patchcenter/app/templates/patching_iexec.html

763 lines
42 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 %}Pré-patching — iexec{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Pré-patching — workflow iexec</h2>
<p class="text-xs text-gray-500 mt-1">
{{ rows|length }} serveur(s) éligible(s).
<span class="text-cyber-yellow">Note : seuls les Linux sont concernés (Windows non géré).</span>
</p>
</div>
<a href="javascript:history.back()" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Retour</a>
</div>
{# ─── Stepper ─── #}
<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="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">
Pour chaque serveur Linux : résolution DNS · connexion SSH ·
joignabilité Satellite (LAN <code>vpdsiasat2</code> / DMZ <code>vpdsiasat1</code>) +
<code>subscription-manager identity</code> + <code>yum repolist enabled</code>.
Toutes les commandes utilisent <code>sudo -n</code>.
</p>
<table class="w-full text-xs">
<thead class="text-cyber-accent border-b border-cyber-border">
<tr>
<th class="text-left p-1">Asset</th>
<th class="text-left p-1">Hostname BDD</th>
<th class="text-left p-1">Env</th>
<th class="text-left p-1">Domaine</th>
<th class="text-left p-1">OS</th>
<th class="text-left p-1">Excludes</th>
<th class="text-left p-1">DNS</th>
<th class="text-left p-1">SSH</th>
<th class="text-left p-1">Disque</th>
<th class="text-left p-1">Satellite</th>
<th class="text-left p-1">Verdict</th>
<th class="text-left p-1">Snapshot</th>
<th class="text-left p-1">Dry-run</th>
<th class="text-left p-1">Pre-capt.</th>
<th class="text-left p-1">Patch</th>
<th class="text-left p-1">Reconnex.</th>
<th class="text-left p-1">Post-cmp.</th>
</tr>
</thead>
<tbody id="check-tbody">
{% for r in rows %}
<tr class="border-b border-cyber-border/30" data-row-id="{{ r.id }}" data-os="{{ r.os or '' }}">
<td class="p-1 font-mono">{{ r.asset_name }}</td>
<td class="p-1 font-mono">{{ r.hostname or '' }}</td>
<td class="p-1">{{ r.environnement or '' }}</td>
<td class="p-1">{{ r.domaine if r.domaine is defined else '' }}</td>
<td class="p-1">{{ r.os or '' }}</td>
<td class="p-1 text-cyber-yellow">{{ r.effective_excludes or '(aucun)' }}</td>
<td class="p-1 cell-dns text-gray-500">·</td>
<td class="p-1 cell-ssh text-gray-500">·</td>
<td class="p-1 cell-disk text-gray-500">·</td>
<td class="p-1 cell-sat text-gray-500">·</td>
<td class="p-1 cell-overall text-gray-500">en attente</td>
<td class="p-1 cell-snap text-gray-500">·</td>
<td class="p-1 cell-dry text-gray-500">·</td>
<td class="p-1 cell-pre text-gray-500">·</td>
<td class="p-1 cell-patch text-gray-500">·</td>
<td class="p-1 cell-recon text-gray-500">·</td>
<td class="p-1 cell-post text-gray-500">·</td>
</tr>
{% else %}
<tr><td colspan="17" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{# ─── Détails par serveur (panneau pliable) ─── #}
<div class="card p-3 mb-4" id="details-card" style="display:none;">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Détails du dernier check</h3>
<pre id="details-pane" class="bg-cyber-bg p-2 text-[11px] whitespace-pre-wrap overflow-x-auto" style="max-height:400px;"></pre>
</div>
{# ─── Terminal style log dry-run / patch ─── #}
<div class="card p-2 mb-4" id="term-card" style="display:none;">
<div class="flex justify-between items-center mb-1">
<h3 id="term-title" class="text-xs text-cyber-accent font-bold font-mono">$ patchcenter iexec — log</h3>
<div class="flex gap-2">
<button id="term-clear" class="text-xs text-gray-500 hover:text-cyber-accent">🗑 Vider</button>
<button id="term-close" class="text-xs text-gray-500 hover:text-cyber-accent">✕ Masquer</button>
</div>
</div>
<pre id="term-pane" class="p-3 text-[11px] whitespace-pre-wrap overflow-auto"
style="max-height:600px; background:#0a0e14; color:#cdd6f4;
font-family:'Cascadia Code','Consolas','Courier New',monospace;
border:1px solid #1f2937; line-height:1.4;"></pre>
<style>
#term-pane .t-section { color:#c678dd; font-weight:bold; }
#term-pane .t-ok { color:#a6e22e; }
#term-pane .t-warn { color:#e5c07b; }
#term-pane .t-ko { color:#ff5555; }
#term-pane .t-info { color:#61afef; }
#term-pane .t-mute { color:#7f848e; }
#term-pane .t-cmd { color:#56b6c2; font-weight:bold; }
#term-pane .t-detail { color:#abb2bf; padding-left:2ch; display:block; }
</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="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>
<script>
(function(){
const btnRun = document.getElementById('btn-run-all');
const btnStep2 = document.getElementById('btn-step2');
const btnDryrun = document.getElementById('btn-dryrun');
const btnPre = document.getElementById('btn-pre');
const btnStep3 = document.getElementById('btn-step3');
const btnReboot = document.getElementById('btn-reboot');
const btnRecon = document.getElementById('btn-recon');
const btnPost = document.getElementById('btn-post');
const tbody = document.getElementById('check-tbody');
const summary = document.getElementById('run-summary');
const detailsCard = document.getElementById('details-card');
const detailsPane = document.getElementById('details-pane');
function escapeHTML(s){
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function statusBadge(st){
if (st === 'ok') return '<span class="text-cyber-green">✓ OK</span>';
if (st === 'warn')return '<span class="text-cyber-yellow">⚠ WARN</span>';
if (st === 'ko') return '<span class="text-cyber-red">✗ KO</span>';
if (st === 'unsupported') return '<span class="text-gray-400">⊘ N/A</span>';
return '<span class="text-gray-500">' + st + '</span>';
}
function isLinux(osStr){
const s = (osStr || '').toLowerCase();
return s && !s.includes('windows') && s !== 'win';
}
async function checkOne(tr){
const rowId = tr.dataset.rowId;
const osStr = tr.dataset.os || '';
const host = tr.querySelector('td:nth-child(3)').textContent.trim();
if (!isLinux(osStr)) {
tr.querySelector('.cell-dns').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-ssh').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-disk').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-sat').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-overall').innerHTML = '<span class="text-gray-400" title="Workflow Linux uniquement">⊘ Windows</span>';
termSection('check ' + host);
termLine('⊘', 'Windows non supporté (Linux uniquement)');
return {overall: 'unsupported'};
}
termSection('check ' + host);
tr.querySelector('.cell-overall').innerHTML = '<span class="text-cyber-yellow">…</span>';
try {
const r = await fetch('/patching/iexec/check/' + rowId, {method:'POST'});
const j = await r.json();
if (!j.ok) {
tr.querySelector('.cell-overall').innerHTML = '<span class="text-cyber-red">err</span>';
termLine('✗', 'erreur backend');
return {overall: 'ko'};
}
if (j.overall === 'unsupported') {
tr.querySelector('.cell-dns').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-ssh').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-disk').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-sat').innerHTML = statusBadge('unsupported');
tr.querySelector('.cell-overall').innerHTML = '<span class="text-gray-400" title="' + (j.skipped_reason||'') + '">⊘ N/A</span>';
termLine('⊘', j.skipped_reason || 'non supporté');
return j;
}
appendTerm(' # target=' + (j.target || '?') + '\n');
const byName = {};
(j.checks || []).forEach(c => {
byName[c.name] = c;
const sym = c.status === 'ok' ? '✓' : (c.status === 'warn' ? '⚠' : '✗');
termLine(sym, c.label + ' : ' + c.message);
if (c.details && c.status !== 'ok') {
appendTerm(' │ ' + (c.details || '').split('\n').slice(0,8).join('\n │ ') + '\n');
}
});
tr.querySelector('.cell-dns').innerHTML = byName.dns ? statusBadge(byName.dns.status) : '';
tr.querySelector('.cell-ssh').innerHTML = byName.ssh ? statusBadge(byName.ssh.status) : '';
tr.querySelector('.cell-disk').innerHTML = byName.disk ? statusBadge(byName.disk.status) + ' <span class="text-[10px] text-gray-400">' + escapeHTML(byName.disk.message) + '</span>' : '';
tr.querySelector('.cell-sat').innerHTML = byName.satellite ? statusBadge(byName.satellite.status) : '';
tr.querySelector('.cell-overall').innerHTML = statusBadge(j.overall) + ' <span class="text-[10px] text-gray-500">(' + (j.duration_ms||0) + 'ms)</span>';
termLine('→', 'verdict ' + j.overall + ' (' + (j.duration_ms||0) + 'ms)');
tr._checkData = j;
return j;
} catch(e) {
tr.querySelector('.cell-overall').innerHTML = '<span class="text-cyber-red">err</span>';
termLine('✗', 'exception : ' + e.message);
return {overall: 'ko'};
}
}
btnRun.addEventListener('click', async () => {
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;
for (const tr of trs) {
const r = await checkOne(tr);
if (r.overall === 'ok') okCount++;
else if (r.overall === 'warn') warnCount++;
else if (r.overall === 'unsupported') naCount++;
else koCount++;
}
summary.innerHTML = '✓ ' + okCount + ' OK · ⚠ ' + warnCount + ' warn · ✗ ' + koCount + ' KO · ⊘ ' + naCount + ' N/A';
refreshStepButtons();
});
// ─── Terminal global (toutes les étapes) ───
const termCard = document.getElementById('term-card');
const termTitle = document.getElementById('term-title');
const termPane = document.getElementById('term-pane');
document.getElementById('term-close').addEventListener('click', () => {
termCard.style.display = 'none';
});
document.getElementById('term-clear').addEventListener('click', () => {
termPane.innerHTML = '';
});
function showTerm(){
if (termCard.style.display === 'none') {
termCard.style.display = '';
termCard.scrollIntoView({behavior:'smooth', block:'nearest'});
}
}
function appendTermHTML(html){
termPane.insertAdjacentHTML('beforeend', html);
termPane.scrollTop = termPane.scrollHeight;
}
function appendTerm(s){
// Compat : ancien API qui écrit du texte brut
appendTermHTML(escapeHTML(s));
}
function termSection(label){
showTerm();
const ts = new Date().toLocaleTimeString('fr-FR', {hour12: false});
appendTermHTML('\n<span class="t-section">══════ [' + escapeHTML(label) + '] ' + ts + ' ══════</span>\n');
}
function termLine(prefix, text){
let cls = '';
if (prefix === '✓') cls = 't-ok';
else if (prefix === '⚠') cls = 't-warn';
else if (prefix === '✗') cls = 't-ko';
else if (prefix === '⊘') cls = 't-mute';
else if (prefix === '⏳') cls = 't-warn';
else if (prefix === '→') cls = 't-info';
else if (prefix === '#') cls = 't-mute';
appendTermHTML(' <span class="' + cls + '">' + escapeHTML(prefix + ' ' + text) + '</span>\n');
}
function termDetail(text){
if (!text) return;
const lines = String(text).split('\n').slice(0, 12);
appendTermHTML('<span class="t-detail">' + escapeHTML(lines.join('\n')) + '</span>\n');
}
function classifyYumLine(s){
const ll = String(s).toLowerCase();
if (ll.startsWith('error') || ll.startsWith('erreur') || ll.includes(' fail') || ll.includes('failed')) return 't-ko';
if (ll.startsWith('warning') || ll.startsWith('attention')) return 't-warn';
if (ll.includes('complete!') || ll.includes('terminé !') || ll.includes('nothing to do') || ll.includes('rien à faire')) return 't-ok';
if (ll.startsWith('==') || ll.startsWith('--') || ll.startsWith('transaction')) return 't-info';
return '';
}
function streamYum(rowId, mode, hostname, extraExcludes){
return new Promise((resolve) => {
const titre = (mode === 'dryrun' ? 'dry-run' : 'PATCH') + ' yum ' + hostname
+ (extraExcludes ? ' [retry +' + extraExcludes + ']' : '');
termSection(titre);
let url = '/patching/iexec/yum-stream/' + rowId + '?mode=' + mode;
if (extraExcludes) url += '&extra_excludes=' + encodeURIComponent(extraExcludes);
const ev = new EventSource(url);
const result = {ok: false, rc: null, lines: 0, summary: [], problems: []};
ev.onmessage = (m) => {
let j;
try { j = JSON.parse(m.data); } catch(e) { return; }
if (j.type === 'cmd') {
appendTerm(' # host : ' + (j.hostname||'') + ' (' + (j.target||'') + ')\n');
appendTerm(' # cmd : ' + (j.cmd||'') + '\n');
appendTerm(' # excludes (' + (j.excludes||[]).length + ')\n');
} else if (j.type === 'line') {
const cls = classifyYumLine(j.data);
if (cls) appendTermHTML(' <span class="' + cls + '">' + escapeHTML(j.data) + '</span>\n');
else appendTerm(j.data + '\n');
result.lines++;
const ll = j.data.toLowerCase();
if (ll.includes('package') || ll.includes('paquet')
|| ll.includes('nothing to do') || ll.includes('rien à faire')
|| ll.includes('complete!') || ll.includes('terminé !')) {
result.summary.push(j.data);
}
} else if (j.type === 'end') {
result.rc = j.rc;
result.problems = j.problems || [];
if (mode === 'dryrun') result.ok = (j.rc === 0 || j.rc === 1);
else result.ok = (j.rc === 0);
appendTerm('\n[exit code: ' + j.rc + ' — ' + (result.ok ? 'OK' : 'KO') + ']\n');
if (!result.ok && result.problems.length) {
appendTerm('[deps détectées : ' + result.problems.join(', ') + ']\n');
}
ev.close();
resolve(result);
} else if (j.type === 'error') {
appendTerm('\n[ERROR] ' + (j.msg||'') + '\n');
result.error = j.msg;
ev.close();
resolve(result);
}
};
ev.onerror = () => {
appendTerm('\n[connection lost]\n');
ev.close();
resolve(result);
};
});
}
function buildRetryButton(rowId, mode, hostname, packages){
// Bouton qui relance streamYum avec extra_excludes
const btn = document.createElement('button');
btn.className = 'text-cyber-yellow underline hover:text-cyber-accent text-[10px]';
btn.textContent = '🔁 Retry sans ' + packages.join(', ');
btn.title = 'Relance le yum en ajoutant ces paquets aux excludes';
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const extra = packages.join(' ');
const result = await streamYum(rowId, mode, hostname, extra);
// Met à jour la cellule
updateYumCell(rowId, mode, hostname, result);
});
return btn;
}
function updateYumCell(rowId, mode, hostname, result){
const tr = tbody.querySelector('tr[data-row-id="' + rowId + '"]');
if (!tr) return;
const cell = tr.querySelector(mode === 'dryrun' ? '.cell-dry' : '.cell-patch');
if (mode === 'dryrun') tr._dryData = {ok: result.ok, rc: result.rc, summary: result.summary};
else tr._patchData = {ok: result.ok, rc: result.rc, summary: result.summary};
cell.innerHTML = '';
if (result.ok) {
const sumLine = (result.summary || []).slice(-2).join(' / ') || ('OK (' + result.lines + ' lignes)');
cell.innerHTML = '<span class="text-cyber-green">✓ </span><span class="text-[10px] text-gray-300">' + escapeHTML(sumLine.slice(0,60)) + '</span>';
} else {
cell.innerHTML = '<span class="text-cyber-red">✗ KO (rc=' + result.rc + ')</span>';
if ((result.problems || []).length) {
cell.appendChild(document.createElement('br'));
cell.appendChild(buildRetryButton(rowId, mode, hostname, result.problems));
}
}
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 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 () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
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;
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();
termSection('snapshot ' + host);
const cell = tr.querySelector('.cell-snap');
cell.innerHTML = '<span class="text-cyber-yellow">… snapshot</span>';
try {
const r = await fetch('/patching/iexec/snapshot/' + tr.dataset.rowId, {method:'POST'});
const j = await r.json();
if (j.ok) {
okCount++;
cell.innerHTML = '<span class="text-cyber-green" title="' + escapeHTML(j.detail||'') + '">✓ ' + escapeHTML(j.snap_name||'OK') + '</span>'
+ ' <span class="text-[10px] text-gray-400">(' + escapeHTML(j.vcenter||'') + ')</span>';
termLine('✓', 'snap_name=' + (j.snap_name||'') + ' on vCenter ' + (j.vcenter||''));
if (j.branch) appendTerm(' # branch=' + j.branch + ' vm=' + (j.vm_name||'') + '\n');
} else if (j.skipped) {
koCount++;
cell.innerHTML = '<span class="text-cyber-yellow">⚠ ' + escapeHTML(j.detail||'skip') + '</span>';
termLine('⚠', j.detail || 'skipped');
} else {
koCount++;
cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.detail||'') + '">✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + '</span>';
termLine('✗', j.detail || 'KO');
}
tr._snapData = j;
} catch(e) {
koCount++;
cell.innerHTML = '<span class="text-cyber-red">✗ erreur réseau</span>';
termLine('✗', 'exception : ' + e.message);
}
}
summary.innerHTML += ' · Snapshot : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
});
btnDryrun.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
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;
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()
|| tr.querySelector('td:nth-child(3)').textContent.trim();
tr.querySelector('.cell-dry').innerHTML = '<span class="text-cyber-yellow">… dry-run (live)</span>';
const result = await streamYum(tr.dataset.rowId, 'dryrun', host);
updateYumCell(tr.dataset.rowId, 'dryrun', host, result);
if (result.ok) okCount++; else koCount++;
}
summary.innerHTML += ' · Dry-run : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
});
btnPre.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
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;
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();
termSection('pre-capture ' + host);
const cell = tr.querySelector('.cell-pre');
cell.innerHTML = '<span class="text-cyber-yellow">… capture</span>';
try {
const r = await fetch('/patching/iexec/pre-capture/' + tr.dataset.rowId, {method:'POST'});
const j = await r.json();
tr._preData = j;
if (j.ok) {
okCount++;
cell.innerHTML = '<span class="text-cyber-green" title="' + escapeHTML(j.stdout||'') + '">✓ snapshot</span>';
termLine('✓', 'services + ports capturés dans /tmp/secops_*_avant_*.txt');
termDetail(j.stdout);
} else {
koCount++;
cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.detail||j.stderr||'') + '">✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + '</span>';
termLine('✗', j.detail || 'KO');
if (j.stderr) termDetail(j.stderr);
}
} catch(e) {
koCount++;
cell.innerHTML = '<span class="text-cyber-red">✗ erreur</span>';
termLine('✗', 'exception : ' + e.message);
}
}
summary.innerHTML += ' · Pre-capt : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
});
btnReboot.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
const targets = trs.filter(tr => tr._patchData && tr._patchData.ok);
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;
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();
termSection('reboot ' + host);
const cell = tr.querySelector('.cell-recon');
cell.innerHTML = '<span class="text-cyber-yellow">… reboot demandé</span>';
try {
const r = await fetch('/patching/iexec/reboot/' + tr.dataset.rowId, {method:'POST'});
const j = await r.json();
tr._rebootData = j;
if (j.ok) {
okCount++;
cell.innerHTML = '<span class="text-cyber-yellow">⏳ reboot dans 1min · ' + escapeHTML(j.started_at||'') + '</span>';
termLine('⏳', 'shutdown -r +1 envoyé · effectif dans 1 min · ' + (j.started_at||''));
} else {
koCount++;
cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.detail||j.stderr||'') + '">✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + '</span>';
termLine('✗', j.detail || 'KO');
if (j.stderr) termDetail(j.stderr);
}
} catch(e) {
koCount++;
cell.innerHTML = '<span class="text-cyber-red">✗ erreur</span>';
termLine('✗', 'exception : ' + e.message);
}
}
summary.innerHTML += ' · Reboot : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
});
btnRecon.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
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;
setBtnState(btnRecon, 'running'); setStepState('recon', 'running');
const startTs = Date.now();
// Pour chaque target, polling indépendant
await Promise.all(targets.map(async (tr) => {
const host = tr.querySelector('td:nth-child(3)').textContent.trim();
termSection('wait reconnexion ' + host);
const cell = tr.querySelector('.cell-recon');
const t0 = Date.now();
const TIMEOUT_MS = 10 * 60 * 1000;
const POLL_MS = 10 * 1000;
cell.innerHTML = '<span class="text-cyber-yellow">⏳ poll TCP/22…</span>';
let last = '';
while (Date.now() - t0 < TIMEOUT_MS) {
await new Promise(r => setTimeout(r, POLL_MS));
try {
const resp = await fetch('/patching/iexec/reboot-status/' + tr.dataset.rowId);
const j = await resp.json();
if (j.tcp22 && j.ssh) {
const dur = Math.round((Date.now() - t0) / 1000);
tr._reconData = {ok: true, downtime_s: dur, uptime: j.uptime};
cell.innerHTML = '<span class="text-cyber-green">✓ revenu en ' + dur + 's</span>'
+ '<br><span class="text-[10px] text-gray-400" title="' + escapeHTML(j.uptime||'') + '">' + escapeHTML((j.uptime||'').slice(0,60)) + '</span>';
termLine('✓', host + ' revenu en ' + dur + 's · ' + (j.uptime||''));
return;
}
const status = j.tcp22 ? 'TCP/22 OK · SSH KO' : 'pas joignable';
const elapsed = Math.round((Date.now()-t0)/1000);
cell.innerHTML = '<span class="text-cyber-yellow">⏳ ' + status + ' · ' + elapsed + 's</span>';
if (status !== last) {
termLine('⏳', host + ' : ' + status + ' (t+' + elapsed + 's)');
last = status;
}
} catch(e) { /* retry */ }
}
tr._reconData = {ok: false};
cell.innerHTML = '<span class="text-cyber-red">✗ timeout 10 min</span>';
termLine('✗', host + ' : timeout 10 min sans reconnexion');
}));
refreshStepButtons();
});
btnPost.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
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;
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();
termSection('post-compare ' + host);
const cell = tr.querySelector('.cell-post');
cell.innerHTML = '<span class="text-cyber-yellow">… compare</span>';
try {
const r = await fetch('/patching/iexec/post-compare/' + tr.dataset.rowId, {method:'POST'});
const j = await r.json();
tr._postData = j;
const st = j.status || (j.ok ? 'ok' : 'ko');
const rep = j.report || {};
const dispSvc = (rep.services_disparus||[]).length;
const appSvc = (rep.services_apparus||[]).length;
const dispPort = (rep.ports_disparus||[]).length;
const appPort = (rep.ports_apparus||[]).length;
const summ = 'svc -' + dispSvc + ' +' + appSvc + ' / port -' + dispPort + ' +' + appPort;
if (st === 'ok') { okCount++; cell.innerHTML = '<span class="text-cyber-green">✓ ' + escapeHTML(summ) + '</span>'; termLine('✓', summ); }
else if (st === 'warn') { warnCount++; cell.innerHTML = '<span class="text-cyber-yellow" title="' + escapeHTML(j.stdout||'') + '">⚠ ' + escapeHTML(summ) + '</span>'; termLine('⚠', summ); }
else { koCount++; cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.stdout||j.detail||'') + '">✗ ' + escapeHTML(summ) + '</span>'; termLine('✗', summ); }
if (rep.services_disparus && rep.services_disparus.length)
appendTermHTML(' <span class="t-ko">services disparus :</span> ' + escapeHTML(rep.services_disparus.join(', ')) + '\n');
if (rep.services_apparus && rep.services_apparus.length)
appendTermHTML(' <span class="t-warn">services apparus :</span> ' + escapeHTML(rep.services_apparus.join(', ')) + '\n');
if (rep.ports_disparus && rep.ports_disparus.length)
appendTermHTML(' <span class="t-ko">ports disparus :</span> ' + escapeHTML(rep.ports_disparus.join(', ')) + '\n');
if (rep.ports_apparus && rep.ports_apparus.length)
appendTermHTML(' <span class="t-warn">ports apparus :</span> ' + escapeHTML(rep.ports_apparus.join(', ')) + '\n');
} catch(e) {
koCount++;
cell.innerHTML = '<span class="text-cyber-red">✗ erreur</span>';
termLine('✗', 'exception : ' + e.message);
}
}
summary.innerHTML += ' · Post-cmp : ✓ ' + okCount + ' · ⚠ ' + warnCount + ' · ✗ ' + koCount;
refreshStepButtons();
});
btnStep3.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
const targets = trs.filter(tr => tr._preData && tr._preData.ok);
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;
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()
|| tr.querySelector('td:nth-child(3)').textContent.trim();
tr.querySelector('.cell-patch').innerHTML = '<span class="text-cyber-yellow">… patch (live)</span>';
const result = await streamYum(tr.dataset.rowId, 'update', host);
updateYumCell(tr.dataset.rowId, 'update', host, result);
if (result.ok) okCount++; else koCount++;
}
summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;
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]');
if (!tr || !tr._checkData) return;
detailsCard.style.display = '';
const j = tr._checkData;
let txt = `Hostname: ${j.hostname || ''}\nTarget: ${j.target || ''}\nVerdict: ${j.overall}\n\n`;
(j.checks || []).forEach(c => {
txt += `[${c.status.toUpperCase()}] ${c.label} : ${c.message}\n`;
if (c.details) txt += c.details + '\n';
txt += '---\n';
});
detailsPane.textContent = txt;
});
})();
</script>
{% endblock %}