gateway: add local backend daemon controls to dashboard
This commit is contained in:
@@ -10,6 +10,7 @@ import { createHistoryHandlers } from './history.js';
|
|||||||
import { createCanvasHandlers } from './canvas.js';
|
import { createCanvasHandlers } from './canvas.js';
|
||||||
import { createConfigHandlers, redactConfig } from './config.js';
|
import { createConfigHandlers, redactConfig } from './config.js';
|
||||||
import { createPairingHandlers } from './pairing.js';
|
import { createPairingHandlers } from './pairing.js';
|
||||||
|
import type { LocalBackendStatus, LocalBackendControlResult } from './localBackends.js';
|
||||||
import { PairingManager } from '../../channels/pairing.js';
|
import { PairingManager } from '../../channels/pairing.js';
|
||||||
import { LaneQueue } from '../lane-queue.js';
|
import { LaneQueue } from '../lane-queue.js';
|
||||||
import { CanvasStore } from '../canvas-store.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<LocalBackendStatus[]> => 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<LocalBackendControlResult> => 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 () => {
|
it('system.presence returns empty result when getPresence is not provided', async () => {
|
||||||
const req: GatewayRequest = { id: 4, method: 'system.presence' };
|
const req: GatewayRequest = { id: 4, method: 'system.presence' };
|
||||||
const result = await handlers['system.presence'](req) as GatewayResponse;
|
const result = await handlers['system.presence'](req) as GatewayResponse;
|
||||||
|
|||||||
@@ -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<SystemctlResult>;
|
||||||
|
|
||||||
|
const LOCAL_BACKEND_UNITS: Record<LocalBackendId, {
|
||||||
|
provider: 'ollama' | 'llamacpp';
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
}> = {
|
||||||
|
ollama: {
|
||||||
|
provider: 'ollama',
|
||||||
|
name: 'Ollama',
|
||||||
|
unit: 'ollama.service',
|
||||||
|
},
|
||||||
|
llamacpp: {
|
||||||
|
provider: 'llamacpp',
|
||||||
|
name: 'llama.cpp server',
|
||||||
|
unit: 'llama-server.service',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultRunner(args: string[]): Promise<SystemctlResult> {
|
||||||
|
return execFile('systemctl', args, { timeout: 10_000, maxBuffer: 1024 * 1024 }) as Promise<SystemctlResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LocalBackendId> {
|
||||||
|
const configured = new Set<LocalBackendId>();
|
||||||
|
|
||||||
|
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<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
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<LocalBackendStatus> {
|
||||||
|
const unitDef = LOCAL_BACKEND_UNITS[id];
|
||||||
|
const base: Omit<LocalBackendStatus, 'availableActions' | 'statusText'> = {
|
||||||
|
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<LocalBackendStatus[]> {
|
||||||
|
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<LocalBackendControlResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.
|
|||||||
import type { ServiceInfo } from './services.js';
|
import type { ServiceInfo } from './services.js';
|
||||||
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
||||||
import type { SessionAnalyticsSnapshot } from '../../session/index.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. */
|
/** Per-session token usage report returned by system.tokenUsage. */
|
||||||
export interface TokenUsageEntry {
|
export interface TokenUsageEntry {
|
||||||
@@ -103,6 +104,20 @@ export interface SystemHandlerDeps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
}>>;
|
}>>;
|
||||||
|
/** Optional callback to retrieve local backend daemon statuses. */
|
||||||
|
getLocalBackends?: () => Promise<LocalBackendStatus[]> | LocalBackendStatus[];
|
||||||
|
/** Optional callback to control local backend daemons. */
|
||||||
|
controlLocalBackend?: (backend: string, action: string) => Promise<LocalBackendControlResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||||
@@ -285,5 +300,40 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
|||||||
});
|
});
|
||||||
return makeResponse(request.id, { providers });
|
return makeResponse(request.id, { providers });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'system.localBackends': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
|
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<OutboundMessage> => {
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from './handlers/index.js';
|
} from './handlers/index.js';
|
||||||
import { discoverServices } from './handlers/services.js';
|
import { discoverServices } from './handlers/services.js';
|
||||||
import { createModelCatalogFetcher } from './modelCatalog.js';
|
import { createModelCatalogFetcher } from './modelCatalog.js';
|
||||||
|
import { listLocalBackendStatuses, controlLocalBackend } from './handlers/localBackends.js';
|
||||||
import type { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js';
|
import type { TokenUsageEntry, ContextUsageEntry } from './handlers/system.js';
|
||||||
import type { NodeConnectionState } from './handlers/node.js';
|
import type { NodeConnectionState } from './handlers/node.js';
|
||||||
import type { SessionManager } from '../session/manager.js';
|
import type { SessionManager } from '../session/manager.js';
|
||||||
@@ -230,6 +231,12 @@ export class GatewayServer {
|
|||||||
getModelCatalog: modelCatalogFetcher
|
getModelCatalog: modelCatalogFetcher
|
||||||
? (opts) => modelCatalogFetcher(opts)
|
? (opts) => modelCatalogFetcher(opts)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
getLocalBackends: runtimeConfig
|
||||||
|
? () => listLocalBackendStatuses(runtimeConfig)
|
||||||
|
: undefined,
|
||||||
|
controlLocalBackend: runtimeConfig
|
||||||
|
? (backend, action) => controlLocalBackend(runtimeConfig, backend, action)
|
||||||
|
: undefined,
|
||||||
getPresence: channelRegistry
|
getPresence: channelRegistry
|
||||||
? (opts) => channelRegistry.getPresence(opts)
|
? (opts) => channelRegistry.getPresence(opts)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ let _lastCouncilTask = '';
|
|||||||
let _lastCouncilResult = null;
|
let _lastCouncilResult = null;
|
||||||
let _lastCouncilError = null;
|
let _lastCouncilError = null;
|
||||||
let _lastServices = [];
|
let _lastServices = [];
|
||||||
|
let _lastLocalBackends = [];
|
||||||
|
let _localBackendActionState = new Map();
|
||||||
let _serviceConfigState = {
|
let _serviceConfigState = {
|
||||||
open: false,
|
open: false,
|
||||||
serviceName: null,
|
serviceName: null,
|
||||||
@@ -31,6 +33,11 @@ let _serviceConfigState = {
|
|||||||
const MODEL_DEFAULT_TASK_KEYS = ['compaction', 'memory_extraction', 'classification', 'tool_summarisation', 'complex_reasoning'];
|
const MODEL_DEFAULT_TASK_KEYS = ['compaction', 'memory_extraction', 'classification', 'tool_summarisation', 'complex_reasoning'];
|
||||||
const MODEL_DEFAULT_TIER_KEYS = ['default', 'fast', 'complex', 'local'];
|
const MODEL_DEFAULT_TIER_KEYS = ['default', 'fast', 'complex', 'local'];
|
||||||
const HEARTBEAT_CHECK_KEYS = ['gateway', 'model', 'channels', 'memory', 'disk', 'process_memory', 'backup', 'provider_errors'];
|
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 = {
|
const SERVICE_TOGGLE_PATCH_PATHS = {
|
||||||
heartbeat: 'automation.heartbeat.enabled',
|
heartbeat: 'automation.heartbeat.enabled',
|
||||||
daily_briefing: 'automation.daily_briefing.enabled',
|
daily_briefing: 'automation.daily_briefing.enabled',
|
||||||
@@ -372,6 +379,12 @@ function renderSkeleton(el) {
|
|||||||
<div id="ops-services" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div id="ops-services" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<div class="text-sm text-zinc-500">Loading...</div>
|
<div class="text-sm text-zinc-500">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Local LLM Backends</h2>
|
||||||
|
<div class="text-xs text-zinc-500 mb-2">User-level daemon status and controls for local providers.</div>
|
||||||
|
<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>
|
||||||
<div id="ops-service-config-modal-root"></div>
|
<div id="ops-service-config-modal-root"></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1406,6 +1419,7 @@ function updateAssistantHealth(configData) {
|
|||||||
_lastAssistantConfig = refreshed.config;
|
_lastAssistantConfig = refreshed.config;
|
||||||
}
|
}
|
||||||
updateServices(refreshed.services);
|
updateServices(refreshed.services);
|
||||||
|
updateLocalBackends(refreshed.localBackends);
|
||||||
updateSessionAnalytics(refreshed.sessionAnalytics);
|
updateSessionAnalytics(refreshed.sessionAnalytics);
|
||||||
updateContextHealth(refreshed.contextUsage);
|
updateContextHealth(refreshed.contextUsage);
|
||||||
// Only re-render assistant controls from a confirmed config snapshot.
|
// Only re-render assistant controls from a confirmed config snapshot.
|
||||||
@@ -1527,6 +1541,114 @@ function updateServices(servicesData) {
|
|||||||
renderServiceConfigModal();
|
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 = '<div class="text-sm text-zinc-500">No local backends detected</div>';
|
||||||
|
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
|
||||||
|
? `<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 = LOCAL_BACKEND_ACTION_LABELS[key] ?? key;
|
||||||
|
return `<button class="local-backend-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-backend-id="${escapeHtml(backendId)}"
|
||||||
|
data-action="${escapeHtml(key)}"
|
||||||
|
${isPending ? 'disabled' : ''}
|
||||||
|
title="${escapeHtml(`${label} ${backend.name ?? backendId}`)}">${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">
|
||||||
|
<div class="text-sm font-semibold text-zinc-50">${escapeHtml(String(backend.name ?? backendId))}</div>
|
||||||
|
<span class="text-xs uppercase ${toneClass}">${escapeHtml(String(backend.statusText ?? backend.activeState ?? 'unknown'))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">Unit: <span class="font-mono text-zinc-400">${escapeHtml(String(backend.unit ?? 'unknown'))}</span></div>
|
||||||
|
<div class="text-xs text-zinc-500">PID: <span class="font-mono text-zinc-400">${escapeHtml(pidText)}</span> · Load: <span class="font-mono text-zinc-400">${escapeHtml(loadText)}</span> · Result: <span class="font-mono text-zinc-400">${escapeHtml(resultText)}</span></div>
|
||||||
|
<div class="text-xs ${configuredClass}">${escapeHtml(configuredText)} · unit file: ${escapeHtml(unitFileText)}</div>
|
||||||
|
${backend.error ? `<div class="text-xs text-red-400">Error: ${escapeHtml(String(backend.error))}</div>` : ''}
|
||||||
|
${actionMessage}
|
||||||
|
<div class="flex flex-wrap gap-2">${actionButtons}</div>
|
||||||
|
</div>`;
|
||||||
|
}).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) {
|
function getConfigValue(path, fallbackValue) {
|
||||||
const value = getByPath(_lastAssistantConfig, path);
|
const value = getByPath(_lastAssistantConfig, path);
|
||||||
return value === undefined ? fallbackValue : value;
|
return value === undefined ? fallbackValue : value;
|
||||||
@@ -1697,6 +1819,7 @@ function renderServiceConfigModal() {
|
|||||||
const refreshed = await fetchSlow(_dashboardClient);
|
const refreshed = await fetchSlow(_dashboardClient);
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
updateServices(refreshed.services);
|
updateServices(refreshed.services);
|
||||||
|
updateLocalBackends(refreshed.localBackends);
|
||||||
updateSessionAnalytics(refreshed.sessionAnalytics);
|
updateSessionAnalytics(refreshed.sessionAnalytics);
|
||||||
updateContextHealth(refreshed.contextUsage);
|
updateContextHealth(refreshed.contextUsage);
|
||||||
if (refreshed.config) {
|
if (refreshed.config) {
|
||||||
@@ -1729,9 +1852,10 @@ async function fetchFast(client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSlow(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.health'),
|
||||||
client.call('system.services'),
|
client.call('system.services'),
|
||||||
|
client.call('system.localBackends'),
|
||||||
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
||||||
client.call('system.contextUsage'),
|
client.call('system.contextUsage'),
|
||||||
client.call('config.get'),
|
client.call('config.get'),
|
||||||
@@ -1749,6 +1873,7 @@ async function fetchSlow(client) {
|
|||||||
return {
|
return {
|
||||||
health: unwrap(health),
|
health: unwrap(health),
|
||||||
services: unwrap(services),
|
services: unwrap(services),
|
||||||
|
localBackends: unwrap(localBackends),
|
||||||
sessionAnalytics: unwrap(sessionAnalytics),
|
sessionAnalytics: unwrap(sessionAnalytics),
|
||||||
contextUsage: unwrap(contextUsage),
|
contextUsage: unwrap(contextUsage),
|
||||||
config: configValue,
|
config: configValue,
|
||||||
@@ -1785,6 +1910,9 @@ async function loadDashboard(el, client) {
|
|||||||
if (slow?.services) {
|
if (slow?.services) {
|
||||||
updateServices(slow.services);
|
updateServices(slow.services);
|
||||||
}
|
}
|
||||||
|
if (slow?.localBackends) {
|
||||||
|
updateLocalBackends(slow.localBackends);
|
||||||
|
}
|
||||||
if (slow?.sessionAnalytics) {
|
if (slow?.sessionAnalytics) {
|
||||||
updateSessionAnalytics(slow.sessionAnalytics);
|
updateSessionAnalytics(slow.sessionAnalytics);
|
||||||
}
|
}
|
||||||
@@ -1820,6 +1948,9 @@ async function loadDashboard(el, client) {
|
|||||||
if (data.services) {
|
if (data.services) {
|
||||||
updateServices(data.services);
|
updateServices(data.services);
|
||||||
}
|
}
|
||||||
|
if (data.localBackends) {
|
||||||
|
updateLocalBackends(data.localBackends);
|
||||||
|
}
|
||||||
if (data.sessionAnalytics) {
|
if (data.sessionAnalytics) {
|
||||||
updateSessionAnalytics(data.sessionAnalytics);
|
updateSessionAnalytics(data.sessionAnalytics);
|
||||||
}
|
}
|
||||||
@@ -1857,5 +1988,18 @@ export const DashboardPage = {
|
|||||||
_assistantModelDefaultsDraft = null;
|
_assistantModelDefaultsDraft = null;
|
||||||
_assistantDraftState = new Map();
|
_assistantDraftState = new Map();
|
||||||
_assistantDraftTouchedAt = 0;
|
_assistantDraftTouchedAt = 0;
|
||||||
|
_lastServices = [];
|
||||||
|
_lastLocalBackends = [];
|
||||||
|
_localBackendActionState = new Map();
|
||||||
|
_serviceConfigState = {
|
||||||
|
open: false,
|
||||||
|
serviceName: null,
|
||||||
|
status: null,
|
||||||
|
tone: 'neutral',
|
||||||
|
advancedPatch: '',
|
||||||
|
};
|
||||||
|
_lastCouncilTask = '';
|
||||||
|
_lastCouncilResult = null;
|
||||||
|
_lastCouncilError = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,6 +91,40 @@ function createInitialConfig() {
|
|||||||
function createMockClient() {
|
function createMockClient() {
|
||||||
const state = {
|
const state = {
|
||||||
config: createInitialConfig(),
|
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<string, unknown> }>,
|
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +158,9 @@ function createMockClient() {
|
|||||||
if (method === 'system.services') {
|
if (method === 'system.services') {
|
||||||
return { services: [] };
|
return { services: [] };
|
||||||
}
|
}
|
||||||
|
if (method === 'system.localBackends') {
|
||||||
|
return { backends: deepClone(state.localBackends) };
|
||||||
|
}
|
||||||
if (method === 'system.sessionAnalytics') {
|
if (method === 'system.sessionAnalytics') {
|
||||||
return {
|
return {
|
||||||
daily: [],
|
daily: [],
|
||||||
@@ -177,6 +214,36 @@ function createMockClient() {
|
|||||||
}
|
}
|
||||||
return { success: true, output: '' };
|
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;
|
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 === 'council.run')).toBe(true);
|
||||||
expect(toolCalls.some((entry) => entry.params?.tool === 'cron.trigger')).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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user