From abaa9be3f10e4dd320b15378c53bb989dc82c3d3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 22 Feb 2026 19:48:27 -0800 Subject: [PATCH] Add whisper docker dependency controls to dashboard --- docs/api/PROTOCOL.md | 39 +++++- docs/plans/state.json | 18 +++ .../handlers/dockerDependencies.test.ts | 86 ++++++++++++- src/gateway/handlers/dockerDependencies.ts | 115 +++++++++++++++++- src/gateway/handlers/handlers.test.ts | 63 +++++++++- src/gateway/handlers/system.ts | 31 ++++- src/gateway/server.ts | 5 +- src/gateway/ui/pages/dashboard.js | 84 +++++++++++++ src/gateway/ui/pages/dashboard.test.ts | 68 +++++++++++ 9 files changed, 501 insertions(+), 8 deletions(-) diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 9ca7417..f0ef06d 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -408,13 +408,50 @@ Return status for docker-compose managed dependencies (currently includes local "state": "running", "health": "healthy", "statusText": "Up 4 minutes (healthy)", - "containerName": "flynn-whisper-server-1" + "containerName": "flynn-whisper-server-1", + "availableActions": ["restart", "stop", "update"] } ] } } ``` +#### `system.dockerDependencyControl` + +Control a docker-compose dependency (`start`, `restart`, `stop`, `update`). + +- `update` for `whisper` pulls the latest image and runs `docker compose up -d` to reconcile. + +**Request:** +```json +{ + "id": 14, + "method": "system.dockerDependencyControl", + "params": { + "dependency": "whisper", + "action": "restart" + } +} +``` + +**Response:** +```json +{ + "id": 14, + "result": { + "dependency": "whisper", + "action": "restart", + "status": { + "id": "whisper", + "state": "running", + "health": "healthy", + "statusText": "running (healthy)" + }, + "message": "Restarted whisper-server container." + } +} +``` + #### `system.localBackendControl` Control a local backend daemon (`start`, `restart`, `stop`, `update`). diff --git a/docs/plans/state.json b/docs/plans/state.json index 349546b..63b4212 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-docker-dependency-controls": { + "status": "completed", + "date": "2026-02-23", + "updated": "2026-02-23", + "summary": "Added daemon-style Start/Restart/Stop/Update controls for Docker Dependencies in Live Ops Dashboard. Introduced `system.dockerDependencyControl` RPC, docker compose control handlers for Whisper service lifecycle + image update, and dashboard action wiring with status feedback.", + "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", + "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-docker-dependencies-profile-aware-detection": { "status": "completed", "date": "2026-02-23", diff --git a/src/gateway/handlers/dockerDependencies.test.ts b/src/gateway/handlers/dockerDependencies.test.ts index 4e8ab88..cf2b2ea 100644 --- a/src/gateway/handlers/dockerDependencies.test.ts +++ b/src/gateway/handlers/dockerDependencies.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { Config } from '../../config/index.js'; -import { listDockerDependencyStatuses } from './dockerDependencies.js'; +import { listDockerDependencyStatuses, controlDockerDependency } from './dockerDependencies.js'; function createConfig(endpoint: string, enabled = true): Config { return { @@ -49,6 +49,7 @@ describe('listDockerDependencyStatuses', () => { health: 'healthy', statusText: 'Up 4 minutes (healthy)', containerName: 'flynn-whisper-server-1', + availableActions: ['restart', 'stop', 'update'], }); expect(seenCalls[0]).toEqual(['--profile', 'voice', 'config', '--services']); expect(seenCalls[1]).toEqual(['--profile', 'voice', 'ps', 'whisper-server', '--format', 'json']); @@ -75,6 +76,7 @@ describe('listDockerDependencyStatuses', () => { statusText: 'defined, not started', health: 'none', configured: true, + availableActions: ['start', 'restart', 'update'], }); }); @@ -95,6 +97,7 @@ describe('listDockerDependencyStatuses', () => { state: 'not-found', statusText: 'service not defined in docker-compose.yml', health: 'none', + availableActions: [], }); }); @@ -108,6 +111,8 @@ describe('listDockerDependencyStatuses', () => { runner, ); expect(statuses[0].statusText).toBe('unavailable'); + expect(statuses[0].state).toBe('unavailable'); + expect(statuses[0].availableActions).toEqual([]); expect(statuses[0].error).toContain('docker: command not found'); }); @@ -129,3 +134,82 @@ describe('listDockerDependencyStatuses', () => { expect(statuses[0]?.configured).toBe(false); }); }); + +describe('controlDockerDependency', () => { + it('starts whisper via compose up and returns refreshed status', async () => { + const calls: string[][] = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args.includes('config')) { + return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + } + if (args.includes('up')) { + return { stdout: '', stderr: '' }; + } + if (args.includes('ps')) { + return { + stdout: JSON.stringify([{ + Name: 'whisper-server', + Service: 'whisper-server', + State: 'running', + Health: 'healthy', + Status: 'Up 2 seconds (healthy)', + }]), + stderr: '', + }; + } + throw new Error(`Unexpected args: ${args.join(' ')}`); + }; + + const result = await controlDockerDependency( + createConfig('http://localhost:18801/v1/audio/transcriptions'), + 'whisper', + 'start', + runner, + ); + expect(result.action).toBe('start'); + expect(result.status.state).toBe('running'); + expect(result.status.availableActions).toEqual(['restart', 'stop', 'update']); + expect(calls).toContainEqual(['--profile', 'voice', 'up', '-d', 'whisper-server']); + }); + + it('updates whisper by pulling image then reconciling container', async () => { + const calls: string[][] = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args.includes('config')) { + return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + } + if (args.includes('pull')) { + return { stdout: 'Pulled', stderr: '' }; + } + if (args.includes('up')) { + return { stdout: 'Started', stderr: '' }; + } + if (args.includes('ps')) { + return { + stdout: JSON.stringify([{ + Name: 'whisper-server', + Service: 'whisper-server', + State: 'running', + Health: 'healthy', + Status: 'Up 1 minute (healthy)', + }]), + stderr: '', + }; + } + throw new Error(`Unexpected args: ${args.join(' ')}`); + }; + + const result = await controlDockerDependency( + createConfig('http://localhost:18801/v1/audio/transcriptions'), + 'whisper', + 'update', + runner, + ); + expect(result.action).toBe('update'); + expect(result.message).toContain('Pulled latest whisper image'); + expect(calls).toContainEqual(['--profile', 'voice', 'pull', 'whisper-server']); + expect(calls).toContainEqual(['--profile', 'voice', 'up', '-d', 'whisper-server']); + }); +}); diff --git a/src/gateway/handlers/dockerDependencies.ts b/src/gateway/handlers/dockerDependencies.ts index 456b718..9998560 100644 --- a/src/gateway/handlers/dockerDependencies.ts +++ b/src/gateway/handlers/dockerDependencies.ts @@ -5,6 +5,7 @@ import type { Config } from '../../config/index.js'; const execFile = promisify(execFileCb); export type DockerDependencyId = 'whisper'; +export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update'; export interface DockerDependencyStatus { id: DockerDependencyId; @@ -15,9 +16,17 @@ export interface DockerDependencyStatus { health: string; statusText: string; containerName: string | null; + availableActions: DockerDependencyAction[]; error?: string; } +export interface DockerDependencyControlResult { + dependency: DockerDependencyId; + action: DockerDependencyAction; + status: DockerDependencyStatus; + message?: string; +} + type DockerComposeResult = { stdout: string; stderr: string }; type DockerComposeRunner = (args: string[]) => Promise; @@ -36,13 +45,21 @@ function withWhisperProfile(args: string[]): string[] { return ['--profile', WHISPER_PROFILE, ...args]; } -function defaultRunner(args: string[]): Promise { +function runCompose(args: string[], timeout: number): Promise { return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], { - timeout: 10_000, - maxBuffer: 1024 * 1024, + timeout, + maxBuffer: 4 * 1024 * 1024, }) as Promise; } +function defaultRunner(args: string[]): Promise { + return runCompose(args, 10_000); +} + +function defaultControlRunner(args: string[]): Promise { + return runCompose(args, 15 * 60_000); +} + function normalizeError(error: unknown): string { if (error && typeof error === 'object') { const maybe = error as { stderr?: string; stdout?: string; message?: string }; @@ -118,6 +135,22 @@ function buildStatusText(state: string, health: string, statusField: string): st return state || 'unknown'; } +function computeAvailableActions(state: string): DockerDependencyAction[] { + if (state === 'not-found' || state === 'unavailable') { + return []; + } + if (state === 'running') { + return ['restart', 'stop', 'update']; + } + if (state === 'stopped' || state === 'not-created' || state === 'created') { + return ['start', 'restart', 'update']; + } + if (state === 'restarting' || state === 'paused') { + return ['restart', 'stop', 'update']; + } + return ['start', 'restart', 'stop', 'update']; +} + function isLocalWhisperEndpoint(endpoint: string): boolean { let parsed: URL; try { @@ -155,6 +188,7 @@ export async function listDockerDependencyStatuses( health: 'unknown', statusText: 'unknown', containerName: null, + availableActions: [], }; let services: string[]; @@ -164,7 +198,9 @@ export async function listDockerDependencyStatuses( } catch (error) { return [{ ...whisperStatus, + state: 'unavailable', statusText: 'unavailable', + availableActions: [], error: normalizeError(error), }]; } @@ -175,6 +211,7 @@ export async function listDockerDependencyStatuses( state: 'not-found', health: 'none', statusText: 'service not defined in docker-compose.yml', + availableActions: [], }]; } @@ -189,6 +226,7 @@ export async function listDockerDependencyStatuses( state: 'not-created', health: 'none', statusText: 'defined, not started', + availableActions: computeAvailableActions('not-created'), }]; } @@ -202,12 +240,83 @@ export async function listDockerDependencyStatuses( health, statusText: buildStatusText(state, health, statusField), containerName: row.Name?.trim() || null, + availableActions: computeAvailableActions(state), }]; } catch (error) { return [{ ...whisperStatus, statusText: 'unknown', + availableActions: computeAvailableActions('unknown'), error: normalizeError(error), }]; } } + +function ensureValidDependency(id: string): asserts id is DockerDependencyId { + if (id !== 'whisper') { + throw new Error(`Unsupported dependency: ${id}`); + } +} + +function ensureValidAction(action: string): asserts action is DockerDependencyAction { + if (action !== 'start' && action !== 'restart' && action !== 'stop' && action !== 'update') { + throw new Error(`Unsupported action: ${action}`); + } +} + +async function ensureWhisperServiceDefined(runner: DockerComposeRunner): Promise { + const response = await runner(withWhisperProfile(['config', '--services'])); + const services = parseServiceList(response.stdout); + if (!services.includes(WHISPER_SERVICE)) { + throw new Error('whisper-server service is not defined in docker-compose.yml'); + } +} + +export async function controlDockerDependency( + config: Config, + dependency: string, + action: string, + runner: DockerComposeRunner = defaultControlRunner, +): Promise { + ensureValidDependency(dependency); + ensureValidAction(action); + await ensureWhisperServiceDefined(runner); + + let message: string | undefined; + if (action === 'start') { + await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE])); + message = 'Started whisper-server container.'; + } else if (action === 'restart') { + try { + await runner(withWhisperProfile(['restart', WHISPER_SERVICE])); + message = 'Restarted whisper-server container.'; + } catch (error) { + const detail = normalizeError(error).toLowerCase(); + if (detail.includes('no containers to restart')) { + await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE])); + message = 'Whisper container was not running; started it.'; + } else { + throw error; + } + } + } else if (action === 'stop') { + await runner(withWhisperProfile(['stop', WHISPER_SERVICE])); + message = 'Stopped whisper-server container.'; + } else if (action === 'update') { + await runner(withWhisperProfile(['pull', WHISPER_SERVICE])); + await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE])); + message = 'Pulled latest whisper image and reconciled container.'; + } + + const status = (await listDockerDependencyStatuses(config, runner))[0]; + if (!status) { + throw new Error('Failed to load docker dependency status after action.'); + } + + return { + dependency, + action, + status, + message, + }; +} diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 911d81a..0ce92ac 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -11,7 +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 type { DockerDependencyStatus, DockerDependencyControlResult } from './dockerDependencies.js'; import { PairingManager } from '../../channels/pairing.js'; import { LaneQueue } from '../lane-queue.js'; import { CanvasStore } from '../canvas-store.js'; @@ -301,6 +301,7 @@ describe('system handlers', () => { health: 'healthy', statusText: 'Up 10 minutes (healthy)', containerName: 'flynn-whisper-server-1', + availableActions: ['restart', 'stop', 'update'], }, ])); const handlers = createSystemHandlers({ @@ -314,6 +315,66 @@ describe('system handlers', () => { expect(getPath(result.result, 'dependencies', '0', 'state')).toBe('running'); }); + it('system.dockerDependencyControl validates required params', async () => { + const handlers = createSystemHandlers({ + ...deps, + controlDockerDependency: vi.fn(), + }); + + const missingDependency = await handlers['system.dockerDependencyControl']({ + id: 44, + method: 'system.dockerDependencyControl', + params: { action: 'restart' }, + }) as GatewayError; + expect(missingDependency.error.code).toBe(ErrorCode.InvalidRequest); + + const missingAction = await handlers['system.dockerDependencyControl']({ + id: 45, + method: 'system.dockerDependencyControl', + params: { dependency: 'whisper' }, + }) as GatewayError; + expect(missingAction.error.code).toBe(ErrorCode.InvalidRequest); + + const badAction = await handlers['system.dockerDependencyControl']({ + id: 46, + method: 'system.dockerDependencyControl', + params: { dependency: 'whisper', action: 'reload' }, + }) as GatewayError; + expect(badAction.error.code).toBe(ErrorCode.InvalidRequest); + }); + + it('system.dockerDependencyControl forwards action to callback', async () => { + const controlDockerDependency = vi.fn(async (): Promise => ({ + dependency: 'whisper' as const, + action: 'restart' as const, + status: { + id: 'whisper' as const, + name: 'Whisper (whisper.cpp)', + service: 'whisper-server', + configured: true, + state: 'running', + health: 'healthy', + statusText: 'running (healthy)', + containerName: 'whisper-server', + availableActions: ['restart', 'stop', 'update'], + }, + message: 'Restarted whisper-server container.', + })); + const handlers = createSystemHandlers({ + ...deps, + controlDockerDependency, + }); + const req: GatewayRequest = { + id: 47, + method: 'system.dockerDependencyControl', + params: { dependency: 'whisper', action: 'restart' }, + }; + const result = await handlers['system.dockerDependencyControl'](req) as GatewayResponse; + expect(controlDockerDependency).toHaveBeenCalledWith('whisper', 'restart'); + expect(getPath(result.result, 'status', 'state')).toBe('running'); + expect(getPath(result.result, 'action')).toBe('restart'); + }); + 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 392a26b..8486396 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -5,7 +5,11 @@ 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'; +import type { + DockerDependencyAction, + DockerDependencyControlResult, + DockerDependencyStatus, +} from './dockerDependencies.js'; /** Per-session token usage report returned by system.tokenUsage. */ export interface TokenUsageEntry { @@ -111,6 +115,8 @@ export interface SystemHandlerDeps { controlLocalBackend?: (backend: string, action: string) => Promise; /** Optional callback to retrieve docker-compose dependency statuses. */ getDockerDependencies?: () => Promise | DockerDependencyStatus[]; + /** Optional callback to control docker-compose dependencies. */ + controlDockerDependency?: (dependency: string, action: string) => Promise; } function normalizeErrorMessage(error: unknown): string { @@ -350,5 +356,28 @@ export function createSystemHandlers(deps: SystemHandlerDeps) { return makeError(request.id, ErrorCode.InternalError, `Failed to load docker dependencies: ${normalizeErrorMessage(error)}`); } }, + + 'system.dockerDependencyControl': async (request: GatewayRequest): Promise => { + if (!deps.controlDockerDependency) { + return makeError(request.id, ErrorCode.InternalError, 'Docker dependency control is not available in this environment'); + } + const params = request.params as { dependency?: string; action?: DockerDependencyAction } | undefined; + if (!params?.dependency || typeof params.dependency !== 'string') { + return makeError(request.id, ErrorCode.InvalidRequest, 'dependency is required'); + } + if (!params?.action || typeof params.action !== 'string') { + return makeError(request.id, ErrorCode.InvalidRequest, 'action is required'); + } + if (!['start', 'restart', 'stop', 'update'].includes(params.action)) { + return makeError(request.id, ErrorCode.InvalidRequest, 'action must be one of: start, restart, stop, update'); + } + + try { + const result = await deps.controlDockerDependency(params.dependency, params.action); + return makeResponse(request.id, result); + } catch (error) { + return makeError(request.id, ErrorCode.InternalError, `Docker dependency control failed: ${normalizeErrorMessage(error)}`); + } + }, }; } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 8b5aa95..e3c61b6 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -35,7 +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 { listDockerDependencyStatuses, controlDockerDependency } 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'; @@ -241,6 +241,9 @@ export class GatewayServer { getDockerDependencies: runtimeConfig ? () => listDockerDependencyStatuses(runtimeConfig) : undefined, + controlDockerDependency: runtimeConfig + ? (dependency, action) => controlDockerDependency(runtimeConfig, dependency, action) + : 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 d5674a1..7616faf 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -21,7 +21,9 @@ let _lastCouncilResult = null; let _lastCouncilError = null; let _lastServices = []; let _lastLocalBackends = []; +let _lastDockerDependencies = []; let _localBackendActionState = new Map(); +let _dockerDependencyActionState = new Map(); let _serviceConfigState = { open: false, serviceName: null, @@ -39,6 +41,12 @@ const LOCAL_BACKEND_ACTION_LABELS = { stop: 'Stop', update: 'Update', }; +const DOCKER_DEPENDENCY_ACTION_LABELS = { + start: 'Start', + restart: 'Restart', + stop: 'Stop', + update: 'Update', +}; const SERVICE_TOGGLE_PATCH_PATHS = { heartbeat: 'automation.heartbeat.enabled', daily_briefing: 'automation.daily_briefing.enabled', @@ -1624,6 +1632,7 @@ function updateDockerDependencies(dockerDependenciesData) { if (!el) {return;} const dependencies = dockerDependenciesData?.dependencies ?? []; + _lastDockerDependencies = dependencies; if (dependencies.length === 0) { el.innerHTML = '
No docker dependencies detected
'; @@ -1631,6 +1640,9 @@ function updateDockerDependencies(dockerDependenciesData) { } el.innerHTML = dependencies.map((dependency) => { + const dependencyId = String(dependency.id ?? ''); + const actionState = _dockerDependencyActionState.get(dependencyId) ?? null; + const isPending = Boolean(actionState?.pending); const state = String(dependency.state ?? 'unknown'); const health = String(dependency.health ?? 'unknown'); const statusText = String(dependency.statusText ?? state); @@ -1645,6 +1657,23 @@ function updateDockerDependencies(dockerDependenciesData) { ? 'text-red-500' : 'text-zinc-400'; const containerName = dependency.containerName ? String(dependency.containerName) : '—'; + const availableActions = Array.isArray(dependency.availableActions) + ? dependency.availableActions.filter((value) => ['start', 'restart', 'stop', 'update'].includes(String(value))) + : []; + const actionMessage = actionState?.message + ? `
${escapeHtml(String(actionState.message))}
` + : ''; + const actionButtons = availableActions.length > 0 + ? availableActions.map((action) => { + const key = String(action); + const label = DOCKER_DEPENDENCY_ACTION_LABELS[key] ?? key; + return ``; + }).join('') + : 'No actions available'; return `
@@ -1656,8 +1685,19 @@ function updateDockerDependencies(dockerDependenciesData) {
State: ${escapeHtml(state)} · Health: ${escapeHtml(health)}
${escapeHtml(configuredText)}
${dependency.error ? `
Error: ${escapeHtml(String(dependency.error))}
` : ''} + ${actionMessage} +
${actionButtons}
`; }).join(''); + + el.querySelectorAll('.docker-dependency-action-btn').forEach((button) => { + button.addEventListener('click', async () => { + const dependencyId = button.getAttribute('data-dependency-id'); + const action = button.getAttribute('data-action'); + if (!dependencyId || !action) {return;} + await handleDockerDependencyAction(dependencyId, action); + }); + }); } async function handleLocalBackendAction(backendId, action) { @@ -1702,6 +1742,48 @@ async function handleLocalBackendAction(backendId, action) { } } +async function handleDockerDependencyAction(dependencyId, action) { + if (!_dashboardClient) {return;} + const actionLabel = DOCKER_DEPENDENCY_ACTION_LABELS[action] ?? action; + _dockerDependencyActionState.set(dependencyId, { pending: true, tone: 'neutral', message: `${actionLabel} requested…` }); + updateDockerDependencies({ dependencies: _lastDockerDependencies }); + + try { + const result = await _dashboardClient.call('system.dockerDependencyControl', { + dependency: dependencyId, + action, + }); + const status = result?.status; + const resultMessage = typeof result?.message === 'string' ? result.message : null; + if (status && typeof status === 'object') { + _lastDockerDependencies = _lastDockerDependencies.map((dependency) => + dependency.id === dependencyId ? status : dependency); + } + _dockerDependencyActionState.set(dependencyId, { + pending: false, + tone: 'success', + message: resultMessage ? `${actionLabel} completed: ${resultMessage}` : `${actionLabel} completed`, + }); + updateDockerDependencies({ dependencies: _lastDockerDependencies }); + + const refreshed = await fetchSlow(_dashboardClient); + if (refreshed?.localBackends) { + updateLocalBackends(refreshed.localBackends); + } + if (refreshed?.dockerDependencies) { + updateDockerDependencies(refreshed.dockerDependencies); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + _dockerDependencyActionState.set(dependencyId, { + pending: false, + tone: 'error', + message: `${actionLabel} failed: ${message}`, + }); + updateDockerDependencies({ dependencies: _lastDockerDependencies }); + } +} + function getConfigValue(path, fallbackValue) { const value = getByPath(_lastAssistantConfig, path); return value === undefined ? fallbackValue : value; @@ -2052,7 +2134,9 @@ export const DashboardPage = { _assistantDraftTouchedAt = 0; _lastServices = []; _lastLocalBackends = []; + _lastDockerDependencies = []; _localBackendActionState = new Map(); + _dockerDependencyActionState = new Map(); _serviceConfigState = { open: false, serviceName: null, diff --git a/src/gateway/ui/pages/dashboard.test.ts b/src/gateway/ui/pages/dashboard.test.ts index e6b6144..a1389c2 100644 --- a/src/gateway/ui/pages/dashboard.test.ts +++ b/src/gateway/ui/pages/dashboard.test.ts @@ -135,6 +135,7 @@ function createMockClient() { health: 'healthy', statusText: 'Up 2 minutes (healthy)', containerName: 'flynn-whisper-server-1', + availableActions: ['restart', 'stop', 'update'], }, ], calls: [] as Array<{ method: string; params?: Record }>, @@ -262,6 +263,39 @@ function createMockClient() { message: action === 'update' ? 'Updated backend assets' : undefined, }; } + if (method === 'system.dockerDependencyControl') { + const dependencyId = String(params?.dependency ?? ''); + const action = String(params?.action ?? ''); + const dependency = state.dockerDependencies.find((entry) => entry.id === dependencyId); + if (!dependency) { + throw new Error(`Unknown dependency: ${dependencyId}`); + } + + if (action === 'start' || action === 'restart') { + dependency.state = 'running'; + dependency.health = 'healthy'; + dependency.statusText = 'running (healthy)'; + dependency.containerName = 'whisper-server'; + dependency.availableActions = ['restart', 'stop', 'update']; + } else if (action === 'stop') { + dependency.state = 'stopped'; + dependency.health = 'none'; + dependency.statusText = 'stopped'; + dependency.availableActions = ['start', 'restart', 'update']; + } else if (action === 'update') { + dependency.state = 'running'; + dependency.health = 'healthy'; + dependency.statusText = 'running (healthy)'; + dependency.availableActions = ['restart', 'stop', 'update']; + } + + return { + dependency: dependencyId, + action, + status: deepClone(dependency), + message: action === 'update' ? 'Pulled latest image' : undefined, + }; + } return null; }, }; @@ -502,4 +536,38 @@ describe('DashboardPage assistant controls', () => { expect(String(card.textContent ?? '')).toContain('whisper-server'); expect(String(card.textContent ?? '')).toContain('Up 2 minutes (healthy)'); }); + + it('renders docker dependency controls and triggers system.dockerDependencyControl actions', async () => { + const { state, client } = createMockClient(); + + await DashboardPage.render(container, client); + + const restartBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="whisper"][data-action="restart"]'); + expect(restartBtn).toBeTruthy(); + restartBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); + await flush(); + + const stopBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="whisper"][data-action="stop"]'); + expect(stopBtn).toBeTruthy(); + stopBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); + await flush(); + + const startBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="whisper"][data-action="start"]'); + expect(startBtn).toBeTruthy(); + startBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); + await flush(); + + const updateBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="whisper"][data-action="update"]'); + expect(updateBtn).toBeTruthy(); + updateBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); + await flush(); + + const dependencyCalls = state.calls.filter((entry) => entry.method === 'system.dockerDependencyControl'); + expect(dependencyCalls).toHaveLength(4); + expect(dependencyCalls[0].params).toEqual({ dependency: 'whisper', action: 'restart' }); + expect(dependencyCalls[1].params).toEqual({ dependency: 'whisper', action: 'stop' }); + expect(dependencyCalls[2].params).toEqual({ dependency: 'whisper', action: 'start' }); + expect(dependencyCalls[3].params).toEqual({ dependency: 'whisper', action: 'update' }); + expect(state.dockerDependencies.find((entry) => entry.id === 'whisper')?.state).toBe('running'); + }); });