diff --git a/README.md b/README.md index 23de99b..80dcd2d 100644 --- a/README.md +++ b/README.md @@ -424,8 +424,8 @@ docker run -d \ --language en \ --inference-path /v1/audio/transcriptions -# Option 2: Using docker-compose (uncomment whisper-server service in docker-compose.yml) -# docker compose up -d +# Option 2: Using docker-compose profile (service is pre-defined in docker-compose.yml) +docker compose --profile voice up -d whisper-server ``` Audio persistence and diagnostics: diff --git a/docker-compose.yml b/docker-compose.yml index bafd2bd..2d24e00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,27 +30,37 @@ services: start_period: 15s retries: 3 - # Optional: Whisper server for audio transcription - # Uncomment and adjust as needed for voice message support - # whisper-server: - # image: ghcr.io/ggml-org/whisper.cpp:main - # container_name: whisper-server - # restart: unless-stopped - # ports: - # - "18801:8080" - # command: whisper-server - # --model /app/models/ggml-base.en.bin - # --host 0.0.0.0 - # --port 8080 - # --convert - # --language en - # --inference-path /v1/audio/transcriptions - # healthcheck: - # test: ["CMD-SHELL", "curl", "-f", "http://localhost:8080/"] - # interval: 30s - # timeout: 5s - # start_period: 15s - # retries: 3 + # Optional local dependency: whisper.cpp server for audio transcription. + # Start with: docker compose --profile voice up -d whisper-server + whisper-server: + image: ghcr.io/ggml-org/whisper.cpp:main + container_name: whisper-server + restart: unless-stopped + profiles: ["voice"] + ports: + - "18801:8080" + volumes: + - whisper-models:/app/models + command: + - whisper-server + - --model + - /app/models/ggml-base.en.bin + - --host + - 0.0.0.0 + - --port + - "8080" + - --convert + - --language + - en + - --inference-path + - /v1/audio/transcriptions + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 volumes: flynn-data: + whisper-models: diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 9634974..9ca7417 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -382,6 +382,39 @@ Return status for user-level local LLM backend daemons (for example `ollama.serv } ``` +#### `system.dockerDependencies` + +Return status for docker-compose managed dependencies (currently includes local Whisper service status for voice transcription workflows). + +**Request:** +```json +{ + "id": 12, + "method": "system.dockerDependencies" +} +``` + +**Response:** +```json +{ + "id": 12, + "result": { + "dependencies": [ + { + "id": "whisper", + "name": "Whisper (whisper.cpp)", + "service": "whisper-server", + "configured": true, + "state": "running", + "health": "healthy", + "statusText": "Up 4 minutes (healthy)", + "containerName": "flynn-whisper-server-1" + } + ] + } +} +``` + #### `system.localBackendControl` Control a local backend daemon (`start`, `restart`, `stop`, `update`). diff --git a/docs/plans/state.json b/docs/plans/state.json index 27e07e1..e6d548c 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -15,6 +15,26 @@ ], "test_status": "pnpm test:run src/frontends/tui/minimal.test.ts passing" }, + "dashboard-docker-dependencies-whisper-status": { + "status": "completed", + "date": "2026-02-23", + "updated": "2026-02-23", + "summary": "Added docker-compose dependency visibility to gateway + dashboard with new `system.dockerDependencies` RPC and Whisper status cards in Live Ops Dashboard. Whisper compose service is now defined in `docker-compose.yml` behind a `voice` profile for optional local transcription runtime.", + "files_modified": [ + "src/gateway/handlers/dockerDependencies.ts", + "src/gateway/handlers/dockerDependencies.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", + "docker-compose.yml", + "README.md", + "docs/api/PROTOCOL.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/gateway/handlers/dockerDependencies.test.ts src/gateway/handlers/handlers.test.ts src/gateway/ui/pages/dashboard.test.ts + pnpm typecheck passing" + }, "dashboard-local-backend-update-actions": { "status": "completed", "date": "2026-02-23", diff --git a/src/gateway/handlers/dockerDependencies.test.ts b/src/gateway/handlers/dockerDependencies.test.ts new file mode 100644 index 0000000..9d1bdca --- /dev/null +++ b/src/gateway/handlers/dockerDependencies.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import type { Config } from '../../config/index.js'; +import { listDockerDependencyStatuses } from './dockerDependencies.js'; + +function createConfig(endpoint: string, enabled = true): Config { + return { + audio: { + enabled, + provider: { + type: 'custom', + endpoint, + }, + }, + } as unknown as Config; +} + +describe('listDockerDependencyStatuses', () => { + it('reports whisper as running when compose ps shows active container', async () => { + const runner = async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + } + if (args[0] === 'ps') { + return { + stdout: JSON.stringify([{ + Name: 'flynn-whisper-server-1', + Service: 'whisper-server', + State: 'running', + Health: 'healthy', + Status: 'Up 4 minutes (healthy)', + }]), + stderr: '', + }; + } + throw new Error(`Unexpected args: ${args.join(' ')}`); + }; + + const statuses = await listDockerDependencyStatuses( + createConfig('http://localhost:18801/v1/audio/transcriptions'), + runner, + ); + expect(statuses).toHaveLength(1); + expect(statuses[0]).toMatchObject({ + id: 'whisper', + configured: true, + state: 'running', + health: 'healthy', + statusText: 'Up 4 minutes (healthy)', + containerName: 'flynn-whisper-server-1', + }); + }); + + it('reports whisper as defined but not started when no container exists yet', async () => { + const runner = async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + } + if (args[0] === 'ps') { + return { stdout: '[]', stderr: '' }; + } + throw new Error(`Unexpected args: ${args.join(' ')}`); + }; + + const statuses = await listDockerDependencyStatuses( + createConfig('http://localhost:18801/v1/audio/transcriptions'), + runner, + ); + expect(statuses[0]).toMatchObject({ + id: 'whisper', + state: 'not-created', + statusText: 'defined, not started', + health: 'none', + configured: true, + }); + }); + + it('reports whisper service as missing when compose file does not define it', async () => { + const runner = async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: 'flynn\n', stderr: '' }; + } + throw new Error(`Unexpected args: ${args.join(' ')}`); + }; + + const statuses = await listDockerDependencyStatuses( + createConfig('http://localhost:18801/v1/audio/transcriptions'), + runner, + ); + expect(statuses[0]).toMatchObject({ + id: 'whisper', + state: 'not-found', + statusText: 'service not defined in docker-compose.yml', + health: 'none', + }); + }); + + it('returns unavailable status when docker compose command fails', async () => { + const runner = async () => { + throw Object.assign(new Error('spawn docker ENOENT'), { stderr: 'docker: command not found' }); + }; + + const statuses = await listDockerDependencyStatuses( + createConfig('http://localhost:18801/v1/audio/transcriptions'), + runner, + ); + expect(statuses[0].statusText).toBe('unavailable'); + expect(statuses[0].error).toContain('docker: command not found'); + }); + + it('marks whisper as not configured for non-local transcription endpoints', async () => { + const runner = async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: 'whisper-server\n', stderr: '' }; + } + if (args[0] === 'ps') { + return { stdout: '[]', stderr: '' }; + } + throw new Error(`Unexpected args: ${args.join(' ')}`); + }; + + const statuses = await listDockerDependencyStatuses( + createConfig('https://api.openai.com/v1/audio/transcriptions'), + runner, + ); + expect(statuses[0]?.configured).toBe(false); + }); +}); diff --git a/src/gateway/handlers/dockerDependencies.ts b/src/gateway/handlers/dockerDependencies.ts new file mode 100644 index 0000000..0791549 --- /dev/null +++ b/src/gateway/handlers/dockerDependencies.ts @@ -0,0 +1,208 @@ +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { Config } from '../../config/index.js'; + +const execFile = promisify(execFileCb); + +export type DockerDependencyId = 'whisper'; + +export interface DockerDependencyStatus { + id: DockerDependencyId; + name: string; + service: string; + configured: boolean; + state: string; + health: string; + statusText: string; + containerName: string | null; + error?: string; +} + +type DockerComposeResult = { stdout: string; stderr: string }; +type DockerComposeRunner = (args: string[]) => Promise; + +interface ComposePsEntry { + Name?: string; + Service?: string; + State?: string; + Status?: string; + Health?: string; +} + +const WHISPER_SERVICE = 'whisper-server'; + +function defaultRunner(args: string[]): Promise { + return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], { + timeout: 10_000, + maxBuffer: 1024 * 1024, + }) as Promise; +} + +function normalizeError(error: unknown): string { + if (error && typeof error === 'object') { + const maybe = error as { stderr?: string; stdout?: string; message?: string }; + const stderr = maybe.stderr?.trim(); + if (stderr) {return stderr;} + const stdout = maybe.stdout?.trim(); + if (stdout) {return stdout;} + if (typeof maybe.message === 'string' && maybe.message.trim().length > 0) { + return maybe.message.trim(); + } + } + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + return String(error); +} + +function parseServiceList(output: string): string[] { + return output + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function parseComposePsOutput(output: string): ComposePsEntry[] { + const trimmed = output.trim(); + if (!trimmed) {return [];} + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + return parsed.filter((entry): entry is ComposePsEntry => Boolean(entry && typeof entry === 'object')); + } + if (parsed && typeof parsed === 'object') { + return [parsed as ComposePsEntry]; + } + } catch { + // Some compose versions emit newline-delimited JSON objects instead of an array. + } + + const entries: ComposePsEntry[] = []; + for (const line of trimmed.split('\n')) { + const item = line.trim(); + if (!item) {continue;} + try { + const parsed = JSON.parse(item) as unknown; + if (parsed && typeof parsed === 'object') { + entries.push(parsed as ComposePsEntry); + } + } catch { + // Ignore non-JSON lines. + } + } + return entries; +} + +function normalizeState(rawState: string | undefined): string { + const state = String(rawState ?? '').trim().toLowerCase(); + if (!state) {return 'unknown';} + if (state === 'running') {return 'running';} + if (state === 'exited' || state === 'dead' || state === 'stopped') {return 'stopped';} + if (state === 'created' || state === 'restarting' || state === 'paused') {return state;} + return state; +} + +function buildStatusText(state: string, health: string, statusField: string): string { + if (statusField.trim().length > 0) { + return statusField.trim(); + } + if (state === 'running' && health && health !== 'none' && health !== 'unknown') { + return `running (${health})`; + } + return state || 'unknown'; +} + +function isLocalWhisperEndpoint(endpoint: string): boolean { + let parsed: URL; + try { + parsed = new URL(endpoint); + } catch { + return false; + } + const isLocalHost = parsed.hostname === 'localhost' + || parsed.hostname === '127.0.0.1' + || parsed.hostname === '::1' + || parsed.hostname === '0.0.0.0'; + if (!isLocalHost) {return false;} + return parsed.pathname.includes('/audio/transcriptions'); +} + +function isWhisperConfigured(config: Config): boolean { + if (!config.audio.enabled) {return false;} + const endpoint = config.audio.provider?.endpoint; + if (typeof endpoint !== 'string' || endpoint.trim().length === 0) { + return false; + } + return isLocalWhisperEndpoint(endpoint); +} + +export async function listDockerDependencyStatuses( + config: Config, + runner: DockerComposeRunner = defaultRunner, +): Promise { + const whisperStatus: DockerDependencyStatus = { + id: 'whisper', + name: 'Whisper (whisper.cpp)', + service: WHISPER_SERVICE, + configured: isWhisperConfigured(config), + state: 'unknown', + health: 'unknown', + statusText: 'unknown', + containerName: null, + }; + + let services: string[]; + try { + const response = await runner(['config', '--services']); + services = parseServiceList(response.stdout); + } catch (error) { + return [{ + ...whisperStatus, + statusText: 'unavailable', + error: normalizeError(error), + }]; + } + + if (!services.includes(WHISPER_SERVICE)) { + return [{ + ...whisperStatus, + state: 'not-found', + health: 'none', + statusText: 'service not defined in docker-compose.yml', + }]; + } + + try { + const response = await runner(['ps', WHISPER_SERVICE, '--format', 'json']); + const rows = parseComposePsOutput(response.stdout) + .filter((entry) => (entry.Service ?? '') === WHISPER_SERVICE || !entry.Service); + + if (rows.length === 0) { + return [{ + ...whisperStatus, + state: 'not-created', + health: 'none', + statusText: 'defined, not started', + }]; + } + + const row = rows[0]; + const state = normalizeState(row.State); + const health = String(row.Health ?? '').trim().toLowerCase() || 'none'; + const statusField = String(row.Status ?? ''); + return [{ + ...whisperStatus, + state, + health, + statusText: buildStatusText(state, health, statusField), + containerName: row.Name?.trim() || null, + }]; + } catch (error) { + return [{ + ...whisperStatus, + statusText: 'unknown', + error: normalizeError(error), + }]; + } +} diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 4dad88a..911d81a 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -11,6 +11,7 @@ import { createCanvasHandlers } from './canvas.js'; import { createConfigHandlers, redactConfig } from './config.js'; import { createPairingHandlers } from './pairing.js'; import type { LocalBackendStatus, LocalBackendControlResult } from './localBackends.js'; +import type { DockerDependencyStatus } from './dockerDependencies.js'; import { PairingManager } from '../../channels/pairing.js'; import { LaneQueue } from '../lane-queue.js'; import { CanvasStore } from '../canvas-store.js'; @@ -283,6 +284,36 @@ describe('system handlers', () => { expect(getPath(result.result, 'updatedModels')).toEqual(['llama3.2', 'nomic-embed-text']); }); + it('system.dockerDependencies returns empty list when callback is not provided', async () => { + const req: GatewayRequest = { id: 42, method: 'system.dockerDependencies' }; + const result = await handlers['system.dockerDependencies'](req) as GatewayResponse; + expect(getPath(result.result, 'dependencies')).toEqual([]); + }); + + it('system.dockerDependencies returns dependency statuses from callback', async () => { + const getDockerDependencies = vi.fn(async (): Promise => ([ + { + id: 'whisper', + name: 'Whisper (whisper.cpp)', + service: 'whisper-server', + configured: true, + state: 'running', + health: 'healthy', + statusText: 'Up 10 minutes (healthy)', + containerName: 'flynn-whisper-server-1', + }, + ])); + const handlers = createSystemHandlers({ + ...deps, + getDockerDependencies, + }); + const req: GatewayRequest = { id: 43, method: 'system.dockerDependencies' }; + const result = await handlers['system.dockerDependencies'](req) as GatewayResponse; + expect(getDockerDependencies).toHaveBeenCalledTimes(1); + expect(getPath(result.result, 'dependencies', '0', 'id')).toBe('whisper'); + expect(getPath(result.result, 'dependencies', '0', 'state')).toBe('running'); + }); + it('system.presence returns empty result when getPresence is not provided', async () => { const req: GatewayRequest = { id: 4, method: 'system.presence' }; const result = await handlers['system.presence'](req) as GatewayResponse; diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 5e60948..392a26b 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -5,6 +5,7 @@ import type { ServiceInfo } from './services.js'; import type { NodeLocation, NodeStatus, NodePushToken } from './node.js'; import type { SessionAnalyticsSnapshot } from '../../session/index.js'; import type { LocalBackendAction, LocalBackendControlResult, LocalBackendStatus } from './localBackends.js'; +import type { DockerDependencyStatus } from './dockerDependencies.js'; /** Per-session token usage report returned by system.tokenUsage. */ export interface TokenUsageEntry { @@ -108,6 +109,8 @@ export interface SystemHandlerDeps { getLocalBackends?: () => Promise | LocalBackendStatus[]; /** Optional callback to control local backend daemons. */ controlLocalBackend?: (backend: string, action: string) => Promise; + /** Optional callback to retrieve docker-compose dependency statuses. */ + getDockerDependencies?: () => Promise | DockerDependencyStatus[]; } function normalizeErrorMessage(error: unknown): string { @@ -335,5 +338,17 @@ export function createSystemHandlers(deps: SystemHandlerDeps) { return makeError(request.id, ErrorCode.InternalError, `Local backend control failed: ${normalizeErrorMessage(error)}`); } }, + + 'system.dockerDependencies': async (request: GatewayRequest): Promise => { + if (!deps.getDockerDependencies) { + return makeResponse(request.id, { dependencies: [] }); + } + try { + const dependencies = await deps.getDockerDependencies(); + return makeResponse(request.id, { dependencies }); + } catch (error) { + return makeError(request.id, ErrorCode.InternalError, `Failed to load docker dependencies: ${normalizeErrorMessage(error)}`); + } + }, }; } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d419310..8b5aa95 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -35,6 +35,7 @@ import { import { discoverServices } from './handlers/services.js'; import { createModelCatalogFetcher } from './modelCatalog.js'; import { listLocalBackendStatuses, controlLocalBackend } from './handlers/localBackends.js'; +import { listDockerDependencyStatuses } from './handlers/dockerDependencies.js'; import type { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js'; import type { NodeConnectionState } from './handlers/node.js'; import type { SessionManager } from '../session/manager.js'; @@ -237,6 +238,9 @@ export class GatewayServer { controlLocalBackend: runtimeConfig ? (backend, action) => controlLocalBackend(runtimeConfig, backend, action) : undefined, + getDockerDependencies: runtimeConfig + ? () => listDockerDependencyStatuses(runtimeConfig) + : undefined, getPresence: channelRegistry ? (opts) => channelRegistry.getPresence(opts) : undefined, diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js index 69fef36..d5674a1 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -386,6 +386,12 @@ function renderSkeleton(el) {
Loading...
+ +

Docker Dependencies

+
Status for docker-compose services Flynn depends on (for example local Whisper transcription).
+
+
Loading...
+
`; } @@ -1421,6 +1427,7 @@ function updateAssistantHealth(configData) { } updateServices(refreshed.services); updateLocalBackends(refreshed.localBackends); + updateDockerDependencies(refreshed.dockerDependencies); updateSessionAnalytics(refreshed.sessionAnalytics); updateContextHealth(refreshed.contextUsage); // Only re-render assistant controls from a confirmed config snapshot. @@ -1612,6 +1619,47 @@ function updateLocalBackends(localBackendsData) { }); } +function updateDockerDependencies(dockerDependenciesData) { + const el = document.getElementById('ops-docker-dependencies'); + if (!el) {return;} + + const dependencies = dockerDependenciesData?.dependencies ?? []; + + if (dependencies.length === 0) { + el.innerHTML = '
No docker dependencies detected
'; + return; + } + + el.innerHTML = dependencies.map((dependency) => { + const state = String(dependency.state ?? 'unknown'); + const health = String(dependency.health ?? 'unknown'); + const statusText = String(dependency.statusText ?? state); + const configured = Boolean(dependency.configured); + const configuredText = configured ? 'configured' : 'not configured'; + const configuredClass = configured ? 'text-blue-400' : 'text-zinc-500'; + const toneClass = state === 'running' + ? 'text-green-500' + : state === 'not-found' + ? 'text-amber-500' + : state === 'unknown' + ? 'text-red-500' + : 'text-zinc-400'; + const containerName = dependency.containerName ? String(dependency.containerName) : '—'; + + return `
+
+
${escapeHtml(String(dependency.name ?? dependency.id ?? 'dependency'))}
+ ${escapeHtml(statusText)} +
+
Compose service: ${escapeHtml(String(dependency.service ?? 'unknown'))}
+
Container: ${escapeHtml(containerName)}
+
State: ${escapeHtml(state)} · Health: ${escapeHtml(health)}
+
${escapeHtml(configuredText)}
+ ${dependency.error ? `
Error: ${escapeHtml(String(dependency.error))}
` : ''} +
`; + }).join(''); +} + async function handleLocalBackendAction(backendId, action) { if (!_dashboardClient) {return;} const actionLabel = LOCAL_BACKEND_ACTION_LABELS[action] ?? action; @@ -1640,6 +1688,9 @@ async function handleLocalBackendAction(backendId, action) { if (refreshed?.localBackends) { updateLocalBackends(refreshed.localBackends); } + if (refreshed?.dockerDependencies) { + updateDockerDependencies(refreshed.dockerDependencies); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); _localBackendActionState.set(backendId, { @@ -1822,6 +1873,7 @@ function renderServiceConfigModal() { if (refreshed) { updateServices(refreshed.services); updateLocalBackends(refreshed.localBackends); + updateDockerDependencies(refreshed.dockerDependencies); updateSessionAnalytics(refreshed.sessionAnalytics); updateContextHealth(refreshed.contextUsage); if (refreshed.config) { @@ -1854,10 +1906,11 @@ async function fetchFast(client) { } async function fetchSlow(client) { - const [health, services, localBackends, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([ + const [health, services, localBackends, dockerDependencies, 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.sessionAnalytics', { days: 14, topLimit: 5 }), client.call('system.contextUsage'), client.call('config.get'), @@ -1876,6 +1929,7 @@ async function fetchSlow(client) { health: unwrap(health), services: unwrap(services), localBackends: unwrap(localBackends), + dockerDependencies: unwrap(dockerDependencies), sessionAnalytics: unwrap(sessionAnalytics), contextUsage: unwrap(contextUsage), config: configValue, @@ -1915,6 +1969,9 @@ async function loadDashboard(el, client) { if (slow?.localBackends) { updateLocalBackends(slow.localBackends); } + if (slow?.dockerDependencies) { + updateDockerDependencies(slow.dockerDependencies); + } if (slow?.sessionAnalytics) { updateSessionAnalytics(slow.sessionAnalytics); } @@ -1953,6 +2010,9 @@ async function loadDashboard(el, client) { if (data.localBackends) { updateLocalBackends(data.localBackends); } + if (data.dockerDependencies) { + updateDockerDependencies(data.dockerDependencies); + } if (data.sessionAnalytics) { updateSessionAnalytics(data.sessionAnalytics); } diff --git a/src/gateway/ui/pages/dashboard.test.ts b/src/gateway/ui/pages/dashboard.test.ts index 24aa0f1..e6b6144 100644 --- a/src/gateway/ui/pages/dashboard.test.ts +++ b/src/gateway/ui/pages/dashboard.test.ts @@ -125,6 +125,18 @@ function createMockClient() { availableActions: ['start', 'restart', 'update'], }, ], + dockerDependencies: [ + { + id: 'whisper', + name: 'Whisper (whisper.cpp)', + service: 'whisper-server', + configured: true, + state: 'running', + health: 'healthy', + statusText: 'Up 2 minutes (healthy)', + containerName: 'flynn-whisper-server-1', + }, + ], calls: [] as Array<{ method: string; params?: Record }>, }; @@ -161,6 +173,9 @@ function createMockClient() { if (method === 'system.localBackends') { return { backends: deepClone(state.localBackends) }; } + if (method === 'system.dockerDependencies') { + return { dependencies: deepClone(state.dockerDependencies) }; + } if (method === 'system.sessionAnalytics') { return { daily: [], @@ -475,4 +490,16 @@ describe('DashboardPage assistant controls', () => { expect(backendCalls[2].params).toEqual({ backend: 'ollama', action: 'update' }); expect(state.localBackends.find((entry) => entry.id === 'llamacpp')?.activeState).toBe('active'); }); + + it('renders docker dependency status cards', async () => { + const { client } = createMockClient(); + + await DashboardPage.render(container, client); + + const card = container.querySelector('#ops-docker-dependencies'); + expect(card).toBeTruthy(); + expect(String(card.textContent ?? '')).toContain('Whisper (whisper.cpp)'); + expect(String(card.textContent ?? '')).toContain('whisper-server'); + expect(String(card.textContent ?? '')).toContain('Up 2 minutes (healthy)'); + }); });