// ── 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';
// 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) + '%');
errorRateEl.classList.toggle('alert', rate > 5);
}
}
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 = `
window total${escapeHTML(formatCount(stats.totalEvents))}
peak bucket${escapeHTML(formatCount(stats.peakTotal))}${escapeHTML(formatBucketLabel(peakBucket.ts))}
mix${escapeHTML(formatCount(stats.totalRuns))}r / ${escapeHTML(formatCount(stats.totalTools))}t / ${escapeHTML(formatCount(stats.totalErrors))}e
bucket${escapeHTML(dashboardState.timeseries.bucket || '-')}${escapeHTML(String(stats.bucketCount))} points
`;
}
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))}
Input
${escapeHTML(formatTokenCount(inputTokens))}
Output
${escapeHTML(formatTokenCount(outputTokens))}
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 = `
Min
${escapeHTML(formatDuration(min))}
Avg
${escapeHTML(formatDuration(avg))}
Max
${escapeHTML(formatDuration(max))}
${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 pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
return `
${escapeHTML(name)}
${total} events
`;
}).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 `
${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);
if (topTools.length === 0) {
list.innerHTML = 'No tool data yet';
return;
}
const maxCount = topTools[0]?.[1] || 1;
list.innerHTML = topTools.map(([name, count]) => {
const pct = (count / maxCount * 100).toFixed(1);
return `
`;
}).join('');
}
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);
if (topModels.length === 0) {
list.innerHTML = 'No model data yet';
return;
}
const maxCount = topModels[0]?.[1] || 1;
list.innerHTML = topModels.map(([name, count]) => {
const pct = (count / maxCount * 100).toFixed(1);
return `
`;
}).join('');
}
// ── 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 = `
Tokens today
-
Cost today
-
Avg run duration
-
Error rate
-
Infrastructure
`;
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;
}
}