Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
062e2d3055 | ||
|
|
d4a53d83d3 |
1 changed files with 123 additions and 92 deletions
|
|
@ -41,6 +41,28 @@ 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,
|
||||||
|
|
@ -1827,89 +1849,92 @@ 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 root = $('reminders-section');
|
const liveRoot = $('reminders-section');
|
||||||
if (!root) return;
|
if (!liveRoot) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/reminders');
|
const resp = await fetch('/api/reminders');
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
root.innerHTML = '';
|
paintAtomic(liveRoot, (root) => {
|
||||||
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) {
|
||||||
root.innerHTML = '';
|
paintAtomic(liveRoot, (root) => {
|
||||||
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 root = $('reminders-section');
|
const liveRoot = $('reminders-section');
|
||||||
if (!root) return;
|
if (!liveRoot) return;
|
||||||
root.innerHTML = '';
|
paintAtomic(liveRoot, (root) => {
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
if (failed) {
|
const ul = el('ul', { class: 'reminders' });
|
||||||
head.append(' ', el('span',
|
for (const r of rows) {
|
||||||
{
|
const failed = (r.attempt_count || 0) > 0;
|
||||||
class: 'badge badge-warn',
|
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
|
||||||
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
|
const dueIn = r.due_at - Math.floor(Date.now() / 1000);
|
||||||
},
|
const dueLabel = dueIn <= 0
|
||||||
`⚠ ${r.attempt_count} failed`));
|
? `overdue ${fmtAgo(r.due_at)}`
|
||||||
}
|
: `in ${fmtDuration(dueIn)}`;
|
||||||
const body = el('div', { class: 'reminder-body' });
|
const head = el('div', { class: 'reminder-head' },
|
||||||
appendLinkified(body, r.message);
|
el('span', { class: 'agent' }, r.agent), ' ',
|
||||||
li.append(head, body);
|
el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
|
||||||
if (r.last_error) {
|
' ',
|
||||||
li.append(el('div', { class: 'reminder-error' },
|
el('span', { class: 'meta' }, `· id ${r.id}`),
|
||||||
el('span', { class: 'msg-sep' }, 'error: '),
|
);
|
||||||
r.last_error,
|
if (r.file_path) {
|
||||||
));
|
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
|
||||||
}
|
appendLinkified(head, r.file_path);
|
||||||
const actions = el('div', { class: 'reminder-actions' });
|
}
|
||||||
if (failed) {
|
if (failed) {
|
||||||
// Retry resets the failure counters so the scheduler picks
|
head.append(' ', el('span',
|
||||||
// the row up again on its next 5s tick. No data-no-refresh
|
{
|
||||||
// — the resulting refreshState re-fires refreshReminders.
|
class: 'badge badge-warn',
|
||||||
const retryForm = el('form', {
|
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
|
||||||
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.`,
|
||||||
});
|
});
|
||||||
retryForm.append(el('button',
|
cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
|
||||||
{ type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
|
actions.append(cancelForm);
|
||||||
actions.append(retryForm);
|
li.append(actions);
|
||||||
|
ul.append(li);
|
||||||
}
|
}
|
||||||
const cancelForm = el('form', {
|
root.append(ul);
|
||||||
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';
|
||||||
|
|
@ -1939,14 +1964,16 @@ window.marked = marked;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/schedules');
|
const resp = await fetch('/api/schedules');
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
listRoot.innerHTML = '';
|
paintAtomic(listRoot, (root) => {
|
||||||
listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
|
root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
schedulesState = await resp.json();
|
schedulesState = await resp.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
listRoot.innerHTML = '';
|
paintAtomic(listRoot, (root) => {
|
||||||
listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
|
root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderScheduleNewForm();
|
renderScheduleNewForm();
|
||||||
|
|
@ -2063,13 +2090,15 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderScheduleNewForm() {
|
function renderScheduleNewForm() {
|
||||||
const root = $('schedule-new-section');
|
const liveRoot = $('schedule-new-section');
|
||||||
if (!root) return;
|
if (!liveRoot) 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.
|
||||||
const carry = readScheduleFormCarry(root);
|
// Read from the live root BEFORE the swap so we see current
|
||||||
root.innerHTML = '';
|
// values (the fragment we build into is fresh).
|
||||||
|
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),
|
||||||
|
|
@ -2165,6 +2194,7 @@ window.marked = marked;
|
||||||
form_.append(actions);
|
form_.append(actions);
|
||||||
|
|
||||||
root.append(form_);
|
root.append(form_);
|
||||||
|
}); // paintAtomic
|
||||||
}
|
}
|
||||||
function readScheduleFormCarry(root) {
|
function readScheduleFormCarry(root) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -2249,23 +2279,24 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function renderSchedulesList() {
|
function renderSchedulesList() {
|
||||||
const root = $('schedules-section');
|
const liveRoot = $('schedules-section');
|
||||||
if (!root) return;
|
if (!liveRoot) return;
|
||||||
root.innerHTML = '';
|
paintAtomic(liveRoot, (root) => {
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue