From d3f90f4cc0e4222e5bd0d8389fd5f3848a107ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Tue, 19 May 2026 00:27:01 +0200 Subject: [PATCH] stats: per-agent /stats page with chart.js trends + breakdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new hive-ag3nt::stats module reads turn_stats.sqlite read-only and aggregates over 24h/7d/30d windows (hourly/daily buckets) β€” turn rate, p50/p95/avg duration, ctx tokens (avg/max), cost token components, top tools, wake mix, result mix. served by the agent itself so per-MCP extensions can register more providers without the host knowing their schemas. /stats route + /api/stats?window=... on the per-agent web ui. chart.js v4.4.4 pulled from jsdelivr (SRI hash deferred). nav links: πŸ“Š chip on the dashboard container row + πŸ“Š stats β†’ on the per-agent header. todo housekeeping: softened damocles-area note at the top, new reverse-proxy + deferred reminder-rollup items, removed the two telemetry-ui items absorbed by this page. --- TODO.md | 10 +- hive-ag3nt/assets/index.html | 1 + hive-ag3nt/assets/stats.html | 76 ++++++ hive-ag3nt/assets/stats.js | 308 +++++++++++++++++++++ hive-ag3nt/src/lib.rs | 1 + hive-ag3nt/src/stats.rs | 500 +++++++++++++++++++++++++++++++++++ hive-ag3nt/src/web_ui.rs | 30 +++ hive-c0re/assets/app.js | 7 + 8 files changed, 930 insertions(+), 3 deletions(-) create mode 100644 hive-ag3nt/assets/stats.html create mode 100644 hive-ag3nt/assets/stats.js create mode 100644 hive-ag3nt/src/stats.rs diff --git a/TODO.md b/TODO.md index 56c0ce6..44b929f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,10 @@ # Hyperhive TODOs +> **Rough split for who picks up what:** harness ergonomics + host-side +> harness plumbing tend to be damocles' interest area; UI-polish work +> for the operator is not. Use that as a hint when picking up items, +> not a hard rule. + ## Architecture / Features - Shared space for all agents to access documents/files without manager routing @@ -18,7 +23,8 @@ ## Dashboard -- **Delivered-reminder rollups**: per-agent delivered-count chip (last 24h) + histogram of attempts-vs-successes on the container row. Needs `Broker::count_delivered_reminders_since(agent, ts)` (cheap COUNT against the `reminders` table, `WHERE agent = ?1 AND sent_at >= ?2`). +- **Unified URL scheme via reverse proxy**: today every agent's web UI is reached at `:/`, so operators juggle a port list. Stand up nginx (or similar) terminating one domain that fans requests to `/agent//...` out to each container's web port, and to `/` for the main dashboard. Touches: a NixOS module on the host, the dashboard's per-agent link rendering, and the per-agent web server's base-path handling (currently assumes root). Lets bookmarks survive port reshuffles and unblocks per-agent stats links being relative URLs instead of hard-coded ports. +- **Delivered-reminder rollup on the per-agent stats page**: surface attempt / success / failure counts for reminders this agent fired (in the existing `/stats` page). Needs an `AgentRequest::ReminderRollup { since_secs }` / matching `ManagerRequest::ReminderRollup` RPC so the agent can pull the counts from the host's broker DB (the reminders table is host-owned; agent state doesn't have them). Deferred from the initial stats page so the first cut stays self-contained to data the agent already owns. ## Security @@ -49,8 +55,6 @@ how often the friction bites in normal use. ## Telemetry - **Per-turn stats: host-side vacuum sweep**: the sink writes to `/state/hyperhive-turn-stats.sqlite` on each agent's state dir; needs a periodic retention sweep mirroring `events_vacuum.rs` so the table doesn't grow forever. Default keep-window: 90 days (turn-stats are denser than events but smaller per-row, ~200B each). -- **Surface per-turn stats on the agent web UI**: "N turns today" chip + rolling tool-call histogram tooltip on the model chip. (`open_threads` and `open_reminders` chips already landed via other paths β€” open-threads section on the page + reminder count chip on the container row.) Reads the per-agent `turn_stats.sqlite`. -- **Stats UI on the main dashboard**: per-agent rollups (avg turn duration, tokens-since-boot, top 5 tools) on the container row. Same data source, host-side aggregation query. ## Harness Behaviour diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 3083990..368eccd 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -8,6 +8,7 @@

