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:
2026-10-30 17:50:05 -06:00
co-authored by Claude Opus 5.5
parent da31b0a87a
commit 6e820ba0a5
17 changed files with 845 additions and 49 deletions
+53
View File
@@ -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`, {}),
};
+8
View File
@@ -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") {
+2 -1
View File
@@ -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>
+4 -1
View File
@@ -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();
});
});
+19
View File
@@ -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>
);
}
+90
View File
@@ -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;
}
+5 -20
View File
@@ -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();
});
});
+4 -1
View File
@@ -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>
+12 -1
View File
@@ -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; }
}
+25
View File
@@ -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();
});
});
+18 -5
View File
@@ -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>
);