Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx
iris 63ee6d12be 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.
2026-10-04 17:49:42 +02:00

294 lines
9.7 KiB
TypeScript

// <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;
}