Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: schedules page says what a past start does, no longer claims a one-shot fired

A start in the past fires the latest missed time once, right away, so the
form says that instead of 'already passed'. A one-shot past its time reads
'past' / 'time passed' (the controller's clock, not a record of firing) and
keeps its pause button. The edit form compares against what it opened with,
so a schedule changing under it is not reverted on save.
This commit is contained in:
iris 2026-10-04 17:07:29 +02:00 • committed by mara
commit 0af7f53e88
4 changed files with 45 additions and 31 deletions

View file

@ -40,12 +40,15 @@ title, with the **agents** it fires for, the message, how often it repeats and
when it fires next. **edit** turns the card into its own form; **pause** and when it fires next. **edit** turns the card into its own form; **pause** and
**resume** stop and restart it; **remove** deletes it after a confirmation. **resume** stop and restart it; **remove** deletes it after a confirmation.
**+ schedule** opens an empty form above the list, and the filters narrow the **+ schedule** opens an empty form above the list, and the filters narrow the
list by name, agent or state (`active`, `paused`, or `done` for a one-shot that list by name, agent or state (`active`, `paused`, or `past` for a one-shot
has fired). whose time has passed). `past` is the controller's clock against the start time,
not a record that an agent fired, so a `past` schedule can still be paused.
A schedule repeats **every** some number of seconds, minutes, hours or days, or A schedule repeats **every** some number of seconds, minutes, hours or days, or
fires **once**. Its start time is the moment a one-shot fires, and the origin of fires **once**. Its start time is the moment a one-shot fires, and the origin of
the fire times of a repeating one. The form previews the next three fire times. the fire times of a repeating one. The form previews the next three fire times.
A start in the past fires the latest missed time once, right away, in every
agent chosen, and the form says so.
Changing the start or the repeat moves them; editing only the name, message or Changing the start or the repeat moves them; editing only the name, message or
agents does not, and the form warns when an edit would. agents does not, and the form warns when an edit would.

View file

@ -18,7 +18,7 @@ import "./ScheduleCard.css";
const STATE_TONE: Record<ScheduleState, BadgeTone> = { const STATE_TONE: Record<ScheduleState, BadgeTone> = {
active: "positive", active: "positive",
paused: "warning", paused: "warning",
done: "neutral", past: "neutral",
}; };
export function ScheduleCard({ export function ScheduleCard({
@ -65,7 +65,7 @@ export function ScheduleCard({
"paused, will not fire" "paused, will not fire"
) : ( ) : (
<> <>
fired{" "} time passed{" "}
<span title={fmtLocal(schedule.anchor_unix)}> <span title={fmtLocal(schedule.anchor_unix)}>
<RelativeTime epochMs={schedule.anchor_unix * 1000} /> <RelativeTime epochMs={schedule.anchor_unix * 1000} />
</span> </span>
@ -76,11 +76,9 @@ export function ScheduleCard({
<Button onClick={onEdit} disabled={pending}> <Button onClick={onEdit} disabled={pending}>
edit edit
</Button> </Button>
{state !== "done" ? (
<Button onClick={onTogglePaused} disabled={pending}> <Button onClick={onTogglePaused} disabled={pending}>
{schedule.paused ? "resume" : "pause"} {schedule.paused ? "resume" : "pause"}
</Button> </Button>
) : null}
<Button onClick={onRemove} disabled={pending}> <Button onClick={onRemove} disabled={pending}>
remove remove
</Button> </Button>

View file

@ -61,17 +61,23 @@ export function ScheduleForm({
onSaved: () => void; onSaved: () => void;
onCancel: () => 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 = const initialInterval =
schedule?.interval_seconds != null original?.interval_seconds != null
? splitInterval(schedule.interval_seconds) ? splitInterval(original.interval_seconds)
: { amount: 1, unit: "h" as IntervalUnit }; : { amount: 1, unit: "h" as IntervalUnit };
const initialStart = toLocalInput(schedule?.anchor_unix ?? nextMinuteUnix());
const [name, setName] = useState(schedule?.name ?? ""); const [name, setName] = useState(original?.name ?? "");
const [body, setBody] = useState(schedule?.body ?? ""); const [body, setBody] = useState(original?.body ?? "");
const [agents, setAgents] = useState<string[]>(schedule?.agents ?? []); const [agents, setAgents] = useState<string[]>(original?.agents ?? []);
const [repeat, setRepeat] = useState( const [repeat, setRepeat] = useState(
schedule ? schedule.interval_seconds !== null : true, original ? original.interval_seconds !== null : true,
); );
const [amount, setAmount] = useState(String(initialInterval.amount)); const [amount, setAmount] = useState(String(initialInterval.amount));
const [unit, setUnit] = useState<IntervalUnit>(initialInterval.unit); const [unit, setUnit] = useState<IntervalUnit>(initialInterval.unit);
@ -79,7 +85,7 @@ export function ScheduleForm({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
const idPrefix = `schedule-${schedule?.id ?? "new"}`; const idPrefix = `schedule-${original?.id ?? "new"}`;
const amountNum = Number(amount); const amountNum = Number(amount);
const intervalSeconds = const intervalSeconds =
repeat && Number.isInteger(amountNum) && amountNum > 0 repeat && Number.isInteger(amountNum) && amountNum > 0
@ -101,16 +107,18 @@ export function ScheduleForm({
// always look edited. // always look edited.
const startChanged = start !== initialStart; const startChanged = start !== initialStart;
const cadenceChanged = const cadenceChanged =
schedule !== undefined && original !== undefined &&
(startChanged || (startChanged ||
(repeat ? intervalSeconds : null) !== schedule.interval_seconds); (repeat ? intervalSeconds : null) !== original.interval_seconds);
const nowUnix = Math.floor(Date.now() / 1000);
const startInPast = startUnix !== null && startUnix <= nowUnix;
const fires = const fires =
cadenceValid && startUnix !== null cadenceValid && startUnix !== null
? upcomingFires( ? upcomingFires(
startUnix, startUnix,
repeat ? intervalSeconds : null, repeat ? intervalSeconds : null,
Math.floor(Date.now() / 1000), nowUnix,
PREVIEW_COUNT, PREVIEW_COUNT,
) )
: []; : [];
@ -122,8 +130,8 @@ export function ScheduleForm({
setError(null); setError(null);
try { try {
const cadence = repeat ? intervalSeconds : null; const cadence = repeat ? intervalSeconds : null;
const patch = schedule const patch = original
? patchBody(schedule, { ? patchBody(original, {
name, name,
body, body,
agents, agents,
@ -136,8 +144,8 @@ export function ScheduleForm({
onSaved(); onSaved();
return; return;
} }
const r = schedule const r = original
? await fetch(`/api/schedules/${encodeURIComponent(schedule.id)}`, { ? await fetch(`/api/schedules/${encodeURIComponent(original.id)}`, {
method: "PATCH", method: "PATCH",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(patch), body: JSON.stringify(patch),
@ -238,8 +246,12 @@ export function ScheduleForm({
{repeat ? "next fires: " : "fires: "} {repeat ? "next fires: " : "fires: "}
{fires.map(fmtLocal).join(" · ")} {fires.map(fmtLocal).join(" · ")}
</p> </p>
) : cadenceValid ? ( ) : null}
<p class="ui-schedule-form-hint">that time has already passed</p> {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} ) : null}
{cadenceChanged ? ( {cadenceChanged ? (
<p class="ui-schedule-form-warn"> <p class="ui-schedule-form-warn">
@ -257,7 +269,7 @@ export function ScheduleForm({
type="submit" type="submit"
disabled={saving || !cadenceValid || agents.length === 0} disabled={saving || !cadenceValid || agents.length === 0}
> >
{saving ? "saving…" : schedule ? "save" : "create"} {saving ? "saving…" : original ? "save" : "create"}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -16,13 +16,14 @@ export interface Schedule {
next_fire_unix: number | null; next_fire_unix: number | null;
} }
export type ScheduleState = "active" | "paused" | "done"; export type ScheduleState = "active" | "paused" | "past";
// A one-shot that has fired reads "done" rather than "active": nothing is // A one-shot whose time has passed reads "past" rather than "active": the
// left to wait for, but the card stays until the operator removes it. // controller has no next fire for it. That is only its clock against the
// anchor, not a record that an agent fired, so the page never says "fired".
export function scheduleState(s: Schedule): ScheduleState { export function scheduleState(s: Schedule): ScheduleState {
if (s.paused) return "paused"; if (s.paused) return "paused";
if (s.interval_seconds === null && s.next_fire_unix === null) return "done"; if (s.interval_seconds === null && s.next_fire_unix === null) return "past";
return "active"; return "active";
} }