Add drag-and-drop archive upload with progress to the backup page
This commit is contained in:
1 parent
f60339a60f
commit
ac38227dac
1 file changed
+157
-2
@@ -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> — 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 — 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 ? ' — 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 () {
|
||||||
|
|||||||
Reference in new issue
Block a user