Add whisper docker dependency status to gateway dashboard
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { Config } from '../../config/index.js';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export type DockerDependencyId = 'whisper';
|
||||
|
||||
export interface DockerDependencyStatus {
|
||||
id: DockerDependencyId;
|
||||
name: string;
|
||||
service: string;
|
||||
configured: boolean;
|
||||
state: string;
|
||||
health: string;
|
||||
statusText: string;
|
||||
containerName: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type DockerComposeResult = { stdout: string; stderr: string };
|
||||
type DockerComposeRunner = (args: string[]) => Promise<DockerComposeResult>;
|
||||
|
||||
interface ComposePsEntry {
|
||||
Name?: string;
|
||||
Service?: string;
|
||||
State?: string;
|
||||
Status?: string;
|
||||
Health?: string;
|
||||
}
|
||||
|
||||
const WHISPER_SERVICE = 'whisper-server';
|
||||
|
||||
function defaultRunner(args: string[]): Promise<DockerComposeResult> {
|
||||
return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}) as Promise<DockerComposeResult>;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
const maybe = error as { stderr?: string; stdout?: string; message?: string };
|
||||
const stderr = maybe.stderr?.trim();
|
||||
if (stderr) {return stderr;}
|
||||
const stdout = maybe.stdout?.trim();
|
||||
if (stdout) {return stdout;}
|
||||
if (typeof maybe.message === 'string' && maybe.message.trim().length > 0) {
|
||||
return maybe.message.trim();
|
||||
}
|
||||
}
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function parseServiceList(output: string): string[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function parseComposePsOutput(output: string): ComposePsEntry[] {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) {return [];}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((entry): entry is ComposePsEntry => Boolean(entry && typeof entry === 'object'));
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return [parsed as ComposePsEntry];
|
||||
}
|
||||
} catch {
|
||||
// Some compose versions emit newline-delimited JSON objects instead of an array.
|
||||
}
|
||||
|
||||
const entries: ComposePsEntry[] = [];
|
||||
for (const line of trimmed.split('\n')) {
|
||||
const item = line.trim();
|
||||
if (!item) {continue;}
|
||||
try {
|
||||
const parsed = JSON.parse(item) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
entries.push(parsed as ComposePsEntry);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON lines.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function normalizeState(rawState: string | undefined): string {
|
||||
const state = String(rawState ?? '').trim().toLowerCase();
|
||||
if (!state) {return 'unknown';}
|
||||
if (state === 'running') {return 'running';}
|
||||
if (state === 'exited' || state === 'dead' || state === 'stopped') {return 'stopped';}
|
||||
if (state === 'created' || state === 'restarting' || state === 'paused') {return state;}
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildStatusText(state: string, health: string, statusField: string): string {
|
||||
if (statusField.trim().length > 0) {
|
||||
return statusField.trim();
|
||||
}
|
||||
if (state === 'running' && health && health !== 'none' && health !== 'unknown') {
|
||||
return `running (${health})`;
|
||||
}
|
||||
return state || 'unknown';
|
||||
}
|
||||
|
||||
function isLocalWhisperEndpoint(endpoint: string): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(endpoint);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const isLocalHost = parsed.hostname === 'localhost'
|
||||
|| parsed.hostname === '127.0.0.1'
|
||||
|| parsed.hostname === '::1'
|
||||
|| parsed.hostname === '0.0.0.0';
|
||||
if (!isLocalHost) {return false;}
|
||||
return parsed.pathname.includes('/audio/transcriptions');
|
||||
}
|
||||
|
||||
function isWhisperConfigured(config: Config): boolean {
|
||||
if (!config.audio.enabled) {return false;}
|
||||
const endpoint = config.audio.provider?.endpoint;
|
||||
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
return isLocalWhisperEndpoint(endpoint);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
let services: string[];
|
||||
try {
|
||||
const response = await runner(['config', '--services']);
|
||||
services = parseServiceList(response.stdout);
|
||||
} catch (error) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
statusText: 'unavailable',
|
||||
error: normalizeError(error),
|
||||
}];
|
||||
}
|
||||
|
||||
if (!services.includes(WHISPER_SERVICE)) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
state: 'not-found',
|
||||
health: 'none',
|
||||
statusText: 'service not defined in docker-compose.yml',
|
||||
}];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await runner(['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',
|
||||
}];
|
||||
}
|
||||
|
||||
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,
|
||||
}];
|
||||
} catch (error) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
statusText: 'unknown',
|
||||
error: normalizeError(error),
|
||||
}];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user