- 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)
340 lines
15 KiB
HTML
340 lines
15 KiB
HTML
{% 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><auteur>_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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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 %}
|