feat(web-ui): add service health graphs and core log viewer
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -149,6 +149,68 @@ function createMockClient() {
|
||||
availableActions: ['restart', 'stop', 'update'],
|
||||
},
|
||||
],
|
||||
observabilitySources: [
|
||||
{
|
||||
id: 'systemd:flynn',
|
||||
name: 'Flynn daemon',
|
||||
kind: 'systemd_system',
|
||||
runtime: 'systemd_system',
|
||||
status: 'running',
|
||||
graphCapable: true,
|
||||
logCapable: true,
|
||||
},
|
||||
{
|
||||
id: 'docker:whisper',
|
||||
name: 'Whisper (whisper.cpp)',
|
||||
kind: 'docker_dependency',
|
||||
runtime: 'docker_compose',
|
||||
status: 'running',
|
||||
graphCapable: true,
|
||||
logCapable: true,
|
||||
},
|
||||
],
|
||||
observabilitySeries: {
|
||||
generatedAt: 1700000000000,
|
||||
windowMinutes: 60,
|
||||
bucketSeconds: 30,
|
||||
series: [
|
||||
{
|
||||
sourceId: 'systemd:flynn',
|
||||
points: [
|
||||
{ ts: 1700000000000, stateCode: 3, healthCode: 2, errorCount: 0, restartCount: 0 },
|
||||
{ ts: 1700000030000, stateCode: 3, healthCode: 2, errorCount: 0, restartCount: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceId: 'docker:whisper',
|
||||
points: [
|
||||
{ ts: 1700000000000, stateCode: 3, healthCode: 2, errorCount: 0, restartCount: 0 },
|
||||
{ ts: 1700000030000, stateCode: 2, healthCode: 1, errorCount: 1, restartCount: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
serviceLogs: {
|
||||
'systemd:flynn': {
|
||||
sourceId: 'systemd:flynn',
|
||||
fetchedAt: 1700000005000,
|
||||
redacted: true,
|
||||
truncated: false,
|
||||
lines: [
|
||||
{ ts: 1700000004000, level: 'info', text: 'gateway online' },
|
||||
{ ts: 1700000004500, level: 'warn', text: 'queue depth rising' },
|
||||
],
|
||||
},
|
||||
'docker:whisper': {
|
||||
sourceId: 'docker:whisper',
|
||||
fetchedAt: 1700000006000,
|
||||
redacted: false,
|
||||
truncated: false,
|
||||
lines: [
|
||||
{ ts: 1700000005500, level: 'error', text: 'model load failed' },
|
||||
],
|
||||
},
|
||||
} as Record<string, unknown>,
|
||||
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
|
||||
};
|
||||
|
||||
@@ -199,6 +261,22 @@ function createMockClient() {
|
||||
averageMessagesPerSession: 0,
|
||||
};
|
||||
}
|
||||
if (method === 'system.observabilitySources') {
|
||||
return { sources: deepClone(state.observabilitySources) };
|
||||
}
|
||||
if (method === 'system.observabilitySeries') {
|
||||
return deepClone(state.observabilitySeries);
|
||||
}
|
||||
if (method === 'system.serviceLogs') {
|
||||
const sourceId = String(params?.sourceId ?? '');
|
||||
return deepClone(state.serviceLogs[sourceId] ?? {
|
||||
sourceId,
|
||||
fetchedAt: Date.now(),
|
||||
redacted: false,
|
||||
truncated: false,
|
||||
lines: [],
|
||||
});
|
||||
}
|
||||
if (method === 'system.contextUsage') {
|
||||
return { sessions: [] };
|
||||
}
|
||||
@@ -590,4 +668,46 @@ describe('DashboardPage assistant controls', () => {
|
||||
expect(state.dockerDependencies.find((entry) => entry.id === 'whisper')?.state).toBe('running');
|
||||
expect(state.dockerDependencies.find((entry) => entry.id === 'brave-search')?.state).toBe('running');
|
||||
});
|
||||
|
||||
it('renders observability graph cards with sampled trend data', async () => {
|
||||
const { client } = createMockClient();
|
||||
|
||||
await DashboardPage.render(container, client);
|
||||
|
||||
const card = container.querySelector('#ops-observability-graphs');
|
||||
expect(card).toBeTruthy();
|
||||
expect(String(card.textContent ?? '')).toContain('Flynn daemon');
|
||||
expect(String(card.textContent ?? '')).toContain('Whisper (whisper.cpp)');
|
||||
expect(String(card.textContent ?? '')).toContain('Window deltas');
|
||||
});
|
||||
|
||||
it('renders service logs and refreshes selected source', async () => {
|
||||
const { state, client } = createMockClient();
|
||||
|
||||
await DashboardPage.render(container, client);
|
||||
|
||||
const logsPanel = container.querySelector('#ops-service-logs');
|
||||
expect(logsPanel).toBeTruthy();
|
||||
expect(String(logsPanel.textContent ?? '')).toContain('gateway online');
|
||||
|
||||
const sourceSelect = container.querySelector('#ops-log-source');
|
||||
expect(sourceSelect).toBeTruthy();
|
||||
const sourceOptions = Array.from(sourceSelect.options ?? []) as Array<{ value: string; selected: boolean }>;
|
||||
sourceOptions.forEach((option) => {
|
||||
option.selected = option.value === 'docker:whisper';
|
||||
});
|
||||
sourceSelect.dispatchEvent(new windowObj.Event('change', { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(String(logsPanel.textContent ?? '')).toContain('model load failed');
|
||||
|
||||
const refreshButton = container.querySelector('#ops-log-refresh');
|
||||
expect(refreshButton).toBeTruthy();
|
||||
refreshButton.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
const logCalls = state.calls.filter((entry) => entry.method === 'system.serviceLogs');
|
||||
expect(logCalls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(logCalls.some((entry) => entry.params?.sourceId === 'docker:whisper')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user