Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx
iris 8c67ac01fe swarm-ui: show, sort and filter the hive domain by one value, say when the roster is empty, keep the schedule agent picker current
The domain column showed a dash unless a hive had a dashboard but sorted
and filtered on the domain alone, so rows looked mis-sorted. It now shows
the domain as text when there is no dashboard to link. An empty hive
roster says so. The schedule form's agent names reload with the schedules
and the form says when that load failed.
2026-10-21 23:22:22 +02:00

317 lines
10 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,
agentsError,
onSaved,
onCancel,
}: {
/** The schedule being edited; absent for a new one. */
schedule?: Schedule;
/** Every agent a schedule can target. */
agentOptions: string[];
/** Why the agent list could not load; unset when it did. */
agentsError?: string;
onSaved: () => void;
onCancel: () => void;
}) {
// What the form opened with. A refetch can change `schedule` under an open
// form; every "did the operator edit this" comparison is against this
// snapshot, so another edit landing meanwhile is not reverted on save.
const [original] = useState(schedule);
const [initialStart] = useState(() =>
toLocalInput(original?.anchor_unix ?? nextMinuteUnix()),
);
const initialInterval =
original?.interval_seconds != null
? splitInterval(original.interval_seconds)
: { amount: 1, unit: "h" as IntervalUnit };
const [name, setName] = useState(original?.name ?? "");
const [body, setBody] = useState(original?.body ?? "");
const [agents, setAgents] = useState<string[]>(original?.agents ?? []);
const [repeat, setRepeat] = useState(
original ? original.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-${original?.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 =
original !== undefined &&
(startChanged ||
(repeat ? intervalSeconds : null) !== original.interval_seconds);
const nowUnix = Math.floor(Date.now() / 1000);
const startInPast = startUnix !== null && startUnix <= nowUnix;
const fires =
cadenceValid && startUnix !== null
? upcomingFires(
startUnix,
repeat ? intervalSeconds : null,
nowUnix,
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 = original
? patchBody(original, {
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 = original
? await fetch(`/api/schedules/${encodeURIComponent(original.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">
{agentsError
? `could not load the agent list: ${agentsError}`
: "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>
) : null}
{startInPast && (original === undefined || startChanged) ? (
<p class="ui-schedule-form-hint">
That start is in the past: saving fires the latest missed time once,
right away, in every agent chosen.
</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…" : original ? "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;
}