Watch
0
0
Fork
You've already forked hyperhive
0

dashboard: flatten the agent list, drop dead topology-tree machinery (#4638)

The backend dropped the agent hierarchy's parent field, so every
container is a root and buildAgentTree/treePrefixDom could only ever
produce a single-level flat list — the .tree-prefix CSS lane rules
already matched nothing. Replaced with sortedContainerRows, a plain
alphabetical sort, and dropped the now-dead .tree-prefix/.tree-lane/
data-depth CSS and the depth/isLast/ancestorIsLast fields from the row
fingerprint and buildContainerLi's signature. No visible behavior
change - the rendered list was already flat, just via dead machinery.

Docs updated to describe the simpler implementation directly instead
of narrating the removal (kept the heading name since swarm.js still
points a comment at it).
This commit is contained in:
iris 2026-09-28 10:30:54 +02:00 • committed by mara
commit 6b5c8cc51a
3 changed files with 21 additions and 170 deletions

View file

@ -976,11 +976,14 @@ 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.
Nothing nests. `swarm.js::sortedContainerRows` returns every container
in alphabetical order, full stop — the SW4RM list is a flat roster, no
indent, no joint, no continuation bar. This used to be a real forest
walk (`buildAgentTree`, keyed on each container's `parent` field) with
a DOM-painted `.tree-prefix` indent ladder. Once the backend dropped
the `parent` field entirely, every container became a root and the
tree machinery could only ever produce this same flat order — so it
was removed rather than kept as code that draws a tree with one level.
## Selection bar

View file

@ -200,84 +200,6 @@ 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;

View file

@ -226,89 +226,27 @@ function derivePortConflicts(containers) {
return out;
}
// ─── topology tree ───────────────────────────────────────────────────────────
// See docs/web-ui/dashboard.md::Topology tree for the rendering contract
// (forest walk, alphabetical sort, orphan + cycle handling).
// ─── container ordering ──────────────────────────────────────────────────────
// See docs/web-ui/dashboard.md::Topology tree for why this is a flat sort
// rather than the forest walk it used to be.
function buildAgentTree(containers) {
function sortedContainerRows(containers) {
// Close any open context menu before replacing the DOM tree — the
// previous dropdown element would otherwise be a stale reference.
closeAllMenus();
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 <span> 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;
return [...containers].sort((a, b) => a.name.localeCompare(b.name));
}
// ─── 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, 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.
// 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.
function containerRowFingerprint(
c,
node,
pending,
opRunning,
selected,
@ -326,9 +264,6 @@ function containerRowFingerprint(
pending,
opRunning,
selected,
depth: node.depth,
isLast: node.isLast,
ancestorIsLast: node.ancestorIsLast,
gatewayLinks,
hostname,
});
@ -338,7 +273,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, node, opts) {
function buildContainerLi(c, opts) {
const { pending, opRunning, selected, url, containerBase, forgeBase, s } =
opts;
// A single `pending-running` class covers any active transient
@ -350,13 +285,6 @@ function buildContainerLi(c, node, 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
// <img> + fire-and-forget load with /favicon.svg fallback. The
@ -744,13 +672,12 @@ 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 tree = buildAgentTree(containers);
const rows = sortedContainerRows(containers);
// Build the ordered list of <li> elements, reusing cached rows
// whose displayed state hasn't changed.
const orderedLis = [];
for (const node of tree) {
const c = node.container;
for (const c of rows) {
const url = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}/`
: `http://${hostname}:${c.port}/`;
@ -774,7 +701,6 @@ export function renderContainers(s) {
const fp = containerRowFingerprint(
c,
node,
pending,
opRunning,
selected,
@ -790,7 +716,7 @@ export function renderContainers(s) {
// intact from the previous build, avoiding a redundant fetch.
li = cached.el;
} else {
li = buildContainerLi(c, node, {
li = buildContainerLi(c, {
pending,
opRunning,
selected,