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

+
+
Loading...
+
+ +

Model Performance

+
+
Loading...
+
+ +

Event Stream

+
+
Loading events...
+
+ +

Active Requests

+
+
Loading...
+
+ +

Channels

+
+
Loading...
+
+ `; +} + +// ── 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} + + + + + + + + + + + + ${rows} +
TimeProviderLatencyTokens/secIn/OutStatus
`; } +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 = ` + + + + + + + + + + ${rows} +
SessionChannelDurationStarted
+ `; +} + +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) {