From 7e5b496aa8a0176c992f8491c36f8ca68f51dcc4 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 13:41:52 +0200 Subject: [PATCH] dashboard: fire-now button on schedule rows (closes #467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/web-ui.md | 13 +++++- frontend/packages/dashboard/src/app.js | 42 +++++++++++++++++++ frontend/packages/dashboard/src/dashboard.css | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index 76853b0c..dd681aca 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -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: diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 48c78aa5..ff206209 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -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); diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 117ed785..d197e174 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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). */