feat(snapshots): filtre format PatchCenter strict + UX feedback + dates fr
- Service: regex stricte '<auteur>_YYYY-MM-DD_avant_patch' (avant: laxiste avec suffixe optionnel)
- Champ is_patchcenter_format ajoute aux snapshots, et auteur seulement si format match
- Router: _get_user_intervenant_name lit JWT 'sub' (correctif - etait 'username' qui n'existe pas)
- UI:
* Nouveau filtre 'Format PatchCenter uniquement' (checkbox, default ON)
* Filtre 'Mes snapshots' marche meme si auteur input vide -> on garde uniquement
ceux dont l'auteur est connu (= snapshots PatchCenter)
* Dates: formattees jj/mm/aaaa HH:MM (fmtDateFR via Date object navigateur)
* Cellule auteur 'inconnu' rendue avec balise <i> proprement (bypass escapeHTML)
* Helper setBusy/clearBusy pour feedback unifie '⏳ Recherche en cours…' / '⏳ Suppression en cours…'
(status + texte du bouton change pendant l'action)
This commit is contained in:
parent
0b98b54a63
commit
77e884d620
@ -24,7 +24,7 @@ def _can_delete_snaps(perms):
|
|||||||
|
|
||||||
def _get_user_intervenant_name(db, user):
|
def _get_user_intervenant_name(db, user):
|
||||||
"""Retourne le nom utilisé comme préfixe dans les snapshots PatchCenter de cet utilisateur.
|
"""Retourne le nom utilisé comme préfixe dans les snapshots PatchCenter de cet utilisateur.
|
||||||
Priorité : settings 'patcher' > username."""
|
Priorité : settings 'patcher' > username (sub du JWT) > display_name."""
|
||||||
try:
|
try:
|
||||||
from ..services.secrets_service import get_secret
|
from ..services.secrets_service import get_secret
|
||||||
patcher = (get_secret(db, "patcher") or "").strip()
|
patcher = (get_secret(db, "patcher") or "").strip()
|
||||||
@ -32,7 +32,8 @@ def _get_user_intervenant_name(db, user):
|
|||||||
return patcher
|
return patcher
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return user.get("username", "")
|
# JWT stocke le username dans 'sub' (cf auth.py)
|
||||||
|
return (user.get("sub") or user.get("username") or user.get("display_name") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/snapshots", response_class=HTMLResponse)
|
@router.get("/snapshots", response_class=HTMLResponse)
|
||||||
|
|||||||
@ -25,8 +25,9 @@ except ImportError:
|
|||||||
log.warning("pyvmomi non disponible — listing snapshots impossible")
|
log.warning("pyvmomi non disponible — listing snapshots impossible")
|
||||||
|
|
||||||
|
|
||||||
|
# Format strict PatchCenter : `<auteur>_YYYY-MM-DD_avant_patch`
|
||||||
SNAP_NAME_RE = re.compile(
|
SNAP_NAME_RE = re.compile(
|
||||||
r"^(?P<author>[A-Za-z0-9_\-\.]+)_(?P<date>\d{4}-\d{2}-\d{2})(?:_(?P<suffix>.+))?$"
|
r"^(?P<author>[A-Za-z0-9_\-\.]+)_(?P<date>\d{4}-\d{2}-\d{2})_avant_patch$"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -71,11 +72,14 @@ def _walk_snapshots(snapshot_list, vm, vcenter_name, vcenter_id, vm_moid, parent
|
|||||||
created_iso = str(created)
|
created_iso = str(created)
|
||||||
age_days = None
|
age_days = None
|
||||||
|
|
||||||
# Auteur déduit du préfixe du nom (format PatchCenter)
|
# Auteur déduit du préfixe du nom (format strict PatchCenter)
|
||||||
|
# `<auteur>_YYYY-MM-DD_avant_patch`
|
||||||
author = None
|
author = None
|
||||||
|
is_pc_format = False
|
||||||
m = SNAP_NAME_RE.match(name)
|
m = SNAP_NAME_RE.match(name)
|
||||||
if m:
|
if m:
|
||||||
author = m.group("author")
|
author = m.group("author")
|
||||||
|
is_pc_format = True
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"vcenter_id": vcenter_id,
|
"vcenter_id": vcenter_id,
|
||||||
@ -89,6 +93,7 @@ def _walk_snapshots(snapshot_list, vm, vcenter_name, vcenter_id, vm_moid, parent
|
|||||||
"created_at": created_iso,
|
"created_at": created_iso,
|
||||||
"age_days": round(age_days, 2) if age_days is not None else None,
|
"age_days": round(age_days, 2) if age_days is not None else None,
|
||||||
"author": author,
|
"author": author,
|
||||||
|
"is_patchcenter_format": is_pc_format,
|
||||||
"is_current": bool(getattr(s, "id", None) and vm.snapshot and vm.snapshot.currentSnapshot
|
"is_current": bool(getattr(s, "id", None) and vm.snapshot and vm.snapshot.currentSnapshot
|
||||||
and vm.snapshot.currentSnapshot._moId == snap_moid),
|
and vm.snapshot.currentSnapshot._moId == snap_moid),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
<label class="text-xs text-gray-500">Auteur (préfixe nom)</label>
|
<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">
|
<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>
|
<label class="text-xs text-gray-400 mt-1"><input type="checkbox" id="f-only-mine" checked> Mes snapshots uniquement</label>
|
||||||
|
<label class="text-xs text-gray-400 mt-1"><input type="checkbox" id="f-pc-format" checked> Format PatchCenter uniquement (<code><auteur>_YYYY-MM-DD_avant_patch</code>)</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-3">
|
<div class="col-span-3">
|
||||||
<label class="text-xs text-gray-500">Âge minimum (jours)</label>
|
<label class="text-xs text-gray-500">Âge minimum (jours)</label>
|
||||||
@ -87,6 +88,7 @@
|
|||||||
const fVc = document.getElementById('f-vcenter');
|
const fVc = document.getElementById('f-vcenter');
|
||||||
const fAuthor = document.getElementById('f-author');
|
const fAuthor = document.getElementById('f-author');
|
||||||
const fOnlyMine = document.getElementById('f-only-mine');
|
const fOnlyMine = document.getElementById('f-only-mine');
|
||||||
|
const fPcFormat = document.getElementById('f-pc-format');
|
||||||
const fMinAge = document.getElementById('f-min-age');
|
const fMinAge = document.getElementById('f-min-age');
|
||||||
const btnRefresh = document.getElementById('btn-refresh');
|
const btnRefresh = document.getElementById('btn-refresh');
|
||||||
const btnDelete = document.getElementById('btn-delete');
|
const btnDelete = document.getElementById('btn-delete');
|
||||||
@ -112,16 +114,36 @@
|
|||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const author = (fAuthor.value || '').trim().toLowerCase();
|
const author = (fAuthor.value || '').trim().toLowerCase();
|
||||||
const onlyMine = fOnlyMine.checked;
|
const onlyMine = fOnlyMine.checked;
|
||||||
|
const pcOnly = fPcFormat.checked;
|
||||||
const minAge = parseFloat(fMinAge.value) || 0;
|
const minAge = parseFloat(fMinAge.value) || 0;
|
||||||
return allSnaps.filter(s => {
|
return allSnaps.filter(s => {
|
||||||
if (onlyMine && author) {
|
// Format PatchCenter uniquement
|
||||||
if (!s.author || s.author.toLowerCase() !== author) return false;
|
if (pcOnly && !s.is_patchcenter_format) return false;
|
||||||
|
// Mes snapshots uniquement (auteur doit etre present et matcher)
|
||||||
|
if (onlyMine) {
|
||||||
|
if (!author) {
|
||||||
|
// pas d'auteur indique -> on prend uniquement ceux dont l'auteur est connu
|
||||||
|
if (!s.author) return false;
|
||||||
|
} else if (!s.author || s.author.toLowerCase() !== author) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (minAge > 0 && (s.age_days === null || s.age_days < minAge)) return false;
|
if (minAge > 0 && (s.age_days === null || s.age_days < minAge)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function updateFilterSummary(visibleCount) {
|
||||||
// Affiche le nombre cachés vs total pour debug
|
// Affiche le nombre cachés vs total pour debug
|
||||||
const total = allSnaps.length;
|
const total = allSnaps.length;
|
||||||
@ -145,18 +167,22 @@
|
|||||||
updateDeleteBtn();
|
updateDeleteBtn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = rows.map((s, idx) => `
|
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
|
||||||
|
return `
|
||||||
<tr class="ss-row border-b border-cyber-border/30" data-idx="${allSnaps.indexOf(s)}">
|
<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"><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.vcenter_name)}</td>
|
||||||
<td class="p-2 font-mono">${escapeHTML(s.vm_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 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">${authorCell}</td>
|
||||||
<td class="p-2 font-mono text-[10px]">${escapeHTML(s.created_at || '–')}</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">${ageBadge(s.age_days)}</td>
|
||||||
<td class="p-2 text-gray-400">${escapeHTML(s.description || '–')}</td>
|
<td class="p-2 text-gray-400">${escapeHTML(s.description || '–')}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
tbody.querySelectorAll('.row-cb').forEach(cb => {
|
tbody.querySelectorAll('.row-cb').forEach(cb => {
|
||||||
cb.addEventListener('change', () => {
|
cb.addEventListener('change', () => {
|
||||||
cb.closest('tr').classList.toggle('selected', cb.checked);
|
cb.closest('tr').classList.toggle('selected', cb.checked);
|
||||||
@ -173,9 +199,23 @@
|
|||||||
btnDelete.textContent = n > 0 ? `✕ SUPPRIMER ${n} SNAPSHOT${n>1?'S':''}` : '✕ SUPPRIMER LA SÉLECTION';
|
btnDelete.textContent = n > 0 ? `✕ SUPPRIMER ${n} SNAPSHOT${n>1?'S':''}` : '✕ SUPPRIMER LA SÉLECTION';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setBusy(msg, btn) {
|
||||||
|
status.innerHTML = '<span class="text-cyber-yellow">⏳ ' + escapeHTML(msg) + '</span>';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn._origText = btn._origText || btn.textContent;
|
||||||
|
btn.textContent = '⏳ ' + msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearBusy(btn) {
|
||||||
|
if (btn && btn._origText) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = btn._origText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
btnRefresh.addEventListener('click', async () => {
|
btnRefresh.addEventListener('click', async () => {
|
||||||
status.textContent = 'Scan en cours… (peut prendre 10-30 s selon les vCenters)';
|
setBusy('Recherche en cours… (peut prendre 10-30 s selon les vCenters)', btnRefresh);
|
||||||
btnRefresh.disabled = true;
|
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
if (fVc.value) fd.append('vcenter_id', fVc.value);
|
if (fVc.value) fd.append('vcenter_id', fVc.value);
|
||||||
@ -195,12 +235,12 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.innerHTML = '<span class="text-cyber-red">Erreur réseau : ' + escapeHTML(String(e)) + '</span>';
|
status.innerHTML = '<span class="text-cyber-red">Erreur réseau : ' + escapeHTML(String(e)) + '</span>';
|
||||||
} finally {
|
} finally {
|
||||||
btnRefresh.disabled = false;
|
clearBusy(btnRefresh);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
[fAuthor, fMinAge, fOnlyMine].forEach(el => el.addEventListener('input', render));
|
[fAuthor, fMinAge, fOnlyMine, fPcFormat].forEach(el => el.addEventListener('input', render));
|
||||||
fOnlyMine.addEventListener('change', render);
|
[fOnlyMine, fPcFormat].forEach(el => el.addEventListener('change', render));
|
||||||
|
|
||||||
selAll.addEventListener('change', () => {
|
selAll.addEventListener('change', () => {
|
||||||
tbody.querySelectorAll('.row-cb').forEach(cb => {
|
tbody.querySelectorAll('.row-cb').forEach(cb => {
|
||||||
@ -226,8 +266,7 @@
|
|||||||
if (!confirm(`⚠ Supprimer ${items.length} snapshot(s) ? Cette action est DÉFINITIVE.\n\n${lines}${more}`)) return;
|
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;
|
if (!confirm(`Confirme une 2e fois : suppression définitive de ${items.length} snapshot(s)`)) return;
|
||||||
|
|
||||||
status.textContent = 'Suppression en cours…';
|
setBusy(`Suppression en cours… (${items.length} snapshot(s))`, btnDelete);
|
||||||
btnDelete.disabled = true;
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/snapshots/delete', {
|
const r = await fetch('/snapshots/delete', {
|
||||||
method: 'POST', credentials: 'same-origin',
|
method: 'POST', credentials: 'same-origin',
|
||||||
@ -242,12 +281,12 @@
|
|||||||
msg += '\n\nÉchecs :\n' + failed.slice(0,10).map(f => ` ${f.vm_name} → ${f.snap_name}: ${f.msg}`).join('\n');
|
msg += '\n\nÉchecs :\n' + failed.slice(0,10).map(f => ` ${f.vm_name} → ${f.snap_name}: ${f.msg}`).join('\n');
|
||||||
}
|
}
|
||||||
alert(msg);
|
alert(msg);
|
||||||
// Recharger
|
clearBusy(btnDelete);
|
||||||
|
// Re-scanne pour refleter l'etat reel cote vCenter
|
||||||
btnRefresh.click();
|
btnRefresh.click();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Erreur réseau : ' + e);
|
alert('Erreur réseau : ' + e);
|
||||||
} finally {
|
clearBusy(btnDelete);
|
||||||
btnDelete.disabled = false;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user