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..7056579c 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), fireBtn)); + 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,68 @@ window.marked = marked; if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; } } } + 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` + + '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; + // 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', + 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 : '')); + restoreBtn(); + return; + } + // 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' : ''; + 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 + // refresh wipes the row in place. + setTimeout(() => { refreshSchedules(); }, 1500); + } catch (err) { + alert('fire-now failed: ' + err); + restoreBtn(); + } + } 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..1c00ae6c 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -850,6 +850,16 @@ 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); } +/* 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). */