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:
1 parent
fcc50d576c
commit
086f6353f8
15 files changed
+904
-11
No files matched your search
+54
-2
@@ -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 };
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user