patchcenter/app/templates/patching_iexec.html
Admin MPCZ 29f6153370 feat(pct): workflow prevenance PCT (auto-detection + gate confirmation + suffixe Teams)
- Migration migrate_pct_workflow_20260507.sql: ajoute patch_planning_import_rows
  pct_required (boolean default false), pct_confirmed_at (timestamptz),
  pct_confirmed_by_user_id (FK users). Backfill depuis servers.pct_required.
- Auto-detection a l'import (planning_import.py): scan referent_technique +
  mode_operatoire + impacts + commentaire pour pattern \bPCT\b mot entier
  (insensible casse) -> pct_required=true sur la row. Propage egalement vers
  servers.pct_required si pas deja true.
- UI iexec: badge orange '⚠ Prév PCT à faire' sur la cellule asset_name si
  pct_required=true et pas confirme, badge vert ' PCT ok' une fois confirme.
- Gate avant Step 3 (PATCH REEL): scan des serveurs cibles, si certains ont
  pct_required && !pct_confirmed -> 2 confirmations successives + appel
  POST /patching/iexec/confirm-pct qui marque pct_confirmed_at + user_id.
  Ne lance pas le patch si l'operateur annule.
- Endpoint POST /patching/iexec/confirm-pct: marque les rows comme PCT confirmes
  (pct_confirmed_at = now(), pct_confirmed_by_user_id = current user).
- Notif Teams: send_notification accepte planning_row_id optionnel ; si la row
  a pct_required && pct_confirmed, le message debut/fin est suffixe par
  ' (Prévenance PCT ok)' pour informer le responsable que l'amont a ete gere.
2026-05-07 08:19:19 +02:00

837 lines
46 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 '' }}"
data-pct-required="{{ '1' if r.pct_required else '0' }}"
data-pct-confirmed="{{ '1' if r.pct_confirmed_at else '0' }}">
<td class="p-1 font-mono">{{ r.asset_name }}{% if r.pct_required %}
<span class="badge {% if r.pct_confirmed_at %}badge-green{% else %}badge-orange{% endif %} ml-1 cell-pct-badge"
title="{% if r.pct_confirmed_at %}PCT confirmé le {{ r.pct_confirmed_at }}{% else %}Prévenance PCT requise — à confirmer avant le patch{% endif %}">
{% if r.pct_confirmed_at %}✅ PCT ok{% else %}⚠ Prév PCT à faire{% endif %}
</span>
{% endif %}</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 mb-4" id="details-card" style="display:none;">
<div class="flex justify-between items-center p-3 border-b border-cyber-border cursor-pointer"
onclick="toggleDetails()">
<h3 class="text-sm font-bold text-cyber-accent">
<span id="details-arrow"></span> Détails du dernier check
</h3>
<button onclick="event.stopPropagation(); document.getElementById('details-card').style.display='none';"
class="text-xs text-gray-500 hover:text-cyber-accent">✕ Fermer</button>
</div>
<pre id="details-pane" class="bg-cyber-bg p-2 text-[11px] whitespace-pre-wrap overflow-x-auto"
style="max-height:400px;"></pre>
</div>
<script>
function toggleDetails(){
const pane = document.getElementById('details-pane');
const arr = document.getElementById('details-arrow');
const collapsed = pane.style.display === 'none';
pane.style.display = collapsed ? '' : 'none';
arr.textContent = collapsed ? '▾' : '▸';
}
</script>
{# ─── 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; }
// ─── Gate Prévenance PCT ─────────────────────────────────────
// Pour chaque serveur cible avec pct_required=1 et pct_confirmed=0,
// demander la confirmation que la PCT a été prévenue.
const pctPending = targets.filter(tr =>
tr.dataset.pctRequired === '1' && tr.dataset.pctConfirmed !== '1'
);
if (pctPending.length) {
const list = pctPending.map(tr =>
' • ' + (tr.querySelector('td:nth-child(2)').textContent.trim()
|| tr.querySelector('td:nth-child(1)').textContent.trim())
).join('\n');
const msg = '⚠ PRÉVENANCE PCT requise pour ' + pctPending.length + ' serveur(s) :\n\n'
+ list + '\n\n'
+ 'La PCT doit avoir été prévenue EN AMONT de cette intervention.\n'
+ 'Confirmer que la prévenance PCT a bien été faite pour ces serveurs ?';
if (!confirm(msg)) {
alert('Patch annulé. Préviens la PCT puis relance le step 3.');
return;
}
// Re-confirm pour éviter le clic réflexe
if (!confirm('Tu confirmes une 2e fois que la PCT est prévenue pour ces ' + pctPending.length + ' serveur(s) ? (cette confirmation est tracée en BDD)')) {
alert('Patch annulé.');
return;
}
// Marquer en BDD
const rowIds = pctPending.map(tr => tr.dataset.rowId).join(',');
try {
const fd = new FormData(); fd.append('row_ids', rowIds);
const r = await fetch('/patching/iexec/confirm-pct', {
method: 'POST', credentials: 'same-origin', body: fd,
});
const j = await r.json();
if (!j.ok) { alert('Échec enregistrement confirmation PCT: ' + (j.msg || '')); return; }
// Mettre à jour DOM : badges en vert, attribut pct_confirmed=1
pctPending.forEach(tr => {
tr.dataset.pctConfirmed = '1';
const badge = tr.querySelector('.cell-pct-badge');
if (badge) {
badge.classList.remove('badge-orange');
badge.classList.add('badge-green');
badge.textContent = '✅ PCT ok';
}
});
} catch (e) {
alert('Erreur réseau confirm-pct: ' + e); return;
}
}
// ─── Fin gate PCT ────────────────────────────────────────────
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 %}