From d143abb63da720464a7f96562b73f266ff70f54b Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 22:38:26 +0200 Subject: [PATCH] dashboard: filter /dashboard/stream by ?kinds= allow-list (#408) --- hive-c0re/src/dashboard.rs | 37 ++++++- hive-c0re/src/dashboard_events.rs | 157 ++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 956f9ae9..86ef4f6d 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -798,14 +798,49 @@ async fn dashboard_history(State(state): State) -> Response { } } +/// `/dashboard/stream` query string. Today's only field is `kinds` +/// (#408): a comma-separated allow-list of event-`kind` strings. +/// Empty / absent ⇒ no filter (current behaviour, all variants +/// forwarded). Set ⇒ only the named kinds reach the subscriber, +/// non-matches are skipped before the JSON serialise cost. +/// +/// Useful for narrow pages (e.g. `flow.js` only cares about `sent` +/// / `delivered` / `container_state_changed` / `container_removed`) +/// that want to drop the dispatch overhead on every unrelated mutation. +#[derive(Deserialize, Default)] +struct DashboardStreamQuery { + /// Comma-separated event kinds to forward. Each token is + /// trimmed; unknown kinds are silently ignored on lookup + /// (subscriber sees nothing instead of an error). + kinds: Option, +} + async fn dashboard_stream( State(state): State, + axum::extract::Query(q): axum::extract::Query, ) -> Sse>> { let rx = state.coord.dashboard_subscribe(); - let stream = BroadcastStream::new(rx).filter_map(|res| { + // Pre-parse the allow-list once at subscription time, so the + // per-event hot path is just a `HashSet::contains` on a + // `&'static str` — no string churn per frame. + let kind_filter: Option> = q.kinds.and_then(|raw| { + let set: std::collections::HashSet = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + if set.is_empty() { None } else { Some(set) } + }); + let stream = BroadcastStream::new(rx).filter_map(move |res| { // Drop lagged frames. Browsers reconnect; the seq dedupe on // reconnect skips any frame already reflected in the snapshot. let event = res.ok()?; + if let Some(filter) = kind_filter.as_ref() + && !filter.contains(event.kind_tag()) + { + return None; + } let json = serde_json::to_string(&event).ok()?; Some(Ok(Event::default().data(json))) }); diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index fcea9c78..0ae794a8 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -218,3 +218,160 @@ pub enum DashboardEvent { queue: Vec, }, } + +impl DashboardEvent { + /// Snake-case identifier matching this variant's serde `tag` + /// (e.g. `Sent` → `"sent"`, `ContainerStateChanged` → + /// `"container_state_changed"`). Lets `/dashboard/stream`'s + /// `?kinds=` filter (#408) decide whether to forward a frame + /// without paying the JSON-serialise cost first. + /// + /// Keep in sync with `#[serde(rename_all = "snake_case", tag = + /// "kind")]` on `DashboardEvent` — if a new variant lands above, + /// add it here too. `cargo test` covers this via the + /// `kind_tag_matches_serde_kind_field` round-trip test. + #[must_use] + pub fn kind_tag(&self) -> &'static str { + match self { + DashboardEvent::Sent { .. } => "sent", + DashboardEvent::Delivered { .. } => "delivered", + DashboardEvent::ApprovalAdded { .. } => "approval_added", + DashboardEvent::ApprovalResolved { .. } => "approval_resolved", + DashboardEvent::QuestionAdded { .. } => "question_added", + DashboardEvent::QuestionResolved { .. } => "question_resolved", + DashboardEvent::TransientSet { .. } => "transient_set", + DashboardEvent::TransientCleared { .. } => "transient_cleared", + DashboardEvent::ContainerStateChanged { .. } => "container_state_changed", + DashboardEvent::ContainerRemoved { .. } => "container_removed", + DashboardEvent::TombstonesChanged { .. } => "tombstones_changed", + DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed", + DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", + DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Round-trip representative variants through serde and confirm + /// the `kind` JSON field matches `kind_tag()`. The exhaustive + /// `match` in `kind_tag` already provides compile-time variant + /// coverage — this test is the value-side guard against + /// typos in the snake_case strings vs serde's `rename_all` + /// output. `ContainerStateChanged` is omitted from the sample + /// list only because `ContainerView` has no `Default` impl and + /// constructing one inline here is more boilerplate than the + /// test is worth; the variant is still covered by the + /// `kind_tag` match arm. + #[test] + fn kind_tag_matches_serde_kind_field() { + let samples: Vec = vec![ + DashboardEvent::Sent { + seq: 1, + id: 1, + from: "a".into(), + to: "b".into(), + body: String::new(), + at: 0, + in_reply_to: None, + file_refs: Vec::new(), + }, + DashboardEvent::Delivered { + seq: 1, + id: 1, + from: "a".into(), + to: "b".into(), + body: String::new(), + at: 0, + in_reply_to: None, + file_refs: Vec::new(), + }, + DashboardEvent::ApprovalAdded { + seq: 1, + id: 1, + agent: "x".into(), + approval_kind: "apply_commit", + sha_short: None, + diff: None, + description: None, + }, + DashboardEvent::ApprovalResolved { + seq: 1, + id: 1, + agent: "x".into(), + approval_kind: "apply_commit", + sha_short: None, + status: "approved", + resolved_at: 0, + note: None, + description: None, + }, + DashboardEvent::QuestionAdded { + seq: 1, + id: 1, + asker: "a".into(), + question: String::new(), + options: Vec::new(), + multi: false, + asked_at: 0, + deadline_at: None, + target: None, + question_refs: Vec::new(), + }, + DashboardEvent::QuestionResolved { + seq: 1, + id: 1, + answer: String::new(), + answerer: "a".into(), + answered_at: 0, + cancelled: false, + target: None, + answer_refs: Vec::new(), + }, + DashboardEvent::TransientSet { + seq: 1, + name: "x".into(), + transient_kind: "rebuilding", + since_unix: 0, + }, + DashboardEvent::TransientCleared { + seq: 1, + name: "x".into(), + }, + DashboardEvent::ContainerRemoved { + seq: 1, + name: "x".into(), + }, + DashboardEvent::TombstonesChanged { + seq: 1, + tombstones: Vec::new(), + }, + DashboardEvent::MetaInputsChanged { + seq: 1, + inputs: Vec::new(), + }, + DashboardEvent::MetaUpdateRunning { + seq: 1, + running: false, + }, + DashboardEvent::RebuildQueueChanged { + seq: 1, + queue: Vec::new(), + }, + ]; + for ev in samples { + let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); + let serde_kind = v + .get("kind") + .and_then(|k| k.as_str()) + .expect("kind field present"); + assert_eq!( + ev.kind_tag(), + serde_kind, + "kind_tag() drift on {ev:?}", + ); + } + } +}