'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 }; } })();