feat(stats): add an 'all' time range to agent + hive stats

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.
This commit is contained in:
iris 2026-06-22 23:36:17 +02:00 committed by mara
commit 684c85686e
4 changed files with 60 additions and 5 deletions

View file

@ -23,6 +23,7 @@
<button type="button" data-tab="3d">last 3d</button>
<button type="button" data-tab="7d">last 7d</button>
<button type="button" data-tab="30d">last 30d</button>
<button type="button" data-tab="all">all</button>
</div>
<div class="summary" id="summary"></div>

View file

@ -34,6 +34,7 @@
<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>
<div class="hive-stats-chips" id="hive-stats-summary"></div>

View file

@ -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<Snapshot> {
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<i64> =
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,

View file

@ -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<String, u64> {
#[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<AgentRollup> = Vec::new();
let mut model_mix: HashMap<String, u64> = HashMap::new();