patchcenter/app/templates/snapshots.html
Admin MPCZ c63b3a9119 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.
2026-05-07 20:13:29 +02:00

238 lines
11 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; }
</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 %}