swarm-ui: shared refresh-interval polling control
Closes #3446. New ui/refresh-interval/RefreshInterval — a RefreshIntervalPicker (off/10s/30s/1m preset select) plus a useRefreshInterval hook that polls on that cadence, pausing while the document is hidden and resyncing immediately on becoming visible again (same pattern RelativeTime already uses). The hook keeps the caller's onTick fresh via a ref rather than an effect dependency, so a new closure each render doesn't re-arm the timer. HivesPage wires it in: defaults to 30s (no inputs on this page to interrupt, and the point of the feature is not needing a manual reload), replacing the old fetch-once-at-mount effect. A successful refresh also clears any previous fetch error instead of leaving a stale failure on screen after the data's recovered. Verified: tsc clean, build succeeds, screenshotted the hives page (auto-loads on mount, picker defaults to 30s) and the components page demo.
This commit is contained in:
parent
8b14d959d6
commit
e270ba309b
3 changed files with 139 additions and 3 deletions
|
|
@ -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<RefreshIntervalMs>(30_000);
|
||||
return <RefreshIntervalPicker id="sample-refresh-interval" value={value} onChange={setValue} />;
|
||||
}
|
||||
|
||||
export function ComponentsPage() {
|
||||
return (
|
||||
<Panel title="components">
|
||||
|
|
@ -127,6 +133,12 @@ export function ComponentsPage() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="RefreshIntervalPicker">
|
||||
<Sample label="editable">
|
||||
<RefreshIntervalPickerSample />
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="TextField">
|
||||
<Sample label="editable">
|
||||
<TextFieldSample />
|
||||
|
|
|
|||
|
|
@ -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<HiveStatus>[] = [
|
|||
},
|
||||
];
|
||||
|
||||
// 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<HiveStatus[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(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 (
|
||||
<Panel title="hives">
|
||||
<RefreshIntervalPicker id="hives-refresh" value={intervalMs} onChange={setIntervalMs} />
|
||||
{error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null}
|
||||
{!error && hives === null ? <p>loading…</p> : null}
|
||||
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
// <RefreshIntervalPicker> + `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 (
|
||||
<SelectField
|
||||
id={id}
|
||||
label="refresh"
|
||||
value={current.label}
|
||||
onChange={(label) => {
|
||||
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<typeof setInterval> | 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]);
|
||||
}
|
||||
Loading…
Reference in a new issue