feat(web-ui): add service health graphs and core log viewer

This commit is contained in:
William Valentin
2026-02-22 20:54:43 -08:00
parent ca463d5ca2
commit 0a5972a732
4 changed files with 614 additions and 1 deletions
+359 -1
View File
@@ -22,6 +22,20 @@ let _lastCouncilError = null;
let _lastServices = [];
let _lastLocalBackends = [];
let _lastDockerDependencies = [];
let _lastObservabilitySources = [];
let _lastObservabilitySeries = null;
let _lastServiceLogs = null;
let _selectedLogSourceId = null;
let _logRefreshTimer = null;
let _logViewerState = {
lines: 200,
sinceSeconds: 900,
level: 'all',
autoRefresh: true,
paused: false,
status: null,
tone: 'neutral',
};
let _localBackendActionState = new Map();
let _dockerDependencyActionState = new Map();
let _serviceConfigState = {
@@ -47,6 +61,14 @@ const DOCKER_DEPENDENCY_ACTION_LABELS = {
stop: 'Stop',
update: 'Update',
};
const LOG_LEVEL_OPTIONS = ['all', 'info', 'warn', 'error'];
const LOG_LINES_OPTIONS = [100, 200, 500, 1000];
const LOG_SINCE_OPTIONS = [
{ value: 300, label: '5m' },
{ value: 900, label: '15m' },
{ value: 3600, label: '1h' },
{ value: 21600, label: '6h' },
];
const SERVICE_TOGGLE_PATCH_PATHS = {
heartbeat: 'automation.heartbeat.enabled',
daily_briefing: 'automation.daily_briefing.enabled',
@@ -92,6 +114,13 @@ function formatDay(day) {
return parsed.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
}
function formatLogTime(timestamp) {
if (!timestamp || !Number.isFinite(Number(timestamp))) {
return '--:--:--';
}
return new Date(Number(timestamp)).toLocaleTimeString('en-GB', { hour12: false });
}
function formatNumber(value) {
return (value ?? 0).toLocaleString();
}
@@ -400,6 +429,18 @@ function renderSkeleton(el) {
<div id="ops-docker-dependencies" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Service Health Graphs</h2>
<div class="text-xs text-zinc-500 mb-2">Bounded 1h trend view (30s buckets) for compose dependencies and systemd daemons.</div>
<div id="ops-observability-graphs" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Service Logs</h2>
<div class="text-xs text-zinc-500 mb-2">Recent core service logs with server-side token masking.</div>
<div id="ops-service-logs" class="bg-zinc-900 border border-zinc-800 rounded-lg p-3">
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<div id="ops-service-config-modal-root"></div>
`;
}
@@ -1436,6 +1477,16 @@ function updateAssistantHealth(configData) {
updateServices(refreshed.services);
updateLocalBackends(refreshed.localBackends);
updateDockerDependencies(refreshed.dockerDependencies);
if (refreshed.observabilitySources) {
_lastObservabilitySources = Array.isArray(refreshed.observabilitySources.sources)
? refreshed.observabilitySources.sources
: [];
}
if (refreshed.observabilitySeries) {
updateObservabilityGraphs(refreshed.observabilitySeries);
}
updateServiceLogsPanel();
await refreshServiceLogs({ force: true });
updateSessionAnalytics(refreshed.sessionAnalytics);
updateContextHealth(refreshed.contextUsage);
// Only re-render assistant controls from a confirmed config snapshot.
@@ -1700,6 +1751,226 @@ function updateDockerDependencies(dockerDependenciesData) {
});
}
function getObservabilityName(sourceId) {
const source = _lastObservabilitySources.find((entry) => entry.id === sourceId);
return source?.name ?? sourceId;
}
function buildSparklinePath(points, key, width = 320, height = 56) {
if (!Array.isArray(points) || points.length === 0) {
return null;
}
const values = points.map((point) => Number(point?.[key] ?? 0));
const min = Math.min(...values);
const max = Math.max(...values);
const denom = max === min ? 1 : (max - min);
return values.map((value, idx) => {
const x = values.length === 1 ? 0 : (idx / (values.length - 1)) * width;
const y = height - (((value - min) / denom) * height);
return `${idx === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
}).join(' ');
}
function updateObservabilityGraphs(seriesData) {
const el = document.getElementById('ops-observability-graphs');
if (!el) {return;}
const series = Array.isArray(seriesData?.series) ? seriesData.series : [];
_lastObservabilitySeries = seriesData ?? null;
if (series.length === 0) {
el.innerHTML = '<div class="text-sm text-zinc-500">No service trend data available yet</div>';
return;
}
const cards = [...series]
.sort((a, b) => getObservabilityName(String(a.sourceId)).localeCompare(getObservabilityName(String(b.sourceId))))
.map((entry) => {
const sourceId = String(entry.sourceId ?? '');
const source = _lastObservabilitySources.find((candidate) => candidate.id === sourceId);
const name = source?.name ?? sourceId;
const status = String(source?.status ?? 'unknown');
const points = Array.isArray(entry.points) ? entry.points : [];
const latest = points.length > 0 ? points[points.length - 1] : null;
const first = points.length > 0 ? points[0] : null;
const restartDelta = latest && first ? Math.max(0, Number(latest.restartCount ?? 0) - Number(first.restartCount ?? 0)) : 0;
const errorDelta = latest && first ? Math.max(0, Number(latest.errorCount ?? 0) - Number(first.errorCount ?? 0)) : 0;
const statePath = buildSparklinePath(points, 'stateCode');
const healthPath = buildSparklinePath(points, 'healthCode');
const statusColor = status === 'running'
? 'text-green-400'
: status === 'degraded'
? 'text-amber-400'
: status === 'stopped'
? 'text-zinc-400'
: 'text-red-400';
return `<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-semibold text-zinc-50">${escapeHtml(name)}</div>
<span class="text-xs uppercase ${statusColor}">${escapeHtml(status)}</span>
</div>
<div class="text-xs text-zinc-500">Source: <span class="font-mono text-zinc-400">${escapeHtml(sourceId)}</span></div>
<div class="text-xs text-zinc-500">Window deltas: <span class="font-mono text-zinc-400">restarts ${restartDelta}</span> · <span class="font-mono text-zinc-400">errors ${errorDelta}</span></div>
<div class="bg-zinc-950 border border-zinc-800 rounded p-2">
${statePath
? `<svg viewBox="0 0 320 56" class="w-full h-14">
<path d="${statePath}" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" />
${healthPath ? `<path d="${healthPath}" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="5 4" stroke-linecap="round" />` : ''}
</svg>`
: '<div class="text-xs text-zinc-500">No sample points yet</div>'}
<div class="text-[11px] text-zinc-500 mt-1">Blue: state code · Green dashed: health code</div>
</div>
</div>`;
});
el.innerHTML = cards.join('');
}
function normalizeLogSourceSelection() {
const logSources = _lastObservabilitySources.filter((source) => source.logCapable);
if (logSources.length === 0) {
_selectedLogSourceId = null;
return [];
}
if (!_selectedLogSourceId || !logSources.some((source) => source.id === _selectedLogSourceId)) {
_selectedLogSourceId = logSources[0].id;
}
return logSources;
}
function updateServiceLogsPanel() {
const el = document.getElementById('ops-service-logs');
if (!el) {return;}
const logSources = normalizeLogSourceSelection();
if (logSources.length === 0) {
el.innerHTML = '<div class="text-sm text-zinc-500">No log-capable services currently available</div>';
return;
}
const snapshot = _lastServiceLogs;
const rawLines = Array.isArray(snapshot?.lines) ? snapshot.lines : [];
const filterLevel = _logViewerState.level;
const lines = rawLines.filter((line) => filterLevel === 'all' || String(line.level ?? 'info') === filterLevel);
const statusToneClass = _logViewerState.tone === 'error'
? 'text-red-400'
: _logViewerState.tone === 'success'
? 'text-green-400'
: 'text-zinc-500';
const redactedBadge = snapshot?.redacted
? '<span class="px-2 py-0.5 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs">redacted</span>'
: '<span class="px-2 py-0.5 rounded bg-zinc-800 text-zinc-400 border border-zinc-700 text-xs">raw-safe</span>';
const linesHtml = lines.length > 0
? lines.map((entry) => {
const level = String(entry.level ?? 'info');
const levelClass = level === 'error' ? 'text-red-400' : level === 'warn' ? 'text-amber-400' : 'text-zinc-400';
const ts = formatLogTime(entry.ts);
return `<div class="px-2 py-1 border-b border-zinc-800/50 last:border-0 break-words"><span class="font-mono text-zinc-500">[${escapeHtml(ts)}]</span> <span class="uppercase ${levelClass}">${escapeHtml(level)}</span> <span class="text-zinc-200">${escapeHtml(String(entry.text ?? ''))}</span></div>`;
}).join('')
: '<div class="px-2 py-1 text-zinc-500">No log lines for current filters</div>';
el.innerHTML = `
<div class="flex flex-wrap items-center gap-2 mb-3">
<label class="text-xs text-zinc-400">Service</label>
<select id="ops-log-source" class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-100">
${logSources.map((source) => `<option value="${escapeHtml(source.id)}" ${source.id === _selectedLogSourceId ? 'selected' : ''}>${escapeHtml(source.name)}</option>`).join('')}
</select>
<label class="text-xs text-zinc-400">Window</label>
<select id="ops-log-since" class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-100">
${LOG_SINCE_OPTIONS.map((opt) => `<option value="${opt.value}" ${Number(_logViewerState.sinceSeconds) === opt.value ? 'selected' : ''}>${opt.label}</option>`).join('')}
</select>
<label class="text-xs text-zinc-400">Lines</label>
<select id="ops-log-lines-select" class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-100">
${LOG_LINES_OPTIONS.map((opt) => `<option value="${opt}" ${Number(_logViewerState.lines) === opt ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
<label class="text-xs text-zinc-400">Level</label>
<select id="ops-log-level" class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-100">
${LOG_LEVEL_OPTIONS.map((opt) => `<option value="${opt}" ${_logViewerState.level === opt ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
<label class="text-xs text-zinc-300 flex items-center gap-1"><input id="ops-log-auto" type="checkbox" ${_logViewerState.autoRefresh ? 'checked' : ''}/>auto</label>
<label class="text-xs text-zinc-300 flex items-center gap-1"><input id="ops-log-pause" type="checkbox" ${_logViewerState.paused ? 'checked' : ''}/>pause</label>
<button id="ops-log-refresh" class="px-2 py-1 text-xs border border-zinc-700 rounded text-zinc-200 hover:bg-zinc-800">Refresh</button>
${redactedBadge}
</div>
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-zinc-500">${snapshot?.fetchedAt ? `Last fetch ${escapeHtml(formatLogTime(snapshot.fetchedAt))}` : 'No logs fetched yet'}</div>
<div class="text-xs ${statusToneClass}">${escapeHtml(String(_logViewerState.status ?? ''))}</div>
</div>
<div id="ops-log-lines-view" class="max-h-80 overflow-y-auto bg-zinc-950 border border-zinc-800 rounded p-2 font-mono text-xs">${linesHtml}</div>
`;
el.querySelector('#ops-log-source')?.addEventListener('change', async (event) => {
_selectedLogSourceId = event.target.value;
await refreshServiceLogs({ force: true });
});
el.querySelector('#ops-log-since')?.addEventListener('change', async (event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed) && parsed > 0) {
_logViewerState.sinceSeconds = parsed;
}
await refreshServiceLogs({ force: true });
});
el.querySelector('#ops-log-lines-select')?.addEventListener('change', async (event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed) && parsed > 0) {
_logViewerState.lines = parsed;
}
await refreshServiceLogs({ force: true });
});
el.querySelector('#ops-log-level')?.addEventListener('change', (event) => {
_logViewerState.level = event.target.value;
updateServiceLogsPanel();
});
el.querySelector('#ops-log-auto')?.addEventListener('change', (event) => {
_logViewerState.autoRefresh = Boolean(event.target.checked);
if (_logViewerState.autoRefresh && !_logViewerState.paused) {
void refreshServiceLogs({ force: true });
}
});
el.querySelector('#ops-log-pause')?.addEventListener('change', (event) => {
_logViewerState.paused = Boolean(event.target.checked);
});
el.querySelector('#ops-log-refresh')?.addEventListener('click', async () => {
await refreshServiceLogs({ force: true });
});
}
async function refreshServiceLogs(opts = {}) {
if (!_dashboardClient || !_selectedLogSourceId) {return;}
const force = opts.force === true;
if (!force && (!_logViewerState.autoRefresh || _logViewerState.paused)) {
return;
}
try {
const result = await _dashboardClient.call('system.serviceLogs', {
sourceId: _selectedLogSourceId,
lines: _logViewerState.lines,
sinceSeconds: _logViewerState.sinceSeconds,
});
_lastServiceLogs = result;
_logViewerState.status = `Fetched ${Array.isArray(result?.lines) ? result.lines.length : 0} line(s)`;
_logViewerState.tone = 'success';
} catch (error) {
_logViewerState.status = `Log fetch failed: ${error instanceof Error ? error.message : String(error)}`;
_logViewerState.tone = 'error';
}
updateServiceLogsPanel();
}
async function handleLocalBackendAction(backendId, action) {
if (!_dashboardClient) {return;}
const actionLabel = LOCAL_BACKEND_ACTION_LABELS[action] ?? action;
@@ -1731,6 +2002,16 @@ async function handleLocalBackendAction(backendId, action) {
if (refreshed?.dockerDependencies) {
updateDockerDependencies(refreshed.dockerDependencies);
}
if (refreshed?.observabilitySources) {
_lastObservabilitySources = Array.isArray(refreshed.observabilitySources.sources)
? refreshed.observabilitySources.sources
: [];
updateServiceLogsPanel();
}
if (refreshed?.observabilitySeries) {
updateObservabilityGraphs(refreshed.observabilitySeries);
}
await refreshServiceLogs({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
_localBackendActionState.set(backendId, {
@@ -1773,6 +2054,16 @@ async function handleDockerDependencyAction(dependencyId, action) {
if (refreshed?.dockerDependencies) {
updateDockerDependencies(refreshed.dockerDependencies);
}
if (refreshed?.observabilitySources) {
_lastObservabilitySources = Array.isArray(refreshed.observabilitySources.sources)
? refreshed.observabilitySources.sources
: [];
updateServiceLogsPanel();
}
if (refreshed?.observabilitySeries) {
updateObservabilityGraphs(refreshed.observabilitySeries);
}
await refreshServiceLogs({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
_dockerDependencyActionState.set(dependencyId, {
@@ -1956,6 +2247,16 @@ function renderServiceConfigModal() {
updateServices(refreshed.services);
updateLocalBackends(refreshed.localBackends);
updateDockerDependencies(refreshed.dockerDependencies);
if (refreshed.observabilitySources) {
_lastObservabilitySources = Array.isArray(refreshed.observabilitySources.sources)
? refreshed.observabilitySources.sources
: [];
}
if (refreshed.observabilitySeries) {
updateObservabilityGraphs(refreshed.observabilitySeries);
}
updateServiceLogsPanel();
await refreshServiceLogs({ force: true });
updateSessionAnalytics(refreshed.sessionAnalytics);
updateContextHealth(refreshed.contextUsage);
if (refreshed.config) {
@@ -1988,11 +2289,24 @@ async function fetchFast(client) {
}
async function fetchSlow(client) {
const [health, services, localBackends, dockerDependencies, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([
const [
health,
services,
localBackends,
dockerDependencies,
observabilitySources,
observabilitySeries,
sessionAnalytics,
contextUsage,
config,
modelCatalog,
] = await Promise.allSettled([
client.call('system.health'),
client.call('system.services'),
client.call('system.localBackends'),
client.call('system.dockerDependencies'),
client.call('system.observabilitySources'),
client.call('system.observabilitySeries', { windowMinutes: 60, bucketSeconds: 30 }),
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
client.call('system.contextUsage'),
client.call('config.get'),
@@ -2012,6 +2326,8 @@ async function fetchSlow(client) {
services: unwrap(services),
localBackends: unwrap(localBackends),
dockerDependencies: unwrap(dockerDependencies),
observabilitySources: unwrap(observabilitySources),
observabilitySeries: unwrap(observabilitySeries),
sessionAnalytics: unwrap(sessionAnalytics),
contextUsage: unwrap(contextUsage),
config: configValue,
@@ -2054,6 +2370,18 @@ async function loadDashboard(el, client) {
if (slow?.dockerDependencies) {
updateDockerDependencies(slow.dockerDependencies);
}
if (slow?.observabilitySources) {
_lastObservabilitySources = Array.isArray(slow.observabilitySources.sources)
? slow.observabilitySources.sources
: [];
}
if (slow?.observabilitySeries) {
updateObservabilityGraphs(slow.observabilitySeries);
} else {
updateObservabilityGraphs({ series: [] });
}
updateServiceLogsPanel();
await refreshServiceLogs({ force: true });
if (slow?.sessionAnalytics) {
updateSessionAnalytics(slow.sessionAnalytics);
}
@@ -2095,6 +2423,15 @@ async function loadDashboard(el, client) {
if (data.dockerDependencies) {
updateDockerDependencies(data.dockerDependencies);
}
if (data.observabilitySources) {
_lastObservabilitySources = Array.isArray(data.observabilitySources.sources)
? data.observabilitySources.sources
: [];
updateServiceLogsPanel();
}
if (data.observabilitySeries) {
updateObservabilityGraphs(data.observabilitySeries);
}
if (data.sessionAnalytics) {
updateSessionAnalytics(data.sessionAnalytics);
}
@@ -2105,6 +2442,10 @@ async function loadDashboard(el, client) {
updateAssistantHealth(_lastAssistantConfig);
}
}, 10000);
_logRefreshTimer = setInterval(async () => {
await refreshServiceLogs();
}, 5000);
}
export const DashboardPage = {
@@ -2121,6 +2462,10 @@ export const DashboardPage = {
clearInterval(_slowTimer);
_slowTimer = null;
}
if (_logRefreshTimer) {
clearInterval(_logRefreshTimer);
_logRefreshTimer = null;
}
_lastHealth = null;
_lastMetrics = null;
_dashboardClient = null;
@@ -2135,6 +2480,19 @@ export const DashboardPage = {
_lastServices = [];
_lastLocalBackends = [];
_lastDockerDependencies = [];
_lastObservabilitySources = [];
_lastObservabilitySeries = null;
_lastServiceLogs = null;
_selectedLogSourceId = null;
_logViewerState = {
lines: 200,
sinceSeconds: 900,
level: 'all',
autoRefresh: true,
paused: false,
status: null,
tone: 'neutral',
};
_localBackendActionState = new Map();
_dockerDependencyActionState = new Map();
_serviceConfigState = {