feat(snapshots): page de gestion snapshots VM (listing + filtre auteur/age + suppression)

Service snapshot_mgmt_service.py:
- list_snapshots(db, vcenter_filter_id=None): itere les vCenters actifs, walk recursif
  des snapshot tree de chaque VM, retourne (vcenter, vm, snap_name, snap_id, vm_moid,
  created_at, age_days, author, description, is_current)
- delete_snapshot(db, vcenter_id, vm_moid, snap_id, remove_children=False): supprime
  un snapshot par moRef, attend la fin de la task vCenter
- Auteur deduit du prefixe du nom (format PatchCenter '<auteur>_YYYY-MM-DD_<suffixe>')

Router /snapshots:
- GET /snapshots: page principale (filtres + table)
- POST /snapshots/list: AJAX scan vCenters, retourne JSON
- POST /snapshots/delete: AJAX suppression batch, double confirmation cote UI

Template snapshots.html:
- Filtres: vCenter, auteur, 'Mes snapshots uniquement' (preselectionne user courant),
  age min en jours (defaut 3)
- Table avec checkboxes, sel-all, badge age (vert <3j, orange 3-7j, rouge >7j)
- Bouton 'Charger/Refresh' (lazy load, eviter scan auto au pageload)
- Bouton 'Supprimer la selection' avec 2 confirmations + liste des snapshots
- Recharge auto apres suppression

Nav: lien '📸 Snapshots VM' ajoute dans le menu Patching.
This commit is contained in:
Pierre & Lumière 2026-05-07 20:13:29 +02:00
parent 2559d58a54
commit c63b3a9119
5 changed files with 570 additions and 1 deletions

View File

@ -12,7 +12,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms
from .database import SessionLocal, SessionLocalDemo
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, planning_import, specifics, audit, contacts, qualys, qualys_tags, quickwin, referentiel, patching, applications, patch_history, duty
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, planning_import, specifics, audit, contacts, qualys, qualys_tags, quickwin, referentiel, patching, applications, patch_history, duty, snapshots
class PermissionsMiddleware(BaseHTTPMiddleware):
@ -74,6 +74,7 @@ app.include_router(patching.router)
app.include_router(patch_history.router)
app.include_router(duty.router)
app.include_router(applications.router)
app.include_router(snapshots.router)
@app.get("/")

121
app/routers/snapshots.py Normal file
View File

