From 1e13b88c8c7de98f04658e003ea4bc4dd3602c63 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 10 Aug 2026 22:50:42 +0200 Subject: [PATCH] jobq graph: state filter on /api/jobq/graph + multi-select checkboxes GET /api/jobq/graph gains a states query param (comma-separated hive_jobq::State names): narrows the served root groups to the named states, keeping a group whole (filtering by a root's own state, which is already its subtree's rolled-up answer). Absent, empty, or fully unrecognised is the identity filter, matching prior behaviour. hive-jobq-graph.js gains a row of per-state checkboxes above the tree, re-fetching the endpoint with the selection on toggle. Default selection hides Done and Skipped. Server-side filtering (not client-side hiding) so hive-jobq-graph-update's node list, and everything downstream of it in builds.js (count pill, live-log panel), only ever sees what's actually shown. --- docs/web-ui/dashboard.md | 18 +++++ .../shared/src/jobq-graph/hive-jobq-graph.css | 20 +++++ .../shared/src/jobq-graph/hive-jobq-graph.js | 79 +++++++++++++++---- hive-c0re/src/dashboard/state_snapshot.rs | 39 ++++++++- hive-c0re/src/job_queue/mod.rs | 54 +++++++++++-- hive-c0re/src/job_queue/tests.rs | 53 ++++++++++++- 6 files changed, 236 insertions(+), 27 deletions(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index ab4c09de..842e9e37 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -255,6 +255,24 @@ Settled entries render their **full step tree**, not just a bare summary — unlike the old `DagView` projection, this wire does not filter `Done` nodes out. +**State filter (hyperhive#2606).** A row of per-state checkboxes above +the tree — one per lifecycle state, matching the row glyphs — lets the +operator narrow which root groups render; unchecking a state re-fetches +`GET /api/jobq/graph?states=` rather than +hiding rows client-side, so `hive-jobq-graph-update`'s node list (and +everything downstream of it — the count pill, the live-log panel) only +ever sees what's actually shown. Filtering is by a **root's own** +state, which is already its subtree's rolled-up answer, so a group is +kept or dropped whole, never split mid-tree. Default selection is +every state **except** `Done`/`Skipped` — a fresh queue view leads +with what's still moving or needs attention, not the settled tail; the +`states` param is omitted entirely (identical request to before this +filter existed) when every state is checked. Server-side: the query +narrows [`Queue::graph_snapshot`]'s already-bounded (`MAX_HISTORY_DAGS`) +root set — the history cap and the state filter are independent +concerns, so a narrow filter never reaches further back in time to +compensate. + Below the queue, a **live build-log panel** (`#rebuild-live-log`, `renderRebuildLiveLog`) shows the currently-running rebuild's output inline — collapsible, with a live/ok/fail badge and a `↓ raw` diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css index d59adb06..883342d5 100644 --- a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css +++ b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css @@ -10,6 +10,26 @@ color: var(--fg); } +.jg-filter { + display: flex; + flex-wrap: wrap; + gap: 0.15em 0.9em; + margin: 0 0 0.5em; + font-size: 0.85em; +} + +.jg-filter-label { + display: inline-flex; + align-items: center; + gap: 0.3em; + color: var(--muted); + cursor: pointer; + user-select: none; +} +.jg-filter-label:has(input:checked) { + color: var(--fg); +} + .jg-body { display: flex; flex-direction: column; diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js index 2d37f420..3cdb32da 100644 --- a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js +++ b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js @@ -14,20 +14,20 @@ // `buildTree`'s `_waitsOn` resolution. `cancellable` attribute adds a // per-node cancel button dispatching `hive-jobq-graph-cancel` instead of // POSTing anything itself — see `CANCELLABLE_STATES` + the click-delegate -// in `connectedCallback` below for both. +// in `connectedCallback` below for both. A per-state checkbox row above +// the tree re-fetches `endpoint` with `?states=` on toggle — see +// `_buildFilterBar`/`_fetchUrl` below and docs/web-ui/dashboard.md's +// R3BU1LD QU3U3 section for the full filter rationale. // // Usage: — // self-fetches on connect. `.refresh()` re-fetches + re-renders; // `.render(nodes)` renders host-pushed data directly, no fetch. Every // render dispatches a bubbling/composed `hive-jobq-graph-update` event -// (`detail: { nodes }`) so a host needing the raw list for something the -// tree doesn't show (a count badge, a live-log panel) listens instead of -// running its own parallel fetch. +// (`detail: { nodes }`) so a host needing the raw list (a count badge, a +// live-log panel) listens instead of running its own parallel fetch. // -// Shadow DOM + own styles, per instruction — unlike light-DOM -// , this renders a whole subtree nothing else needs to -// select into. Theme custom properties (--fg, --red, ...) still pierce -// the shadow boundary by inheritance; only plain class rules are local. +// Shadow DOM + own styles: theme custom properties (--fg, --red, ...) +// pierce the shadow boundary by inheritance; only plain class rules local. import { el } from '../dom.js'; import { attachShadowCss } from '../shadow-css.js'; @@ -43,6 +43,14 @@ const STATE_GLYPH = { Skipped: '·', }; +// Declaration order doubles as render order for the filter checkboxes — +// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in the +// same lifecycle order the rollup endpoint counts in. +const ALL_STATES = Object.keys(STATE_GLYPH); + +// Product call: "default selection filters out skipped and done." +const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']); + // Non-terminal states a cancel button makes sense on. Finishing is // included — "own logic done, children still running" is still a subtree // worth stopping early. @@ -133,9 +141,14 @@ class HiveJobqGraph extends HTMLElement { // hit when a row cache moves an already-built // element without a real detach. if (this._root) return; + // Set before the first `refresh()` call below, so the very first fetch + // already carries the default filter rather than flashing every state + // and re-fetching a moment later. + this._selectedStates = new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))); this._root = attachShadowCss(this, graphCss); + this._filterBar = this._buildFilterBar(); this._body = el('div', { class: 'jg-body' }); - this._root.append(this._body); + this._root.append(this._filterBar, this._body); // One delegated listener rather than a per-button one — cancel buttons // come and go on every re-render, a delegated listener on the stable // container doesn't need rebinding. @@ -151,15 +164,51 @@ class HiveJobqGraph extends HTMLElement { this.refresh(); } - // Re-fetch `endpoint` and re-render. Public so a host page can call it - // on its own refresh cadence (SSE tick, poll, whatever fits the page) — - // this element intentionally owns no transport of its own. - async refresh() { + // One checkbox per `ALL_STATES` entry, pre-ticked per `_selectedStates`. + // Built once at connect — toggling a box mutates `_selectedStates` and + // re-fetches rather than rebuilding the row, so focus/scroll position in + // the row itself is never disturbed by a data refresh. + _buildFilterBar() { + const bar = el('div', { class: 'jg-filter' }); + for (const state of ALL_STATES) { + const id = 'jg-filter-' + state.toLowerCase(); + const cb = el('input', { type: 'checkbox', id }); + cb.checked = this._selectedStates.has(state); + cb.addEventListener('change', () => { + if (cb.checked) this._selectedStates.add(state); + else this._selectedStates.delete(state); + this.refresh(); + }); + const label = el('label', { for: id, class: 'jg-filter-label jg-state-' + state.toLowerCase() }, + cb, ' ', STATE_GLYPH[state] + ' ' + state); + bar.append(label); + } + return bar; + } + + // `endpoint` plus the current filter selection as a `states=` query + // param — omitted entirely when every state is checked, so the + // unfiltered default case sends the exact same request as before this + // filter existed. + _fetchUrl() { const endpoint = this.getAttribute('endpoint'); - if (!endpoint || !this._body) return; + if (!endpoint) return null; + if (!this._selectedStates || this._selectedStates.size >= ALL_STATES.length) return endpoint; + const url = new URL(endpoint, window.location.origin); + url.searchParams.set('states', Array.from(this._selectedStates).join(',')); + return url.pathname + url.search; + } + + // Re-fetch `endpoint` (filtered by the current checkbox selection) and + // re-render. Public so a host page can call it on its own refresh + // cadence (SSE tick, poll, whatever fits the page) — this element + // intentionally owns no transport of its own. + async refresh() { + const url = this._fetchUrl(); + if (!url || !this._body) return; let nodes; try { - const r = await fetch(endpoint); + const r = await fetch(url); if (!r.ok) throw new Error('http ' + r.status); nodes = await r.json(); } catch (err) { diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index d98aafbf..c32ccc74 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -651,24 +651,57 @@ fn build_approval_views(approvals: Vec) -> Vec { out } +/// `/api/jobq/graph` query string. Today's only field is `states`: a +/// comma-separated allow-list of `hive_jobq::State` names (`"Pending"`, +/// `"Running"`, ...). Empty / absent ⇒ no filter (current behaviour, every +/// visible root). Set ⇒ only **root** groups whose own state is named are +/// served — a root's state is already its subtree's rolled-up answer (see +/// `hive_jobq_wire`'s doc), so filtering the root filters the whole group. +/// Unknown tokens are silently ignored (an unrecognised name matches +/// nothing rather than erroring the whole request), mirroring +/// `DashboardStreamQuery::kinds` above. +#[derive(Deserialize, Default, IntoParams)] +pub(super) struct JobqGraphQuery { + states: Option, +} + +/// Parses [`JobqGraphQuery::states`] into the list +/// [`crate::job_queue::JobQueue::graph_snapshot`] wants. `None` when absent +/// or when every token failed to parse — both mean "no filter" rather than +/// "match nothing", so an empty/garbled query reads as the unfiltered call +/// it replaces rather than an empty result set. +fn parse_states(raw: Option<&str>) -> Option> { + let states: Vec = raw? + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter_map(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) + .collect(); + (!states.is_empty()).then_some(states) +} + #[utoipa::path( get, path = "/api/jobq/graph", + params(JobqGraphQuery), responses( (status = 200, description = "every node of every retained job group, \ as generic `hive_jobq` graph nodes: identity, the parent tree, \ dependency edges with their accepted-outcome sets, lifecycle, and \ one opaque per-node payload. Group roots ride as ordinary nodes \ - (`parent: null`) and `Done` nodes are not filtered — a consumer \ - renders the graph without knowing what any node means.", + (`parent: null`) and `Done` nodes are not filtered by default — a \ + consumer renders the graph without knowing what any node means. \ + `?states=` narrows to root groups in the named states.", body = Vec), ), tag = "state_snapshot" )] pub(super) async fn jobq_graph( State(state): State, + axum::extract::Query(q): axum::extract::Query, ) -> axum::Json> { - axum::Json(state.coord.job_queue.graph_snapshot()) + let states = parse_states(q.states.as_deref()); + axum::Json(state.coord.job_queue.graph_snapshot(states.as_deref())) } #[utoipa::path( diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 04ed00af..c83acf3f 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -326,19 +326,32 @@ impl JobQueue { /// Every node of every visible group, as generic graph nodes. /// - /// **Nothing is hidden.** Group roots ride as ordinary nodes (so a - /// consumer needs no special case for "the container" and reads the - /// root's own `state` as the group's answer), and `Done` nodes stay (so a - /// finished step is visible rather than vanishing from the payload, which - /// is what makes a fast rebuild render as a single node). + /// **Nothing is hidden**, other than an explicit `states` ask. Group + /// roots ride as ordinary nodes (so a consumer needs no special case for + /// "the container" and reads the root's own `state` as the group's + /// answer), and `Done` nodes stay by default (so a finished step is + /// visible rather than vanishing from the payload, which is what makes a + /// fast rebuild render as a single node). + /// + /// `states`, when given, keeps only the **root** groups whose own state + /// is named — a root's state is already its subtree's rolled-up answer + /// (see `hive_jobq_wire`'s doc), so filtering the root filters the whole + /// group without needing to also filter descendants. `None` (or the + /// full state set) is the unfiltered call, matching prior behaviour. + /// A slice rather than a set: `State` (`hive_jobq`'s) derives `Eq` but + /// not `Hash`, and the whole vocabulary is 7 variants — a linear + /// membership check per root costs nothing at that size, so there is no + /// reason to widen `hive_jobq::State`'s derive list just for this. /// /// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies /// is *which* groups to show — see [`visible_roots`] for why the graph /// can't decide that for itself. #[must_use] - pub fn graph_snapshot(&self) -> Vec { + pub fn graph_snapshot(&self, states: Option<&[State]>) -> Vec { let inner = self.lock(); - inner.graph().wire_snapshot(visible_roots(&inner)) + let roots = visible_roots(&inner); + let roots = filter_roots_by_state(&inner, roots, states); + inner.graph().wire_snapshot(roots) } /// Per-state counts over the **same** groups [`Queue::graph_snapshot`] @@ -393,6 +406,33 @@ fn find_node(sched: &Sched, id: u64) -> Option { .find_map(|n| (n.id.get() == id).then_some(n.id)) } +/// [`Queue::graph_snapshot`]'s `states` ask, applied to an already-bounded +/// root set: keeps only the roots whose own `state` is named. +/// +/// Applied *after* [`visible_roots`] rather than folded into it — the +/// history cap bounds how much settled work is retained at all, which is a +/// different question from which of the retained (and live) groups the +/// caller wants shown right now. `None` (or an unrecognised/absent query) +/// is the identity filter. +fn filter_roots_by_state( + sched: &Sched, + roots: Vec, + states: Option<&[State]>, +) -> Vec { + let Some(states) = states else { + return roots; + }; + roots + .into_iter() + .filter(|&id| { + sched + .graph() + .node(id) + .is_some_and(|n| states.contains(&n.state)) + }) + .collect() +} + /// The visible **group** set for [`Queue::graph_snapshot`]: every live group /// root, plus the newest [`MAX_HISTORY_DAGS`] settled ones. /// diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index 41e45018..cc82cae7 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -306,7 +306,7 @@ fn state_of(q: &JobQueue, dag_id: u64) -> State { // rolled-up outcome), so there is nothing to derive here any more. // A root aged out of the retained history reads `Done`: it settled, or // it would still be live. - q.graph_snapshot() + q.graph_snapshot(None) .iter() .find(|n| n.id == dag_id) .map_or(State::Done, |n| n.state) @@ -318,7 +318,7 @@ fn state_of(q: &JobQueue, dag_id: u64) -> State { /// asking "how many DAGs" counts the parentless ones. Counting rows would /// count steps, which is a different number: one rebuild is ~7 nodes. fn dag_count(q: &JobQueue) -> usize { - q.graph_snapshot() + q.graph_snapshot(None) .iter() .filter(|n| n.parent.is_none()) .count() @@ -1471,6 +1471,55 @@ fn history_retains_live_dags_and_the_newest_terminals() { ); } +/// `graph_snapshot`'s `states` ask filters by **root** state — a whole group +/// is kept or dropped together, never split mid-subtree. `None` is the +/// identity filter (every visible root, current default behaviour). +#[test] +fn graph_snapshot_states_filters_whole_groups_by_root_state() { + let q = JobQueue::new(2); + let roots_a = insert_named(&q, |builder| restart_online(builder, &["agent-a"], false)); + let roots_b = insert_named(&q, |builder| restart_online(builder, &["agent-b"], false)); + let [head_a] = roots_a.as_slice() else { + panic!("a one-agent restart names one root, got {roots_a:?}") + }; + let [head_b] = roots_b.as_slice() else { + panic!("a one-agent restart names one root, got {roots_b:?}") + }; + + assert!(q.cancel(*head_a), "queued dag cancels"); + assert_eq!(state_of(&q, *head_a), State::Cancelled); + assert_eq!(state_of(&q, *head_b), State::Pending, "untouched sibling"); + + let roots_of = |states: Option<&[State]>| -> Vec { + q.graph_snapshot(states) + .into_iter() + .filter(|n| n.parent.is_none()) + .map(|n| n.id) + .collect() + }; + + assert_eq!( + roots_of(Some(&[State::Cancelled])), + vec![*head_a], + "only the cancelled group's root rides" + ); + + assert_eq!( + roots_of(Some(&[State::Pending])), + vec![*head_b], + "only the pending group's root rides" + ); + + let mut both = roots_of(None); + both.sort_unstable(); + let mut expected = vec![*head_a, *head_b]; + expected.sort_unstable(); + assert_eq!( + both, expected, + "no filter shows every visible root, as before" + ); +} + #[test] fn error_truncation_cuts_on_a_char_boundary() { // `truncate_error` is a pure `&str -> String`. This used to submit a DAG,