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:
parent
2559d58a54
commit
c63b3a9119
@ -12,7 +12,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from .config import APP_NAME, APP_VERSION
|
from .config import APP_NAME, APP_VERSION
|
||||||
from .dependencies import get_current_user, get_user_perms
|
from .dependencies import get_current_user, get_user_perms
|
||||||
from .database import SessionLocal, SessionLocalDemo
|
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):
|
class PermissionsMiddleware(BaseHTTPMiddleware):
|
||||||
@ -74,6 +74,7 @@ app.include_router(patching.router)
|
|||||||
app.include_router(patch_history.router)
|
app.include_router(patch_history.router)
|
||||||
app.include_router(duty.router)
|
app.include_router(duty.router)
|
||||||
app.include_router(applications.router)
|
app.include_router(applications.router)
|
||||||
|
app.include_router(snapshots.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
121
app/routers/snapshots.py
Normal file
121
app/routers/snapshots.py
Normal 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,
|
||||||
|
})
|
||||||
209
app/services/snapshot_mgmt_service.py
Normal file
209
app/services/snapshot_mgmt_service.py
Normal 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
|
||||||
@ -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.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 %}
|
{% 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>
|
<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>
|
<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 #}
|
{# Quickwin sous-groupe #}
|
||||||
|
|||||||
237
app/templates/snapshots.html
Normal file
237
app/templates/snapshots.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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 %}
|
||||||
Loading…
Reference in New Issue
Block a user