diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md
index 4d826c9..989bc6e 100644
--- a/docs/api/PROTOCOL.md
+++ b/docs/api/PROTOCOL.md
@@ -484,6 +484,122 @@ Control a local backend daemon (`start`, `restart`, `stop`, `update`).
}
```
+#### `system.observabilitySources`
+
+Return graph/log-capable observability sources for the dashboard.
+
+**Request:**
+```json
+{
+ "id": 15,
+ "method": "system.observabilitySources"
+}
+```
+
+**Response:**
+```json
+{
+ "id": 15,
+ "result": {
+ "sources": [
+ {
+ "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
+ }
+ ]
+ }
+}
+```
+
+#### `system.observabilitySeries`
+
+Return sampled service trend points (bounded, in-memory) for dashboard charts.
+
+**Request:**
+```json
+{
+ "id": 16,
+ "method": "system.observabilitySeries",
+ "params": {
+ "windowMinutes": 60,
+ "bucketSeconds": 30,
+ "sourceIds": ["systemd:flynn", "docker:whisper"]
+ }
+}
+```
+
+**Response:**
+```json
+{
+ "id": 16,
+ "result": {
+ "generatedAt": 1739999999000,
+ "windowMinutes": 60,
+ "bucketSeconds": 30,
+ "series": [
+ {
+ "sourceId": "systemd:flynn",
+ "points": [
+ { "ts": 1739999970000, "stateCode": 3, "healthCode": 2, "errorCount": 0, "restartCount": 0 },
+ { "ts": 1739999985000, "stateCode": 3, "healthCode": 2, "errorCount": 0, "restartCount": 1 }
+ ]
+ }
+ ]
+ }
+}
+```
+
+#### `system.serviceLogs`
+
+Return recent logs for a discovered observability source. Flynn applies secret masking heuristics to returned lines.
+
+**Request:**
+```json
+{
+ "id": 17,
+ "method": "system.serviceLogs",
+ "params": {
+ "sourceId": "docker:whisper",
+ "lines": 200,
+ "sinceSeconds": 900
+ }
+}
+```
+
+**Response:**
+```json
+{
+ "id": 17,
+ "result": {
+ "sourceId": "docker:whisper",
+ "fetchedAt": 1739999999000,
+ "redacted": false,
+ "truncated": false,
+ "lines": [
+ {
+ "ts": 1739999990000,
+ "level": "warn",
+ "text": "queue depth rising"
+ }
+ ]
+ }
+}
+```
+
**Response:**
```json
{
diff --git a/docs/plans/state.json b/docs/plans/state.json
index 4cd54f3..3019fa4 100644
--- a/docs/plans/state.json
+++ b/docs/plans/state.json
@@ -3,6 +3,24 @@
"updated_at": "2026-02-23",
"description": "Tracks the status of all Flynn plans and implementation phases",
"plans": {
+ "dashboard-observability-graphs-and-service-logs": {
+ "status": "completed",
+ "date": "2026-02-23",
+ "updated": "2026-02-23",
+ "summary": "Implemented focused observability upgrades in the web dashboard: sampled service health trend graphs for systemd/docker sources and core service log viewing with source selection, filters, and auto-refresh. Added new gateway RPCs (`system.observabilitySources`, `system.observabilitySeries`, `system.serviceLogs`) backed by a bounded observability collector and server-side secret masking on returned log lines.",
+ "files_modified": [
+ "src/gateway/handlers/observability.ts",
+ "src/gateway/handlers/observability.test.ts",
+ "src/gateway/handlers/system.ts",
+ "src/gateway/handlers/handlers.test.ts",
+ "src/gateway/server.ts",
+ "src/gateway/ui/pages/dashboard.js",
+ "src/gateway/ui/pages/dashboard.test.ts",
+ "docs/api/PROTOCOL.md",
+ "docs/plans/state.json"
+ ],
+ "test_status": "pnpm test:run src/gateway/handlers/observability.test.ts src/gateway/handlers/handlers.test.ts src/gateway/ui/pages/dashboard.test.ts + pnpm typecheck passing"
+ },
"dashboard-docker-dependency-discovery": {
"status": "completed",
"date": "2026-02-23",
@@ -6221,6 +6239,7 @@
"tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes",
"feature_gap_scorecard": "128/128 match (100%), 0 partial (0%), 0 missing (0%)",
"operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done",
+ "dashboard_observability": "completed — service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling",
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback, plus 2026-02-23 arg hydration hardening, tool.args_rewritten audit metric, transient fetch retry/timeout hardening, localhost->127.0.0.1 fallback for transcription endpoint connectivity, and whisper docker-compose entrypoint arg fix for port 18801",
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js
index 7616faf..ddc9ed3 100644
--- a/src/gateway/ui/pages/dashboard.js
+++ b/src/gateway/ui/pages/dashboard.js
@@ -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) {
+
+ Service Health Graphs
+ Bounded 1h trend view (30s buckets) for compose dependencies and systemd daemons.
+
+
+ Service Logs
+ Recent core service logs with server-side token masking.
+
`;
}
@@ -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 = 'No service trend data available yet
';
+ 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 `
+
+
${escapeHtml(name)}
+
${escapeHtml(status)}
+
+
Source: ${escapeHtml(sourceId)}
+
Window deltas: restarts ${restartDelta} · errors ${errorDelta}
+
+ ${statePath
+ ? `
`
+ : '
No sample points yet
'}
+
Blue: state code · Green dashed: health code
+
+
`;
+ });
+
+ 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 = 'No log-capable services currently available
';
+ 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
+ ? 'redacted'
+ : 'raw-safe';
+
+ 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 `[${escapeHtml(ts)}] ${escapeHtml(level)} ${escapeHtml(String(entry.text ?? ''))}
`;
+ }).join('')
+ : 'No log lines for current filters
';
+
+ el.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${redactedBadge}
+
+
+
${snapshot?.fetchedAt ? `Last fetch ${escapeHtml(formatLogTime(snapshot.fetchedAt))}` : 'No logs fetched yet'}
+
${escapeHtml(String(_logViewerState.status ?? ''))}
+
+ ${linesHtml}
+ `;
+
+ 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 = {
diff --git a/src/gateway/ui/pages/dashboard.test.ts b/src/gateway/ui/pages/dashboard.test.ts
index 076d900..1d9d945 100644
--- a/src/gateway/ui/pages/dashboard.test.ts
+++ b/src/gateway/ui/pages/dashboard.test.ts
@@ -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,
calls: [] as Array<{ method: string; params?: Record }>,
};
@@ -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);
+ });
});