diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index 8f850528..0bdca539 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 ( @@ -95,6 +101,14 @@ export function ComponentsPage() { panel body content, no title + + {}} />} + > + panel body content + +
@@ -127,6 +141,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..7149e62f 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,18 @@ 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/panel/Panel.css b/frontend/packages/swarm-ui/src/ui/panel/Panel.css index 23a40980..0132d514 100644 --- a/frontend/packages/swarm-ui/src/ui/panel/Panel.css +++ b/frontend/packages/swarm-ui/src/ui/panel/Panel.css @@ -3,12 +3,26 @@ border-radius: 0.5em; background: var(--bg-elev); } +.ui-panel-header { + display: flex; + align-items: center; + gap: 1em; + padding: 0.75em 1em; + border-bottom: 1px solid var(--border); +} .ui-panel-title { margin: 0; - padding: 0.75em 1em; font-size: 1em; font-weight: 600; - border-bottom: 1px solid var(--border); +} +/* `margin-left: auto` (not `justify-content: space-between` on the + header) so actions still land at the right edge even on the rare + panel that has actions but no title. */ +.ui-panel-actions { + display: flex; + align-items: center; + gap: 0.5em; + margin-left: auto; } .ui-panel-body { padding: 1em; diff --git a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx index de8f88db..eea69b56 100644 --- a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx +++ b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx @@ -2,13 +2,35 @@ // bordered surface with an optional title, no other opinions. Not a // card-with-actions/footer/whatever kit — those get added the first // time a real page actually needs one, not speculatively ahead of it. +// +// `actions` is that one addition: a slot in the title row, right- +// aligned, for a control that belongs next to the panel's own heading +// rather than its own row inside the body (design guide: "a control +// belongs next to the thing it affects, not tucked into a catch-all +// menu" — and per mara's review on the refresh-interval PR, not a +// dedicated row stealing vertical space from the panel's actual +// content either). `HivesPage`'s refresh-interval picker is the +// motivating caller. import type { ComponentChildren } from 'preact'; import './Panel.css'; -export function Panel({ title, children }: { title?: string; children: ComponentChildren }) { +export function Panel({ + title, + actions, + children, +}: { + title?: string; + actions?: ComponentChildren; + children: ComponentChildren; +}) { return (
- {title ?

{title}

: null} + {title || actions ? ( +
+ {title ?

{title}

: null} + {actions ?
{actions}
: null} +
+ ) : null}
{children}
); diff --git a/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.css b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.css new file mode 100644 index 00000000..0e1a134d --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.css @@ -0,0 +1,42 @@ +/* Compact "clock icon · value · chevron" control — see RefreshInterval.tsx + for why this isn't `SelectField`'s label+bordered-control chrome. Quiet + until interacted with (no border/fill at rest), matching LinksMenu's + header-button treatment so it reads as chrome, not a form. */ +.ui-refresh-picker { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.35em; + min-height: 2.75em; + padding: 0 0.6em; + border: 1px solid transparent; + border-radius: 0.4em; + color: var(--muted); + font-size: 0.9em; + cursor: pointer; +} +.ui-refresh-picker:hover, +.ui-refresh-picker:focus-within { + border-color: var(--border); + background: var(--bg); + color: var(--fg); +} +/* The select drives the interaction (native listbox, keyboard, a11y) but + contributes no chrome of its own — its own box is invisible, sized to + just its selected option's text, and its default arrow is stripped + since the chevron span stands in for it. */ +.ui-refresh-picker-select { + appearance: none; + border: none; + background: none; + color: inherit; + font: inherit; + padding: 0; + margin: 0; + cursor: pointer; +} +.ui-refresh-picker-icon, +.ui-refresh-picker-chevron { + font-size: 0.85em; + line-height: 1; +} 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..5a0979bd --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.tsx @@ -0,0 +1,131 @@ +// + `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 picker itself is a compact "clock icon · value · chevron" inline +// control, not a labelled `SelectField` — per mara's review, a full +// label+bordered-control form field reads as way too heavy for a +// passive-until-touched setting that lives in a panel's title row +// (`Panel`'s `actions` slot), not a form. Visually quiet (no border/ +// fill until hovered/focused) the same way `LinksMenu`'s header button +// is, so it reads as chrome rather than another input to fill in. A +// native `` for the accessible + // name a sighted-only icon+chevron can't supply). + + ); +} + +// 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]); +}