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,53 @@
|
||||
// Admin: the archive folder that filed packets are copied to (phase 10, api in section A of the phase 10 contract).
|
||||
import { api } from "./client";
|
||||
|
||||
/** Who ran a check: the server may send a name or a user object. */
|
||||
export type CheckedBy = string | { display_name?: string | null; sam_account?: string | null } | null;
|
||||
|
||||
export interface ArchiveCheck {
|
||||
ok: boolean;
|
||||
at: string;
|
||||
by: CheckedBy;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface ArchiveFailure {
|
||||
seal_id: number;
|
||||
packet_id: number;
|
||||
packet_label: string | null;
|
||||
attempts: number;
|
||||
last_error: string | null;
|
||||
next_try_at: string | null;
|
||||
}
|
||||
|
||||
export interface FileLocations {
|
||||
/** False when ARCHIVE_ROOT is not set on the server. */
|
||||
enabled: boolean;
|
||||
root: string | null;
|
||||
/** The saved folder, relative to the root. */
|
||||
archive_dir: string | null;
|
||||
version: number;
|
||||
/** The saved folder as a full path on the server. */
|
||||
resolved: string | null;
|
||||
last_check: ArchiveCheck | null;
|
||||
copies: { pending: number; failed: number; done: number; recent_failures: ArchiveFailure[] };
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
ok: boolean;
|
||||
resolved: string | null;
|
||||
free_bytes: number | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
const BASE = "/api/admin/file-locations";
|
||||
|
||||
export const fileLocationsApi = {
|
||||
get: (signal?: AbortSignal) => api.get<FileLocations>(BASE, signal),
|
||||
/** Checks a folder without saving it. */
|
||||
check: (archive_dir: string) => api.post<CheckResult>(`${BASE}/check`, { archive_dir }),
|
||||
/** Checks and saves. 409 version_conflict when someone else saved first. */
|
||||
save: (archive_dir: string, version: number) => api.put<FileLocations | null>(BASE, { archive_dir, version }),
|
||||
/** Asks the worker to run the copy job now. */
|
||||
retry: () => api.post<unknown>(`${BASE}/retry`, {}),
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { streamReconnected } from "../lib/buildWatch";
|
||||
import { AuthCtx } from "./context";
|
||||
import { isScreen, SCREEN_LABELS } from "./roles";
|
||||
import type { PresenceUser } from "./types";
|
||||
@@ -92,6 +93,13 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers, opt
|
||||
if (!sessionId || typeof EventSource === "undefined") return;
|
||||
const es = new EventSource(eventsUrl(packetId, screen), { withCredentials: true });
|
||||
|
||||
// A second open on the same EventSource is a reconnect: the server may have restarted on a new build.
|
||||
let opened = false;
|
||||
es.addEventListener("open", () => {
|
||||
if (opened) streamReconnected();
|
||||
opened = true;
|
||||
});
|
||||
|
||||
es.addEventListener("session", (ev) => {
|
||||
const p = parse((ev as MessageEvent).data);
|
||||
if (p && typeof p === "object") {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { canChecklists, canStampCheck, shellScreens } from "../lib/toolAccess";
|
||||
import { LockScreen } from "./LockScreen";
|
||||
import { RoleSwitcher } from "./RoleSwitcher";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { VersionBanner } from "./VersionBanner";
|
||||
|
||||
function chipClass(s: Screen): string {
|
||||
if (s === "admin") return "rchip admin";
|
||||
@@ -47,7 +48,7 @@ export function Layout() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar nav={me && !locked ? <RoleSwitcher screens={screens} active={active} /> : null}>
|
||||
<TopBar nav={me && !locked ? <RoleSwitcher screens={screens} active={active} /> : null} banner={<VersionBanner />}>
|
||||
{me ? (
|
||||
<>
|
||||
<b className="uname">{me.user.display_name}</b>
|
||||
|
||||
@@ -41,12 +41,15 @@ interface TopBarProps {
|
||||
nav?: ReactNode;
|
||||
/** Right-hand side: the signed-in user, the role chip, Account and Sign out. */
|
||||
children?: ReactNode;
|
||||
/** A strip above the bar, inside the sticky header so it stays in view (the build-version banner). */
|
||||
banner?: ReactNode;
|
||||
}
|
||||
|
||||
/** Sticky shop header. Same markup classes as the old page's header.top. */
|
||||
export function TopBar({ nav, children }: TopBarProps) {
|
||||
export function TopBar({ nav, children, banner }: TopBarProps) {
|
||||
return (
|
||||
<header className="top">
|
||||
{banner}
|
||||
<div className="top-in">
|
||||
<Link className="brand" to="/" aria-label="Packet Station home">
|
||||
<img alt="PESCO" src={logo} width={119} height={34} />
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useEvents } from "../auth/useEvents";
|
||||
import { buildOf, onStreamReconnect, streamReconnected } from "../lib/buildWatch";
|
||||
import { callsTo, mockFetch } from "../test/fetchMock";
|
||||
import { UPDATED_TEXT, VersionBanner } from "./VersionBanner";
|
||||
|
||||
/** /api/version answers with whatever `now.build` holds at the time of the call. */
|
||||
function server(first: string) {
|
||||
const now = { build: first };
|
||||
const fetch = mockFetch({ "/api/version": () => ({ body: { build: now.build, stack: "ci", site_tz: "America/Denver" } }) });
|
||||
const versionCalls = () => callsTo(fetch, "GET", "/api/version").length;
|
||||
return { now, fetch, versionCalls };
|
||||
}
|
||||
|
||||
describe("VersionBanner", () => {
|
||||
it("stays hidden while the build is the same, and shows on focus once it changes", async () => {
|
||||
const s = server("sha-aaa");
|
||||
const onReload = vi.fn();
|
||||
render(<VersionBanner minGapMs={0} onReload={onReload} />);
|
||||
await waitFor(() => expect(s.versionCalls()).toBe(1));
|
||||
|
||||
act(() => void window.dispatchEvent(new Event("focus")));
|
||||
await waitFor(() => expect(s.versionCalls()).toBe(2));
|
||||
expect(screen.queryByTestId("version-banner")).toBeNull();
|
||||
|
||||
s.now.build = "sha-bbb";
|
||||
act(() => void window.dispatchEvent(new Event("focus")));
|
||||
const banner = await screen.findByTestId("version-banner");
|
||||
expect(banner).toHaveTextContent(UPDATED_TEXT);
|
||||
expect(banner).toHaveTextContent("Packet Station was updated. Reload to get the new version.");
|
||||
// Never reloads by itself.
|
||||
expect(onReload).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
|
||||
expect(onReload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("checks again when the event stream reconnects", async () => {
|
||||
const s = server("1");
|
||||
render(<VersionBanner minGapMs={0} />);
|
||||
await waitFor(() => expect(s.versionCalls()).toBe(1));
|
||||
s.now.build = "2";
|
||||
act(() => streamReconnected());
|
||||
expect(await screen.findByTestId("version-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("checks again on a timer", async () => {
|
||||
const s = server("1");
|
||||
render(<VersionBanner minGapMs={0} intervalMs={40} />);
|
||||
await waitFor(() => expect(s.versionCalls()).toBe(1));
|
||||
s.now.build = "2";
|
||||
expect(await screen.findByTestId("version-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("checks again when a hidden tab comes back", async () => {
|
||||
const s = server("1");
|
||||
render(<VersionBanner minGapMs={0} />);
|
||||
await waitFor(() => expect(s.versionCalls()).toBe(1));
|
||||
s.now.build = "2";
|
||||
act(() => void document.dispatchEvent(new Event("visibilitychange")));
|
||||
expect(await screen.findByTestId("version-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks at most once per gap when focus comes in bursts", async () => {
|
||||
const s = server("1");
|
||||
render(<VersionBanner />);
|
||||
await waitFor(() => expect(s.versionCalls()).toBe(1));
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
streamReconnected();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(s.versionCalls()).toBe(1);
|
||||
});
|
||||
|
||||
it("rides out a server that is down or restarting", async () => {
|
||||
let answer: { status?: number; body: unknown } = { body: { build: "1" } };
|
||||
const fetch = mockFetch({ "/api/version": () => answer });
|
||||
render(<VersionBanner minGapMs={0} />);
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(1));
|
||||
answer = { status: 502, body: { detail: "Bad gateway" } };
|
||||
act(() => void window.dispatchEvent(new Event("focus")));
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(2));
|
||||
expect(screen.queryByTestId("version-banner")).toBeNull();
|
||||
answer = { body: { build: "1" } };
|
||||
act(() => void window.dispatchEvent(new Event("focus")));
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/version")).toHaveLength(3));
|
||||
expect(screen.queryByTestId("version-banner")).toBeNull();
|
||||
answer = { body: { build: "2" } };
|
||||
act(() => void window.dispatchEvent(new Event("focus")));
|
||||
expect(await screen.findByTestId("version-banner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOf", () => {
|
||||
it("reads build, falls back to version, and ignores odd shapes", () => {
|
||||
expect(buildOf({ build: " sha-1 ", version: "0.1" })).toBe("sha-1");
|
||||
expect(buildOf({ version: "0.1" })).toBe("0.1");
|
||||
expect(buildOf({ build: "", version: "" })).toBeNull();
|
||||
expect(buildOf("1.2.3")).toBeNull();
|
||||
expect(buildOf(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useEvents reconnect", () => {
|
||||
it("reports a reconnect on the second open of the same stream, not the first", () => {
|
||||
const sources: FakeEventSource[] = [];
|
||||
class FakeEventSource {
|
||||
listeners = new Map<string, () => void>();
|
||||
constructor() {
|
||||
sources.push(this);
|
||||
}
|
||||
addEventListener(type: string, fn: () => void) {
|
||||
this.listeners.set(type, fn);
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal("EventSource", FakeEventSource);
|
||||
const seen = vi.fn();
|
||||
const off = onStreamReconnect(seen);
|
||||
renderHook(() => useEvents("sess-1", { onRevoked: () => undefined }));
|
||||
const es = sources.at(-1);
|
||||
act(() => es?.listeners.get("open")?.());
|
||||
expect(seen).not.toHaveBeenCalled();
|
||||
act(() => es?.listeners.get("open")?.());
|
||||
expect(seen).toHaveBeenCalledTimes(1);
|
||||
off();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useBuildChanged, type BuildWatchOptions } from "../lib/buildWatch";
|
||||
|
||||
export const UPDATED_TEXT = "Packet Station was updated. Reload to get the new version.";
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
/** A strip across the top when the server build changed since this page loaded. Reload is the user's call. */
|
||||
export function VersionBanner({ onReload = reloadPage, ...opts }: BuildWatchOptions & { onReload?: () => void }) {
|
||||
const changed = useBuildChanged(opts);
|
||||
if (!changed) return null;
|
||||
return (
|
||||
<div className="vbanner" role="status" data-testid="version-banner">
|
||||
<span>{UPDATED_TEXT}</span>
|
||||
<button className="btn sm pri" type="button" onClick={onReload}>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,33 +1,17 @@
|
||||
// Prints a server PDF from the page, as the old build did (change C4, Gitea issue 27): pdf.js renders each page to an
|
||||
// image in a hidden print area, and the browser's print dialog opens on the same tab. No new tab, no frame, so the
|
||||
// CSP stays as it is. The server stamps the copy and records the print when the PDF is fetched.
|
||||
// The print rules are in theme.css (#ps-print-root, body.ps-printing): a <style> element would need 'unsafe-inline'.
|
||||
import { loadPdfJs } from "./pdf";
|
||||
|
||||
const ROOT_ID = "ps-print-root";
|
||||
const STYLE_ID = "ps-print-style";
|
||||
/** On body while the print dialog is open: the print rules hide the page and show only the print area. */
|
||||
const PRINTING = "ps-printing";
|
||||
/** Render scale: 2 is about 150 dpi on a letter page, sharp enough for drawings and stamps. */
|
||||
const SCALE = 2;
|
||||
|
||||
const CSS = `
|
||||
#${ROOT_ID} { display: none; }
|
||||
@media print {
|
||||
@page { margin: 0; }
|
||||
body > *:not(#${ROOT_ID}) { display: none !important; }
|
||||
#${ROOT_ID} { display: block !important; }
|
||||
#${ROOT_ID} img { display: block; width: 100%; height: auto; max-height: 100vh; object-fit: contain; break-after: page; }
|
||||
#${ROOT_ID} img:last-child { break-after: auto; }
|
||||
}`;
|
||||
|
||||
let busy = false;
|
||||
|
||||
function ensureStyle(): void {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = STYLE_ID;
|
||||
s.textContent = CSS;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function canvasUrl(canvas: HTMLCanvasElement): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((b) => (b ? resolve(URL.createObjectURL(b)) : reject(new Error("The page could not be drawn."))), "image/png");
|
||||
@@ -69,14 +53,15 @@ export async function printPdf(url: string): Promise<void> {
|
||||
} finally {
|
||||
void task.destroy().catch(() => undefined);
|
||||
}
|
||||
ensureStyle();
|
||||
document.getElementById(ROOT_ID)?.remove();
|
||||
document.body.appendChild(root);
|
||||
await Promise.all(
|
||||
Array.from(root.querySelectorAll("img")).map((img) => (img.complete ? Promise.resolve() : img.decode().catch(() => undefined))),
|
||||
);
|
||||
document.body.classList.add(PRINTING);
|
||||
window.print();
|
||||
} finally {
|
||||
document.body.classList.remove(PRINTING);
|
||||
root.remove();
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
busy = false;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// Admin, Storage tab: the archive folder that each filed packet is copied to (phase 10, Gitea issue 7).
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { errorCode } from "../../api/client";
|
||||
import { fileLocationsApi, type CheckedBy } from "../../api/fileLocations";
|
||||
import { errorText, fmtBytes, fmtTime } from "../../lib/format";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { Notice, Problem, type Msg } from "./common";
|
||||
|
||||
export const ARCHIVE_OFF = "The archive is off. Set ARCHIVE_ROOT on the server first.";
|
||||
|
||||
function byName(by: CheckedBy): string {
|
||||
if (!by) return "";
|
||||
if (typeof by === "string") return by;
|
||||
return by.display_name ?? by.sam_account ?? "";
|
||||
}
|
||||
|
||||
export function FileLocations() {
|
||||
const [load, reload] = useLoad((s) => fileLocationsApi.get(s));
|
||||
const [dir, setDir] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
|
||||
// The input follows the saved folder each time it loads.
|
||||
useEffect(() => {
|
||||
if (load.state === "ok") setDir(load.data.archive_dir ?? "");
|
||||
}, [load]);
|
||||
|
||||
const run = async (what: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await what();
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "version_conflict") {
|
||||
setMsg({ ok: false, text: "Someone else saved the folder first. Their value is loaded now." });
|
||||
reload();
|
||||
} else {
|
||||
setMsg({ ok: false, text: errorText(err) });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (load.state !== "ok") {
|
||||
return (
|
||||
<section className="panel stack" aria-labelledby="files-h">
|
||||
<h3 id="files-h">File locations</h3>
|
||||
<Problem load={load} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const f = load.data;
|
||||
if (!f.enabled) {
|
||||
return (
|
||||
<section className="panel stack" aria-labelledby="files-h">
|
||||
<h3 id="files-h">File locations</h3>
|
||||
<div className="callout flush">{ARCHIVE_OFF}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const want = dir.trim();
|
||||
const needDir = () => {
|
||||
if (want) return true;
|
||||
setMsg({ ok: false, text: "Give a folder under the archive root." });
|
||||
return false;
|
||||
};
|
||||
|
||||
const check = () => {
|
||||
if (!needDir()) return;
|
||||
void run(async () => {
|
||||
const r = await fileLocationsApi.check(want);
|
||||
if (r.ok) {
|
||||
const free = typeof r.free_bytes === "number" ? ` ${fmtBytes(r.free_bytes)} free.` : "";
|
||||
setMsg({ ok: true, text: `Check passed. ${r.resolved ?? want} can be written.${free}` });
|
||||
} else {
|
||||
setMsg({ ok: false, text: `Check failed. ${r.message || "The folder cannot be written."}` });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const save = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!needDir()) return;
|
||||
if (want === (f.archive_dir ?? "")) {
|
||||
setMsg({ ok: true, text: "Nothing changed." });
|
||||
return;
|
||||
}
|
||||
void run(async () => {
|
||||
await fileLocationsApi.save(want, f.version);
|
||||
setMsg({ ok: true, text: "Archive folder saved." });
|
||||
reload();
|
||||
});
|
||||
};
|
||||
|
||||
const retry = () =>
|
||||
void run(async () => {
|
||||
await fileLocationsApi.retry();
|
||||
setMsg({ ok: true, text: "Retry requested. The worker runs the copy job shortly." });
|
||||
reload();
|
||||
});
|
||||
|
||||
const lc = f.last_check;
|
||||
const c = f.copies;
|
||||
return (
|
||||
<section className="panel stack" aria-labelledby="files-h">
|
||||
<div className="sec-h flush">
|
||||
<h3 id="files-h">File locations</h3>
|
||||
<span className="grow" />
|
||||
<button className="btn sm ghost" type="button" disabled={busy} onClick={reload}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="small muted">Each filed packet is copied to the archive folder: the sealed data book, its documents and a manifest.</div>
|
||||
<div>
|
||||
Archive root: <span className="mono">{f.root ?? "-"}</span>
|
||||
</div>
|
||||
<div data-testid="archive-saved">
|
||||
Saved folder:{" "}
|
||||
{f.archive_dir ? (
|
||||
<>
|
||||
<span className="mono">{f.archive_dir}</span> {f.resolved ? <span className="small muted mono">({f.resolved})</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="muted">None yet. Nothing is copied until a folder is saved.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="stack tight" onSubmit={save} noValidate>
|
||||
<label className="f">
|
||||
Archive folder
|
||||
<input
|
||||
className="in mono"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={dir}
|
||||
aria-describedby="files-dir-hint"
|
||||
onChange={(e) => setDir(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<span id="files-dir-hint" className="small muted">
|
||||
A folder under the archive root, for example packets. It must exist already.
|
||||
</span>
|
||||
<div className="frow">
|
||||
<button className="btn" type="button" disabled={busy} onClick={check}>
|
||||
Check
|
||||
</button>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Notice msg={msg} />
|
||||
|
||||
<div data-testid="archive-last-check">
|
||||
Last check:{" "}
|
||||
{lc ? (
|
||||
<>
|
||||
<span className={lc.ok ? "st-ok" : "st-bad"}>{lc.ok ? "passed" : "failed"}</span> {fmtTime(lc.at)}
|
||||
{byName(lc.by) ? ` by ${byName(lc.by)}` : ""}.{lc.message ? ` ${lc.message}` : ""}
|
||||
</>
|
||||
) : (
|
||||
<span className="muted">none yet.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sec-h flush">
|
||||
<div data-testid="archive-copies">
|
||||
Copies: <b>{c.done}</b> done, <b>{c.pending}</b> waiting, <b className={c.failed ? "st-bad" : undefined}>{c.failed}</b> failed.
|
||||
</div>
|
||||
<span className="grow" />
|
||||
{c.failed || c.pending ? (
|
||||
<button className="btn sm" type="button" disabled={busy} onClick={retry}>
|
||||
Retry now
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{c.recent_failures.length ? (
|
||||
<div className="tw">
|
||||
<table aria-label="Failed copies">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Packet</th>
|
||||
<th>Seal</th>
|
||||
<th>Attempts</th>
|
||||
<th>Last error</th>
|
||||
<th>Next try</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{c.recent_failures.map((r) => (
|
||||
<tr key={r.seal_id}>
|
||||
<td>{r.packet_label || `Packet ${r.packet_id}`}</td>
|
||||
<td className="mono small">{r.seal_id}</td>
|
||||
<td className="mono small">{r.attempts}</td>
|
||||
<td className="small">{r.last_error ?? "-"}</td>
|
||||
<td className="small mono nowrap">{fmtTime(r.next_try_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,45 @@ function base(clamav: { configured: boolean; host: string | null; reachable: boo
|
||||
body: { usage: { total: 100 * GB, used: 90 * GB, free: 10 * GB, pct: 90, warn: true }, quota_warn_pct: 85, alert: ALERT, clamav },
|
||||
},
|
||||
"/api/admin/alerts": { body: { alerts: [ALERT] } },
|
||||
"/api/admin/file-locations": { body: OFF },
|
||||
};
|
||||
}
|
||||
|
||||
const OFF = {
|
||||
enabled: false,
|
||||
root: null,
|
||||
archive_dir: null,
|
||||
version: 0,
|
||||
resolved: null,
|
||||
last_check: null,
|
||||
copies: { pending: 0, failed: 0, done: 0, recent_failures: [] },
|
||||
};
|
||||
|
||||
const ON = {
|
||||
enabled: true,
|
||||
root: "/archive",
|
||||
archive_dir: "packets",
|
||||
version: 3,
|
||||
resolved: "/archive/packets",
|
||||
last_check: { ok: true, at: "2026-10-30T15:00:00Z", by: "Ann Admin", message: null },
|
||||
copies: {
|
||||
pending: 1,
|
||||
failed: 2,
|
||||
done: 40,
|
||||
recent_failures: [
|
||||
{ seal_id: 77, packet_id: 12, packet_label: "P1001 U1 Weld", attempts: 3, last_error: "No space left on device", next_try_at: "2026-10-30T16:00:00Z" },
|
||||
{ seal_id: 78, packet_id: 13, packet_label: null, attempts: 1, last_error: null, next_try_at: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Opens Admin, then Storage, and waits for the File locations section. */
|
||||
async function openFiles() {
|
||||
renderApp("/admin");
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Storage" }));
|
||||
return screen.findByRole("region", { name: "File locations" });
|
||||
}
|
||||
|
||||
describe("Admin Storage tab", () => {
|
||||
it("shows the usage, the warning threshold, the scanner and the open alert, and acknowledges it", async () => {
|
||||
const fetch = mockFetch({ ...base({ configured: true, host: "clamav", reachable: true }), "POST /api/admin/alerts/3/ack": { body: { ...ALERT, acknowledged_at: "2026-10-16T08:00:00Z" } } });
|
||||
@@ -59,3 +95,130 @@ describe("Admin Storage tab", () => {
|
||||
expect(await screen.findByText("Not configured")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Admin File locations", () => {
|
||||
it("says the archive is off when the server has no ARCHIVE_ROOT", async () => {
|
||||
mockFetch(base({ configured: true, host: "clamav", reachable: true }));
|
||||
const sec = await openFiles();
|
||||
expect(await within(sec).findByText("The archive is off. Set ARCHIVE_ROOT on the server first.")).toBeInTheDocument();
|
||||
expect(within(sec).queryByLabelText("Archive folder")).toBeNull();
|
||||
expect(within(sec).queryByRole("button", { name: "Save" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the root, the saved folder, the last check, the copy counts and the failures", async () => {
|
||||
mockFetch({ ...base({ configured: true, host: "clamav", reachable: true }), "/api/admin/file-locations": { body: ON } });
|
||||
const sec = await openFiles();
|
||||
expect(await within(sec).findByLabelText("Archive folder")).toHaveValue("packets");
|
||||
expect(within(sec).getByText("/archive")).toBeInTheDocument();
|
||||
expect(within(sec).getByTestId("archive-saved")).toHaveTextContent("Saved folder: packets (/archive/packets)");
|
||||
expect(within(sec).getByTestId("archive-last-check")).toHaveTextContent(/Last check: passed .* by Ann Admin\./);
|
||||
expect(within(sec).getByTestId("archive-copies")).toHaveTextContent("Copies: 40 done, 1 waiting, 2 failed.");
|
||||
const table = within(sec).getByRole("table", { name: "Failed copies" });
|
||||
expect(within(table).getByText("P1001 U1 Weld")).toBeInTheDocument();
|
||||
expect(within(table).getByText("No space left on device")).toBeInTheDocument();
|
||||
expect(within(table).getByText("Packet 13")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("checks a folder without saving it, and shows a refused path", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base({ configured: true, host: "clamav", reachable: true }),
|
||||
"/api/admin/file-locations": { body: ON },
|
||||
"POST /api/admin/file-locations/check": (req) =>
|
||||
(req.body as { archive_dir: string }).archive_dir === "new"
|
||||
? { body: { ok: true, resolved: "/archive/new", free_bytes: 500 * GB, message: null } }
|
||||
: { status: 422, body: { detail: "The folder must be under the archive root.", code: "bad_path" } },
|
||||
});
|
||||
const sec = await openFiles();
|
||||
const input = await within(sec).findByLabelText("Archive folder");
|
||||
fireEvent.change(input, { target: { value: " new " } });
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Check" }));
|
||||
expect(await within(sec).findByRole("status")).toHaveTextContent("Check passed. /archive/new can be written. 500 GB free.");
|
||||
fireEvent.change(input, { target: { value: "../etc" } });
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Check" }));
|
||||
expect(await within(sec).findByRole("alert")).toHaveTextContent("The folder must be under the archive root.");
|
||||
expect(callsTo(fetch, "POST", "/api/admin/file-locations/check")).toEqual([{ archive_dir: "new" }, { archive_dir: "../etc" }]);
|
||||
expect(callsTo(fetch, "PUT", "/api/admin/file-locations")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows a failed check with the server's message", async () => {
|
||||
mockFetch({
|
||||
...base({ configured: true, host: "clamav", reachable: true }),
|
||||
"/api/admin/file-locations": { body: ON },
|
||||
"POST /api/admin/file-locations/check": { body: { ok: false, resolved: "/archive/ro", free_bytes: null, message: "Permission denied." } },
|
||||
});
|
||||
const sec = await openFiles();
|
||||
fireEvent.change(await within(sec).findByLabelText("Archive folder"), { target: { value: "ro" } });
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Check" }));
|
||||
expect(await within(sec).findByRole("alert")).toHaveTextContent("Check failed. Permission denied.");
|
||||
});
|
||||
|
||||
it("saves the folder with the version it loaded, then reloads", async () => {
|
||||
let saved = false;
|
||||
const fetch = mockFetch({
|
||||
...base({ configured: true, host: "clamav", reachable: true }),
|
||||
"/api/admin/file-locations": () => ({ body: saved ? { ...ON, archive_dir: "new", resolved: "/archive/new", version: 4 } : ON }),
|
||||
"PUT /api/admin/file-locations": () => {
|
||||
saved = true;
|
||||
return { body: { ...ON, archive_dir: "new", version: 4 } };
|
||||
},
|
||||
});
|
||||
const sec = await openFiles();
|
||||
const input = await within(sec).findByLabelText("Archive folder");
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Save" }));
|
||||
expect(await within(sec).findByRole("status")).toHaveTextContent("Nothing changed.");
|
||||
fireEvent.change(input, { target: { value: "new" } });
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Save" }));
|
||||
expect(await within(sec).findByText("Archive folder saved.")).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "PUT", "/api/admin/file-locations")).toEqual([{ archive_dir: "new", version: 3 }]);
|
||||
await waitFor(() => expect(within(sec).getByTestId("archive-saved")).toHaveTextContent("Saved folder: new (/archive/new)"));
|
||||
});
|
||||
|
||||
it("says when someone else saved first, and loads their value", async () => {
|
||||
let calls = 0;
|
||||
mockFetch({
|
||||
...base({ configured: true, host: "clamav", reachable: true }),
|
||||
"/api/admin/file-locations": () => ({ body: calls++ === 0 ? ON : { ...ON, archive_dir: "theirs", version: 4 } }),
|
||||
"PUT /api/admin/file-locations": { status: 409, body: { detail: "Version conflict", code: "version_conflict" } },
|
||||
});
|
||||
const sec = await openFiles();
|
||||
fireEvent.change(await within(sec).findByLabelText("Archive folder"), { target: { value: "mine" } });
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Save" }));
|
||||
expect(await within(sec).findByRole("alert")).toHaveTextContent("Someone else saved the folder first. Their value is loaded now.");
|
||||
await waitFor(() => expect(within(sec).getByLabelText("Archive folder")).toHaveValue("theirs"));
|
||||
});
|
||||
|
||||
it("shows a save refused by the check", async () => {
|
||||
mockFetch({
|
||||
...base({ configured: true, host: "clamav", reachable: true }),
|
||||
"/api/admin/file-locations": { body: ON },
|
||||
"PUT /api/admin/file-locations": { status: 422, body: { detail: "The folder does not exist.", code: "check_failed" } },
|
||||
});
|
||||
const sec = await openFiles();
|
||||
fireEvent.change(await within(sec).findByLabelText("Archive folder"), { target: { value: "missing" } });
|
||||
fireEvent.click(within(sec).getByRole("button", { name: "Save" }));
|
||||
expect(await within(sec).findByRole("alert")).toHaveTextContent("The folder does not exist.");
|
||||
});
|
||||
|
||||
it("asks the worker to retry the copies", async () => {
|
||||
const fetch = mockFetch({
|
||||
...base({ configured: true, host: "clamav", reachable: true }),
|
||||
"/api/admin/file-locations": { body: ON },
|
||||
"POST /api/admin/file-locations/retry": { status: 202, body: { ok: true } },
|
||||
});
|
||||
const sec = await openFiles();
|
||||
fireEvent.click(await within(sec).findByRole("button", { name: "Retry now" }));
|
||||
expect(await within(sec).findByRole("status")).toHaveTextContent("Retry requested. The worker runs the copy job shortly.");
|
||||
expect(callsTo(fetch, "POST", "/api/admin/file-locations/retry")).toEqual([{}]);
|
||||
});
|
||||
|
||||
it("hides Retry now when nothing waits or failed", async () => {
|
||||
mockFetch({
|
||||
...base({ configured: true, host: "clamav", reachable: true }),
|
||||
"/api/admin/file-locations": { body: { ...ON, copies: { pending: 0, failed: 0, done: 5, recent_failures: [] } } },
|
||||
});
|
||||
const sec = await openFiles();
|
||||
expect(await within(sec).findByTestId("archive-copies")).toHaveTextContent("Copies: 5 done, 0 waiting, 0 failed.");
|
||||
expect(within(sec).queryByRole("button", { name: "Retry now" })).toBeNull();
|
||||
expect(within(sec).queryByRole("table")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Admin: the assets volume, the virus scanner and the open alerts (phase 4).
|
||||
// Admin: the assets volume, the virus scanner, the archive folder (phase 10) and the open alerts (phase 4).
|
||||
import { useState } from "react";
|
||||
import { storageApi } from "../../api/storage";
|
||||
import type { AlertRow } from "../../api/types";
|
||||
import { errorText, fmtBytes, fmtTime } from "../../lib/format";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { FileLocations } from "./FileLocations";
|
||||
|
||||
const whoName = (a: AlertRow["acknowledged_by"]) => (a && typeof a === "object" ? a.display_name : (a ?? ""));
|
||||
|
||||
@@ -96,6 +97,8 @@ export function StorageTab() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FileLocations />
|
||||
|
||||
<section className="sec" aria-labelledby="alerts-h">
|
||||
<div className="sec-h">
|
||||
<h3 id="alerts-h">Open alerts</h3>
|
||||
|
||||
@@ -407,7 +407,6 @@ td.shortqty { font-size: 18px; font-weight: 700; color: var(--pink); }
|
||||
td.late { color: var(--red); }
|
||||
.btn.dim { opacity: .55; pointer-events: none; }
|
||||
.btn.pinktxt { color: var(--pink); }
|
||||
.mtrframe { width: 100%; height: min(75vh, 900px); border: 1px solid var(--line); border-radius: 10px; background: var(--white); }
|
||||
.mtrimg { max-width: 100%; height: auto; border-radius: 10px; background: var(--white); align-self: center; }
|
||||
.bar.usage { position: relative; height: 12px; border-radius: 6px; }
|
||||
.bar.usage .mark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); }
|
||||
@@ -758,3 +757,15 @@ a.itpcard { flex-direction: row; text-align: left; }
|
||||
.kpi .n { font-size: 34px; }
|
||||
button.kpi::after { display: none; }
|
||||
}
|
||||
/* Phase 10: the build-version banner, inside the sticky header so it stays in view. */
|
||||
.vbanner { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; max-width: 1500px; margin: 0 auto 10px; padding: 8px 14px; border: 1px solid #5a4720; border-left: 3px solid var(--amber); border-radius: 0 10px 10px 0; background: #211c10; font-size: 14px; font-weight: 600; }
|
||||
.vbanner span { flex: 1 1 260px; }
|
||||
/* Phase 10: the print area printPdf.ts fills. Here and not in a <style> element, so the CSP needs no 'unsafe-inline'. */
|
||||
#ps-print-root { display: none; }
|
||||
@media print {
|
||||
@page psprint { margin: 0; }
|
||||
body.ps-printing > *:not(#ps-print-root) { display: none !important; }
|
||||
body.ps-printing #ps-print-root { display: block !important; }
|
||||
#ps-print-root img { page: psprint; display: block; width: 100%; height: auto; max-height: 100vh; object-fit: contain; break-after: page; }
|
||||
#ps-print-root img:last-child { break-after: auto; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mockFetch } from "../test/fetchMock";
|
||||
import { MtrViewer } from "./MtrViewer";
|
||||
|
||||
const asset = (mime: string, pages: number | null) => ({
|
||||
body: { id: "a-mtr", mime, size: 9, sha256: "00", pages, original_name: "m", url: "/_blob/a-mtr" },
|
||||
});
|
||||
|
||||
describe("MtrViewer", () => {
|
||||
it("draws a PDF with pdf.js, one entry per page, and never in a frame (CSP frame-src 'none')", async () => {
|
||||
mockFetch({ "/api/assets/a-mtr": asset("application/pdf", 2) });
|
||||
const { container } = render(<MtrViewer assetId="a-mtr" title="MTR · item 1" onClose={() => undefined} />);
|
||||
expect(await screen.findByRole("img", { name: "Drawing page 1" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("list", { name: "Pages" }).querySelectorAll("li")).toHaveLength(2);
|
||||
expect(container.ownerDocument.querySelector("iframe, object, embed")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Open in new tab" })).toHaveAttribute("href", "/_blob/a-mtr");
|
||||
});
|
||||
|
||||
it("says a file it cannot show opens in a new tab", async () => {
|
||||
mockFetch({ "/api/assets/a-mtr": asset("application/zip", null) });
|
||||
render(<MtrViewer assetId="a-mtr" title="MTR · item 1" onClose={() => undefined} />);
|
||||
expect(await screen.findByText("This file cannot be shown here. Open it in a new tab.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,25 @@
|
||||
import { assetsApi, blobUrl } from "../api/assets";
|
||||
import type { PacketPage } from "../api/types";
|
||||
import { Sheet } from "../components/Sheet";
|
||||
import { useLoad } from "../lib/useLoad";
|
||||
import { DrawingViewer } from "../packet/DrawingViewer";
|
||||
|
||||
/** Shows an MTR file: a PDF in a frame, an image as itself, and a link to open it in a new tab. */
|
||||
/** The pages of a stored PDF, for DrawingViewer: the asset's page count, else one page. */
|
||||
function pdfPages(url: string, count: number | null): PacketPage[] {
|
||||
const n = count && count > 0 ? Math.floor(count) : 1;
|
||||
return Array.from({ length: n }, (_, i) => ({ n: i + 1, pn: i + 1, type: "info", asset_id: null, dwg: null, rev: null, void: null, url }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an MTR file: a PDF drawn by pdf.js, an image as itself, and a link to open it in a new tab.
|
||||
* No frame: the CSP has frame-src 'none', and the file's own frame-ancestors 'none' would refuse one anyway.
|
||||
*/
|
||||
export function MtrViewer({ assetId, title, onClose }: { assetId: string; title: string; onClose: () => void }) {
|
||||
const [meta] = useLoad((s) => assetsApi.get(assetId, s), assetId);
|
||||
const url = blobUrl(assetId);
|
||||
const mime = meta.state === "ok" ? meta.data.mime : null;
|
||||
const pdf = meta.state === "ok" && mime === "application/pdf";
|
||||
const image = !!mime?.startsWith("image/");
|
||||
return (
|
||||
<Sheet title={title} onClose={onClose} wide>
|
||||
<div className="sheet-acts start">
|
||||
@@ -19,10 +32,10 @@ export function MtrViewer({ assetId, title, onClose }: { assetId: string; title:
|
||||
</button>
|
||||
</div>
|
||||
{meta.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||
{mime?.startsWith("image/") ? (
|
||||
<img className="mtrimg" src={url} alt={title} />
|
||||
) : meta.state !== "loading" ? (
|
||||
<iframe className="mtrframe" src={url} title={title} />
|
||||
{image ? <img className="mtrimg" src={url} alt={title} /> : null}
|
||||
{pdf ? <DrawingViewer pages={pdfPages(url, meta.data.pages)} /> : null}
|
||||
{meta.state !== "loading" && !pdf && !image ? (
|
||||
<div className="callout flush">This file cannot be shown here. Open it in a new tab.</div>
|
||||
) : null}
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user