agent-system/webui/index.html
2026-05-12 17:30:00 +02:00

498 lines
16 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Operator Control Plane</title>
<style>
:root {
--bg-color: #0a0c0e;
--sidebar-color: #14171a;
--card-color: #1c2024;
--border-color: #2a3540;
--text-color: #e7edf3;
--text-muted: #94a3b8;
--accent-color: #3eaf7c;
--nominal: #3eaf7c;
--degraded: #e7c000;
--unstable: #e67e22;
--reconciling: #3498db;
--error: #c0392b;
--safe: #3eaf7c;
--guarded: #e67e22;
--dangerous: #c0392b;
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--bg-color);
color: var(--text-color);
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 240px;
background: var(--sidebar-color);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 24px;
font-weight: 800;
font-size: 14px;
letter-spacing: 0.1em;
color: var(--accent-color);
border-bottom: 1px solid var(--border-color);
}
.nav-list {
list-style: none;
padding: 12px 0;
margin: 0;
flex-grow: 1;
}
.nav-item {
padding: 12px 24px;
cursor: pointer;
font-size: 14px;
color: var(--text-muted);
transition: all 0.2s;
display: flex;
align-items: center;
gap: 12px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-color);
}
.nav-item.active {
background: rgba(62, 175, 124, 0.1);
color: var(--accent-color);
border-left: 3px solid var(--accent-color);
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border-color);
font-size: 12px;
}
/* Content Area */
.main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
height: 64px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 24px;
justify-content: space-between;
background: var(--bg-color);
}
.view-title {
font-size: 18px;
font-weight: 600;
}
.content-scroll {
flex-grow: 1;
overflow-y: auto;
padding: 24px;
}
/* Cards & Grids */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.card {
background: var(--card-color);
border: 1px solid var(--border-color);
padding: 20px;
border-radius: 4px;
position: relative;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-title {
font-weight: 700;
font-size: 16px;
}
/* Status Badges */
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.status-nominal { background: rgba(62, 175, 124, 0.1); color: var(--nominal); }
.status-degraded { background: rgba(231, 192, 0, 0.1); color: var(--degraded); }
.status-unstable { background: rgba(230, 126, 34, 0.1); color: var(--unstable); }
.status-reconciling { background: rgba(52, 152, 219, 0.1); color: var(--reconciling); }
.status-error { background: rgba(192, 57, 43, 0.1); color: var(--error); }
/* Timeline */
.timeline {
display: flex;
flex-direction: column;
gap: 12px;
}
.event {
padding: 12px;
border-left: 2px solid var(--border-color);
background: rgba(255, 255, 255, 0.02);
font-family: ui-monospace, monospace;
font-size: 13px;
}
.event.high { border-left-color: var(--error); }
.event.medium { border-left-color: var(--unstable); }
.event.low { border-left-color: var(--nominal); }
.event-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
color: var(--text-muted);
}
/* Forms & Inputs */
.controls {
display: flex;
gap: 12px;
margin-top: 20px;
}
input, button {
background: var(--card-color);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
}
button {
cursor: pointer;
font-weight: 600;
}
button:hover { background: var(--border-color); }
.btn-primary { background: var(--accent-color); color: white; border: none; }
.btn-primary:hover { background: #359b6d; }
/* Utility */
.hidden { display: none !important; }
.mono { font-family: ui-monospace, monospace; }
.label { color: var(--text-muted); font-size: 12px; margin-bottom: 4px; }
.value { font-weight: 500; margin-bottom: 12px; }
.risk-safe { color: var(--safe); }
.risk-guarded { color: var(--guarded); }
.risk-dangerous { color: var(--dangerous); }
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">HOMELAB OPERATOR</div>
<ul class="nav-list">
<li class="nav-item active" onclick="showView('dashboard', this)">
<span>Dashboard</span>
</li>
<li class="nav-item" onclick="showView('nodes', this)">
<span>Nodes</span>
</li>
<li class="nav-item" onclick="showView('services', this)">
<span>Services</span>
</li>
<li class="nav-item" onclick="showView('deployments', this)">
<span>Deployments</span>
</li>
<li class="nav-item" onclick="showView('events', this)">
<span>Events</span>
</li>
<li class="nav-item" onclick="showView('recommendations', this)">
<span>Recommendations</span>
</li>
<li class="nav-item" onclick="showView('settings', this)">
<span>Settings</span>
</li>
</ul>
<div class="sidebar-footer">
<div id="summary-status">System Status: Loading...</div>
</div>
</aside>
<main class="main-content">
<header>
<div class="view-title" id="current-view-title">Dashboard</div>
<div class="header-actions">
<button onclick="refreshData()">Refresh</button>
</div>
</header>
<div class="content-scroll">
<!-- Dashboard View -->
<div id="view-dashboard" class="view">
<div class="grid">
<div class="card">
<div class="card-title">System Overview</div>
<div id="dashboard-summary" style="margin-top:20px"></div>
</div>
<div class="card">
<div class="card-title">Active Incidents</div>
<div id="dashboard-incidents" style="margin-top:20px"></div>
</div>
</div>
</div>
<!-- Nodes View -->
<div id="view-nodes" class="view hidden">
<div class="grid" id="nodes-list"></div>
</div>
<!-- Services View -->
<div id="view-services" class="view hidden">
<div class="grid" id="services-list"></div>
</div>
<!-- Deployments View -->
<div id="view-deployments" class="view hidden">
<div class="grid" id="deployments-list"></div>
</div>
<!-- Events View -->
<div id="view-events" class="view hidden">
<div class="timeline" id="events-timeline"></div>
</div>
<!-- Recommendations View -->
<div id="view-recommendations" class="view hidden">
<div class="grid" id="recommendations-list"></div>
</div>
<!-- Settings View -->
<div id="view-settings" class="view hidden">
<div class="card">
<div class="card-title">Configuration</div>
<div id="settings-content" style="margin-top:20px"></div>
</div>
</div>
</div>
</main>
<script>
let currentView = 'dashboard';
const pollInterval = 5000;
function showView(viewId, el) {
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById('view-' + viewId).classList.remove('hidden');
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
if (el) el.classList.add('active');
currentView = viewId;
document.getElementById('current-view-title').textContent = viewId.charAt(0).toUpperCase() + viewId.slice(1);
refreshData();
}
async function fetchData(endpoint) {
try {
const res = await fetch(endpoint, {cache: 'no-store'});
return await res.json();
} catch (e) {
console.error('Fetch error:', endpoint, e);
return null;
}
}
function formatTime(ts) {
if (!ts) return 'N/A';
return new Date(ts * 1000).toLocaleString();
}
function getStatusClass(status) {
status = (status || '').toLowerCase();
if (['nominal', 'healthy', 'ok', 'up'].includes(status)) return 'status-nominal';
if (['degraded', 'warning'].includes(status)) return 'status-degraded';
if (['unstable'].includes(status)) return 'status-unstable';
if (['reconciling'].includes(status)) return 'status-reconciling';
if (['error', 'down', 'failed'].includes(status)) return 'status-error';
return '';
}
async function refreshData() {
// Refresh summary always
const summary = await fetchData('/summary');
if (summary) {
const statusEl = document.getElementById('summary-status');
statusEl.textContent = `System Status: ${summary.status.toUpperCase()}`;
statusEl.className = 'sidebar-footer ' + getStatusClass(summary.status);
if (currentView === 'dashboard') {
const dashSummary = document.getElementById('dashboard-summary');
dashSummary.innerHTML = `
<div class="label">Nodes</div><div class="value">${summary.node_count}</div>
<div class="label">Services</div><div class="value">${summary.service_count}</div>
<div class="label">Last Update</div><div class="value">${formatTime(summary.last_update)}</div>
`;
}
}
if (currentView === 'dashboard' || currentView === 'events') {
const incidents = await fetchData('/incidents');
if (currentView === 'dashboard') {
const dashIncidents = document.getElementById('dashboard-incidents');
if (!incidents || incidents.length === 0) {
dashIncidents.textContent = 'No active incidents.';
} else {
dashIncidents.innerHTML = incidents.map(inc => `
<div class="event ${inc.severity}">
<strong>${inc.severity.toUpperCase()}:</strong> ${inc.message}<br>
<small>${formatTime(inc.timestamp)} | Node: ${inc.node}</small>
</div>
`).join('');
}
}
}
if (currentView === 'nodes') {
const nodes = await fetchData('/nodes');
const list = document.getElementById('nodes-list');
list.innerHTML = nodes.map(node => `
<div class="card">
<div class="card-header">
<div class="card-title">${node.hostname}</div>
<span class="badge ${getStatusClass(node.health)}">${node.health}</span>
</div>
<div class="label">ID</div><div class="value mono">${node.id}</div>
<div class="label">Capabilities</div><div class="value">${node.capabilities.join(', ')}</div>
<div class="label">Connectivity</div><div class="value">${node.connectivity}</div>
<div class="label">Incidents (24h)</div><div class="value">${node.incidents}</div>
<div class="label">Last Seen</div><div class="value">${formatTime(node.last_seen)}</div>
<div class="label">Runtime Status</div><div class="value">${node.status}</div>
</div>
`).join('');
}
if (currentView === 'services') {
const services = await fetchData('/services');
const list = document.getElementById('services-list');
list.innerHTML = services.map(svc => `
<div class="card">
<div class="card-header">
<div class="card-title">${svc.name}</div>
<span class="badge ${getStatusClass(svc.health)}">${svc.health}</span>
</div>
<div class="label">State (Desired/Actual)</div><div class="value">${svc.desired_state} / ${svc.actual_state}</div>
<div class="label">Deployment</div><div class="value">${svc.deployment_state}</div>
<div class="label">Dependencies</div><div class="value">${svc.dependencies.join(', ') || 'None'}</div>
<div class="label">Recommendations</div><div class="value">${svc.recommendations.join(', ') || 'None'}</div>
</div>
`).join('');
}
if (currentView === 'deployments') {
const deps = await fetchData('/deployments');
const list = document.getElementById('deployments-list');
list.innerHTML = deps.map(dep => `
<div class="card">
<div class="card-header">
<div class="card-title">${dep.service}</div>
<span class="badge ${dep.status === 'failed' ? 'status-error' : 'status-reconciling'}">${dep.status}</span>
</div>
<div class="label">ID</div><div class="value mono">${dep.id}</div>
<div class="label">Stage</div><div class="value">${dep.stage}</div>
<div class="label">Diagnostics</div><div class="value">${dep.diagnostics || 'No data'}</div>
<div class="label">Resumable</div><div class="value">${dep.resumable ? 'Yes' : 'No'}</div>
${dep.resumable ? '<button class="btn-primary">Resume</button>' : ''}
</div>
`).join('');
}
if (currentView === 'events') {
const events = await fetchData('/events');
const timeline = document.getElementById('events-timeline');
timeline.innerHTML = events.map(ev => `
<div class="event ${ev.severity}">
<div class="event-header">
<span>${ev.type.toUpperCase()}</span>
<span>${formatTime(ev.timestamp)}</span>
</div>
<div>${ev.message}</div>
<div class="label" style="margin-top:8px">Node: ${ev.node} ${ev.service ? '| Service: ' + ev.service : ''}</div>
</div>
`).join('');
}
if (currentView === 'recommendations') {
const recs = await fetchData('/recommendations');
const list = document.getElementById('recommendations-list');
list.innerHTML = recs.map(rec => `
<div class="card">
<div class="card-header">
<div class="card-title">${rec.title}</div>
<span class="badge risk-${rec.risk_level}">${rec.risk_level}</span>
</div>
<p>${rec.description}</p>
<div class="label">Confidence</div><div class="value">${Math.round(rec.confidence * 100)}%</div>
<div class="label">Autonomous Eligible</div><div class="value">${rec.autonomous_eligible ? 'Yes' : 'No'}</div>
<div class="label">Blocked Actions</div><div class="value">${rec.blocked_actions.join(', ') || 'None'}</div>
<div class="controls">
<button class="btn-primary" ${rec.risk_level === 'dangerous' ? 'style="background:var(--dangerous)"' : ''}>Approve Action</button>
</div>
</div>
`).join('');
}
if (currentView === 'settings') {
const config = await fetchData('/config');
const content = document.getElementById('settings-content');
content.innerHTML = `
<div class="label">Auto Mode</div>
<div class="value">${config.auto_mode ? 'Enabled' : 'Disabled'}</div>
<div class="label">Action Thresholds</div>
<div class="value mono">${JSON.stringify(config.action_thresholds, null, 2)}</div>
<button onclick="alert('Settings update not implemented in this demo')">Edit Configuration</button>
`;
}
}
// Initial load
refreshData();
// Poll for updates
setInterval(refreshData, pollInterval);
</script>
</body>
</html>