novela/containers/novela/templates/backup.html
Ivo Oskamp e4d2e2c636 DB-stored books, full-text search, backup restore, and AO3 scraper
- DB-stored books (Fase 1–6): chapters and images stored in PostgreSQL; grabber writes to DB, EPUB→DB conversion, DB→EPUB export, FTS search page (/search)
- Chapter editor: Monaco editor supports DB-stored books; inline title editing
- Grabber: DB/EPUB storage toggle on Convert page
- Backup: restore from Dropbox snapshot (browse snapshots, restore individual or selected files)
- AO3 scraper: initial implementation
- Changelog: v0.1.2 and v0.1.3 entries added to changelog.py and changelog.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:13:08 +02:00

696 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela - Backup</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
:root {
--ok: #7fbe7f; --warn: #d2b063; --err: #d0674c;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
.main {
margin-left: var(--sidebar);
min-height: 100vh;
padding: 2.6rem 1rem 4rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
@media (max-width: 768px) {
.main { margin-left: 0; padding-top: 4rem; }
}
.title {
width: 100%; max-width: 860px;
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.card {
width: 100%; max-width: 860px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.1rem 1.1rem 1rem;
}
.card-head {
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--accent);
margin-bottom: 0.7rem;
}
.muted { color: var(--text-dim); font-size: 0.85rem; }
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 860px) {
.grid { grid-template-columns: 1fr; }
}
.row {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem 0.7rem;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.k { color: var(--text-dim); font-family: var(--mono); font-size: 0.72rem; }
.v { color: var(--text); font-family: var(--mono); font-size: 0.75rem; text-align: right; word-break: break-word; }
.actions { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.btn {
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
border-radius: 6px;
padding: 0.5rem 0.9rem;
font-family: var(--mono);
font-size: 0.75rem;
cursor: pointer;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { border-color: rgba(255,162,14,0.45); background: rgba(255,162,14,0.12); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.field-label {
display: block;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--text-dim);
margin-bottom: 0.4rem;
}
.field-input {
width: 100%;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
border-radius: 6px;
padding: 0.55rem 0.7rem;
font-family: var(--mono);
font-size: 0.78rem;
margin-bottom: 0.7rem;
}
.status-line { margin-top: 0.7rem; font-family: var(--mono); font-size: 0.74rem; }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.err { color: var(--err); }
table {
width: 100%; border-collapse: collapse;
font-family: var(--mono); font-size: 0.72rem;
}
th, td {
border-bottom: 1px solid var(--border);
padding: 0.5rem 0.25rem;
text-align: left;
vertical-align: top;
}
th { color: var(--text-dim); font-weight: 500; }
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="title">Backup</div>
<section class="card">
<div class="card-head">Dropbox Settings</div>
<!-- Stap 1: App key + secret invullen en auth URL genereren -->
<div id="oauth-step1">
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
Enter your App Key and App Secret from the
<a href="https://www.dropbox.com/developers/apps" target="_blank" style="color:var(--accent);">Dropbox Developer Console</a>.
Then click <strong>Generate Auth URL</strong> and follow the instructions.
</p>
<label class="field-label" for="app-key">App Key</label>
<input class="field-input" id="app-key" type="text" placeholder="fyh6wd677d54ger"
autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"/>
<label class="field-label" for="app-secret">App Secret</label>
<input class="field-input" id="app-secret" type="password" placeholder="App secret"
autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other"/>
<div class="actions">
<button class="btn primary" onclick="oauthPrepare()">Generate Auth URL</button>
</div>
<div class="status-line" id="oauth-step1-status"></div>
</div>
<!-- Stap 2: Auth URL tonen + code invoeren (initieel verborgen) -->
<div id="oauth-step2" style="display:none;margin-top:1rem;border-top:1px solid var(--border);padding-top:1rem;">
<p class="muted" style="margin-top:0;margin-bottom:0.5rem;">
Open the URL below in your browser, log in to Dropbox and approve the app.
Dropbox will then show a code — paste it below.
</p>
<div style="margin-bottom:0.7rem;">
<a id="oauth-auth-url" href="#" target="_blank" style="color:var(--accent);font-family:var(--mono);font-size:0.75rem;word-break:break-all;"></a>
</div>
<label class="field-label" for="oauth-code">Authorization Code</label>
<input class="field-input" id="oauth-code" type="text" placeholder="Paste the code from Dropbox here" autocomplete="off"/>
<div class="actions">
<button class="btn primary" onclick="oauthExchange()">Save &amp; Activate</button>
<button class="btn" onclick="oauthReset()">Cancel</button>
</div>
<div class="status-line" id="oauth-step2-status"></div>
</div>
<!-- Overige instellingen -->
<div style="margin-top:1rem;border-top:1px solid var(--border);padding-top:1rem;">
<label class="field-label" for="dropbox-root">Dropbox Root Path</label>
<input class="field-input" id="dropbox-root" type="text" placeholder="/novela" autocomplete="off"/>
<label class="field-label" for="retention-count">Snapshots To Keep</label>
<input class="field-input" id="retention-count" type="number" min="1" step="1" value="14" autocomplete="off"/>
<label class="field-label" for="schedule-enabled">Schedule Enabled</label>
<select class="field-input" id="schedule-enabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
<label class="field-label" for="schedule-hours">Schedule Interval (hours)</label>
<input class="field-input" id="schedule-hours" type="number" min="1" step="1" value="24" autocomplete="off"/>
<div class="actions">
<button class="btn primary" onclick="saveDropboxToken()">Save Settings</button>
<button class="btn" onclick="clearDropboxToken()">Remove Token</button>
</div>
<div class="status-line" id="dropbox-status"></div>
</div>
</section>
<section class="card">
<div class="card-head">Run</div>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
Use <strong>Dry Run</strong> to validate without uploading (including pg_dump).
</p>
<div class="actions">
<button class="btn" id="btn-dry" onclick="runBackup(true)">Run Dry Backup</button>
<button class="btn primary" id="btn-live" onclick="runBackup(false)">Run Live Backup</button>
<button class="btn" onclick="refreshAll()">Refresh</button>
</div>
<div class="status-line" id="run-result"></div>
</section>
<section class="card">
<div class="card-head">Health</div>
<div class="grid" id="health-grid"></div>
</section>
<section class="card">
<div class="card-head">Latest Status</div>
<div class="grid" id="status-grid"></div>
</section>
<section class="card">
<div class="card-head">History (Last 20)</div>
<div style="overflow:auto;">
<table>
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Files</th>
<th>Bytes</th>
<th>Started</th>
<th>Finished</th>
<th>Error</th>
</tr>
</thead>
<tbody id="history-body"></tbody>
</table>
</div>
</section>
<section class="card">
<div class="card-head">Restore</div>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
Browse a snapshot and restore individual books from Dropbox back to disk.
</p>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
<select class="field-input" id="snapshot-select" style="flex:1;min-width:220px;margin:0;" onchange="onSnapshotChange()">
<option value="">— select snapshot —</option>
</select>
<button class="btn" onclick="loadSnapshots()">Refresh</button>
</div>
<div id="restore-file-panel" style="display:none;">
<input class="field-input" id="restore-search" type="text" placeholder="Filter by filename or path…" oninput="renderRestoreFiles()" style="margin-bottom:0.5rem;"/>
<div class="actions" style="margin-bottom:0.7rem;">
<button class="btn" onclick="selectAllRestoreFiles()">Select all</button>
<button class="btn" onclick="clearRestoreSelection()">Clear</button>
<button class="btn primary" id="btn-restore-selected" onclick="restoreSelected()" disabled>Restore selected</button>
</div>
<div style="overflow:auto;">
<table>
<thead>
<tr>
<th style="width:1.5rem;"></th>
<th style="width:3rem;">Format</th>
<th>Path</th>
<th style="width:5rem;">Size</th>
<th style="width:5rem;">On disk</th>
<th style="width:5rem;"></th>
</tr>
</thead>
<tbody id="restore-file-body"></tbody>
</table>
</div>
</div>
<div class="status-line" id="restore-status"></div>
</section>
</main>
<script src="/static/books.js"></script>
<script>
function rowHtml(k, v) {
return `<div class="row"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`;
}
function fmtStatus(v) {
if (v === true || v === 'success') return 'OK';
if (v === false || v === 'error') return 'FAIL';
return v ?? '-';
}
async function loadHealth() {
const el = document.getElementById('health-grid');
el.innerHTML = rowHtml('Loading', '...');
const r = await fetch('/api/backup/health');
const d = await r.json();
el.innerHTML = [
rowHtml('Dropbox token', d.token_present ? 'present' : 'missing'),
rowHtml('Dropbox auth', fmtStatus(d.dropbox_ok)),
rowHtml('Dropbox error', d.dropbox_error || '-'),
rowHtml('Dropbox root', d.dropbox_root || '/novela'),
rowHtml('Snapshots keep', d.retention_count ?? 14),
rowHtml('Schedule', d.schedule_enabled ? `enabled (${d.schedule_interval_hours || 24}h)` : 'disabled'),
rowHtml('pg_dump', d.pg_dump_available ? (d.pg_dump_path || 'available') : 'missing'),
rowHtml('Library exists', fmtStatus(d.library_exists)),
rowHtml('Library path', d.library_path || '-'),
].join('');
}
async function loadStatus() {
const el = document.getElementById('status-grid');
el.innerHTML = rowHtml('Loading', '...');
const r = await fetch('/api/backup/status');
const d = await r.json();
if (d.status === 'never') {
el.innerHTML = rowHtml('Status', 'Never run');
return;
}
el.innerHTML = [
rowHtml('ID', d.id),
rowHtml('Status', d.status),
rowHtml('Files', d.files_count ?? '-'),
rowHtml('Bytes', d.size_bytes ?? '-'),
rowHtml('Started', d.started_at ?? '-'),
rowHtml('Finished', d.finished_at ?? '-'),
rowHtml('Error', d.error_msg ?? '-'),
].join('');
}
async function loadHistory() {
const body = document.getElementById('history-body');
body.innerHTML = '<tr><td colspan="7">Loading...</td></tr>';
const r = await fetch('/api/backup/history');
const rows = await r.json();
if (!rows.length) {
body.innerHTML = '<tr><td colspan="7">No backup history yet.</td></tr>';
return;
}
body.innerHTML = rows.map((x) => `
<tr>
<td>${esc(x.id)}</td>
<td>${esc(x.status)}</td>
<td>${esc(x.files_count ?? '-')}</td>
<td>${esc(x.size_bytes ?? '-')}</td>
<td>${esc(x.started_at ?? '-')}</td>
<td>${esc(x.finished_at ?? '-')}</td>
<td>${esc(x.error_msg ?? '-')}</td>
</tr>
`).join('');
}
async function loadDropboxSettings() {
const out = document.getElementById('dropbox-status');
const rootEl = document.getElementById('dropbox-root');
const retentionEl = document.getElementById('retention-count');
const scheduleEnabledEl = document.getElementById('schedule-enabled');
const scheduleHoursEl = document.getElementById('schedule-hours');
out.className = 'status-line';
out.textContent = 'Loading Dropbox settings...';
try {
const r = await fetch('/api/backup/credentials');
const d = await r.json();
rootEl.value = d.dropbox_root || '/novela';
retentionEl.value = d.retention_count ?? 14;
scheduleEnabledEl.value = String(!!d.schedule_enabled);
scheduleHoursEl.value = d.schedule_interval_hours ?? 24;
if (d.configured) {
out.className = 'status-line ok';
const mode = d.app_key_configured ? ' • refresh token' : ' • legacy token';
out.textContent = `Configured (${d.token_preview || 'token set'})${mode}${d.updated_at ? ` • updated ${d.updated_at}` : ''}`;
} else {
out.className = 'status-line warn';
out.textContent = 'No Dropbox token configured.';
}
} catch (e) {
out.className = 'status-line err';
out.textContent = `Failed to load settings: ${e}`;
}
}
async function oauthPrepare() {
const out = document.getElementById('oauth-step1-status');
const appKey = (document.getElementById('app-key').value || '').trim();
const appSecret = (document.getElementById('app-secret').value || '').trim();
if (!appKey || !appSecret) {
out.className = 'status-line err';
out.textContent = 'Fill in both App Key and App Secret.';
return;
}
out.className = 'status-line warn';
out.textContent = 'Generating auth URL...';
try {
const r = await fetch('/api/backup/oauth/prepare', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({app_key: appKey, app_secret: appSecret}),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'prepare failed');
document.getElementById('oauth-auth-url').href = d.auth_url;
document.getElementById('oauth-auth-url').textContent = d.auth_url;
document.getElementById('oauth-step2').style.display = '';
out.className = 'status-line ok';
out.textContent = 'Auth URL generated. Open the link above and paste the code below.';
} catch (e) {
out.className = 'status-line err';
out.textContent = `Failed: ${e}`;
}
}
async function oauthExchange() {
const out = document.getElementById('oauth-step2-status');
const code = (document.getElementById('oauth-code').value || '').trim();
if (!code) {
out.className = 'status-line err';
out.textContent = 'Paste the authorization code from Dropbox first.';
return;
}
out.className = 'status-line warn';
out.textContent = 'Exchanging code for refresh token...';
try {
const r = await fetch('/api/backup/oauth/exchange', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code}),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'exchange failed');
out.className = 'status-line ok';
out.textContent = d.message || 'Dropbox connected successfully.';
document.getElementById('oauth-code').value = '';
document.getElementById('oauth-step2').style.display = 'none';
document.getElementById('oauth-step1-status').textContent = '';
await Promise.all([loadDropboxSettings(), loadHealth()]);
} catch (e) {
out.className = 'status-line err';
out.textContent = `Failed: ${e}`;
}
}
function oauthReset() {
document.getElementById('oauth-step2').style.display = 'none';
document.getElementById('oauth-code').value = '';
document.getElementById('oauth-step2-status').textContent = '';
document.getElementById('oauth-step1-status').textContent = '';
}
async function saveDropboxToken() {
const out = document.getElementById('dropbox-status');
const dropboxRoot = (document.getElementById('dropbox-root').value || '').trim();
const retentionCount = Math.max(1, parseInt((document.getElementById('retention-count').value || '14').trim(), 10) || 14);
const scheduleEnabled = document.getElementById('schedule-enabled').value === 'true';
const scheduleIntervalHours = Math.max(1, parseInt((document.getElementById('schedule-hours').value || '24').trim(), 10) || 24);
out.className = 'status-line warn';
out.textContent = 'Saving backup settings...';
try {
const r = await fetch('/api/backup/credentials', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
dropbox_root: dropboxRoot,
retention_count: retentionCount,
schedule_enabled: scheduleEnabled,
schedule_interval_hours: scheduleIntervalHours,
}),
});
const raw = await r.text();
let d;
try { d = JSON.parse(raw); } catch (_) {
throw new Error(`HTTP ${r.status}: ${raw.slice(0, 180) || 'non-JSON response'}`);
}
if (!d.ok) throw new Error(d.error || 'save failed');
out.className = 'status-line ok';
out.textContent = `Settings saved. Root: ${d.dropbox_root || dropboxRoot || '/novela'} • keep: ${d.retention_count || retentionCount} • schedule: ${d.schedule_enabled ? 'on' : 'off'} (${d.schedule_interval_hours || scheduleIntervalHours}h)`;
await Promise.all([loadDropboxSettings(), loadHealth()]);
} catch (e) {
out.className = 'status-line err';
out.textContent = `Save failed: ${e}`;
}
}
async function clearDropboxToken() {
if (!confirm('Remove Dropbox token for backup?')) return;
const out = document.getElementById('dropbox-status');
out.className = 'status-line warn';
out.textContent = 'Removing token...';
try {
await fetch('/api/backup/credentials', {method: 'DELETE'});
out.className = 'status-line ok';
out.textContent = 'Dropbox token removed.';
document.getElementById('app-key').value = '';
document.getElementById('app-secret').value = '';
document.getElementById('dropbox-root').value = '/novela';
document.getElementById('retention-count').value = 14;
document.getElementById('schedule-enabled').value = 'false';
document.getElementById('schedule-hours').value = 24;
await Promise.all([loadDropboxSettings(), loadHealth()]);
} catch (e) {
out.className = 'status-line err';
out.textContent = `Remove failed: ${e}`;
}
}
async function runBackup(dryRun) {
const btnDry = document.getElementById('btn-dry');
const btnLive = document.getElementById('btn-live');
const out = document.getElementById('run-result');
btnDry.disabled = true;
btnLive.disabled = true;
out.className = 'status-line warn';
out.textContent = dryRun ? 'Running dry backup...' : 'Running live backup...';
try {
const r = await fetch('/api/backup/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({dry_run: dryRun}),
});
const d = await r.json();
if (d.ok) {
out.className = 'status-line ok';
if (d.status === 'running') {
out.textContent = `Backup started in background. id=${d.backup_id}, dry_run=${d.dry_run}`;
// Immediately kick off sidebar progress polling
if (typeof loadBackupProgress === 'function') loadBackupProgress();
} else {
out.textContent = `Backup ${d.status}. id=${d.backup_id}, files=${d.files_count}, bytes=${d.size_bytes}, dry_run=${d.dry_run}`;
}
} else {
out.className = 'status-line err';
out.textContent = `Backup failed: ${d.error || 'unknown error'}`;
}
} catch (e) {
out.className = 'status-line err';
out.textContent = `Request failed: ${e}`;
} finally {
btnDry.disabled = false;
btnLive.disabled = false;
await refreshAll();
}
}
async function refreshAll() {
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots()]);
}
// ── Restore ─────────────────────────────────────────────────────────────
let _restoreFiles = [];
async function loadSnapshots() {
const sel = document.getElementById('snapshot-select');
try {
const r = await fetch('/api/backup/snapshots');
const d = await r.json();
if (!d.ok || !d.snapshots.length) {
sel.innerHTML = '<option value="">— no snapshots available —</option>';
return;
}
const current = sel.value;
sel.innerHTML = '<option value="">— select snapshot —</option>' +
d.snapshots.map(s => {
const label = s.created_at
? `${s.name} (${s.created_at.replace('T', ' ').replace('Z', ' UTC')})`
: s.name;
return `<option value="${esc(s.name)}"${s.name === current ? ' selected' : ''}>${esc(label)}</option>`;
}).join('');
} catch (_) {
sel.innerHTML = '<option value="">— Dropbox not configured —</option>';
}
}
async function onSnapshotChange() {
const name = document.getElementById('snapshot-select').value;
const panel = document.getElementById('restore-file-panel');
const status = document.getElementById('restore-status');
if (!name) {
panel.style.display = 'none';
_restoreFiles = [];
status.textContent = '';
return;
}
status.className = 'status-line warn';
status.textContent = 'Loading snapshot files…';
try {
const r = await fetch(`/api/backup/snapshots/${encodeURIComponent(name)}/files`);
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'failed');
_restoreFiles = d.files;
document.getElementById('restore-search').value = '';
panel.style.display = '';
renderRestoreFiles();
status.className = 'status-line ok';
status.textContent = `${d.files.length} file(s) in snapshot.`;
} catch (e) {
status.className = 'status-line err';
status.textContent = `Failed to load snapshot files: ${e}`;
panel.style.display = 'none';
}
}
function fmtBytes(bytes) {
if (!bytes) return '-';
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB';
return bytes + ' B';
}
function renderRestoreFiles() {
const q = (document.getElementById('restore-search').value || '').toLowerCase().trim();
const body = document.getElementById('restore-file-body');
const filtered = q ? _restoreFiles.filter(f => f.path.toLowerCase().includes(q)) : _restoreFiles;
if (!filtered.length) {
body.innerHTML = '<tr><td colspan="6" style="color:var(--text-dim);padding:0.6rem 0.25rem">No files found.</td></tr>';
document.getElementById('btn-restore-selected').disabled = true;
return;
}
body.innerHTML = filtered.map(f => {
const ext = f.path.split('.').pop().toUpperCase();
const parts = f.path.split('/');
const name = parts[parts.length - 1];
const dir = parts.slice(0, -1).join('/');
const onDisk = f.exists_locally
? '<span class="ok" title="File already on disk">&#10003; exists</span>'
: '<span class="warn">missing</span>';
return `<tr>
<td><input type="checkbox" class="restore-chk" data-path="${esc(f.path)}" onchange="updateRestoreBtn()"/></td>
<td><span style="font-family:var(--mono);font-size:0.68rem;color:var(--text-dim)">${esc(ext)}</span></td>
<td><span style="font-size:0.8rem">${esc(name)}</span><br/><span style="font-size:0.68rem;color:var(--text-dim)">${esc(dir)}</span></td>
<td style="white-space:nowrap;font-family:var(--mono);font-size:0.72rem">${esc(fmtBytes(f.size))}</td>
<td style="font-family:var(--mono);font-size:0.72rem">${onDisk}</td>
<td><button class="btn" style="padding:0.3rem 0.6rem" data-path="${esc(f.path)}" onclick="restoreRowBtn(this)">Restore</button></td>
</tr>`;
}).join('');
updateRestoreBtn();
}
function updateRestoreBtn() {
const checked = document.querySelectorAll('.restore-chk:checked').length;
document.getElementById('btn-restore-selected').disabled = checked === 0;
}
function selectAllRestoreFiles() {
document.querySelectorAll('.restore-chk').forEach(el => { el.checked = true; });
updateRestoreBtn();
}
function clearRestoreSelection() {
document.querySelectorAll('.restore-chk').forEach(el => { el.checked = false; });
updateRestoreBtn();
}
function restoreRowBtn(btn) {
const snapshotName = document.getElementById('snapshot-select').value;
_doRestore(snapshotName, [btn.dataset.path]);
}
function restoreSelected() {
const snapshotName = document.getElementById('snapshot-select').value;
const paths = Array.from(document.querySelectorAll('.restore-chk:checked')).map(el => el.dataset.path);
_doRestore(snapshotName, paths);
}
async function _doRestore(snapshotName, paths) {
if (!paths.length) return;
const status = document.getElementById('restore-status');
status.className = 'status-line warn';
status.textContent = `Restoring ${paths.length} file(s)…`;
try {
const r = await fetch('/api/backup/restore', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({snapshot_name: snapshotName, files: paths}),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'failed');
const failed = (d.results || []).filter(x => !x.ok);
if (failed.length) {
status.className = 'status-line warn';
status.textContent = `Restored ${d.restored}/${d.total}. Errors: ${failed.map(x => `${x.path}: ${x.error}`).join(' | ')}`;
} else {
status.className = 'status-line ok';
status.textContent = `Restored ${d.restored}/${d.total} file(s) successfully.`;
}
// Refresh exists_locally state
await onSnapshotChange();
} catch (e) {
status.className = 'status-line err';
status.textContent = `Restore failed: ${e}`;
}
}
refreshAll();
</script>
</body>
</html>