Add notes backup management

This commit is contained in:
Jonathan Sykes
2026-10-14 02:06:33 +08:00
parent efac16ddaa
commit e3821621f3
5 changed files with 204 additions and 3 deletions

View File

@@ -646,6 +646,15 @@
</div>
</section>
<section id="p-backups" class="panel">
<h1 class="page">Backups</h1>
<p class="lead">Daily note snapshots can be inspected, downloaded, and restored. Every restore writes a separate safety snapshot first.</p>
<div class="card"><div class="row"><h2 style="margin:0">Notes snapshots</h2><span class="grow"></span><button class="btn pri" id="backupNow" type="button">Create backup now</button><a class="btn sm" href="/api/admin/backups/export/sqlite-dump">Export SQLite dump</a></div>
<p id="backupStatus" class="msg" role="status" aria-live="polite"></p><div class="scroll"><table class="tbl" id="backupTable"></table></div>
</div>
<div class="card"><h2>Preview and restore</h2><div class="row"><label>Backup date<select class="field" id="backupDate"></select></label><label>Optional song ID<input class="field" id="backupSong" placeholder="Restore one song only" /></label><button class="btn" id="backupPreview" type="button">Preview diff</button><button class="btn dng" id="backupRestore" type="button">Restore</button></div><div id="backupDiff" class="muted"></div></div>
</section>
<!-- ================= ACCESS ================= -->
<section id="p-analytics" class="panel">
<h1 class="page">Server analytics</h1>
@@ -774,12 +783,13 @@
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
backups: '<path d="M4 5h16v15H4zM8 5V3h8v2M8 11h8M8 15h5"/>',
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
'deep-search': '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5M8 10.5h5M10.5 8v5"/>',
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
};
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
let tab = 'editor';
function buildTabs() {
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
@@ -798,6 +808,7 @@
if (name === 'cache') loadCacheHealth();
if (name === 'index') loadIndexHealth();
if (name === 'history') loadRevs();
if (name === 'backups') loadBackups();
if (name === 'peers') loadP2p();
if (name === 'library') loadUploads();
if (name === 'access') loadTokens();
@@ -987,6 +998,43 @@
$('auditPrev').addEventListener('click', () => { auditOffset = Math.max(0, auditOffset - 50); loadAudit(); });
$('auditNext').addEventListener('click', () => { auditOffset += 50; loadAudit(); });
/* ---------------- note backups ---------------- */
async function loadBackups() {
const table = $('backupTable'); table.innerHTML = '<tr><td class="muted">Loading backups…</td></tr>';
try {
const result = await api('/api/admin/backups'); if (!result.ok) throw Error(result.error || 'Backup list unavailable.');
const valid = result.backups.filter(item => item.valid);
$('backupDate').innerHTML = valid.map(item => `<option value="${esc(item.date)}">${esc(item.date)} · ${item.notes} notes · ${bytes(item.bytes)}</option>`).join('');
table.innerHTML = result.backups.length ? `<tr><th>Date</th><th>Size</th><th>Notes</th><th>Integrity</th><th></th></tr>${result.backups.map(item => `<tr><td>${esc(item.date)}</td><td>${bytes(item.bytes)}</td><td>${item.notes}</td><td>${item.valid ? 'Verified' : 'Invalid JSON'}</td><td>${item.valid ? `<a class="btn sm" href="/api/admin/backups/${encodeURIComponent(item.date)}/download">Download</a><button class="btn sm" data-backup-preview="${esc(item.date)}">Preview</button>` : ''}</td></tr>`).join('')}` : '<tr><td class="muted">No daily backups found yet.</td></tr>';
$('backupPreview').disabled = $('backupRestore').disabled = !valid.length;
if (valid.length) await previewBackup();
} catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
}
async function previewBackup() {
const date = $('backupDate').value; if (!date) return;
const result = await api(`/api/admin/backups/${encodeURIComponent(date)}`);
if (!result.ok) { $('backupDiff').textContent = result.error || 'Could not preview backup.'; return; }
const diff = result.diff;
$('backupDiff').innerHTML = `<p>${esc(date)} · ${diff.changed} changed · ${diff.added} only in backup · ${diff.removed} only live</p><ul>${diff.preview.map(item => `<li><code>${esc(item.key)}</code> — ${esc(item.change)}</li>`).join('') || '<li>No differences</li>'}</ul>`;
}
$('backupNow').addEventListener('click', async () => {
$('backupStatus').textContent = 'Writing snapshot…';
const result = await api('/api/admin/backups', { method: 'POST', body: {} });
$('backupStatus').className = `msg ${result.ok ? 'ok' : 'err'}`; $('backupStatus').textContent = result.ok ? `Saved ${result.notes} notes to ${result.date}.` : result.error || 'Backup failed.';
await loadBackups();
});
$('backupDate').addEventListener('change', previewBackup); $('backupPreview').addEventListener('click', previewBackup);
$('backupTable').addEventListener('click', event => { const button = event.target.closest('[data-backup-preview]'); if (button) { $('backupDate').value = button.dataset.backupPreview; previewBackup(); } });
$('backupRestore').addEventListener('click', async () => {
const date = $('backupDate').value, videoId = $('backupSong').value.trim(); if (!date) return;
const scope = videoId ? `song ${videoId}` : 'all shared notes';
if (!confirm(`Restore ${scope} from ${date}? A safety snapshot will be created first.`)) return;
$('backupStatus').textContent = 'Creating safety snapshot and restoring…';
const result = await api(`/api/admin/backups/${encodeURIComponent(date)}/restore`, { method: 'POST', body: videoId ? { videoId } : {} });
$('backupStatus').className = `msg ${result.ok ? 'ok' : 'err'}`; $('backupStatus').textContent = result.ok ? `Restored ${result.restored} note documents. Safety snapshot: ${result.safetySnapshot}` : result.error || 'Restore failed.';
await loadBackups();
});
/* ---------------- lyrics quality queue ---------------- */
let qualityOffset = 0;
async function loadLyricsQuality() {