Per mara's review: '2 and maybe 1, but 3 also sounds reasonable on first
glance' (against 3 options I posted). Doing 2 and 1, leaving open()/
openNamed() as-is (option 3, tentative only).
Both dashboard/common.js and agent/app.js now export/use the
<hive-side-panel> element instance directly (sidePanel) instead of a
thin Panel = { open, openNamed, refresh, close } object that existed
purely to keep the old call-site shape unchanged. All 6 real call sites
updated to call the element's own methods directly.
The .side-panel-body class each wrapper stamped onto its own instance,
purely so common.css/agent.css's pre-existing content-styling selectors
kept matching, is gone too -- those selectors now use the element's own
tag name as the root (hive-side-panel .md, hive-side-panel .agent-inbox),
which already uniquely identifies the light-DOM instance without a
compatibility class. Verified via headless Chromium/CDP that the
tag-name selectors resolve correctly with no class needed.
Drive-by: removed an unrelated dead Panel import in call.js.
623 lines
26 KiB
JavaScript
623 lines
26 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';
|
|
import { el } from '@hive/shared/dom.js';
|
|
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — 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 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) now lives in
|
|
// `@hive/shared/forms.js` alongside `asyncBtn` — both the dashboard and the
|
|
// per-agent UI import it directly from there rather than through this file.
|
|
|
|
// `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 `sidePanel.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.
|
|
//
|
|
// The instance is created once, eagerly, at module evaluation time — ES
|
|
// modules run after the document is parsed (same timing `defer` scripts
|
|
// get), so `document.body` already exists here. Exported directly (not
|
|
// wrapped in a forwarding object) — callers use the element's own
|
|
// `open`/`openNamed`/`refresh`/`close`/`currentOwner` methods, see
|
|
// @hive/shared/side-panel.js for what they do. `common.css` reaches the
|
|
// 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');
|
|
document.body.append(sidePanel);
|
|
|
|
// ─── 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). `text` is untrusted (agent-authored state files served
|
|
// verbatim by /api/state-file) — the parsed HTML is run through DOMPurify
|
|
// before it touches innerHTML, since markdown can carry raw HTML/script
|
|
// 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') {
|
|
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';
|
|
});
|
|
} 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);
|
|
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);
|
|
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 */ });
|
|
}
|