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:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user