Watch
0
0
Fork
You've already forked hyperhive
0

remove the hive dashboard's stats page and the price table behind it

The ST4TS page (/stats.html), GET /api/stats-hive, hive-c0re's hive_stats
rollup and the modelPrices option that only fed its cost estimate are
gone. Each agent's own /stats page and the turn-stats database it reads
are unchanged. Setting modelPrices now fails evaluation with a message
saying so.
This commit is contained in:
iris 2026-11-02 23:15:51 +01:00 • committed by mara
commit 5e9a22cb38
32 changed files with 112 additions and 1207 deletions

View file

@ -12,8 +12,7 @@ what you actually do here.
Everything starts at the **dashboard**, served at `/`. A pages menu Everything starts at the **dashboard**, served at `/`. A pages menu
(the link icon beside the profile avatar, at the right of the tab strip) links to the other (the link icon beside the profile avatar, at the right of the tab strip) links to the other
surfaces — Stats, the API docs and the swarm UI — and each of surfaces — the API docs and the swarm UI.
those pages links back to the dashboard.
The dashboard is where you'll spend most of your time. It's a single The dashboard is where you'll spend most of your time. It's a single
page with four tabs: page with four tabs:
@ -25,9 +24,6 @@ page with four tabs:
- **K3PT ST4T3** — kept state from agents with no container - **K3PT ST4T3** — kept state from agents with no container
(tombstones), with a purge button per entry. (tombstones), with a purge button per entry.
Usage stats live on their own page, reached from the pages menu:
**Stats** (`/stats.html`, swarm-wide usage stats).
Each agent also has its own page — a full terminal view of that Each agent also has its own page — a full terminal view of that
agent's session, reachable by selecting its name anywhere in the agent's session, reachable by selecting its name anywhere in the
dashboard, or directly at `/agent/<name>/` (or `http://<host>:<port>/` dashboard, or directly at `/agent/<name>/` (or `http://<host>:<port>/`

View file

@ -12,15 +12,13 @@ hive-c0re serves the dashboard at `/`.
It has a fixed chrome header at the top and a `<main>` that shows exactly It has a fixed chrome header at the top and a `<main>` that shows exactly
one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`, one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
`#kept`) drives which pane is `#kept`) drives which pane is
active; hash changes don't reload the page. ST4TS active; hash changes don't reload the page.
is a standalone page reached from the pages menu, not from the
dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane): **Chrome header** (fixed, overlays the active tab pane):
- **Pages menu**: a link icon at the right end of the chrome, beside the - **Pages menu**: a link icon at the right end of the chrome, beside the
profile avatar (`links-menu.js`, `@hive/shared`'s `LinksMenu`, the same profile avatar (`links-menu.js`, `@hive/shared`'s `LinksMenu`, the same
component the swarm UI uses for its service links), opening a popover with links to **stats** component the swarm UI uses for its service links), opening a popover with links to **api docs**
(`/stats.html`), **api docs** (`/api/docs/`) and **swarm** (the swarm (`/api/docs/`) and **swarm** (the swarm
UI at the swarm apex domain, opened in a new tab). The swarm entry UI at the swarm apex domain, opened in a new tab). The swarm entry
comes from `state.swarm_public_url`, which hive-c0re reads from comes from `state.swarm_public_url`, which hive-c0re reads from
`HIVE_SWARM_PUBLIC_URL` (set from `services.hyperhive.swarm.domain`); `HIVE_SWARM_PUBLIC_URL` (set from `services.hyperhive.swarm.domain`);
@ -35,8 +33,7 @@ dashboard tab strip.
If the name doesn't load the avatar shows a question mark, and without the URL the If the name doesn't load the avatar shows a question mark, and without the URL the
popover has no links. popover has no links.
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, - **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`,
`◆ K3PT ST4T3 ◆`. In-page tabs only — ST4TS `◆ K3PT ST4T3 ◆`.
lives on its own page, reached from the pages menu.
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`) - **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip. — sits below the tab strip.
- **No warning banner.** No page shows hive-level warnings (host disk - **No warning banner.** No page shows hive-level warnings (host disk
@ -165,48 +162,6 @@ A build's output and history aren't on this page. hive-c0re logs every container
to the journal, and the swarm's `builds` Grafana board reads those lines: the builds with to the journal, and the swarm's `builds` Grafana board reads those lines: the builds with
their outcome and runtime, and the output of the one you pick. their outcome and runtime, and the output of the one you pick.
## ST4TS page (`/stats.html`)
Hive-wide turn statistics, aggregated across every agent's
`hyperhive-turn-stats.sqlite` for the selected window. Not a dashboard
tab — a standalone page reached from the dashboard's pages menu,
minimal chrome (a back-link and a title). Distinct from
each agent's own `/stats` page (which carries the per-agent trend
charts): ST4TS is the swarm-level rollup.
- **Window selector** (`1h`, `4h`, `24h`, `3d`, `7d`, `30d`, `all`) —
a hash-routed tab strip (`#1h` / `#24h` / …) — 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.
- **Favorite tools** — most-run normalised bash-command heads across the
swarm (top 10, as CSS bars), aggregated from each agent's
`bash_commands` table (written by the hive-bash-daemon capture). The
header + list stay hidden until at least one agent has recorded a
command, so the section never shows an empty block on a fresh hive.
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, skipping missing / unreadable / zero-turn dbs so one
bad db never fails the endpoint. This is a **pull** surface (no SSE):
the page fetches the data on page load 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 a per-model
price table (`est_cost_usd`); it drifts with list pricing and the page
labels it accordingly. The table is operator-tunable via the
`services.hyperhive.deploy.hive-controller.modelPrices` nix option — each key is a
model-family short name (matched case-insensitively as a substring of
the model id, longest match wins) mapping to
`{ input, output, cache_read, cache_write }` USD-per-million-token
prices. Models not covered fall back to hive-c0re's built-in estimate.
## Container row ## Container row
A full-height **square agent icon** (5em, capped) on the left. The A full-height **square agent icon** (5em, capped) on the left. The
@ -509,12 +464,6 @@ below — some endpoints aren't in it yet.
build logs. build logs.
- `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|all>` — 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 /api/cancel-reminder/{id}` — hard-delete a pending reminder. - `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery - `POST /api/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).

View file

@ -16,13 +16,10 @@
- `GET /static/*` → bundled CSS + JS produced by esbuild - `GET /static/*` → bundled CSS + JS produced by esbuild
(`frontend/packages/{dashboard,agent}/build.mjs`). Both pages (`frontend/packages/{dashboard,agent}/build.mjs`). Both pages
pull the shared terminal pane + Catppuccin palette + typography pull the shared terminal pane + Catppuccin palette + typography
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one from `@hive/shared` (was `hive-fr0nt`); the dashboard ships
CSS bundle per page (`colors.css` + `theme.css` + `common.css`, `colors.css` + `theme.css` + `common.css` + `dashboard.css`;
loaded by every page, plus a page-specific bundle —
`dashboard.css` /
`stats.css`);
`common.css` inlines `@hive/shared`'s `common.css` inlines `@hive/shared`'s
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` + `base.css` + `terminal.css` + `tabs.css` +
`pill.css` via esbuild's `@import` resolution. `pill.css` via esbuild's `@import` resolution.
`terminal.js` exports `{ create, linkify }` as ES module `terminal.js` exports `{ create, linkify }` as ES module
members; a back-compat shim in the IIFE bodies still exposes members; a back-compat shim in the IIFE bodies still exposes

View file

@ -1,6 +1,5 @@
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (the // esbuild build for @hive/dashboard: one JS+CSS bundle per page (the
// dashboard at `/`, stats at its own // dashboard at `/`), each named after its own entry point below — see the
// `.html`), each named after its own entry point below — see the
// `entryPoints`/`for` lists for the exact map, not repeated here to // `entryPoints`/`for` lists for the exact map, not repeated here to
// avoid this comment drifting out of sync with the real build steps. // avoid this comment drifting out of sync with the real build steps.
// //
@ -29,7 +28,7 @@ mkdirSync(staticDir(""), { recursive: true });
// follow-up once asset sizes warrant it). esbuild writes each entry // follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename. // to `static/<name>.js` based on the entryPoint basename.
await build({ await build({
entryPoints: [src("tabs.js"), src("stats.js")], entryPoints: [src("tabs.js")],
outdir: staticDir(""), outdir: staticDir(""),
bundle: true, bundle: true,
format: "esm", format: "esm",
@ -58,7 +57,7 @@ await build({
// Stream-worker entry. Lives in a separate bundle: SharedWorker // Stream-worker entry. Lives in a separate bundle: SharedWorker
// scripts run in a different global (`self` is the worker scope, no // scripts run in a different global (`self` is the worker scope, no
// `window`) so they can't be inlined into tabs.js / stats.js. Output is // `window`) so they can't be inlined into tabs.js. Output is
// at `static/stream-worker.js`; common.js's `openStream` references // at `static/stream-worker.js`; common.js's `openStream` references
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'` // `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
// matches the classic-script load (`new SharedWorker(url, name)` with // matches the classic-script load (`new SharedWorker(url, name)` with
@ -89,7 +88,6 @@ for (const entry of [
"theme.css", "theme.css",
"common.css", "common.css",
"dashboard.css", "dashboard.css",
"stats.css",
]) { ]) {
await build({ await build({
entryPoints: [src(entry)], entryPoints: [src(entry)],
@ -100,8 +98,6 @@ for (const entry of [
}); });
} }
for (const html of ["index.html", "stats.html"]) { copyFileSync(src("index.html"), dist("index.html"));
copyFileSync(src(html), dist(html));
}
console.log("dashboard build ok →", dist("")); console.log("dashboard build ok →", dist(""));

View file

@ -3,7 +3,6 @@
@import "@hive/shared/base.css"; @import "@hive/shared/base.css";
@import "@hive/shared/terminal.css"; @import "@hive/shared/terminal.css";
@import "@hive/shared/tabs.css"; @import "@hive/shared/tabs.css";
@import "@hive/shared/chrome.css";
/* Shared pill/chip/badge shape (the "extract pill component" issue, /* Shared pill/chip/badge shape (the "extract pill component" issue,
resolved as shared CSS rather than a JS component) — see pill.css resolved as shared CSS rather than a JS component) — see pill.css
itself for the classnames it covers. */ itself for the classnames it covers. */
@ -271,8 +270,7 @@ code {
carries the horizontal gutter. Full-width chrome (the tab strip, the carries the horizontal gutter. Full-width chrome (the tab strip, the
footer) then spans edge-to-edge for free, footer) then spans edge-to-edge for free,
with no negative-margin breakout hacks. Only matches on pages that opt with no negative-margin breakout hacks. Only matches on pages that opt
in by adding the wrapper (index.html); ST4TS in by adding the wrapper (index.html). */
is full-bleed with its own `.<page>-main` padding. */
.page-content { .page-content {
padding: 0 1.5em; padding: 0 1.5em;
} }
@ -285,31 +283,3 @@ code {
top: 0; top: 0;
z-index: 50; z-index: 50;
} }
/* ─── stats table ─────────────────────────────────────────────────
Shared right-aligned numeric table. Used by /stats.html (busiest
agents) — moved here from dashboard.css when ST4TS became its own
page. */
.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;
}

