513 lines
20 KiB
HTML
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 & 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>
|