feat(patching/iexec): boutons et stepper avec etats visuels (gris pending / orange en cours / vert done / rouge failed) - cascade automatique selon resultats accumules + animation pulse pour running

This commit is contained in:
Pierre & Lumière 2026-05-05 12:58:39 +02:00
parent 0f5296ab40
commit 830eaaa519

View File

@ -13,19 +13,44 @@
</div>
{# ─── Stepper ─── #}
<div class="flex items-center mb-4 gap-2 text-xs">
<span class="px-3 py-1 rounded bg-cyber-yellow/20 text-cyber-yellow font-bold">1. Vérifications</span>
<span class="text-gray-500"></span>
<span class="px-3 py-1 rounded bg-cyber-green/20 text-cyber-green font-bold">2. Snapshot</span>
<span class="text-gray-500"></span>
<span class="px-3 py-1 rounded bg-cyber-blue/20 text-cyber-blue font-bold">3. Patch yum</span>
<style>
.step-pill { padding:.35rem .75rem; border-radius:.5rem; font-weight:bold;
font-size:.7rem; transition:all .2s; border:1px solid transparent; }
.step-pill.s-pending { background:rgba(75,85,99,.15); color:#6b7280;
border-color:rgba(75,85,99,.3); opacity:.6; }
.step-pill.s-current { background:rgba(245,158,11,.18); color:#f59e0b;
border-color:#f59e0b; box-shadow:0 0 12px rgba(245,158,11,.35); }
.step-pill.s-done { background:rgba(34,197,94,.18); color:#22c55e;
border-color:rgba(34,197,94,.5); }
.step-pill.s-failed { background:rgba(239,68,68,.2); color:#ef4444;
border-color:#ef4444; box-shadow:0 0 10px rgba(239,68,68,.35); }
.step-pill.s-running { background:rgba(245,158,11,.25); color:#f59e0b;
border-color:#f59e0b; animation: pulseIt 1.2s infinite; }
@keyframes pulseIt { 0%,100% { opacity:1 } 50% { opacity:.55 } }
</style>
<div class="flex items-center mb-4 gap-1 text-xs flex-wrap" id="stepper">
<span data-step="check" class="step-pill s-current">1 Vérif</span>
<span class="text-gray-600"></span>
<span data-step="snap" class="step-pill s-pending">2 Snapshot</span>
<span class="text-gray-600"></span>
<span data-step="dry" class="step-pill s-pending">3a Dry-run</span>
<span class="text-gray-600"></span>
<span data-step="pre" class="step-pill s-pending">3b Pre-capt</span>
<span class="text-gray-600"></span>
<span data-step="patch" class="step-pill s-pending">3c Patch</span>
<span class="text-gray-600"></span>
<span data-step="reboot" class="step-pill s-pending">3e Reboot</span>
<span class="text-gray-600"></span>
<span data-step="recon" class="step-pill s-pending">3f Reconn.</span>
<span class="text-gray-600"></span>
<span data-step="post" class="step-pill s-pending">3g Post-cmp</span>
</div>
<div class="card p-3 mb-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-bold text-cyber-accent">Step 1 — Vérifications pré-patching</h3>
<div class="flex gap-2">
<button id="btn-run-all" class="btn-primary px-3 py-1 text-xs">Lancer les checks</button>
<button id="btn-run-all" class="step-btn s-current" title="Lance les vérifs DNS+SSH+Disque+Satellite">1 Lancer les vérifs</button>
</div>
</div>
<p class="text-xs text-gray-500 mb-3">
@ -116,30 +141,35 @@
</style>
</div>
<style>
.step-btn { padding:.5rem 1rem; border-radius:.5rem; font-weight:bold;
font-size:.75rem; transition:all .15s; border:1px solid transparent;
white-space:nowrap; cursor:pointer; }
.step-btn:disabled { cursor:not-allowed; }
.step-btn.s-pending { background:rgba(75,85,99,.15); color:#6b7280;
border-color:rgba(75,85,99,.3); opacity:.55; }
.step-btn.s-current { background:rgba(245,158,11,.2); color:#f59e0b;
border-color:#f59e0b; box-shadow:0 0 14px rgba(245,158,11,.4); }
.step-btn.s-current:hover { background:rgba(245,158,11,.35); }
.step-btn.s-done { background:rgba(34,197,94,.2); color:#22c55e;
border-color:rgba(34,197,94,.5); }
.step-btn.s-done:hover { background:rgba(34,197,94,.35); }
.step-btn.s-failed { background:rgba(239,68,68,.2); color:#ef4444;
border-color:#ef4444; box-shadow:0 0 12px rgba(239,68,68,.4); }
.step-btn.s-failed:hover { background:rgba(239,68,68,.35); }
.step-btn.s-running { background:rgba(245,158,11,.3); color:#f59e0b;
border-color:#f59e0b; animation: pulseIt 1.2s infinite; }
</style>
<div class="flex justify-between items-center mt-4 flex-wrap gap-2">
<span id="run-summary" class="text-xs text-gray-400"></span>
<div class="flex gap-2 flex-wrap">
<button id="btn-step2" class="btn-sm bg-cyber-green/20 text-cyber-green px-4 py-2 text-xs" disabled>
→ Step 2 (snapshot vCenter)
</button>
<button id="btn-dryrun" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-4 py-2 text-xs" disabled title="yum update --assumeno : simule sans appliquer">
→ 3a Dry-run
</button>
<button id="btn-pre" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-4 py-2 text-xs" disabled title="Capture services + ports avant patch">
→ 3b Pre-capt.
</button>
<button id="btn-step3" class="btn-sm bg-cyber-blue/20 text-cyber-blue px-4 py-2 text-xs" disabled title="yum update -y : applique réellement les patchs">
→ 3c Patcher
</button>
<button id="btn-reboot" class="btn-sm bg-cyber-red/20 text-cyber-red px-4 py-2 text-xs" disabled title="shutdown -r +1 sur les serveurs patchés (double confirmation)">
→ 3e Reboot
</button>
<button id="btn-recon" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-4 py-2 text-xs" disabled title="Polle TCP/22 + SSH jusqu'à reconnexion">
→ 3f Wait reconn.
</button>
<button id="btn-post" class="btn-sm bg-cyber-blue/20 text-cyber-blue px-4 py-2 text-xs" disabled title="Compare services/ports avant/après patch (à lancer après reconnexion)">
→ 3g Post-cmp.
</button>
<button id="btn-step2" class="step-btn s-pending" disabled title="Snapshot vCenter">2 Snapshot</button>
<button id="btn-dryrun" class="step-btn s-pending" disabled title="yum update --assumeno : simule sans appliquer">3a Dry-run</button>
<button id="btn-pre" class="step-btn s-pending" disabled title="Capture services + ports avant patch">3b Pre-capt.</button>
<button id="btn-step3" class="step-btn s-pending" disabled title="yum update -y : applique réellement (double confirmation)">3c Patcher</button>
<button id="btn-reboot" class="step-btn s-pending" disabled title="shutdown -r +1 sur les serveurs patchés (double confirmation)">3e Reboot</button>
<button id="btn-recon" class="step-btn s-pending" disabled title="Polle TCP/22 + SSH jusqu'à reconnexion">3f Wait reconn.</button>
<button id="btn-post" class="step-btn s-pending" disabled title="Compare services/ports avant/après patch">3g Post-cmp.</button>
</div>
</div>
@ -235,7 +265,7 @@
}
btnRun.addEventListener('click', async () => {
btnRun.disabled = true; btnStep2.disabled = true;
setBtnState(btnRun, 'running'); setStepState('check', 'running');
summary.textContent = 'Lancement…';
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
let okCount = 0, warnCount = 0, koCount = 0, naCount = 0;
@ -247,8 +277,7 @@
else koCount++;
}
summary.innerHTML = '✓ ' + okCount + ' OK · ⚠ ' + warnCount + ' warn · ✗ ' + koCount + ' KO · ⊘ ' + naCount + ' N/A';
btnRun.disabled = false;
btnStep2.disabled = (okCount === 0);
refreshStepButtons();
});
// ─── Terminal global (toutes les étapes) ───
@ -394,21 +423,77 @@
refreshStepButtons();
}
// ─── États visuels boutons + stepper ───
function setBtnState(btn, state){
if (!btn) return;
btn.classList.remove('s-pending','s-current','s-done','s-failed','s-running');
btn.classList.add('s-' + state);
btn.disabled = (state === 'pending' || state === 'running');
}
function setStepState(name, state){
const el = document.querySelector('#stepper [data-step="' + name + '"]');
if (!el) return;
el.classList.remove('s-pending','s-current','s-done','s-failed','s-running');
el.classList.add('s-' + state);
}
// Calcul de l'état de chaque étape selon les données accumulées
function deriveState(prevDoneCount, ownAttempted, ownOkCount){
// pending : étape précédente pas encore validée
if (prevDoneCount === 0) return 'pending';
// pas encore tenté : étape actuelle (à faire)
if (!ownAttempted) return 'current';
// tenté : ok si au moins 1 OK, failed sinon
return ownOkCount > 0 ? 'done' : 'failed';
}
function refreshStepButtons(){
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok');
const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok);
const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok);
const preOk = trs.filter(tr => tr._preData && tr._preData.ok);
const patchOk = trs.filter(tr => tr._patchData && tr._patchData.ok);
const recOk = trs.filter(tr => tr._reconData && tr._reconData.ok);
btnStep2.disabled = (ckOk.length === 0);
btnDryrun.disabled = (snapOk.length === 0);
btnPre.disabled = (dryOk.length === 0);
btnStep3.disabled = (preOk.length === 0);
btnReboot.disabled = (patchOk.length === 0);
btnRecon.disabled = (patchOk.length === 0);
btnPost.disabled = (recOk.length === 0 && patchOk.length === 0);
const checkAttempted = trs.some(tr => tr._checkData);
const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok').length;
const snapAttempted = trs.some(tr => tr._snapData);
const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok).length;
const dryAttempted = trs.some(tr => tr._dryData);
const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok).length;
const preAttempted = trs.some(tr => tr._preData);
const preOk = trs.filter(tr => tr._preData && tr._preData.ok).length;
const patchAttempted = trs.some(tr => tr._patchData);
const patchOk = trs.filter(tr => tr._patchData && tr._patchData.ok).length;
const rebootAttempted = trs.some(tr => tr._rebootData);
const rebootOk = trs.filter(tr => tr._rebootData && tr._rebootData.ok).length;
const reconAttempted = trs.some(tr => tr._reconData);
const reconOk = trs.filter(tr => tr._reconData && tr._reconData.ok).length;
const postAttempted = trs.some(tr => tr._postData);
const postOk = trs.filter(tr => tr._postData && tr._postData.ok).length;
// Step 1 (check) : toujours dispo. current si pas tenté, done si OK, failed si tenté KO
const checkState = !checkAttempted ? 'current' : (ckOk > 0 ? 'done' : 'failed');
setBtnState(btnRun, checkState);
setStepState('check', checkState);
// Cascade : chaque étape dépend du `done` de la précédente
const snapState = deriveState(ckOk, snapAttempted, snapOk);
setBtnState(btnStep2, snapState); setStepState('snap', snapState);
const dryState = deriveState(snapOk, dryAttempted, dryOk);
setBtnState(btnDryrun, dryState); setStepState('dry', dryState);
const preState = deriveState(dryOk, preAttempted, preOk);
setBtnState(btnPre, preState); setStepState('pre', preState);
const patchState = deriveState(preOk, patchAttempted, patchOk);
setBtnState(btnStep3, patchState); setStepState('patch', patchState);
const rebootState = deriveState(patchOk, rebootAttempted, rebootOk);
setBtnState(btnReboot, rebootState); setStepState('reboot', rebootState);
const reconState = deriveState(patchOk, reconAttempted, reconOk);
setBtnState(btnRecon, reconState); setStepState('recon', reconState);
// Post-cmp dispo dès qu'on a soit recon OK (idéal après reboot) soit patch OK
const postPrevDone = Math.max(reconOk, patchOk);
const postState = deriveState(postPrevDone, postAttempted, postOk);
setBtnState(btnPost, postState); setStepState('post', postState);
}
btnStep2.addEventListener('click', async () => {
@ -416,8 +501,7 @@
const okTrs = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok');
if (!okTrs.length) { alert('Aucune ligne avec verdict OK'); return; }
if (!confirm('Lancer snapshot vCenter pour ' + okTrs.length + ' serveur(s) ?\n(nom = <intervenant>_YYYY-MM-DD_avant_patch)')) return;
btnStep2.disabled = true;
btnRun.disabled = true;
setBtnState(btnStep2, 'running'); setStepState('snap', 'running');
let okCount = 0, koCount = 0;
for (const tr of okTrs) {
const host = tr.querySelector('td:nth-child(3)').textContent.trim();
@ -449,7 +533,6 @@
termLine('✗', 'exception : ' + e.message);
}
}
btnRun.disabled = false;
summary.innerHTML += ' · Snapshot : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
});
@ -459,7 +542,7 @@
const targets = trs.filter(tr => tr._snapData && tr._snapData.ok);
if (!targets.length) { alert('Aucun serveur avec snapshot OK'); return; }
if (!confirm('Lancer dry-run yum (simulation) sur ' + targets.length + ' serveur(s) ?\nLog en temps réel dans le terminal.')) return;
btnDryrun.disabled = true; btnStep3.disabled = true;
setBtnState(btnDryrun, 'running'); setStepState('dry', 'running');
let okCount = 0, koCount = 0;
for (const tr of targets) {
const host = tr.querySelector('td:nth-child(2)').textContent.trim()
@ -478,7 +561,7 @@
const targets = trs.filter(tr => tr._dryData && tr._dryData.ok);
if (!targets.length) { alert('Aucun serveur avec dry-run OK'); return; }
if (!confirm('Capture services+ports avant patch sur ' + targets.length + ' serveur(s) ?')) return;
btnPre.disabled = true; btnStep3.disabled = true;
setBtnState(btnPre, 'running'); setStepState('pre', 'running');
let okCount = 0, koCount = 0;
for (const tr of targets) {
const host = tr.querySelector('td:nth-child(3)').textContent.trim();
@ -516,7 +599,7 @@
if (!targets.length) { alert('Aucun serveur avec patch OK'); return; }
if (!confirm('⚠ REBOOT ⚠\n\nDéclencher `shutdown -r +1` sur ' + targets.length + ' serveur(s) ?\n(le reboot effectif a lieu dans 1 minute)')) return;
if (!confirm('Vraiment ? Liste des hôtes :\n' + targets.map(tr => tr.querySelector('td:nth-child(3)').textContent.trim()).join('\n') + '\n\nConfirmer le reboot ?')) return;
btnReboot.disabled = true;
setBtnState(btnReboot, 'running'); setStepState('reboot', 'running');
let okCount = 0, koCount = 0;
for (const tr of targets) {
const host = tr.querySelector('td:nth-child(3)').textContent.trim();
@ -544,6 +627,7 @@
}
}
summary.innerHTML += ' · Reboot : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
});
btnRecon.addEventListener('click', async () => {
@ -551,7 +635,7 @@
const targets = trs.filter(tr => tr._patchData && tr._patchData.ok);
if (!targets.length) { alert('Aucun serveur avec patch OK'); return; }
if (!confirm('Attendre la reconnexion (TCP/22 + SSH) sur ' + targets.length + ' serveur(s) ?\nPoll toutes les 10s, timeout 10 min par serveur.')) return;
btnRecon.disabled = true;
setBtnState(btnRecon, 'running'); setStepState('recon', 'running');
const startTs = Date.now();
// Pour chaque target, polling indépendant
await Promise.all(targets.map(async (tr) => {
@ -597,7 +681,7 @@
const targets = trs.filter(tr => tr._patchData && tr._patchData.ok);
if (!targets.length) { alert('Aucun serveur avec patch OK'); return; }
if (!confirm('Comparer services+ports avant/après patch sur ' + targets.length + ' serveur(s) ?\n(à lancer après le reboot du serveur)')) return;
btnPost.disabled = true;
setBtnState(btnPost, 'running'); setStepState('post', 'running');
let okCount = 0, warnCount = 0, koCount = 0;
for (const tr of targets) {
const host = tr.querySelector('td:nth-child(3)').textContent.trim();
@ -642,7 +726,7 @@
if (!targets.length) { alert('Aucun serveur avec pre-capture OK'); return; }
if (!confirm('⚠ ATTENTION ⚠\n\nLancer yum update -y (PATCH RÉEL) sur ' + targets.length + ' serveur(s) ?\nSnapshot + pre-capture déjà faits.\nLog en temps réel dans le terminal.')) return;
if (!confirm('Confirmer une 2e fois : patcher RÉELLEMENT ' + targets.length + ' serveur(s) ?')) return;
btnStep3.disabled = true; btnDryrun.disabled = true; btnStep2.disabled = true;
setBtnState(btnStep3, 'running'); setStepState('patch', 'running');
let okCount = 0, koCount = 0;
for (const tr of targets) {
const host = tr.querySelector('td:nth-child(2)').textContent.trim()
@ -656,6 +740,9 @@
refreshStepButtons();
});
// Init états visuels au chargement
refreshStepButtons();
// Click sur une ligne → afficher les détails
tbody.addEventListener('click', (ev) => {
const tr = ev.target.closest('tr[data-row-id]');