The dashboard has a themed modal/dialog system (modal.js: themedToast/ themedConfirm/themedPrompt) and a data-async form submit interceptor (bindAsyncForms) that every dashboard action routes through. The per-agent UI never adopted either — it had its own more primitive data-async handler using native window.confirm()/alert() (8 call sites) and a duplicated el() DOM helper. - Moved el() out of dashboard/common.js into shared/src/dom.js. - Moved modal.js + modal.css from dashboard/src/ to shared/src/, updating its internal el import. - Moved bindAsyncForms from dashboard/common.js into shared/forms.js, alongside the asyncBtn primitive it's built on. - Updated every dashboard file's imports to the new shared locations (no re-export shims). - agent.css now @imports shared/modal.css so the dialogs render themed there too. - agent/app.js: dropped its local el()/data-async duplicate, wired bindAsyncForms(), and replaced all 8 window.confirm() sites with themedConfirm (async, wrapped in a fire-and-forget IIFE where the call site needs a synchronous boolean return, e.g. the slash-command dispatcher). Closes hyperhive#2791. Verified with a full frontend build (npm run build) — both dashboard and agent bundles compile clean and agent.css picks up the .tc-* dialog styles it previously lacked.
1057 lines
42 KiB
JavaScript
1057 lines
42 KiB
JavaScript
// Dashboard SCH3DUL3S tab — scheduled-prompts.
|
||
//
|
||
// The operator's scheduled prompts: the list, the inline create row,
|
||
// the per-row edit form, and the fire-now / cancel actions. Live updates
|
||
// arrive via the `schedules_changed` dashboard event (wired into the
|
||
// entry's mutation dispatch table); the tab-activation re-fetch is the
|
||
// cold-load / reconnect recovery path.
|
||
//
|
||
// Owns its own module state (the schedules list + edit-in-progress
|
||
// tracking); reads the shared agent roster from state.js for the
|
||
// target-chip pickers. Render + format helpers come from util.js.
|
||
//
|
||
// Note: per-agent reminders moved to the in-container store (reminders
|
||
// migration) and are no longer listed here — they surface via
|
||
// get_loose_ends / the todos pill on the agent page instead.
|
||
|
||
import { $ } from './common.js';
|
||
import { el } from '@hive/shared/dom.js';
|
||
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
||
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from './util.js';
|
||
import { containersState } from './state.js';
|
||
import { asyncBtn } from '@hive/shared/forms.js';
|
||
// ─── scheduled prompts ─────────────────────────────────────────────────
|
||
// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
|
||
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
|
||
// (whole or per-target), `/api/schedules/{id}` (PATCH edit),
|
||
// `/api/schedules/{id}/fire-now` (POST). Mutations now emit a
|
||
// `schedules_changed` SSE event so the list updates live;
|
||
// `applySchedulesChanged` handles it. Tab-activation re-fetch kept as
|
||
// a safety net for approval-path inserts and disconnect windows.
|
||
// Local cache lets `refreshTabCounts` show the active count without
|
||
// re-fetching every second.
|
||
let schedulesState = [];
|
||
// Schedule ids whose inline edit form is currently open. The set
|
||
// survives across refreshSchedules() calls so a state
|
||
// poll doesn't yank the form out from under the operator. Per-id
|
||
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
|
||
// also rides the refresh.
|
||
const editingSchedules = new Set();
|
||
const scheduleEditCarry = new Map();
|
||
export async function refreshSchedules() {
|
||
const listRoot = $('schedules-section');
|
||
if (!listRoot) return;
|
||
try {
|
||
const resp = await fetch('/api/schedules');
|
||
if (!resp.ok) {
|
||
paintAtomic(listRoot, (root) => {
|
||
root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
|
||
});
|
||
return;
|
||
}
|
||
schedulesState = await resp.json();
|
||
} catch (err) {
|
||
paintAtomic(listRoot, (root) => {
|
||
root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
|
||
});
|
||
return;
|
||
}
|
||
renderSchedulesList();
|
||
}
|
||
// Active = at least one target still alive (no `cancelled_at_unix`)
|
||
// AND the whole schedule isn't cancelled. Drives the tab pill count.
|
||
export function activeScheduleCount() {
|
||
let n = 0;
|
||
for (const s of schedulesState) {
|
||
if (s.cancelled_at_unix) continue;
|
||
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
|
||
}
|
||
return n;
|
||
}
|
||
// Label + caption wrapper shared by every field on the schedule
|
||
// forms — `<label class="schedule-field"><span
|
||
// class="schedule-field-label">…</span>…children…</label>`. Variadic
|
||
// children land inside the label after the caption span, which is
|
||
// what every caller wants (the input + any preset/parts/preview
|
||
// sub-rows live next to the caption).
|
||
function scheduleField(labelText, ...children) {
|
||
return el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, labelText),
|
||
...children);
|
||
}
|
||
|
||
// Interval composer — shared between the new-schedule form and
|
||
// the edit-schedule form. Builds the preset chip row +
|
||
// d/h/m/s sub-fields + live preview, and returns helpers to read
|
||
// and write the value.
|
||
//
|
||
// The composer carries no opinion on what "0 / blank" means at the
|
||
// semantic layer: it just reports the integer total. Callers decide
|
||
// whether to treat 0 as `null` (one-shot) for their own submit path.
|
||
function buildIntervalComposer({
|
||
label = 'interval (blank / all-zero = one-shot)',
|
||
namePrefix = 'interval_',
|
||
initialSeconds = 0,
|
||
} = {}) {
|
||
const wrapper = scheduleField(label);
|
||
const presets = [
|
||
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
|
||
['1h', 3600], ['6h', 21600], ['12h', 43200],
|
||
['1d', 86400], ['7d', 604800],
|
||
];
|
||
const presetsRow = el('div', { class: 'schedule-interval-presets' });
|
||
for (const [presetLabel, secs] of presets) {
|
||
presetsRow.append(el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-interval-preset',
|
||
'data-secs': String(secs),
|
||
}, presetLabel));
|
||
}
|
||
presetsRow.append(el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-interval-preset btn-interval-oneshot',
|
||
'data-secs': '0',
|
||
}, 'one-shot'));
|
||
wrapper.append(presetsRow);
|
||
|
||
const partsRow = el('div', { class: 'schedule-interval-parts' });
|
||
const mkPart = (suffix, unit) => {
|
||
const inp = el('input', {
|
||
type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0',
|
||
class: 'schedule-interval-num',
|
||
});
|
||
partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
|
||
return inp;
|
||
};
|
||
const dInp = mkPart('d', 'd');
|
||
const hInp = mkPart('h', 'h');
|
||
const mInp = mkPart('m', 'm');
|
||
const sInp = mkPart('s', 's');
|
||
wrapper.append(partsRow);
|
||
|
||
const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
|
||
wrapper.append(preview);
|
||
|
||
function getSeconds() {
|
||
const n = (v) => {
|
||
const x = parseInt(v, 10);
|
||
return Number.isFinite(x) && x > 0 ? x : 0;
|
||
};
|
||
return n(dInp.value) * 86400 + n(hInp.value) * 3600
|
||
+ n(mInp.value) * 60 + n(sInp.value);
|
||
}
|
||
function fillFromSeconds(total) {
|
||
const t = Math.max(0, Math.floor(total));
|
||
const d = Math.floor(t / 86400);
|
||
const h = Math.floor((t % 86400) / 3600);
|
||
const m = Math.floor((t % 3600) / 60);
|
||
const s = t % 60;
|
||
dInp.value = d > 0 ? String(d) : '';
|
||
hInp.value = h > 0 ? String(h) : '';
|
||
mInp.value = m > 0 ? String(m) : '';
|
||
sInp.value = s > 0 ? String(s) : '';
|
||
}
|
||
function setParts({ d, h, m, s }) {
|
||
if (d !== undefined) dInp.value = d;
|
||
if (h !== undefined) hInp.value = h;
|
||
if (m !== undefined) mInp.value = m;
|
||
if (s !== undefined) sInp.value = s;
|
||
}
|
||
function updatePreview() {
|
||
const total = getSeconds();
|
||
preview.textContent = total > 0
|
||
? '↻ every ' + fmtDuration(total)
|
||
: 'one-shot (fires once at first-fire time)';
|
||
preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
|
||
}
|
||
for (const inp of [dInp, hInp, mInp, sInp]) {
|
||
inp.addEventListener('input', updatePreview);
|
||
}
|
||
for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
|
||
b.addEventListener('click', () => {
|
||
fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0);
|
||
updatePreview();
|
||
});
|
||
}
|
||
if (initialSeconds > 0) fillFromSeconds(initialSeconds);
|
||
updatePreview();
|
||
|
||
return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
|
||
}
|
||
|
||
// Parse the d/h/m/s sub-fields of a `buildIntervalComposer` instance
|
||
// out of submitted FormData. Returns total seconds, or NaN if any
|
||
// field carries a non-integer / negative value — the caller alerts
|
||
// and bails. Total `0` means "one-shot" at the call site (wire is
|
||
// `interval_seconds: null`). `namePrefix` matches the prefix passed
|
||
// to `buildIntervalComposer` (e.g. `interval_` or `edit_interval_`).
|
||
function intervalSecondsFromFormData(fd, namePrefix) {
|
||
const part = (suffix, mult) => {
|
||
const raw = String(fd.get(namePrefix + suffix) || '').trim();
|
||
if (!raw) return 0;
|
||
const n = parseInt(raw, 10);
|
||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||
return n * mult;
|
||
};
|
||
return part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
|
||
}
|
||
|
||
// Targets multi-select chip box — shared between the new-schedule
|
||
// form and the edit-schedule form. Same DOM shape, same candidate
|
||
// list (containers + operator + root); only the chip element id
|
||
// prefix and checkbox field name vary. Used to be inlined twice in
|
||
// near-identical 18-line blocks; consolidated here so a future
|
||
// change (new chip kind, candidate-list source swap, etc.) lives in
|
||
// one place. Returns the wrapping `<label class="schedule-field">`
|
||
// ready to append to the form.
|
||
function buildTargetChips({ idPrefix, fieldName, checked, extraNames = [] }) {
|
||
const candidates = ['operator'];
|
||
const containerNames = Array.from(containersState.values())
|
||
.map((c) => c.name)
|
||
.filter((n) => n !== 'operator')
|
||
.sort();
|
||
for (const n of containerNames) candidates.push(n);
|
||
// `extraNames` lets the edit form keep showing an already-active
|
||
// target that's vanished from the live container list (operator's
|
||
// typo, container destroyed mid-schedule, etc.) so it's still
|
||
// explicitly uncheckable. New-schedule callers pass `[]`.
|
||
for (const n of extraNames) if (!candidates.includes(n)) candidates.push(n);
|
||
|
||
const box = el('div', { class: 'schedule-targets' });
|
||
for (const name of candidates) {
|
||
const id_ = idPrefix + name;
|
||
const cb = el('input', {
|
||
type: 'checkbox', name: fieldName, value: name, id: id_,
|
||
});
|
||
if (checked.has(name)) cb.checked = true;
|
||
box.append(el('label', { class: 'schedule-target-chip', for: id_ },
|
||
cb, el('span', {}, name)));
|
||
}
|
||
return scheduleField('targets', box);
|
||
}
|
||
|
||
// Inline-create carry-state — survives paintAtomic re-renders.
|
||
// The schedules table refreshes on tab activate + after every mutation,
|
||
// and each refresh rebuilds the DOM via `paintAtomic`. The bottom
|
||
// create row's inputs would lose mid-typing values without this
|
||
// carry. `readNewScheduleCarryFromDOM` is called BEFORE every
|
||
// re-render so the carry sees the latest user input; the row
|
||
// builders then pre-fill from `newScheduleCarry`.
|
||
const newScheduleCarry = {
|
||
targets: new Set(),
|
||
body: '',
|
||
description: '',
|
||
first_fire: '',
|
||
interval_d: '',
|
||
interval_h: '',
|
||
interval_m: '',
|
||
interval_s: '',
|
||
};
|
||
function readNewScheduleCarryFromDOM(tr) {
|
||
if (!tr) return;
|
||
const get = (sel) => tr.querySelector(sel);
|
||
const checked = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
|
||
.map((i) => i.value);
|
||
newScheduleCarry.targets = new Set(checked);
|
||
const setIfDefined = (key, sel) => {
|
||
const el_ = get(sel);
|
||
if (el_) newScheduleCarry[key] = el_.value || '';
|
||
};
|
||
setIfDefined('body', 'textarea[name="new_body"]');
|
||
setIfDefined('description', 'input[name="new_description"]');
|
||
setIfDefined('first_fire', 'input[name="new_first_fire"]');
|
||
setIfDefined('interval_d', 'input[name="new_interval_d"]');
|
||
setIfDefined('interval_h', 'input[name="new_interval_h"]');
|
||
setIfDefined('interval_m', 'input[name="new_interval_m"]');
|
||
setIfDefined('interval_s', 'input[name="new_interval_s"]');
|
||
}
|
||
function resetNewScheduleCarry() {
|
||
newScheduleCarry.targets = new Set();
|
||
newScheduleCarry.body = '';
|
||
newScheduleCarry.description = '';
|
||
newScheduleCarry.first_fire = '';
|
||
newScheduleCarry.interval_d = '';
|
||
newScheduleCarry.interval_h = '';
|
||
newScheduleCarry.interval_m = '';
|
||
newScheduleCarry.interval_s = '';
|
||
}
|
||
function isoForDatetimeLocal(date) {
|
||
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
|
||
// in the user's local timezone (with no trailing Z). Build it
|
||
// by hand rather than slicing toISOString which is always UTC.
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
}
|
||
// Schedules render as a single table — one row per schedule,
|
||
// attribute columns + one ✓/✕ column per agent (tilted 45° header
|
||
// so a row of agents takes ~28px each instead of full word width),
|
||
// actions column on the right. Edit form expands into a colspan'd
|
||
// row underneath when its row's `✎` is toggled on.
|
||
function renderSchedulesList() {
|
||
const liveRoot = $('schedules-section');
|
||
if (!liveRoot) return;
|
||
// Capture any mid-typed inline-create state BEFORE paintAtomic
|
||
// blows the row away so a refresh doesn't yank the operator's
|
||
// half-filled form. The carry is replayed by
|
||
// `renderInlineCreateRow` below.
|
||
readNewScheduleCarryFromDOM(liveRoot.querySelector('.schedules-table-create-row'));
|
||
paintAtomic(liveRoot, (root) => {
|
||
const agents = schedulesTableAgentSet();
|
||
const table = el('table', { class: 'schedules-table' });
|
||
table.append(renderSchedulesTableHead(agents));
|
||
const tbody = el('tbody', {});
|
||
const sorted = schedulesState.slice().sort((a, b) => {
|
||
// Cancelled → bucket 2, paused → bucket 1, active → bucket 0.
|
||
const aOrd = a.cancelled_at_unix ? 2 : a.paused_at_unix ? 1 : 0;
|
||
const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0;
|
||
if (aOrd !== bOrd) return aOrd - bOrd;
|
||
return epochSec(a.next_fire_at_unix) - epochSec(b.next_fire_at_unix);
|
||
});
|
||
for (const s of sorted) {
|
||
tbody.append(renderScheduleRow(s, agents));
|
||
if (editingSchedules.has(s.id) && !s.cancelled_at_unix) {
|
||
tbody.append(renderScheduleEditRow(s, agents));
|
||
}
|
||
}
|
||
// Always-visible inline create row at the bottom of the table
|
||
// — fill cells + click + to POST. See
|
||
// docs/web-ui.md::SCH3DUL3S tab for the layout rationale.
|
||
tbody.append(renderInlineCreateRow(agents));
|
||
table.append(tbody);
|
||
const wrap = el('div', { class: 'schedules-table-wrap' });
|
||
wrap.append(table);
|
||
root.append(wrap);
|
||
});
|
||
}
|
||
// Inline create row. Each column carries an input matching its
|
||
// display semantics (datetime-local for next-fire, mini d/h/m/s
|
||
// number inputs for every, textarea for body, checkbox per agent
|
||
// column for targets). Submitting POSTs `/api/schedules` and clears
|
||
// the carry on success; the next `refreshSchedules` redraws the
|
||
// table with the new schedule above.
|
||
function renderInlineCreateRow(agents) {
|
||
const tr = el('tr', { class: 'schedules-table-create-row' });
|
||
|
||
tr.append(el('td', { class: 'meta schedules-table-id' }, 'new'));
|
||
tr.append(el('td', { class: 'meta' }, '—'));
|
||
|
||
const nextInput = el('input', {
|
||
type: 'datetime-local',
|
||
name: 'new_first_fire',
|
||
class: 'schedules-table-inline-input schedules-table-inline-datetime',
|
||
required: 'required',
|
||
title: 'first fire time (defaults to 5 minutes from now)',
|
||
});
|
||
// Default: 5 minutes from now so a stale-clock or quick-submit
|
||
// accident doesn't fire immediately on `now()`.
|
||
nextInput.value = newScheduleCarry.first_fire
|
||
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
|
||
tr.append(el('td', { class: 'schedules-table-create-cell' }, nextInput));
|
||
|
||
const intervalRow = el('div', {
|
||
class: 'schedules-table-inline-interval',
|
||
title: 'recurring every D days H hours M minutes S seconds (all blank / zero = one-shot)',
|
||
});
|
||
const mkUnit = (suffix, unit) => {
|
||
const inp = el('input', {
|
||
type: 'number',
|
||
name: 'new_interval_' + suffix,
|
||
min: '0',
|
||
step: '1',
|
||
placeholder: '0',
|
||
class: 'schedules-table-inline-num',
|
||
'aria-label': 'interval ' + suffix,
|
||
});
|
||
inp.value = newScheduleCarry['interval_' + suffix] || '';
|
||
intervalRow.append(inp, el('span', { class: 'schedules-table-inline-unit' }, unit));
|
||
};
|
||
mkUnit('d', 'd');
|
||
mkUnit('h', 'h');
|
||
mkUnit('m', 'm');
|
||
mkUnit('s', 's');
|
||
tr.append(el('td', { class: 'schedules-table-create-cell' }, intervalRow));
|
||
|
||
tr.append(el('td', { class: 'meta' }, 'operator'));
|
||
|
||
const bodyTa = el('textarea', {
|
||
name: 'new_body',
|
||
rows: '1',
|
||
required: 'required',
|
||
placeholder: 'prompt body (required, multi-line ok)',
|
||
class: 'schedules-table-inline-textarea',
|
||
});
|
||
bodyTa.value = newScheduleCarry.body;
|
||
const descInput = el('input', {
|
||
type: 'text',
|
||
name: 'new_description',
|
||
placeholder: 'description (optional)',
|
||
class: 'schedules-table-inline-input schedules-table-inline-desc',
|
||
});
|
||
descInput.value = newScheduleCarry.description;
|
||
tr.append(el('td', { class: 'schedules-table-create-cell schedules-table-create-body' },
|
||
bodyTa, descInput));
|
||
|
||
// Per-agent target checkboxes — one cell per agent column. Wrap
|
||
// each checkbox in a label so the whole cell area is clickable.
|
||
for (const a of agents) {
|
||
const td = el('td', { class: 'schedules-table-check schedules-table-create-cell' });
|
||
const id_ = 'new-target-' + a;
|
||
const cb = el('input', {
|
||
type: 'checkbox',
|
||
name: 'new_targets',
|
||
value: a,
|
||
id: id_,
|
||
class: 'schedules-table-inline-check',
|
||
});
|
||
if (newScheduleCarry.targets.has(a)) cb.checked = true;
|
||
const lbl = el('label', {
|
||
for: id_,
|
||
class: 'schedules-table-inline-check-lbl',
|
||
title: 'tick to target ' + a,
|
||
}, cb);
|
||
td.append(lbl);
|
||
tr.append(td);
|
||
}
|
||
|
||
// Actions cell — submit button + reset.
|
||
const actionsCell = el('td', { class: 'schedules-table-actions' });
|
||
const submitBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-spawn btn-inline-small schedules-table-create-submit',
|
||
title: 'queue this new schedule',
|
||
}, '+');
|
||
submitBtn.addEventListener('click', () => submitNewScheduleInline(tr, submitBtn));
|
||
const resetBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-inline-small schedules-table-create-reset',
|
||
title: 'clear all fields',
|
||
}, '⌫');
|
||
resetBtn.addEventListener('click', () => {
|
||
resetNewScheduleCarry();
|
||
renderSchedulesList();
|
||
});
|
||
actionsCell.append(submitBtn, resetBtn);
|
||
tr.append(actionsCell);
|
||
|
||
return tr;
|
||
}
|
||
async function submitNewScheduleInline(tr, submitBtn) {
|
||
const targets = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
|
||
.map((i) => i.value);
|
||
const body = String(tr.querySelector('textarea[name="new_body"]')?.value || '').trim();
|
||
const description = String(tr.querySelector('input[name="new_description"]')?.value || '').trim();
|
||
const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || '');
|
||
|
||
if (!targets.length) {
|
||
themedToast('schedule must have at least one target — tick at least one agent column', { type: 'error' });
|
||
return;
|
||
}
|
||
if (!body) { themedToast('prompt body must be non-empty', { type: 'error' }); return; }
|
||
if (!firstFireStr) { themedToast('first fire timestamp is required', { type: 'error' }); return; }
|
||
const firstFireDate = new Date(firstFireStr);
|
||
if (Number.isNaN(firstFireDate.getTime())) {
|
||
themedToast('first fire is not a valid datetime', { type: 'error' }); return;
|
||
}
|
||
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
||
|
||
// Parse the d/h/m/s parts inline — we don't use FormData since
|
||
// the inline row isn't wrapped in a <form>. Mirrors
|
||
// `intervalSecondsFromFormData` semantics: blank/0 = one-shot,
|
||
// anything non-integer or negative → NaN → alert.
|
||
const part = (suffix, mult) => {
|
||
const raw = String(tr.querySelector(`input[name="new_interval_${suffix}"]`)?.value || '').trim();
|
||
if (!raw) return 0;
|
||
const n = parseInt(raw, 10);
|
||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||
return n * mult;
|
||
};
|
||
const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
|
||
if (Number.isNaN(intervalTotal)) {
|
||
themedToast('interval fields must be non-negative integers (or blank for one-shot)', { type: 'error' });
|
||
return;
|
||
}
|
||
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
|
||
|
||
const payload = { targets, body, first_fire_at_unix };
|
||
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
|
||
if (description) payload.description = description;
|
||
|
||
asyncBtn(submitBtn, async () => {
|
||
try {
|
||
const resp = await fetch('/api/schedules', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
themedToast('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||
return;
|
||
}
|
||
// Reset carry so the next render shows an empty row.
|
||
resetNewScheduleCarry();
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast('schedule submit failed: ' + err, { type: 'error' });
|
||
}
|
||
});
|
||
}
|
||
// The set of agent columns in the schedules table: operator + root
|
||
// (manager) first, then live containers (sorted), then any extra names
|
||
// that appear as a schedule target but aren't in the live container list
|
||
// (operator typo, container destroyed mid-schedule, etc.) — same
|
||
// membership rule as `buildTargetChips` so the table and the new-/
|
||
// edit-form chip boxes agree on what's addressable.
|
||
function schedulesTableAgentSet() {
|
||
const seen = new Set();
|
||
const out = [];
|
||
const push = (n) => { if (!seen.has(n)) { seen.add(n); out.push(n); } };
|
||
push('operator');
|
||
const containerNames = Array.from(containersState.values())
|
||
.map((c) => c.name)
|
||
.filter((n) => n !== 'operator')
|
||
.sort();
|
||
for (const n of containerNames) push(n);
|
||
for (const s of schedulesState) {
|
||
for (const t of s.targets || []) push(t.target);
|
||
}
|
||
return out;
|
||
}
|
||
function renderSchedulesTableHead(agents) {
|
||
const thead = el('thead', {});
|
||
const headerRow = el('tr', {});
|
||
headerRow.append(
|
||
el('th', { class: 'schedules-table-id' }, '#'),
|
||
el('th', {}, 'src'),
|
||
el('th', { class: 'schedules-table-next-col' }, 'next'),
|
||
el('th', { class: 'schedules-table-every-col' }, 'every'),
|
||
el('th', {}, 'owner'),
|
||
el('th', { class: 'schedules-table-body-th' }, 'body'),
|
||
);
|
||
// 'operator' is always a valid target; every other column maps to a
|
||
// container, so flag any column whose agent is no longer in the live
|
||
// roster — its past schedules linger in the table but the agent is gone.
|
||
const liveNames = new Set(Array.from(containersState.values()).map((c) => c.name));
|
||
for (const a of agents) {
|
||
const gone = a !== 'operator' && !liveNames.has(a);
|
||
headerRow.append(el('th', {
|
||
class: 'schedules-table-agent-th' + (gone ? ' schedules-table-agent-th--gone' : ''),
|
||
title: gone ? a + ' — no longer exists (gone)' : a,
|
||
}, el('div', {}, el('span', {}, a))));
|
||
}
|
||
headerRow.append(el('th', { class: 'schedules-table-actions-th' }, ''));
|
||
thead.append(headerRow);
|
||
return thead;
|
||
}
|
||
function renderScheduleRow(s, agents) {
|
||
const cancelled = !!s.cancelled_at_unix;
|
||
const paused = !cancelled && !!s.paused_at_unix;
|
||
const tr = el('tr', {
|
||
class: 'schedules-table-row'
|
||
+ (cancelled ? ' schedules-table-row-cancelled' : '')
|
||
+ (paused ? ' schedules-table-row-paused' : ''),
|
||
});
|
||
|
||
tr.append(el('td', { class: 'meta schedules-table-id' }, '#' + s.id));
|
||
|
||
const srcKind = s.source && s.source.kind === 'approval' ? 'approval' : 'manual';
|
||
tr.append(el('td', {},
|
||
el('span', { class: 'rqe-source rqe-source-' + srcKind },
|
||
srcKind === 'approval' ? 'approval' : 'operator')));
|
||
|
||
// "next" cell — relative due-in for active schedules, "cancelled"
|
||
// for cancelled ones, "paused" for paused ones. Both carry the
|
||
// absolute ISO in the title.
|
||
const nextCell = el('td', { class: 'meta schedules-table-next-col' });
|
||
if (cancelled) {
|
||
nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix).toISOString();
|
||
nextCell.textContent = 'cancelled';
|
||
} else if (paused) {
|
||
nextCell.title = 'paused since '
|
||
+ new Date(s.paused_at_unix).toISOString()
|
||
+ '\nwould fire at '
|
||
+ new Date(s.next_fire_at_unix).toISOString();
|
||
nextCell.append(
|
||
el('span', { class: 'sched-paused-label' }, '⏸ paused'),
|
||
);
|
||
} else {
|
||
const dueIn = epochSec(s.next_fire_at_unix) - Math.floor(Date.now() / 1000);
|
||
nextCell.title = new Date(s.next_fire_at_unix).toISOString();
|
||
nextCell.textContent = dueIn <= 0
|
||
? 'overdue ' + fmtAgo(s.next_fire_at_unix)
|
||
: fmtDuration(dueIn);
|
||
nextCell.classList.add('sched-due');
|
||
nextCell.dataset.dueAt = String(epochSec(s.next_fire_at_unix));
|
||
}
|
||
tr.append(nextCell);
|
||
|
||
tr.append(el('td', { class: 'meta schedules-table-every-col' },
|
||
s.interval_seconds ? '↻ ' + fmtDuration(s.interval_seconds) : 'one-shot'));
|
||
|
||
tr.append(el('td', { class: 'meta' }, s.owner));
|
||
|
||
// Body cell — truncates with ellipsis; full body + description on hover.
|
||
const bodyCell = el('td', { class: 'schedules-table-body-cell' });
|
||
const bodyText = s.body || '';
|
||
bodyCell.title = (s.description ? s.description + '\n\n' : '') + bodyText;
|
||
bodyCell.textContent = bodyText;
|
||
tr.append(bodyCell);
|
||
|
||
// Per-agent target cells. Three states:
|
||
// - active target → ✓ button that cancels just that target on
|
||
// click (same affordance as the per-row ✕ on the old layout's
|
||
// targets table)
|
||
// - cancelled target → muted ✕ glyph (no button — backend
|
||
// re-add flows through the edit form's targets multi-select)
|
||
// - not a target → empty cell
|
||
const targetByName = new Map();
|
||
for (const t of s.targets || []) targetByName.set(t.target, t);
|
||
for (const a of agents) {
|
||
const t = targetByName.get(a);
|
||
const td = el('td', { class: 'schedules-table-check' });
|
||
if (!t) {
|
||
// empty — no button, no glyph
|
||
} else if (t.cancelled_at_unix) {
|
||
td.title = 'cancelled — '
|
||
+ (t.last_fired_at_unix
|
||
? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago'
|
||
: 'never fired')
|
||
+ (t.last_result ? ' · ' + t.last_result : '');
|
||
td.append(el('span', { class: 'schedules-table-check-cancelled' }, '✕'));
|
||
} else {
|
||
const lastFireDesc = t.last_fired_at_unix
|
||
? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago'
|
||
: 'never fired';
|
||
const lastResultDesc = t.last_result ? ' · ' + t.last_result : '';
|
||
const checkBtn = el('button', {
|
||
type: 'button',
|
||
class: 'schedules-table-check-btn',
|
||
}, '✓');
|
||
checkBtn.title = lastFireDesc + lastResultDesc
|
||
+ (cancelled ? '' : '\nclick to cancel this target');
|
||
if (cancelled) {
|
||
checkBtn.disabled = true;
|
||
} else {
|
||
checkBtn.addEventListener('click', () => cancelScheduleTargets(s.id, [a]));
|
||
}
|
||
td.append(checkBtn);
|
||
}
|
||
tr.append(td);
|
||
}
|
||
|
||
// Actions cell — fire / pause-toggle / edit / cancel-all.
|
||
// Glyph-only to fit a compact column; colour classes carry the
|
||
// visual cue: mauve = fire, teal = pause/resume, yellow = edit,
|
||
// red = cancel.
|
||
const actionsCell = el('td', { class: 'schedules-table-actions' });
|
||
if (!cancelled) {
|
||
const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix);
|
||
const isOneShot = !s.interval_seconds;
|
||
const fireBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-fire-now btn-inline-small',
|
||
}, '↯');
|
||
fireBtn.title = isOneShot
|
||
? 'fire once — one-shot, consumed after the manual fire'
|
||
: 'fire once now — recurring; choose whether to reset the next-fire timer';
|
||
if (!activeTargets.length || paused) {
|
||
fireBtn.disabled = true;
|
||
fireBtn.title = paused
|
||
? 'resume the schedule first to use fire-now'
|
||
: 'every target is cancelled — nothing to fire';
|
||
}
|
||
fireBtn.addEventListener('click', () =>
|
||
fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target), fireBtn));
|
||
actionsCell.append(fireBtn);
|
||
|
||
// Pause / resume toggle — only meaningful for recurring schedules.
|
||
// One-shots can still be paused (to delay a one-time fire), so we
|
||
// show the button for both cases.
|
||
const pauseBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-pause-schedule btn-inline-small' + (paused ? ' btn-resume-schedule' : ''),
|
||
}, paused ? '▶' : '⏸');
|
||
pauseBtn.title = paused ? 'resume schedule' : 'pause schedule';
|
||
pauseBtn.addEventListener('click', () =>
|
||
paused ? resumeSchedule(s.id) : pauseSchedule(s.id));
|
||
actionsCell.append(pauseBtn);
|
||
|
||
const editingThis = editingSchedules.has(s.id);
|
||
const editBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-edit-schedule btn-inline-small',
|
||
}, editingThis ? '✎×' : '✎');
|
||
editBtn.title = editingThis
|
||
? 'close edit'
|
||
: 'edit body / description / interval / next-fire / targets';
|
||
editBtn.addEventListener('click', () => {
|
||
if (editingThis) {
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
} else {
|
||
editingSchedules.add(s.id);
|
||
}
|
||
renderSchedulesList();
|
||
});
|
||
actionsCell.append(editBtn);
|
||
|
||
const cancelBtn = el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-deny btn-inline-small',
|
||
}, '✕');
|
||
cancelBtn.title = 'cancel the whole schedule';
|
||
cancelBtn.addEventListener('click', () => cancelScheduleAll(s.id));
|
||
actionsCell.append(cancelBtn);
|
||
}
|
||
tr.append(actionsCell);
|
||
|
||
return tr;
|
||
}
|
||
function renderScheduleEditRow(s, agents) {
|
||
// colspan = 6 attribute cols + N agent cols + 1 actions col
|
||
const colCount = 7 + agents.length;
|
||
const tr = el('tr', { class: 'schedules-table-edit-row' });
|
||
const td = el('td', { colspan: String(colCount) });
|
||
td.append(renderScheduleEditForm(s));
|
||
tr.append(td);
|
||
return tr;
|
||
}
|
||
|
||
// Inline edit form. Renders inside the schedule row when the
|
||
// row's `✎ edit` button is toggled on. Pre-filled with current
|
||
// values; submit PATCHes /api/schedules/{id}. Targets stay
|
||
// immutable (per damocles's backend; the workaround for retargeting
|
||
// is cancel + new schedule). Mid-edit field values survive a
|
||
// state-poll refresh via `scheduleEditCarry`.
|
||
function renderScheduleEditForm(s) {
|
||
const wrapper = el('div', { class: 'schedule-edit-form-wrapper' });
|
||
const form_ = el('form', { class: 'schedule-edit-form' });
|
||
form_.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
submitEditSchedule(s, form_);
|
||
});
|
||
|
||
const carry = scheduleEditCarry.get(s.id) || {};
|
||
|
||
const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' });
|
||
bodyInput.value = carry.body !== undefined ? carry.body : s.body;
|
||
form_.append(scheduleField('body', bodyInput));
|
||
|
||
const descInput = el('input', { type: 'text', name: 'description' });
|
||
descInput.value = carry.description !== undefined
|
||
? carry.description
|
||
: (s.description || '');
|
||
form_.append(scheduleField('description (blank to clear)', descInput));
|
||
|
||
const firstFireInput = el('input', {
|
||
type: 'datetime-local', name: 'next_fire', required: 'required',
|
||
});
|
||
firstFireInput.value = carry.next_fire !== undefined
|
||
? carry.next_fire
|
||
: isoForDatetimeLocal(new Date(s.next_fire_at_unix));
|
||
form_.append(scheduleField('next fire', firstFireInput));
|
||
|
||
const intervalCx = buildIntervalComposer({
|
||
label: 'interval (blank / all-zero = flip to one-shot)',
|
||
namePrefix: 'edit_interval_',
|
||
initialSeconds: 0,
|
||
});
|
||
form_.append(intervalCx.wrapper);
|
||
if (carry.interval_d !== undefined || carry.interval_h !== undefined
|
||
|| carry.interval_m !== undefined || carry.interval_s !== undefined) {
|
||
intervalCx.setParts({
|
||
d: carry.interval_d || '',
|
||
h: carry.interval_h || '',
|
||
m: carry.interval_m || '',
|
||
s: carry.interval_s || '',
|
||
});
|
||
} else if (s.interval_seconds) {
|
||
intervalCx.fillFromSeconds(s.interval_seconds);
|
||
}
|
||
intervalCx.updatePreview();
|
||
|
||
// Persist carry on every input + checkbox change so a refresh
|
||
// repaint preserves it.
|
||
const saveCarry = () => {
|
||
const fd = new FormData(form_);
|
||
scheduleEditCarry.set(s.id, {
|
||
body: String(fd.get('body') || ''),
|
||
description: String(fd.get('description') || ''),
|
||
next_fire: String(fd.get('next_fire') || ''),
|
||
interval_d: String(fd.get('edit_interval_d') || ''),
|
||
interval_h: String(fd.get('edit_interval_h') || ''),
|
||
interval_m: String(fd.get('edit_interval_m') || ''),
|
||
interval_s: String(fd.get('edit_interval_s') || ''),
|
||
targets: fd.getAll('edit_targets').map(String),
|
||
});
|
||
};
|
||
form_.addEventListener('input', saveCarry);
|
||
form_.addEventListener('change', saveCarry);
|
||
|
||
// Targets multi-select. Shares the chip-box pattern with the
|
||
// new-schedule form via `buildTargetChips`;
|
||
// cancelled tombstones aren't listed (re-adding them flows
|
||
// through `targets_add`, which the backend replace-on-conflict
|
||
// drops the tombstone for). Submit diffs against the original
|
||
// active set to populate `targets_add` / `targets_remove` on the
|
||
// PATCH body.
|
||
const originalActiveTargets = new Set(
|
||
(s.targets || []).filter((t) => !t.cancelled_at_unix).map((t) => t.target),
|
||
);
|
||
form_.append(buildTargetChips({
|
||
idPrefix: 'se-' + s.id + '-',
|
||
fieldName: 'edit_targets',
|
||
checked: carry.targets ? new Set(carry.targets) : originalActiveTargets,
|
||
extraNames: [...originalActiveTargets],
|
||
}));
|
||
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
|
||
're-adding a previously cancelled target drops its history and starts fresh; '
|
||
+ 'unchecking an active target cancels it.'));
|
||
|
||
const actions = el('div', { class: 'schedule-actions' });
|
||
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
|
||
const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel');
|
||
cancelEdit.addEventListener('click', () => {
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
renderSchedulesList();
|
||
});
|
||
actions.append(submit, cancelEdit);
|
||
form_.append(actions);
|
||
|
||
wrapper.append(form_);
|
||
return wrapper;
|
||
}
|
||
|
||
async function submitEditSchedule(originalSchedule, form_) {
|
||
const s = originalSchedule;
|
||
const fd = new FormData(form_);
|
||
const newBody = String(fd.get('body') || '').trim();
|
||
const newDescription = String(fd.get('description') || '').trim();
|
||
const newNextFireStr = String(fd.get('next_fire') || '');
|
||
|
||
if (!newBody) { themedToast('body must be non-empty', { type: 'error' }); return; }
|
||
if (!newNextFireStr) { themedToast('next-fire timestamp is required', { type: 'error' }); return; }
|
||
const newNextFireDate = new Date(newNextFireStr);
|
||
if (Number.isNaN(newNextFireDate.getTime())) {
|
||
themedToast('next-fire is not a valid datetime', { type: 'error' }); return;
|
||
}
|
||
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
|
||
|
||
// Compute interval total from the d/h/m/s fields (composer uses
|
||
// `edit_interval_*` names in this form).
|
||
const intervalTotal = intervalSecondsFromFormData(fd, 'edit_interval_');
|
||
if (Number.isNaN(intervalTotal)) {
|
||
themedToast('interval fields must be non-negative integers', { type: 'error' });
|
||
return;
|
||
}
|
||
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
|
||
|
||
// Compute target diff against the schedule's currently-active set
|
||
// (cancelled tombstones don't count). The backend treats
|
||
// `targets_add` as replace-on-conflict (re-adding a tombstoned
|
||
// target drops its history), so we can just blanket "send the
|
||
// checked list as add, send the unchecked-but-was-active list as
|
||
// remove."
|
||
const newTargets = new Set(fd.getAll('edit_targets').map(String));
|
||
const originalActive = new Set(
|
||
(s.targets || [])
|
||
.filter((t) => !t.cancelled_at_unix)
|
||
.map((t) => t.target),
|
||
);
|
||
if (!newTargets.size) {
|
||
themedToast('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead', { type: 'error' });
|
||
return;
|
||
}
|
||
const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t));
|
||
const targetsRemove = [...originalActive].filter((t) => !newTargets.has(t));
|
||
|
||
// Build the PATCH body. Only include keys for fields that
|
||
// actually changed; explicit `null` clears description / flips
|
||
// recurring→one-shot. `targets_add` / `targets_remove` only
|
||
// populated when non-empty so the wire body stays minimal.
|
||
const patch = {};
|
||
if (newBody !== s.body) patch.body = newBody;
|
||
if (newDescription !== (s.description || '')) {
|
||
patch.description = newDescription || null;
|
||
}
|
||
if (newNextFireUnix !== epochSec(s.next_fire_at_unix)) {
|
||
patch.next_fire_at_unix = newNextFireUnix;
|
||
}
|
||
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
||
patch.interval_seconds = newIntervalSeconds;
|
||
}
|
||
if (targetsAdd.length) patch.targets_add = targetsAdd;
|
||
if (targetsRemove.length) patch.targets_remove = targetsRemove;
|
||
if (!Object.keys(patch).length) {
|
||
// No-op submit. Treat as "close edit form".
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
renderSchedulesList();
|
||
return;
|
||
}
|
||
|
||
const submitBtn = form_.querySelector('button[type="submit"]');
|
||
const doEdit = async () => {
|
||
try {
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
themedToast('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||
return;
|
||
}
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast('edit failed: ' + err, { type: 'error' });
|
||
}
|
||
};
|
||
if (submitBtn) asyncBtn(submitBtn, doEdit);
|
||
else await doEdit();
|
||
}
|
||
async function fireScheduleNow(id, isOneShot, targets, btn) {
|
||
const targetList = targets.length ? targets.join(', ') : '(no active targets)';
|
||
// Recurring schedules get a "reset timer" checkbox (default on): firing
|
||
// now also re-arms the next regular fire to now + interval. One-shot is
|
||
// consumed regardless, so the flag is moot there (no checkbox).
|
||
const checkboxes = isOneShot ? [] : [{
|
||
name: 'reset_timer',
|
||
label: 'reset timer — re-arm next fire to now + interval',
|
||
checked: true,
|
||
}];
|
||
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. '
|
||
+ 'leave "reset timer" on to re-arm the next regular fire to '
|
||
+ 'now + interval; uncheck it to keep the existing cadence.';
|
||
const confirmRes = await themedConfirm({ message: prompt, danger: true, checkboxes });
|
||
if (!confirmRes) return;
|
||
const resetTimer = !!confirmRes.reset_timer;
|
||
// Capture child nodes so we can restore on error, then replace
|
||
// with DOM-built content (textContent + element children rather
|
||
// than innerHTML — 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' },
|
||
body: JSON.stringify({ reset_timer: resetTimer }),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
themedToast('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||
restoreBtn();
|
||
return;
|
||
}
|
||
// Backend returns FireNowReport { ok, failed, missing, one_shot_consumed,
|
||
// timer_reset }. 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'
|
||
: (report.timer_reset ? ' — timer reset' : '');
|
||
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) {
|
||
themedToast('fire-now failed: ' + err, { type: 'error' });
|
||
restoreBtn();
|
||
}
|
||
}
|
||
async function cancelScheduleAll(id) {
|
||
if (!(await themedConfirm({ message: `cancel schedule #${id}? this stops all future fires for every target.`, danger: true }))) return;
|
||
await postScheduleCancel(id, null);
|
||
}
|
||
async function cancelScheduleTargets(id, targets) {
|
||
if (!(await themedConfirm({ message: `cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`, danger: true }))) return;
|
||
await postScheduleCancel(id, targets);
|
||
}
|
||
async function postScheduleCancel(id, targets) {
|
||
try {
|
||
const opts = {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
// Always send a valid JSON body — cancel-all is `{}` (an empty body
|
||
// with a json content-type would 400 a strict extractor). Per-target
|
||
// cancel carries the list.
|
||
body: JSON.stringify(targets ? { targets } : {}),
|
||
};
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
themedToast('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast('cancel failed: ' + err, { type: 'error' });
|
||
}
|
||
}
|
||
|
||
async function pauseSchedule(id) {
|
||
try {
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/pause', {
|
||
method: 'POST',
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
themedToast('pause failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast('pause failed: ' + err, { type: 'error' });
|
||
}
|
||
}
|
||
async function resumeSchedule(id) {
|
||
try {
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/resume', {
|
||
method: 'POST',
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
themedToast('resume failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast('resume failed: ' + err, { type: 'error' });
|
||
}
|
||
}
|
||
|
||
export function applySchedulesChanged(ev) {
|
||
schedulesState = (ev.schedules || []).slice();
|
||
renderSchedulesList();
|
||
}
|