swarm-ui: a schedules page, one card per scheduled prompt
/schedules lists the controller's scheduled prompts as cards: name as the title, the agents it fires for, the message, its cadence and next fire. Edit turns a card into its own form; pause, resume and remove act in place, and + schedule opens an empty form. The form sends only what changed, so a rename never moves the fire times, and warns when the start or the repeat does. Filters reuse the shared card filter bar. Adds a TextAreaField to the ui kit, and an upcoming mode on RelativeTime so a next-fire time reads "in 5m". Needs the controller's /api/schedules routes.
This commit is contained in:
parent
7f597f5c43
commit
63ee6d12be
14 changed files with 931 additions and 3 deletions
252
frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx
Normal file
252
frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// <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 "../agents/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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue