Extracts the hive-wide turn-stats rollup out of the dashboard tab strip
into a standalone /stats.html page, reached from the H0M3 hub — same
minimal-chrome pattern as /flow.html and /logs.html. The dashboard tab
strip is now purely operational.
- new stats.{html,css,js}; stats.js holds the moved render JS and
fetches /api/stats-hive on load + window change.
- migrate the window selector (#hive-stats-windows) from a bespoke
data-w/.active toggle to the shared createTabStrip — now hash-routed
(#1h / #24h / …) and deep-linkable, matching the per-agent /stats page.
- drop the ST4TS tab + pane from dashboard.html and the hive-stats render
block + the stats->refreshHiveStats lazy-load from tabs.js.
- move the shared .hive-stats-table to common.css (the dashboard SYST3M >
C0NT41N3R L04D table still uses it); the ST4TS-only window/chip/bar
styles go to stats.css.
- add a Stats tile to the H0M3 hub (and drop the now-stale "stats" from
the Dashboard tile desc); wire build.mjs + the nix/frontend.nix manifest.
Second slice of #1464 step 2; follows the /settings.html extraction.
167 lines
6.3 KiB
JavaScript
167 lines
6.3 KiB
JavaScript
// /stats.html — hive-wide turn-stats rollup.
|
|
//
|
|
// Extracted from the dashboard ST4TS tab. Pull-only (no SSE): fetched
|
|
// from /api/stats-hive on load and on window change. Plain tables/bars —
|
|
// this bundle has no chart lib; per-agent trend charts live on each
|
|
// agent's own /stats page. The window selector is a hash-routed
|
|
// createTabStrip (#1h / #24h / …), matching the per-agent stats page.
|
|
import { $, initServerWarnings } from './common.js';
|
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
|
|
|
let hiveStatsWindow = '24h';
|
|
|
|
function hsFmtInt(n) {
|
|
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
|
|
}
|
|
function hsFmtTokens(n) {
|
|
if (!Number.isFinite(n)) return '0';
|
|
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
|
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
|
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
|
return String(Math.round(n));
|
|
}
|
|
function hsFmtUsd(n) {
|
|
if (!Number.isFinite(n)) return '$0';
|
|
if (n >= 100) return '$' + n.toFixed(0);
|
|
if (n >= 1) return '$' + n.toFixed(2);
|
|
return '$' + n.toFixed(3);
|
|
}
|
|
function hsChip(parent, label, value, est) {
|
|
const c = document.createElement('span');
|
|
c.className = 'hive-stats-chip' + (est ? ' est' : '');
|
|
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
|
|
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
|
|
c.append(k, v);
|
|
parent.append(c);
|
|
}
|
|
function hsMeta(parent, text) {
|
|
parent.replaceChildren();
|
|
const p = document.createElement('p');
|
|
p.className = 'meta';
|
|
p.textContent = text;
|
|
parent.append(p);
|
|
}
|
|
|
|
function renderHiveStats(s) {
|
|
const sum = $('hive-stats-summary');
|
|
if (sum) {
|
|
sum.replaceChildren();
|
|
hsChip(sum, 'window', s.window);
|
|
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
|
|
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
|
|
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
|
|
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
|
|
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
|
|
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
|
|
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
|
|
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
|
|
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
|
|
}
|
|
|
|
const at = $('hive-stats-agents');
|
|
if (at) {
|
|
const agents = s.agents || [];
|
|
if (!agents.length) {
|
|
hsMeta(at, 'no turns in window');
|
|
} else {
|
|
at.replaceChildren();
|
|
const table = document.createElement('table');
|
|
table.className = 'hive-stats-table';
|
|
table.innerHTML = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
|
|
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
|
|
const tb = document.createElement('tbody');
|
|
for (const a of agents) {
|
|
const tr = document.createElement('tr');
|
|
const cells = [
|
|
a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens),
|
|
hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens),
|
|
hsFmtUsd(a.est_cost_usd),
|
|
];
|
|
cells.forEach((txt, i) => {
|
|
const td = document.createElement('td');
|
|
if (i > 0) td.className = 'num';
|
|
td.textContent = txt;
|
|
tr.append(td);
|
|
});
|
|
tb.append(tr);
|
|
}
|
|
table.append(tb);
|
|
at.append(table);
|
|
}
|
|
}
|
|
|
|
const mm = $('hive-stats-models');
|
|
if (mm) {
|
|
const mix = s.model_mix || [];
|
|
if (!mix.length) {
|
|
hsMeta(mm, 'no turns in window');
|
|
} else {
|
|
mm.replaceChildren();
|
|
const max = mix[0].count || 1;
|
|
for (const kc of mix) {
|
|
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
|
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
|
const track = document.createElement('span'); track.className = 'track';
|
|
const fill = document.createElement('span'); fill.className = 'fill';
|
|
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
|
track.append(fill);
|
|
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
|
row.append(lbl, track, cnt);
|
|
mm.append(row);
|
|
}
|
|
}
|
|
}
|
|
|
|
// "favorite tools": most-run bash commands across the swarm. Hidden
|
|
// (header + list) until the capture has recorded data, so the
|
|
// section never shows an empty block while capture is pre-data.
|
|
const bh = $('hive-stats-bash');
|
|
const bhH = $('hive-stats-bash-h');
|
|
if (bh) {
|
|
const bmix = s.bash_mix || [];
|
|
if (!bmix.length) {
|
|
bh.replaceChildren();
|
|
bh.hidden = true;
|
|
if (bhH) bhH.hidden = true;
|
|
} else {
|
|
bh.hidden = false;
|
|
if (bhH) bhH.hidden = false;
|
|
bh.replaceChildren();
|
|
const max = bmix[0].count || 1;
|
|
for (const kc of bmix) {
|
|
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
|
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
|
const track = document.createElement('span'); track.className = 'track';
|
|
const fill = document.createElement('span'); fill.className = 'fill';
|
|
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
|
track.append(fill);
|
|
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
|
row.append(lbl, track, cnt);
|
|
bh.append(row);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshHiveStats() {
|
|
try {
|
|
const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow));
|
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
renderHiveStats(await resp.json());
|
|
} catch (e) {
|
|
const at = $('hive-stats-agents');
|
|
if (at) hsMeta(at, 'stats fetch failed: ' + e);
|
|
}
|
|
}
|
|
|
|
initServerWarnings();
|
|
|
|
// Window selector → hash-routed createTabStrip (#1h / #24h / …). No panes
|
|
// here (the selector swaps data, not DOM); paneFor() returns null for each
|
|
// tab and the strip's `if (pane)` guard handles it. The initial show()
|
|
// fires onShow once → sets the window + does the first fetch, so no
|
|
// separate refreshHiveStats() call is needed.
|
|
createTabStrip(document.getElementById('hive-stats-windows'), {
|
|
defaultId: hiveStatsWindow,
|
|
onShow: (w) => { hiveStatsWindow = w; refreshHiveStats(); },
|
|
});
|