hyperhive/frontend/packages/dashboard/src/common.js
iris e797b75ca9 dashboard: stream the running rebuild's build log inline in the queue
The C0R3 rebuild queue only linked out to the logs page (logs →). Add an
inline live-log panel under the queue that streams the currently-running
rebuild's build output, so the operator watches progress without leaving the
page.

Extract the build-log SSE streaming logic (append stdout/stderr, sticky-bottom
scroll, stderr separator, reconnect-replay reset, done/error handling) into a
shared `openBuildLogStream(id, pre, {onDone, onError})` in common.js, and use it
from BOTH the L0GS page BUILD tab (logs.js, previously inline) and the new C0R3
panel (core.js) — one implementation, no duplication.

The panel is one persistent instance keyed to the running entry's build_log_id
(the queue runs one build at a time), in its own container (#rebuild-live-log)
outside rebuild-queue-section so the queue's per-row re-render — rows rebuild as
the build step advances — never tears down the open stream; it reconnects only
when the running build_log_id changes and won't reopen a stream that already
sent done. Collapsible, live/ok/fail badge, raw download. Hidden when nothing
is building; each row keeps its logs → link for full history.

Frontend-only — no backend change (endpoint + build_log_id already existed).
Closes #1860.
2026-06-22 01:55:38 +02:00

812 lines
33 KiB
JavaScript

// Shared dashboard helpers used by both dashboard.html (./tabs.js) and
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
// the OS-notification module, and the path-link / file-preview
// infrastructure for the side panel.
import { linkify as termLinkify } from '@hive/shared/terminal.js';
// Themed dialog/toast helpers (modal.js imports `el` back from here — a safe
// deferred cycle: neither side uses the other at module-init time, only inside
// runtime handlers).
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
// ─── 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 el = (tag, attrs = {}, ...children) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k.startsWith('data-')) e.setAttribute(k, v);
else e.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
e.append(c.nodeType ? c : document.createTextNode(c));
}
return e;
};
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;
};
// Page-level submit interceptor for `data-async` forms — the pattern every
// dashboard action button uses (meta-update, spawn, cancel, purge, …).
// Without this a `data-async` form POSTs natively and the browser navigates
// to the bare `ok` response page. Each page that renders such forms must
// call this once at boot (dashboard via ./tabs.js, /core.html via ./core.js).
// `onSuccess` runs after a successful submit unless the form opts out with
// `data-no-refresh` (forms whose mutation arrives faster via an SSE event).
export function bindAsyncForms(onSuccess) {
document.addEventListener('submit', async (e) => {
const f = e.target;
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
e.preventDefault();
if (f.dataset.confirm && !(await themedConfirm({ message: f.dataset.confirm }))) return;
if (f.dataset.prompt) {
const ans = await themedPrompt({ message: f.dataset.prompt });
if (ans === null) return; // operator hit Cancel
// Drop into a hidden input named after `data-prompt-field` (or
// 'note' by default) so the value rides along on the POST.
const field = f.dataset.promptField || 'note';
let input = f.querySelector(`input[name="${field}"]`);
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = field;
f.append(input);
}
input.value = ans;
}
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
const original = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
try {
const resp = await fetch(f.action, {
method: f.method || 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(f)),
redirect: 'manual',
});
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' });
if (btn) { btn.disabled = false; btn.innerHTML = original; }
return;
}
// Re-enable the button — the refresh rebuilds most lists but skips
// forms that didn't change, so without this the spinner sticks.
if (btn) { btn.disabled = false; btn.innerHTML = original; }
// Clear text inputs whose value was just submitted.
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
if (!f.hasAttribute('data-no-refresh') && typeof onSuccess === 'function') {
onSuccess();
}
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
if (btn) { btn.disabled = false; btn.innerHTML = original; }
}
});
}
// `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.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.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. Shared by the
// L0GS page BUILD tab and the C0R3 rebuild-queue live-log panel so the
// append / sticky-scroll / stderr-separator / reconnect-replay logic lives in
// one place. 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;
}
// ─── side panel ─────────────────────────────────────────────────────────
// Singleton drawer that swipes in from the right. Long content
// (file previews, approval diffs, journald logs, applied config)
// opens here via `Panel.open(title, node)` instead of expanding
// inline. Body is swapped on each open; closing just slides out so
// the content stays visible through the transition.
export const Panel = (() => {
let root = null;
let titleEl = null;
let bodyEl = null;
let drawer = null;
/** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)`
* is a no-op when the current owner doesn't match, so live
* updates can re-render an open view without grabbing focus
* from a closed one (or from an unrelated open view like a
* diff drill-in). Untyped calls via `open(title, content)`
* clear the owner — the legacy file-preview/diff/log paths
* don't participate in named-refresh semantics. */
let owner = null;
function ensure() {
if (!root) {
root = $('side-panel');
titleEl = $('side-panel-title');
bodyEl = $('side-panel-body');
drawer = root && root.querySelector('.side-panel-drawer');
}
return root != null;
}
function open(title, content) {
if (!ensure()) return;
owner = null;
titleEl.textContent = title;
bodyEl.replaceChildren(...(content ? [content] : []));
root.classList.add('open');
root.setAttribute('aria-hidden', 'false');
}
function openNamed(name, title, content) {
open(title, content);
owner = name;
}
function refresh(name, title, content) {
if (!ensure()) return;
if (owner !== name) return;
titleEl.textContent = title;
bodyEl.replaceChildren(...(content ? [content] : []));
}
function close() {
if (!ensure()) return;
owner = null;
root.classList.remove('open');
root.setAttribute('aria-hidden', 'true');
}
// Drag-to-resize the drawer's width. See docs/web-ui.md::Side panel
// for the hit-strip + pointer-capture + localStorage persistence
// model; CSS clamps the stored value to min 320px / max 96vw and
// out-of-range stored values are dropped silently.
const WIDTH_KEY = 'hyperhive:side-panel-width';
const WIDTH_MIN = 320;
function clampWidth(w) {
// Use document.documentElement.clientWidth rather than window.innerWidth:
// clientWidth gives the actual CSS layout viewport width and is not
// altered by Firefox's Fingerprinting Protection (which rounds
// window.innerWidth/outerWidth to the nearest 200px).
const max = Math.floor(document.documentElement.clientWidth * 0.96);
return Math.max(WIDTH_MIN, Math.min(max, w));
}
function applyStoredWidth() {
if (!drawer) return;
const raw = (() => {
try { return localStorage.getItem(WIDTH_KEY); }
catch { return null; }
})();
if (!raw) return;
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return;
drawer.style.setProperty('--side-panel-w', clampWidth(parsed) + 'px');
}
function bindResize() {
if (!drawer) return;
const handle = document.createElement('div');
handle.className = 'side-panel-resize';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.setAttribute('aria-label', 'drag to resize side panel');
handle.title = 'drag to resize';
drawer.prepend(handle);
let dragging = false;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
dragging = true;
document.body.classList.add('side-panel-resizing');
// Capture so we keep getting pointermove even when the cursor
// outpaces the handle band (drag-fast-then-pause loses the
// handle's :hover state otherwise).
try { handle.setPointerCapture(e.pointerId); } catch { /* legacy */ }
});
document.addEventListener('pointermove', (e) => {
if (!dragging) return;
// Drawer is anchored to the right edge — width = viewport - pointer X.
const w = clampWidth(document.documentElement.clientWidth - e.clientX);
drawer.style.setProperty('--side-panel-w', w + 'px');
});
function stopDrag() {
if (!dragging) return;
dragging = false;
document.body.classList.remove('side-panel-resizing');
// Persist the final width. Read the actual rendered width
// rather than re-deriving so the stored value matches what
// the operator saw at mouseup.
const w = drawer.getBoundingClientRect().width;
try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); }
catch { /* localStorage unavailable — width is session-only */ }
}
document.addEventListener('pointerup', stopDrag);
document.addEventListener('pointercancel', stopDrag);
// Re-clamp on viewport resize so a persisted width that exceeds
// 96vw doesn't push the drawer off-screen after a window shrink.
window.addEventListener('resize', () => {
if (dragging) return;
const cur = drawer.getBoundingClientRect().width;
const clamped = clampWidth(cur);
if (clamped !== Math.round(cur)) {
drawer.style.setProperty('--side-panel-w', clamped + 'px');
}
});
}
function bind() {
if (!ensure()) return;
$('side-panel-close').addEventListener('click', close);
$('side-panel-backdrop').addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && root.classList.contains('open')) close();
});
applyStoredWidth();
bindResize();
}
return { open, openNamed, refresh, close, bind };
})();
// ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
// PATH_RE patterns becomes a clickable anchor; clicking expands an
// inline <details> with the file's contents, fetched lazily from
// /api/state-file. The legacy in-container `/state/...` prefix is
// deliberately not matched — it's ambiguous from the host's
// perspective (we'd need to know which agent the message is about
// 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 text = await resp.text();
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' });
function show(mode) {
for (const b of tabs.children) {
b.classList.toggle('active', b.dataset.mode === mode);
}
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));
tabs.append(b);
}
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)'));
});
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
return img;
}
// Marked-rendered markdown node (raw text fallback if `marked`
// failed to load).
function mdNode(text) {
const div = el('div', { class: 'md' });
if (window.marked && typeof window.marked.parse === 'function') {
window.marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = 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';
});
} else {
div.textContent = text;
}
return div;
}
// Raster image extensions the preview renders as an <img> pointed
// straight at /api/state-file (served binary with a real
// content-type). SVG is handled on the text path instead.
const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
// Lazy-load `path` from /api/state-file into the side panel.
// Markdown + SVG get a rendered/plain tabbed view; raster images
// 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)'));
});
img.src = '/api/state-file?path=' + encodeURIComponent(path);
Panel.open('↳ ' + path, img);
return;
}
const isMd = /\.(md|markdown)$/i.test(path);
const isSvg = /\.svg$/i.test(path);
const view = el('div');
view.textContent = '(fetching…)';
Panel.open('↳ ' + path, view);
try {
const text = await fetchStateFile(path);
if (isSvg) {
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
} else if (isMd) {
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
} else {
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
}
} catch (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) => {
e.preventDefault();
openFilePanel(path);
});
return anchor;
}
// Append a plain-text run, with bare http(s) URLs turned into clickable
// links via the shared terminal linkifier.
export function appendText(parent, s) {
if (!s) return;
parent.appendChild(termLinkify(s));
}
// Append `text` to `parent` as a mix of text nodes + path anchors.
// `refs` is the server-attached `file_refs` array (verified-file
// tokens that appear in `text`); each occurrence of a ref becomes a
// clickable anchor that opens the file in the side panel. Anything
// not in `refs` stays plain text. No client-side regex, no probe
// endpoint — the server saw the body first and made the call. When
// `refs` is empty/missing we just emit plain text.
export function appendLinkified(parent, text, refs) {
if (text == null) return;
const str = String(text);
const tokens = (refs || []).slice();
if (!tokens.length) {
appendText(parent, str);
return;
}
// Walk the string left-to-right, at each step looking for the
// next occurrence of any token. Longest-first tie-break so a
// ref like `/agents/foo/state/x.md` wins over a (hypothetical)
// shorter token that prefixes it. O(text * refs) worst case;
// refs is bounded server-side to whatever fits in a body, so
// this stays cheap.
tokens.sort((a, b) => b.length - a.length);
let i = 0;
while (i < str.length) {
let bestStart = -1;
let bestToken = null;
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)) {
bestStart = idx;
bestToken = t;
}
}
if (bestStart === -1) {
appendText(parent, str.slice(i));
break;
}
if (bestStart > i) {
appendText(parent, str.slice(i, bestStart));
}
parent.appendChild(makePathLink(bestToken));
i = bestStart + bestToken.length;
}
}
// ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on three operator-bound signals:
// - new approval landed in the queue
// - new operator question queued (ask, target IS NULL)
// - broker message sent `to: "operator"`
// Permission grant is per-browser; a localStorage "muted" toggle lets
// the operator silence without revoking. Secure-context only (HTTPS /
// 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);
function renderControls() {
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';
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';
}
function bind() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
if (!supported || !enable || !mute || !unmute) return;
enable.addEventListener('click', async () => {
await Notification.requestPermission();
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');
return;
}
if (Notification.permission !== 'granted') {
console.debug('notify: permission not granted', Notification.permission);
return;
}
if (isMuted()) {
console.debug('notify: muted');
return;
}
try {
// Per-event tag so distinct messages stack instead of
// collapsing into one slot. Caller passes a unique tag per
// notification kind/id; we don't fall back to 'hyperhive'
// because that one tag would replace itself on every fire.
const n = new Notification(title, {
body,
tag: tag || ('hyperhive:' + Date.now()),
});
n.onclick = () => { window.focus(); n.close(); };
console.debug('notify: shown', title, 'tag=', tag);
} catch (err) {
console.warn('notification show failed', err);
}
}
return { bind, show, renderControls };
})();
// ─── server warnings banner ──────────────────────────────────────────
// A generic top-of-page banner shown on every page (dashboard + the
// stand-alone FL0W / L0GS / H0M3 pages). The backend decides what to
// warn about — `/api/state.server_warnings` is a list of
// `{ kind, level, message }` — and this just renders it, coloured by
// `level` (`warn` amber / `crit` red). Adding a new system warning is a
// backend-only change. The bar is injected at the top of <body> so no
// page needs to add markup.
// The sticky top region — one sticky container holding the warning
// banner above the page's chrome (tab bar / page header), so the banner
// stacks with the chrome instead of being overlaid by it (two separate
// `top:0` stickies would otherwise collide). Built once by wrapping the
// 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');
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;
}
function ensureServerWarningsBar() {
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.hidden = true;
ensureStickyTop().prepend(bar);
}
return bar;
}
/// Render a `server_warnings` list (from /api/state) into the banner.
/// Empty / missing → the bar hides itself.
export function renderServerWarnings(warnings) {
const bar = ensureServerWarningsBar();
bar.replaceChildren();
if (!Array.isArray(warnings) || warnings.length === 0) {
bar.hidden = true;
return;
}
for (const w of warnings) {
const row = el('div', {
class: 'server-warn server-warn-' + (w && w.level === 'crit' ? 'crit' : 'warn'),
});
appendText(row, '⚠ ' + ((w && w.message) || ''));
bar.append(row);
}
bar.hidden = false;
}
/// One-shot init for pages that don't otherwise poll /api/state: ensure
/// the bar exists, fetch the snapshot once, render. The dashboard (which
/// already polls /api/state) calls `renderServerWarnings` directly for
/// live updates instead.
export function initServerWarnings() {
ensureServerWarningsBar();
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 */ });
}