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:
iris 2026-05-24 04:46:24 +02:00 committed by Mara
parent 3fa12bf363
commit 8c7bc850f3
2 changed files with 110 additions and 4 deletions

View file

@ -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 <img> absolutely positioned inside a wrapper div:

View file

@ -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