// ── dashboard.js — Dashboard page ──────────────────────── import { escapeHTML, formatDuration, formatCount, formatCost, formatTokenCount, tryParseJSON, animateCounter, getEnvelopeType, getEnvelopePayload, getEnvelopeAttributes, getEnvelopeSource, getEnvelopeCorrelation, getEnvelopeTS, getVMName, getVMClassName, getEventIcon, getEventLabel, getEventBody, isDashboardFeedEvent, getRecordID, isCurrentPath, } from '../utils.js'; import { subscribeWS } from '../ws.js'; import { openclawState, swarmState, mergeOpenClawEvents, mergeSwarmSnapshot, mergeSwarmServiceSnapshot, getVMStatus, getDashboardInfraPill, } from '../state.js'; import { clearErrorBadge } from '../palette.js'; import { app, navigate, isRouteCurrent } from '../router.js'; import { api } from '../api.js'; import { barRankList, barRow, metricPill, metricStrip, chartHeader, } from '../components.js'; // uPlot is loaded as a global IIFE; access via window.uPlot /* global uPlot */ // ── Constants & module-level state ────────────────────── const DASH_RECENT_EVENTS_LIMIT = 10; const DASH_RECENT_EVENTS_STORAGE_KEY = 'agentmon:dash:recent-events'; let dashboardState = null; let dashboardUnsubscribe = null; let dashboardChart = null; let dashboardResizeObserver = null; let _dashFeedRenderTimer = null; // ── Private helpers ────────────────────────────────────── function getDashboardChartMode() { const mode = localStorage.getItem('agentmon:dash:chart-mode'); return mode === 'lines' ? 'lines' : 'stacked'; } function persistDashboardRecentEvents() { if (!dashboardState) return; localStorage.setItem( DASH_RECENT_EVENTS_STORAGE_KEY, JSON.stringify(dashboardState.recentEvents.slice(-DASH_RECENT_EVENTS_LIMIT)), ); } function addDashboardRecentEvent(evt) { if (!dashboardState || !isDashboardFeedEvent(evt)) return false; const id = getRecordID(evt); if (id && dashboardState.recentEventIDs.has(id)) return false; if (id) dashboardState.recentEventIDs.add(id); dashboardState.recentEvents.push(evt); while (dashboardState.recentEvents.length > DASH_RECENT_EVENTS_LIMIT) { const removed = dashboardState.recentEvents.shift(); const removedID = getRecordID(removed); if (removedID) dashboardState.recentEventIDs.delete(removedID); } persistDashboardRecentEvents(); return true; } function buildSparklineSVG(values, color) { if (!values || values.length < 2) return ''; const max = Math.max(...values, 1); const w = 100; const h = 30; const points = values.map((v, i) => { const x = (i / (values.length - 1)) * w; const y = h - (v / max) * h; return `${x.toFixed(1)},${y.toFixed(1)}`; }); const polyline = points.join(' '); const areaPath = `M0,${h} L${points.map(p => p).join(' L')} L${w},${h} Z`; return ` `; } function renderDashSparklines() { const ts = dashboardState.timeseries; if (!ts || !ts.series || ts.series.length < 2) return; const cards = document.querySelectorAll('.summary-card'); if (cards.length < 4) return; const runsData = ts.series.map(b => b.runs || 0); const toolsData = ts.series.map(b => b.tools || 0); const errorsData = ts.series.map(b => b.errors || 0); const totalData = ts.series.map((b, i) => runsData[i] + toolsData[i] + errorsData[i]); cards.forEach(c => { const s = c.querySelector('.summary-card-sparkline'); if (s) s.remove(); }); cards[0].insertAdjacentHTML('beforeend', buildSparklineSVG(totalData, 'var(--accent)')); cards[1].insertAdjacentHTML('beforeend', buildSparklineSVG(runsData, 'var(--success)')); cards[2].insertAdjacentHTML('beforeend', buildSparklineSVG(toolsData, 'var(--purple)')); cards[3].insertAdjacentHTML('beforeend', buildSparklineSVG(errorsData, 'var(--error)')); } function renderDashVMStrip() { const strip = document.getElementById('dash-vm-strip'); if (!strip) return; const vms = getVMStatus(); const infra = getDashboardInfraPill(); strip.innerHTML = [ ...vms.map(vm => `
${escapeHTML(vm.name)} ${vm.active ? 'online' : 'offline'}
`), `
${escapeHTML(infra.name)} ${escapeHTML(infra.label)}
`, ].join(''); } function renderSummaryCards() { const s = dashboardState.summary; if (!s) return; animateCounter('dash-active', s.active_sessions); animateCounter('dash-runs', s.runs_today); animateCounter('dash-tools', s.tool_calls_today); animateCounter('dash-errors', s.errors_today); const fws = Object.keys(s.by_framework || {}); if (fws.length > 0) { const sub = document.getElementById('dash-active-sub'); if (sub) { const activeByFramework = fws .map(f => [f, s.by_framework[f].active_sessions || 0]) .filter(([, count]) => count > 0); sub.textContent = activeByFramework.length > 0 ? activeByFramework.map(([f, count]) => `${f} ${count}`).join(' · ') : 'no live sessions'; } } const errEl = document.getElementById('dash-errors'); if (errEl) errEl.classList.toggle('has-errors', s.errors_today > 0); animateCounter('dash-tokens-today', formatTokenCount(s.tokens_today || 0)); animateCounter('dash-cost-today', s.cost_today ? formatCost(s.cost_today) : '$0.0000'); animateCounter('dash-avg-duration', s.avg_duration_ms ? formatDuration(s.avg_duration_ms) : '-'); const errorRateEl = document.getElementById('dash-error-rate'); if (errorRateEl) { const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0); const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0; animateCounter('dash-error-rate', rate.toFixed(1) + '%'); const pill = errorRateEl.closest('.am-pill'); if (pill) pill.classList.toggle('alert', rate > 5); } if (document.getElementById('dash-cost-per-run')) { const avgCost = (s.runs_today || 0) > 0 ? (s.cost_today || 0) / s.runs_today : 0; animateCounter('dash-cost-per-run', avgCost ? formatCost(avgCost) : '$0.0000'); } } async function loadTimeseries() { try { if (dashboardChart) { dashboardChart.destroy(); dashboardChart = null; } dashboardState.chartCursorIndex = null; const cachedWin = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window)); if (cachedWin) { dashboardState.timeseries = cachedWin; renderTimeseriesChart(); renderDashSparklines(); renderRightPanel(); } const data = await api('/v1/stats/timeseries?window=' + dashboardState.window); if (!isCurrentPath('/')) return; dashboardState.timeseries = data; localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(data)); renderTimeseriesChart(); renderDashSparklines(); renderRightPanel(); } catch (e) { console.error('Failed to load timeseries:', e); } } function getDashboardBucketIntervalMS() { const bucket = dashboardState && dashboardState.timeseries ? dashboardState.timeseries.bucket : ''; switch (bucket) { case '1m': return 60 * 1000; case '5m': return 5 * 60 * 1000; case '15m': return 15 * 60 * 1000; case '1h': return 60 * 60 * 1000; default: return 60 * 1000; } } function formatBucketLabel(ts) { const start = new Date(ts); if (Number.isNaN(start.getTime())) return '-'; const end = new Date(start.getTime() + getDashboardBucketIntervalMS()); const sameDay = start.toLocaleDateString() === end.toLocaleDateString(); const startLabel = start.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); const endLabel = end.toLocaleString([], sameDay ? { hour: 'numeric', minute: '2-digit' } : { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); return startLabel + ' to ' + endLabel; } function getDashboardChartStats() { const ts = dashboardState.timeseries; if (!ts || !ts.series || ts.series.length === 0) return null; const totals = ts.series.map(b => (b.runs || 0) + (b.tools || 0) + (b.errors || 0)); const sum = values => values.reduce((acc, value) => acc + value, 0); let peakIndex = 0; for (let i = 1; i < totals.length; i++) { if (totals[i] > totals[peakIndex]) peakIndex = i; } return { totalRuns: sum(ts.series.map(b => b.runs || 0)), totalTools: sum(ts.series.map(b => b.tools || 0)), totalErrors: sum(ts.series.map(b => b.errors || 0)), totalEvents: sum(totals), peakIndex, peakTotal: totals[peakIndex] || 0, bucketCount: ts.series.length, }; } function buildChartData() { const ts = dashboardState.timeseries; if (!ts || !ts.series || ts.series.length === 0) return null; const timestamps = ts.series.map(b => Math.floor(new Date(b.ts).getTime() / 1000)); const runs = ts.series.map(b => b.runs || 0); const tools = ts.series.map(b => b.tools || 0); const errors = ts.series.map(b => b.errors || 0); const totals = ts.series.map((b, i) => runs[i] + tools[i] + errors[i]); if (dashboardState.chartMode === 'lines') { return [timestamps, totals, runs, tools, errors]; } const stackedTools = tools.map((value, i) => value + errors[i]); return [timestamps, totals, stackedTools, errors]; } function renderDashboardChartInsights() { const container = document.getElementById('dash-chart-insights'); if (!container) return; const stats = getDashboardChartStats(); if (!stats) { container.innerHTML = ''; return; } const peakBucket = dashboardState.timeseries.series[stats.peakIndex]; container.innerHTML = metricStrip([ { label: 'window total', value: formatCount(stats.totalEvents), variant: 'insight' }, { label: 'peak bucket', value: formatCount(stats.peakTotal), meta: formatBucketLabel(peakBucket.ts), variant: 'insight' }, { label: 'mix', value: `${formatCount(stats.totalRuns)}r / ${formatCount(stats.totalTools)}t / ${formatCount(stats.totalErrors)}e`, variant: 'insight' }, { label: 'bucket', value: dashboardState.timeseries.bucket || '-', meta: stats.bucketCount + ' points', variant: 'insight' }, ], { variant: 'insights' }); } function renderDashboardChartHover(idx) { const container = document.getElementById('dash-chart-hover'); if (!container) return; const ts = dashboardState.timeseries; if (!ts || !ts.series || ts.series.length === 0) { container.innerHTML = ''; return; } const safeIdx = Number.isInteger(idx) && idx >= 0 && idx < ts.series.length ? idx : ts.series.length - 1; const bucket = ts.series[safeIdx]; const prev = safeIdx > 0 ? ts.series[safeIdx - 1] : null; const total = (bucket.runs || 0) + (bucket.tools || 0) + (bucket.errors || 0); const prevTotal = prev ? (prev.runs || 0) + (prev.tools || 0) + (prev.errors || 0) : 0; const delta = total - prevTotal; const deltaLabel = (delta > 0 ? '+' : '') + delta; const bucketLabel = safeIdx === ts.series.length - 1 ? 'Latest bucket' : 'Selected bucket'; container.innerHTML = `
${escapeHTML(bucketLabel)}
${escapeHTML(formatBucketLabel(bucket.ts))}
Total ${escapeHTML(formatCount(total))}
Runs${escapeHTML(formatCount(bucket.runs || 0))}
Tools${escapeHTML(formatCount(bucket.tools || 0))}
Errors${escapeHTML(formatCount(bucket.errors || 0))}
Delta${escapeHTML(deltaLabel)}
`; } function renderTimeseriesChart() { const container = document.getElementById('dash-chart'); if (!container || !dashboardState.timeseries) return; const data = buildChartData(); renderDashboardChartInsights(); renderDashboardChartHover(dashboardState.chartCursorIndex); if (!data) { container.innerHTML = '

No data for this window

'; return; } if (dashboardChart) { dashboardChart.setData(data); return; } container.innerHTML = ''; const width = container.clientWidth || 600; const height = 200; const commonSeries = [ {}, { label: 'Total', stroke: '#f8fafc', width: 1.5, dash: [6, 4], points: { show: false }, }, ]; const lineSeries = [ ...commonSeries, { label: 'Runs', stroke: '#34d399', width: 1.75, fill: 'rgba(52, 211, 153, 0.08)' }, { label: 'Tools', stroke: '#22d3ee', width: 1.75, fill: 'rgba(34, 211, 238, 0.08)' }, { label: 'Errors', stroke: '#f87171', width: 1.75, fill: 'rgba(248, 113, 113, 0.08)' }, ]; const stackedSeries = [ ...commonSeries, { label: 'Tools+Errors', stroke: 'rgba(34, 211, 238, 0.85)', width: 1.25, points: { show: false } }, { label: 'Errors', stroke: '#f87171', width: 1.25, points: { show: false }, fill: 'rgba(248, 113, 113, 0.18)' }, ]; const opts = { width, height, cursor: { show: true }, hooks: { setCursor: [ u => { dashboardState.chartCursorIndex = Number.isInteger(u.cursor.idx) ? u.cursor.idx : null; renderDashboardChartHover(dashboardState.chartCursorIndex); }, ], }, scales: { x: { time: true }, y: { auto: true, min: 0 }, }, axes: [ { stroke: '#4e6070', grid: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 }, ticks: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 }, font: '11px Fira Code', }, { stroke: '#4e6070', grid: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 }, ticks: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 }, font: '11px Fira Code', size: 50, }, ], series: dashboardState.chartMode === 'lines' ? lineSeries : stackedSeries, bands: dashboardState.chartMode === 'lines' ? [] : [ { series: [1, 2], fill: 'rgba(52, 211, 153, 0.18)' }, { series: [2, 3], fill: 'rgba(34, 211, 238, 0.18)' }, ], }; dashboardChart = new window.uPlot(opts, data, container); if (dashboardResizeObserver) dashboardResizeObserver.disconnect(); dashboardResizeObserver = new ResizeObserver(entries => { for (const entry of entries) { if (dashboardChart) dashboardChart.setSize({ width: entry.contentRect.width, height: 200 }); } }); dashboardResizeObserver.observe(container); } function appendToCurrentBucket(evt) { const ts = dashboardState.timeseries; if (!ts || !ts.series || ts.series.length === 0) return; const now = Math.floor(Date.now() / 60000) * 60000; const last = ts.series[ts.series.length - 1]; const lastTs = new Date(last.ts).getTime(); let bucket; if (Math.abs(now - lastTs) < 60000) { bucket = last; } else { bucket = { ts: new Date(now).toISOString(), runs: 0, tools: 0, errors: 0, tokens: 0, input_tokens: 0, output_tokens: 0, cost: 0, avg_duration_ms: 0 }; ts.series.push(bucket); } const eventType = getEnvelopeType(evt); if (eventType === 'run.start') bucket.runs++; if (eventType === 'error') bucket.errors++; if (eventType === 'span.end') { const attrs = getEnvelopeAttributes(evt); if (attrs.span_kind === 'tool') bucket.tools++; } if (eventType === 'run.end') { const payload = getEnvelopePayload(evt); const usage = payload.usage || {}; bucket.tokens = (bucket.tokens || 0) + (usage.total_tokens || 0); bucket.input_tokens = (bucket.input_tokens || 0) + (usage.input_tokens || 0); bucket.output_tokens = (bucket.output_tokens || 0) + (usage.output_tokens || 0); bucket.cost = (bucket.cost || 0) + (usage.total_cost || 0); if (payload.duration_ms) { const runCount = bucket.runs || 1; const prev = bucket.avg_duration_ms || 0; bucket.avg_duration_ms = prev + (payload.duration_ms - prev) / runCount; } } dashboardState.chartCursorIndex = ts.series.length - 1; renderTimeseriesChart(); renderDashSparklines(); } function tallyTool(evt) { const eventType = getEnvelopeType(evt); if (eventType === 'span.end') { const attrs = getEnvelopeAttributes(evt); if (attrs.span_kind === 'tool') { const name = attrs.name || 'unknown'; dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1; } } } function tallyModel(evt) { const eventType = getEnvelopeType(evt); const payload = getEnvelopePayload(evt); if (eventType === 'run.end' && payload.model) { const name = String(payload.model); dashboardState.modelCounts[name] = (dashboardState.modelCounts[name] || 0) + 1; return; } if (eventType === 'metric.snapshot' && payload.metrics && payload.metrics.model) { const name = String(payload.metrics.model); if (!dashboardState.modelCounts[name]) dashboardState.modelCounts[name] = 1; } } function handleDashboardWS(msg) { if (msg.type !== 'message') return; const eventType = getEnvelopeType(msg.data); if (eventType === 'openclaw.snapshot') { mergeOpenClawEvents([msg.data]); renderDashVMStrip(); return; } if (eventType === 'swarm.snapshot') { mergeSwarmSnapshot(msg.data); renderDashVMStrip(); return; } if (eventType === 'swarm.service.snapshot') { mergeSwarmServiceSnapshot(msg.data); renderDashVMStrip(); return; } if (dashboardState.summary) { if (eventType === 'session.start') dashboardState.summary.active_sessions++; if (eventType === 'session.end') dashboardState.summary.active_sessions = Math.max(0, dashboardState.summary.active_sessions - 1); if (eventType === 'run.start') dashboardState.summary.runs_today++; if (eventType === 'error') dashboardState.summary.errors_today++; if (eventType === 'span.end') { const attrs = getEnvelopeAttributes(msg.data); if (attrs.span_kind === 'tool') dashboardState.summary.tool_calls_today++; } if (eventType === 'run.end') { const payload = getEnvelopePayload(msg.data); const usage = payload.usage || {}; dashboardState.summary.tokens_today = (dashboardState.summary.tokens_today || 0) + (usage.total_tokens || 0); dashboardState.summary.cost_today = (dashboardState.summary.cost_today || 0) + (usage.total_cost || 0); if (payload.duration_ms) { const runs = dashboardState.summary.runs_today || 1; const prev = dashboardState.summary.avg_duration_ms || 0; dashboardState.summary.avg_duration_ms = prev + (payload.duration_ms - prev) / runs; } } renderSummaryCards(); } if (!isDashboardFeedEvent(msg.data)) { if (dashboardState.timeseries && dashboardState.window === '1h') { appendToCurrentBucket(msg.data); } return; } if (addDashboardRecentEvent(msg.data)) { tallyTool(msg.data); tallyModel(msg.data); if (!_dashFeedRenderTimer) { _dashFeedRenderTimer = requestAnimationFrame(() => { _dashFeedRenderTimer = null; renderDashFeed(); renderDashTopTools(); renderDashTopModels(); }); } } if (dashboardState.timeseries && dashboardState.window === '1h') { appendToCurrentBucket(msg.data); } } function renderTokenPanel() { const container = document.getElementById('dash-right-panel'); if (!container) return; const s = dashboardState.summary; const ts = dashboardState.timeseries; const totalTokens = s ? (s.tokens_today || 0) : 0; const inputTokens = ts && ts.series ? ts.series.reduce((acc, b) => acc + (b.input_tokens || 0), 0) : 0; const outputTokens = ts && ts.series ? ts.series.reduce((acc, b) => acc + (b.output_tokens || 0), 0) : 0; const totalCost = s ? (s.cost_today || 0) : 0; const maxIO = Math.max(inputTokens, outputTokens, 1); container.innerHTML = `
Total tokens today
${escapeHTML(formatTokenCount(totalTokens))}
${barRow({ name: 'Input', count: inputTokens, countDisplay: formatTokenCount(inputTokens), max: maxIO, modifier: 'input', size: 'md' })} ${barRow({ name: 'Output', count: outputTokens, countDisplay: formatTokenCount(outputTokens), max: maxIO, modifier: 'output', size: 'md' })}
Est. cost today ${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}
`; } function renderLatencyPanel() { const container = document.getElementById('dash-right-panel'); if (!container) return; const ts = dashboardState.timeseries; if (!ts || !ts.series || ts.series.length === 0) { container.innerHTML = '

No latency data

'; return; } const durSeries = ts.series.map(b => b.avg_duration_ms || 0).filter(v => v > 0); if (durSeries.length === 0) { container.innerHTML = '

No run latency recorded yet

'; return; } const avg = durSeries.reduce((a, b) => a + b, 0) / durSeries.length; const min = Math.min(...durSeries); const max = Math.max(...durSeries); const maxBar = max || 1; container.innerHTML = `
${metricPill({ label: 'Min', value: formatDuration(min), variant: 'range' })} ${metricPill({ label: 'Avg', value: formatDuration(avg), variant: 'range' })} ${metricPill({ label: 'Max', value: formatDuration(max), variant: 'range' })}
${durSeries.map((v, i) => { const pct = (v / maxBar * 100).toFixed(1); const label = ts.series.filter(b => b.avg_duration_ms > 0)[i]; const title = label ? formatBucketLabel(label.ts) + ': ' + formatDuration(v) : formatDuration(v); return `
`; }).join('')}
Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})
`; } function renderFrameworkBars() { const container = document.getElementById('dash-right-panel'); if (!container || !dashboardState.summary) return; const byFw = dashboardState.summary.by_framework || {}; const entries = Object.entries(byFw).sort((a, b) => { const totalA = a[1].runs + a[1].tools + a[1].errors; const totalB = b[1].runs + b[1].tools + b[1].errors; return totalB - totalA; }); if (entries.length === 0) { container.innerHTML = '

No framework data

'; return; } const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors)); container.innerHTML = '
' + entries.map(([name, stats]) => { const total = stats.runs + stats.tools + stats.errors; const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); return barRow({ name, count: total, countDisplay: total + ' events', max: maxTotal, fwClass: cssClass, size: 'lg', }); }).join('') + '
'; } function renderRightPanel() { const mode = dashboardState && dashboardState.rightPanelMode; if (mode === 'tokens') { renderTokenPanel(); } else if (mode === 'latency') { renderLatencyPanel(); } else { renderFrameworkBars(); } } function renderDashFeedItem(evt) { const eventType = getEnvelopeType(evt); const correlation = getEnvelopeCorrelation(evt); const vmName = getVMName(evt); const vmClass = getVMClassName(vmName); const source = getEnvelopeSource(evt); const framework = source.framework || ''; const tag = framework ? `${escapeHTML(framework)}` : ''; const sessionID = correlation.session_id || ''; const clickableClass = sessionID ? ' timeline-event-link' : ''; const attrs = sessionID ? ` role="link" tabindex="0" data-session-id="${escapeHTML(sessionID)}"` : ''; return `
${getEventIcon(eventType)} ${tag} ${escapeHTML(getEventLabel(eventType))} ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}
${getEventBody(evt)}
`; } function renderDashFeed() { const feed = document.getElementById('dash-feed'); if (!feed) return; const recent = dashboardState.recentEvents.slice(-DASH_RECENT_EVENTS_LIMIT).reverse(); if (recent.length === 0) { feed.innerHTML = '

Waiting for events...

'; return; } feed.innerHTML = recent.map(renderDashFeedItem).join(''); feed.querySelectorAll('.timeline-event-link').forEach(item => { const sessionID = item.dataset.sessionId || ''; if (!sessionID) return; item.addEventListener('click', () => navigate('/sessions/' + sessionID)); item.addEventListener('keydown', event => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); navigate('/sessions/' + sessionID); }); }); } function renderDashTopTools() { const list = document.getElementById('dash-top-tools'); if (!list) return; const topTools = Object.entries(dashboardState.toolCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([name, count]) => ({ name, count })); list.innerHTML = barRankList(topTools, { emptyText: 'No tool data yet' }); } function renderDashTopModels() { const list = document.getElementById('dash-top-models'); if (!list) return; const topModels = Object.entries(dashboardState.modelCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([name, count]) => ({ name, count, modifier: 'model' })); list.innerHTML = barRankList(topModels, { emptyText: 'No model data yet' }); } // ── Exports ────────────────────────────────────────────── export async function renderDashboard(routeToken) { clearErrorBadge(); dashboardState = { summary: null, timeseries: null, window: '1h', chartMode: getDashboardChartMode(), chartCursorIndex: null, recentEvents: [], recentEventIDs: new Set(), toolCounts: {}, modelCounts: {}, rightPanelMode: localStorage.getItem('agentmon:dash:right-panel') || 'framework', }; app.innerHTML = `
Active Sessions
-
 
