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:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -24,4 +24,6 @@ body.builds-shell {
|
|||
padding: 1.2em 1.5em 2em;
|
||||
}
|
||||
|
||||
.builds-pane[hidden] { display: none; }
|
||||
.builds-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
||||
);
|
||||
export const esc = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 */
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.<account>.tokenFile</code> by the host coordinator — 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.<account>.tokenFile</code> by the host
|
||||
coordinator — 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 — 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 — 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 — 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 & 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 — 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 & 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 —
|
||||
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 —
|
||||
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 — 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 — 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 — 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 — 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 — 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 — 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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">@—></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">@—></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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue