From fd51cbd19dfbd5d11abc8c469ba681ecccdc1f2e Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 5 Nov 2026 23:14:25 +0100 Subject: [PATCH] swarm-ui: pages that only display start from the answer they last saw, then refresh behind it usePolled takes a cache key: it saves each answer in localStorage and starts from the saved one, so opening the agents, alerts, builds, review runs or hives view shows the last data at once and the refresh spinner marks the update. A saved answer is dropped after a day, when it was saved by another build of the UI, at log out (a new onLogout of the shared user menu) and when the gateway ends the login (a new onSessionEnded of the session guard). Pages with an editor (schedules, notification flows, broadcast) do not use it. --- docs/swarm/ui.md | 7 ++ .../shared/src/user-menu/UserMenu.tsx | 9 +- frontend/packages/swarm-ui/src/main.tsx | 4 +- .../swarm-ui/src/pages/AlertsPage.tsx | 6 +- .../swarm-ui/src/pages/agents/AgentsPage.tsx | 6 +- .../swarm-ui/src/pages/builds/BuildsPage.tsx | 4 +- .../src/pages/review-runs/ReviewRuns.tsx | 6 +- .../swarm-ui/src/pages/swarm/HivesCard.tsx | 6 +- .../packages/swarm-ui/src/shell/UserMenu.tsx | 9 +- .../swarm-ui/src/ui/polled/answer-cache.ts | 87 +++++++++++++++++++ .../swarm-ui/src/ui/polled/usePolled.ts | 19 +++- .../swarm-ui/src/ui/session/session.ts | 9 ++ .../swarm-ui/test/answer-cache.test.mjs | 80 +++++++++++++++++ 13 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/polled/answer-cache.ts create mode 100644 frontend/packages/swarm-ui/test/answer-cache.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index d70c74ad..db36ead9 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -42,6 +42,13 @@ error. While a page is fetching, its refresh picker shows a spinner in place of the clock, for at least 0.4 seconds so a quick answer still reads as a refresh. The Jobs page has none yet. +The pages that only display (agents, alerts, builds, review runs and the hives +card) show the answer they last saw at once, then replace it with the fresh one +while the spinner runs, so opening one never starts on an empty page. The browser +keeps the answer for itself only, and forgets it after a day, after a new build +of the UI, at **log out**, and when the login ends. The pages with an editor +(schedules, notification flows, broadcast) start empty as before. + The **wanted** badge changes the state of an agent the swarm has declared. For an agent the swarm never declared on its hive, such as one only its hive knows about, the badge reads **no declaration** and doesn't open: create the agent diff --git a/frontend/packages/shared/src/user-menu/UserMenu.tsx b/frontend/packages/shared/src/user-menu/UserMenu.tsx index 311aff3d..3bd3a273 100644 --- a/frontend/packages/shared/src/user-menu/UserMenu.tsx +++ b/frontend/packages/shared/src/user-menu/UserMenu.tsx @@ -28,12 +28,16 @@ import { export function UserMenu({ whoamiUrl, autheliaUrl, + onLogout, }: { // Where the signed-in user is asked for, on this page's own origin. whoamiUrl: string; // Authelia's base URL, for the settings and sign-out links; none when it // is not known, and the menu then shows only the name. autheliaUrl: string | null; + // Runs when "log out" is chosen, for a page to forget what it kept of this + // session. + onLogout?: () => void; }) { const [who, setWho] = useState(null); const [open, setOpen] = useState(false); @@ -109,7 +113,10 @@ export function UserMenu({ class="user-menu-item" href={links.logout} role="menuitem" - onClick={() => setOpen(false)} + onClick={() => { + onLogout?.(); + setOpen(false); + }} > log out diff --git a/frontend/packages/swarm-ui/src/main.tsx b/frontend/packages/swarm-ui/src/main.tsx index c4570bfa..8dc8bdd4 100644 --- a/frontend/packages/swarm-ui/src/main.tsx +++ b/frontend/packages/swarm-ui/src/main.tsx @@ -1,8 +1,10 @@ import { render } from "preact"; import { App } from "./App.js"; -import { installSessionGuard } from "./ui/session/session.js"; +import { clearAnswers } from "./ui/polled/answer-cache.js"; +import { installSessionGuard, onSessionEnded } from "./ui/session/session.js"; // Before anything fetches: a 401 that means "log in again" reloads the page. +onSessionEnded(clearAnswers); installSessionGuard(); const root = document.getElementById("root"); diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index 6f08dab0..d7bfc4fe 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -79,8 +79,10 @@ function AlertsBody() { data: alerts, error, refreshing, - } = usePolled(intervalMs, (signal) => - fetchJson("/api/alerts", signal), + } = usePolled( + intervalMs, + (signal) => fetchJson("/api/alerts", signal), + { cacheKey: "alerts" }, ); const listed = alerts ? listedAlerts(alerts, showAll) : null; const hidden = alerts && listed ? alerts.length - listed.length : 0; diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx index 27e83724..d63f72ee 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx @@ -100,8 +100,10 @@ export function AgentsPage() { error, refreshing, setData: setRows, - } = usePolled(intervalMs, (signal) => - fetchJson("/api/agents/status", signal), + } = usePolled( + intervalMs, + (signal) => fetchJson("/api/agents/status", signal), + { cacheKey: "agents" }, ); const [createOpen, setCreateOpen] = useState(false); // The PUT response is the hive's whole declaration set (every agent's diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx index 0c6ff268..e944fac4 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx @@ -100,7 +100,9 @@ function BuildsBody() { data: runs, error, refreshing, - } = usePolled(intervalMs, (signal) => fetchJson(RUNS, signal)); + } = usePolled(intervalMs, (signal) => fetchJson(RUNS, signal), { + cacheKey: "builds", + }); // Which cards are open. Survives a refresh, so a list that updates under // you does not fold the card you are reading. A linked build starts open. const [open, setOpen] = useState>( diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx index 544f82cf..898c3df3 100644 --- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx @@ -97,8 +97,10 @@ export function ReviewRuns() { data: runs, error, refreshing, - } = usePolled(intervalMs, (signal) => - fetchJson(RUNS, signal), + } = usePolled( + intervalMs, + (signal) => fetchJson(RUNS, signal), + { cacheKey: "review-runs" }, ); // Which cards are expanded. Survives a refresh, so a list that updates // under you does not fold the card you are reading. diff --git a/frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx b/frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx index e910af66..6d763db2 100644 --- a/frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx @@ -120,8 +120,10 @@ function HiveRoster() { data: hives, error, refreshing, - } = usePolled(intervalMs, (signal) => - fetchJson("/api/hives/status", signal), + } = usePolled( + intervalMs, + (signal) => fetchJson("/api/hives/status", signal), + { cacheKey: "hives" }, ); return ( diff --git a/frontend/packages/swarm-ui/src/shell/UserMenu.tsx b/frontend/packages/swarm-ui/src/shell/UserMenu.tsx index 4e8ee0f1..5fe5bcc6 100644 --- a/frontend/packages/swarm-ui/src/shell/UserMenu.tsx +++ b/frontend/packages/swarm-ui/src/shell/UserMenu.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "preact/hooks"; import { UserMenu as SharedUserMenu } from "@hive/shared/user-menu.js"; import "@hive/shared/user-menu.css"; +import { clearAnswers } from "../ui/polled/answer-cache.js"; interface ServiceLink { label: string; @@ -32,5 +33,11 @@ export function UserMenu() { }); }, []); - return ; + return ( + clearAnswers()} + /> + ); } diff --git a/frontend/packages/swarm-ui/src/ui/polled/answer-cache.ts b/frontend/packages/swarm-ui/src/ui/polled/answer-cache.ts new file mode 100644 index 00000000..eb2fd541 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/polled/answer-cache.ts @@ -0,0 +1,87 @@ +// The last answer of a polled request, kept in the browser (`localStorage`) so +// a page can show it at once on the next visit and refresh behind it. An entry +// is dropped when it is older than `MAX_AGE_MS`, when it was saved by another +// build of the page (a deploy can change what an answer looks like, and a page +// must not read the old shape), when it does not parse, and when the session +// ends: `clearAnswers` runs at log out and when the gateway answers 401. +// Pure apart from the storage it is given, so `npm test` runs it. + +const PREFIX = "swarm-ui:answer:"; + +/** An answer older than this is not shown. */ +export const MAX_AGE_MS = 24 * 60 * 60 * 1000; + +/** Where `import.meta.url` names the build: the bundle's file name has its hash. */ +export const THIS_BUILD = import.meta.url; + +interface Entry { + at: number; + build: string; + data: unknown; +} + +export function readAnswer( + key: string, + opts: { + storage?: Pick; + now?: number; + build?: string; + } = {}, +): T | null { + const { storage = localStorage, now = Date.now(), build = THIS_BUILD } = opts; + try { + const raw = storage.getItem(PREFIX + key); + if (raw === null) return null; + const entry = JSON.parse(raw) as Partial | null; + if ( + entry === null || + typeof entry !== "object" || + entry.build !== build || + typeof entry.at !== "number" || + now - entry.at > MAX_AGE_MS || + entry.at > now + 60_000 || + entry.data === undefined || + entry.data === null + ) { + return null; + } + return entry.data as T; + } catch { + // No storage (a locked-down browser) or a value that is not JSON: no answer. + return null; + } +} + +export function writeAnswer( + key: string, + data: unknown, + opts: { + storage?: Pick; + now?: number; + build?: string; + } = {}, +): void { + const { storage = localStorage, now = Date.now(), build = THIS_BUILD } = opts; + const entry: Entry = { at: now, build, data }; + try { + storage.setItem(PREFIX + key, JSON.stringify(entry)); + } catch { + // Over quota or no storage: the page just does not keep this answer. + } +} + +/** Forgets every saved answer: another account may sign in next. */ +export function clearAnswers( + storage: Pick = localStorage, +): void { + try { + const keys: string[] = []; + for (let i = 0; i < storage.length; i++) { + const k = storage.key(i); + if (k !== null && k.startsWith(PREFIX)) keys.push(k); + } + keys.forEach((k) => storage.removeItem(k)); + } catch { + // Nothing to clear without storage. + } +} diff --git a/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts b/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts index 38a33c52..cc07ca48 100644 --- a/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts +++ b/frontend/packages/swarm-ui/src/ui/polled/usePolled.ts @@ -1,5 +1,7 @@ // A resource refetched on a timer: `{ data, error, refreshing, refresh, setData }`. -// `data` is `null` until the first answer; a failed fetch sets `error` and +// `data` is `null` until the first answer (or, with a `cacheKey`, the saved one +// of the last visit, which is shown at once and replaced by the fresh answer; +// see `answer-cache.ts`); a failed fetch sets `error` and // keeps the last good `data`; the next good answer clears `error`. The // request discipline (skip a tick while one is in flight, drop answers that // are out of date or arrive after unmount) lives in `createPoller`. @@ -16,6 +18,7 @@ import { useRefreshInterval, type RefreshIntervalMs, } from "../refresh-interval/RefreshInterval.js"; +import { readAnswer, writeAnswer } from "./answer-cache.js"; import { createBusyHold } from "./busy-hold.js"; import { createPoller, type Poller } from "./poller.js"; @@ -43,6 +46,10 @@ function toProblem(e: unknown): ProblemDetails { export function usePolled( intervalMs: RefreshIntervalMs, fetcher: (signal: AbortSignal) => Promise, + opts: { + /** Saves each answer under this key and starts from the saved one. */ + cacheKey?: string; + } = {}, ): { data: T | null; error: ProblemDetails | null; @@ -50,7 +57,12 @@ export function usePolled( refresh: () => void; setData: (update: (prev: T | null) => T | null) => void; } { - const [data, setData] = useState(null); + const { cacheKey } = opts; + const cacheKeyRef = useRef(cacheKey); + cacheKeyRef.current = cacheKey; + const [data, setData] = useState(() => + cacheKey === undefined ? null : readAnswer(cacheKey), + ); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); @@ -66,6 +78,9 @@ export function usePolled( pollerRef.current = createPoller({ fetch: (signal) => fetcherRef.current(signal), onData: (next) => { + if (cacheKeyRef.current !== undefined) { + writeAnswer(cacheKeyRef.current, next); + } setData(next); setError(null); }, diff --git a/frontend/packages/swarm-ui/src/ui/session/session.ts b/frontend/packages/swarm-ui/src/ui/session/session.ts index 27e1217e..513fe4d0 100644 --- a/frontend/packages/swarm-ui/src/ui/session/session.ts +++ b/frontend/packages/swarm-ui/src/ui/session/session.ts @@ -43,7 +43,16 @@ export function mayReload(lastAtMs: number | null, nowMs: number): boolean { ); } +let sessionEnded: () => void = () => undefined; + +/** `fn` runs when the login is found to have ended, before the reload: the + * place to forget what the page kept of this session. */ +export function onSessionEnded(fn: () => void): void { + sessionEnded = fn; +} + function reloadForLogin(): void { + sessionEnded(); const now = Date.now(); let last: number | null = null; try { diff --git a/frontend/packages/swarm-ui/test/answer-cache.test.mjs b/frontend/packages/swarm-ui/test/answer-cache.test.mjs new file mode 100644 index 00000000..5a804404 --- /dev/null +++ b/frontend/packages/swarm-ui/test/answer-cache.test.mjs @@ -0,0 +1,80 @@ +// The saved last answer of a polled request: what is shown from a past visit, +// and when it is not. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + clearAnswers, + MAX_AGE_MS, + readAnswer, + writeAnswer, +} from "../src/ui/polled/answer-cache.ts"; + +// A `Storage` that lives in a map. +function fakeStorage() { + const m = new Map(); + return { + m, + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => void m.set(k, v), + removeItem: (k) => void m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + }; +} +const NOW = 1_800_000_000_000; + +test("an answer written is read back, and nothing is read before one is written", () => { + const storage = fakeStorage(); + assert.equal(readAnswer("a", { storage, now: NOW, build: "b1" }), null); + writeAnswer("a", [{ name: "iris" }], { storage, now: NOW, build: "b1" }); + assert.deepEqual(readAnswer("a", { storage, now: NOW + 5000, build: "b1" }), [ + { name: "iris" }, + ]); +}); + +test("an answer from another build, or older than a day, is not shown", () => { + const storage = fakeStorage(); + writeAnswer("a", { x: 1 }, { storage, now: NOW, build: "b1" }); + assert.equal(readAnswer("a", { storage, now: NOW, build: "b2" }), null); + assert.deepEqual( + readAnswer("a", { storage, now: NOW + MAX_AGE_MS, build: "b1" }), + { x: 1 }, + ); + assert.equal( + readAnswer("a", { storage, now: NOW + MAX_AGE_MS + 1, build: "b1" }), + null, + ); +}); + +test("a value that does not parse, or is not an entry, is no answer", () => { + const storage = fakeStorage(); + storage.setItem("swarm-ui:answer:a", "{not json"); + assert.equal(readAnswer("a", { storage, now: NOW, build: "b1" }), null); + storage.setItem( + "swarm-ui:answer:a", + JSON.stringify({ at: NOW, build: "b1" }), + ); + assert.equal(readAnswer("a", { storage, now: NOW, build: "b1" }), null); + storage.setItem("swarm-ui:answer:a", "7"); + assert.equal(readAnswer("a", { storage, now: NOW, build: "b1" }), null); +}); + +test("a write that fails (over quota) is dropped without an error", () => { + const storage = { + setItem() { + throw new Error("QuotaExceededError"); + }, + }; + writeAnswer("a", { x: 1 }, { storage, now: NOW, build: "b1" }); +}); + +test("clearing forgets the answers and leaves every other key alone", () => { + const storage = fakeStorage(); + writeAnswer("a", 1, { storage, now: NOW, build: "b1" }); + writeAnswer("b", 2, { storage, now: NOW, build: "b1" }); + storage.setItem("swarm-ui:term-groups", "[]"); + clearAnswers(storage); + assert.deepEqual([...storage.m.keys()], ["swarm-ui:term-groups"]); +});