Add built-in backup of the data folder (BL-0044)

The reader owns a scheduled backup into BACKUP (Compose: BACKUP_PATH mounted at
/backup, off when unset). A run pauses the scraper queue through its control
API, waits (bounded) until no job is active, writes a verified tar.gz of DATA
with db.json from memory and scraper/queue.json stored paused, excludes
runtime/lock/pid/temp files, resumes only a queue it paused, then applies
count- or day-based retention to its own archive names only. Schedule,
retention, "Back up now" and last result live in Settings > Service health.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-22 18:03:21 +02:00
1 parent fcc50d576c
commit 086f6353f8
15 files changed
+904 -11

No files matched your search

+54 -2
View File
@@ -4,10 +4,13 @@
try { me = await api('api/me'); } catch (e) { return showError(e); }
topNav(me);
if (!me.admin) return showError(new Error('Administrator access required.'));
const main = settingsShell(me, 'admin/health').content;
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'} · Sentinel: ${health.sentinel.status} · Scraper: ${scraper.status || 'unavailable'}`),
@@ -28,7 +31,56 @@
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 : 10000);
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 };
}
})();