diff --git a/docs/web-ui/README.md b/docs/web-ui/README.md index 18fadbf0..c0af27d2 100644 --- a/docs/web-ui/README.md +++ b/docs/web-ui/README.md @@ -115,7 +115,8 @@ touching the code rather than using the UI: [`shape.md`](shape.md) (SSE multiplexing, Worker-death self-heal, atomic repaint). - **"What does a container row contain?"** → - [`dashboard.md`](dashboard.md) (Container row, Selection bar). + [`dashboard.md`](dashboard.md) (Container row, Topology tree, + Selection bar). - **"What endpoints does the dashboard expose?"** → [`dashboard.md`](dashboard.md) (Dashboard endpoints, Dashboard event channel). diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 89342d12..9990aa9b 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -57,7 +57,8 @@ surfaces, not tab panes. ## SW4RM tab **C0NTAINERS** — live containers as a flat list, one row per -container, sorted alphabetically by agent name; every container the +container, sorted alphabetically by agent name. No row nests under +another, none carries an indent or a tree glyph, and every container the rescan reports gets a row. Pulsing red banner at the top of this section if any two sub-agents hash to the same port (`port_conflicts` from `/api/state`): the operator must rename @@ -973,6 +974,14 @@ build. (The `hivectl stop --graceful` CLI flag enqueues the same `Signal`/`Drain` pair, so the dashboard and CLI paths behave identically.) +### Topology tree + +Nothing nests. `swarm.js::buildAgentTree` returns every container at +depth 0 in alphabetical order, and the row builder emits no prefix +column for a depth-0 row — so a container row carries no indent, no +joint and no continuation bar, and the SW4RM list reads as a flat +roster. The `.tree-prefix` lane rules in `dashboard.css` match nothing. + ## Selection bar Bulk actions (`R3ST4RT` / `ST0P` / `ST4RT` / `P4US3` / `R3SUM3` / diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index e87d1af7..71106782 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -200,6 +200,84 @@ body.dashboard-shell { opacity 200ms ease, border-color 200ms ease; } +/* Topology indent ladder. See docs/web-ui/dashboard.md::Topology tree (Indent + + lane geometry paragraph) for the 1.8em-per-depth-level + rationale + CSS-attr()-not-yet-portable caveat. */ +.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; +} +/* Tree prefix lanes — DOM-painted, not text-glyph-painted. */ +.container-row .tree-prefix { + position: absolute; + top: 0; + bottom: -0.4em; + display: flex; + flex-direction: row; + pointer-events: none; + user-select: none; + color: var(--purple-dim); +} +.container-row[data-depth="1"] .tree-prefix { + left: -1.8em; +} +.container-row[data-depth="2"] .tree-prefix { + left: -3.6em; +} +.container-row[data-depth="3"] .tree-prefix { + left: -5.4em; +} +.container-row[data-depth="4"] .tree-prefix { + left: -7.2em; +} +.container-row[data-depth="5"] .tree-prefix { + left: -9em; +} +.container-row[data-depth="6"] .tree-prefix { + left: -10.8em; +} +.tree-prefix .tree-lane { + flex: 0 0 1.8em; + position: relative; +} +.tree-prefix .lane-line::before, +.tree-prefix .lane-joint::before { + content: ""; + position: absolute; + left: 0.6em; + top: 0; + bottom: 0; + border-left: 1px solid currentColor; +} +.tree-prefix .lane-joint-last::before { + bottom: auto; + height: 3.1em; +} +.tree-prefix .lane-joint::after { + content: ""; + position: absolute; + left: 0.6em; + top: 3.1em; + width: 2em; + border-top: 1px solid currentColor; +} .container-row:not(.tombstone) { display: flex; align-items: flex-start; diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index 7ae5c817..69c77bab 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -226,26 +226,89 @@ function derivePortConflicts(containers) { return out; } -// ─── container ordering ────────────────────────────────────────────────────── -// Flat alphabetical sort — see docs/web-ui/dashboard.md::C0NTAINERS. +// ─── topology tree ─────────────────────────────────────────────────────────── +// See docs/web-ui/dashboard.md::Topology tree for the rendering contract +// (forest walk, alphabetical sort, orphan + cycle handling). -function sortedContainerRows(containers) { +function buildAgentTree(containers) { // Close any open context menu before replacing the DOM tree — the // previous dropdown element would otherwise be a stale reference. closeAllMenus(); - return [...containers].sort((a, b) => a.name.localeCompare(b.name)); + 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; +} +// Builds the .tree-prefix DOM for a row at the given depth. +// See docs/web-ui/dashboard.md::Topology tree for why this is DOM-painted +// (one positioned per lane) rather than text-glyph-painted. +function treePrefixDom({ depth, ancestorIsLast, isLast }) { + if (depth === 0) return null; + const prefix = el("span", { class: "tree-prefix", "aria-hidden": "true" }); + // Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no + // continuation column — top-level rows are separated visually as + // top-level rows already). + for (let d = 1; d < depth; d++) { + const cls = ancestorIsLast[d] + ? "tree-lane lane-blank" + : "tree-lane lane-line"; + prefix.append(el("span", { class: cls })); + } + const jointCls = + "tree-lane lane-joint " + + (isLast ? "lane-joint-last" : "lane-joint-branch"); + prefix.append(el("span", { class: jointCls })); + return prefix; } // ─── container row ─────────────────────────────────────────────────────────── // Serialise the visible state of a container row into a stable string // for change-detection. Includes everything that affects what the row -// renders — container fields, derived pending/selection state, and -// link-base context. The async dashboard-state (nav strip, ctx badge, -// status text) is intentionally excluded: it populates in-place and is -// preserved when a row is reused. +// renders — container fields, derived pending/selection state, tree +// position, and link-base context. The async dashboard-state (nav +// strip, ctx badge, status text) is intentionally excluded: it +// populates in-place and is preserved when a row is reused. function containerRowFingerprint( c, + node, pending, opRunning, selected, @@ -263,6 +326,9 @@ function containerRowFingerprint( pending, opRunning, selected, + depth: node.depth, + isLast: node.isLast, + ancestorIsLast: node.ancestorIsLast, gatewayLinks, hostname, }); @@ -272,7 +338,7 @@ function containerRowFingerprint( // renderContainers can call this only for rows whose fingerprint // changed (keyed cache), skipping the build + async dashboard-state // fetch for stable rows. -function buildContainerLi(c, opts) { +function buildContainerLi(c, node, opts) { const { pending, opRunning, selected, url, containerBase, forgeBase, s } = opts; // A single `pending-running` class covers any active transient @@ -284,6 +350,13 @@ function buildContainerLi(c, opts) { (opRunning ? " pending-running" : "") + (selected ? " selected" : ""), }); + // 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. A depth-0 row gets neither — no glyph, no indent. + if (node.depth > 0) li.dataset.depth = String(node.depth); + const prefix = treePrefixDom(node); + if (prefix) li.prepend(prefix); // Agent icon: 5em square wrapper with an absolutely-positioned // + fire-and-forget load with /favicon.svg fallback. The @@ -671,12 +744,13 @@ export function renderContainers(s) { // rather than emit a possibly-wrong one. const forgeBase = (s && s.forge_public_url) || null; const ul = existingUl ?? el("ul", { class: "containers" }); - const rows = sortedContainerRows(containers); + const tree = buildAgentTree(containers); // Build the ordered list of
  • elements, reusing cached rows // whose displayed state hasn't changed. const orderedLis = []; - for (const c of rows) { + for (const node of tree) { + const c = node.container; const url = gatewayLinks ? `/agent/${encodeURIComponent(c.name)}/` : `http://${hostname}:${c.port}/`; @@ -700,6 +774,7 @@ export function renderContainers(s) { const fp = containerRowFingerprint( c, + node, pending, opRunning, selected, @@ -715,7 +790,7 @@ export function renderContainers(s) { // intact from the previous build, avoiding a redundant fetch. li = cached.el; } else { - li = buildContainerLi(c, { + li = buildContainerLi(c, node, { pending, opRunning, selected,