patchcenter/app/templates/snapshots.html
Admin MPCZ 46b80474c2 feat(snapshots): UI simplifiee - dropdown Intervenant unique (defaut user connecte)
Avant: 3 champs (Auteur input + 2 checkboxes 'Mes snapshots' / 'Format PatchCenter') -> trop.

Apres:
- Un seul dropdown 'Intervenant' avec liste dynamique des auteurs detectes dans les
  snapshots PatchCenter charges
- Defaut = user connecte (intervenant_default depuis JWT 'sub')
- Ajoute '(toi)' a cote de l'option qui matche le user connecte
- Si le user connecte n'a pas encore de snapshot, son option apparait quand meme en tete
  avec le tag '(toi, aucun snap)'
- Le filtre 'PatchCenter uniquement' est implicite (toujours actif) -> les snapshots
  SLPM ou manuels n'apparaissent plus du tout
- Les autres users peuvent etre selectionnes pour afficher/supprimer leurs snapshots
  (pas de restriction par compte au-dela de l'authentification)
- updateFilterSummary affiche le filtre actif clairement
2026-05-07 21:00:28 +02:00

361 lines
17 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">Intervenant</label>
<select id="f-intervenant" class="w-full">
<option value="{{ intervenant_default }}" selected>{{ intervenant_default or '(toi)' }}</option>
</select>
<p class="text-xs text-gray-500 mt-1">Liste alimentée après chargement (auteurs détectés dans les snapshots PatchCenter).</p>
</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">Origine</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 fIntervenant = document.getElementById('f-intervenant');
const fMinAge = document.getElementById('f-min-age');
const intervenantDefault = "{{ intervenant_default|e }}";
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() {
// Toujours: snapshots PatchCenter uniquement (origin === 'patchcenter')
// + intervenant choisi dans le dropdown
// + age minimum
const intervenant = (fIntervenant.value || '').trim().toLowerCase();
const minAge = parseFloat(fMinAge.value) || 0;
return allSnaps.filter(s => {
if (s.origin !== 'patchcenter') return false;
if (intervenant && (!s.author || s.author.toLowerCase() !== intervenant)) return false;
if (minAge > 0 && (s.age_days === null || s.age_days < minAge)) return false;
return true;
});
}
function rebuildIntervenantDropdown() {
// Liste des auteurs distincts des snapshots PatchCenter trouvés
const authors = Array.from(new Set(
allSnaps.filter(s => s.origin === 'patchcenter' && s.author).map(s => s.author)
)).sort((a,b) => a.localeCompare(b, 'fr', {sensitivity:'base'}));
// Conserve la sélection courante (ou défaut user connecté)
const current = fIntervenant.value || intervenantDefault;
let opts = '';
// Si user connecté pas dans la liste (= aucun snap encore), on l'ajoute en tête
const haveCurrent = authors.some(a => a.toLowerCase() === current.toLowerCase());
if (intervenantDefault && !authors.some(a => a.toLowerCase() === intervenantDefault.toLowerCase())) {
opts += `<option value="${intervenantDefault}">${intervenantDefault} (toi, aucun snap)</option>`;
}
for (const a of authors) {
const isYou = a.toLowerCase() === intervenantDefault.toLowerCase();
const sel = (a.toLowerCase() === current.toLowerCase()) ? ' selected' : '';
opts += `<option value="${a}"${sel}>${a}${isYou ? ' (toi)' : ''}</option>`;
}
if (!opts) opts = '<option value="">(aucun snap PatchCenter trouvé)</option>';
fIntervenant.innerHTML = opts;
// Re-sélectionne current ou défaut si présent
if (haveCurrent) fIntervenant.value = current;
}
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 combien sont cachés et la cible
const baseStatus = status.dataset.baseMsg || '';
const intervenant = (fIntervenant.value || '').trim();
const minAge = parseFloat(fMinAge.value) || 0;
const filterDesc = `intervenant=${intervenant || 'tous'}` + (minAge > 0 ? `, âge≥${minAge}j` : '');
status.innerHTML = baseStatus + ` <span class="text-cyber-accent">→ ${visibleCount} affiché(s) (filtre : ${escapeHTML(filterDesc)})</span>`;
}
function render() {
const rows = applyFilters();
updateFilterSummary(rows.length);
if (!rows.length) {
const total = allSnaps.length;
tbody.innerHTML = total === 0
? '<tr><td colspan="9" class="p-4 text-center text-gray-500">Aucun snapshot dans le scan. Vérifie credentials vCenter.</td></tr>'
: `<tr><td colspan="9" 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
let originBadge;
if (s.origin === 'patchcenter') originBadge = '<span class="badge badge-blue">PatchCenter</span>';
else if (s.origin === 'slpm') originBadge = '<span class="badge badge-gray">SLPM (.exe)</span>';
else originBadge = '<span class="badge badge-orange">manuel</span>';
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">${originBadge}</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 pcCount = allSnaps.filter(s => s.origin === 'patchcenter').length;
const baseMsg = `${j.snap_count} snapshot(s) trouvé(s) sur ${j.vcenter_count} vCenter(s) (dont ${pcCount} PatchCenter).` +
(errs ? ` <span class="text-cyber-yellow">⚠ ${escapeHTML(errs)}</span>` : '');
status.dataset.baseMsg = baseMsg;
status.innerHTML = baseMsg;
rebuildIntervenantDropdown();
render();
} catch (e) {
status.innerHTML = '<span class="text-cyber-red">Erreur réseau : ' + escapeHTML(String(e)) + '</span>';
} finally {
clearBusy(btnRefresh);
}
});
[fIntervenant, fMinAge].forEach(el => el.addEventListener('input', render));
fIntervenant.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 %}