View file

@ -600,7 +600,3 @@ footer .banner-thin {
body.dashboard-shell.has-selection { body.dashboard-shell.has-selection {
padding-bottom: 4.5em; padding-bottom: 4.5em;
} }
/* ST4TS moved to its own page (`/stats.html`): the
window selector / summary chips / bars live in stats.css, and the
shared `.hive-stats-table` moved to common.css. */

View file

@ -55,15 +55,10 @@
<!-- (Peer hives are no longer a tab — they render as a headline <!-- (Peer hives are no longer a tab — they render as a headline
under 4G3NTS. See tab-pane-agents.) --> under 4G3NTS. See tab-pane-agents.) -->
<!-- ST4TS is a standalone page reached from the pages
menu at the right of this strip, not tabs — the strip holds only
in-page tabs. -->
</nav> </nav>
<!-- The pages menu and the profile menu, together at the right. <!-- The pages menu and the profile menu, together at the right.
Pages menu: the standalone pages (stats, API docs) and the swarm UI, Pages menu: the API docs and the swarm UI, filled by links-menu.js
filled by links-menu.js from /api/state; the pages link back to `/`, from /api/state. Profile menu: the signed-in user, filled by
which is this dashboard. Profile menu: the signed-in user, filled by
user-menu.js. --> user-menu.js. -->
<div class="dashboard-chrome-actions"> <div class="dashboard-chrome-actions">
<div class="links-menu-slot" id="links-menu"></div> <div class="links-menu-slot" id="links-menu"></div>
@ -148,11 +143,6 @@
<p class="meta">loading…</p> <p class="meta">loading…</p>
</div> </div>
</section> </section>
<!-- ST4TS lives on its own page (`/stats.html`).
Its DOM only exists there — when tabs.js boots
on this page the corresponding renderers no-op silently (each
guard is `if (!el) return`). -->
</main> </main>
</div> </div>
<!-- /.page-content --> <!-- /.page-content -->

View file

@ -1,6 +1,6 @@
// Pages menu in the dashboard chrome: one icon button, beside the profile // Pages menu in the dashboard chrome: one icon button, beside the profile
// avatar at the right, that opens a popover linking the standalone pages // avatar at the right, that opens a popover linking the standalone pages
// (stats, API docs) and the swarm UI. It is `@hive/shared`'s `LinksMenu`, the // (API docs) and the swarm UI. It is `@hive/shared`'s `LinksMenu`, the
// same component the swarm UI mounts for its service links, rendered with // same component the swarm UI mounts for its service links, rendered with
// Preact like the profile menu. The swarm entry only exists when `/api/state` // Preact like the profile menu. The swarm entry only exists when `/api/state`
// carries `swarm_public_url`, so a hive without a configured swarm domain // carries `swarm_public_url`, so a hive without a configured swarm domain
@ -10,11 +10,10 @@ import { h, render } from "preact";
import { LinksMenu } from "@hive/shared/links-menu.js"; import { LinksMenu } from "@hive/shared/links-menu.js";
import { $ } from "./common.js"; import { $ } from "./common.js";
/** Menu entries for a state snapshot. The two pages open in place; the swarm /** Menu entries for a state snapshot. The API docs open in place; the swarm
* UI is another site, so it opens in a new tab. */ * UI is another site, so it opens in a new tab. */
export function menuLinks(state) { export function menuLinks(state) {
const links = [ const links = [
{ icon: "📊", label: "stats", url: "/stats.html", external: false },
{ icon: "🧬", label: "api docs", url: "/api/docs/", external: false }, { icon: "🧬", label: "api docs", url: "/api/docs/", external: false },
]; ];
if (state && state.swarm_public_url) { if (state && state.swarm_public_url) {

View file

@ -1,97 +0,0 @@
/* ─── /stats.html — hive-wide turn-stats rollup ────────────────────
Extracted from the dashboard ST4TS tab. Minimal chrome:
the back-link header +
title (.page-header / .page-back / .page-title) live in
@hive/shared/chrome.css.
`.hive-stats-table` lives in common.css. Only the ST4TS-specific
window selector, summary chips, and bars are page-specific. */
body.stats-shell {
margin: 0;
padding: 0;
}
.stats-main {
padding: 1.2em 1.5em 2em;
}
/* Window selector — now lives in the page-header (same row as ← home /
ST4TS title). Fills the remaining header space and right-aligns the
buttons. The shared `.btn` pill look is kept; createTabStrip flags the
active one with `.hive-tab--active`. */
.hive-stats-windows {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
min-width: 0;
justify-content: flex-end;
/* Smaller than the default .btn so the pills fit comfortably in the
header without competing visually with the page title. */
font-size: 0.82em;
}
/* Inactive buttons: slightly muted to distinguish from the active window,
but readable even on low-contrast operator colour schemes. */
.hive-stats-windows .btn {
color: var(--subtext1, var(--muted));
border-color: var(--border);
font-size: 1em; /* relative to the .hive-stats-windows 0.82em */
}
.hive-stats-windows .btn.hive-tab--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-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;
}

View file

@ -1,63 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // ST4TS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/stats.css" />
</head>
<body class="stats-shell">
<!-- Minimal chrome: back link + title —
no full dashboard tabbar. Back link points to the
dashboard (served at /). -->
<header class="page-header">
<a class="page-back" href="/">← dashboard</a>
<span class="page-title">ST4TS</span>
<!-- Window selector lives in the header so it behaves like a tab strip.
Hash-routed via createTabStrip. -->
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
<button type="button" class="btn" data-tab="1h">1h</button>
<button type="button" class="btn" data-tab="4h">4h</button>
<button type="button" class="btn" data-tab="24h">24h</button>
<button type="button" class="btn" data-tab="3d">3d</button>
<button type="button" class="btn" data-tab="7d">7d</button>
<button type="button" class="btn" data-tab="30d">30d</button>
<button type="button" class="btn" data-tab="all">all</button>
</nav>
</header>
<main class="stats-main">
<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-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>
<!-- "favorite tools": most-run bash commands across the swarm.
Header + list hidden until the hive-bash-daemon capture has
recorded data, so the section never shows an empty block. -->
<h3 id="hive-stats-bash-h" hidden>
◇ favorite tools (bash commands across the swarm)
</h3>
<div id="hive-stats-bash" hidden></div>
<!-- most-triggered skills across the swarm. Header + list hidden until
at least one agent has actually invoked a skill. -->
<h3 id="hive-stats-skills-h" hidden>
◇ skill mix (invocations across the swarm)
</h3>
<div id="hive-stats-skills" hidden></div>
</main>
<script type="module" src="/static/stats.js" defer></script>
</body>
</html>

View file

@ -1,189 +0,0 @@
// /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 { $, initStickyTop } 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);
}
// Shared bar-list renderer for any `KeyCount[]` mix (model/bash/skill).
function renderKeyCountBars(container, mix) {
container.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);
container.append(row);
}
}
// A mix section that hides itself (+ its header) until the backing
// capture has recorded data, so the section never shows an empty block
// while data is pre-capture. Used by bash_mix and skill_mix.
function renderOptionalMix(containerId, headerId, mix) {
const el = $(containerId);
const h = $(headerId);
if (!el) return;
if (!mix.length) {
el.replaceChildren();
el.hidden = true;
if (h) h.hidden = true;
return;
}
el.hidden = false;
if (h) h.hidden = false;
renderKeyCountBars(el, mix);
}
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 renderKeyCountBars(mm, mix);
}
// "favorite tools": most-run bash commands across the swarm.
renderOptionalMix("hive-stats-bash", "hive-stats-bash-h", s.bash_mix || []);
// Most-triggered skills across the swarm.
renderOptionalMix(
"hive-stats-skills",
"hive-stats-skills-h",
s.skill_mix || [],
);
}
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);
}
}
initStickyTop();
// 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();
},
});

