Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4be1e9841 | ||
|
|
f35382e57c | ||
|
|
7e5b496aa8 |
3 changed files with 101 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Reference in a new issue