Add Backup and Restore admin page
This commit is contained in:
1 parent
ca3232c515
commit
b16106db7d
1 file changed
+395
@@ -0,0 +1,395 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Backup & Restore - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-1"><i class="bi bi-archive"></i> Backup & Restore</h1>
|
||||
<p class="text-muted mb-0">Everything needed to rebuild this library elsewhere: database rows, PDFs, images, covers, thumbnails and extra files.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- What will be captured -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-1">Uploaded files</h6>
|
||||
<h3 class="mb-0">{{ upload_file_count }}</h3>
|
||||
<small class="text-muted">{{ uploads_human }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stored backups</h6>
|
||||
<h3 class="mb-0">{{ backups|length }}</h3>
|
||||
<small class="text-muted">{{ backup_total_human }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card h-100 {% if missing %}border-warning{% endif %}">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-1">Missing files</h6>
|
||||
<h3 class="mb-0 {% if missing %}text-warning{% endif %}">{{ missing|length }}</h3>
|
||||
<small class="text-muted">referenced but not on disk</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-1">Orphaned files</h6>
|
||||
<h3 class="mb-0">{{ orphaned|length }}</h3>
|
||||
<small class="text-muted">on disk, no record</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body small text-muted">
|
||||
<div><strong>Database:</strong> {{ dialect }}{% if sqlite_path %} — <code>{{ sqlite_path }}</code>{% endif %}</div>
|
||||
<div><strong>Uploads folder:</strong> <code>{{ uploads_root }}</code></div>
|
||||
<div><strong>Backup folder:</strong> <code>{{ backup_folder }}</code>{% if keep %} — keeping the newest {{ keep }}{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if missing %}
|
||||
<div class="alert alert-warning">
|
||||
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle"></i> {{ missing|length }} file(s) referenced in the database are missing from disk</h6>
|
||||
<p class="mb-2 small">These records will survive the migration but their file will be gone. Worth fixing before you cut over.</p>
|
||||
<details>
|
||||
<summary class="small">Show paths</summary>
|
||||
<ul class="small mb-0 mt-2">
|
||||
{% for path in missing[:50] %}<li><code>{{ path }}</code></li>{% endfor %}
|
||||
{% if missing|length > 50 %}<li>… and {{ missing|length - 50 }} more</li>{% endif %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Create -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-download"></i> Create a backup</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label for="backupLabel" class="form-label">Label <span class="text-muted">(optional)</span></label>
|
||||
<input type="text" class="form-control" id="backupLabel" placeholder="e.g. pre-docker">
|
||||
<div class="form-text">Appended to the filename so you can find it later.</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<button type="button" class="btn btn-danger" id="startBackup">
|
||||
<i class="bi bi-play-fill"></i> Start backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-none" id="progressWrap">
|
||||
<div class="progress" style="height: 22px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated bg-danger"
|
||||
id="progressBar" role="progressbar" style="width: 0%;">0%</div>
|
||||
</div>
|
||||
<div class="small text-muted mt-2" id="progressNote">Starting…</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-none" id="backupResult"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing backups -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-hdd-stack"></i> Stored backups</h5>
|
||||
<a href="{{ url_for('backup.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if backups %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Archive</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Contents</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in backups %}
|
||||
{% set m = entry.manifest or {} %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ entry.filename }}</code>
|
||||
{% if not entry.valid %}
|
||||
<span class="badge bg-secondary">unknown format</span>
|
||||
{% endif %}
|
||||
{% if m.created_by %}<div class="small text-muted">by {{ m.created_by }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="small">{{ entry.modified.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td class="small">{{ entry.size_human }}</td>
|
||||
<td class="small">
|
||||
{% if m.counts %}
|
||||
{{ m.counts.sets }} sets ·
|
||||
{{ m.counts.instructions }} instructions ·
|
||||
{{ m.counts.extra_files }} extra
|
||||
<div class="text-muted">
|
||||
{{ m.uploads.file_count }} files, {{ m.uploads.total_human }}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="{{ url_for('backup.download', filename=entry.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary verify-btn"
|
||||
data-file="{{ entry.filename }}" title="Verify checksums">
|
||||
<i class="bi bi-shield-check"></i>
|
||||
</button>
|
||||
{% if entry.valid %}
|
||||
<button class="btn btn-sm btn-outline-warning"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#restoreModal"
|
||||
data-file="{{ entry.filename }}"
|
||||
title="Restore">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<form method="POST" class="d-inline"
|
||||
action="{{ url_for('backup.delete', filename=entry.filename) }}"
|
||||
onsubmit="return confirm('Delete {{ entry.filename }}?');">
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="d-none" id="verify-{{ loop.index }}"><td colspan="5"></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-muted">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
No backups yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Migration help -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-box-seam"></i> Moving to Docker</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="mb-2">
|
||||
<li>Create a backup here and download the <code>.zip</code>.</li>
|
||||
<li>Copy it into the new instance's backup folder (or bind-mount it).</li>
|
||||
<li>On the new instance run:
|
||||
<pre class="bg-light p-2 mt-2 mb-2"><code>flask backup verify lim-backup-....zip
|
||||
flask backup restore lim-backup-....zip --mode fresh</code></pre>
|
||||
</li>
|
||||
<li>Log in with your existing credentials — password hashes come across intact.</li>
|
||||
</ol>
|
||||
<p class="small text-muted mb-0">
|
||||
Stored paths are rewritten to forward slashes during restore, so a Windows-made
|
||||
backup lands correctly on Linux. See <code>docs/BACKUP_AND_MIGRATION.md</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-muted small">Created by: Jess Rogerson (yelling commands at Claude.AI)</p>
|
||||
|
||||
<!-- Restore modal -->
|
||||
<div class="modal fade" id="restoreModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" id="restoreForm">
|
||||
<div class="modal-header bg-warning">
|
||||
<h5 class="modal-title"><i class="bi bi-exclamation-triangle"></i> Restore backup</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-3">Restoring <code id="restoreFilename"></code> into <strong>this</strong> instance.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Mode</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="mode" value="merge" id="modeMerge" checked>
|
||||
<label class="form-check-label" for="modeMerge">
|
||||
<strong>Merge</strong> — keep what's here. Users are matched by username,
|
||||
sets by set number; sets that already exist are skipped whole.
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="radio" name="mode" value="fresh" id="modeFresh">
|
||||
<label class="form-check-label" for="modeFresh">
|
||||
<strong>Fresh</strong> — restore into an empty database, preserving all IDs.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="force" id="forceWipe">
|
||||
<label class="form-check-label text-danger" for="forceWipe">
|
||||
Fresh mode: wipe existing rows first (destructive)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="overwrite_files" id="overwriteFiles">
|
||||
<label class="form-check-label" for="overwriteFiles">
|
||||
Overwrite upload files that already exist on disk
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label for="confirmText" class="form-label">Type <code>RESTORE</code> to confirm</label>
|
||||
<input type="text" class="form-control" name="confirm" id="confirmText" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Restore</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
const startBtn = document.getElementById('startBackup');
|
||||
const wrap = document.getElementById('progressWrap');
|
||||
const bar = document.getElementById('progressBar');
|
||||
const note = document.getElementById('progressNote');
|
||||
const resultBox = document.getElementById('backupResult');
|
||||
|
||||
function poll(jobId) {
|
||||
fetch("{{ url_for('backup.status', job_id='JOB') }}".replace('JOB', jobId))
|
||||
.then(r => r.json())
|
||||
.then(job => {
|
||||
if (job.error) { throw new Error(job.error); }
|
||||
|
||||
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
|
||||
bar.style.width = pct + '%';
|
||||
bar.textContent = pct + '%';
|
||||
note.textContent = (job.stage || '') + ': ' + (job.note || '');
|
||||
|
||||
if (job.state === 'complete') {
|
||||
bar.classList.remove('progress-bar-animated');
|
||||
bar.style.width = '100%';
|
||||
bar.textContent = '100%';
|
||||
const r = job.result;
|
||||
let html = '<div class="alert alert-success mb-0">' +
|
||||
'<strong>Backup complete:</strong> <code>' + r.filename + '</code> (' + r.size_human + ')<br>' +
|
||||
'<span class="small">' + r.counts.sets + ' sets, ' + r.counts.instructions +
|
||||
' instructions, ' + r.counts.extra_files + ' extra files, ' +
|
||||
r.uploads.file_count + ' uploaded files (' + r.uploads.total_human + ')</span>';
|
||||
if (r.integrity.missing_count) {
|
||||
html += '<br><span class="small text-danger">' + r.integrity.missing_count +
|
||||
' referenced file(s) were missing on disk.</span>';
|
||||
}
|
||||
html += ' <a href="{{ url_for('backup.index') }}" class="alert-link small">Reload page</a></div>';
|
||||
resultBox.innerHTML = html;
|
||||
resultBox.classList.remove('d-none');
|
||||
startBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.state === 'failed') {
|
||||
bar.classList.remove('progress-bar-animated');
|
||||
bar.classList.replace('bg-danger', 'bg-dark');
|
||||
resultBox.innerHTML = '<div class="alert alert-danger mb-0"><strong>Backup failed:</strong> ' +
|
||||
job.error + '</div>';
|
||||
resultBox.classList.remove('d-none');
|
||||
startBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => poll(jobId), 1000);
|
||||
})
|
||||
.catch(err => {
|
||||
resultBox.innerHTML = '<div class="alert alert-danger mb-0">' + err.message + '</div>';
|
||||
resultBox.classList.remove('d-none');
|
||||
startBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', function () {
|
||||
startBtn.disabled = true;
|
||||
resultBox.classList.add('d-none');
|
||||
wrap.classList.remove('d-none');
|
||||
bar.classList.add('progress-bar-animated');
|
||||
bar.style.width = '0%';
|
||||
bar.textContent = '0%';
|
||||
note.textContent = 'Starting...';
|
||||
|
||||
const body = new FormData();
|
||||
body.append('label', document.getElementById('backupLabel').value);
|
||||
|
||||
fetch("{{ url_for('backup.create') }}", { method: 'POST', body: body })
|
||||
.then(r => r.json())
|
||||
.then(data => poll(data.job_id))
|
||||
.catch(err => {
|
||||
resultBox.innerHTML = '<div class="alert alert-danger mb-0">' + err.message + '</div>';
|
||||
resultBox.classList.remove('d-none');
|
||||
startBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Verify buttons
|
||||
document.querySelectorAll('.verify-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const file = btn.dataset.file;
|
||||
const row = btn.closest('tr').nextElementSibling;
|
||||
const cell = row.querySelector('td');
|
||||
row.classList.remove('d-none');
|
||||
cell.innerHTML = '<span class="small text-muted">Verifying ' + file + '...</span>';
|
||||
|
||||
fetch("{{ url_for('backup.verify', filename='FILE') }}".replace('FILE', encodeURIComponent(file)))
|
||||
.then(r => r.json())
|
||||
.then(report => {
|
||||
if (report.ok) {
|
||||
cell.innerHTML = '<span class="small text-success"><i class="bi bi-check-circle"></i> ' +
|
||||
report.checked + ' entries verified, archive intact.</span>';
|
||||
} else {
|
||||
const problems = [].concat(report.failed || [], report.missing || [], report.errors || []);
|
||||
cell.innerHTML = '<span class="small text-danger"><i class="bi bi-x-circle"></i> Verification failed: ' +
|
||||
problems.join(', ') + '</span>';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
cell.innerHTML = '<span class="small text-danger">' + err.message + '</span>';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Restore modal wiring
|
||||
const restoreModal = document.getElementById('restoreModal');
|
||||
restoreModal.addEventListener('show.bs.modal', function (event) {
|
||||
const file = event.relatedTarget.dataset.file;
|
||||
document.getElementById('restoreFilename').textContent = file;
|
||||
document.getElementById('restoreForm').action =
|
||||
"{{ url_for('backup.restore', filename='FILE') }}".replace('FILE', encodeURIComponent(file));
|
||||
document.getElementById('confirmText').value = '';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user