Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/docs/web-ui/shape.md
atlas 884fb3e08b dashboard: remove the L0GS page and its journal backend
Logs live at swarm level now (VictoriaLogs, read through Grafana
Explore). The hive dashboard's own journal viewer goes, with
everything that existed only to serve it:

- frontend: /logs.html, logs.js, logs.css, the H0M3 "Logs" tile and
  the per-agent menu's "journal logs ->" link. The build-log list
  rules BU1LDS used from logs.css move into builds.css.
- hive-c0re: GET /api/journal/{name} and GET /api/journal-host, and
  priv_client::read_container_journal.
- hive-priv / hive-priv-sock: the ReadContainerJournal request, its
  JournalQuery/JournalOutput wire types and the journalctl -M executor.
- nix: hive-c0re's systemd-journal supplementary group, whose only
  reader was the journal-host handler.
- docs: the L0GS section, the API entry and the security table row.

Closes #5113
Closes #3819
2026-10-12 01:26:33 +02:00

344 lines
19 KiB
Markdown

# Shape (shared by both)
> Part of [Web UI](README.md). See also:
> [Dashboard layout](dashboard.md) · [Per-agent page](agent.md)
## Shared routes
- `GET /` → `index.html` from the bundled frontend dist (see
`frontend/`). Both binaries' routers declare their dynamic
endpoints first and then `fallback_service(ServeDir::new(...))`
pointed at `HIVE_STATIC_DIR` — that fallback serves anything not
matched by an API or action route from the dist. Dashboard dist lives at
`${frontend}/dashboard`; per-agent dist is the merged
`services.hyperhive.agent.frontend.mergedDist` (default agent dist + per-agent
`extraFiles` overlay).
- `GET /static/*` → bundled CSS + JS produced by esbuild
(`frontend/packages/{dashboard,agent}/build.mjs`). Both pages
pull the shared terminal pane + Catppuccin palette + typography
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
loaded by every page, plus a page-specific bundle —
`dashboard.css` / `flow.css` / `home.css` /
`stats.css` / `core.css` / `builds.css`);
`common.css` inlines `@hive/shared`'s
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
`pill.css` via esbuild's `@import` resolution.
`terminal.js` exports `{ create, linkify }` as ES module
members; a back-compat shim in the IIFE bodies still exposes
a `window.HiveTerminal` global for callers that need it. The dashboard's
`#msgflow` and the per-agent `#live` log are both backed by
this terminal — sticky-bottom autoscroll, "↓ N new" pill,
history backfill, SSE plumbing all live there. Each page
registers a kind→renderer map; unknown kinds fall through to
a JSON-dump note row. Bare `http(s)://` URLs in row text are
turned into clickable new-tab links by `linkify` (text-node
based, no `innerHTML` — XSS-safe); markdown bodies go through
`marked` (npm dep, replacing the vendored UMD bundle) and then
`DOMPurify.sanitize()` before hitting `innerHTML` — untrusted row
text (peer-agent / matrix-relayed message bodies, agent-authored
state files) can carry arbitrary HTML/script via markdown, so
`DOMPurify.sanitize()` cleans the markdown path rather than it being
XSS-safe by construction the way the text-node `linkify` path is.
Rendered `<a>`s are rewritten to `target="_blank"`.
- `GET /api/state` → JSON snapshot the JS app renders into the
DOM. Includes a top-level `seq` (the dashboard event channel's
high-water mark at the moment the server assembled the snapshot);
clients use it to dedupe their buffered SSE traffic against
the snapshot (drop frames with `seq <= snapshot.seq`).
- `GET /api/dashboard/stream` (dashboard) / `GET /events/stream`
(per-agent) → `text/event-stream` SSE for live updates. The
dashboard stream carries broker `Sent` / `Delivered` (mirrored
by a forwarder task from the broker's intra-process channel)
plus mutation events (`transient_set` / `transient_cleared`). Each frame carries a `seq`. The
matching backfill endpoint is `GET /api/dashboard/history` (last
~200 broker messages wrapped in `{ seq, events }`) on the
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
also wrapped in `{ seq, events }`) on the agent.
**One unified channel**: browsers cap concurrent SSE
connections per origin (~6 in Chrome). Using one channel per
domain would exhaust this budget on a live hive; dispatching
by `kind` on the client is a one-liner. Per-domain splits are
reserved for high-volume sub-streams most consumers skip (none
exist yet). The broker's intra-process channel stays separate
from the dashboard channel to avoid coupling `recv_blocking_batch`
(hot path inside the harness turn loop) to presentation concerns.
**SSE multiplexing**: the dashboard uses a
`SharedWorker` (`stream-worker.js`) to hold one upstream
`EventSource` per URL. All same-origin tabs share this worker
— a second dashboard tab joins the existing connection rather
than opening a duplicate. The worker fans SSE events out to
each subscribed tab via `MessagePort`; on bfcache restore the
page re-subscribes (gets a synthetic `open` event immediately
if the upstream is already connected). Falls back gracefully
when `SharedWorker` is unavailable (for example some private-mode
browsers). **Worker-death self-heal**: Firefox kills
"idle" SharedWorkers under memory pressure with no client-side
signal — the port silently goes no-op. The worker now pings
every connected port every 30s; the client bumps a
last-activity timestamp on every message (incl. pings, which
carry no URL — bumped before the URL filter). A
visibility-gated watchdog polls every 15s and, if the page is
visible AND has active subs AND hasn't heard from the worker
in >90s (three missed pings), presumes the worker dead and
re-subscribes on a fresh `SharedWorker` port (same code path
bfcache-restore uses). Recovery is per-tab; pings are
invisible on the healthy path.
## Shared terminal pane
The shared terminal factory in `@hive/shared/terminal.js` backs both
surfaces' scrollable log streams (`#msgflow` on the dashboard, `#live`
on the per-agent page). The factory wires up
sticky-bottom autoscroll, a "↓ N new" pill, history backfill, and
SSE replay. Pages register a `kind → renderer` map; unknown kinds
fall through to a JSON-dump note row. The factory ships three row
shapes the renderers call:
- `api.row(cls, text, icon?)` — single-line row with an inline `linkify`
pass over the text; optional `icon` lands in a fixed-width `.row-glyph`
cell so glyphs of varying rendered width all align.
- `api.details(cls, summary, body, icon?)` — collapsible `<details>` with
a `<pre>` body (used by long tool-results and stack traces).
- `api.detailsDiff(cls, summary, body, icon?)` — same shape, splits the
body on newlines and tags each line as `diff-add` / `diff-del` /
`diff-ctx` so the renderer's diff bodies get coloured without
emitting raw HTML.
- `api.placeholder(text)` — replaces the log with a single muted row,
cleared automatically when the next real row arrives.
- `api.fromHistory` — `true` while backfill replay is running; renderers
use it to suppress live-only side effects.
<!-- vale write-good.Passive = NO -->
**`create(opts)` options:** `logEl` (log container element), `streamUrl`
(SSE endpoint), `historyUrl?` (optional replay endpoint — skipped if absent
or if the fetch fails), `renderers` (kind→fn map; unknown kinds fall through
to `renderers._default`, itself defaulting to a JSON-dump note row),
`onLiveEvent?(ev)` (live-only side effects), `onAnyEvent?(ev, {fromHistory})`
(runs for both replay and live — use for derived views that need the full
event picture), `onBackfillDone?(count)` (one-shot after replay; `count=0` on
failed/skipped fetch), `onStreamOpen?()` (fires on every EventSource
(re)connect — use to re-sync snapshot-derived state after a reconnect gap),
`pillAnchor?` (parent element for the "↓ N new" pill; defaults to
`logEl.parentElement`), `expandDetails?` (boolean or zero-arg function
returning one, re-read on every `api.details`/`api.detailsDiff` call rather
than captured once — lets a page default otherwise-collapsed panels open
per a live browser-local preference; renderers that force a row open
regardless, for example message-bearing tool_use, are unaffected either way).
<!-- vale write-good.Passive = YES -->
**Sticky-bottom + snap animation.** `stickToBottom` is the
operator's intent: true means "keep snapping to bottom on every
mutation," false means "I scrolled up, leave me alone." The flag
flips when a scroll event lands further than `NEAR_BOTTOM_PX = 48`
from the bottom. New rows then either snap to bottom (when sticky)
or bump the unseen-count and surface the "↓ N new" pill. The snap
is a brief 140ms ease-out (`SCROLL_ANIM_MS`) — the browser's
default `behavior: 'smooth'` ~500ms reads as "still smooth, but
visibly slow"; 140ms feels snap-y while still reading as motion
rather than a jump. Distances under `SCROLL_SNAP_PX = 24`
short-circuit to instant — animating a 12px nudge would just be
jitter. Each new snap cancels the previous `requestAnimationFrame`
so a burst of mutations coalesces into one ride to the latest
bottom; the per-frame step re-reads `scrollHeight - clientHeight`
so mutations landing mid-animation extend the destination smoothly
rather than land short.
**Mid-animation scroll-event guard.** The scroll handler's
`isNearBottom` check would flip `stickToBottom` false mid-snap as
the smooth animation eases through positions that are technically
"not near bottom yet," which would strand the operator partway. A
`smoothScrollingUntil` timestamp gates the scroll handler — set to
the animation end + ~80ms headroom, re-armed on each fresh snap.
Programmatic `scrollTop` writes (the animation's per-frame update)
fire scroll events that the gate swallows. One caveat: if live events
arrive faster than the animation window (< 220ms apart), the
MutationObserver keeps re-firing `snapToBottom()` which perpetually
re-arms the gate — trapping the operator at the bottom with no way
to scroll up or trigger `loadMore()`. Safety valve: even inside the
guard the handler checks `isNearBottom()`; if the operator has
scrolled away from the bottom, `stickToBottom` is immediately forced
false. The MO's `if (stickToBottom)` check then stops re-arming
snaps, and the gate expires within ≤220ms.
**Post-append `MutationObserver`.** Renderers commonly call
`api.row(cls, text)` to create the row shell then append more
children (badges, multi-line bodies, tool result panes) after the
factory returned. The initial sticky-snap fires off the row's
empty shape; the renderer's later appends grow the row past the
visible bottom. A `MutationObserver` on the log subtree fires once
per microtask after each batch of synchronous mutations and snaps
again when `stickToBottom` is true. Programmatic `scrollTop`
writes don't re-trigger the observer (scroll isn't a DOM
mutation), so no feedback loop. The pre-append
`nearBottomBeforeAppend` snapshot is still useful — it keeps the
initial visual lag to one frame instead of one microtask + frame.
**Backfill + SSE.** Cold load fetches `historyUrl` (replay), then
subscribes to `streamUrl` (live tail). Both endpoints return
`{ seq, events }` so the client can dedupe — the client silently
drops events with `seq <= snapshot.seq` from the SSE stream
(the snapshot already covers them). History rows render with a
`.no-anim` class so they don't stagger in like live events. The
optional `streamFactory(url)` callback lets the dashboard hand
the factory a `SharedWorker`-backed `EventSource` facade (so
multiple tabs share one upstream connection — see _SSE
multiplexing_ above); when omitted, the factory falls back to a
plain `new EventSource(url)`.
**`linkify` (text-node based).** Bare `http(s)://` URLs in row
text get wrapped in `<a target="_blank" rel="noopener noreferrer">`
inside a fresh text node, so the autolinker never touches
`innerHTML` and untrusted row content can't smuggle markup. The
trailing-punctuation strip keeps `.,;:` outside the link surface.
Markdown bodies go through `marked` separately, then
`DOMPurify.sanitize()` on the resulting HTML before it's assigned to
`innerHTML` (see `mdNode` in `app.js` / `common.js`) —
`DOMPurify.sanitize()` cleans this path rather than it being
text-node-safe like `linkify`, since markdown can carry raw HTML.
Rendered `<a>`s get the same target rewrite.
The JS app handles all `form[data-async]` submissions via a delegated
listener: read `data-confirm`, swap the button to a spinner, POST
`application/x-www-form-urlencoded`, re-enable the button on success
(refreshState may keep the form mounted, so the handler doesn't rely on a
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`, `call.js`, etc.) render function.
**Focus preservation:** `refreshState` checks whether
`document.activeElement` sits inside one of the managed sections
and, if so, skips the refresh (defers 2s). The operator never has
the form yanked out from under them mid-type; the update lands as
soon as they blur.
**Atomic section repaint:** every managed-section renderer goes
through `paintAtomic(liveRoot, build)`: the builder appends into
a fresh `DocumentFragment` (off-DOM) and the commit is one
`replaceChildren` call, so the intermediate empty state is never
visible — clearing and re-appending directly into the live root can
leave it empty for a paint (on async builders) or a whole frame (on
large synchronous ones), which reads as a visible flash on every
poll cycle. Builders receive the fragment as their
`root`, so existing renderer code carries over unchanged; early
returns inside the builder still commit whatever it appended
before returning.
**Keyed DOM caching:** for sections whose rows hold interactive state
(textarea drafts, checkboxes, focused inputs) `paintAtomic` isn't
enough — wiping and rebuilding still destroys the state even if
`paintAtomic` hides the flash. The keyed pattern keeps a
`Map<id, {el, fingerprint}>` where the fingerprint is
`JSON.stringify({...visible fields...})`. On each render, the pattern
reuses cache-hit rows verbatim (preserving textarea draft, checkbox
state, and event listeners) and rebuilds only cache-miss rows. Used for: containers (`containerRowCache`),
rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus and meta-input
checkboxes use a lighter snapshot-then-restore pattern (snapshot
before `replaceChildren`, restore after) since they're single
values rather than per-row caches.
<!-- vale write-good.Passive = NO -->
**`<details>` open-state preservation:** any collapsible element
tagged with `data-restore-key="<stable-key>"` survives the
refresh. `snapshotOpenDetails()` walks managed sections before
render, `restoreOpenDetails()` re-applies after. Long-content
drill-ins (file previews, diffs) now open in the
**side panel** (see below) rather than expanding inline. The
dashboard's own answered-questions history list was the only
`<details data-restore-key>` consumer and is gone
— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have
nothing to restore, kept in place for the next collapsible section
that needs it rather than ripped out along with their one caller.
<!-- vale write-good.Passive = YES -->
**Side panel (dashboard):** long content opens in a drawer that
swipes in from the right — a singleton `#side-panel` with a
titled header, a close button, and a scrollable body. Closes on
the button, selecting the backdrop, or `Escape`. `Panel.open(title,
node)` swaps the body; the JS builder for file previews
renders into it. **The
drawer width is drag-to-resize**: a thin 6px hit-strip on
the left edge captures pointer events, resizes the drawer in
real-time (pointer capture keeps dragging even if the cursor
outpaces the handle), and persists the chosen width to
`localStorage` (key `hyperhive:side-panel-width`) so it
survives page reload. CSS clamps the width between `min-width: 320px`
and `max-width: 96vw`; the viewport-resize handler re-clamps
persisted values after a window shrink. File
previews are type-aware:
- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain`
tabbed view: `rendered` (default) is the vendored `marked`
bundle (`GET /static/marked.js`), `plain` is the raw source.
- **SVG** (`.svg`) — a `rendered` / `source` tabbed view;
`rendered` shows the image via an `<img>` `data:` URI (the
browser's secure static mode, so an untrusted SVG can't run
scripts), `source` shows the raw markup.
- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` /
`.bmp` / `.ico` / `.avif`) — render as an `<img>` pointed at
`/api/state-file`, which serves them as binary with their
real content-type (text files stay UTF-8-lossy `text/plain`).
- **Everything else** — raw text in a `<pre>`.
## Listener bind
Both bind their TCP listener with `SO_REUSEADDR` via
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse`
(exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself, but the two
binaries differ on the attempt budget. The dashboard's
`bind_with_retry` (`hive-c0re/src/dashboard/mod.rs`) has **no
attempt cap** — retrying forever is deliberate, since
`lifecycle::{spawn,rebuild}` preflights genuine port collisions
host-side (refuses with a clear error, surfaced on the dashboard as a banner),
so at this layer a persistent `AddrInUse` always reflects a
recoverable stale socket. Its first 12 attempts log at WARN; after
that the level drops to INFO so a long-held stale socket doesn't
flood the journal. The per-agent UI's `bind_with_retry`
(`hive-agent/src/web_ui/mod.rs`) instead gives up after
`MAX_BIND_ATTEMPTS` (12) and returns the `AddrInUse` error rather
than looping forever.
The per-agent UI optionally binds a `UnixListener` instead of
TCP when the nix module sets `HIVE_WEB_SOCKET` — the unix-socket transition
mechanics (per-agent `/run/hive-agent/<name>/` bind-mount,
`.bound` marker filtering, `agent-sockets.json` consumer on the
gateway side) live in [`docs/networking/gateway.md::Per-agent unix-socket
upstream`](../networking/gateway.md). The env var is opt-in per agent so the
two modes coexist while sub-agents transition.
## Per-agent relative paths
The per-agent UI uses **document-relative paths everywhere** for
assets, API calls, form actions, and the screen WebSocket. Bare
references like `static/main.js`, `api/state`, `events/stream`,
`screen/ws` resolve against `document.baseURI` — the page's URL
without its last path segment.
That makes the page work under any prefix the agent ends up mounted
at without rebuilding the dist. The cases that matter:
| served at | `api/state` resolves to |
| ------------------------------------------------ | ----------------------- |
| `/` (own port, today's shape) | `/api/state` |
| `/agent/iris/` (gateway-prefixed) | `/agent/iris/api/state` |
| `/agent/iris/stats` (subpage, no trailing slash) | `/agent/iris/api/state` |
The gateway upstream config strips the prefix before forwarding to
the per-agent server, so the agent's Rust routes (`api/state`,
`events/stream`, `screen/ws`, `login/start`, …) keep their absolute
paths server-side. Only the browser-facing URLs key off the
mount prefix.
The router serves subpages (`stats`, `screen`) without a trailing slash so
the relative-path resolution stays correct: `static/stats.js` from
`/stats` becomes `/static/stats.js` (last segment `stats` gets
replaced), not `/stats/static/stats.js`. Adding a trailing slash to
those routes would break the resolution; either keep them
slash-less or use `<base href>` injection at serve time.