β—† … β—†

+

πŸ“Š stats β†’

loading…

diff --git a/hive-ag3nt/assets/stats.html b/hive-ag3nt/assets/stats.html new file mode 100644 index 0000000..f7a6c4e --- /dev/null +++ b/hive-ag3nt/assets/stats.html @@ -0,0 +1,76 @@ + + + + + hyperhive agent β€” stats + + + + + +
+ ← live + dashboard +

β—† … β—†

+
+ +
+ + + +
+ +
+ +
+

turns per bucket

+

turn duration (ms) β€” p50 / p95 / avg

+

context tokens (last inference per turn) β€” avg / max

+

token cost per bucket (sum across inferences)

+

top tools

+

wake source mix

+

result mix

+
+ + + + + + diff --git a/hive-ag3nt/assets/stats.js b/hive-ag3nt/assets/stats.js new file mode 100644 index 0000000..8749920 --- /dev/null +++ b/hive-ag3nt/assets/stats.js @@ -0,0 +1,308 @@ +// Per-agent stats page. Fetches /api/state for the title + dashboard link +// once on load, then /api/stats?window=... for the chart data β€” re-fetches +// when the operator clicks a window tab. + +(function () { + 'use strict'; + + const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + const palette = { + bg: cssVar('--bg'), + bgElev: cssVar('--bg-elev'), + fg: cssVar('--fg'), + muted: cssVar('--muted'), + purple: cssVar('--purple'), + cyan: cssVar('--cyan'), + pink: cssVar('--pink'), + amber: cssVar('--amber'), + green: cssVar('--green'), + red: cssVar('--red'), + border: cssVar('--border'), + }; + // Distinct hues for categorical charts (top tools / wake mix / result mix). + const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber, + palette.green, palette.red, '#94e2d5', '#f9e2af', + '#74c7ec', '#b4befe']; + + // Apply Catppuccin defaults globally so each Chart inherits without per-call + // overrides. Chart.js v4 reads these on chart construction. + Chart.defaults.color = palette.fg; + Chart.defaults.borderColor = palette.border; + Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace'; + Chart.defaults.font.size = 11; + Chart.defaults.plugins.legend.labels.color = palette.fg; + + const charts = {}; + let currentWindow = '24h'; + + function fmtMs(ms) { + if (!Number.isFinite(ms) || ms <= 0) return '0'; + if (ms < 1000) return ms.toFixed(0) + 'ms'; + return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's'; + } + + function fmtInt(n) { + if (!Number.isFinite(n)) return '0'; + return new Intl.NumberFormat().format(Math.round(n)); + } + + function bucketLabel(ts, bucketSecs) { + const d = new Date(ts * 1000); + if (bucketSecs >= 86400) { + return d.toISOString().slice(5, 10); // MM-DD + } + return d.toISOString().slice(11, 16); // HH:MM + } + + function destroy(name) { + if (charts[name]) { + charts[name].destroy(); + delete charts[name]; + } + } + + function paintEmpty(canvasId, msg) { + destroy(canvasId); + const cv = document.getElementById(canvasId); + if (!cv) return; + const ctx = cv.getContext('2d'); + ctx.clearRect(0, 0, cv.width, cv.height); + ctx.fillStyle = palette.muted; + ctx.font = '12px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(msg, cv.width / 2, cv.height / 2); + } + + function renderSummary(s) { + const root = document.getElementById('summary'); + root.replaceChildren(); + const chips = [ + ['turns', fmtInt(s.turn_count)], + ['avg duration', fmtMs(s.duration_summary.avg_ms)], + ['p50 duration', fmtMs(s.duration_summary.p50_ms)], + ['p95 duration', fmtMs(s.duration_summary.p95_ms)], + ['window', s.window], + ]; + for (const [label, value] of chips) { + const chip = document.createElement('span'); + chip.className = 'chip'; + const l = document.createElement('span'); + l.className = 'label'; + l.textContent = label; + const v = document.createElement('span'); + v.className = 'value'; + v.textContent = value; + chip.append(l, v); + root.append(chip); + } + } + + function renderTurnsChart(s) { + const id = 'chart-turns'; + destroy(id); + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + const data = s.buckets.map((b) => b.turn_count); + charts[id] = new Chart(document.getElementById(id), { + type: 'bar', + data: { + labels, + datasets: [{ + label: 'turns', + data, + backgroundColor: palette.purple, + borderColor: palette.purple, + borderWidth: 1, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { grid: { color: palette.border } }, + y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } }, + }, + }, + }); + } + + function renderDurationChart(s) { + const id = 'chart-duration'; + destroy(id); + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + const ds = (label, color, key) => ({ + label, data: s.buckets.map((b) => b[key]), + borderColor: color, backgroundColor: color + '33', + tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, + }); + charts[id] = new Chart(document.getElementById(id), { + type: 'line', + data: { + labels, + datasets: [ + ds('p50', palette.cyan, 'p50_duration_ms'), + ds('p95', palette.pink, 'p95_duration_ms'), + ds('avg', palette.amber, 'avg_duration_ms'), + ], + }, + options: { + responsive: true, maintainAspectRatio: false, + scales: { + x: { grid: { color: palette.border } }, + y: { + beginAtZero: true, + grid: { color: palette.border }, + ticks: { callback: (v) => fmtMs(v) }, + }, + }, + }, + }); + } + + function renderCtxChart(s) { + const id = 'chart-ctx'; + destroy(id); + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + charts[id] = new Chart(document.getElementById(id), { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'avg ctx', + data: s.buckets.map((b) => b.avg_ctx_tokens), + borderColor: palette.cyan, + backgroundColor: palette.cyan + '33', + tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, + }, + { + label: 'max ctx', + data: s.buckets.map((b) => b.max_ctx_tokens), + borderColor: palette.amber, + backgroundColor: palette.amber + '33', + tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, + }, + ], + }, + options: { + responsive: true, maintainAspectRatio: false, + scales: { + x: { grid: { color: palette.border } }, + y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, + }, + }, + }); + } + + function renderCostChart(s) { + const id = 'chart-cost'; + destroy(id); + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + // Stacked bars: cache_read (cheap) / cache_creation / input / output. + // Highlights "what's actually getting billed at full rate" vs cache hits. + charts[id] = new Chart(document.getElementById(id), { + type: 'bar', + data: { + labels, + datasets: [ + { label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens), + backgroundColor: palette.muted }, + { label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens), + backgroundColor: palette.cyan }, + { label: 'input', data: s.buckets.map((b) => b.input_tokens), + backgroundColor: palette.amber }, + { label: 'output', data: s.buckets.map((b) => b.output_tokens), + backgroundColor: palette.pink }, + ], + }, + options: { + responsive: true, maintainAspectRatio: false, + scales: { + x: { stacked: true, grid: { color: palette.border } }, + y: { stacked: true, beginAtZero: true, + grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, + }, + }, + }); + } + + function renderKeyCount(canvasId, items, emptyMsg) { + destroy(canvasId); + if (!items || items.length === 0) { + paintEmpty(canvasId, emptyMsg); + return; + } + const labels = items.map((kc) => kc.key); + const data = items.map((kc) => kc.count); + const colors = items.map((_, i) => wheel[i % wheel.length]); + charts[canvasId] = new Chart(document.getElementById(canvasId), { + type: 'doughnut', + data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } }, + }, + }); + } + + function render(s) { + renderSummary(s); + if (s.turn_count === 0) { + paintEmpty('chart-turns', 'no turns in window'); + paintEmpty('chart-duration', 'no turns in window'); + paintEmpty('chart-ctx', 'no turns in window'); + paintEmpty('chart-cost', 'no turns in window'); + paintEmpty('chart-tools', 'no tool calls'); + paintEmpty('chart-wake', 'no wakes'); + paintEmpty('chart-result', 'no results'); + return; + } + renderTurnsChart(s); + renderDurationChart(s); + renderCtxChart(s); + renderCostChart(s); + renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls'); + renderKeyCount('chart-wake', s.wake_mix, 'no wakes'); + renderKeyCount('chart-result', s.result_mix, 'no results'); + } + + async function loadStats() { + try { + const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow)); + if (!resp.ok) throw new Error('http ' + resp.status); + const snap = await resp.json(); + render(snap); + } catch (e) { + document.getElementById('summary').textContent = 'stats fetch failed: ' + e; + } + } + + async function loadIdentity() { + try { + const resp = await fetch('/api/state'); + if (!resp.ok) return; + const s = await resp.json(); + document.title = 'stats Β· ' + s.label; + document.getElementById('title').textContent = 'β—† ' + s.label + ' β—†'; + const dl = document.getElementById('dashboard-link'); + dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/'; + } catch (_) { /* non-fatal */ } + } + + function bindTabs() { + const tabs = document.getElementById('window-tabs'); + tabs.addEventListener('click', (ev) => { + const btn = ev.target.closest('button[data-w]'); + if (!btn) return; + currentWindow = btn.dataset.w; + for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn); + loadStats(); + }); + } + + document.addEventListener('DOMContentLoaded', () => { + bindTabs(); + loadIdentity(); + loadStats(); + }); +})(); diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index 219e644..fa3a8f3 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -8,6 +8,7 @@ pub mod login_session; pub mod mcp; pub mod paths; pub mod plugins; +pub mod stats; pub mod turn; pub mod turn_stats; pub mod web_ui; diff --git a/hive-ag3nt/src/stats.rs b/hive-ag3nt/src/stats.rs new file mode 100644 index 0000000..eca7f67 --- /dev/null +++ b/hive-ag3nt/src/stats.rs @@ -0,0 +1,500 @@ +//! Read-side aggregations over the per-agent `turn_stats.sqlite` for +//! the agent's `/stats` web page. Owned by the agent (same process +//! that writes the sink) so per-MCP extensions can register more +//! providers without the host needing to know their schemas. +//! +//! Best-effort: any sqlite error returns an empty snapshot rather than +//! propagating β€” the stats page is decorative, not authoritative, and +//! a missing db on a brand-new agent shouldn't 500 the route. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use rusqlite::{Connection, OpenFlags}; +use serde::Serialize; + +/// Window param accepted by `/api/stats?window=`. Each maps to a +/// total span + the bucket width used to roll up trend series. +#[derive(Debug, Clone, Copy)] +pub enum Window { + Day, + Week, + Month, +} + +impl Window { + pub fn parse(s: &str) -> Self { + match s { + "7d" => Self::Week, + "30d" => Self::Month, + _ => Self::Day, + } + } + + fn label(self) -> &'static str { + match self { + Self::Day => "24h", + Self::Week => "7d", + Self::Month => "30d", + } + } + + fn span_secs(self) -> i64 { + match self { + Self::Day => 24 * 3600, + Self::Week => 7 * 24 * 3600, + Self::Month => 30 * 24 * 3600, + } + } + + fn bucket_secs(self) -> i64 { + match self { + // hourly for the 24h view, daily for the longer ranges. + Self::Day => 3600, + Self::Week | Self::Month => 24 * 3600, + } + } +} + +#[derive(Debug, Serialize)] +pub struct Snapshot { + pub window: &'static str, + pub bucket_seconds: i64, + pub now: i64, + pub from: i64, + /// Total turns in the window. + pub turn_count: u64, + /// Time-bucketed trend series, oldest first. Always covers the + /// full window even for empty buckets (so charts paint a stable + /// x-axis instead of skipping gaps). + pub buckets: Vec, + /// Top tools by call count across the window. Capped to 10. + pub tool_breakdown: Vec, + pub wake_mix: Vec, + pub result_mix: Vec, + /// Across-window p50 / p95 / avg of `duration_ms`. Same numbers + /// as the per-bucket fields but aggregated over the whole window + /// for the headline summary chips. + pub duration_summary: DurationSummary, +} + +#[derive(Debug, Serialize)] +pub struct Bucket { + /// Unix timestamp of the bucket start. + pub ts: i64, + pub turn_count: u64, + pub avg_duration_ms: f64, + pub p50_duration_ms: f64, + pub p95_duration_ms: f64, + /// Sums across the bucket. JS picks how to combine them + /// (input + output for cost, etc.) so we don't bake a policy in. + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_input_tokens: u64, + pub cache_creation_input_tokens: u64, + /// Mean of `last_input_tokens` across the bucket (the context + /// size at turn-end β€” useful for spotting drift toward compaction). + pub avg_ctx_tokens: f64, + pub max_ctx_tokens: u64, +} + +#[derive(Debug, Serialize)] +pub struct KeyCount { + pub key: String, + pub count: u64, +} + +#[derive(Debug, Default, Serialize)] +pub struct DurationSummary { + pub avg_ms: f64, + pub p50_ms: f64, + pub p95_ms: f64, +} + +#[must_use] +pub fn snapshot_default(window: Window) -> Snapshot { + let path = default_path(); + match snapshot(&path, window) { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = ?e, path = %path.display(), "stats: snapshot failed"); + empty_snapshot(window) + } + } +} + +fn default_path() -> PathBuf { + crate::paths::state_dir().join("hyperhive-turn-stats.sqlite") +} + +fn empty_snapshot(window: Window) -> Snapshot { + let now = now_secs(); + let from = now - window.span_secs(); + let buckets = fill_buckets(from, now, window.bucket_secs(), &HashMap::new()); + Snapshot { + window: window.label(), + bucket_seconds: window.bucket_secs(), + now, + from, + turn_count: 0, + buckets, + tool_breakdown: Vec::new(), + wake_mix: Vec::new(), + result_mix: Vec::new(), + duration_summary: DurationSummary::default(), + } +} + +fn snapshot(path: &Path, window: Window) -> Result { + // Read-only open so an in-flight writer (the harness's own + // turn_stats sink) never blocks us and we can't corrupt the db + // via a query bug. + let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .with_context(|| format!("open {} read-only", path.display()))?; + let now = now_secs(); + let from = now - window.span_secs(); + let bucket_secs = window.bucket_secs(); + + let mut stmt = conn.prepare( + "SELECT started_at, duration_ms, + input_tokens, output_tokens, + cache_read_input_tokens, cache_creation_input_tokens, + last_input_tokens, + tool_call_breakdown_json, + wake_from, result_kind + FROM turn_stats + WHERE started_at >= ?1 + ORDER BY started_at ASC", + )?; + let rows = stmt.query_map([from], |row| { + Ok(Row { + started_at: row.get(0)?, + duration_ms: row.get::<_, i64>(1)?, + input_tokens: u64_from_i64(row.get::<_, i64>(2)?), + output_tokens: u64_from_i64(row.get::<_, i64>(3)?), + cache_read_input_tokens: u64_from_i64(row.get::<_, i64>(4)?), + cache_creation_input_tokens: u64_from_i64(row.get::<_, i64>(5)?), + last_input_tokens: u64_from_i64(row.get::<_, i64>(6)?), + tool_breakdown_json: row.get::<_, Option>(7)?, + wake_from: row.get::<_, String>(8)?, + result_kind: row.get::<_, String>(9)?, + }) + })?; + + let mut by_bucket: HashMap = HashMap::new(); + let mut tool_totals: HashMap = HashMap::new(); + let mut wake_totals: HashMap = HashMap::new(); + let mut result_totals: HashMap = HashMap::new(); + let mut all_durations: Vec = Vec::new(); + let mut turn_count: u64 = 0; + + for r in rows { + let r = r?; + turn_count += 1; + let bucket_ts = (r.started_at / bucket_secs) * bucket_secs; + let acc = by_bucket.entry(bucket_ts).or_default(); + acc.turn_count += 1; + acc.durations.push(r.duration_ms.max(0)); + acc.input_tokens = acc.input_tokens.saturating_add(r.input_tokens); + acc.output_tokens = acc.output_tokens.saturating_add(r.output_tokens); + acc.cache_read_input_tokens = acc + .cache_read_input_tokens + .saturating_add(r.cache_read_input_tokens); + acc.cache_creation_input_tokens = acc + .cache_creation_input_tokens + .saturating_add(r.cache_creation_input_tokens); + acc.ctx_sum = acc.ctx_sum.saturating_add(r.last_input_tokens); + acc.ctx_max = acc.ctx_max.max(r.last_input_tokens); + + all_durations.push(r.duration_ms.max(0)); + *wake_totals.entry(r.wake_from).or_insert(0) += 1; + *result_totals.entry(r.result_kind).or_insert(0) += 1; + + if let Some(json) = r.tool_breakdown_json + && let Ok(map) = serde_json::from_str::>(&json) + { + for (k, v) in map { + *tool_totals.entry(k).or_insert(0) += v; + } + } + } + + let buckets = fill_buckets(from, now, bucket_secs, &by_bucket); + let duration_summary = summarize_durations(&mut all_durations); + + Ok(Snapshot { + window: window.label(), + bucket_seconds: bucket_secs, + now, + from, + turn_count, + buckets, + tool_breakdown: top_n(tool_totals, 10), + wake_mix: top_n(wake_totals, 20), + result_mix: top_n(result_totals, 20), + duration_summary, + }) +} + +struct Row { + started_at: i64, + duration_ms: i64, + input_tokens: u64, + output_tokens: u64, + cache_read_input_tokens: u64, + cache_creation_input_tokens: u64, + last_input_tokens: u64, + tool_breakdown_json: Option, + wake_from: String, + result_kind: String, +} + +#[derive(Default)] +struct BucketAcc { + turn_count: u64, + durations: Vec, + input_tokens: u64, + output_tokens: u64, + cache_read_input_tokens: u64, + cache_creation_input_tokens: u64, + ctx_sum: u64, + ctx_max: u64, +} + +fn fill_buckets( + from: i64, + now: i64, + bucket_secs: i64, + by_bucket: &HashMap, +) -> Vec { + let start = (from / bucket_secs) * bucket_secs; + let mut out = Vec::new(); + let mut ts = start; + while ts <= now { + let bucket = if let Some(acc) = by_bucket.get(&ts) { + let mut sorted = acc.durations.clone(); + sorted.sort_unstable(); + let avg = if sorted.is_empty() { + 0.0 + } else { + #[allow(clippy::cast_precision_loss)] + let sum_f = sorted.iter().sum::() as f64; + #[allow(clippy::cast_precision_loss)] + let len_f = sorted.len() as f64; + sum_f / len_f + }; + let p50 = percentile(&sorted, 50); + let p95 = percentile(&sorted, 95); + let avg_ctx = if acc.turn_count == 0 { + 0.0 + } else { + #[allow(clippy::cast_precision_loss)] + let sum_f = acc.ctx_sum as f64; + #[allow(clippy::cast_precision_loss)] + let cnt_f = acc.turn_count as f64; + sum_f / cnt_f + }; + Bucket { + ts, + turn_count: acc.turn_count, + avg_duration_ms: avg, + p50_duration_ms: p50, + p95_duration_ms: p95, + input_tokens: acc.input_tokens, + output_tokens: acc.output_tokens, + cache_read_input_tokens: acc.cache_read_input_tokens, + cache_creation_input_tokens: acc.cache_creation_input_tokens, + avg_ctx_tokens: avg_ctx, + max_ctx_tokens: acc.ctx_max, + } + } else { + Bucket { + ts, + turn_count: 0, + avg_duration_ms: 0.0, + p50_duration_ms: 0.0, + p95_duration_ms: 0.0, + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + avg_ctx_tokens: 0.0, + max_ctx_tokens: 0, + } + }; + out.push(bucket); + ts += bucket_secs; + } + out +} + +fn summarize_durations(all: &mut [i64]) -> DurationSummary { + if all.is_empty() { + return DurationSummary::default(); + } + all.sort_unstable(); + #[allow(clippy::cast_precision_loss)] + let sum_f = all.iter().sum::() as f64; + #[allow(clippy::cast_precision_loss)] + let len_f = all.len() as f64; + DurationSummary { + avg_ms: sum_f / len_f, + p50_ms: percentile(all, 50), + p95_ms: percentile(all, 95), + } +} + +#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn percentile(sorted: &[i64], pct: u8) -> f64 { + if sorted.is_empty() { + return 0.0; + } + if sorted.len() == 1 { + return sorted[0] as f64; + } + // Nearest-rank, clamped. + let rank = ((f64::from(pct) / 100.0) * (sorted.len() as f64 - 1.0)).round() as usize; + sorted[rank.min(sorted.len() - 1)] as f64 +} + +fn top_n(map: HashMap, n: usize) -> Vec { + let mut v: Vec = map + .into_iter() + .map(|(key, count)| KeyCount { key, count }) + .collect(); + v.sort_unstable_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key))); + v.truncate(n); + v +} + +fn u64_from_i64(v: i64) -> u64 { + u64::try_from(v).unwrap_or(0) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + use std::sync::atomic::{AtomicU32, Ordering}; + + static SEQ: AtomicU32 = AtomicU32::new(0); + + fn tmp_db() -> PathBuf { + let n = SEQ.fetch_add(1, Ordering::SeqCst); + let pid = std::process::id(); + std::env::temp_dir().join(format!("hyperhive-stats-test-{pid}-{n}.sqlite")) + } + + fn seed_db(path: &Path, rows: &[(i64, i64, &str, &str, &str)]) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE turn_stats ( + id INTEGER PRIMARY KEY, + started_at INTEGER NOT NULL, + ended_at INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + model TEXT NOT NULL, + wake_from TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, + last_input_tokens INTEGER NOT NULL DEFAULT 0, + last_output_tokens INTEGER NOT NULL DEFAULT 0, + last_cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, + last_cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, + tool_call_count INTEGER NOT NULL DEFAULT 0, + tool_call_breakdown_json TEXT, + open_threads_count INTEGER, + open_reminders_count INTEGER, + result_kind TEXT NOT NULL, + note TEXT + );", + ) + .unwrap(); + for (started, dur, wake, result, tools_json) in rows { + conn.execute( + "INSERT INTO turn_stats + (started_at, ended_at, duration_ms, model, wake_from, + last_input_tokens, tool_call_breakdown_json, result_kind) + VALUES (?1, ?2, ?3, 'm', ?4, 1000, ?5, ?6)", + params![started, started + dur / 1000, dur, wake, tools_json, result], + ) + .unwrap(); + } + } + + #[test] + fn snapshot_aggregates_rows() { + let db = tmp_db(); + let _ = std::fs::remove_file(&db); + let now = now_secs(); + seed_db( + &db, + &[ + (now - 600, 5_000, "recv", "ok", r#"{"Read":2,"Bash":1}"#), + (now - 300, 10_000, "recv", "ok", r#"{"Read":3}"#), + (now - 100, 20_000, "operator", "failed", "{}"), + ], + ); + let s = snapshot(&db, Window::Day).unwrap(); + assert_eq!(s.turn_count, 3); + assert_eq!(s.window, "24h"); + assert_eq!(s.bucket_seconds, 3600); + let tool_map: HashMap<_, _> = s + .tool_breakdown + .iter() + .map(|kc| (kc.key.clone(), kc.count)) + .collect(); + assert_eq!(tool_map.get("Read").copied(), Some(5)); + assert_eq!(tool_map.get("Bash").copied(), Some(1)); + let wake_map: HashMap<_, _> = s + .wake_mix + .iter() + .map(|kc| (kc.key.clone(), kc.count)) + .collect(); + assert_eq!(wake_map.get("recv").copied(), Some(2)); + assert_eq!(wake_map.get("operator").copied(), Some(1)); + let result_map: HashMap<_, _> = s + .result_mix + .iter() + .map(|kc| (kc.key.clone(), kc.count)) + .collect(); + assert_eq!(result_map.get("ok").copied(), Some(2)); + assert_eq!(result_map.get("failed").copied(), Some(1)); + // Durations: [5000, 10000, 20000] β†’ avg β‰ˆ 11666.67, p50 = 10000, p95 ~ 20000 + assert!((s.duration_summary.avg_ms - 11_666.666_666_666_666).abs() < 1.0); + assert!((s.duration_summary.p50_ms - 10_000.0).abs() < 1.0); + assert!((s.duration_summary.p95_ms - 20_000.0).abs() < 1.0); + } + + #[test] + fn empty_window_still_paints_buckets() { + let db = tmp_db(); + let _ = std::fs::remove_file(&db); + seed_db(&db, &[]); + let s = snapshot(&db, Window::Day).unwrap(); + assert_eq!(s.turn_count, 0); + // 24h / 1h buckets = ~24-25 buckets covering the window. + assert!(s.buckets.len() >= 24); + assert!(s.buckets.iter().all(|b| b.turn_count == 0)); + } + + #[test] + fn week_uses_daily_buckets() { + let db = tmp_db(); + let _ = std::fs::remove_file(&db); + seed_db(&db, &[]); + let s = snapshot(&db, Window::Week).unwrap(); + assert_eq!(s.window, "7d"); + assert_eq!(s.bucket_seconds, 86_400); + assert!(s.buckets.len() >= 7); + } +} diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index caca25e..45a4a60 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -107,6 +107,9 @@ pub async fn serve( .route("/api/model", post(post_set_model)) .route("/api/new-session", post(post_new_session)) .route("/api/loose-ends", get(api_loose_ends)) + .route("/stats", get(serve_stats)) + .route("/static/stats.js", get(serve_stats_js)) + .route("/api/stats", get(api_stats)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; @@ -198,6 +201,33 @@ async fn serve_marked_js() -> impl IntoResponse { ) } +async fn serve_stats() -> impl IntoResponse { + ( + [("content-type", "text/html; charset=utf-8")], + include_str!("../assets/stats.html"), + ) +} + +async fn serve_stats_js() -> impl IntoResponse { + ( + [("content-type", "application/javascript")], + include_str!("../assets/stats.js"), + ) +} + +#[derive(Deserialize)] +struct StatsQuery { + window: Option, +} + +async fn api_stats( + State(_state): State, + axum::extract::Query(q): axum::extract::Query, +) -> axum::Json { + let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h")); + axum::Json(crate::stats::snapshot_default(window)) +} + #[derive(Serialize)] struct StateSnapshot { /// Bus seq at the moment this snapshot was assembled. Clients dedupe diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 0c079f7..3c8f470 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -487,6 +487,13 @@ el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), + el('a', { + class: 'meta', + href: url + 'stats', + target: '_blank', + rel: 'noopener', + title: 'per-agent stats page (turn rate, durations, tokens, tool mix)', + }, 'πŸ“Š'), ); if (pending) { head.append(el('span', { class: 'pending-state' },