treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
This commit is contained in:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -3,35 +3,55 @@
|
|||
// 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 '@hive/shared/hive-warn.js'; // registers <hive-warn> — side-effect import
|
||||
import DOMPurify from 'dompurify';
|
||||
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 "@hive/shared/hive-warn.js"; // registers <hive-warn> — 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 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 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 } : {}),
|
||||
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': '' } : {}),
|
||||
...(opts.noRefresh ? { "data-no-refresh": "" } : {}),
|
||||
});
|
||||
for (const [name, value] of Object.entries(extra)) {
|
||||
f.append(el('input', { type: 'hidden', name, value }));
|
||||
f.append(el("input", { type: "hidden", name, value }));
|
||||
}
|
||||
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
|
||||
f.append(
|
||||
el("button", { type: "submit", class: "btn " + btnClass }, btnLabel),
|
||||
);
|
||||
return f;
|
||||
};
|
||||
|
||||
|
|
@ -55,8 +75,8 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
|||
// 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';
|
||||
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
|
||||
|
|
@ -64,13 +84,16 @@ const SHARED_WORKER_NAME = 'hyperhive-stream';
|
|||
// closed while this page was frozen.
|
||||
let _sharedPort = null;
|
||||
function makeSharedPort() {
|
||||
if (typeof SharedWorker === 'undefined') return null;
|
||||
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);
|
||||
console.warn(
|
||||
"SharedWorker unavailable, falling back to direct EventSource:",
|
||||
err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -87,19 +110,27 @@ function getSharedPort() {
|
|||
const WORKER_DEAD_THRESHOLD_MS = 90_000;
|
||||
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
|
||||
let _lastWorkerActivityAt = 0;
|
||||
function noteWorkerActivity() { _lastWorkerActivityAt = Date.now(); }
|
||||
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 (
|
||||
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');
|
||||
"hyperhive-stream worker silent for " +
|
||||
Math.round(sinceLast / 1000) +
|
||||
"s, presumed dead — re-subscribing on a fresh port",
|
||||
);
|
||||
rebindOnFreshPort();
|
||||
}, WORKER_WATCHDOG_INTERVAL_MS);
|
||||
}
|
||||
|
|
@ -109,7 +140,9 @@ function rebindOnFreshPort() {
|
|||
// 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 {}
|
||||
try {
|
||||
_sharedPort.removeEventListener("message", sub.route);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
_sharedPort = null;
|
||||
|
|
@ -117,8 +150,10 @@ function rebindOnFreshPort() {
|
|||
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 {}
|
||||
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.
|
||||
|
|
@ -143,32 +178,41 @@ function bindLifecycleOnce() {
|
|||
if (_lifecycleBound) return;
|
||||
_lifecycleBound = true;
|
||||
startWorkerWatchdog();
|
||||
window.addEventListener('pagehide', () => {
|
||||
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 */ }
|
||||
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 */ }
|
||||
try {
|
||||
_sharedPort.removeEventListener("message", sub.route);
|
||||
} catch {
|
||||
/* same */
|
||||
}
|
||||
}
|
||||
_sharedPort = null;
|
||||
});
|
||||
window.addEventListener('pageshow', (ev) => {
|
||||
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 */ }
|
||||
port.addEventListener("message", sub.route);
|
||||
try {
|
||||
port.postMessage({ kind: "subscribe", url });
|
||||
} catch {
|
||||
/* port dead immediately — skip */
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -191,10 +235,16 @@ export function openStream(url) {
|
|||
close() {
|
||||
const p = _sharedPort;
|
||||
if (p) {
|
||||
try { p.postMessage({ kind: 'unsubscribe', url }); }
|
||||
catch { /* port dead */ }
|
||||
try { p.removeEventListener('message', route); }
|
||||
catch { /* same */ }
|
||||
try {
|
||||
p.postMessage({ kind: "unsubscribe", url });
|
||||
} catch {
|
||||
/* port dead */
|
||||
}
|
||||
try {
|
||||
p.removeEventListener("message", route);
|
||||
} catch {
|
||||
/* same */
|
||||
}
|
||||
}
|
||||
_activeSubs.delete(url);
|
||||
},
|
||||
|
|
@ -204,29 +254,38 @@ export function openStream(url) {
|
|||
// before the URL filter, since heartbeat pings carry no URL.
|
||||
noteWorkerActivity();
|
||||
const m = e.data;
|
||||
if (!m || m.kind === 'ping') return;
|
||||
if (!m || m.kind === "ping") return;
|
||||
if (m.url !== url) return;
|
||||
if (m.kind === 'open') {
|
||||
if (m.kind === "open") {
|
||||
target.readyState = 1; // OPEN
|
||||
if (target.onopen) {
|
||||
try { target.onopen({ target }); }
|
||||
catch (err) { console.error('openStream onopen threw', err); }
|
||||
try {
|
||||
target.onopen({ target });
|
||||
} catch (err) {
|
||||
console.error("openStream onopen threw", err);
|
||||
}
|
||||
}
|
||||
} else if (m.kind === 'message') {
|
||||
} else if (m.kind === "message") {
|
||||
if (target.onmessage) {
|
||||
try { target.onmessage({ data: m.data, target }); }
|
||||
catch (err) { console.error('openStream onmessage threw', err); }
|
||||
try {
|
||||
target.onmessage({ data: m.data, target });
|
||||
} catch (err) {
|
||||
console.error("openStream onmessage threw", err);
|
||||
}
|
||||
}
|
||||
} else if (m.kind === 'error') {
|
||||
} else if (m.kind === "error") {
|
||||
if (target.onerror) {
|
||||
try { target.onerror({ target }); }
|
||||
catch (err) { console.error('openStream onerror threw', err); }
|
||||
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 });
|
||||
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();
|
||||
|
|
@ -244,33 +303,41 @@ export function openStream(url) {
|
|||
// (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', () => {
|
||||
pre.addEventListener("scroll", () => {
|
||||
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
|
||||
});
|
||||
let stderrSeen = false;
|
||||
const es = new EventSource('/api/build-logs/id/' + id + '/stream');
|
||||
const es = new EventSource("/api/build-logs/id/" + id + "/stream");
|
||||
es.onmessage = (e) => {
|
||||
let frame;
|
||||
try { frame = JSON.parse(e.data); } catch { return; }
|
||||
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; }
|
||||
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');
|
||||
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;
|
||||
pre.textContent = "";
|
||||
stderrSeen = false;
|
||||
return;
|
||||
}
|
||||
es.close();
|
||||
|
|
@ -295,7 +362,7 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
|
|||
// 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');
|
||||
export const sidePanel = document.createElement("hive-side-panel");
|
||||
document.body.append(sidePanel);
|
||||
|
||||
// ─── path linkification ─────────────────────────────────────────────────
|
||||
|
|
@ -309,45 +376,54 @@ document.body.append(sidePanel);
|
|||
// 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 resp = await fetch("/api/state-file?path=" + encodeURIComponent(path));
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) throw new Error(text || ('http ' + resp.status));
|
||||
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' });
|
||||
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);
|
||||
b.classList.toggle("active", b.dataset.mode === mode);
|
||||
}
|
||||
host.replaceChildren(mode === 'plain'
|
||||
? el('pre', { class: 'path-preview-body' }, plainText)
|
||||
: renderRendered());
|
||||
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));
|
||||
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);
|
||||
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)'));
|
||||
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);
|
||||
img.src = "data:image/svg+xml," + encodeURIComponent(text);
|
||||
return img;
|
||||
}
|
||||
// Marked-rendered markdown node (raw text fallback if `marked`
|
||||
|
|
@ -357,15 +433,15 @@ function svgImage(text) {
|
|||
// 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') {
|
||||
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';
|
||||
div.querySelectorAll("a[href]").forEach((a) => {
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
});
|
||||
} else {
|
||||
div.textContent = text;
|
||||
|
|
@ -381,38 +457,53 @@ const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
|
|||
// 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)'));
|
||||
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);
|
||||
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);
|
||||
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'));
|
||||
view.replaceChildren(
|
||||
buildTabbedPreview(() => svgImage(text), text, "source"),
|
||||
);
|
||||
} else if (isMd) {
|
||||
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
|
||||
view.replaceChildren(
|
||||
buildTabbedPreview(() => mdNode(text), text, "plain"),
|
||||
);
|
||||
} else {
|
||||
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
|
||||
view.replaceChildren(el("pre", { class: "path-preview-body" }, text));
|
||||
}
|
||||
} catch (e) {
|
||||
view.textContent = 'error: ' + (e.message || 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) => {
|
||||
const anchor = el(
|
||||
"a",
|
||||
{
|
||||
href: "#",
|
||||
class: "path-link",
|
||||
title: "open " + path + " in panel",
|
||||
},
|
||||
path,
|
||||
);
|
||||
anchor.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
openFilePanel(path);
|
||||
});
|
||||
|
|
@ -453,7 +544,11 @@ export function appendLinkified(parent, text, refs) {
|
|||
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)) {
|
||||
if (
|
||||
bestStart === -1 ||
|
||||
idx < bestStart ||
|
||||
(idx === bestStart && t.length > bestToken.length)
|
||||
) {
|
||||
bestStart = idx;
|
||||
bestToken = t;
|
||||
}
|
||||
|
|
@ -479,55 +574,63 @@ export function appendLinkified(parent, text, refs) {
|
|||
// 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);
|
||||
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');
|
||||
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';
|
||||
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';
|
||||
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');
|
||||
const enable = $("notif-enable");
|
||||
const mute = $("notif-mute");
|
||||
const unmute = $("notif-unmute");
|
||||
if (!supported || !enable || !mute || !unmute) return;
|
||||
enable.addEventListener('click', async () => {
|
||||
enable.addEventListener("click", async () => {
|
||||
await Notification.requestPermission();
|
||||
renderControls();
|
||||
});
|
||||
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
|
||||
unmute.addEventListener('click', () => { setMuted(false); 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');
|
||||
console.debug("notify: Notification API not supported");
|
||||
return;
|
||||
}
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.debug('notify: permission not granted', Notification.permission);
|
||||
if (Notification.permission !== "granted") {
|
||||
console.debug("notify: permission not granted", Notification.permission);
|
||||
return;
|
||||
}
|
||||
if (isMuted()) {
|
||||
console.debug('notify: muted');
|
||||
console.debug("notify: muted");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -537,12 +640,15 @@ export const NOTIF = (() => {
|
|||
// because that one tag would replace itself on every fire.
|
||||
const n = new Notification(title, {
|
||||
body,
|
||||
tag: tag || ('hyperhive:' + Date.now()),
|
||||
tag: tag || "hyperhive:" + Date.now(),
|
||||
});
|
||||
n.onclick = () => { window.focus(); n.close(); };
|
||||
console.debug('notify: shown', title, 'tag=', tag);
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
n.close();
|
||||
};
|
||||
console.debug("notify: shown", title, "tag=", tag);
|
||||
} catch (err) {
|
||||
console.warn('notification show failed', err);
|
||||
console.warn("notification show failed", err);
|
||||
}
|
||||
}
|
||||
return { bind, show, renderControls };
|
||||
|
|
@ -564,11 +670,11 @@ export const NOTIF = (() => {
|
|||
// 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');
|
||||
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');
|
||||
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);
|
||||
|
|
@ -579,12 +685,12 @@ function ensureStickyTop() {
|
|||
}
|
||||
|
||||
function ensureServerWarningsBar() {
|
||||
let bar = document.getElementById('server-warnings');
|
||||
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 = document.createElement("div");
|
||||
bar.id = "server-warnings";
|
||||
bar.className = "server-warnings";
|
||||
bar.setAttribute("role", "alert");
|
||||
bar.hidden = true;
|
||||
ensureStickyTop().prepend(bar);
|
||||
}
|
||||
|
|
@ -601,10 +707,12 @@ export function renderServerWarnings(warnings) {
|
|||
return;
|
||||
}
|
||||
for (const w of warnings) {
|
||||
const row = el('div', {
|
||||
class: 'server-warn server-warn-' + (w && w.level === 'crit' ? 'crit' : 'warn'),
|
||||
const row = el("div", {
|
||||
class:
|
||||
"server-warn server-warn-" +
|
||||
(w && w.level === "crit" ? "crit" : "warn"),
|
||||
});
|
||||
appendText(row, '⚠ ' + ((w && w.message) || ''));
|
||||
appendText(row, "⚠ " + ((w && w.message) || ""));
|
||||
bar.append(row);
|
||||
}
|
||||
bar.hidden = false;
|
||||
|
|
@ -616,8 +724,10 @@ export function renderServerWarnings(warnings) {
|
|||
/// live updates instead.
|
||||
export function initServerWarnings() {
|
||||
ensureServerWarningsBar();
|
||||
fetch('/api/state')
|
||||
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 */ });
|
||||
.catch(() => {
|
||||
/* non-fatal: no banner if the snapshot is unreachable */
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue