diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index d9f3da2..b2a4119 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -10,6 +10,7 @@ import { createHistoryHandlers } from './history.js'; import { createCanvasHandlers } from './canvas.js'; import { createConfigHandlers, redactConfig } from './config.js'; import { createPairingHandlers } from './pairing.js'; +import type { LocalBackendStatus, LocalBackendControlResult } from './localBackends.js'; import { PairingManager } from '../../channels/pairing.js'; import { LaneQueue } from '../lane-queue.js'; import { CanvasStore } from '../canvas-store.js'; @@ -142,6 +143,107 @@ describe('system handlers', () => { ]); }); + it('system.localBackends returns empty list when callback is not provided', async () => { + const req: GatewayRequest = { id: 35, method: 'system.localBackends' }; + const result = await handlers['system.localBackends'](req) as GatewayResponse; + expect(getPath(result.result, 'backends')).toEqual([]); + }); + + it('system.localBackends returns backend statuses from callback', async () => { + const localBackends: LocalBackendStatus[] = [ + { + id: 'ollama', + provider: 'ollama', + name: 'Ollama', + unit: 'ollama.service', + configured: true, + loadState: 'loaded', + activeState: 'active', + subState: 'running', + unitFileState: 'enabled', + description: 'Ollama Service', + pid: 1234, + result: 'success', + statusText: 'active (running)', + availableActions: ['restart', 'stop'], + }, + ]; + const getLocalBackends = vi.fn(async (): Promise => localBackends); + + const handlers = createSystemHandlers({ + ...deps, + getLocalBackends, + }); + const req: GatewayRequest = { id: 36, method: 'system.localBackends' }; + const result = await handlers['system.localBackends'](req) as GatewayResponse; + expect(getLocalBackends).toHaveBeenCalledTimes(1); + expect(getPath(result.result, 'backends')).toHaveLength(1); + expect(getPath(result.result, 'backends', '0', 'id')).toBe('ollama'); + }); + + it('system.localBackendControl validates required params', async () => { + const handlers = createSystemHandlers({ + ...deps, + controlLocalBackend: vi.fn(), + }); + const missingBackend = await handlers['system.localBackendControl']({ + id: 37, + method: 'system.localBackendControl', + params: { action: 'restart' }, + }) as GatewayError; + expect(missingBackend.error.code).toBe(ErrorCode.InvalidRequest); + + const missingAction = await handlers['system.localBackendControl']({ + id: 38, + method: 'system.localBackendControl', + params: { backend: 'ollama' }, + }) as GatewayError; + expect(missingAction.error.code).toBe(ErrorCode.InvalidRequest); + + const badAction = await handlers['system.localBackendControl']({ + id: 39, + method: 'system.localBackendControl', + params: { backend: 'ollama', action: 'reload' }, + }) as GatewayError; + expect(badAction.error.code).toBe(ErrorCode.InvalidRequest); + }); + + it('system.localBackendControl forwards action to callback', async () => { + const controlResult: LocalBackendControlResult = { + backend: 'ollama', + action: 'restart', + status: { + id: 'ollama', + provider: 'ollama', + name: 'Ollama', + unit: 'ollama.service', + configured: true, + loadState: 'loaded', + activeState: 'active', + subState: 'running', + unitFileState: 'enabled', + description: 'Ollama Service', + pid: 321, + result: 'success', + statusText: 'active (running)', + availableActions: ['restart', 'stop'], + }, + }; + const controlLocalBackend = vi.fn(async (): Promise => controlResult); + const handlers = createSystemHandlers({ + ...deps, + controlLocalBackend, + }); + const req: GatewayRequest = { + id: 40, + method: 'system.localBackendControl', + params: { backend: 'ollama', action: 'restart' }, + }; + const result = await handlers['system.localBackendControl'](req) as GatewayResponse; + expect(controlLocalBackend).toHaveBeenCalledWith('ollama', 'restart'); + expect(getPath(result.result, 'status', 'activeState')).toBe('active'); + }); + 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; diff --git a/src/gateway/handlers/localBackends.ts b/src/gateway/handlers/localBackends.ts new file mode 100644 index 0000000..b2008d0 --- /dev/null +++ b/src/gateway/handlers/localBackends.ts @@ -0,0 +1,278 @@ +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { Config, ModelConfig } from '../../config/index.js'; + +const execFile = promisify(execFileCb); + +export type LocalBackendId = 'ollama' | 'llamacpp'; +export type LocalBackendAction = 'start' | 'restart' | 'stop'; + +export interface LocalBackendStatus { + id: LocalBackendId; + provider: 'ollama' | 'llamacpp'; + name: string; + unit: string; + configured: boolean; + loadState: string; + activeState: string; + subState: string; + unitFileState: string; + description: string; + pid: number | null; + result: string; + statusText: string; + availableActions: LocalBackendAction[]; + error?: string; +} + +export interface LocalBackendControlResult { + backend: LocalBackendId; + action: LocalBackendAction; + status: LocalBackendStatus; +} + +type SystemctlResult = { stdout: string; stderr: string }; +type SystemctlRunner = (args: string[]) => Promise; + +const LOCAL_BACKEND_UNITS: Record = { + ollama: { + provider: 'ollama', + name: 'Ollama', + unit: 'ollama.service', + }, + llamacpp: { + provider: 'llamacpp', + name: 'llama.cpp server', + unit: 'llama-server.service', + }, +}; + +function defaultRunner(args: string[]): Promise { + return execFile('systemctl', args, { timeout: 10_000, maxBuffer: 1024 * 1024 }) as Promise; +} + +function collectModelConfigs(config: Config): ModelConfig[] { + const models: ModelConfig[] = [config.models.default]; + if (config.models.local) {models.push(config.models.local);} + if (config.models.fast) {models.push(config.models.fast);} + if (config.models.complex) {models.push(config.models.complex);} + if (config.models.local_providers) { + models.push(...Object.values(config.models.local_providers)); + } + return models; +} + +function collectConfiguredLocalBackends(config: Config): Set { + const configured = new Set(); + + for (const model of collectModelConfigs(config)) { + if (model.provider === 'ollama') { + configured.add('ollama'); + } + if (model.provider === 'llamacpp') { + configured.add('llamacpp'); + } + } + + const embeddingProvider = config.memory.embedding.provider; + if (embeddingProvider === 'ollama') { + configured.add('ollama'); + } + if (embeddingProvider === 'llamacpp') { + configured.add('llamacpp'); + } + + if (config.audio.enabled) { + const audioProvider = config.audio.provider?.type; + if (audioProvider === 'ollama') { + configured.add('ollama'); + } + if (audioProvider === 'llamacpp') { + configured.add('llamacpp'); + } + } + + return configured; +} + +function parseKeyValueOutput(output: string): Record { + const result: Record = {}; + for (const line of output.split('\n')) { + if (!line.trim()) {continue;} + const idx = line.indexOf('='); + if (idx <= 0) {continue;} + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + result[key] = value; + } + return result; +} + +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()) {return maybe.message.trim();} + } + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + return String(error); +} + +function isUnitMissing(errorText: string): boolean { + return ( + errorText.includes('could not be found') + || errorText.includes('not-found') + || errorText.includes('No such file or directory') + ); +} + +function computeAvailableActions(activeState: string, loadState: string): LocalBackendAction[] { + if (loadState === 'not-found') { + return []; + } + if (activeState === 'active') { + return ['restart', 'stop']; + } + if (activeState === 'inactive' || activeState === 'failed') { + return ['start', 'restart']; + } + if (activeState === 'activating' || activeState === 'deactivating') { + return ['restart', 'stop']; + } + return ['start', 'restart', 'stop']; +} + +function buildStatusText(activeState: string, subState: string): string { + if (activeState === 'active' && subState) { + return `${activeState} (${subState})`; + } + if (activeState && subState && activeState !== subState) { + return `${activeState}/${subState}`; + } + return activeState || subState || 'unknown'; +} + +async function fetchUnitStatus( + id: LocalBackendId, + configured: boolean, + runner: SystemctlRunner, +): Promise { + const unitDef = LOCAL_BACKEND_UNITS[id]; + const base: Omit = { + id, + provider: unitDef.provider, + name: unitDef.name, + unit: unitDef.unit, + configured, + loadState: 'unknown', + activeState: 'unknown', + subState: 'unknown', + unitFileState: 'unknown', + description: unitDef.name, + pid: null, + result: 'unknown', + }; + + try { + const response = await runner([ + '--user', + 'show', + unitDef.unit, + '--property=LoadState,ActiveState,SubState,UnitFileState,Description,ExecMainPID,Result', + '--no-pager', + ]); + const parsed = parseKeyValueOutput(response.stdout); + const pidRaw = parsed.ExecMainPID; + const pid = pidRaw ? Number(pidRaw) : NaN; + + const status: LocalBackendStatus = { + ...base, + loadState: parsed.LoadState || base.loadState, + activeState: parsed.ActiveState || base.activeState, + subState: parsed.SubState || base.subState, + unitFileState: parsed.UnitFileState || base.unitFileState, + description: parsed.Description || base.description, + pid: Number.isFinite(pid) && pid > 0 ? pid : null, + result: parsed.Result || base.result, + statusText: '', + availableActions: [], + }; + status.availableActions = computeAvailableActions(status.activeState, status.loadState); + status.statusText = buildStatusText(status.activeState, status.subState); + return status; + } catch (error) { + const detail = normalizeError(error); + if (isUnitMissing(detail)) { + const missingStatus: LocalBackendStatus = { + ...base, + loadState: 'not-found', + activeState: 'inactive', + subState: 'dead', + result: 'not-found', + unitFileState: 'not-found', + statusText: 'missing', + availableActions: [], + error: detail, + }; + return missingStatus; + } + return { + ...base, + statusText: 'unknown', + availableActions: ['start', 'restart', 'stop'], + error: detail, + }; + } +} + +function ensureValidAction(action: string): asserts action is LocalBackendAction { + if (action !== 'start' && action !== 'restart' && action !== 'stop') { + throw new Error(`Unsupported action: ${action}`); + } +} + +function ensureValidBackend(id: string): asserts id is LocalBackendId { + if (id !== 'ollama' && id !== 'llamacpp') { + throw new Error(`Unsupported backend: ${id}`); + } +} + +export async function listLocalBackendStatuses( + config: Config, + runner: SystemctlRunner = defaultRunner, +): Promise { + const configured = collectConfiguredLocalBackends(config); + return Promise.all((Object.keys(LOCAL_BACKEND_UNITS) as LocalBackendId[]).map((id) => + fetchUnitStatus(id, configured.has(id), runner), + )); +} + +export async function controlLocalBackend( + config: Config, + backend: string, + action: string, + runner: SystemctlRunner = defaultRunner, +): Promise { + ensureValidBackend(backend); + ensureValidAction(action); + + const unitDef = LOCAL_BACKEND_UNITS[backend]; + await runner(['--user', action, unitDef.unit, '--no-pager']); + + const configured = collectConfiguredLocalBackends(config); + const status = await fetchUnitStatus(backend, configured.has(backend), runner); + return { + backend, + action, + status, + }; +} diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 12ea7ac..5d38686 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -4,6 +4,7 @@ import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics. 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'; /** Per-session token usage report returned by system.tokenUsage. */ export interface TokenUsageEntry { @@ -103,6 +104,20 @@ export interface SystemHandlerDeps { error?: string; fetchedAt: number; }>>; + /** Optional callback to retrieve local backend daemon statuses. */ + getLocalBackends?: () => Promise | LocalBackendStatus[]; + /** Optional callback to control local backend daemons. */ + controlLocalBackend?: (backend: string, action: string) => Promise; +} + +function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if (typeof error === 'string' && error.trim().length > 0) { + return error; + } + return 'Unknown error'; } export function createSystemHandlers(deps: SystemHandlerDeps) { @@ -285,5 +300,40 @@ export function createSystemHandlers(deps: SystemHandlerDeps) { }); return makeResponse(request.id, { providers }); }, + + 'system.localBackends': async (request: GatewayRequest): Promise => { + if (!deps.getLocalBackends) { + return makeResponse(request.id, { backends: [] }); + } + try { + const backends = await deps.getLocalBackends(); + return makeResponse(request.id, { backends }); + } catch (error) { + return makeError(request.id, ErrorCode.InternalError, `Failed to load local backends: ${normalizeErrorMessage(error)}`); + } + }, + + 'system.localBackendControl': async (request: GatewayRequest): Promise => { + if (!deps.controlLocalBackend) { + return makeError(request.id, ErrorCode.InternalError, 'Local backend control is not available in this environment'); + } + const params = request.params as { backend?: string; action?: LocalBackendAction } | undefined; + if (!params?.backend || typeof params.backend !== 'string') { + return makeError(request.id, ErrorCode.InvalidRequest, 'backend is required'); + } + if (!params?.action || typeof params.action !== 'string') { + return makeError(request.id, ErrorCode.InvalidRequest, 'action is required'); + } + if (!['start', 'restart', 'stop'].includes(params.action)) { + return makeError(request.id, ErrorCode.InvalidRequest, 'action must be one of: start, restart, stop'); + } + + try { + const result = await deps.controlLocalBackend(params.backend, params.action); + return makeResponse(request.id, result); + } catch (error) { + return makeError(request.id, ErrorCode.InternalError, `Local backend control failed: ${normalizeErrorMessage(error)}`); + } + }, }; } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index effed14..d419310 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -34,6 +34,7 @@ import { } from './handlers/index.js'; import { discoverServices } from './handlers/services.js'; import { createModelCatalogFetcher } from './modelCatalog.js'; +import { listLocalBackendStatuses, controlLocalBackend } from './handlers/localBackends.js'; import type { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js'; import type { NodeConnectionState } from './handlers/node.js'; import type { SessionManager } from '../session/manager.js'; @@ -230,6 +231,12 @@ export class GatewayServer { getModelCatalog: modelCatalogFetcher ? (opts) => modelCatalogFetcher(opts) : undefined, + getLocalBackends: runtimeConfig + ? () => listLocalBackendStatuses(runtimeConfig) + : undefined, + controlLocalBackend: runtimeConfig + ? (backend, action) => controlLocalBackend(runtimeConfig, backend, action) + : undefined, getPresence: channelRegistry ? (opts) => channelRegistry.getPresence(opts) : undefined, diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js index 575d54c..ac60ea8 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -20,6 +20,8 @@ let _lastCouncilTask = ''; let _lastCouncilResult = null; let _lastCouncilError = null; let _lastServices = []; +let _lastLocalBackends = []; +let _localBackendActionState = new Map(); let _serviceConfigState = { open: false, serviceName: null, @@ -31,6 +33,11 @@ let _serviceConfigState = { const MODEL_DEFAULT_TASK_KEYS = ['compaction', 'memory_extraction', 'classification', 'tool_summarisation', 'complex_reasoning']; const MODEL_DEFAULT_TIER_KEYS = ['default', 'fast', 'complex', 'local']; const HEARTBEAT_CHECK_KEYS = ['gateway', 'model', 'channels', 'memory', 'disk', 'process_memory', 'backup', 'provider_errors']; +const LOCAL_BACKEND_ACTION_LABELS = { + start: 'Start', + restart: 'Restart', + stop: 'Stop', +}; const SERVICE_TOGGLE_PATCH_PATHS = { heartbeat: 'automation.heartbeat.enabled', daily_briefing: 'automation.daily_briefing.enabled', @@ -372,6 +379,12 @@ function renderSkeleton(el) {
Loading...
+ +

Local LLM Backends

+
User-level daemon status and controls for local providers.
+
+
Loading...
+
`; } @@ -1406,6 +1419,7 @@ function updateAssistantHealth(configData) { _lastAssistantConfig = refreshed.config; } updateServices(refreshed.services); + updateLocalBackends(refreshed.localBackends); updateSessionAnalytics(refreshed.sessionAnalytics); updateContextHealth(refreshed.contextUsage); // Only re-render assistant controls from a confirmed config snapshot. @@ -1527,6 +1541,114 @@ function updateServices(servicesData) { renderServiceConfigModal(); } +function updateLocalBackends(localBackendsData) { + const el = document.getElementById('ops-local-backends'); + if (!el) {return;} + + const backends = localBackendsData?.backends ?? []; + _lastLocalBackends = backends; + + if (backends.length === 0) { + el.innerHTML = '
No local backends detected
'; + return; + } + + el.innerHTML = backends.map((backend) => { + const backendId = String(backend.id ?? ''); + const actionState = _localBackendActionState.get(backendId) ?? null; + const isPending = Boolean(actionState?.pending); + const toneClass = backend.activeState === 'active' + ? 'text-green-500' + : backend.activeState === 'failed' + ? 'text-red-500' + : 'text-zinc-400'; + const configuredText = backend.configured ? 'configured' : 'not configured'; + const configuredClass = backend.configured ? 'text-blue-400' : 'text-zinc-500'; + const pidText = backend.pid ? String(backend.pid) : '—'; + const unitFileText = backend.unitFileState || 'unknown'; + const loadText = backend.loadState || 'unknown'; + const resultText = backend.result || 'unknown'; + const availableActions = Array.isArray(backend.availableActions) + ? backend.availableActions.filter((value) => ['start', 'restart', 'stop'].includes(String(value))) + : []; + const actionMessage = actionState?.message + ? `
${escapeHtml(String(actionState.message))}
` + : ''; + + const actionButtons = availableActions.length > 0 + ? availableActions.map((action) => { + const key = String(action); + const label = LOCAL_BACKEND_ACTION_LABELS[key] ?? key; + return ``; + }).join('') + : 'No actions available'; + + return `
+
+
${escapeHtml(String(backend.name ?? backendId))}
+ ${escapeHtml(String(backend.statusText ?? backend.activeState ?? 'unknown'))} +
+
Unit: ${escapeHtml(String(backend.unit ?? 'unknown'))}
+
PID: ${escapeHtml(pidText)} · Load: ${escapeHtml(loadText)} · Result: ${escapeHtml(resultText)}
+
${escapeHtml(configuredText)} · unit file: ${escapeHtml(unitFileText)}
+ ${backend.error ? `
Error: ${escapeHtml(String(backend.error))}
` : ''} + ${actionMessage} +
${actionButtons}
+
`; + }).join(''); + + el.querySelectorAll('.local-backend-action-btn').forEach((button) => { + button.addEventListener('click', async () => { + const backendId = button.getAttribute('data-backend-id'); + const action = button.getAttribute('data-action'); + if (!backendId || !action) {return;} + await handleLocalBackendAction(backendId, action); + }); + }); +} + +async function handleLocalBackendAction(backendId, action) { + if (!_dashboardClient) {return;} + const actionLabel = LOCAL_BACKEND_ACTION_LABELS[action] ?? action; + _localBackendActionState.set(backendId, { pending: true, tone: 'neutral', message: `${actionLabel} requested…` }); + updateLocalBackends({ backends: _lastLocalBackends }); + + try { + const result = await _dashboardClient.call('system.localBackendControl', { + backend: backendId, + action, + }); + const status = result?.status; + if (status && typeof status === 'object') { + _lastLocalBackends = _lastLocalBackends.map((backend) => + backend.id === backendId ? status : backend); + } + _localBackendActionState.set(backendId, { + pending: false, + tone: 'success', + message: `${actionLabel} completed`, + }); + updateLocalBackends({ backends: _lastLocalBackends }); + + const refreshed = await fetchSlow(_dashboardClient); + if (refreshed?.localBackends) { + updateLocalBackends(refreshed.localBackends); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + _localBackendActionState.set(backendId, { + pending: false, + tone: 'error', + message: `${actionLabel} failed: ${message}`, + }); + updateLocalBackends({ backends: _lastLocalBackends }); + } +} + function getConfigValue(path, fallbackValue) { const value = getByPath(_lastAssistantConfig, path); return value === undefined ? fallbackValue : value; @@ -1697,6 +1819,7 @@ function renderServiceConfigModal() { const refreshed = await fetchSlow(_dashboardClient); if (refreshed) { updateServices(refreshed.services); + updateLocalBackends(refreshed.localBackends); updateSessionAnalytics(refreshed.sessionAnalytics); updateContextHealth(refreshed.contextUsage); if (refreshed.config) { @@ -1729,9 +1852,10 @@ async function fetchFast(client) { } async function fetchSlow(client) { - const [health, services, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([ + const [health, services, localBackends, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([ client.call('system.health'), client.call('system.services'), + client.call('system.localBackends'), client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }), client.call('system.contextUsage'), client.call('config.get'), @@ -1749,6 +1873,7 @@ async function fetchSlow(client) { return { health: unwrap(health), services: unwrap(services), + localBackends: unwrap(localBackends), sessionAnalytics: unwrap(sessionAnalytics), contextUsage: unwrap(contextUsage), config: configValue, @@ -1785,6 +1910,9 @@ async function loadDashboard(el, client) { if (slow?.services) { updateServices(slow.services); } + if (slow?.localBackends) { + updateLocalBackends(slow.localBackends); + } if (slow?.sessionAnalytics) { updateSessionAnalytics(slow.sessionAnalytics); } @@ -1820,6 +1948,9 @@ async function loadDashboard(el, client) { if (data.services) { updateServices(data.services); } + if (data.localBackends) { + updateLocalBackends(data.localBackends); + } if (data.sessionAnalytics) { updateSessionAnalytics(data.sessionAnalytics); } @@ -1857,5 +1988,18 @@ export const DashboardPage = { _assistantModelDefaultsDraft = null; _assistantDraftState = new Map(); _assistantDraftTouchedAt = 0; + _lastServices = []; + _lastLocalBackends = []; + _localBackendActionState = new Map(); + _serviceConfigState = { + open: false, + serviceName: null, + status: null, + tone: 'neutral', + advancedPatch: '', + }; + _lastCouncilTask = ''; + _lastCouncilResult = null; + _lastCouncilError = null; }, }; diff --git a/src/gateway/ui/pages/dashboard.test.ts b/src/gateway/ui/pages/dashboard.test.ts index 347bd7e..085acb9 100644 --- a/src/gateway/ui/pages/dashboard.test.ts +++ b/src/gateway/ui/pages/dashboard.test.ts @@ -91,6 +91,40 @@ function createInitialConfig() { function createMockClient() { const state = { config: createInitialConfig(), + localBackends: [ + { + id: 'ollama', + provider: 'ollama', + name: 'Ollama', + unit: 'ollama.service', + configured: true, + loadState: 'loaded', + activeState: 'active', + subState: 'running', + unitFileState: 'enabled', + description: 'Ollama daemon', + pid: 111, + result: 'success', + statusText: 'active (running)', + availableActions: ['restart', 'stop'], + }, + { + id: 'llamacpp', + provider: 'llamacpp', + name: 'llama.cpp server', + unit: 'llama-server.service', + configured: true, + loadState: 'loaded', + activeState: 'inactive', + subState: 'dead', + unitFileState: 'enabled', + description: 'llama.cpp daemon', + pid: null, + result: 'success', + statusText: 'inactive/dead', + availableActions: ['start', 'restart'], + }, + ], calls: [] as Array<{ method: string; params?: Record }>, }; @@ -124,6 +158,9 @@ function createMockClient() { if (method === 'system.services') { return { services: [] }; } + if (method === 'system.localBackends') { + return { backends: deepClone(state.localBackends) }; + } if (method === 'system.sessionAnalytics') { return { daily: [], @@ -177,6 +214,36 @@ function createMockClient() { } return { success: true, output: '' }; } + if (method === 'system.localBackendControl') { + const backendId = String(params?.backend ?? ''); + const action = String(params?.action ?? ''); + const backend = state.localBackends.find((entry) => entry.id === backendId); + if (!backend) { + throw new Error(`Unknown backend: ${backendId}`); + } + + if (action === 'start' || action === 'restart') { + backend.activeState = 'active'; + backend.subState = 'running'; + backend.statusText = 'active (running)'; + backend.pid = backend.id === 'ollama' ? 222 : 333; + backend.availableActions = ['restart', 'stop']; + backend.result = 'success'; + } else if (action === 'stop') { + backend.activeState = 'inactive'; + backend.subState = 'dead'; + backend.statusText = 'inactive/dead'; + backend.pid = null; + backend.availableActions = ['start', 'restart']; + backend.result = 'success'; + } + + return { + backend: backendId, + action, + status: deepClone(backend), + }; + } return null; }, }; @@ -377,4 +444,26 @@ describe('DashboardPage assistant controls', () => { expect(toolCalls.some((entry) => entry.params?.tool === 'council.run')).toBe(true); expect(toolCalls.some((entry) => entry.params?.tool === 'cron.trigger')).toBe(true); }); + + it('renders local backend controls and triggers system.localBackendControl', async () => { + const { state, client } = createMockClient(); + + await DashboardPage.render(container, client); + + const restartBtn = container.querySelector('#ops-local-backends .local-backend-action-btn[data-backend-id="ollama"][data-action="restart"]'); + expect(restartBtn).toBeTruthy(); + restartBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); + await flush(); + + const startBtn = container.querySelector('#ops-local-backends .local-backend-action-btn[data-backend-id="llamacpp"][data-action="start"]'); + expect(startBtn).toBeTruthy(); + startBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); + await flush(); + + const backendCalls = state.calls.filter((entry) => entry.method === 'system.localBackendControl'); + expect(backendCalls).toHaveLength(2); + expect(backendCalls[0].params).toEqual({ backend: 'ollama', action: 'restart' }); + expect(backendCalls[1].params).toEqual({ backend: 'llamacpp', action: 'start' }); + expect(state.localBackends.find((entry) => entry.id === 'llamacpp')?.activeState).toBe('active'); + }); });