Watch
0
0
Fork
You've already forked hyperhive
0

dashboard: serve the dashboard at /, fold kept state in as a tab, add a pages menu

Drop the H0M3 tile hub and the core page. The dashboard is now the index;
kept state is its fifth tab. A pages menu in the chrome links flow, stats,
API docs and the swarm UI. The swarm apex URL comes from
services.hyperhive.swarm.domain via HIVE_SWARM_PUBLIC_URL and the new
swarm_public_url field of the state snapshot.
This commit is contained in:
iris 2026-10-16 14:03:31 +02:00 • committed by mara
commit 0622736b30
35 changed files with 661 additions and 986 deletions

View file

@ -1,5 +1,5 @@
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (H0M3 at
// `/`, dashboard/flow/core/stats at their own
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (the
// dashboard at `/`, flow/stats at their own
// `.html`), each named after its own entry point below — see the
// `entryPoints`/`for` lists for the exact map, not repeated here to
// avoid this comment drifting out of sync with the real build steps.
@ -9,10 +9,7 @@
// module into each bundle rather than emitting a shared chunk (no
// `splitting: true`). The Rust binary mounts `dist/` as a
// `tower_http::ServeDir` fallback (`append_index_html_on_directories`
// serves index.html at /); the dashboard SPA lives at /dashboard.html
// (a plain file, not a /dashboard/ prefix) so it never shadows the
// exact-match /dashboard/stream + /dashboard/history SSE routes
// registered before the ServeDir fallback.
// serves index.html at /); the dashboard SPA is that index.html.
import { build } from "esbuild";
import { mkdirSync, copyFileSync, rmSync } from "node:fs";
@ -32,13 +29,7 @@ mkdirSync(staticDir(""), { recursive: true });
// follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename.
await build({
entryPoints: [
src("tabs.js"),
src("flow.js"),
src("home.js"),
src("stats.js"),
src("core.js"),
],
entryPoints: [src("tabs.js"), src("flow.js"), src("stats.js")],
outdir: staticDir(""),
bundle: true,
format: "esm",
@ -99,9 +90,7 @@ for (const entry of [
"common.css",
"dashboard.css",
"flow.css",
"home.css",
"stats.css",
"core.css",
]) {
await build({
entryPoints: [src(entry)],
@ -112,13 +101,7 @@ for (const entry of [
});
}
for (const html of [
"index.html",
"dashboard.html",
"flow.html",
"stats.html",
"core.html",
]) {
for (const html of ["index.html", "flow.html", "stats.html"]) {
copyFileSync(src(html), dist(html));
}

View file

@ -60,7 +60,7 @@ const selectionState = new Set();
//
// 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
// index.html) — a section that gets wiped on every container-state
// tick would defeat the component owning its own fetch lifecycle.
let jobqRollupToken = 0;
@ -587,7 +587,7 @@ function buildContainerLi(c, opts) {
export function renderContainers(s) {
const root = $("containers-section");
// #containers-section only exists on /dashboard.html. tabs.js is the
// #containers-section only exists on the dashboard. 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

View file

@ -1,4 +1,4 @@
// The build lifecycle tabs of /dashboard.html — rebuild queue, live build
// The build lifecycle tabs of the dashboard — rebuild queue, live build
// log, meta inputs, and build log history. Not an entry point: tabs.js
// imports it, owns the tab strip and the dashboard event stream, and calls
// the exports below. Three tabs, the panes' ids matching the tab ids:
@ -168,7 +168,7 @@ function renderMetaInputs(s) {
// the generic component's.
// Split from `renderRebuildQueue` below: `replaceChildren()` only
// belongs on the *first* render, to clear the static "loading…"
// placeholder `#rebuild-queue-section` starts with in `dashboard.html` —
// placeholder `#rebuild-queue-section` starts with in `index.html` —
// Preact doesn't know that placeholder is there, so a later render
// call must NOT repeat `replaceChildren()` (it would wipe Preact's own
// tracked children out from under its diffing rather than let it

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
(agents.js/core.js/builds.js). Used on container rows and build-log
(agents.js/kept.js/builds.js). Used on container rows and build-log
rows. */
.badge-warn {
color: var(--amber);
@ -561,12 +561,12 @@ hive-side-panel .md td {
}
/* ─── page-content gutter ─────────────────────────────────────────────
Inverted-gutter layout: the dashboard + H0M3 keep `<body>` full-bleed
and wrap their padded content in a single `.page-content` div that
Inverted-gutter layout: the dashboard keeps `<body>` full-bleed
and wraps their padded content in a single `.page-content` div that
carries the horizontal gutter. Full-width chrome (the tab strip, the
server-warnings banner, the footer) then spans edge-to-edge for free,
with no negative-margin breakout hacks. Only matches on pages that opt
in by adding the wrapper (dashboard.html, index.html); FL0W / ST4TS
in by adding the wrapper (index.html); FL0W / ST4TS
are full-bleed with their own `.<page>-main` padding. */
.page-content {
padding: 0 1.5em;
@ -590,7 +590,7 @@ hive-side-panel .md td {
by common.js (renderServerWarnings) on every page. One row per warning;
colour comes from the per-warning `level` (warn = amber, crit = red).
Stickiness lives on the `.sticky-top` wrapper, not here. The bar spans
the full width edge-to-edge; the dashboard + H0M3 are full-bleed at the
the full width edge-to-edge; the dashboard is full-bleed at the
body level (their 1.5em gutter lives on an inner `.page-content`
wrapper), so the banner is full-width for free. */
.server-warnings[hidden] {

View file

@ -1,4 +1,4 @@
// Shared dashboard helpers used by both dashboard.html (./tabs.js) and
// Shared dashboard helpers used by both index.html (./tabs.js) and
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
// and the path-link / file-preview infrastructure for the side panel.
@ -564,7 +564,7 @@ export function appendLinkified(parent, text, refs) {
// ─── server warnings banner ──────────────────────────────────────────
// A generic top-of-page banner shown on every page (dashboard + the
// stand-alone FL0W / H0M3 pages). The backend decides what to
// stand-alone FL0W / ST4TS pages). The backend decides what to
// warn about — `/api/state.server_warnings` is a list of
// `{ kind, level, message }` — and this just renders it, coloured by
// `level` (`warn` amber / `crit` red). Adding a new system warning is a
@ -575,8 +575,8 @@ export function appendLinkified(parent, text, refs) {
// banner above the page's chrome (tab bar / page header), so the banner
// stacks with the chrome instead of being overlaid by it (two separate
// `top:0` stickies would otherwise collide). Built once by wrapping the
// page's existing chrome element; pages without a chrome (e.g. the H0M3
// hub) get a banner-only sticky region at the top of <body>.
// page's existing chrome element; pages without a chrome (none today)
// get a banner-only sticky region at the top of <body>.
function ensureStickyTop() {
let top = document.querySelector(".sticky-top");
if (top) return top;

View file

@ -1,66 +0,0 @@
/* ─── /core.html — C0R3 page ───────────────────────────────────────
Kept state (tombstones) and stale permission entries. Section rules
imported from system-sections.css (shared with dashboard.css). */
@import "./system-sections.css";
body.core-shell {
margin: 0;
padding: 0;
}
.core-main {
padding: 1.2em 1.5em 2em;
}
/* ─── K3PT ST4T3 container cards ────────────────────────────────────
core.html doesn't load dashboard.css (that's the operator SPA),
so the .container-row card styles aren't inherited. Redefine them
here so tombstone entries look like proper cards (similar to agent
cards on the main dashboard) rather than bare list items. */
.containers {
list-style: none;
padding: 0;
margin: 0.5em 0 0;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.container-row {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.55em 0.8em 0.5em;
}
.container-row.tombstone {
border-style: dashed;
background: color-mix(in srgb, var(--bg-elev) 35%, transparent);
opacity: 0.85;
}
.container-row .head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5em;
margin-bottom: 0.4em;
}
.container-row .head .name {
font-size: 1.05em;
font-weight: bold;
}
.container-row.tombstone .head .name {
color: var(--muted);
}
.container-row .head .meta {
margin-left: auto;
font-size: 0.88em;
color: var(--muted);
}
.container-row .actions {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.container-row .actions form.inline {
display: inline-block;
margin: 0;
}

View file

@ -1,40 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // C0R3</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/core.css" />
</head>
<body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean: kept state (tombstones). Rebuild
queue + meta inputs are tabs of /dashboard.html. The section <div>
id (tombstones-section) matches what core.js's renderers target. Hive
infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have
no dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">K3PT ST4T3</span>
</header>
<main class="core-main">
<!-- tombstones-section shows destroyed agents (purge button). -->
<p class="meta">
kept state from previously tombstoned agents — recreating an agent with
the same name reuses it.
</p>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
</main>
<script type="module" src="/static/core.js" defer></script>
</body>
</html>

View file

@ -1,156 +0,0 @@
// C0R3 page entry (/core.html). The host/coordinator surface, its own
// standalone page so the dashboard tab strip stays lean: kept state
// (tombstones).
//
// Rebuild queue + meta inputs are tabs of /dashboard.html, not here. This
// page is its own esbuild bundle with its own runtime: it cold-loads
// /api/state and subscribes to /dashboard/stream (the same broker event
// channel the dashboard uses), maintaining its own copy of the tombstones
// state.
import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
// ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = [];
function syncFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
}
// ─── kept state (tombstones) ──────────────────────────────────────────────
function renderTombstones(s) {
const root = $("tombstones-section");
if (!root) return;
root.replaceChildren();
if (!s.tombstones || !s.tombstones.length) {
root.append(el("p", { class: "empty" }, "no kept state — clean"));
return;
}
const fmtBytes = (n) => {
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const fmtAgeDays = (ts) => {
if (!ts) return "?";
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return "today";
if (d === 1) return "1 day ago";
return d + " days ago";
};
// Only shown alongside actual rows — a caveat over an empty list is noise.
// Wording is deliberately about what the list *is* rather than what it
// isn't: nothing records a destroy, so "container absent" is the only thing
// the backend can actually tell.
const warn = el("hive-warn", { level: "warning" });
warn.append(
el("strong", {}, "shows every agent whose container is absent"),
" — not only destroyed ones. An agent part-way through being spawned " +
"looks identical here, because nothing records a destroy. Check it is " +
"really gone before you PURG3.",
);
root.append(warn);
const ul = el("ul", { class: "containers" });
for (const t of s.tombstones) {
const li = el("li", { class: "container-row tombstone" });
const head = el("div", { class: "head" });
head.append(
el("span", { class: "name" }, t.name),
// Was `destroyed`, which the backend cannot actually know — see the
// caveat above. `offline` is what the absence of a container proves.
el("span", { class: "hive-pill-sm badge-muted" }, "offline"),
);
if (t.has_creds)
head.append(
el("span", { class: "hive-pill-sm badge-muted" }, "creds kept"),
);
head.append(
el(
"span",
{ class: "meta" },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`,
),
);
li.append(head);
const actions = el("div", { class: "actions" });
actions.append(
form(
"/api/purge-tombstone/" + t.name,
"btn-destroy",
"PURG3",
"PURGE " +
t.name +
"? config history, claude creds, " +
"and notes are all WIPED. no undo.",
{},
{ noRefresh: true },
),
);
li.append(actions);
ul.append(li);
}
root.append(ul);
}
// ─── render-all (cold load + any full re-render) ──────────────────────────
function renderAll() {
renderTombstones({ tombstones: tombstonesState });
}
// ─── live SSE handlers ───────────────────────────────────────────────────
const SSE_HANDLERS = {
tombstones_changed(ev) {
tombstonesState = (ev.tombstones || []).slice();
renderTombstones({ tombstones: tombstonesState });
},
};
// ─── boot ─────────────────────────────────────────────────────────────────
// Re-fetch /api/state, re-sync the derived state, re-render. Used for the
// cold load and as the post-submit refresh for `data-async` forms whose
// mutation doesn't arrive via an SSE event (e.g. the meta-update / spawn /
// purge actions that opt out of `data-no-refresh`).
async function refreshState() {
try {
const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json());
} catch {
// best-effort: the page keeps its last-rendered state
}
renderAll();
}
async function init() {
initServerWarnings();
// `data-async` form submit interceptor — without this the meta-update /
// cancel / respawn / purge buttons POST natively and the browser navigates
// to the bare `ok` response page.
bindAsyncForms(() => refreshState());
await refreshState();
// kinds= matches SSE_HANDLERS below verbatim — narrows the stream down
// to the kinds this page acts on. This page was one
// of 4 unfiltered `/api/dashboard/stream` subscribers before this
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream("/api/dashboard/stream?kinds=tombstones_changed");
if (es) {
es.onmessage = (e) => {
let ev;
try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind];
if (h) h(ev);
};
}
}
init();

View file

@ -3,9 +3,7 @@
component rules that this file depends on. */
/* SYST3M panel rules (rebuild queue / meta inputs / kept state)
live in system-sections.css, shared with /core.html. A few are still
referenced from the dashboard (`.rebuild-queue`), so the dashboard
pulls them in here. */
live in system-sections.css. */
@import "./system-sections.css";
@import "@hive/shared/jobq-rollup.css";
@import "@hive/shared/jobq-graph.css";
@ -29,7 +27,7 @@ body.dashboard-shell {
}
.dashboard-chrome {
/* Home back-link + tab strip share one row (flex). The chrome owns the
/* Pages menu + tab strip share one row (flex). The chrome owns the
horizontal gutter and the single full-width bottom divider; its
children drop their own side padding / border so they sit inline. */
display: flex;
@ -45,20 +43,64 @@ body.dashboard-shell {
padding: 0.3em 1em;
margin: 0 0 1em;
}
/* ← home back-link to the H0M3 hub. Small purple link top-left of the
chrome, mirroring the .page-back treatment the standalone pages use
(now shared from @hive/shared/chrome.css) so the navigation reads
consistently. The dashboard keeps this richer sticky chrome of its
own rather than folding into the simple page header. */
.dash-home-back {
/* Pages menu (links-menu.js): one icon button at the left of the chrome
that opens a popover of the standalone pages and the swarm UI. Same
shape as the swarm UI's LinksMenu — quiet until hovered or open. The
explicit `[hidden]` rule is needed because `display: flex` on the
popover would otherwise beat the UA `[hidden]` style. */
.links-menu {
position: relative;
flex: none;
}
.links-menu-button {
display: flex;
align-items: center;
justify-content: center;
width: 2em;
height: 2em;
padding: 0;
border: 1px solid transparent;
border-radius: 0.4em;
background: none;
color: var(--purple);
font-size: 1em;
line-height: 1;
cursor: pointer;
}
.links-menu-button:hover,
.links-menu-button[aria-expanded="true"] {
border-color: var(--border);
background: var(--bg-elev);
}
.links-menu-popover {
position: absolute;
top: calc(100% + 0.4em);
left: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 10em;
padding: 0.4em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg-elev);
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
}
.links-menu-popover[hidden] {
display: none;
}
.links-menu-item {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.4em 0.6em;
border-radius: 0.35em;
color: var(--fg);
text-decoration: none;
font-size: 0.82em;
white-space: nowrap;
}
.dash-home-back:hover {
text-decoration: underline;
.links-menu-item:hover {
background: var(--bg);
}
.banner-thin {
text-align: center;
@ -77,7 +119,7 @@ body.dashboard-shell {
flex-wrap: nowrap;
align-items: center;
gap: 0.2em;
/* On one row with the home link now; the chrome owns the gutter +
/* On one row with the pages menu; the chrome owns the gutter +
the bottom divider, so no outer padding / border here. */
}
/* Shares the flat-pill aesthetic of the shared `.hive-tab` (sub-tabs on

View file

@ -1,260 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/dashboard.css" />
</head>
<body class="dashboard-shell">
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
slug lives at the page footer below `<main>`; chrome is
navigation only. Tabs route via the URL hash so F5 / back-
button / shared links keep you on the same view. JS owns
the show/hide. -->
<header class="dashboard-chrome">
<!-- Back to the H0M3 menu hub (served at /). Sub-pages and the
dashboard all link back to the landing page rather than to each
other — H0M3 is the single navigation hub. -->
<a class="dash-home-back" href="/" title="back to the H0M3 menu"
>← home</a
>
<nav class="tabbar" id="tabbar" role="tablist">
<a
class="tab"
id="tab-agents"
href="#agents"
role="tab"
aria-controls="tab-pane-agents"
data-tab="agents"
>
<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.
The build lifecycle is three tabs here (builds.js): the rebuild
queue with its live log, the meta flake inputs, and the build log
history. -->
<a
class="tab"
id="tab-rebuild"
href="#rebuild"
role="tab"
aria-controls="tab-pane-rebuild"
data-tab="rebuild"
>
<span class="tab-label">◆ R3BU1LD QU3U3 ◆</span>
<span class="tab-count" id="tab-count-rebuild" hidden></span>
</a>
<a
class="tab"
id="tab-meta"
href="#meta"
role="tab"
aria-controls="tab-pane-meta"
data-tab="meta"
>
<span class="tab-label">◆ M3T4 1NPUTS ◆</span>
</a>
<a
class="tab"
id="tab-buildlogs"
href="#buildlogs"
role="tab"
aria-controls="tab-pane-buildlogs"
data-tab="buildlogs"
>
<span class="tab-label">◆ BUILD L0GS ◆</span>
</a>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only
view, not part of the operational tab strip. -->
<!-- (Peer hives are no longer a tab — they render as a headline
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 —
the strip holds only in-page tabs. -->
</nav>
</header>
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL
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">
<!-- 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 4G3NTS already says it. -->
<section
class="tab-pane"
id="tab-pane-agents"
data-tab-pane="agents"
hidden
role="tabpanel"
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 agents.js::renderContainers), which
would tear down and remount the Preact tree on every
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">
<p class="meta">loading…</p>
</div>
</section>
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Rendered from GET /api/jobq/graph by the
`JobqGraph` Preact component (@hive/shared/jobq-graph.js);
`rebuild_queue_changed` over /api/dashboard/stream is the
refresh trigger and carries no payload of its own. -->
<section
class="tab-pane"
id="tab-pane-rebuild"
data-tab-pane="rebuild"
hidden
role="tabpanel"
aria-labelledby="tab-rebuild"
>
<p class="meta">
pending + running rebuilds, meta-updates, and first-spawns. one runs
at a time; meta-update cascades nest under their parent. dedup:
re-enqueueing a still-queued op collapses into the existing entry.
</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
time). Managed by renderRebuildLiveLog in builds.js, separate from
rebuild-queue-section so the queue's row re-render never disturbs
the open poll. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section
class="tab-pane"
id="tab-pane-meta"
data-tab-pane="meta"
hidden
role="tabpanel"
aria-labelledby="tab-meta"
>
<p class="meta">
select inputs to <code>nix flake update</code> in
<code>/meta/</code>. selected agents rebuild in sequence after the
lock bump; the submitting agent learns each outcome via the usual
<code>rebuilt</code> system event.
</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- BUILD L0GS: all-agents build log history. Click a row to expand
stdout + stderr. Live builds stream in real time. Lazy-loaded the
first time the tab is shown; auto-refreshes when
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
<section
class="tab-pane"
id="tab-pane-buildlogs"
data-tab-pane="buildlogs"
hidden
role="tabpanel"
aria-labelledby="tab-buildlogs"
>
<p class="meta">
all-agents build log history. click a row to expand stdout + stderr.
live builds stream in real time.
</p>
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">
↻ refresh
</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on the
dashboard the renderers are simply gone (no stats tab to
activate). -->
<!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when
tabs.js boots on this page the corresponding renderers
no-op silently (each guard is `if (!el) return`). -->
</main>
</div>
<!-- /.page-content -->
<footer>
<pre class="banner banner-thin">
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre
>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p>
▲△▲
<a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a>
▲△▲ hive-c0re on this host ▲△▲
</p>
</footer>
<!-- Slide-in detail panel (clicked file previews, applied config)
is a <hive-side-panel> element
(@hive/shared/side-panel.js) — the Panel singleton in common.js
creates + appends it to <body> lazily on first use, so nothing
needs to be pre-declared here. -->
<!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui/dashboard.md::Selection bar for the bulk
action gating + clear semantics. -->
<div
id="selection-bar"
class="selection-bar"
hidden
role="toolbar"
aria-label="bulk agent actions"
>
<span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button
type="button"
class="btn selection-clear"
id="selection-clear"
title="clear selection (esc)"
>
✕ clear
</button>
</div>
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
flow.html has its own flow.js bundle. esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -11,12 +11,11 @@
<link rel="stylesheet" href="/static/flow.css" />
</head>
<body class="flow-shell">
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /).
<!-- Minimal chrome: just a back link to the dashboard (served at /).
No full tabbar — the flow page is a dedicated full-viewport
terminal surface; navigating back to the menu is the only chrome
needed. Pages link back to H0M3, not the dashboard. -->
terminal surface; navigating back is the only chrome needed. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<a class="page-back" href="/">← dashboard</a>
<span class="page-title">FL0W</span>
<select
id="flow-agent-filter"

View file

@ -5,7 +5,7 @@
//
// Does NOT contain the dashboard's tab renderers, mutation-event
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
// /dashboard.html. The flow page runs purely on the broker stream + an
// the dashboard. The flow page runs purely on the broker stream + an
// initial /api/state fetch (compose autocomplete needs the live
// container list).
@ -24,7 +24,7 @@ import { epochSec } from "./util.js";
// ─── local containers cache (for compose autocomplete) ──────────────────
// The compose box's @-mention completion suggests known agent names.
// /dashboard.html (tabs.js) maintains the canonical `containersState`
// the dashboard (tabs.js) maintains the canonical `containersState`
// from /api/state + SSE; here we keep a small local mirror updated
// by the same `container_state_changed` / `container_removed` events
// the dashboard would handle.
@ -261,7 +261,7 @@ import { epochSec } from "./util.js";
// Drop every other mutation kind silently — without this
// they'd fall through to the terminal module's default
// renderer and clutter the log with JSON dumps. The dashboard
// tabs handle these on /dashboard.html via tabs.js.
// tabs handle these on the dashboard via tabs.js.
_default: () => {},
},
// Re-sync the local containers cache on every SSE (re)connect.

View file

@ -1,94 +0,0 @@
/* H0M3 — the menu-hub page. Minimal portal styling: a banner
+ a responsive grid of link tiles. Colours come from theme.css (the
shared palette); base typography from common.css. When the shared
page-chrome component lands (a later refactor step) the banner + tile
styling can fold into it. */
body.home-shell {
/* Full-bleed body; the 1.5em side gutter lives on the inner
`.page-content` wrapper (common.css) so the server-warnings banner
spans full width. `padding-bottom` keeps breathing room at the foot. */
margin: 0;
padding-bottom: 2em;
min-height: 100vh;
}
/* Matrix-rain backdrop (driven by home.js). Fixed, full-viewport, dimmed,
and behind every other element (z-index below the auto-stacked content).
Decorative only — pointer-events off so it never intercepts clicks. The
low opacity keeps the menu tiles legible over it; glyph + fade colours
are themed from the stylix palette in JS. */
#matrix-rain {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
opacity: 0.16;
pointer-events: none;
}
.home-header {
text-align: center;
padding: 1.5em 0 0.5em;
}
.home-header .banner {
font-size: 1.05em;
}
.home-main {
max-width: 60em;
margin: 1.5em auto 0;
}
.home-menu {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16em, 1fr));
gap: 1em;
}
.home-tile {
display: flex;
flex-direction: column;
gap: 0.35em;
padding: 1.1em 1.2em;
border: 1px solid var(--purple-dim);
border-radius: 6px;
background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
text-decoration: none;
color: var(--fg);
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.home-tile:hover {
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 6%, transparent);
box-shadow: 0 -2px 14px -6px
color-mix(in srgb, var(--purple) 50%, transparent);
}
/* Icon + label share the top row of each tile; the description sits
below. The icon is decorative (aria-hidden) — the label carries the
accessible name. */
.home-tile-head {
display: flex;
align-items: center;
gap: 0.55em;
}
.home-tile-icon {
font-size: 1.25em;
line-height: 1;
flex: none;
}
.home-tile-label {
font-weight: bold;
letter-spacing: 0.08em;
color: var(--purple);
}
.home-tile-desc {
font-size: 0.85em;
color: var(--muted);
}

View file

@ -1,145 +0,0 @@
// H0M3 page script. The page is static markup; this only does
// a few small things off a single `/api/state` read:
// 1. fill the swarm/hive identity line when configured;
// 2. render the shared server-warnings banner (top of every page).
// No SSE — a portal doesn't need live updates.
//
// It also drives the decorative matrix-rain backdrop (#matrix-rain) —
// see startMatrixRain() at the foot.
import { renderServerWarnings } from "./common.js";
const $ = (id) => document.getElementById(id);
async function init() {
let state;
try {
const resp = await fetch("/api/state");
if (!resp.ok) return;
state = await resp.json();
} catch {
return; // best-effort: the tiles still work without it
}
renderServerWarnings(state.server_warnings);
const ident = $("hive-identity");
if (ident && (state.swarm_name || state.hive_name)) {
const parts = [state.swarm_name, state.hive_name].filter(Boolean);
ident.textContent = parts.join(" / ");
ident.hidden = false;
}
// Rev line: absent when the flake ref isn't a local path pin (a bare
// `github:` url has no on-disk rev to canonicalize) — hidden rather
// than showing a placeholder in that case.
//
// `current_flake_rev` canonicalizes to a nix store path
// (/nix/store/<hash>-<pname>) — the part that actually varies between
// builds is the hash right after `/nix/store/`, not the trailing
// `-<pname>` (constant across revs). Slice the hash out of the front
// rather than truncating from the tail, so two different builds don't
// render as the same string. Falls back to a plain head-slice for a
// non-store path (e.g. a bare local dir during dev).
const rev = $("hive-rev");
if (rev && state.hyperhive_rev) {
const storeMatch = state.hyperhive_rev.match(/^\/nix\/store\/([^-]+)/);
const short = storeMatch
? storeMatch[1].slice(0, 12)
: state.hyperhive_rev.slice(0, 12);
rev.textContent = `rev ${short}`;
rev.title = state.hyperhive_rev;
rev.hidden = false;
}
}
init();
// ── matrix-rain backdrop ─────────────────────────────────────────────
// Classic falling-glyph "digital rain" on the #matrix-rain canvas, purely
// decorative. Colours are resolved from the live stylix palette (--green
// glyphs, --bg fade-trail) so a theme swap re-colours it; the CSS already
// dims it + parks it behind the content. Stepped via setInterval (the
// steppy cadence is the look and costs little CPU); paused while the tab is
// hidden, and skipped entirely under prefers-reduced-motion.
function startMatrixRain() {
const canvas = $("matrix-rain");
if (!canvas || !canvas.getContext) return;
if (
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
return;
const ctx = canvas.getContext("2d");
// getComputedStyle on a custom property returns its *declared* value
// (e.g. "var(--base0B)") unresolved, so resolve to a concrete rgb() by
// probing a real `color` computation instead.
function resolveColor(varName, fallback) {
const probe = document.createElement("span");
probe.style.color = `var(${varName})`;
probe.style.display = "none";
document.body.appendChild(probe);
const c = getComputedStyle(probe).color;
probe.remove();
return /^rgb/.test(c) ? c : fallback;
}
const glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
// Fade-trail = the bg at low alpha. Pull r,g,b numerically rather than
// string-rewriting the rgb() — robust to whatever rgb()/rgba() spacing
// getComputedStyle hands back, so the trail always fades (never fills
// solid) even if the format shifts.
const bgRGB = resolveColor("--bg", "rgb(30, 30, 46)").match(
/(\d+)[^\d]+(\d+)[^\d]+(\d+)/,
);
const fadeColor = bgRGB
? `rgba(${bgRGB[1]}, ${bgRGB[2]}, ${bgRGB[3]}, 0.09)`
: "rgba(30, 30, 46, 0.09)";
const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
const CELL = 16; // glyph cell size (px)
let cols = 0;
let drops = [];
function resize() {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
cols = Math.max(1, Math.ceil(canvas.width / CELL));
drops = Array.from({ length: cols }, () => Math.floor(Math.random() * -40));
ctx.font = `${CELL}px monospace`; // canvas reset on resize clears this
}
function tick() {
// Translucent bg wash leaves a fading trail behind each glyph.
ctx.fillStyle = fadeColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = glyphColor;
for (let i = 0; i < cols; i++) {
const ch = GLYPHS[(Math.random() * GLYPHS.length) | 0];
ctx.fillText(ch, i * CELL, drops[i] * CELL);
if (drops[i] * CELL > canvas.height && Math.random() > 0.975)
drops[i] = 0;
drops[i]++;
}
}
let timer = null;
const play = () => {
if (!timer) timer = setInterval(tick, 55);
};
const pause = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
resize();
window.addEventListener("resize", resize);
document.addEventListener("visibilitychange", () =>
document.hidden ? pause() : play(),
);
play();
}
startMatrixRain();

View file

@ -3,90 +3,277 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h0m3</title>
<title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/home.css" />
<link rel="stylesheet" href="/static/dashboard.css" />
</head>
<body class="home-shell">
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of
falling glyphs behind everything (home.js drives it). aria-hidden +
pointer-events:none so it's purely cosmetic; glyph/bg colours are
read from the stylix palette at runtime so a theme swap re-colours
it, and it's disabled under prefers-reduced-motion. -->
<canvas id="matrix-rain" aria-hidden="true"></canvas>
<body class="dashboard-shell">
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
slug lives at the page footer below `<main>`; chrome is
navigation only. Tabs route via the URL hash so F5 / back-
button / shared links keep you on the same view. JS owns
the show/hide. -->
<header class="dashboard-chrome">
<!-- Pages menu: the standalone pages (flow, stats, API docs) and the
swarm UI. Filled by links-menu.js from /api/state. The pages link
back to `/`, which is this dashboard. -->
<div class="links-menu" id="links-menu"></div>
<nav class="tabbar" id="tabbar" role="tablist">
<a
class="tab"
id="tab-agents"
href="#agents"
role="tab"
aria-controls="tab-pane-agents"
data-tab="agents"
>
<span class="tab-label">◆ 4G3NTS ◆</span>
<span class="tab-count" id="tab-count-agents" hidden></span>
</a>
<!-- The build lifecycle is three tabs here (builds.js): the rebuild
queue with its live log, the meta flake inputs, and the build log
history. Kept state (kept.js) is the fourth. -->
<a
class="tab"
id="tab-rebuild"
href="#rebuild"
role="tab"
aria-controls="tab-pane-rebuild"
data-tab="rebuild"
>
<span class="tab-label">◆ R3BU1LD QU3U3 ◆</span>
<span class="tab-count" id="tab-count-rebuild" hidden></span>
</a>
<a
class="tab"
id="tab-meta"
href="#meta"
role="tab"
aria-controls="tab-pane-meta"
data-tab="meta"
>
<span class="tab-label">◆ M3T4 1NPUTS ◆</span>
</a>
<a
class="tab"
id="tab-buildlogs"
href="#buildlogs"
role="tab"
aria-controls="tab-pane-buildlogs"
data-tab="buildlogs"
>
<span class="tab-label">◆ BUILD L0GS ◆</span>
</a>
<a
class="tab"
id="tab-kept"
href="#kept"
role="tab"
aria-controls="tab-pane-kept"
data-tab="kept"
>
<span class="tab-label">◆ K3PT ST4T3 ◆</span>
<span class="tab-count" id="tab-count-kept" hidden></span>
</a>
<!-- H0M3: the menu hub. A plain grid of links to every top-level
surface. This is the page served at `/` — the landing page — with
the dashboard relocated to /dashboard.html. No tabbar / SSE —
it's a static portal. -->
<!-- (Peer hives are no longer a tab — they render as a headline
under 4G3NTS. See tab-pane-agents.) -->
<!-- FL0W and ST4TS are standalone pages reached from the pages
menu at the left of this strip, not tabs — the strip holds only
in-page tabs. -->
</nav>
</header>
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL
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
server-warnings banner spans the full width. -->
sticky chrome above + the footer below span the full width. -->
<div class="page-content">
<header class="home-header">
<p class="banner-thin" id="hive-identity" hidden></p>
<pre class="banner">
░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre
<main class="dashboard-main">
<!-- 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 4G3NTS already says it. -->
<section
class="tab-pane"
id="tab-pane-agents"
data-tab-pane="agents"
hidden
role="tabpanel"
aria-labelledby="tab-agents"
>
<p class="banner-thin" id="hive-rev" hidden></p>
</header>
<!-- 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 agents.js::renderContainers), which
would tear down and remount the Preact tree on every
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">
<p class="meta">loading…</p>
</div>
</section>
<main class="home-main">
<nav class="home-menu" aria-label="hyperhive surfaces">
<a class="home-tile" href="/dashboard.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🖥</span>
<span class="home-tile-label">Dashboard</span>
</span>
<span class="home-tile-desc">containers · builds</span>
</a>
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Rendered from GET /api/jobq/graph by the
`JobqGraph` Preact component (@hive/shared/jobq-graph.js);
`rebuild_queue_changed` over /api/dashboard/stream is the
refresh trigger and carries no payload of its own. -->
<section
class="tab-pane"
id="tab-pane-rebuild"
data-tab-pane="rebuild"
hidden
role="tabpanel"
aria-labelledby="tab-rebuild"
>
<p class="meta">
pending + running rebuilds, meta-updates, and first-spawns. one runs
at a time; meta-update cascades nest under their parent. dedup:
re-enqueueing a still-queued op collapses into the existing entry.
</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
time). Managed by renderRebuildLiveLog in builds.js, separate from
rebuild-queue-section so the queue's row re-render never disturbs
the open poll. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<a class="home-tile" href="/flow.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📡</span>
<span class="home-tile-label">Flow</span>
</span>
<span class="home-tile-desc">live all-agents message firehose</span>
</a>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section
class="tab-pane"
id="tab-pane-meta"
data-tab-pane="meta"
hidden
role="tabpanel"
aria-labelledby="tab-meta"
>
<p class="meta">
select inputs to <code>nix flake update</code> in
<code>/meta/</code>. selected agents rebuild in sequence after the
lock bump; the submitting agent learns each outcome via the usual
<code>rebuilt</code> system event.
</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<a class="home-tile" href="/stats.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📊</span>
<span class="home-tile-label">Stats</span>
</span>
<span class="home-tile-desc"
>hive-wide turn stats · cost · model mix</span
>
</a>
<!-- BUILD L0GS: all-agents build log history. Click a row to expand
stdout + stderr. Live builds stream in real time. Lazy-loaded the
first time the tab is shown; auto-refreshes when
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
<section
class="tab-pane"
id="tab-pane-buildlogs"
data-tab-pane="buildlogs"
hidden
role="tabpanel"
aria-labelledby="tab-buildlogs"
>
<p class="meta">
all-agents build log history. click a row to expand stdout + stderr.
live builds stream in real time.
</p>
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">
↻ refresh
</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
<a class="home-tile" href="/core.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">⬡</span>
<span class="home-tile-label">Core</span>
</span>
<span class="home-tile-desc">kept state</span>
</a>
<!-- K3PT ST4T3: kept state from agents whose container is absent,
with a purge button per row. Rendered by kept.js; refreshed by the
`tombstones_changed` stream event. -->
<section
class="tab-pane"
id="tab-pane-kept"
data-tab-pane="kept"
hidden
role="tabpanel"
aria-labelledby="tab-kept"
>
<p class="meta">
kept state from previously tombstoned agents — recreating an agent
with the same name reuses it.
</p>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
endpoints), so this tile is never gated/hidden. -->
<a class="home-tile" href="/api/docs">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🧬</span>
<span class="home-tile-label">API</span>
</span>
<span class="home-tile-desc"
>interactive OpenAPI spec (Swagger UI)</span
>
</a>
</nav>
<!-- ST4TS and FL0W live on their own pages (`/stats.html`,
`/flow.html`). Their DOM only exists there — when tabs.js boots
on this page the corresponding renderers no-op silently (each
guard is `if (!el) return`). -->
</main>
</div>
<!-- /.page-content -->
<script type="module" src="/static/home.js" defer></script>
<footer>
<pre class="banner banner-thin">
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre
>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p>
▲△▲
<a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a>
▲△▲ hive-c0re on this host ▲△▲
</p>
<!-- Short build id of the running hive; filled from hyperhive_rev. -->
<p class="meta" id="hive-rev" hidden></p>
</footer>
<!-- Slide-in detail panel (clicked file previews, applied config)
is a <hive-side-panel> element
(@hive/shared/side-panel.js) — the Panel singleton in common.js
creates + appends it to <body> lazily on first use, so nothing
needs to be pre-declared here. -->
<!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui/dashboard.md::Selection bar for the bulk
action gating + clear semantics. -->
<div
id="selection-bar"
class="selection-bar"
hidden
role="toolbar"
aria-label="bulk agent actions"
>
<span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button
type="button"
class="btn selection-clear"
id="selection-clear"
title="clear selection (esc)"
>
✕ clear
</button>
</div>
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
flow.html has its own flow.js bundle. esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -0,0 +1,102 @@
// K3PT ST4T3 tab of the dashboard: kept state from agents whose container
// is absent (tombstones), with a purge button per row. Owns its slice of
// state like agents.js / builds.js do; tabs.js feeds it the /api/state
// snapshot and the `tombstones_changed` stream event.
import { $, form } from "./common.js";
import { el } from "@hive/shared/dom.js";
let tombstonesState = [];
const fmtBytes = (n) => {
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const fmtAgeDays = (ts) => {
if (!ts) return "?";
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return "today";
if (d === 1) return "1 day ago";
return d + " days ago";
};
/** Number of kept-state entries, for the tab's count pill. */
export function keptCount() {
return tombstonesState.length;
}
export function syncKeptFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
}
export function renderKept() {
const root = $("tombstones-section");
if (!root) return;
root.replaceChildren();
if (!tombstonesState.length) {
root.append(el("p", { class: "empty" }, "no kept state — clean"));
return;
}
// Only shown alongside actual rows — a caveat over an empty list is noise.
// Wording is deliberately about what the list *is* rather than what it
// isn't: nothing records a destroy, so "container absent" is the only thing
// the backend can actually tell.
const warn = el("hive-warn", { level: "warning" });
warn.append(
el("strong", {}, "shows every agent whose container is absent"),
" — not only destroyed ones. An agent part-way through being spawned " +
"looks identical here, because nothing records a destroy. Check it is " +
"really gone before you PURG3.",
);
root.append(warn);
const ul = el("ul", { class: "containers" });
for (const t of tombstonesState) {
const li = el("li", { class: "container-row tombstone" });
const head = el("div", { class: "head" });
head.append(
el("span", { class: "name" }, t.name),
// `offline` is what the absence of a container proves; `destroyed`
// is something the backend cannot know — see the caveat above.
el("span", { class: "hive-pill-sm badge-muted" }, "offline"),
);
if (t.has_creds)
head.append(
el("span", { class: "hive-pill-sm badge-muted" }, "creds kept"),
);
head.append(
el(
"span",
{ class: "meta" },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`,
),
);
li.append(head);
const actions = el("div", { class: "actions" });
actions.append(
form(
"/api/purge-tombstone/" + t.name,
"btn-destroy",
"PURG3",
"PURGE " +
t.name +
"? config history, claude creds, " +
"and notes are all WIPED. no undo.",
{},
{ noRefresh: true },
),
);
li.append(actions);
ul.append(li);
}
root.append(ul);
}
/** `tombstones_changed` stream event: replace the list and re-render. */
export function applyTombstonesChanged(ev) {
tombstonesState = (ev.tombstones || []).slice();
renderKept();
}

View file

@ -0,0 +1,99 @@
// Pages menu in the dashboard chrome: one button that opens a popover
// linking the standalone pages (flow, stats, API docs) and the swarm UI.
// Modelled on the swarm UI's `LinksMenu`. The swarm entry only exists
// when `/api/state` carries `swarm_public_url`, so a hive without a
// configured swarm domain shows no dead link.
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
/** Menu entries for a state snapshot. `external` opens in a new tab. */
export function menuLinks(state) {
const links = [
{ icon: "📡", label: "flow", url: "/flow.html" },
{ icon: "📊", label: "stats", url: "/stats.html" },
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
];
if (state && state.swarm_public_url) {
links.push({
icon: "🐝",
label: "swarm",
url: state.swarm_public_url,
external: true,
});
}
return links;
}
let wired = false;
function closeMenu(root) {
const pop = root.querySelector(".links-menu-popover");
const btn = root.querySelector(".links-menu-button");
if (pop) pop.hidden = true;
if (btn) btn.setAttribute("aria-expanded", "false");
}
function wire(root) {
if (wired) return;
wired = true;
root.querySelector(".links-menu-button").addEventListener("click", () => {
const pop = root.querySelector(".links-menu-popover");
const btn = root.querySelector(".links-menu-button");
pop.hidden = !pop.hidden;
btn.setAttribute("aria-expanded", String(!pop.hidden));
});
// Outside click / Escape close the popover.
document.addEventListener("pointerdown", (e) => {
if (!root.contains(e.target)) closeMenu(root);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenu(root);
});
}
/** (Re)render the menu into `#links-menu`. Safe to call on every state
* refresh: the open/closed state lives on the popover element, which is
* kept; only its items are rebuilt. */
export function renderLinksMenu(state) {
const root = $("links-menu");
if (!root) return;
if (!root.firstChild) {
root.append(
el(
"button",
{
type: "button",
class: "links-menu-button",
"aria-haspopup": "true",
"aria-expanded": "false",
"aria-label": "pages",
title: "pages",
},
"☰",
),
el("div", { class: "links-menu-popover", role: "menu", hidden: "" }),
);
}
const pop = root.querySelector(".links-menu-popover");
pop.replaceChildren(
...menuLinks(state).map((link) => {
const attrs = {
class: "links-menu-item",
href: link.url,
role: "menuitem",
};
if (link.external) {
attrs.target = "_blank";
attrs.rel = "noreferrer";
}
return el(
"a",
attrs,
el("span", { "aria-hidden": "true" }, link.icon),
link.label,
);
}),
);
wire(root);
}

View file

@ -13,12 +13,12 @@
<body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html —
no full dashboard tabbar. Back link points to the
H0M3 hub (served at /), not the dashboard. -->
dashboard (served at /). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<a class="page-back" href="/">← dashboard</a>
<span class="page-title">ST4TS</span>
<!-- Window selector lives in the header so it behaves like a tab strip
(matches the sub-tab strips on /core.html). Hash-routed via createTabStrip. -->
<!-- Window selector lives in the header so it behaves like a tab strip.
Hash-routed via createTabStrip. -->
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
<button type="button" class="btn" data-tab="1h">1h</button>
<button type="button" class="btn" data-tab="4h">4h</button>

View file

@ -1,6 +1,6 @@
/* ─── SYST3M/BU1LDS section panels — shared by dashboard and /core.html ───
/* ─── SYST3M/BU1LDS section panels — used by the dashboard ───
Rebuild queue, meta inputs and kept-state panels.
Imported by core.css and dashboard.css. */
Imported by dashboard.css. */
/* ─── meta inputs panel ────────────────────────────────────────────── */
.meta-inputs {

View file

@ -1,4 +1,4 @@
// /dashboard.html entry point: tab renderers + tab routing + refreshState.
// the dashboard entry point: tab renderers + tab routing + refreshState.
// Reads /api/state on cold load and after every async-form submit; live
// updates run through `applyXxx` mutation handlers triggered by the
// dashboard event stream (subscribed via `openStream` from common.js). See
@ -7,7 +7,7 @@
// Pure helpers (DOM, side panel, path linkification)
// 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.
// `index.html` each load their own bundle.
// 4G3NTS (containers) domain lives in `./agents.js`.
import { marked } from "marked";
@ -36,6 +36,13 @@ import {
rebuildCount,
syncBuildsFromSnapshot,
} from "./builds.js";
import {
applyTombstonesChanged,
keptCount,
renderKept,
syncKeptFromSnapshot,
} from "./kept.js";
import { renderLinksMenu } from "./links-menu.js";
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
@ -44,8 +51,8 @@ window.marked = marked;
(() => {
// ─── async forms ────────────────────────────────────────────────────────
// Shared `data-async` submit interceptor (now in common.js so /core.html's
// bundle gets it too). On success it re-runs refreshState unless the form
// Shared `data-async` submit interceptor (in common.js, shared with the other
// pages' bundles). On success it re-runs refreshState unless the form
// opts out via `data-no-refresh` (its mutation arrives via an SSE event).
bindAsyncForms(() => refreshState());
@ -95,6 +102,22 @@ window.marked = marked;
});
}
// Rev line in the footer: absent when the flake ref isn't a local path
// pin (a bare `github:` url has no on-disk rev to canonicalize), so it
// stays hidden rather than showing a placeholder. `hyperhive_rev`
// canonicalizes to a nix store path (/nix/store/<hash>-<pname>); the
// part that varies between builds is the hash, so slice that out rather
// than truncating from the tail. Falls back to a plain head-slice for a
// non-store path (e.g. a bare local dir during dev).
function renderHiveRev(rev) {
const node = $("hive-rev");
if (!node || !rev) return;
const storeMatch = rev.match(/^\/nix\/store\/([^-]+)/);
node.textContent = `rev ${(storeMatch ? storeMatch[1] : rev).slice(0, 12)}`;
node.title = rev;
node.hidden = false;
}
async function refreshState() {
// Don't yank the form out from under the operator. Try again
// shortly on the next tick; eventually they'll blur and the
@ -144,7 +167,11 @@ window.marked = marked;
// mount's own effect handles the actual fetch.
initJobqRollup();
syncBuildsFromSnapshot(s);
syncKeptFromSnapshot(s);
renderContainers(s);
renderKept();
renderLinksMenu(s);
renderHiveRev(s.hyperhive_rev);
restoreOpenDetails(openDetails);
// No periodic refresh timer. Phase 6 covers every container
// mutation with `ContainerStateChanged` / `ContainerRemoved`
@ -181,7 +208,7 @@ window.marked = marked;
// lost during the disconnect window (same pattern as flow.js's
// onStreamOpen).
//
// Both /dashboard.html and /flow.html subscribe to `/api/dashboard/stream`
// Both the dashboard and /flow.html subscribe to `/api/dashboard/stream`
// and filter client-side — the dashboard ignores broker traffic
// and FL0W ignores mutation events.
const MUTATION_HANDLERS = {
@ -198,6 +225,7 @@ window.marked = marked;
},
meta_inputs_changed: onMetaInputsChanged,
meta_update_running: onMetaUpdateRunning,
tombstones_changed: applyTombstonesChanged,
};
(function bindDashboardStream() {
// Route through the SharedWorker so all open hyperhive tabs on the
@ -208,12 +236,12 @@ window.marked = marked;
// fallback when SharedWorker isn't supported.
//
// kinds= matches MUTATION_HANDLERS verbatim, so the stream carries
// only the 7 wire kinds this page acts on.
// only the 8 wire kinds this page acts on.
const es = openStream(
"/api/dashboard/stream?kinds=" +
"transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," +
"meta_inputs_changed,meta_update_running",
"meta_inputs_changed,meta_update_running,tombstones_changed",
);
es.onmessage = (e) => {
let ev;
@ -245,11 +273,11 @@ window.marked = marked;
})();
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs`
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / `#kept`
// activate the matching pane on the dashboard. Empty
// 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
// the pages menu (links-menu.js). Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page
// itself, where these elements don't exist and the loop no-ops).
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
@ -299,6 +327,8 @@ window.marked = marked;
setTabCount("agents", stale);
// R3BU1LD QU3U3 — rebuilds in flight.
setTabCount("rebuild", rebuildCount());
// K3PT ST4T3 — entries of kept state.
setTabCount("kept", keptCount());
}
// Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE