// Per-agent stats page. Fetches /api/state for the title + dashboard link // once on load, then /api/stats?window=... for the chart data โ€” re-fetches // when the operator clicks a window tab. (function () { 'use strict'; const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const palette = { bg: cssVar('--bg'), bgElev: cssVar('--bg-elev'), fg: cssVar('--fg'), muted: cssVar('--muted'), purple: cssVar('--purple'), cyan: cssVar('--cyan'), pink: cssVar('--pink'), amber: cssVar('--amber'), green: cssVar('--green'), red: cssVar('--red'), border: cssVar('--border'), }; // Distinct hues for categorical charts (top tools / wake mix / result mix). const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber, palette.green, palette.red, '#94e2d5', '#f9e2af', '#74c7ec', '#b4befe']; // Apply Catppuccin defaults globally so each Chart inherits without per-call // overrides. Chart.js v4 reads these on chart construction. Chart.defaults.color = palette.fg; Chart.defaults.borderColor = palette.border; Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace'; Chart.defaults.font.size = 11; Chart.defaults.plugins.legend.labels.color = palette.fg; const charts = {}; let currentWindow = '24h'; function fmtMs(ms) { if (!Number.isFinite(ms) || ms <= 0) return '0'; if (ms < 1000) return ms.toFixed(0) + 'ms'; return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's'; } function fmtInt(n) { if (!Number.isFinite(n)) return '0'; return new Intl.NumberFormat().format(Math.round(n)); } function bucketLabel(ts, bucketSecs) { const d = new Date(ts * 1000); if (bucketSecs >= 86400) { return d.toISOString().slice(5, 10); // MM-DD } return d.toISOString().slice(11, 16); // HH:MM } function destroy(name) { if (charts[name]) { charts[name].destroy(); delete charts[name]; } } function paintEmpty(canvasId, msg) { destroy(canvasId); const cv = document.getElementById(canvasId); if (!cv) return; const ctx = cv.getContext('2d'); ctx.clearRect(0, 0, cv.width, cv.height); ctx.fillStyle = palette.muted; ctx.font = '12px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(msg, cv.width / 2, cv.height / 2); } function renderSummary(s) { const root = document.getElementById('summary'); root.replaceChildren(); const chips = [ ['turns', fmtInt(s.turn_count)], ['avg duration', fmtMs(s.duration_summary.avg_ms)], ['p50 duration', fmtMs(s.duration_summary.p50_ms)], ['p95 duration', fmtMs(s.duration_summary.p95_ms)], ['window', s.window], ]; for (const [label, value] of chips) { const chip = document.createElement('span'); chip.className = 'chip'; const l = document.createElement('span'); l.className = 'label'; l.textContent = label; const v = document.createElement('span'); v.className = 'value'; v.textContent = value; chip.append(l, v); root.append(chip); } } function renderTurnsChart(s) { const id = 'chart-turns'; destroy(id); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const data = s.buckets.map((b) => b.turn_count); charts[id] = new Chart(document.getElementById(id), { type: 'bar', data: { labels, datasets: [{ label: 'turns', data, backgroundColor: palette.purple, borderColor: palette.purple, borderWidth: 1, }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { color: palette.border } }, y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } }, }, }, }); } function renderDurationChart(s) { const id = 'chart-duration'; destroy(id); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const ds = (label, color, key) => ({ label, data: s.buckets.map((b) => b[key]), borderColor: color, backgroundColor: color + '33', tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, }); charts[id] = new Chart(document.getElementById(id), { type: 'line', data: { labels, datasets: [ ds('p50', palette.cyan, 'p50_duration_ms'), ds('p95', palette.pink, 'p95_duration_ms'), ds('avg', palette.amber, 'avg_duration_ms'), ], }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { grid: { color: palette.border } }, y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtMs(v) }, }, }, }, }); } function renderCtxChart(s) { const id = 'chart-ctx'; destroy(id); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); charts[id] = new Chart(document.getElementById(id), { type: 'line', data: { labels, datasets: [ { label: 'avg ctx', data: s.buckets.map((b) => b.avg_ctx_tokens), borderColor: palette.cyan, backgroundColor: palette.cyan + '33', tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, }, { label: 'max ctx', data: s.buckets.map((b) => b.max_ctx_tokens), borderColor: palette.amber, backgroundColor: palette.amber + '33', tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, }, ], }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { grid: { color: palette.border } }, y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, }, }, }); } function renderCostChart(s) { const id = 'chart-cost'; destroy(id); const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); // Stacked bars: cache_read (cheap) / cache_creation / input / output. // Highlights "what's actually getting billed at full rate" vs cache hits. charts[id] = new Chart(document.getElementById(id), { type: 'bar', data: { labels, datasets: [ { label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens), backgroundColor: palette.muted }, { label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens), backgroundColor: palette.cyan }, { label: 'input', data: s.buckets.map((b) => b.input_tokens), backgroundColor: palette.amber }, { label: 'output', data: s.buckets.map((b) => b.output_tokens), backgroundColor: palette.pink }, ], }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { stacked: true, grid: { color: palette.border } }, y: { stacked: true, beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, }, }, }); } function renderModelChart(s) { const id = 'chart-model'; destroy(id); const models = s.models || []; if (!models.length) { paintEmpty(id, 'no turns in window'); return; } const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); // One stacked series per model. Model choice drives token cost, // so this lines up against the cost chart above it. const datasets = models.map((m, i) => ({ label: m, data: s.buckets.map((b) => (b.model_counts && b.model_counts[m]) || 0), backgroundColor: wheel[i % wheel.length], })); charts[id] = new Chart(document.getElementById(id), { type: 'bar', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } }, scales: { x: { stacked: true, grid: { color: palette.border } }, y: { stacked: true, beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } }, }, }, }); } function renderKeyCount(canvasId, items, emptyMsg) { destroy(canvasId); if (!items || items.length === 0) { paintEmpty(canvasId, emptyMsg); return; } const labels = items.map((kc) => kc.key); const data = items.map((kc) => kc.count); const colors = items.map((_, i) => wheel[i % wheel.length]); charts[canvasId] = new Chart(document.getElementById(canvasId), { type: 'doughnut', data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } }, }, }); } function render(s) { renderSummary(s); if (s.turn_count === 0) { paintEmpty('chart-turns', 'no turns in window'); paintEmpty('chart-duration', 'no turns in window'); paintEmpty('chart-ctx', 'no turns in window'); paintEmpty('chart-cost', 'no turns in window'); paintEmpty('chart-model', 'no turns in window'); paintEmpty('chart-tools', 'no tool calls'); paintEmpty('chart-wake', 'no wakes'); paintEmpty('chart-result', 'no results'); return; } renderTurnsChart(s); renderDurationChart(s); renderCtxChart(s); renderCostChart(s); renderModelChart(s); renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls'); renderKeyCount('chart-wake', s.wake_mix, 'no wakes'); renderKeyCount('chart-result', s.result_mix, 'no results'); } async function loadStats() { try { const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow)); if (!resp.ok) throw new Error('http ' + resp.status); const snap = await resp.json(); render(snap); } catch (e) { document.getElementById('summary').textContent = 'stats fetch failed: ' + e; } } async function loadIdentity() { try { const resp = await fetch('/api/state'); if (!resp.ok) return; const s = await resp.json(); document.title = 'stats ยท ' + s.label; document.getElementById('title').textContent = 'โ—† ' + s.label + ' โ—†'; const dl = document.getElementById('dashboard-link'); dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/'; } catch (_) { /* non-fatal */ } } function bindTabs() { const tabs = document.getElementById('window-tabs'); tabs.addEventListener('click', (ev) => { const btn = ev.target.closest('button[data-w]'); if (!btn) return; currentWindow = btn.dataset.w; for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn); loadStats(); }); } document.addEventListener('DOMContentLoaded', () => { bindTabs(); loadIdentity(); loadStats(); }); })();