patchcenter/app/templates/snapshots.html
Admin MPCZ 0a3fde36b7 feat(snapshots): overlay plein page pendant action async + beforeunload guard
- Overlay 'busy-overlay' plein ecran (z-index 9999) avec spinner anime
- Bloque toute interaction (clics) pendant recherche/suppression
- setBusy active l'overlay + change le texte du bouton
- clearBusy retire l'overlay
- beforeunload: avertit l'utilisateur s'il tente de quitter la page
  pendant une requete en vol (close tab, navigation, refresh)
2026-05-07 20:40:13 +02:00

340 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'base.html' %}
{% block title %}Snapshots VM{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Gestion des snapshots VM</h2>
<p class="text-xs text-gray-500 mt-1">
Liste les snapshots existants sur les vCenters configurés. Filtre par auteur (préfixe nom = intervenant) et âge.
La suppression est définitive — confirmation requise.
</p>
</div>
</div>
<style>
.ss-row.dim { opacity: 0.4; }
.ss-row.selected { background: rgba(245,158,11,.1); }
.badge-age-old { background: rgba(239,68,68,.20); color: #ef4444; border: 1px solid #ef4444; }
.badge-age-warn { background: rgba(245,158,11,.20); color: #f59e0b; border: 1px solid #f59e0b; }
.badge-age-fresh { background: rgba(34,197,94,.20); color: #22c55e; border: 1px solid #22c55e; }
.filters-card label { display: flex; align-items: center; gap: 0.5rem; }
/* Overlay plein page pendant les actions async */
#busy-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(10,14,23,0.75); backdrop-filter: blur(2px);
display: none; align-items: center; justify-content: center;
flex-direction: column; gap: 1rem;
}
#busy-overlay.active { display: flex; }
.spinner {
width: 64px; height: 64px;
border: 4px solid rgba(245,158,11,0.2);
border-top-color: #f59e0b;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#busy-text { color: #f59e0b; font-size: 1.05rem; font-weight: 600; text-shadow: 0 0 12px rgba(245,158,11,0.6); }
#busy-sub { color: #9ca3af; font-size: 0.85rem; }
</style>
<div id="busy-overlay">
<div class="spinner"></div>
<div id="busy-text">Action en cours…</div>
<div id="busy-sub">Ne ferme pas la page, ne navigue pas ailleurs.</div>
</div>
<div class="card p-4 mb-4 filters-card">
<div class="grid grid-cols-12 gap-3 items-end">
<div class="col-span-3">
<label class="text-xs text-gray-500">vCenter</label>
<select id="f-vcenter" class="w-full">
<option value="">— Tous les vCenters actifs —</option>
{% for vc in vcenters %}
<option value="{{ vc.id }}">{{ vc.name }} ({{ vc.endpoint }})</option>
{% endfor %}
</select>
</div>
<div class="col-span-3">
<label class="text-xs text-gray-500">Auteur (préfixe nom)</label>
<input type="text" id="f-author" value="{{ intervenant_default }}" placeholder="ex: khalid" class="w-full">
<label class="text-xs text-gray-400 mt-1"><input type="checkbox" id="f-only-mine" checked> Mes snapshots uniquement</label>
<label class="text-xs text-gray-400 mt-1"><input type="checkbox" id="f-pc-format" checked> Format PatchCenter uniquement (<code>&lt;auteur&gt;_YYYY-MM-DD_avant_patch</code>)</label>
</div>
<div class="col-span-3">
<label class="text-xs text-gray-500">Âge minimum (jours)</label>
<input type="number" id="f-min-age" value="3" min="0" class="w-full">
</div>
<div class="col-span-3 flex gap-2 items-end">
<button id="btn-refresh" class="btn-action btn-pre" type="button" style="
padding: 6px 14px; font-size: 0.8rem; font-weight: 700;
border-radius: 6px; cursor: pointer; border: 1px solid #f59e0b;
background: rgba(245,158,11,.18); color: #f59e0b;
box-shadow: 0 0 8px #f59e0b; text-transform: uppercase;">
⟳ Charger / Rafraîchir
</button>
{% if can_delete %}
<button id="btn-delete" type="button" style="
padding: 6px 14px; font-size: 0.8rem; font-weight: 700;
border-radius: 6px; cursor: pointer; border: 1px solid #ef4444;
background: rgba(239,68,68,.18); color: #ef4444;
box-shadow: 0 0 8px #ef4444; text-transform: uppercase;
opacity: 0.5;">
✕ Supprimer la sélection
</button>
{% endif %}
</div>
</div>
</div>
<div id="status" class="mb-3 text-xs text-gray-400">Cliquer "Charger" pour scanner les vCenters.</div>
<div class="card overflow-hidden">
<table class="w-full text-xs" id="snap-table">
<thead class="text-cyber-accent border-b border-cyber-border">
<tr>
<th class="p-2 text-left"><input type="checkbox" id="sel-all"></th>
<th class="p-2 text-left">vCenter</th>
<th class="p-2 text-left">VM</th>
<th class="p-2 text-left">Snapshot</th>
<th class="p-2 text-left">Auteur</th>
<th class="p-2 text-left">Créé le</th>
<th class="p-2 text-left">Âge</th>
<th class="p-2 text-left">Description</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<script>
(function() {
const fVc = document.getElementById('f-vcenter');
const fAuthor = document.getElementById('f-author');
const fOnlyMine = document.getElementById('f-only-mine');
const fPcFormat = document.getElementById('f-pc-format');
const fMinAge = document.getElementById('f-min-age');
const btnRefresh = document.getElementById('btn-refresh');
const btnDelete = document.getElementById('btn-delete');
const status = document.getElementById('status');
const tbody = document.getElementById('tbody');
const selAll = document.getElementById('sel-all');
let allSnaps = [];
function escapeHTML(s) {
return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
}
function ageBadge(ageDays) {
if (ageDays === null || ageDays === undefined) return '<span class="text-gray-500"></span>';
const d = Number(ageDays);
let cls = 'badge-age-fresh';
if (d >= 7) cls = 'badge-age-old';
else if (d >= 3) cls = 'badge-age-warn';
return `<span class="badge ${cls}">${d.toFixed(1)} j</span>`;
}
function applyFilters() {
const author = (fAuthor.value || '').trim().toLowerCase();
const onlyMine = fOnlyMine.checked;
const pcOnly = fPcFormat.checked;
const minAge = parseFloat(fMinAge.value) || 0;
return allSnaps.filter(s => {
// Format PatchCenter uniquement
if (pcOnly && !s.is_patchcenter_format) return false;
// Mes snapshots uniquement (auteur doit etre present et matcher)
if (onlyMine) {
if (!author) {
// pas d'auteur indique -> on prend uniquement ceux dont l'auteur est connu
if (!s.author) return false;
} else if (!s.author || s.author.toLowerCase() !== author) {
return false;
}
}
if (minAge > 0 && (s.age_days === null || s.age_days < minAge)) return false;
return true;
});
}
function fmtDateFR(iso) {
// ISO 8601 -> 'jj/mm/aaaa HH:MM' (heure locale du navigateur)
if (!iso) return '';
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}/${pad(d.getMonth()+1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch (e) { return iso; }
}
function updateFilterSummary(visibleCount) {
// Affiche le nombre cachés vs total pour debug
const total = allSnaps.length;
const hidden = total - visibleCount;
const baseStatus = status.dataset.baseMsg || '';
if (hidden > 0) {
status.innerHTML = baseStatus + ` <span class="text-cyber-yellow">⚠ ${hidden} snapshot(s) caché(s) par les filtres</span> (décoche "Mes snapshots" ou mets âge=0 pour tout voir).`;
} else {
status.innerHTML = baseStatus;
}
}
function render() {
const rows = applyFilters();
updateFilterSummary(rows.length);
if (!rows.length) {
const total = allSnaps.length;
tbody.innerHTML = total === 0
? '<tr><td colspan="8" class="p-4 text-center text-gray-500">Aucun snapshot dans le scan. Vérifie credentials vCenter.</td></tr>'
: `<tr><td colspan="8" class="p-4 text-center text-cyber-yellow">${total} snapshot(s) trouvé(s) mais tous filtrés. Ajuste les filtres ci-dessus.</td></tr>`;
updateDeleteBtn();
return;
}
tbody.innerHTML = rows.map((s, idx) => {
const authorCell = s.author
? escapeHTML(s.author)
: '<i class="text-gray-500">inconnu</i>'; // bypass escape pour ce litteral HTML
return `
<tr class="ss-row border-b border-cyber-border/30" data-idx="${allSnaps.indexOf(s)}">
<td class="p-2"><input type="checkbox" class="row-cb"></td>
<td class="p-2 font-mono">${escapeHTML(s.vcenter_name)}</td>
<td class="p-2 font-mono">${escapeHTML(s.vm_name)}</td>
<td class="p-2 font-mono">${escapeHTML(s.snap_name)}</td>
<td class="p-2">${authorCell}</td>
<td class="p-2 font-mono text-[10px]">${escapeHTML(fmtDateFR(s.created_at))}</td>
<td class="p-2">${ageBadge(s.age_days)}</td>
<td class="p-2 text-gray-400">${escapeHTML(s.description || '')}</td>
</tr>`;
}).join('');
tbody.querySelectorAll('.row-cb').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('tr').classList.toggle('selected', cb.checked);
updateDeleteBtn();
});
});
updateDeleteBtn();
}
function updateDeleteBtn() {
if (!btnDelete) return;
const n = tbody.querySelectorAll('.row-cb:checked').length;
btnDelete.style.opacity = n > 0 ? '1' : '0.5';
btnDelete.textContent = n > 0 ? `✕ SUPPRIMER ${n} SNAPSHOT${n>1?'S':''}` : '✕ SUPPRIMER LA SÉLECTION';
}
// Overlay plein page : bloque toute interaction pendant les actions async.
// Empêche aussi la navigation accidentelle via beforeunload.
const overlay = document.getElementById('busy-overlay');
const busyText = document.getElementById('busy-text');
const busySub = document.getElementById('busy-sub');
let busyActive = false;
function setBusy(msg, btn, sub) {
status.innerHTML = '<span class="text-cyber-yellow">⏳ ' + escapeHTML(msg) + '</span>';
busyText.textContent = '⏳ ' + msg;
busySub.textContent = sub || 'Ne ferme pas la page, ne navigue pas ailleurs.';
overlay.classList.add('active');
busyActive = true;
if (btn) {
btn.disabled = true;
btn._origText = btn._origText || btn.textContent;
btn.textContent = '⏳ ' + msg;
}
}
function clearBusy(btn) {
overlay.classList.remove('active');
busyActive = false;
if (btn && btn._origText) {
btn.disabled = false;
btn.textContent = btn._origText;
}
}
// Avertit si l'utilisateur tente de quitter la page pendant une action
window.addEventListener('beforeunload', (e) => {
if (busyActive) {
e.preventDefault();
e.returnValue = 'Une action est en cours. Quitter maintenant peut interrompre la requête.';
return e.returnValue;
}
});
btnRefresh.addEventListener('click', async () => {
setBusy('Recherche en cours… (peut prendre 10-30 s selon les vCenters)', btnRefresh);
try {
const fd = new FormData();
if (fVc.value) fd.append('vcenter_id', fVc.value);
const r = await fetch('/snapshots/list', {method: 'POST', credentials: 'same-origin', body: fd});
const j = await r.json();
if (!j.ok) {
status.innerHTML = '<span class="text-cyber-red">Erreur : ' + escapeHTML(j.msg || 'inconnue') + '</span>';
return;
}
allSnaps = j.snapshots || [];
const errs = (j.errors || []).map(e => `${e.vcenter}: ${e.msg}`).join(' · ');
const baseMsg = `${j.snap_count} snapshot(s) trouvé(s) sur ${j.vcenter_count} vCenter(s).` +
(errs ? ` <span class="text-cyber-yellow">⚠ ${escapeHTML(errs)}</span>` : '');
status.dataset.baseMsg = baseMsg;
status.innerHTML = baseMsg;
render();
} catch (e) {
status.innerHTML = '<span class="text-cyber-red">Erreur réseau : ' + escapeHTML(String(e)) + '</span>';
} finally {
clearBusy(btnRefresh);
}
});
[fAuthor, fMinAge, fOnlyMine, fPcFormat].forEach(el => el.addEventListener('input', render));
[fOnlyMine, fPcFormat].forEach(el => el.addEventListener('change', render));
selAll.addEventListener('change', () => {
tbody.querySelectorAll('.row-cb').forEach(cb => {
cb.checked = selAll.checked;
cb.closest('tr').classList.toggle('selected', cb.checked);
});
updateDeleteBtn();
});
if (btnDelete) btnDelete.addEventListener('click', async () => {
const checked = Array.from(tbody.querySelectorAll('.row-cb:checked'));
if (!checked.length) { alert('Sélectionne au moins un snapshot.'); return; }
const items = checked.map(cb => {
const idx = parseInt(cb.closest('tr').dataset.idx, 10);
const s = allSnaps[idx];
return {
vcenter_id: s.vcenter_id, vm_moid: s.vm_moid, snap_id: s.snap_id,
vm_name: s.vm_name, snap_name: s.snap_name,
};
});
const lines = items.slice(0, 15).map(i => `${i.vm_name}${i.snap_name}`).join('\n');
const more = items.length > 15 ? `\n … et ${items.length - 15} autre(s)` : '';
if (!confirm(`⚠ Supprimer ${items.length} snapshot(s) ? Cette action est DÉFINITIVE.\n\n${lines}${more}`)) return;
if (!confirm(`Confirme une 2e fois : suppression définitive de ${items.length} snapshot(s)`)) return;
setBusy(`Suppression en cours… (${items.length} snapshot(s))`, btnDelete);
try {
const r = await fetch('/snapshots/delete', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({items}),
});
const j = await r.json();
const okCount = (j.results || []).filter(x => x.ok).length;
const failed = (j.results || []).filter(x => !x.ok);
let msg = `${okCount}/${items.length} supprimés.`;
if (failed.length) {
msg += '\n\nÉchecs :\n' + failed.slice(0,10).map(f => ` ${f.vm_name}${f.snap_name}: ${f.msg}`).join('\n');
}
alert(msg);
clearBusy(btnDelete);
// Re-scanne pour refleter l'etat reel cote vCenter
btnRefresh.click();
} catch (e) {
alert('Erreur réseau : ' + e);
clearBusy(btnDelete);
}
});
})();
</script>
{% endblock %}