Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx
iris 8917ba27b6 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.
2026-10-21 23:05:35 +02:00

238 lines
7.5 KiB
TypeScript

// <SchedulesPage> — the swarm's scheduled prompts, one card per schedule.
// Backed by swarm-controller's `GET/POST /api/schedules` and
// `PATCH/DELETE /api/schedules/{id}`; the controller is the only writer of
// the schedule store and computes each schedule's `next_fire_unix`, so
// nothing here derives a fire time except the form's preview. Operator-only:
// the click is the approval, there is no approval queue behind it.
//
// Filtering reuses `FilterableView`'s card bar (name text, agent and state
// 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 { 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";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { Panel } from "../../ui/panel/Panel.js";
import {
RefreshIntervalPicker,
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";
import { ScheduleForm } from "./ScheduleForm.js";
import { scheduleState, type Schedule } from "./ScheduleTypes.js";
// A refetch never touches an open form's own state (it lives in the form),
// so the usual 30s default is safe here too.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
const COLUMNS: TableColumn<Schedule>[] = [
{
key: "name",
header: "name",
render: (s) => s.name,
sortBy: (s) => s.name,
filterValue: (s) => s.name,
},
{
key: "agents",
header: "agents",
render: (s) => s.agents.join(", "),
filterValues: (s) => s.agents,
filterMode: "multiselect",
},
{
key: "state",
header: "state",
render: (s) => scheduleState(s),
sortBy: (s) => scheduleState(s),
filterValues: (s) => [scheduleState(s)],
filterMode: "multiselect",
},
];
// The agent names a schedule can target, from the same roster the agents
// page lists. A failed load leaves the picker empty rather than blocking
// the page: the cards themselves do not need it.
async function loadAgentNames(): Promise<string[]> {
const r = await fetch("/api/agents/status");
if (!r.ok) return [];
const rows = (await r.json()) as AgentRow[];
return rows.map((a) => a.name).sort();
}
export function SchedulesPage() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
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.
const [editingId, setEditingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
const [removeTarget, setRemoveTarget] = useState<Schedule | null>(null);
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
useEffect(() => {
loadAgentNames().then(setAgentNames, () => setAgentNames([]));
}, []);
function markPending(id: string, on: boolean) {
setPendingIds((cur) => {
const next = new Set(cur);
if (on) next.add(id);
else next.delete(id);
return next;
});
}
// One mutation on one schedule: pending while in flight, the error kept
// for the banner, a refetch on success so the card shows the controller's
// own answer rather than a guessed one.
async function act(id: string, request: () => Promise<Response>) {
markPending(id, true);
setActionError(null);
try {
const r = await request();
if (!r.ok) {
setActionError(await readApiError(r));
return;
}
refresh();
} catch (err) {
setActionError({ detail: String(err) });
} finally {
markPending(id, false);
}
}
function togglePaused(s: Schedule) {
return act(s.id, () =>
fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ paused: !s.paused }),
}),
);
}
async function confirmRemove() {
const target = removeTarget;
if (!target) return;
setRemoveTarget(null);
await act(target.id, () =>
fetch(`/api/schedules/${encodeURIComponent(target.id)}`, {
method: "DELETE",
}),
);
}
// A schedule may still target an agent that has since left the roster;
// its own agents stay pickable so an edit can keep or drop them.
const optionsFor = useMemo(
() => (s?: Schedule) =>
[...new Set([...agentNames, ...(s?.agents ?? [])])].sort(),
[agentNames],
);
function saved() {
setCreating(false);
setEditingId(null);
refresh();
}
return (
<Panel
title="schedules"
icon="⏰"
actions={
<>
<Button
variant="primary"
onClick={() => setCreating(true)}
disabled={creating}
>
+ schedule
</Button>
<RefreshIntervalPicker
id="schedules-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</>
}
>
{error ? (
<ApiErrorPanel context="failed to load the schedules" problem={error} />
) : null}
{actionError ? (
<ApiErrorPanel
context="the schedule was not changed"
problem={actionError}
/>
) : null}
{creating ? (
<ScheduleForm
agentOptions={optionsFor()}
onSaved={saved}
onCancel={() => setCreating(false)}
/>
) : null}
{!error && schedules === null ? <p>loading…</p> : null}
{schedules ? (
<FilterableView
columns={COLUMNS}
rows={schedules}
rowKey={(s) => s.id}
storageKey="swarm-ui:schedules:table-filters"
view="cards"
emptyMessage="no schedules yet — add one with + schedule"
renderCard={(s) =>
editingId === s.id ? (
<ScheduleForm
schedule={s}
agentOptions={optionsFor(s)}
onSaved={saved}
onCancel={() => setEditingId(null)}
/>
) : (
<ScheduleCard
schedule={s}
pending={pendingIds.has(s.id)}
onEdit={() => setEditingId(s.id)}
onTogglePaused={() => togglePaused(s)}
onRemove={() => setRemoveTarget(s)}
/>
)
}
/>
) : null}
<ConfirmDialog
open={removeTarget !== null}
label="remove schedule"
confirmLabel="remove"
onCancel={() => setRemoveTarget(null)}
onConfirm={confirmRemove}
>
{removeTarget ? (
<p>
Remove <strong>{removeTarget.name}</strong>? It stops firing for{" "}
{removeTarget.agents.join(", ")} and cannot be brought back.
</p>
) : null}
</ConfirmDialog>
</Panel>
);
}