patchcenter/app/templates/snapshots.html
Admin MPCZ 720b0789e6 feat(snapshots): reconnait les formats manuels SANEF + toggle UI 'Tous formats'
- backend: nouveau regex SNAP_MANUAL_RE qui capture le premier token avant
  espace/underscore comme auteur. Permet de classer les snaps style
  'kmoad-ext avant maj' ou 'kmoad-ext s1' en origin='manual' avec auteur
  extrait, au lieu de origin=None.
- frontend: checkbox 'Tous formats' (cochée par défaut) qui inclut les
  snaps manual/slpm/patchcenter. Decoche pour PatchCenter only (ancien
  comportement).
- frontend: filtre intervenant elargi - match aussi sur nom du snap
  (contains) en plus de l'auteur extrait, pour couvrir les snaps dont
  l'auteur est concatene avec d'autres mots.

Resout le cas ou un utilisateur ne voyait qu'1 seul snap PatchCenter
alors qu'il avait des dizaines de snaps crees manuellement (format
'<user> avant maj').
2026-05-18 16:05:25 +02:00

357 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>
<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">
{% for u in intervenants_list %}
<option value="{{ u.username }}"{% if u.username == intervenant_default %} selected{% endif %}>{{ u.username }}{% if u.username == intervenant_default %} (toi){% endif %}{% if u.display_name and u.display_name != u.username %} — {{ u.display_name }}{% endif %}</option>
{% endfor %}
{% if intervenant_default and intervenant_default not in intervenants_list|map(attribute='username')|list %}
<option value="{{ intervenant_default }}" selected>{{ intervenant_default }} (toi, hors liste)</option>
{% endif %}
</select>
<p class="text-xs text-gray-500 mt-1">Users actifs (hors admins) — défaut = toi.</p>
</div>
<div class="col-span-2">
<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-1">
<label class="text-xs text-gray-500" title="Inclure les snapshots créés hors PatchCenter (formats manuels SANEF)">
<input type="checkbox" id="f-include-manual" checked>
Tous formats
</label>
</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 fIncludeManual = document.getElementById('f-include-manual');
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() {
// - Si "Tous formats" décoché : snapshots PatchCenter uniquement (origin === 'patchcenter')
// - Sinon : tous formats reconnus (patchcenter / slpm / manual)
// - Filtre intervenant : author extrait du regex OU nom du snap contient l'intervenant
// - Filtre âge minimum
const intervenant = (fIntervenant.value || '').trim().toLowerCase();
const minAge = parseFloat(fMinAge.value) || 0;
const includeManual = fIncludeManual ? fIncludeManual.checked : false;
return allSnaps.filter(s => {
if (!includeManual && s.origin !== 'patchcenter') return false;
if (intervenant) {
const authorOk = s.author && s.author.toLowerCase() === intervenant;
const nameOk = s.snap_name && s.snap_name.toLowerCase().includes(intervenant);
if (!authorOk && !nameOk) return false;
}
if (minAge > 0 && (s.age_days === null || s.age_days < minAge)) return false;
return true;
});
}
// Note: la liste des intervenants est désormais figée au pageload (cf template,
// alimentée depuis la table users actifs hors admin). On ne la reconstruit plus
// dynamiquement après scan vCenter — un user qui aurait fait un snapshot mais
// serait inactif/admin ne sera pas filtrable nominativement, tant pis pour ce cas.
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');
let busyActive = false;
function setBusy(msg, btn) {
status.innerHTML = '<span class="text-cyber-yellow">⏳ ' + escapeHTML(msg) + '</span>';
busyText.textContent = '⏳ ' + msg;
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;
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);
if (fIncludeManual) fIncludeManual.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 %}