Compare commits

..

View file

@ -41,28 +41,6 @@ window.marked = marked;
// Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406). // Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406).
// #464 — atomic-swap render helper. Each managed section's render
// function used to do `root.innerHTML = ''; root.append(...);` in
// sequence; even though both operations sit in the same JS turn,
// operators could still see a "blink" on every poll cycle because
// (a) on async paths the await yield gave the browser a paint
// opportunity, and (b) complex builds with many `el()` allocations
// can blow the browser's per-task budget enough for layout to flash
// empty before the new children land.
//
// The fix: build the new content off-DOM into a `DocumentFragment`,
// then move it into the live root in a single `replaceChildren`
// call. The browser never sees an intermediate empty state. Builder
// callbacks receive the fragment as their `root` argument, so each
// renderer's existing `root.append(...)` code carries over with
// zero internal changes. Early-return inside the builder is fine —
// the commit still happens with whatever the builder appended.
function paintAtomic(liveRoot, build) {
const buf = document.createDocumentFragment();
build(buf);
liveRoot.replaceChildren(buf);
}
// Side panel singleton (Panel) moved to ./common.js (#406). // Side panel singleton (Panel) moved to ./common.js (#406).
// Path linkification + file-preview side panel (openFilePanel, // Path linkification + file-preview side panel (openFilePanel,
@ -1849,92 +1827,89 @@ window.marked = marked;
// agents themselves and the operator already sees them next time // agents themselves and the operator already sees them next time
// they interact with the page. // they interact with the page.
async function refreshReminders() { async function refreshReminders() {
const liveRoot = $('reminders-section'); const root = $('reminders-section');
if (!liveRoot) return; if (!root) return;
try { try {
const resp = await fetch('/api/reminders'); const resp = await fetch('/api/reminders');
if (!resp.ok) { if (!resp.ok) {
paintAtomic(liveRoot, (root) => { root.innerHTML = '';
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status)); root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
});
return; return;
} }
const rows = await resp.json(); const rows = await resp.json();
renderReminders(rows); renderReminders(rows);
} catch (err) { } catch (err) {
paintAtomic(liveRoot, (root) => { root.innerHTML = '';
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err)); root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
});
} }
} }
function renderReminders(rows) { function renderReminders(rows) {
const liveRoot = $('reminders-section'); const root = $('reminders-section');
if (!liveRoot) return; if (!root) return;
paintAtomic(liveRoot, (root) => { root.innerHTML = '';
if (!rows.length) { if (!rows.length) {
root.append(el('p', { class: 'empty' }, 'no queued reminders')); root.append(el('p', { class: 'empty' }, 'no queued reminders'));
return; 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', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
' ',
el('span', { class: 'meta' }, `· id ${r.id}`),
);
if (r.file_path) {
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
appendLinkified(head, r.file_path);
} }
const ul = el('ul', { class: 'reminders' }); if (failed) {
for (const r of rows) { head.append(' ', el('span',
const failed = (r.attempt_count || 0) > 0; {
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') }); class: 'badge badge-warn',
const dueIn = r.due_at - Math.floor(Date.now() / 1000); title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
const dueLabel = dueIn <= 0 },
? `overdue ${fmtAgo(r.due_at)}` `${r.attempt_count} failed`));
: `in ${fmtDuration(dueIn)}`; }
const head = el('div', { class: 'reminder-head' }, const body = el('div', { class: 'reminder-body' });
el('span', { class: 'agent' }, r.agent), ' ', appendLinkified(body, r.message);
el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel), li.append(head, body);
' ', if (r.last_error) {
el('span', { class: 'meta' }, `· id ${r.id}`), li.append(el('div', { class: 'reminder-error' },
); el('span', { class: 'msg-sep' }, 'error: '),
if (r.file_path) { r.last_error,
head.append(' ', el('span', { class: 'meta' }, '· payload → ')); ));
appendLinkified(head, r.file_path); }
} const actions = el('div', { class: 'reminder-actions' });
if (failed) { if (failed) {
head.append(' ', el('span', // Retry resets the failure counters so the scheduler picks
{ // the row up again on its next 5s tick. No data-no-refresh
class: 'badge badge-warn', // — the resulting refreshState re-fires refreshReminders.
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)', const retryForm = el('form', {
}, method: 'POST', action: '/retry-reminder/' + r.id,
`${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': '', 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')); retryForm.append(el('button',
actions.append(cancelForm); { type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
li.append(actions); actions.append(retryForm);
ul.append(li);
} }
root.append(ul); 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);
} }
function fmtDuration(secs) { function fmtDuration(secs) {
if (secs < 60) return secs + 's'; if (secs < 60) return secs + 's';
@ -1964,16 +1939,14 @@ window.marked = marked;
try { try {
const resp = await fetch('/api/schedules'); const resp = await fetch('/api/schedules');
if (!resp.ok) { if (!resp.ok) {
paintAtomic(listRoot, (root) => { listRoot.innerHTML = '';
root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status)); listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
});
return; return;
} }
schedulesState = await resp.json(); schedulesState = await resp.json();
} catch (err) { } catch (err) {
paintAtomic(listRoot, (root) => { listRoot.innerHTML = '';
root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err)); listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
});
return; return;
} }
renderScheduleNewForm(); renderScheduleNewForm();
@ -2090,15 +2063,13 @@ window.marked = marked;
} }
function renderScheduleNewForm() { function renderScheduleNewForm() {
const liveRoot = $('schedule-new-section'); const root = $('schedule-new-section');
if (!liveRoot) return; if (!root) return;
// Preserve any field the operator was mid-typing in by reading // Preserve any field the operator was mid-typing in by reading
// current values before we re-render. Only `body` + `description` // current values before we re-render. Only `body` + `description`
// are big enough to feel — the others are toggle/datetime/number. // are big enough to feel — the others are toggle/datetime/number.
// Read from the live root BEFORE the swap so we see current const carry = readScheduleFormCarry(root);
// values (the fragment we build into is fresh). root.innerHTML = '';
const carry = readScheduleFormCarry(liveRoot);
paintAtomic(liveRoot, (root) => {
// Targets multi-select pulls from the live containers list. The // Targets multi-select pulls from the live containers list. The
// backend accepts any string (forward-compat for richer recipients), // backend accepts any string (forward-compat for richer recipients),
@ -2194,7 +2165,6 @@ window.marked = marked;
form_.append(actions); form_.append(actions);
root.append(form_); root.append(form_);
}); // paintAtomic
} }
function readScheduleFormCarry(root) { function readScheduleFormCarry(root) {
return { return {
@ -2279,24 +2249,23 @@ window.marked = marked;
} }
} }
function renderSchedulesList() { function renderSchedulesList() {
const liveRoot = $('schedules-section'); const root = $('schedules-section');
if (!liveRoot) return; if (!root) return;
paintAtomic(liveRoot, (root) => { root.innerHTML = '';
if (!schedulesState.length) { if (!schedulesState.length) {
root.append(el('p', { class: 'empty' }, 'no schedules queued')); root.append(el('p', { class: 'empty' }, 'no schedules queued'));
return; return;
} }
const ul = el('ul', { class: 'schedules' }); const ul = el('ul', { class: 'schedules' });
// Active schedules first (still firing), then cancelled tail. // Active schedules first (still firing), then cancelled tail.
const sorted = schedulesState.slice().sort((a, b) => { const sorted = schedulesState.slice().sort((a, b) => {
const aDone = a.cancelled_at_unix ? 1 : 0; const aDone = a.cancelled_at_unix ? 1 : 0;
const bDone = b.cancelled_at_unix ? 1 : 0; const bDone = b.cancelled_at_unix ? 1 : 0;
if (aDone !== bDone) return aDone - bDone; if (aDone !== bDone) return aDone - bDone;
return a.next_fire_at_unix - b.next_fire_at_unix; return a.next_fire_at_unix - b.next_fire_at_unix;
});
for (const s of sorted) ul.append(renderScheduleCard(s));
root.append(ul);
}); });
for (const s of sorted) ul.append(renderScheduleCard(s));
root.append(ul);
} }
function renderScheduleCard(s) { function renderScheduleCard(s) {
const cancelled = !!s.cancelled_at_unix; const cancelled = !!s.cancelled_at_unix;