Dashboard layout

Part of Web UI. See also: Shape (shared) · Per-agent page

This page covers the per-hive UI that hive-c0re serves. Swarm-wide surfaces — the agent roster, agent creation, linking external forge and matrix accounts, the controller's job graph — are on the swarm UI.

hive-c0re serves the dashboard 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 (#agents, #rebuild, #meta, #buildlogs, #kept) drives which pane is active; hash changes don't reload the page. ST4TS is a standalone page reached from the pages menu, not from the dashboard tab strip.

Chrome header (fixed, overlays the active tab pane):

4G3NTS tab

C0NTAINERS — live containers as a flat list, one row per container, sorted alphabetically by agent name; every container the rescan reports gets a row. Pulsing red banner at the top of this section if any two sub-agents hash to the same port (port_conflicts from /api/state): the operator must rename one of them and rebuild. lifecycle::{spawn,rebuild} also preflight this and refuse with a clear error message naming the conflicting agent.

↻ UPD4TE 4LL button appears above the containers list when any agent is stale.

K3PT ST4T3 tab

Passive / rare-interaction state. kept.js renders it from the /api/state snapshot and keeps it live from the tombstones_changed stream event. (Rebuild queue and meta inputs are dashboard tabs too — see below.)

Per-container CPU, memory and disk load isn't shown in the hive UI. The same figures go out as OTEL metrics and appear in Grafana (see observability). An agent's CPU and memory caps live per agent in the swarm UI.

K3PT ST4T3 — agents with kept state and no container (size + age + claude-creds badge). One action: PURG3 (wipes state + applied dirs; POST /api/purge-tombstone/{name}).

⚠️ Not only destroyed agents. Nothing records a destroy: Provision writes every definition-side artifact (state subvolume, proposed + applied repos, meta registration, topology entry) before the container exists, and that artifact set survives lifecycle::destroy. An agent part-way through a spawn is byte-identical on disk to a tombstone, and the pane lists both. The pane carries a standing warning to that effect, and the row badge says offline rather than destroyed — the absence of a container is all hive-c0re can actually prove. Fixing it properly needs a recorded destroy — deferred to the swarm-controller / snapshot-storage rework where the problem changes shape.

Build tabs (#rebuild, #meta, #buildlogs)

The build lifecycle — rebuild queue, live build log, meta inputs, and build log history — is three tabs of the dashboard: R3BU1LD QU3U3 (#rebuild), M3T4 1NPUTS (#meta) and BUILD L0GS (#buildlogs). They live in builds.js, which tabs.js imports: the dashboard's one tab strip, /api/state snapshot and /api/dashboard/stream subscription serve them, with 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 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 handful of pre-tallied counts, not the graph. The banner's "view queue →" link goes to the #rebuild tab.

R3BU1LD QU3U3 — pending, in-flight, and recently settled container operations: rebuilds, meta-update cascades, and first-spawns. One operation runs at a time; the worker drains FIFO. Is a mounted JobqGraph (the shared generic graph-viewer Preact component, @hive/shared/jobq-graph.js — the same one swarm-ui uses) — builds.js doesn't render the queue itself; it just mounts the component (mountJobqGraph(container, props), since this page has no JSX pipeline) with an onUpdate callback to drive the two things below it that the generic view doesn't show. The component owns fetching, cold and live: GET /api/jobq/graph on mount, and a refetch via the mount handle's .refresh() on every rebuild_queue_changed SSE tick (that event is a bare { seq } trigger carrying no queue payload; both dashboard pages treat the tick as a pure refetch trigger).

Each row is one root graph node (parent: null); a multi-step op's per-agent subgraphs and sub-steps render as nodes within that one entry (structural parent edges define the tree; sibling order is array order, unchanged from the wire). A row shows a state glyph (⏸ pending / ▶ running / ◐ finishing — own work done, a sub-node still running / ✔ done / ✖ failed / ⊘ cancelled / · skipped) and each step's own label/agent. A Node-kind dep on a sibling shows as a plain "waits on: <label>" text line under the row rather than a gutter rail — a rail breaks visually whenever a nested subtree sits between the two related rows, since a text line needs no continuous vertical space to draw. builds.js mounts the component with cancellable set, which turns on a per-node cancel button (✕) on any non-terminal row — the button calls the mount's onCancel(id) callback, and the page does the actual POST /api/rebuild-queue/{id}/cancel, matching that callback to its own domain concept (the component knows nothing about that endpoint). Rows carry no source chip, kind label, timing, or build-log deep-link — the generic graph wire doesn't carry those fields, and rows deliberately present exactly what the endpoint provides rather than reconstructing chrome hive-c0re doesn't send. Settled entries render their full step tree, not just a bare summary — the wire doesn't filter Done nodes out.

State filter. A row of per-state checkboxes above the tree — one per lifecycle state, matching the row glyphs — lets the operator narrow which root groups render; unchecking a state re-fetches GET /api/jobq/graph?states=<checked, comma-joined> rather than hiding rows client-side, so the onUpdate callback's node list (and everything downstream of it — the count pill, the live-log panel) only ever sees what's actually shown. Filtering is by a root's own state, which is already its subtree's rolled-up answer, so the filter keeps or drops a whole group, never splitting mid-tree. Default selection is every state except Done/Skipped — a fresh queue view leads with what's still moving or needs attention, not the settled tail; builds.js omits the states param entirely (identical request to before this filter existed) when the operator has checked every state. Server-side: the query narrows [Queue::graph_snapshot]'s already-bounded (MAX_HISTORY_DAGS) root set — the history cap and the state filter are independent concerns, so a narrow filter never reaches further back in time to compensate.

Below the queue, a live build-log panel (#rebuild-live-log, renderRebuildLiveLog) shows the currently running rebuild's output inline — collapsible, with a live/ok/fail badge and a ↓ raw download. It's keyed to the first Running node (in wire order) whose payload.data.build_log_id has a value — read from the onUpdate callback's node list, same source as the count pill, no separate fetch — and polls GET /api/build-log/{id} every 2s (fetchAndRenderLiveLog / liveLogPollTimer); not an EventSource (that's the BUILD L0GS tab's own per-row expand view below — GET /api/build-logs/id/{id}/stream, real SSE, replays accumulated output on connect — a separate mechanism). The live-log panel lives in its own container outside #rebuild-queue-section so the mounted JobqGraph's own re-renders never disturb the open poll; it hides when nothing is building.

M3T4 1NPUTS — inputs in meta/flake.lock the operator can selectively nix flake update, rendered as an indented tree: every fetched input at every depth (hyperhive, hyperhive/nixpkgs, agent-<n>, agent-<n>/mcp-<x>, …), each shown once at its shallowest path. read_meta_inputs walks the lock graph with a visited set, skipping follows aliases and rev-less nodes. A select all / select none control sits above the tree. Checking inputs + submitting bumps the lock in /meta/ and rebuilds the selected agents in sequence; each outcome reaches the root agent as a rebuilt system event. POST /meta-update. While a lock-bump ripple runs, the panel shows a pulsing "⏳ meta-update running" banner and disables the update button (snapshot field meta_update_running, live event meta_update_running).

BUILD L0GS — all-agents build log history. Lazy-loaded on first tab activation; autorefreshes when rebuild_queue_changed fires. Fetches GET /api/build-logs?limit=30. Renders a scrollable list of build entries; each row is a collapsible button showing status badge (live / ok / fail), agent name, elapsed duration, build kind, age, and the invocation command line. Expanding a row fetches the full stdout+stderr via GET /api/build-logs/id/{id}. A live in-progress build shows a live badge with a ticking elapsed-time chip; expanding streams output via GET /api/build-logs/id/{id}/stream with sticky-bottom autoscroll (suspends on manual scroll-up). Deep-link: ?id=N#buildlogs opens the entry with that id pre-expanded.

ST4TS page (/stats.html)

Hive-wide turn statistics, aggregated across every agent's hyperhive-turn-stats.sqlite for the selected window. Not a dashboard tab — a standalone page reached from the dashboard's pages menu, minimal chrome (a back-link and a title). Distinct from each agent's own /stats page (which carries the per-agent trend charts): ST4TS is the swarm-level rollup.

Backed by GET /api/stats-hive?window=<w> in hive-c0re (hive_stats.rs): for every name from Coordinator::kept_state_names() it opens agent_harness_dir(name)/hyperhive-turn-stats.sqlite read-only (with a 500 ms busy_timeout, since turn_stats is rollback-journal) and rolls the rows up, skipping missing / unreadable / zero-turn dbs so one bad db never fails the endpoint. This is a pull surface (no SSE): the page fetches the data on page load and on window change. Rendered with plain tables + CSS bars — the dashboard bundle ships no chart library.

The cost figure is a deliberately rough estimate from a per-model price table (est_cost_usd); it drifts with list pricing and the page labels it accordingly. The table is operator-tunable via the services.hyperhive.c0re.modelPrices nix option — each key is a model-family short name (matched case-insensitively as a substring of the model id, longest match wins) mapping to { input, output, cache_read, cache_write } USD-per-million-token prices. Models not covered fall back to hive-c0re's built-in estimate.

Container row

A full-height square agent icon (5em, capped) on the left. The icon is the selection toggle: select (or Enter/Space) adds/removes the agent from the selection set; aria-pressed reflects the state; 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/agents.js::renderContainers).

Icon layout + load strategy: the <img> is absolutely positioned (inset: 0) inside the .container-icon wrapper — the wrapper is the flex child and sizes itself via width: 5em + aspect-ratio: 1, the <img> is out of flow so its load state (pending, loaded, broken) can never contribute intrinsic size or reflow the row. Without that, the row would briefly grow as the image's natural dimensions arrived, then snap back on object-fit: contain. The load itself is fire-and-forget: the dashboard doesn't pre-check whether the agent is reachable, it just lets the <img> try and listens for an error event. On failure the handler swaps the src to /favicon.svg (served by the dashboard itself, always reachable) and adds the icon-unreachable class for the dimmed look. When the dashboard already knows the container is stopped (ContainerView.running = false) the fallback fires immediately, skipping the doomed <url>/icon fetch entirely.

Line 1 — agent name (link → new tab), m1nd/ag3nt chip, an icon-only nav strip plus live agent-owned state, all populated async from a single GET /api/dashboard-state call to the agent's own hive-agent. The response (DashboardState) carries: links (nav strip entries — 📊 stats, 🖥 screen when GUI is enabled, ⬡ forge profile, ↳ agent-configs mirror, plus any agent-declared dashboardLinks extras), status_text / status_set_at (agent self-reported status — the (set N ago) chip carries a data-set-at stamp and ticks every 30s to stay fresh across the long-lived keyed row cache), rate_limited, ctx_tokens / context_window_tokens (context-window badge data). hive-agent is the single source of truth for all of these. The dashboard resolves each AgentLink.kind against a per-agent base URL depending on whether hive-gateway is in front (StateSnapshot.gateway_enabled, sourced from the HIVE_GATEWAY_ENABLED env). The c0re NixOS module sets that env unconditionally — the gateway always runs; the flag stays so the dashboard doesn't have to learn that. Gateway-on: base URL is /agent/<name> (same origin, gateway proxies to the per-agent harness — TCP or unix-domain depending on the agent's HIVE_WEB_SOCKET opt-in, see docs/networking/gateway.md::Per-agent unix-socket upstream). Gateway-off is the flag-absent fallback (a dashboard served outside a hyperhive deploy): base URL is http://<host>:<container.port> (direct TCP). Forge links resolve against state.forge_public_url (sourced from services.hyperhive.swarm.forge.publicUrl) and disappear entirely when that's unset — never guessed from <host>:3000. External links are already absolute. The same base URL drives the primary agent-name link + favicon fetch, so the whole row routes through the gateway as a unit.

When the container is stopped (ContainerView.running = false), the dashboard skips the async dashboard-state fetch entirely (the agent web server is down), so a single badge replaces the badge chain, the nav strip is empty, and status text / rate-limited / ctx badges disappear. The agent icon goes straight to the dimmed /favicon.svg fallback instead of attempting a doomed load from the container's URL. Static fields — needs_update, deployed_sha, pending_reminders, parent, config link — remain visible regardless of run state.

Line 2 — status badges only (no per-card action buttons — actions live in the selection bar or the per-agent ⋮ menu, see below).

Status badges

Which single badge: ContainerView.failed (systemd ActiveState=failed — the unit exhausted its bounded restarts and gave up on its own) draws a red ✖ gave up badge; otherwise a plain muted ■ not running — a container an operator stopped deliberately. Both states read running: false; failed is the orthogonal fact (a fifth one alongside paused/needs_update/ needs_login, same "independent flags, no state machine" shape — see ContainerView's own doc comment) that tells them apart. An older hive-c0re without the field serves failed: undefined, which reads falsy — degrades cleanly to the single not running badge.

When the container is running, status badges follow — ⊘ rate limited (red, while the harness parks itself after a 429), needs login, needs update — plus one ◐ pending-state… pill per active transient (replaces buttons during operator-initiated start / stop / restart / rebuild / destroy). An agent can carry multiple transients at once — for example a lease-exempt prebuild running alongside a stop_for_update on the same agent — and each renders as its own independent badge instead of collapsing into one label, matching the existing multi-badge convention this line already uses for paused/needs_update/model/ctx.

Any pending badge means the row is actually running something right now (see Pending-badge derivation below); every row carrying ≥1 badge keeps the amber row tint AND draws a rotating amber ring around the agent icon.

Pending-badge derivation: transients only (transientsState, keyed agent -> Map<kind, since_unix>) — a transient is derived from a job-queue node currently Running against that agent, not declared per request, so its label follows the operation as it progresses (a rebuild reads stop_for_update, then swap, then reconcile rather than one constant rebuilding for its whole life). Two consequences for anything rendering it:

Ops with no queue node behind them (destroy, migration) supply their own label directly via TransientSet/TransientCleared events carrying no backing node at all.

Queued (not-yet-started) work shows nothing on the card. Only running work gets a per-agent pending badge, by design — the swarm view stays independent of job-queue internals. The queue-summary banner below is the only queued-work indicator on this tab, and it reads the narrow /api/jobq/rollup endpoint rather than the full graph.

Active model badge (model · <name>, blue) appears when the container is running and the harness has persisted a model name (the active_model field of hyperhive-harness.json, the consolidated harness state file in the agent's state dir). Read by hive-c0re's ContainerView (read_active_model); absent until the agent has completed at least one turn, and hive-c0re suppresses stale values for stopped containers.

ctx · Nk chip shows the agent's last-turn context size, populated from DashboardState.ctx_tokens (absent until the agent has completed at least one turn). The chip colour (green / yellow / red) keys off DashboardState.context_window_tokens (the real context window for the model the agent last ran on, authoritative from the agent side); the badge goes yellow ≥ 50% and red ≥ 75% of that window, matching the harness compaction watermarks. When the window value is absent the badge falls back to fixed 100k / 150k thresholds.

Per-agent ⋮ overflow menu — a ⋮ button appears on the right edge of each container row. Selecting it opens a small dropdown with per-agent actions and navigation links. Contents:

↻ UPD4TE 4LL button appears above the containers list when any agent is stale. Banner pulses on each broker SSE event (pulseBanner with a 4s grace timer).

Build-queue summary banner — when the job queue has any active work, a compact amber banner sits above the container list: ◐ build 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 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 }>, every lifecycle state present in a fixed order, zero counts included — rather than the full /api/jobq/graph tree: running sums the Running and Finishing entries' roots (Finishing = own work done, subtree still going, still in flight), queued reads the Pending entry's roots. roots specifically, not nodes — the banner means N whole operations, not raw steps (one rebuild is ~7 nodes but 1 root); nodes exists on the same endpoint for a consumer that wants step-level counts instead, unused here.

Themed dialogs

All confirmations and transient error notices use an in-app themed dialog system (@hive/shared/modal.js) rather than the browser's native confirm() / alert() chrome, so they match the Catppuccin palette and the OS can't style them away. Two primitives, both built on the openDialog core:

Every destructive run-state action (ST0P, R3ST4RT, R3BU1LD, DESTR0Y, PURG3) routes through themedConfirm, on both the per-agent ⋮ menu and the bulk selection bar.

Graceful stop — the ■ ST0P confirm dialog (per-agent and bulk) carries a stop gracefully — let the agent finish its turn and flush state before the container stops checkbox. When ticked, the action POSTs /api/kill/<name>?graceful=true (the bulk path appends the flag per-agent); unticked is the instant hard stop (/api/kill/<name> with no query). hive-c0re enqueues a Signal → Drain job-queue node pair (NodeKind::Signal / NodeKind::Drain): Signal sets the graceful-stop fence and kicks the harness so it runs one stop-checkpoint turn (so the agent can flush /state); Drain awaits the harness clearing that fence, bounded by a 3-minute timeout (GRACEFUL_STOP_TIMEOUT) that resolves either way and falls back to the downstream mechanical stop. The quiescing progress surfaces through the same rebuild-queue pending-badge mechanism the card already reads for a rebuild, driven by the Signal/Drain pair rather than a nix build. (The hivectl stop --graceful CLI flag enqueues the same Signal/Drain pair, so the dashboard and CLI paths behave identically.)

Selection bar

Bulk actions (R3ST4RT / ST0P / ST4RT / P4US3 / R3SUM3 / R3BU1LD / DESTR0Y / PURG3) live here rather than as per-card 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-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 frosted-mauve bar slides up from the bottom of the viewport (#selection-bar, position: fixed; bottom: 0). It shows:

That list is the whole set — the bar offers no other bulk action.

Every render prunes stale selections (agents destroyed while selected) before the bar appears.

Dashboard endpoints

Also browsable interactively at /api/docs (a Swagger UI, linked from the pages menu's api docs entry), with the raw spec at /api/openapi.json. It's a growing supplement, not yet a full replacement for the list below — some endpoints aren't in it yet.

Dashboard event channel

Wire vocabulary on /api/dashboard/stream (kind tag is in the JSON payload):

/api/state is only fetched on cold-load and on the few forms that mutate non-event-derived state (PURG3 + meta-update, since tombstones + meta_inputs aren't event- shaped yet). Every other section — transients, containers — derives from /api/dashboard/stream after the initial snapshot, maintaining its own client-side store and applying events on top.

Generalised form helper: form[data-confirm="…"] pops confirm() before submit.