feat: Add admin DB backup and restore functionality

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-21 20:08:58 +00:00
co-authored by mathieumoran1
parent beaeaf57dd
commit 448c28caee
6 changed files with 826 additions and 2 deletions
+70
View File
@@ -80,6 +80,11 @@
Films
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
Backup / Restore
</button>
</li>
</ul>
<div class="tab-content pt-3">
@@ -317,6 +322,71 @@
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-backup" role="tabpanel" aria-labelledby="admin-backup-tab">
<div class="row g-3">
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1">Backup</h4>
<p class="text-muted mb-3">
Crée une sauvegarde de la base MongoDB (format <code>.ndjson.gz</code>) et conserve-la sur le serveur.
</p>
<div class="d-flex flex-wrap gap-2">
<button type="button" id="db-backup-create" class="btn btn-warning">Créer une sauvegarde</button>
<button type="button" id="db-backup-refresh" class="btn btn-outline-primary">Rafraîchir la liste</button>
</div>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Fichier</th>
<th class="text-nowrap" style="width: 1%;">Taille</th>
<th class="text-nowrap" style="width: 1%;">Modifié</th>
<th class="text-nowrap text-end" style="width: 1%;">Actions</th>
</tr>
</thead>
<tbody id="db-backup-list">
<tr><td colspan="4" class="text-muted">Ouvre l’onglet pour charger la liste…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1 text-danger">Restore</h4>
<p class="text-muted mb-3">
Restaure une sauvegarde (fichier <code>.ndjson.gz</code>). Option recommandée: <strong>drop</strong> pour supprimer les collections avant import.
</p>
<div class="alert alert-warning">
La restauration peut <strong>écraser</strong> les données. Fais un backup juste avant si besoin.
</div>
<div class="mb-3">
<label for="db-restore-file" class="form-label">Fichier de backup</label>
<input class="form-control" type="file" id="db-restore-file" accept=".gz,.ndjson.gz,application/gzip">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="db-restore-drop" checked>
<label class="form-check-label" for="db-restore-drop">
Drop les collections avant restore (recommandé)
</label>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="db-restore-run" class="btn btn-danger">Restaurer</button>
</div>
<div id="db-restore-result" class="small text-muted mt-3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
+246
View File
@@ -218,6 +218,18 @@ window.onload = async function () {
const adminUsersCountEl = document.getElementById('admin-users-count');
const adminUsersTabBtn = document.getElementById('admin-users-tab');
// Admin DB backup/restore elements
const adminBackupTabBtn = document.getElementById('admin-backup-tab');
const dbBackupCreateBtn = document.getElementById('db-backup-create');
const dbBackupRefreshBtn = document.getElementById('db-backup-refresh');
const dbBackupListBody = document.getElementById('db-backup-list');
const dbRestoreFileEl = document.getElementById('db-restore-file');
const dbRestoreDropEl = document.getElementById('db-restore-drop');
const dbRestoreRunBtn = document.getElementById('db-restore-run');
const dbRestoreResultEl = document.getElementById('db-restore-result');
let backupsLoadedOnce = false;
// Admin: add user form elements
const openAddUserModalBtn = document.getElementById('open-add-user-modal');
const addUserModalEl = document.getElementById('addUserModal');
@@ -755,6 +767,240 @@ window.onload = async function () {
adminUsersTabBtn.addEventListener('shown.bs.tab', () => loadAdminUsers({ force: false }));
}
function formatBytes(n) {
const v = Number(n);
if (!Number.isFinite(v) || v < 0) return '—';
if (v < 1024) return `${v} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let x = v / 1024;
let idx = 0;
while (x >= 1024 && idx < units.length - 1) {
x /= 1024;
idx += 1;
}
return `${x.toFixed(x >= 10 ? 1 : 2)} ${units[idx]}`;
}
function formatDateTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString('fr-FR', { hour12: false });
}
async function fetchBackups() {
const res = await fetch('/api/admin/db/backups', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
return Array.isArray(data.backups) ? data.backups : [];
}
function renderBackups(backups) {
if (!dbBackupListBody) return;
const list = Array.isArray(backups) ? backups : [];
if (!list.length) {
dbBackupListBody.innerHTML = '<tr><td colspan="4" class="text-muted">Aucune sauvegarde.</td></tr>';
return;
}
dbBackupListBody.innerHTML = '';
list.forEach((b) => {
const name = String(b?.name || '');
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="font-monospace">${name || '—'}</td>
<td class="text-nowrap">${formatBytes(b?.sizeBytes)}</td>
<td class="text-nowrap">${formatDateTime(b?.mtime)}</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-backup-download="${name}">Télécharger</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-backup-delete="${name}">Supprimer</button>
</td>
`;
dbBackupListBody.appendChild(tr);
});
dbBackupListBody.querySelectorAll('button[data-backup-download]').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.getAttribute('data-backup-download');
if (!name) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
const res = await fetch(`/api/admin/db/backups/${encodeURIComponent(name)}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
});
dbBackupListBody.querySelectorAll('button[data-backup-delete]').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.getAttribute('data-backup-delete');
if (!name) return;
const ok = window.confirm(`Supprimer la sauvegarde "${name}" ?`);
if (!ok) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
const res = await fetch(`/api/admin/db/backups/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
showResponse('success', data.message || 'Sauvegarde supprimée.');
await loadBackups({ force: true });
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
});
}
async function loadBackups(options = {}) {
const force = !!options.force;
if (!dbBackupListBody) return;
if (backupsLoadedOnce && !force) return;
dbBackupListBody.innerHTML = '<tr><td colspan="4" class="text-muted">Chargement…</td></tr>';
try {
const backups = await fetchBackups();
backupsLoadedOnce = true;
renderBackups(backups);
} catch (err) {
dbBackupListBody.innerHTML = `<tr><td colspan="4" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
}
}
async function createBackup() {
if (!dbBackupCreateBtn) return;
dbBackupCreateBtn.disabled = true;
const old = dbBackupCreateBtn.textContent;
dbBackupCreateBtn.textContent = 'Création...';
try {
const res = await fetch('/api/admin/db/backup', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
showResponse('success', `Sauvegarde créée: ${data?.backup?.name || 'OK'}`);
await loadBackups({ force: true });
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
dbBackupCreateBtn.disabled = false;
dbBackupCreateBtn.textContent = old;
}
}
async function runRestore() {
if (!dbRestoreRunBtn || !dbRestoreFileEl) return;
const file = dbRestoreFileEl.files && dbRestoreFileEl.files[0];
if (!file) {
showResponse('warning', 'Choisis un fichier de sauvegarde (.ndjson.gz).');
return;
}
const drop = !!dbRestoreDropEl?.checked;
const warning = drop
? 'RESTORE avec DROP: toutes les collections restaurées seront remplacées.'
: 'RESTORE sans DROP: risque élevé de doublons/erreurs si la DB contient déjà des données.';
const ok = window.confirm(`${warning}\n\nContinuer ?`);
if (!ok) return;
dbRestoreRunBtn.disabled = true;
const old = dbRestoreRunBtn.textContent;
dbRestoreRunBtn.textContent = 'Restauration...';
if (dbRestoreResultEl) dbRestoreResultEl.textContent = '';
try {
const fd = new FormData();
fd.append('backup', file);
// drop passed as query (server supports both)
const res = await fetch(`/api/admin/db/restore?drop=${drop ? 'true' : 'false'}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: fd,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
showResponse('success', 'Restore terminé.');
if (dbRestoreResultEl) {
const inserted = data?.inserted && typeof data.inserted === 'object' ? data.inserted : {};
const keys = Object.keys(inserted);
const summary = keys.length
? keys.map((k) => `${k}: ${inserted[k]}`).join(' | ')
: 'Aucun document inséré (ou backup vide).';
dbRestoreResultEl.textContent = summary;
}
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
dbRestoreRunBtn.disabled = false;
dbRestoreRunBtn.textContent = old;
}
}
if (adminBackupTabBtn) {
adminBackupTabBtn.addEventListener('shown.bs.tab', () => loadBackups({ force: false }));
}
if (dbBackupRefreshBtn) {
dbBackupRefreshBtn.addEventListener('click', async () => {
dbBackupRefreshBtn.disabled = true;
const old = dbBackupRefreshBtn.textContent;
dbBackupRefreshBtn.textContent = 'Chargement...';
try {
await loadBackups({ force: true });
} finally {
dbBackupRefreshBtn.disabled = false;
dbBackupRefreshBtn.textContent = old;
}
});
}
if (dbBackupCreateBtn) {
dbBackupCreateBtn.addEventListener('click', createBackup);
}
if (dbRestoreRunBtn) {
dbRestoreRunBtn.addEventListener('click', runRestore);
}
form.addEventListener('submit', async function (e) {
e.preventDefault();