Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx
iris 74e588415b swarm-ui: split pages/agents into two pages and the agent components
pages/agents held the roster page, the terminal page and every component under them. It keeps the two pages; the components move to src/agent, by concern: roster (card, wanted menu, actions), detail (panel body, dialog, inference, linked accounts, link menu) and terminal (preview, input, subagents, stream hooks, favicon), with the shared row type and icon at the top. Moves and import rewrites only.
2026-10-12 00:19:33 +02:00

249 lines
7.9 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 { useCallback, 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,
useRefreshInterval,
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.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 [schedules, setSchedules] = useState<Schedule[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
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);
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([]));
}, []);
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;
}
await load();
} 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],
);
async function saved() {
setCreating(false);
setEditingId(null);
await load();
}
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>
);
}