Add whisper docker dependency controls to dashboard
This commit is contained in:
+38
-1
@@ -408,13 +408,50 @@ Return status for docker-compose managed dependencies (currently includes local
|
|||||||
"state": "running",
|
"state": "running",
|
||||||
"health": "healthy",
|
"health": "healthy",
|
||||||
"statusText": "Up 4 minutes (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`
|
#### `system.localBackendControl`
|
||||||
|
|
||||||
Control a local backend daemon (`start`, `restart`, `stop`, `update`).
|
Control a local backend daemon (`start`, `restart`, `stop`, `update`).
|
||||||
|
|||||||
@@ -3,6 +3,24 @@
|
|||||||
"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-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": {
|
"dashboard-docker-dependencies-profile-aware-detection": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-23",
|
"date": "2026-02-23",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import type { Config } from '../../config/index.js';
|
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 {
|
function createConfig(endpoint: string, enabled = true): Config {
|
||||||
return {
|
return {
|
||||||
@@ -49,6 +49,7 @@ describe('listDockerDependencyStatuses', () => {
|
|||||||
health: 'healthy',
|
health: 'healthy',
|
||||||
statusText: 'Up 4 minutes (healthy)',
|
statusText: 'Up 4 minutes (healthy)',
|
||||||
containerName: 'flynn-whisper-server-1',
|
containerName: 'flynn-whisper-server-1',
|
||||||
|
availableActions: ['restart', 'stop', 'update'],
|
||||||
});
|
});
|
||||||
expect(seenCalls[0]).toEqual(['--profile', 'voice', 'config', '--services']);
|
expect(seenCalls[0]).toEqual(['--profile', 'voice', 'config', '--services']);
|
||||||
expect(seenCalls[1]).toEqual(['--profile', 'voice', 'ps', 'whisper-server', '--format', 'json']);
|
expect(seenCalls[1]).toEqual(['--profile', 'voice', 'ps', 'whisper-server', '--format', 'json']);
|
||||||
@@ -75,6 +76,7 @@ describe('listDockerDependencyStatuses', () => {
|
|||||||
statusText: 'defined, not started',
|
statusText: 'defined, not started',
|
||||||
health: 'none',
|
health: 'none',
|
||||||
configured: true,
|
configured: true,
|
||||||
|
availableActions: ['start', 'restart', 'update'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ describe('listDockerDependencyStatuses', () => {
|
|||||||
state: 'not-found',
|
state: 'not-found',
|
||||||
statusText: 'service not defined in docker-compose.yml',
|
statusText: 'service not defined in docker-compose.yml',
|
||||||
health: 'none',
|
health: 'none',
|
||||||
|
availableActions: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,6 +111,8 @@ describe('listDockerDependencyStatuses', () => {
|
|||||||
runner,
|
runner,
|
||||||
);
|
);
|
||||||
expect(statuses[0].statusText).toBe('unavailable');
|
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');
|
expect(statuses[0].error).toContain('docker: command not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,3 +134,82 @@ describe('listDockerDependencyStatuses', () => {
|
|||||||
expect(statuses[0]?.configured).toBe(false);
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Config } from '../../config/index.js';
|
|||||||
const execFile = promisify(execFileCb);
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
export type DockerDependencyId = 'whisper';
|
export type DockerDependencyId = 'whisper';
|
||||||
|
export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update';
|
||||||
|
|
||||||
export interface DockerDependencyStatus {
|
export interface DockerDependencyStatus {
|
||||||
id: DockerDependencyId;
|
id: DockerDependencyId;
|
||||||
@@ -15,9 +16,17 @@ export interface DockerDependencyStatus {
|
|||||||
health: string;
|
health: string;
|
||||||
statusText: string;
|
statusText: string;
|
||||||
containerName: string | null;
|
containerName: string | null;
|
||||||
|
availableActions: DockerDependencyAction[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DockerDependencyControlResult {
|
||||||
|
dependency: DockerDependencyId;
|
||||||
|
action: DockerDependencyAction;
|
||||||
|
status: DockerDependencyStatus;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
type DockerComposeResult = { stdout: string; stderr: string };
|
type DockerComposeResult = { stdout: string; stderr: string };
|
||||||
type DockerComposeRunner = (args: string[]) => Promise<DockerComposeResult>;
|
type DockerComposeRunner = (args: string[]) => Promise<DockerComposeResult>;
|
||||||
|
|
||||||
@@ -36,13 +45,21 @@ function withWhisperProfile(args: string[]): string[] {
|
|||||||
return ['--profile', WHISPER_PROFILE, ...args];
|
return ['--profile', WHISPER_PROFILE, ...args];
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultRunner(args: string[]): Promise<DockerComposeResult> {
|
function runCompose(args: string[], timeout: number): Promise<DockerComposeResult> {
|
||||||
return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], {
|
return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], {
|
||||||
timeout: 10_000,
|
timeout,
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
}) as Promise<DockerComposeResult>;
|
}) as Promise<DockerComposeResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultRunner(args: string[]): Promise<DockerComposeResult> {
|
||||||
|
return runCompose(args, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultControlRunner(args: string[]): Promise<DockerComposeResult> {
|
||||||
|
return runCompose(args, 15 * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeError(error: unknown): string {
|
function normalizeError(error: unknown): string {
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
const maybe = error as { stderr?: string; stdout?: string; message?: string };
|
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';
|
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 {
|
function isLocalWhisperEndpoint(endpoint: string): boolean {
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
@@ -155,6 +188,7 @@ export async function listDockerDependencyStatuses(
|
|||||||
health: 'unknown',
|
health: 'unknown',
|
||||||
statusText: 'unknown',
|
statusText: 'unknown',
|
||||||
containerName: null,
|
containerName: null,
|
||||||
|
availableActions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
let services: string[];
|
let services: string[];
|
||||||
@@ -164,7 +198,9 @@ export async function listDockerDependencyStatuses(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [{
|
return [{
|
||||||
...whisperStatus,
|
...whisperStatus,
|
||||||
|
state: 'unavailable',
|
||||||
statusText: 'unavailable',
|
statusText: 'unavailable',
|
||||||
|
availableActions: [],
|
||||||
error: normalizeError(error),
|
error: normalizeError(error),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -175,6 +211,7 @@ export async function listDockerDependencyStatuses(
|
|||||||
state: 'not-found',
|
state: 'not-found',
|
||||||
health: 'none',
|
health: 'none',
|
||||||
statusText: 'service not defined in docker-compose.yml',
|
statusText: 'service not defined in docker-compose.yml',
|
||||||
|
availableActions: [],
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +226,7 @@ export async function listDockerDependencyStatuses(
|
|||||||
state: 'not-created',
|
state: 'not-created',
|
||||||
health: 'none',
|
health: 'none',
|
||||||
statusText: 'defined, not started',
|
statusText: 'defined, not started',
|
||||||
|
availableActions: computeAvailableActions('not-created'),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,12 +240,83 @@ export async function listDockerDependencyStatuses(
|
|||||||
health,
|
health,
|
||||||
statusText: buildStatusText(state, health, statusField),
|
statusText: buildStatusText(state, health, statusField),
|
||||||
containerName: row.Name?.trim() || null,
|
containerName: row.Name?.trim() || null,
|
||||||
|
availableActions: computeAvailableActions(state),
|
||||||
}];
|
}];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [{
|
return [{
|
||||||
...whisperStatus,
|
...whisperStatus,
|
||||||
statusText: 'unknown',
|
statusText: 'unknown',
|
||||||
|
availableActions: computeAvailableActions('unknown'),
|
||||||
error: normalizeError(error),
|
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<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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function controlDockerDependency(
|
||||||
|
config: Config,
|
||||||
|
dependency: string,
|
||||||
|
action: string,
|
||||||
|
runner: DockerComposeRunner = defaultControlRunner,
|
||||||
|
): Promise<DockerDependencyControlResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createCanvasHandlers } from './canvas.js';
|
|||||||
import { createConfigHandlers, redactConfig } from './config.js';
|
import { createConfigHandlers, redactConfig } from './config.js';
|
||||||
import { createPairingHandlers } from './pairing.js';
|
import { createPairingHandlers } from './pairing.js';
|
||||||
import type { LocalBackendStatus, LocalBackendControlResult } from './localBackends.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 { PairingManager } from '../../channels/pairing.js';
|
||||||
import { LaneQueue } from '../lane-queue.js';
|
import { LaneQueue } from '../lane-queue.js';
|
||||||
import { CanvasStore } from '../canvas-store.js';
|
import { CanvasStore } from '../canvas-store.js';
|
||||||
@@ -301,6 +301,7 @@ describe('system handlers', () => {
|
|||||||
health: 'healthy',
|
health: 'healthy',
|
||||||
statusText: 'Up 10 minutes (healthy)',
|
statusText: 'Up 10 minutes (healthy)',
|
||||||
containerName: 'flynn-whisper-server-1',
|
containerName: 'flynn-whisper-server-1',
|
||||||
|
availableActions: ['restart', 'stop', 'update'],
|
||||||
},
|
},
|
||||||
]));
|
]));
|
||||||
const handlers = createSystemHandlers({
|
const handlers = createSystemHandlers({
|
||||||
@@ -314,6 +315,66 @@ describe('system handlers', () => {
|
|||||||
expect(getPath(result.result, 'dependencies', '0', 'state')).toBe('running');
|
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<DockerDependencyControlResult> => ({
|
||||||
|
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 () => {
|
it('system.presence returns empty result when getPresence is not provided', async () => {
|
||||||
const req: GatewayRequest = { id: 4, method: 'system.presence' };
|
const req: GatewayRequest = { id: 4, method: 'system.presence' };
|
||||||
const result = await handlers['system.presence'](req) as GatewayResponse;
|
const result = await handlers['system.presence'](req) as GatewayResponse;
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import type { ServiceInfo } from './services.js';
|
|||||||
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
||||||
import type { SessionAnalyticsSnapshot } from '../../session/index.js';
|
import type { SessionAnalyticsSnapshot } from '../../session/index.js';
|
||||||
import type { LocalBackendAction, LocalBackendControlResult, LocalBackendStatus } from './localBackends.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. */
|
/** Per-session token usage report returned by system.tokenUsage. */
|
||||||
export interface TokenUsageEntry {
|
export interface TokenUsageEntry {
|
||||||
@@ -111,6 +115,8 @@ export interface SystemHandlerDeps {
|
|||||||
controlLocalBackend?: (backend: string, action: string) => Promise<LocalBackendControlResult>;
|
controlLocalBackend?: (backend: string, action: string) => Promise<LocalBackendControlResult>;
|
||||||
/** Optional callback to retrieve docker-compose dependency statuses. */
|
/** Optional callback to retrieve docker-compose dependency statuses. */
|
||||||
getDockerDependencies?: () => Promise<DockerDependencyStatus[]> | DockerDependencyStatus[];
|
getDockerDependencies?: () => Promise<DockerDependencyStatus[]> | DockerDependencyStatus[];
|
||||||
|
/** Optional callback to control docker-compose dependencies. */
|
||||||
|
controlDockerDependency?: (dependency: string, action: string) => Promise<DockerDependencyControlResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeErrorMessage(error: unknown): string {
|
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)}`);
|
return makeError(request.id, ErrorCode.InternalError, `Failed to load docker dependencies: ${normalizeErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'system.dockerDependencyControl': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
import { discoverServices } from './handlers/services.js';
|
import { discoverServices } from './handlers/services.js';
|
||||||
import { createModelCatalogFetcher } from './modelCatalog.js';
|
import { createModelCatalogFetcher } from './modelCatalog.js';
|
||||||
import { listLocalBackendStatuses, controlLocalBackend } from './handlers/localBackends.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 { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js';
|
||||||
import type { NodeConnectionState } from './handlers/node.js';
|
import type { NodeConnectionState } from './handlers/node.js';
|
||||||
import type { SessionManager } from '../session/manager.js';
|
import type { SessionManager } from '../session/manager.js';
|
||||||
@@ -241,6 +241,9 @@ export class GatewayServer {
|
|||||||
getDockerDependencies: runtimeConfig
|
getDockerDependencies: runtimeConfig
|
||||||
? () => listDockerDependencyStatuses(runtimeConfig)
|
? () => listDockerDependencyStatuses(runtimeConfig)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
controlDockerDependency: runtimeConfig
|
||||||
|
? (dependency, action) => controlDockerDependency(runtimeConfig, dependency, action)
|
||||||
|
: undefined,
|
||||||
getPresence: channelRegistry
|
getPresence: channelRegistry
|
||||||
? (opts) => channelRegistry.getPresence(opts)
|
? (opts) => channelRegistry.getPresence(opts)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ let _lastCouncilResult = null;
|
|||||||
let _lastCouncilError = null;
|
let _lastCouncilError = null;
|
||||||
let _lastServices = [];
|
let _lastServices = [];
|
||||||
let _lastLocalBackends = [];
|
let _lastLocalBackends = [];
|
||||||
|
let _lastDockerDependencies = [];
|
||||||
let _localBackendActionState = new Map();
|
let _localBackendActionState = new Map();
|
||||||
|
let _dockerDependencyActionState = new Map();
|
||||||
let _serviceConfigState = {
|
let _serviceConfigState = {
|
||||||
open: false,
|
open: false,
|
||||||
serviceName: null,
|
serviceName: null,
|
||||||
@@ -39,6 +41,12 @@ const LOCAL_BACKEND_ACTION_LABELS = {
|
|||||||
stop: 'Stop',
|
stop: 'Stop',
|
||||||
update: 'Update',
|
update: 'Update',
|
||||||
};
|
};
|
||||||
|
const DOCKER_DEPENDENCY_ACTION_LABELS = {
|
||||||
|
start: 'Start',
|
||||||
|
restart: 'Restart',
|
||||||
|
stop: 'Stop',
|
||||||
|
update: 'Update',
|
||||||
|
};
|
||||||
const SERVICE_TOGGLE_PATCH_PATHS = {
|
const SERVICE_TOGGLE_PATCH_PATHS = {
|
||||||
heartbeat: 'automation.heartbeat.enabled',
|
heartbeat: 'automation.heartbeat.enabled',
|
||||||
daily_briefing: 'automation.daily_briefing.enabled',
|
daily_briefing: 'automation.daily_briefing.enabled',
|
||||||
@@ -1624,6 +1632,7 @@ function updateDockerDependencies(dockerDependenciesData) {
|
|||||||
if (!el) {return;}
|
if (!el) {return;}
|
||||||
|
|
||||||
const dependencies = dockerDependenciesData?.dependencies ?? [];
|
const dependencies = dockerDependenciesData?.dependencies ?? [];
|
||||||
|
_lastDockerDependencies = dependencies;
|
||||||
|
|
||||||
if (dependencies.length === 0) {
|
if (dependencies.length === 0) {
|
||||||
el.innerHTML = '<div class="text-sm text-zinc-500">No docker dependencies detected</div>';
|
el.innerHTML = '<div class="text-sm text-zinc-500">No docker dependencies detected</div>';
|
||||||
@@ -1631,6 +1640,9 @@ function updateDockerDependencies(dockerDependenciesData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = dependencies.map((dependency) => {
|
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 state = String(dependency.state ?? 'unknown');
|
||||||
const health = String(dependency.health ?? 'unknown');
|
const health = String(dependency.health ?? 'unknown');
|
||||||
const statusText = String(dependency.statusText ?? state);
|
const statusText = String(dependency.statusText ?? state);
|
||||||
@@ -1645,6 +1657,23 @@ function updateDockerDependencies(dockerDependenciesData) {
|
|||||||
? 'text-red-500'
|
? 'text-red-500'
|
||||||
: 'text-zinc-400';
|
: 'text-zinc-400';
|
||||||
const containerName = dependency.containerName ? String(dependency.containerName) : '—';
|
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
|
||||||
|
? `<div class="text-xs ${actionState.tone === 'error' ? 'text-red-400' : actionState.tone === 'success' ? 'text-green-400' : 'text-zinc-400'}">${escapeHtml(String(actionState.message))}</div>`
|
||||||
|
: '';
|
||||||
|
const actionButtons = availableActions.length > 0
|
||||||
|
? availableActions.map((action) => {
|
||||||
|
const key = String(action);
|
||||||
|
const label = DOCKER_DEPENDENCY_ACTION_LABELS[key] ?? key;
|
||||||
|
return `<button class="docker-dependency-action-btn px-2 py-1 text-xs border border-zinc-700 rounded text-zinc-200 hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-dependency-id="${escapeHtml(dependencyId)}"
|
||||||
|
data-action="${escapeHtml(key)}"
|
||||||
|
${isPending ? 'disabled' : ''}
|
||||||
|
title="${escapeHtml(`${label} ${dependency.name ?? dependencyId}`)}">${escapeHtml(label)}</button>`;
|
||||||
|
}).join('')
|
||||||
|
: '<span class="text-xs text-zinc-500">No actions available</span>';
|
||||||
|
|
||||||
return `<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-2">
|
return `<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
@@ -1656,8 +1685,19 @@ function updateDockerDependencies(dockerDependenciesData) {
|
|||||||
<div class="text-xs text-zinc-500">State: <span class="font-mono text-zinc-400">${escapeHtml(state)}</span> · Health: <span class="font-mono text-zinc-400">${escapeHtml(health)}</span></div>
|
<div class="text-xs text-zinc-500">State: <span class="font-mono text-zinc-400">${escapeHtml(state)}</span> · Health: <span class="font-mono text-zinc-400">${escapeHtml(health)}</span></div>
|
||||||
<div class="text-xs ${configuredClass}">${escapeHtml(configuredText)}</div>
|
<div class="text-xs ${configuredClass}">${escapeHtml(configuredText)}</div>
|
||||||
${dependency.error ? `<div class="text-xs text-red-400">Error: ${escapeHtml(String(dependency.error))}</div>` : ''}
|
${dependency.error ? `<div class="text-xs text-red-400">Error: ${escapeHtml(String(dependency.error))}</div>` : ''}
|
||||||
|
${actionMessage}
|
||||||
|
<div class="flex flex-wrap gap-2">${actionButtons}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).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) {
|
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) {
|
function getConfigValue(path, fallbackValue) {
|
||||||
const value = getByPath(_lastAssistantConfig, path);
|
const value = getByPath(_lastAssistantConfig, path);
|
||||||
return value === undefined ? fallbackValue : value;
|
return value === undefined ? fallbackValue : value;
|
||||||
@@ -2052,7 +2134,9 @@ export const DashboardPage = {
|
|||||||
_assistantDraftTouchedAt = 0;
|
_assistantDraftTouchedAt = 0;
|
||||||
_lastServices = [];
|
_lastServices = [];
|
||||||
_lastLocalBackends = [];
|
_lastLocalBackends = [];
|
||||||
|
_lastDockerDependencies = [];
|
||||||
_localBackendActionState = new Map();
|
_localBackendActionState = new Map();
|
||||||
|
_dockerDependencyActionState = new Map();
|
||||||
_serviceConfigState = {
|
_serviceConfigState = {
|
||||||
open: false,
|
open: false,
|
||||||
serviceName: null,
|
serviceName: null,
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ function createMockClient() {
|
|||||||
health: 'healthy',
|
health: 'healthy',
|
||||||
statusText: 'Up 2 minutes (healthy)',
|
statusText: 'Up 2 minutes (healthy)',
|
||||||
containerName: 'flynn-whisper-server-1',
|
containerName: 'flynn-whisper-server-1',
|
||||||
|
availableActions: ['restart', 'stop', 'update'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
|
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
|
||||||
@@ -262,6 +263,39 @@ function createMockClient() {
|
|||||||
message: action === 'update' ? 'Updated backend assets' : undefined,
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -502,4 +536,38 @@ describe('DashboardPage assistant controls', () => {
|
|||||||
expect(String(card.textContent ?? '')).toContain('whisper-server');
|
expect(String(card.textContent ?? '')).toContain('whisper-server');
|
||||||
expect(String(card.textContent ?? '')).toContain('Up 2 minutes (healthy)');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user