The stall watchdog now runs inside the scraper (ScraperService.checkStalls, every 30 s from the work loop): same rule as before, one checkpoint-safe restart, quarantine on the second stall, missing checkpoint is no evidence, and paused time no longer counts. Stalls are recorded as scraper incidents (failure.kind 'stall'). The private /api/recovery endpoint is gone. Dropped with the service: reader/scraper-unreachable incidents (supervisor and Docker handle crashes; the health page shows "Scraper: unreachable"), duplicate job-failure incidents and the unused /metrics endpoint. The stack is now reader + scraper (+ optional peer-gateway). The supervisor test moved to runtime/test (npm run test:runtime stays). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
6.6 KiB
JavaScript
86 lines
6.6 KiB
JavaScript
'use strict';
|
|
(async () => {
|
|
let me;
|
|
try { me = await api('api/me'); } catch (e) { return showError(e); }
|
|
topNav(me);
|
|
if (!me.admin) return showError(new Error('Administrator access required.'));
|
|
const shell = settingsShell(me, 'admin/health').content;
|
|
const main = h('div'), backup = backupCard();
|
|
shell.append(main, backup.card);
|
|
async function load() {
|
|
try {
|
|
const [health, scraper] = await Promise.all([api('api/admin/health'), api('api/admin/scraper')]);
|
|
backup.draw(health.backup);
|
|
main.textContent = '';
|
|
main.append(h('section', { class: 'card' },
|
|
h('p', {}, `Reader: ${health.reader.ok ? 'healthy' : 'scan error'} · Scraper: ${scraper.status || 'unreachable'}`),
|
|
h('a', { class: 'btn', href: 'settings.html#admin/scraper' }, 'Queue & hoster settings')));
|
|
for (const entry of scraper.active || []) main.append(h('section', { class: 'card' }, h('h2', {}, entry.source),
|
|
h('p', {}, `${entry.current.title} · ${entry.job?.completed || 0}/${entry.job?.total || '?'} chapters`),
|
|
h('p', { class: 'muted' }, `Checkpoint: ${entry.job?.updated || 'catalog discovery'}`)));
|
|
const failures = (scraper.queue || []).filter(item => ['error', 'quarantined', 'unavailable', 'retry_wait'].includes(item.status));
|
|
main.append(h('section', { class: 'card' }, h('h2', {}, 'Jobs needing attention'), failures.length ? failures.map(item => h('div', { class: 'collection-editor' },
|
|
h('p', {}, `${item.title} · ${item.status} · ${item.failure?.kind || 'unknown'}`), h('p', { class: 'muted' }, item.error || ''),
|
|
item.nextAttemptAt ? h('p', {}, `Retry scheduled: ${new Date(item.nextAttemptAt).toLocaleString()}`) : null,
|
|
h('div', { class: 'row' }, h('button', { class: 'btn', onclick: async () => {
|
|
if (item.failure?.kind === 'access' && !confirm('Has the source access problem been resolved? This manually authorizes one retry.')) return;
|
|
await api('api/admin/scraper', { command: 'control', action: item.status === 'unavailable' ? 'inspect' : 'retry', id: item.id }); load();
|
|
} }, item.status === 'unavailable' ? 'Inspect source' : 'Retry'), h('button', { class: 'btn', onclick: async () => { await api('api/admin/scraper', { command: 'control', action: 'skip', id: item.id }); load(); } }, 'Skip job')))) : h('p', { class: 'muted' }, 'No failed jobs.')));
|
|
const incidents = scraper.incidents || [];
|
|
main.append(h('details', { class: 'card', open: incidents.some(i => i.state === 'open') }, h('summary', {}, 'Incident & recovery history'), incidents.slice(-50).map(i =>
|
|
h('p', {}, `${i.at} · ${i.state} · ${i.message || i.failure?.message || ''}${i.resolution ? ' · ' + i.resolution : ''}`))));
|
|
} catch (e) { main.textContent = ''; main.append(h('p', { class: 'error', role: 'alert' }, e.message)); }
|
|
setTimeout(load, document.hidden ? 30000 : backup.busy() ? 2000 : 10000);
|
|
}
|
|
load();
|
|
|
|
// Built-in backup: status redraws on every poll; the schedule form keeps its draft.
|
|
function backupCard() {
|
|
const mb = n => (n / 1048576).toFixed(n < 10485760 ? 1 : 0) + ' MB';
|
|
const secs = ms => ms < 60000 ? Math.round(ms / 1000) + ' s' : Math.round(ms / 60000) + ' min';
|
|
const status = h('div'), msg = h('p', { class: 'small', role: 'status' });
|
|
const enabled = h('input', { type: 'checkbox' }), time = h('input', { type: 'time', required: true });
|
|
const mode = h('select', {}, h('option', { value: 'count' }, 'backups'), h('option', { value: 'days' }, 'days'));
|
|
const keep = h('input', { type: 'number', min: '1', step: '1', style: 'width:6em' });
|
|
let state = null, filled = false;
|
|
const fill = s => { enabled.checked = s.enabled; time.value = s.time; mode.value = s.retention.mode; keep.value = s.retention.value; filled = true; };
|
|
const command = async (body, button) => {
|
|
button.disabled = true; msg.textContent = ''; msg.className = 'small';
|
|
try { const next = await api('api/admin/backup', body); if (body.command === 'settings') { fill(next.settings); msg.textContent = 'Saved.'; } draw(next); }
|
|
catch (e) { msg.textContent = e.message; msg.className = 'small error'; }
|
|
finally { button.disabled = false; }
|
|
};
|
|
const runButton = h('button', { class: 'btn primary', onclick: e => command({ command: 'run' }, e.target) }, 'Back up now');
|
|
const saveButton = h('button', { class: 'btn', onclick: e => command({ command: 'settings', settings: {
|
|
enabled: enabled.checked, time: time.value, retention: { mode: mode.value, value: Number(keep.value) } } }, e.target) }, 'Save schedule');
|
|
const form = h('div', { class: 'field' }, h('span', {}, 'Schedule'),
|
|
h('div', { class: 'row' }, h('label', { class: 'row' }, enabled, 'Back up daily at'), time),
|
|
h('div', { class: 'row', style: 'margin-top:8px' }, h('span', {}, 'Keep the newest'), keep, mode),
|
|
h('div', { class: 'row', style: 'margin-top:8px' }, saveButton, runButton), msg);
|
|
const card = h('section', { class: 'card', id: 'backup' }, h('h2', {}, 'Backups'), status, form);
|
|
function draw(next) {
|
|
if (!next) return;
|
|
state = next;
|
|
if (!filled) fill(next.settings);
|
|
const last = next.last, lines = [];
|
|
if (!next.configured) lines.push(h('p', { class: 'muted' }, 'Backups are off: mount a folder and set BACKUP_PATH (Compose) or BACKUP (native). See docs/backup.md.'));
|
|
else {
|
|
lines.push(h('p', {}, `Target: ${next.path} · ${next.archives.count} archive${next.archives.count === 1 ? '' : 's'}, ${mb(next.archives.bytes)}`));
|
|
if (next.targetError) lines.push(h('p', { class: 'error' }, next.targetError));
|
|
}
|
|
if (next.running) lines.push(h('p', { class: 'tag' }, `Running (${next.running.trigger}): ${next.running.phase}…`));
|
|
if (last) {
|
|
lines.push(h('p', {}, `Last backup: ${new Date(last.at).toLocaleString()} (${last.trigger})`,
|
|
last.error ? '' : ` · ${mb(last.size)} · ${secs(last.durationMs || 0)} · ${last.file}`));
|
|
if (last.error) lines.push(h('p', { class: 'error', role: 'alert' }, `Failed: ${last.error}`));
|
|
for (const warning of last.warnings || []) lines.push(h('p', { class: 'small error' }, warning));
|
|
} else if (next.configured) lines.push(h('p', { class: 'muted' }, 'No backup yet.'));
|
|
lines.push(h('p', { class: 'muted small' }, next.nextAt ? `Next scheduled backup: ${new Date(next.nextAt).toLocaleString()}` : 'No scheduled backup.'));
|
|
status.textContent = ''; status.append(...lines);
|
|
runButton.disabled = !next.configured || !!next.running;
|
|
form.hidden = !next.configured && !next.settings.enabled;
|
|
}
|
|
return { card, draw, busy: () => !!state?.running };
|
|
}
|
|
})();
|