// 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 — 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
, 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 
 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 
 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
// .
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;
}