Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/dashboard/src/common.js
iris deeb5ecd86 hive ui: remove the BUILD L0GS tab and the live build-log panel
A build's output and history are the swarm's builds Grafana board now, from the
lines hive-c0re logs. Removes the tab and its pane, the live log under the
rebuild queue, their styles, the log stream helper and the duration formatter
only they used, and the docs; also the tab-counts sentence 5925 missed.
2026-11-01 17:19:26 +01:00

308 lines
11 KiB
JavaScript

// Shared dashboard helpers used by index.html (./tabs.js): pure DOM helpers,
// the shared-worker SSE pipe, and the sticky top region.
import { el } from "@hive/shared/dom.js";
import "@hive/shared/hive-warn.js"; // registers <hive-warn> — side-effect import
// ─── 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 esc = (s) =>
String(s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
export const form = (
action,
btnClass,
btnLabel,
confirmMsg,
extra = {},
opts = {},
) => {
const f = el("form", {
method: "POST",
action,
class: "inline",
"data-async": "",
...(confirmMsg ? { "data-confirm": confirmMsg } : {}),
// 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": "" } : {}),
});
for (const [name, value] of Object.entries(extra)) {
f.append(el("input", { type: "hidden", name, value }));
}
f.append(
el("button", { type: "submit", class: "btn " + btnClass }, btnLabel),
);
return f;
};
// `bindAsyncForms` (the `data-async` form submit interceptor) and
// `asyncBtn` live in `@hive/shared/forms.js`, not here — both the
// dashboard and the per-agent UI import them directly from there.
// `truncate`, `fmtAgo`, `fmtElapsed` + the `paintAtomic`
// render helper live in the dashboard-internal `./util.js`, not here —
// their phrasing ("X running", "X ago") is dashboard-specific, so they
// stay out of the cross-page `common.js` but are shared across the
// dashboard's own tab modules.
// ─── shared-worker SSE pipe ─────────────────────────────────────────────
// Returns an EventSource-shaped facade backed by a SharedWorker that
// holds one upstream `new EventSource(url)` and fans events out to
// every connected tab. See docs/web-ui/shape.md (SSE multiplexing paragraph)
// for the design + Firefox throttling motivation; graceful fallback to
// direct EventSource on environments without SharedWorker.
//
// 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";
// One SharedWorker port per page, reused by all openStream calls on
// that page. Invalidated on `pagehide` so a bfcache restore picks up
// a fresh port — the cached port may be dead if all other tabs
// closed while this page was frozen.
let _sharedPort = null;
function makeSharedPort() {
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,
);
return null;
}
}
function getSharedPort() {
if (!_sharedPort) _sharedPort = makeSharedPort();
return _sharedPort;
}
// SharedWorker death detection: pings from the worker bump the
// activity clock; a visibility-gated watchdog polls and re-subscribes
// on a fresh port if the page has been silent past the threshold.
// See docs/web-ui/shape.md (Worker-death self-heal paragraph) for the
// timing rationale + Firefox reclaim symptom.
const WORKER_DEAD_THRESHOLD_MS = 90_000;
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
let _lastWorkerActivityAt = 0;
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 (!_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",
);
rebindOnFreshPort();
}, WORKER_WATCHDOG_INTERVAL_MS);
}
function rebindOnFreshPort() {
// Drop any listeners attached to the dead port (best-effort; calls
// on a dead port throw, which we swallow). The active-subs registry
// 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 {}
}
}
_sharedPort = null;
const port = getSharedPort();
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 {}
}
// Reset the activity clock so the watchdog gives the fresh worker
// a full window to settle before re-triggering.
noteWorkerActivity();
}
// Registry of live subscriptions on this page. Keyed by url; entries
// cache the route function so bfcache-restore re-bind can re-attach
// it to the fresh port. Today's pages only call openStream once with
// one URL; the registry shape just keeps the bfcache path correct
// if that changes.
const _activeSubs = new Map();
// One-shot wiring of page-wide lifecycle hooks. On bfcache freeze
// we unsubscribe so the worker can close the upstream when the last
// live subscriber leaves; on bfcache restore we invalidate the cached
// port (may be dead after the freeze) and re-attach every active
// subscription to a fresh port. Without this, the consumer's
// onmessage stays bound but no events flow after restore.
let _lifecycleBound = false;
function bindLifecycleOnce() {
if (_lifecycleBound) return;
_lifecycleBound = true;
startWorkerWatchdog();
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 */
}
}
// 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 */
}
}
_sharedPort = null;
});
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 */
}
}
});
}
export function openStream(url) {
const port = getSharedPort();
if (!port) return new EventSource(url);
bindLifecycleOnce();
// Build an EventSource-shaped facade so consumer code is unchanged.
// `target.onmessage` / `onopen` / `onerror` are assigned by the
// consumer; the routing function below forwards events received
// from the worker (filtered by url, since one port can multiplex
// multiple subscriptions).
const target = {
onmessage: null,
onopen: null,
onerror: null,
readyState: 0, // CONNECTING
close() {
const p = _sharedPort;
if (p) {
try {
p.postMessage({ kind: "unsubscribe", url });
} catch {
/* port dead */
}
try {
p.removeEventListener("message", route);
} catch {
/* same */
}
}
_activeSubs.delete(url);
},
};
const route = (e) => {
// Any message from the worker is proof of life — note it
// before the URL filter, since heartbeat pings carry no URL.
noteWorkerActivity();
const m = e.data;
if (!m || m.kind === "ping") return;
if (m.url !== url) return;
if (m.kind === "open") {
target.readyState = 1; // OPEN
if (target.onopen) {
try {
target.onopen({ target });
} catch (err) {
console.error("openStream onopen threw", err);
}
}
} else if (m.kind === "message") {
if (target.onmessage) {
try {
target.onmessage({ data: m.data, target });
} catch (err) {
console.error("openStream onmessage threw", err);
}
}
} else if (m.kind === "error") {
if (target.onerror) {
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 });
// Seed the activity clock so the watchdog has a baseline (would
// otherwise compare against 0 and trigger immediately).
noteWorkerActivity();
return target;
}
// ─── sticky top region ───────────────────────────────────────────────
// One sticky container wrapping the page's chrome (tab bar / page
// header), built once by wrapping the existing chrome element. A page
// without a chrome (none today) gets an empty sticky region at the top of
// <body>.
export function initStickyTop() {
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");
if (chrome && chrome.parentNode) {
chrome.parentNode.insertBefore(top, chrome);
top.append(chrome);
} else {
document.body.prepend(top);
}
return top;
}