Per mara's steer on #3053 ("chip/pill/badge is the same if you squint ... theme unification is part of the goal" then "make it common css instead of component, thats fine. but make them look unified (not as much per usage css)"): shared CSS, not a JS component. New @hive/shared/pill.css defines two classes, `.hive-pill` (primary state chips) and `.hive-pill-sm` (secondary meta chips) — border/ border-radius/padding/font-size/letter-spacing (colour stays per-site, the meaningful semantic part). Every render call site across dashboard (swarm.js/core.js/builds.js) and agent (index.html/app.js) now carries one of the two shared classes directly, alongside its own existing semantic-colour modifier class. Second cut of this PR, per argus's approve + mara's follow-up review comment on the first cut: the first version instead enumerated every legacy classname (`.badge`, `.status-badge`, `.header-pill`, etc.) straight into pill.css's own selector groups so no call sites needed touching. Mara's correction: that just relocates the duplication rather than removing it, and the shared CSS shouldn't have to keep naming every consumer. This version does the real rename instead. Most visible consequence, unchanged from the first cut: dashboard's `.badge` family moves off its own shape (2px square corners, uppercase, tighter padding) onto the shared rounded-pill shape + agent's "sm" tier sizing. `npm run build` clean across all three packages; verified the compiled bundles carry the new classnames at every call site (dashboard JS, agent index.html + app.js), not just the source tree. Fixes #3053
1103 lines
49 KiB
JavaScript
1103 lines
49 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, transient, and
|
|
// job-queue-rollup ops. See docs/web-ui.md::Container row for the
|
|
// rendering contract.
|
|
|
|
import {
|
|
$, form, fmtAgeSecs,
|
|
} from './common.js';
|
|
import { el } from '@hive/shared/dom.js';
|
|
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
|
import {
|
|
containersState, questionsState,
|
|
} from './state.js';
|
|
import { closeAllMenus } from '@hive/shared/hive-menu.js';
|
|
import './agent-menu/hive-agent-menu.js'; // registers <hive-agent-menu> — side-effect import
|
|
|
|
// 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 ─────────────────────────────────────────────────────
|
|
|
|
// 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 -> Map<transient_kind, since_unix>: an agent can hold
|
|
// several pills at once (every *running* node naming the agent lights
|
|
// one now, not just a curated "worth surfacing" subset — see
|
|
// docs/web-ui.md::Container row), keyed the same way the backend's
|
|
// own tombstone map is, `(agent, label)`, so a clear only removes the
|
|
// specific pill it names rather than guessing. `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();
|
|
|
|
// ─── job queue rollup (queue-summary banner only) ──────────────────────────
|
|
//
|
|
// The ONE piece of job-queue-derived state on this page, and deliberately
|
|
// narrow — mara, on review of the DagView migration: "swarm.js should not
|
|
// need to pull in the jobq to do its job." An earlier version of this file
|
|
// fetched GET /api/jobq/graph (the full node tree) for two things: a
|
|
// per-agent queued-but-not-running badge (`queuedOpsByAgent()`, removed
|
|
// per "remove the per agent pending stuff - only show what is running" —
|
|
// a card's pending badges are transients-only now, which already means
|
|
// "what is running") and this banner, which was pulled entirely per "dont
|
|
// replace one legacy thing with another" (a client-side tally over the
|
|
// generic graph was itself judged a stopgap). Now that the dedicated
|
|
// rollup endpoint exists (hive-jobq-wire::state_rollup, served at
|
|
// GET /api/jobq/rollup), the banner reads *that* instead — a handful of
|
|
// pre-tallied counts, not the graph.
|
|
let jobqRollupState = [];
|
|
|
|
// Fetches the rollup fresh and re-renders. Called on cold load (see
|
|
// tabs.js's refreshState) and whenever `rebuild_queue_changed` fires
|
|
// (applyRebuildQueueChanged below) — a payload-less push trigger by
|
|
// design, confirmed with atlas on the jobq-deletion tracker: the event
|
|
// carries no `queue` field this page reads, same "something changed,
|
|
// go refetch" treatment builds.js already gives it for
|
|
// <hive-jobq-graph>.refresh().
|
|
// Best-effort: a failed fetch leaves the previous snapshot in place
|
|
// rather than wiping the banner on a network blip.
|
|
export async function refreshJobqRollup() {
|
|
let counts;
|
|
try {
|
|
const r = await fetch('/api/jobq/rollup');
|
|
if (!r.ok) return;
|
|
counts = await r.json();
|
|
} catch {
|
|
return;
|
|
}
|
|
jobqRollupState = counts;
|
|
renderContainersFromState();
|
|
}
|
|
export function applyRebuildQueueChanged() {
|
|
refreshJobqRollup();
|
|
}
|
|
|
|
// ─── transients ─────────────────────────────────────────────────────────────
|
|
|
|
export function syncTransientsFromSnapshot(s) {
|
|
transientsState.clear();
|
|
const nowUnix = Math.floor(Date.now() / 1000);
|
|
for (const t of s.transients || []) {
|
|
// Snapshot is a flat list — multiple rows CAN share the same
|
|
// `name` (one row per running node) — so group into this agent's
|
|
// kind map rather than overwrite.
|
|
let byKind = transientsState.get(t.name);
|
|
if (!byKind) {
|
|
byKind = new Map();
|
|
transientsState.set(t.name, byKind);
|
|
}
|
|
// Snapshot ships `secs` (server-computed); reconstruct an
|
|
// approximate since_unix so the live ticker keeps progressing
|
|
// without surprising jumps when the next snapshot lands.
|
|
byKind.set(t.kind, t.since_unix ?? (nowUnix - (t.secs || 0)));
|
|
}
|
|
}
|
|
export function applyTransientSet(ev) {
|
|
let byKind = transientsState.get(ev.name);
|
|
if (!byKind) {
|
|
byKind = new Map();
|
|
transientsState.set(ev.name, byKind);
|
|
}
|
|
byKind.set(ev.transient_kind, ev.since_unix);
|
|
renderContainersFromState();
|
|
}
|
|
export function applyTransientCleared(ev) {
|
|
const byKind = transientsState.get(ev.name);
|
|
if (!byKind || !byKind.delete(ev.transient_kind)) return;
|
|
// Drop the now-empty outer entry too — keeps `transientsState.size`
|
|
// meaning "agents with at least one pill" (checked at the
|
|
// no-containers-and-no-transients empty-state gate below).
|
|
if (byKind.size === 0) 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.
|
|
// Rendering + agent-specific interaction lives in the <hive-agent-menu>
|
|
// custom element (./agent-menu/hive-agent-menu.js, imported above for its
|
|
// customElements.define side effect); the generic dropdown mechanics
|
|
// (open/close, positioning, singleton coordination — closed via
|
|
// `closeAllMenus` below) live in the shared `<hive-menu>` component it
|
|
// composes internally (@hive/shared/hive-menu.js). This is a thin
|
|
// wrapper matching <hive-dialog>'s `._opts`-before-append convention,
|
|
// since a custom element created via `document.createElement` can't
|
|
// take constructor args.
|
|
function buildAgentMenu(c, forgeBase) {
|
|
const menu = document.createElement('hive-agent-menu');
|
|
menu._opts = { c, forgeBase };
|
|
return menu;
|
|
}
|
|
|
|
// ─── 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.
|
|
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.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,
|
|
failed: c.failed,
|
|
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;
|
|
// A single `pending-running` class now covers the whole "has at
|
|
// least one badge" state — there's no more queued-but-not-running
|
|
// row to distinguish it from (see the pending-badge derivation
|
|
// comment in renderContainers), so the separate no-tint `pending`
|
|
// class from before that removal is gone rather than kept as a
|
|
// class that would now always co-occur with this one.
|
|
const li = el('li', {
|
|
class: 'container-row'
|
|
+ (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) {
|
|
// No public forge URL to link to — hide rather than build
|
|
// a broken/guessed href (see forgeBase above).
|
|
if (lnk.kind === 'forge' && !forgeBase) continue;
|
|
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: 'hive-pill-sm 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: `hive-pill-sm ${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 badge — `✖ gave
|
|
// up` (red) when systemd's restart bound was hit (`c.failed`), else
|
|
// the plain muted `■ not running`. Both read `running: false`;
|
|
// `failed` is what tells "gave up" apart from "stopped on purpose"
|
|
// (an absent `failed` — an older backend — is `undefined`, falsy,
|
|
// so this degrades to the old single 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.length) {
|
|
// One badge per pill — an agent can carry several transients at
|
|
// once now (see docs/web-ui.md::Container row), each rendered
|
|
// independently rather than collapsed into one label.
|
|
for (const label of pending) {
|
|
head.append(el('span', { class: 'pending-state' },
|
|
el('span', { class: 'spinner' }, '◐'), ' ', label + '…'));
|
|
}
|
|
} else if (!c.running && c.failed) {
|
|
head.append(el('span',
|
|
{
|
|
class: 'hive-pill-sm badge-fail',
|
|
title: 'container gave up — its unit hit systemd\'s bounded restart limit and stopped '
|
|
+ 'on its own, not on operator request; start it to bring the harness back up',
|
|
},
|
|
'✖ gave up'));
|
|
} else if (!c.running) {
|
|
head.append(el('span',
|
|
{ class: 'hive-pill-sm 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: 'hive-pill-sm 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, 'hive-pill-sm 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, 'hive-pill-sm badge-warn btn-inline', 'needs update ↻',
|
|
'rebuild ' + c.name + '? hot-reloads the container.',
|
|
{}, { noRefresh: true },
|
|
));
|
|
}
|
|
|
|
if (c.active_model) {
|
|
head.append(el('span',
|
|
{ class: 'hive-pill-sm 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: 'hive-pill-sm 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('hive-warn', { level: 'error' },
|
|
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: one compact line above the container list when
|
|
// the job queue has active work, linking to the full queue on the
|
|
// BU1LDS page. Reads GET /api/jobq/rollup's pre-tallied `roots` counts
|
|
// (see jobqRollupState above) rather than the full graph — `roots`
|
|
// because "N running / M queued" has always meant *operations*, not
|
|
// raw steps (one rebuild is ~7 nodes but 1 root); `nodes` exists on
|
|
// the same endpoint for a consumer that wants steps instead, unused
|
|
// here. `Finishing` counts as running (own work done, subtree still
|
|
// going, still in flight) — same treatment `roots` gets nowhere else,
|
|
// since the rollup endpoint doesn't collapse the two itself.
|
|
const byState = new Map(jobqRollupState.map((c) => [c.state, c]));
|
|
const running = (byState.get('Running')?.roots ?? 0)
|
|
+ (byState.get('Finishing')?.roots ?? 0);
|
|
const queued = byState.get('Pending')?.roots ?? 0;
|
|
if (running || queued) {
|
|
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: state.forge_public_url (set by the NixOS module
|
|
// from services.hyperhive.forge.publicUrl) or null — never guessed
|
|
// from "<hostname>:3000". `null` propagates down to
|
|
// buildContainerLi/buildAgentMenu, which already hide forge links
|
|
// 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);
|
|
|
|
// 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-badge derivation: transients only — "what is running,"
|
|
// full stop (mara, on review: "remove the per agent pending stuff -
|
|
// only show what is running"; rendered one badge per pill — mara,
|
|
// earlier: "show all running nodes that name the agent"). See
|
|
// docs/web-ui.md::Container row.
|
|
const transientKindsMap = transientsState.get(c.name);
|
|
// Sorted for stable badge order across renders — Map iteration
|
|
// order is insertion order, which shifts as pills clear/re-add.
|
|
const pending = transientKindsMap
|
|
? Array.from(transientKindsMap.keys()).sort() : [];
|
|
const opRunning = pending.length > 0;
|
|
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);
|