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) {
Loading...
+ +

Service Health Graphs

+
Bounded 1h trend view (30s buckets) for compose dependencies and systemd daemons.
+
+
Loading...
+
+ +

Service Logs

+
Recent core service logs with server-side token masking.
+
+
Loading...
+
`; } @@ -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 + ? ` + + ${healthPath ? `` : ''} + ` + : '
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); + }); });