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:
parent
47c447409b
commit
fd51cbd19d
13 changed files with 238 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>>(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
87
frontend/packages/swarm-ui/src/ui/polled/answer-cache.ts
Normal file
87
frontend/packages/swarm-ui/src/ui/polled/answer-cache.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
80
frontend/packages/swarm-ui/test/answer-cache.test.mjs
Normal file
80
frontend/packages/swarm-ui/test/answer-cache.test.mjs
Normal 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"]);
|
||||
});
|
||||
Loading…
Reference in a new issue