dashboard: fire-now button on schedule rows (closes #467)

Operator wants to trigger a scheduled prompt immediately
instead of waiting for the next interval. Adds a `↯ fire now`
button on every active schedule row, next to `✕ cancel all`.

Semantics (per design discussion with damocles):

- recurring schedules → out-of-band pulse, `next_fire_at_unix`
  untouched; the regular cadence keeps firing on the original
  schedule. Operator gets an extra fan-out, not a phase shift.
- one-shots → consumed after the manual fire. Operator's
  intent reads as "the scheduled time was wrong, send NOW";
  leaving the original time would be surprising.

Confirm dialog spells out the recurring-vs-one-shot
difference up front so the operator knows what they're about
to do. Button is disabled when every target is already
cancelled (nothing to fire).

Talks to `POST /api/schedules/{id}/fire-now` (damocles is
wiring the backend in parallel). Mauve styling distinguishes
it from cancel (red) and submit (amber); fits the existing
btn pattern.

docs/web-ui.md updated.
This commit is contained in:
iris 2026-05-26 13:41:52 +02:00 committed by Mara
commit 7e5b496aa8
3 changed files with 55 additions and 1 deletions

View file

@ -227,7 +227,10 @@ they share enough conceptual ground to live together).
**N3W SCH3DUL3 / QU3U3D SCH3DUL3S** — operator-managed
scheduled prompts (#444 / #459). Lists every schedule with
its description, targets, body, recurrence interval, next-fire
time, and per-target last-result. Per-row controls: an
time, and per-target last-result. Per-row controls: a
`↯ fire now` button sends an out-of-band manual pulse to
every active target (#467 — recurring schedules keep their
cadence; one-shots are consumed after the manual fire), an
`✎ edit` button opens an inline edit form (#474 — body /
description / interval / next-fire editable, targets stay
immutable; submit PATCHes `/api/schedules/{id}`), and a
@ -500,6 +503,14 @@ not ours.
- `POST /api/schedules/{id}/cancel` — cancel a schedule. Body
`{ targets?: ["name", …] }` cancels just those recipients;
absent or empty body cancels the whole schedule.
- `POST /api/schedules/{id}/fire-now` — out-of-band manual
pulse (#467). Fires the schedule body once immediately to
every active target. Recurring schedules: `next_fire_at_unix`
is untouched; the regular cadence continues. One-shots: the
schedule is consumed (cancelled) after the manual fan-out.
Per-target `last_result` is annotated as a manual fire so
the audit trail distinguishes scheduled fires from operator-
triggered ones.
- `POST /meta-update``nix flake update` the selected
`meta/flake.lock` inputs, then rebuild the affected agents.
- `GET /dashboard/stream` — unified live event channel:

View file

@ -2345,6 +2345,23 @@ window.marked = marked;
if (!cancelled) {
const actions = el('div', { class: 'schedule-actions' });
// Fire-now (#467): out-of-band manual pulse. Recurring schedules
// get an extra fan-out without disturbing `next_fire_at`; one-shots
// are consumed (cancelled after fan-out), per damocles's design.
// Disabled when every target is cancelled — there's nothing to
// fire to. Backend: POST /api/schedules/{id}/fire-now.
const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix);
const fireBtn = el('button', { type: 'button', class: 'btn btn-fire-now' }, '↯ fire now');
const isOneShot = !s.interval_seconds;
fireBtn.title = isOneShot
? 'fire this schedule once, immediately (one-shot — schedule is consumed after the manual fire)'
: 'fire this schedule once, immediately (recurring — next regularly-scheduled fire is unaffected)';
if (!activeTargets.length) {
fireBtn.disabled = true;
fireBtn.title = 'every target is cancelled — nothing to fire';
}
fireBtn.addEventListener('click', () => fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target)));
actions.append(fireBtn);
const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule' },
editingSchedules.has(s.id) ? '✎ close edit' : '✎ edit');
editBtn.title = 'edit body / description / interval / next-fire (targets stay immutable)';
@ -2551,6 +2568,31 @@ window.marked = marked;
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
}
}
async function fireScheduleNow(id, isOneShot, targets) {
const targetList = targets.length ? targets.join(', ') : '(no active targets)';
const prompt = isOneShot
? `fire schedule #${id} now to ${targetList}?\n\n`
+ 'this is a ONE-SHOT — firing now consumes the schedule. '
+ 'the scheduled fire time will no longer trigger.'
: `fire schedule #${id} now to ${targetList}?\n\n`
+ 'this is RECURRING — sends an extra pulse out-of-band. '
+ 'the regular cadence keeps firing on schedule.';
if (!confirm(prompt)) return;
try {
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
await refreshSchedules();
} catch (err) {
alert('fire-now failed: ' + err);
}
}
async function cancelScheduleAll(id) {
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
await postScheduleCancel(id, null);

View file

@ -850,6 +850,7 @@ ul form.inline { display: inline-block; }
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
.btn-fire-now { color: var(--mauve, #cba6f7); border-color: var(--mauve, #cba6f7); }
/* #474: inline edit button on each schedule row. Yellow reads as a
parallel destructive-adjacent action (edit changes state, but
isn't deletion). */