novela/containers/novela/templates/backup.html
2026-03-31 20:03:18 +02:00

513 lines
20 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>
</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()]);
}
refreshAll();
</script>
</body>
</html>