Runs Today
-
 
Tool Calls
-
 
Errors
-
 
${metricStrip([ { label: 'Tokens today', valueId: 'dash-tokens-today' }, { label: 'Cost today', valueId: 'dash-cost-today' }, { label: 'Avg run duration', valueId: 'dash-avg-duration' }, { label: 'Error rate', valueId: 'dash-error-rate' }, { label: 'Cost / run', valueId: 'dash-cost-per-run' }, ])}
Infrastructure
${chartHeader({ title: 'Event Rate', subtitle: 'Runs, tool spans, and errors over time', controls: `
total runs tools errors
`, })}
${chartHeader({ controls: `
`, })}

Loading...

${chartHeader({ title: 'Recent Activity' })}

Loading...

${chartHeader({ title: 'Top Usage' })}
Tools

Loading...

Models

Loading...

`; document.querySelectorAll('.window-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.window-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); dashboardState.window = btn.dataset.w; loadTimeseries(); }); }); document.querySelectorAll('.mode-btn').forEach(btn => { btn.addEventListener('click', () => { const nextMode = btn.dataset.mode; if (dashboardState.chartMode === nextMode) return; document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b === btn)); dashboardState.chartMode = nextMode; localStorage.setItem('agentmon:dash:chart-mode', nextMode); if (dashboardChart) { dashboardChart.destroy(); dashboardChart = null; } renderTimeseriesChart(); }); }); document.querySelectorAll('.right-panel-tab').forEach(btn => { btn.addEventListener('click', () => { const panel = btn.dataset.panel; if (dashboardState.rightPanelMode === panel) return; document.querySelectorAll('.right-panel-tab').forEach(b => b.classList.toggle('active', b === btn)); dashboardState.rightPanelMode = panel; localStorage.setItem('agentmon:dash:right-panel', panel); renderRightPanel(); }); }); renderDashVMStrip(); const cachedRecentEvents = tryParseJSON(localStorage.getItem(DASH_RECENT_EVENTS_STORAGE_KEY)); if (Array.isArray(cachedRecentEvents)) { for (const evt of cachedRecentEvents) { addDashboardRecentEvent(evt); } renderDashFeed(); } const cachedSummary = tryParseJSON(localStorage.getItem('agentmon:dash:summary')); const cachedTS = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window)); if (cachedSummary) { dashboardState.summary = cachedSummary; renderSummaryCards(); } if (cachedTS) { dashboardState.timeseries = cachedTS; renderTimeseriesChart(); renderDashSparklines(); renderRightPanel(); } try { const [summaryData, tsData, recentData, snapshots, swarmSnaps, topToolsData, topModelsData] = await Promise.all([ api('/v1/stats/summary'), api('/v1/stats/timeseries?window=1h'), api('/v1/events?limit=10'), api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })), api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })), api('/v1/stats/top-tools').catch(() => ({ tools: [] })), api('/v1/stats/top-models').catch(() => ({ models: [] })), ]); if ((routeToken && !isRouteCurrent(routeToken)) || !isCurrentPath('/')) return; mergeOpenClawEvents(snapshots.events || []); for (const evt of swarmSnaps.events || []) mergeSwarmSnapshot(evt); renderDashVMStrip(); dashboardState.summary = summaryData; dashboardState.timeseries = tsData; localStorage.setItem('agentmon:dash:summary', JSON.stringify(summaryData)); localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(tsData)); renderSummaryCards(); renderTimeseriesChart(); renderDashSparklines(); renderRightPanel(); for (const t of (topToolsData.tools || [])) { dashboardState.toolCounts[t.name] = t.count; } for (const m of (topModelsData.models || [])) { dashboardState.modelCounts[m.name] = m.count; } const events = (recentData.events || []) .filter(isDashboardFeedEvent) .slice() .reverse(); for (const evt of events) { addDashboardRecentEvent(evt); } renderDashFeed(); renderDashTopTools(); renderDashTopModels(); } catch (e) { console.error('Dashboard load error:', e); } if (!routeToken || isRouteCurrent(routeToken)) { dashboardUnsubscribe = subscribeWS(handleDashboardWS); } } export function cleanup() { if (dashboardUnsubscribe) { dashboardUnsubscribe(); dashboardUnsubscribe = null; } if (dashboardChart) { dashboardChart.destroy(); dashboardChart = null; } if (dashboardResizeObserver) { dashboardResizeObserver.disconnect(); dashboardResizeObserver = null; } if (_dashFeedRenderTimer) { cancelAnimationFrame(_dashFeedRenderTimer); _dashFeedRenderTimer = null; } }