The swarm lives in the swarm UI; this tab lists the hive's agents. The tab is 4G3NTS at #agents, its pane and count pill follow, and the module that renders the containers is agents.js. Comments and docs that named SW4RM or swarm.js follow.
987 lines
38 KiB
JavaScript
987 lines
38 KiB
JavaScript
// 4G3NTS (containers) domain — extracted from tabs.js.
|
|
// Agent topology, container-row rendering, selection bar, and all
|
|
// live-update apply handlers for container-state, transient, and
|
|
// job-queue-rollup ops. See docs/web-ui/dashboard.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 { h, render } from "preact";
|
|
import { JobqRollup } from "@hive/shared/jobq-rollup.js";
|
|
import { containersState } 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.5; // >= 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/dashboard.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/dashboard.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: "agents.js should not need to pull in the jobq to do its
|
|
// job." Rendering is owned by the shared `JobqRollup` Preact component
|
|
// (same one swarm-ui's /jobs page mounts, self-fetching
|
|
// GET /api/jobq/rollup); this file just calls `render(h(...))` and bumps
|
|
// a refresh token to force a refetch.
|
|
//
|
|
// Rendered into #jobq-rollup-section, a sibling of #containers-section
|
|
// kept OUTSIDE that section's per-render `replaceChildren()` wipe (see
|
|
// dashboard.html) — a section that gets wiped on every container-state
|
|
// tick would defeat the component owning its own fetch lifecycle.
|
|
let jobqRollupToken = 0;
|
|
|
|
export function initJobqRollup() {
|
|
const root = $("jobq-rollup-section");
|
|
if (!root) return;
|
|
render(
|
|
h(JobqRollup, {
|
|
endpoint: "/api/jobq/rollup",
|
|
queueHref: "#rebuild",
|
|
refreshToken: jobqRollupToken,
|
|
}),
|
|
root,
|
|
);
|
|
}
|
|
// Refetches on cold load (tabs.js's refreshState) and whenever
|
|
// `rebuild_queue_changed` fires — 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 gives the R3BU1LD QU3U3 tab's JobqGraph.
|
|
export function applyRebuildQueueChanged() {
|
|
jobqRollupToken += 1;
|
|
initJobqRollup();
|
|
}
|
|
|
|
// ─── 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 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;
|
|
}
|
|
|
|
// ─── container ordering ──────────────────────────────────────────────────────
|
|
// Flat alphabetical sort — see docs/web-ui/dashboard.md::C0NTAINERS.
|
|
|
|
function sortedContainerRows(containers) {
|
|
// Close any open context menu before replacing the DOM tree — the
|
|
// previous dropdown element would otherwise be a stale reference.
|
|
closeAllMenus();
|
|
return [...containers].sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
// ─── container row ───────────────────────────────────────────────────────────
|
|
|
|
// Serialise the visible state of a container row into a stable string
|
|
// for change-detection. Includes everything that affects what the row
|
|
// renders — container fields, derived pending/selection state, and
|
|
// link-base context. The async dashboard-state (nav strip, ctx badge,
|
|
// status text) is intentionally excluded: it populates in-place and is
|
|
// preserved when a row is reused.
|
|
function containerRowFingerprint(
|
|
c,
|
|
pending,
|
|
opRunning,
|
|
selected,
|
|
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,
|
|
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, opts) {
|
|
const { pending, opRunning, selected, url, containerBase, forgeBase, s } =
|
|
opts;
|
|
// A single `pending-running` class covers any active transient
|
|
// badge state (see the pending-badge derivation comment in
|
|
// renderContainers).
|
|
const li = el("li", {
|
|
class:
|
|
"container-row" +
|
|
(opRunning ? " pending-running" : "") +
|
|
(selected ? " selected" : ""),
|
|
});
|
|
|
|
// 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/dashboard.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/dashboard.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,
|
|
),
|
|
);
|
|
}
|
|
|
|
body.append(head);
|
|
|
|
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
|
|
// DESTR0Y / PURG3) live on the selection bar, not here — see
|
|
// docs/web-ui/dashboard.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
|
|
// 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: see initJobqRollup, above.
|
|
|
|
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/dashboard.md::Container row` + `docs/networking/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 rows = sortedContainerRows(containers);
|
|
|
|
// Build the ordered list of <li> elements, reusing cached rows
|
|
// whose displayed state hasn't changed.
|
|
const orderedLis = [];
|
|
for (const c of rows) {
|
|
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/dashboard.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);
|
|
|
|
const fp = containerRowFingerprint(
|
|
c,
|
|
pending,
|
|
opRunning,
|
|
selected,
|
|
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, {
|
|
pending,
|
|
opRunning,
|
|
selected,
|
|
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 4G3NTS
|
|
// tab. See docs/web-ui/dashboard.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 4G3NTS (the only tab with agent
|
|
// cards to cross-reference). Selection state lives in-memory and
|
|
// the bar reappears on return to 4G3NTS if still non-empty.
|
|
const onAgentsTab =
|
|
(document.body.dataset.activeTab || "agents") === "agents";
|
|
if (!selected.length || !onAgentsTab) {
|
|
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);
|
|
// Pause/resume is orthogonal to running (see hive-agent-menu.js's own
|
|
// comment on the per-agent version of these two actions) — a paused
|
|
// stopped agent boots paused, a paused running agent keeps its
|
|
// container but drives no turns. Same all-or-nothing enablement rule
|
|
// as every other bulk action here: mixed pause state disables the
|
|
// button rather than silently no-op'ing on part of the selection.
|
|
const allUnpaused = selected.every((c) => !c.paused);
|
|
const allPaused = selected.every((c) => c.paused);
|
|
|
|
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
|
|
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
|
|
const pausedNames = selected.filter((c) => c.paused).map((c) => c.name);
|
|
const unpausedNames = selected.filter((c) => !c.paused).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-pause", "⏸ P4US3", allUnpaused, selected, {
|
|
action: "/api/pause/",
|
|
confirm: (names) =>
|
|
`pause ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? parks the turn loop — inbox messages queue unacked.`,
|
|
disabledTitle: why(
|
|
"⏸ P4US3",
|
|
pausedNames.map((n) => `\`${n}\` is already paused`),
|
|
),
|
|
});
|
|
addBulkButton(actions, "btn-resume", "▶ R3SUM3", allPaused, selected, {
|
|
action: "/api/resume/",
|
|
confirm: (names) =>
|
|
`resume ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? the turn loop restarts and drains queued messages.`,
|
|
disabledTitle: why(
|
|
"▶ R3SUM3",
|
|
unpausedNames.map((n) => `\`${n}\` is not paused`),
|
|
),
|
|
});
|
|
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.`,
|
|
});
|
|
}
|
|
|
|
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.
|
|
//
|
|
// `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.
|
|
for (const name of names) {
|
|
const body = new URLSearchParams(opts.body || {});
|
|
const url =
|
|
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);
|
|
}
|
|
|
|
// ─── 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);
|