// 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)], ); } // Session count: fresh claude sessions started in the window (each // new-session or auto-compaction-fallback mints one). Omitted until // the sessions table exists (older db). if (typeof s.session_count === "number") { chips.push(["sessions", fmtInt(s.session_count)]); } // 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-daemon -> 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(); }, }); }); })();