Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfafc9d93d | ||
|
|
a8a30dd39b | ||
|
|
8e74dda4c5 | ||
|
|
447a84e8a6 |
7 changed files with 608 additions and 2 deletions
|
|
@ -14,7 +14,8 @@ swap.
|
||||||
|
|
||||||
**Chrome header** (fixed, overlays the active tab pane):
|
**Chrome header** (fixed, overlays the active tab pane):
|
||||||
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`,
|
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`,
|
||||||
`◆ P3RM1SS10NS ◆`, `◆ SCH3DUL3S ◆`, `◆ P33RS ◆` (hidden when `swarm.peers` is empty),
|
`◆ P3RM1SS10NS ◆`, `◆ SCH3DUL3S ◆`, `◆ ST4TS ◆`,
|
||||||
|
`◆ P33RS ◆` (hidden when `swarm.peers` is empty),
|
||||||
`◆ M4TR1X ◆ →` (optional page link, see below), `◆ FL0W ◆ →`
|
`◆ M4TR1X ◆ →` (optional page link, see below), `◆ FL0W ◆ →`
|
||||||
(page link), and `◆ S3TT1NGS ◆`. Count pills on SW4RM (container
|
(page link), and `◆ S3TT1NGS ◆`. Count pills on SW4RM (container
|
||||||
count), Y3R C4LL (pending approvals + questions), and SCH3DUL3S
|
count), Y3R C4LL (pending approvals + questions), and SCH3DUL3S
|
||||||
|
|
@ -282,6 +283,36 @@ attribute; a shared 1s ticker rewrites it in-place — showing
|
||||||
`overdue X ago` once the deadline passes — without triggering a
|
`overdue X ago` once the deadline passes — without triggering a
|
||||||
full re-render of the list.
|
full re-render of the list.
|
||||||
|
|
||||||
|
## ST4TS tab
|
||||||
|
|
||||||
|
Hive-wide turn statistics, aggregated across every agent's
|
||||||
|
`hyperhive-turn-stats.sqlite` for the selected window. Distinct from
|
||||||
|
each agent's own `/stats` page (which carries the per-agent trend
|
||||||
|
charts): ST4TS is the swarm-level rollup.
|
||||||
|
|
||||||
|
- **Window selector** (`1h`–`30d`) re-fetches on change.
|
||||||
|
- **Summary chips**: active agents, turns, total/input/output/cache-read
|
||||||
|
tokens, and a labelled **est cost**.
|
||||||
|
- **Busiest agents** table — one row per agent (most turns first):
|
||||||
|
turns, input / output / cache-read tokens, est cost.
|
||||||
|
- **Model mix** — turns per model across the swarm, as CSS bars.
|
||||||
|
|
||||||
|
Backed by `GET /api/stats-hive?window=<w>` in `hive-c0re`
|
||||||
|
(`hive_stats.rs`): for every name from
|
||||||
|
`Coordinator::kept_state_names()` it opens
|
||||||
|
`agent_harness_dir(name)/hyperhive-turn-stats.sqlite` read-only (with a
|
||||||
|
500 ms `busy_timeout`, since `turn_stats` is rollback-journal) and rolls
|
||||||
|
the rows up — missing / unreadable / zero-turn dbs are skipped so one
|
||||||
|
bad db never fails the endpoint. This is a **pull** surface (no SSE):
|
||||||
|
the data is fetched on tab activation and on window change. Rendered
|
||||||
|
with plain tables + CSS bars — the dashboard bundle ships no chart
|
||||||
|
library.
|
||||||
|
|
||||||
|
The cost figure is a deliberately rough estimate from an approximate
|
||||||
|
per-model price table (`est_cost_usd`); it drifts with list pricing and
|
||||||
|
is labelled accordingly. (A follow-up can move the table to a nix
|
||||||
|
option so it's operator-tunable.)
|
||||||
|
|
||||||
## P33RS tab
|
## P33RS tab
|
||||||
|
|
||||||
Peer hives in this swarm. The tab is hidden when the
|
Peer hives in this swarm. The tab is hidden when the
|
||||||
|
|
@ -801,6 +832,12 @@ that's a browser-level decision, not ours.
|
||||||
`/dashboard/history` backfill.
|
`/dashboard/history` backfill.
|
||||||
- `GET /api/reminders` — list pending reminders for the
|
- `GET /api/reminders` — list pending reminders for the
|
||||||
dashboard's queued-reminders panel.
|
dashboard's queued-reminders panel.
|
||||||
|
- `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d>` — hive-wide
|
||||||
|
turn-stats rollup for the ST4TS tab. Aggregates every agent's
|
||||||
|
`hyperhive-turn-stats.sqlite` read-only (skips missing / unreadable /
|
||||||
|
zero-turn dbs); returns swarm totals, a busiest-first per-agent
|
||||||
|
rollup, swarm model mix, and a labelled `est_cost_usd`. Window
|
||||||
|
defaults to `24h`.
|
||||||
- `POST /cancel-reminder/{id}` — hard-delete a pending reminder.
|
- `POST /cancel-reminder/{id}` — hard-delete a pending reminder.
|
||||||
- `POST /retry-reminder/{id}` — re-arm a reminder whose delivery
|
- `POST /retry-reminder/{id}` — re-arm a reminder whose delivery
|
||||||
failed (clears the failure state so the scheduler retries).
|
failed (clears the failure state so the scheduler retries).
|
||||||
|
|
|
||||||
|
|
@ -1521,3 +1521,91 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: default;
|
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>
|
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||||
</a>
|
</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
|
<!-- P33RS: peer hive navigation links. Hidden until at least one
|
||||||
peer is configured in `services.hyperhive.peers`. -->
|
peer is configured in `services.hyperhive.peers`. -->
|
||||||
<a class="tab" id="tab-peers" href="#peers" role="tab"
|
<a class="tab" id="tab-peers" href="#peers" role="tab"
|
||||||
|
|
@ -257,6 +266,31 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||||
message-flow + inbox + compose DOM only exists there — when
|
message-flow + inbox + compose DOM only exists there — when
|
||||||
tabs.js boots on this page the corresponding renderers
|
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
|
// (`/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
|
// 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).
|
// 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) {
|
function activateTab(name) {
|
||||||
const target = TABS.includes(name) ? name : TABS[0];
|
const target = TABS.includes(name) ? name : TABS[0];
|
||||||
for (const t of TABS) {
|
for (const t of TABS) {
|
||||||
|
|
@ -4074,6 +4074,8 @@ window.marked = marked;
|
||||||
fetchAndRenderCapabilities();
|
fetchAndRenderCapabilities();
|
||||||
fetchAndRenderToolGroups();
|
fetchAndRenderToolGroups();
|
||||||
}
|
}
|
||||||
|
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
|
||||||
|
if (target === 'stats') { refreshHiveStats(); }
|
||||||
}
|
}
|
||||||
// ─── tabbar overflow menu ────────────────────────────────────────────────
|
// ─── tabbar overflow menu ────────────────────────────────────────────────
|
||||||
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
|
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
|
||||||
|
|
@ -4095,6 +4097,139 @@ window.marked = marked;
|
||||||
const overflowDrop = $('tabbar-overflow-dropdown');
|
const overflowDrop = $('tabbar-overflow-dropdown');
|
||||||
let overflowOpen = false;
|
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() {
|
function syncTabFromHash() {
|
||||||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||||||
activateTab(h);
|
activateTab(h);
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
||||||
.route("/api/state-file", get(get_state_file))
|
.route("/api/state-file", get(get_state_file))
|
||||||
.route("/api/reminders", get(api_reminders))
|
.route("/api/reminders", get(api_reminders))
|
||||||
|
.route("/api/stats-hive", get(api_stats_hive))
|
||||||
.route("/api/build-logs", get(get_build_logs_all))
|
.route("/api/build-logs", get(get_build_logs_all))
|
||||||
.route("/api/build-logs/{agent}", get(get_build_logs_agent))
|
.route("/api/build-logs/{agent}", get(get_build_logs_agent))
|
||||||
.route("/api/build-logs/id/{id}", get(get_build_log_full))
|
.route("/api/build-logs/id/{id}", get(get_build_log_full))
|
||||||
|
|
@ -1734,6 +1735,19 @@ async fn api_reminders(State(state): State<AppState>) -> Response {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct StatsHiveQuery {
|
||||||
|
window: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hive-wide turn-stats rollup for the dashboard swarm-stats view.
|
||||||
|
/// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only
|
||||||
|
/// (skips missing/unreadable ones). Window defaults to `24h`.
|
||||||
|
async fn api_stats_hive(axum::extract::Query(q): axum::extract::Query<StatsHiveQuery>) -> Response {
|
||||||
|
let window = crate::hive_stats::Window::parse(q.window.as_deref().unwrap_or("24h"));
|
||||||
|
axum::Json(crate::hive_stats::hive_snapshot(window)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct BuildLogsQuery {
|
struct BuildLogsQuery {
|
||||||
/// Maximum number of rows to return. Capped server-side at 50
|
/// Maximum number of rows to return. Capped server-side at 50
|
||||||
|
|
|
||||||
297
hive-c0re/src/hive_stats.rs
Normal file
297
hive-c0re/src/hive_stats.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
//! Hive-wide turn-stats aggregation for the dashboard's swarm stats
|
||||||
|
//! view. Reads every agent's per-agent
|
||||||
|
//! `hyperhive-turn-stats.sqlite` read-only and rolls the rows up into
|
||||||
|
//! swarm totals + a per-agent rollup + model mix + a *labelled*
|
||||||
|
//! cost estimate.
|
||||||
|
//!
|
||||||
|
//! Why re-read the rows here instead of reusing `hive-ag3nt`'s
|
||||||
|
//! `stats.rs`: that module lives in a different crate (the agent
|
||||||
|
//! harness) which hive-c0re can't import. The stable contract is the
|
||||||
|
//! turn-stats *schema*, so we run a focused query against it. If we
|
||||||
|
//! ever want a single source of truth, the row-read + aggregation can
|
||||||
|
//! be lifted into a shared crate — overkill for now.
|
||||||
|
//!
|
||||||
|
//! Privsep: the sqlite files are mode 0644 owned by the agent user;
|
||||||
|
//! `hive-core` reads them fine (same as `stats_vacuum`). We open
|
||||||
|
//! read-only so an in-flight harness writer never blocks us.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use rusqlite::{Connection, OpenFlags};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
|
/// Window accepted by `/api/stats-hive?window=`. Maps to a lookback
|
||||||
|
/// span; the hive view is a flat rollup (no per-bucket trend — the
|
||||||
|
/// per-agent `/stats` page owns the trend charts).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Window {
|
||||||
|
Hour,
|
||||||
|
FourHour,
|
||||||
|
Day,
|
||||||
|
ThreeDay,
|
||||||
|
Week,
|
||||||
|
Month,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Window {
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"1h" => Self::Hour,
|
||||||
|
"4h" => Self::FourHour,
|
||||||
|
"3d" => Self::ThreeDay,
|
||||||
|
"7d" => Self::Week,
|
||||||
|
"30d" => Self::Month,
|
||||||
|
_ => Self::Day,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Hour => "1h",
|
||||||
|
Self::FourHour => "4h",
|
||||||
|
Self::Day => "24h",
|
||||||
|
Self::ThreeDay => "3d",
|
||||||
|
Self::Week => "7d",
|
||||||
|
Self::Month => "30d",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn span_secs(self) -> i64 {
|
||||||
|
match self {
|
||||||
|
Self::Hour => 3600,
|
||||||
|
Self::FourHour => 4 * 3600,
|
||||||
|
Self::Day => 24 * 3600,
|
||||||
|
Self::ThreeDay => 3 * 24 * 3600,
|
||||||
|
Self::Week => 7 * 24 * 3600,
|
||||||
|
Self::Month => 30 * 24 * 3600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approximate USD price per **million** tokens, per model class.
|
||||||
|
/// Matched by substring against the model id. This is a deliberately
|
||||||
|
/// rough estimate — Anthropic list pricing drifts, so the dashboard
|
||||||
|
/// labels the figure as an estimate. A follow-up can wire it from a
|
||||||
|
/// nix option (like `contextWindowTokens`) instead of hard-coding.
|
||||||
|
struct Prices {
|
||||||
|
input: f64,
|
||||||
|
output: f64,
|
||||||
|
cache_read: f64,
|
||||||
|
cache_write: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_prices(model: &str) -> Prices {
|
||||||
|
let m = model.to_ascii_lowercase();
|
||||||
|
if m.contains("opus") {
|
||||||
|
Prices {
|
||||||
|
input: 15.0,
|
||||||
|
output: 75.0,
|
||||||
|
cache_read: 1.5,
|
||||||
|
cache_write: 18.75,
|
||||||
|
}
|
||||||
|
} else if m.contains("haiku") {
|
||||||
|
Prices {
|
||||||
|
input: 0.8,
|
||||||
|
output: 4.0,
|
||||||
|
cache_read: 0.08,
|
||||||
|
cache_write: 1.0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// sonnet + unknown fallback
|
||||||
|
Prices {
|
||||||
|
input: 3.0,
|
||||||
|
output: 15.0,
|
||||||
|
cache_read: 0.3,
|
||||||
|
cache_write: 3.75,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct KeyCount {
|
||||||
|
pub key: String,
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AgentRollup {
|
||||||
|
pub name: String,
|
||||||
|
pub turns: u64,
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub cache_read_tokens: u64,
|
||||||
|
pub cache_creation_tokens: u64,
|
||||||
|
/// Labelled estimate — see [`Prices`].
|
||||||
|
pub est_cost_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HiveStats {
|
||||||
|
pub window: &'static str,
|
||||||
|
pub from: i64,
|
||||||
|
pub now: i64,
|
||||||
|
/// Number of agents that had at least one turn in the window.
|
||||||
|
pub active_agents: u64,
|
||||||
|
pub total_turns: u64,
|
||||||
|
pub total_input_tokens: u64,
|
||||||
|
pub total_output_tokens: u64,
|
||||||
|
pub total_cache_read_tokens: u64,
|
||||||
|
pub total_cache_creation_tokens: u64,
|
||||||
|
/// Labelled estimate — see [`Prices`].
|
||||||
|
pub est_cost_usd: f64,
|
||||||
|
/// Per-agent rollup, busiest (most turns) first.
|
||||||
|
pub agents: Vec<AgentRollup>,
|
||||||
|
/// Turns per model across the whole swarm, busiest first.
|
||||||
|
pub model_mix: Vec<KeyCount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AgentAgg {
|
||||||
|
turns: u64,
|
||||||
|
input: u64,
|
||||||
|
output: u64,
|
||||||
|
cache_read: u64,
|
||||||
|
cache_creation: u64,
|
||||||
|
cost: f64,
|
||||||
|
models: HashMap<String, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||||
|
fn u64_from_i64(v: i64) -> u64 {
|
||||||
|
v.max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate one agent's turn-stats over `[from, now]`. Errors bubble
|
||||||
|
/// up so the caller can skip a single bad/locked db without failing
|
||||||
|
/// 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
|
||||||
|
FROM turn_stats
|
||||||
|
WHERE started_at >= ?1",
|
||||||
|
)?;
|
||||||
|
let mut agg = AgentAgg::default();
|
||||||
|
let rows = stmt.query_map([from], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
u64_from_i64(row.get::<_, i64>(1)?),
|
||||||
|
u64_from_i64(row.get::<_, i64>(2)?),
|
||||||
|
u64_from_i64(row.get::<_, i64>(3)?),
|
||||||
|
u64_from_i64(row.get::<_, i64>(4)?),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
for r in rows {
|
||||||
|
let (model, input, output, cache_read, cache_creation) = r?;
|
||||||
|
agg.turns += 1;
|
||||||
|
agg.input = agg.input.saturating_add(input);
|
||||||
|
agg.output = agg.output.saturating_add(output);
|
||||||
|
agg.cache_read = agg.cache_read.saturating_add(cache_read);
|
||||||
|
agg.cache_creation = agg.cache_creation.saturating_add(cache_creation);
|
||||||
|
let p = model_prices(&model);
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
{
|
||||||
|
agg.cost += (input as f64 * p.input
|
||||||
|
+ output as f64 * p.output
|
||||||
|
+ cache_read as f64 * p.cache_read
|
||||||
|
+ cache_creation as f64 * p.cache_write)
|
||||||
|
/ 1_000_000.0;
|
||||||
|
}
|
||||||
|
*agg.models.entry(model).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
Ok(agg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the swarm-wide rollup. Best-effort: a missing or unreadable
|
||||||
|
/// per-agent db is skipped (logged), never fatal.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hive_snapshot(window: Window) -> HiveStats {
|
||||||
|
let now = now_secs();
|
||||||
|
let from = now - window.span_secs();
|
||||||
|
|
||||||
|
let mut agents: Vec<AgentRollup> = Vec::new();
|
||||||
|
let mut model_mix: HashMap<String, u64> = HashMap::new();
|
||||||
|
let mut total_turns = 0u64;
|
||||||
|
let mut total_input = 0u64;
|
||||||
|
let mut total_output = 0u64;
|
||||||
|
let mut total_cache_read = 0u64;
|
||||||
|
let mut total_cache_creation = 0u64;
|
||||||
|
let mut total_cost = 0.0f64;
|
||||||
|
let mut active_agents = 0u64;
|
||||||
|
|
||||||
|
for name in Coordinator::kept_state_names() {
|
||||||
|
let path = Coordinator::agent_harness_dir(&name).join("hyperhive-turn-stats.sqlite");
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let agg = match read_agent(&path, from) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(agent = %name, error = ?e, "hive-stats: read failed; skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if agg.turns == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
active_agents += 1;
|
||||||
|
total_turns += agg.turns;
|
||||||
|
total_input = total_input.saturating_add(agg.input);
|
||||||
|
total_output = total_output.saturating_add(agg.output);
|
||||||
|
total_cache_read = total_cache_read.saturating_add(agg.cache_read);
|
||||||
|
total_cache_creation = total_cache_creation.saturating_add(agg.cache_creation);
|
||||||
|
total_cost += agg.cost;
|
||||||
|
for (m, c) in &agg.models {
|
||||||
|
*model_mix.entry(m.clone()).or_insert(0) += c;
|
||||||
|
}
|
||||||
|
agents.push(AgentRollup {
|
||||||
|
name,
|
||||||
|
turns: agg.turns,
|
||||||
|
input_tokens: agg.input,
|
||||||
|
output_tokens: agg.output,
|
||||||
|
cache_read_tokens: agg.cache_read,
|
||||||
|
cache_creation_tokens: agg.cache_creation,
|
||||||
|
est_cost_usd: agg.cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busiest agents first.
|
||||||
|
agents.sort_by(|a, b| b.turns.cmp(&a.turns).then_with(|| a.name.cmp(&b.name)));
|
||||||
|
|
||||||
|
let mut model_mix: Vec<KeyCount> = model_mix
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, count)| KeyCount { key, count })
|
||||||
|
.collect();
|
||||||
|
model_mix.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key)));
|
||||||
|
|
||||||
|
HiveStats {
|
||||||
|
window: window.label(),
|
||||||
|
from,
|
||||||
|
now,
|
||||||
|
active_agents,
|
||||||
|
total_turns,
|
||||||
|
total_input_tokens: total_input,
|
||||||
|
total_output_tokens: total_output,
|
||||||
|
total_cache_read_tokens: total_cache_read,
|
||||||
|
total_cache_creation_tokens: total_cache_creation,
|
||||||
|
est_cost_usd: total_cost,
|
||||||
|
agents,
|
||||||
|
model_mix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ pub mod events_vacuum;
|
||||||
pub mod flake_check;
|
pub mod flake_check;
|
||||||
pub mod forge;
|
pub mod forge;
|
||||||
pub mod gateway_nginx;
|
pub mod gateway_nginx;
|
||||||
|
pub mod hive_stats;
|
||||||
pub mod knowledge;
|
pub mod knowledge;
|
||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
pub mod limits;
|
pub mod limits;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue