From 684c85686e57982323f4f1d94f9f7968c1b3ae54 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 22 Jun 2026 23:36:17 +0200 Subject: [PATCH] feat(stats): add an 'all' time range to agent + hive stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Window::All option to the per-agent stats page and the hive-wide rollup, selectable as a new 'all' tab on both. - hive-ag3nt (per-agent, time-bucketed): All ranges from MIN(started_at) to now (fallback to now on an empty table) with an adaptive bucket width laddered by span — hourly <=2d, daily <=90d, weekly <=2y, 30-day beyond — so the trend series stays bounded (~<=104 buckets) at any age. - hive-c0re (swarm rollup, not time-bucketed): All sets from=0 so the aggregate covers every recorded turn across all agents. - frontend: an 'all' button on both the agent stats and dashboard hive stats window selectors (createTabStrip + fetch already pass the window string through, so no JS change needed). cargo check passes on both crates; FE builds clean. Fixes #1919. --- frontend/packages/agent/src/stats.html | 1 + frontend/packages/dashboard/src/stats.html | 1 + hive-ag3nt/src/stats.rs | 47 ++++++++++++++++++++-- hive-c0re/src/hive_stats.rs | 16 +++++++- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/frontend/packages/agent/src/stats.html b/frontend/packages/agent/src/stats.html index 271f8650..0709130f 100644 --- a/frontend/packages/agent/src/stats.html +++ b/frontend/packages/agent/src/stats.html @@ -23,6 +23,7 @@ +
diff --git a/frontend/packages/dashboard/src/stats.html b/frontend/packages/dashboard/src/stats.html index 88182a1c..97ac3954 100644 --- a/frontend/packages/dashboard/src/stats.html +++ b/frontend/packages/dashboard/src/stats.html @@ -34,6 +34,7 @@ +
diff --git a/hive-ag3nt/src/stats.rs b/hive-ag3nt/src/stats.rs index d7c3515d..80298c04 100644 --- a/hive-ag3nt/src/stats.rs +++ b/hive-ag3nt/src/stats.rs @@ -26,6 +26,10 @@ pub enum Window { ThreeDay, Week, Month, + /// All available data: the range starts at the earliest recorded turn + /// (`MIN(started_at)`) rather than a fixed lookback, with an adaptive + /// bucket width so the trend series stays bounded at any span. + All, } impl Window { @@ -37,6 +41,7 @@ impl Window { "3d" => Self::ThreeDay, "7d" => Self::Week, "30d" => Self::Month, + "all" => Self::All, _ => Self::Day, } } @@ -49,6 +54,7 @@ impl Window { Self::ThreeDay => "3d", Self::Week => "7d", Self::Month => "30d", + Self::All => "all", } } @@ -61,21 +67,42 @@ impl Window { Self::ThreeDay => 3 * 24 * 3600, Self::Week => 7 * 24 * 3600, Self::Month => 30 * 24 * 3600, + // `All` has no fixed lookback — its range is computed from + // `MIN(started_at)` in `snapshot()`. 0 is only a safe fallback + // (→ `from == now` → empty range) if ever reached generically. + Self::All => 0, } } fn bucket_secs(self) -> i64 { match self { // 5-min buckets for 1h (12 buckets), 15-min for 4h (16 buckets), - // hourly for 24h + 3d, daily for 7d + 30d. + // hourly for 24h + 3d, daily for 7d + 30d. `All` shares the daily + // fallback, but its real bucket width is chosen adaptively in + // `snapshot()` via `adaptive_bucket_secs` — this arm is only + // reached defensively. Self::Hour => 300, Self::FourHour => 900, Self::Day | Self::ThreeDay => 3600, - Self::Week | Self::Month => 24 * 3600, + Self::Week | Self::Month | Self::All => 24 * 3600, } } } +/// Bucket width for the unbounded `all` window, laddered by the actual +/// span so the trend series stays bounded (≈ ≤100 buckets) at any range: +/// hourly ≤ 2d, daily ≤ 90d, weekly ≤ 2y, ~monthly (30d) beyond. +fn adaptive_bucket_secs(span_secs: i64) -> i64 { + const HOUR: i64 = 3600; + const DAY: i64 = 24 * HOUR; + match span_secs { + s if s <= 2 * DAY => HOUR, + s if s <= 90 * DAY => DAY, + s if s <= 730 * DAY => 7 * DAY, + _ => 30 * DAY, + } +} + #[derive(Debug, Serialize)] pub struct Snapshot { pub window: &'static str, @@ -208,8 +235,20 @@ fn snapshot(path: &Path, window: Window) -> Result { 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(); + // Fixed windows look back a constant span; `all` starts at the earliest + // recorded turn (`MIN(started_at)`, falling back to `now` on an empty + // table) and sizes its buckets adaptively from that span. + let (from, bucket_secs) = match window { + Window::All => { + let min_ts: Option = + conn.query_row("SELECT MIN(started_at) FROM turn_stats", [], |row| { + row.get(0) + })?; + let from = min_ts.unwrap_or(now); + (from, adaptive_bucket_secs(now - from)) + } + _ => (now - window.span_secs(), window.bucket_secs()), + }; let mut stmt = conn.prepare( "SELECT started_at, duration_ms, diff --git a/hive-c0re/src/hive_stats.rs b/hive-c0re/src/hive_stats.rs index 82115e2a..6f19c841 100644 --- a/hive-c0re/src/hive_stats.rs +++ b/hive-c0re/src/hive_stats.rs @@ -35,6 +35,9 @@ pub enum Window { ThreeDay, Week, Month, + /// All available data: aggregate over every recorded turn since the + /// earliest (`from == 0`), with no fixed lookback. + All, } impl Window { @@ -46,6 +49,7 @@ impl Window { "3d" => Self::ThreeDay, "7d" => Self::Week, "30d" => Self::Month, + "all" => Self::All, _ => Self::Day, } } @@ -58,6 +62,7 @@ impl Window { Self::ThreeDay => "3d", Self::Week => "7d", Self::Month => "30d", + Self::All => "all", } } @@ -69,6 +74,9 @@ impl Window { Self::ThreeDay => 3 * 24 * 3600, Self::Week => 7 * 24 * 3600, Self::Month => 30 * 24 * 3600, + // `All` has no fixed lookback; `hive_snapshot` uses `from == 0` + // (every recorded turn). 0 here is a consistent safe fallback. + Self::All => 0, } } } @@ -314,7 +322,13 @@ fn read_bash_heads(conn: &Connection, from: i64) -> HashMap { #[must_use] pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats { let now = now_secs(); - let from = now - window.span_secs(); + // Fixed windows look back a constant span; `all` aggregates every + // recorded turn (`from == 0`). The hive rollup isn't time-bucketed, so + // unlike the per-agent snapshot it needs no adaptive bucket sizing. + let from = match window { + Window::All => 0, + _ => now - window.span_secs(), + }; let mut agents: Vec = Vec::new(); let mut model_mix: HashMap = HashMap::new();