hyperhive/frontend/packages/agent/src/stats.js
atlas 39b95c2ede treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
2026-09-02 15:25:07 +02:00

538 lines
17 KiB
JavaScript

// 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/<name>/`
// 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();
},
});
});
})();