feat(gateway): add system.services and dashboard services grid
This commit is contained in:
+17
-1
@@ -59,6 +59,22 @@
|
|||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/daemon/routing.test.ts + pnpm typecheck passing"
|
"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": {
|
"p0-p1-implementation-plan": {
|
||||||
"file": "2026-02-06-p0-p1-implementation-plan.md",
|
"file": "2026-02-06-p0-p1-implementation-plan.md",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -1875,7 +1891,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1625,
|
"total_test_count": 1629,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
|
|||||||
@@ -40,6 +40,30 @@ describe('system handlers', () => {
|
|||||||
expect(typeof r.uptime).toBe('number');
|
expect(typeof r.uptime).toBe('number');
|
||||||
expect(r.uptime).toBeGreaterThanOrEqual(59);
|
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', () => {
|
describe('system.tokenUsage handler', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { createSystemHandlers } from './system.js';
|
export { createSystemHandlers } from './system.js';
|
||||||
export type { SystemHandlerDeps, TokenUsageEntry } from './system.js';
|
export type { SystemHandlerDeps, TokenUsageEntry } from './system.js';
|
||||||
|
export type { ServiceInfo, ServiceType, ServiceStatus } from './services.js';
|
||||||
export { createSessionHandlers } from './sessions.js';
|
export { createSessionHandlers } from './sessions.js';
|
||||||
export type { SessionHandlerDeps } from './sessions.js';
|
export type { SessionHandlerDeps } from './sessions.js';
|
||||||
export { createToolHandlers } from './tools.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 type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||||
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||||
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
||||||
|
import type { ServiceInfo } from './services.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 {
|
||||||
@@ -28,6 +29,8 @@ export interface SystemHandlerDeps {
|
|||||||
getEvents?: (opts?: { level?: string; limit?: number }) => EventEntry[];
|
getEvents?: (opts?: { level?: string; limit?: number }) => EventEntry[];
|
||||||
/** Optional callback to retrieve active requests. */
|
/** Optional callback to retrieve active requests. */
|
||||||
getActiveRequests?: () => ActiveRequestInfo[];
|
getActiveRequests?: () => ActiveRequestInfo[];
|
||||||
|
/** Optional callback to retrieve all services (channels + automation). */
|
||||||
|
getServices?: () => ServiceInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||||
@@ -68,6 +71,13 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
|||||||
return makeResponse(request.id, { channels: deps.getChannels() });
|
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> => {
|
'system.usage': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
|
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
|
||||||
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
|
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
createRoutingHandlers,
|
createRoutingHandlers,
|
||||||
createHistoryHandlers,
|
createHistoryHandlers,
|
||||||
} from './handlers/index.js';
|
} from './handlers/index.js';
|
||||||
|
import { discoverServices } from './handlers/services.js';
|
||||||
import type { TokenUsageEntry } from './handlers/system.js';
|
import type { TokenUsageEntry } from './handlers/system.js';
|
||||||
import type { SessionManager } from '../session/manager.js';
|
import type { SessionManager } from '../session/manager.js';
|
||||||
import type { Config } from '../config/index.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 { CommandRegistry } from '../commands/index.js';
|
||||||
import type { ComponentRegistry } from '../intents/index.js';
|
import type { ComponentRegistry } from '../intents/index.js';
|
||||||
import type { RoutingPolicy } from '../routing/index.js';
|
import type { RoutingPolicy } from '../routing/index.js';
|
||||||
|
import type { ChannelRegistry } from '../channels/index.js';
|
||||||
|
|
||||||
export interface GatewayServerConfig {
|
export interface GatewayServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -58,7 +60,7 @@ export interface GatewayServerConfig {
|
|||||||
config?: Config;
|
config?: Config;
|
||||||
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
||||||
restart?: () => Promise<void>;
|
restart?: () => Promise<void>;
|
||||||
channelRegistry?: { list(): Array<{ readonly name: string; readonly status: string }> };
|
channelRegistry?: ChannelRegistry;
|
||||||
/** Optional webhook handler for inbound webhook HTTP routes. */
|
/** Optional webhook handler for inbound webhook HTTP routes. */
|
||||||
webhookHandler?: WebhookHandler;
|
webhookHandler?: WebhookHandler;
|
||||||
/** Optional Gmail handler for Pub/Sub push notifications. */
|
/** Optional Gmail handler for Pub/Sub push notifications. */
|
||||||
@@ -116,6 +118,9 @@ export class GatewayServer {
|
|||||||
getChannels: this.config.channelRegistry
|
getChannels: this.config.channelRegistry
|
||||||
? () => this.config.channelRegistry!.list().map(a => ({ name: a.name, status: a.status }))
|
? () => this.config.channelRegistry!.list().map(a => ({ name: a.name, status: a.status }))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
getServices: this.config.config && this.config.channelRegistry
|
||||||
|
? () => discoverServices(this.config.config!, this.config.channelRegistry!)
|
||||||
|
: undefined,
|
||||||
getUsage: () => ({
|
getUsage: () => ({
|
||||||
totalSessions: this.config.sessionManager.listSessions().length,
|
totalSessions: this.config.sessionManager.listSessions().length,
|
||||||
activeConnections: this.sessionBridge.connectionCount,
|
activeConnections: this.sessionBridge.connectionCount,
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ function renderSkeleton(el) {
|
|||||||
<div class="text-muted text-sm">Loading...</div>
|
<div class="text-muted text-sm">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title">Channels</h2>
|
<h2 class="section-title">Services</h2>
|
||||||
<div id="ops-channels" class="channels-grid">
|
<div id="ops-services" class="services-grid">
|
||||||
<div class="text-muted text-sm">Loading...</div>
|
<div class="text-muted text-sm">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -236,6 +236,38 @@ function updateChannels(channelsData) {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateServices(servicesData) {
|
||||||
|
const el = document.getElementById('ops-services');
|
||||||
|
if (!el) {return;}
|
||||||
|
|
||||||
|
const services = servicesData?.services ?? [];
|
||||||
|
|
||||||
|
if (services.length === 0) {
|
||||||
|
el.innerHTML = '<div class="text-muted text-sm">No services configured</div>';
|
||||||
|
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 `<div class="service-card service-${statusClass}">
|
||||||
|
<span class="service-type-icon">${typeIcon}</span>
|
||||||
|
<span class="service-name">${escapeHtml(svc.name)}${itemCount}</span>
|
||||||
|
<span class="service-status">${escapeHtml(svc.status)}</span>
|
||||||
|
<span class="service-description text-muted text-xs">${escapeHtml(svc.description)}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Data fetching ───────────────────────────────────────────────
|
// ── Data fetching ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchFast(client) {
|
async function fetchFast(client) {
|
||||||
@@ -253,11 +285,11 @@ async function fetchFast(client) {
|
|||||||
|
|
||||||
async function fetchSlow(client) {
|
async function fetchSlow(client) {
|
||||||
try {
|
try {
|
||||||
const [health, channels] = await Promise.all([
|
const [health, services] = await Promise.all([
|
||||||
client.call('system.health'),
|
client.call('system.health'),
|
||||||
client.call('system.channels'),
|
client.call('system.services'),
|
||||||
]);
|
]);
|
||||||
return { health, channels };
|
return { health, services };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -287,7 +319,7 @@ async function loadDashboard(el, client) {
|
|||||||
updateActiveRequests(fast.requestsData);
|
updateActiveRequests(fast.requestsData);
|
||||||
}
|
}
|
||||||
if (slow) {
|
if (slow) {
|
||||||
updateChannels(slow.channels);
|
updateServices(slow.services);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast refresh: 3 seconds for metrics, events, requests
|
// Fast refresh: 3 seconds for metrics, events, requests
|
||||||
@@ -302,13 +334,13 @@ async function loadDashboard(el, client) {
|
|||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
// Slow refresh: 10 seconds for health, channels
|
// Slow refresh: 10 seconds for health, services
|
||||||
_slowTimer = setInterval(async () => {
|
_slowTimer = setInterval(async () => {
|
||||||
const data = await fetchSlow(client);
|
const data = await fetchSlow(client);
|
||||||
if (data) {
|
if (data) {
|
||||||
_lastHealth = data.health;
|
_lastHealth = data.health;
|
||||||
updateCounters(_lastMetrics, _lastHealth);
|
updateCounters(_lastMetrics, data.health);
|
||||||
updateChannels(data.channels);
|
updateServices(data.services);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -771,6 +771,82 @@ header #status.status-ok {
|
|||||||
text-transform: capitalize;
|
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 Headers ────────────────────────────────────────── */
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
|
|||||||
Reference in New Issue
Block a user