diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index 8f850528..94cec45f 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -9,6 +9,7 @@ import { useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { Panel } from '../ui/panel/Panel.js'; import { RelativeTime } from '../ui/relative-time/RelativeTime.js'; +import { RefreshIntervalPicker, type RefreshIntervalMs } from '../ui/refresh-interval/RefreshInterval.js'; import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js'; import { Table, type TableColumn } from '../ui/table/Table.js'; import { TextField } from '../ui/text-field/TextField.js'; @@ -80,6 +81,11 @@ function SelectFieldSample() { ); } +function RefreshIntervalPickerSample() { + const [value, setValue] = useState(30_000); + return ; +} + export function ComponentsPage() { return ( @@ -127,6 +133,12 @@ export function ComponentsPage() { +
+ + + +
+
diff --git a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx index f2f5550c..5df35f72 100644 --- a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx @@ -7,11 +7,21 @@ // that placeholder's comment laid out. Its own component/file rather // than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is // routing, a page owns its own fetch + render. -import { useEffect, useState } from 'preact/hooks'; +// +// Polls on a `RefreshIntervalPicker` cadence rather than fetching once +// at mount — no inputs on this page, so a re-fetch clobbering an +// in-progress edit (the hook's stated caller obligation) isn't a live +// concern here. +import { useState } from 'preact/hooks'; import { ApiErrorPanel } from '@hive/shared/api-error-panel.js'; import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js'; import { Panel } from '../ui/panel/Panel.js'; import { RelativeTime } from '../ui/relative-time/RelativeTime.js'; +import { + RefreshIntervalPicker, + useRefreshInterval, + type RefreshIntervalMs, +} from '../ui/refresh-interval/RefreshInterval.js'; import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js'; import { Table, type TableColumn } from '../ui/table/Table.js'; @@ -75,11 +85,19 @@ const COLUMNS: TableColumn[] = [ }, ]; +// 30s default: this page has no inputs to interrupt, and the whole +// point of a refresh-interval control is "no manual reload needed" — an +// operator who wants it off still can, but the out-of-the-box behaviour +// should actually solve the staleness problem rather than require an +// opt-in every visit. +const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; + export function HivesPage() { const [hives, setHives] = useState(null); const [error, setError] = useState(null); + const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); - useEffect(() => { + useRefreshInterval(intervalMs, () => { (async () => { const r = await fetch('/api/hives/status'); if (!r.ok) { @@ -87,11 +105,16 @@ export function HivesPage() { return; } setHives((await r.json()) as HiveStatus[]); + // A refresh that succeeds clears a previous failure — otherwise a + // transient error would sit on screen forever after the data + // itself has recovered. + setError(null); })().catch((e: unknown) => setError({ detail: String(e) })); - }, []); + }); return ( + {error ? : null} {!error && hives === null ?

loading…

: null} {hives ? h.name} /> : null} diff --git a/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.tsx b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.tsx new file mode 100644 index 00000000..ca98dac1 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.tsx @@ -0,0 +1,101 @@ +// + `useRefreshInterval` — the shared "grafana- +// like" polling pattern from the design-language discussion: the +// operator picks a cadence, the page re-fetches on it, and the timer +// pauses while the tab is backgrounded rather than burning requests for +// no visible benefit. Deliberately small (per mara: "component should +// not be too big") — a preset cadence list and a pause-on-hidden timer, +// nothing more. The UI (picker) and the behaviour (hook) live in one +// file since neither is useful without the other and splitting them +// would be two files for one concern. +// +// The hook, not the picker, owns re-fetch safety: a caller passes its +// own fetch callback, and it's the CALLER's job to make sure that +// callback doesn't clobber an input the operator is mid-edit on — this +// hook only decides *when* to call it. +import { useEffect, useRef } from 'preact/hooks'; +import { SelectField, type SelectOption } from '../select-field/SelectField.js'; + +// `null` means "off" throughout this module — no separate boolean, so +// there's exactly one way to represent "not polling". +export type RefreshIntervalMs = number | null; + +const PRESETS: { value: RefreshIntervalMs; label: string }[] = [ + { value: null, label: 'off' }, + { value: 10_000, label: '10s' }, + { value: 30_000, label: '30s' }, + { value: 60_000, label: '1m' }, +]; + +// `SelectField` only speaks strings, so presets round-trip through the +// label side of `PRESETS` rather than a second parallel list. +const OPTIONS: SelectOption[] = PRESETS.map((p) => ({ value: p.label, label: p.label })); + +export function RefreshIntervalPicker({ + id, + value, + onChange, +}: { + id: string; + value: RefreshIntervalMs; + onChange: (value: RefreshIntervalMs) => void; +}) { + const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0]; + return ( + { + const preset = PRESETS.find((p) => p.label === label); + if (preset) onChange(preset.value); + }} + options={OPTIONS} + /> + ); +} + +// Calls `onTick` immediately and then every `intervalMs`, paused while +// the document is hidden and resynced (an immediate call, not a stale +// leftover wait) on becoming visible again — same pattern +// `RelativeTime` already uses for the same reason. `intervalMs = null` +// means "off": no calls at all, not even the initial one — a caller +// that wants data on mount regardless of the picker's starting value +// should default its own `intervalMs` state to a real cadence, not +// `null`, and add a separate mount effect only if it genuinely wants +// "off" to still mean "load once." +export function useRefreshInterval(intervalMs: RefreshIntervalMs, onTick: () => void) { + // Always-current via a ref rather than a `useEffect` dependency: + // callers pass an inline closure that's a new value every render, and + // depending on it directly would re-arm the timer (losing whatever's + // left of the current interval) on every render instead of only when + // the cadence itself changes. The ref keeps the *timer* stable while + // still calling the *latest* callback, not a stale one captured at + // mount. + const onTickRef = useRef(onTick); + onTickRef.current = onTick; + + useEffect(() => { + if (intervalMs === null) return; + let id: ReturnType | undefined; + + const start = () => { + onTickRef.current(); + id = setInterval(() => onTickRef.current(), intervalMs); + }; + const stop = () => { + if (id !== undefined) clearInterval(id); + id = undefined; + }; + + if (document.visibilityState === 'visible') start(); + const onVisibility = () => { + if (document.visibilityState === 'visible') start(); + else stop(); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => { + stop(); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [intervalMs]); +}