Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the refresh picker shows a spinner in place of its clock while its page is fetching

usePolled reports when a request is in flight (through a new onBusy of the
poller) and holds it on for at least 0.4 seconds, so a quick answer still
reads as a refresh. The picker swaps its clock for the spinner, in the same
box so its width stays, and the spinner stands still where motion is off.
Every page with a picker passes it; a page with several polled requests shows
it while any of them runs. The Jobs page fetches through shared components
and has none yet.
This commit is contained in:
iris 2026-11-05 23:09:07 +01:00
commit 47c447409b
17 changed files with 296 additions and 23 deletions

View file

@ -38,7 +38,9 @@ Everything it shows comes from [`swarm-controller`](../../swarm-controller/READM
Pages that refresh on a timer share one polling rule: the page sends no new Pages that refresh on a timer share one polling rule: the page sends no new
request while the last one is still running, so an older answer never overtakes request while the last one is still running, so an older answer never overtakes
a slow one, and a failed refresh keeps the rows already on screen under an a slow one, and a failed refresh keeps the rows already on screen under an
error. 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 **wanted** badge changes the state of an agent the swarm has declared. For an 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 agent the swarm never declared on its hive, such as one only its hive knows

View file

@ -75,7 +75,11 @@ function AlertsBody() {
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: alerts, error } = usePolled(intervalMs, (signal) => const {
data: alerts,
error,
refreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<Alert[]>("/api/alerts", signal), fetchJson<Alert[]>("/api/alerts", signal),
); );
const listed = alerts ? listedAlerts(alerts, showAll) : null; const listed = alerts ? listedAlerts(alerts, showAll) : null;
@ -96,6 +100,7 @@ function AlertsBody() {
id="alerts-refresh" id="alerts-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
refreshing={refreshing}
/> />
</div> </div>
{error ? ( {error ? (

View file

@ -510,6 +510,14 @@ export function ComponentsPage() {
<Sample label="editable"> <Sample label="editable">
<RefreshIntervalPickerSample /> <RefreshIntervalPickerSample />
</Sample> </Sample>
<Sample label="refreshing">
<RefreshIntervalPicker
id="sample-refresh-interval-busy"
value={30_000}
onChange={() => undefined}
refreshing
/>
</Sample>
</Section> </Section>
<Section title="TextField"> <Section title="TextField">

View file

@ -98,6 +98,7 @@ export function AgentsPage() {
const { const {
data: rows, data: rows,
error, error,
refreshing,
setData: setRows, setData: setRows,
} = usePolled(intervalMs, (signal) => } = usePolled(intervalMs, (signal) =>
fetchJson<AgentRow[]>("/api/agents/status", signal), fetchJson<AgentRow[]>("/api/agents/status", signal),
@ -131,6 +132,7 @@ export function AgentsPage() {
id="agents-refresh" id="agents-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
refreshing={refreshing}
/> />
</> </>
); );

View file

@ -60,7 +60,11 @@ function storedFilter(): AgentFilter {
export function BroadcastComposer() { export function BroadcastComposer() {
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: agents, error: loadError } = usePolled(intervalMs, (signal) => const {
data: agents,
error: loadError,
refreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<BroadcastAgent[]>("/api/agents/status", signal), fetchJson<BroadcastAgent[]>("/api/agents/status", signal),
); );
const [filter, setFilterState] = useState<AgentFilter>(storedFilter); const [filter, setFilterState] = useState<AgentFilter>(storedFilter);
@ -173,6 +177,7 @@ export function BroadcastComposer() {
id="broadcast-refresh" id="broadcast-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
refreshing={refreshing}
/> />
</div> </div>

View file

@ -96,9 +96,11 @@ function BuildsBody() {
const wanted = wantedRun(useSearch()); const wanted = wantedRun(useSearch());
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: runs, error } = usePolled(intervalMs, (signal) => const {
fetchJson<BuildRun[]>(RUNS, signal), data: runs,
); error,
refreshing,
} = usePolled(intervalMs, (signal) => fetchJson<BuildRun[]>(RUNS, signal));
// Which cards are open. Survives a refresh, so a list that updates under // 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. // you does not fold the card you are reading. A linked build starts open.
const [open, setOpen] = useState<ReadonlySet<string>>( const [open, setOpen] = useState<ReadonlySet<string>>(
@ -134,6 +136,7 @@ function BuildsBody() {
id="builds-refresh" id="builds-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
refreshing={refreshing}
/> />
</div> </div>
<p> <p>

View file

@ -41,18 +41,24 @@ export function NotificationRouter() {
const { const {
data: view, data: view,
error, error,
refreshing: flowsRefreshing,
refresh, refresh,
} = usePolled(intervalMs, (signal) => } = usePolled(intervalMs, (signal) =>
fetchJson<FlowsView>("/api/notify/flows", signal), fetchJson<FlowsView>("/api/notify/flows", signal),
); );
// The router's own answers. Either is 503 while the running router predates // The router's own answers. Either is 503 while the running router predates
// it, which the page says in place of the section. // it, which the page says in place of the section.
const { data: dry, error: dryError } = usePolled(intervalMs, (signal) => const {
fetchJson<DryRun>(DRY_RUN_URL, signal), data: dry,
); error: dryError,
const { data: deliveries, error: deliveriesError } = usePolled( refreshing: dryRefreshing,
intervalMs, } = usePolled(intervalMs, (signal) => fetchJson<DryRun>(DRY_RUN_URL, signal));
(signal) => fetchJson<DeliveriesView>(DELIVERIES_URL, signal), const {
data: deliveries,
error: deliveriesError,
refreshing: deliveriesRefreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<DeliveriesView>(DELIVERIES_URL, signal),
); );
// A failed poll keeps the last answer; the counts are shown only while the // A failed poll keeps the last answer; the counts are shown only while the
// latest poll worked. // latest poll worked.
@ -102,6 +108,7 @@ export function NotificationRouter() {
id="notifications-refresh" id="notifications-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
refreshing={flowsRefreshing || dryRefreshing || deliveriesRefreshing}
/> />
<Button variant="primary" onClick={() => draftFrom()}> <Button variant="primary" onClick={() => draftFrom()}>
create flow create flow

View file

@ -93,7 +93,11 @@ function RunCard({
export function ReviewRuns() { export function ReviewRuns() {
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: runs, error } = usePolled(intervalMs, (signal) => const {
data: runs,
error,
refreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<ClassifierRun[]>(RUNS, signal), fetchJson<ClassifierRun[]>(RUNS, signal),
); );
// Which cards are expanded. Survives a refresh, so a list that updates // Which cards are expanded. Survives a refresh, so a list that updates
@ -117,6 +121,7 @@ export function ReviewRuns() {
id="review-runs-refresh" id="review-runs-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
refreshing={refreshing}
/> />
} }
> >

View file

@ -64,6 +64,7 @@ function SchedulesBody() {
const { const {
data: schedules, data: schedules,
error, error,
refreshing: schedulesRefreshing,
refresh, refresh,
} = usePolled(intervalMs, (signal) => } = usePolled(intervalMs, (signal) =>
fetchJson<Schedule[]>("/api/schedules", signal), fetchJson<Schedule[]>("/api/schedules", signal),
@ -72,7 +73,11 @@ function SchedulesBody() {
// lists, polled on the same cadence so an agent created after this page // lists, polled on the same cadence so an agent created after this page
// opened shows up. A failed load does not block the page, the cards do not // opened shows up. A failed load does not block the page, the cards do not
// need it; the form says the list could not load. // need it; the form says the list could not load.
const { data: roster, error: rosterError } = usePolled(intervalMs, (signal) => const {
data: roster,
error: rosterError,
refreshing: rosterRefreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<AgentRow[]>("/api/agents/status", signal), fetchJson<AgentRow[]>("/api/agents/status", signal),
); );
const agentNames = useMemo(() => agentNamesFrom(roster), [roster]); const agentNames = useMemo(() => agentNamesFrom(roster), [roster]);
@ -159,6 +164,7 @@ function SchedulesBody() {
id="schedules-refresh" id="schedules-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
refreshing={schedulesRefreshing || rosterRefreshing}
/> />
</div> </div>
{error ? ( {error ? (

View file

@ -116,7 +116,11 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
function HiveRoster() { function HiveRoster() {
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: hives, error } = usePolled(intervalMs, (signal) => const {
data: hives,
error,
refreshing,
} = usePolled(intervalMs, (signal) =>
fetchJson<HiveStatus[]>("/api/hives/status", signal), fetchJson<HiveStatus[]>("/api/hives/status", signal),
); );
@ -127,6 +131,7 @@ function HiveRoster() {
id="hives-refresh" id="hives-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
refreshing={refreshing}
/> />
</div> </div>
{error ? ( {error ? (

View file

@ -0,0 +1,60 @@
// What a refresh indicator shows, from whether a request is in flight: it turns
// on at once and, once the request is over, stays on for the rest of `minMs`
// since it came on, so an answer that takes a blink still shows as a refresh
// instead of a flash. Free of Preact (and of the real clock, for the tests).
export const MIN_BUSY_MS = 400;
export function createBusyHold(opts: {
/** Called with what to show, only when it changes. */
show: (shown: boolean) => void;
minMs?: number;
now?: () => number;
after?: (fn: () => void, ms: number) => unknown;
cancel?: (timer: unknown) => void;
}): { busy(busy: boolean): void; stop(): void } {
const minMs = opts.minMs ?? MIN_BUSY_MS;
const now = opts.now ?? Date.now;
const after = opts.after ?? ((fn, ms) => setTimeout(fn, ms));
const cancel =
opts.cancel ?? ((t) => clearTimeout(t as ReturnType<typeof setTimeout>));
let shown = false;
let since = 0;
let timer: unknown = null;
function set(next: boolean) {
if (next === shown) return;
shown = next;
opts.show(next);
}
function clear() {
if (timer === null) return;
cancel(timer);
timer = null;
}
return {
busy(busy) {
if (busy) {
clear();
if (!shown) since = now();
set(true);
return;
}
if (!shown) return;
const left = minMs - (now() - since);
if (left <= 0) {
set(false);
return;
}
clear();
timer = after(() => {
timer = null;
set(false);
}, left);
},
stop() {
clear();
},
};
}

View file

@ -4,7 +4,9 @@
// request in flight and asks again, for a refresh that follows a change. // request in flight and asks again, for a refresh that follows a change.
// `stop` drops the request in flight. An answer from a dropped request never // `stop` drops the request in flight. An answer from a dropped request never
// reaches `onData` or `onError`, so an older response cannot overwrite a // reaches `onData` or `onError`, so an older response cannot overwrite a
// newer one or land on a page that is gone. // newer one or land on a page that is gone. `onBusy` tells when a request goes
// in flight and when none is any more, however it ended, so a page can show
// that it is refreshing.
export interface Poller { export interface Poller {
tick(): void; tick(): void;
refetch(): void; refetch(): void;
@ -15,29 +17,35 @@ export function createPoller<T>(opts: {
fetch: (signal: AbortSignal) => Promise<T>; fetch: (signal: AbortSignal) => Promise<T>;
onData: (data: T) => void; onData: (data: T) => void;
onError: (error: unknown) => void; onError: (error: unknown) => void;
onBusy?: (busy: boolean) => void;
}): Poller { }): Poller {
let current: { controller: AbortController } | null = null; let current: { controller: AbortController } | null = null;
function start() { function start() {
const run = { controller: new AbortController() }; const run = { controller: new AbortController() };
current = run; current = run;
opts.onBusy?.(true);
opts.fetch(run.controller.signal).then( opts.fetch(run.controller.signal).then(
(data) => { (data) => {
if (current !== run) return; if (current !== run) return;
current = null; current = null;
opts.onBusy?.(false);
opts.onData(data); opts.onData(data);
}, },
(error: unknown) => { (error: unknown) => {
if (current !== run) return; if (current !== run) return;
current = null; current = null;
opts.onBusy?.(false);
opts.onError(error); opts.onError(error);
}, },
); );
} }
function stop() { function stop() {
current?.controller.abort(); if (current === null) return;
current.controller.abort();
current = null; current = null;
opts.onBusy?.(false);
} }
return { return {

View file

@ -1,4 +1,4 @@
// A resource refetched on a timer: `{ data, error, refresh, setData }`. // 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; a failed fetch sets `error` and
// keeps the last good `data`; the next good answer clears `error`. The // 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 // request discipline (skip a tick while one is in flight, drop answers that
@ -8,12 +8,15 @@
// does that for a GET. It may change between renders: the latest one runs. // does that for a GET. It may change between renders: the latest one runs.
// `refresh` refetches now, dropping a request in flight, for after a change // `refresh` refetches now, dropping a request in flight, for after a change
// the page made. `setData` patches what is shown without a request. // the page made. `setData` patches what is shown without a request.
// `refreshing` is true while a request is in flight, and for a short minimum
// after it starts (`createBusyHold`), for the refresh picker's spinner.
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { import {
useRefreshInterval, useRefreshInterval,
type RefreshIntervalMs, type RefreshIntervalMs,
} from "../refresh-interval/RefreshInterval.js"; } from "../refresh-interval/RefreshInterval.js";
import { createBusyHold } from "./busy-hold.js";
import { createPoller, type Poller } from "./poller.js"; import { createPoller, type Poller } from "./poller.js";
/** A failed request, carrying the problem the API answered with. */ /** A failed request, carrying the problem the API answered with. */
@ -43,15 +46,22 @@ export function usePolled<T>(
): { ): {
data: T | null; data: T | null;
error: ProblemDetails | null; error: ProblemDetails | null;
refreshing: boolean;
refresh: () => void; refresh: () => void;
setData: (update: (prev: T | null) => T | null) => void; setData: (update: (prev: T | null) => T | null) => void;
} { } {
const [data, setData] = useState<T | null>(null); const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
const [refreshing, setRefreshing] = useState(false);
const fetcherRef = useRef(fetcher); const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher; fetcherRef.current = fetcher;
const pollerRef = useRef<Poller | null>(null); const pollerRef = useRef<Poller | null>(null);
const holdRef = useRef<ReturnType<typeof createBusyHold> | null>(null);
if (holdRef.current === null) {
holdRef.current = createBusyHold({ show: setRefreshing });
}
const hold = holdRef.current;
if (pollerRef.current === null) { if (pollerRef.current === null) {
pollerRef.current = createPoller<T>({ pollerRef.current = createPoller<T>({
fetch: (signal) => fetcherRef.current(signal), fetch: (signal) => fetcherRef.current(signal),
@ -60,12 +70,19 @@ export function usePolled<T>(
setError(null); setError(null);
}, },
onError: (e) => setError(toProblem(e)), onError: (e) => setError(toProblem(e)),
onBusy: (busy) => hold.busy(busy),
}); });
} }
const poller = pollerRef.current; const poller = pollerRef.current;
useEffect(() => () => poller.stop(), [poller]); useEffect(
() => () => {
poller.stop();
hold.stop();
},
[poller, hold],
);
useRefreshInterval(intervalMs, () => poller.tick()); useRefreshInterval(intervalMs, () => poller.tick());
return { data, error, refresh: () => poller.refetch(), setData }; return { data, error, refreshing, refresh: () => poller.refetch(), setData };
} }

View file

@ -35,6 +35,40 @@
color: inherit; color: inherit;
} }
.ui-refresh-picker-icon { .ui-refresh-picker-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2em;
height: 1.2em;
font-size: 0.85em; font-size: 0.85em;
line-height: 1; line-height: 1;
} }
/* The spinner takes the clock's place: the same box, so the picker keeps its
width. Motion follows the page's setting, the same three rules the page
switch uses; where motion is off it is a static arc. */
.ui-refresh-picker-spinner {
box-sizing: border-box;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ui-refresh-spin 0.8s linear infinite;
}
@keyframes ui-refresh-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-refresh-picker-spinner {
animation: none;
}
}
:root[data-motion="reduce"] .ui-refresh-picker-spinner {
animation: none;
}
:root[data-motion="allow"] .ui-refresh-picker-spinner {
animation: ui-refresh-spin 0.8s linear infinite;
}

View file

@ -43,16 +43,23 @@ export function RefreshIntervalPicker({
id, id,
value, value,
onChange, onChange,
refreshing = false,
}: { }: {
id: string; id: string;
value: RefreshIntervalMs; value: RefreshIntervalMs;
onChange: (value: RefreshIntervalMs) => void; onChange: (value: RefreshIntervalMs) => void;
/** A request is in flight: a spinner takes the clock's place, so the width stays. */
refreshing?: boolean;
}) { }) {
const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0]; const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0];
return ( return (
<div class="ui-refresh-picker"> <div class="ui-refresh-picker" aria-busy={refreshing}>
<span class="ui-refresh-picker-icon" aria-hidden="true"> <span
🕐 class="ui-refresh-picker-icon"
aria-hidden="true"
title={refreshing ? "refreshing" : undefined}
>
{refreshing ? <span class="ui-refresh-picker-spinner" /> : "🕐"}
</span> </span>
<Listbox <Listbox
id={id} id={id}

View file

@ -0,0 +1,77 @@
// What the refresh spinner shows: on at once, off only after a minimum time.
import assert from "node:assert/strict";
import { test } from "node:test";
import { createBusyHold } from "../src/ui/polled/busy-hold.ts";
// A clock and a timer the test moves by hand.
function rig() {
let t = 0;
const timers = [];
const shown = [];
const hold = createBusyHold({
show: (s) => shown.push(s),
minMs: 400,
now: () => t,
after: (fn, ms) => {
const timer = { fn, at: t + ms, live: true };
timers.push(timer);
return timer;
},
cancel: (timer) => {
timer.live = false;
},
});
const advance = (ms) => {
t += ms;
for (const timer of timers) {
if (timer.live && timer.at <= t) {
timer.live = false;
timer.fn();
}
}
};
return { hold, shown, advance };
}
test("it turns on at once and, for a quick answer, off only after the minimum", () => {
const { hold, shown, advance } = rig();
hold.busy(true);
assert.deepEqual(shown, [true]);
advance(50);
hold.busy(false);
assert.deepEqual(shown, [true]);
advance(349);
assert.deepEqual(shown, [true]);
advance(1);
assert.deepEqual(shown, [true, false]);
});
test("a slow answer turns it off as soon as it arrives", () => {
const { hold, shown, advance } = rig();
hold.busy(true);
advance(900);
hold.busy(false);
assert.deepEqual(shown, [true, false]);
});
test("a new request while it is held keeps it on and cancels the pending off", () => {
const { hold, shown, advance } = rig();
hold.busy(true);
advance(100);
hold.busy(false);
hold.busy(true);
advance(1000);
assert.deepEqual(shown, [true]);
hold.busy(false);
assert.deepEqual(shown, [true, false]);
});
test("stop drops a pending off, and busy(false) with nothing shown does nothing", () => {
const { hold, shown, advance } = rig();
hold.busy(false);
hold.busy(true);
hold.busy(false);
hold.stop();
advance(1000);
assert.deepEqual(shown, [true]);
});

View file

@ -92,3 +92,25 @@ test("the answer of a dropped request that fails is not reported", async () => {
await flush(); await flush();
assert.deepEqual(sink.seen.error, []); assert.deepEqual(sink.seen.error, []);
}); });
test("onBusy follows a request in flight, however it ends", async () => {
const { calls, fetch } = manualFetch();
const sink = collect();
const busy = [];
const poller = createPoller({ fetch, ...sink, onBusy: (b) => busy.push(b) });
poller.tick();
poller.tick();
assert.deepEqual(busy, [true]);
calls[0].resolve("one");
await flush();
assert.deepEqual(busy, [true, false]);
poller.tick();
calls[1].reject(new Error("boom"));
await flush();
assert.deepEqual(busy, [true, false, true, false]);
poller.tick();
poller.stop();
assert.deepEqual(busy, [true, false, true, false, true, false]);
poller.stop();
assert.equal(busy.length, 6);
});