View file

@ -258,9 +258,8 @@ import { renderUserMenu } from "./user-menu.js";
// ─── tab routing ─────────────────────────────────────────────────────── // ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#kept` // Hash-based: `#agents` / `#rebuild` / `#kept`
// activate the matching pane on the dashboard. Empty // activate the matching pane on the dashboard. Empty
// hash defaults to 4G3NTS. ST4TS is NOT a tab — it is a separate page // hash defaults to 4G3NTS. Tab routing only applies when the tab DOM is
// (`/stats.html`) reached from the pages menu (links-menu.js). Tab routing // present.
// only applies when the tab DOM is present.
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the // The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
// tab/pane toggle + aria-selected + routing; `activateTab` runs the // tab/pane toggle + aria-selected + routing; `activateTab` runs the
// per-tab side-effects via the strip's onShow. Constructed below (after // per-tab side-effects via the strip's onShow. Constructed below (after

View file

@ -1,9 +1,8 @@
/* ─── page chrome: back-link header ──────────────────────────────── /* ─── page chrome: back-link header ────────────────────────────────
The minimal sticky "frosted bar" header used by every standalone page The minimal sticky "frosted bar" header used by every standalone page of
reached from the dashboard's pages menu — /stats. A back an agent's own UI (/stats, /screen). A back link on the left, then a page
link to the dashboard on the left, then a page title and, on /stats, a title and, on /stats, a sub-tab nav. The operator dashboard uses its own
sub-tab nav. The operator dashboard uses its own richer richer sticky chrome (`.dashboard-chrome`); these classes cover the simple
sticky chrome (`.dashboard-chrome`); these classes cover the simple
pages. pages.
Shared across packages via @hive/shared so a new page only links the Shared across packages via @hive/shared so a new page only links the

View file

@ -1,6 +1,6 @@
// What the links menu shows for one link, without a DOM: where it opens and // What the links menu shows for one link, without a DOM: where it opens and
// what it is called. Shared by the swarm UI (its `/api/links`) and the hive // what it is called. Shared by the swarm UI (its `/api/links`) and the hive
// dashboard (its stats and API docs pages, and the swarm UI). // dashboard (its API docs page and the swarm UI).
/** One entry of the links menu. */ /** One entry of the links menu. */
export interface MenuLink { export interface MenuLink {

View file

@ -17,7 +17,7 @@ test("a link the menu was handed opens in a new tab, without a referrer", () =>
test("a page of the same site opens in place", () => { test("a page of the same site opens in place", () => {
assert.deepEqual( assert.deepEqual(
linkAttrs({ label: "stats", url: "/stats.html", external: false }), linkAttrs({ label: "api docs", url: "/api/docs/", external: false }),
{}, {},
); );
}); });

View file

@ -11,8 +11,8 @@
//! `note` (failure detail for `result_kind = "failed"`). //! `note` (failure detail for `result_kind = "failed"`).
//! //!
//! Lives in the harness dir (`HYPERHIVE_HARNESS_DIR`) as //! Lives in the harness dir (`HYPERHIVE_HARNESS_DIR`) as
//! `hyperhive-turn-stats.sqlite`; the dashboard reads it read-only and no //! `hyperhive-turn-stats.sqlite`; the agent's own `/stats` page reads it
//! vacuum sweeps it. Schema is //! and no vacuum sweeps it. Schema is
//! intentionally append-only — every column has a default so future //! intentionally append-only — every column has a default so future
//! additions don't break old readers; new columns land via //! additions don't break old readers; new columns land via
//! `ALTER TABLE ... ADD COLUMN ... DEFAULT ...` in the migration //! `ALTER TABLE ... ADD COLUMN ... DEFAULT ...` in the migration
@ -164,8 +164,8 @@ impl TurnStats {
} }
let conn = Connection::open(path) let conn = Connection::open(path)
.with_context(|| format!("open turn_stats db {}", path.display()))?; .with_context(|| format!("open turn_stats db {}", path.display()))?;
// Not WAL: hive-c0re reads this db read-only as another uid, which // Rollback journal, not WAL: the default, and what every existing
// fails on a WAL database whose sidecars it cannot create. // database already uses.
conn.busy_timeout(crate::sqlite_writer::BUSY_TIMEOUT) conn.busy_timeout(crate::sqlite_writer::BUSY_TIMEOUT)
.context("set turn_stats busy_timeout")?; .context("set turn_stats busy_timeout")?;
conn.execute_batch(SCHEMA) conn.execute_batch(SCHEMA)

View file

@ -4,7 +4,7 @@
//! //!
//! Best-effort: a missing db, a lock, or a parse miss is logged and //! Best-effort: a missing db, a lock, or a parse miss is logged and
//! swallowed — recording a stat never affects task execution. The //! swallowed — recording a stat never affects task execution. The
//! read/aggregation side lives host-side in `hive-c0re` and reads the //! read/aggregation side is the agent harness's `stats.rs`, which reads the
//! same `bash_commands(ts INTEGER, head TEXT)` rows. //! same `bash_commands(ts INTEGER, head TEXT)` rows.
//! //!
//! Normalisation is deliberately rough (quoting-naive) — it just needs //! Normalisation is deliberately rough (quoting-naive) — it just needs

View file

@ -69,13 +69,6 @@ pub struct Coordinator {
/// Same drop-in, same relative-share and `None` semantics as /// Same drop-in, same relative-share and `None` semantics as
/// [`Self::agent_cpu_weight`]. /// [`Self::agent_cpu_weight`].
pub agent_io_weight: Option<u32>, pub agent_io_weight: Option<u32>,
/// Operator-tunable model→price table backing the hive-wide cost
/// estimate on the ST4TS tab. Set via `services.hyperhive.modelPrices`
/// and passed to `hive-c0re serve --model-prices <json>`. Models not
/// in the table fall back to the built-in estimate in `hive_stats`.
/// hive-c0re-local (read only by the `/api/stats-hive` handler), so it
/// is *not* part of `HiveEnv` and is never injected into containers.
pub model_prices: crate::hive_stats::PriceTable,
agents: Mutex<HashMap<String, AgentSocket>>, agents: Mutex<HashMap<String, AgentSocket>>,
/// Tombstone for transients that have JUST been cleared. The /// Tombstone for transients that have JUST been cleared. The
/// crash watcher polls every 10s and would race the /// crash watcher polls every 10s and would race the
@ -215,9 +208,9 @@ impl Default for HiveEnv {
/// On-disk shape of the `hive-c0re serve --config <file>` JSON: the /// On-disk shape of the `hive-c0re serve --config <file>` JSON: the
/// container-injected [`HiveEnv`] (flattened) plus the hive-c0re-local /// container-injected [`HiveEnv`] (flattened) plus the hive-c0re-local
/// `model_prices` table (read only by the `/api/stats-hive` handler, never /// settings that are never injected into containers — hence kept out of
/// injected into containers — hence kept out of `HiveEnv`). The NixOS /// `HiveEnv`. The NixOS module writes this so the `ExecStart` carries one
/// module writes this so the `ExecStart` carries one `--config` flag /// `--config` flag
/// instead of every host-level setting as its own JSON-blob argument. /// instead of every host-level setting as its own JSON-blob argument.
/// `#[serde(default)]` lets any field be omitted and fall back to its /// `#[serde(default)]` lets any field be omitted and fall back to its
/// canonical default. /// canonical default.
@ -226,10 +219,9 @@ impl Default for HiveEnv {
pub struct ServeConfig { pub struct ServeConfig {
#[serde(flatten)] #[serde(flatten)]
pub env: HiveEnv, pub env: HiveEnv,
pub model_prices: crate::hive_stats::PriceTable,
/// Number of concurrent closure-fetching job-queue nodes (prebuild / /// Number of concurrent closure-fetching job-queue nodes (prebuild /
/// profile-swap / create / meta lock). hive-c0re-local like /// profile-swap / create / meta lock). hive-c0re-local — never
/// `model_prices` — never injected into containers. Set via /// injected into containers. Set via
/// `services.hyperhive.deploy.hive-controller.buildSlots`. /// `services.hyperhive.deploy.hive-controller.buildSlots`.
pub build_slots: usize, pub build_slots: usize,
} }
@ -238,7 +230,6 @@ impl Default for ServeConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
env: HiveEnv::default(), env: HiveEnv::default(),
model_prices: crate::hive_stats::PriceTable::default(),
build_slots: 1, build_slots: 1,
} }
} }
@ -250,16 +241,14 @@ mod serve_config_tests {
#[test] #[test]
fn deserialises_flat_with_per_field_defaults() { fn deserialises_flat_with_per_field_defaults() {
// The flattened HiveEnv fields + the local model_prices all live at // The flattened HiveEnv fields + the local build_slots all live at
// the JSON top level. Present fields are read; omitted ones fall // the JSON top level. Present fields are read; omitted ones fall
// back to their canonical defaults (so a partial config is valid). // back to their canonical defaults (so a partial config is valid).
let sc: ServeConfig = serde_json::from_str( let sc: ServeConfig = serde_json::from_str(
r#"{ r#"{
"hyperhive_flake": "/x", "hyperhive_flake": "/x",
"dashboard_port": 1234, "dashboard_port": 1234,
"model_prices": { "build_slots": 3
"opus": {"input": 5.0, "output": 25.0, "cache_read": 0.5, "cache_write": 10.0}
}
}"#, }"#,
) )
.expect("flat ServeConfig should deserialise"); .expect("flat ServeConfig should deserialise");
@ -267,7 +256,15 @@ mod serve_config_tests {
assert_eq!(sc.env.dashboard_port, 1234); assert_eq!(sc.env.dashboard_port, 1234);
// omitted → HiveEnv::default() // omitted → HiveEnv::default()
assert_eq!(sc.env.agent_cpu_quota, "200%"); assert_eq!(sc.env.agent_cpu_quota, "200%");
assert!(sc.model_prices.contains_key("opus")); assert_eq!(sc.build_slots, 3);
}
#[test]
fn a_leftover_model_prices_key_is_ignored() {
// Configs written before the hive stats page went carry the key.
let sc: ServeConfig = serde_json::from_str(r#"{"model_prices": {"opus": {"input": 5.0}}}"#)
.expect("an unknown key must not break startup");
assert_eq!(sc.build_slots, 1);
} }
#[test] #[test]
@ -275,7 +272,7 @@ mod serve_config_tests {
let sc: ServeConfig = serde_json::from_str("{}").expect("empty config valid"); let sc: ServeConfig = serde_json::from_str("{}").expect("empty config valid");
assert_eq!(sc.env.dashboard_port, 7000); assert_eq!(sc.env.dashboard_port, 7000);
assert_eq!(sc.env.hyperhive_flake, "/etc/hyperhive"); assert_eq!(sc.env.hyperhive_flake, "/etc/hyperhive");
assert!(sc.model_prices.is_empty()); assert_eq!(sc.build_slots, 1);
} }
} }
@ -336,12 +333,7 @@ impl std::fmt::Display for SetPausedByNameError {
} }
impl Coordinator { impl Coordinator {
pub fn open( pub fn open(db_path: &Path, env: HiveEnv, build_slots: usize) -> Result<Self> {
db_path: &Path,
env: HiveEnv,
model_prices: crate::hive_stats::PriceTable,
build_slots: usize,
) -> Result<Self> {
let HiveEnv { let HiveEnv {
hyperhive_flake, hyperhive_flake,
claude_code_path, claude_code_path,
@ -386,7 +378,6 @@ impl Coordinator {
agent_memory_max, agent_memory_max,
agent_cpu_weight, agent_cpu_weight,
agent_io_weight, agent_io_weight,
model_prices,
agents: Mutex::new(HashMap::new()), agents: Mutex::new(HashMap::new()),
recent_transient: Mutex::new(HashMap::new()), recent_transient: Mutex::new(HashMap::new()),
graceful_stop_pending: Mutex::new(HashSet::new()), graceful_stop_pending: Mutex::new(HashSet::new()),
@ -1072,13 +1063,7 @@ mod open_tests {
use super::{Coordinator, HiveEnv}; use super::{Coordinator, HiveEnv};
fn open(db: &std::path::Path) -> Coordinator { fn open(db: &std::path::Path) -> Coordinator {
Coordinator::open( Coordinator::open(db, HiveEnv::default(), 1).expect("open coordinator")
db,
HiveEnv::default(),
crate::hive_stats::PriceTable::new(),
1,
)
.expect("open coordinator")
} }
/// A build log row the previous process left open is closed as failed /// A build log row the previous process left open is closed as failed

View file

@ -1,45 +1,15 @@
//! Remaining single-endpoint dashboard handlers: mark-all-read, //! Remaining single-endpoint dashboard handlers: mark-all-read and
//! spawn-request and hive-wide turn stats. //! spawn-request.
use axum::{ use axum::{
extract::{Path as AxumPath, State}, extract::{Path as AxumPath, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use serde::{Deserialize, Serialize}; use serde::Serialize;
use utoipa::{IntoParams, ToSchema}; use utoipa::ToSchema;
use super::{AppState, Ident, error_response}; use super::{AppState, Ident, error_response};
use crate::hive_stats::HiveStats;
#[derive(Deserialize, IntoParams)]
pub(super) struct StatsHiveQuery {
/// Stats window; defaults to `24h`.
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).
#[utoipa::path(
get,
path = "/api/stats-hive",
params(StatsHiveQuery),
responses((status = 200, description = "hive-wide turn-stats rollup", body = HiveStats)),
tag = "misc_api"
)]
pub(super) async fn api_stats_hive(
State(state): State<AppState>,
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,
&state.coord.model_prices,
))
.into_response()
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub(super) struct MarkAllReadBody { pub(super) struct MarkAllReadBody {

View file

@ -103,7 +103,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(health::get_health_live)) .routes(routes!(health::get_health_live))
.routes(routes!(health::get_health_ready)) .routes(routes!(health::get_health_ready))
.routes(routes!(state_snapshot::api_state)) .routes(routes!(state_snapshot::api_state))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(build_logs::get_build_logs_all)) .routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node)) .routes(routes!(build_logs::get_build_log_for_node))
@ -264,7 +263,6 @@ mod tests {
let coord = Coordinator::open( let coord = Coordinator::open(
&dir.path().join("broker.sqlite"), &dir.path().join("broker.sqlite"),
crate::coordinator::HiveEnv::default(), crate::coordinator::HiveEnv::default(),
crate::hive_stats::PriceTable::new(),
1, 1,
) )
.expect("open coordinator"); .expect("open coordinator");
@ -352,7 +350,6 @@ mod router_build_probe {
.routes(routes!(health::get_health_live)) .routes(routes!(health::get_health_live))
.routes(routes!(health::get_health_ready)) .routes(routes!(health::get_health_ready))
.routes(routes!(state_snapshot::api_state)) .routes(routes!(state_snapshot::api_state))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(build_logs::get_build_logs_all)) .routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node)) .routes(routes!(build_logs::get_build_log_for_node))

Some files were not shown because too many files have changed in this diff Show more