diff --git a/frontend/src/api/fileLocations.ts b/frontend/src/api/fileLocations.ts new file mode 100644 index 0000000..fd1638e --- /dev/null +++ b/frontend/src/api/fileLocations.ts @@ -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(BASE, signal), + /** Checks a folder without saving it. */ + check: (archive_dir: string) => api.post(`${BASE}/check`, { archive_dir }), + /** Checks and saves. 409 version_conflict when someone else saved first. */ + save: (archive_dir: string, version: number) => api.put(BASE, { archive_dir, version }), + /** Asks the worker to run the copy job now. */ + retry: () => api.post(`${BASE}/retry`, {}), +}; diff --git a/frontend/src/auth/useEvents.ts b/frontend/src/auth/useEvents.ts index 3f0d2b4..148dc53 100644 --- a/frontend/src/auth/useEvents.ts +++ b/frontend/src/auth/useEvents.ts @@ -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") { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 98b907c..2402e5e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 ( <> - : null}> + : null} banner={}> {me ? ( <> {me.user.display_name} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index d47bec4..b9bd222 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -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 (
+ {banner}
PESCO diff --git a/frontend/src/components/VersionBanner.test.tsx b/frontend/src/components/VersionBanner.test.tsx new file mode 100644 index 0000000..2fec531 --- /dev/null +++ b/frontend/src/components/VersionBanner.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 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(); + }); +}); diff --git a/frontend/src/components/VersionBanner.tsx b/frontend/src/components/VersionBanner.tsx new file mode 100644 index 0000000..3be3180 --- /dev/null +++ b/frontend/src/components/VersionBanner.tsx @@ -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 ( +
+ {UPDATED_TEXT} + +
+ ); +} diff --git a/frontend/src/lib/buildWatch.ts b/frontend/src/lib/buildWatch.ts new file mode 100644 index 0000000..a8eba7f --- /dev/null +++ b/frontend/src/lib/buildWatch.ts @@ -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; + 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("/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; +} diff --git a/frontend/src/lib/printPdf.ts b/frontend/src/lib/printPdf.ts index 774ce68..d5a2095 100644 --- a/frontend/src/lib/printPdf.ts +++ b/frontend/src/lib/printPdf.ts @@ -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