feat: discover docker compose deps for dashboard
This commit is contained in:
+14
-2
@@ -384,7 +384,7 @@ 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).
|
||||
Return status for docker-compose managed dependencies discovered from `docker-compose.yml` (excluding Flynn's own `flynn` service). Includes profile-scoped services (for example `whisper-server`, `brave-search`) when profiles are defined.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
@@ -410,6 +410,17 @@ Return status for docker-compose managed dependencies (currently includes local
|
||||
"statusText": "Up 4 minutes (healthy)",
|
||||
"containerName": "flynn-whisper-server-1",
|
||||
"availableActions": ["restart", "stop", "update"]
|
||||
},
|
||||
{
|
||||
"id": "brave-search",
|
||||
"name": "Brave Search",
|
||||
"service": "brave-search",
|
||||
"configured": true,
|
||||
"state": "running",
|
||||
"health": "healthy",
|
||||
"statusText": "Up 2 minutes",
|
||||
"containerName": "brave-search",
|
||||
"availableActions": ["restart", "stop", "update"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -420,7 +431,8 @@ Return status for docker-compose managed dependencies (currently includes local
|
||||
|
||||
Control a docker-compose dependency (`start`, `restart`, `stop`, `update`).
|
||||
|
||||
- `update` for `whisper` pulls the latest image and runs `docker compose up -d` to reconcile.
|
||||
- `dependency` must match an ID returned by `system.dockerDependencies`.
|
||||
- `update` pulls the latest image for that compose service and runs `docker compose up -d` to reconcile.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
|
||||
+15
-1
@@ -3,6 +3,20 @@
|
||||
"updated_at": "2026-02-23",
|
||||
"description": "Tracks the status of all Flynn plans and implementation phases",
|
||||
"plans": {
|
||||
"dashboard-docker-dependency-discovery": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-23",
|
||||
"updated": "2026-02-23",
|
||||
"summary": "Replaced whisper-only docker dependency handling with dynamic docker-compose discovery. Gateway now discovers profile-scoped services via `docker compose config --profiles` + `config --services` (with `ps --all` fallback) and returns status/control cards for all non-`flynn` compose dependencies (including `brave-search`). Docker dependency controls now operate generically on discovered service IDs.",
|
||||
"files_modified": [
|
||||
"src/gateway/handlers/dockerDependencies.ts",
|
||||
"src/gateway/handlers/dockerDependencies.test.ts",
|
||||
"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"
|
||||
},
|
||||
"brave-search-tooling-docs": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-23",
|
||||
@@ -6163,7 +6177,7 @@
|
||||
}
|
||||
},
|
||||
"overall_progress": {
|
||||
"total_test_count": 1951,
|
||||
"total_test_count": 1952,
|
||||
"all_tests_passing": true,
|
||||
"p0_completion": "3/3 (100%)",
|
||||
"p1_completion": "4/4 (100%)",
|
||||
|
||||
@@ -2,27 +2,48 @@ import { describe, it, expect } from 'vitest';
|
||||
import type { Config } from '../../config/index.js';
|
||||
import { listDockerDependencyStatuses, controlDockerDependency } from './dockerDependencies.js';
|
||||
|
||||
function createConfig(endpoint: string, enabled = true): Config {
|
||||
function createConfig(params?: {
|
||||
audioEndpoint?: string;
|
||||
audioEnabled?: boolean;
|
||||
webSearchProvider?: 'brave' | 'searxng';
|
||||
webSearchApiKey?: string;
|
||||
}): Config {
|
||||
return {
|
||||
audio: {
|
||||
enabled,
|
||||
enabled: params?.audioEnabled ?? true,
|
||||
provider: {
|
||||
type: 'custom',
|
||||
endpoint,
|
||||
endpoint: params?.audioEndpoint ?? 'http://localhost:18801/v1/audio/transcriptions',
|
||||
},
|
||||
},
|
||||
web_search: {
|
||||
provider: params?.webSearchProvider ?? 'brave',
|
||||
api_key: params?.webSearchApiKey,
|
||||
endpoint: undefined,
|
||||
max_results: 5,
|
||||
},
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function profileArgs(): string[] {
|
||||
return ['--profile', 'voice', '--profile', 'search'];
|
||||
}
|
||||
|
||||
describe('listDockerDependencyStatuses', () => {
|
||||
it('reports whisper as running when compose ps shows active container', async () => {
|
||||
it('discovers compose services across profiles and reports whisper + brave statuses', async () => {
|
||||
const seenCalls: string[][] = [];
|
||||
const runner = async (args: string[]) => {
|
||||
seenCalls.push(args);
|
||||
if (args.includes('config')) {
|
||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
||||
if (args.join(' ') === 'config --profiles') {
|
||||
return { stdout: 'voice\nsearch\n', stderr: '' };
|
||||
}
|
||||
if (args.includes('ps')) {
|
||||
if (args.join(' ') === '--profile voice --profile search config --services') {
|
||||
return { stdout: 'flynn\nwhisper-server\nbrave-search\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search ps --all --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search ps whisper-server --format json') {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'flynn-whisper-server-1',
|
||||
@@ -34,16 +55,28 @@ describe('listDockerDependencyStatuses', () => {
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search ps brave-search --format json') {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'brave-search',
|
||||
Service: 'brave-search',
|
||||
State: 'running',
|
||||
Health: '',
|
||||
Status: 'Up 2 minutes',
|
||||
}]),
|
||||
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({
|
||||
const statuses = await listDockerDependencyStatuses(createConfig(), runner);
|
||||
expect(statuses).toHaveLength(2);
|
||||
|
||||
const whisper = statuses.find((entry) => entry.id === 'whisper');
|
||||
expect(whisper).toMatchObject({
|
||||
id: 'whisper',
|
||||
service: 'whisper-server',
|
||||
configured: true,
|
||||
state: 'running',
|
||||
health: 'healthy',
|
||||
@@ -51,86 +84,121 @@ describe('listDockerDependencyStatuses', () => {
|
||||
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']);
|
||||
|
||||
const brave = statuses.find((entry) => entry.id === 'brave-search');
|
||||
expect(brave).toMatchObject({
|
||||
id: 'brave-search',
|
||||
name: 'Brave Search',
|
||||
service: 'brave-search',
|
||||
configured: false,
|
||||
state: 'running',
|
||||
statusText: 'Up 2 minutes',
|
||||
containerName: 'brave-search',
|
||||
availableActions: ['restart', 'stop', 'update'],
|
||||
});
|
||||
|
||||
it('reports whisper as defined but not started when no container exists yet', async () => {
|
||||
expect(seenCalls[0]).toEqual(['config', '--profiles']);
|
||||
expect(seenCalls[1]).toEqual([...profileArgs(), 'config', '--services']);
|
||||
});
|
||||
|
||||
it('includes runtime services surfaced by compose ps --all even if not listed in config services', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args.includes('config')) {
|
||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
||||
if (args.join(' ') === 'config --profiles') {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
if (args.includes('ps')) {
|
||||
if (args.join(' ') === 'config --services') {
|
||||
return { stdout: 'flynn\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === 'ps --all --format json') {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'brave-search',
|
||||
Service: 'brave-search',
|
||||
State: 'running',
|
||||
Status: 'Up 1 minute',
|
||||
}]),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (args.join(' ') === 'ps brave-search --format json') {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'brave-search',
|
||||
Service: 'brave-search',
|
||||
State: 'running',
|
||||
Status: 'Up 1 minute',
|
||||
}]),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const statuses = await listDockerDependencyStatuses(createConfig(), runner);
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]).toMatchObject({
|
||||
id: 'brave-search',
|
||||
service: 'brave-search',
|
||||
state: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty list when no compose dependencies are defined', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args.join(' ') === 'config --profiles') {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === 'config --services') {
|
||||
return { stdout: 'flynn\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === 'ps --all --format json') {
|
||||
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,
|
||||
availableActions: ['start', 'restart', 'update'],
|
||||
});
|
||||
const statuses = await listDockerDependencyStatuses(createConfig(), runner);
|
||||
expect(statuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports whisper service as missing when compose file does not define it', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args.includes('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',
|
||||
availableActions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unavailable status when docker compose command fails', async () => {
|
||||
it('returns unavailable status when docker compose discovery 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].state).toBe('unavailable');
|
||||
expect(statuses[0].availableActions).toEqual([]);
|
||||
expect(statuses[0].error).toContain('docker: command not found');
|
||||
const statuses = await listDockerDependencyStatuses(createConfig(), runner);
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]).toMatchObject({
|
||||
id: 'compose',
|
||||
state: 'unavailable',
|
||||
statusText: 'unavailable',
|
||||
availableActions: [],
|
||||
});
|
||||
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.includes('config')) {
|
||||
return { stdout: 'whisper-server\n', stderr: '' };
|
||||
if (args.join(' ') === 'config --profiles') {
|
||||
return { stdout: 'voice\n', stderr: '' };
|
||||
}
|
||||
if (args.includes('ps')) {
|
||||
if (args.join(' ') === '--profile voice config --services') {
|
||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice ps --all --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice ps whisper-server --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const statuses = await listDockerDependencyStatuses(
|
||||
createConfig('https://api.openai.com/v1/audio/transcriptions'),
|
||||
createConfig({ audioEndpoint: 'https://api.openai.com/v1/audio/transcriptions' }),
|
||||
runner,
|
||||
);
|
||||
expect(statuses[0]?.id).toBe('whisper');
|
||||
expect(statuses[0]?.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -140,13 +208,19 @@ describe('controlDockerDependency', () => {
|
||||
const calls: string[][] = [];
|
||||
const runner = async (args: string[]) => {
|
||||
calls.push(args);
|
||||
if (args.includes('config')) {
|
||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
||||
if (args.join(' ') === 'config --profiles') {
|
||||
return { stdout: 'voice\nsearch\n', stderr: '' };
|
||||
}
|
||||
if (args.includes('up')) {
|
||||
if (args.join(' ') === '--profile voice --profile search config --services') {
|
||||
return { stdout: 'flynn\nwhisper-server\nbrave-search\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search ps --all --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search up -d whisper-server') {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
if (args.includes('ps')) {
|
||||
if (args.join(' ') === '--profile voice --profile search ps whisper-server --format json') {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'whisper-server',
|
||||
@@ -158,42 +232,55 @@ describe('controlDockerDependency', () => {
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search ps brave-search --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const result = await controlDockerDependency(
|
||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
||||
createConfig(),
|
||||
'whisper',
|
||||
'start',
|
||||
runner,
|
||||
);
|
||||
expect(result.action).toBe('start');
|
||||
expect(result.dependency).toBe('whisper');
|
||||
expect(result.status.state).toBe('running');
|
||||
expect(result.status.availableActions).toEqual(['restart', 'stop', 'update']);
|
||||
expect(calls).toContainEqual(['--profile', 'voice', 'up', '-d', 'whisper-server']);
|
||||
expect(result.message).toContain('Started whisper-server');
|
||||
expect(calls).toContainEqual([...profileArgs(), 'up', '-d', 'whisper-server']);
|
||||
});
|
||||
|
||||
it('updates whisper by pulling image then reconciling container', async () => {
|
||||
it('updates brave-search 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.join(' ') === 'config --profiles') {
|
||||
return { stdout: 'voice\nsearch\n', stderr: '' };
|
||||
}
|
||||
if (args.includes('pull')) {
|
||||
if (args.join(' ') === '--profile voice --profile search config --services') {
|
||||
return { stdout: 'flynn\nwhisper-server\nbrave-search\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search ps --all --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search pull brave-search') {
|
||||
return { stdout: 'Pulled', stderr: '' };
|
||||
}
|
||||
if (args.includes('up')) {
|
||||
if (args.join(' ') === '--profile voice --profile search up -d brave-search') {
|
||||
return { stdout: 'Started', stderr: '' };
|
||||
}
|
||||
if (args.includes('ps')) {
|
||||
if (args.join(' ') === '--profile voice --profile search ps whisper-server --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice --profile search ps brave-search --format json') {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'whisper-server',
|
||||
Service: 'whisper-server',
|
||||
Name: 'brave-search',
|
||||
Service: 'brave-search',
|
||||
State: 'running',
|
||||
Health: 'healthy',
|
||||
Status: 'Up 1 minute (healthy)',
|
||||
Status: 'Up 1 minute',
|
||||
}]),
|
||||
stderr: '',
|
||||
};
|
||||
@@ -202,14 +289,37 @@ describe('controlDockerDependency', () => {
|
||||
};
|
||||
|
||||
const result = await controlDockerDependency(
|
||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
||||
'whisper',
|
||||
createConfig(),
|
||||
'brave-search',
|
||||
'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']);
|
||||
expect(result.dependency).toBe('brave-search');
|
||||
expect(result.message).toContain('Pulled latest brave-search image');
|
||||
expect(calls).toContainEqual([...profileArgs(), 'pull', 'brave-search']);
|
||||
expect(calls).toContainEqual([...profileArgs(), 'up', '-d', 'brave-search']);
|
||||
});
|
||||
|
||||
it('rejects unsupported dependency ids', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args.join(' ') === 'config --profiles') {
|
||||
return { stdout: 'voice\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice config --services') {
|
||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
||||
}
|
||||
if (args.join(' ') === '--profile voice ps --all --format json') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
await expect(controlDockerDependency(
|
||||
createConfig(),
|
||||
'not-real',
|
||||
'restart',
|
||||
runner,
|
||||
)).rejects.toThrow('Unsupported dependency');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Config } from '../../config/index.js';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export type DockerDependencyId = 'whisper';
|
||||
export type DockerDependencyId = string;
|
||||
export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update';
|
||||
|
||||
export interface DockerDependencyStatus {
|
||||
@@ -38,15 +38,26 @@ interface ComposePsEntry {
|
||||
Health?: string;
|
||||
}
|
||||
|
||||
const WHISPER_SERVICE = 'whisper-server';
|
||||
const WHISPER_PROFILE = 'voice';
|
||||
|
||||
function withWhisperProfile(args: string[]): string[] {
|
||||
return ['--profile', WHISPER_PROFILE, ...args];
|
||||
interface ComposeDiscovery {
|
||||
profileArgs: string[];
|
||||
services: string[];
|
||||
}
|
||||
|
||||
interface DockerDependencyDescriptor {
|
||||
id: DockerDependencyId;
|
||||
name: string;
|
||||
service: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
const COMPOSE_FILE = 'docker-compose.yml';
|
||||
const FLYNN_SERVICE = 'flynn';
|
||||
const WHISPER_SERVICE = 'whisper-server';
|
||||
const WHISPER_DEPENDENCY_ID = 'whisper';
|
||||
const BRAVE_SEARCH_SERVICE = 'brave-search';
|
||||
|
||||
function runCompose(args: string[], timeout: number): Promise<DockerComposeResult> {
|
||||
return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], {
|
||||
return execFile('docker', ['compose', '-f', COMPOSE_FILE, ...args], {
|
||||
timeout,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
}) as Promise<DockerComposeResult>;
|
||||
@@ -84,6 +95,26 @@ function parseServiceList(output: string): string[] {
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function unique(items: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of items) {
|
||||
const key = item.trim();
|
||||
if (!key || seen.has(key)) {continue;}
|
||||
seen.add(key);
|
||||
out.push(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildProfileArgs(profiles: string[]): string[] {
|
||||
return unique(profiles).flatMap((profile) => ['--profile', profile]);
|
||||
}
|
||||
|
||||
function withProfiles(profileArgs: string[], args: string[]): string[] {
|
||||
return [...profileArgs, ...args];
|
||||
}
|
||||
|
||||
function parseComposePsOutput(output: string): ComposePsEntry[] {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) {return [];}
|
||||
@@ -175,15 +206,123 @@ function isWhisperConfigured(config: Config): boolean {
|
||||
return isLocalWhisperEndpoint(endpoint);
|
||||
}
|
||||
|
||||
function isBraveSearchConfigured(config: Config): boolean {
|
||||
if (config.web_search.provider !== 'brave') {return false;}
|
||||
const apiKey = config.web_search.api_key;
|
||||
return typeof apiKey === 'string' && apiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
function toDependencyId(service: string): DockerDependencyId {
|
||||
if (service === WHISPER_SERVICE) {
|
||||
return WHISPER_DEPENDENCY_ID;
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
function toDependencyName(service: string): string {
|
||||
if (service === WHISPER_SERVICE) {
|
||||
return 'Whisper (whisper.cpp)';
|
||||
}
|
||||
return service
|
||||
.split(/[-_]+/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function describeDependency(service: string, config: Config): DockerDependencyDescriptor {
|
||||
if (service === WHISPER_SERVICE) {
|
||||
return {
|
||||
id: WHISPER_DEPENDENCY_ID,
|
||||
name: 'Whisper (whisper.cpp)',
|
||||
service,
|
||||
configured: isWhisperConfigured(config),
|
||||
};
|
||||
}
|
||||
if (service === BRAVE_SEARCH_SERVICE) {
|
||||
return {
|
||||
id: BRAVE_SEARCH_SERVICE,
|
||||
name: 'Brave Search',
|
||||
service,
|
||||
configured: isBraveSearchConfigured(config),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: toDependencyId(service),
|
||||
name: toDependencyName(service),
|
||||
service,
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableStatus(error: unknown): DockerDependencyStatus {
|
||||
return {
|
||||
id: 'compose',
|
||||
name: 'Docker Compose',
|
||||
service: COMPOSE_FILE,
|
||||
configured: false,
|
||||
state: 'unavailable',
|
||||
health: 'none',
|
||||
statusText: 'unavailable',
|
||||
containerName: null,
|
||||
availableActions: [],
|
||||
error: normalizeError(error),
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverCompose(runner: DockerComposeRunner): Promise<ComposeDiscovery> {
|
||||
let profiles: string[] = [];
|
||||
try {
|
||||
const response = await runner(['config', '--profiles']);
|
||||
profiles = parseServiceList(response.stdout);
|
||||
} catch {
|
||||
profiles = [];
|
||||
}
|
||||
|
||||
const profileArgs = buildProfileArgs(profiles);
|
||||
const response = await runner(withProfiles(profileArgs, ['config', '--services']));
|
||||
const configuredServices = parseServiceList(response.stdout)
|
||||
.filter((service) => service !== FLYNN_SERVICE);
|
||||
|
||||
// Compose `ps --all` can surface running services even when profile discovery is unavailable.
|
||||
let runtimeServices: string[] = [];
|
||||
try {
|
||||
const psResponse = await runner(withProfiles(profileArgs, ['ps', '--all', '--format', 'json']));
|
||||
runtimeServices = parseComposePsOutput(psResponse.stdout)
|
||||
.map((entry) => String(entry.Service ?? '').trim())
|
||||
.filter((service) => service.length > 0 && service !== FLYNN_SERVICE);
|
||||
} catch {
|
||||
runtimeServices = [];
|
||||
}
|
||||
|
||||
return {
|
||||
profileArgs,
|
||||
services: unique([...configuredServices, ...runtimeServices]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDockerDependencyStatuses(
|
||||
config: Config,
|
||||
runner: DockerComposeRunner = defaultRunner,
|
||||
): Promise<DockerDependencyStatus[]> {
|
||||
const whisperStatus: DockerDependencyStatus = {
|
||||
id: 'whisper',
|
||||
name: 'Whisper (whisper.cpp)',
|
||||
service: WHISPER_SERVICE,
|
||||
configured: isWhisperConfigured(config),
|
||||
let discovery: ComposeDiscovery;
|
||||
try {
|
||||
discovery = await discoverCompose(runner);
|
||||
} catch (error) {
|
||||
return [unavailableStatus(error)];
|
||||
}
|
||||
|
||||
if (discovery.services.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(discovery.services.map(async (service): Promise<DockerDependencyStatus> => {
|
||||
const descriptor = describeDependency(service, config);
|
||||
const baseStatus: DockerDependencyStatus = {
|
||||
id: descriptor.id,
|
||||
name: descriptor.name,
|
||||
service: descriptor.service,
|
||||
configured: descriptor.configured,
|
||||
state: 'unknown',
|
||||
health: 'unknown',
|
||||
statusText: 'unknown',
|
||||
@@ -191,71 +330,42 @@ export async function listDockerDependencyStatuses(
|
||||
availableActions: [],
|
||||
};
|
||||
|
||||
let services: string[];
|
||||
try {
|
||||
const response = await runner(withWhisperProfile(['config', '--services']));
|
||||
services = parseServiceList(response.stdout);
|
||||
} catch (error) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
state: 'unavailable',
|
||||
statusText: 'unavailable',
|
||||
availableActions: [],
|
||||
error: normalizeError(error),
|
||||
}];
|
||||
}
|
||||
|
||||
if (!services.includes(WHISPER_SERVICE)) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
state: 'not-found',
|
||||
health: 'none',
|
||||
statusText: 'service not defined in docker-compose.yml',
|
||||
availableActions: [],
|
||||
}];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await runner(withWhisperProfile(['ps', WHISPER_SERVICE, '--format', 'json']));
|
||||
const response = await runner(withProfiles(discovery.profileArgs, ['ps', service, '--format', 'json']));
|
||||
const rows = parseComposePsOutput(response.stdout)
|
||||
.filter((entry) => (entry.Service ?? '') === WHISPER_SERVICE || !entry.Service);
|
||||
.filter((entry) => (entry.Service ?? '') === service || !entry.Service);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
return {
|
||||
...baseStatus,
|
||||
state: 'not-created',
|
||||
health: 'none',
|
||||
statusText: 'defined, not started',
|
||||
availableActions: computeAvailableActions('not-created'),
|
||||
}];
|
||||
};
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
const state = normalizeState(row.State);
|
||||
const health = String(row.Health ?? '').trim().toLowerCase() || 'none';
|
||||
const statusField = String(row.Status ?? '');
|
||||
return [{
|
||||
...whisperStatus,
|
||||
return {
|
||||
...baseStatus,
|
||||
state,
|
||||
health,
|
||||
statusText: buildStatusText(state, health, statusField),
|
||||
containerName: row.Name?.trim() || null,
|
||||
availableActions: computeAvailableActions(state),
|
||||
}];
|
||||
};
|
||||
} catch (error) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
return {
|
||||
...baseStatus,
|
||||
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 {
|
||||
@@ -264,12 +374,18 @@ function ensureValidAction(action: string): asserts action is DockerDependencyAc
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWhisperServiceDefined(runner: DockerComposeRunner): Promise<void> {
|
||||
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');
|
||||
async function resolveDiscoveredDependency(
|
||||
config: Config,
|
||||
dependency: string,
|
||||
runner: DockerComposeRunner,
|
||||
): Promise<{ discovery: ComposeDiscovery; descriptor: DockerDependencyDescriptor }> {
|
||||
const discovery = await discoverCompose(runner);
|
||||
const descriptors = discovery.services.map((service) => describeDependency(service, config));
|
||||
const descriptor = descriptors.find((entry) => entry.id === dependency || entry.service === dependency);
|
||||
if (!descriptor) {
|
||||
throw new Error(`Unsupported dependency: ${dependency}`);
|
||||
}
|
||||
return { discovery, descriptor };
|
||||
}
|
||||
|
||||
export async function controlDockerDependency(
|
||||
@@ -278,43 +394,44 @@ export async function controlDockerDependency(
|
||||
action: string,
|
||||
runner: DockerComposeRunner = defaultControlRunner,
|
||||
): Promise<DockerDependencyControlResult> {
|
||||
ensureValidDependency(dependency);
|
||||
ensureValidAction(action);
|
||||
await ensureWhisperServiceDefined(runner);
|
||||
const { discovery, descriptor } = await resolveDiscoveredDependency(config, dependency, runner);
|
||||
const service = descriptor.service;
|
||||
|
||||
let message: string | undefined;
|
||||
if (action === 'start') {
|
||||
await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE]));
|
||||
message = 'Started whisper-server container.';
|
||||
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
|
||||
message = `Started ${service} container.`;
|
||||
} else if (action === 'restart') {
|
||||
try {
|
||||
await runner(withWhisperProfile(['restart', WHISPER_SERVICE]));
|
||||
message = 'Restarted whisper-server container.';
|
||||
await runner(withProfiles(discovery.profileArgs, ['restart', service]));
|
||||
message = `Restarted ${service} 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.';
|
||||
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
|
||||
message = `${service} container was not running; started it.`;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (action === 'stop') {
|
||||
await runner(withWhisperProfile(['stop', WHISPER_SERVICE]));
|
||||
message = 'Stopped whisper-server container.';
|
||||
await runner(withProfiles(discovery.profileArgs, ['stop', service]));
|
||||
message = `Stopped ${service} 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.';
|
||||
await runner(withProfiles(discovery.profileArgs, ['pull', service]));
|
||||
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
|
||||
message = `Pulled latest ${service} image and reconciled container.`;
|
||||
}
|
||||
|
||||
const status = (await listDockerDependencyStatuses(config, runner))[0];
|
||||
const status = (await listDockerDependencyStatuses(config, runner))
|
||||
.find((entry) => entry.id === descriptor.id || entry.service === descriptor.service);
|
||||
if (!status) {
|
||||
throw new Error('Failed to load docker dependency status after action.');
|
||||
}
|
||||
|
||||
return {
|
||||
dependency,
|
||||
dependency: descriptor.id,
|
||||
action,
|
||||
status,
|
||||
message,
|
||||
|
||||
@@ -137,6 +137,17 @@ function createMockClient() {
|
||||
containerName: 'flynn-whisper-server-1',
|
||||
availableActions: ['restart', 'stop', 'update'],
|
||||
},
|
||||
{
|
||||
id: 'brave-search',
|
||||
name: 'Brave Search',
|
||||
service: 'brave-search',
|
||||
configured: true,
|
||||
state: 'running',
|
||||
health: 'healthy',
|
||||
statusText: 'Up 2 minutes',
|
||||
containerName: 'brave-search',
|
||||
availableActions: ['restart', 'stop', 'update'],
|
||||
},
|
||||
],
|
||||
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
|
||||
};
|
||||
@@ -275,7 +286,7 @@ function createMockClient() {
|
||||
dependency.state = 'running';
|
||||
dependency.health = 'healthy';
|
||||
dependency.statusText = 'running (healthy)';
|
||||
dependency.containerName = 'whisper-server';
|
||||
dependency.containerName = String(dependency.service ?? dependency.id ?? '');
|
||||
dependency.availableActions = ['restart', 'stop', 'update'];
|
||||
} else if (action === 'stop') {
|
||||
dependency.state = 'stopped';
|
||||
@@ -533,7 +544,9 @@ describe('DashboardPage assistant controls', () => {
|
||||
const card = container.querySelector('#ops-docker-dependencies');
|
||||
expect(card).toBeTruthy();
|
||||
expect(String(card.textContent ?? '')).toContain('Whisper (whisper.cpp)');
|
||||
expect(String(card.textContent ?? '')).toContain('Brave Search');
|
||||
expect(String(card.textContent ?? '')).toContain('whisper-server');
|
||||
expect(String(card.textContent ?? '')).toContain('brave-search');
|
||||
expect(String(card.textContent ?? '')).toContain('Up 2 minutes (healthy)');
|
||||
});
|
||||
|
||||
@@ -562,12 +575,19 @@ describe('DashboardPage assistant controls', () => {
|
||||
updateBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
const braveRestartBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="brave-search"][data-action="restart"]');
|
||||
expect(braveRestartBtn).toBeTruthy();
|
||||
braveRestartBtn.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).toHaveLength(5);
|
||||
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(dependencyCalls[4].params).toEqual({ dependency: 'brave-search', action: 'restart' });
|
||||
expect(state.dockerDependencies.find((entry) => entry.id === 'whisper')?.state).toBe('running');
|
||||
expect(state.dockerDependencies.find((entry) => entry.id === 'brave-search')?.state).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user