Add drag-and-drop archive upload with progress to the backup page

This commit is contained in:
jessikitty committed 2026-09-24 20:31:21 +10:00
1 parent f60339a60f
commit ac38227dac
1 file changed
+157 -2
+157 -2
View File
@@ -106,6 +106,47 @@
</div> </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> &mdash; 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 --> <!-- Existing backups -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
@@ -201,8 +242,8 @@
<div class="card-body"> <div class="card-body">
<ol class="mb-2"> <ol class="mb-2">
<li>Create a backup here and download the <code>.zip</code>.</li> <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>Upload it to the new instance with the box above (or copy it into the backup folder).</li>
<li>On the new instance run: <li>Restore it from the table &mdash; 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 <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> flask backup restore lim-backup-....zip --mode fresh</code></pre>
</li> </li>
@@ -354,6 +395,120 @@ flask backup restore lim-backup-....zip --mode fresh</code></pre>
}); });
}); });
// ---- 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 ? ' &mdash; 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 // Verify buttons
document.querySelectorAll('.verify-btn').forEach(function (btn) { document.querySelectorAll('.verify-btn').forEach(function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {