feat(gateway): add system.services and dashboard services grid
This commit is contained in:
@@ -40,6 +40,30 @@ describe('system handlers', () => {
|
||||
expect(typeof r.uptime).toBe('number');
|
||||
expect(r.uptime).toBeGreaterThanOrEqual(59);
|
||||
});
|
||||
|
||||
it('system.services returns empty list when getServices is not provided', async () => {
|
||||
const req: GatewayRequest = { id: 2, method: 'system.services' };
|
||||
const result = await handlers['system.services'](req) as GatewayResponse;
|
||||
expect(result.id).toBe(2);
|
||||
expect((result.result as any).services).toEqual([]);
|
||||
});
|
||||
|
||||
it('system.services returns services from getServices callback', async () => {
|
||||
const handlers = createSystemHandlers({
|
||||
...deps,
|
||||
getServices: () => ([
|
||||
{ name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' },
|
||||
{ name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 },
|
||||
] as any),
|
||||
});
|
||||
|
||||
const req: GatewayRequest = { id: 3, method: 'system.services' };
|
||||
const result = await handlers['system.services'](req) as GatewayResponse;
|
||||
expect((result.result as any).services).toEqual([
|
||||
{ name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' },
|
||||
{ name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('system.tokenUsage handler', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { createSystemHandlers } from './system.js';
|
||||
export type { SystemHandlerDeps, TokenUsageEntry } from './system.js';
|
||||
export type { ServiceInfo, ServiceType, ServiceStatus } from './services.js';
|
||||
export { createSessionHandlers } from './sessions.js';
|
||||
export type { SessionHandlerDeps } from './sessions.js';
|
||||
export { createToolHandlers } from './tools.js';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { discoverServices } from './services.js';
|
||||
import { ChannelRegistry } from '../../channels/registry.js';
|
||||
import type { Config } from '../../config/index.js';
|
||||
|
||||
function makeBaseConfig(): Config {
|
||||
return {
|
||||
server: { localhost: true, port: 18800 },
|
||||
models: { default: { provider: 'anthropic', model: 'claude-sonnet-4', api_key: 'sk-test' }, fallback_chain: ['anthropic'] },
|
||||
backends: { native: { enabled: true }, opencode: { enabled: false }, claude_code: { enabled: false } },
|
||||
hooks: { confirm: [], log: [], silent: [] },
|
||||
mcp: { servers: [] },
|
||||
automation: {
|
||||
cron: [],
|
||||
webhooks: [],
|
||||
gmail: undefined,
|
||||
heartbeat: undefined,
|
||||
gcal: undefined,
|
||||
gdocs: undefined,
|
||||
gdrive: undefined,
|
||||
gtasks: undefined,
|
||||
},
|
||||
telegram: undefined,
|
||||
discord: undefined,
|
||||
slack: undefined,
|
||||
whatsapp: undefined,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('discoverServices', () => {
|
||||
it('includes known services and marks not_configured when disabled', () => {
|
||||
const cfg = makeBaseConfig();
|
||||
const reg = new ChannelRegistry();
|
||||
|
||||
const services = discoverServices(cfg, reg);
|
||||
|
||||
expect(services).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'telegram', status: 'not_configured' }),
|
||||
expect.objectContaining({ name: 'cron', status: 'not_configured' }),
|
||||
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('marks configured channels as disconnected when adapter is not registered', () => {
|
||||
const cfg = makeBaseConfig();
|
||||
(cfg as any).telegram = { bot_token: 'x', allowed_chat_ids: [123] };
|
||||
|
||||
const reg = new ChannelRegistry();
|
||||
const services = discoverServices(cfg, reg);
|
||||
|
||||
expect(services.find(s => s.name === 'telegram')?.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('uses adapter status when channel adapter is registered', () => {
|
||||
const cfg = makeBaseConfig();
|
||||
(cfg as any).telegram = { bot_token: 'x', allowed_chat_ids: [123] };
|
||||
|
||||
const reg = new ChannelRegistry();
|
||||
reg.register({
|
||||
name: 'telegram',
|
||||
status: 'connected',
|
||||
connect: async () => {},
|
||||
disconnect: async () => {},
|
||||
send: async () => {},
|
||||
onMessage: () => {},
|
||||
});
|
||||
|
||||
const services = discoverServices(cfg, reg);
|
||||
expect(services.find(s => s.name === 'telegram')?.status).toBe('connected');
|
||||
});
|
||||
|
||||
it('marks enabled automation subsystems as configured and carries item counts', () => {
|
||||
const cfg = makeBaseConfig();
|
||||
cfg.automation.cron = [
|
||||
{ name: 'job', schedule: '0 0 * * *', message: 'hi', output: { channel: 'webchat', peer: 'x' }, enabled: true },
|
||||
] as any;
|
||||
cfg.mcp.servers = [{ name: 'srv', command: 'x', args: [] }] as any;
|
||||
|
||||
const reg = new ChannelRegistry();
|
||||
const services = discoverServices(cfg, reg);
|
||||
|
||||
expect(services.find(s => s.name === 'cron')?.status).toBe('configured');
|
||||
expect(services.find(s => s.name === 'cron')?.itemCount).toBe(1);
|
||||
expect(services.find(s => s.name === 'mcp')?.metadata).toEqual({ serverCount: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Service discovery helpers for the Web UI dashboard.
|
||||
*
|
||||
* The gateway can surface a single "services" list so operators can see:
|
||||
* - which channel adapters are configured vs connected
|
||||
* - which automation subsystems are enabled (cron, webhooks, gmail, etc.)
|
||||
*/
|
||||
|
||||
import type { ChannelRegistry } from '../../channels/index.js';
|
||||
import type { Config } from '../../config/index.js';
|
||||
|
||||
export type ServiceType = 'channel' | 'automation' | 'tool';
|
||||
|
||||
// Channel adapters expose: disconnected|connecting|connected|error.
|
||||
// For config-driven subsystems we additionally use:
|
||||
// - configured: enabled in config, runtime status not tracked at this layer
|
||||
// - not_configured: disabled / missing config
|
||||
export type ServiceStatus =
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'error'
|
||||
| 'configured'
|
||||
| 'not_configured';
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string;
|
||||
type: ServiceType;
|
||||
status: ServiceStatus;
|
||||
description: string;
|
||||
itemCount?: number;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all services from config and channel registry.
|
||||
*
|
||||
* Returns configured channels and detects potential services from config
|
||||
* that may not yet be registered (e.g., gcal configured but no adapter created).
|
||||
*/
|
||||
export function discoverServices(
|
||||
config: Config,
|
||||
channelRegistry: ChannelRegistry,
|
||||
): ServiceInfo[] {
|
||||
const services: ServiceInfo[] = [];
|
||||
|
||||
const registeredChannels = channelRegistry.list();
|
||||
|
||||
const channelConfigs: Array<{ key: keyof Config; name: string; description: string }> = [
|
||||
{ key: 'telegram', name: 'telegram', description: 'Telegram bot' },
|
||||
{ key: 'discord', name: 'discord', description: 'Discord bot' },
|
||||
{ key: 'slack', name: 'slack', description: 'Slack app' },
|
||||
{ key: 'whatsapp', name: 'whatsapp', description: 'WhatsApp gateway' },
|
||||
];
|
||||
|
||||
for (const { key, name, description } of channelConfigs) {
|
||||
const configured = Boolean((config as unknown as Record<string, unknown>)[String(key)]);
|
||||
const registered = registeredChannels.find(c => c.name === name);
|
||||
|
||||
if (!configured) {
|
||||
services.push({ name, type: 'channel', status: 'not_configured', description });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (registered) {
|
||||
services.push({ name, type: 'channel', status: registered.status as ServiceStatus, description });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Config present but adapter not registered (or gateway running without it).
|
||||
services.push({ name, type: 'channel', status: 'disconnected', description });
|
||||
}
|
||||
|
||||
const automation = config.automation;
|
||||
|
||||
const automationConfigs: Array<{ enabled: boolean; name: string; description: string; itemCount?: number }> = [
|
||||
{ enabled: automation.cron.length > 0, name: 'cron', description: 'Cron scheduler', itemCount: automation.cron.length },
|
||||
{ enabled: automation.webhooks.length > 0, name: 'webhooks', description: 'Webhook handler', itemCount: automation.webhooks.length },
|
||||
{ enabled: automation.gmail?.enabled ?? false, name: 'gmail', description: 'Gmail watcher' },
|
||||
{ enabled: automation.heartbeat?.enabled ?? false, name: 'heartbeat', description: 'Heartbeat monitor' },
|
||||
{ enabled: automation.gcal?.enabled ?? false, name: 'gcal', description: 'Google Calendar' },
|
||||
{ enabled: automation.gdocs?.enabled ?? false, name: 'gdocs', description: 'Google Docs' },
|
||||
{ enabled: automation.gdrive?.enabled ?? false, name: 'gdrive', description: 'Google Drive' },
|
||||
{ enabled: automation.gtasks?.enabled ?? false, name: 'gtasks', description: 'Google Tasks' },
|
||||
];
|
||||
|
||||
for (const auto of automationConfigs) {
|
||||
services.push({
|
||||
name: auto.name,
|
||||
type: 'automation',
|
||||
status: auto.enabled ? 'configured' : 'not_configured',
|
||||
description: auto.description,
|
||||
...(auto.itemCount !== undefined ? { itemCount: auto.itemCount } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
services.push({
|
||||
name: 'mcp',
|
||||
type: 'automation',
|
||||
status: config.mcp.servers.length > 0 ? 'configured' : 'not_configured',
|
||||
description: 'MCP servers',
|
||||
metadata: { serverCount: config.mcp.servers.length },
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
||||
import type { ServiceInfo } from './services.js';
|
||||
|
||||
/** Per-session token usage report returned by system.tokenUsage. */
|
||||
export interface TokenUsageEntry {
|
||||
@@ -28,6 +29,8 @@ export interface SystemHandlerDeps {
|
||||
getEvents?: (opts?: { level?: string; limit?: number }) => EventEntry[];
|
||||
/** Optional callback to retrieve active requests. */
|
||||
getActiveRequests?: () => ActiveRequestInfo[];
|
||||
/** Optional callback to retrieve all services (channels + automation). */
|
||||
getServices?: () => ServiceInfo[];
|
||||
}
|
||||
|
||||
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
@@ -68,6 +71,13 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
return makeResponse(request.id, { channels: deps.getChannels() });
|
||||
},
|
||||
|
||||
'system.services': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.getServices) {
|
||||
return makeResponse(request.id, { services: [] });
|
||||
}
|
||||
return makeResponse(request.id, { services: deps.getServices() });
|
||||
},
|
||||
|
||||
'system.usage': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
|
||||
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
|
||||
|
||||
Reference in New Issue
Block a user