appendLinkified had one caller, the flow page, and it pulled in the path-link and file-preview chain, the side-panel singleton, the marked and DOMPurify wiring and the css only they used. The docs stop describing the dashboard side panel and the client reading file_refs.
360 lines
12 KiB
JavaScript
360 lines
12 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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`, `fmtDuration` + 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;
|
|
}
|
|
|
|
// Stream a build log into a <pre>, returning the EventSource. Used by the
|
|
// BUILD L0GS tab. Appends `stdout_append` then
|
|
// `stderr_append` (one `--- stderr ---` separator) frames from
|
|
// `GET /api/build-logs/id/{id}/stream`; auto-scrolls to the bottom unless the operator scrolled up; on the terminal `done` frame
|
|
// closes the stream and calls `onDone(status)`; on a non-transient error
|
|
// closes and calls `onError()`. The backend replays accumulated output on each
|
|
// (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", () => {
|
|
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
|
|
});
|
|
let stderrSeen = false;
|
|
const es = new EventSource("/api/build-logs/id/" + id + "/stream");
|
|
es.onmessage = (e) => {
|
|
let frame;
|
|
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;
|
|
}
|
|
pre.textContent += frame.stderr_append;
|
|
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
|
}
|
|
if (frame.done) {
|
|
es.close();
|
|
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;
|
|
return;
|
|
}
|
|
es.close();
|
|
if (onError) onError();
|
|
};
|
|
return es;
|
|
}
|
|
|
|
// ─── 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;
|
|
}
|