Phase 10: regression bench, hardening, archive location, native VM kit, go-live docs #55
@@ -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 { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import { streamReconnected } from "../lib/buildWatch";
|
||||||
import { AuthCtx } from "./context";
|
import { AuthCtx } from "./context";
|
||||||
import { isScreen, SCREEN_LABELS } from "./roles";
|
import { isScreen, SCREEN_LABELS } from "./roles";
|
||||||
import type { PresenceUser } from "./types";
|
import type { PresenceUser } from "./types";
|
||||||
@@ -92,6 +93,13 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers, opt
|
|||||||
if (!sessionId || typeof EventSource === "undefined") return;
|
if (!sessionId || typeof EventSource === "undefined") return;
|
||||||
const es = new EventSource(eventsUrl(packetId, screen), { withCredentials: true });
|
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) => {
|
es.addEventListener("session", (ev) => {
|
||||||
const p = parse((ev as MessageEvent).data);
|
const p = parse((ev as MessageEvent).data);
|
||||||
if (p && typeof p === "object") {
|
if (p && typeof p === "object") {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { canChecklists, canStampCheck, shellScreens } from "../lib/toolAccess";
|
|||||||
import { LockScreen } from "./LockScreen";
|
import { LockScreen } from "./LockScreen";
|
||||||
import { RoleSwitcher } from "./RoleSwitcher";
|
import { RoleSwitcher } from "./RoleSwitcher";
|
||||||
import { TopBar } from "./TopBar";
|
import { TopBar } from "./TopBar";
|
||||||
|
import { VersionBanner } from "./VersionBanner";
|
||||||
|
|
||||||
function chipClass(s: Screen): string {
|
function chipClass(s: Screen): string {
|
||||||
if (s === "admin") return "rchip admin";
|
if (s === "admin") return "rchip admin";
|
||||||
@@ -47,7 +48,7 @@ export function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar nav={me && !locked ? <RoleSwitcher screens={screens} active={active} /> : null}>
|
<TopBar nav={me && !locked ? <RoleSwitcher screens={screens} active={active} /> : null} banner={<VersionBanner />}>
|
||||||
{me ? (
|
{me ? (
|
||||||
<>
|
<>
|
||||||
<b className="uname">{me.user.display_name}</b>
|
<b className="uname">{me.user.display_name}</b>
|
||||||
|
|||||||
@@ -41,12 +41,15 @@ interface TopBarProps {
|
|||||||
nav?: ReactNode;
|
nav?: ReactNode;
|
||||||
/** Right-hand side: the signed-in user, the role chip, Account and Sign out. */
|
/** Right-hand side: the signed-in user, the role chip, Account and Sign out. */
|
||||||
children?: ReactNode;
|
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. */
|
/** 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 (
|
return (
|
||||||
<header className="top">
|
<header className="top">
|
||||||
|
{banner}
|
||||||
<div className="top-in">
|
<div className="top-in">
|
||||||
<Link className="brand" to="/" aria-label="Packet Station home">
|
<Link className="brand" to="/" aria-label="Packet Station home">
|
||||||
<img alt="PESCO" src={logo} width={119} height={34} />
|
<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
|
// 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
|
// 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.
|
// 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";
|
import { loadPdfJs } from "./pdf";
|
||||||
|
|
||||||
const ROOT_ID = "ps-print-root";
|
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. */
|
/** Render scale: 2 is about 150 dpi on a letter page, sharp enough for drawings and stamps. */
|
||||||
const SCALE = 2;
|
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;
|
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> {
|
function canvasUrl(canvas: HTMLCanvasElement): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob((b) => (b ? resolve(URL.createObjectURL(b)) : reject(new Error("The page could not be drawn."))), "image/png");
|
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 {
|
} finally {
|
||||||
void task.destroy().catch(() => undefined);
|
void task.destroy().catch(() => undefined);
|
||||||
}
|
}
|
||||||
ensureStyle();
|
|
||||||
document.getElementById(ROOT_ID)?.remove();
|
document.getElementById(ROOT_ID)?.remove();
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(root.querySelectorAll("img")).map((img) => (img.complete ? Promise.resolve() : img.decode().catch(() => undefined))),
|
Array.from(root.querySelectorAll("img")).map((img) => (img.complete ? Promise.resolve() : img.decode().catch(() => undefined))),
|
||||||
);
|
);
|
||||||
|
document.body.classList.add(PRINTING);
|
||||||
window.print();
|
window.print();
|
||||||
} finally {
|
} finally {
|
||||||
|
document.body.classList.remove(PRINTING);
|
||||||
root.remove();
|
root.remove();
|
||||||
for (const u of urls) URL.revokeObjectURL(u);
|
for (const u of urls) URL.revokeObjectURL(u);
|
||||||
busy = false;
|
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 },
|
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/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", () => {
|
describe("Admin Storage tab", () => {
|
||||||
it("shows the usage, the warning threshold, the scanner and the open alert, and acknowledges it", async () => {
|
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" } } });
|
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();
|
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 { useState } from "react";
|
||||||
import { storageApi } from "../../api/storage";
|
import { storageApi } from "../../api/storage";
|
||||||
import type { AlertRow } from "../../api/types";
|
import type { AlertRow } from "../../api/types";
|
||||||
import { errorText, fmtBytes, fmtTime } from "../../lib/format";
|
import { errorText, fmtBytes, fmtTime } from "../../lib/format";
|
||||||
import { useLoad } from "../../lib/useLoad";
|
import { useLoad } from "../../lib/useLoad";
|
||||||
|
import { FileLocations } from "./FileLocations";
|
||||||
|
|
||||||
const whoName = (a: AlertRow["acknowledged_by"]) => (a && typeof a === "object" ? a.display_name : (a ?? ""));
|
const whoName = (a: AlertRow["acknowledged_by"]) => (a && typeof a === "object" ? a.display_name : (a ?? ""));
|
||||||
|
|
||||||
@@ -96,6 +97,8 @@ export function StorageTab() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<FileLocations />
|
||||||
|
|
||||||
<section className="sec" aria-labelledby="alerts-h">
|
<section className="sec" aria-labelledby="alerts-h">
|
||||||
<div className="sec-h">
|
<div className="sec-h">
|
||||||
<h3 id="alerts-h">Open alerts</h3>
|
<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); }
|
td.late { color: var(--red); }
|
||||||
.btn.dim { opacity: .55; pointer-events: none; }
|
.btn.dim { opacity: .55; pointer-events: none; }
|
||||||
.btn.pinktxt { color: var(--pink); }
|
.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; }
|
.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 { position: relative; height: 12px; border-radius: 6px; }
|
||||||
.bar.usage .mark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); }
|
.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; }
|
.kpi .n { font-size: 34px; }
|
||||||
button.kpi::after { display: none; }
|
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 { assetsApi, blobUrl } from "../api/assets";
|
||||||
|
import type { PacketPage } from "../api/types";
|
||||||
import { Sheet } from "../components/Sheet";
|
import { Sheet } from "../components/Sheet";
|
||||||
import { useLoad } from "../lib/useLoad";
|
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 }) {
|
export function MtrViewer({ assetId, title, onClose }: { assetId: string; title: string; onClose: () => void }) {
|
||||||
const [meta] = useLoad((s) => assetsApi.get(assetId, s), assetId);
|
const [meta] = useLoad((s) => assetsApi.get(assetId, s), assetId);
|
||||||
const url = blobUrl(assetId);
|
const url = blobUrl(assetId);
|
||||||
const mime = meta.state === "ok" ? meta.data.mime : null;
|
const mime = meta.state === "ok" ? meta.data.mime : null;
|
||||||
|
const pdf = meta.state === "ok" && mime === "application/pdf";
|
||||||
|
const image = !!mime?.startsWith("image/");
|
||||||
return (
|
return (
|
||||||
<Sheet title={title} onClose={onClose} wide>
|
<Sheet title={title} onClose={onClose} wide>
|
||||||
<div className="sheet-acts start">
|
<div className="sheet-acts start">
|
||||||
@@ -19,10 +32,10 @@ export function MtrViewer({ assetId, title, onClose }: { assetId: string; title:
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{meta.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
{meta.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||||
{mime?.startsWith("image/") ? (
|
{image ? <img className="mtrimg" src={url} alt={title} /> : null}
|
||||||
<img className="mtrimg" src={url} alt={title} />
|
{pdf ? <DrawingViewer pages={pdfPages(url, meta.data.pages)} /> : null}
|
||||||
) : meta.state !== "loading" ? (
|
{meta.state !== "loading" && !pdf && !image ? (
|
||||||
<iframe className="mtrframe" src={url} title={title} />
|
<div className="callout flush">This file cannot be shown here. Open it in a new tab.</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,20 +8,37 @@ import time
|
|||||||
from starlette.datastructures import MutableHeaders
|
from starlette.datastructures import MutableHeaders
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
CSP = "; ".join(
|
|
||||||
[
|
def _policy(object_src: str) -> str:
|
||||||
"default-src 'self'",
|
return "; ".join(
|
||||||
"script-src 'self'",
|
[
|
||||||
"style-src 'self' 'unsafe-inline'", # React inline styles; tighten in phase 10
|
"default-src 'self'",
|
||||||
"img-src 'self' data: blob:",
|
# The built bundle only: Vite emits no inline script, and pdf.js 6 needs no eval (wasm stays off).
|
||||||
"font-src 'self'",
|
"script-src 'self'",
|
||||||
"connect-src 'self'",
|
# No 'unsafe-inline'. React style props and pdf.js set CSSOM properties, which CSP allows. The print
|
||||||
"worker-src 'self' blob:", # pdf.js
|
# rules live in theme.css; pdf.js adds fonts through the FontFace API, not a <style> element.
|
||||||
"frame-ancestors 'none'",
|
"style-src 'self'",
|
||||||
"base-uri 'self'",
|
# blob: for the page images printPdf.ts draws before it opens the print dialog. No data: images.
|
||||||
"form-action 'self'",
|
"img-src 'self' blob:",
|
||||||
]
|
"font-src 'self'",
|
||||||
)
|
"connect-src 'self'",
|
||||||
|
# The pdf.js worker is a same-origin file from the build, so it needs no blob: wrapper.
|
||||||
|
"worker-src 'self'",
|
||||||
|
# No frames: PDFs draw on a canvas through pdf.js, and "Open in new tab" is a top-level page.
|
||||||
|
"frame-src 'none'",
|
||||||
|
f"object-src {object_src}",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CSP = _policy("'none'")
|
||||||
|
|
||||||
|
# A PDF opened in its own tab is a plugin document: Chrome checks the PDF's own URL against object-src, so 'none'
|
||||||
|
# would leave the tab blank. The PDF runs none of our script, so 'self' there costs nothing.
|
||||||
|
PDF_CSP = _policy("'self'")
|
||||||
|
|
||||||
HEADERS: dict[str, str] = {
|
HEADERS: dict[str, str] = {
|
||||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||||
@@ -52,6 +69,8 @@ class SecurityHeadersMiddleware:
|
|||||||
headers = MutableHeaders(scope=message)
|
headers = MutableHeaders(scope=message)
|
||||||
for k, v in HEADERS.items():
|
for k, v in HEADERS.items():
|
||||||
headers[k] = v
|
headers[k] = v
|
||||||
|
if headers.get("content-type", "").lower().startswith("application/pdf"):
|
||||||
|
headers["Content-Security-Policy"] = PDF_CSP
|
||||||
if is_api:
|
if is_api:
|
||||||
headers["Cache-Control"] = "no-store"
|
headers["Cache-Control"] = "no-store"
|
||||||
await send(message)
|
await send(message)
|
||||||
|
|||||||
@@ -29,19 +29,21 @@ def _assert_headers(r) -> None: # type: ignore[no-untyped-def]
|
|||||||
for k, v in EXPECTED.items():
|
for k, v in EXPECTED.items():
|
||||||
assert r.headers.get(k) == v, k
|
assert r.headers.get(k) == v, k
|
||||||
csp = r.headers["content-security-policy"]
|
csp = r.headers["content-security-policy"]
|
||||||
for part in (
|
assert [p.strip() for p in csp.split(";")] == [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self'",
|
"script-src 'self'",
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self'",
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' blob:",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
"worker-src 'self' blob:",
|
"worker-src 'self'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
):
|
]
|
||||||
assert part in csp
|
assert "unsafe-inline" not in csp and "unsafe-eval" not in csp
|
||||||
assert csp == security.CSP
|
assert csp == security.CSP
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""The CSP per response type: the strict policy for pages and JSON, object-src 'self' only for PDFs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from packetstation import security
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/page")
|
||||||
|
def page() -> HTMLResponse:
|
||||||
|
return HTMLResponse("<!doctype html><title>x</title>")
|
||||||
|
|
||||||
|
@app.get("/_blob/doc")
|
||||||
|
def pdf() -> Response:
|
||||||
|
return Response(b"%PDF-1.7\n", media_type="application/pdf")
|
||||||
|
|
||||||
|
@app.get("/api/print")
|
||||||
|
def api_pdf() -> Response:
|
||||||
|
return Response(b"%PDF-1.7\n", media_type="Application/PDF; charset=binary")
|
||||||
|
|
||||||
|
app.add_middleware(security.SecurityHeadersMiddleware)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _directives(csp: str) -> dict[str, str]:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for part in csp.split(";"):
|
||||||
|
name, _, value = part.strip().partition(" ")
|
||||||
|
out[name] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_gets_the_strict_policy() -> None:
|
||||||
|
r = _client().get("/page")
|
||||||
|
csp = r.headers["content-security-policy"]
|
||||||
|
assert csp == security.CSP
|
||||||
|
d = _directives(csp)
|
||||||
|
assert d["style-src"] == "'self'"
|
||||||
|
assert d["object-src"] == "'none'"
|
||||||
|
assert d["frame-src"] == "'none'"
|
||||||
|
assert d["frame-ancestors"] == "'none'"
|
||||||
|
assert r.headers["x-frame-options"] == "DENY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_gets_object_src_self_and_nothing_else_changes() -> None:
|
||||||
|
c = _client()
|
||||||
|
for path in ("/_blob/doc", "/api/print"):
|
||||||
|
r = c.get(path)
|
||||||
|
csp = r.headers["content-security-policy"]
|
||||||
|
assert csp == security.PDF_CSP, path
|
||||||
|
strict, pdf = _directives(security.CSP), _directives(csp)
|
||||||
|
assert pdf.pop("object-src") == "'self'"
|
||||||
|
strict.pop("object-src")
|
||||||
|
assert pdf == strict
|
||||||
|
assert r.headers["x-frame-options"] == "DENY"
|
||||||
|
assert c.get("/api/print").headers["cache-control"] == "no-store"
|
||||||
Reference in New Issue
Block a user