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:
parent
63ee6d12be
commit
0af7f53e88
4 changed files with 45 additions and 31 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue