c44e7fe72e
Introduce components.js with barTrack, barRow, barRankList, metricPill, metricStrip, and chartHeader helpers. Migrate dashboard.js and usage.js to use these primitives, replacing 13 families of duplicated CSS (stat-list, fw-bar, token-bar, metric-pill, chart-insight, chart-header, usage-chart-total, etc.) with a unified .am-* namespace. Net: -256 lines. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1000 lines
36 KiB
JavaScript
1000 lines
36 KiB
JavaScript
// ── 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 `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" class="summary-card-sparkline">
|
|
<path d="${areaPath}" fill="${color}" opacity="0.3"/>
|
|
<polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="1.5"/>
|
|
</svg>`;
|
|
}
|
|
|
|
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 => `
|
|
<div class="vm-pill ${vm.active ? 'active' : 'inactive'}">
|
|
<span class="vm-pill-dot"></span>
|
|
<span class="vm-pill-name">${escapeHTML(vm.name)}</span>
|
|
<span class="vm-pill-label">${vm.active ? 'online' : 'offline'}</span>
|
|
</div>
|
|
`),
|
|
`
|
|
<div class="vm-pill ${infra.className}">
|
|
<span class="vm-pill-dot"></span>
|
|
<span class="vm-pill-name">${escapeHTML(infra.name)}</span>
|
|
<span class="vm-pill-label">${escapeHTML(infra.label)}</span>
|
|
</div>
|
|
`,
|
|
].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 = `
|
|
<div class="chart-hover-head">
|
|
<div>
|
|
<div class="chart-hover-label">${escapeHTML(bucketLabel)}</div>
|
|
<div class="chart-hover-time">${escapeHTML(formatBucketLabel(bucket.ts))}</div>
|
|
</div>
|
|
<div class="chart-hover-total">
|
|
<span>Total</span>
|
|
<strong>${escapeHTML(formatCount(total))}</strong>
|
|
</div>
|
|
</div>
|
|
<div class="chart-hover-grid">
|
|
<div class="chart-hover-metric runs"><span>Runs</span><strong>${escapeHTML(formatCount(bucket.runs || 0))}</strong></div>
|
|
<div class="chart-hover-metric tools"><span>Tools</span><strong>${escapeHTML(formatCount(bucket.tools || 0))}</strong></div>
|
|
<div class="chart-hover-metric errors"><span>Errors</span><strong>${escapeHTML(formatCount(bucket.errors || 0))}</strong></div>
|
|
<div class="chart-hover-metric delta"><span>Delta</span><strong>${escapeHTML(deltaLabel)}</strong></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderTimeseriesChart() {
|
|
const container = document.getElementById('dash-chart');
|
|
if (!container || !dashboardState.timeseries) return;
|
|
|
|
const data = buildChartData();
|
|
renderDashboardChartInsights();
|
|
renderDashboardChartHover(dashboardState.chartCursorIndex);
|
|
if (!data) {
|
|
container.innerHTML = '<p class="empty-state" style="padding:2rem">No data for this window</p>';
|
|
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 = `
|
|
<div class="token-panel">
|
|
<div class="token-stat-big">
|
|
<div class="token-stat-label">Total tokens today</div>
|
|
<div class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div>
|
|
</div>
|
|
<div class="token-io-bars">
|
|
${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' })}
|
|
</div>
|
|
<div class="token-cost-display">
|
|
<span class="am-pill-label">Est. cost today</span>
|
|
<strong>${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = '<p class="empty-state" style="padding:1rem">No latency data</p>';
|
|
return;
|
|
}
|
|
|
|
const durSeries = ts.series.map(b => b.avg_duration_ms || 0).filter(v => v > 0);
|
|
if (durSeries.length === 0) {
|
|
container.innerHTML = '<p class="empty-state" style="padding:1rem">No run latency recorded yet</p>';
|
|
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 = `
|
|
<div class="latency-panel">
|
|
<div class="latency-range">
|
|
${metricPill({ label: 'Min', value: formatDuration(min), variant: 'range' })}
|
|
${metricPill({ label: 'Avg', value: formatDuration(avg), variant: 'range' })}
|
|
${metricPill({ label: 'Max', value: formatDuration(max), variant: 'range' })}
|
|
</div>
|
|
<div class="latency-mini-bars">
|
|
${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 `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`;
|
|
}).join('')}
|
|
</div>
|
|
<div class="am-pill-label" style="margin-top:0.5rem">Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = '<p class="empty-state" style="padding:1rem">No framework data</p>';
|
|
return;
|
|
}
|
|
|
|
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors));
|
|
|
|
container.innerHTML = '<div style="display:flex;flex-direction:column;gap:0.75rem;margin-top:0.25rem">' + 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('') + '</div>';
|
|
}
|
|
|
|
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
|
|
? `<span class="timeline-vm-tag ${vmClass}">${escapeHTML(framework)}</span>`
|
|
: '';
|
|
const sessionID = correlation.session_id || '';
|
|
const clickableClass = sessionID ? ' timeline-event-link' : '';
|
|
const attrs = sessionID
|
|
? ` role="link" tabindex="0" data-session-id="${escapeHTML(sessionID)}"`
|
|
: '';
|
|
|
|
return `
|
|
<div class="timeline-event${clickableClass}"${attrs}>
|
|
<div class="timeline-event-header">
|
|
${getEventIcon(eventType)}
|
|
${tag}
|
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
</div>
|
|
${getEventBody(evt)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = '<p class="empty-state" style="padding:1rem">Waiting for events...</p>';
|
|
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 = `
|
|
<div class="page-header">
|
|
<h2>Dashboard <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
|
</div>
|
|
<div class="dashboard-summary">
|
|
<div class="summary-card" style="--card-accent:var(--accent)">
|
|
<div class="summary-card-label"><span class="summary-card-icon">◉</span>Active Sessions</div>
|
|
<div class="summary-card-value" id="dash-active">-</div>
|
|
<div class="summary-card-sub" id="dash-active-sub"> </div>
|
|
</div>
|
|
<div class="summary-card" style="--card-accent:var(--success)">
|
|
<div class="summary-card-label"><span class="summary-card-icon">▶</span>Runs Today</div>
|
|
<div class="summary-card-value" id="dash-runs">-</div>
|
|
<div class="summary-card-sub" id="dash-runs-sub"> </div>
|
|
</div>
|
|
<div class="summary-card" style="--card-accent:var(--purple)">
|
|
<div class="summary-card-label"><span class="summary-card-icon">⚡</span>Tool Calls</div>
|
|
<div class="summary-card-value" id="dash-tools">-</div>
|
|
<div class="summary-card-sub" id="dash-tools-sub"> </div>
|
|
</div>
|
|
<div class="summary-card" style="--card-accent:var(--error)">
|
|
<div class="summary-card-label"><span class="summary-card-icon">⚠</span>Errors</div>
|
|
<div class="summary-card-value" id="dash-errors">-</div>
|
|
<div class="summary-card-sub" id="dash-errors-sub"> </div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom:1.25rem">${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' },
|
|
])}</div>
|
|
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
|
<div class="vm-strip" id="dash-vm-strip"></div>
|
|
<div class="charts-row">
|
|
<div class="chart-panel">
|
|
${chartHeader({
|
|
title: 'Event Rate',
|
|
subtitle: 'Runs, tool spans, and errors over time',
|
|
controls: `
|
|
<div class="chart-legend">
|
|
<span class="chart-legend-item"><span class="chart-legend-dot total"></span>total</span>
|
|
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#34d399"></span>runs</span>
|
|
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#22d3ee"></span>tools</span>
|
|
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#f87171"></span>errors</span>
|
|
</div>
|
|
<div class="mode-selector" id="dash-mode-selector">
|
|
<button class="mode-btn ${dashboardState.chartMode === 'stacked' ? 'active' : ''}" data-mode="stacked">stacked</button>
|
|
<button class="mode-btn ${dashboardState.chartMode === 'lines' ? 'active' : ''}" data-mode="lines">lines</button>
|
|
</div>
|
|
<div class="window-selector">
|
|
<button class="window-btn active" data-w="1h">1h</button>
|
|
<button class="window-btn" data-w="6h">6h</button>
|
|
<button class="window-btn" data-w="24h">24h</button>
|
|
<button class="window-btn" data-w="7d">7d</button>
|
|
</div>`,
|
|
})}
|
|
<div id="dash-chart-insights" style="margin-bottom:0.9rem"></div>
|
|
<div class="chart-container" id="dash-chart"></div>
|
|
<div class="chart-hover-panel" id="dash-chart-hover"></div>
|
|
</div>
|
|
<div class="chart-panel right-panel">
|
|
${chartHeader({
|
|
controls: `
|
|
<div class="right-panel-tabs" id="dash-right-tabs">
|
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
|
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
|
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
|
|
</div>`,
|
|
})}
|
|
<div class="right-panel-body" id="dash-right-panel">
|
|
<p class="empty-state" style="padding:1rem">Loading...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bottom-panels">
|
|
<div class="feed-panel">
|
|
${chartHeader({ title: 'Recent Activity' })}
|
|
<div class="timeline" id="dash-feed">
|
|
<p class="empty-state" style="padding:1rem">Loading...</p>
|
|
</div>
|
|
</div>
|
|
<div class="tools-panel">
|
|
${chartHeader({ title: 'Top Usage' })}
|
|
<div class="usage-rank-group">
|
|
<div class="usage-rank-header">Tools</div>
|
|
<div id="dash-top-tools"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
|
|
</div>
|
|
<div class="usage-rank-group">
|
|
<div class="usage-rank-header">Models</div>
|
|
<div id="dash-top-models"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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;
|
|
}
|
|
}
|