From 8c7bc850f3dc422e761aff268212d79e357182e0 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 24 May 2026 04:46:24 +0200 Subject: [PATCH] dashboard: render agent topology as a tree in the container list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #363 (frontend half of milestone #361). Consumes the `ContainerView.parent: Option` 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 `` 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. --- frontend/packages/dashboard/src/app.js | 84 ++++++++++++++++++- frontend/packages/dashboard/src/dashboard.css | 30 +++++++ 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index d7b912b..71453e3 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -579,15 +579,78 @@ window.marked = marked; } // ─── 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) { const root = $('containers-section'); root.innerHTML = ''; // Containers come from the derived map (event-driven) rather than // `s.containers`; `s` still supplies hostname (for the web-ui - // link) and tombstones/meta_inputs (not event-derived yet). - const containers = Array.from(containersState.values()) - .sort((a, b) => a.name.localeCompare(b.name)); + // link) and tombstones/meta_inputs (not event-derived yet). The + // tree builder handles the ordering — we don't pre-sort here. + const containers = Array.from(containersState.values()); const portConflicts = derivePortConflicts(containers); const anyStale = containers.some((c) => c.needs_update); @@ -633,13 +696,26 @@ window.marked = marked; const hostname = (s && s.hostname) || window.location.hostname; 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}/`; // Pending state is overlaid from the transient store, not from // the container row — `ContainerStateChanged` doesn't carry it, // `TransientSet` / `TransientCleared` do. const pending = transientsState.get(c.name)?.kind || null; 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 // icon is an absolutely positioned inside a wrapper div: diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 39d6cbe..592614a 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -82,6 +82,36 @@ a:hover { background: rgba(24, 24, 37, 0.55); 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 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