551 lines
26 KiB
HTML
551 lines
26 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 & 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>
|
|
|
|
<!-- Upload -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="bi bi-upload"></i> Upload an archive</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted small">
|
|
Drop a <code>.zip</code> taken from another LIM instance here, then restore it
|
|
from the table below. The file streams straight to
|
|
<code>{{ backup_folder }}</code> — it is not held in memory, and the
|
|
50MB limit on instruction uploads does not apply.
|
|
{% if max_upload %}Ceiling: {{ max_upload }} bytes.{% endif %}
|
|
</p>
|
|
|
|
<div id="dropZone" class="border rounded p-4 text-center mb-3"
|
|
style="border-style: dashed !important; cursor: pointer;">
|
|
<i class="bi bi-file-earmark-zip fs-1 d-block mb-2 text-muted"></i>
|
|
<span class="text-muted">Drop a backup archive here, or click to choose one</span>
|
|
<input type="file" id="archiveInput" accept=".zip,application/zip" hidden>
|
|
</div>
|
|
|
|
<div class="d-flex align-items-center gap-2 d-none" id="chosenWrap">
|
|
<code id="chosenName" class="flex-grow-1"></code>
|
|
<span class="text-muted small" id="chosenSize"></span>
|
|
<button type="button" class="btn btn-sm btn-danger" id="startUpload">
|
|
<i class="bi bi-upload"></i> Upload
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-3 d-none" id="uploadProgressWrap">
|
|
<div class="progress" style="height: 22px;">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
id="uploadBar" role="progressbar" style="width: 0%;">0%</div>
|
|
</div>
|
|
<div class="small text-muted mt-2" id="uploadNote"></div>
|
|
</div>
|
|
|
|
<div class="mt-3 d-none" id="uploadResult"></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>Upload it to the new instance with the box above (or copy it into the backup folder).</li>
|
|
<li>Restore it from the table — or, on a fresh instance with no admin user yet:
|
|
<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;
|
|
});
|
|
});
|
|
|
|
// ---- upload ----------------------------------------------------------
|
|
const dropZone = document.getElementById('dropZone');
|
|
const archiveInput = document.getElementById('archiveInput');
|
|
const chosenWrap = document.getElementById('chosenWrap');
|
|
const chosenName = document.getElementById('chosenName');
|
|
const chosenSize = document.getElementById('chosenSize');
|
|
const startUpload = document.getElementById('startUpload');
|
|
const upWrap = document.getElementById('uploadProgressWrap');
|
|
const upBar = document.getElementById('uploadBar');
|
|
const upNote = document.getElementById('uploadNote');
|
|
const upResult = document.getElementById('uploadResult');
|
|
let chosenFile = null;
|
|
|
|
function humanSize(bytes) {
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let size = bytes, i = 0;
|
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
|
return size.toFixed(1) + ' ' + units[i];
|
|
}
|
|
|
|
function chooseFile(file) {
|
|
if (!file) { return; }
|
|
if (!file.name.toLowerCase().endsWith('.zip')) {
|
|
upResult.innerHTML = '<div class="alert alert-warning mb-0">That is not a .zip archive.</div>';
|
|
upResult.classList.remove('d-none');
|
|
return;
|
|
}
|
|
chosenFile = file;
|
|
chosenName.textContent = file.name;
|
|
chosenSize.textContent = humanSize(file.size);
|
|
chosenWrap.classList.remove('d-none');
|
|
upResult.classList.add('d-none');
|
|
}
|
|
|
|
dropZone.addEventListener('click', () => archiveInput.click());
|
|
archiveInput.addEventListener('change', () => chooseFile(archiveInput.files[0]));
|
|
['dragenter', 'dragover'].forEach(evt =>
|
|
dropZone.addEventListener(evt, e => {
|
|
e.preventDefault();
|
|
dropZone.classList.add('bg-light');
|
|
}));
|
|
['dragleave', 'drop'].forEach(evt =>
|
|
dropZone.addEventListener(evt, e => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove('bg-light');
|
|
}));
|
|
dropZone.addEventListener('drop', e => chooseFile(e.dataTransfer.files[0]));
|
|
|
|
startUpload.addEventListener('click', function () {
|
|
if (!chosenFile) { return; }
|
|
startUpload.disabled = true;
|
|
upWrap.classList.remove('d-none');
|
|
upResult.classList.add('d-none');
|
|
upBar.classList.add('progress-bar-animated');
|
|
upBar.classList.remove('bg-success', 'bg-danger');
|
|
upBar.style.width = '0%';
|
|
upBar.textContent = '0%';
|
|
upNote.textContent = 'Uploading ' + chosenFile.name + '...';
|
|
|
|
// Raw body, not multipart: keeps the whole file out of Flask's
|
|
// form parser (and its 50MB ceiling) and off the temp disk.
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', "{{ url_for('backup.upload') }}?filename=" +
|
|
encodeURIComponent(chosenFile.name));
|
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
|
|
|
xhr.upload.addEventListener('progress', function (e) {
|
|
if (!e.lengthComputable) { return; }
|
|
const pct = Math.round((e.loaded / e.total) * 100);
|
|
upBar.style.width = pct + '%';
|
|
upBar.textContent = pct + '%';
|
|
upNote.textContent = humanSize(e.loaded) + ' of ' + humanSize(e.total);
|
|
});
|
|
|
|
xhr.addEventListener('load', function () {
|
|
upBar.classList.remove('progress-bar-animated');
|
|
startUpload.disabled = false;
|
|
let data = {};
|
|
try { data = JSON.parse(xhr.responseText); } catch (err) { /* below */ }
|
|
|
|
if (xhr.status === 200) {
|
|
upBar.classList.add('bg-success');
|
|
upBar.style.width = '100%';
|
|
upBar.textContent = '100%';
|
|
upNote.textContent = 'Validated as a LIM backup.';
|
|
const c = data.counts || {};
|
|
upResult.innerHTML = '<div class="alert alert-success mb-0">' +
|
|
'<strong>Uploaded:</strong> <code>' + data.filename + '</code> (' +
|
|
data.size_human + ')<br><span class="small">' +
|
|
(c.sets || 0) + ' sets, ' + (c.instructions || 0) + ' instructions, ' +
|
|
(c.extra_files || 0) + ' extra files' +
|
|
(data.source ? ' — from ' + data.source : '') +
|
|
'</span><br><a href="{{ url_for('backup.index') }}" class="alert-link small">' +
|
|
'Reload to restore it</a></div>';
|
|
} else {
|
|
upBar.classList.add('bg-danger');
|
|
upResult.innerHTML = '<div class="alert alert-danger mb-0"><strong>Upload failed:</strong> ' +
|
|
(data.error || ('HTTP ' + xhr.status)) + '</div>';
|
|
}
|
|
upResult.classList.remove('d-none');
|
|
});
|
|
|
|
xhr.addEventListener('error', function () {
|
|
upBar.classList.remove('progress-bar-animated');
|
|
upBar.classList.add('bg-danger');
|
|
startUpload.disabled = false;
|
|
upResult.innerHTML = '<div class="alert alert-danger mb-0">' +
|
|
'Upload failed. If you are behind a reverse proxy, check its max body size.</div>';
|
|
upResult.classList.remove('d-none');
|
|
});
|
|
|
|
xhr.send(chosenFile);
|
|
});
|
|
|
|
// 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 %}
|