Compare commits
4 changed files with 177 additions and 14 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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` /
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue