// 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. import Chart from 'chart.js/auto'; import { createTabStrip } from '@hive/shared/tabs.js'; // Expose for the IIFE below — pre-split this was a window global from // the jsDelivr CDN script tag. esbuild now bundles chart.js into // stats.js; once the IIFE opens up we can use the imported `Chart` // directly. window.Chart = Chart; (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); } // Sum the four token streams across every bucket in the window. function tokenTotals(s) { let input = 0, output = 0, cacheRead = 0, cacheCreation = 0; for (const b of s.buckets || []) { input += b.input_tokens || 0; output += b.output_tokens || 0; cacheRead += b.cache_read_input_tokens || 0; cacheCreation += b.cache_creation_input_tokens || 0; } return { input, output, cacheRead, cacheCreation }; } 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], ]; // Token-efficiency chips: cache hit-rate (cached input vs all // input-side tokens) and average tokens billed per turn. const t = tokenTotals(s); const inputSide = t.input + t.cacheRead + t.cacheCreation; if (inputSide > 0) { chips.push(['cache hit-rate', (100 * t.cacheRead / inputSide).toFixed(1) + '%']); } if (s.turn_count > 0) { const perTurn = (t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count; chips.push(['tokens/turn', fmtInt(perTurn)]); } if (s.reminder_stats) { chips.push( ['reminders scheduled', fmtInt(s.reminder_stats.scheduled)], ['reminders delivered', fmtInt(s.reminder_stats.delivered)], ['reminders pending', fmtInt(s.reminder_stats.pending)], ); } // First-turn ctx: input tokens of the most recent fresh session's // first turn — the cold system-prompt + CLAUDE.md cost, a sprawl // proxy. Omitted from the JSON (and so absent here) until the // per-session capture has data. if (typeof s.first_turn_ctx === 'number') { chips.push(['first-turn ctx', fmtInt(s.first_turn_ctx)]); } 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 renderResultTrendChart(s) { const id = 'chart-result-trend'; destroy(id); // Series = the result kinds seen in the window (same order + // colours as the result-mix doughnut). One stacked bar series per // kind, so error / rate-limit / compaction spikes line up in time. const kinds = (s.result_mix || []).map((kc) => kc.key); if (!kinds.length) { paintEmpty(id, 'no results'); return; } const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); const datasets = kinds.map((k, i) => ({ label: k, data: s.buckets.map((b) => (b.result_counts && b.result_counts[k]) || 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 } } }, }, }); } // "favorite tools" doughnut: most-run shell commands. The capture // (hive-bash-mcp -> bash_commands table) lands separately, so until // there's data we hide the whole card rather than show an empty // doughnut. Runs independently of turn_count (a bash task is tied to // a turn, but we don't want to couple the two reads). function renderBashCard(s) { const card = document.getElementById('card-bash'); const items = s.bash_breakdown || []; if (!items.length) { if (card) card.hidden = true; destroy('chart-bash'); return; } if (card) card.hidden = false; renderKeyCount('chart-bash', items, 'no bash commands'); } function render(s) { renderSummary(s); renderBashCard(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'); paintEmpty('chart-result-trend', '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'); renderResultTrendChart(s); } 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'); // When accessed via hive-gateway the page lives at `/agent//` // on the same origin as the dashboard. Detect via path prefix // rather than the direct port (which is unreachable or wrong scheme // behind HTTPS TLS termination). The dashboard SPA lives at // `dashboard.html` — the `/` root now serves the H0M3 menu hub. dl.href = window.location.pathname.startsWith('/agent/') ? window.location.origin + '/dashboard.html' : 'http://' + window.location.hostname + ':' + s.dashboard_port + '/dashboard.html'; } catch (_) { /* non-fatal */ } } document.addEventListener('DOMContentLoaded', () => { loadIdentity(); // The shared hash-routed tab strip drives the stat-window selector, // making it deep-linkable (#1h / #24h / …). onShow updates the // window + reloads; the strip's initial show fires onShow once, so // there's no separate loadStats() call here (avoids a double fetch). createTabStrip(document.getElementById('window-tabs'), { defaultId: currentWindow, onShow: (w) => { currentWindow = w; loadStats(); }, }); }); })();