dashboard: render agent topology as a tree in the container list
Closes #363 (frontend half of milestone #361). Consumes the `ContainerView.parent: Option<String>` field landing in damocles' backend slice — when present, the container list renders depth-first with sibling-position tree glyphs (├─ / └─ joints + │ continuation columns). When absent (pre-#361 state — every container's `parent` is None) the tree collapses to a flat list with no glyphs and no indent — bit-identical to the legacy render. ## Tree shape - Roots: containers whose `parent` is None OR whose parent name isn't in the current container set (orphan tolerance). - Sibling ordering: alphabetical by name within each level (matches damocles' wire spec at #363#issuecomment-3356). - Cycle safety: any container not reached via the root-walk gets emitted as a root at the end — no agent ever silently disappears from the list when the topology is malformed. - Tree glyphs: ancestor at depth d contributes a │ continuation column when that ancestor has more siblings below; otherwise a 3-space gap. The joint is ├─ for non-last siblings, └─ for the last child. - Depth-0 ancestor column is suppressed: roots already separate visually as top-level rows, no need for a column 0 vertical line. ## DOM / CSS - New `buildAgentTree(containers)` + `treePrefix(node)` helpers in app.js. The render loop walks the tree-ordered list instead of the legacy alphabetical containers array. - Each container row gets `data-depth=N` (only when N > 0) and a `<span class="tree-prefix">…</span>` prepended (absolute-positioned into the row's left margin so the existing flex icon/body layout isn't disrupted). - CSS: per-depth `margin-left` step rules for depths 1-6 (hardcoded rather than typed-attr() because CSS Values 5 is Chromium-only as of 2026). 6 depths cover any reasonable hive topology with headroom; deeper agents render at depth 6 indent without further step — visually clamps gracefully. - `.tree-prefix` rendered with `var(--purple-dim)` so the structural lines read as supporting chrome, not as content. ## Validation - `npm run build` clean. Bundle deltas: dashboard app.js +1.8kb (tree builder + treePrefix + render-loop tweak), dashboard.css +0.4kb (tree-prefix + per-depth indent rules). - The render is a no-op until `ContainerView.parent` is populated — validation in production deferred to once damocles' meta-topology field lands. The pre-#361 path (every parent=None) is exercised by every existing dashboard load. - Forward-compatible with damocles' design pivot at #364 (topology source moved from agent.nix to meta/topology.json). The wire shape on ContainerView is unchanged from the frontend's perspective — the field is just sourced from a different backend store.
This commit is contained in:
parent
3fa12bf363
commit
8c7bc850f3
2 changed files with 110 additions and 4 deletions
|
|
@ -579,15 +579,78 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── state rendering ────────────────────────────────────────────────────
|
// ─── state rendering ────────────────────────────────────────────────────
|
||||||
|
// ─── agent topology (#363) ──────────────────────────────────────────────
|
||||||
|
// Build a forest from `ContainerView.parent` and walk depth-first to
|
||||||
|
// produce a render order with per-row depth + sibling-position info.
|
||||||
|
// Top-level (parent = null OR parent not in the container map) are
|
||||||
|
// roots. Within each level, children are sorted alphabetically by
|
||||||
|
// name; roots likewise. Cycles in the parent graph (malformed config)
|
||||||
|
// are tolerated: any container not reached via root-walk is appended
|
||||||
|
// as a root at the end, so no agent ever disappears from the list.
|
||||||
|
//
|
||||||
|
// For the visual: each row gets a textual prefix column (`├─`, `└─`,
|
||||||
|
// continuation `│ ` or padding ` ` for ancestor columns). When
|
||||||
|
// every container has `parent = null` (today's pre-#361 state), the
|
||||||
|
// tree collapses to a flat list with no glyphs and no indent — bit-
|
||||||
|
// identical to the legacy render.
|
||||||
|
function buildAgentTree(containers) {
|
||||||
|
const byName = new Map();
|
||||||
|
for (const c of containers) byName.set(c.name, c);
|
||||||
|
const children = new Map(); // parent_name → [child_name, …]
|
||||||
|
const roots = [];
|
||||||
|
for (const c of containers) {
|
||||||
|
const p = c.parent || null;
|
||||||
|
if (p == null || !byName.has(p)) {
|
||||||
|
roots.push(c.name);
|
||||||
|
} else {
|
||||||
|
const list = children.get(p) || [];
|
||||||
|
list.push(c.name);
|
||||||
|
children.set(p, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.sort();
|
||||||
|
for (const list of children.values()) list.sort();
|
||||||
|
const out = [];
|
||||||
|
const visited = new Set();
|
||||||
|
function visit(name, depth, ancestorIsLast, isLast) {
|
||||||
|
if (visited.has(name)) return;
|
||||||
|
visited.add(name);
|
||||||
|
const c = byName.get(name);
|
||||||
|
if (!c) return;
|
||||||
|
out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast });
|
||||||
|
const kids = children.get(name) || [];
|
||||||
|
kids.forEach((kid, i) =>
|
||||||
|
visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1));
|
||||||
|
}
|
||||||
|
roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1));
|
||||||
|
// Cycle safety: anything not reached lands at root level so no
|
||||||
|
// agent silently disappears when a config is malformed.
|
||||||
|
for (const c of containers) {
|
||||||
|
if (!visited.has(c.name)) visit(c.name, 0, [], true);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function treePrefix({ depth, ancestorIsLast, isLast }) {
|
||||||
|
if (depth === 0) return '';
|
||||||
|
let s = '';
|
||||||
|
// Ancestor at depth 0 (root) doesn't get a vertical-line column —
|
||||||
|
// roots are separated visually as top-level rows already.
|
||||||
|
for (let d = 1; d < depth; d++) {
|
||||||
|
s += ancestorIsLast[d] ? ' ' : '│ ';
|
||||||
|
}
|
||||||
|
s += isLast ? '└─ ' : '├─ ';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
function renderContainers(s) {
|
function renderContainers(s) {
|
||||||
const root = $('containers-section');
|
const root = $('containers-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
||||||
// Containers come from the derived map (event-driven) rather than
|
// Containers come from the derived map (event-driven) rather than
|
||||||
// `s.containers`; `s` still supplies hostname (for the web-ui
|
// `s.containers`; `s` still supplies hostname (for the web-ui
|
||||||
// link) and tombstones/meta_inputs (not event-derived yet).
|
// link) and tombstones/meta_inputs (not event-derived yet). The
|
||||||
const containers = Array.from(containersState.values())
|
// tree builder handles the ordering — we don't pre-sort here.
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
const containers = Array.from(containersState.values());
|
||||||
const portConflicts = derivePortConflicts(containers);
|
const portConflicts = derivePortConflicts(containers);
|
||||||
const anyStale = containers.some((c) => c.needs_update);
|
const anyStale = containers.some((c) => c.needs_update);
|
||||||
|
|
||||||
|
|
@ -633,13 +696,26 @@ window.marked = marked;
|
||||||
|
|
||||||
const hostname = (s && s.hostname) || window.location.hostname;
|
const hostname = (s && s.hostname) || window.location.hostname;
|
||||||
const ul = el('ul', { class: 'containers' });
|
const ul = el('ul', { class: 'containers' });
|
||||||
for (const c of containers) {
|
const tree = buildAgentTree(containers);
|
||||||
|
for (const node of tree) {
|
||||||
|
const c = node.container;
|
||||||
const url = `http://${hostname}:${c.port}/`;
|
const url = `http://${hostname}:${c.port}/`;
|
||||||
// Pending state is overlaid from the transient store, not from
|
// Pending state is overlaid from the transient store, not from
|
||||||
// the container row — `ContainerStateChanged` doesn't carry it,
|
// the container row — `ContainerStateChanged` doesn't carry it,
|
||||||
// `TransientSet` / `TransientCleared` do.
|
// `TransientSet` / `TransientCleared` do.
|
||||||
const pending = transientsState.get(c.name)?.kind || null;
|
const pending = transientsState.get(c.name)?.kind || null;
|
||||||
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
|
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
|
||||||
|
// Topology: depth contributes left-padding; the glyph string in
|
||||||
|
// the .tree-prefix span draws the ├─ / └─ joint + continuation
|
||||||
|
// lines (`│ `) for ancestors whose subtree extends below this
|
||||||
|
// row. Both are CSS-driven from the data attributes so the
|
||||||
|
// legacy flat layout (every container at depth 0) is bit-
|
||||||
|
// identical to today's render — no glyph, no indent.
|
||||||
|
if (node.depth > 0) li.dataset.depth = String(node.depth);
|
||||||
|
const prefix = treePrefix(node);
|
||||||
|
if (prefix) {
|
||||||
|
li.prepend(el('span', { class: 'tree-prefix', 'aria-hidden': 'true' }, prefix));
|
||||||
|
}
|
||||||
|
|
||||||
// Full-height square agent icon, left of the card body. The
|
// Full-height square agent icon, left of the card body. The
|
||||||
// icon is an <img> absolutely positioned inside a wrapper div:
|
// icon is an <img> absolutely positioned inside a wrapper div:
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,36 @@ a:hover {
|
||||||
background: rgba(24, 24, 37, 0.55);
|
background: rgba(24, 24, 37, 0.55);
|
||||||
transition: opacity 200ms ease, border-color 200ms ease;
|
transition: opacity 200ms ease, border-color 200ms ease;
|
||||||
}
|
}
|
||||||
|
/* Topology indent (#363). Each depth level shifts the row right by one
|
||||||
|
step; the .tree-prefix span (drawn by app.js::treePrefix) carries
|
||||||
|
the ├─ / └─ glyph and any continuation lines that thread through
|
||||||
|
ancestor columns. When every container has parent=null (pre-#361
|
||||||
|
state) `[data-depth]` is absent on every row and these rules are
|
||||||
|
no-ops — the layout reads exactly like the legacy flat list.
|
||||||
|
Per-depth indent: hardcoded steps for 6 levels (sufficient for any
|
||||||
|
plausible hive topology) — the typed `attr()` function from CSS
|
||||||
|
Values 5 would collapse this to one rule, but browser support is
|
||||||
|
still partial (Chromium-only as of 2026). */
|
||||||
|
.container-row[data-depth] { position: relative; }
|
||||||
|
.container-row[data-depth="1"] { margin-left: 1.8em; }
|
||||||
|
.container-row[data-depth="2"] { margin-left: 3.6em; }
|
||||||
|
.container-row[data-depth="3"] { margin-left: 5.4em; }
|
||||||
|
.container-row[data-depth="4"] { margin-left: 7.2em; }
|
||||||
|
.container-row[data-depth="5"] { margin-left: 9em; }
|
||||||
|
.container-row[data-depth="6"] { margin-left: 10.8em; }
|
||||||
|
.container-row .tree-prefix {
|
||||||
|
/* Tree-line glyphs sit in the left margin so the rest of the
|
||||||
|
container card layout (icon + body flex split) stays unchanged.
|
||||||
|
Mono font for column alignment with the ├─ / └─ / │ bricks. */
|
||||||
|
position: absolute;
|
||||||
|
left: -1.6em;
|
||||||
|
top: 0.6em;
|
||||||
|
color: var(--purple-dim);
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: pre;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
/* Live cards get the icon-left / body-right split; tombstone rows keep
|
/* Live cards get the icon-left / body-right split; tombstone rows keep
|
||||||
the plain stacked block layout. The icon is a background-image div
|
the plain stacked block layout. The icon is a background-image div
|
||||||
with no intrinsic size, so its load state can never reflow the row
|
with no intrinsic size, so its load state can never reflow the row
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue