Version banner, File locations section, final CSP (no unsafe-inline styles, frame-src none, object-src none)
The MTR viewer draws PDFs with pdf.js instead of an iframe the server's frame-ancestors refused. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Notices a new server build while the page is open, so the user can reload (phase 10).
|
||||
// The page remembers the build at load and asks again on window focus, when the event stream reconnects
|
||||
// (a deploy restarts the server and drops the stream) and every few minutes. It never reloads by itself:
|
||||
// a form may be half filled.
|
||||
import { useEffect, useState } from "react";
|
||||
import { request } from "../api/client";
|
||||
|
||||
export const BUILD_CHECK_MS = 5 * 60_000;
|
||||
/** Focus and reconnects can come in bursts. One check per this many ms is enough. */
|
||||
export const BUILD_MIN_GAP_MS = 10_000;
|
||||
|
||||
/** The build id from /api/version: `build`, else `version`. Null when neither is there. */
|
||||
export function buildOf(raw: unknown): string | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
for (const v of [o.build, o.version]) if (typeof v === "string" && v.trim() !== "") return v.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
const reconnectListeners = new Set<() => void>();
|
||||
|
||||
/** The event stream calls this when it opens again after a drop. */
|
||||
export function streamReconnected(): void {
|
||||
for (const fn of reconnectListeners) fn();
|
||||
}
|
||||
|
||||
export function onStreamReconnect(fn: () => void): () => void {
|
||||
reconnectListeners.add(fn);
|
||||
return () => {
|
||||
reconnectListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildWatchOptions {
|
||||
intervalMs?: number;
|
||||
minGapMs?: number;
|
||||
}
|
||||
|
||||
/** True once /api/version answers with a build other than the one the page loaded with. Stays true. */
|
||||
export function useBuildChanged({ intervalMs = BUILD_CHECK_MS, minGapMs = BUILD_MIN_GAP_MS }: BuildWatchOptions = {}): boolean {
|
||||
const [changed, setChanged] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ctl = new AbortController();
|
||||
let base: string | null = null;
|
||||
let busy = false;
|
||||
let done = false;
|
||||
let last = Number.NEGATIVE_INFINITY;
|
||||
|
||||
const check = async () => {
|
||||
const now = Date.now();
|
||||
if (busy || done || now - last < minGapMs) return;
|
||||
busy = true;
|
||||
last = now;
|
||||
try {
|
||||
const b = buildOf(await request<unknown>("/api/version", { signal: ctl.signal }));
|
||||
if (!b || ctl.signal.aborted) return;
|
||||
if (base === null) base = b;
|
||||
else if (b !== base) {
|
||||
done = true;
|
||||
setChanged(true);
|
||||
}
|
||||
} catch {
|
||||
// Offline or restarting. The next focus, reconnect or tick asks again, with no wait.
|
||||
last = Number.NEGATIVE_INFINITY;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
const kick = () => void check();
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") kick();
|
||||
};
|
||||
|
||||
kick();
|
||||
window.addEventListener("focus", kick);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
const offReconnect = onStreamReconnect(kick);
|
||||
const timer = window.setInterval(kick, intervalMs);
|
||||
return () => {
|
||||
ctl.abort();
|
||||
window.removeEventListener("focus", kick);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
offReconnect();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [intervalMs, minGapMs]);
|
||||
|
||||
return changed;
|
||||
}
|
||||
Reference in New Issue
Block a user