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
294
frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx
Normal file
294
frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
// <ScheduleForm> — create or edit one scheduled prompt, rendered as a card
|
||||
// so an edited schedule's card turns into its own form in place (mara: "the
|
||||
// edit button changes the card to be the edit form"). With `schedule` it
|
||||
// edits and sends a PATCH carrying only what changed; without, it POSTs a
|
||||
// new one.
|
||||
//
|
||||
// Cadence is either "once" or "every N unit", plus a start time: the fire
|
||||
// time of a one-shot, the origin of the fire grid of a repeating schedule.
|
||||
// Changing the start or the interval moves that grid; the name, message and
|
||||
// agents never do, so the form says which of the two an edit is.
|
||||
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 { Button } from "../../ui/button/Button.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import {
|
||||
MultiselectFilterOptions,
|
||||
type MultiselectFilterState,
|
||||
} from "../../ui/multiselect-filter/MultiselectFilter.js";
|
||||
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import {
|
||||
UNIT_SECONDS,
|
||||
fmtLocal,
|
||||
fromLocalInput,
|
||||
splitInterval,
|
||||
toLocalInput,
|
||||
upcomingFires,
|
||||
type IntervalUnit,
|
||||
type Schedule,
|
||||
} from "./ScheduleTypes.js";
|
||||
import "./ScheduleForm.css";
|
||||
|
||||
const UNIT_OPTIONS = [
|
||||
{ value: "s", label: "seconds" },
|
||||
{ value: "m", label: "minutes" },
|
||||
{ value: "h", label: "hours" },
|
||||
{ value: "d", label: "days" },
|
||||
];
|
||||
|
||||
const PREVIEW_COUNT = 3;
|
||||
|
||||
// The current time rounded up to the next whole minute: what a new schedule
|
||||
// starts at unless the operator picks another time. Rounded because the
|
||||
// time input has minute resolution.
|
||||
function nextMinuteUnix(): number {
|
||||
return Math.ceil(Date.now() / 60_000) * 60;
|
||||
}
|
||||
|
||||
export function ScheduleForm({
|
||||
schedule,
|
||||
agentOptions,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
/** The schedule being edited; absent for a new one. */
|
||||
schedule?: Schedule;
|
||||
/** Every agent a schedule can target. */
|
||||
agentOptions: string[];
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const initialInterval =
|
||||
schedule?.interval_seconds != null
|
||||
? splitInterval(schedule.interval_seconds)
|
||||
: { amount: 1, unit: "h" as IntervalUnit };
|
||||
const initialStart = toLocalInput(schedule?.anchor_unix ?? nextMinuteUnix());
|
||||
|
||||
const [name, setName] = useState(schedule?.name ?? "");
|
||||
const [body, setBody] = useState(schedule?.body ?? "");
|
||||
const [agents, setAgents] = useState<string[]>(schedule?.agents ?? []);
|
||||
const [repeat, setRepeat] = useState(
|
||||
schedule ? schedule.interval_seconds !== null : true,
|
||||
);
|
||||
const [amount, setAmount] = useState(String(initialInterval.amount));
|
||||
const [unit, setUnit] = useState<IntervalUnit>(initialInterval.unit);
|
||||
const [start, setStart] = useState(initialStart);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
const idPrefix = `schedule-${schedule?.id ?? "new"}`;
|
||||
const amountNum = Number(amount);
|
||||
const intervalSeconds =
|
||||
repeat && Number.isInteger(amountNum) && amountNum > 0
|
||||
? amountNum * UNIT_SECONDS[unit]
|
||||
: null;
|
||||
const startUnix = fromLocalInput(start);
|
||||
const cadenceValid = startUnix !== null && (!repeat || intervalSeconds !== null);
|
||||
|
||||
const picked: MultiselectFilterState = { values: agents, negate: false };
|
||||
const toggleAgent = (a: string) =>
|
||||
setAgents((cur) =>
|
||||
cur.includes(a) ? cur.filter((x) => x !== a) : [...cur, a],
|
||||
);
|
||||
|
||||
// The grid moves when the start or the repeat interval differs from what
|
||||
// the schedule has now; only meaningful when editing. The start compares
|
||||
// as the text the input shows, not as seconds: the input has minute
|
||||
// resolution, so a schedule created off a whole minute would otherwise
|
||||
// always look edited.
|
||||
const startChanged = start !== initialStart;
|
||||
const cadenceChanged =
|
||||
schedule !== undefined &&
|
||||
(startChanged ||
|
||||
(repeat ? intervalSeconds : null) !== schedule.interval_seconds);
|
||||
|
||||
const fires =
|
||||
cadenceValid && startUnix !== null
|
||||
? upcomingFires(
|
||||
startUnix,
|
||||
repeat ? intervalSeconds : null,
|
||||
Math.floor(Date.now() / 1000),
|
||||
PREVIEW_COUNT,
|
||||
)
|
||||
: [];
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!cadenceValid || startUnix === null) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const cadence = repeat ? intervalSeconds : null;
|
||||
const patch = schedule
|
||||
? patchBody(schedule, {
|
||||
name,
|
||||
body,
|
||||
agents,
|
||||
startUnix: startChanged ? startUnix : null,
|
||||
cadence,
|
||||
})
|
||||
: null;
|
||||
// Nothing differs: there is nothing to send.
|
||||
if (patch && Object.keys(patch).length === 0) {
|
||||
onSaved();
|
||||
return;
|
||||
}
|
||||
const r = schedule
|
||||
? await fetch(`/api/schedules/${encodeURIComponent(schedule.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
: await fetch("/api/schedules", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
agents,
|
||||
body,
|
||||
anchor_unix: startUnix,
|
||||
interval_seconds: cadence,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError({ detail: String(err) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card class="ui-schedule-form-card">
|
||||
<form class="ui-schedule-form" onSubmit={submit}>
|
||||
<TextField
|
||||
id={`${idPrefix}-name`}
|
||||
label="name"
|
||||
value={name}
|
||||
required
|
||||
onInput={setName}
|
||||
/>
|
||||
<div class="ui-schedule-form-agents">
|
||||
<span class="ui-form-field-label">agents</span>
|
||||
{agentOptions.length === 0 ? (
|
||||
<span class="ui-schedule-form-hint">no agents to pick from</span>
|
||||
) : (
|
||||
<MultiselectFilterOptions
|
||||
options={agentOptions}
|
||||
filter={picked}
|
||||
onToggle={toggleAgent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TextAreaField
|
||||
id={`${idPrefix}-body`}
|
||||
label="message"
|
||||
value={body}
|
||||
required
|
||||
onInput={setBody}
|
||||
/>
|
||||
<div class="ui-schedule-form-cadence">
|
||||
<SelectField
|
||||
id={`${idPrefix}-repeat`}
|
||||
label="repeats"
|
||||
value={repeat ? "every" : "once"}
|
||||
onChange={(v) => setRepeat(v === "every")}
|
||||
options={[
|
||||
{ value: "every", label: "every…" },
|
||||
{ value: "once", label: "once" },
|
||||
]}
|
||||
/>
|
||||
{repeat ? (
|
||||
<>
|
||||
<TextField
|
||||
id={`${idPrefix}-amount`}
|
||||
label="every"
|
||||
type="number"
|
||||
value={amount}
|
||||
required
|
||||
onInput={setAmount}
|
||||
/>
|
||||
<SelectField
|
||||
id={`${idPrefix}-unit`}
|
||||
label="unit"
|
||||
value={unit}
|
||||
onChange={(v) => setUnit(v as IntervalUnit)}
|
||||
options={UNIT_OPTIONS}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<TextField
|
||||
id={`${idPrefix}-start`}
|
||||
label={repeat ? "starts at" : "fires at"}
|
||||
type="datetime-local"
|
||||
value={start}
|
||||
required
|
||||
onInput={setStart}
|
||||
/>
|
||||
</div>
|
||||
{fires.length > 0 ? (
|
||||
<p class="ui-schedule-form-hint">
|
||||
{repeat ? "next fires: " : "fires: "}
|
||||
{fires.map(fmtLocal).join(" · ")}
|
||||
</p>
|
||||
) : cadenceValid ? (
|
||||
<p class="ui-schedule-form-hint">that time has already passed</p>
|
||||
) : null}
|
||||
{cadenceChanged ? (
|
||||
<p class="ui-schedule-form-warn">
|
||||
Changing the start or the repeat moves when this schedule fires.
|
||||
Editing only the name, message or agents does not.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<ApiErrorPanel context="failed to save the schedule" problem={error} />
|
||||
) : null}
|
||||
<div class="ui-schedule-form-actions">
|
||||
<Button onClick={onCancel}>cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={saving || !cadenceValid || agents.length === 0}
|
||||
>
|
||||
{saving ? "saving…" : schedule ? "save" : "create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// A PATCH body holding only the fields that differ from `schedule`, so an
|
||||
// edit never rewrites what the operator did not touch (and a body-only edit
|
||||
// cannot move the fire grid by resending the anchor).
|
||||
function patchBody(
|
||||
schedule: Schedule,
|
||||
next: {
|
||||
name: string;
|
||||
body: string;
|
||||
agents: string[];
|
||||
/** The new start, or null when the operator left it alone. */
|
||||
startUnix: number | null;
|
||||
cadence: number | null;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (next.name !== schedule.name) patch.name = next.name;
|
||||
if (next.body !== schedule.body) patch.body = next.body;
|
||||
if (next.startUnix !== null) patch.anchor_unix = next.startUnix;
|
||||
if (next.cadence !== schedule.interval_seconds) {
|
||||
patch.interval_seconds = next.cadence;
|
||||
}
|
||||
const add = next.agents.filter((a) => !schedule.agents.includes(a));
|
||||
const remove = schedule.agents.filter((a) => !next.agents.includes(a));
|
||||
if (add.length > 0) patch.add_agents = add;
|
||||
if (remove.length > 0) patch.remove_agents = remove;
|
||||
return patch;
|
||||
}
|
||||
Loading…
Reference in a new issue