From 7f97acf19e6cb3fadecf2abf2114f9d3e0a5b461 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 22 May 2026 23:47:03 +0200 Subject: [PATCH] dashboard: render META INPUTS as a full tree with bulk select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/web-ui.md | 14 +++++-- hive-c0re/assets/app.js | 28 ++++++++++++-- hive-c0re/assets/dashboard.css | 25 +++++++++++++ hive-c0re/src/dashboard.rs | 67 +++++++++++++++++++++------------- 4 files changed, 102 insertions(+), 32 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index 4933af3..b1ecb86 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -107,10 +107,16 @@ the previous process's socket release resolves itself. Spawn approval; existing state is reused), `PURG3` (wipes state + applied dirs; `POST /purge-tombstone/{name}`). 4. **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can - selectively `nix flake update` (hyperhive first, then - `agent-` rows). 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. + selectively `nix flake update`, rendered as an indented tree: + every fetched input at every depth (`hyperhive`, + `hyperhive/nixpkgs`, `agent-`, `agent-/mcp-`, …), each + 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 background; while it does, the panel shows a pulsing "⏳ meta-update running" banner and the update button is disabled diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index fd87d0b..9fa58ae 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 546cb71..e61c007 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -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); diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 9d31dfa..6a64e11 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -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-`) 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-`), 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 { 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 { 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, node_name: &str, prefix: &str, - depth: u32, - max_depth: u32, + visited: &mut std::collections::HashSet, out: &mut Vec, ) { - 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); } }