diff --git a/docs/plans/state.json b/docs/plans/state.json index ad365c3..2cf02c4 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -59,6 +59,22 @@ ], "test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/daemon/routing.test.ts + pnpm typecheck passing" }, + "gateway-services-dashboard": { + "status": "completed", + "date": "2026-02-14", + "summary": "Added a system.services RPC that returns a unified list of channel adapters plus automation subsystems (configured vs connected), and updated the web UI dashboard to render these as a Services grid.", + "files_modified": [ + "src/gateway/handlers/services.ts", + "src/gateway/handlers/services.test.ts", + "src/gateway/handlers/system.ts", + "src/gateway/handlers/index.ts", + "src/gateway/handlers/handlers.test.ts", + "src/gateway/server.ts", + "src/gateway/ui/pages/dashboard.js", + "src/gateway/ui/style.css" + ], + "test_status": "pnpm test:run src/gateway/handlers/services.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" + }, "p0-p1-implementation-plan": { "file": "2026-02-06-p0-p1-implementation-plan.md", "status": "completed", @@ -1875,7 +1891,7 @@ }, "overall_progress": { - "total_test_count": 1625, + "total_test_count": 1629, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 1cbf0c2..9a2daca 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -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', () => { diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index fbf178d..40a6d1f 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -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'; diff --git a/src/gateway/handlers/services.test.ts b/src/gateway/handlers/services.test.ts new file mode 100644 index 0000000..cf2b3a1 --- /dev/null +++ b/src/gateway/handlers/services.test.ts @@ -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 }); + }); +}); + diff --git a/src/gateway/handlers/services.ts b/src/gateway/handlers/services.ts new file mode 100644 index 0000000..ee7e9e4 --- /dev/null +++ b/src/gateway/handlers/services.ts @@ -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; +} + +/** + * 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(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; +} diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index bb15381..7951d4c 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -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 => { + if (!deps.getServices) { + return makeResponse(request.id, { services: [] }); + } + return makeResponse(request.id, { services: deps.getServices() }); + }, + 'system.usage': async (request: GatewayRequest): Promise => { const uptime = Math.floor((Date.now() - deps.startTime) / 1000); const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 }; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 4dfcf84..40b5d01 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -27,6 +27,7 @@ import { createRoutingHandlers, createHistoryHandlers, } from './handlers/index.js'; +import { discoverServices } from './handlers/services.js'; import type { TokenUsageEntry } from './handlers/system.js'; import type { SessionManager } from '../session/manager.js'; import type { Config } from '../config/index.js'; @@ -39,6 +40,7 @@ import type { MemoryStore } from '../memory/store.js'; import type { CommandRegistry } from '../commands/index.js'; import type { ComponentRegistry } from '../intents/index.js'; import type { RoutingPolicy } from '../routing/index.js'; +import type { ChannelRegistry } from '../channels/index.js'; export interface GatewayServerConfig { port: number; @@ -58,7 +60,7 @@ export interface GatewayServerConfig { config?: Config; /** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */ restart?: () => Promise; - channelRegistry?: { list(): Array<{ readonly name: string; readonly status: string }> }; + channelRegistry?: ChannelRegistry; /** Optional webhook handler for inbound webhook HTTP routes. */ webhookHandler?: WebhookHandler; /** Optional Gmail handler for Pub/Sub push notifications. */ @@ -116,6 +118,9 @@ export class GatewayServer { getChannels: this.config.channelRegistry ? () => this.config.channelRegistry!.list().map(a => ({ name: a.name, status: a.status })) : undefined, + getServices: this.config.config && this.config.channelRegistry + ? () => discoverServices(this.config.config!, this.config.channelRegistry!) + : undefined, getUsage: () => ({ totalSessions: this.config.sessionManager.listSessions().length, activeConnections: this.sessionBridge.connectionCount, diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js index 8165c0a..0b433fc 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -65,8 +65,8 @@ function renderSkeleton(el) {
Loading...
-

Channels

-
+

Services

+
Loading...
`; @@ -236,6 +236,38 @@ function updateChannels(channelsData) { ).join(''); } +function updateServices(servicesData) { + const el = document.getElementById('ops-services'); + if (!el) {return;} + + const services = servicesData?.services ?? []; + + if (services.length === 0) { + el.innerHTML = '
No services configured
'; + return; + } + + el.innerHTML = services.map(svc => { + const typeIcon = svc.type === 'channel' ? '📡' : svc.type === 'automation' ? '⚙️' : '🔧'; + const statusClass = svc.status === 'connected' + ? 'connected' + : svc.status === 'configured' + ? 'configured' + : svc.status === 'error' + ? 'error' + : svc.status === 'not_configured' + ? 'not-configured' + : 'disconnected'; + const itemCount = svc.itemCount ? ` (${svc.itemCount})` : ''; + return `
+ ${typeIcon} + ${escapeHtml(svc.name)}${itemCount} + ${escapeHtml(svc.status)} + ${escapeHtml(svc.description)} +
`; + }).join(''); +} + // ── Data fetching ─────────────────────────────────────────────── async function fetchFast(client) { @@ -253,11 +285,11 @@ async function fetchFast(client) { async function fetchSlow(client) { try { - const [health, channels] = await Promise.all([ + const [health, services] = await Promise.all([ client.call('system.health'), - client.call('system.channels'), + client.call('system.services'), ]); - return { health, channels }; + return { health, services }; } catch { return null; } @@ -287,7 +319,7 @@ async function loadDashboard(el, client) { updateActiveRequests(fast.requestsData); } if (slow) { - updateChannels(slow.channels); + updateServices(slow.services); } // Fast refresh: 3 seconds for metrics, events, requests @@ -302,13 +334,13 @@ async function loadDashboard(el, client) { } }, 3000); - // Slow refresh: 10 seconds for health, channels + // Slow refresh: 10 seconds for health, services _slowTimer = setInterval(async () => { const data = await fetchSlow(client); if (data) { _lastHealth = data.health; - updateCounters(_lastMetrics, _lastHealth); - updateChannels(data.channels); + updateCounters(_lastMetrics, data.health); + updateServices(data.services); } }, 10000); } diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css index 0180e66..904717c 100644 --- a/src/gateway/ui/style.css +++ b/src/gateway/ui/style.css @@ -771,6 +771,82 @@ header #status.status-ok { text-transform: capitalize; } +/* ── Services Grid ──────────────────────────────────────────── */ + +.services-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + margin-bottom: 32px; +} + +.service-card { + background-color: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + border-left: 3px solid var(--border); +} + +.service-card.service-connected { + border-left-color: var(--success); +} + +.service-card.service-configured { + border-left-color: var(--accent); +} + +.service-card.service-error { + border-left-color: var(--error); +} + +.service-card.service-not-configured { + border-left-color: var(--text-muted); + opacity: 0.6; +} + +.service-type-icon { + font-size: var(--font-size-base); + flex-shrink: 0; +} + +.service-name { + font-weight: 600; + color: var(--text-primary); + text-transform: capitalize; +} + +.service-status { + font-size: var(--font-size-sm); + color: var(--text-muted); + text-transform: uppercase; +} + +.service-card.service-connected .service-status { + color: var(--success); +} + +.service-card.service-configured .service-status { + color: var(--accent); +} + +.service-card.service-error .service-status { + color: var(--error); +} + +.service-card.service-not-configured .service-status { + color: var(--text-muted); + font-style: italic; +} + +.service-description { + font-size: var(--font-size-sm); + color: var(--text-muted); +} + /* ── Section Headers ────────────────────────────────────────── */ .section-title {