feat(dashboard): ST4TS tab — hive-wide stats view
Builds the dashboard UI on the /api/stats-hive endpoint: a new ST4TS tab showing swarm totals (active agents, turns, tokens, labelled est cost), a busiest-first per-agent table, and a model-mix bar list. Plain tables/CSS bars — no chart lib in the dashboard bundle; per-agent trend charts stay on each agent's own /stats page. Fetched on tab activation + window change (pull-only, no SSE). Also adds conn.busy_timeout(500ms) in hive_stats read_agent per review: turn_stats is rollback-journal, so a read landing mid-INSERT would hit SQLITE_BUSY and silently drop that active agent — wait the write out.
This commit is contained in:
parent
447a84e8a6
commit
8e74dda4c5
4 changed files with 263 additions and 2 deletions
|
|
@ -1521,3 +1521,91 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
|
|||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ─── ST4TS tab: hive-wide turn-stats rollup ──────────────────────────────
|
||||
Plain tables + CSS bars (no chart lib in the dashboard bundle). */
|
||||
.hive-stats-windows {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.hive-stats-windows .btn.active {
|
||||
border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
}
|
||||
.hive-stats-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hive-stats-chip {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
min-width: 7em;
|
||||
}
|
||||
.hive-stats-chip .k {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.hive-stats-chip .v {
|
||||
font-size: 1.05rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
.hive-stats-chip.est .v {
|
||||
color: var(--amber);
|
||||
}
|
||||
.hive-stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hive-stats-table th,
|
||||
.hive-stats-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 5px 8px;
|
||||
text-align: right;
|
||||
}
|
||||
.hive-stats-table th:first-child,
|
||||
.hive-stats-table td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
.hive-stats-table th {
|
||||
color: var(--muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
.hive-stats-table td.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hive-stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 12em 1fr 4em;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 3px 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.hive-stats-bar .track {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hive-stats-bar .fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--purple);
|
||||
}
|
||||
.hive-stats-bar .cnt {
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,15 @@
|
|||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
|
||||
<!-- ST4TS: hive-wide turn-stats rollup (swarm totals, busiest
|
||||
agents, model mix, est cost). Fetched on tab activation +
|
||||
window change from GET /api/stats-hive. -->
|
||||
<a class="tab" id="tab-stats" href="#stats" role="tab"
|
||||
aria-controls="tab-pane-stats"
|
||||
data-tab="stats">
|
||||
<span class="tab-label">◆ ST4TS ◆</span>
|
||||
</a>
|
||||
|
||||
<!-- P33RS: peer hive navigation links. Hidden until at least one
|
||||
peer is configured in `services.hyperhive.peers`. -->
|
||||
<a class="tab" id="tab-peers" href="#peers" role="tab"
|
||||
|
|
@ -257,6 +266,31 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ST4TS: hive-wide turn-stats aggregate. Plain tables/bars (the
|
||||
dashboard bundle has no chart lib); data from GET
|
||||
/api/stats-hive?window=, fetched on tab activation + on
|
||||
window change. Per-agent trend charts stay on each agent's
|
||||
own /stats page. -->
|
||||
<section class="tab-pane" id="tab-pane-stats"
|
||||
role="tabpanel" aria-labelledby="tab-stats">
|
||||
<h2>◆ ST4TS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p>
|
||||
<div class="hive-stats-windows" id="hive-stats-windows">
|
||||
<button type="button" class="btn" data-w="1h">1h</button>
|
||||
<button type="button" class="btn" data-w="4h">4h</button>
|
||||
<button type="button" class="btn active" data-w="24h">24h</button>
|
||||
<button type="button" class="btn" data-w="3d">3d</button>
|
||||
<button type="button" class="btn" data-w="7d">7d</button>
|
||||
<button type="button" class="btn" data-w="30d">30d</button>
|
||||
</div>
|
||||
<div class="hive-stats-chips" id="hive-stats-summary"></div>
|
||||
<h3>◇ busiest agents</h3>
|
||||
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
|
||||
<h3>◇ model mix (turns across the swarm)</h3>
|
||||
<div id="hive-stats-models"></div>
|
||||
</section>
|
||||
|
||||
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||
message-flow + inbox + compose DOM only exists there — when
|
||||
tabs.js boots on this page the corresponding renderers
|
||||
|
|
|
|||
|
|
@ -4043,7 +4043,7 @@ window.marked = marked;
|
|||
// (`/flow.html`) reached via the tab-strip link. Tab routing only
|
||||
// applies when the tab DOM is present (e.g. not on the flow page
|
||||
// itself, where these elements don't exist and the loop no-ops).
|
||||
const TABS = ['swarm', 'call', 'system', 'permissions', 'schedules', 'peers', 'settings'];
|
||||
const TABS = ['swarm', 'call', 'system', 'permissions', 'schedules', 'stats', 'peers', 'settings'];
|
||||
function activateTab(name) {
|
||||
const target = TABS.includes(name) ? name : TABS[0];
|
||||
for (const t of TABS) {
|
||||
|
|
@ -4074,6 +4074,8 @@ window.marked = marked;
|
|||
fetchAndRenderCapabilities();
|
||||
fetchAndRenderToolGroups();
|
||||
}
|
||||
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
|
||||
if (target === 'stats') { refreshHiveStats(); }
|
||||
}
|
||||
// ─── tabbar overflow menu ────────────────────────────────────────────────
|
||||
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
|
||||
|
|
@ -4095,6 +4097,139 @@ window.marked = marked;
|
|||
const overflowDrop = $('tabbar-overflow-dropdown');
|
||||
let overflowOpen = false;
|
||||
|
||||
// ─── ST4TS: hive-wide turn-stats rollup ──────────────────────────────────
|
||||
// Pull-only (no SSE): fetched from /api/stats-hive on tab activation and
|
||||
// on window change. Plain tables/bars — the dashboard bundle has no chart
|
||||
// lib, and per-agent trend charts live on each agent's own /stats page.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function bindHiveStatsWindows() {
|
||||
const tabs = $('hive-stats-windows');
|
||||
if (!tabs) return;
|
||||
tabs.addEventListener('click', (ev) => {
|
||||
const btn = ev.target.closest('button[data-w]');
|
||||
if (!btn) return;
|
||||
hiveStatsWindow = btn.dataset.w;
|
||||
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
||||
refreshHiveStats();
|
||||
});
|
||||
}
|
||||
bindHiveStatsWindows();
|
||||
|
||||
function syncTabFromHash() {
|
||||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||||
activateTab(h);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde::Serialize;
|
||||
|
|
@ -177,6 +177,10 @@ fn u64_from_i64(v: i64) -> u64 {
|
|||
/// the whole endpoint.
|
||||
fn read_agent(path: &Path, from: i64) -> rusqlite::Result<AgentAgg> {
|
||||
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
// turn_stats is rollback-journal (not WAL): a read landing while the
|
||||
// harness is mid-INSERT would get SQLITE_BUSY and drop that active
|
||||
// agent from the rollup. Wait out the brief write instead.
|
||||
conn.busy_timeout(Duration::from_millis(500))?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT model, input_tokens, output_tokens,
|
||||
cache_read_input_tokens, cache_creation_input_tokens
|
||||
|
|
|
|||
Loading…
Reference in a new issue