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