Files
lego-instructions-manager/app/templates/admin/backup.html
T

396 lines
18 KiB
HTML

{% 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 &amp; 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 %} &mdash; <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 %} &mdash; 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>&hellip; 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&hellip;</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 &middot;
{{ m.counts.instructions }} instructions &middot;
{{ m.counts.extra_files }} extra
<div class="text-muted">
{{ m.uploads.file_count }} files, {{ m.uploads.total_human }}
</div>
{% else %}
<span class="text-muted">&mdash;</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 &mdash; 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> &mdash; 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> &mdash; 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 %}