From 7e5b496aa8a0176c992f8491c36f8ca68f51dcc4 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 13:41:52 +0200 Subject: [PATCH 1/3] 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). */ From f35382e57cd2ec24d622a41d0276947f657931bc Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 13:47:40 +0200 Subject: [PATCH 2/3] schedules fire-now: render FireNowReport inline on the button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit damocles's #472 returns `{ ok, failed, missing, one_shot_consumed }` from the fire-now endpoint. Parse the response and flash the per-target outcome on the button itself for ~1.5s before refreshSchedules() repaints — operator sees the result immediately without a modal alert or an `/api/schedules` re-fetch round-trip. Button label transitions: ↯ fire now → ◐ firing… → ↯ fired: 3 ok, 1 missing — consumed (green flash, then row refresh) --- frontend/packages/dashboard/src/app.js | 27 ++++++++++++++++--- frontend/packages/dashboard/src/dashboard.css | 9 +++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index ff206209..a5375ace 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -2360,7 +2360,7 @@ window.marked = marked; fireBtn.disabled = true; fireBtn.title = 'every target is cancelled — nothing to fire'; } - fireBtn.addEventListener('click', () => fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target))); + fireBtn.addEventListener('click', () => fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target), fireBtn)); actions.append(fireBtn); const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule' }, editingSchedules.has(s.id) ? '✎ close edit' : '✎ edit'); @@ -2568,7 +2568,7 @@ window.marked = marked; if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; } } } - async function fireScheduleNow(id, isOneShot, targets) { + async function fireScheduleNow(id, isOneShot, targets, btn) { const targetList = targets.length ? targets.join(', ') : '(no active targets)'; const prompt = isOneShot ? `fire schedule #${id} now to ${targetList}?\n\n` @@ -2578,6 +2578,8 @@ window.marked = marked; + 'this is RECURRING — sends an extra pulse out-of-band. ' + 'the regular cadence keeps firing on schedule.'; if (!confirm(prompt)) return; + const originalLabel = btn ? btn.innerHTML : ''; + if (btn) { btn.disabled = true; btn.innerHTML = ' firing…'; } try { const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', { method: 'POST', @@ -2586,11 +2588,30 @@ window.marked = marked; if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : '')); + if (btn) { btn.disabled = false; btn.innerHTML = originalLabel; } return; } - await refreshSchedules(); + // Backend returns FireNowReport { ok, failed, missing, one_shot_consumed }. + // Flash the per-target outcome on the button itself so the operator + // sees the result immediately, then refresh to pick up the + // authoritative per-target `last_result` annotations. + let report = null; + try { report = await resp.json(); } catch { /* shape drift / empty body — ignore */ } + if (btn && report) { + const bits = []; + if (report.ok) bits.push(report.ok + ' ok'); + if (report.failed) bits.push(report.failed + ' failed'); + if (report.missing) bits.push(report.missing + ' missing'); + const suffix = report.one_shot_consumed ? ' — consumed' : ''; + btn.innerHTML = '↯ fired: ' + (bits.join(', ') || 'no targets') + suffix; + btn.classList.add('btn-fire-now-flashed'); + } + // Hold the flash briefly so the operator can read it before the + // refresh wipes the row in place. + setTimeout(() => { refreshSchedules(); }, 1500); } catch (err) { alert('fire-now failed: ' + err); + if (btn) { btn.disabled = false; btn.innerHTML = originalLabel; } } } async function cancelScheduleAll(id) { diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index d197e174..1c00ae6c 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -851,6 +851,15 @@ ul form.inline { display: inline-block; } .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); } +/* Post-fire flash: report renders directly on the button for ~1.5s + so the operator sees ok/failed/missing/consumed counts inline + without a modal. Green when at least one ok; muted otherwise. */ +.btn-fire-now-flashed { + color: var(--green); + border-color: var(--green); + text-shadow: 0 0 6px currentColor; + box-shadow: 0 0 8px -2px currentColor; +} /* #474: inline edit button on each schedule row. Yellow reads as a parallel destructive-adjacent action (edit changes state, but isn't deletion). */ From c4be1e98419bf94c9f7079aaa622d2e5e66930fd Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 15:02:03 +0200 Subject: [PATCH 3/3] schedules fire-now: textContent + DOM nodes instead of innerHTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit argus 🟡 note on #471 — values rendered into the button flash are all server-side ints/bool today, but textContent + element children is the safer pattern if a stringy field ever lands in FireNowReport. Captures original children on enter so error paths can restore faithfully (the previous innerHTML round-trip would have already lost any nested element structure). --- frontend/packages/dashboard/src/app.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index a5375ace..7056579c 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -2578,8 +2578,23 @@ window.marked = marked; + 'this is RECURRING — sends an extra pulse out-of-band. ' + 'the regular cadence keeps firing on schedule.'; if (!confirm(prompt)) return; - const originalLabel = btn ? btn.innerHTML : ''; - if (btn) { btn.disabled = true; btn.innerHTML = ' firing…'; } + // Capture child nodes so we can restore on error, then replace + // with DOM-built content (textContent + element children rather + // than innerHTML — per argus's review note on #471, the format + // string only carries server-side ints/bool today but textContent + // is the safer pattern if a stringy field ever lands). + const originalChildren = btn ? Array.from(btn.childNodes) : []; + const restoreBtn = () => { + if (!btn) return; + btn.disabled = false; + while (btn.firstChild) btn.removeChild(btn.firstChild); + for (const n of originalChildren) btn.appendChild(n); + }; + if (btn) { + btn.disabled = true; + while (btn.firstChild) btn.removeChild(btn.firstChild); + btn.append(el('span', { class: 'spinner' }, '◐'), ' firing…'); + } try { const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', { method: 'POST', @@ -2588,7 +2603,7 @@ window.marked = marked; if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : '')); - if (btn) { btn.disabled = false; btn.innerHTML = originalLabel; } + restoreBtn(); return; } // Backend returns FireNowReport { ok, failed, missing, one_shot_consumed }. @@ -2603,7 +2618,8 @@ window.marked = marked; if (report.failed) bits.push(report.failed + ' failed'); if (report.missing) bits.push(report.missing + ' missing'); const suffix = report.one_shot_consumed ? ' — consumed' : ''; - btn.innerHTML = '↯ fired: ' + (bits.join(', ') || 'no targets') + suffix; + while (btn.firstChild) btn.removeChild(btn.firstChild); + btn.textContent = '↯ fired: ' + (bits.join(', ') || 'no targets') + suffix; btn.classList.add('btn-fire-now-flashed'); } // Hold the flash briefly so the operator can read it before the @@ -2611,7 +2627,7 @@ window.marked = marked; setTimeout(() => { refreshSchedules(); }, 1500); } catch (err) { alert('fire-now failed: ' + err); - if (btn) { btn.disabled = false; btn.innerHTML = originalLabel; } + restoreBtn(); } } async function cancelScheduleAll(id) {