Add backup recovery center
This commit is contained in:
@@ -15,6 +15,9 @@ const repairMessage = document.querySelector('#repair-message');
|
||||
const previewRepairButton = document.querySelector('#preview-repair');
|
||||
const applyRepairButton = document.querySelector('#apply-repair');
|
||||
const restoreRepairButton = document.querySelector('#restore-repair');
|
||||
const loadBackupsButton = document.querySelector('#load-backups');
|
||||
const backupSummary = document.querySelector('#backup-summary');
|
||||
const backupList = document.querySelector('#backup-list');
|
||||
let latestAnalysis = null;
|
||||
let selectedDuplicateGroup = null;
|
||||
let previewedRepair = null;
|
||||
@@ -34,6 +37,52 @@ function escapeHtml(value) {
|
||||
}[character]));
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / (1024 ** unit)).toFixed(unit ? 1 : 0)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString([], {dateStyle: 'medium', timeStyle: 'short'});
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
loadBackupsButton.disabled = true;
|
||||
backupSummary.textContent = 'Looking for recovery snapshots…';
|
||||
try {
|
||||
const response = await fetch('/api/backups', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value)})});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Could not load backups');
|
||||
backupSummary.textContent = `${data.total} backup${data.total === 1 ? '' : 's'} · ${formatBytes(data.size_bytes)} on disk`;
|
||||
backupList.innerHTML = data.items.length ? data.items.map((backup) => `
|
||||
<article class="backup-item">
|
||||
<div><span class="backup-status ${backup.status}">${backup.status === 'restored' ? 'Restored' : 'Ready to restore'}</span><strong>${formatDate(backup.created_at)}</strong><small>Kept: ${escapeHtml(backup.keeper)}</small><small>${backup.replaced.length} original file${backup.replaced.length === 1 ? '' : 's'} · ${formatBytes(backup.size_bytes)}</small></div>
|
||||
<button type="button" data-restore-backup="${escapeHtml(backup.path)}" ${backup.status === 'restored' ? 'disabled' : ''}>${backup.status === 'restored' ? 'Already restored' : 'Restore'}</button>
|
||||
</article>
|
||||
`).join('') : '<div class="empty-detail">No repair backups found for this library yet.</div>';
|
||||
} catch (error) {
|
||||
backupSummary.textContent = error.message;
|
||||
backupList.innerHTML = '';
|
||||
} finally { loadBackupsButton.disabled = false; }
|
||||
}
|
||||
|
||||
loadBackupsButton.addEventListener('click', loadBackups);
|
||||
backupList.addEventListener('click', async (event) => {
|
||||
const button = event.target.closest('[data-restore-backup]');
|
||||
if (!button || button.disabled) return;
|
||||
if (!window.confirm('Restore the original duplicate files from this backup? Existing real files will never be overwritten.')) return;
|
||||
button.disabled = true; button.textContent = 'Restoring…';
|
||||
try {
|
||||
const response = await fetch('/api/backups/restore', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value), backup: button.dataset.restoreBackup})});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Restore failed');
|
||||
await loadBackups();
|
||||
} catch (error) { backupSummary.textContent = error.message; button.disabled = false; button.textContent = 'Restore'; }
|
||||
});
|
||||
|
||||
function detailLines(item) {
|
||||
if (item.files) {
|
||||
return item.files.map((file) => `<li>${escapeHtml(file)}</li>`).join('');
|
||||
@@ -127,6 +176,7 @@ applyRepairButton.addEventListener('click', async () => {
|
||||
repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
|
||||
previewRepairButton.disabled = true;
|
||||
restoreRepairButton.hidden = false;
|
||||
await loadBackups();
|
||||
} catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; }
|
||||
});
|
||||
|
||||
@@ -139,6 +189,7 @@ restoreRepairButton.addEventListener('click', async () => {
|
||||
if (!response.ok) throw new Error(result.error || 'Restore failed');
|
||||
repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`;
|
||||
restoreRepairButton.hidden = true;
|
||||
await loadBackups();
|
||||
} catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; }
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Serato Doctor</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<link rel="stylesheet" href="/recovery.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-one"></div>
|
||||
@@ -20,6 +21,7 @@
|
||||
<a class="nav-item active" href="#dashboard"><span>⌁</span> Dashboard</a>
|
||||
<a class="nav-item" href="#scan"><span>◎</span> New analysis</a>
|
||||
<a class="nav-item" href="#diagnostics"><span>◇</span> Diagnostics</a>
|
||||
<a class="nav-item" href="#recovery"><span>↶</span> Recovery</a>
|
||||
</nav>
|
||||
<div class="safety-card">
|
||||
<span class="safety-icon">✓</span>
|
||||
@@ -55,6 +57,13 @@
|
||||
<div id="error-message" class="error" role="alert" hidden></div>
|
||||
</section>
|
||||
|
||||
<section id="recovery" class="recovery-panel panel">
|
||||
<div class="section-heading"><div><p class="eyebrow">Safety net</p><h2>Backup recovery</h2></div><button id="load-backups" type="button">Check backups</button></div>
|
||||
<p>Review every repair snapshot saved for this Serato library. Older backups remain available after restarting Serato Doctor.</p>
|
||||
<div id="backup-summary" class="backup-summary">Enter your Serato folder above, then check backups.</div>
|
||||
<div id="backup-list" class="backup-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="dashboard" class="results" aria-live="polite" hidden>
|
||||
<div class="section-heading"><div><p class="eyebrow">Latest analysis</p><h2>Library health</h2></div><span id="analysis-time"></span></div>
|
||||
<div class="hero-grid">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.recovery-panel{margin-top:18px;padding:25px}.recovery-panel>p{color:var(--muted);font-size:12px;line-height:1.6}.recovery-panel .section-heading button{border:1px solid rgba(155,135,245,.35);border-radius:10px;padding:9px 12px;background:rgba(155,135,245,.12);color:#ddd8fa;font:700 11px inherit;cursor:pointer}.recovery-panel button:disabled{opacity:.5;cursor:not-allowed}.backup-summary{padding:11px 13px;border-radius:11px;background:rgba(255,255,255,.03);color:#bfc4cf;font-size:11px;margin:15px 0 10px}.backup-list{display:grid;gap:9px}.backup-item{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:14px;border:1px solid var(--line);border-radius:13px;background:rgba(255,255,255,.025)}.backup-item>div{min-width:0}.backup-item strong,.backup-item small{display:block}.backup-item strong{font-size:12px;margin:7px 0}.backup-item small{font-size:10px;color:var(--muted);line-height:1.5;overflow-wrap:anywhere}.backup-item>button{flex:0 0 auto;border:0;border-radius:10px;padding:10px 13px;background:#6f5bd0;color:#fff;font:700 11px inherit;cursor:pointer}.backup-status{display:inline-block;padding:4px 7px;border-radius:999px;font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--cyan);background:rgba(102,217,232,.1)}.backup-status.restored{color:#aeb4c2;background:rgba(255,255,255,.06)}@media(max-width:520px){.backup-item{align-items:stretch;flex-direction:column}.backup-item>button{width:100%}}
|
||||
Reference in New Issue
Block a user