diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js
index 3595180..b96c967 100644
--- a/src/gateway/ui/pages/dashboard.js
+++ b/src/gateway/ui/pages/dashboard.js
@@ -1,11 +1,12 @@
/**
- * Flynn Dashboard Page
+ * Flynn Live Ops Dashboard
*
- * Shows system health cards, channel status, and usage stats.
- * Auto-refreshes every 10 seconds.
+ * Shows core counters, model performance, event stream, active requests,
+ * and channel status. Fast metrics refresh every 3s, slow health every 10s.
*/
-let _timer = null;
+let _fastTimer = null;
+let _slowTimer = null;
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -20,91 +21,313 @@ function formatUptime(seconds) {
return parts.join(' ');
}
-async function loadDashboard(el, client) {
- let health, channels, usage;
+function timeAgo(timestamp) {
+ const secs = Math.floor((Date.now() - timestamp) / 1000);
+ if (secs < 60) return `${secs}s ago`;
+ if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
+ return `${Math.floor(secs / 3600)}h ago`;
+}
- try {
- [health, channels, usage] = await Promise.all([
- client.call('system.health'),
- client.call('system.channels'),
- client.call('system.usage'),
- ]);
- } catch (err) {
- el.innerHTML = `
Failed to load dashboard: ${err.message}
`;
+function formatTime(timestamp) {
+ const d = new Date(timestamp);
+ return d.toLocaleTimeString('en-GB', { hour12: false });
+}
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+// ── Initial full render ─────────────────────────────────────────
+
+function renderSkeleton(el) {
+ el.innerHTML = `
+ Live Ops Dashboard
+
+ Core Counters
+
+
+ Model Performance
+
+
+ Event Stream
+
+
+ Active Requests
+
+
+ Channels
+
+ `;
+}
+
+// ── Section updaters (targeted DOM updates) ─────────────────────
+
+function updateCounters(metrics, health) {
+ const el = document.getElementById('ops-counters');
+ if (!el) return;
+
+ const sessions = health?.sessions ?? 0;
+ const errCount = metrics?.errors ?? 0;
+
+ const cards = [
+ { label: 'Messages Processed', value: String(metrics?.messagesProcessed ?? 0), cls: '' },
+ { label: 'Active Sessions', value: String(sessions), cls: '' },
+ { label: 'Queue Depth', value: String(metrics?.queueDepth ?? 0), cls: '' },
+ { label: 'Uptime', value: formatUptime(metrics?.uptime ?? 0), cls: '' },
+ { label: 'Active Requests', value: String(metrics?.activeRequests ?? 0), cls: '' },
+ { label: 'Errors', value: String(errCount), cls: errCount > 0 ? 'error' : '' },
+ ];
+
+ el.innerHTML = cards.map(c =>
+ `
+
${c.label}
+
${c.value}
+
`
+ ).join('');
+}
+
+function updateModelTable(metrics) {
+ const el = document.getElementById('ops-model-table');
+ if (!el) return;
+
+ const mc = metrics?.modelCalls;
+ const calls = mc?.recentCalls ?? [];
+
+ if (calls.length === 0) {
+ el.innerHTML = 'No model calls recorded yet
';
return;
}
- // Build stats grid
- const stats = [
- { label: 'Status', value: health.status?.toUpperCase() ?? 'UNKNOWN', cls: health.status === 'ok' ? 'ok' : 'error' },
- { label: 'Version', value: health.version ?? '-', cls: '' },
- { label: 'Uptime', value: formatUptime(health.uptime ?? 0), cls: '' },
- { label: 'Connections', value: String(health.connections ?? 0), cls: '' },
- { label: 'Sessions', value: String(health.sessions ?? 0), cls: '' },
- { label: 'Tools', value: String(health.tools ?? 0), cls: '' },
- ];
+ const totalCalls = mc.total ?? 0;
+ const avgLatency = mc.avgLatency ?? 0;
+ const errorRate = mc.errorRate ?? 0;
- const statsHtml = stats.map(s =>
- `
-
${s.label}
-
${s.value}
-
`
- ).join('');
+ const summaryHtml = `
+
+
Total Calls: ${totalCalls}
+
Avg Latency: ${avgLatency}ms
+
Error Rate: ${(errorRate * 100).toFixed(2)}%
+
+ `;
- // Build channels grid
- const channelList = channels?.channels ?? [];
- let channelsHtml = '';
- if (channelList.length > 0) {
- channelsHtml = channelList.map(ch =>
- `
-
- ${ch.name}
-
`
- ).join('');
- } else {
- channelsHtml = 'No channels registered
';
- }
-
- // Build usage section
- const usageItems = [
- { label: 'Total Sessions', value: String(usage?.totalSessions ?? 0) },
- { label: 'Active Connections', value: String(usage?.activeConnections ?? 0) },
- { label: 'Available Tools', value: String(usage?.tools ?? 0) },
- { label: 'Uptime', value: formatUptime(usage?.uptime ?? 0) },
- ];
-
- const usageHtml = usageItems.map(u =>
- `
-
${u.label}
-
${u.value}
-
`
- ).join('');
+ // Show newest first
+ const rows = [...calls].reverse().map(c => {
+ const status = c.error ? '✗' : '✓';
+ return `
+ | ${timeAgo(c.timestamp)} |
+ ${escapeHtml(c.provider)} |
+ ${c.latency}ms |
+ ${c.tokensPerSec.toFixed(1)} |
+ ${c.inputTokens}/${c.outputTokens} |
+ ${status} |
+
`;
+ }).join('');
el.innerHTML = `
- Dashboard
- System Health
- ${statsHtml}
- Channels
- ${channelsHtml}
- Usage
- ${usageHtml}
+ ${summaryHtml}
+
+
+
+ | Time |
+ Provider |
+ Latency |
+ Tokens/sec |
+ In/Out |
+ Status |
+
+
+ ${rows}
+
`;
}
+function updateEvents(eventsData) {
+ const el = document.getElementById('ops-events');
+ if (!el) return;
+
+ const events = eventsData?.events ?? [];
+
+ if (events.length === 0) {
+ el.innerHTML = 'No events recorded yet
';
+ return;
+ }
+
+ // Events come newest-first from the API; show newest at bottom for log feel
+ const reversed = [...events].reverse();
+
+ el.innerHTML = reversed.map(e => {
+ const time = formatTime(e.timestamp);
+ const level = (e.level || 'info').toUpperCase();
+ const cls = `event-level-${e.level || 'info'}`;
+ return `[${time}] [${level}] ${escapeHtml(e.source)}: ${escapeHtml(e.message)}
`;
+ }).join('');
+
+ // Auto-scroll to bottom
+ el.scrollTop = el.scrollHeight;
+}
+
+function updateActiveRequests(requestsData) {
+ const el = document.getElementById('ops-requests');
+ if (!el) return;
+
+ const requests = requestsData?.requests ?? [];
+
+ if (requests.length === 0) {
+ el.innerHTML = 'No active requests
';
+ return;
+ }
+
+ const rows = requests.map(r => {
+ const duration = r.durationMs < 1000
+ ? `${r.durationMs}ms`
+ : `${(r.durationMs / 1000).toFixed(1)}s`;
+ const started = formatTime(r.startedAt);
+ return `
+ | ${escapeHtml(r.sessionId)} |
+ ${escapeHtml(r.channel)} |
+ ${duration} |
+ ${started} |
+
`;
+ }).join('');
+
+ el.innerHTML = `
+
+
+
+ | Session |
+ Channel |
+ Duration |
+ Started |
+
+
+ ${rows}
+
+ `;
+}
+
+function updateChannels(channelsData) {
+ const el = document.getElementById('ops-channels');
+ if (!el) return;
+
+ const channels = channelsData?.channels ?? [];
+
+ if (channels.length === 0) {
+ el.innerHTML = 'No channels registered
';
+ return;
+ }
+
+ el.innerHTML = channels.map(ch =>
+ `
+
+ ${escapeHtml(ch.name)}
+
`
+ ).join('');
+}
+
+// ── Data fetching ───────────────────────────────────────────────
+
+async function fetchFast(client) {
+ try {
+ const [metrics, eventsData, requestsData] = await Promise.all([
+ client.call('system.metrics'),
+ client.call('system.events', { limit: 50 }),
+ client.call('system.activeRequests'),
+ ]);
+ return { metrics, eventsData, requestsData };
+ } catch {
+ return null;
+ }
+}
+
+async function fetchSlow(client) {
+ try {
+ const [health, channels] = await Promise.all([
+ client.call('system.health'),
+ client.call('system.channels'),
+ ]);
+ return { health, channels };
+ } catch {
+ return null;
+ }
+}
+
+// ── Main load function ──────────────────────────────────────────
+
+let _lastHealth = null;
+let _lastMetrics = null;
+
+async function loadDashboard(el, client) {
+ renderSkeleton(el);
+
+ // Fetch everything initially
+ const [fast, slow] = await Promise.all([
+ fetchFast(client),
+ fetchSlow(client),
+ ]);
+
+ _lastHealth = slow?.health ?? null;
+ _lastMetrics = fast?.metrics ?? null;
+
+ if (fast) {
+ updateCounters(fast.metrics, _lastHealth);
+ updateModelTable(fast.metrics);
+ updateEvents(fast.eventsData);
+ updateActiveRequests(fast.requestsData);
+ }
+ if (slow) {
+ updateChannels(slow.channels);
+ }
+
+ // Fast refresh: 3 seconds for metrics, events, requests
+ _fastTimer = setInterval(async () => {
+ const data = await fetchFast(client);
+ if (data) {
+ _lastMetrics = data.metrics;
+ updateCounters(data.metrics, _lastHealth);
+ updateModelTable(data.metrics);
+ updateEvents(data.eventsData);
+ updateActiveRequests(data.requestsData);
+ }
+ }, 3000);
+
+ // Slow refresh: 10 seconds for health, channels
+ _slowTimer = setInterval(async () => {
+ const data = await fetchSlow(client);
+ if (data) {
+ _lastHealth = data.health;
+ updateCounters(_lastMetrics, _lastHealth);
+ updateChannels(data.channels);
+ }
+ }, 10000);
+}
+
export const DashboardPage = {
async render(el, client) {
await loadDashboard(el, client);
-
- // Auto-refresh every 10 seconds
- _timer = setInterval(() => {
- loadDashboard(el, client).catch(() => {});
- }, 10000);
},
teardown() {
- if (_timer) {
- clearInterval(_timer);
- _timer = null;
+ if (_fastTimer) {
+ clearInterval(_fastTimer);
+ _fastTimer = null;
}
+ if (_slowTimer) {
+ clearInterval(_slowTimer);
+ _slowTimer = null;
+ }
+ _lastHealth = null;
+ _lastMetrics = null;
},
};
diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css
index 38218dd..b70f770 100644
--- a/src/gateway/ui/style.css
+++ b/src/gateway/ui/style.css
@@ -1120,6 +1120,52 @@ tr:hover td {
font-size: var(--font-size-base);
}
+/* ── Event Stream ──────────────────────────────────────── */
+.event-stream {
+ max-height: 300px;
+ overflow-y: auto;
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 8px;
+ font-size: var(--font-size-sm);
+ font-family: var(--font-mono);
+}
+
+.event-row {
+ padding: 4px 8px;
+ border-bottom: 1px solid var(--border-light);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.event-row:last-child {
+ border-bottom: none;
+}
+
+.event-level-error { color: var(--error); }
+.event-level-warn { color: var(--warning); }
+.event-level-info { color: var(--text-secondary); }
+
+/* ── Model Metrics Summary ─────────────────────────────── */
+.metrics-summary {
+ display: flex;
+ gap: 24px;
+ margin-bottom: 12px;
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+}
+
+.metrics-summary .metric {
+ display: flex;
+ gap: 6px;
+}
+
+.metrics-summary .metric-value {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
/* ── Responsive: Mobile ─────────────────────────────────────── */
@media (max-width: 768px) {