diff --git a/frontend/packages/dashboard/src/schedules.js b/frontend/packages/dashboard/src/schedules.js new file mode 100644 index 00000000..7b2eabb3 --- /dev/null +++ b/frontend/packages/dashboard/src/schedules.js @@ -0,0 +1,1074 @@ +// 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 } from './common.js'; +import { paintAtomic, 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 = 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 * 1000).toISOString(), + 'data-due-at': String(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: '/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: '/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 — ``. 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 `