Add whisper docker dependency controls to dashboard

This commit is contained in:
William Valentin
2026-02-22 19:48:27 -08:00
parent 453eb264df
commit abaa9be3f1
9 changed files with 501 additions and 8 deletions
+112 -3
View File
@@ -5,6 +5,7 @@ import type { Config } from '../../config/index.js';
const execFile = promisify(execFileCb);
export type DockerDependencyId = 'whisper';
export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update';
export interface DockerDependencyStatus {
id: DockerDependencyId;
@@ -15,9 +16,17 @@ export interface DockerDependencyStatus {
health: string;
statusText: string;
containerName: string | null;
availableActions: DockerDependencyAction[];
error?: string;
}
export interface DockerDependencyControlResult {
dependency: DockerDependencyId;
action: DockerDependencyAction;
status: DockerDependencyStatus;
message?: string;
}
type DockerComposeResult = { stdout: string; stderr: string };
type DockerComposeRunner = (args: string[]) => Promise<DockerComposeResult>;
@@ -36,13 +45,21 @@ function withWhisperProfile(args: string[]): string[] {
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], {
timeout: 10_000,
maxBuffer: 1024 * 1024,
timeout,
maxBuffer: 4 * 1024 * 1024,
}) 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 {
if (error && typeof error === 'object') {
const maybe = error as { stderr?: string; stdout?: string; message?: string };
@@ -118,6 +135,22 @@ function buildStatusText(state: string, health: string, statusField: string): st
return state || 'unknown';
}
function computeAvailableActions(state: string): DockerDependencyAction[] {
if (state === 'not-found' || state === 'unavailable') {
return [];
}
if (state === 'running') {
return ['restart', 'stop', 'update'];
}
if (state === 'stopped' || state === 'not-created' || state === 'created') {
return ['start', 'restart', 'update'];
}
if (state === 'restarting' || state === 'paused') {
return ['restart', 'stop', 'update'];
}
return ['start', 'restart', 'stop', 'update'];
}
function isLocalWhisperEndpoint(endpoint: string): boolean {
let parsed: URL;
try {
@@ -155,6 +188,7 @@ export async function listDockerDependencyStatuses(
health: 'unknown',
statusText: 'unknown',
containerName: null,
availableActions: [],
};
let services: string[];
@@ -164,7 +198,9 @@ export async function listDockerDependencyStatuses(
} catch (error) {
return [{
...whisperStatus,
state: 'unavailable',
statusText: 'unavailable',
availableActions: [],
error: normalizeError(error),
}];
}
@@ -175,6 +211,7 @@ export async function listDockerDependencyStatuses(
state: 'not-found',
health: 'none',
statusText: 'service not defined in docker-compose.yml',
availableActions: [],
}];
}
@@ -189,6 +226,7 @@ export async function listDockerDependencyStatuses(
state: 'not-created',
health: 'none',
statusText: 'defined, not started',
availableActions: computeAvailableActions('not-created'),
}];
}
@@ -202,12 +240,83 @@ export async function listDockerDependencyStatuses(
health,
statusText: buildStatusText(state, health, statusField),
containerName: row.Name?.trim() || null,
availableActions: computeAvailableActions(state),
}];
} catch (error) {
return [{
...whisperStatus,
statusText: 'unknown',
availableActions: computeAvailableActions('unknown'),
error: normalizeError(error),
}];
}
}
function ensureValidDependency(id: string): asserts id is DockerDependencyId {
if (id !== 'whisper') {
throw new Error(`Unsupported dependency: ${id}`);
}
}
function ensureValidAction(action: string): asserts action is DockerDependencyAction {
if (action !== 'start' && action !== 'restart' && action !== 'stop' && action !== 'update') {
throw new Error(`Unsupported action: ${action}`);
}
}
async function ensureWhisperServiceDefined(runner: DockerComposeRunner): Promise<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,
};
}