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.
317 lines
10 KiB
TypeScript
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;
|
|
}
|