Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-11-05 23:14:25 +01:00
commit fd51cbd19d
13 changed files with 238 additions and 14 deletions

View file

@ -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

View file

@ -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<WhoAmI | null>(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
</a>

View file

@ -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");

View file

@ -79,8 +79,10 @@ function AlertsBody() {
data: alerts,
error,
refreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<Alert[]>("/api/alerts", signal),
} = usePolled(
intervalMs,
(signal) => fetchJson<Alert[]>("/api/alerts", signal),
{ cacheKey: "alerts" },
);
const listed = alerts ? listedAlerts(alerts, showAll) : null;
const hidden = alerts && listed ? alerts.length - listed.length : 0;

View file

@ -100,8 +100,10 @@ export function AgentsPage() {
error,
refreshing,
setData: setRows,
} = usePolled(intervalMs, (signal) =>
fetchJson<AgentRow[]>("/api/agents/status", signal),
} = usePolled(
intervalMs,
(signal) => fetchJson<AgentRow[]>("/api/agents/status", signal),
{ cacheKey: "agents" },
);
const [createOpen, setCreateOpen] = useState(false);
// The PUT response is the hive's whole declaration set (every agent's

View file

@ -100,7 +100,9 @@ function BuildsBody() {
data: runs,
error,
refreshing,
} = usePolled(intervalMs, (signal) => fetchJson<BuildRun[]>(RUNS, signal));
} = usePolled(intervalMs, (signal) => fetchJson<BuildRun[]>(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<ReadonlySet<string>>(

View file

@ -97,8 +97,10 @@ export function ReviewRuns() {
data: runs,
error,
refreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<ClassifierRun[]>(RUNS, signal),
} = usePolled(
intervalMs,
(signal) => fetchJson<ClassifierRun[]>(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.

View file

@ -120,8 +120,10 @@ function HiveRoster() {
data: hives,
error,
refreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<HiveStatus[]>("/api/hives/status", signal),
} = usePolled(
intervalMs,
(signal) => fetchJson<HiveStatus[]>("/api/hives/status", signal),
{ cacheKey: "hives" },
);
return (

View file

@ -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 <SharedUserMenu whoamiUrl="/api/whoami" autheliaUrl={autheliaUrl} />;
return (
<SharedUserMenu
whoamiUrl="/api/whoami"
autheliaUrl={autheliaUrl}
onLogout={() => clearAnswers()}
/>
);
}

View file

@ -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<T>(
key: string,
opts: {
storage?: Pick<Storage, "getItem">;
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<Entry> | 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<Storage, "setItem">;
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<Storage, "length" | "key" | "removeItem"> = 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.
}
}

View file

@ -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<T>(
intervalMs: RefreshIntervalMs,
fetcher: (signal: AbortSignal) => Promise<T>,
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<T>(
refresh: () => void;
setData: (update: (prev: T | null) => T | null) => void;
} {
const [data, setData] = useState<T | null>(null);
const { cacheKey } = opts;
const cacheKeyRef = useRef(cacheKey);
cacheKeyRef.current = cacheKey;
const [data, setData] = useState<T | null>(() =>
cacheKey === undefined ? null : readAnswer<T>(cacheKey),
);
const [error, setError] = useState<ProblemDetails | null>(null);
const [refreshing, setRefreshing] = useState(false);
@ -66,6 +78,9 @@ export function usePolled<T>(
pollerRef.current = createPoller<T>({
fetch: (signal) => fetcherRef.current(signal),
onData: (next) => {
if (cacheKeyRef.current !== undefined) {
writeAnswer(cacheKeyRef.current, next);
}
setData(next);
setError(null);
},

View file

@ -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 {

View file

@ -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"]);
});