// — 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[] = [ { 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 { 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(DEFAULT_INTERVAL_MS); const [schedules, setSchedules] = useState(null); const [error, setError] = useState(null); const [agentNames, setAgentNames] = useState([]); // The schedule whose card is currently its edit form, and whether the // "new schedule" form is open above the list. const [editingId, setEditingId] = useState(null); const [creating, setCreating] = useState(false); const [pendingIds, setPendingIds] = useState>(new Set()); const [removeTarget, setRemoveTarget] = useState(null); const [actionError, setActionError] = useState(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) { 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 ( } > {error ? ( ) : null} {actionError ? ( ) : null} {creating ? ( setCreating(false)} /> ) : null} {!error && schedules === null ?

loading…

: null} {schedules ? ( s.id} storageKey="swarm-ui:schedules:table-filters" view="cards" emptyMessage="no schedules yet — add one with + schedule" renderCard={(s) => editingId === s.id ? ( setEditingId(null)} /> ) : ( setEditingId(s.id)} onTogglePaused={() => togglePaused(s)} onRemove={() => setRemoveTarget(s)} /> ) } /> ) : null} setRemoveTarget(null)} onConfirm={confirmRemove} > {removeTarget ? (

Remove {removeTarget.name}? It stops firing for{" "} {removeTarget.agents.join(", ")} and cannot be brought back.

) : null}
); }