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.
This commit is contained in:
iris 2026-08-10 22:50:42 +02:00 committed by mara
commit 1e13b88c8c
6 changed files with 236 additions and 27 deletions

View file

@ -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<u64> {
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,