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
+84
View File
@@ -21,7 +21,9 @@ let _lastCouncilResult = null;
let _lastCouncilError = null;
let _lastServices = [];
let _lastLocalBackends = [];
let _lastDockerDependencies = [];
let _localBackendActionState = new Map();
let _dockerDependencyActionState = new Map();
let _serviceConfigState = {
open: false,
serviceName: null,
@@ -39,6 +41,12 @@ const LOCAL_BACKEND_ACTION_LABELS = {
stop: 'Stop',
update: 'Update',
};
const DOCKER_DEPENDENCY_ACTION_LABELS = {
start: 'Start',
restart: 'Restart',
stop: 'Stop',
update: 'Update',
};
const SERVICE_TOGGLE_PATCH_PATHS = {
heartbeat: 'automation.heartbeat.enabled',
daily_briefing: 'automation.daily_briefing.enabled',
@@ -1624,6 +1632,7 @@ function updateDockerDependencies(dockerDependenciesData) {
if (!el) {return;}
const dependencies = dockerDependenciesData?.dependencies ?? [];
_lastDockerDependencies = dependencies;
if (dependencies.length === 0) {
el.innerHTML = '<div class="text-sm text-zinc-500">No docker dependencies detected</div>';
@@ -1631,6 +1640,9 @@ function updateDockerDependencies(dockerDependenciesData) {
}
el.innerHTML = dependencies.map((dependency) => {
const dependencyId = String(dependency.id ?? '');
const actionState = _dockerDependencyActionState.get(dependencyId) ?? null;
const isPending = Boolean(actionState?.pending);
const state = String(dependency.state ?? 'unknown');
const health = String(dependency.health ?? 'unknown');
const statusText = String(dependency.statusText ?? state);
@@ -1645,6 +1657,23 @@ function updateDockerDependencies(dockerDependenciesData) {
? 'text-red-500'
: 'text-zinc-400';
const containerName = dependency.containerName ? String(dependency.containerName) : '—';
const availableActions = Array.isArray(dependency.availableActions)
? dependency.availableActions.filter((value) => ['start', 'restart', 'stop', 'update'].includes(String(value)))
: [];
const actionMessage = actionState?.message
? `<div class="text-xs ${actionState.tone === 'error' ? 'text-red-400' : actionState.tone === 'success' ? 'text-green-400' : 'text-zinc-400'}">${escapeHtml(String(actionState.message))}</div>`
: '';
const actionButtons = availableActions.length > 0
? availableActions.map((action) => {
const key = String(action);
const label = DOCKER_DEPENDENCY_ACTION_LABELS[key] ?? key;
return `<button class="docker-dependency-action-btn px-2 py-1 text-xs border border-zinc-700 rounded text-zinc-200 hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed"
data-dependency-id="${escapeHtml(dependencyId)}"
data-action="${escapeHtml(key)}"
${isPending ? 'disabled' : ''}
title="${escapeHtml(`${label} ${dependency.name ?? dependencyId}`)}">${escapeHtml(label)}</button>`;
}).join('')
: '<span class="text-xs text-zinc-500">No actions available</span>';
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">
@@ -1656,8 +1685,19 @@ function updateDockerDependencies(dockerDependenciesData) {
<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>` : ''}
${actionMessage}
<div class="flex flex-wrap gap-2">${actionButtons}</div>
</div>`;
}).join('');
el.querySelectorAll('.docker-dependency-action-btn').forEach((button) => {
button.addEventListener('click', async () => {
const dependencyId = button.getAttribute('data-dependency-id');
const action = button.getAttribute('data-action');
if (!dependencyId || !action) {return;}
await handleDockerDependencyAction(dependencyId, action);
});
});
}
async function handleLocalBackendAction(backendId, action) {
@@ -1702,6 +1742,48 @@ async function handleLocalBackendAction(backendId, action) {
}
}
async function handleDockerDependencyAction(dependencyId, action) {
if (!_dashboardClient) {return;}
const actionLabel = DOCKER_DEPENDENCY_ACTION_LABELS[action] ?? action;
_dockerDependencyActionState.set(dependencyId, { pending: true, tone: 'neutral', message: `${actionLabel} requested…` });
updateDockerDependencies({ dependencies: _lastDockerDependencies });
try {
const result = await _dashboardClient.call('system.dockerDependencyControl', {
dependency: dependencyId,
action,
});
const status = result?.status;
const resultMessage = typeof result?.message === 'string' ? result.message : null;
if (status && typeof status === 'object') {
_lastDockerDependencies = _lastDockerDependencies.map((dependency) =>
dependency.id === dependencyId ? status : dependency);
}
_dockerDependencyActionState.set(dependencyId, {
pending: false,
tone: 'success',
message: resultMessage ? `${actionLabel} completed: ${resultMessage}` : `${actionLabel} completed`,
});
updateDockerDependencies({ dependencies: _lastDockerDependencies });
const refreshed = await fetchSlow(_dashboardClient);
if (refreshed?.localBackends) {
updateLocalBackends(refreshed.localBackends);
}
if (refreshed?.dockerDependencies) {
updateDockerDependencies(refreshed.dockerDependencies);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
_dockerDependencyActionState.set(dependencyId, {
pending: false,
tone: 'error',
message: `${actionLabel} failed: ${message}`,
});
updateDockerDependencies({ dependencies: _lastDockerDependencies });
}
}
function getConfigValue(path, fallbackValue) {
const value = getByPath(_lastAssistantConfig, path);
return value === undefined ? fallbackValue : value;
@@ -2052,7 +2134,9 @@ export const DashboardPage = {
_assistantDraftTouchedAt = 0;
_lastServices = [];
_lastLocalBackends = [];
_lastDockerDependencies = [];
_localBackendActionState = new Map();
_dockerDependencyActionState = new Map();
_serviceConfigState = {
open: false,
serviceName: null,
+68
View File
@@ -135,6 +135,7 @@ function createMockClient() {
health: 'healthy',
statusText: 'Up 2 minutes (healthy)',
containerName: 'flynn-whisper-server-1',
availableActions: ['restart', 'stop', 'update'],
},
],
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
@@ -262,6 +263,39 @@ function createMockClient() {
message: action === 'update' ? 'Updated backend assets' : undefined,
};
}
if (method === 'system.dockerDependencyControl') {
const dependencyId = String(params?.dependency ?? '');
const action = String(params?.action ?? '');
const dependency = state.dockerDependencies.find((entry) => entry.id === dependencyId);
if (!dependency) {
throw new Error(`Unknown dependency: ${dependencyId}`);
}
if (action === 'start' || action === 'restart') {
dependency.state = 'running';
dependency.health = 'healthy';
dependency.statusText = 'running (healthy)';
dependency.containerName = 'whisper-server';
dependency.availableActions = ['restart', 'stop', 'update'];
} else if (action === 'stop') {
dependency.state = 'stopped';
dependency.health = 'none';
dependency.statusText = 'stopped';
dependency.availableActions = ['start', 'restart', 'update'];
} else if (action === 'update') {
dependency.state = 'running';
dependency.health = 'healthy';
dependency.statusText = 'running (healthy)';
dependency.availableActions = ['restart', 'stop', 'update'];
}
return {
dependency: dependencyId,
action,
status: deepClone(dependency),
message: action === 'update' ? 'Pulled latest image' : undefined,
};
}
return null;
},
};
@@ -502,4 +536,38 @@ describe('DashboardPage assistant controls', () => {
expect(String(card.textContent ?? '')).toContain('whisper-server');
expect(String(card.textContent ?? '')).toContain('Up 2 minutes (healthy)');
});
it('renders docker dependency controls and triggers system.dockerDependencyControl actions', async () => {
const { state, client } = createMockClient();
await DashboardPage.render(container, client);
const restartBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="whisper"][data-action="restart"]');
expect(restartBtn).toBeTruthy();
restartBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await flush();
const stopBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="whisper"][data-action="stop"]');
expect(stopBtn).toBeTruthy();
stopBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await flush();
const startBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="whisper"][data-action="start"]');
expect(startBtn).toBeTruthy();
startBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await flush();
const updateBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="whisper"][data-action="update"]');
expect(updateBtn).toBeTruthy();
updateBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await flush();
const dependencyCalls = state.calls.filter((entry) => entry.method === 'system.dockerDependencyControl');
expect(dependencyCalls).toHaveLength(4);
expect(dependencyCalls[0].params).toEqual({ dependency: 'whisper', action: 'restart' });
expect(dependencyCalls[1].params).toEqual({ dependency: 'whisper', action: 'stop' });
expect(dependencyCalls[2].params).toEqual({ dependency: 'whisper', action: 'start' });
expect(dependencyCalls[3].params).toEqual({ dependency: 'whisper', action: 'update' });
expect(state.dockerDependencies.find((entry) => entry.id === 'whisper')?.state).toBe('running');
});
});