hyperhive/frontend/packages/dashboard/src/swarm.js
iris 59041d4f03 feat(dashboard): add paused badge and pause/resume toggle to agent cards
When ContainerView.paused is true, show a clickable yellow `⏸ paused`
badge on the agent card that POSTs to the new /api/resume/{name} endpoint
to un-park the turn loop. The badge doubles as the resume button so the
state is self-documenting and one click to fix.

The agent action menu gains ⏸ P4US3 (when not paused) and ▶ R3SUM3
(when paused), orthogonal to the running/stopped start/stop actions.

On the backend, /api/pause/{name} and /api/resume/{name} POST routes
wire to Coordinator::set_paused and trigger an immediate rescan so the
badge flips via the existing SSE ContainerUpdate without polling.

Depends on the ContainerView.paused field and Coordinator::set_paused
added in the parent PR.
2026-07-26 14:11:02 +02:00

1256 lines
52 KiB
JavaScript

// SW4RM (containers) domain — extracted from tabs.js.
// Agent topology, container-row rendering, selection bar, peer-hives block,
// and all live-update apply handlers for container-state, rebuild-queue, and
// transient ops. See docs/web-ui.md::Container row for the rendering contract.
import {
$, el, form, fmtAgeSecs,
} from './common.js';
import { themedConfirm, themedToast } from './modal.js';
import {
containersState, questionsState,
} from './state.js';
// Context-window badge thresholds. Preferred source is each container's
// `context_window_tokens` from /api/state (the real window for the model
// it last ran on) — thresholds are then 75% / 50% of it, matching the
// harness compaction watermarks (compact at 75%, auto-reset at 50%). The
// fixed token constants are the fallback for when that field is absent
// (agent has no turns yet, or no per-model config matched the model).
const CTX_WARN_FRACTION = 0.75; // >= this share of the window -> red
const CTX_CAUTION_FRACTION = 0.50; // >= this share of the window -> yellow
const CTX_WARN_TOKENS = 150_000; // fallback red threshold (~= 75% of 200k)
const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (~= 50% of 200k)
// ─── module-level state ─────────────────────────────────────────────────────
let rebuildQueueState = [];
// Keyed container row cache. Maps agent name -> { el: <li>, fingerprint }.
// Allows renderContainers to skip rebuilding rows whose displayed state
// hasn't changed — prevents full-wipe flicker + avoids redundant async
// dashboard-state fetches on every SSE event.
const containerRowCache = new Map();
// Derived transient state — cold-loaded from /api/state.transients,
// then mutated live by `transient_set` / `transient_cleared`. Keyed
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
// the elapsed-seconds badge ticks without polling.
const transientsState = new Map();
// In-memory set of selected agent logical names backing the sticky
// #selection-bar. See docs/web-ui.md::Selection bar for the
// interaction model (icon-click toggle, Esc/clear button drop,
// tab-gated visibility).
const selectionState = new Set();
let openAgentMenu = null; // currently open dropdown element, or null
// ─── rebuild queue ──────────────────────────────────────────────────────────
export function syncRebuildQueueFromSnapshot(s) {
rebuildQueueState = (s.rebuild_queue || []).slice();
}
export function applyRebuildQueueChanged(ev) {
rebuildQueueState = (ev.queue || []).slice();
// Re-render the SW4RM tab so newly-queued / newly-running
// rebuild-queue ops light up the right card with a building...
// badge, and finished ops fall back to the regular state badges.
// See docs/web-ui.md::Container row for the badge taxonomy.
renderContainersFromState();
}
// Map from agent name -> highest-priority in-flight op ({ kind, state })
// (`running` beats `queued`). Used by the container row renderer to
// surface "building..." / "meta-updating..." badges on the SW4RM tab
// when an op is still in the rebuild queue but no operator-initiated
// transient is set.
//
// Agent is per-node, not per-DAG (a DAG can span agents — e.g. the
// startup sweep's MetaLock cascade, or a hive-wide restart), so this
// derives each agent's in-flight state from its own node(s) within the
// entry rather than the DAG's overall `state`/`kind` — a DAG can be
// `running` overall while a given agent's subgraph hasn't started yet
// (still queued behind an earlier node in its chain), and vice versa.
function inFlightOpsByAgent() {
const out = new Map();
for (const e of rebuildQueueState) {
if (e.state !== 'queued' && e.state !== 'running') continue;
// spawn ops target an agent that doesn't exist yet as a
// container — the transient store already drives the
// pending row for that case. Skip here to avoid double-
// surfacing if the spawn op happens to land in the queue
// while the row exists transiently.
if (e.kind === 'spawn') continue;
const perAgentState = new Map();
for (const n of e.nodes || []) {
if (!n.agent) continue;
if (n.state !== 'queued' && n.state !== 'running') continue;
const cur = perAgentState.get(n.agent);
if (!cur || (cur === 'queued' && n.state === 'running')) {
perAgentState.set(n.agent, n.state);
}
}
for (const [agent, state] of perAgentState) {
const cur = out.get(agent);
if (!cur || (cur.state === 'queued' && state === 'running')) {
out.set(agent, { kind: e.kind, state });
}
}
}
return out;
}
// ─── transients ─────────────────────────────────────────────────────────────
export function syncTransientsFromSnapshot(s) {
transientsState.clear();
for (const t of s.transients || []) {
// Snapshot ships `secs` (server-computed); reconstruct an
// approximate since_unix so the live ticker keeps progressing
// without surprising jumps when the next snapshot lands.
const nowUnix = Math.floor(Date.now() / 1000);
transientsState.set(t.name, {
kind: t.kind,
since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
});
}
}
export function applyTransientSet(ev) {
transientsState.set(ev.name, {
kind: ev.transient_kind,
since_unix: ev.since_unix,
});
renderContainersFromState();
}
export function applyTransientCleared(ev) {
if (transientsState.delete(ev.name)) renderContainersFromState();
}
// Re-render using the last cached snapshot (containers come from
// /api/state, transients overlay from the derived map). The snapshot
// is stashed on window.__hyperhive_state by refreshState; on cold
// load before the first snapshot we just skip.
export function renderContainersFromState() {
const s = window.__hyperhive_state;
if (s) renderContainers(s);
}
// ─── container-state apply ──────────────────────────────────────────────────
export function applyContainerStateChanged(ev) {
if (!ev.container || !ev.container.name) return;
containersState.set(ev.container.name, ev.container);
renderContainersFromState();
}
export function applyContainerRemoved(ev) {
if (containersState.delete(ev.name)) renderContainersFromState();
}
// ─── selection ──────────────────────────────────────────────────────────────
function toggleSelection(name) {
if (selectionState.has(name)) selectionState.delete(name);
else selectionState.add(name);
renderContainersFromState();
}
function clearSelection() {
if (selectionState.size === 0) return;
selectionState.clear();
renderContainersFromState();
}
// Esc clears the current selection (operator escape hatch — mirrors
// the side-panel close pattern). Ignored when an editable element
// has focus so typing in compose / answer / journal-search isn't
// intercepted.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!selectionState.size) return;
const a = document.activeElement;
if (a && (a.isContentEditable
|| a.tagName === 'INPUT'
|| a.tagName === 'TEXTAREA'
|| a.tagName === 'SELECT')) return;
e.preventDefault();
clearSelection();
});
document.addEventListener('click', (e) => {
if (e.target && e.target.closest('#selection-clear')) {
clearSelection();
}
});
// ─── per-agent context menu ──────────────────────────────────────────────────
// Three-dot (⋮) button on each agent card for quick single-agent
// lifecycle actions without needing to select first. State-aware:
// restart/stop only shown when running, start only shown when
// stopped; rebuild + destroy/purge always shown.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
function closeAllAgentMenus() {
if (!openAgentMenu) return;
openAgentMenu.hidden = true;
const wrap = openAgentMenu.closest('.agent-menu');
if (wrap) {
wrap.classList.remove('open');
const btn = wrap.querySelector('.agent-menu-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
openAgentMenu = null;
}
// Close on any click outside an agent-menu element.
document.addEventListener('click', (e) => {
if (!e.target.closest('.agent-menu')) closeAllAgentMenus();
}, true);
// Close on Escape. stopImmediatePropagation so the selection-clear
// handler on the same element doesn't also fire when a menu is open.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && openAgentMenu) {
closeAllAgentMenus();
e.stopImmediatePropagation();
}
}, true);
// Single-agent POST helper shared by all menu items.
async function agentMenuPost(actionPath, name, body, graceful) {
const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
try {
const resp = await fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
body: body ? new URLSearchParams(body) : undefined,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
}
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
}
}
function buildAgentMenu(c, forgeBase) {
const wrap = el('div', { class: 'agent-menu' });
const btn = el('button', {
type: 'button',
class: 'agent-menu-btn',
title: `actions for ${c.name}`,
'aria-label': `actions for ${c.name}`,
'aria-haspopup': 'menu',
'aria-expanded': 'false',
}, '⋮');
const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' });
function menuItem(label, opts) {
const li = el('li', { role: 'presentation' });
const item = el('button', {
type: 'button',
class: 'agent-menu-item',
role: 'menuitem',
}, label);
item.addEventListener('click', async () => {
closeAllAgentMenus();
let graceful = false;
if (opts.confirm) {
const r = await themedConfirm({
message: opts.confirm,
danger: true,
confirmLabel: opts.confirmLabel || 'confirm',
checkboxes: opts.graceful
? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
: [],
});
if (!r) return;
graceful = !!r.graceful;
}
await agentMenuPost(opts.action, c.name, opts.body || null, graceful);
});
li.append(item);
return li;
}
function menuSep() {
return el('li', { class: 'agent-menu-sep', role: 'separator' });
}
// Navigation link item (opens in same tab by default).
function menuLink(label, href, title) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href,
role: 'menuitem',
title: title || '',
}, label);
a.addEventListener('click', () => closeAllAgentMenus());
li.append(a);
return li;
}
// Show only actions that are applicable in the current state.
if (c.running) {
dropdown.append(
menuItem('↺ R3ST4RT', {
action: '/api/restart/',
confirm: `restart ${c.name}?`,
graceful: true,
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts',
}),
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
);
} else {
dropdown.append(
menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }),
);
}
// Pause/resume is orthogonal to running: a paused stopped agent boots
// paused; a paused running agent keeps its container but drives no turns.
if (c.paused) {
dropdown.append(
menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }),
);
} else {
dropdown.append(
menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }),
);
}
dropdown.append(
menuSep(),
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load and pre-selects this
// agent's journal without extra clicks.
menuLink('journal logs →',
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
`view ${c.name} journal logs`),
);
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/api/destroy/',
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/api/destroy/',
body: { purge: 'on' },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
target: '_blank',
rel: 'noopener',
role: 'menuitem',
title: 'deployed config commit on forge',
}, `deployed:${c.deployed_sha}`);
li.append(a);
dropdown.append(menuSep(), li);
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wasHidden = dropdown.hidden;
closeAllAgentMenus();
if (wasHidden) {
dropdown.hidden = false;
btn.setAttribute('aria-expanded', 'true');
wrap.classList.add('open');
openAgentMenu = dropdown;
}
});
wrap.append(btn, dropdown);
return wrap;
}
// ─── port conflicts ──────────────────────────────────────────────────────────
// Re-derive port conflicts from the live containers map. Mirrors the
// server-side `build_port_conflicts` so the banner reacts to event
// updates instead of waiting for a /api/state refetch.
function derivePortConflicts(containers) {
const byPort = new Map();
for (const c of containers) {
if (!byPort.has(c.port)) byPort.set(c.port, []);
byPort.get(c.port).push(c.name);
}
const out = [];
for (const [port, agents] of byPort) {
if (agents.length > 1) {
agents.sort();
out.push({ port, agents });
}
}
out.sort((a, b) => a.port - b.port);
return out;
}
// ─── topology tree ───────────────────────────────────────────────────────────
// See docs/web-ui.md::Topology tree for the rendering contract
// (forest walk, alphabetical sort, orphan + cycle handling).
function buildAgentTree(containers) {
// Close any open context menu before replacing the DOM tree — the
// previous dropdown element would otherwise be a stale reference.
closeAllAgentMenus();
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.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, 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,
askerCount, targetCount, gatewayLinks, hostname) {
return JSON.stringify({
running: c.running,
paused: c.paused,
needs_login: c.needs_login,
needs_update: c.needs_update,
active_model: c.active_model,
port: c.port,
pending,
opRunning,
selected,
askerCount,
targetCount,
depth: node.depth,
isLast: node.isLast,
ancestorIsLast: node.ancestorIsLast,
gatewayLinks,
hostname,
});
}
// Build a single container-row <li> from scratch. Extracted so
// 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) {
const {
pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s,
} = opts;
const li = el('li', {
class: 'container-row'
+ (pending ? ' pending' : '')
+ (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. Both are CSS-driven from the data attributes so the
// legacy flat layout (every container at depth 0) is bit-
// identical to today's render — 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
// wrapper doubles as the selection toggle (click / keyboard).
// See docs/web-ui.md::Container row for the layout + load-strategy
// rationale.
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
const icon = el('div', {
class: 'container-icon',
role: 'button',
tabindex: '0',
'aria-pressed': selected ? 'true' : 'false',
title: selected
? `deselect ${c.name} (or press Esc to clear all)`
: `select ${c.name} for bulk actions`,
}, iconImg);
icon.addEventListener('click', (e) => {
e.preventDefault();
toggleSelection(c.name);
});
icon.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSelection(c.name);
}
});
if (c.running) {
iconImg.src = `${url}icon`;
iconImg.addEventListener('error', () => {
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
iconImg.dataset.fallback = '1';
icon.classList.add('icon-unreachable');
iconImg.src = '/favicon.svg';
});
} else {
// Container stopped — skip the doomed `${url}icon` fetch and
// go straight to the dimmed hyperhive mark. Avoids a noisy
// failed request in the console + the brief broken-image flash.
icon.classList.add('icon-unreachable');
iconImg.src = '/favicon.svg';
}
// Card body: the three stacked content lines, right of the icon.
const body = el('div', { class: 'card-body' });
// -- identity -------------------------------------------------------
const head = el('div', { class: 'head' });
head.append(
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
);
// Icon-only nav strip — populated async from the agent's own
// `GET /api/dashboard-state` (via gateway when enabled, direct
// TCP otherwise). The agent is the single source of truth for its
// link list: stats / screen (GUI agents only — c0re's disk-based
// fallback cannot detect this) / forge profile / agent-configs /
// extras. DOM-built — link strings come from the agent's process
// and must never reach the HTML parser.
const navStrip = el('span', { class: 'nav-strip' });
head.append(navStrip);
if (c.running) {
// Fetch the lean dashboard-state snapshot from the agent directly.
// Populates: nav strip links (including the screen link that
// c0re's disk-based build cannot detect), rate_limited badge,
// ctx-window badge, and self-reported status text.
// Fails gracefully when the agent is starting up or the gateway
// is not yet routing to it — badges simply don't appear.
// Only runs when the row is first built (fingerprint changed) —
// reused rows keep their previously-fetched nav strip + badges.
fetch(`${containerBase}/api/dashboard-state`)
.then((r) => (r.ok ? r.json() : null))
.then((ds) => {
if (!ds) return;
// -- nav strip -----------------------------------------------
if (Array.isArray(ds.links)) {
for (const lnk of ds.links) {
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
: lnk.kind === 'external' ? (lnk.url || '')
: /* container */ containerBase + '/' + (lnk.url || '');
const a = el('a', {
class: 'nav-link',
href,
target: '_blank',
rel: 'noopener',
title: lnk.label || '',
});
// Plain text — agent-controlled strings stay out of innerHTML.
a.textContent = lnk.icon || lnk.label || '';
navStrip.append(a);
}
}
// -- agent-owned status badges --------------------------------
// rate_limited: only show when no other critical badge is
// already shown (pending / not-running already handled sync).
if (ds.rate_limited) {
head.append(el('span',
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
'⊘ rate limited'));
}
// ctx-window badge
if (ds.ctx_tokens != null) {
const k = Math.round(ds.ctx_tokens / 1000);
const win = ds.context_window_tokens;
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn'
: ds.ctx_tokens >= caution ? 'badge-ctx-caution'
: 'badge-ctx-ok';
const title = win != null
? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
+ `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
: `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`));
}
// -- agent status text (self-reported via set_status) ---------
if (ds.status_text) {
const nowUnix = Math.floor(Date.now() / 1000);
const ageStr = ds.status_set_at != null
? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : '';
// Stamp data-set-at so the 30s ticker below keeps the age
// label current even when the row is reused from the cache.
const ageAttrs = ds.status_set_at != null
? { class: 'status-age', 'data-set-at': String(ds.status_set_at) }
: { class: 'status-age' };
// Icon + message are wrapped together in `.status-msg` so the
// CSS can clamp *just the message* to two lines; the age span is
// a separate flex sibling that is never clipped, so the "(set N
// ago)" stamp stays visible even when the status text is long.
body.append(el('div', {
class: 'agent-status',
title: `agent self-reported status${ageStr}`,
},
el('span', { class: 'status-msg' },
el('span', { class: 'status-icon' }, '◈ '),
ds.status_text,
),
el('span', ageAttrs, ageStr),
));
}
})
.catch(() => { /* graceful: agent starting / gateway miss -> no data */ });
}
// Status / runtime badges. Pending transients always win
// (start / stop / restart / rebuild is in progress). Otherwise,
// when the container is stopped, surface a single `■ not running`
// badge. `needs_login` is still c0re-owned (reads auth sentinel
// files on the host). rate_limited / ctx / status_text are
// agent-owned and rendered by the async dashboard-state fetch above.
if (pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
} else if (!c.running) {
head.append(el('span',
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
'■ not running'));
} else if (c.needs_login) {
head.append(el('a',
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
'needs login →'));
}
if (c.paused) {
// Paused badge is also a resume button: clicking POSTs /api/resume/{name}
// which removes the marker and flips the badge off via the SSE rescan.
head.append(form(
'/api/resume/' + c.name, 'badge badge-paused btn-inline', '⏸ paused',
`resume ${c.name}? the turn loop restarts and drains queued messages.`,
{}, { noRefresh: true },
));
}
if (c.needs_update) {
head.append(form(
'/api/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true },
));
}
if (c.active_model) {
head.append(el('span',
{ class: 'badge badge-model', title: `active claude model: ${c.active_model}` },
c.active_model));
}
// Pending questions where this agent is the asker (awaiting an
// answer) or the target (owes a reply). Derived live from
// questionsState so the badge updates instantly on QuestionAdded /
// QuestionResolved without a separate backend field.
if (agentQCount > 0) {
const parts = [];
if (askerCount > 0) parts.push(`${askerCount} asked`);
if (targetCount > 0) parts.push(`${targetCount} to answer`);
head.append(el('span',
{
class: 'badge badge-loose-ends',
title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`,
},
`${agentQCount}`));
}
body.append(head);
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
// DESTR0Y / PURG3) moved to the selection bar — see
// docs/web-ui.md::Selection bar. The contextual `needs update ↻`
// chip in the head row stays — it's a state-hint, not an
// action button.
li.append(icon, body, buildAgentMenu(c, forgeBase));
return li;
}
// ─── containers render ───────────────────────────────────────────────────────
export function renderContainers(s) {
const root = $('containers-section');
// #containers-section only exists on /dashboard.html. tabs.js is the
// bundle for that page only (flow.html loads flow.js instead).
// Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the
// no-op-when-target-absent convention the other renderers
// (renderQuestions, renderApprovals, etc.) follow.
if (!root) return;
// Containers come from the derived map (event-driven) rather than
// `s.containers`; `s` still supplies hostname (for the web-ui
// link) and tombstones/meta_inputs (not event-derived yet). The
// tree builder handles the ordering — we don't pre-sort here.
const containers = Array.from(containersState.values());
const portConflicts = derivePortConflicts(containers);
const anyStale = containers.some((c) => c.needs_update);
// Preserve the keyed container list across the header-section
// rebuild. We wipe the banners/buttons above the list on every
// render (simple), but recycle <li> elements for unchanged rows
// (keyed) to avoid full-DOM thrash and redundant async fetches.
const existingUl = root.querySelector('ul.containers');
root.replaceChildren();
// Port-hash collisions: rename one of the listed agents and
// rebuild. The banner sits above the agent list so it's the
// first thing the operator sees when something's wedged.
if (portConflicts.length) {
const banner = el('div', { class: 'port-conflict' },
el('strong', {}, '⚠ port collision'), ' — ');
const groups = portConflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
}
if (anyStale) {
root.append(form(
'/api/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
{}, { noRefresh: true },
));
}
// Queue-summary banner: when the rebuild queue has active work,
// show one compact at-a-glance line + a link to the full queue on the
// C0R3 page. Replaces the old per-transient spinner list — the actual
// running step is already visible per-agent on each card (transient +
// in-flight-queue badges), so the top of the tab only needs the summary.
const activeQueue = rebuildQueueState.filter(
(e) => e.state === 'queued' || e.state === 'running',
);
if (activeQueue.length) {
const running = activeQueue.filter((e) => e.state === 'running').length;
const queued = activeQueue.length - running;
const parts = [];
if (running) parts.push(`${running} running`);
if (queued) parts.push(`${queued} queued`);
root.append(el('div', { class: 'queue-summary' },
el('span', { class: 'glyph spinner' }, '◐'), ' ',
el('strong', {}, 'build queue'), ' — ',
parts.join(' · '), ' ',
el('a', { class: 'queue-summary-link', href: '/builds.html' }, 'view queue →'),
));
}
if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
// Drop stale selections (agent destroyed while selected). Defensive —
// the action bar would otherwise loop POST against a gone agent.
const liveNames = new Set(containers.map((c) => c.name));
for (const n of Array.from(selectionState)) {
if (!liveNames.has(n)) selectionState.delete(n);
}
const hostname = (s && s.hostname) || window.location.hostname;
// When hive-gateway is in front of the dashboard, build same-origin
// `/agent/<name>/` URLs instead of the direct `http://<host>:<port>/`
// TCP fallback — the gateway proxies the prefix to the per-agent
// harness (unix-domain via `agent-sockets.json`, or a computed TCP
// loopback port while the socket marker is absent). See
// `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
const gatewayLinks = !!(s && s.gateway_enabled);
// Forge public URL: prefer state.forge_public_url (set by the NixOS
// module when forge.behindGateway=true), fall back to
// "<hostname>:3000" for gateway-off / local-dev deploys.
const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
const ul = existingUl ?? el('ul', { class: 'containers' });
const tree = buildAgentTree(containers);
// In-flight rebuild / meta-update / destroy ops per agent name —
// see docs/web-ui.md::Container row for the building... badge
// rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
// gap when no operator transient is set).
const inFlight = inFlightOpsByAgent();
// 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;
const url = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}/`
: `http://${hostname}:${c.port}/`;
// Container nav-strip base: gateway prefix or direct TCP.
const containerBase = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}`
: `http://${hostname}:${c.port}`;
// Pending-state derivation + queued-vs-running split — see
// docs/web-ui.md::Container row for the transient -> in-flight
// queue priority order and the opRunning rationale.
const transientKind = transientsState.get(c.name)?.kind || null;
const op = !transientKind ? inFlight.get(c.name) : null;
const pending = transientKind
|| (op && (op.state === 'running'
? (op.kind === 'meta_update' ? 'meta-updating'
: op.kind === 'destroy' ? 'destroying'
: op.kind === 'restart' ? 'restarting'
: op.kind === 'start' ? 'starting'
: op.kind === 'stop' ? 'stopping'
: op.kind === 'graceful_stop' ? 'stopping'
: op.kind === 'reconcile' ? 'reconciling'
: 'rebuilding')
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: op.kind === 'restart' ? 'restart queued'
: op.kind === 'start' ? 'start queued'
: op.kind === 'stop' ? 'stop queued'
: op.kind === 'graceful_stop' ? 'stop queued'
: op.kind === 'reconcile' ? 'reconcile queued'
: 'rebuild queued')));
const opRunning = transientKind != null
|| (op != null && op.state === 'running');
const selected = selectionState.has(c.name);
// Pending questions where this agent is the asker (awaiting an
// answer) or the target (owes a reply). Derived live from
// questionsState so the badge updates instantly on QuestionAdded /
// QuestionResolved without a separate backend field.
const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
const agentQCount = questionsState.pending.filter(
(q) => q.asker === c.name || q.target === c.name,
).length;
const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
askerCount, targetCount, gatewayLinks, hostname);
const cached = containerRowCache.get(c.name);
let li;
if (cached && cached.fingerprint === fp) {
// Row unchanged — reuse the existing DOM node. The async
// dashboard-state (nav strip, ctx badge, status text) stays
// intact from the previous build, avoiding a redundant fetch.
li = cached.el;
} else {
li = buildContainerLi(c, node, {
pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s,
});
containerRowCache.set(c.name, { el: li, fingerprint: fp });
}
orderedLis.push(li);
}
// Remove cache entries for agents that no longer exist.
for (const [name, entry] of containerRowCache) {
if (!liveNames.has(name)) {
entry.el.remove();
containerRowCache.delete(name);
}
}
// Apply correct DOM order without a full wipe. insertBefore is a
// no-op when the node is already at position i, so stable sections
// of the list cause zero layout work.
for (let i = 0; i < orderedLis.length; i++) {
if (ul.children[i] !== orderedLis[i]) {
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
}
}
// Trim any excess children (defensive — shouldn't happen after the
// cache-removal pass above, but keeps the ul length exact).
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
root.append(ul);
renderSelectionBar(containers);
}
// ─── selection bar ───────────────────────────────────────────────────────────
// Sticky-bottom strip; visible when >=1 agent selected on the SW4RM
// tab. See docs/web-ui.md::Selection bar for the interaction model
// and the per-action availability rules (disabled-with-tooltip for
// actions that don't apply to the full selection). Actions POST per
// agent in a loop (endpoints are individually idempotent /
// event-covered, so no new bulk backend wire is needed).
export function renderSelectionBar(containers) {
const bar = $('selection-bar');
if (!bar) return;
const countSpan = $('selection-count');
const namesSpan = $('selection-names');
const actions = $('selection-actions');
if (!countSpan || !namesSpan || !actions) return;
const selected = containers.filter((c) => selectionState.has(c.name));
// Tab-gate: bar only renders on SW4RM (the only tab with agent
// cards to cross-reference). Selection state lives in-memory and
// the bar reappears on return to SW4RM if still non-empty.
const onSwarmTab = (document.body.dataset.activeTab || 'swarm') === 'swarm';
if (!selected.length || !onSwarmTab) {
bar.hidden = true;
document.body.classList.remove('has-selection');
return;
}
bar.hidden = false;
document.body.classList.add('has-selection');
countSpan.textContent = selected.length === 1
? '1 agent selected'
: selected.length + ' agents selected';
namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', ');
// Recompute action availability + tooltips per render. Each
// action declares which agents it CAN'T run on; the bar disables
// the button and surfaces the offending names in the tooltip.
actions.replaceChildren();
const allRunning = selected.every((c) => c.running);
const allStopped = selected.every((c) => !c.running);
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
function why(label, blockers) {
if (!blockers.length) return null;
return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`;
}
addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
action: '/api/restart/',
confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
graceful: true,
gracefulLabel: 'restart gracefully — let each agent finish its turn and flush state before the container restarts',
disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
});
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
action: '/api/kill/',
confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
confirmLabel: '■ stop',
graceful: true,
disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
});
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
action: '/api/start/',
confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)),
});
addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, {
action: '/api/rebuild/',
confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
});
addBulkButton(actions, 'btn-destroy', 'DESTR0Y', true, selected, {
action: '/api/destroy/',
confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
});
addBulkButton(actions, 'btn-destroy', 'PURG3', true, selected, {
action: '/api/destroy/',
body: { purge: 'on' },
confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
});
// Move agent(s) in the topology tree — selecting an option in the
// M0V3 dropdown immediately confirms + executes the move. "(no parent)"
// promotes to root (empty new_parent on the backend). Cycle-safe:
// dropdown filters out self and descendants on the client side; the
// backend rechecks via `topology::set_parent`.
//
// Backend: POST /api/topology/set-parent (dashboard.rs),
// form-encoded `child=<name>&new_parent=<target-or-empty>`. Re-emits
// container snapshots on success so the tree repaints without a
// separate refresh.
addMoveActions(actions, selected, containers);
}
// Render the M0V3 picker in the selection bar. Selecting any real option
// (including "(no parent)") immediately fires a confirm + POST — no
// separate button. Backend `topology::set_parent` refuses invalid moves
// and the refusal surfaces in the alert roll-up.
function addMoveActions(parent, selected, containers) {
const candidates = validReparentCandidates(selected, containers);
const wrap = el('span', { class: 'move-picker' });
const selectTitle = selected.length === 1
? `change ${selected[0].name}'s parent`
: `change ${selected.length} agents' parent`;
const sel = el('select', { class: 'move-picker-select', title: selectTitle });
sel.append(el('option', { value: '' }, '⇢ M0V3 →'));
// "(no parent)" -> empty new_parent on the backend (promotes to root).
sel.append(el('option', { value: '__root__' }, '(no parent)'));
for (const name of candidates) {
sel.append(el('option', { value: name }, name));
}
sel.addEventListener('change', async () => {
if (sel.selectedIndex === 0) return;
const newParent = sel.value === '__root__' ? '' : sel.value;
const newParentLabel = sel.value === '__root__' ? '(no parent)' : sel.value;
const names = selected.map((c) => c.name);
const promptMsg = names.length === 1
? `move ${names[0]}${newParentLabel}?`
: `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`;
if (!(await themedConfirm({ message: promptMsg, danger: true }))) {
sel.selectedIndex = 0;
return;
}
sel.disabled = true;
const failures = [];
if (names.length === 1) {
// Single agent — use the form-encoded endpoint (backwards compat).
try {
const body = new URLSearchParams({ child: names[0], new_parent: newParent });
const resp = await fetch('/api/topology/set-parent', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
failures.push(`${names[0]}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
}
} catch (err) {
failures.push(`${names[0]}: ${err}`);
}
} else {
// Multiple agents — use the bulk endpoint so all moves land in
// a single git commit instead of one per agent.
try {
const payload = names.map((n) => ({ child: n, new_parent: newParent || null }));
const resp = await fetch('/api/topology/set-parent-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
failures.push(`bulk: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
}
} catch (err) {
failures.push(`bulk: ${err}`);
}
}
sel.disabled = false;
sel.selectedIndex = 0;
if (failures.length) {
themedToast(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 });
}
});
wrap.append(sel);
parent.append(wrap);
}
// Filter the dashboard's container list to those that are valid
// re-parent targets for the `selected` agents: anyone who isn't IN
// the selection itself, isn't a descendant of any selected agent
// (cycle prevention across the whole batch). The backend re-checks
// per-agent via `topology::set_parent`; this client-side filter is
// purely UX so the operator can't pick an obviously-invalid option.
function validReparentCandidates(selected, containers) {
// Build child map once.
const childrenOf = new Map();
for (const c of containers) {
const p = c.parent || null;
if (!childrenOf.has(p)) childrenOf.set(p, []);
childrenOf.get(p).push(c.name);
}
// Union descendant set across every selected agent (each agent's
// descendants AND itself).
const blocked = new Set();
for (const t of selected) {
const queue = [t.name];
blocked.add(t.name);
while (queue.length) {
const n = queue.shift();
for (const child of (childrenOf.get(n) || [])) {
if (blocked.has(child)) continue;
blocked.add(child);
queue.push(child);
}
}
}
return containers
.filter((c) => !blocked.has(c.name))
.map((c) => c.name)
.sort();
}
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
const names = selected.map((c) => c.name);
const btn = el('button', {
type: 'button',
class: 'btn ' + btnClass,
}, label);
if (!enabled) {
btn.disabled = true;
if (opts.disabledTitle) btn.title = opts.disabledTitle;
}
btn.addEventListener('click', async () => {
if (btn.disabled) return;
const msg = opts.confirm(names);
let graceful = false;
if (msg) {
const r = await themedConfirm({
message: msg,
danger: true,
confirmLabel: opts.confirmLabel || 'confirm',
checkboxes: opts.graceful
? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let each agent finish its turn and flush state before the container stops' }]
: [],
});
if (!r) return;
graceful = !!r.graceful;
}
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
const failures = [];
// Sequential POSTs to keep server-side serialisation predictable
// (rebuild_queue dedups but other endpoints don't); the loop is
// short — bulk selections are typically a handful of agents.
//
// Two URL shapes:
// - `opts.action` is a path prefix and the agent name gets
// appended (lifecycle endpoints: /start/<name>, /rebuild/<name>).
// `opts.body` is a static object applied to every POST.
// - `opts.perAgentBodyFor(name)` is set: `opts.action` is the
// full URL (no name appended) and the per-agent body comes
// from the callback. Used by /api/topology/set-parent, where
// the agent name is a body field rather than a URL component.
for (const name of names) {
const body = opts.perAgentBodyFor
? new URLSearchParams(opts.perAgentBodyFor(name))
: new URLSearchParams(opts.body || {});
const url = opts.perAgentBodyFor
? opts.action
: opts.action + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
}
} catch (err) {
failures.push(`${name}: ${err}`);
}
}
btn.disabled = false;
btn.innerHTML = original;
if (failures.length) {
themedToast(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 });
}
// Container-lifecycle events (ContainerStateChanged /
// ContainerRemoved / RebuildQueueChanged) flow over the existing
// SSE channel and update the derived stores live — no manual
// refresh needed.
});
parent.append(btn);
}
// ─── peer hives ──────────────────────────────────────────────────────────────
// Peer hives: render link cards as a headline section under SW4RM
// (state.peer_hives). Called on every state refresh. When nothing is
// federated, the "P33R H1V3S" headline block is hidden entirely and a
// quiet grey note replaces the cards.
export function renderPeerHives(peers) {
const block = $('peers-block');
const root = $('peers-section');
if (!root) return;
root.replaceChildren();
const hasPeers = Array.isArray(peers) && peers.length > 0;
if (block) block.hidden = !hasPeers;
if (!hasPeers) {
root.append(el('p', { class: 'empty' }, 'no peer hives configured'));
return;
}
const ul = el('ul', { class: 'containers' });
for (const p of peers) {
const li = el('li', { class: 'container-row' });
const head = el('div', { class: 'head' });
const icon = el('span', { class: 'container-icon' }, '⬡');
const nameEl = el('span', { class: 'name' }, p.name || p.url);
const linkEl = el('a', {
href: p.url,
target: '_blank',
rel: 'noopener noreferrer',
class: 'meta',
title: 'open ' + p.name + ' dashboard',
}, p.url);
head.append(icon, nameEl);
li.append(head, el('div', { class: 'meta' }, linkEl));
ul.append(li);
}
root.append(ul);
}
// ─── tickers ─────────────────────────────────────────────────────────────────
// 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
// (unix seconds) on the `.status-age` span. Keyed container rows persist
// across re-renders, so without this ticker the "(set N ago)" label would
// become stale as time passes. 30s granularity matches fmtAgeSecs precision
// (sub-minute values round to seconds, coarser above that).
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.status-age[data-set-at]').forEach((node) => {
const setAt = Number(node.dataset.setAt);
if (!Number.isFinite(setAt) || setAt === 0) return;
node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`;
});
}, 30_000);