feat: discover docker compose deps for dashboard

This commit is contained in:
William Valentin
2026-02-22 20:22:37 -08:00
parent ba6abfb078
commit 58eee60023
5 changed files with 460 additions and 187 deletions
+213 -96
View File
@@ -4,7 +4,7 @@ import type { Config } from '../../config/index.js';
const execFile = promisify(execFileCb);
export type DockerDependencyId = 'whisper';
export type DockerDependencyId = string;
export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update';
export interface DockerDependencyStatus {
@@ -38,15 +38,26 @@ interface ComposePsEntry {
Health?: string;
}
const WHISPER_SERVICE = 'whisper-server';
const WHISPER_PROFILE = 'voice';
function withWhisperProfile(args: string[]): string[] {
return ['--profile', WHISPER_PROFILE, ...args];
interface ComposeDiscovery {
profileArgs: string[];
services: string[];
}
interface DockerDependencyDescriptor {
id: DockerDependencyId;
name: string;
service: string;
configured: boolean;
}
const COMPOSE_FILE = 'docker-compose.yml';
const FLYNN_SERVICE = 'flynn';
const WHISPER_SERVICE = 'whisper-server';
const WHISPER_DEPENDENCY_ID = 'whisper';
const BRAVE_SEARCH_SERVICE = 'brave-search';
function runCompose(args: string[], timeout: number): Promise<DockerComposeResult> {
return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], {
return execFile('docker', ['compose', '-f', COMPOSE_FILE, ...args], {
timeout,
maxBuffer: 4 * 1024 * 1024,
}) as Promise<DockerComposeResult>;
@@ -84,6 +95,26 @@ function parseServiceList(output: string): string[] {
.filter((line) => line.length > 0);
}
function unique(items: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const item of items) {
const key = item.trim();
if (!key || seen.has(key)) {continue;}
seen.add(key);
out.push(key);
}
return out;
}
function buildProfileArgs(profiles: string[]): string[] {
return unique(profiles).flatMap((profile) => ['--profile', profile]);
}
function withProfiles(profileArgs: string[], args: string[]): string[] {
return [...profileArgs, ...args];
}
function parseComposePsOutput(output: string): ComposePsEntry[] {
const trimmed = output.trim();
if (!trimmed) {return [];}
@@ -175,87 +206,166 @@ function isWhisperConfigured(config: Config): boolean {
return isLocalWhisperEndpoint(endpoint);
}
function isBraveSearchConfigured(config: Config): boolean {
if (config.web_search.provider !== 'brave') {return false;}
const apiKey = config.web_search.api_key;
return typeof apiKey === 'string' && apiKey.trim().length > 0;
}
function toDependencyId(service: string): DockerDependencyId {
if (service === WHISPER_SERVICE) {
return WHISPER_DEPENDENCY_ID;
}
return service;
}
function toDependencyName(service: string): string {
if (service === WHISPER_SERVICE) {
return 'Whisper (whisper.cpp)';
}
return service
.split(/[-_]+/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function describeDependency(service: string, config: Config): DockerDependencyDescriptor {
if (service === WHISPER_SERVICE) {
return {
id: WHISPER_DEPENDENCY_ID,
name: 'Whisper (whisper.cpp)',
service,
configured: isWhisperConfigured(config),
};
}
if (service === BRAVE_SEARCH_SERVICE) {
return {
id: BRAVE_SEARCH_SERVICE,
name: 'Brave Search',
service,
configured: isBraveSearchConfigured(config),
};
}
return {
id: toDependencyId(service),
name: toDependencyName(service),
service,
configured: true,
};
}
function unavailableStatus(error: unknown): DockerDependencyStatus {
return {
id: 'compose',
name: 'Docker Compose',
service: COMPOSE_FILE,
configured: false,
state: 'unavailable',
health: 'none',
statusText: 'unavailable',
containerName: null,
availableActions: [],
error: normalizeError(error),
};
}
async function discoverCompose(runner: DockerComposeRunner): Promise<ComposeDiscovery> {
let profiles: string[] = [];
try {
const response = await runner(['config', '--profiles']);
profiles = parseServiceList(response.stdout);
} catch {
profiles = [];
}
const profileArgs = buildProfileArgs(profiles);
const response = await runner(withProfiles(profileArgs, ['config', '--services']));
const configuredServices = parseServiceList(response.stdout)
.filter((service) => service !== FLYNN_SERVICE);
// Compose `ps --all` can surface running services even when profile discovery is unavailable.
let runtimeServices: string[] = [];
try {
const psResponse = await runner(withProfiles(profileArgs, ['ps', '--all', '--format', 'json']));
runtimeServices = parseComposePsOutput(psResponse.stdout)
.map((entry) => String(entry.Service ?? '').trim())
.filter((service) => service.length > 0 && service !== FLYNN_SERVICE);
} catch {
runtimeServices = [];
}
return {
profileArgs,
services: unique([...configuredServices, ...runtimeServices]),
};
}
export async function listDockerDependencyStatuses(
config: Config,
runner: DockerComposeRunner = defaultRunner,
): Promise<DockerDependencyStatus[]> {
const whisperStatus: DockerDependencyStatus = {
id: 'whisper',
name: 'Whisper (whisper.cpp)',
service: WHISPER_SERVICE,
configured: isWhisperConfigured(config),
state: 'unknown',
health: 'unknown',
statusText: 'unknown',
containerName: null,
availableActions: [],
};
let services: string[];
let discovery: ComposeDiscovery;
try {
const response = await runner(withWhisperProfile(['config', '--services']));
services = parseServiceList(response.stdout);
discovery = await discoverCompose(runner);
} catch (error) {
return [{
...whisperStatus,
state: 'unavailable',
statusText: 'unavailable',
availableActions: [],
error: normalizeError(error),
}];
return [unavailableStatus(error)];
}
if (!services.includes(WHISPER_SERVICE)) {
return [{
...whisperStatus,
state: 'not-found',
health: 'none',
statusText: 'service not defined in docker-compose.yml',
availableActions: [],
}];
if (discovery.services.length === 0) {
return [];
}
try {
const response = await runner(withWhisperProfile(['ps', WHISPER_SERVICE, '--format', 'json']));
const rows = parseComposePsOutput(response.stdout)
.filter((entry) => (entry.Service ?? '') === WHISPER_SERVICE || !entry.Service);
if (rows.length === 0) {
return [{
...whisperStatus,
state: 'not-created',
health: 'none',
statusText: 'defined, not started',
availableActions: computeAvailableActions('not-created'),
}];
}
const row = rows[0];
const state = normalizeState(row.State);
const health = String(row.Health ?? '').trim().toLowerCase() || 'none';
const statusField = String(row.Status ?? '');
return [{
...whisperStatus,
state,
health,
statusText: buildStatusText(state, health, statusField),
containerName: row.Name?.trim() || null,
availableActions: computeAvailableActions(state),
}];
} catch (error) {
return [{
...whisperStatus,
return Promise.all(discovery.services.map(async (service): Promise<DockerDependencyStatus> => {
const descriptor = describeDependency(service, config);
const baseStatus: DockerDependencyStatus = {
id: descriptor.id,
name: descriptor.name,
service: descriptor.service,
configured: descriptor.configured,
state: 'unknown',
health: 'unknown',
statusText: 'unknown',
availableActions: computeAvailableActions('unknown'),
error: normalizeError(error),
}];
}
}
containerName: null,
availableActions: [],
};
function ensureValidDependency(id: string): asserts id is DockerDependencyId {
if (id !== 'whisper') {
throw new Error(`Unsupported dependency: ${id}`);
}
try {
const response = await runner(withProfiles(discovery.profileArgs, ['ps', service, '--format', 'json']));
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 {
@@ -264,12 +374,18 @@ function ensureValidAction(action: string): asserts action is DockerDependencyAc
}
}
async function ensureWhisperServiceDefined(runner: DockerComposeRunner): Promise<void> {
const response = await runner(withWhisperProfile(['config', '--services']));
const services = parseServiceList(response.stdout);
if (!services.includes(WHISPER_SERVICE)) {
throw new Error('whisper-server service is not defined in docker-compose.yml');
async function resolveDiscoveredDependency(
config: Config,
dependency: string,
runner: DockerComposeRunner,
): Promise<{ discovery: ComposeDiscovery; descriptor: DockerDependencyDescriptor }> {
const discovery = await discoverCompose(runner);
const descriptors = discovery.services.map((service) => describeDependency(service, config));
const descriptor = descriptors.find((entry) => entry.id === dependency || entry.service === dependency);
if (!descriptor) {
throw new Error(`Unsupported dependency: ${dependency}`);
}
return { discovery, descriptor };
}
export async function controlDockerDependency(
@@ -278,43 +394,44 @@ export async function controlDockerDependency(
action: string,
runner: DockerComposeRunner = defaultControlRunner,
): Promise<DockerDependencyControlResult> {
ensureValidDependency(dependency);
ensureValidAction(action);
await ensureWhisperServiceDefined(runner);
const { discovery, descriptor } = await resolveDiscoveredDependency(config, dependency, runner);
const service = descriptor.service;
let message: string | undefined;
if (action === 'start') {
await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE]));
message = 'Started whisper-server container.';
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
message = `Started ${service} container.`;
} else if (action === 'restart') {
try {
await runner(withWhisperProfile(['restart', WHISPER_SERVICE]));
message = 'Restarted whisper-server container.';
await runner(withProfiles(discovery.profileArgs, ['restart', service]));
message = `Restarted ${service} container.`;
} catch (error) {
const detail = normalizeError(error).toLowerCase();
if (detail.includes('no containers to restart')) {
await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE]));
message = 'Whisper container was not running; started it.';
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
message = `${service} container was not running; started it.`;
} else {
throw error;
}
}
} else if (action === 'stop') {
await runner(withWhisperProfile(['stop', WHISPER_SERVICE]));
message = 'Stopped whisper-server container.';
await runner(withProfiles(discovery.profileArgs, ['stop', service]));
message = `Stopped ${service} container.`;
} else if (action === 'update') {
await runner(withWhisperProfile(['pull', WHISPER_SERVICE]));
await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE]));
message = 'Pulled latest whisper image and reconciled container.';
await runner(withProfiles(discovery.profileArgs, ['pull', service]));
await runner(withProfiles(discovery.profileArgs, ['up', '-d', service]));
message = `Pulled latest ${service} image and reconciled container.`;
}
const status = (await listDockerDependencyStatuses(config, runner))[0];
const status = (await listDockerDependencyStatuses(config, runner))
.find((entry) => entry.id === descriptor.id || entry.service === descriptor.service);
if (!status) {
throw new Error('Failed to load docker dependency status after action.');
}
return {
dependency,
dependency: descriptor.id,
action,
status,
message,