swarm-ui: one polled-resource hook for the refreshing pages
Each polling page repeated its own fetch-and-set code on a fixed timer, so a slow answer could be overtaken by the next tick, an older response could overwrite a newer one, and nothing aborted a request when the page went away. usePolled skips a tick while a request is in flight, aborts on unmount or refresh, ignores answers from dropped requests, and keeps the last good data under an error. The alerts, hives, agents, schedules, builds, review runs and broadcast pages and the subagent list use it.
This commit is contained in:
parent
9338496e24
commit
8917ba27b6
12 changed files with 274 additions and 160 deletions
|
|
@ -5,16 +5,15 @@
|
|||
// rendered as a `Table`.
|
||||
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 { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { Button } from "../ui/button/Button.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 { fetchJson, usePolled } from "../ui/polled/usePolled.js";
|
||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||
import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.js";
|
||||
|
||||
|
|
@ -73,26 +72,15 @@ const COLUMNS: TableColumn<Alert>[] = [
|
|||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
export function AlertsPage() {
|
||||
const [alerts, setAlerts] = useState<Alert[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const { data: alerts, error } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<Alert[]>("/api/alerts", signal),
|
||||
);
|
||||
const listed = alerts ? listedAlerts(alerts, showAll) : null;
|
||||
const hidden = alerts && listed ? alerts.length - listed.length : 0;
|
||||
|
||||
useRefreshInterval(intervalMs, () => {
|
||||
(async () => {
|
||||
const r = await fetch("/api/alerts");
|
||||
if (!r.ok) {
|
||||
setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
setAlerts((await r.json()) as Alert[]);
|
||||
setError(null);
|
||||
})().catch((e: unknown) => setError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="alerts"
|
||||
|
|
|
|||
|
|
@ -14,15 +14,14 @@
|
|||
// 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 { Badge, type BadgeTone } from "@hive/shared/badge.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 { fetchJson, usePolled } from "../ui/polled/usePolled.js";
|
||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||
|
||||
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
|
||||
|
|
@ -108,25 +107,11 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
|||
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);
|
||||
|
||||
useRefreshInterval(intervalMs, () => {
|
||||
(async () => {
|
||||
const r = await fetch("/api/hives/status");
|
||||
if (!r.ok) {
|
||||
setError(await readApiError(r));
|
||||
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) }));
|
||||
});
|
||||
const { data: hives, error } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<HiveStatus[]>("/api/hives/status", signal),
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@
|
|||
// here just says which one to show.
|
||||
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 { Badge } from "@hive/shared/badge.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
import { useAgentActions } from "../../agent/roster/AgentActions.js";
|
||||
|
|
@ -42,9 +41,9 @@ 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 { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import { SplitView } from "../../ui/split-view/SplitView.js";
|
||||
import { type TableColumn } from "../../ui/table/Table.js";
|
||||
import { CreateAgentForm } from "../CreateAgentForm.js";
|
||||
|
|
@ -62,10 +61,15 @@ type ViewMode = "cards" | "table";
|
|||
const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
|
||||
|
||||
export function AgentsPage() {
|
||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const {
|
||||
data: rows,
|
||||
error,
|
||||
setData: setRows,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<AgentRow[]>("/api/agents/status", signal),
|
||||
);
|
||||
// Lazy initializer (not a mount effect) so the very first render
|
||||
// already reflects the stored choice — no default-then-flip flash.
|
||||
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
|
||||
|
|
@ -116,23 +120,6 @@ export function AgentsPage() {
|
|||
const [detailTargetName, setDetailTargetName] = useState<string | null>(null);
|
||||
const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null;
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch("/api/agents/status");
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setRows((await res.json()) as AgentRow[]);
|
||||
// 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);
|
||||
}
|
||||
|
||||
useRefreshInterval(intervalMs, () => {
|
||||
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
// `FilterableView`'s `columns` — the full field set (including
|
||||
// per-column sort/filter, which drives both the table's own popovers
|
||||
// and the card view's filter bar now — see that component's own
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ import { Button } from "../../ui/button/Button.js";
|
|||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
||||
import {
|
||||
failedAgents,
|
||||
|
|
@ -58,10 +58,11 @@ function storedFilter(): AgentFilter {
|
|||
}
|
||||
|
||||
export function BroadcastPage() {
|
||||
const [agents, setAgents] = useState<BroadcastAgent[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const { data: agents, error: loadError } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<BroadcastAgent[]>("/api/agents/status", signal),
|
||||
);
|
||||
const [filter, setFilterState] = useState<AgentFilter>(storedFilter);
|
||||
const [ticked, setTicked] = useState<string[]>([]);
|
||||
const [body, setBody] = useState("");
|
||||
|
|
@ -76,20 +77,6 @@ export function BroadcastPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch("/api/agents/status");
|
||||
if (!res.ok) {
|
||||
setLoadError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setAgents((await res.json()) as BroadcastAgent[]);
|
||||
setLoadError(null);
|
||||
}
|
||||
|
||||
useRefreshInterval(intervalMs, () => {
|
||||
refresh().catch((e: unknown) => setLoadError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
const all = agents ?? [];
|
||||
const shown = visibleAgents(all, filter);
|
||||
const targets = selectedVisible(ticked, shown);
|
||||
|
|
|
|||
|
|
@ -6,15 +6,14 @@
|
|||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useSearch } from "wouter-preact";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import { durationLabel } from "../review-runs/RunsTypes.js";
|
||||
import {
|
||||
|
|
@ -96,8 +95,9 @@ export function BuildsPage() {
|
|||
const wanted = wantedRun(useSearch());
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const [runs, setRuns] = useState<BuildRun[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const { data: runs, error } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<BuildRun[]>(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<ReadonlySet<string>>(
|
||||
|
|
@ -105,22 +105,6 @@ export function BuildsPage() {
|
|||
);
|
||||
const scrolledTo = useRef<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(RUNS);
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setRuns((await res.json()) as BuildRun[]);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
setError({ detail: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
useRefreshInterval(intervalMs, () => void load());
|
||||
|
||||
// Scroll to the linked build once its card exists, not on every refresh.
|
||||
useEffect(() => {
|
||||
if (!wanted || !runs || scrolledTo.current === wanted) return;
|
||||
|
|
|
|||
|
|
@ -6,15 +6,14 @@
|
|||
// posts nothing, and shows no token counts, cache counts or cost.
|
||||
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 { Badge } from "@hive/shared/badge.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import { TryOutResult } from "../review-tryout/TryOutResult.js";
|
||||
import {
|
||||
|
|
@ -94,28 +93,13 @@ function RunCard({
|
|||
export function ReviewRuns() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const [runs, setRuns] = useState<ClassifierRun[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const { data: runs, error } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<ClassifierRun[]>(RUNS, signal),
|
||||
);
|
||||
// Which cards are expanded. Survives a refresh, so a list that updates
|
||||
// under you does not fold the card you are reading.
|
||||
const [open, setOpen] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(RUNS);
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setRuns((await res.json()) as ClassifierRun[]);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
setError({ detail: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
useRefreshInterval(intervalMs, () => void load());
|
||||
|
||||
function toggle(id: string) {
|
||||
setOpen((cur) => {
|
||||
const next = new Set(cur);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
// multiselects). A card being edited keeps its form state across the
|
||||
// periodic refetch because `FilterableView` keys cards by schedule id and
|
||||
// the open edit is held here by id, not by position.
|
||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
|
|
@ -18,9 +18,9 @@ import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
|||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import type { TableColumn } from "../../ui/table/Table.js";
|
||||
import type { AgentRow } from "../../agent/AgentTypes.js";
|
||||
import { ScheduleCard } from "./ScheduleCard.js";
|
||||
|
|
@ -69,8 +69,13 @@ async function loadAgentNames(): Promise<string[]> {
|
|||
export function SchedulesPage() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const [schedules, setSchedules] = useState<Schedule[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const {
|
||||
data: schedules,
|
||||
error,
|
||||
refresh,
|
||||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<Schedule[]>("/api/schedules", signal),
|
||||
);
|
||||
const [agentNames, setAgentNames] = useState<string[]>([]);
|
||||
// The schedule whose card is currently its edit form, and whether the
|
||||
// "new schedule" form is open above the list.
|
||||
|
|
@ -80,22 +85,6 @@ export function SchedulesPage() {
|
|||
const [removeTarget, setRemoveTarget] = useState<Schedule | null>(null);
|
||||
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/schedules");
|
||||
if (!r.ok) {
|
||||
setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
setSchedules((await r.json()) as Schedule[]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError({ detail: String(err) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useRefreshInterval(intervalMs, load);
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentNames().then(setAgentNames, () => setAgentNames([]));
|
||||
}, []);
|
||||
|
|
@ -121,7 +110,7 @@ export function SchedulesPage() {
|
|||
setActionError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
refresh();
|
||||
} catch (err) {
|
||||
setActionError({ detail: String(err) });
|
||||
} finally {
|
||||
|
|
@ -158,10 +147,10 @@ export function SchedulesPage() {
|
|||
[agentNames],
|
||||
);
|
||||
|
||||
async function saved() {
|
||||
function saved() {
|
||||
setCreating(false);
|
||||
setEditingId(null);
|
||||
await load();
|
||||
refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Reference in a new issue