Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
4 changed files with 177 additions and 14 deletions

View file

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

View file

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

View file

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

View file

@ -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 <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;
}
// ─── 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
// <img> + 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 <li> 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,