@ -0,0 +1,121 @@
"""Router /snapshots — listing + suppression des snapshots VM existants sur les vCenters."""
import logging
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..config import APP_NAME
log = logging.getLogger("patchcenter.snapshots_router")
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _can_view_snaps(perms):
return can_view(perms, "campaigns") or can_view(perms, "planning") or can_view(perms, "settings")
def _can_delete_snaps(perms):
return can_edit(perms, "campaigns") or can_edit(perms, "planning") or can_edit(perms, "settings")
def _get_user_intervenant_name(db, user):
"""Retourne le nom utilisé comme préfixe dans les snapshots PatchCenter de cet utilisateur.
Priorité : settings 'patcher' > username."""
try:
from ..services.secrets_service import get_secret
patcher = (get_secret(db, "patcher") or "").strip()
if patcher:
return patcher
except Exception:
pass
return user.get("username", "")
@router.get("/snapshots", response_class=HTMLResponse)
async def snapshots_page(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not _can_view_snaps(perms):
return RedirectResponse(url="/dashboard")
intervenant = _get_user_intervenant_name(db, user)
vcenters = db.execute(text(
"SELECT id, name, endpoint FROM vcenters WHERE is_active = true ORDER BY name"
)).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"intervenant_default": intervenant,
"vcenters": vcenters,
"can_delete": _can_delete_snaps(perms),
})
return templates.TemplateResponse("snapshots.html", ctx)
@router.post("/snapshots/list")
async def snapshots_list(request: Request, db=Depends(get_db),
vcenter_id: str = Form("")):
"""Endpoint AJAX : liste les snapshots (tous vCenters ou un seul)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not _can_view_snaps(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
from ..services.snapshot_mgmt_service import list_snapshots
vc_id = int(vcenter_id) if vcenter_id and vcenter_id.isdigit() else None
res = list_snapshots(db, vcenter_filter_id=vc_id)
return JSONResponse(res)
@router.post("/snapshots/delete")
async def snapshots_delete(request: Request, db=Depends(get_db)):
"""Endpoint AJAX : supprime une liste de snapshots.
Body JSON : [{vcenter_id, vm_moid, snap_id}, ...]"""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not _can_delete_snaps(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
try:
body = await request.json()
except Exception:
return JSONResponse({"ok": False, "msg": "Body JSON invalide"}, status_code=400)
items = body.get("items") if isinstance(body, dict) else None
if not isinstance(items, list) or not items:
return JSONResponse({"ok": False, "msg": "Aucun snapshot ciblé"}, status_code=400)
from ..services.snapshot_mgmt_service import delete_snapshot
results = []
n_ok = 0
for it in items:
try:
r = delete_snapshot(
db,
vcenter_id=int(it["vcenter_id"]),
vm_moid=str(it["vm_moid"]),
snap_id=str(it["snap_id"]),
remove_children=bool(it.get("remove_children", False)),
)
except Exception as e:
r = {"ok": False, "msg": f"Param error: {e}"}
r["vm_name"] = it.get("vm_name", "?")
r["snap_name"] = it.get("snap_name", "?")
results.append(r)
if r.get("ok"):
n_ok += 1
return JSONResponse({
"ok": n_ok == len(items),
"summary": f"{n_ok}/{len(items)} snapshots supprimés",
"results": results,
})

View File

@ -0,0 +1,209 @@
"""Service de gestion des snapshots VM existants — listing + suppression.
- list_snapshots(db) : itère les vCenters actifs et retourne tous les snapshots
avec leurs métadonnées (VM, nom snap, créé le, taille, description, créateur).
- delete_snapshot(db, vcenter_id, vm_moid, snapshot_id) : supprime un snapshot
spécifique par son moRef.
Identification de l'auteur : on parse le préfixe du nom du snapshot
(format PatchCenter : `<intervenant>_YYYY-MM-DD_avant_patch`).
"""
import re
import ssl
import logging
from datetime import datetime, timezone
from typing import Dict, Any, List
log = logging.getLogger("patchcenter.snapshot_mgmt")
try:
from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim
PYVMOMI_OK = True
except ImportError:
PYVMOMI_OK = False
log.warning("pyvmomi non disponible — listing snapshots impossible")
SNAP_NAME_RE = re.compile(
r"^(?P<author>[A-Za-z0-9_\-\.]+)_(?P<date>\d{4}-\d{2}-\d{2})(?:_(?P<suffix>.+))?$"
)
def _get_vcenter_creds(db):
from .secrets_service import get_secret
user = (get_secret(db, "vcenter_user") or get_secret(db, "vsphere_user") or "").strip()
pwd = (get_secret(db, "vcenter_pass") or get_secret(db, "vsphere_pass") or "").strip()
return user, pwd
def _connect(endpoint, user, password):
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return SmartConnect(host=endpoint, user=user, pwd=password, sslContext=ctx)
except Exception as e:
log.warning(f"Connexion vCenter {endpoint} échouée: {e}")
return None
def _walk_snapshots(snapshot_list, vm, vcenter_name, vcenter_id, vm_moid, parent_path=""):
"""Walk récursif de l'arbre des snapshots d'une VM. Yield des dicts."""
for s in snapshot_list:
snap = s.snapshot # ManagedObject (vim.vm.Snapshot)
snap_moid = snap._moId # string ex 'snapshot-1234'
name = s.name or ""
path = f"{parent_path}/{name}" if parent_path else name
# Création (createTime peut être absent dans certaines versions)
created = getattr(s, "createTime", None)
if created is None:
created_iso = None
age_days = None
else:
try:
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
created_iso = created.isoformat()
age_days = (datetime.now(timezone.utc) - created).total_seconds() / 86400.0
except Exception:
created_iso = str(created)
age_days = None
# Auteur déduit du préfixe du nom (format PatchCenter)
author = None
m = SNAP_NAME_RE.match(name)
if m:
author = m.group("author")
yield {
"vcenter_id": vcenter_id,
"vcenter_name": vcenter_name,
"vm_name": vm.name,
"vm_moid": vm_moid,
"snap_id": snap_moid,
"snap_name": name,
"snap_path": path,
"description": s.description or "",
"created_at": created_iso,
"age_days": round(age_days, 2) if age_days is not None else None,
"author": author,
"is_current": bool(getattr(s, "id", None) and vm.snapshot and vm.snapshot.currentSnapshot
and vm.snapshot.currentSnapshot._moId == snap_moid),
}
# Récurse enfants
if s.childSnapshotList:
yield from _walk_snapshots(s.childSnapshotList, vm, vcenter_name, vcenter_id,
vm_moid, parent_path=path)
def list_snapshots(db, vcenter_filter_id: int = None) -> Dict[str, Any]:
"""Itère les vCenters actifs et retourne tous les snapshots existants.
Si `vcenter_filter_id` est fourni, ne scanne que ce vCenter.
Retourne {ok, snapshots: list, errors: list}."""
if not PYVMOMI_OK:
return {"ok": False, "msg": "pyvmomi non disponible côté serveur PatchCenter",
"snapshots": [], "errors": []}
from sqlalchemy import text as sqlt
user, pwd = _get_vcenter_creds(db)
if not user or not pwd:
return {"ok": False, "msg": "Credentials vCenter manquants (Settings > vSphere)",
"snapshots": [], "errors": []}
where = "WHERE is_active = true"
params = {}
if vcenter_filter_id:
where += " AND id = :id"
params["id"] = vcenter_filter_id
vcs = db.execute(sqlt(f"SELECT id, name, endpoint FROM vcenters {where} ORDER BY name"),
params).fetchall()
if not vcs:
return {"ok": False, "msg": "Aucun vCenter actif", "snapshots": [], "errors": []}
all_snaps = []
errors = []
for vc in vcs:
si = _connect(vc.endpoint, user, pwd)
if not si:
errors.append({"vcenter": vc.name, "msg": "Connexion KO"})
continue
try:
content = si.RetrieveContent()
container = content.viewManager.CreateContainerView(
content.rootFolder, [vim.VirtualMachine], True)
try:
for vm in container.view:
try:
if not vm.snapshot or not vm.snapshot.rootSnapshotList:
continue
for snap in _walk_snapshots(vm.snapshot.rootSnapshotList,
vm, vc.name, vc.id, vm._moId):
all_snaps.append(snap)
except Exception as e:
# VM inaccessible / template / config invalide → ignore
log.debug(f"VM scan err on {vc.name}: {e}")
finally:
container.Destroy()
except Exception as e:
errors.append({"vcenter": vc.name, "msg": str(e)[:200]})
finally:
try:
Disconnect(si)
except Exception:
pass
# Tri : plus ancien en haut
all_snaps.sort(key=lambda s: (s.get("age_days") or 0), reverse=True)
return {"ok": True, "snapshots": all_snaps, "errors": errors,
"vcenter_count": len(vcs), "snap_count": len(all_snaps)}
def delete_snapshot(db, vcenter_id: int, vm_moid: str, snap_id: str,
remove_children: bool = False) -> Dict[str, Any]:
"""Supprime un snapshot identifié par (vcenter_id, vm_moid, snap_id).
remove_children=True supprime aussi les snapshots enfants."""
if not PYVMOMI_OK:
return {"ok": False, "msg": "pyvmomi non disponible"}
from sqlalchemy import text as sqlt
vc = db.execute(sqlt("SELECT id, name, endpoint FROM vcenters WHERE id=:id"),
{"id": vcenter_id}).fetchone()
if not vc:
return {"ok": False, "msg": f"vCenter id={vcenter_id} introuvable"}
user, pwd = _get_vcenter_creds(db)
if not user or not pwd:
return {"ok": False, "msg": "Credentials vCenter manquants (Settings > vSphere)"}
si = _connect(vc.endpoint, user, pwd)
if not si:
return {"ok": False, "msg": f"Connexion vCenter {vc.name} KO"}
try:
# On reconstruit le ManagedObject directement par ses moRef
snap_mo = vim.vm.Snapshot(snap_id, si._stub)
task = snap_mo.RemoveSnapshot_Task(removeChildren=remove_children)
# Wait task
from pyVmomi import vim as _vim
while task.info.state in (_vim.TaskInfo.State.queued, _vim.TaskInfo.State.running):
import time as _t
_t.sleep(1)
if task.info.state == _vim.TaskInfo.State.success:
return {"ok": True, "msg": f"Snapshot supprimé sur {vc.name}",
"vcenter": vc.name, "vm_moid": vm_moid, "snap_id": snap_id}
else:
err = task.info.error
err_msg = err.msg if err else "task failed"
return {"ok": False, "msg": f"Échec suppression: {err_msg}",
"vcenter": vc.name}
except Exception as e:
return {"ok": False, "msg": f"Exception: {type(e).__name__}: {e}",
"vcenter": vc.name}
finally:
try:
Disconnect(si)
except Exception:
pass

View File

@ -94,6 +94,7 @@
{% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %}
{% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/validations" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/validations' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Validations</a>{% endif %}
<a href="/patching/historique" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/historique' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Historique</a>
{% if p.campaigns or p.planning or p.settings %}<a href="/snapshots" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/snapshots' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">📸 Snapshots VM</a>{% endif %}
<a href="/duty" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/duty' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tour de garde</a>
{# Quickwin sous-groupe #}

View File

@ -0,0 +1,237 @@
{% 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; }
</style>
<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>
</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 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 minAge = parseFloat(fMinAge.value) || 0;
return allSnaps.filter(s => {
if (onlyMine && author) {
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 render() {
const rows = applyFilters();
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="8" class="p-4 text-center text-gray-500">Aucun snapshot correspondant aux filtres.</td></tr>';
updateDeleteBtn();
return;
}
tbody.innerHTML = rows.map((s, idx) => `
<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">${escapeHTML(s.author || '<i class="text-gray-500">inconnu</i>')}</td>
<td class="p-2 font-mono text-[10px]">${escapeHTML(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';
}
btnRefresh.addEventListener('click', async () => {
status.textContent = 'Scan en cours… (peut prendre 10-30 s selon les vCenters)';
btnRefresh.disabled = true;
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(' · ');
status.innerHTML = `${j.snap_count} snapshot(s) trouvé(s) sur ${j.vcenter_count} vCenter(s).` +
(errs ? ` <span class="text-cyber-yellow">⚠ ${escapeHTML(errs)}</span>` : '');
render();
} catch (e) {
status.innerHTML = '<span class="text-cyber-red">Erreur réseau : ' + escapeHTML(String(e)) + '</span>';
} finally {
btnRefresh.disabled = false;
}
});
[fAuthor, fMinAge, fOnlyMine].forEach(el => el.addEventListener('input', render));
fOnlyMine.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;
status.textContent = 'Suppression en cours…';
btnDelete.disabled = true;
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);
// Recharger
btnRefresh.click();
} catch (e) {
alert('Erreur réseau : ' + e);
} finally {
btnDelete.disabled = false;
}
});
})();
</script>
{% endblock %}