Watch
0
0
Fork
You've already forked hyperhive
0

dashboard: the SW4RM tab is the agents tab

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.
This commit is contained in:
iris 2026-10-14 20:23:14 +02:00 • committed by mara
commit e455964cb3
16 changed files with 70 additions and 69 deletions

View file

@ -127,7 +127,7 @@ Two things live in the `agent-configs` Forgejo organization:
[How a hive reads the forge](#how-a-hive-reads-the-forge)).
- The dashboard links each container's deployed config commit to this
config repo on the swarm's forge (`swarm.forge.publicUrl`; `null` means no
link), so operators can go straight from the SW4RM tab into
link), so operators can go straight from the 4G3NTS tab into
the rendered repo without an extra `git` step.
The `hive-forge` CLI (separate workspace crate, see

View file

@ -18,7 +18,7 @@ never more than one step from the hub.
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
of your time. It's a single page with five tabs:
- **SW4RM** — every agent, live. This is the default tab and the one
- **4G3NTS** — every agent, live. This is the default tab and the one
you'll check most.
- **R3BU1LD QU3U3**, **M3T4 1NPUTS** and **BUILD L0GS** — the build
lifecycle: the rebuild queue with a live log of whatever is building,
@ -37,7 +37,7 @@ if the gateway isn't in front).
## The things you'll actually do
**Check on an agent.** SW4RM shows every container as a row: name,
**Check on an agent.** 4G3NTS shows every container as a row: name,
whether it's running, what it's currently doing (a live status pill —
`rebuilding…`, `starting…`, or similar — while something's in flight),
and quick links (stats, screen, forge profile). Select the name to open
@ -49,7 +49,7 @@ repo. Review and merge them on the forge; the merge deploys the change.
See [`config-changes.md`](../agent-lifecycle/config-changes.md#config-changes).
**Start, stop, restart, or rebuild an agent.** Select one or more
agents on SW4RM (select the icon) and use the selection bar, or use the
agents on 4G3NTS (select the icon) and use the selection bar, or use the
per-agent `⋮` menu on a single row. Rebuilding re-applies that agent's
current config; use it whenever an agent
shows as "needs update."

View file

@ -10,7 +10,7 @@ matrix accounts, the controller's job graph — are on the
hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`).
It has a fixed chrome header at the top and a `<main>` that shows exactly
one tab pane at a time. The URL hash (`#swarm`, `#rebuild`, `#meta`,
one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
`#buildlogs`, `#permissions`) drives which pane is
active; hash changes don't reload the page. FL0W and the other
standalone pages are reachable from the H0M3 hub at `/`, not
@ -19,12 +19,12 @@ from the dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane):
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
at `/`. Every surface links back to the hub rather than to each other.
- **Tab strip**: `◆ SW4RM ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
`◆ BUILD L0GS ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only —
the SYST3M panels live on the
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
live on their own pages too, all reachable from the
H0M3 hub (not the tab strip). Count pills on SW4RM
H0M3 hub (not the tab strip). Count pills on 4G3NTS
(stale containers) and R3BU1LD QU3U3 (rebuilds in flight).
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip.
@ -56,7 +56,7 @@ The FL0W page uses a slim header (a `← home` back-link + the page
title) rather than the dashboard tab strip — it's a standalone surface,
not a tab pane.
## SW4RM tab
## 4G3NTS tab
**C0NTAINERS** — live containers as a flat list, one row per
container, sorted alphabetically by agent name; every container the
@ -123,7 +123,7 @@ dashboard's one tab strip, `/api/state` snapshot and
`rebuild_queue_changed`, `meta_inputs_changed` and `meta_update_running`
on the stream alongside the dashboard's other kinds. The count pill on
R3BU1LD QU3U3 is the number of rebuilds in flight.
The SW4RM tab does **not** read the graph endpoint, by design — the swarm
The 4G3NTS tab does **not** read the graph endpoint, by design — the swarm
view stays independent of job-queue internals. Its per-agent pending
badges are transient-only, and its queue-summary banner reads the much
narrower `GET /api/jobq/rollup` instead (see Container row, below) — a
@ -414,7 +414,7 @@ the tooltip says "select … for bulk actions" or "deselect … (or press
Esc to clear all)." The `<img>` points at `<url>/icon`; load failure
falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
body sits to the right with three stacked lines
(`assets/swarm.js::renderContainers`).
(`assets/agents.js::renderContainers`).
<!-- vale write-good.Passive = NO -->
**Icon layout + load strategy:** the `<img>` is absolutely
@ -586,7 +586,7 @@ queue — N running · M queued — view queue →` (the link goes to the
R3BU1LD QU3U3 tab). The shared `JobqRollup` Preact component
(`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page
mounts, pointed at swarm-controller's own rollup endpoint instead),
mounted once into `#jobq-rollup-section` by `swarm.js::initJobqRollup`
mounted once into `#jobq-rollup-section` by `agents.js::initJobqRollup`
and refreshed via its own handle rather than being re-rendered by
`renderContainers`. Reads `GET /api/jobq/rollup`
(`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`,
@ -648,7 +648,7 @@ Bulk actions (`R3ST4RT` / `ST0P` / `ST4RT` / `P4US3` / `R3SUM3` /
buttons — see **Container row** above. Selecting an agent's icon
toggles its selection (an in-memory `Set<name>`); `Esc` or the bar's
`✕ clear` button drops everything. The selection persists across tab
switches in-memory — the bar just hides on non-SW4RM tabs since other
switches in-memory — the bar just hides on non-4G3NTS tabs since other
tabs don't show the agent cards needed to cross-reference.
When the operator selects one or more agents (via the icon), a sticky

View file

@ -208,7 +208,7 @@ re-render), call `refreshState()`. State shapes live in
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
`web_ui/state.rs::StateSnapshot` — when
adding state fields, plumb through the snapshot struct and the
relevant domain module (`swarm.js`, `permissions.js`, etc.) render function.
relevant domain module (`agents.js`, `permissions.js`, etc.) render function.
**Focus preservation:** `refreshState` checks whether
`document.activeElement` sits inside one of the managed sections

View file

@ -30,7 +30,7 @@ layers per-agent additions on top of the default agent dist.
output is reproducible without network access at build time.
- **esbuild**: `agent` and `swarm-ui` are Preact apps; `dashboard` is a
vanilla-JS + custom-element MPA with a few Preact-rendered pages
(`builds.js`, `swarm.js`, imported by the dashboard entry); `shared` holds both kinds of module. No
(`builds.js`, `agents.js`, imported by the dashboard entry); `shared` holds both kinds of module. No
framework runtime beyond Preact where it's used. Each workspace's
`build.mjs` is a short esbuild script.
- **Single-PR migration**: see the tracked design proposal and

View file

@ -1,6 +1,6 @@
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
// custom element. One instance per agent card, appended after
// .card-body in the container row's flex layout (swarm.js's
// .card-body in the container row's flex layout (agents.js's
// buildContainerLi via buildAgentMenu).
//
// Deliberately a *light-DOM* element with no shadow root of its own: all
@ -66,7 +66,7 @@ async function agentMenuPost(actionPath, name, body, flags) {
class HiveAgentMenu extends HTMLElement {
connectedCallback() {
// Same reconnect-without-detach hazard as <hive-menu> (see its
// connectedCallback comment) — swarm.js's row cache can move an
// connectedCallback comment) — agents.js's row cache can move an
// already-built <li> subtree without a real detach. Without this
// guard a reconnect would append a second <hive-menu> on top of the
// first, doubling the dropdown.

View file

@ -0,0 +1,987 @@
// 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);

View file

@ -84,7 +84,7 @@ code {
Semantic colour variants only — shape comes from `hive-pill-sm`
(shared with the agent UI's pill/chip family, @hive/shared/pill.css),
carried directly in the markup alongside each of these classnames
(swarm.js/core.js/builds.js). Used on container rows and build-log
(agents.js/core.js/builds.js). Used on container rows and build-log
rows. */
.badge-warn {
color: var(--amber);

View file

@ -127,14 +127,14 @@ body.dashboard-shell {
font-weight: bold;
}
/* `display` above beats the UA's `[hidden]` rule, so an empty count (the
SW4RM and R3BU1LD QU3U3 pills at zero) would otherwise draw a "0". */
4G3NTS and R3BU1LD QU3U3 pills at zero) would otherwise draw a "0". */
.tab-count[hidden] {
display: none;
}
/* Tab pane visibility — createTabStrip (@hive/shared/tabs.js) sets the
`hidden` attribute on every inactive pane (resolved from the URL hash;
default — no hash — is SW4RM). Only the active pane stays visible. */
default — no hash — is 4G3NTS). Only the active pane stays visible. */
.tab-pane[hidden] {
display: none;
}
@ -322,7 +322,7 @@ hive-agent-menu {
--menu-btn-opacity: 1;
}
/* Card actions dim while a row has any pending-state badge (transient-
driven only now — see swarm.js's pending-badge derivation comment;
driven only now — see agents.js's pending-badge derivation comment;
there is no separate queued-but-not-running row state to tell apart
from this one anymore). */
.container-row.pending-running .actions {
@ -443,12 +443,12 @@ hive-agent-menu {
shared <hive-warn> component — amber/no-pulse for
the standing caveat (a pulsing banner that's always present just
teaches you to stop seeing it), `severity="red" pulse` for the port
collision (an incident, not a caveat). See core.js/swarm.js and
collision (an incident, not a caveat). See core.js/agents.js and
hive-warn.css for the actual styling now. */
/* Build-queue summary banner on the SW4RM tab moved to the shared
/* Build-queue summary banner on the 4G3NTS tab moved to the shared
`JobqRollup` Preact component (`.jqr-*` classes, imported above) —
mounted by swarm.js::initJobqRollup into #jobq-rollup-section. */
mounted by agents.js::initJobqRollup into #jobq-rollup-section. */
.pending-state {
color: var(--amber);

View file

@ -26,14 +26,14 @@
<nav class="tabbar" id="tabbar" role="tablist">
<a
class="tab"
id="tab-swarm"
href="#swarm"
id="tab-agents"
href="#agents"
role="tab"
aria-controls="tab-pane-swarm"
data-tab="swarm"
aria-controls="tab-pane-agents"
data-tab="agents"
>
<span class="tab-label">◆ SW4RM ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span>
<span class="tab-label">◆ 4G3NTS ◆</span>
<span class="tab-count" id="tab-count-agents" hidden></span>
</a>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
reached from the H0M3 hub — keeps the dashboard tab strip lean.
@ -89,7 +89,7 @@
view, not part of the operational tab strip. -->
<!-- (Peer hives are no longer a tab — they render as a headline
under SW4RM. See tab-pane-swarm.) -->
under 4G3NTS. See tab-pane-agents.) -->
<!-- FL0W and the other standalone pages are reachable from the
H0M3 hub (served at /), not from the dashboard tab strip —
@ -99,33 +99,33 @@
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL
hash (default SW4RM). Panes start `hidden` to avoid a flash before
hash (default 4G3NTS). Panes start `hidden` to avoid a flash before
the script runs. -->
<!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the
sticky chrome above + the footer below span the full width. -->
<div class="page-content">
<main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing
<!-- 4G3NTS: the agents. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation
that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label SW4RM already says it. -->
— the tab label 4G3NTS already says it. -->
<section
class="tab-pane"
id="tab-pane-swarm"
data-tab-pane="swarm"
id="tab-pane-agents"
data-tab-pane="agents"
hidden
role="tabpanel"
aria-labelledby="tab-swarm"
aria-labelledby="tab-agents"
>
<!-- Swarm / hive identity headline. Populated by refreshState from
hive_name + swarm_name; stays hidden when neither is set. -->
<h2 id="swarm-identity" hidden></h2>
<!-- JobqRollup mount point, kept outside #containers-section
deliberately — that section is wiped + rebuilt on every
container-list render (see swarm.js::renderContainers), which
container-list render (see agents.js::renderContainers), which
would tear down and remount the Preact tree on every
container-state tick. Mounted once by swarm.js::initJobqRollup,
container-state tick. Mounted once by agents.js::initJobqRollup,
refreshed via its own handle rather than by re-rendering. -->
<div id="jobq-rollup-section"></div>
<div id="containers-section">

View file

@ -8,7 +8,7 @@
// live in `./common.js`; the flow-page surface (broker
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
// `dashboard.html` each load their own bundle.
// SW4RM (containers) domain lives in `./swarm.js`.
// 4G3NTS (containers) domain lives in `./agents.js`.
import { marked } from "marked";
import { $, openStream, renderServerWarnings } from "./common.js";
@ -33,7 +33,7 @@ import {
applyTransientCleared,
renderContainers,
renderSelectionBar,
} from "./swarm.js";
} from "./agents.js";
import {
initBuilds,
onBuildsTabShown,
@ -126,7 +126,7 @@ window.marked = marked;
window.__hyperhive_state = s;
renderServerWarnings(s.server_warnings);
// Hive identity: render the swarm/hive name as a headline at the
// top of the SW4RM pane + update the page title once the
// top of the 4G3NTS pane + update the page title once the
// server-side display names are known. `hive_name` / `swarm_name`
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
// (set by services.hyperhive.{hiveName,swarmName} nix options).
@ -148,8 +148,8 @@ window.marked = marked;
// `transientsState` + `containersState`, not from `s.*`).
syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s);
// Job-queue rollup feeds only the SW4RM queue-summary banner
// (per-agent card badges are transient-only — see swarm.js). Its
// Job-queue rollup feeds only the 4G3NTS queue-summary banner
// (per-agent card badges are transient-only — see agents.js). Its
// own `JobqRollup` mount self-fetches GET /api/jobq/rollup — not
// read off `s` (this page's snapshot carries no jobq field).
// initJobqRollup mounts once and is a no-op on later calls; the
@ -182,7 +182,7 @@ window.marked = marked;
// ─── live updates: dashboard event stream ──────────────────────────────
// The dashboard subscribes to /dashboard/stream for live mutation
// events so the SW4RM / SYST3M panes update without an
// events so the 4G3NTS / SYST3M panes update without an
// operator action triggering a refreshState.
//
// Bare `EventSource` (no terminal infrastructure needed — the
@ -202,7 +202,7 @@ window.marked = marked;
container_state_changed: applyContainerStateChanged,
container_removed: applyContainerRemoved,
// rebuild_queue_changed: a payload-less push trigger. Refreshes the
// SW4RM queue-summary banner (see swarm.js) and the R3BU1LD QU3U3
// 4G3NTS queue-summary banner (see agents.js) and the R3BU1LD QU3U3
// tab's JobqGraph (see builds.js).
rebuild_queue_changed: () => {
applyRebuildQueueChanged();
@ -260,9 +260,9 @@ window.marked = marked;
})();
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#swarm` / `#rebuild` / `#meta` / `#buildlogs` /
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` /
// `#permissions` activate the matching pane on the dashboard. Empty
// hash defaults to SW4RM. FL0W and ST4TS are NOT tabs —
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from
// the H0M3 hub. Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page
@ -275,7 +275,7 @@ window.marked = marked;
// to their own pages rather than hide them in a dropdown.
function activateTab(target) {
// Track active tab on the body so renderSelectionBar can gate
// visibility (bar only belongs on SW4RM where agent cards live).
// visibility (bar only belongs on 4G3NTS where agent cards live).
document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values()));
// Permissions tables: SSE covers worker-applied changes
@ -292,9 +292,9 @@ window.marked = marked;
// Wire the shared tab strip now that activateTab + the lazy-load fns it
// calls are defined. The strip resolves the active tab from the hash
// (default SW4RM), toggles the active tab/pane + aria-selected, and
// (default 4G3NTS), toggles the active tab/pane + aria-selected, and
// fires activateTab for the per-tab side-effects on every change.
createTabStrip($("tabbar"), { defaultId: "swarm", onShow: activateTab });
createTabStrip($("tabbar"), { defaultId: "agents", onShow: activateTab });
// The rebuild queue mounts now, not on first show, so its tab's count pill
// is live from the start.
initBuilds();
@ -313,14 +313,14 @@ window.marked = marked;
/** Recompute every tab's count from the current state. Called on
* every renderXxx that's tab-relevant. */
function refreshTabCounts() {
// SW4RM — flag any container that's stale (needs_update). Empty
// 4G3NTS — flag any container that's stale (needs_update). Empty
// when everyone's current. Container-row pulse signals state
// transitions; the pill catches "deploy-pending" specifically.
let swarm = 0;
let stale = 0;
for (const c of containersState.values()) {
if (c.needs_update) swarm++;
if (c.needs_update) stale++;
}
setTabCount("swarm", swarm);
setTabCount("agents", stale);
// R3BU1LD QU3U3 — rebuilds in flight.
setTabCount("rebuild", rebuildCount());
}

View file

@ -21,7 +21,7 @@
// widening from the old per-agent-menu-only coordination) so
// `closeAll()` can close each via its own `.close()`. `closeAllMenus()`
// force-closes everything before tearing down DOM one might anchor off
// of (e.g. swarm.js before replacing the container tree).
// of (e.g. agents.js before replacing the container tree).
//
// The document-level listeners below, registered once: shadow-DOM
// retargeting means `e.target` for a click inside a shadow tree gets
@ -58,7 +58,7 @@ document.addEventListener(
true,
);
// Close on Escape. stopImmediatePropagation so a caller's own
// selection-clear Escape handler (e.g. swarm.js's) doesn't also fire
// selection-clear Escape handler (e.g. agents.js's) doesn't also fire
// while a menu is open.
document.addEventListener(
"keydown",
@ -76,7 +76,7 @@ class HiveMenu extends HTMLElement {
// A custom element's connectedCallback fires again on a same-document
// *move* (insertBefore/append repositioning an already-connected node
// runs the removal + insertion steps for its whole subtree), not just
// on a fresh mount — swarm.js's row-fingerprint cache reuses/reorders
// on a fresh mount — agents.js's row-fingerprint cache reuses/reorders
// existing <li> subtrees, which can move an already-initialised
// <hive-menu> without ever detaching it from the document. Guard
// against re-running setup (attachShadow throws if the host already

View file

@ -1,7 +1,7 @@
// hive-warn.js — <hive-warn>, the shared inline warning-banner
// component. Consolidates what were independently-written instances of
// the same thing, among them `.tombstone-warn` (core.js, JS-built) and
// `.port-conflict` (swarm.js, JS-built) — some differed only by an
// `.port-conflict` (agents.js, JS-built) — some differed only by an
// undeliberate 10% vs 8% tint, the strongest evidence this was drift,
// not genuinely different needs.
//

View file

@ -1,7 +1,7 @@
// JobqRollup.tsx — <JobqRollup>, a compact one-line banner over
// GET .../api/jobq/rollup's pre-tallied `Vec<hive_jobq_wire::StateCount>`
// (any endpoint serving that shape works — hive-c0re and swarm-controller
// both do). Extracted out of dashboard/src/swarm.js's hand-rolled
// both do). Extracted out of dashboard/src/agents.js's hand-rolled
// queue-summary banner, which this component now replaces there too —
// same "one shared component, not two parallel implementations" pattern
// `JobqGraph` already established for the rebuild-queue tree view.
@ -24,7 +24,7 @@
//
// JSX (swarm-ui) — `<JobqRollup endpoint="..." queueHref="..." />`. Or
// plain `render(h(JobqRollup, props), container)` (dashboard/src/
// swarm.js, no JSX pragma needed) — call again with a bumped
// agents.js, no JSX pragma needed) — call again with a bumped
// `refreshToken` to force a refetch. No mount wrapper: `render` is
// already the re-render/diff entry point.

View file

@ -9,7 +9,7 @@
// swarm-level job (e.g. CreateAgent) lands.
//
// JobqRollup sits above the graph, same "N running / M queued" banner
// the dashboard's SW4RM tab shows — no `queueHref`, since a "view
// the dashboard's 4G3NTS tab shows — no `queueHref`, since a "view
// queue →" link back to this same page would be noise.
//
// Auto-refresh reuses `HivesPage`'s exact pattern (`RefreshIntervalPicker`

View file

@ -14,7 +14,7 @@ use super::AppState;
/// option) when present, otherwise **404** — there is no bundled
/// server-side default any more (that was `hive_sh4re::assets::
/// branding_svg`, now removed). Consumers fall back client-side: the
/// dashboard's `swarm.js` and this agent's own `app.js` both swap an
/// dashboard's `agents.js` and this agent's own `app.js` both swap an
/// `/icon` load failure to the frontend-bundled `/favicon.svg` rather
/// than probing first, so a 404 here is the expected "unconfigured"
/// signal, not an error case to work around.