feat(03-02): extend dashboard with live ops sections

- Core counters: messages processed, sessions, queue depth, uptime, active requests, errors
- Model performance table: recent calls with latency, tokens/sec, provider, status
- Event stream: scrollable log with color-coded levels (error/warn/info)
- Active requests: in-flight request table with session, channel, duration
- Channels grid: existing channel status cards preserved
- Dual timer refresh: 3s for metrics/events/requests, 10s for health/channels
- Targeted DOM updates via getElementById for flicker-free fast updates
This commit is contained in:
William Valentin
2026-02-09 21:34:11 -08:00
parent 7065b5e650
commit c3ca3f3776
2 changed files with 341 additions and 72 deletions
+295 -72
View File
@@ -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 = `<div class="empty-state">Failed to load dashboard: ${err.message}</div>`;
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 = `
<h1 class="page-title">Live Ops Dashboard</h1>
<h2 class="section-title">Core Counters</h2>
<div class="stats-grid" id="ops-counters">
<div class="stat-card"><div class="stat-label">Loading...</div><div class="stat-value">—</div></div>
</div>
<h2 class="section-title">Model Performance</h2>
<div id="ops-model-table">
<div class="text-muted text-sm">Loading...</div>
</div>
<h2 class="section-title">Event Stream</h2>
<div class="event-stream" id="ops-events">
<div class="event-row event-level-info">Loading events...</div>
</div>
<h2 class="section-title">Active Requests</h2>
<div id="ops-requests">
<div class="text-muted text-sm">Loading...</div>
</div>
<h2 class="section-title">Channels</h2>
<div id="ops-channels" class="channels-grid">
<div class="text-muted text-sm">Loading...</div>
</div>
`;
}
// ── 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 =>
`<div class="stat-card">
<div class="stat-label">${c.label}</div>
<div class="stat-value ${c.cls}">${c.value}</div>
</div>`
).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 = '<div class="text-muted text-sm">No model calls recorded yet</div>';
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 =>
`<div class="stat-card">
<div class="stat-label">${s.label}</div>
<div class="stat-value ${s.cls}">${s.value}</div>
</div>`
).join('');
const summaryHtml = `
<div class="metrics-summary">
<div class="metric"><span>Total Calls:</span> <span class="metric-value">${totalCalls}</span></div>
<div class="metric"><span>Avg Latency:</span> <span class="metric-value">${avgLatency}ms</span></div>
<div class="metric"><span>Error Rate:</span> <span class="metric-value">${(errorRate * 100).toFixed(2)}%</span></div>
</div>
`;
// Build channels grid
const channelList = channels?.channels ?? [];
let channelsHtml = '';
if (channelList.length > 0) {
channelsHtml = channelList.map(ch =>
`<div class="channel-card">
<span class="channel-dot ${ch.status}"></span>
<span class="channel-name">${ch.name}</span>
</div>`
).join('');
} else {
channelsHtml = '<div class="text-muted text-sm">No channels registered</div>';
}
// 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 =>
`<div class="stat-card">
<div class="stat-label">${u.label}</div>
<div class="stat-value">${u.value}</div>
</div>`
).join('');
// Show newest first
const rows = [...calls].reverse().map(c => {
const status = c.error ? '<span class="text-error">✗</span>' : '<span class="text-success">✓</span>';
return `<tr>
<td>${timeAgo(c.timestamp)}</td>
<td>${escapeHtml(c.provider)}</td>
<td>${c.latency}ms</td>
<td>${c.tokensPerSec.toFixed(1)}</td>
<td>${c.inputTokens}/${c.outputTokens}</td>
<td>${status}</td>
</tr>`;
}).join('');
el.innerHTML = `
<h1 class="page-title">Dashboard</h1>
<h2 class="section-title">System Health</h2>
<div class="stats-grid">${statsHtml}</div>
<h2 class="section-title">Channels</h2>
<div class="channels-grid">${channelsHtml}</div>
<h2 class="section-title">Usage</h2>
<div class="stats-grid">${usageHtml}</div>
${summaryHtml}
<table>
<thead>
<tr>
<th>Time</th>
<th>Provider</th>
<th>Latency</th>
<th>Tokens/sec</th>
<th>In/Out</th>
<th>Status</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
function updateEvents(eventsData) {
const el = document.getElementById('ops-events');
if (!el) return;
const events = eventsData?.events ?? [];
if (events.length === 0) {
el.innerHTML = '<div class="event-row event-level-info">No events recorded yet</div>';
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 `<div class="event-row ${cls}">[${time}] [${level}] ${escapeHtml(e.source)}: ${escapeHtml(e.message)}</div>`;
}).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 = '<div class="text-muted text-sm">No active requests</div>';
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 `<tr>
<td>${escapeHtml(r.sessionId)}</td>
<td>${escapeHtml(r.channel)}</td>
<td>${duration}</td>
<td>${started}</td>
</tr>`;
}).join('');
el.innerHTML = `
<table>
<thead>
<tr>
<th>Session</th>
<th>Channel</th>
<th>Duration</th>
<th>Started</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
function updateChannels(channelsData) {
const el = document.getElementById('ops-channels');
if (!el) return;
const channels = channelsData?.channels ?? [];
if (channels.length === 0) {
el.innerHTML = '<div class="text-muted text-sm">No channels registered</div>';
return;
}
el.innerHTML = channels.map(ch =>
`<div class="channel-card">
<span class="channel-dot ${ch.status}"></span>
<span class="channel-name">${escapeHtml(ch.name)}</span>
</div>`
).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;
},
};
+46
View File
@@ -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) {