Add backup/restore scripts and update web/admin UI
This commit is contained in:
+289
-25
@@ -70,8 +70,91 @@
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- ── Backup Manager ──────────────────────────────────────────── -->
|
||||
<div class="col-md-8">
|
||||
<div class="card h-100" style="border:2px solid #198754">
|
||||
<div class="card-header" style="background:#198754;color:#fff">
|
||||
<h5 class="mb-0"><i class="fas fa-archive me-2"></i>Backup Manager</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3 gap-3 flex-wrap">
|
||||
<button class="btn btn-success" id="btn-run-backup" onclick="runBackup()">
|
||||
<i class="fas fa-play me-2"></i>Run Backup Now
|
||||
</button>
|
||||
<span class="small" id="backup-status-msg"></span>
|
||||
</div>
|
||||
<hr class="my-2">
|
||||
<h6 class="text-muted mb-2"><i class="fas fa-list me-1"></i>Existing Backups</h6>
|
||||
<div id="backup-list-container">
|
||||
<div class="text-center text-muted py-3">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Loading…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Restore / Migrate ───────────────────────────────────────── -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100" style="border:2px solid #0d6efd">
|
||||
<div class="card-header" style="background:#0d6efd;color:#fff">
|
||||
<h5 class="mb-0"><i class="fas fa-upload me-2"></i>Restore / Migrate</h5>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<p class="text-muted small mb-3">
|
||||
Restore devices, Ansible inventory & config from a backup archive.
|
||||
Use <strong>Upload</strong> to migrate to a new server.
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small mb-1">Source</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="restore-source"
|
||||
id="src-existing" value="existing" checked onchange="toggleRestoreSource()">
|
||||
<label class="form-check-label small" for="src-existing">Select existing backup</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="restore-source"
|
||||
id="src-upload" value="upload" onchange="toggleRestoreSource()">
|
||||
<label class="form-check-label small" for="src-upload">Upload backup file (.tar.gz)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="restore-existing-picker" class="mb-3">
|
||||
<select class="form-select form-select-sm" id="restore-existing-select">
|
||||
<option value="">— loading… —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="restore-upload-picker" class="mb-3" style="display:none">
|
||||
<input type="file" class="form-control form-control-sm"
|
||||
id="restore-file-input" accept=".tar.gz">
|
||||
<div class="form-text">Only .tar.gz archives produced by this system.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small mb-1">Restore Scope</label>
|
||||
<select class="form-select form-select-sm" id="restore-mode">
|
||||
<option value="all">Full restore (DB + Ansible + Config)</option>
|
||||
<option value="db-only">Database only (devices, groups, WMT config)</option>
|
||||
<option value="ansible-only">Ansible only (inventory, playbooks, SSH keys)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning py-2 small mb-3">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>
|
||||
A safety copy of the current DB is created automatically before restore.
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary mt-auto w-100" id="btn-restore" onclick="runRestore()">
|
||||
<i class="fas fa-undo me-2"></i>Restore Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Log Entries -->
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card warning-card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-stream me-2"></i>Clear Log Entries</h5>
|
||||
@@ -93,31 +176,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Device Logs -->
|
||||
<div class="col-md-3">
|
||||
<div class="card warning-card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-file-alt me-2"></i>Clear Device Logs</h5>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<p class="text-muted flex-grow-1">
|
||||
Deletes <strong>all log entries</strong> from the database.
|
||||
Registered devices are <strong>not affected</strong> and will continue logging automatically.
|
||||
</p>
|
||||
<div class="alert alert-warning py-2 mb-3">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>
|
||||
Currently <strong id="badge-logs2">{{ stats.get('logs', '?') }}</strong> log entries.
|
||||
</div>
|
||||
<button class="btn btn-warning w-100"
|
||||
onclick="runAction('clear-device-logs', 'Delete ALL device log entries? Devices stay registered. This cannot be undone.')">
|
||||
<i class="fas fa-trash me-2"></i>Clear Device Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registered Device Registry -->
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-8">
|
||||
<div class="card danger-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-server me-2"></i>Registered Device Registry</h5>
|
||||
@@ -215,6 +275,23 @@
|
||||
|
||||
</div><!-- /container -->
|
||||
|
||||
<!-- Output Modal (backup / restore log) -->
|
||||
<div class="modal fade" id="outputModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="outputModalTitle">Output</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-2">
|
||||
<pre id="outputModalBody"
|
||||
class="bg-dark text-light p-3 rounded mb-0"
|
||||
style="max-height:420px;overflow-y:auto;font-size:.78rem;white-space:pre-wrap;word-break:break-all;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result toast -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999">
|
||||
<div id="resultToast" class="toast align-items-center text-white border-0" role="alert" aria-live="assertive">
|
||||
@@ -310,5 +387,192 @@ function refreshStats() {
|
||||
// Reload the page stats after a short delay to let DB settle
|
||||
setTimeout(() => location.reload(), 800);
|
||||
}
|
||||
|
||||
// ── Backup Manager ──────────────────────────────────────────────────────────
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function showOutputModal(title, content) {
|
||||
document.getElementById('outputModalTitle').textContent = title;
|
||||
document.getElementById('outputModalBody').textContent = content || '(no output)';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('outputModal')).show();
|
||||
}
|
||||
|
||||
function loadBackupList() {
|
||||
fetch('/admin/backup/list')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
renderBackupList(data.success ? data.backups : []);
|
||||
const sel = document.getElementById('restore-existing-select');
|
||||
if (data.success && data.backups.length) {
|
||||
sel.innerHTML = data.backups
|
||||
.map(b => `<option value="${escHtml(b.name)}">${escHtml(b.name)} (${escHtml(b.size_human)})</option>`)
|
||||
.join('');
|
||||
} else {
|
||||
sel.innerHTML = '<option value="">— no backups found —</option>';
|
||||
}
|
||||
})
|
||||
.catch(() => renderBackupList(null));
|
||||
}
|
||||
|
||||
function renderBackupList(backups) {
|
||||
const el = document.getElementById('backup-list-container');
|
||||
if (!backups) {
|
||||
el.innerHTML = '<div class="text-danger small">Failed to load backup list.</div>';
|
||||
return;
|
||||
}
|
||||
if (backups.length === 0) {
|
||||
el.innerHTML = `<div class="text-muted small text-center py-3">
|
||||
<i class="fas fa-inbox fa-2x d-block mb-2"></i>
|
||||
No backups yet. Click "Run Backup Now" to create one.</div>`;
|
||||
return;
|
||||
}
|
||||
const rows = backups.map(b => `
|
||||
<tr>
|
||||
<td class="small font-monospace text-break">${escHtml(b.name)}</td>
|
||||
<td class="small text-nowrap">${escHtml(b.mtime)}</td>
|
||||
<td class="small text-end text-nowrap">${escHtml(b.size_human)}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="/admin/backup/download/${encodeURIComponent(b.name)}"
|
||||
class="btn btn-sm btn-outline-secondary me-1" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete"
|
||||
onclick="deleteBackup('${escHtml(b.name)}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
el.innerHTML = `
|
||||
<div class="table-responsive" style="max-height:220px;overflow-y:auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>Archive</th>
|
||||
<th class="text-nowrap">Created</th>
|
||||
<th class="text-end">Size</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function runBackup() {
|
||||
if (!confirm('Run a full backup now?')) return;
|
||||
const btn = document.getElementById('btn-run-backup');
|
||||
const msg = document.getElementById('backup-status-msg');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Running…';
|
||||
msg.textContent = '';
|
||||
|
||||
fetch('/admin/backup/run', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
msg.className = 'text-success small';
|
||||
msg.textContent = data.archive ? '✓ Created: ' + data.archive : '✓ Backup completed.';
|
||||
loadBackupList();
|
||||
showOutputModal('Backup Output', data.output);
|
||||
} else {
|
||||
msg.className = 'text-danger small';
|
||||
msg.textContent = 'Backup failed — see details';
|
||||
showOutputModal('Backup Error', data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
msg.className = 'text-danger small';
|
||||
msg.textContent = 'Network error: ' + err;
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-play me-2"></i>Run Backup Now';
|
||||
});
|
||||
}
|
||||
|
||||
function deleteBackup(name) {
|
||||
if (!confirm('Delete backup "' + name + '"?\nThis cannot be undone.')) return;
|
||||
fetch('/admin/backup/delete/' + encodeURIComponent(name), {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadBackupList();
|
||||
showToast('success', 'Deleted: ' + data.deleted);
|
||||
} else {
|
||||
showToast('danger', 'Error: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('danger', 'Network error: ' + err));
|
||||
}
|
||||
|
||||
function toggleRestoreSource() {
|
||||
const val = document.querySelector('input[name="restore-source"]:checked').value;
|
||||
document.getElementById('restore-existing-picker').style.display = val === 'existing' ? '' : 'none';
|
||||
document.getElementById('restore-upload-picker').style.display = val === 'upload' ? '' : 'none';
|
||||
}
|
||||
|
||||
function runRestore() {
|
||||
const source = document.querySelector('input[name="restore-source"]:checked').value;
|
||||
const mode = document.getElementById('restore-mode').value;
|
||||
|
||||
if (!confirm('Restore from backup (scope: ' + mode + ')?\n\n' +
|
||||
'This will overwrite the current database / Ansible files.\n' +
|
||||
'A safety copy of the current DB is created automatically.')) return;
|
||||
|
||||
const btn = document.getElementById('btn-restore');
|
||||
btn.disabled = true;
|
||||
const origLabel = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Restoring…';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('mode', mode);
|
||||
|
||||
if (source === 'existing') {
|
||||
const name = document.getElementById('restore-existing-select').value;
|
||||
if (!name) {
|
||||
showToast('danger', 'Select a backup first.');
|
||||
btn.disabled = false; btn.innerHTML = origLabel; return;
|
||||
}
|
||||
fd.append('existing_backup', name);
|
||||
} else {
|
||||
const file = document.getElementById('restore-file-input').files[0];
|
||||
if (!file) {
|
||||
showToast('danger', 'Select a .tar.gz file to upload.');
|
||||
btn.disabled = false; btn.innerHTML = origLabel; return;
|
||||
}
|
||||
fd.append('backup_file', file);
|
||||
}
|
||||
|
||||
fetch('/admin/backup/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: fd
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', 'Restore completed successfully.');
|
||||
showOutputModal('Restore Output', data.output);
|
||||
} else {
|
||||
showOutputModal('Restore Error', data.error);
|
||||
showToast('danger', 'Restore failed — see output');
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('danger', 'Network error: ' + err))
|
||||
.finally(() => { btn.disabled = false; btn.innerHTML = origLabel; });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadBackupList);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user