feat: discover docker compose deps for dashboard
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user