hyperhive/frontend/packages/dashboard/src/schedules.js

1152 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Dashboard SCH3DUL3S tab — reminders + scheduled-prompts.
//
// Reminders (a separate sqlite table, not part of /api/state) and the
// operator's scheduled prompts: the lists, the inline create row, the
// per-row edit form, and the fire-now / cancel actions. Live updates
// arrive via the `reminders_changed` / `schedules_changed` dashboard
// events (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.
import { $, el, appendLinkified } from './common.js';
import { themedConfirm, themedToast } from './modal.js';
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from './util.js';
import { containersState } from './state.js';
export async function refreshReminders() {
const liveRoot = $('reminders-section');
if (!liveRoot) return;
try {
const resp = await fetch('/api/reminders');
if (!resp.ok) {
paintAtomic(liveRoot, (root) => {
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
});
return;
}
const rows = await resp.json();
renderReminders(rows);
} catch (err) {
paintAtomic(liveRoot, (root) => {
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
});
}
}
function renderReminders(rows) {
const liveRoot = $('reminders-section');
if (!liveRoot) return;
paintAtomic(liveRoot, (root) => {
if (!rows.length) {
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
return;
}
const ul = el('ul', { class: 'reminders' });
for (const r of rows) {
const failed = (r.attempt_count || 0) > 0;
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
const dueIn = epochSec(r.due_at) - Math.floor(Date.now() / 1000);
const dueLabel = dueIn <= 0
? `overdue ${fmtAgo(r.due_at)}`
: `in ${fmtDuration(dueIn)}`;
const head = el('div', { class: 'reminder-head' },
el('span', { class: 'agent' }, r.agent), ' ',
el('span', {
class: 'meta reminder-due',
title: new Date(r.due_at).toISOString(),
'data-due-at': String(epochSec(r.due_at)),
}, dueLabel),
' ',
el('span', { class: 'meta' }, `· id ${r.id}`),
);
if (r.file_path) {
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
appendLinkified(head, r.file_path);
}
if (failed) {
head.append(' ', el('span',
{
class: 'badge badge-warn',
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
},
`${r.attempt_count} failed`));
}
const body = el('div', { class: 'reminder-body' });
appendLinkified(body, r.message);
li.append(head, body);
if (r.last_error) {
li.append(el('div', { class: 'reminder-error' },
el('span', { class: 'msg-sep' }, 'error: '),
r.last_error,
));
}
const actions = el('div', { class: 'reminder-actions' });
if (failed) {
// Retry resets the failure counters so the scheduler picks
// the row up again on its next 5s tick. No data-no-refresh
// — the resulting refreshState re-fires refreshReminders.
const retryForm = el('form', {
method: 'POST', action: '/api/retry-reminder/' + r.id,
class: 'inline', 'data-async': '',
});
retryForm.append(el('button',
{ type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
actions.append(retryForm);
}
const cancelForm = el('form', {
method: 'POST', action: '/api/cancel-reminder/' + r.id,
class: 'inline', 'data-async': '',
'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
});
cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
actions.append(cancelForm);
li.append(actions);
ul.append(li);
}
root.append(ul);
});
}
// ─── 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;
const originalLabel = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner">◐</span>';
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' });
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalLabel;
}
}
// 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 originalLabel = submitBtn ? submitBtn.textContent : '';
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; }
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' });
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
}
}
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();
}
export function applyRemindersChanged(ev) {
renderReminders(ev.reminders || []);
}