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

@ -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);
}
}