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,
|
[`shape.md`](shape.md) (SSE multiplexing, Worker-death self-heal,
|
||||||
atomic repaint).
|
atomic repaint).
|
||||||
- **"What does a container row contain?"** →
|
- **"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?"** →
|
- **"What endpoints does the dashboard expose?"** →
|
||||||
[`dashboard.md`](dashboard.md) (Dashboard endpoints, Dashboard event
|
[`dashboard.md`](dashboard.md) (Dashboard endpoints, Dashboard event
|
||||||
channel).
|
channel).
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@ surfaces, not tab panes.
|
||||||
## SW4RM tab
|
## SW4RM tab
|
||||||
|
|
||||||
**C0NTAINERS** — live containers as a flat list, one row per
|
**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
|
rescan reports gets a row. Pulsing red banner at the top
|
||||||
of this section if any two sub-agents hash to the same port
|
of this section if any two sub-agents hash to the same port
|
||||||
(`port_conflicts` from `/api/state`): the operator must rename
|
(`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
|
`Signal`/`Drain` pair, so the dashboard and CLI paths behave
|
||||||
identically.)
|
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
|
## Selection bar
|
||||||
|
|
||||||
Bulk actions (`R3ST4RT` / `ST0P` / `ST4RT` / `P4US3` / `R3SUM3` /
|
Bulk actions (`R3ST4RT` / `ST0P` / `ST4RT` / `P4US3` / `R3SUM3` /
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,84 @@ body.dashboard-shell {
|
||||||
opacity 200ms ease,
|
opacity 200ms ease,
|
||||||
border-color 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) {
|
.container-row:not(.tombstone) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
|
||||||
|
|
@ -226,26 +226,89 @@ function derivePortConflicts(containers) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── container ordering ──────────────────────────────────────────────────────
|
// ─── topology tree ───────────────────────────────────────────────────────────
|
||||||
// Flat alphabetical sort — see docs/web-ui/dashboard.md::C0NTAINERS.
|
// 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
|
// Close any open context menu before replacing the DOM tree — the
|
||||||
// previous dropdown element would otherwise be a stale reference.
|
// previous dropdown element would otherwise be a stale reference.
|
||||||
closeAllMenus();
|
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 ───────────────────────────────────────────────────────────
|
// ─── container row ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Serialise the visible state of a container row into a stable string
|
// Serialise the visible state of a container row into a stable string
|
||||||
// for change-detection. Includes everything that affects what the row
|
// for change-detection. Includes everything that affects what the row
|
||||||
// renders — container fields, derived pending/selection state, and
|
// renders — container fields, derived pending/selection state, tree
|
||||||
// link-base context. The async dashboard-state (nav strip, ctx badge,
|
// position, and link-base context. The async dashboard-state (nav
|
||||||
// status text) is intentionally excluded: it populates in-place and is
|
// strip, ctx badge, status text) is intentionally excluded: it
|
||||||
// preserved when a row is reused.
|
// populates in-place and is preserved when a row is reused.
|
||||||
function containerRowFingerprint(
|
function containerRowFingerprint(
|
||||||
c,
|
c,
|
||||||
|
node,
|
||||||
pending,
|
pending,
|
||||||
opRunning,
|
opRunning,
|
||||||
selected,
|
selected,
|
||||||
|
|
@ -263,6 +326,9 @@ function containerRowFingerprint(
|
||||||
pending,
|
pending,
|
||||||
opRunning,
|
opRunning,
|
||||||
selected,
|
selected,
|
||||||
|
depth: node.depth,
|
||||||
|
isLast: node.isLast,
|
||||||
|
ancestorIsLast: node.ancestorIsLast,
|
||||||
gatewayLinks,
|
gatewayLinks,
|
||||||
hostname,
|
hostname,
|
||||||
});
|
});
|
||||||
|
|
@ -272,7 +338,7 @@ function containerRowFingerprint(
|
||||||
// renderContainers can call this only for rows whose fingerprint
|
// renderContainers can call this only for rows whose fingerprint
|
||||||
// changed (keyed cache), skipping the build + async dashboard-state
|
// changed (keyed cache), skipping the build + async dashboard-state
|
||||||
// fetch for stable rows.
|
// fetch for stable rows.
|
||||||
function buildContainerLi(c, opts) {
|
function buildContainerLi(c, node, opts) {
|
||||||
const { pending, opRunning, selected, url, containerBase, forgeBase, s } =
|
const { pending, opRunning, selected, url, containerBase, forgeBase, s } =
|
||||||
opts;
|
opts;
|
||||||
// A single `pending-running` class covers any active transient
|
// A single `pending-running` class covers any active transient
|
||||||
|
|
@ -284,6 +350,13 @@ function buildContainerLi(c, opts) {
|
||||||
(opRunning ? " pending-running" : "") +
|
(opRunning ? " pending-running" : "") +
|
||||||
(selected ? " selected" : ""),
|
(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
|
// Agent icon: 5em square wrapper with an absolutely-positioned
|
||||||
// <img> + fire-and-forget load with /favicon.svg fallback. The
|
// <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.
|
// rather than emit a possibly-wrong one.
|
||||||
const forgeBase = (s && s.forge_public_url) || null;
|
const forgeBase = (s && s.forge_public_url) || null;
|
||||||
const ul = existingUl ?? el("ul", { class: "containers" });
|
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
|
// Build the ordered list of <li> elements, reusing cached rows
|
||||||
// whose displayed state hasn't changed.
|
// whose displayed state hasn't changed.
|
||||||
const orderedLis = [];
|
const orderedLis = [];
|
||||||
for (const c of rows) {
|
for (const node of tree) {
|
||||||
|
const c = node.container;
|
||||||
const url = gatewayLinks
|
const url = gatewayLinks
|
||||||
? `/agent/${encodeURIComponent(c.name)}/`
|
? `/agent/${encodeURIComponent(c.name)}/`
|
||||||
: `http://${hostname}:${c.port}/`;
|
: `http://${hostname}:${c.port}/`;
|
||||||
|
|
@ -700,6 +774,7 @@ export function renderContainers(s) {
|
||||||
|
|
||||||
const fp = containerRowFingerprint(
|
const fp = containerRowFingerprint(
|
||||||
c,
|
c,
|
||||||
|
node,
|
||||||
pending,
|
pending,
|
||||||
opRunning,
|
opRunning,
|
||||||
selected,
|
selected,
|
||||||
|
|
@ -715,7 +790,7 @@ export function renderContainers(s) {
|
||||||
// intact from the previous build, avoiding a redundant fetch.
|
// intact from the previous build, avoiding a redundant fetch.
|
||||||
li = cached.el;
|
li = cached.el;
|
||||||
} else {
|
} else {
|
||||||
li = buildContainerLi(c, {
|
li = buildContainerLi(c, node, {
|
||||||
pending,
|
pending,
|
||||||
opRunning,
|
opRunning,
|
||||||
selected,
|
selected,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue