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:
parent
dd3a820e57
commit
7f97acf19e
4 changed files with 102 additions and 32 deletions
|
|
@ -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-<n>` 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-<n>`, `agent-<n>/mcp-<x>`, …), 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue