dashboard: render META INPUTS as a full tree with bulk select

Remove the depth-2 cap in walk_meta_inputs so every fetched input
at every depth is surfaced, not just two levels (issue #275). The
uncapped walk needs a guard: a visited-node set makes it a spanning
tree — each fetched node walked once, at its shallowest path — so
shared subtrees don't re-walk and a cycle can't recurse forever.
A two-pass walk (claim a node's direct inputs before descending)
keeps shallow inputs at a shallow path.

Frontend: renderMetaInputs indents each row by its slash-path depth
and shows the leaf segment (full path on hover), plus a select-all /
select-none control so a long input list isn't ticked box by box.
This commit is contained in:
iris 2026-05-22 23:47:03 +02:00 committed by Mara
parent dd3a820e57
commit 7f97acf19e
4 changed files with 102 additions and 32 deletions

View file

@ -107,10 +107,16 @@ the previous process's socket release resolves itself.
Spawn approval; existing state is reused), `PURG3` (wipes Spawn approval; existing state is reused), `PURG3` (wipes
state + applied dirs; `POST /purge-tombstone/{name}`). state + applied dirs; `POST /purge-tombstone/{name}`).
4. **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can 4. **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
selectively `nix flake update` (hyperhive first, then selectively `nix flake update`, rendered as an indented tree:
`agent-<n>` rows). Checking inputs + submitting bumps the lock every fetched input at every depth (`hyperhive`,
in `/meta/` and rebuilds the selected agents in sequence; each `hyperhive/nixpkgs`, `agent-<n>`, `agent-<n>/mcp-<x>`, …), each
outcome reaches the manager as a `rebuilt` system event. shown once at its shallowest path. `read_meta_inputs` walks the
lock graph with a `visited` set — `follows` aliases and rev-less
nodes are skipped (issue #275). A `select all / select none`
control sits above the tree. Checking inputs + submitting bumps
the lock in `/meta/` and rebuilds the selected agents in
sequence; each outcome reaches the manager as a `rebuilt`
system event.
`POST /meta-update`. The lock bump + rebuild ripple runs in the `POST /meta-update`. The lock bump + rebuild ripple runs in the
background; while it does, the panel shows a pulsing "⏳ background; while it does, the panel shows a pulsing "⏳
meta-update running" banner and the update button is disabled meta-update running" banner and the update button is disabled

View file

@ -1520,9 +1520,22 @@
'data-no-refresh': '', 'data-no-refresh': '',
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?', 'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
}); });
// Bulk select — the full input tree gets long; ticking each box
// one by one is tedious (issue #275).
const bulk = el('div', { class: 'meta-inputs-bulk' });
const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all');
const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none');
bulk.append('bulk: ', selAll, ' ', selNone);
form.append(bulk);
const ul = el('ul', { class: 'meta-inputs' }); const ul = el('ul', { class: 'meta-inputs' });
for (const inp of inputs) { for (const inp of inputs) {
// `name` is a slash-path from the meta root. Indent depth = its
// segment count; the row label shows just the leaf segment, the
// full path stays as the checkbox value + the label title.
const depth = (inp.name.match(/\//g) || []).length;
const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1);
const li = el('li'); const li = el('li');
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_'); const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
const cb = el('input', { const cb = el('input', {
type: 'checkbox', type: 'checkbox',
@ -1531,10 +1544,11 @@
value: inp.name, value: inp.name,
'data-meta-input': inp.name, 'data-meta-input': inp.name,
}); });
const label = el('label', { for: id }); const label = el('label', { for: id, title: inp.name });
label.append(cb);
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
label.append( label.append(
cb, el('span', { class: 'meta-input-name' }, leaf), ' ',
el('span', { class: 'meta-input-name' }, inp.name), ' ',
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ', el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)), el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
); );
@ -1565,6 +1579,14 @@
else btn.setAttribute('disabled', ''); else btn.setAttribute('disabled', '');
} }
form.addEventListener('change', refreshDisabled); form.addEventListener('change', refreshDisabled);
function setAllChecked(val) {
for (const b of form.querySelectorAll('input[data-meta-input]')) {
b.checked = val;
}
refreshDisabled();
}
selAll.addEventListener('click', () => setAllChecked(true));
selNone.addEventListener('click', () => setAllChecked(false));
form.addEventListener('submit', () => { form.addEventListener('submit', () => {
const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked')) const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked'))
.map((b) => b.dataset.metaInput); .map((b) => b.dataset.metaInput);

View file

@ -429,6 +429,31 @@ code {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* Bulk select-all / -none control above the meta-inputs tree (#275). */
.meta-inputs-bulk {
margin: 0 0 0.5em;
font-size: 0.8em;
color: var(--muted);
}
.meta-bulk-btn {
font: inherit;
font-size: 1em;
background: transparent;
border: 1px solid var(--purple-dim);
color: var(--cyan);
padding: 0.1em 0.6em;
margin-right: 0.2em;
cursor: pointer;
}
.meta-bulk-btn:hover {
border-color: var(--cyan);
text-shadow: 0 0 6px currentColor;
}
/* Tree twig glyph prefixing a nested (sub-)input row (#275). */
.meta-input-twig {
color: var(--purple-dim);
margin-right: 0.1em;
}
.btn-meta-update { .btn-meta-update {
background: rgba(203, 166, 247, 0.12); background: rgba(203, 166, 247, 0.12);
border: 1px solid var(--purple); border: 1px solid var(--purple);

View file

@ -447,19 +447,24 @@ pub(crate) struct MetaInputView {
} }
/// Walk `flake.lock`'s `nodes` graph from `root` and emit one /// Walk `flake.lock`'s `nodes` graph from `root` and emit one
/// `MetaInputView` per fetched input, up to two levels deep. That /// `MetaInputView` per fetched input, at **every** depth. That
/// surfaces the direct meta inputs (`hyperhive`, `agent-<n>`) AND /// surfaces the direct meta inputs (`hyperhive`, `agent-<n>`), the
/// the agent flakes' own inputs (`agent-dmatrix/mcp-matrix`, /// agent flakes' own inputs (`agent-dmatrix/mcp-matrix`,
/// `hyperhive/nixpkgs`, etc.) so the operator can bump them /// `hyperhive/nixpkgs`), and any deeper transitive inputs — so the
/// individually from the UI. Deeper transitive nodes aren't shown /// operator can bump any of them individually. Names are
/// to keep the panel readable — bumping the level-2 entry will /// slash-separated paths from root, the syntax `nix flake update`
/// re-fetch its own sub-inputs anyway. Names are slash-separated /// accepts for transitive inputs.
/// paths from root, which is the syntax `nix flake update` accepts
/// for transitive inputs.
/// ///
/// Inputs that resolve via a `follows` chain (lock value is an /// Filtering (see issue #275):
/// Array of strings) are skipped — they're aliases, not their own /// - Inputs that resolve via a `follows` chain (lock value is an
/// fetched derivation, and updating them does nothing. /// array) are skipped — they alias another node, not their own
/// fetched derivation, so updating them does nothing.
/// - A node is emitted only when it carries a `locked.rev`.
/// - Each fetched node is walked exactly once (a `visited` set):
/// the lock graph shares nodes (many flakes reference one
/// nixpkgs), so without this a shared subtree re-walks per parent
/// and a cycle would recurse forever. The result is a spanning
/// tree — every input shown once, at its shallowest path.
fn read_meta_inputs() -> Vec<MetaInputView> { fn read_meta_inputs() -> Vec<MetaInputView> {
let mut out = Vec::new(); let mut out = Vec::new();
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else { let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
@ -474,9 +479,13 @@ fn read_meta_inputs() -> Vec<MetaInputView> {
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
return out; return out;
}; };
walk_meta_inputs(nodes, root_name, "", 0, 2, &mut out); let mut visited = std::collections::HashSet::new();
// hyperhive first, then alphabetical (sub-paths sort under their visited.insert(root_name.to_owned());
// parent, which gives a tidy 'agent-foo, agent-foo/bar' grouping). walk_meta_inputs(nodes, root_name, "", &mut visited, &mut out);
// hyperhive first, then alphabetical. String-sorting the
// slash-paths puts every node directly above its own children
// (`agent-foo`, `agent-foo/bar`, `agent-foo/bar/baz`), so the
// result is a pre-order traversal the tree renderer can consume.
out.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) { out.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
("hyperhive", _) => std::cmp::Ordering::Less, ("hyperhive", _) => std::cmp::Ordering::Less,
(_, "hyperhive") => std::cmp::Ordering::Greater, (_, "hyperhive") => std::cmp::Ordering::Greater,
@ -489,28 +498,33 @@ fn walk_meta_inputs(
nodes: &serde_json::Map<String, serde_json::Value>, nodes: &serde_json::Map<String, serde_json::Value>,
node_name: &str, node_name: &str,
prefix: &str, prefix: &str,
depth: u32, visited: &mut std::collections::HashSet<String>,
max_depth: u32,
out: &mut Vec<MetaInputView>, out: &mut Vec<MetaInputView>,
) { ) {
if depth >= max_depth {
return;
}
let Some(node) = nodes.get(node_name) else { let Some(node) = nodes.get(node_name) else {
return; return;
}; };
let Some(inputs_map) = node.get("inputs").and_then(|v| v.as_object()) else { let Some(inputs_map) = node.get("inputs").and_then(|v| v.as_object()) else {
return; return;
}; };
// Two passes: claim (and emit) every direct input of this node
// before descending into any of them. A shallow input that a
// deeper flake also references then keeps its shallow path
// rather than being captured first by the deep walk.
let mut to_recurse: Vec<(String, String)> = Vec::new();
for (alias, target) in inputs_map { for (alias, target) in inputs_map {
// Inputs map value is either a string (node name) or an // Inputs map value is either a string (node name) or an
// array (a `follows` chain). The latter just aliases another // array (a `follows` chain). The latter just aliases another
// node — we can't `nix flake update` it directly, so skip. // node — we can't `nix flake update` it directly, so skip.
let target_name = match target { let serde_json::Value::String(target_name) = target else {
serde_json::Value::String(s) => s.clone(), continue;
_ => continue,
}; };
let Some(target_node) = nodes.get(&target_name) else { // Walk each fetched node once — guards shared subtrees and
// cycles, and keeps the panel free of duplicate rows.
if !visited.insert(target_name.clone()) {
continue;
}
let Some(target_node) = nodes.get(target_name) else {
continue; continue;
}; };
let path = if prefix.is_empty() { let path = if prefix.is_empty() {
@ -540,7 +554,10 @@ fn walk_meta_inputs(
url, url,
}); });
} }
walk_meta_inputs(nodes, &target_name, &path, depth + 1, max_depth, out); to_recurse.push((target_name.clone(), path));
}
for (target_name, path) in to_recurse {
walk_meta_inputs(nodes, &target_name, &path, visited, out);
} }
} }