From 47c447409b624dfdb0743708c3bf191738267a31 Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 5 Nov 2026 23:09:07 +0100 Subject: [PATCH] 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. --- docs/swarm/ui.md | 4 +- .../swarm-ui/src/pages/AlertsPage.tsx | 7 +- .../swarm-ui/src/pages/ComponentsPage.tsx | 8 ++ .../swarm-ui/src/pages/agents/AgentsPage.tsx | 2 + .../src/pages/broadcast/BroadcastComposer.tsx | 7 +- .../swarm-ui/src/pages/builds/BuildsPage.tsx | 9 ++- .../pages/notifications/NotificationsPage.tsx | 19 +++-- .../src/pages/review-runs/ReviewRuns.tsx | 7 +- .../src/pages/schedules/SchedulesPage.tsx | 8 +- .../swarm-ui/src/pages/swarm/HivesCard.tsx | 7 +- .../swarm-ui/src/ui/polled/busy-hold.ts | 60 +++++++++++++++ .../packages/swarm-ui/src/ui/polled/poller.ts | 12 ++- .../swarm-ui/src/ui/polled/usePolled.ts | 23 +++++- .../ui/refresh-interval/RefreshInterval.css | 34 ++++++++ .../ui/refresh-interval/RefreshInterval.tsx | 13 +++- .../packages/swarm-ui/test/busy-hold.test.mjs | 77 +++++++++++++++++++ .../packages/swarm-ui/test/poller.test.mjs | 22 ++++++ 17 files changed, 296 insertions(+), 23 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/polled/busy-hold.ts create mode 100644 frontend/packages/swarm-ui/test/busy-hold.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 4603eec3..d70c74ad 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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 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 -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 agent the swarm never declared on its hive, such as one only its hive knows diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index fa6bf982..6f08dab0 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -75,7 +75,11 @@ function AlertsBody() { const [showAll, setShowAll] = useState(false); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const { data: alerts, error } = usePolled(intervalMs, (signal) => + const { + data: alerts, + error, + refreshing, + } = usePolled(intervalMs, (signal) => fetchJson("/api/alerts", signal), ); const listed = alerts ? listedAlerts(alerts, showAll) : null; @@ -96,6 +100,7 @@ function AlertsBody() { id="alerts-refresh" value={intervalMs} onChange={setIntervalMs} + refreshing={refreshing} /> {error ? ( diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index f1328eba..39c318e1 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -510,6 +510,14 @@ export function ComponentsPage() { + + undefined} + refreshing + /> +
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx index 7b547161..27e83724 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx @@ -98,6 +98,7 @@ export function AgentsPage() { const { data: rows, error, + refreshing, setData: setRows, } = usePolled(intervalMs, (signal) => fetchJson("/api/agents/status", signal), @@ -131,6 +132,7 @@ export function AgentsPage() { id="agents-refresh" value={intervalMs} onChange={setIntervalMs} + refreshing={refreshing} /> ); diff --git a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.tsx b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.tsx index 082a035d..13892a13 100644 --- a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.tsx +++ b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.tsx @@ -60,7 +60,11 @@ function storedFilter(): AgentFilter { export function BroadcastComposer() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const { data: agents, error: loadError } = usePolled(intervalMs, (signal) => + const { + data: agents, + error: loadError, + refreshing, + } = usePolled(intervalMs, (signal) => fetchJson("/api/agents/status", signal), ); const [filter, setFilterState] = useState(storedFilter); @@ -173,6 +177,7 @@ export function BroadcastComposer() { id="broadcast-refresh" value={intervalMs} onChange={setIntervalMs} + refreshing={refreshing} /> diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx index 909325f8..0c6ff268 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx @@ -96,9 +96,11 @@ function BuildsBody() { const wanted = wantedRun(useSearch()); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - const { data: runs, error } = usePolled(intervalMs, (signal) => - fetchJson(RUNS, signal), - ); + const { + data: runs, + error, + refreshing, + } = usePolled(intervalMs, (signal) => fetchJson(RUNS, signal)); // 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>( @@ -134,6 +136,7 @@ function BuildsBody() { id="builds-refresh" value={intervalMs} onChange={setIntervalMs} + refreshing={refreshing} />

diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx index d779977e..c2bf805a 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx @@ -41,18 +41,24 @@ export function NotificationRouter() { const { data: view, error, + refreshing: flowsRefreshing, refresh, } = usePolled(intervalMs, (signal) => fetchJson("/api/notify/flows", signal), ); // The router's own answers. Either is 503 while the running router predates // it, which the page says in place of the section. - const { data: dry, error: dryError } = usePolled(intervalMs, (signal) => - fetchJson(DRY_RUN_URL, signal), - ); - const { data: deliveries, error: deliveriesError } = usePolled( - intervalMs, - (signal) => fetchJson(DELIVERIES_URL, signal), + const { + data: dry, + error: dryError, + refreshing: dryRefreshing, + } = usePolled(intervalMs, (signal) => fetchJson(DRY_RUN_URL, signal)); + const { + data: deliveries, + error: deliveriesError, + refreshing: deliveriesRefreshing, + } = usePolled(intervalMs, (signal) => + fetchJson(DELIVERIES_URL, signal), ); // A failed poll keeps the last answer; the counts are shown only while the // latest poll worked. @@ -102,6 +108,7 @@ export function NotificationRouter() { id="notifications-refresh" value={intervalMs} onChange={setIntervalMs} + refreshing={flowsRefreshing || dryRefreshing || deliveriesRefreshing} />