patchcenter/app/templates/patching_iexec.html
Admin MPCZ 90444c0c56 feat(patching): particularites par serveur (notes wiki SANEF) + skip_first_reboot + reboot_delay cluster
Migration migrate_patching_notes_20260507.sql:
- servers.skip_first_reboot boolean (TPV1: vptraatpf1/2 a true)
- servers.patching_notes text (markdown — meme operateur)
- server_clusters.reboot_delay_min_minutes int default 0
- Backfill patching_notes pour 22 cas particuliers du wiki SANEF:
  ASM Oracle (~50 hosts kernel*), TPV1, HAproxy FL, Covoiturage,
  SI Patrimoine (vrpatbsip1 avant vrpataels1, kibana, certs),
  Talend, Scoop (Debian apt-mark hold + CentOS containers Docker),
  DATI (pm2/tomcat post-reboot), COMMVAULT (mode maintenance),
  Masterparc (kmeihm pm2), Splunk (RPM special),
  Site institutionnel (HAproxy backend rotation, no *node*),
  Centreon (1 par 1 + check centengine), Sextan (10min reboot delay),
  OCTAN, PAIPOR (site maintenance), Gaspar, Postgres, Oracle OEM, SMTP relay
- Cluster Sextan cree (10 min entre reboots) + 10 serveurs Sextan rattaches

UI iexec:
- Banner cumule en haut: '⚠ Particularites pour N serveur(s)' si au moins 1 note
- Badges sur la cellule asset_name: ⚠ note (modal markdown au clic),
  ⏭ skip 1st reboot, ⏱ Xmin (cluster reboot delay)
- Modal patching_notes avec rendu pre/markdown, fermeture Escape

UI fiche serveur (server_detail.html):
- Ligne 'Skip 1er reboot' dans bloc Patching
- Bandeau orange particularites avec contenu patching_notes si renseigne

Pas encore implemente cote logique d'execution (Phase 2):
- skip_first_reboot logic dans le step reboot
- enforcement reboot_delay_min_minutes entre membres cluster
- Pour l'instant: notes affichees en mode 'memo operateur' uniquement
2026-05-07 11:41:05 +02:00

883 lines
49 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>
{# Banner cumulé des particularités (servers avec patching_notes) #}
{% set has_notes = rows|selectattr('patching_notes')|list %}
{% if has_notes %}
<div class="card p-3 mb-4 border-l-4 border-cyber-orange bg-cyber-orange/10">
<h3 class="text-sm font-bold text-cyber-orange mb-2">⚠ Particularités de patching pour {{ has_notes|length }} serveur(s)</h3>
<p class="text-xs text-gray-400">Clique sur le badge <span class="badge badge-orange">⚠ note</span> à côté du nom du serveur pour voir la procédure spécifique.</p>
</div>
{% endif %}
<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' }}"
data-skip-first-reboot="{{ '1' if r.skip_first_reboot else '0' }}"
data-cluster-name="{{ r.cluster_name or '' }}"
data-reboot-delay-min="{{ r.reboot_delay_min_minutes or 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 %}{% if r.skip_first_reboot %}
<span class="badge badge-blue ml-1" title="Pas de 1er reboot post-patch">⏭ skip 1st reboot</span>
{% endif %}{% if r.patching_notes %}
<button type="button" class="badge badge-orange ml-1 cursor-pointer"
onclick="showPatchingNote(this, '{{ r.asset_name|e }}')"
data-note="{{ r.patching_notes|e }}"
title="Particularités de patching — clique pour afficher">⚠ note</button>
{% endif %}{% if r.cluster_name and r.reboot_delay_min_minutes and r.reboot_delay_min_minutes > 0 %}
<span class="badge badge-gray ml-1" title="Cluster {{ r.cluster_name }} — délai min {{ r.reboot_delay_min_minutes }} min entre reboots">⏱ {{ r.reboot_delay_min_minutes }}min</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>
{# Modal de patching_notes (affiché au clic sur badge ⚠ note) #}
<div id="patching-note-modal" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50" onclick="if(event.target===this)closePatchingNote()">
<div class="card p-5 max-w-3xl w-full max-h-[80vh] overflow-y-auto m-4">
<div class="flex justify-between items-center mb-3 border-b border-cyber-border pb-2">
<h3 class="text-cyber-orange font-bold text-lg">⚠ Particularités de patching — <span id="patching-note-title"></span></h3>
<button type="button" onclick="closePatchingNote()" class="text-gray-400 hover:text-white text-xl"></button>
</div>
<pre id="patching-note-body" class="text-xs whitespace-pre-wrap text-gray-200 font-mono"></pre>
</div>
</div>
<script>
function showPatchingNote(btn, asset) {
document.getElementById('patching-note-title').textContent = asset;
document.getElementById('patching-note-body').textContent = btn.dataset.note || '';
const m = document.getElementById('patching-note-modal');
m.classList.remove('hidden'); m.classList.add('flex');
}
function closePatchingNote() {
const m = document.getElementById('patching-note-modal');
m.classList.add('hidden'); m.classList.remove('flex');
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closePatchingNote(); });
</script>
{% endblock %}