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`
|
#### `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:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
@@ -410,6 +410,17 @@ Return status for docker-compose managed dependencies (currently includes local
|
|||||||
"statusText": "Up 4 minutes (healthy)",
|
"statusText": "Up 4 minutes (healthy)",
|
||||||
"containerName": "flynn-whisper-server-1",
|
"containerName": "flynn-whisper-server-1",
|
||||||
"availableActions": ["restart", "stop", "update"]
|
"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`).
|
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:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
|
|||||||
+15
-1
@@ -3,6 +3,20 @@
|
|||||||
"updated_at": "2026-02-23",
|
"updated_at": "2026-02-23",
|
||||||
"description": "Tracks the status of all Flynn plans and implementation phases",
|
"description": "Tracks the status of all Flynn plans and implementation phases",
|
||||||
"plans": {
|
"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": {
|
"brave-search-tooling-docs": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-23",
|
"date": "2026-02-23",
|
||||||
@@ -6163,7 +6177,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1951,
|
"total_test_count": 1952,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
|
|||||||
@@ -2,27 +2,48 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import type { Config } from '../../config/index.js';
|
import type { Config } from '../../config/index.js';
|
||||||
import { listDockerDependencyStatuses, controlDockerDependency } from './dockerDependencies.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 {
|
return {
|
||||||
audio: {
|
audio: {
|
||||||
enabled,
|
enabled: params?.audioEnabled ?? true,
|
||||||
provider: {
|
provider: {
|
||||||
type: 'custom',
|
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;
|
} as unknown as Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileArgs(): string[] {
|
||||||
|
return ['--profile', 'voice', '--profile', 'search'];
|
||||||
|
}
|
||||||
|
|
||||||
describe('listDockerDependencyStatuses', () => {
|
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 seenCalls: string[][] = [];
|
||||||
const runner = async (args: string[]) => {
|
const runner = async (args: string[]) => {
|
||||||
seenCalls.push(args);
|
seenCalls.push(args);
|
||||||
if (args.includes('config')) {
|
if (args.join(' ') === 'config --profiles') {
|
||||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
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 {
|
return {
|
||||||
stdout: JSON.stringify([{
|
stdout: JSON.stringify([{
|
||||||
Name: 'flynn-whisper-server-1',
|
Name: 'flynn-whisper-server-1',
|
||||||
@@ -34,16 +55,28 @@ describe('listDockerDependencyStatuses', () => {
|
|||||||
stderr: '',
|
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(' ')}`);
|
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statuses = await listDockerDependencyStatuses(
|
const statuses = await listDockerDependencyStatuses(createConfig(), runner);
|
||||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
expect(statuses).toHaveLength(2);
|
||||||
runner,
|
|
||||||
);
|
const whisper = statuses.find((entry) => entry.id === 'whisper');
|
||||||
expect(statuses).toHaveLength(1);
|
expect(whisper).toMatchObject({
|
||||||
expect(statuses[0]).toMatchObject({
|
|
||||||
id: 'whisper',
|
id: 'whisper',
|
||||||
|
service: 'whisper-server',
|
||||||
configured: true,
|
configured: true,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
health: 'healthy',
|
health: 'healthy',
|
||||||
@@ -51,86 +84,121 @@ describe('listDockerDependencyStatuses', () => {
|
|||||||
containerName: 'flynn-whisper-server-1',
|
containerName: 'flynn-whisper-server-1',
|
||||||
availableActions: ['restart', 'stop', 'update'],
|
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'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seenCalls[0]).toEqual(['config', '--profiles']);
|
||||||
|
expect(seenCalls[1]).toEqual([...profileArgs(), 'config', '--services']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports whisper as defined but not started when no container exists yet', async () => {
|
it('includes runtime services surfaced by compose ps --all even if not listed in config services', async () => {
|
||||||
const runner = async (args: string[]) => {
|
const runner = async (args: string[]) => {
|
||||||
if (args.includes('config')) {
|
if (args.join(' ') === 'config --profiles') {
|
||||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
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: '' };
|
return { stdout: '[]', stderr: '' };
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statuses = await listDockerDependencyStatuses(
|
const statuses = await listDockerDependencyStatuses(createConfig(), runner);
|
||||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
expect(statuses).toEqual([]);
|
||||||
runner,
|
|
||||||
);
|
|
||||||
expect(statuses[0]).toMatchObject({
|
|
||||||
id: 'whisper',
|
|
||||||
state: 'not-created',
|
|
||||||
statusText: 'defined, not started',
|
|
||||||
health: 'none',
|
|
||||||
configured: true,
|
|
||||||
availableActions: ['start', 'restart', 'update'],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports whisper service as missing when compose file does not define it', async () => {
|
it('returns unavailable status when docker compose discovery fails', 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 () => {
|
|
||||||
const runner = async () => {
|
const runner = async () => {
|
||||||
throw Object.assign(new Error('spawn docker ENOENT'), { stderr: 'docker: command not found' });
|
throw Object.assign(new Error('spawn docker ENOENT'), { stderr: 'docker: command not found' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const statuses = await listDockerDependencyStatuses(
|
const statuses = await listDockerDependencyStatuses(createConfig(), runner);
|
||||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
expect(statuses).toHaveLength(1);
|
||||||
runner,
|
expect(statuses[0]).toMatchObject({
|
||||||
);
|
id: 'compose',
|
||||||
expect(statuses[0].statusText).toBe('unavailable');
|
state: 'unavailable',
|
||||||
expect(statuses[0].state).toBe('unavailable');
|
statusText: 'unavailable',
|
||||||
expect(statuses[0].availableActions).toEqual([]);
|
availableActions: [],
|
||||||
expect(statuses[0].error).toContain('docker: command not found');
|
});
|
||||||
|
expect(statuses[0]?.error).toContain('docker: command not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks whisper as not configured for non-local transcription endpoints', async () => {
|
it('marks whisper as not configured for non-local transcription endpoints', async () => {
|
||||||
const runner = async (args: string[]) => {
|
const runner = async (args: string[]) => {
|
||||||
if (args.includes('config')) {
|
if (args.join(' ') === 'config --profiles') {
|
||||||
return { stdout: 'whisper-server\n', stderr: '' };
|
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: '' };
|
return { stdout: '[]', stderr: '' };
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statuses = await listDockerDependencyStatuses(
|
const statuses = await listDockerDependencyStatuses(
|
||||||
createConfig('https://api.openai.com/v1/audio/transcriptions'),
|
createConfig({ audioEndpoint: 'https://api.openai.com/v1/audio/transcriptions' }),
|
||||||
runner,
|
runner,
|
||||||
);
|
);
|
||||||
|
expect(statuses[0]?.id).toBe('whisper');
|
||||||
expect(statuses[0]?.configured).toBe(false);
|
expect(statuses[0]?.configured).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -140,13 +208,19 @@ describe('controlDockerDependency', () => {
|
|||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const runner = async (args: string[]) => {
|
const runner = async (args: string[]) => {
|
||||||
calls.push(args);
|
calls.push(args);
|
||||||
if (args.includes('config')) {
|
if (args.join(' ') === 'config --profiles') {
|
||||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
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: '' };
|
return { stdout: '', stderr: '' };
|
||||||
}
|
}
|
||||||
if (args.includes('ps')) {
|
if (args.join(' ') === '--profile voice --profile search ps whisper-server --format json') {
|
||||||
return {
|
return {
|
||||||
stdout: JSON.stringify([{
|
stdout: JSON.stringify([{
|
||||||
Name: 'whisper-server',
|
Name: 'whisper-server',
|
||||||
@@ -158,42 +232,55 @@ describe('controlDockerDependency', () => {
|
|||||||
stderr: '',
|
stderr: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (args.join(' ') === '--profile voice --profile search ps brave-search --format json') {
|
||||||
|
return { stdout: '[]', stderr: '' };
|
||||||
|
}
|
||||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await controlDockerDependency(
|
const result = await controlDockerDependency(
|
||||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
createConfig(),
|
||||||
'whisper',
|
'whisper',
|
||||||
'start',
|
'start',
|
||||||
runner,
|
runner,
|
||||||
);
|
);
|
||||||
expect(result.action).toBe('start');
|
expect(result.action).toBe('start');
|
||||||
|
expect(result.dependency).toBe('whisper');
|
||||||
expect(result.status.state).toBe('running');
|
expect(result.status.state).toBe('running');
|
||||||
expect(result.status.availableActions).toEqual(['restart', 'stop', 'update']);
|
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 calls: string[][] = [];
|
||||||
const runner = async (args: string[]) => {
|
const runner = async (args: string[]) => {
|
||||||
calls.push(args);
|
calls.push(args);
|
||||||
if (args.includes('config')) {
|
if (args.join(' ') === 'config --profiles') {
|
||||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
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: '' };
|
return { stdout: 'Pulled', stderr: '' };
|
||||||
}
|
}
|
||||||
if (args.includes('up')) {
|
if (args.join(' ') === '--profile voice --profile search up -d brave-search') {
|
||||||
return { stdout: 'Started', stderr: '' };
|
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 {
|
return {
|
||||||
stdout: JSON.stringify([{
|
stdout: JSON.stringify([{
|
||||||
Name: 'whisper-server',
|
Name: 'brave-search',
|
||||||
Service: 'whisper-server',
|
Service: 'brave-search',
|
||||||
State: 'running',
|
State: 'running',
|
||||||
Health: 'healthy',
|
Status: 'Up 1 minute',
|
||||||
Status: 'Up 1 minute (healthy)',
|
|
||||||
}]),
|
}]),
|
||||||
stderr: '',
|
stderr: '',
|
||||||
};
|
};
|
||||||
@@ -202,14 +289,37 @@ describe('controlDockerDependency', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await controlDockerDependency(
|
const result = await controlDockerDependency(
|
||||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
createConfig(),
|
||||||
'whisper',
|
'brave-search',
|
||||||
'update',
|
'update',
|
||||||
runner,
|
runner,
|
||||||
);
|
);
|
||||||
expect(result.action).toBe('update');
|
expect(result.action).toBe('update');
|
||||||
expect(result.message).toContain('Pulled latest whisper image');
|
expect(result.dependency).toBe('brave-search');
|
||||||
expect(calls).toContainEqual(['--profile', 'voice', 'pull', 'whisper-server']);
|
expect(result.message).toContain('Pulled latest brave-search image');
|
||||||
expect(calls).toContainEqual(['--profile', 'voice', 'up', '-d', 'whisper-server']);
|
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);
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
export type DockerDependencyId = 'whisper';
|
export type DockerDependencyId = string;
|
||||||
export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update';
|
export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update';
|
||||||
|
|
||||||
export interface DockerDependencyStatus {
|
export interface DockerDependencyStatus {
|
||||||
@@ -38,15 +38,26 @@ interface ComposePsEntry {
|
|||||||
Health?: string;
|
Health?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WHISPER_SERVICE = 'whisper-server';
|
interface ComposeDiscovery {
|
||||||
const WHISPER_PROFILE = 'voice';
|
profileArgs: string[];
|
||||||
|
services: string[];
|
||||||
function withWhisperProfile(args: string[]): string[] {
|
|
||||||
return ['--profile', WHISPER_PROFILE, ...args];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
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,
|
timeout,
|
||||||
maxBuffer: 4 * 1024 * 1024,
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
}) as Promise<DockerComposeResult>;
|
}) as Promise<DockerComposeResult>;
|
||||||
@@ -84,6 +95,26 @@ function parseServiceList(output: string): string[] {
|
|||||||
.filter((line) => line.length > 0);
|
.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[] {
|
function parseComposePsOutput(output: string): ComposePsEntry[] {
|
||||||
const trimmed = output.trim();
|
const trimmed = output.trim();
|
||||||
if (!trimmed) {return [];}
|
if (!trimmed) {return [];}
|
||||||
@@ -175,87 +206,166 @@ function isWhisperConfigured(config: Config): boolean {
|
|||||||
return isLocalWhisperEndpoint(endpoint);
|
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(
|
export async function listDockerDependencyStatuses(
|
||||||
config: Config,
|
config: Config,
|
||||||
runner: DockerComposeRunner = defaultRunner,
|
runner: DockerComposeRunner = defaultRunner,
|
||||||
): Promise<DockerDependencyStatus[]> {
|
): Promise<DockerDependencyStatus[]> {
|
||||||
const whisperStatus: DockerDependencyStatus = {
|
let discovery: ComposeDiscovery;
|
||||||
id: 'whisper',
|
|
||||||
name: 'Whisper (whisper.cpp)',
|
|
||||||
service: WHISPER_SERVICE,
|
|
||||||
configured: isWhisperConfigured(config),
|
|
||||||
state: 'unknown',
|
|
||||||
health: 'unknown',
|
|
||||||
statusText: 'unknown',
|
|
||||||
containerName: null,
|
|
||||||
availableActions: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let services: string[];
|
|
||||||
try {
|
try {
|
||||||
const response = await runner(withWhisperProfile(['config', '--services']));
|
discovery = await discoverCompose(runner);
|
||||||
services = parseServiceList(response.stdout);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [{
|
return [unavailableStatus(error)];
|
||||||
...whisperStatus,
|
|
||||||
state: 'unavailable',
|
|
||||||
statusText: 'unavailable',
|
|
||||||
availableActions: [],
|
|
||||||
error: normalizeError(error),
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!services.includes(WHISPER_SERVICE)) {
|
if (discovery.services.length === 0) {
|
||||||
return [{
|
return [];
|
||||||
...whisperStatus,
|
|
||||||
state: 'not-found',
|
|
||||||
health: 'none',
|
|
||||||
statusText: 'service not defined in docker-compose.yml',
|
|
||||||
availableActions: [],
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return Promise.all(discovery.services.map(async (service): Promise<DockerDependencyStatus> => {
|
||||||
const response = await runner(withWhisperProfile(['ps', WHISPER_SERVICE, '--format', 'json']));
|
const descriptor = describeDependency(service, config);
|
||||||
const rows = parseComposePsOutput(response.stdout)
|
const baseStatus: DockerDependencyStatus = {
|
||||||
.filter((entry) => (entry.Service ?? '') === WHISPER_SERVICE || !entry.Service);
|
id: descriptor.id,
|
||||||
|
name: descriptor.name,
|
||||||
if (rows.length === 0) {
|
service: descriptor.service,
|
||||||
return [{
|
configured: descriptor.configured,
|
||||||
...whisperStatus,
|
state: 'unknown',
|
||||||
state: 'not-created',
|
health: 'unknown',
|
||||||
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,
|
|
||||||
state,
|
|
||||||
health,
|
|
||||||
statusText: buildStatusText(state, health, statusField),
|
|
||||||
containerName: row.Name?.trim() || null,
|
|
||||||
availableActions: computeAvailableActions(state),
|
|
||||||
}];
|
|
||||||
} catch (error) {
|
|
||||||
return [{
|
|
||||||
...whisperStatus,
|
|
||||||
statusText: 'unknown',
|
statusText: 'unknown',
|
||||||
availableActions: computeAvailableActions('unknown'),
|
containerName: null,
|
||||||
error: normalizeError(error),
|
availableActions: [],
|
||||||
}];
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureValidDependency(id: string): asserts id is DockerDependencyId {
|
try {
|
||||||
if (id !== 'whisper') {
|
const response = await runner(withProfiles(discovery.profileArgs, ['ps', service, '--format', 'json']));
|
||||||
throw new Error(`Unsupported dependency: ${id}`);
|
const rows = parseComposePsOutput(response.stdout)
|
||||||
}
|
.filter((entry) => (entry.Service ?? '') === service || !entry.Service);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
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 {
|
||||||
|
...baseStatus,
|
||||||
|
state,
|
||||||
|
health,
|
||||||
|
statusText: buildStatusText(state, health, statusField),
|
||||||
|
containerName: row.Name?.trim() || null,
|
||||||
|
availableActions: computeAvailableActions(state),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...baseStatus,
|
||||||
|
statusText: 'unknown',
|
||||||
|
availableActions: computeAvailableActions('unknown'),
|
||||||
|
error: normalizeError(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureValidAction(action: string): asserts action is DockerDependencyAction {
|
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> {
|
async function resolveDiscoveredDependency(
|
||||||
const response = await runner(withWhisperProfile(['config', '--services']));
|
config: Config,
|
||||||
const services = parseServiceList(response.stdout);
|
dependency: string,
|
||||||
if (!services.includes(WHISPER_SERVICE)) {
|
runner: DockerComposeRunner,
|
||||||
throw new Error('whisper-server service is not defined in docker-compose.yml');
|
): 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(
|
export async function controlDockerDependency(
|
||||||
@@ -278,43 +394,44 @@ export async function controlDockerDependency(
|
|||||||
action: string,
|
action: string,
|
||||||
runner: DockerComposeRunner = defaultControlRunner,
|
runner: DockerComposeRunner = defaultControlRunner,
|
||||||
): Promise<DockerDependencyControlResult> {
|
): Promise<DockerDependencyControlResult> {
|
||||||
ensureValidDependency(dependency);
|
|
||||||
ensureValidAction(action);
|
ensureValidAction(action);
|
||||||
await ensureWhisperServiceDefined(runner);
|
const { discovery, descriptor } = await resolveDiscoveredDependency(config, dependency, runner);
|
||||||
|
const service = descriptor.service;
|
||||||
|
|
||||||
let message: string | undefined;
|
let message: string | undefined;
|
||||||
if (action === 'start') {
|
if (action === 'start') {
|
||||||
await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE]));
|
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
|
||||||
message = 'Started whisper-server container.';
|
message = `Started ${service} container.`;
|
||||||
} else if (action === 'restart') {
|
} else if (action === 'restart') {
|
||||||
try {
|
try {
|
||||||
await runner(withWhisperProfile(['restart', WHISPER_SERVICE]));
|
await runner(withProfiles(discovery.profileArgs, ['restart', service]));
|
||||||
message = 'Restarted whisper-server container.';
|
message = `Restarted ${service} container.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = normalizeError(error).toLowerCase();
|
const detail = normalizeError(error).toLowerCase();
|
||||||
if (detail.includes('no containers to restart')) {
|
if (detail.includes('no containers to restart')) {
|
||||||
await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE]));
|
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
|
||||||
message = 'Whisper container was not running; started it.';
|
message = `${service} container was not running; started it.`;
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (action === 'stop') {
|
} else if (action === 'stop') {
|
||||||
await runner(withWhisperProfile(['stop', WHISPER_SERVICE]));
|
await runner(withProfiles(discovery.profileArgs, ['stop', service]));
|
||||||
message = 'Stopped whisper-server container.';
|
message = `Stopped ${service} container.`;
|
||||||
} else if (action === 'update') {
|
} else if (action === 'update') {
|
||||||
await runner(withWhisperProfile(['pull', WHISPER_SERVICE]));
|
await runner(withProfiles(discovery.profileArgs, ['pull', service]));
|
||||||
await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE]));
|
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
|
||||||
message = 'Pulled latest whisper image and reconciled container.';
|
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) {
|
if (!status) {
|
||||||
throw new Error('Failed to load docker dependency status after action.');
|
throw new Error('Failed to load docker dependency status after action.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dependency,
|
dependency: descriptor.id,
|
||||||
action,
|
action,
|
||||||
status,
|
status,
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -137,6 +137,17 @@ function createMockClient() {
|
|||||||
containerName: 'flynn-whisper-server-1',
|
containerName: 'flynn-whisper-server-1',
|
||||||
availableActions: ['restart', 'stop', 'update'],
|
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> }>,
|
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
|
||||||
};
|
};
|
||||||
@@ -275,7 +286,7 @@ function createMockClient() {
|
|||||||
dependency.state = 'running';
|
dependency.state = 'running';
|
||||||
dependency.health = 'healthy';
|
dependency.health = 'healthy';
|
||||||
dependency.statusText = 'running (healthy)';
|
dependency.statusText = 'running (healthy)';
|
||||||
dependency.containerName = 'whisper-server';
|
dependency.containerName = String(dependency.service ?? dependency.id ?? '');
|
||||||
dependency.availableActions = ['restart', 'stop', 'update'];
|
dependency.availableActions = ['restart', 'stop', 'update'];
|
||||||
} else if (action === 'stop') {
|
} else if (action === 'stop') {
|
||||||
dependency.state = 'stopped';
|
dependency.state = 'stopped';
|
||||||
@@ -533,7 +544,9 @@ describe('DashboardPage assistant controls', () => {
|
|||||||
const card = container.querySelector('#ops-docker-dependencies');
|
const card = container.querySelector('#ops-docker-dependencies');
|
||||||
expect(card).toBeTruthy();
|
expect(card).toBeTruthy();
|
||||||
expect(String(card.textContent ?? '')).toContain('Whisper (whisper.cpp)');
|
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('whisper-server');
|
||||||
|
expect(String(card.textContent ?? '')).toContain('brave-search');
|
||||||
expect(String(card.textContent ?? '')).toContain('Up 2 minutes (healthy)');
|
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 }));
|
updateBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
|
||||||
await flush();
|
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');
|
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[0].params).toEqual({ dependency: 'whisper', action: 'restart' });
|
||||||
expect(dependencyCalls[1].params).toEqual({ dependency: 'whisper', action: 'stop' });
|
expect(dependencyCalls[1].params).toEqual({ dependency: 'whisper', action: 'stop' });
|
||||||
expect(dependencyCalls[2].params).toEqual({ dependency: 'whisper', action: 'start' });
|
expect(dependencyCalls[2].params).toEqual({ dependency: 'whisper', action: 'start' });
|
||||||
expect(dependencyCalls[3].params).toEqual({ dependency: 'whisper', action: 'update' });
|
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 === 'whisper')?.state).toBe('running');
|
||||||
|
expect(state.dockerDependencies.find((entry) => entry.id === 'brave-search')?.state).toBe('running');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user