Compare commits

...
Author SHA1 Message Date
iris
fc44891ab5 swarm-ui: compact refresh-interval picker, moved into the panel header
Per mara's review on this PR: the labelled SelectField-based picker
read as way too heavy for a passive-until-touched setting - a full
label+bordered-control form field row above the table. Replace it with
a compact inline "clock icon - value - chevron" control (a native
<select> still drives the interaction, just stripped of SelectField/
FormField's chrome), quiet until hovered/focused the same way
LinksMenu's header button is.

Also move it out of the panel body entirely: Panel gains an `actions`
slot in its title row (right-aligned via margin-left: auto), so the
picker sits next to the "hives" heading instead of taking its own row
and pushing the table down - per the design guide's own "a control
belongs next to the thing it affects" rule.

Verified with headless chromium screenshots (full page + a tight
close-up crop) against the built bundle: the picker now reads as
"(clock) 30s (chevron)" inline with the panel title, no extra vertical
space taken from the table. Added a Panel "with actions" demo to
ComponentsPage.
2026-08-18 23:28:47 +02:00
iris
e270ba309b 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.
2026-08-18 23:28:47 +02:00
6 changed files with 262 additions and 8 deletions

View file

@ -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">
@ -95,6 +101,14 @@ export function ComponentsPage() {
<Sample label="without title">
<Panel>panel body content, no title</Panel>
</Sample>
<Sample label="with actions (e.g. HivesPage's refresh picker)">
<Panel
title="example title"
actions={<RefreshIntervalPicker id="sample-panel-actions" value={30_000} onChange={() => {}} />}
>
panel body content
</Panel>
</Sample>
</Section>
<Section title="StatusChip">
@ -127,6 +141,12 @@ export function ComponentsPage() {
</div>
</Section>
<Section title="RefreshIntervalPicker">
<Sample label="editable">
<RefreshIntervalPickerSample />
</Sample>
</Section>
<Section title="TextField">
<Sample label="editable">
<TextFieldSample />

View file

@ -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,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 (
<Panel title="hives">
<Panel
title="hives"
actions={<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}

View file

@ -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;

View file

@ -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 (
<section class="ui-panel">
{title ? <h2 class="ui-panel-title">{title}</h2> : null}
{title || actions ? (
<div class="ui-panel-header">
{title ? <h2 class="ui-panel-title">{title}</h2> : null}
{actions ? <div class="ui-panel-actions">{actions}</div> : null}
</div>
) : null}
<div class="ui-panel-body">{children}</div>
</section>
);

View file

@ -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;
}

View file

@ -0,0 +1,131 @@
// <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 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 `<select>` still drives it (no custom dropdown/listbox to
// reinvent) — `appearance: none` strips its default arrow so the
// chevron span can stand in for it, and the visible "value" text is
// just whichever `<option>` the browser is already rendering as
// selected, not a second copy of the label kept in sync by hand.
//
// 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 './RefreshInterval.css';
// `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' },
];
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 (
// A `<label>` wrapper (not a bare `<span>`) so the icon/chevron
// padding is part of the click/tap target too, not just the native
// select box itself — same touch-target-floor reasoning as the rest
// of the kit, just without a visible label string to hang it off
// of (hence the `aria-label` on the `<select>` for the accessible
// name a sighted-only icon+chevron can't supply).
<label class="ui-refresh-picker" for={id}>
<span class="ui-refresh-picker-icon" aria-hidden="true">
🕐
</span>
<select
id={id}
class="ui-refresh-picker-select"
aria-label="refresh interval"
value={current.label}
onChange={(e) => {
const label = (e.target as HTMLSelectElement).value;
const preset = PRESETS.find((p) => p.label === label);
if (preset) onChange(preset.value);
}}
>
{PRESETS.map((p) => (
<option key={p.label} value={p.label}>
{p.label}
</option>
))}
</select>
<span class="ui-refresh-picker-chevron" aria-hidden="true">
</span>
</label>
);
}
// 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]);
}