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

@ -1520,9 +1520,22 @@
'data-no-refresh': '',
'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' });
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');
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
const cb = el('input', {
type: 'checkbox',
@ -1531,10 +1544,11 @@
value: 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(
cb,
el('span', { class: 'meta-input-name' }, inp.name), ' ',
el('span', { class: 'meta-input-name' }, leaf), ' ',
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
);
@ -1565,6 +1579,14 @@
else btn.setAttribute('disabled', '');
}
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', () => {
const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked'))
.map((b) => b.dataset.metaInput);

View file

@ -429,6 +429,31 @@ code {
text-overflow: ellipsis;
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 {
background: rgba(203, 166, 247, 0.12);
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
/// `MetaInputView` per fetched input, up to two levels deep. That
/// surfaces the direct meta inputs (`hyperhive`, `agent-<n>`) AND
/// the agent flakes' own inputs (`agent-dmatrix/mcp-matrix`,
/// `hyperhive/nixpkgs`, etc.) so the operator can bump them
/// individually from the UI. Deeper transitive nodes aren't shown
/// to keep the panel readable — bumping the level-2 entry will
/// re-fetch its own sub-inputs anyway. Names are slash-separated
/// paths from root, which is the syntax `nix flake update` accepts
/// for transitive inputs.
/// `MetaInputView` per fetched input, at **every** depth. That
/// surfaces the direct meta inputs (`hyperhive`, `agent-<n>`), the
/// agent flakes' own inputs (`agent-dmatrix/mcp-matrix`,
/// `hyperhive/nixpkgs`), and any deeper transitive inputs — so the
/// operator can bump any of them individually. Names are
/// slash-separated paths from root, the syntax `nix flake update`
/// accepts for transitive inputs.
///
/// Inputs that resolve via a `follows` chain (lock value is an
/// Array of strings) are skipped — they're aliases, not their own
/// fetched derivation, and updating them does nothing.
/// Filtering (see issue #275):
/// - Inputs that resolve via a `follows` chain (lock value is an
/// 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> {
let mut out = Vec::new();
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 {
return out;
};
walk_meta_inputs(nodes, root_name, "", 0, 2, &mut out);
// hyperhive first, then alphabetical (sub-paths sort under their
// parent, which gives a tidy 'agent-foo, agent-foo/bar' grouping).
let mut visited = std::collections::HashSet::new();
visited.insert(root_name.to_owned());
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()) {
("hyperhive", _) => std::cmp::Ordering::Less,
(_, "hyperhive") => std::cmp::Ordering::Greater,
@ -489,28 +498,33 @@ fn walk_meta_inputs(
nodes: &serde_json::Map<String, serde_json::Value>,
node_name: &str,
prefix: &str,
depth: u32,
max_depth: u32,
visited: &mut std::collections::HashSet<String>,
out: &mut Vec<MetaInputView>,
) {
if depth >= max_depth {
return;
}
let Some(node) = nodes.get(node_name) else {
return;
};
let Some(inputs_map) = node.get("inputs").and_then(|v| v.as_object()) else {
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 {
// Inputs map value is either a string (node name) or an
// array (a `follows` chain). The latter just aliases another
// node — we can't `nix flake update` it directly, so skip.
let target_name = match target {
serde_json::Value::String(s) => s.clone(),
_ => continue,
let serde_json::Value::String(target_name) = target else {
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;
};
let path = if prefix.is_empty() {
@ -540,7 +554,10 @@ fn walk_meta_inputs(
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);
}
}