treefmt: apply prettier

Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
This commit is contained in:
atlas 2026-09-02 14:29:33 +02:00
commit 39b95c2ede
203 changed files with 10090 additions and 6085 deletions

View file

@ -48,32 +48,41 @@
// prefix) so it never shadows the exact-match /dashboard/stream +
// /dashboard/history SSE routes registered before the ServeDir fallback.
import { build } from 'esbuild';
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from "esbuild";
import { mkdirSync, copyFileSync, rmSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p);
const dist = (p) => resolve(here, 'dist', p);
const staticDir = (p) => resolve(here, 'dist', 'static', p);
const src = (p) => resolve(here, "src", p);
const dist = (p) => resolve(here, "dist", p);
const staticDir = (p) => resolve(here, "dist", "static", p);
rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true });
rmSync(dist(""), { recursive: true, force: true });
mkdirSync(staticDir(""), { recursive: true });
// Bundle the JS entries. ES-module output, browser target, no minify
// (line-aligned source aids debugging; minification belongs in a later
// 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('logs.js'), src('home.js'), src('stats.js'), src('core.js'), src('builds.js'), src('credentials.js')],
outdir: staticDir(''),
entryPoints: [
src("tabs.js"),
src("flow.js"),
src("logs.js"),
src("home.js"),
src("stats.js"),
src("core.js"),
src("builds.js"),
src("credentials.js"),
],
outdir: staticDir(""),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
logLevel: 'info',
logLevel: "info",
// `@hive/shared/modal.js` and `hive-btn.js` import their shadow-DOM
// component CSS (hive-dialog.css, hive-toast.css, hive-btn.css) as raw
// text via a plain `import css from './foo.css'` — the `text` loader turns
@ -82,15 +91,15 @@ await build({
// file any other way, so this doesn't collide with the separate
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
// runs as its own esbuild invocation over different entry points.
loader: { '.css': 'text' },
loader: { ".css": "text" },
// `@hive/shared/jobq-graph.js` resolves to a real `.jsx` file
// (`JobqGraph.jsx`), pulled in transitively by `builds.js` — esbuild
// already picks the `jsx` loader for `.jsx` by extension, this just
// sets the transform mode to match swarm-ui's (which also authors
// this file). No other entry here uses JSX today; this doesn't turn
// any plain `.js` file into one, `.js` still parses as plain JS.
jsx: 'automatic',
jsxImportSource: 'preact',
jsx: "automatic",
jsxImportSource: "preact",
});
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
@ -106,14 +115,14 @@ await build({
// the IIFE format will surface it as a build error rather than
// silently shipping broken code.
await build({
entryPoints: [src('stream-worker.js')],
outdir: staticDir(''),
entryPoints: [src("stream-worker.js")],
outdir: staticDir(""),
bundle: true,
format: 'iife',
platform: 'browser',
target: ['es2022'],
format: "iife",
platform: "browser",
target: ["es2022"],
sourcemap: true,
logLevel: 'info',
logLevel: "info",
});
// Bundle CSS — one entry per page. esbuild resolves @import including
@ -122,18 +131,39 @@ await build({
// so a swap replaces only it) + theme.css (the semantic derivation
// layer) + common.css (shared typography, badges, buttons, inbox, side
// panel) plus its own page-specific bundle.
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'stats.css', 'core.css', 'builds.css', 'credentials.css']) {
for (const entry of [
"colors.css",
"theme.css",
"common.css",
"dashboard.css",
"flow.css",
"logs.css",
"home.css",
"stats.css",
"core.css",
"builds.css",
"credentials.css",
]) {
await build({
entryPoints: [src(entry)],
outfile: staticDir(entry),
bundle: true,
loader: { '.css': 'css' },
logLevel: 'info',
loader: { ".css": "css" },
logLevel: "info",
});
}
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'stats.html', 'core.html', 'builds.html', 'credentials.html']) {
for (const html of [
"index.html",
"dashboard.html",
"flow.html",
"logs.html",
"stats.html",
"core.html",
"builds.html",
"credentials.html",
]) {
copyFileSync(src(html), dist(html));
}
console.log('dashboard build ok →', dist(''));
console.log("dashboard build ok →", dist(""));

View file

@ -22,9 +22,9 @@
// pure positioning rule which is now generic and lives in `<hive-menu>`'s
// own shadow-scoped `.menu-dropdown`).
import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
import { el } from "@hive/shared/dom.js";
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import "@hive/shared/hive-menu.js"; // registers <hive-menu> — side-effect import
// Single-agent POST helper shared by all menu items. `flags` is an object
// of boolean query params to set truthy (e.g. `{ graceful: true }` or
@ -34,25 +34,32 @@ import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect imp
async function agentMenuPost(actionPath, name, body, flags) {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(flags || {})) {
if (v) params.set(k, 'true');
if (v) params.set(k, "true");
}
const qs = params.toString();
const url = actionPath + encodeURIComponent(name) + (qs ? '?' + qs : '');
const url = actionPath + encodeURIComponent(name) + (qs ? "?" + qs : "");
try {
const resp = await fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
method: "POST",
headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: {},
body: body ? new URLSearchParams(body) : undefined,
redirect: 'manual',
redirect: "manual",
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
const text = await resp.text().catch(() => "");
themedToast(
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
{ type: "error" },
);
}
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
themedToast("action failed: " + err, { type: "error" });
}
}
@ -67,40 +74,58 @@ class HiveAgentMenu extends HTMLElement {
const { c, forgeBase } = this._opts || {};
const btn = el('button', {
type: 'button',
class: 'agent-menu-btn',
title: `actions for ${c.name}`,
'aria-label': `actions for ${c.name}`,
'aria-haspopup': 'menu',
'aria-expanded': 'false',
}, '⋮');
const dropdown = el('ul', { class: 'agent-menu-dropdown', role: 'menu' });
const btn = el(
"button",
{
type: "button",
class: "agent-menu-btn",
title: `actions for ${c.name}`,
"aria-label": `actions for ${c.name}`,
"aria-haspopup": "menu",
"aria-expanded": "false",
},
"⋮",
);
const dropdown = el("ul", { class: "agent-menu-dropdown", role: "menu" });
const close = () => this._menu.close();
const menuItem = (label, opts) => {
const li = el('li', { role: 'presentation' });
const item = el('button', {
type: 'button',
class: 'agent-menu-item',
role: 'menuitem',
}, label);
item.addEventListener('click', async () => {
const li = el("li", { role: "presentation" });
const item = el(
"button",
{
type: "button",
class: "agent-menu-item",
role: "menuitem",
},
label,
);
item.addEventListener("click", async () => {
close();
let flags = {};
if (opts.confirm) {
const checkboxes = [];
if (opts.graceful) {
checkboxes.push({ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' });
checkboxes.push({
name: "graceful",
label:
opts.gracefulLabel ||
"stop gracefully — let the agent finish its turn and flush state before the container stops",
});
}
if (opts.paused) {
checkboxes.push({ name: 'paused', label: opts.pausedLabel || 'start paused — come up without driving turns until resumed' });
checkboxes.push({
name: "paused",
label:
opts.pausedLabel ||
"start paused — come up without driving turns until resumed",
});
}
const r = await themedConfirm({
message: opts.confirm,
danger: true,
confirmLabel: opts.confirmLabel || 'confirm',
confirmLabel: opts.confirmLabel || "confirm",
checkboxes,
});
if (!r) return;
@ -112,18 +137,23 @@ class HiveAgentMenu extends HTMLElement {
return li;
};
const menuSep = () => el('li', { class: 'agent-menu-sep', role: 'separator' });
const menuSep = () =>
el("li", { class: "agent-menu-sep", role: "separator" });
// Navigation link item (opens in same tab by default).
const menuLink = (label, href, title) => {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href,
role: 'menuitem',
title: title || '',
}, label);
a.addEventListener('click', close);
const li = el("li", { role: "presentation" });
const a = el(
"a",
{
class: "agent-menu-item",
href,
role: "menuitem",
title: title || "",
},
label,
);
a.addEventListener("click", close);
li.append(a);
return li;
};
@ -131,21 +161,28 @@ class HiveAgentMenu extends HTMLElement {
// Show only actions that are applicable in the current state.
if (c.running) {
dropdown.append(
menuItem('↺ R3ST4RT', {
action: '/api/restart/',
menuItem("↺ R3ST4RT", {
action: "/api/restart/",
confirm: `restart ${c.name}?`,
graceful: true,
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts',
gracefulLabel:
"restart gracefully — let the agent finish its turn and flush state before the container restarts",
}),
menuItem("■ ST0P", {
action: "/api/kill/",
confirm: `stop ${c.name}?`,
confirmLabel: "■ stop",
graceful: true,
}),
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
);
} else {
dropdown.append(
menuItem('▶ ST4RT', {
action: '/api/start/',
menuItem("▶ ST4RT", {
action: "/api/start/",
confirm: `start ${c.name}?`,
paused: true,
pausedLabel: 'start paused — come up without driving turns until resumed',
pausedLabel:
"start paused — come up without driving turns until resumed",
}),
);
}
@ -153,55 +190,70 @@ class HiveAgentMenu extends HTMLElement {
// paused; a paused running agent keeps its container but drives no turns.
if (c.paused) {
dropdown.append(
menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }),
menuItem("▶ R3SUM3", {
action: "/api/resume/",
confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.`,
}),
);
} else {
dropdown.append(
menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }),
menuItem("⏸ P4US3", {
action: "/api/pause/",
confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.`,
}),
);
}
dropdown.append(
menuSep(),
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuItem("↻ R3BU1LD", {
action: "/api/rebuild/",
confirm: `rebuild ${c.name}? hot-reloads the container.`,
}),
menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load and pre-selects this
// agent's journal without extra clicks.
menuLink('journal logs →',
menuLink(
"journal logs →",
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
`view ${c.name} journal logs`),
`view ${c.name} journal logs`,
),
);
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/api/destroy/',
menuItem("DESTR0Y", {
action: "/api/destroy/",
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/api/destroy/',
body: { purge: 'on' },
menuItem("PURG3", {
action: "/api/destroy/",
body: { purge: "on" },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
target: '_blank',
rel: 'noopener',
role: 'menuitem',
title: 'deployed config commit on forge',
}, `deployed:${c.deployed_sha}`);
const li = el("li", { role: "presentation" });
const a = el(
"a",
{
class: "agent-menu-item",
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
target: "_blank",
rel: "noopener",
role: "menuitem",
title: "deployed config commit on forge",
},
`deployed:${c.deployed_sha}`,
);
li.append(a);
dropdown.append(menuSep(), li);
}
this._menu = document.createElement('hive-menu');
this._menu = document.createElement("hive-menu");
this._menu._opts = { trigger: btn, content: dropdown };
this.append(this._menu);
}
}
customElements.define('hive-agent-menu', HiveAgentMenu);
customElements.define("hive-agent-menu", HiveAgentMenu);

View file

@ -24,4 +24,6 @@ body.builds-shell {
padding: 1.2em 1.5em 2em;
}
.builds-pane[hidden] { display: none; }
.builds-pane[hidden] {
display: none;
}

View file

@ -1,73 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // BU1LDS</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/builds.css">
</head>
<body class="builds-shell">
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // BU1LDS</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/builds.css" />
</head>
<body class="builds-shell">
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
meta inputs, and build log history on one page. Carved out of the
old /core.html (rebuild queue + meta inputs) and /logs.html (BUILD
tab) so the full build lifecycle is in one place. Same minimal
chrome as /core.html — a `← home` back-link + a <hive-tab-strip>
sub-tab nav. Default sub-tab is the rebuild queue. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar builds-tabbar" id="builds-tabbar" prefix="builds"
role="tablist"></hive-tab-strip>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar builds-tabbar"
id="builds-tabbar"
prefix="builds"
role="tablist"
></hive-tab-strip>
</header>
<main class="builds-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
<main class="builds-main">
<!-- 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. Default tab. -->
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild"
role="tabpanel" aria-labelledby="builds-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
<section
class="builds-pane"
id="builds-pane-rebuild"
data-tab-pane="rebuild"
role="tabpanel"
aria-labelledby="builds-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 SSE stream. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section class="builds-pane" id="builds-pane-meta" data-tab-pane="meta"
role="tabpanel" aria-labelledby="builds-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>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section
class="builds-pane"
id="builds-pane-meta"
data-tab-pane="meta"
role="tabpanel"
aria-labelledby="builds-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 (moved from /logs.html).
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
Click a row to expand stdout + stderr. Live builds stream in real
time. Lazy-loaded on first tab activation; auto-refreshes when
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
<section class="builds-pane" id="builds-pane-buildlogs" data-tab-pane="buildlogs"
role="tabpanel" aria-labelledby="builds-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>
<section
class="builds-pane"
id="builds-pane-buildlogs"
data-tab-pane="buildlogs"
role="tabpanel"
aria-labelledby="builds-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>
</main>
</main>
<script type="module" src="/static/builds.js" defer></script>
</body>
<script type="module" src="/static/builds.js" defer></script>
</body>
</html>

View file

@ -10,14 +10,20 @@
// renderers here are direct copies from core.js / logs.js with only the
// deep-link URL and count-pill id adjusted.
import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js';
import { h, render } from 'preact';
import { fmtAgo, fmtDuration, truncate } from './util.js';
import '@hive/shared/hive-tab-strip.js';
import { JobqGraph } from '@hive/shared/jobq-graph.js';
import {
$,
fmtAgeSecs,
openStream,
openBuildLogStream,
initServerWarnings,
} from "./common.js";
import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { themedConfirm } from "@hive/shared/modal.js";
import { h, render } from "preact";
import { fmtAgo, fmtDuration, truncate } from "./util.js";
import "@hive/shared/hive-tab-strip.js";
import { JobqGraph } from "@hive/shared/jobq-graph.js";
// ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = [];
@ -36,93 +42,122 @@ function syncFromSnapshot(s) {
// ─── meta inputs ─────────────────────────────────────────────────────────────
function renderMetaInputs(s) {
const root = $('meta-inputs-section');
const root = $("meta-inputs-section");
if (!root) return;
// Snapshot ticked checkboxes before wiping so a concurrent
// MetaInputsChanged doesn't silently clear a pending selection.
const checkedInputs = new Set(
Array.from(root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'))
.map((cb) => cb.dataset.metaInput),
Array.from(
root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'),
).map((cb) => cb.dataset.metaInput),
);
root.replaceChildren();
const inputs = s.meta_inputs || [];
if (!inputs.length) {
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
root.append(el("p", { class: "empty" }, "meta repo not seeded yet"));
return;
}
if (metaUpdateRunning) {
root.append(el('p', { class: 'meta-update-running' },
'⏳ meta-update running — flake lock bump + affected agents rebuilding. '
+ 'watch the agent cards for per-rebuild progress.'));
root.append(
el(
"p",
{ class: "meta-update-running" },
"⏳ meta-update running — flake lock bump + affected agents rebuilding. " +
"watch the agent cards for per-rebuild progress.",
),
);
}
const f = el('form', {
method: 'POST',
action: '/api/meta-update',
class: 'meta-inputs-form',
'data-async': '',
'data-no-refresh': '',
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
const f = el("form", {
method: "POST",
action: "/api/meta-update",
class: "meta-inputs-form",
"data-async": "",
"data-no-refresh": "",
"data-confirm":
"update selected meta flake inputs + rebuild affected agents?",
});
const bulk = el('div', { class: 'meta-inputs-bulk' });
const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all');
const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none');
bulk.append('bulk: ', selAll, ' ', selNone);
const bulk = el("div", { class: "meta-inputs-bulk" });
const selAll = el(
"button",
{ type: "button", class: "meta-bulk-btn" },
"select all",
);
const selNone = el(
"button",
{ type: "button", class: "meta-bulk-btn" },
"select none",
);
bulk.append("bulk: ", selAll, " ", selNone);
f.append(bulk);
const ul = el('ul', { class: 'meta-inputs' });
const ul = el("ul", { class: "meta-inputs" });
for (const inp of inputs) {
const depth = (inp.name.match(/\//g) || []).length;
const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1);
const li = el('li');
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
const cb = el('input', {
type: 'checkbox',
name: 'meta_input_' + inp.name,
const leaf = inp.name.slice(inp.name.lastIndexOf("/") + 1);
const li = el("li");
if (depth > 0) li.style.marginLeft = depth * 1.3 + "em";
const id = "meta-input-" + inp.name.replace(/[^a-z0-9-]/gi, "_");
const cb = el("input", {
type: "checkbox",
name: "meta_input_" + inp.name,
id,
value: inp.name,
'data-meta-input': inp.name,
"data-meta-input": inp.name,
});
if (checkedInputs.has(inp.name)) cb.checked = true;
const label = el('label', { for: id, title: inp.name });
const label = el("label", { for: id, title: inp.name });
label.append(cb);
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
if (depth > 0) label.append(el("span", { class: "meta-input-twig" }, "└ "));
label.append(
el('span', { class: 'meta-input-name' }, leaf), ' ',
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
el("span", { class: "meta-input-name" }, leaf),
" ",
el("code", { class: "meta-input-rev" }, inp.rev.slice(0, 12)),
" ",
el("span", { class: "meta-input-ts" }, fmtAgo(inp.last_modified)),
);
if (inp.url) {
label.append(' ', el('span', { class: 'meta-input-url', title: inp.url },
'· ' + truncate(inp.url, 48)));
label.append(
" ",
el(
"span",
{ class: "meta-input-url", title: inp.url },
"· " + truncate(inp.url, 48),
),
);
}
li.append(label);
ul.append(li);
}
f.append(ul);
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
const hidden = el("input", { type: "hidden", name: "inputs", value: "" });
f.append(hidden);
const btn = el('button', {
type: 'submit',
class: 'btn btn-meta-update',
disabled: '',
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
const btn = el(
"button",
{
type: "submit",
class: "btn btn-meta-update",
disabled: "",
},
metaUpdateRunning ? "⏳ UPD4T1NG…" : "◆ UPD4TE & R3BU1LD",
);
f.append(btn);
function refreshDisabled() {
const any = f.querySelectorAll('input[data-meta-input]:checked').length > 0;
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
else btn.setAttribute('disabled', '');
const any = f.querySelectorAll("input[data-meta-input]:checked").length > 0;
if (any && !metaUpdateRunning) btn.removeAttribute("disabled");
else btn.setAttribute("disabled", "");
}
f.addEventListener('change', refreshDisabled);
f.addEventListener("change", refreshDisabled);
function setAllChecked(val) {
for (const b of f.querySelectorAll('input[data-meta-input]')) b.checked = val;
for (const b of f.querySelectorAll("input[data-meta-input]"))
b.checked = val;
refreshDisabled();
}
selAll.addEventListener('click', () => setAllChecked(true));
selNone.addEventListener('click', () => setAllChecked(false));
f.addEventListener('submit', () => {
const selected = Array.from(f.querySelectorAll('input[data-meta-input]:checked'))
.map((b) => b.dataset.metaInput);
hidden.value = selected.join(',');
selAll.addEventListener("click", () => setAllChecked(true));
selNone.addEventListener("click", () => setAllChecked(false));
f.addEventListener("submit", () => {
const selected = Array.from(
f.querySelectorAll("input[data-meta-input]:checked"),
).map((b) => b.dataset.metaInput);
hidden.value = selected.join(",");
});
root.append(f);
}
@ -150,41 +185,50 @@ function renderMetaInputs(s) {
// tracked children out from under its diffing rather than let it
// update them minimally).
function mountRebuildQueue() {
const root = $('rebuild-queue-section');
const root = $("rebuild-queue-section");
if (!root) return;
root.replaceChildren();
renderRebuildQueue();
}
function renderRebuildQueue() {
const root = $('rebuild-queue-section');
const root = $("rebuild-queue-section");
if (!root) return;
render(h(JobqGraph, {
endpoint: '/api/jobq/graph',
cancellable: true,
refreshToken: jobqGraphToken,
onUpdate: (nodes) => {
jobqNodes = nodes || [];
renderRebuildLiveLog();
updateRebuildCount();
},
onCancel: async (id) => {
const node = jobqNodes.find((n) => n.id === id);
const label = node ? node.payload.label : 'node ' + id;
if (!(await themedConfirm({
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
danger: true, confirmLabel: '✕ cancel',
}))) return;
try {
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
if (!r.ok) throw new Error('http ' + r.status);
// No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
// already bumps jobqGraphToken and re-renders on that tick.
} catch (err) {
console.error('cancel failed', err);
}
},
}), root);
render(
h(JobqGraph, {
endpoint: "/api/jobq/graph",
cancellable: true,
refreshToken: jobqGraphToken,
onUpdate: (nodes) => {
jobqNodes = nodes || [];
renderRebuildLiveLog();
updateRebuildCount();
},
onCancel: async (id) => {
const node = jobqNodes.find((n) => n.id === id);
const label = node ? node.payload.label : "node " + id;
if (
!(await themedConfirm({
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
danger: true,
confirmLabel: "✕ cancel",
}))
)
return;
try {
const r = await fetch("/api/rebuild-queue/" + id + "/cancel", {
method: "POST",
});
if (!r.ok) throw new Error("http " + r.status);
// No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
// already bumps jobqGraphToken and re-renders on that tick.
} catch (err) {
console.error("cancel failed", err);
}
},
}),
root,
);
}
// ─── running-rebuild live log ─────────────────────────────────────────────────
@ -196,13 +240,16 @@ function renderRebuildQueue() {
// {stdout, stderr}) rather than SSE — a running node's log re-fetches on the
// rebuild_queue_changed tick; a terminal node's log is static (one last fetch
// on transition, then done).
let liveLogId = null; // current node id being shown
let liveLogDone = false; // true once the node left 'Running'
let liveLogId = null; // current node id being shown
let liveLogDone = false; // true once the node left 'Running'
let liveLogCollapsed = false;
let liveLogPollTimer = null;
function clearLiveLogPoll() {
if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; }
if (liveLogPollTimer) {
clearInterval(liveLogPollTimer);
liveLogPollTimer = null;
}
}
// First running node with a log, in wire order (root-then-subtree per
@ -210,26 +257,39 @@ function clearLiveLogPoll() {
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank
// panel.
function findLiveBuild() {
return jobqNodes.find((n) => n.state === 'Running' && n.payload.data && n.payload.data.build_log_id != null)
|| null;
return (
jobqNodes.find(
(n) =>
n.state === "Running" &&
n.payload.data &&
n.payload.data.build_log_id != null,
) || null
);
}
async function fetchAndRenderLiveLog(nodeId, pre) {
try {
const r = await fetch('/api/build-log/' + nodeId);
const r = await fetch("/api/build-log/" + nodeId);
if (!r.ok) return;
const data = await r.json();
const text = [data.stdout, data.stderr ? '--- stderr ---\n' + data.stderr : ''].filter(Boolean).join('\n');
const text = [
data.stdout,
data.stderr ? "--- stderr ---\n" + data.stderr : "",
]
.filter(Boolean)
.join("\n");
if (pre.textContent !== text) {
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
pre.textContent = text;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
} catch { /* network blip — ignore, next poll will retry */ }
} catch {
/* network blip — ignore, next poll will retry */
}
}
function renderRebuildLiveLog() {
const root = $('rebuild-live-log');
const root = $("rebuild-live-log");
if (!root) return;
const liveNode = findLiveBuild();
@ -237,22 +297,26 @@ function renderRebuildLiveLog() {
clearLiveLogPoll();
liveLogId = null;
liveLogDone = false;
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
if (!root.hidden) {
root.hidden = true;
root.replaceChildren();
}
return;
}
// Same node, already polling — just let the timer tick (or do a final
// fetch if the node just went non-running and we haven't marked done yet).
if (liveNode.id === liveLogId) {
if (!liveLogDone && liveNode.state !== 'Running') {
if (!liveLogDone && liveNode.state !== "Running") {
clearLiveLogPoll();
liveLogDone = true;
const pre = root.querySelector('.rebuild-live-log-output');
const badge = root.querySelector('.rebuild-live-log-badge');
const pre = root.querySelector(".rebuild-live-log-output");
const badge = root.querySelector(".rebuild-live-log-badge");
if (pre) fetchAndRenderLiveLog(liveNode.id, pre);
if (badge) {
const ok = liveNode.state !== 'Failed';
badge.className = 'rebuild-live-log-badge ' + (ok ? 'rll-ok' : 'rll-fail');
const ok = liveNode.state !== "Failed";
badge.className =
"rebuild-live-log-badge " + (ok ? "rll-ok" : "rll-fail");
badge.textContent = liveNode.state;
}
}
@ -266,54 +330,84 @@ function renderRebuildLiveLog() {
root.hidden = false;
root.replaceChildren();
const pre = el('pre', { class: 'rebuild-live-log-output' }, '');
const pre = el("pre", { class: "rebuild-live-log-output" }, "");
pre.hidden = liveLogCollapsed;
const badge = el('span', { class: 'rebuild-live-log-badge rll-running' }, 'live');
const toggle = el('button', {
type: 'button',
class: 'rebuild-live-log-toggle',
'aria-expanded': String(!liveLogCollapsed),
title: liveLogCollapsed ? 'expand live log' : 'collapse live log',
}, liveLogCollapsed ? '▸' : '▾');
toggle.addEventListener('click', () => {
const badge = el(
"span",
{ class: "rebuild-live-log-badge rll-running" },
"live",
);
const toggle = el(
"button",
{
type: "button",
class: "rebuild-live-log-toggle",
"aria-expanded": String(!liveLogCollapsed),
title: liveLogCollapsed ? "expand live log" : "collapse live log",
},
liveLogCollapsed ? "▸" : "▾",
);
toggle.addEventListener("click", () => {
liveLogCollapsed = !liveLogCollapsed;
pre.hidden = liveLogCollapsed;
toggle.textContent = liveLogCollapsed ? '▸' : '▾';
toggle.setAttribute('aria-expanded', String(!liveLogCollapsed));
toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log';
toggle.textContent = liveLogCollapsed ? "▸" : "▾";
toggle.setAttribute("aria-expanded", String(!liveLogCollapsed));
toggle.title = liveLogCollapsed ? "expand live log" : "collapse live log";
});
const liveAgent = liveNode.payload.data && liveNode.payload.data.agent;
const header = el('div', { class: 'rebuild-live-log-header' },
toggle, ' ',
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
const header = el(
"div",
{ class: "rebuild-live-log-header" },
toggle,
" ",
el("span", { class: "rebuild-live-log-title" }, "live build log — "),
// Label the specific node's agent, not the whole group's agent set.
el('code', { class: 'rqe-agent' }, liveAgent || ''),
' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label),
' ', badge, ' ',
el('a', {
class: 'rebuild-live-log-raw',
href: '/api/build-log/' + liveNode.id + '/raw',
download: 'build-log-' + liveNode.id + '.txt',
}, '↓ raw'),
el("code", { class: "rqe-agent" }, liveAgent || ""),
" ",
el("span", { class: "rqe-kind" }, liveNode.payload.label),
" ",
badge,
" ",
el(
"a",
{
class: "rebuild-live-log-raw",
href: "/api/build-log/" + liveNode.id + "/raw",
download: "build-log-" + liveNode.id + ".txt",
},
"↓ raw",
),
);
root.append(header, pre);
// Start polling.
fetchAndRenderLiveLog(liveNode.id, pre);
liveLogPollTimer = setInterval(() => fetchAndRenderLiveLog(liveNode.id, pre), 2000);
liveLogPollTimer = setInterval(
() => fetchAndRenderLiveLog(liveNode.id, pre),
2000,
);
}
// ─── rebuild-queue count pill ─────────────────────────────────────────────────
function updateRebuildCount() {
const pill = $('builds-tab-count-rebuild');
const pill = $("builds-tab-count-rebuild");
if (!pill) return;
// Pending/Running/Finishing = in flight (Finishing = own work done, a
// sub-node still running — still counts). Root nodes only: each is one
// queue entry.
const n = jobqNodes.filter((n) => n.parent == null
&& (n.state === 'Pending' || n.state === 'Running' || n.state === 'Finishing')).length;
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
else { pill.hidden = true; }
const n = jobqNodes.filter(
(n) =>
n.parent == null &&
(n.state === "Pending" ||
n.state === "Running" ||
n.state === "Finishing"),
).length;
if (n > 0) {
pill.textContent = String(n);
pill.hidden = false;
} else {
pill.hidden = true;
}
}
// ─── render-all (cold load + any full re-render) ──────────────────────────────
@ -330,43 +424,63 @@ function renderAll() {
// 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active.
// Deep-link: ?id=N#buildlogs auto-expands the target row.
const buildList = $('build-list');
const buildRefresh = $('build-refresh');
const buildList = $("build-list");
const buildRefresh = $("build-refresh");
let buildLogsLoaded = false;
let buildTabs; // set in init
function fmtTs(unixSecs) {
if (!unixSecs) return '';
if (!unixSecs) return "";
const age = Math.floor(Date.now() / 1000) - unixSecs;
return fmtAgeSecs(Math.max(0, age)) + ' ago';
return fmtAgeSecs(Math.max(0, age)) + " ago";
}
async function fetchBuild() {
if (!buildList) return;
buildList.replaceChildren();
buildList.append(el('p', { class: 'meta' }, 'loading…'));
buildList.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch('/api/build-logs?limit=30');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/build-logs?limit=30");
if (!resp.ok) throw new Error("http " + resp.status);
const rows = await resp.json();
buildList.replaceChildren();
if (!rows || rows.length === 0) {
buildList.append(el('p', { class: 'meta' }, '(no build logs yet)'));
buildList.append(el("p", { class: "meta" }, "(no build logs yet)"));
return;
}
const ul = el('ul', { class: 'build-logs-list' });
const ul = el("ul", { class: "build-logs-list" });
for (const h of rows) {
const li = el('li', { class: 'build-logs-item', 'data-log-id': String(h.id) });
const li = el("li", {
class: "build-logs-item",
"data-log-id": String(h.id),
});
const live = !h.status;
const ok = h.status === 'ok';
const statusClass = live ? 'hive-pill-sm badge-running' : ok ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail';
const statusLabel = live ? 'live' : ok ? 'ok' : 'fail';
const age = h.finished_at ? fmtTs(h.finished_at) : (live ? '' : fmtTs(h.started_at));
const runtime = h.runtime_secs != null
? el('span', { class: 'build-logs-runtime meta' }, fmtDuration(Math.max(0, h.runtime_secs)))
const ok = h.status === "ok";
const statusClass = live
? "hive-pill-sm badge-running"
: ok
? "hive-pill-sm badge-ok"
: "hive-pill-sm badge-fail";
const statusLabel = live ? "live" : ok ? "ok" : "fail";
const age = h.finished_at
? fmtTs(h.finished_at)
: live
? el('span', { class: 'build-logs-runtime meta build-logs-live-dur' }, '…')
: el('span', { class: 'build-logs-runtime meta' }, '');
? ""
: fmtTs(h.started_at);
const runtime =
h.runtime_secs != null
? el(
"span",
{ class: "build-logs-runtime meta" },
fmtDuration(Math.max(0, h.runtime_secs)),
)
: live
? el(
"span",
{ class: "build-logs-runtime meta build-logs-live-dur" },
"…",
)
: el("span", { class: "build-logs-runtime meta" }, "");
let durTimer = null;
if (live && h.started_at) {
@ -378,78 +492,106 @@ async function fetchBuild() {
durTimer = setInterval(updateDur, 1000);
}
const rowBtn = el('button', {
type: 'button',
class: 'build-logs-row-btn',
'aria-expanded': 'false',
},
el('span', { class: statusClass }, statusLabel),
el('span', { class: 'build-logs-agent' }, h.agent),
const rowBtn = el(
"button",
{
type: "button",
class: "build-logs-row-btn",
"aria-expanded": "false",
},
el("span", { class: statusClass }, statusLabel),
el("span", { class: "build-logs-agent" }, h.agent),
runtime,
el('span', { class: 'build-logs-kind' }, h.kind),
el('span', { class: 'build-logs-age meta' }, age),
el('span', { class: 'build-logs-cmdline meta' }, h.cmdline),
el("span", { class: "build-logs-kind" }, h.kind),
el("span", { class: "build-logs-age meta" }, age),
el("span", { class: "build-logs-cmdline meta" }, h.cmdline),
);
const detail = el('div', { class: 'build-logs-detail' });
const detail = el("div", { class: "build-logs-detail" });
detail.hidden = true;
let streamEs = null;
rowBtn.addEventListener('click', async () => {
const expanded = rowBtn.getAttribute('aria-expanded') === 'true';
rowBtn.setAttribute('aria-expanded', String(!expanded));
rowBtn.addEventListener("click", async () => {
const expanded = rowBtn.getAttribute("aria-expanded") === "true";
rowBtn.setAttribute("aria-expanded", String(!expanded));
detail.hidden = expanded;
if (expanded) {
if (streamEs) { streamEs.close(); streamEs = null; }
if (streamEs) {
streamEs.close();
streamEs = null;
}
return;
}
if (detail.dataset.loaded) return;
detail.replaceChildren();
detail.append(
el('a', {
href: '/api/build-logs/id/' + h.id + '/raw',
download: 'build-log-' + h.id + '.txt',
class: 'build-logs-dl',
}, '↓ download raw'),
el(
"a",
{
href: "/api/build-logs/id/" + h.id + "/raw",
download: "build-log-" + h.id + ".txt",
class: "build-logs-dl",
},
"↓ download raw",
),
);
if (live) {
const pre = el('pre', { class: 'build-logs-output build-logs-live' }, '');
const badge = el('span', { class: 'build-logs-live-badge hive-pill-sm badge-running' }, 'live');
const pre = el(
"pre",
{ class: "build-logs-output build-logs-live" },
"",
);
const badge = el(
"span",
{ class: "build-logs-live-badge hive-pill-sm badge-running" },
"live",
);
detail.append(badge, pre);
streamEs = openBuildLogStream(h.id, pre, {
onDone: (status) => {
if (durTimer) { clearInterval(durTimer); durTimer = null; }
if (durTimer) {
clearInterval(durTimer);
durTimer = null;
}
if (h.started_at) {
const elapsed = Math.floor(Date.now() / 1000) - h.started_at;
runtime.textContent = fmtDuration(Math.max(0, elapsed));
}
badge.className = status === 'ok' ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail';
badge.className =
status === "ok"
? "hive-pill-sm badge-ok"
: "hive-pill-sm badge-fail";
badge.textContent = status;
streamEs = null;
detail.dataset.loaded = '1';
detail.dataset.loaded = "1";
},
onError: () => {
if (durTimer) { clearInterval(durTimer); durTimer = null; }
badge.textContent = 'stream error';
badge.className = 'hive-pill-sm badge-fail';
if (durTimer) {
clearInterval(durTimer);
durTimer = null;
}
badge.textContent = "stream error";
badge.className = "hive-pill-sm badge-fail";
streamEs = null;
},
});
} else {
const pre = el('pre', { class: 'build-logs-output' }, 'fetching…');
const pre = el("pre", { class: "build-logs-output" }, "fetching…");
detail.append(pre);
try {
const r2 = await fetch('/api/build-logs/id/' + h.id);
const r2 = await fetch("/api/build-logs/id/" + h.id);
if (!r2.ok) {
pre.textContent = 'error ' + r2.status;
pre.textContent = "error " + r2.status;
} else {
const full = await r2.json();
const out = [full.stdout, full.stderr].filter(Boolean).join('\n--- stderr ---\n');
pre.textContent = out || '(empty)';
const out = [full.stdout, full.stderr]
.filter(Boolean)
.join("\n--- stderr ---\n");
pre.textContent = out || "(empty)";
}
detail.dataset.loaded = '1';
detail.dataset.loaded = "1";
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
pre.textContent = "fetch failed: " + err;
}
}
});
@ -458,24 +600,24 @@ async function fetchBuild() {
}
buildList.append(ul);
// Deep-link: ?id=N auto-expands the target row.
const deepId = new URLSearchParams(location.search).get('id');
const deepId = new URLSearchParams(location.search).get("id");
if (deepId) {
const target = ul.querySelector('[data-log-id="' + deepId + '"]');
if (target) {
const btn = target.querySelector('.build-logs-row-btn');
const btn = target.querySelector(".build-logs-row-btn");
if (btn) {
btn.click();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
}
} catch (err) {
buildList.replaceChildren();
buildList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
buildList.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild);
if (buildRefresh) buildRefresh.addEventListener("click", fetchBuild);
// ─── SSE handlers ─────────────────────────────────────────────────────────────
let buildRefreshTimer = null;
@ -488,7 +630,7 @@ const SSE_HANDLERS = {
jobqGraphToken += 1;
renderRebuildQueue();
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === 'buildlogs') {
if (buildTabs && buildTabs.active() === "buildlogs") {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
buildRefreshTimer = setTimeout(fetchBuild, 2000);
}
@ -506,7 +648,7 @@ const SSE_HANDLERS = {
// ─── boot ─────────────────────────────────────────────────────────────────────
async function refreshState() {
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json());
} catch {
// best-effort
@ -521,15 +663,19 @@ async function init() {
// fetch needed here, unlike meta inputs below.
mountRebuildQueue();
buildTabs = document.getElementById('builds-tabbar').configure({
buildTabs = document.getElementById("builds-tabbar").configure({
tabs: [
{ id: 'rebuild', label: 'R3BU1LD QU3U3', badgeId: 'builds-tab-count-rebuild' },
{ id: 'meta', label: 'M3T4 1NPUTS' },
{ id: 'buildlogs', label: 'BUILD L0GS' },
{
id: "rebuild",
label: "R3BU1LD QU3U3",
badgeId: "builds-tab-count-rebuild",
},
{ id: "meta", label: "M3T4 1NPUTS" },
{ id: "buildlogs", label: "BUILD L0GS" },
],
defaultId: 'rebuild',
defaultId: "rebuild",
onShow: (id) => {
if (id === 'buildlogs' && !buildLogsLoaded) {
if (id === "buildlogs" && !buildLogsLoaded) {
buildLogsLoaded = true;
fetchBuild();
}
@ -544,12 +690,16 @@ async function init() {
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream(
'/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running',
"/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running",
);
if (es) {
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind];
if (h) h(ev);
};

View file

@ -10,16 +10,17 @@
// live-mutation paths call an injected `onCountsChanged` callback the entry
// registers once via `initCall`.
import { $, form, appendLinkified } from './common.js';
import { el } from '@hive/shared/dom.js';
import { epochSec, fmtAgo, fmtDuration } from './util.js';
import { $, form, appendLinkified } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { epochSec, fmtAgo, fmtDuration } from "./util.js";
// Registered by the dashboard entry at boot; defaults to a no-op so the
// module is safe to call before wiring.
let onCountsChanged = () => {};
export function initCall(opts = {}) {
if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged;
if (typeof opts.onCountsChanged === "function")
onCountsChanged = opts.onCountsChanged;
}
// ─── operator inbox — unread agent→operator messages ────────────
@ -31,50 +32,66 @@ export function initCall(opts = {}) {
// Count folds into the Y3R C4LL pill + browser-title prefix.
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
export function operatorInboxCount() { return operatorInbox.length; }
export function operatorInboxCount() {
return operatorInbox.length;
}
export async function refreshOperatorInbox() {
try {
const r = await fetch('/api/operator-inbox');
const r = await fetch("/api/operator-inbox");
if (r.ok) {
const data = await r.json();
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
}
} catch { /* keep prior list on transient failure */ }
} catch {
/* keep prior list on transient failure */
}
renderOperatorInbox();
onCountsChanged();
}
function renderOperatorInbox() {
const root = $('operator-inbox-section');
const root = $("operator-inbox-section");
if (!root) return;
root.replaceChildren();
if (!operatorInbox.length) {
root.append(el('p', { class: 'meta' }, 'no unread messages'));
root.append(el("p", { class: "meta" }, "no unread messages"));
return;
}
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
`✓ mark all read (${operatorInbox.length})`);
mark.addEventListener('click', markOperatorInboxRead);
root.append(el('div', { class: 'inbox-toolbar' }, mark));
const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'inbox' });
const mark = el(
"button",
{ type: "button", class: "btn", id: "op-inbox-mark-read" },
`✓ mark all read (${operatorInbox.length})`,
);
mark.addEventListener("click", markOperatorInboxRead);
root.append(el("div", { class: "inbox-toolbar" }, mark));
const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19);
const ul = el("ul", { class: "inbox" });
for (const m of operatorInbox) {
const body = el('span', { class: 'msg-body' });
const body = el("span", { class: "msg-body" });
appendLinkified(body, m.body, m.file_refs);
ul.append(el('li', {},
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
body,
));
ul.append(
el(
"li",
{},
el("span", { class: "msg-ts" }, fmt(m.at)),
" ",
el("span", { class: "msg-from" }, m.from),
" ",
el("span", { class: "msg-sep" }, "→ "),
body,
),
);
}
root.append(ul);
}
async function markOperatorInboxRead() {
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
catch { /* best-effort; the next refresh reconciles */ }
try {
await fetch("/api/agent/operator/mark-all-read", { method: "POST" });
} catch {
/* best-effort; the next refresh reconciles */
}
operatorInbox = [];
renderOperatorInbox();
onCountsChanged();
@ -86,7 +103,10 @@ async function markOperatorInboxRead() {
export function operatorInboxAppendFromEvent(ev) {
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
operatorInbox.unshift({
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
id: ev.id,
from: ev.from,
body: ev.body,
at: ev.at,
file_refs: ev.file_refs || [],
});
if (operatorInbox.length > 100) operatorInbox.length = 100;
@ -95,14 +115,16 @@ export function operatorInboxAppendFromEvent(ev) {
}
// ─── approvals — the operator config-change / spawn approval queue ────────
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
// Derived approval state — cold-loaded from /api/state, then mutated
// live by `approval_added` / `approval_resolved` dashboard events.
// `pending` is the open queue (newest-first); `history` is the last
// 30 resolved rows.
const APPROVAL_HISTORY_LIMIT = 30;
const approvalsState = { pending: [], history: [] };
export function activeApprovalCount() { return approvalsState.pending.length; }
export function activeApprovalCount() {
return approvalsState.pending.length;
}
export function syncApprovalsFromSnapshot(s) {
approvalsState.pending = (s.approvals || []).slice();
approvalsState.history = (s.approval_history || []).slice();
@ -122,8 +144,8 @@ export function applyApprovalAdded(ev) {
// approval was queued just now, so client-now is accurate — and
// consistent with how fmtAgo compares everything to client-now.
// A later /api/state cold-load swaps in the server value.
requested_at: ev.requested_at != null
? ev.requested_at : Math.floor(Date.now() / 1000),
requested_at:
ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000),
};
if (existing >= 0) approvalsState.pending[existing] = row;
else approvalsState.pending.push(row);
@ -154,7 +176,7 @@ export function applyApprovalResolved(ev) {
renderApprovals();
}
export function renderApprovals() {
const root = $('approvals-section');
const root = $("approvals-section");
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
// events route through here on every page that loads the bundle.
@ -163,42 +185,42 @@ export function renderApprovals() {
const pending = approvalsState.pending;
const history = approvalsState.history;
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
const tabs = el('div', { class: 'approval-tabs' });
const active = localStorage.getItem(APPROVAL_TAB_KEY) || "pending";
const tabs = el("div", { class: "approval-tabs" });
const pendingTab = el(
'button',
"button",
{
type: 'button',
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
type: "button",
class: "approval-tab" + (active === "pending" ? " active" : ""),
},
`pending · ${pending.length}`,
);
const historyTab = el(
'button',
"button",
{
type: 'button',
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
type: "button",
class: "approval-tab" + (active === "history" ? " active" : ""),
},
`history · ${history.length}`,
);
pendingTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
pendingTab.addEventListener("click", () => {
localStorage.setItem(APPROVAL_TAB_KEY, "pending");
renderApprovals();
});
historyTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
historyTab.addEventListener("click", () => {
localStorage.setItem(APPROVAL_TAB_KEY, "history");
renderApprovals();
});
tabs.append(pendingTab, historyTab);
root.append(tabs);
if (active === 'history') {
if (active === "history") {
renderApprovalHistory(root, history);
return;
}
if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'queue empty'));
root.append(el("p", { class: "empty" }, "queue empty"));
return;
}
// forge link base — only when the hive-forge container is up.
@ -208,81 +230,143 @@ export function renderApprovals() {
// below already gates on forgeBase being truthy.
const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null;
const ul = el('ul', { class: 'approvals' });
const ul = el("ul", { class: "approvals" });
for (const a of pending) {
const isInit = a.kind === 'init_config';
const isMergePr = a.kind === 'merge_config_pr';
const isUpdateMeta = a.kind === 'update_meta_inputs';
const isSchedule = a.kind === 'schedule_prompt';
const li = el('li', { class: 'approval-card' });
const isInit = a.kind === "init_config";
const isMergePr = a.kind === "merge_config_pr";
const isUpdateMeta = a.kind === "update_meta_inputs";
const isSchedule = a.kind === "schedule_prompt";
const li = el("li", { class: "approval-card" });
// ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' },
el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'),
el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent),
el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') },
isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'),
const head = el(
"div",
{ class: "approval-head" },
el(
"span",
{ class: "glyph" },
isMergePr ? "⇒" : isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⊕",
),
el("span", { class: "id" }, "#" + a.id),
el("span", { class: "agent" }, a.agent),
el(
"span",
{
class:
"kind" +
(isMergePr || isUpdateMeta || isSchedule ? "" : " kind-spawn"),
},
isMergePr
? "merge-pr"
: isUpdateMeta
? "meta-update"
: isSchedule
? "schedule"
: isInit
? "init"
: "spawn",
),
);
if (isMergePr && a.sha_short) head.append(el('code', {}, a.sha_short));
if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short));
// When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui.md::Approval card).
if (a.requested_at != null) {
const requestedSec = epochSec(a.requested_at);
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec));
head.append(el('span', {
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
title: 'requested ' + new Date(a.requested_at).toLocaleString(),
'data-requested-at': String(requestedSec),
}, 'requested ' + fmtAgo(a.requested_at)));
head.append(
el(
"span",
{
class: "approval-ts" + (ageSec >= 3600 ? " stale" : ""),
title: "requested " + new Date(a.requested_at).toLocaleString(),
"data-requested-at": String(requestedSec),
},
"requested " + fmtAgo(a.requested_at),
),
);
}
li.append(head);
// ── what-changed body ────────────────────────────────────────
const body = el('div', { class: 'approval-body' });
const body = el("div", { class: "approval-body" });
if (a.description) {
body.append(el('div', { class: 'approval-description' }, a.description));
body.append(el("div", { class: "approval-description" }, a.description));
}
if (isMergePr) {
// PR-based config deploy: link to the reviewed PR on the forge.
// The config diff lives on the forge PR itself.
const drill = el('div', { class: 'drill-ins' });
const drill = el("div", { class: "drill-ins" });
if (forgeBase && a.pr_number != null) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
title: 'review this config PR on the hive forge',
}, '↳ review PR on forge ↗'));
drill.append(
el(
"a",
{
class: "panel-trigger",
target: "_blank",
rel: "noopener",
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
title: "review this config PR on the hive forge",
},
"↳ review PR on forge ↗",
),
);
}
body.append(drill);
} else if (isUpdateMeta) {
let inputs;
try { inputs = JSON.parse(a.commit_ref || '[]'); } catch (_) { inputs = []; }
body.append(el('span', { class: 'meta' },
inputs.length
? 'bump flake inputs: ' + inputs.join(', ')
: 'bump all flake inputs'));
try {
inputs = JSON.parse(a.commit_ref || "[]");
} catch (_) {
inputs = [];
}
body.append(
el(
"span",
{ class: "meta" },
inputs.length
? "bump flake inputs: " + inputs.join(", ")
: "bump all flake inputs",
),
);
} else if (isSchedule) {
let payload;
try { payload = JSON.parse(a.commit_ref || '{}'); } catch (_) { payload = {}; }
const targets = (payload.targets || []).join(', ');
try {
payload = JSON.parse(a.commit_ref || "{}");
} catch (_) {
payload = {};
}
const targets = (payload.targets || []).join(", ");
const firstFire = payload.first_fire_at_unix
? new Date(payload.first_fire_at_unix * 1000).toLocaleString()
: '?';
: "?";
const cadence = payload.interval_seconds
? ' · ↻ every ' + fmtDuration(payload.interval_seconds)
: ' · one-shot';
body.append(el('div', { class: 'meta' }, '→ ' + targets + ' · first: ' + firstFire + cadence));
? " · ↻ every " + fmtDuration(payload.interval_seconds)
: " · one-shot";
body.append(
el(
"div",
{ class: "meta" },
"→ " + targets + " · first: " + firstFire + cadence,
),
);
if (payload.body) {
const excerpt = payload.body.length > 80 ? payload.body.slice(0, 80) + '…' : payload.body;
body.append(el('div', { class: 'approval-description' }, excerpt));
const excerpt =
payload.body.length > 80
? payload.body.slice(0, 80) + "…"
: payload.body;
body.append(el("div", { class: "approval-description" }, excerpt));
}
} else {
body.append(el('span', { class: 'meta' },
isInit
? 'scaffold proposed config repo — submitting agent customises agent.nix before spawn'
: 'new sub-agent — container will be created on approve'));
body.append(
el(
"span",
{ class: "meta" },
isInit
? "scaffold proposed config repo — submitting agent customises agent.nix before spawn"
: "new sub-agent — container will be created on approve",
),
);
}
li.append(body);
@ -291,16 +375,32 @@ export function renderApprovals() {
// handler stashes it into a hidden `note` input that rides along
// on the POST and is surfaced to the submitting agent via
// HelperEvent::ApprovalResolved { note }.
const denyForm = el('form', {
method: 'POST', action: '/api/deny/' + a.id,
class: 'inline', 'data-async': '', 'data-no-refresh': '',
'data-prompt': 'reason for denying (optional, sent to submitter):',
const denyForm = el("form", {
method: "POST",
action: "/api/deny/" + a.id,
class: "inline",
"data-async": "",
"data-no-refresh": "",
"data-prompt": "reason for denying (optional, sent to submitter):",
});
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
li.append(el('div', { class: 'approval-actions' },
form('/api/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
denyForm,
));
denyForm.append(
el("button", { type: "submit", class: "btn btn-deny" }, "DENY"),
);
li.append(
el(
"div",
{ class: "approval-actions" },
form(
"/api/approve/" + a.id,
"btn-approve",
"◆ APPR0VE",
null,
{},
{ noRefresh: true },
),
denyForm,
),
);
ul.append(li);
}
@ -309,31 +409,52 @@ export function renderApprovals() {
function renderApprovalHistory(root, history) {
if (!history.length) {
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
root.append(el("p", { class: "empty" }, "no resolved approvals yet"));
return;
}
const ul = el('ul', { class: 'approvals approvals-history' });
const ul = el("ul", { class: "approvals approvals-history" });
for (const a of history) {
const li = el('li');
const row = el('div', { class: 'row' });
const glyph = a.status === 'approved' ? '✓'
: a.status === 'denied' ? '✗'
: a.status === 'cancelled' ? '⊘'
: '⚠';
const li = el("li");
const row = el("div", { class: "row" });
const glyph =
a.status === "approved"
? "✓"
: a.status === "denied"
? "✗"
: a.status === "cancelled"
? "⊘"
: "⚠";
row.append(
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
el("span", { class: "glyph glyph-" + a.status }, glyph),
" ",
el("span", { class: "id" }, "#" + a.id),
" ",
el("span", { class: "agent" }, a.agent),
" ",
el(
"span",
{ class: "kind" },
a.kind === "merge_config_pr"
? "merge-pr"
: a.kind === "update_meta_inputs"
? "meta-update"
: a.kind === "schedule_prompt"
? "schedule"
: a.kind === "init_config"
? "init"
: "spawn",
),
" ",
);
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
if (a.sha_short) row.append(el("code", {}, a.sha_short), " ");
row.append(
el('span', { class: 'status status-' + a.status }, a.status), ' ',
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
el("span", { class: "status status-" + a.status }, a.status),
" ",
el("span", { class: "msg-ts" }, fmtAgo(a.resolved_at)),
);
li.append(row);
if (a.note) {
li.append(el('div', { class: 'history-note' }, a.note));
li.append(el("div", { class: "history-note" }, a.note));
}
ul.append(li);
}

View file

@ -13,7 +13,8 @@
Element-level rules shared across all three pages (index, flow,
logs). These must not reference page-specific chrome classes. */
h1, h2 {
h1,
h2 {
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
@ -26,9 +27,17 @@ h1, h2 {
white-space: nowrap;
margin-bottom: 0.5em;
}
ul { list-style: none; padding-left: 0; }
li { padding: 0.5em 0; }
.glyph { color: var(--purple); margin-right: 0.5em; }
ul {
list-style: none;
padding-left: 0;
}
li {
padding: 0.5em 0;
}
.glyph {
color: var(--purple);
margin-right: 0.5em;
}
a {
color: var(--cyan);
text-decoration: none;
@ -51,10 +60,25 @@ code {
/* shared inline labels
.meta / .id / .agent / .empty appear in message rows, build-log
toolbars, and side-panel content rendered by common.js. */
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
.empty { color: var(--muted); font-style: italic; }
.meta {
color: var(--muted);
font-size: 0.85em;
margin-left: 0.4em;
}
.id {
color: var(--pink);
font-weight: bold;
margin-right: 0.4em;
}
.agent {
color: var(--amber);
font-weight: bold;
margin-right: 0.6em;
}
.empty {
color: var(--muted);
font-style: italic;
}
/* status badges
Semantic colour variants only shape comes from `hive-pill-sm`
@ -63,48 +87,69 @@ code {
(swarm.js/core.js/builds.js). Used on container rows and build-log
rows. */
.badge-warn {
color: var(--amber); border-color: var(--amber);
color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
}
.badge-rate-limited {
color: var(--red); border-color: var(--red);
color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent);
}
.badge-muted {
color: var(--muted); border-color: var(--purple-dim);
color: var(--muted);
border-color: var(--purple-dim);
background: color-mix(in srgb, var(--muted) 8%, transparent);
}
.badge-loose-ends {
color: var(--purple); border-color: var(--purple);
color: var(--purple);
border-color: var(--purple);
text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 40%, transparent);
}
/* Paused agent: turn loop parked, container still up. Clickable (btn-inline):
clicking sends POST /api/resume/{name} so the badge doubles as a resume button. */
.badge-paused {
color: var(--yellow); border-color: var(--yellow);
color: var(--yellow);
border-color: var(--yellow);
background: color-mix(in srgb, var(--yellow) 10%, transparent);
}
/* Active Claude model badge on dashboard container rows. */
.badge-model {
color: var(--blue); border-color: var(--blue);
color: var(--blue);
border-color: var(--blue);
opacity: 0.8;
}
/* Context-window usage badges on dashboard container rows. */
.badge-ctx-ok {
color: var(--green); border-color: var(--green);
color: var(--green);
border-color: var(--green);
opacity: 0.85;
}
.badge-ctx-caution {
color: var(--amber); border-color: var(--amber);
color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
}
.badge-ctx-warn {
color: var(--red); border-color: var(--red);
color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent);
}
.badge-ok { background: color-mix(in srgb, var(--green) 12%, transparent); color: var(--green); border-color: var(--green); }
.badge-fail { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); border-color: var(--red); }
.badge-running { background: color-mix(in srgb, var(--amber) 12%, transparent); color: var(--amber); border-color: var(--amber); }
.badge-ok {
background: color-mix(in srgb, var(--green) 12%, transparent);
color: var(--green);
border-color: var(--green);
}
.badge-fail {
background: color-mix(in srgb, var(--red) 12%, transparent);
color: var(--red);
border-color: var(--red);
}
.badge-running {
background: color-mix(in srgb, var(--amber) 12%, transparent);
color: var(--amber);
border-color: var(--amber);
}
/* buttons
.btn base + semantic colour/size modifiers. Logs page uses
@ -145,26 +190,89 @@ code {
text-shadow: none;
box-shadow: none;
}
.btn-approve { color: var(--green); border-color: var(--green); }
.btn-deny { color: var(--red); border-color: var(--red); }
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-approve {
color: var(--green);
border-color: var(--green);
}
.btn-deny {
color: var(--red);
border-color: var(--red);
}
.btn-destroy {
color: var(--red);
border-color: var(--red);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-rebuild {
color: var(--amber);
border-color: var(--amber);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-restart {
color: var(--cyan);
border-color: var(--cyan);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-stop {
color: var(--pink);
border-color: var(--pink);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-start {
color: var(--green);
border-color: var(--green);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
/* Same yellow as .badge-paused (the paused-state pill) pause enters
that state, so the trigger and the resulting badge read as one
colour, not two unrelated ones. Resume reads as green like
.btn-start: both are "go" actions, back to the turn loop running. */
.btn-pause { color: var(--yellow); border-color: var(--yellow); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-resume { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-pause {
color: var(--yellow);
border-color: var(--yellow);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-resume {
color: var(--green);
border-color: var(--green);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
/* M0V3 affordance (selection bar) mauve reads as "structural
change" rather than the destructive red / amber chrome of
destroy / rebuild. See docs/web-ui.md::Selection bar. */
.btn-move { color: var(--purple); border-color: var(--purple); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
.btn-fire-now { color: var(--purple); border-color: var(--purple); }
.btn-move {
color: var(--purple);
border-color: var(--purple);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-talk {
color: var(--cyan);
border-color: var(--cyan);
}
.btn-spawn {
color: var(--amber);
border-color: var(--amber);
}
.btn-fire-now {
color: var(--purple);
border-color: var(--purple);
}
/* Post-fire flash: report renders directly on the button for ~1.5s
so the operator sees ok/failed/missing/consumed counts inline
without a modal. Green when at least one ok; muted otherwise. */
@ -177,13 +285,24 @@ code {
/* Inline edit button on each schedule row. Yellow reads as a
parallel destructive-adjacent action (edit changes state, but
isn't deletion). */
.btn-edit-schedule { color: var(--yellow); border-color: var(--yellow); }
.btn-edit-schedule {
color: var(--yellow);
border-color: var(--yellow);
}
/* file-preview / diff panel (common.js Panel)
Side-panel content rendered by openFilePanel / buildTabbedPreview
in common.js; all three pages load the Panel singleton. */
.diff-panel { display: flex; flex-direction: column; gap: 0.6em; }
.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; }
.diff-panel {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.diff-base-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.diff-base-tab {
background: transparent;
border: 1px solid var(--border);
@ -193,14 +312,18 @@ code {
padding: 0.2em 0.7em;
cursor: pointer;
}
.diff-base-tab:hover { color: var(--fg); }
.diff-base-tab:hover {
color: var(--fg);
}
.diff-base-tab.active {
color: var(--purple);
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 8%, transparent);
}
/* Image / tabbed file preview */
.preview-host { margin-top: 0.5em; }
.preview-host {
margin-top: 0.5em;
}
.img-preview {
display: block;
max-width: 100%;
@ -209,7 +332,8 @@ code {
border: 1px solid var(--border);
border-radius: 4px;
/* checkerboard so transparent regions of the image read clearly */
background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%) 50% / 18px 18px;
background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%)
50% / 18px 18px;
}
/* Path linkification agents drop pointer strings into messages;
@ -219,7 +343,9 @@ code {
text-decoration: underline dotted;
cursor: pointer;
}
.path-link:hover { color: var(--amber); }
.path-link:hover {
color: var(--amber);
}
/* File-preview body — rendered inside the side panel. */
.path-preview-body {
background: var(--bg);
@ -255,11 +381,24 @@ code {
gap: 0.5em;
align-items: baseline;
}
.inbox li:last-child { border-bottom: 0; }
.inbox .msg-ts { color: var(--muted); font-size: 0.85em; }
.inbox .msg-from { color: var(--amber); }
.inbox .msg-sep { color: var(--muted); }
.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
.inbox li:last-child {
border-bottom: 0;
}
.inbox .msg-ts {
color: var(--muted);
font-size: 0.85em;
}
.inbox .msg-from {
color: var(--amber);
}
.inbox .msg-sep {
color: var(--muted);
}
.inbox .msg-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
}
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
msgrow / msg-* rules below power both the dashboard CALL tab and
the /flow.html full-page terminal. */
@ -275,8 +414,12 @@ code {
flex: 1 1 100%;
min-width: 0;
}
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
.live .msgrow.delivered .msg-arrow { color: var(--green); }
.live .msgrow.sent .msg-arrow {
color: var(--cyan);
}
.live .msgrow.delivered .msg-arrow {
color: var(--green);
}
/* Reply-thread rendering: indented border-left + muted reply tag. */
.live .msgrow.msg-reply {
padding-left: 1.2em;
@ -294,18 +437,41 @@ code {
text-shadow: none;
font-weight: normal;
}
.msg-reply-tag a:hover { color: var(--fg); }
.msg-reply-tag a:hover {
color: var(--fg);
}
@keyframes msg-highlight-fade {
from { background: color-mix(in srgb, var(--purple) 18%, transparent); }
to { background: transparent; }
from {
background: color-mix(in srgb, var(--purple) 18%, transparent);
}
to {
background: transparent;
}
}
.msg-highlight {
animation: msg-highlight-fade 1.5s ease-out forwards;
}
.msg-ts {
color: var(--muted);
font-size: 0.85em;
}
.msg-arrow {
font-weight: bold;
}
.msg-from {
color: var(--amber);
}
.msg-sep {
color: var(--muted);
}
.msg-to {
color: var(--pink);
}
.msg-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
}
.msg-highlight { animation: msg-highlight-fade 1.5s ease-out forwards; }
.msg-ts { color: var(--muted); font-size: 0.85em; }
.msg-arrow { font-weight: bold; }
.msg-from { color: var(--amber); }
.msg-sep { color: var(--muted); }
.msg-to { color: var(--pink); }
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
/* operator compose box
Sits inside `.terminal-wrap` on both /flow.html and the dashboard
@ -339,8 +505,12 @@ code {
max-height: 8em;
padding: 0;
}
.op-compose-input:focus { outline: none; }
.op-compose-input::placeholder { color: var(--muted); }
.op-compose-input:focus {
outline: none;
}
.op-compose-input::placeholder {
color: var(--muted);
}
.op-compose-suggest {
position: absolute;
left: 3em;
@ -387,15 +557,29 @@ code {
body.side-panel-resizing {
user-select: none;
}
body.side-panel-resizing * { cursor: ew-resize !important; }
hive-side-panel .md { color: var(--fg); line-height: 1.5; }
hive-side-panel .md > :first-child { margin-top: 0; }
hive-side-panel .md > :last-child { margin-bottom: 0; }
hive-side-panel .md p { margin: 0.5em 0; }
body.side-panel-resizing * {
cursor: ew-resize !important;
}
hive-side-panel .md {
color: var(--fg);
line-height: 1.5;
}
hive-side-panel .md > :first-child {
margin-top: 0;
}
hive-side-panel .md > :last-child {
margin-bottom: 0;
}
hive-side-panel .md p {
margin: 0.5em 0;
}
hive-side-panel .md h1,
hive-side-panel .md h2,
hive-side-panel .md h3,
hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
hive-side-panel .md h4 {
color: var(--purple);
margin: 0.9em 0 0.4em;
}
hive-side-panel .md code {
background: var(--bg);
border: 1px solid var(--border);
@ -410,17 +594,29 @@ hive-side-panel .md pre {
overflow-x: auto;
margin: 0.5em 0;
}
hive-side-panel .md pre code { background: none; border: none; padding: 0; }
hive-side-panel .md a { color: var(--cyan); }
hive-side-panel .md pre code {
background: none;
border: none;
padding: 0;
}
hive-side-panel .md a {
color: var(--cyan);
}
hive-side-panel .md ul,
hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; }
hive-side-panel .md ol {
margin: 0.4em 0;
padding-left: 1.5em;
}
hive-side-panel .md blockquote {
border-left: 3px solid var(--purple-dim);
padding-left: 0.8em;
margin: 0.4em 0;
color: var(--muted);
}
hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; }
hive-side-panel .md table {
border-collapse: collapse;
margin: 0.5em 0;
}
hive-side-panel .md th,
hive-side-panel .md td {
border: 1px solid var(--border);
@ -435,7 +631,9 @@ hive-side-panel .md td {
with no negative-margin breakout hacks. Only matches on pages that opt
in by adding the wrapper (dashboard.html, index.html); FL0W / L0GS /
ST4TS are full-bleed with their own `.<page>-main` padding. */
.page-content { padding: 0 1.5em; }
.page-content {
padding: 0 1.5em;
}
/* sticky top region
One sticky container per page holding the warning banner above the
@ -458,7 +656,9 @@ hive-side-panel .md td {
the full width edge-to-edge; the dashboard + H0M3 are 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] { display: none; }
.server-warnings[hidden] {
display: none;
}
.server-warn {
text-align: center;
padding: 0.4em 1em;

View file

@ -3,35 +3,55 @@
// the OS-notification module, and the path-link / file-preview
// infrastructure for the side panel.
import { linkify as termLinkify } from '@hive/shared/terminal.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import
import '@hive/shared/hive-warn.js'; // registers <hive-warn> — side-effect import
import DOMPurify from 'dompurify';
import { linkify as termLinkify } from "@hive/shared/terminal.js";
import { el } from "@hive/shared/dom.js";
import "@hive/shared/side-panel.js"; // registers <hive-side-panel> — side-effect import
import "@hive/shared/hive-warn.js"; // registers <hive-warn> — side-effect import
import DOMPurify from "dompurify";
// ─── helpers ────────────────────────────────────────────────────────────
export const $ = (id) => document.getElementById(id);
export const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m`
: s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`;
export const fmtAgeSecs = (s) =>
s < 60
? `${s}s`
: s < 3600
? `${Math.floor(s / 60)}m`
: s < 86400
? `${Math.floor(s / 3600)}h`
: `${Math.floor(s / 86400)}d`;
export const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c])
);
export const esc = (s) =>
String(s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
const f = el('form', {
method: 'POST', action, class: 'inline', 'data-async': '',
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
export const form = (
action,
btnClass,
btnLabel,
confirmMsg,
extra = {},
opts = {},
) => {
const f = el("form", {
method: "POST",
action,
class: "inline",
"data-async": "",
...(confirmMsg ? { "data-confirm": confirmMsg } : {}),
// Endpoints whose mutation fires a DashboardEvent (and whose
// derived store applies it live) opt out of the post-submit
// /api/state refetch. See the async-form handler.
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}),
...(opts.noRefresh ? { "data-no-refresh": "" } : {}),
});
for (const [name, value] of Object.entries(extra)) {
f.append(el('input', { type: 'hidden', name, value }));
f.append(el("input", { type: "hidden", name, value }));
}
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
f.append(
el("button", { type: "submit", class: "btn " + btnClass }, btnLabel),
);
return f;
};
@ -55,8 +75,8 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()`
// drops the subscription (the worker closes the upstream when the last
// subscriber leaves).
const SHARED_WORKER_PATH = '/static/stream-worker.js';
const SHARED_WORKER_NAME = 'hyperhive-stream';
const SHARED_WORKER_PATH = "/static/stream-worker.js";
const SHARED_WORKER_NAME = "hyperhive-stream";
// One SharedWorker port per page, reused by all openStream calls on
// that page. Invalidated on `pagehide` so a bfcache restore picks up
@ -64,13 +84,16 @@ const SHARED_WORKER_NAME = 'hyperhive-stream';
// closed while this page was frozen.
let _sharedPort = null;
function makeSharedPort() {
if (typeof SharedWorker === 'undefined') return null;
if (typeof SharedWorker === "undefined") return null;
try {
const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME);
sw.port.start();
return sw.port;
} catch (err) {
console.warn('SharedWorker unavailable, falling back to direct EventSource:', err);
console.warn(
"SharedWorker unavailable, falling back to direct EventSource:",
err,
);
return null;
}
}
@ -87,19 +110,27 @@ function getSharedPort() {
const WORKER_DEAD_THRESHOLD_MS = 90_000;
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
let _lastWorkerActivityAt = 0;
function noteWorkerActivity() { _lastWorkerActivityAt = Date.now(); }
function noteWorkerActivity() {
_lastWorkerActivityAt = Date.now();
}
let _watchdogTimer = null;
function startWorkerWatchdog() {
if (_watchdogTimer != null) return;
_watchdogTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
if (
typeof document !== "undefined" &&
document.visibilityState !== "visible"
)
return;
if (!_activeSubs.size) return;
if (!_sharedPort) return;
const sinceLast = Date.now() - _lastWorkerActivityAt;
if (sinceLast < WORKER_DEAD_THRESHOLD_MS) return;
console.warn(
'hyperhive-stream worker silent for ' + Math.round(sinceLast / 1000)
+ 's, presumed dead — re-subscribing on a fresh port');
"hyperhive-stream worker silent for " +
Math.round(sinceLast / 1000) +
"s, presumed dead — re-subscribing on a fresh port",
);
rebindOnFreshPort();
}, WORKER_WATCHDOG_INTERVAL_MS);
}
@ -109,7 +140,9 @@ function rebindOnFreshPort() {
// is the source of truth for what we need to re-attach.
if (_sharedPort) {
for (const sub of _activeSubs.values()) {
try { _sharedPort.removeEventListener('message', sub.route); } catch {}
try {
_sharedPort.removeEventListener("message", sub.route);
} catch {}
}
}
_sharedPort = null;
@ -117,8 +150,10 @@ function rebindOnFreshPort() {
if (!port) return; // SharedWorker unsupported / unavailable — nothing to do
for (const [url, sub] of _activeSubs) {
sub.target.readyState = 0; // CONNECTING — worker will (re-)fire 'open'
port.addEventListener('message', sub.route);
try { port.postMessage({ kind: 'subscribe', url }); } catch {}
port.addEventListener("message", sub.route);
try {
port.postMessage({ kind: "subscribe", url });
} catch {}
}
// Reset the activity clock so the watchdog gives the fresh worker
// a full window to settle before re-triggering.
@ -143,32 +178,41 @@ function bindLifecycleOnce() {
if (_lifecycleBound) return;
_lifecycleBound = true;
startWorkerWatchdog();
window.addEventListener('pagehide', () => {
window.addEventListener("pagehide", () => {
if (!_sharedPort) return;
for (const url of _activeSubs.keys()) {
try { _sharedPort.postMessage({ kind: 'unsubscribe', url }); }
catch { /* port dead — worker side already cleaned up */ }
try {
_sharedPort.postMessage({ kind: "unsubscribe", url });
} catch {
/* port dead — worker side already cleaned up */
}
}
// Drop port routes too; the bfcache-restore path will re-add
// them on a fresh port. Leaving stale routes on a dead port
// would just keep a closure alive without cost, but cleaning
// up keeps the registry shape honest.
for (const sub of _activeSubs.values()) {
try { _sharedPort.removeEventListener('message', sub.route); }
catch { /* same */ }
try {
_sharedPort.removeEventListener("message", sub.route);
} catch {
/* same */
}
}
_sharedPort = null;
});
window.addEventListener('pageshow', (ev) => {
window.addEventListener("pageshow", (ev) => {
if (!ev.persisted) return; // cold load — openStream just bound listeners
if (!_activeSubs.size) return;
const port = getSharedPort();
if (!port) return; // SharedWorker really gone; fallback already in place
for (const [url, sub] of _activeSubs) {
sub.target.readyState = 0; // CONNECTING — the worker will fire 'open'
port.addEventListener('message', sub.route);
try { port.postMessage({ kind: 'subscribe', url }); }
catch { /* port dead immediately — skip */ }
port.addEventListener("message", sub.route);
try {
port.postMessage({ kind: "subscribe", url });
} catch {
/* port dead immediately — skip */
}
}
});
}
@ -191,10 +235,16 @@ export function openStream(url) {
close() {
const p = _sharedPort;
if (p) {
try { p.postMessage({ kind: 'unsubscribe', url }); }
catch { /* port dead */ }
try { p.removeEventListener('message', route); }
catch { /* same */ }
try {
p.postMessage({ kind: "unsubscribe", url });
} catch {
/* port dead */
}
try {
p.removeEventListener("message", route);
} catch {
/* same */
}
}
_activeSubs.delete(url);
},
@ -204,29 +254,38 @@ export function openStream(url) {
// before the URL filter, since heartbeat pings carry no URL.
noteWorkerActivity();
const m = e.data;
if (!m || m.kind === 'ping') return;
if (!m || m.kind === "ping") return;
if (m.url !== url) return;
if (m.kind === 'open') {
if (m.kind === "open") {
target.readyState = 1; // OPEN
if (target.onopen) {
try { target.onopen({ target }); }
catch (err) { console.error('openStream onopen threw', err); }
try {
target.onopen({ target });
} catch (err) {
console.error("openStream onopen threw", err);
}
}
} else if (m.kind === 'message') {
} else if (m.kind === "message") {
if (target.onmessage) {
try { target.onmessage({ data: m.data, target }); }
catch (err) { console.error('openStream onmessage threw', err); }
try {
target.onmessage({ data: m.data, target });
} catch (err) {
console.error("openStream onmessage threw", err);
}
}
} else if (m.kind === 'error') {
} else if (m.kind === "error") {
if (target.onerror) {
try { target.onerror({ target }); }
catch (err) { console.error('openStream onerror threw', err); }
try {
target.onerror({ target });
} catch (err) {
console.error("openStream onerror threw", err);
}
}
}
};
_activeSubs.set(url, { target, route });
port.addEventListener('message', route);
port.postMessage({ kind: 'subscribe', url });
port.addEventListener("message", route);
port.postMessage({ kind: "subscribe", url });
// Seed the activity clock so the watchdog has a baseline (would
// otherwise compare against 0 and trigger immediately).
noteWorkerActivity();
@ -244,33 +303,41 @@ export function openStream(url) {
// (re)connect, so a CONNECTING reconnect resets the <pre> to avoid doubling.
export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
let atBottom = true;
pre.addEventListener('scroll', () => {
pre.addEventListener("scroll", () => {
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
});
let stderrSeen = false;
const es = new EventSource('/api/build-logs/id/' + id + '/stream');
const es = new EventSource("/api/build-logs/id/" + id + "/stream");
es.onmessage = (e) => {
let frame;
try { frame = JSON.parse(e.data); } catch { return; }
try {
frame = JSON.parse(e.data);
} catch {
return;
}
if (frame.stdout_append) {
pre.textContent += frame.stdout_append;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
if (frame.stderr_append) {
if (!stderrSeen) { pre.textContent += '\n--- stderr ---\n'; stderrSeen = true; }
if (!stderrSeen) {
pre.textContent += "\n--- stderr ---\n";
stderrSeen = true;
}
pre.textContent += frame.stderr_append;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
if (frame.done) {
es.close();
if (onDone) onDone(frame.status || 'done');
if (onDone) onDone(frame.status || "done");
}
};
es.onerror = () => {
// CONNECTING = the browser is auto-reconnecting; the stream replays from
// the start, so clear the <pre> to avoid duplicated output and wait.
if (es.readyState === EventSource.CONNECTING) {
pre.textContent = ''; stderrSeen = false;
pre.textContent = "";
stderrSeen = false;
return;
}
es.close();
@ -295,7 +362,7 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
// slotted content via a plain `hive-side-panel .md …` tag-name selector
// (no compatibility class needed — the element's own tag name already
// uniquely identifies it in the light DOM).
export const sidePanel = document.createElement('hive-side-panel');
export const sidePanel = document.createElement("hive-side-panel");
document.body.append(sidePanel);
// ─── path linkification ─────────────────────────────────────────────────
@ -309,45 +376,54 @@ document.body.append(sidePanel);
// to translate it). Prefer `/agents/<name>/state/...` in agent
// outputs and the link will resolve.
async function fetchStateFile(path) {
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
const resp = await fetch("/api/state-file?path=" + encodeURIComponent(path));
const text = await resp.text();
if (!resp.ok) throw new Error(text || ('http ' + resp.status));
if (!resp.ok) throw new Error(text || "http " + resp.status);
return text;
}
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
// `renderRendered()` produces the rendered-tab node fresh on each
// switch; `plainText` backs the raw tab; `plainLabel` names it.
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
const tabs = el('div', { class: 'diff-base-tabs' });
const host = el('div', { class: 'preview-host' });
const tabs = el("div", { class: "diff-base-tabs" });
const host = el("div", { class: "preview-host" });
function show(mode) {
for (const b of tabs.children) {
b.classList.toggle('active', b.dataset.mode === mode);
b.classList.toggle("active", b.dataset.mode === mode);
}
host.replaceChildren(mode === 'plain'
? el('pre', { class: 'path-preview-body' }, plainText)
: renderRendered());
host.replaceChildren(
mode === "plain"
? el("pre", { class: "path-preview-body" }, plainText)
: renderRendered(),
);
}
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
const b = el('button',
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
b.addEventListener('click', () => show(mode));
for (const [mode, label] of [
["rendered", "rendered"],
["plain", plainLabel],
]) {
const b = el(
"button",
{ type: "button", class: "diff-base-tab", "data-mode": mode },
label,
);
b.addEventListener("click", () => show(mode));
tabs.append(b);
}
show('rendered');
return el('div', {}, tabs, host);
show("rendered");
return el("div", {}, tabs, host);
}
// Rendered <img> for an SVG, loaded via an <img> data: URI —
// <img>-loaded SVG runs in the browser's secure static mode (no
// scripts, no external fetches), so an untrusted SVG from an
// agent's state dir can't execute code in the dashboard.
function svgImage(text) {
const img = el('img', { class: 'img-preview', alt: 'SVG preview' });
img.addEventListener('error', () => {
img.replaceWith(el('div', { class: 'meta' },
'(could not render — see the source tab)'));
const img = el("img", { class: "img-preview", alt: "SVG preview" });
img.addEventListener("error", () => {
img.replaceWith(
el("div", { class: "meta" }, "(could not render — see the source tab)"),
);
});
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
img.src = "data:image/svg+xml," + encodeURIComponent(text);
return img;
}
// Marked-rendered markdown node (raw text fallback if `marked`
@ -357,15 +433,15 @@ function svgImage(text) {
// tags that `marked` itself no longer strips (v5+ dropped the built-in
// sanitizer).
function mdNode(text) {
const div = el('div', { class: 'md' });
if (window.marked && typeof window.marked.parse === 'function') {
const div = el("div", { class: "md" });
if (window.marked && typeof window.marked.parse === "function") {
window.marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = DOMPurify.sanitize(window.marked.parse(text));
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates away from the dashboard.
div.querySelectorAll('a[href]').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
div.querySelectorAll("a[href]").forEach((a) => {
a.target = "_blank";
a.rel = "noopener noreferrer";
});
} else {
div.textContent = text;
@ -381,38 +457,53 @@ const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
// render as an <img>; every other file stays raw text in a <pre>.
async function openFilePanel(path) {
if (RASTER_RE.test(path)) {
const img = el('img', { class: 'img-preview', alt: path });
img.addEventListener('error', () => {
img.replaceWith(el('pre', { class: 'path-preview-body' },
'(could not load image — it may be missing or over the preview size cap)'));
const img = el("img", { class: "img-preview", alt: path });
img.addEventListener("error", () => {
img.replaceWith(
el(
"pre",
{ class: "path-preview-body" },
"(could not load image — it may be missing or over the preview size cap)",
),
);
});
img.src = '/api/state-file?path=' + encodeURIComponent(path);
sidePanel.open('↳ ' + path, img);
img.src = "/api/state-file?path=" + encodeURIComponent(path);
sidePanel.open("↳ " + path, img);
return;
}
const isMd = /\.(md|markdown)$/i.test(path);
const isSvg = /\.svg$/i.test(path);
const view = el('div');
view.textContent = '(fetching…)';
sidePanel.open('↳ ' + path, view);
const view = el("div");
view.textContent = "(fetching…)";
sidePanel.open("↳ " + path, view);
try {
const text = await fetchStateFile(path);
if (isSvg) {
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
view.replaceChildren(
buildTabbedPreview(() => svgImage(text), text, "source"),
);
} else if (isMd) {
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
view.replaceChildren(
buildTabbedPreview(() => mdNode(text), text, "plain"),
);
} else {
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
view.replaceChildren(el("pre", { class: "path-preview-body" }, text));
}
} catch (e) {
view.textContent = 'error: ' + (e.message || e);
view.textContent = "error: " + (e.message || e);
}
}
export function makePathLink(path) {
const anchor = el('a', {
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
}, path);
anchor.addEventListener('click', (e) => {
const anchor = el(
"a",
{
href: "#",
class: "path-link",
title: "open " + path + " in panel",
},
path,
);
anchor.addEventListener("click", (e) => {
e.preventDefault();
openFilePanel(path);
});
@ -453,7 +544,11 @@ export function appendLinkified(parent, text, refs) {
for (const t of tokens) {
const idx = str.indexOf(t, i);
if (idx === -1) continue;
if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
if (
bestStart === -1 ||
idx < bestStart ||
(idx === bestStart && t.length > bestToken.length)
) {
bestStart = idx;
bestToken = t;
}
@ -479,55 +574,63 @@ export function appendLinkified(parent, text, refs) {
// localhost) — on other origins the API is unavailable and we hide
// the controls.
export const NOTIF = (() => {
const supported = typeof Notification !== 'undefined';
const MUTED_KEY = 'hyperhive.notify.muted';
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
const setMuted = (v) => v
? localStorage.setItem(MUTED_KEY, '1')
: localStorage.removeItem(MUTED_KEY);
const supported = typeof Notification !== "undefined";
const MUTED_KEY = "hyperhive.notify.muted";
const isMuted = () => localStorage.getItem(MUTED_KEY) === "1";
const setMuted = (v) =>
v
? localStorage.setItem(MUTED_KEY, "1")
: localStorage.removeItem(MUTED_KEY);
function renderControls() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
const status = $('notif-status');
const enable = $("notif-enable");
const mute = $("notif-mute");
const unmute = $("notif-unmute");
const status = $("notif-status");
if (!enable || !mute || !unmute || !status) return;
if (!supported) {
enable.hidden = mute.hidden = unmute.hidden = true;
status.hidden = false;
status.textContent = 'notifications unsupported in this browser';
status.textContent = "notifications unsupported in this browser";
return;
}
const perm = Notification.permission;
enable.hidden = perm === 'granted';
mute.hidden = perm !== 'granted' || isMuted();
unmute.hidden = perm !== 'granted' || !isMuted();
status.hidden = perm !== 'denied';
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
enable.hidden = perm === "granted";
mute.hidden = perm !== "granted" || isMuted();
unmute.hidden = perm !== "granted" || !isMuted();
status.hidden = perm !== "denied";
if (perm === "denied")
status.textContent = "notifications blocked — grant in site settings";
}
function bind() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
const enable = $("notif-enable");
const mute = $("notif-mute");
const unmute = $("notif-unmute");
if (!supported || !enable || !mute || !unmute) return;
enable.addEventListener('click', async () => {
enable.addEventListener("click", async () => {
await Notification.requestPermission();
renderControls();
});
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
mute.addEventListener("click", () => {
setMuted(true);
renderControls();
});
unmute.addEventListener("click", () => {
setMuted(false);
renderControls();
});
renderControls();
}
function show(title, body, tag) {
if (!supported) {
console.debug('notify: Notification API not supported');
console.debug("notify: Notification API not supported");
return;
}
if (Notification.permission !== 'granted') {
console.debug('notify: permission not granted', Notification.permission);
if (Notification.permission !== "granted") {
console.debug("notify: permission not granted", Notification.permission);
return;
}
if (isMuted()) {
console.debug('notify: muted');
console.debug("notify: muted");
return;
}
try {
@ -537,12 +640,15 @@ export const NOTIF = (() => {
// because that one tag would replace itself on every fire.
const n = new Notification(title, {
body,
tag: tag || ('hyperhive:' + Date.now()),
tag: tag || "hyperhive:" + Date.now(),
});
n.onclick = () => { window.focus(); n.close(); };
console.debug('notify: shown', title, 'tag=', tag);
n.onclick = () => {
window.focus();
n.close();
};
console.debug("notify: shown", title, "tag=", tag);
} catch (err) {
console.warn('notification show failed', err);
console.warn("notification show failed", err);
}
}
return { bind, show, renderControls };
@ -564,11 +670,11 @@ export const NOTIF = (() => {
// 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>.
function ensureStickyTop() {
let top = document.querySelector('.sticky-top');
let top = document.querySelector(".sticky-top");
if (top) return top;
top = document.createElement('div');
top.className = 'sticky-top';
const chrome = document.querySelector('.dashboard-chrome, .page-header');
top = document.createElement("div");
top.className = "sticky-top";
const chrome = document.querySelector(".dashboard-chrome, .page-header");
if (chrome && chrome.parentNode) {
chrome.parentNode.insertBefore(top, chrome);
top.append(chrome);
@ -579,12 +685,12 @@ function ensureStickyTop() {
}
function ensureServerWarningsBar() {
let bar = document.getElementById('server-warnings');
let bar = document.getElementById("server-warnings");
if (!bar) {
bar = document.createElement('div');
bar.id = 'server-warnings';
bar.className = 'server-warnings';
bar.setAttribute('role', 'alert');
bar = document.createElement("div");
bar.id = "server-warnings";
bar.className = "server-warnings";
bar.setAttribute("role", "alert");
bar.hidden = true;
ensureStickyTop().prepend(bar);
}
@ -601,10 +707,12 @@ export function renderServerWarnings(warnings) {
return;
}
for (const w of warnings) {
const row = el('div', {
class: 'server-warn server-warn-' + (w && w.level === 'crit' ? 'crit' : 'warn'),
const row = el("div", {
class:
"server-warn server-warn-" +
(w && w.level === "crit" ? "crit" : "warn"),
});
appendText(row, '⚠ ' + ((w && w.message) || ''));
appendText(row, "⚠ " + ((w && w.message) || ""));
bar.append(row);
}
bar.hidden = false;
@ -616,8 +724,10 @@ export function renderServerWarnings(warnings) {
/// live updates instead.
export function initServerWarnings() {
ensureServerWarningsBar();
fetch('/api/state')
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => renderServerWarnings(s && s.server_warnings))
.catch(() => { /* non-fatal: no banner if the snapshot is unreachable */ });
.catch(() => {
/* non-fatal: no banner if the snapshot is unreachable */
});
}

View file

@ -20,7 +20,9 @@ body.core-shell {
/* createTabStrip toggles the native `hidden` attribute on inactive
panes; ensure it wins over any inherited display. */
.core-pane[hidden] { display: none; }
.core-pane[hidden] {
display: none;
}
/* K3PT ST4T3 container cards
core.html doesn't load dashboard.css (that's the operator SPA),
@ -57,7 +59,9 @@ body.core-shell {
font-size: 1.05em;
font-weight: bold;
}
.container-row.tombstone .head .name { color: var(--muted); }
.container-row.tombstone .head .name {
color: var(--muted);
}
.container-row .head .meta {
margin-left: auto;
font-size: 0.88em;
@ -68,4 +72,7 @@ body.core-shell {
flex-wrap: wrap;
gap: 0.4em;
}
.container-row .actions form.inline { display: inline-block; margin: 0; }
.container-row .actions form.inline {
display: inline-block;
margin: 0;
}

View file

@ -1,18 +1,17 @@
<!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
<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. Same minimal chrome as
/logs.html — a `← home` back-link to the H0M3 hub + a
@ -24,41 +23,62 @@
(`hive-ci`, `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>
<hive-tab-strip class="hive-tabbar core-tabbar" id="core-tabbar" prefix="core"
role="tablist"></hive-tab-strip>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar core-tabbar"
id="core-tabbar"
prefix="core"
role="tablist"
></hive-tab-strip>
</header>
<main class="core-main">
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
<main class="core-main">
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
tombstones-section shows destroyed agents (purge button).
tombstones-stale-perms is lazy-loaded on tab activation and shows
agents with explicit capability/tool-group entries but no live
container (typically renamed/deleted agents whose JSON entries
persisted). Each stale entry gets a "✕ clear perms" button. -->
<section class="core-pane" id="core-pane-kept" data-tab-pane="kept"
role="tabpanel" aria-labelledby="core-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>
<div id="tombstones-stale-perms"></div>
</section>
<section
class="core-pane"
id="core-pane-kept"
data-tab-pane="kept"
role="tabpanel"
aria-labelledby="core-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>
<div id="tombstones-stale-perms"></div>
</section>
<!-- C0NT41N3R L04D: live cpu + memory per agent container, from
<!-- C0NT41N3R L04D: live cpu + memory per agent container, from
cgroup v2 on the host. Polled only while this sub-tab is active. -->
<section class="core-pane" id="core-pane-load" data-tab-pane="load"
role="tabpanel" aria-labelledby="core-tab-load">
<p class="meta">live cpu + memory per agent container, from cgroup v2 on the host. cpu is % of total host capacity (all cores), sampled over ~200ms each refresh; polled every 5s while this tab is open. network is omitted on purpose — agents share the host netns, so there is no per-container counter (see <code>docs/networking/network.md</code>).</p>
<div id="container-load-section">
<p class="meta">loading…</p>
</div>
</section>
<section
class="core-pane"
id="core-pane-load"
data-tab-pane="load"
role="tabpanel"
aria-labelledby="core-tab-load"
>
<p class="meta">
live cpu + memory per agent container, from cgroup v2 on the host. cpu
is % of total host capacity (all cores), sampled over ~200ms each
refresh; polled every 5s while this tab is open. network is omitted on
purpose — agents share the host netns, so there is no per-container
counter (see <code>docs/networking/network.md</code>).
</p>
<div id="container-load-section">
<p class="meta">loading…</p>
</div>
</section>
</main>
</main>
<script type="module" src="/static/core.js" defer></script>
</body>
<script type="module" src="/static/core.js" defer></script>
</body>
</html>

View file

@ -9,10 +9,10 @@
// (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 { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
import '@hive/shared/hive-tab-strip.js';
import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js";
import "@hive/shared/hive-tab-strip.js";
// ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = [];
@ -26,60 +26,75 @@ function syncFromSnapshot(s) {
// ─── kept state (tombstones) ──────────────────────────────────────────────
function renderTombstones(s) {
const root = $('tombstones-section');
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'));
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';
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 '?';
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';
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' });
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.',
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' });
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' });
const li = el("li", { class: "container-row tombstone" });
const head = el("div", { class: "head" });
head.append(
el('span', { class: 'name' }, t.name),
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'),
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)}`,
),
);
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 },
));
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);
}
@ -98,37 +113,53 @@ let stalePermsLoaded = false;
function renderStalePerms(root, ghosts) {
root.replaceChildren();
if (!ghosts.length) return;
root.append(el('p', { class: 'tombstones-stale-heading' }, 'stale permission entries'));
root.append(el('p', { class: 'meta' },
'agents with explicit capability or tool-group entries but no live container '
+ 'or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).'));
const errP = el('p', { class: 'tombstones-stale-err', hidden: true });
const ul = el('ul', { class: 'tombstones-stale-list' });
root.append(
el("p", { class: "tombstones-stale-heading" }, "stale permission entries"),
);
root.append(
el(
"p",
{ class: "meta" },
"agents with explicit capability or tool-group entries but no live container " +
"or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).",
),
);
const errP = el("p", { class: "tombstones-stale-err", hidden: true });
const ul = el("ul", { class: "tombstones-stale-list" });
for (const name of ghosts) {
const li = el('li', { class: 'tombstones-stale-row' });
li.append(el('span', { class: 'tombstones-stale-name' }, name));
li.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'stale perms'));
const btn = el('button', {
type: 'button',
class: 'btn btn-destroy',
title: 'remove explicit capability and tool-group entries for ' + name,
}, '✕ clear perms');
btn.addEventListener('click', () => asyncBtn(btn, async () => {
errP.hidden = true;
try {
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = 'failed to clear perms for ' + name + ': ' + msg;
const li = el("li", { class: "tombstones-stale-row" });
li.append(el("span", { class: "tombstones-stale-name" }, name));
li.append(el("span", { class: "hive-pill-sm badge-muted" }, "stale perms"));
const btn = el(
"button",
{
type: "button",
class: "btn btn-destroy",
title: "remove explicit capability and tool-group entries for " + name,
},
"✕ clear perms",
);
btn.addEventListener("click", () =>
asyncBtn(btn, async () => {
errP.hidden = true;
try {
const resp = await fetch(
"/api/permissions/" + encodeURIComponent(name),
{ method: "DELETE" },
);
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = "failed to clear perms for " + name + ": " + msg;
errP.hidden = false;
return;
}
await fetchAndRenderStalePerms();
} catch (err) {
errP.textContent = "failed to clear perms for " + name + ": " + err;
errP.hidden = false;
return;
}
await fetchAndRenderStalePerms();
} catch (err) {
errP.textContent = 'failed to clear perms for ' + name + ': ' + err;
errP.hidden = false;
}
}));
}),
);
li.append(btn);
ul.append(li);
}
@ -140,16 +171,18 @@ function renderStalePerms(root, ghosts) {
// but are absent from both the live roster and the kept-state tombstones.
// One call, no client-side roster cache, always authoritative.
async function fetchAndRenderStalePerms() {
const root = $('tombstones-stale-perms');
const root = $("tombstones-stale-perms");
if (!root) return;
try {
const resp = await fetch('/api/permissions/stale');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/permissions/stale");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderStalePerms(root, data.stale || []);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'failed to load stale perm data: ' + err));
root.append(
el("p", { class: "meta" }, "failed to load stale perm data: " + err),
);
}
stalePermsLoaded = true;
}
@ -158,21 +191,25 @@ async function fetchAndRenderStalePerms() {
let containerLoadTimer = null;
function cloadFmtBytes(n) {
if (!Number.isFinite(n) || n <= 0) return '0';
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let i = 0; let v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; }
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
if (!Number.isFinite(n) || n <= 0) return "0";
const u = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0;
let v = n;
while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i += 1;
}
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + " " + u[i];
}
function cloadMeter(pct) {
const p = Math.max(0, Math.min(100, pct));
const cls = p >= 90 ? 'hot' : (p >= 70 ? 'warn' : '');
const m = document.createElement('span');
m.className = 'cload-meter';
m.title = p.toFixed(0) + '%';
const fill = document.createElement('span');
fill.className = 'fill' + (cls ? ' ' + cls : '');
fill.style.width = p + '%';
const cls = p >= 90 ? "hot" : p >= 70 ? "warn" : "";
const m = document.createElement("span");
m.className = "cload-meter";
m.title = p.toFixed(0) + "%";
const fill = document.createElement("span");
fill.className = "fill" + (cls ? " " + cls : "");
fill.style.width = p + "%";
m.append(fill);
return m;
}
@ -183,12 +220,14 @@ let lastLoadRows = [];
function renderContainerLoad(rows) {
lastLoadRows = rows;
const root = $('container-load-section');
const root = $("container-load-section");
if (!root) return;
if (!Array.isArray(rows) || rows.length === 0) {
root.replaceChildren();
const p = document.createElement('p'); p.className = 'meta';
p.textContent = 'no running agent containers'; root.append(p);
const p = document.createElement("p");
p.className = "meta";
p.textContent = "no running agent containers";
root.append(p);
return;
}
@ -197,83 +236,105 @@ function renderContainerLoad(rows) {
// resolved by the server) next to the live cgroup readings.
const cvByName = new Map(containersState.map((c) => [c.name, c]));
const table = document.createElement('table');
table.className = 'hive-stats-table';
const table = document.createElement("table");
table.className = "hive-stats-table";
// "cpu cap" / "mem cap" show the configured ceilings from ContainerView
// (effective drop-in values, take effect on next start/restart).
// "limit" remains the live cgroup memory ceiling from /api/container-resources.
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
+ '<th>peak</th><th>limit</th><th>disk</th>'
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>'
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>'
+ '<th></th></tr></thead>';
const tb = document.createElement('tbody');
table.innerHTML =
"<thead><tr><th>agent</th><th>cpu</th><th>memory</th>" +
"<th>peak</th><th>limit</th><th>disk</th>" +
'<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>' +
'<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>' +
"<th></th></tr></thead>";
const tb = document.createElement("tbody");
for (const r of rows) {
const cv = cvByName.get(r.name);
const tr = document.createElement('tr');
const name = document.createElement('td'); name.textContent = r.name; tr.append(name);
const cpu = document.createElement('td'); cpu.className = 'num';
cpu.append((Number(r.cpu_pct) || 0).toFixed(1) + '%', cloadMeter(Number(r.cpu_pct) || 0));
const tr = document.createElement("tr");
const name = document.createElement("td");
name.textContent = r.name;
tr.append(name);
const cpu = document.createElement("td");
cpu.className = "num";
cpu.append(
(Number(r.cpu_pct) || 0).toFixed(1) + "%",
cloadMeter(Number(r.cpu_pct) || 0),
);
tr.append(cpu);
const mem = document.createElement('td'); mem.className = 'num';
const mem = document.createElement("td");
mem.className = "num";
const memCur = Number(r.mem_current_bytes) || 0;
if (r.mem_max_bytes) {
mem.append(cloadFmtBytes(memCur), cloadMeter(100 * memCur / r.mem_max_bytes));
mem.append(
cloadFmtBytes(memCur),
cloadMeter((100 * memCur) / r.mem_max_bytes),
);
} else {
mem.textContent = cloadFmtBytes(memCur);
}
tr.append(mem);
const peak = document.createElement('td'); peak.className = 'num';
peak.textContent = r.mem_peak_bytes ? cloadFmtBytes(Number(r.mem_peak_bytes)) : '—';
const peak = document.createElement("td");
peak.className = "num";
peak.textContent = r.mem_peak_bytes
? cloadFmtBytes(Number(r.mem_peak_bytes))
: "—";
tr.append(peak);
const lim = document.createElement('td'); lim.className = 'num';
lim.textContent = r.mem_max_bytes ? cloadFmtBytes(Number(r.mem_max_bytes)) : '∞';
const lim = document.createElement("td");
lim.className = "num";
lim.textContent = r.mem_max_bytes
? cloadFmtBytes(Number(r.mem_max_bytes))
: "∞";
tr.append(lim);
// Disk: on-disk footprint (state dir + container writable rootfs, shared
// nix store excluded). Sampled out-of-band every few minutes server-side,
// so it's null until the first sample lands — show an em-dash then.
const disk = document.createElement('td'); disk.className = 'num';
disk.title = 'state dir + container writable rootfs (shared nix store excluded); sampled every few minutes';
disk.textContent = (r.disk_bytes != null) ? cloadFmtBytes(Number(r.disk_bytes)) : '—';
const disk = document.createElement("td");
disk.className = "num";
disk.title =
"state dir + container writable rootfs (shared nix store excluded); sampled every few minutes";
disk.textContent =
r.disk_bytes != null ? cloadFmtBytes(Number(r.disk_bytes)) : "—";
tr.append(disk);
// Configured CPU / memory caps from ContainerView (resolved effective
// values: per-agent override when set, hive-wide default otherwise).
const cpuCap = document.createElement('td'); cpuCap.className = 'num cload-cap';
cpuCap.title = 'configured ceiling — takes effect on next start';
cpuCap.textContent = cv?.cpu_quota || '—';
const cpuCap = document.createElement("td");
cpuCap.className = "num cload-cap";
cpuCap.title = "configured ceiling — takes effect on next start";
cpuCap.textContent = cv?.cpu_quota || "—";
tr.append(cpuCap);
const memCap = document.createElement('td'); memCap.className = 'num cload-cap';
memCap.title = 'configured ceiling — takes effect on next start';
memCap.textContent = cv?.memory_max || '—';
const memCap = document.createElement("td");
memCap.className = "num cload-cap";
memCap.title = "configured ceiling — takes effect on next start";
memCap.textContent = cv?.memory_max || "—";
tr.append(memCap);
// S3T button toggles the inline edit row for this agent.
const actTd = document.createElement('td');
const setBtn = document.createElement('button');
setBtn.type = 'button';
setBtn.className = 'btn btn-sm cload-set-btn';
setBtn.textContent = 'S3T';
setBtn.title = 'set CPU / memory cap for ' + r.name;
const actTd = document.createElement("td");
const setBtn = document.createElement("button");
setBtn.type = "button";
setBtn.className = "btn btn-sm cload-set-btn";
setBtn.textContent = "S3T";
setBtn.title = "set CPU / memory cap for " + r.name;
tr.append(actTd);
actTd.append(setBtn);
tb.append(tr);
// Inline edit row (hidden by default, toggled by the S3T button).
const editRow = document.createElement('tr');
editRow.className = 'cload-edit-row';
const editRow = document.createElement("tr");
editRow.className = "cload-edit-row";
editRow.hidden = true;
const editTd = document.createElement('td');
const editTd = document.createElement("td");
editTd.colSpan = 9;
editTd.className = 'cload-edit-cell';
editTd.className = "cload-edit-cell";
const editForm = document.createElement('form');
editForm.className = 'cload-edit-form';
editForm.addEventListener('submit', async (e) => {
const editForm = document.createElement("form");
editForm.className = "cload-edit-form";
editForm.addEventListener("submit", async (e) => {
e.preventDefault();
const cpuInput = editForm.querySelector('.cload-cpu-input');
const memInput = editForm.querySelector('.cload-mem-input');
const errSpan = editForm.querySelector('.cload-edit-err');
const cpuInput = editForm.querySelector(".cload-cpu-input");
const memInput = editForm.querySelector(".cload-mem-input");
const errSpan = editForm.querySelector(".cload-edit-err");
const submitBtn = editForm.querySelector('[type="submit"]');
errSpan.hidden = true;
submitBtn.disabled = true;
@ -283,11 +344,13 @@ function renderContainerLoad(rows) {
memory_max: memInput.value.trim(),
});
const resp = await fetch(
'/api/resource-limits/' + encodeURIComponent(r.name),
{ method: 'POST', body },
"/api/resource-limits/" + encodeURIComponent(r.name),
{ method: "POST", body },
);
if (!resp.ok) {
errSpan.textContent = await resp.text().catch(() => 'error ' + resp.status);
errSpan.textContent = await resp
.text()
.catch(() => "error " + resp.status);
errSpan.hidden = false;
return;
}
@ -295,7 +358,7 @@ function renderContainerLoad(rows) {
// ContainerView data (cpu_quota/memory_max) to containersState,
// triggering a re-render of the cap columns via refreshContainerLoad.
editRow.hidden = true;
setBtn.textContent = 'S3T';
setBtn.textContent = "S3T";
} catch (err) {
errSpan.textContent = String(err);
errSpan.hidden = false;
@ -304,44 +367,50 @@ function renderContainerLoad(rows) {
}
});
const cpuLabel = document.createElement('label');
cpuLabel.className = 'cload-edit-label';
cpuLabel.textContent = 'cpu quota';
const cpuInput = document.createElement('input');
cpuInput.type = 'text'; cpuInput.className = 'cload-cpu-input';
cpuInput.placeholder = cv ? cv.cpu_quota : 'e.g. 200%';
cpuInput.title = 'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
const cpuLabel = document.createElement("label");
cpuLabel.className = "cload-edit-label";
cpuLabel.textContent = "cpu quota";
const cpuInput = document.createElement("input");
cpuInput.type = "text";
cpuInput.className = "cload-cpu-input";
cpuInput.placeholder = cv ? cv.cpu_quota : "e.g. 200%";
cpuInput.title =
'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
cpuLabel.append(cpuInput);
const memLabel = document.createElement('label');
memLabel.className = 'cload-edit-label';
memLabel.textContent = 'mem max';
const memInput = document.createElement('input');
memInput.type = 'text'; memInput.className = 'cload-mem-input';
memInput.placeholder = cv ? cv.memory_max : 'e.g. 8G';
memInput.title = 'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
const memLabel = document.createElement("label");
memLabel.className = "cload-edit-label";
memLabel.textContent = "mem max";
const memInput = document.createElement("input");
memInput.type = "text";
memInput.className = "cload-mem-input";
memInput.placeholder = cv ? cv.memory_max : "e.g. 8G";
memInput.title =
'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
memLabel.append(memInput);
const submitBtn = document.createElement('button');
submitBtn.type = 'submit'; submitBtn.className = 'btn btn-restart cload-save-btn';
submitBtn.textContent = 'S4V3';
const submitBtn = document.createElement("button");
submitBtn.type = "submit";
submitBtn.className = "btn btn-restart cload-save-btn";
submitBtn.textContent = "S4V3";
const hintSpan = document.createElement('span');
hintSpan.className = 'meta cload-edit-hint';
hintSpan.textContent = '↺ restart to apply to a running container';
const hintSpan = document.createElement("span");
hintSpan.className = "meta cload-edit-hint";
hintSpan.textContent = "↺ restart to apply to a running container";
const errSpan = document.createElement('span');
errSpan.className = 'cload-edit-err'; errSpan.hidden = true;
const errSpan = document.createElement("span");
errSpan.className = "cload-edit-err";
errSpan.hidden = true;
editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan);
editTd.append(editForm);
editRow.append(editTd);
tb.append(editRow);
setBtn.addEventListener('click', () => {
setBtn.addEventListener("click", () => {
const open = !editRow.hidden;
editRow.hidden = open;
setBtn.textContent = open ? 'S3T' : '✕';
setBtn.textContent = open ? "S3T" : "✕";
});
}
table.append(tb);
@ -350,15 +419,17 @@ function renderContainerLoad(rows) {
async function refreshContainerLoad() {
try {
const resp = await fetch('/api/container-resources');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/container-resources");
if (!resp.ok) throw new Error("http " + resp.status);
renderContainerLoad(await resp.json());
} catch (e) {
const root = $('container-load-section');
const root = $("container-load-section");
if (root) {
root.replaceChildren();
const p = document.createElement('p'); p.className = 'meta';
p.textContent = 'container load fetch failed: ' + e; root.append(p);
const p = document.createElement("p");
p.className = "meta";
p.textContent = "container load fetch failed: " + e;
root.append(p);
}
}
}
@ -368,7 +439,10 @@ function startContainerLoadPolling() {
containerLoadTimer = setInterval(refreshContainerLoad, 5000);
}
function stopContainerLoadPolling() {
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
if (containerLoadTimer) {
clearInterval(containerLoadTimer);
containerLoadTimer = null;
}
}
// ─── render-all (cold load + any full re-render) ──────────────────────────
@ -412,7 +486,7 @@ const SSE_HANDLERS = {
// purge actions that opt out of `data-no-refresh`).
async function refreshState() {
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json());
} catch {
// best-effort: the page keeps its last-rendered state
@ -431,18 +505,18 @@ async function init() {
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
// polling runs only while the LOAD sub-tab is open (cpu is a short
// two-sample read each refresh on the server).
document.getElementById('core-tabbar').configure({
document.getElementById("core-tabbar").configure({
tabs: [
{ id: 'kept', label: 'K3PT ST4T3' },
{ id: 'load', label: 'C0NT41N3R L04D' },
{ id: "kept", label: "K3PT ST4T3" },
{ id: "load", label: "C0NT41N3R L04D" },
],
defaultId: 'kept',
defaultId: "kept",
onShow: (id) => {
if (id === 'load') startContainerLoadPolling();
if (id === "load") startContainerLoadPolling();
else stopContainerLoadPolling();
// Lazy-load stale-perms on first K3PT ST4T3 activation; always
// re-fetch on subsequent visits in case perms changed.
if (id === 'kept') fetchAndRenderStalePerms();
if (id === "kept") fetchAndRenderStalePerms();
},
});
@ -454,13 +528,17 @@ async function init() {
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream(
'/api/dashboard/stream?kinds=tombstones_changed,container_state_changed,' +
'capabilities_changed,tool_groups_changed',
"/api/dashboard/stream?kinds=tombstones_changed,container_state_changed," +
"capabilities_changed,tool_groups_changed",
);
if (es) {
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind];
if (h) h(ev);
};

View file

@ -25,9 +25,13 @@ body.cred-shell {
padding: 1.2em 1.25rem 3rem;
}
.cred-pane[hidden] { display: none; }
.cred-pane[hidden] {
display: none;
}
.gh-status { margin: 0.5rem 0 1.2rem; }
.gh-status {
margin: 0.5rem 0 1.2rem;
}
.gh-status-line {
display: flex;
align-items: center;
@ -39,10 +43,18 @@ body.cred-shell {
border-radius: 50%;
flex: none;
}
.gh-dot.present { background: var(--green); }
.gh-dot.absent { background: var(--muted); }
.gh-status-text.present { color: var(--green); }
.gh-status-text.absent { color: var(--muted); }
.gh-dot.present {
background: var(--green);
}
.gh-dot.absent {
background: var(--muted);
}
.gh-status-text.present {
color: var(--green);
}
.gh-status-text.absent {
color: var(--muted);
}
.ma-field {
display: flex;
@ -50,7 +62,10 @@ body.cred-shell {
gap: 0.25rem;
margin: 0.55rem 0;
}
.ma-field > span { font-size: 0.8rem; color: var(--muted); }
.ma-field > span {
font-size: 0.8rem;
color: var(--muted);
}
.ma-field input,
.ma-field select {
padding: 0.4rem 0.5rem;
@ -72,13 +87,28 @@ body.cred-shell {
padding: 0.45rem 0.75rem 0.6rem;
margin: 0.85rem 0;
}
.ma-mode legend { font-size: 0.8rem; color: var(--muted); padding: 0 0.3rem; }
.ma-mode label { margin-right: 1.3rem; cursor: pointer; }
.ma-mode legend {
font-size: 0.8rem;
color: var(--muted);
padding: 0 0.3rem;
}
.ma-mode label {
margin-right: 1.3rem;
cursor: pointer;
}
.ma-modefields { margin: 0.4rem 0; }
.ma-modefields {
margin: 0.4rem 0;
}
.ma-list { margin: 0.5rem 0 1.2rem; }
.ma-accounts { list-style: none; padding: 0; margin: 0; }
.ma-list {
margin: 0.5rem 0 1.2rem;
}
.ma-accounts {
list-style: none;
padding: 0;
margin: 0;
}
.ma-account {
display: flex;
align-items: center;
@ -96,10 +126,16 @@ body.cred-shell {
`offline`/`stale` are the amber states (provisioned-not-live / container-down
daemon-down). `absent` = no token. */
.ma-dot.ok,
.ma-dot.live { background: var(--green); }
.ma-dot.live {
background: var(--green);
}
.ma-dot.offline,
.ma-dot.stale { background: var(--amber); }
.ma-dot.absent { background: var(--muted); }
.ma-dot.stale {
background: var(--amber);
}
.ma-dot.absent {
background: var(--muted);
}
/* `live stale-age`: snapshot still says live but the daemon heartbeat stalled
(> ~90s). Keep the green hue but dim + desaturate so it reads "was online,
now uncertain" visually distinct from the solid amber container-down
@ -109,23 +145,52 @@ body.cred-shell {
opacity: 0.4;
filter: saturate(0.45);
}
.ma-name { font-weight: 600; color: var(--fg); }
.ma-uid { color: var(--muted); font-size: 0.8rem; margin-left: 0.4em; }
.ma-hs { color: var(--muted); font-size: 0.85rem; }
.ma-status { margin-left: auto; font-size: 0.8rem; }
.ma-name {
font-weight: 600;
color: var(--fg);
}
.ma-uid {
color: var(--muted);
font-size: 0.8rem;
margin-left: 0.4em;
}
.ma-hs {
color: var(--muted);
font-size: 0.85rem;
}
.ma-status {
margin-left: auto;
font-size: 0.8rem;
}
.ma-status.ok,
.ma-status.live { color: var(--green); }
.ma-status.live {
color: var(--green);
}
.ma-status.offline,
.ma-status.stale { color: var(--amber); }
.ma-status.absent { color: var(--muted); }
.ma-status.live.stale-age { color: var(--green); opacity: 0.6; }
.ma-status.stale {
color: var(--amber);
}
.ma-status.absent {
color: var(--muted);
}
.ma-status.live.stale-age {
color: var(--green);
opacity: 0.6;
}
.ma-result {
margin-top: 0.7rem;
font-size: 0.9rem;
min-height: 1.2em;
}
.ma-result.ok { color: var(--green); }
.ma-result.err { color: var(--red); }
.ma-result.ok {
color: var(--green);
}
.ma-result.err {
color: var(--red);
}
.ma-list .err { color: var(--red); font-size: 0.9rem; }
.ma-list .err {
color: var(--red);
font-size: 0.9rem;
}

View file

@ -1,156 +1,253 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // CR3D3NTIALS</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/credentials.css">
</head>
<body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // CR3D3NTIALS</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/credentials.css" />
</head>
<body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
logs.html (MATRIX / GITHUB instead of AGENT/INFRA/SYSTEM). Back
link points to the H0M3 hub (served at /). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar cred-tabbar" id="cred-tabbar" prefix="cred"
role="tablist"></hive-tab-strip>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar cred-tabbar"
id="cred-tabbar"
prefix="cred"
role="tablist"
></hive-tab-strip>
</header>
<main class="cred-main">
<!-- Agent picker: shared across both tabs (one agent selected at a
<main class="cred-main">
<!-- Agent picker: shared across both tabs (one agent selected at a
time drives both the matrix account list and the github status). -->
<h3>◇ agent</h3>
<label class="ma-field">
<span>agent</span>
<select id="ma-agent"></select>
</label>
<h3>◇ agent</h3>
<label class="ma-field">
<span>agent</span>
<select id="ma-agent"></select>
</label>
<!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just
<!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just
moved under a tab pane. -->
<section class="cred-pane" id="cred-pane-matrix" data-tab-pane="matrix"
role="tabpanel" aria-labelledby="cred-tab-matrix">
<p class="meta">provision or log in an <strong>external</strong> matrix account for an agent and store its access token. the token is written to the agent's <code>matrixAccounts.&lt;account&gt;.tokenFile</code> by the host coordinator &mdash; it is never displayed back on this page.</p>
<section
class="cred-pane"
id="cred-pane-matrix"
data-tab-pane="matrix"
role="tabpanel"
aria-labelledby="cred-tab-matrix"
>
<p class="meta">
provision or log in an <strong>external</strong> matrix account for an
agent and store its access token. the token is written to the agent's
<code>matrixAccounts.&lt;account&gt;.tokenFile</code> by the host
coordinator &mdash; it is never displayed back on this page.
</p>
<h3>◇ provisioned accounts</h3>
<p class="meta">accounts that have a stored token (provision one below to add it here); a config-declared account that hasn't been provisioned yet won't appear until it has a token. status reflects whether a <em>token is stored</em>, not a live session &mdash; a true online/offline indicator is a follow-up that needs the daemon's account registry.</p>
<div id="ma-list" class="ma-list"><p class="meta">select an agent to see its matrix accounts.</p></div>
<h3>◇ provision / log in</h3>
<form id="ma-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>account name</span>
<input type="text" name="account" placeholder="e.g. public" required>
</label>
<label class="ma-field">
<span>homeserver</span>
<input type="text" name="homeserver" placeholder="https://matrix.org" required>
</label>
<fieldset class="ma-mode">
<legend>login method</legend>
<label><input type="radio" name="mode" value="password" checked> password</label>
<label><input type="radio" name="mode" value="token"> existing token</label>
</fieldset>
<div id="ma-pw-fields" class="ma-modefields">
<label class="ma-field">
<span>user id</span>
<input type="text" name="user_id" placeholder="@user:matrix.org" autocomplete="username">
</label>
<label class="ma-field">
<span>password</span>
<input type="password" name="password" autocomplete="new-password">
</label>
<h3>◇ provisioned accounts</h3>
<p class="meta">
accounts that have a stored token (provision one below to add it
here); a config-declared account that hasn't been provisioned yet
won't appear until it has a token. status reflects whether a
<em>token is stored</em>, not a live session &mdash; a true
online/offline indicator is a follow-up that needs the daemon's
account registry.
</p>
<div id="ma-list" class="ma-list">
<p class="meta">select an agent to see its matrix accounts.</p>
</div>
<div id="ma-token-fields" class="ma-modefields" hidden>
<h3>◇ provision / log in</h3>
<form id="ma-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off">
<span>account name</span>
<input
type="text"
name="account"
placeholder="e.g. public"
required
/>
</label>
<label class="ma-field">
<span>user id <span class="meta">(optional &mdash; derived via whoami)</span></span>
<input type="text" name="user_id" placeholder="@user:matrix.org">
<span>homeserver</span>
<input
type="text"
name="homeserver"
placeholder="https://matrix.org"
required
/>
</label>
</div>
<button type="submit" class="btn btn-spawn">log in &amp; store token</button>
<p id="ma-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<fieldset class="ma-mode">
<legend>login method</legend>
<label
><input type="radio" name="mode" value="password" checked />
password</label
>
<label
><input type="radio" name="mode" value="token" /> existing
token</label
>
</fieldset>
<!-- GITHUB tab: single-account PAT paste. No login flow — the
<div id="ma-pw-fields" class="ma-modefields">
<label class="ma-field">
<span>user id</span>
<input
type="text"
name="user_id"
placeholder="@user:matrix.org"
autocomplete="username"
/>
</label>
<label class="ma-field">
<span>password</span>
<input
type="password"
name="password"
autocomplete="new-password"
/>
</label>
</div>
<div id="ma-token-fields" class="ma-modefields" hidden>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" />
</label>
<label class="ma-field">
<span
>user id
<span class="meta"
>(optional &mdash; derived via whoami)</span
></span
>
<input
type="text"
name="user_id"
placeholder="@user:matrix.org"
/>
</label>
</div>
<button type="submit" class="btn btn-spawn">
log in &amp; store token
</button>
<p id="ma-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<!-- GITHUB tab: single-account PAT paste. No login flow — the
operator pastes an existing PAT for a dedicated bot account.
Security-warning banner + a link to generate a PAT. -->
<section class="cred-pane" id="cred-pane-github" data-tab-pane="github"
role="tabpanel" aria-labelledby="cred-tab-github" hidden>
<hive-warn level="warning">
⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash;
and a <strong>minimally-scoped</strong> personal access token (only
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
<code>workflow</code>). the container boundary is the enforcement:
anything within the token's scopes is reachable if the agent is
ever compromised. the token is injected into the agent's state dir
and is <strong>never displayed back</strong> on this page.
</hive-warn>
<section
class="cred-pane"
id="cred-pane-github"
data-tab-pane="github"
role="tabpanel"
aria-labelledby="cred-tab-github"
hidden
>
<hive-warn level="warning">
⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash;
and a <strong>minimally-scoped</strong> personal access token (only
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
<code>workflow</code>). the container boundary is the enforcement:
anything within the token's scopes is reachable if the agent is ever
compromised. the token is injected into the agent's state dir and is
<strong>never displayed back</strong> on this page.
</hive-warn>
<h3>◇ status</h3>
<div id="gh-status" class="gh-status"><p class="meta">select an agent to see its github credential status.</p></div>
<h3>◇ status</h3>
<div id="gh-status" class="gh-status">
<p class="meta">
select an agent to see its github credential status.
</p>
</div>
<h3>◇ provision</h3>
<p class="meta">generate a token at
<a href="https://github.com/settings/tokens" target="_blank" rel="noopener">github.com/settings/tokens</a>
and paste it below. one account per agent &mdash; pasting a new token replaces the stored one.</p>
<form id="gh-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>personal access token</span>
<input type="password" name="token" autocomplete="off" required>
</label>
<button type="submit" class="btn btn-spawn">store token</button>
<p id="gh-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<h3>◇ provision</h3>
<p class="meta">
generate a token at
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener"
>github.com/settings/tokens</a
>
and paste it below. one account per agent &mdash; pasting a new token
replaces the stored one.
</p>
<form id="gh-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>personal access token</span>
<input type="password" name="token" autocomplete="off" required />
</label>
<button type="submit" class="btn btn-spawn">store token</button>
<p id="gh-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
Entirely dashboard-provisioned, no host-side nix config &mdash; same
shape as GITHUB plus a base-URL field (like MATRIX's homeserver).
The operator creates a token on the external forge themselves
(however that forge lets them) and pastes label + URL + token
below. No remote account minting/revoking &mdash; purely local. -->
<section class="cred-pane" id="cred-pane-forges" data-tab-pane="forges"
role="tabpanel" aria-labelledby="cred-tab-forges" hidden>
<p class="meta">store a <strong>label + base URL + access token</strong> for an external Forgejo/Gitea/Codeberg-compatible forge, per agent. no account is created on the remote forge &mdash; create the token there yourself first. the token is never displayed back on this page.</p>
<section
class="cred-pane"
id="cred-pane-forges"
data-tab-pane="forges"
role="tabpanel"
aria-labelledby="cred-tab-forges"
hidden
>
<p class="meta">
store a <strong>label + base URL + access token</strong> for an
external Forgejo/Gitea/Codeberg-compatible forge, per agent. no
account is created on the remote forge &mdash; create the token there
yourself first. the token is never displayed back on this page.
</p>
<h3>◇ provisioned forges</h3>
<div id="ef-list" class="ef-list"><p class="meta">select an agent to see its forge accounts.</p></div>
<h3>◇ provisioned forges</h3>
<div id="ef-list" class="ef-list">
<p class="meta">select an agent to see its forge accounts.</p>
</div>
<h3>◇ add forge account</h3>
<form id="ef-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>label</span>
<input type="text" name="label" placeholder="e.g. codeberg" required>
</label>
<label class="ma-field">
<span>base url</span>
<input type="text" name="base_url" placeholder="https://codeberg.org" required>
</label>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" required>
</label>
<button type="submit" class="btn btn-spawn">store account</button>
<p id="ef-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<h3>◇ add forge account</h3>
<form id="ef-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>label</span>
<input
type="text"
name="label"
placeholder="e.g. codeberg"
required
/>
</label>
<label class="ma-field">
<span>base url</span>
<input
type="text"
name="base_url"
placeholder="https://codeberg.org"
required
/>
</label>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" required />
</label>
<button type="submit" class="btn btn-spawn">store account</button>
<p id="ef-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
</main>
</main>
<script type="module" src="/static/credentials.js" defer></script>
</body>
<script type="module" src="/static/credentials.js" defer></script>
</body>
</html>

View file

@ -19,11 +19,11 @@
// themselves and pastes it in, same trust model as GITHUB.
// Per-tab detail comments live next to their section below.
import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/hive-tab-strip.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import { readApiError, problemMessage } from '@hive/shared/api-error.js';
import { $, esc, fmtAgeSecs, renderServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import "@hive/shared/hive-tab-strip.js";
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
let agents = [];
// agent name → container running (bool), from /api/state. Cross-referenced by
@ -35,7 +35,7 @@ const containerRunning = new Map();
async function loadState() {
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (!resp.ok) return;
const s = await resp.json();
renderServerWarnings(s.server_warnings);
@ -43,7 +43,7 @@ async function loadState() {
// object carrying `.name` + `.running`); there is no top-level `agents`
// field, so the picker stays compatible with both string + object shapes.
const containers = (s.containers || [])
.map((a) => (typeof a === 'string' ? { name: a } : a))
.map((a) => (typeof a === "string" ? { name: a } : a))
.filter((c) => c && c.name);
agents = containers.map((c) => c.name).sort();
containerRunning.clear();
@ -56,14 +56,14 @@ async function loadState() {
}
function renderAgentPicker() {
const sel = $('ma-agent');
const sel = $("ma-agent");
sel.replaceChildren();
if (!agents.length) {
sel.append(el('option', { value: '' }, '— no agents —'));
sel.append(el("option", { value: "" }, "— no agents —"));
return;
}
sel.append(el('option', { value: '' }, '— select agent —'));
for (const a of agents) sel.append(el('option', { value: a }, a));
sel.append(el("option", { value: "" }, "— select agent —"));
for (const a of agents) sel.append(el("option", { value: a }, a));
}
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
@ -86,91 +86,116 @@ function renderAgentPicker() {
// v1 backend (no `live` field) falls back to token-present rendering.
async function loadAccounts(agent) {
const list = $('ma-list');
const list = $("ma-list");
if (!agent) {
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.'));
list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its matrix accounts."),
);
return;
}
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data;
try {
const resp = await fetch('/api/matrix-accounts?agent=' + encodeURIComponent(agent));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const resp = await fetch(
"/api/matrix-accounts?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json();
} catch (err) {
list.replaceChildren(el('p', { class: 'err' },
'could not load accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
list.replaceChildren(
el(
"p",
{ class: "err" },
"could not load accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
const accounts = data.accounts || [];
const asOf = typeof data.as_of_unix === 'number' ? data.as_of_unix : null;
const asOf = typeof data.as_of_unix === "number" ? data.as_of_unix : null;
// `false` only when the container is explicitly down; `undefined` (unknown,
// e.g. a failed /api/state read) is treated as not-down so we never flag a
// false stale.
const running = containerRunning.get(agent);
// The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances
// while it's alive — this is a heartbeat, and a stalled value is meaningful.
const ageSecs = asOf != null
? Math.max(0, Math.floor(Date.now() / 1000) - asOf)
: null;
const asOfText = asOf != null
? 'matrix snapshot · live as of ' + fmtAgeSecs(ageSecs) + ' ago'
: 'no daemon snapshot yet';
const ageSecs =
asOf != null ? Math.max(0, Math.floor(Date.now() / 1000) - asOf) : null;
const asOfText =
asOf != null
? "matrix snapshot · live as of " + fmtAgeSecs(ageSecs) + " ago"
: "no daemon snapshot yet";
// 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is
// NOT down means the daemon stopped publishing (dead/wedged) — dim its dot.
const STALE_AGE_SECS = 90;
const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS;
list.replaceChildren();
if (!accounts.length) {
list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.'));
list.append(
el(
"p",
{ class: "meta" },
"no matrix accounts configured for this agent.",
),
);
return;
}
const ul = el('ul', { class: 'ma-accounts' });
const ul = el("ul", { class: "ma-accounts" });
for (const acc of accounts) {
const present = !!acc.token_present;
// 3-state dot. `live` is absent on the v1 backend (pre BE-4); when
// undefined, fall back to the v1 token-present rendering so the page
// degrades cleanly before the snapshot backend deploys.
let cls; let statusText; let dotTitle;
let cls;
let statusText;
let dotTitle;
if (acc.live === undefined) {
cls = present ? 'ok' : 'absent';
statusText = present ? 'token stored ✓' : 'no token';
dotTitle = present ? 'token stored' : 'no token yet';
cls = present ? "ok" : "absent";
statusText = present ? "token stored ✓" : "no token";
dotTitle = present ? "token stored" : "no token yet";
} else if (acc.live && running === false) {
// container down ⟹ daemon down ⟹ a "live" snapshot is stale.
cls = 'stale';
statusText = 'container stopped';
dotTitle = 'container is stopped — live status is stale. ' + asOfText;
cls = "stale";
statusText = "container stopped";
dotTitle = "container is stopped — live status is stale. " + asOfText;
} else if (acc.live && staleByAge) {
// Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't
// advanced in > ~90s while the container is NOT down — the daemon stopped
// publishing, so the "live" is no longer trustworthy. Keep the green
// family but dim it (distinct from the amber container-down 'stale').
cls = 'live stale-age';
statusText = 'online · no heartbeat';
dotTitle = 'snapshot says live but the daemon heartbeat stalled '
+ fmtAgeSecs(ageSecs) + ' ago (publishes every ~30s) — likely dead or wedged. '
+ asOfText;
cls = "live stale-age";
statusText = "online · no heartbeat";
dotTitle =
"snapshot says live but the daemon heartbeat stalled " +
fmtAgeSecs(ageSecs) +
" ago (publishes every ~30s) — likely dead or wedged. " +
asOfText;
} else if (acc.live) {
cls = 'live';
statusText = 'online ✓';
cls = "live";
statusText = "online ✓";
dotTitle = asOfText;
} else if (present) {
cls = 'offline';
statusText = 'token stored · offline';
dotTitle = 'provisioned but not live. ' + asOfText;
cls = "offline";
statusText = "token stored · offline";
dotTitle = "provisioned but not live. " + asOfText;
} else {
cls = 'absent';
statusText = 'no token';
dotTitle = 'no token yet';
cls = "absent";
statusText = "no token";
dotTitle = "no token yet";
}
ul.append(el('li', { class: 'ma-account' },
el('span', { class: 'ma-dot ' + cls, title: dotTitle }),
el('span', { class: 'ma-name' }, acc.name || '(unnamed)'),
acc.user_id ? el('span', { class: 'ma-uid' }, acc.user_id) : null,
el('span', { class: 'ma-hs' }, acc.homeserver || '—'),
el('span', { class: 'ma-status ' + cls, title: asOfText }, statusText),
));
ul.append(
el(
"li",
{ class: "ma-account" },
el("span", { class: "ma-dot " + cls, title: dotTitle }),
el("span", { class: "ma-name" }, acc.name || "(unnamed)"),
acc.user_id ? el("span", { class: "ma-uid" }, acc.user_id) : null,
el("span", { class: "ma-hs" }, acc.homeserver || "—"),
el("span", { class: "ma-status " + cls, title: asOfText }, statusText),
),
);
}
list.append(ul);
}
@ -181,72 +206,90 @@ async function loadAccounts(agent) {
// both — would be submitted).
function toggleModeFields() {
const mode = document.querySelector('input[name="mode"]:checked');
const value = mode ? mode.value : 'password';
const pw = $('ma-pw-fields');
const tok = $('ma-token-fields');
pw.hidden = value !== 'password';
tok.hidden = value !== 'token';
pw.querySelectorAll('input').forEach((i) => { i.disabled = pw.hidden; });
tok.querySelectorAll('input').forEach((i) => { i.disabled = tok.hidden; });
const value = mode ? mode.value : "password";
const pw = $("ma-pw-fields");
const tok = $("ma-token-fields");
pw.hidden = value !== "password";
tok.hidden = value !== "token";
pw.querySelectorAll("input").forEach((i) => {
i.disabled = pw.hidden;
});
tok.querySelectorAll("input").forEach((i) => {
i.disabled = tok.hidden;
});
}
function clearSecrets(formEl) {
formEl.querySelectorAll('input[type="password"], input[name="token"]')
.forEach((i) => { i.value = ''; });
formEl
.querySelectorAll('input[type="password"], input[name="token"]')
.forEach((i) => {
i.value = "";
});
}
async function submitLogin(e) {
e.preventDefault();
const formEl = e.target;
const out = $('ma-result');
out.className = 'ma-result';
out.textContent = '';
const out = $("ma-result");
out.className = "ma-result";
out.textContent = "";
const agent = $('ma-agent').value;
const agent = $("ma-agent").value;
if (!agent) {
out.className = 'ma-result err';
out.textContent = 'select an agent first.';
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set('agent', agent);
fd.set("agent", agent);
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = 'logging in…';
btn.textContent = "logging in…";
try {
const resp = await fetch('/api/matrix-account-login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
const resp = await fetch("/api/matrix-account-login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
// Success is 200 + JSON { ok, user_id }.
let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = 'ma-result ok';
out.textContent = '✓ logged in as ' + (body.user_id || '(unknown)') + ' — token stored.';
out.className = "ma-result ok";
out.textContent =
"✓ logged in as " +
(body.user_id || "(unknown)") +
" — token stored.";
clearSecrets(formEl);
loadAccounts(agent);
} else {
out.className = 'ma-result err';
out.textContent = '✗ login failed (unexpected response).';
out.className = "ma-result err";
out.textContent = "✗ login failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "login failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = 'ma-result err';
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
@ -256,81 +299,111 @@ async function submitLogin(e) {
// ─── GITHUB tab ─────────────────────────────────────────────────────────
async function loadGithubStatus(agent) {
const status = $('gh-status');
const status = $("gh-status");
if (!agent) {
status.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its github credential status.'));
status.replaceChildren(
el(
"p",
{ class: "meta" },
"select an agent to see its github credential status.",
),
);
return;
}
status.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
status.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data;
try {
const resp = await fetch('/api/github-account?agent=' + encodeURIComponent(agent));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const resp = await fetch(
"/api/github-account?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json();
} catch (err) {
status.replaceChildren(el('p', { class: 'err' },
'could not load status: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
status.replaceChildren(
el(
"p",
{ class: "err" },
"could not load status: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
const present = !!data.present;
status.replaceChildren(el('div', { class: 'gh-status-line' },
el('span', { class: 'gh-dot ' + (present ? 'present' : 'absent') }),
el('span', { class: 'gh-status-text ' + (present ? 'present' : 'absent') },
present ? 'token stored ✓' : 'not set'),
));
status.replaceChildren(
el(
"div",
{ class: "gh-status-line" },
el("span", { class: "gh-dot " + (present ? "present" : "absent") }),
el(
"span",
{ class: "gh-status-text " + (present ? "present" : "absent") },
present ? "token stored ✓" : "not set",
),
),
);
}
async function submitGithub(e) {
e.preventDefault();
const formEl = e.target;
const out = $('gh-result');
out.className = 'ma-result';
out.textContent = '';
const out = $("gh-result");
out.className = "ma-result";
out.textContent = "";
const agent = $('ma-agent').value;
const agent = $("ma-agent").value;
if (!agent) {
out.className = 'ma-result err';
out.textContent = 'select an agent first.';
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set('agent', agent);
fd.set("agent", agent);
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = 'storing…';
btn.textContent = "storing…";
try {
const resp = await fetch('/api/github-account', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
const resp = await fetch("/api/github-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = 'ma-result ok';
out.textContent = '✓ token stored.';
out.className = "ma-result ok";
out.textContent = "✓ token stored.";
clearSecrets(formEl);
loadGithubStatus(agent);
} else {
out.className = 'ma-result err';
out.textContent = '✗ store failed (unexpected response).';
out.className = "ma-result err";
out.textContent = "✗ store failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = 'ma-result err';
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
@ -347,39 +420,56 @@ async function submitGithub(e) {
// forge themselves.
async function loadForgeAccounts(agent) {
const list = $('ef-list');
const list = $("ef-list");
if (!agent) {
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its forge accounts.'));
list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its forge accounts."),
);
return;
}
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let forges;
try {
const resp = await fetch('/api/extra-forges?agent=' + encodeURIComponent(agent));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const resp = await fetch(
"/api/extra-forges?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
forges = (await resp.json()).forges || [];
} catch (err) {
list.replaceChildren(el('p', { class: 'err' },
'could not load forge accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
list.replaceChildren(
el(
"p",
{ class: "err" },
"could not load forge accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
list.replaceChildren();
if (!forges.length) {
list.replaceChildren(el('p', { class: 'meta' }, 'no forge accounts stored for this agent.'));
list.replaceChildren(
el("p", { class: "meta" }, "no forge accounts stored for this agent."),
);
return;
}
const ul = el('ul', { class: 'ma-accounts' });
const ul = el("ul", { class: "ma-accounts" });
for (const forge of forges) {
const btn = el('button', { class: 'btn', type: 'button' }, 'remove');
btn.addEventListener('click', () => onForgeRemoveClick(agent, forge, btn));
ul.append(el('li', { class: 'ma-account' },
el('span', { class: 'ma-dot ok' }),
el('span', { class: 'ma-name' }, forge.label),
el('span', { class: 'ma-hs' }, forge.base_url || '—'),
el('span', { class: 'ma-status ok' }, 'token stored ✓'),
btn,
));
const btn = el("button", { class: "btn", type: "button" }, "remove");
btn.addEventListener("click", () => onForgeRemoveClick(agent, forge, btn));
ul.append(
el(
"li",
{ class: "ma-account" },
el("span", { class: "ma-dot ok" }),
el("span", { class: "ma-name" }, forge.label),
el("span", { class: "ma-hs" }, forge.base_url || "—"),
el("span", { class: "ma-status ok" }, "token stored ✓"),
btn,
),
);
}
list.append(ul);
}
@ -388,18 +478,22 @@ async function onForgeRemoveClick(agent, forge, btn) {
const r = await themedConfirm({
message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`,
danger: true,
confirmLabel: '⊘ remove',
confirmLabel: "⊘ remove",
});
if (!r) return;
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = 'removing…';
btn.textContent = "removing…";
try {
const resp = await fetch('/api/extra-forge-account', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ agent, label: forge.label, action: 'remove' }),
const resp = await fetch("/api/extra-forge-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
agent,
label: forge.label,
action: "remove",
}),
});
if (resp.ok) {
loadForgeAccounts(agent);
@ -408,66 +502,76 @@ async function onForgeRemoveClick(agent, forge, btn) {
const msg = problemMessage(await readApiError(resp));
btn.textContent = orig;
btn.disabled = false;
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
themedToast("✗ " + (msg || "remove failed (HTTP " + resp.status + ")"), {
type: "error",
});
} catch (err) {
btn.textContent = orig;
btn.disabled = false;
themedToast('✗ request failed: ' + String(err), { type: 'error' });
themedToast("✗ request failed: " + String(err), { type: "error" });
}
}
async function submitForgeAccount(e) {
e.preventDefault();
const formEl = e.target;
const out = $('ef-result');
out.className = 'ma-result';
out.textContent = '';
const out = $("ef-result");
out.className = "ma-result";
out.textContent = "";
const agent = $('ma-agent').value;
const agent = $("ma-agent").value;
if (!agent) {
out.className = 'ma-result err';
out.textContent = 'select an agent first.';
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set('agent', agent);
fd.set('action', 'add');
fd.set("agent", agent);
fd.set("action", "add");
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = 'storing…';
btn.textContent = "storing…";
try {
const resp = await fetch('/api/extra-forge-account', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
const resp = await fetch("/api/extra-forge-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = 'ma-result ok';
out.textContent = '✓ forge account stored.';
out.className = "ma-result ok";
out.textContent = "✓ forge account stored.";
clearSecrets(formEl);
loadForgeAccounts(agent);
} else {
out.className = 'ma-result err';
out.textContent = '✗ store failed (unexpected response).';
out.className = "ma-result err";
out.textContent = "✗ store failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = 'ma-result err';
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
@ -485,24 +589,27 @@ async function onAgentChange(agent) {
async function init() {
await loadState();
renderAgentPicker();
$('ma-agent').addEventListener('change', (e) => onAgentChange(e.target.value));
document.querySelectorAll('input[name="mode"]')
.forEach((r) => r.addEventListener('change', toggleModeFields));
$("ma-agent").addEventListener("change", (e) =>
onAgentChange(e.target.value),
);
document
.querySelectorAll('input[name="mode"]')
.forEach((r) => r.addEventListener("change", toggleModeFields));
toggleModeFields();
$('ma-form').addEventListener('submit', submitLogin);
$('gh-form').addEventListener('submit', submitGithub);
$('ef-form').addEventListener('submit', submitForgeAccount);
$("ma-form").addEventListener("submit", submitLogin);
$("gh-form").addEventListener("submit", submitGithub);
$("ef-form").addEventListener("submit", submitForgeAccount);
document.getElementById('cred-tabbar').configure({
document.getElementById("cred-tabbar").configure({
tabs: [
{ id: 'matrix', label: 'MATRIX' },
{ id: 'github', label: 'GITHUB' },
{ id: 'forges', label: 'FORGES' },
{ id: "matrix", label: "MATRIX" },
{ id: "github", label: "GITHUB" },
{ id: "forges", label: "FORGES" },
],
defaultId: 'matrix',
defaultId: "matrix",
});
onAgentChange('');
onAgentChange("");
}
init();

View file

@ -56,7 +56,9 @@ body.dashboard-shell {
font-size: 0.82em;
white-space: nowrap;
}
.dash-home-back:hover { text-decoration: underline; }
.dash-home-back:hover {
text-decoration: underline;
}
.banner-thin {
text-align: center;
margin: 0;
@ -95,7 +97,9 @@ body.dashboard-shell {
border: 0;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
transition:
color 0.15s ease,
background 0.15s ease;
}
.tabbar .tab:hover {
color: var(--fg);
@ -105,7 +109,10 @@ body.dashboard-shell {
color: var(--purple);
background: var(--border);
}
.tab-label { font-weight: bold; white-space: nowrap; }
.tab-label {
font-weight: bold;
white-space: nowrap;
}
.tab-count {
display: inline-block;
background: var(--purple-dim);
@ -124,11 +131,12 @@ body.dashboard-shell {
color: var(--red);
}
/* Tab pane visibility createTabStrip (@hive/shared/tabs.js) sets the
`hidden` attribute on every inactive pane (resolved from the URL hash;
default no hash is SW4RM). Only the active pane stays visible. */
.tab-pane[hidden] { display: none; }
.tab-pane[hidden] {
display: none;
}
/* FL0W is a separate page (`/flow.html`) its full-viewport
styling lives in flow.css. */
@ -148,14 +156,20 @@ body.dashboard-shell {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--purple) 45%, transparent));
filter: drop-shadow(
0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)
);
}
.banner.active {
animation: banner-shimmer 1.8s linear infinite;
}
@keyframes banner-shimmer {
from { background-position: 200% 0; }
to { background-position: -100% 0; }
from {
background-position: 200% 0;
}
to {
background-position: -100% 0;
}
}
.role {
@ -172,24 +186,44 @@ body.dashboard-shell {
/* Container rows: a full-height square agent icon on the left, the
identity / actions / drill-in lines stacked in the card body on the
right. Pending rows dim everything except the pending indicator. */
.containers { display: flex; flex-direction: column; gap: 0.4em; }
.containers {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.container-row {
padding: 0.6em 0.8em;
border: 1px solid var(--border);
border-radius: 4px;
background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
transition: opacity 200ms ease, border-color 200ms ease;
transition:
opacity 200ms ease,
border-color 200ms ease;
}
/* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent
+ lane geometry paragraph) for the 1.8em-per-depth-level
rationale + CSS-attr()-not-yet-portable caveat. */
.container-row[data-depth] { position: relative; }
.container-row[data-depth="1"] { margin-left: 1.8em; }
.container-row[data-depth="2"] { margin-left: 3.6em; }
.container-row[data-depth="3"] { margin-left: 5.4em; }
.container-row[data-depth="4"] { margin-left: 7.2em; }
.container-row[data-depth="5"] { margin-left: 9em; }
.container-row[data-depth="6"] { margin-left: 10.8em; }
.container-row[data-depth] {
position: relative;
}
.container-row[data-depth="1"] {
margin-left: 1.8em;
}
.container-row[data-depth="2"] {
margin-left: 3.6em;
}
.container-row[data-depth="3"] {
margin-left: 5.4em;
}
.container-row[data-depth="4"] {
margin-left: 7.2em;
}
.container-row[data-depth="5"] {
margin-left: 9em;
}
.container-row[data-depth="6"] {
margin-left: 10.8em;
}
/* Tree prefix lanes — DOM-painted, not text-glyph-painted. */
.container-row .tree-prefix {
position: absolute;
@ -201,19 +235,31 @@ body.dashboard-shell {
user-select: none;
color: var(--purple-dim);
}
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
.container-row[data-depth="4"] .tree-prefix { left: -7.2em; }
.container-row[data-depth="5"] .tree-prefix { left: -9em; }
.container-row[data-depth="6"] .tree-prefix { left: -10.8em; }
.container-row[data-depth="1"] .tree-prefix {
left: -1.8em;
}
.container-row[data-depth="2"] .tree-prefix {
left: -3.6em;
}
.container-row[data-depth="3"] .tree-prefix {
left: -5.4em;
}
.container-row[data-depth="4"] .tree-prefix {
left: -7.2em;
}
.container-row[data-depth="5"] .tree-prefix {
left: -9em;
}
.container-row[data-depth="6"] .tree-prefix {
left: -10.8em;
}
.tree-prefix .tree-lane {
flex: 0 0 1.8em;
position: relative;
}
.tree-prefix .lane-line::before,
.tree-prefix .lane-joint::before {
content: '';
content: "";
position: absolute;
left: 0.6em;
top: 0;
@ -225,7 +271,7 @@ body.dashboard-shell {
height: 3.1em;
}
.tree-prefix .lane-joint::after {
content: '';
content: "";
position: absolute;
left: 0.6em;
top: 3.1em;
@ -246,7 +292,9 @@ body.dashboard-shell {
border-radius: 6px;
background-color: color-mix(in srgb, var(--crust) 60%, transparent);
cursor: pointer;
transition: box-shadow 120ms ease, transform 120ms ease;
transition:
box-shadow 120ms ease,
transform 120ms ease;
}
.container-row:not(.tombstone) > .container-icon:hover {
box-shadow: 0 0 0 2px var(--purple);
@ -261,7 +309,9 @@ body.dashboard-shell {
background: color-mix(in srgb, var(--purple) 6%, transparent);
}
.container-row.selected > .container-icon {
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
box-shadow:
0 0 0 2px var(--purple),
0 0 12px -4px var(--purple);
}
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
position: absolute;
@ -352,17 +402,24 @@ hive-agent-menu {
driven only now see swarm.js's pending-badge derivation comment;
there is no separate queued-but-not-running row state to tell apart
from this one anymore). */
.container-row.pending-running .actions { opacity: 0.4; pointer-events: none; }
.container-row.pending-running .actions {
opacity: 0.4;
pointer-events: none;
}
.container-row.pending-running {
border-color: var(--amber);
background: color-mix(in srgb, var(--amber) 5%, transparent);
}
@keyframes container-icon-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.container-row.pending-running > .container-icon::after {
content: '';
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
@ -382,7 +439,9 @@ hive-agent-menu {
font-size: 1.05em;
font-weight: bold;
}
.container-row .head .meta { margin-left: auto; }
.container-row .head .meta {
margin-left: auto;
}
.container-row .head .nav-strip {
display: inline-flex;
align-items: center;
@ -396,7 +455,9 @@ hive-agent-menu {
padding: 0.15em 0.35em;
border-radius: 3px;
text-decoration: none;
transition: background 0.12s ease, color 0.12s ease;
transition:
background 0.12s ease,
color 0.12s ease;
}
.nav-link:hover {
background: color-mix(in srgb, var(--purple) 12%, transparent);
@ -408,7 +469,10 @@ hive-agent-menu {
flex-wrap: wrap;
gap: 0.4em;
}
.container-row .actions form.inline { display: inline-block; margin: 0; }
.container-row .actions form.inline {
display: inline-block;
margin: 0;
}
.agent-status {
font-size: 0.82em;
@ -433,7 +497,9 @@ hive-agent-menu {
overflow: hidden;
overflow-wrap: anywhere;
}
.agent-status .status-icon { opacity: 0.65; }
.agent-status .status-icon {
opacity: 0.65;
}
.agent-status .status-age {
flex: 0 0 auto;
opacity: 0.5;
@ -446,7 +512,9 @@ hive-agent-menu {
background: color-mix(in srgb, var(--bg-elev) 35%, transparent);
opacity: 0.85;
}
.container-row.tombstone .name { color: var(--muted); }
.container-row.tombstone .name {
color: var(--muted);
}
/* K3PT ST4T3 caveat and the port-collision banner both moved to the
shared <hive-warn> component amber/no-pulse for
@ -497,13 +565,22 @@ hive-agent-menu {
animation: badge-pulse 1.6s ease-in-out infinite;
}
@keyframes badge-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* Pending approval: a card with three stacked sections identity
header, what-changed body, decision actions. */
.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; }
.approvals {
list-style: none;
padding: 0;
margin: 0.4em 0 0;
}
.approval-card {
background: var(--bg-elev);
border: 1px solid var(--border);
@ -543,7 +620,9 @@ hive-agent-menu {
padding-top: 0.45em;
border-top: 1px solid var(--border);
}
.approval-actions form.inline { display: inline; }
.approval-actions form.inline {
display: inline;
}
/* Inline drill-in triggers (logs / config repo / view diff). */
.drill-ins {
display: flex;
@ -551,7 +630,9 @@ hive-agent-menu {
gap: 0.15em 1.1em;
margin-top: 0.4em;
}
.drill-ins .panel-trigger { margin-top: 0; }
.drill-ins .panel-trigger {
margin-top: 0;
}
.approval-tabs {
display: flex;
@ -567,9 +648,14 @@ hive-agent-menu {
letter-spacing: 0.08em;
padding: 0.25em 0.9em;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
}
.approval-tab:hover {
color: var(--fg);
}
.approval-tab:hover { color: var(--fg); }
.approval-tab.active {
color: var(--purple);
border-color: var(--purple);
@ -587,18 +673,43 @@ hive-agent-menu {
padding: 0.35em 0.8em;
margin-bottom: 0.4em;
}
.approvals-history li:has(.glyph-approved) { border-left-color: var(--green); }
.approvals-history li:has(.glyph-denied) { border-left-color: var(--red); }
.approvals-history li:has(.glyph-failed) { border-left-color: var(--amber); }
.approvals-history .status { font-size: 0.85em; padding: 0 0.5em; }
.status-approved { color: var(--green); }
.status-denied { color: var(--red); }
.status-failed { color: var(--amber); }
.status-cancelled { color: var(--muted); }
.glyph-approved { color: var(--green); }
.glyph-denied { color: var(--red); }
.glyph-failed { color: var(--amber); }
.glyph-cancelled { color: var(--muted); }
.approvals-history li:has(.glyph-approved) {
border-left-color: var(--green);
}
.approvals-history li:has(.glyph-denied) {
border-left-color: var(--red);
}
.approvals-history li:has(.glyph-failed) {
border-left-color: var(--amber);
}
.approvals-history .status {
font-size: 0.85em;
padding: 0 0.5em;
}
.status-approved {
color: var(--green);
}
.status-denied {
color: var(--red);
}
.status-failed {
color: var(--amber);
}
.status-cancelled {
color: var(--muted);
}
.glyph-approved {
color: var(--green);
}
.glyph-denied {
color: var(--red);
}
.glyph-failed {
color: var(--amber);
}
.glyph-cancelled {
color: var(--muted);
}
.history-note {
margin-left: 1.8em;
@ -608,9 +719,14 @@ hive-agent-menu {
white-space: pre-wrap;
word-break: break-word;
}
ul form.inline { display: inline-block; }
ul form.inline {
display: inline-block;
}
.role-pending { color: var(--amber); border-color: var(--amber); }
.role-pending {
color: var(--amber);
border-color: var(--amber);
}
.btn-inline {
font-family: inherit;
background: transparent;
@ -619,7 +735,9 @@ ul form.inline { display: inline-block; }
}
/* Off-palette warm-amber hover tint: not an exact theme var (brighter than
--amber), left as a literal pending a dedicated named var. */
.btn-inline:hover { background: rgba(255, 184, 77, 0.1); }
.btn-inline:hover {
background: rgba(255, 184, 77, 0.1);
}
.kind {
display: inline-block;
margin-left: 0.4em;
@ -631,8 +749,13 @@ ul form.inline { display: inline-block; }
letter-spacing: 0.1em;
text-transform: uppercase;
}
.kind-spawn { color: var(--amber); border-color: var(--amber); }
details { margin-top: 0.5em; }
.kind-spawn {
color: var(--amber);
border-color: var(--amber);
}
details {
margin-top: 0.5em;
}
summary {
cursor: pointer;
color: var(--muted);
@ -640,7 +763,9 @@ summary {
text-transform: uppercase;
letter-spacing: 0.1em;
}
summary:hover { color: var(--purple); }
summary:hover {
color: var(--purple);
}
.diff {
background: var(--bg-elev);
border: 1px solid var(--border);
@ -652,12 +777,25 @@ summary:hover { color: var(--purple); }
color: var(--muted);
white-space: pre;
}
.diff span { display: block; }
.diff .diff-add { color: var(--green); }
.diff .diff-del { color: var(--red); }
.diff .diff-hunk { color: var(--cyan); }
.diff .diff-file { color: var(--purple); font-weight: bold; }
.diff .diff-ctx { color: var(--fg); }
.diff span {
display: block;
}
.diff .diff-add {
color: var(--green);
}
.diff .diff-del {
color: var(--red);
}
.diff .diff-hunk {
color: var(--cyan);
}
.diff .diff-file {
color: var(--purple);
font-weight: bold;
}
.diff .diff-ctx {
color: var(--fg);
}
footer {
margin-top: 4em;
@ -665,7 +803,9 @@ footer {
color: var(--muted);
font-size: 0.9em;
}
footer a { color: var(--purple); }
footer a {
color: var(--purple);
}
footer .banner-thin {
margin-bottom: 0.8em;
}
@ -674,8 +814,15 @@ footer .banner-thin {
Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout
as the tool-groups table below. Horizontally scrollable on narrow
viewports. */
.cap-table-wrap { overflow-x: auto; margin-top: 0.5em; }
.cap-table { border-collapse: collapse; font-size: 0.82em; min-width: 100%; }
.cap-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.cap-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.cap-table th,
.cap-table td {
padding: 0.35em 0.6em;
@ -714,16 +861,48 @@ footer .banner-thin {
Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout
as the capabilities table above. Horizontally scrollable on narrow
viewports. */
.tg-table-wrap { overflow-x: auto; margin-top: 0.5em; }
.tg-table { border-collapse: collapse; font-size: 0.82em; min-width: 100%; }
.tg-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.tg-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.tg-table th,
.tg-table td { padding: 0.35em 0.6em; border: 1px solid var(--border); text-align: center; vertical-align: middle; }
.tg-table thead th { background: var(--bg-elev); color: var(--muted); letter-spacing: 0.05em; white-space: nowrap; }
.tg-agent-col { text-align: left !important; min-width: 8em; }
.tg-group-col { min-width: 5em; }
.tg-agent-name { color: var(--fg); font-weight: 600; }
.tg-cb { cursor: pointer; width: 1em; height: 1em; accent-color: var(--purple); }
.tg-row:hover td { background: var(--bg-elev); }
.tg-table td {
padding: 0.35em 0.6em;
border: 1px solid var(--border);
text-align: center;
vertical-align: middle;
}
.tg-table thead th {
background: var(--bg-elev);
color: var(--muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.tg-agent-col {
text-align: left !important;
min-width: 8em;
}
.tg-group-col {
min-width: 5em;
}
.tg-agent-name {
color: var(--fg);
font-weight: 600;
}
.tg-cb {
cursor: pointer;
width: 1em;
height: 1em;
accent-color: var(--purple);
}
.tg-row:hover td {
background: var(--bg-elev);
}
/* permissions save bar (save-all)
One page-level save button for the whole P3RM1SS10NS tab. Sticks to
@ -747,10 +926,14 @@ footer .banner-thin {
opacity: 0.45;
cursor: not-allowed;
}
.perm-save-err { color: var(--red); }
.perm-save-err {
color: var(--red);
}
/* Stale permission entry (agent not in the live roster) */
.perm-row-stale td { opacity: 0.7; }
.perm-row-stale td {
opacity: 0.7;
}
.perm-stale-label {
font-size: 0.75em;
color: var(--muted);
@ -768,8 +951,13 @@ footer .banner-thin {
font-family: inherit;
opacity: 0.8;
}
.perm-remove-btn:hover { opacity: 1; }
.perm-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.perm-remove-btn:hover {
opacity: 1;
}
.perm-remove-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* scheduled prompts tab
Creation form at the top, list of queued schedule cards below. */
@ -816,7 +1004,10 @@ footer .banner-thin {
border-radius: 3px;
padding: 0.35em 0.5em;
}
.schedule-field textarea { resize: vertical; min-height: 4em; }
.schedule-field textarea {
resize: vertical;
min-height: 4em;
}
.schedule-targets {
display: flex;
flex-wrap: wrap;
@ -833,13 +1024,17 @@ footer .banner-thin {
cursor: pointer;
background: color-mix(in srgb, var(--bg-elev) 40%, transparent);
}
.schedule-target-chip:hover { border-color: var(--purple-dim); }
.schedule-target-chip:hover {
border-color: var(--purple-dim);
}
.schedule-target-chip:has(input:checked) {
border-color: var(--purple);
color: var(--purple);
background: color-mix(in srgb, var(--purple) 8%, transparent);
}
.schedule-target-chip input { margin: 0; }
.schedule-target-chip input {
margin: 0;
}
.schedule-actions {
display: flex;
gap: 0.5em;
@ -886,9 +1081,15 @@ footer .banner-thin {
font-size: 0.9em;
font-variant-numeric: tabular-nums;
}
.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; }
.schedule-interval-preview-oneshot {
color: var(--muted);
font-style: italic;
}
.schedules-table-wrap { overflow-x: auto; margin-top: 0.5em; }
.schedules-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.schedules-table {
min-width: 100%;
border-collapse: collapse;
@ -908,13 +1109,26 @@ footer .banner-thin {
letter-spacing: 0.05em;
white-space: nowrap;
}
.schedules-table-id { width: 3em; text-align: center; }
.schedules-table-id {
width: 3em;
text-align: center;
}
/* next / every columns: sized to typical content (short durations).
Previous 8em / 7em wasted horizontal space. */
.schedules-table-next-col { width: 5.5em; white-space: nowrap; }
.schedules-table-every-col { width: 5em; white-space: nowrap; }
.schedules-table-body-th { min-width: 12em; }
.schedules-table-actions-th { width: 7em; }
.schedules-table-next-col {
width: 5.5em;
white-space: nowrap;
}
.schedules-table-every-col {
width: 5em;
white-space: nowrap;
}
.schedules-table-body-th {
min-width: 12em;
}
.schedules-table-actions-th {
width: 7em;
}
/* Agent-name columns: 90° vertical text (writing-mode) so names are fully
readable without truncation. Previously used a -45° CSS transform which
clipped names mid-glyph. writing-mode + rotate(180deg) is in-flow and
@ -930,7 +1144,7 @@ footer .banner-thin {
overflow: hidden;
}
.schedules-table-agent-th > div {
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.85em;
color: var(--fg);
/* Rotate 90° bottom-to-top (conventional rotated column header). */
@ -953,17 +1167,28 @@ footer .banner-thin {
text-decoration: line-through;
opacity: 0.75;
}
.schedules-table-row-cancelled td { opacity: 0.55; }
.schedules-table-row-paused td { opacity: 0.75; }
.sched-paused-label { color: var(--yellow); font-size: 0.9em; }
.btn-pause-schedule { color: var(--teal); }
.btn-resume-schedule { color: var(--green); }
.schedules-table-row-cancelled td {
opacity: 0.55;
}
.schedules-table-row-paused td {
opacity: 0.75;
}
.sched-paused-label {
color: var(--yellow);
font-size: 0.9em;
}
.btn-pause-schedule {
color: var(--teal);
}
.btn-resume-schedule {
color: var(--green);
}
.schedules-table-body-cell {
max-width: 30em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.9em;
}
.schedules-table-check {
@ -1019,7 +1244,7 @@ footer .banner-thin {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.85em;
padding: 0.2em 0.4em;
border-radius: 2px;
@ -1111,13 +1336,18 @@ footer .banner-thin {
flex-wrap: wrap;
gap: 0.6em;
padding: 0.55em 1em;
background: var(--flow-frost-bg, color-mix(in srgb, var(--bg) 74%, transparent));
background: var(
--flow-frost-bg,
color-mix(in srgb, var(--bg) 74%, transparent)
);
-webkit-backdrop-filter: blur(12px) saturate(140%);
backdrop-filter: blur(12px) saturate(140%);
border-top: 1px solid var(--purple);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.4);
}
.selection-bar[hidden] { display: none; }
.selection-bar[hidden] {
display: none;
}
.selection-count {
color: var(--purple);
font-weight: bold;
@ -1150,7 +1380,9 @@ footer .banner-thin {
}
/* Pad the dashboard body so the sticky bar doesn't cover the
bottom of the agent list. Only applies when bar is visible. */
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
body.dashboard-shell.has-selection {
padding-bottom: 4.5em;
}
.move-picker {
display: inline-flex;
@ -1178,4 +1410,3 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
shared `.hive-stats-table` moved to common.css (the SYST3M
C0NT41N3R L04D table in system-sections.css / on /core.html still
uses it). */

View file

@ -1,199 +1,291 @@
<!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"
<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
<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-swarm" href="#swarm" role="tab"
aria-controls="tab-pane-swarm"
data-tab="swarm">
<span class="tab-label">◆ SW4RM ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span>
</a>
<a class="tab" id="tab-call" href="#call" role="tab"
aria-controls="tab-pane-call"
data-tab="call">
<span class="tab-label">◆ Y3R C4LL ◆</span>
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
</a>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
<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-swarm"
href="#swarm"
role="tab"
aria-controls="tab-pane-swarm"
data-tab="swarm"
>
<span class="tab-label">◆ SW4RM ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span>
</a>
<a
class="tab"
id="tab-call"
href="#call"
role="tab"
aria-controls="tab-pane-call"
data-tab="call"
>
<span class="tab-label">◆ Y3R C4LL ◆</span>
<span
class="tab-count tab-count-attn"
id="tab-count-call"
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 rebuild-queue STATE still lives in tabs.js (it drives the
"building…" badges on SW4RM agent cards); only the SYST3M tab +
its section renderers moved out. -->
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
assignments. Both tables are fetched on tab activation. -->
<a class="tab" id="tab-permissions" href="#permissions" role="tab"
aria-controls="tab-pane-permissions"
data-tab="permissions">
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<a
class="tab"
id="tab-permissions"
href="#permissions"
role="tab"
aria-controls="tab-pane-permissions"
data-tab="permissions"
>
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
schedules + an operator-direct creation form. Count pill
mirrors the active (non-cancelled) schedule count; hidden
when zero. -->
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
aria-controls="tab-pane-schedules"
data-tab="schedules">
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<a
class="tab"
id="tab-schedules"
href="#schedules"
role="tab"
aria-controls="tab-pane-schedules"
data-tab="schedules"
>
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
<!-- 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
<!-- (Peer hives are no longer a tab — they render as a headline
under SW4RM. See tab-pane-swarm.) -->
<!-- FL0W, L0GS, and M4TR1X are separate pages reachable from the
<!-- FL0W, L0GS, and M4TR1X are separate pages reachable from the
H0M3 hub (served at /), not from the dashboard tab strip — the
strip holds only real in-page tabs now. Contextual deep-links
into the log viewer (an agent's logs, a build entry's log)
still live inside the relevant dashboard content. -->
</nav>
</header>
</nav>
</header>
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL
hash (default SW4RM). Panes start `hidden` to avoid a flash before
the script runs. -->
<!-- Padded content wrapper: carries the 1.5em side gutter
<!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the
sticky chrome above + the footer below span the full width. -->
<div class="page-content">
<main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing
<div class="page-content">
<main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation
that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label SW4RM already says it. -->
<section class="tab-pane" id="tab-pane-swarm" data-tab-pane="swarm" hidden
role="tabpanel" aria-labelledby="tab-swarm">
<!-- Swarm / hive identity headline. Populated by refreshState from
<section
class="tab-pane"
id="tab-pane-swarm"
data-tab-pane="swarm"
hidden
role="tabpanel"
aria-labelledby="tab-swarm"
>
<!-- 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
<h2 id="swarm-identity" hidden></h2>
<!-- JobqRollup mount point, kept outside #containers-section
deliberately — that section is wiped + rebuilt on every
container-list render (see swarm.js::renderContainers), which
would tear down and remount the Preact tree on every
container-state tick. Mounted once by swarm.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>
<div id="jobq-rollup-section"></div>
<div id="containers-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- Y3R C4LL: things blocked on operator decision — the approval
<!-- Y3R C4LL: things blocked on operator decision — the approval
queue; surfaces full bodies inline so the operator can decide
without leaving the pane. -->
<section class="tab-pane" id="tab-pane-call" data-tab-pane="call" hidden
role="tabpanel" aria-labelledby="tab-call">
<!-- 1NB0X: unread agent→operator messages. Fetched on
<section
class="tab-pane"
id="tab-pane-call"
data-tab-pane="call"
hidden
role="tabpanel"
aria-labelledby="tab-call"
>
<!-- 1NB0X: unread agent→operator messages. Fetched on
tab activation + cold load, appended live from the broker
stream, cleared via "mark all read". Count folds into the
Y3R C4LL pill so messages aren't missed. -->
<h2>◆ 1NB0X ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="operator-inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ 1NB0X ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<div id="operator-inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<!-- Operator-local preferences (browser notifications). Was its own
<!-- Operator-local preferences (browser notifications). Was its own
S3TT1NGS page (`/settings.html`); mara moved it in here since a
standalone page for one toggle didn't earn its own tile. State
lives in the browser's localStorage — NOTIF.bind() (common.js)
wires the buttons, same dispatch NOTIF.show() already used for
approval/inbox notifications above. -->
<h2>◆ PR3F3R3NC3S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">operator-local preferences. these live in the browser's localStorage — they do not sync between devices and do not survive a profile wipe.</p>
<h3>◇ browser notifications</h3>
<p class="meta">desktop notifications for new approvals and broker messages addressed to you. requires a secure context (https or localhost). mute silences the notifications without revoking the OS-level permission.</p>
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
</section>
<h2>◆ PR3F3R3NC3S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
operator-local preferences. these live in the browser's localStorage
— they do not sync between devices and do not survive a profile
wipe.
</p>
<h3>◇ browser notifications</h3>
<p class="meta">
desktop notifications for new approvals and broker messages
addressed to you. requires a secure context (https or localhost).
mute silences the notifications without revoking the OS-level
permission.
</p>
<div id="notif-row" class="notif-row">
<button
type="button"
id="notif-enable"
class="btn btn-notif"
hidden
>
🔔 enable notifications
</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>
🔕 mute
</button>
<button
type="button"
id="notif-unmute"
class="btn btn-notif"
hidden
>
🔔 unmute
</button>
<span id="notif-status" class="meta" hidden></span>
</div>
</section>
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
meta inputs, rebuild queue, kept state, container load. Reached
from the H0M3 hub. -->
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
assignments. Both tables are column-driven from the backend
(GET /api/capabilities, GET /api/tool-groups) so new entries
require no UI change. Edits stage in-browser across both
matrices; the single page-level "save all" button POSTs every
changed agent to /api/permissions in one atomic batch (caps +
groups coalesce into one rebuild per agent). -->
<section class="tab-pane" id="tab-pane-permissions" data-tab-pane="permissions" hidden
role="tabpanel" aria-labelledby="tab-permissions">
<h2>◆ C4P4B1L1T13S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">per-agent capability grants. capabilities unlock gated MCP tools and system access. toggle any cells across both tables, then hit "save all" at the bottom — each changed agent rebuilds exactly once.</p>
<div id="capabilities-section">
<p class="meta">loading…</p>
</div>
<section
class="tab-pane"
id="tab-pane-permissions"
data-tab-pane="permissions"
hidden
role="tabpanel"
aria-labelledby="tab-permissions"
>
<h2>◆ C4P4B1L1T13S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent capability grants. capabilities unlock gated MCP tools and
system access. toggle any cells across both tables, then hit "save
all" at the bottom — each changed agent rebuilds exactly once.
</p>
<div id="capabilities-section">
<p class="meta">loading…</p>
</div>
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
cols = tool groups fetched from GET /api/tool-groups.
Checking / unchecking is staged in the UI; the single page-level
"save all" button POSTs every changed agent to
/api/permissions in one atomic batch, coalescing caps + groups
per agent into one rebuild each.
Absent agents default to the role default (shown in parens). -->
<h2>◆ T00L GR0UPS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">per-agent tool-group permissions. columns are filled from the backend — adding a new group requires no UI change. agents without an explicit entry use the role default (agents: messaging, meta, inbox, execution).</p>
<div id="tool-groups-section">
<p class="meta">loading…</p>
</div>
<h2>◆ T00L GR0UPS ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent tool-group permissions. columns are filled from the
backend — adding a new group requires no UI change. agents without
an explicit entry use the role default (agents: messaging, meta,
inbox, execution).
</p>
<div id="tool-groups-section">
<p class="meta">loading…</p>
</div>
<!-- Page-level save bar (save-all issue): one button for the whole
<!-- Page-level save bar (save-all issue): one button for the whole
permissions page. Staged checkbox edits across BOTH matrices are
diffed against their render-time baselines and POSTed together to
/api/permissions; caps + groups for one agent coalesce into a
single rebuild. Disabled until something is dirty. The atomic
batch either fully lands (queued ✓ → rebuilding) or fully fails
(error note, nothing applied). -->
<div class="perm-save-bar">
<button type="button" id="perm-save-all" class="btn btn-approve" disabled>save all</button>
<span id="perm-save-note" class="meta"></span>
</div>
</section>
<div class="perm-save-bar">
<button
type="button"
id="perm-save-all"
class="btn btn-approve"
disabled
>
save all
</button>
<span id="perm-save-note" class="meta"></span>
</div>
</section>
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
into the same table — empty bottom row is the create form
(fill cells, click ), inline-edit-row expands on the `✎`
toggle for existing schedules. Schedules list driven by
@ -203,60 +295,91 @@
`schedules_changed` SSE; tab activation re-fetches as a
safety net for disconnect windows.
See docs/web-ui.md::SCH3DUL3S tab. -->
<section class="tab-pane" id="tab-pane-schedules" data-tab-pane="schedules" hidden
role="tabpanel" aria-labelledby="tab-schedules">
<h2>◆ SCH3DUL3S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">all schedules currently in the table. fill the bottom row to queue a new schedule (operator-direct, no approval gate; recurring when an interval is set). cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p>
<div id="schedules-section">
<p class="meta">loading…</p>
</div>
<section
class="tab-pane"
id="tab-pane-schedules"
data-tab-pane="schedules"
hidden
role="tabpanel"
aria-labelledby="tab-schedules"
>
<h2>◆ SCH3DUL3S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
all schedules currently in the table. fill the bottom row to queue a
new schedule (operator-direct, no approval gate; recurring when an
interval is set). cancel a single target with the row button or the
whole schedule with <code>✕ cancel all</code>.
</p>
<div id="schedules-section">
<p class="meta">loading…</p>
</div>
</section>
</section>
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
<!-- 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
<!-- 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 -->
</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>
<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, approval diffs,
<!-- Slide-in detail panel (clicked file previews, approval diffs,
journald logs, 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
<!-- 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.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>
<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;
<!-- 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>
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -23,9 +23,11 @@ body.flow-shell {
height: 100vh;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%),
radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%
),
var(--bg);
}
@ -101,5 +103,9 @@ body.flow-shell .flow-main-slim {
padding: 0.25em 0.5em;
margin-left: auto;
}
.flow-filter:focus { outline: 1px solid var(--purple); }
.msgrow.flow-hidden { display: none; }
.flow-filter:focus {
outline: 1px solid var(--purple);
}
.msgrow.flow-hidden {
display: none;
}

View file

@ -1,54 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // FL0W</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/flow.css">
</head>
<body class="flow-shell">
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /).
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // FL0W</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/flow.css" />
</head>
<body class="flow-shell">
<!-- Minimal chrome: just a back link to the H0M3 hub (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. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">FL0W</span>
<select id="flow-agent-filter" class="flow-filter" title="filter timeline by agent" aria-label="filter timeline by agent">
<option value="">all agents</option>
</select>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">FL0W</span>
<select
id="flow-agent-filter"
class="flow-filter"
title="filter timeline by agent"
aria-label="filter timeline by agent"
>
<option value="">all agents</option>
</select>
</header>
<!-- Main content: the full-viewport terminal. Padded for the
<!-- Main content: the full-viewport terminal. Padded for the
overlay header + composer so the first/last rows stay
reachable. -->
<main class="flow-main flow-main-slim">
<div class="terminal-wrap">
<div id="msgflow" class="live terminal"><div class="meta">connecting…</div></div>
</div>
</main>
<main class="flow-main flow-main-slim">
<div class="terminal-wrap">
<div id="msgflow" class="live terminal">
<div class="meta">connecting…</div>
</div>
</div>
</main>
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
as the header — symmetric framing, terminal goes edge-to-edge
between them. -->
<footer class="flow-composer">
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea id="op-compose-input" class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1" autocomplete="off"></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</footer>
<footer class="flow-composer">
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea
id="op-compose-input"
class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1"
autocomplete="off"
></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</footer>
<!-- Flow-specific bundle. Contains the broker terminal init + the
<!-- Flow-specific bundle. Contains the broker terminal init + the
@-mention composer. Tab renderers etc. live in
`/static/tabs.js` which /flow.html doesn't load. -->
<script type="module" src="/static/flow.js" defer></script>
</body>
<script type="module" src="/static/flow.js" defer></script>
</body>
</html>

View file

@ -11,15 +11,16 @@
// initial /api/state fetch (compose autocomplete needs the live
// container list).
import { create as termCreate } from '@hive/shared/terminal.js';
import { create as termCreate } from "@hive/shared/terminal.js";
import {
$,
NOTIF,
appendLinkified,
openStream, initServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { epochSec } from './util.js';
openStream,
initServerWarnings,
} from "./common.js";
import { el } from "@hive/shared/dom.js";
import { epochSec } from "./util.js";
(() => {
NOTIF.bind();
@ -32,11 +33,16 @@ import { epochSec } from './util.js';
// by the same `container_state_changed` / `container_removed` events
// the dashboard would handle.
const flowContainers = new Map();
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
})
.catch(() => {
/* graceful: compose just shows `*` and nothing else */
});
// ─── agent filter ───────────────────────────────────────────────
// A select in the FL0W header narrows the timeline to messages involving
@ -46,40 +52,40 @@ import { epochSec } from './util.js';
// filter re-scans existing rows. Selection persists in localStorage so a
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
// so it works regardless of the message-flow IIFE's local scope.
let agentFilter = localStorage.getItem('flow-agent-filter') || '';
let agentFilter = localStorage.getItem("flow-agent-filter") || "";
function rowMatchesFilter(from, to) {
return !agentFilter || from === agentFilter || to === agentFilter;
}
function applyAgentFilter() {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
for (const row of flow.children) {
const { from, to } = row.dataset;
if (from === undefined && to === undefined) continue; // non-message row
row.classList.toggle('flow-hidden', !rowMatchesFilter(from, to));
row.classList.toggle("flow-hidden", !rowMatchesFilter(from, to));
}
}
function populateAgentFilter() {
const sel = $('flow-agent-filter');
const sel = $("flow-agent-filter");
if (!sel) return;
const names = [...flowContainers.keys()].sort();
sel.replaceChildren();
sel.append(el('option', { value: '' }, 'all agents'));
for (const n of names) sel.append(el('option', { value: n }, n));
sel.append(el("option", { value: "" }, "all agents"));
for (const n of names) sel.append(el("option", { value: n }, n));
// Preserve a saved selection even if that agent isn't in the live
// container list (yet / anymore) so the filter doesn't silently reset.
if (agentFilter && !names.includes(agentFilter)) {
sel.append(el('option', { value: agentFilter }, agentFilter));
sel.append(el("option", { value: agentFilter }, agentFilter));
}
sel.value = agentFilter;
}
{
const sel = $('flow-agent-filter');
const sel = $("flow-agent-filter");
if (sel) {
sel.addEventListener('change', () => {
sel.addEventListener("change", () => {
agentFilter = sel.value;
if (agentFilter) localStorage.setItem('flow-agent-filter', agentFilter);
else localStorage.removeItem('flow-agent-filter');
if (agentFilter) localStorage.setItem("flow-agent-filter", agentFilter);
else localStorage.removeItem("flow-agent-filter");
applyAgentFilter();
});
}
@ -91,7 +97,7 @@ import { epochSec } from './util.js';
// side effects (banner pulse, OS notifications on operator-bound
// traffic).
(() => {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
flow.replaceChildren();
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
@ -100,13 +106,16 @@ import { epochSec } from './util.js';
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
// there's no element to find. Kept for parity if a future chrome
// change reintroduces a banner.
const banner = document.querySelector('.banner');
const banner = document.querySelector(".banner");
let bannerOffTimer = null;
function pulseBanner() {
if (!banner) return;
banner.classList.add('active');
banner.classList.add("active");
if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
bannerOffTimer = setTimeout(
() => banner.classList.remove("active"),
4000,
);
}
// Map of broker row id → rendered row element. Lets reply rows add
// a visual "↳ in reply to" indicator that links back to the parent.
@ -116,55 +125,60 @@ import { epochSec } from './util.js';
function renderMsg(ev, api, glyph) {
const isReply = ev.in_reply_to != null;
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
const row = api.row(cls, '');
const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
const row = api.row(cls, "");
// Build via DOM so path anchors stay live + escape rules are
// automatic (text nodes don't need esc()).
const ts = document.createElement('span');
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
const arrow = document.createElement('span');
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
const from = document.createElement('span');
from.className = 'msg-from'; from.textContent = ev.from;
const sep = document.createElement('span');
sep.className = 'msg-sep'; sep.textContent = '→';
const to = document.createElement('span');
to.className = 'msg-to'; to.textContent = ev.to;
const body = document.createElement('span');
body.className = 'msg-body';
const ts = document.createElement("span");
ts.className = "msg-ts";
ts.textContent = tsFmt(ev.at);
const arrow = document.createElement("span");
arrow.className = "msg-arrow";
arrow.textContent = glyph;
const from = document.createElement("span");
from.className = "msg-from";
from.textContent = ev.from;
const sep = document.createElement("span");
sep.className = "msg-sep";
sep.textContent = "→";
const to = document.createElement("span");
to.className = "msg-to";
to.textContent = ev.to;
const body = document.createElement("span");
body.className = "msg-body";
appendLinkified(body, ev.body, ev.file_refs);
// Reply thread indicator: a small "↳ reply to <from>" hint that
// shows which message this is responding to. If we have the parent
// in our row map, clicking scrolls it into view.
if (isReply) {
const replyTag = document.createElement('span');
replyTag.className = 'msg-reply-tag';
const replyTag = document.createElement("span");
replyTag.className = "msg-reply-tag";
const parentRow = msgRowMap.get(ev.in_reply_to);
if (parentRow) {
const link = document.createElement('a');
link.href = '#';
link.textContent = '↳ reply';
link.title = 'scroll to parent message';
link.addEventListener('click', (e) => {
const link = document.createElement("a");
link.href = "#";
link.textContent = "↳ reply";
link.title = "scroll to parent message";
link.addEventListener("click", (e) => {
e.preventDefault();
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
parentRow.classList.add('msg-highlight');
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
parentRow.classList.add("msg-highlight");
setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
});
replyTag.append(link);
} else {
replyTag.textContent = '↳ reply';
replyTag.textContent = "↳ reply";
}
row.prepend(replyTag);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
} else {
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
}
// Tag with the participants so the agent filter can match
// on `from`/`to`, and hide the row up-front if a filter is active.
row.dataset.from = ev.from;
row.dataset.to = ev.to;
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add('flow-hidden');
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
return row;
@ -193,13 +207,13 @@ import { epochSec } from './util.js';
if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id);
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector('.msg-arrow');
if (arrow) arrow.textContent = '✓';
const arrow = s.row.querySelector(".msg-arrow");
if (arrow) arrow.textContent = "✓";
// Re-style the row as delivered (green ✓) — the collapsed line now
// represents the delivered state; it was sent + delivered as one.
s.row.classList.remove('sent');
s.row.classList.add('delivered');
s.row.title = 'sent + delivered';
s.row.classList.remove("sent");
s.row.classList.add("delivered");
s.row.title = "sent + delivered";
recentSent.delete(ev.id);
return true;
}
@ -207,11 +221,11 @@ import { epochSec } from './util.js';
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
// page (Terminal-wrap) for the backdrop-filter stacking-context
// gotcha (same shape on the flow page).
const flowMain = document.querySelector('.flow-main');
const flowMain = document.querySelector(".flow-main");
termCreate({
logEl: flow,
pillAnchor: flowMain,
historyUrl: '/api/dashboard/history',
historyUrl: "/api/dashboard/history",
// Server-side filter — only the kinds this page actually renders
// or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache).
@ -220,18 +234,19 @@ import { epochSec } from './util.js';
// JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores.
streamUrl: '/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
streamUrl:
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
// Route through the SharedWorker — see docs/web-ui.md (SSE
// multiplexing paragraph). Worker keys on the full URL incl.
// query string, so this filtered subscribe is its own upstream
// and won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream,
renderers: {
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, '→')),
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
delivered: (ev, api) => {
// Fold into the matching sent row when it just happened;
// otherwise render the delivery as its own line.
if (!collapseDelivered(ev)) renderMsg(ev, api, '✓');
if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
},
// Maintain the local containers cache from the same stream
// (compose autocomplete reads from `flowContainers`). The
@ -259,21 +274,27 @@ import { epochSec } from './util.js';
// are never replayed, so without this the compose autocomplete
// could drift stale.
onStreamOpen: () => {
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
}).catch(() => {});
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
})
.catch(() => {});
},
onLiveEvent: (ev) => {
pulseBanner();
if (ev.kind === 'sent' && ev.to === 'operator') {
if (ev.kind === "sent" && ev.to === "operator") {
NOTIF.show(
'◆ ' + ev.from + ' → operator',
String(ev.body || '').slice(0, 200),
"◆ " + ev.from + " → operator",
String(ev.body || "").slice(0, 200),
// Unique-per-arrival tag so a burst stacks instead of
// overwriting itself in the OS notification center.
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
"hyperhive:msg:" +
ev.at +
":" +
Math.random().toString(36).slice(2, 6),
);
}
},
@ -282,28 +303,27 @@ import { epochSec } from './util.js';
// ─── compose: @-mention with sticky recipient ───────────────────────────
(() => {
const input = $('op-compose-input');
const prompt = $('op-compose-prompt');
const suggest = $('op-compose-suggest');
const input = $("op-compose-input");
const prompt = $("op-compose-prompt");
const suggest = $("op-compose-suggest");
if (!input || !prompt || !suggest) return;
const STORAGE_KEY = 'hyperhive:op-compose:to';
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
const STORAGE_KEY = "hyperhive:op-compose:to";
let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
let suggestActive = -1;
function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
}
function knownAgents() {
// Read live from the flow-local containers cache so newly-spawned
// agents become addressable without a manual reload.
const names = Array.from(flowContainers.values())
.map((c) => c.name);
const names = Array.from(flowContainers.values()).map((c) => c.name);
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift('*');
names.unshift("*");
return names;
}
function autosize() {
input.style.height = 'auto';
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
}
/// Parse "@name body…" — return {to, body} when the input opens
@ -320,12 +340,15 @@ import { epochSec } from './util.js';
}
function renderSuggest(matches) {
suggest.replaceChildren();
if (!matches.length) { hideSuggest(); return; }
if (!matches.length) {
hideSuggest();
return;
}
for (let i = 0; i < matches.length; i += 1) {
const item = document.createElement('div');
item.className = 'item' + (i === suggestActive ? ' active' : '');
item.textContent = '@' + matches[i];
item.addEventListener('mousedown', (e) => {
const item = document.createElement("div");
item.className = "item" + (i === suggestActive ? " active" : "");
item.textContent = "@" + matches[i];
item.addEventListener("mousedown", (e) => {
e.preventDefault();
applySuggestion(matches[i]);
});
@ -338,7 +361,7 @@ import { epochSec } from './util.js';
const v = input.value;
const m = v.match(/^@(\S*)/);
if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
} else {
input.value = `@${name} ` + v;
}
@ -353,11 +376,20 @@ import { epochSec } from './util.js';
// input — switching recipient is always "redirect this whole
// line." Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)/);
if (!m) { hideSuggest(); return; }
if (!m) {
hideSuggest();
return;
}
const partial = m[1].toLowerCase();
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
if (!matches.length) { hideSuggest(); return; }
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
const matches = knownAgents().filter((n) =>
n.toLowerCase().startsWith(partial),
);
if (!matches.length) {
hideSuggest();
return;
}
if (suggestActive < 0 || suggestActive >= matches.length)
suggestActive = 0;
renderSuggest(matches);
}
async function submit() {
@ -373,20 +405,20 @@ import { epochSec } from './util.js';
to = stickyTo;
body = raw;
} else {
flashError('no recipient — start with @name to address a message');
flashError("no recipient — start with @name to address a message");
return;
}
if (!body) return;
const fd = new FormData();
fd.append('to', to);
fd.append('body', body);
fd.append("to", to);
fd.append("body", body);
input.disabled = true;
try {
// /op-send returns 200. The SSE channel carries the resulting
// MessageEvent → the terminal renders the sent row on its own;
// no /api/state refetch needed.
const resp = await fetch('/api/op-send', {
method: 'POST',
const resp = await fetch("/api/op-send", {
method: "POST",
body: new URLSearchParams(fd),
});
if (!resp.ok) {
@ -401,17 +433,17 @@ import { epochSec } from './util.js';
}
stickyTo = to;
localStorage.setItem(STORAGE_KEY, to);
input.value = '';
input.value = "";
autosize();
renderPrompt();
input.focus();
}
function flashError(msg) {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
const row = document.createElement('div');
row.className = 'msgrow meta';
row.textContent = '✗ ' + msg;
const row = document.createElement("div");
row.className = "msgrow meta";
row.textContent = "✗ " + msg;
// Append at the bottom so the error is visible — the terminal
// renders newest-last, so inserting before firstChild would place
// the error at the top (oldest end) and hide it from view.
@ -420,43 +452,46 @@ import { epochSec } from './util.js';
const wrap = flow.parentElement;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
input.addEventListener('input', () => { autosize(); updateSuggest(); });
input.addEventListener('keydown', (e) => {
input.addEventListener("input", () => {
autosize();
updateSuggest();
});
input.addEventListener("keydown", (e) => {
if (!suggest.hidden) {
if (e.key === 'ArrowDown') {
const items = suggest.querySelectorAll('.item');
if (e.key === "ArrowDown") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive + 1) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'ArrowUp') {
const items = suggest.querySelectorAll('.item');
if (e.key === "ArrowUp") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive - 1 + items.length) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
const active = suggest.querySelector('.item.active');
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
const active = suggest.querySelector(".item.active");
if (active) {
applySuggestion(active.textContent.slice(1));
e.preventDefault();
return;
}
}
if (e.key === 'Escape') {
if (e.key === "Escape") {
hideSuggest();
e.preventDefault();
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
});
input.addEventListener('blur', () => {
input.addEventListener("blur", () => {
// Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100);
});

View file

@ -57,13 +57,17 @@ body.home-shell {
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,
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);
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

View file

@ -7,14 +7,14 @@
// It also drives the decorative matrix-rain backdrop (#matrix-rain) —
// see startMatrixRain() at the foot.
import { renderServerWarnings } from './common.js';
import { renderServerWarnings } from "./common.js";
const $ = (id) => document.getElementById(id);
async function init() {
let state;
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (!resp.ok) return;
state = await resp.json();
} catch {
@ -23,10 +23,10 @@ async function init() {
renderServerWarnings(state.server_warnings);
const ident = $('hive-identity');
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.textContent = parts.join(" / ");
ident.hidden = false;
}
@ -41,10 +41,12 @@ async function init() {
// 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');
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);
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;
@ -61,34 +63,40 @@ init();
// 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');
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');
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');
const probe = document.createElement("span");
probe.style.color = `var(${varName})`;
probe.style.display = 'none';
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)');
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 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)';
: "rgba(30, 30, 46, 0.09)";
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦';
const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
const CELL = 16; // glyph cell size (px)
let cols = 0;
let drops = [];
@ -109,18 +117,28 @@ function startMatrixRain() {
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;
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; } };
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()));
window.addEventListener("resize", resize);
document.addEventListener("visibilitychange", () =>
document.hidden ? pause() : play(),
);
play();
}

View file

@ -1,112 +1,122 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // h0m3</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">
</head>
<body class="home-shell">
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h0m3</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" />
</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>
<canvas id="matrix-rain" aria-hidden="true"></canvas>
<!-- H0M3: the menu hub. A plain grid of links to every top-level
<!-- 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. -->
<!-- Padded content wrapper: carries the 1.5em side gutter
<!-- 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. -->
<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>
<p class="banner-thin" id="hive-rev" hidden></p>
</header>
<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
>
<p class="banner-thin" id="hive-rev" hidden></p>
</header>
<main class="home-main">
<nav class="home-menu" aria-label="hyperhive surfaces">
<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 · approvals · permissions · schedules · system</span
>
</a>
<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 · approvals · permissions · schedules · system</span>
</a>
<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>
<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>
<a class="home-tile" href="/builds.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔨</span>
<span class="home-tile-label">Builds</span>
</span>
<span class="home-tile-desc"
>rebuild queue · meta inputs · build logs</span
>
</a>
<a class="home-tile" href="/builds.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔨</span>
<span class="home-tile-label">Builds</span>
</span>
<span class="home-tile-desc">rebuild queue · meta inputs · build logs</span>
</a>
<a class="home-tile" href="/logs.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📜</span>
<span class="home-tile-label">Logs</span>
</span>
<span class="home-tile-desc">agent · infra · system logs</span>
</a>
<a class="home-tile" href="/logs.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📜</span>
<span class="home-tile-label">Logs</span>
</span>
<span class="home-tile-desc">agent · infra · system logs</span>
</a>
<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>
<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>
<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 · container load</span>
</a>
<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 · container load</span>
</a>
<a class="home-tile" href="/credentials.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔑</span>
<span class="home-tile-label">Credentials</span>
</span>
<span class="home-tile-desc"
>provision per-agent matrix + github accounts</span
>
</a>
<a class="home-tile" href="/credentials.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔑</span>
<span class="home-tile-label">Credentials</span>
</span>
<span class="home-tile-desc">provision per-agent matrix + github accounts</span>
</a>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
<!-- 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>
<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>
</main>
</div>
<!-- /.page-content -->
</nav>
</main>
</div><!-- /.page-content -->
<script type="module" src="/static/home.js" defer></script>
</body>
<script type="module" src="/static/home.js" defer></script>
</body>
</html>

View file

@ -33,7 +33,9 @@ body.logs-shell {
flex-direction: column;
}
.logs-pane[hidden] { display: none; }
.logs-pane[hidden] {
display: none;
}
.logs-pane {
flex: 1 1 0;
min-height: 0;
@ -75,9 +77,15 @@ body.logs-shell {
padding: 0.25em 0.5em;
font-size: 0.85em;
}
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
.journal-refresh {
font-size: 0.75em;
padding: 0.15em 0.5em;
}
/* "fetched N ago" chip next to the refresh button on AGENT + SYSTEM tabs. */
.logs-fetch-ts { font-size: 0.8em; color: var(--muted); }
.logs-fetch-ts {
font-size: 0.8em;
color: var(--muted);
}
.journal-output {
margin: 0;
background: var(--crust);
@ -108,7 +116,10 @@ body.logs-shell {
gap: 0.5em;
align-items: center;
}
.build-logs-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
.build-logs-refresh {
font-size: 0.75em;
padding: 0.15em 0.5em;
}
.build-logs-list {
list-style: none;
padding: 0;
@ -124,7 +135,9 @@ body.logs-shell {
font-size: 0.85em;
padding: 0.3em 0;
}
.build-logs-error { color: var(--red); }
.build-logs-error {
color: var(--red);
}
.build-logs-item {
border-bottom: 1px solid var(--border);
padding: 0.15em 0;
@ -145,9 +158,15 @@ body.logs-shell {
cursor: pointer;
border-radius: 3px;
}
.build-logs-row-btn:hover { background: var(--bg-elev); }
.build-logs-kind { font-weight: 600; }
.build-logs-age { font-size: 0.88em; }
.build-logs-row-btn:hover {
background: var(--bg-elev);
}
.build-logs-kind {
font-weight: 600;
}
.build-logs-age {
font-size: 0.88em;
}
.build-logs-cmdline {
color: var(--muted);
font-size: 0.82em;
@ -156,7 +175,9 @@ body.logs-shell {
white-space: nowrap;
max-width: 40em;
}
.build-logs-detail { padding: 0 0.4em 0.4em; }
.build-logs-detail {
padding: 0 0.4em 0.4em;
}
.build-logs-output {
margin: 0.3em 0 0;
padding: 0.4em 0.6em;
@ -181,16 +202,32 @@ body.logs-shell {
margin-top: 0.3em;
display: none;
}
.build-logs-dl:not([hidden]) { display: inline-block; }
.build-logs-dl:hover { color: var(--fg); border-color: var(--purple-dim); }
.build-logs-dl:not([hidden]) {
display: inline-block;
}
.build-logs-dl:hover {
color: var(--fg);
border-color: var(--purple-dim);
}
.build-logs-live-badge { margin-bottom: 0.4em; }
.build-logs-live-badge {
margin-bottom: 0.4em;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.build-logs-live-badge.badge-running {
animation: live-pulse 1.4s ease-in-out infinite;
}
.build-logs-live-badge.badge-running { animation: live-pulse 1.4s ease-in-out infinite; }
.build-logs-runtime { font-size: 0.85em; color: var(--muted); }
.build-logs-runtime {
font-size: 0.85em;
color: var(--muted);
}

View file

@ -1,82 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // LOGS</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/logs.css">
</head>
<body class="logs-shell">
<!-- Minimal chrome: back link + sub-tab strip.
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // LOGS</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/logs.css" />
</head>
<body class="logs-shell">
<!-- Minimal chrome: back link + sub-tab strip.
Same pattern as flow.html — no full dashboard tabbar. Back link
points to the H0M3 hub (served at /), not the dashboard.
Three sub-tabs: AGENT, INFRA, SYSTEM. Build log history has
moved to /builds.html (the build lifecycle hub). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar logs-tabbar" id="logs-tabbar" prefix="logs"
role="tablist"></hive-tab-strip>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar logs-tabbar"
id="logs-tabbar"
prefix="logs"
role="tablist"
></hive-tab-strip>
</header>
<main class="logs-main">
<!-- AGENT: journald viewer for a specific agent container.
<main class="logs-main">
<!-- AGENT: journald viewer for a specific agent container.
Agent selector + unit filter + line count. Backed by
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
<section class="logs-pane" id="logs-pane-agent" data-tab-pane="agent"
role="tabpanel" aria-labelledby="logs-tab-agent">
<div class="logs-toolbar">
<select id="agent-select" class="journal-unit"></select>
<select id="agent-unit-select" class="journal-unit">
<option value="hive-agent.service">hive-agent.service</option>
<option value="hive-mcp-http.service">hive-mcp-http.service</option>
<option value="hive-bash-daemon.service">hive-bash-daemon.service</option>
<option value="hive-matrix-daemon.service">hive-matrix-daemon.service</option>
<option value="">(full machine journal)</option>
</select>
<button type="button" class="btn btn-restart" id="agent-refresh">↻ refresh</button>
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="agent-output" class="journal-output">select an agent above</pre>
</section>
<section
class="logs-pane"
id="logs-pane-agent"
data-tab-pane="agent"
role="tabpanel"
aria-labelledby="logs-tab-agent"
>
<div class="logs-toolbar">
<select id="agent-select" class="journal-unit"></select>
<select id="agent-unit-select" class="journal-unit">
<option value="hive-agent.service">hive-agent.service</option>
<option value="hive-mcp-http.service">hive-mcp-http.service</option>
<option value="hive-bash-daemon.service">
hive-bash-daemon.service
</option>
<option value="hive-matrix-daemon.service">
hive-matrix-daemon.service
</option>
<option value="">(full machine journal)</option>
</select>
<button type="button" class="btn btn-restart" id="agent-refresh">
↻ refresh
</button>
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="agent-output" class="journal-output">
select an agent above</pre
>
</section>
<!-- INFRA: journald viewer for infrastructure containers (hive-ci,
<!-- INFRA: journald viewer for infrastructure containers (hive-ci,
hive-forge, hive-gateway, hive-matrix). Always fetches the full
machine journal — no unit filter (infra containers don't run the
per-agent hive daemons). Backed by GET /api/journal/{name}?lines=N.
Deep-link: ?agent=hive-ci routes here instead of the AGENT tab. -->
<section class="logs-pane" id="logs-pane-infra" data-tab-pane="infra"
role="tabpanel" aria-labelledby="logs-tab-infra">
<div class="logs-toolbar">
<select id="infra-select" class="journal-unit"></select>
<button type="button" class="btn btn-restart" id="infra-refresh">↻ refresh</button>
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="infra-output" class="journal-output">select a container above</pre>
</section>
<section
class="logs-pane"
id="logs-pane-infra"
data-tab-pane="infra"
role="tabpanel"
aria-labelledby="logs-tab-infra"
>
<div class="logs-toolbar">
<select id="infra-select" class="journal-unit"></select>
<button type="button" class="btn btn-restart" id="infra-refresh">
↻ refresh
</button>
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="infra-output" class="journal-output">
select a container above</pre
>
</section>
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
journal via GET /api/journal-host?unit=hive-c0re.service. -->
<section class="logs-pane" id="logs-pane-system" data-tab-pane="system"
role="tabpanel" aria-labelledby="logs-tab-system">
<div class="logs-toolbar">
<select id="system-unit-select" class="journal-unit">
<option value="hive-c0re.service">hive-c0re.service</option>
<option value="hive-priv.service">hive-priv.service</option>
</select>
<button type="button" class="btn btn-restart" id="system-refresh">↻ refresh</button>
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="system-output" class="journal-output">loading…</pre>
</section>
<section
class="logs-pane"
id="logs-pane-system"
data-tab-pane="system"
role="tabpanel"
aria-labelledby="logs-tab-system"
>
<div class="logs-toolbar">
<select id="system-unit-select" class="journal-unit">
<option value="hive-c0re.service">hive-c0re.service</option>
<option value="hive-priv.service">hive-priv.service</option>
</select>
<button type="button" class="btn btn-restart" id="system-refresh">
↻ refresh
</button>
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="system-output" class="journal-output">loading…</pre>
</section>
</main>
</main>
<script type="module" src="/static/logs.js" defer></script>
</body>
<script type="module" src="/static/logs.js" defer></script>
</body>
</html>

View file

@ -12,11 +12,9 @@
// Last-fetched timestamp is shown next to the refresh button on AGENT,
// INFRA, and SYSTEM tabs so the operator knows how stale the output is.
import {
$, fmtAgeSecs, initServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/hive-tab-strip.js';
import { $, fmtAgeSecs, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import "@hive/shared/hive-tab-strip.js";
(() => {
initServerWarnings();
@ -34,34 +32,43 @@ import '@hive/shared/hive-tab-strip.js';
// Format a last-fetched timestamp: "fetched just now" / "fetched 3m ago".
function fmtFetchTs(fetchedAt) {
const ageSecs = Math.floor((Date.now() - fetchedAt) / 1000);
return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago');
return (
"fetched " + (ageSecs < 5 ? "just now" : fmtAgeSecs(ageSecs) + " ago")
);
}
// ─── AGENT tab ────────────────────────────────────────────────────────
const agentSelect = $('agent-select');
const agentUnitSelect = $('agent-unit-select');
const agentRefresh = $('agent-refresh');
const agentOutput = $('agent-output');
const agentFetchTs = $('agent-fetch-ts');
const agentSelect = $("agent-select");
const agentUnitSelect = $("agent-unit-select");
const agentRefresh = $("agent-refresh");
const agentOutput = $("agent-output");
const agentFetchTs = $("agent-fetch-ts");
let agentFetching = false;
let agentLastFetch = 0;
async function fetchAgent() {
if (!agentSelect || !agentOutput) return;
const name = agentSelect.value;
if (!name) { agentOutput.textContent = 'select an agent above'; return; }
if (!name) {
agentOutput.textContent = "select an agent above";
return;
}
if (agentFetching) return;
agentFetching = true;
agentOutput.textContent = 'fetching…';
agentOutput.textContent = "fetching…";
if (agentFetchTs) agentFetchTs.hidden = true;
const unit = agentUnitSelect ? agentUnitSelect.value : '';
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
const unit = agentUnitSelect ? agentUnitSelect.value : "";
const params = new URLSearchParams({ lines: "500" });
if (unit) params.set("unit", unit);
try {
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params);
const resp = await fetch(
"/api/journal/" + encodeURIComponent(name) + "?" + params,
);
const text = await resp.text();
agentOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
agentOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
agentOutput.scrollTop = agentOutput.scrollHeight;
if (resp.ok) {
agentLastFetch = Date.now();
@ -71,41 +78,48 @@ import '@hive/shared/hive-tab-strip.js';
}
}
} catch (err) {
agentOutput.textContent = 'fetch failed: ' + err;
agentOutput.textContent = "fetch failed: " + err;
} finally {
agentFetching = false;
}
}
if (agentSelect) agentSelect.addEventListener('change', fetchAgent);
if (agentUnitSelect) agentUnitSelect.addEventListener('change', fetchAgent);
if (agentRefresh) agentRefresh.addEventListener('click', fetchAgent);
if (agentSelect) agentSelect.addEventListener("change", fetchAgent);
if (agentUnitSelect) agentUnitSelect.addEventListener("change", fetchAgent);
if (agentRefresh) agentRefresh.addEventListener("click", fetchAgent);
// ─── INFRA tab ────────────────────────────────────────────────────────
// Infra containers (hive-ci, hive-forge, hive-gateway, hive-matrix) don't
// run the per-agent hive daemons, so the unit filter is inapplicable. We
// always fetch the full machine journal for them.
const infraSelect = $('infra-select');
const infraRefresh = $('infra-refresh');
const infraOutput = $('infra-output');
const infraFetchTs = $('infra-fetch-ts');
const infraSelect = $("infra-select");
const infraRefresh = $("infra-refresh");
const infraOutput = $("infra-output");
const infraFetchTs = $("infra-fetch-ts");
let infraFetching = false;
let infraLastFetch = 0;
async function fetchInfra() {
if (!infraSelect || !infraOutput) return;
const name = infraSelect.value;
if (!name) { infraOutput.textContent = 'select a container above'; return; }
if (!name) {
infraOutput.textContent = "select a container above";
return;
}
if (infraFetching) return;
infraFetching = true;
infraOutput.textContent = 'fetching…';
infraOutput.textContent = "fetching…";
if (infraFetchTs) infraFetchTs.hidden = true;
const params = new URLSearchParams({ lines: '500' });
const params = new URLSearchParams({ lines: "500" });
try {
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params);
const resp = await fetch(
"/api/journal/" + encodeURIComponent(name) + "?" + params,
);
const text = await resp.text();
infraOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
infraOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
infraOutput.scrollTop = infraOutput.scrollHeight;
if (resp.ok) {
infraLastFetch = Date.now();
@ -115,20 +129,20 @@ import '@hive/shared/hive-tab-strip.js';
}
}
} catch (err) {
infraOutput.textContent = 'fetch failed: ' + err;
infraOutput.textContent = "fetch failed: " + err;
} finally {
infraFetching = false;
}
}
if (infraSelect) infraSelect.addEventListener('change', fetchInfra);
if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra);
if (infraSelect) infraSelect.addEventListener("change", fetchInfra);
if (infraRefresh) infraRefresh.addEventListener("click", fetchInfra);
// Fixed allowlist — the four hive infra services never change at
// runtime, and there's no dashboard API exposing just the name list
// (the one that used to, `/api/state`'s `infra_containers` field, was
// start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`.
const INFRA_NAMES = ['hive-ci', 'hive-forge', 'hive-gateway', 'hive-matrix'];
const INFRA_NAMES = ["hive-ci", "hive-forge", "hive-gateway", "hive-matrix"];
// ─── container list init ──────────────────────────────────────────────
// Fetch /api/state once to populate the AGENT selector (agents only);
@ -138,59 +152,64 @@ import '@hive/shared/hive-tab-strip.js';
async function loadContainerLists() {
if (infraSelect) {
infraSelect.replaceChildren();
infraSelect.append(el('option', { value: '' }, '— select container —'));
infraSelect.append(el("option", { value: "" }, "— select container —"));
for (const name of INFRA_NAMES) {
infraSelect.append(el('option', { value: name }, name));
infraSelect.append(el("option", { value: name }, name));
}
}
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (!resp.ok) return;
const state = await resp.json();
// Populate AGENT selector (agents only — no infra optgroup).
if (agentSelect) {
agentSelect.replaceChildren();
agentSelect.append(el('option', { value: '' }, '— select agent —'));
for (const c of (state.containers || [])) {
agentSelect.append(el('option', { value: c.name }, c.name));
agentSelect.append(el("option", { value: "" }, "— select agent —"));
for (const c of state.containers || []) {
agentSelect.append(el("option", { value: c.name }, c.name));
}
}
// Deep-link: honour ?agent= and ?unit= URL params.
const urlAgent = new URLSearchParams(location.search).get('agent');
const urlUnit = new URLSearchParams(location.search).get('unit');
const urlAgent = new URLSearchParams(location.search).get("agent");
const urlUnit = new URLSearchParams(location.search).get("unit");
if (urlAgent) {
if (INFRA_NAMES.includes(urlAgent)) {
// Route to INFRA tab.
logTabs.show('infra');
logTabs.show("infra");
if (infraSelect) {
infraSelect.value = urlAgent;
fetchInfra();
}
} else if (agentSelect) {
// Route to AGENT tab.
const found = Array.from(agentSelect.options).some((o) => o.value === urlAgent);
const found = Array.from(agentSelect.options).some(
(o) => o.value === urlAgent,
);
if (found) {
agentSelect.value = urlAgent;
if (urlUnit && agentUnitSelect) {
const unitFound = Array.from(agentUnitSelect.options)
.some((o) => o.value === urlUnit);
const unitFound = Array.from(agentUnitSelect.options).some(
(o) => o.value === urlUnit,
);
if (unitFound) agentUnitSelect.value = urlUnit;
}
fetchAgent();
}
}
}
} catch { /**/ }
} catch {
/**/
}
}
// ─── SYSTEM tab ───────────────────────────────────────────────────────
const systemUnitSelect = $('system-unit-select');
const systemRefresh = $('system-refresh');
const systemOutput = $('system-output');
const systemFetchTs = $('system-fetch-ts');
const systemUnitSelect = $("system-unit-select");
const systemRefresh = $("system-refresh");
const systemOutput = $("system-output");
const systemFetchTs = $("system-fetch-ts");
let systemFetching = false;
let systemLastFetch = 0;
@ -198,15 +217,19 @@ import '@hive/shared/hive-tab-strip.js';
if (!systemOutput) return;
if (systemFetching) return;
systemFetching = true;
systemOutput.textContent = 'fetching…';
systemOutput.textContent = "fetching…";
if (systemFetchTs) systemFetchTs.hidden = true;
const unit = systemUnitSelect ? systemUnitSelect.value : 'hive-c0re.service';
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
const unit = systemUnitSelect
? systemUnitSelect.value
: "hive-c0re.service";
const params = new URLSearchParams({ lines: "500" });
if (unit) params.set("unit", unit);
try {
const resp = await fetch('/api/journal-host?' + params);
const resp = await fetch("/api/journal-host?" + params);
const text = await resp.text();
systemOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
systemOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
systemOutput.scrollTop = systemOutput.scrollHeight;
if (resp.ok) {
systemLastFetch = Date.now();
@ -216,14 +239,15 @@ import '@hive/shared/hive-tab-strip.js';
}
}
} catch (err) {
systemOutput.textContent = 'fetch failed: ' + err;
systemOutput.textContent = "fetch failed: " + err;
} finally {
systemFetching = false;
}
}
if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem);
if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem);
if (systemUnitSelect)
systemUnitSelect.addEventListener("change", fetchSystem);
if (systemRefresh) systemRefresh.addEventListener("click", fetchSystem);
// ─── init ─────────────────────────────────────────────────────────────
@ -231,15 +255,15 @@ import '@hive/shared/hive-tab-strip.js';
// needs are defined. Its initial show() paints the active pane and, if
// the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
// Default: AGENT.
logTabs = document.getElementById('logs-tabbar').configure({
logTabs = document.getElementById("logs-tabbar").configure({
tabs: [
{ id: 'agent', label: 'AGENT' },
{ id: 'infra', label: 'INFRA' },
{ id: 'system', label: 'SYSTEM' },
{ id: "agent", label: "AGENT" },
{ id: "infra", label: "INFRA" },
{ id: "system", label: "SYSTEM" },
],
defaultId: 'agent',
defaultId: "agent",
onShow: (id) => {
if (id === 'system') fetchSystem();
if (id === "system") fetchSystem();
},
});
loadContainerLists();
@ -257,5 +281,4 @@ import '@hive/shared/hive-tab-strip.js';
systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
}
}, 30_000);
})();

View file

@ -21,10 +21,10 @@
// coalesces caps+groups per agent into ONE queue entry (one rebuild, no
// double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200.
import { $ } from './common.js';
import { el } from '@hive/shared/dom.js';
import { containersState } from './state.js';
import { asyncBtn } from '@hive/shared/forms.js';
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { containersState } from "./state.js";
import { asyncBtn } from "@hive/shared/forms.js";
// ── SSE re-render guards ────────────────────────────────────────────
// Skip the live re-render when the operator has unsaved edits in that
@ -33,14 +33,14 @@ import { asyncBtn } from '@hive/shared/forms.js';
// and the post-save re-fetch are the recovery paths; both clear dirty.
export function applyCapabilitiesChanged(ev) {
const root = $('capabilities-section');
const root = $("capabilities-section");
if (!root) return;
if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return;
renderCapabilities(root, ev);
}
export function applyToolGroupsChanged(ev) {
const root = $('tool-groups-section');
const root = $("tool-groups-section");
if (!root) return;
if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return;
@ -50,25 +50,25 @@ export function applyToolGroupsChanged(ev) {
// A section is dirty if any checkbox diverges from its render-time
// baseline. Cheap DOM scan; no module-level mirror to drift.
function sectionHasDirty(root) {
for (const cb of root.querySelectorAll('input[type=checkbox]')) {
if (cb.checked !== (cb.dataset.baseline === '1')) return true;
for (const cb of root.querySelectorAll("input[type=checkbox]")) {
if (cb.checked !== (cb.dataset.baseline === "1")) return true;
}
return false;
}
export async function fetchAndRenderCapabilities() {
const root = $('capabilities-section');
const root = $("capabilities-section");
if (!root) return;
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'loading…'));
root.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch('/api/capabilities');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/capabilities");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderCapabilities(root, data);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
@ -76,7 +76,7 @@ function renderCapabilities(root, data) {
root.replaceChildren();
const { caps, descriptions = {}, assignments, effective = {} } = data;
if (!caps || !caps.length) {
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
root.append(el("p", { class: "meta" }, "(no capabilities defined)"));
updateSaveBar();
return;
}
@ -84,33 +84,36 @@ function renderCapabilities(root, data) {
// Agent rows: prefer the backend roster (every manageable agent,
// default-perms included). Fall back to the live-container explicit
// union for older payloads that don't carry `agents`.
const agentNames = (data.agents && data.agents.length)
? [...data.agents]
: [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
const agentNames =
data.agents && data.agents.length
? [...data.agents]
: [
...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
]),
].sort();
if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)'));
root.append(el("p", { class: "meta" }, "(no agents)"));
updateSaveBar();
return;
}
const wrap = el('div', { class: 'cap-table-wrap' });
const table = el('table', { class: 'cap-table' });
const wrap = el("div", { class: "cap-table-wrap" });
const table = el("table", { class: "cap-table" });
// Header row.
const thead = el('thead');
const hrow = el('tr');
hrow.append(el('th', { class: 'cap-agent-col' }, 'agent'));
const thead = el("thead");
const hrow = el("tr");
hrow.append(el("th", { class: "cap-agent-col" }, "agent"));
for (const c of caps) {
hrow.append(el('th', { class: 'cap-col', title: descriptions[c] || c }, c));
hrow.append(el("th", { class: "cap-col", title: descriptions[c] || c }, c));
}
thead.append(hrow);
table.append(thead);
const tbody = el('tbody');
const tbody = el("tbody");
for (const name of agentNames) {
// Effective caps (explicit-or-default) drive the checkboxes so
// default-perms agents show their real grants, not blank.
@ -119,19 +122,26 @@ function renderCapabilities(root, data) {
// includes stopped-but-configured containers — so a temporarily-stopped
// agent is NOT stale. Only destroyed/renamed agents are absent here.
const isStale = !containersState.has(name);
const tr = el('tr', { class: 'cap-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name });
const tr = el("tr", {
class: "cap-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell.
const nameTd = el('td', { class: 'cap-agent-col' });
nameTd.append(el('span', { class: 'cap-agent-name' }, name));
const nameTd = el("td", { class: "cap-agent-col" });
nameTd.append(el("span", { class: "cap-agent-name" }, name));
if (isStale) {
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)'));
const removeBtn = el('button', {
type: 'button',
class: 'perm-remove-btn',
title: 'remove stale permission entries for ' + name,
}, '✕ remove');
removeBtn.addEventListener('click', () => clearStaleAgent(name, root));
nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
const removeBtn = el(
"button",
{
type: "button",
class: "perm-remove-btn",
title: "remove stale permission entries for " + name,
},
"✕ remove",
);
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
nameTd.append(removeBtn);
}
tr.append(nameTd);
@ -139,16 +149,16 @@ function renderCapabilities(root, data) {
// One checkbox per capability.
for (const c of caps) {
const checked = assigned.includes(c);
const td = el('td', { class: 'cap-col' });
const cb = el('input', {
type: 'checkbox',
class: 'cap-cb',
'data-cap': c,
'data-baseline': checked ? '1' : '0',
'aria-label': c,
const td = el("td", { class: "cap-col" });
const cb = el("input", {
type: "checkbox",
class: "cap-cb",
"data-cap": c,
"data-baseline": checked ? "1" : "0",
"aria-label": c,
});
cb.checked = checked;
cb.addEventListener('change', onCellToggle);
cb.addEventListener("change", onCellToggle);
td.append(cb);
tr.append(td);
}
@ -162,18 +172,18 @@ function renderCapabilities(root, data) {
}
export async function fetchAndRenderToolGroups() {
const root = $('tool-groups-section');
const root = $("tool-groups-section");
if (!root) return;
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'loading…'));
root.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch('/api/tool-groups');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/tool-groups");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderToolGroups(root, data);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
@ -181,40 +191,45 @@ function renderToolGroups(root, data) {
root.replaceChildren();
const { groups, descriptions = {}, assignments, effective = {} } = data;
if (!groups || !groups.length) {
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
root.append(el("p", { class: "meta" }, "(no tool groups defined)"));
updateSaveBar();
return;
}
// Agent rows: prefer the backend roster (default-perms agents included);
// fall back to the live-container explicit union for older payloads.
const agentNames = (data.agents && data.agents.length)
? [...data.agents]
: [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
const agentNames =
data.agents && data.agents.length
? [...data.agents]
: [
...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
]),
].sort();
if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)'));
root.append(el("p", { class: "meta" }, "(no agents)"));
updateSaveBar();
return;
}
const wrap = el('div', { class: 'tg-table-wrap' });
const table = el('table', { class: 'tg-table' });
const wrap = el("div", { class: "tg-table-wrap" });
const table = el("table", { class: "tg-table" });
// Header row.
const thead = el('thead');
const hrow = el('tr');
hrow.append(el('th', { class: 'tg-agent-col' }, 'agent'));
const thead = el("thead");
const hrow = el("tr");
hrow.append(el("th", { class: "tg-agent-col" }, "agent"));
for (const g of groups) {
hrow.append(el('th', { class: 'tg-group-col', title: descriptions[g] || g }, g));
hrow.append(
el("th", { class: "tg-group-col", title: descriptions[g] || g }, g),
);
}
thead.append(hrow);
table.append(thead);
const tbody = el('tbody');
const tbody = el("tbody");
for (const name of agentNames) {
// Effective groups (explicit-or-role-default) drive the checkboxes so
// a default agent shows its real groups, not blank — and saving keeps
@ -226,38 +241,47 @@ function renderToolGroups(root, data) {
// includes stopped-but-configured containers — only destroyed/renamed
// agents are absent.
const isStale = !containersState.has(name);
const tr = el('tr', { class: 'tg-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name });
const tr = el("tr", {
class: "tg-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell.
const nameTd = el('td', { class: 'tg-agent-col' });
nameTd.append(el('span', { class: 'tg-agent-name' }, name));
const nameTd = el("td", { class: "tg-agent-col" });
nameTd.append(el("span", { class: "tg-agent-name" }, name));
if (isStale) {
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)'));
const removeBtn = el('button', {
type: 'button',
class: 'perm-remove-btn',
title: 'remove stale permission entries for ' + name,
}, '✕ remove');
removeBtn.addEventListener('click', () => clearStaleAgent(name, root));
nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
const removeBtn = el(
"button",
{
type: "button",
class: "perm-remove-btn",
title: "remove stale permission entries for " + name,
},
"✕ remove",
);
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
nameTd.append(removeBtn);
} else if (!hasExplicit) {
nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)'));
nameTd.append(
el("span", { class: "meta tg-default-label" }, "(default)"),
);
}
tr.append(nameTd);
// One checkbox per group.
for (const g of groups) {
const checked = assigned.includes(g);
const td = el('td', { class: 'tg-group-col' });
const cb = el('input', {
type: 'checkbox',
class: 'tg-cb',
'data-group': g,
'data-baseline': checked ? '1' : '0',
'aria-label': g,
const td = el("td", { class: "tg-group-col" });
const cb = el("input", {
type: "checkbox",
class: "tg-cb",
"data-group": g,
"data-baseline": checked ? "1" : "0",
"aria-label": g,
});
cb.checked = checked;
cb.addEventListener('change', onCellToggle);
cb.addEventListener("change", onCellToggle);
td.append(cb);
tr.append(td);
}
@ -285,8 +309,22 @@ function onCellToggle() {
// a full replacement → omitting it leaves that file alone).
function collectChanges() {
const byAgent = new Map(); // agent -> { capabilities?, tool_groups? }
collectSection($('capabilities-section'), '.cap-row', '.cap-cb', 'cap', 'capabilities', byAgent);
collectSection($('tool-groups-section'), '.tg-row', '.tg-cb', 'group', 'tool_groups', byAgent);
collectSection(
$("capabilities-section"),
".cap-row",
".cap-cb",
"cap",
"capabilities",
byAgent,
);
collectSection(
$("tool-groups-section"),
".tg-row",
".tg-cb",
"group",
"tool_groups",
byAgent,
);
const changes = [];
for (const [agent, obj] of byAgent) changes.push({ agent, ...obj });
return changes;
@ -300,7 +338,7 @@ function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
let dirty = false;
const selected = [];
for (const cb of tr.querySelectorAll(cbSel)) {
if (cb.checked !== (cb.dataset.baseline === '1')) dirty = true;
if (cb.checked !== (cb.dataset.baseline === "1")) dirty = true;
if (cb.checked) selected.push(cb.dataset[dataKey]);
}
if (dirty) {
@ -312,13 +350,14 @@ function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
}
function updateSaveBar() {
const btn = $('perm-save-all');
const btn = $("perm-save-all");
if (!btn) return;
// Don't stomp a transient saving/rebuilding label.
if (btn.dataset.busy === '1') return;
if (btn.dataset.busy === "1") return;
const n = collectChanges().length;
btn.disabled = n === 0;
btn.textContent = n === 0 ? 'save all' : `save all (${n} agent${n === 1 ? '' : 's'})`;
btn.textContent =
n === 0 ? "save all" : `save all (${n} agent${n === 1 ? "" : "s"})`;
}
// Remove all explicit permission entries for a stale (non-running)
@ -328,20 +367,27 @@ function updateSaveBar() {
// the delete so the row disappears immediately.
async function clearStaleAgent(name, sectionRoot) {
const btn = sectionRoot
? sectionRoot.querySelector(`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`)
? sectionRoot.querySelector(
`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`,
)
: null;
const doDelete = async () => {
try {
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
const resp = await fetch("/api/permissions/" + encodeURIComponent(name), {
method: "DELETE",
});
if (!resp.ok) {
const text = await resp.text().catch(() => resp.status);
setSaveNote('failed to remove ' + name + ': ' + text, true);
setSaveNote("failed to remove " + name + ": " + text, true);
return;
}
// Re-fetch both sections so the stale row disappears.
await Promise.all([fetchAndRenderCapabilities(), fetchAndRenderToolGroups()]);
await Promise.all([
fetchAndRenderCapabilities(),
fetchAndRenderToolGroups(),
]);
} catch (err) {
setSaveNote('failed to remove ' + name + ': ' + err, true);
setSaveNote("failed to remove " + name + ": " + err, true);
}
};
// asyncBtn guards double-submit; fall through without guard when there
@ -352,38 +398,41 @@ async function clearStaleAgent(name, sectionRoot) {
}
function clearSaveStatus() {
const note = $('perm-save-note');
if (note) { note.textContent = ''; note.classList.remove('perm-save-err'); }
const note = $("perm-save-note");
if (note) {
note.textContent = "";
note.classList.remove("perm-save-err");
}
}
function setSaveNote(text, isErr) {
const note = $('perm-save-note');
const note = $("perm-save-note");
if (!note) return;
note.textContent = text;
note.classList.toggle('perm-save-err', !!isErr);
note.classList.toggle("perm-save-err", !!isErr);
}
async function saveAll() {
const btn = $('perm-save-all');
const btn = $("perm-save-all");
if (!btn) return;
const changes = collectChanges();
if (!changes.length) return;
btn.dataset.busy = '1';
btn.dataset.busy = "1";
btn.disabled = true;
btn.textContent = 'saving…';
btn.textContent = "saving…";
clearSaveStatus();
try {
const r = await fetch('/api/permissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const r = await fetch("/api/permissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
if (!r.ok) {
const txt = await r.text();
btn.dataset.busy = '';
btn.dataset.busy = "";
btn.disabled = false;
btn.textContent = 'save all';
setSaveNote('save failed: ' + (txt || ('http ' + r.status)), true);
btn.textContent = "save all";
setSaveNote("save failed: " + (txt || "http " + r.status), true);
updateSaveBar();
return;
}
@ -391,18 +440,18 @@ async function saveAll() {
// re-fetch both tables once the queue worker has committed — that
// resets baselines (dirty clears) and the bar disables itself.
const n = changes.length;
btn.textContent = 'queued ✓';
setSaveNote(`rebuilding ${n} agent${n === 1 ? '' : 's'}`, false);
btn.textContent = "queued ✓";
setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}`, false);
setTimeout(() => {
btn.dataset.busy = '';
btn.dataset.busy = "";
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}, 900);
} catch (err) {
btn.dataset.busy = '';
btn.dataset.busy = "";
btn.disabled = false;
btn.textContent = 'save all';
setSaveNote('save failed: ' + String(err), true);
btn.textContent = "save all";
setSaveNote("save failed: " + String(err), true);
updateSaveBar();
}
}
@ -411,9 +460,9 @@ async function saveAll() {
// after the DOM is ready (the button lives in the static permissions
// pane markup, so it exists before any fetch).
export function initPermissions() {
const btn = $('perm-save-all');
const btn = $("perm-save-all");
if (btn && !btn.dataset.bound) {
btn.dataset.bound = '1';
btn.addEventListener('click', saveAll);
btn.dataset.bound = "1";
btn.addEventListener("click", saveAll);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,55 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // ST4TS</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/stats.css">
</head>
<body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // ST4TS</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/stats.css" />
</head>
<body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
logs.html — no full dashboard tabbar. Back link points to the
H0M3 hub (served at /), not the dashboard. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">ST4TS</span>
<!-- Window selector lives in the header so it behaves like a tab strip
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">ST4TS</span>
<!-- Window selector lives in the header so it behaves like a tab strip
(matches the pattern on /logs.html). 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>
<button type="button" class="btn" data-tab="24h">24h</button>
<button type="button" class="btn" data-tab="3d">3d</button>
<button type="button" class="btn" data-tab="7d">7d</button>
<button type="button" class="btn" data-tab="30d">30d</button>
<button type="button" class="btn" data-tab="all">all</button>
</nav>
</header>
<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>
<button type="button" class="btn" data-tab="24h">24h</button>
<button type="button" class="btn" data-tab="3d">3d</button>
<button type="button" class="btn" data-tab="7d">7d</button>
<button type="button" class="btn" data-tab="30d">30d</button>
<button type="button" class="btn" data-tab="all">all</button>
</nav>
</header>
<main class="stats-main">
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p>
<main class="stats-main">
<p class="meta">
hive-wide turn statistics, aggregated across every agent over the
selected window. <strong>cost is a rough estimate</strong> from
approximate per-model list prices — it drifts and is a ballpark, not a
bill.
</p>
<div class="hive-stats-chips" id="hive-stats-summary"></div>
<h3>◇ busiest agents</h3>
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
<h3>◇ model mix (turns across the swarm)</h3>
<div id="hive-stats-models"></div>
<!-- "favorite tools": most-run bash commands across the swarm.
<div class="hive-stats-chips" id="hive-stats-summary"></div>
<h3>◇ busiest agents</h3>
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
<h3>◇ model mix (turns across the swarm)</h3>
<div id="hive-stats-models"></div>
<!-- "favorite tools": most-run bash commands across the swarm.
Header + list hidden until the hive-bash-daemon capture has
recorded data, so the section never shows an empty block. -->
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
<div id="hive-stats-bash" hidden></div>
<!-- most-triggered skills across the swarm. Header + list hidden until
<h3 id="hive-stats-bash-h" hidden>
◇ favorite tools (bash commands across the swarm)
</h3>
<div id="hive-stats-bash" hidden></div>
<!-- most-triggered skills across the swarm. Header + list hidden until
at least one agent has actually invoked a skill. -->
<h3 id="hive-stats-skills-h" hidden>◇ skill mix (invocations across the swarm)</h3>
<div id="hive-stats-skills" hidden></div>
</main>
<h3 id="hive-stats-skills-h" hidden>
◇ skill mix (invocations across the swarm)
</h3>
<div id="hive-stats-skills" hidden></div>
</main>
<script type="module" src="/static/stats.js" defer></script>
</body>
<script type="module" src="/static/stats.js" defer></script>
</body>
</html>

View file

@ -5,39 +5,45 @@
// this bundle has no chart lib; per-agent trend charts live on each
// agent's own /stats page. The window selector is a hash-routed
// createTabStrip (#1h / #24h / …), matching the per-agent stats page.
import { $, initServerWarnings } from './common.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import { $, initServerWarnings } from "./common.js";
import { createTabStrip } from "@hive/shared/tabs.js";
let hiveStatsWindow = '24h';
let hiveStatsWindow = "24h";
function hsFmtInt(n) {
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
return Number.isFinite(n)
? new Intl.NumberFormat().format(Math.round(n))
: "0";
}
function hsFmtTokens(n) {
if (!Number.isFinite(n)) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
if (!Number.isFinite(n)) return "0";
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
return String(Math.round(n));
}
function hsFmtUsd(n) {
if (!Number.isFinite(n)) return '$0';
if (n >= 100) return '$' + n.toFixed(0);
if (n >= 1) return '$' + n.toFixed(2);
return '$' + n.toFixed(3);
if (!Number.isFinite(n)) return "$0";
if (n >= 100) return "$" + n.toFixed(0);
if (n >= 1) return "$" + n.toFixed(2);
return "$" + n.toFixed(3);
}
function hsChip(parent, label, value, est) {
const c = document.createElement('span');
c.className = 'hive-stats-chip' + (est ? ' est' : '');
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
const c = document.createElement("span");
c.className = "hive-stats-chip" + (est ? " est" : "");
const k = document.createElement("span");
k.className = "k";
k.textContent = label;
const v = document.createElement("span");
v.className = "v";
v.textContent = value;
c.append(k, v);
parent.append(c);
}
function hsMeta(parent, text) {
parent.replaceChildren();
const p = document.createElement('p');
p.className = 'meta';
const p = document.createElement("p");
p.className = "meta";
p.textContent = text;
parent.append(p);
}
@ -46,13 +52,20 @@ function renderKeyCountBars(container, mix) {
container.replaceChildren();
const max = mix[0].count || 1;
for (const kc of mix) {
const row = document.createElement('div'); row.className = 'hive-stats-bar';
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
const track = document.createElement('span'); track.className = 'track';
const fill = document.createElement('span'); fill.className = 'fill';
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
const row = document.createElement("div");
row.className = "hive-stats-bar";
const lbl = document.createElement("span");
lbl.className = "lbl";
lbl.textContent = kc.key;
const track = document.createElement("span");
track.className = "track";
const fill = document.createElement("span");
fill.className = "fill";
fill.style.width = Math.max(2, Math.round((100 * kc.count) / max)) + "%";
track.append(fill);
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
const cnt = document.createElement("span");
cnt.className = "cnt";
cnt.textContent = hsFmtInt(kc.count);
row.append(lbl, track, cnt);
container.append(row);
}
@ -76,43 +89,50 @@ function renderOptionalMix(containerId, headerId, mix) {
}
function renderHiveStats(s) {
const sum = $('hive-stats-summary');
const sum = $("hive-stats-summary");
if (sum) {
sum.replaceChildren();
hsChip(sum, 'window', s.window);
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
hsChip(sum, "window", s.window);
hsChip(sum, "active agents", hsFmtInt(s.active_agents));
hsChip(sum, "turns", hsFmtInt(s.total_turns));
const totalTok =
(s.total_input_tokens || 0) +
(s.total_output_tokens || 0) +
(s.total_cache_read_tokens || 0) +
(s.total_cache_creation_tokens || 0);
hsChip(sum, "tokens", hsFmtTokens(totalTok));
hsChip(sum, "input", hsFmtTokens(s.total_input_tokens));
hsChip(sum, "output", hsFmtTokens(s.total_output_tokens));
hsChip(sum, "cache read", hsFmtTokens(s.total_cache_read_tokens));
hsChip(sum, "est cost", hsFmtUsd(s.est_cost_usd), true);
}
const at = $('hive-stats-agents');
const at = $("hive-stats-agents");
if (at) {
const agents = s.agents || [];
if (!agents.length) {
hsMeta(at, 'no turns in window');
hsMeta(at, "no turns in window");
} else {
at.replaceChildren();
const table = document.createElement('table');
table.className = 'hive-stats-table';
table.innerHTML = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
const tb = document.createElement('tbody');
const table = document.createElement("table");
table.className = "hive-stats-table";
table.innerHTML =
"<thead><tr><th>agent</th><th>turns</th><th>input</th>" +
"<th>output</th><th>cache read</th><th>est cost</th></tr></thead>";
const tb = document.createElement("tbody");
for (const a of agents) {
const tr = document.createElement('tr');
const tr = document.createElement("tr");
const cells = [
a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens),
hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens),
a.name,
hsFmtInt(a.turns),
hsFmtTokens(a.input_tokens),
hsFmtTokens(a.output_tokens),
hsFmtTokens(a.cache_read_tokens),
hsFmtUsd(a.est_cost_usd),
];
cells.forEach((txt, i) => {
const td = document.createElement('td');
if (i > 0) td.className = 'num';
const td = document.createElement("td");
if (i > 0) td.className = "num";
td.textContent = txt;
tr.append(td);
});
@ -123,27 +143,33 @@ function renderHiveStats(s) {
}
}
const mm = $('hive-stats-models');
const mm = $("hive-stats-models");
if (mm) {
const mix = s.model_mix || [];
if (!mix.length) hsMeta(mm, 'no turns in window');
if (!mix.length) hsMeta(mm, "no turns in window");
else renderKeyCountBars(mm, mix);
}
// "favorite tools": most-run bash commands across the swarm.
renderOptionalMix('hive-stats-bash', 'hive-stats-bash-h', s.bash_mix || []);
renderOptionalMix("hive-stats-bash", "hive-stats-bash-h", s.bash_mix || []);
// Most-triggered skills across the swarm.
renderOptionalMix('hive-stats-skills', 'hive-stats-skills-h', s.skill_mix || []);
renderOptionalMix(
"hive-stats-skills",
"hive-stats-skills-h",
s.skill_mix || [],
);
}
async function refreshHiveStats() {
try {
const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow));
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch(
"/api/stats-hive?window=" + encodeURIComponent(hiveStatsWindow),
);
if (!resp.ok) throw new Error("http " + resp.status);
renderHiveStats(await resp.json());
} catch (e) {
const at = $('hive-stats-agents');
if (at) hsMeta(at, 'stats fetch failed: ' + e);
const at = $("hive-stats-agents");
if (at) hsMeta(at, "stats fetch failed: " + e);
}
}
@ -154,7 +180,10 @@ initServerWarnings();
// tab and the strip's `if (pane)` guard handles it. The initial show()
// fires onShow once → sets the window + does the first fetch, so no
// separate refreshHiveStats() call is needed.
createTabStrip(document.getElementById('hive-stats-windows'), {
createTabStrip(document.getElementById("hive-stats-windows"), {
defaultId: hiveStatsWindow,
onShow: (w) => { hiveStatsWindow = w; refreshHiveStats(); },
onShow: (w) => {
hiveStatsWindow = w;
refreshHiveStats();
},
});

View file

@ -33,8 +33,11 @@ const allPorts = new Set();
const PING_INTERVAL_MS = 30_000;
setInterval(() => {
for (const port of allPorts) {
try { port.postMessage({ kind: 'ping' }); }
catch { /* port dead — left in the Set; see onconnect's closing comment */ }
try {
port.postMessage({ kind: "ping" });
} catch {
/* port dead — left in the Set; see onconnect's closing comment */
}
}
}, PING_INTERVAL_MS);
@ -45,20 +48,29 @@ function getOrCreateStream(url) {
entry = { es, url, ports: new Set() };
es.onopen = () => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'open', url }); }
catch { /* port dead — cleanup happens on unsubscribe / next subscribe */ }
try {
port.postMessage({ kind: "open", url });
} catch {
/* port dead — cleanup happens on unsubscribe / next subscribe */
}
}
};
es.onmessage = (e) => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'message', url, data: e.data }); }
catch { /* same */ }
try {
port.postMessage({ kind: "message", url, data: e.data });
} catch {
/* same */
}
}
};
es.onerror = () => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'error', url }); }
catch { /* same */ }
try {
port.postMessage({ kind: "error", url });
} catch {
/* same */
}
}
};
streams.set(url, entry);
@ -81,8 +93,8 @@ self.onconnect = (connectEvent) => {
const subscribedUrls = new Set();
port.onmessage = (e) => {
const msg = e.data;
if (!msg || typeof msg.url !== 'string') return;
if (msg.kind === 'subscribe') {
if (!msg || typeof msg.url !== "string") return;
if (msg.kind === "subscribe") {
if (subscribedUrls.has(msg.url)) return; // idempotent
const entry = getOrCreateStream(msg.url);
entry.ports.add(port);
@ -93,10 +105,13 @@ self.onconnect = (connectEvent) => {
// the next reconnect. Hand the new tab the open event explicitly
// so its onStreamOpen handler runs.
if (entry.es.readyState === EventSource.OPEN) {
try { port.postMessage({ kind: 'open', url: msg.url }); }
catch { /* port dead immediately — give up */ }
try {
port.postMessage({ kind: "open", url: msg.url });
} catch {
/* port dead immediately — give up */
}
}
} else if (msg.kind === 'unsubscribe') {
} else if (msg.kind === "unsubscribe") {
if (!subscribedUrls.has(msg.url)) return;
unsubscribe(port, msg.url);
subscribedUrls.delete(msg.url);

File diff suppressed because it is too large Load diff

View file

@ -23,9 +23,17 @@
cursor: pointer;
font-size: 0.9em;
}
.meta-input-name { color: var(--amber); font-weight: bold; }
.meta-input-rev { color: var(--muted); }
.meta-input-ts { color: var(--muted); font-size: 0.85em; }
.meta-input-name {
color: var(--amber);
font-weight: bold;
}
.meta-input-rev {
color: var(--muted);
}
.meta-input-ts {
color: var(--muted);
font-size: 0.85em;
}
.meta-input-url {
color: var(--muted);
font-size: 0.85em;
@ -67,7 +75,9 @@
font-size: 0.85em;
letter-spacing: 0.08em;
cursor: pointer;
transition: box-shadow 0.15s ease, background 0.15s ease;
transition:
box-shadow 0.15s ease,
background 0.15s ease;
}
.btn-meta-update:hover:not([disabled]) {
background: color-mix(in srgb, var(--purple) 22%, transparent);
@ -105,8 +115,13 @@
align-items: baseline;
gap: 0.4em;
}
.rqe-kind { color: var(--cyan); }
.rqe-agent { color: var(--amber); font-weight: bold; }
.rqe-kind {
color: var(--cyan);
}
.rqe-agent {
color: var(--amber);
font-weight: bold;
}
.rqe-source {
font-size: 0.75em;
padding: 0.05em 0.45em;
@ -116,11 +131,25 @@
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rqe-source-manual { color: var(--cyan); border-color: var(--cyan); }
.rqe-source-meta_update { color: var(--purple); border-color: var(--purple); }
.rqe-source-auto_update { color: var(--muted); }
.rqe-source-crash_recover { color: var(--amber); border-color: var(--amber); }
.rqe-source-approval { color: var(--green); border-color: var(--green); }
.rqe-source-manual {
color: var(--cyan);
border-color: var(--cyan);
}
.rqe-source-meta_update {
color: var(--purple);
border-color: var(--purple);
}
.rqe-source-auto_update {
color: var(--muted);
}
.rqe-source-crash_recover {
color: var(--amber);
border-color: var(--amber);
}
.rqe-source-approval {
color: var(--green);
border-color: var(--green);
}
/* running-rebuild live log (renderRebuildLiveLog in builds.js)
Streams the currently-running rebuild's build log inline under the queue.
Hidden (native `hidden` attr) when nothing is building. Used on
@ -149,15 +178,21 @@
font-size: 1em;
padding: 0 0.2em;
}
.rebuild-live-log-toggle:hover { color: var(--fg); }
.rebuild-live-log-title { color: var(--muted); }
.rebuild-live-log-toggle:hover {
color: var(--fg);
}
.rebuild-live-log-title {
color: var(--muted);
}
.rebuild-live-log-raw {
margin-left: auto;
color: var(--purple);
text-decoration: none;
font-size: 0.9em;
}
.rebuild-live-log-raw:hover { text-decoration: underline; }
.rebuild-live-log-raw:hover {
text-decoration: underline;
}
.rebuild-live-log-badge {
font-size: 0.75em;
font-weight: bold;
@ -184,7 +219,8 @@
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-family:
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-size: 0.8em;
line-height: 1.4;
color: var(--fg);

View file

@ -11,41 +11,47 @@
// `dashboard.html` each load their own bundle.
// SW4RM (containers) domain lives in `./swarm.js`.
import { marked } from 'marked';
import { marked } from "marked";
import { $, NOTIF, openStream, renderServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js";
import { containersState, syncContainersFromSnapshot } from "./state.js";
import { fmtAgo, fmtDuration } from "./util.js";
import {
$,
NOTIF,
openStream, renderServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import {
containersState, syncContainersFromSnapshot,
} from './state.js';
import { fmtAgo, fmtDuration } from './util.js';
import {
applyCapabilitiesChanged, applyToolGroupsChanged,
fetchAndRenderCapabilities, fetchAndRenderToolGroups,
applyCapabilitiesChanged,
applyToolGroupsChanged,
fetchAndRenderCapabilities,
fetchAndRenderToolGroups,
initPermissions,
} from './permissions.js';
} from "./permissions.js";
import {
applySchedulesChanged,
refreshSchedules, activeScheduleCount,
} from './schedules.js';
refreshSchedules,
activeScheduleCount,
} from "./schedules.js";
import {
initCall,
refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount,
syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved,
renderApprovals, activeApprovalCount,
} from './call.js';
refreshOperatorInbox,
operatorInboxAppendFromEvent,
operatorInboxCount,
syncApprovalsFromSnapshot,
applyApprovalAdded,
applyApprovalResolved,
renderApprovals,
activeApprovalCount,
} from "./call.js";
import {
initJobqRollup, syncTransientsFromSnapshot,
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
applyTransientSet, applyTransientCleared,
initJobqRollup,
syncTransientsFromSnapshot,
applyRebuildQueueChanged,
applyContainerStateChanged,
applyContainerRemoved,
applyTransientSet,
applyTransientCleared,
renderContainers,
renderSelectionBar,
} from './swarm.js';
} from "./swarm.js";
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
@ -56,7 +62,7 @@ window.marked = marked;
// Track which items we've already notified about so a re-render
// doesn't re-fire for the same row. Keyed by stable ids; reset only
// when the page reloads.
const seenApprovals = new Set();
const seenApprovals = new Set();
let seededNotify = false;
function notifyDeltas(s) {
@ -75,11 +81,17 @@ window.marked = marked;
for (const a of approvals) {
if (seenApprovals.has(a.id)) continue;
seenApprovals.add(a.id);
const verb = a.kind === 'spawn' ? 'spawn approval'
: a.kind === 'init_config' ? 'config-init approval'
: 'config commit';
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
'hyperhive:approval:' + a.id);
const verb =
a.kind === "spawn"
? "spawn approval"
: a.kind === "init_config"
? "config-init approval"
: "config commit";
NOTIF.show(
"◆ approval #" + a.id,
`${verb} for ${a.agent}`,
"hyperhive:approval:" + a.id,
);
}
}
@ -96,20 +108,21 @@ window.marked = marked;
// the renderers so this loop can refresh them without a full re-render).
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => {
const requestedAt = Number(node.getAttribute('data-requested-at'));
if (!Number.isFinite(requestedAt)) return;
const ageSec = Math.max(0, now - requestedAt);
node.textContent = 'requested ' + fmtAgo(requestedAt);
node.classList.toggle('stale', ageSec >= 3600);
});
document.querySelectorAll('.sched-due[data-due-at]').forEach((node) => {
const dueAt = Number(node.getAttribute('data-due-at'));
document
.querySelectorAll(".approval-ts[data-requested-at]")
.forEach((node) => {
const requestedAt = Number(node.getAttribute("data-requested-at"));
if (!Number.isFinite(requestedAt)) return;
const ageSec = Math.max(0, now - requestedAt);
node.textContent = "requested " + fmtAgo(requestedAt);
node.classList.toggle("stale", ageSec >= 3600);
});
document.querySelectorAll(".sched-due[data-due-at]").forEach((node) => {
const dueAt = Number(node.getAttribute("data-due-at"));
if (!Number.isFinite(dueAt)) return;
const dueIn = dueAt - now;
node.textContent = dueIn <= 0
? 'overdue ' + fmtAgo(dueAt)
: fmtDuration(dueIn);
node.textContent =
dueIn <= 0 ? "overdue " + fmtAgo(dueAt) : fmtDuration(dueIn);
});
}, 1000);
@ -119,12 +132,12 @@ window.marked = marked;
// operator is typing in one of them, skip the refresh — the next
// tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [
'containers-section',
'inbox-section',
'approvals-section',
'schedules-section',
'capabilities-section',
'tool-groups-section',
"containers-section",
"inbox-section",
"approvals-section",
"schedules-section",
"capabilities-section",
"tool-groups-section",
];
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
@ -138,7 +151,7 @@ window.marked = marked;
for (const id of MANAGED_SECTION_IDS) {
const sect = document.getElementById(id);
if (!sect) continue;
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
for (const d of sect.querySelectorAll("details[data-restore-key]")) {
if (d.open) open.add(d.dataset.restoreKey);
}
}
@ -149,7 +162,7 @@ window.marked = marked;
for (const id of MANAGED_SECTION_IDS) {
const sect = document.getElementById(id);
if (!sect) continue;
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
for (const d of sect.querySelectorAll("details[data-restore-key]")) {
if (open.has(d.dataset.restoreKey)) d.open = true;
}
}
@ -159,7 +172,7 @@ window.marked = marked;
const el_ = document.activeElement;
if (!el_ || el_ === document.body) return false;
const tag = el_.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
if (tag !== "INPUT" && tag !== "TEXTAREA" && tag !== "SELECT") return false;
return MANAGED_SECTION_IDS.some((id) => {
const sect = document.getElementById(id);
return sect && sect.contains(el_);
@ -176,8 +189,8 @@ window.marked = marked;
return;
}
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/state");
if (!resp.ok) throw new Error("http " + resp.status);
const s = await resp.json();
// Stash the latest snapshot for any sub-widget that wants a
// synchronous read (e.g. the compose autocomplete pulls agent
@ -190,19 +203,18 @@ window.marked = marked;
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
// (set by services.hyperhive.{hiveName,swarmName} nix options).
// When unset we fall back gracefully — the headline stays hidden.
const hiveId = $('swarm-identity');
const hiveId = $("swarm-identity");
if (hiveId) {
const hive = s.hive_name;
const swarm = s.swarm_name;
if (hive || swarm) {
const label = swarm && hive ? `${swarm} / ${hive}`
: hive || swarm;
const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm;
hiveId.textContent = label;
hiveId.hidden = false;
// Preserve any (N) call-count prefix already applied by
// refreshTabCounts so the title doesn't flicker on reload.
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || '';
document.title = existingPrefix + label + ' // h1ve-c0re';
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || "";
document.title = existingPrefix + label + " // h1ve-c0re";
}
}
const openDetails = snapshotOpenDetails();
@ -235,9 +247,12 @@ window.marked = marked;
// /api/state fetches are the initial cold load and the
// post-submit refetch on forms without `data-no-refresh`
// (tombstones, meta-input updates).
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
} catch (err) {
console.error('refreshState failed', err);
console.error("refreshState failed", err);
// Schedule a single retry on transient errors so the page
// recovers from a brief network blip without making the
// operator reload.
@ -275,20 +290,20 @@ window.marked = marked;
// and filter client-side — the dashboard ignores broker traffic
// and the inbox ignores mutation events.
const MUTATION_HANDLERS = {
approval_added: applyApprovalAdded,
approval_added: applyApprovalAdded,
approval_resolved: applyApprovalResolved,
transient_set: applyTransientSet,
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
container_state_changed: applyContainerStateChanged,
container_removed: applyContainerRemoved,
container_removed: applyContainerRemoved,
// rebuild_queue_changed: refreshes the SW4RM queue-summary banner
// (see swarm.js) — a payload-less push trigger, same treatment
// /builds.html gives it for its JobqGraph mount handle's .refresh()
// (its own separate subscription).
rebuild_queue_changed: applyRebuildQueueChanged,
schedules_changed: applySchedulesChanged,
capabilities_changed: applyCapabilitiesChanged,
tool_groups_changed: applyToolGroupsChanged,
rebuild_queue_changed: applyRebuildQueueChanged,
schedules_changed: applySchedulesChanged,
capabilities_changed: applyCapabilitiesChanged,
tool_groups_changed: applyToolGroupsChanged,
};
(function bindDashboardStream() {
// Route through the SharedWorker so all open hyperhive tabs on the
@ -305,24 +320,31 @@ window.marked = marked;
// `/api/dashboard/stream` subscribers before this (subscription
// discipline, part 1 of the dashboard-event-stream-split issue).
const es = openStream(
'/api/dashboard/stream?kinds=sent,approval_added,approval_resolved,' +
'transient_set,transient_cleared,' +
'container_state_changed,container_removed,rebuild_queue_changed,' +
'schedules_changed,capabilities_changed,tool_groups_changed',
"/api/dashboard/stream?kinds=sent,approval_added,approval_resolved," +
"transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," +
"schedules_changed,capabilities_changed,tool_groups_changed",
);
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
try {
ev = JSON.parse(e.data);
} catch {
return;
}
// Broker `sent` frames aren't mutation events, but the operator
// inbox cares about ones addressed to "operator".
if (ev.kind === 'sent' && ev.to === 'operator') {
if (ev.kind === "sent" && ev.to === "operator") {
operatorInboxAppendFromEvent(ev);
return;
}
const h = MUTATION_HANDLERS[ev.kind];
if (!h) return; // broker rows + future kinds — dashboard doesn't care
try { h(ev); }
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
try {
h(ev);
} catch (err) {
console.error("dashboard SSE handler", ev.kind, err);
}
};
es.onopen = () => {
// Re-sync to recover events that fired during the SSE disconnect
@ -334,7 +356,7 @@ window.marked = marked;
};
es.onerror = () => {
// EventSource auto-reconnects; nothing to do beyond logging.
console.debug('dashboard SSE error, will retry');
console.debug("dashboard SSE error, will retry");
};
})();
@ -360,38 +382,41 @@ window.marked = marked;
// Re-fetch on activation as a safety net: SSE covers live mutations,
// re-sync covers disconnect windows / approval-path inserts that
// don't yet emit.
if (target === 'schedules') { refreshSchedules(); }
if (target === "schedules") {
refreshSchedules();
}
// Permissions tables: SSE covers worker-applied changes
// (capabilities_changed / tool_groups_changed); re-fetch on
// activation as a safety net for any gap between SSE events and
// the cold-load snapshot.
if (target === 'permissions') {
if (target === "permissions") {
initPermissions();
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}
if (target === 'call') { refreshOperatorInbox(); }
if (target === "call") {
refreshOperatorInbox();
}
}
// Wire the shared tab strip now that activateTab + the lazy-load fns it
// calls are defined. The strip resolves the active tab from the hash
// (default SW4RM), toggles the active tab/pane + aria-selected, and
// fires activateTab for the per-tab side-effects on every change.
createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab });
createTabStrip($("tabbar"), { defaultId: "swarm", onShow: activateTab });
// Register the Y3R C4LL domain's count callback (call.js) — its live
// mutations (inbox stream append, mark-read) trigger a tab-count refresh
// through this instead of reaching back into the coordinator directly.
initCall({ onCountsChanged: refreshTabCounts });
// Tab count pills — pure derived data from the existing state
// stores so SSE-driven updates flow through without extra plumbing.
// Set `hidden` when the count is zero so the pill doesn't draw
// attention to an empty room.
function setTabCount(tab, n) {
const el_ = $('tab-count-' + tab);
const el_ = $("tab-count-" + tab);
if (!el_) return;
el_.textContent = String(n);
el_.hidden = n <= 0;
@ -406,22 +431,20 @@ window.marked = marked;
for (const c of containersState.values()) {
if (c.needs_update) swarm++;
}
setTabCount('swarm', swarm);
setTabCount("swarm", swarm);
// Y3R C4LL — pending approvals + unread agent->operator messages.
const callCount =
activeApprovalCount() +
operatorInboxCount();
setTabCount('call', callCount);
const callCount = activeApprovalCount() + operatorInboxCount();
setTabCount("call", callCount);
// Browser tab title prefix — lets the operator see the pending
// call count without switching to the window. Strips any existing
// `(N) ` prefix before re-applying so identity-title updates
// (which run once on state load, not every tick) compose cleanly.
const rawTitle = document.title.replace(/^\(\d+\) /, '');
const rawTitle = document.title.replace(/^\(\d+\) /, "");
document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle;
// SCH3DUL3S — count of schedules with at least one still-active
// target (whole-schedule cancellation or all-targets-cancelled
// means "not waiting on the worker"; those don't pull attention).
setTabCount('schedules', activeScheduleCount());
setTabCount("schedules", activeScheduleCount());
}
// Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE

View file

@ -24,35 +24,46 @@ export function paintAtomic(liveRoot, build) {
// meta inputs, turn stats) still carry unix-second numbers, so
// numbers pass through unchanged.
export function epochSec(ts) {
return typeof ts === 'number' ? ts : Math.floor(Date.parse(ts) / 1000);
return typeof ts === "number" ? ts : Math.floor(Date.parse(ts) / 1000);
}
// Relative age of a timestamp (RFC 3339 string or unix seconds),
// coarsened to one unit ("5m ago").
export function fmtAgo(ts) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - epochSec(ts)));
if (ageSec < 60) return ageSec + 's ago';
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
return Math.floor(ageSec / 86400) + 'd ago';
if (ageSec < 60) return ageSec + "s ago";
if (ageSec < 3600) return Math.floor(ageSec / 60) + "m ago";
if (ageSec < 86400) return Math.floor(ageSec / 3600) + "h ago";
return Math.floor(ageSec / 86400) + "d ago";
}
// Truncate a string to `n` chars, appending an ellipsis when clipped.
export function truncate(s, n) {
return s.length <= n ? s : s.slice(0, n - 1) + '…';
return s.length <= n ? s : s.slice(0, n - 1) + "…";
}
// Running-duration label for in-flight items ("3m 12s running").
export function fmtElapsed(secs) {
if (secs < 60) return secs + 's running';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
if (secs < 60) return secs + "s running";
if (secs < 3600)
return Math.floor(secs / 60) + "m " + (secs % 60) + "s running";
return (
Math.floor(secs / 3600) +
"h " +
Math.floor((secs % 3600) / 60) +
"m running"
);
}
// Compact duration label, two units deep ("1h 5m", "2d 3h").
export function fmtDuration(secs) {
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
if (secs < 60) return secs + "s";
if (secs < 3600) return Math.floor(secs / 60) + "m " + (secs % 60) + "s";
if (secs < 86400)
return (
Math.floor(secs / 3600) + "h " + Math.floor((secs % 3600) / 60) + "m"
);
return (
Math.floor(secs / 86400) + "d " + Math.floor((secs % 86400) / 3600) + "h"
);
}