Add whisper docker dependency status to gateway dashboard
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Config } from '../../config/index.js';
|
||||
import { listDockerDependencyStatuses } from './dockerDependencies.js';
|
||||
|
||||
function createConfig(endpoint: string, enabled = true): Config {
|
||||
return {
|
||||
audio: {
|
||||
enabled,
|
||||
provider: {
|
||||
type: 'custom',
|
||||
endpoint,
|
||||
},
|
||||
},
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('listDockerDependencyStatuses', () => {
|
||||
it('reports whisper as running when compose ps shows active container', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args[0] === 'config') {
|
||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
||||
}
|
||||
if (args[0] === 'ps') {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'flynn-whisper-server-1',
|
||||
Service: 'whisper-server',
|
||||
State: 'running',
|
||||
Health: 'healthy',
|
||||
Status: 'Up 4 minutes (healthy)',
|
||||
}]),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const statuses = await listDockerDependencyStatuses(
|
||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
||||
runner,
|
||||
);
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]).toMatchObject({
|
||||
id: 'whisper',
|
||||
configured: true,
|
||||
state: 'running',
|
||||
health: 'healthy',
|
||||
statusText: 'Up 4 minutes (healthy)',
|
||||
containerName: 'flynn-whisper-server-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports whisper as defined but not started when no container exists yet', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args[0] === 'config') {
|
||||
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
|
||||
}
|
||||
if (args[0] === 'ps') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const statuses = await listDockerDependencyStatuses(
|
||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
||||
runner,
|
||||
);
|
||||
expect(statuses[0]).toMatchObject({
|
||||
id: 'whisper',
|
||||
state: 'not-created',
|
||||
statusText: 'defined, not started',
|
||||
health: 'none',
|
||||
configured: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports whisper service as missing when compose file does not define it', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args[0] === 'config') {
|
||||
return { stdout: 'flynn\n', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const statuses = await listDockerDependencyStatuses(
|
||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
||||
runner,
|
||||
);
|
||||
expect(statuses[0]).toMatchObject({
|
||||
id: 'whisper',
|
||||
state: 'not-found',
|
||||
statusText: 'service not defined in docker-compose.yml',
|
||||
health: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unavailable status when docker compose command fails', async () => {
|
||||
const runner = async () => {
|
||||
throw Object.assign(new Error('spawn docker ENOENT'), { stderr: 'docker: command not found' });
|
||||
};
|
||||
|
||||
const statuses = await listDockerDependencyStatuses(
|
||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
||||
runner,
|
||||
);
|
||||
expect(statuses[0].statusText).toBe('unavailable');
|
||||
expect(statuses[0].error).toContain('docker: command not found');
|
||||
});
|
||||
|
||||
it('marks whisper as not configured for non-local transcription endpoints', async () => {
|
||||
const runner = async (args: string[]) => {
|
||||
if (args[0] === 'config') {
|
||||
return { stdout: 'whisper-server\n', stderr: '' };
|
||||
}
|
||||
if (args[0] === 'ps') {
|
||||
return { stdout: '[]', stderr: '' };
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const statuses = await listDockerDependencyStatuses(
|
||||
createConfig('https://api.openai.com/v1/audio/transcriptions'),
|
||||
runner,
|
||||
);
|
||||
expect(statuses[0]?.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { createCanvasHandlers } from './canvas.js';
|
||||
import { createConfigHandlers, redactConfig } from './config.js';
|
||||
import { createPairingHandlers } from './pairing.js';
|
||||
import type { LocalBackendStatus, LocalBackendControlResult } from './localBackends.js';
|
||||
import type { DockerDependencyStatus } from './dockerDependencies.js';
|
||||
import { PairingManager } from '../../channels/pairing.js';
|
||||
import { LaneQueue } from '../lane-queue.js';
|
||||
import { CanvasStore } from '../canvas-store.js';
|
||||
@@ -283,6 +284,36 @@ describe('system handlers', () => {
|
||||
expect(getPath(result.result, 'updatedModels')).toEqual(['llama3.2', 'nomic-embed-text']);
|
||||
});
|
||||
|
||||
it('system.dockerDependencies returns empty list when callback is not provided', async () => {
|
||||
const req: GatewayRequest = { id: 42, method: 'system.dockerDependencies' };
|
||||
const result = await handlers['system.dockerDependencies'](req) as GatewayResponse;
|
||||
expect(getPath(result.result, 'dependencies')).toEqual([]);
|
||||
});
|
||||
|
||||
it('system.dockerDependencies returns dependency statuses from callback', async () => {
|
||||
const getDockerDependencies = vi.fn(async (): Promise<DockerDependencyStatus[]> => ([
|
||||
{
|
||||
id: 'whisper',
|
||||
name: 'Whisper (whisper.cpp)',
|
||||
service: 'whisper-server',
|
||||
configured: true,
|
||||
state: 'running',
|
||||
health: 'healthy',
|
||||
statusText: 'Up 10 minutes (healthy)',
|
||||
containerName: 'flynn-whisper-server-1',
|
||||
},
|
||||
]));
|
||||
const handlers = createSystemHandlers({
|
||||
...deps,
|
||||
getDockerDependencies,
|
||||
});
|
||||
const req: GatewayRequest = { id: 43, method: 'system.dockerDependencies' };
|
||||
const result = await handlers['system.dockerDependencies'](req) as GatewayResponse;
|
||||
expect(getDockerDependencies).toHaveBeenCalledTimes(1);
|
||||
expect(getPath(result.result, 'dependencies', '0', 'id')).toBe('whisper');
|
||||
expect(getPath(result.result, 'dependencies', '0', 'state')).toBe('running');
|
||||
});
|
||||
|
||||
it('system.presence returns empty result when getPresence is not provided', async () => {
|
||||
const req: GatewayRequest = { id: 4, method: 'system.presence' };
|
||||
const result = await handlers['system.presence'](req) as GatewayResponse;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ServiceInfo } from './services.js';
|
||||
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
||||
import type { SessionAnalyticsSnapshot } from '../../session/index.js';
|
||||
import type { LocalBackendAction, LocalBackendControlResult, LocalBackendStatus } from './localBackends.js';
|
||||
import type { DockerDependencyStatus } from './dockerDependencies.js';
|
||||
|
||||
/** Per-session token usage report returned by system.tokenUsage. */
|
||||
export interface TokenUsageEntry {
|
||||
@@ -108,6 +109,8 @@ export interface SystemHandlerDeps {
|
||||
getLocalBackends?: () => Promise<LocalBackendStatus[]> | LocalBackendStatus[];
|
||||
/** Optional callback to control local backend daemons. */
|
||||
controlLocalBackend?: (backend: string, action: string) => Promise<LocalBackendControlResult>;
|
||||
/** Optional callback to retrieve docker-compose dependency statuses. */
|
||||
getDockerDependencies?: () => Promise<DockerDependencyStatus[]> | DockerDependencyStatus[];
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
@@ -335,5 +338,17 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
return makeError(request.id, ErrorCode.InternalError, `Local backend control failed: ${normalizeErrorMessage(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
'system.dockerDependencies': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.getDockerDependencies) {
|
||||
return makeResponse(request.id, { dependencies: [] });
|
||||
}
|
||||
try {
|
||||
const dependencies = await deps.getDockerDependencies();
|
||||
return makeResponse(request.id, { dependencies });
|
||||
} catch (error) {
|
||||
return makeError(request.id, ErrorCode.InternalError, `Failed to load docker dependencies: ${normalizeErrorMessage(error)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { discoverServices } from './handlers/services.js';
|
||||
import { createModelCatalogFetcher } from './modelCatalog.js';
|
||||
import { listLocalBackendStatuses, controlLocalBackend } from './handlers/localBackends.js';
|
||||
import { listDockerDependencyStatuses } from './handlers/dockerDependencies.js';
|
||||
import type { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js';
|
||||
import type { NodeConnectionState } from './handlers/node.js';
|
||||
import type { SessionManager } from '../session/manager.js';
|
||||
@@ -237,6 +238,9 @@ export class GatewayServer {
|
||||
controlLocalBackend: runtimeConfig
|
||||
? (backend, action) => controlLocalBackend(runtimeConfig, backend, action)
|
||||
: undefined,
|
||||
getDockerDependencies: runtimeConfig
|
||||
? () => listDockerDependencyStatuses(runtimeConfig)
|
||||
: undefined,
|
||||
getPresence: channelRegistry
|
||||
? (opts) => channelRegistry.getPresence(opts)
|
||||
: undefined,
|
||||
|
||||
@@ -386,6 +386,12 @@ function renderSkeleton(el) {
|
||||
<div id="ops-local-backends" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="text-sm text-zinc-500">Loading...</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Docker Dependencies</h2>
|
||||
<div class="text-xs text-zinc-500 mb-2">Status for docker-compose services Flynn depends on (for example local Whisper transcription).</div>
|
||||
<div id="ops-docker-dependencies" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="text-sm text-zinc-500">Loading...</div>
|
||||
</div>
|
||||
<div id="ops-service-config-modal-root"></div>
|
||||
`;
|
||||
}
|
||||
@@ -1421,6 +1427,7 @@ function updateAssistantHealth(configData) {
|
||||
}
|
||||
updateServices(refreshed.services);
|
||||
updateLocalBackends(refreshed.localBackends);
|
||||
updateDockerDependencies(refreshed.dockerDependencies);
|
||||
updateSessionAnalytics(refreshed.sessionAnalytics);
|
||||
updateContextHealth(refreshed.contextUsage);
|
||||
// Only re-render assistant controls from a confirmed config snapshot.
|
||||
@@ -1612,6 +1619,47 @@ function updateLocalBackends(localBackendsData) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateDockerDependencies(dockerDependenciesData) {
|
||||
const el = document.getElementById('ops-docker-dependencies');
|
||||
if (!el) {return;}
|
||||
|
||||
const dependencies = dockerDependenciesData?.dependencies ?? [];
|
||||
|
||||
if (dependencies.length === 0) {
|
||||
el.innerHTML = '<div class="text-sm text-zinc-500">No docker dependencies detected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = dependencies.map((dependency) => {
|
||||
const state = String(dependency.state ?? 'unknown');
|
||||
const health = String(dependency.health ?? 'unknown');
|
||||
const statusText = String(dependency.statusText ?? state);
|
||||
const configured = Boolean(dependency.configured);
|
||||
const configuredText = configured ? 'configured' : 'not configured';
|
||||
const configuredClass = configured ? 'text-blue-400' : 'text-zinc-500';
|
||||
const toneClass = state === 'running'
|
||||
? 'text-green-500'
|
||||
: state === 'not-found'
|
||||
? 'text-amber-500'
|
||||
: state === 'unknown'
|
||||
? 'text-red-500'
|
||||
: 'text-zinc-400';
|
||||
const containerName = dependency.containerName ? String(dependency.containerName) : '—';
|
||||
|
||||
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="text-sm font-semibold text-zinc-50">${escapeHtml(String(dependency.name ?? dependency.id ?? 'dependency'))}</div>
|
||||
<span class="text-xs uppercase ${toneClass}">${escapeHtml(statusText)}</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">Compose service: <span class="font-mono text-zinc-400">${escapeHtml(String(dependency.service ?? 'unknown'))}</span></div>
|
||||
<div class="text-xs text-zinc-500">Container: <span class="font-mono text-zinc-400">${escapeHtml(containerName)}</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>
|
||||
${dependency.error ? `<div class="text-xs text-red-400">Error: ${escapeHtml(String(dependency.error))}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function handleLocalBackendAction(backendId, action) {
|
||||
if (!_dashboardClient) {return;}
|
||||
const actionLabel = LOCAL_BACKEND_ACTION_LABELS[action] ?? action;
|
||||
@@ -1640,6 +1688,9 @@ async function handleLocalBackendAction(backendId, action) {
|
||||
if (refreshed?.localBackends) {
|
||||
updateLocalBackends(refreshed.localBackends);
|
||||
}
|
||||
if (refreshed?.dockerDependencies) {
|
||||
updateDockerDependencies(refreshed.dockerDependencies);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
_localBackendActionState.set(backendId, {
|
||||
@@ -1822,6 +1873,7 @@ function renderServiceConfigModal() {
|
||||
if (refreshed) {
|
||||
updateServices(refreshed.services);
|
||||
updateLocalBackends(refreshed.localBackends);
|
||||
updateDockerDependencies(refreshed.dockerDependencies);
|
||||
updateSessionAnalytics(refreshed.sessionAnalytics);
|
||||
updateContextHealth(refreshed.contextUsage);
|
||||
if (refreshed.config) {
|
||||
@@ -1854,10 +1906,11 @@ async function fetchFast(client) {
|
||||
}
|
||||
|
||||
async function fetchSlow(client) {
|
||||
const [health, services, localBackends, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([
|
||||
const [health, services, localBackends, dockerDependencies, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([
|
||||
client.call('system.health'),
|
||||
client.call('system.services'),
|
||||
client.call('system.localBackends'),
|
||||
client.call('system.dockerDependencies'),
|
||||
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
||||
client.call('system.contextUsage'),
|
||||
client.call('config.get'),
|
||||
@@ -1876,6 +1929,7 @@ async function fetchSlow(client) {
|
||||
health: unwrap(health),
|
||||
services: unwrap(services),
|
||||
localBackends: unwrap(localBackends),
|
||||
dockerDependencies: unwrap(dockerDependencies),
|
||||
sessionAnalytics: unwrap(sessionAnalytics),
|
||||
contextUsage: unwrap(contextUsage),
|
||||
config: configValue,
|
||||
@@ -1915,6 +1969,9 @@ async function loadDashboard(el, client) {
|
||||
if (slow?.localBackends) {
|
||||
updateLocalBackends(slow.localBackends);
|
||||
}
|
||||
if (slow?.dockerDependencies) {
|
||||
updateDockerDependencies(slow.dockerDependencies);
|
||||
}
|
||||
if (slow?.sessionAnalytics) {
|
||||
updateSessionAnalytics(slow.sessionAnalytics);
|
||||
}
|
||||
@@ -1953,6 +2010,9 @@ async function loadDashboard(el, client) {
|
||||
if (data.localBackends) {
|
||||
updateLocalBackends(data.localBackends);
|
||||
}
|
||||
if (data.dockerDependencies) {
|
||||
updateDockerDependencies(data.dockerDependencies);
|
||||
}
|
||||
if (data.sessionAnalytics) {
|
||||
updateSessionAnalytics(data.sessionAnalytics);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,18 @@ function createMockClient() {
|
||||
availableActions: ['start', 'restart', 'update'],
|
||||
},
|
||||
],
|
||||
dockerDependencies: [
|
||||
{
|
||||
id: 'whisper',
|
||||
name: 'Whisper (whisper.cpp)',
|
||||
service: 'whisper-server',
|
||||
configured: true,
|
||||
state: 'running',
|
||||
health: 'healthy',
|
||||
statusText: 'Up 2 minutes (healthy)',
|
||||
containerName: 'flynn-whisper-server-1',
|
||||
},
|
||||
],
|
||||
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
|
||||
};
|
||||
|
||||
@@ -161,6 +173,9 @@ function createMockClient() {
|
||||
if (method === 'system.localBackends') {
|
||||
return { backends: deepClone(state.localBackends) };
|
||||
}
|
||||
if (method === 'system.dockerDependencies') {
|
||||
return { dependencies: deepClone(state.dockerDependencies) };
|
||||
}
|
||||
if (method === 'system.sessionAnalytics') {
|
||||
return {
|
||||
daily: [],
|
||||
@@ -475,4 +490,16 @@ describe('DashboardPage assistant controls', () => {
|
||||
expect(backendCalls[2].params).toEqual({ backend: 'ollama', action: 'update' });
|
||||
expect(state.localBackends.find((entry) => entry.id === 'llamacpp')?.activeState).toBe('active');
|
||||
});
|
||||
|
||||
it('renders docker dependency status cards', async () => {
|
||||
const { client } = createMockClient();
|
||||
|
||||
await DashboardPage.render(container, client);
|
||||
|
||||
const card = container.querySelector('#ops-docker-dependencies');
|
||||
expect(card).toBeTruthy();
|
||||
expect(String(card.textContent ?? '')).toContain('Whisper (whisper.cpp)');
|
||||
expect(String(card.textContent ?? '')).toContain('whisper-server');
|
||||
expect(String(card.textContent ?? '')).toContain('Up 2 minutes (healthy)');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user