- /patching/config-exclusions: exclusions iTop par serveur + bulk + push iTop
- /quickwin/config: liste globale reboot packages (au lieu de per-server)
- /patching/correspondance: builder mark PROD/NON-PROD + bulk change env/app
+ auto-detect par nomenclature + exclut stock/obsolete
- /patching/validations: workflow post-patching (en_attente/OK/KO/force)
validator obligatoire depuis contacts iTop
- /patching/validations/history/{id}: historique par serveur
- Auto creation patch_validation apres status='patched' dans QuickWin
- check_prod_validations: banniere rouge sur quickwin detail si non-prod non valides
- Menu: Correspondance sous Serveurs, Config exclusions+Validations sous Patching
- Colonne Equivalent(s) sur /servers + section Correspondance sur detail
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
919 lines
51 KiB
HTML
919 lines
51 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}QuickWin #{{ run.id }}{% endblock %}
|
|
|
|
{% set STEPS = [
|
|
("draft", "Brouillon", "#94a3b8"),
|
|
("prereq", "Pr\u00e9requis", "#00d4ff"),
|
|
("snapshot", "Snapshot", "#a78bfa"),
|
|
("patching", "Patching", "#ffcc00"),
|
|
("result", "R\u00e9sultats", "#00ff88"),
|
|
("completed", "Termin\u00e9", "#10b981"),
|
|
] %}
|
|
{% set current_step_idx = namespace(val=0) %}
|
|
{% for s in STEPS %}{% if s[0] == run.status %}{% set current_step_idx.val = loop.index0 %}{% endif %}{% endfor %}
|
|
{% set can_modify = run.status in ('draft', 'prereq') %}
|
|
|
|
{% macro qs(hp=hp_page, pp=p_page) -%}
|
|
?hp_page={{ hp }}&p_page={{ pp }}&per_page={{ per_page }}&search={{ filters.search or '' }}&status={{ filters.status or '' }}&domain={{ filters.domain or '' }}
|
|
{%- endmacro %}
|
|
|
|
{% block content %}
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagnes</a>
|
|
<h1 class="text-xl font-bold" style="color:#00d4ff">{{ run.label }}</h1>
|
|
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }}</p>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
<a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
|
|
<a href="/patching/validations?campaign_id={{ run.id }}" class="btn-sm" style="background:#1e3a5f;color:#00ff88;padding:4px 14px;text-decoration:none">Validations</a>
|
|
<a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
|
|
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">
|
|
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{% if msg %}
|
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">{{ msg }}</div>
|
|
{% endif %}
|
|
|
|
<!-- Step Progress Bar -->
|
|
<div class="card mb-4" style="padding:16px 20px">
|
|
<div style="display:flex;align-items:center;gap:0">
|
|
{% for step_id, step_label, step_color in STEPS %}
|
|
{% set idx = loop.index0 %}
|
|
{% set is_current = (step_id == run.status) %}
|
|
{% set is_done = (idx < current_step_idx.val) %}
|
|
<div style="flex:1;text-align:center;position:relative">
|
|
<div style="width:32px;height:32px;border-radius:50%;margin:0 auto 4px;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:0.8rem;
|
|
{% if is_done %}background:{{ step_color }};color:#0a0e17{% elif is_current %}background:{{ step_color }};color:#0a0e17;box-shadow:0 0 12px {{ step_color }}{% else %}background:#1e3a5f;color:#4a5568{% endif %}">
|
|
{% if is_done %}✓{% else %}{{ idx + 1 }}{% endif %}
|
|
</div>
|
|
<div style="font-size:0.7rem;{% if is_current %}color:{{ step_color }};font-weight:bold{% elif is_done %}color:#94a3b8{% else %}color:#4a5568{% endif %}">{{ step_label }}</div>
|
|
</div>
|
|
{% if not loop.last %}
|
|
<div style="flex:1;height:2px;{% if idx < current_step_idx.val %}background:{{ step_color }}{% else %}background:#1e3a5f{% endif %};margin-bottom:18px"></div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;margin-top:12px">
|
|
{% if current_step_idx.val > 0 %}
|
|
<form method="post" action="/quickwin/{{ run.id }}/advance">
|
|
<input type="hidden" name="target" value="{{ STEPS[current_step_idx.val - 1][0] }}">
|
|
<button class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px">← Étape précédente</button>
|
|
</form>
|
|
{% else %}<div></div>{% endif %}
|
|
{% if current_step_idx.val < 5 %}
|
|
<form method="post" action="/quickwin/{{ run.id }}/advance">
|
|
<input type="hidden" name="target" value="{{ STEPS[current_step_idx.val + 1][0] }}">
|
|
<button class="btn-primary" style="padding:4px 18px;font-size:0.85rem">Étape suivante : {{ STEPS[current_step_idx.val + 1][1] }} →</button>
|
|
</form>
|
|
{% else %}<div></div>{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPIs -->
|
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px">
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div>
|
|
<div class="text-xs text-gray-500">Total</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#00d4ff">{{ stats.hprod_total }}</div>
|
|
<div class="text-xs text-gray-500">H-Prod</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#ffcc00">{{ stats.prod_total }}</div>
|
|
<div class="text-xs text-gray-500">Prod</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#00ff88">{{ stats.patched }}</div>
|
|
<div class="text-xs text-gray-500">Patchés</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#ff3366">{{ stats.failed }}</div>
|
|
<div class="text-xs text-gray-500">KO</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#ff8800">{{ stats.reboot_count }}</div>
|
|
<div class="text-xs text-gray-500">Reboot</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ========== SCOPE SELECTOR (draft + prereq) ========== -->
|
|
{% if run.status == 'draft' and scope %}
|
|
<div class="card mb-4" style="border-left:3px solid {% if run.status == 'draft' %}#94a3b8{% else %}#00d4ff{% endif %}">
|
|
<div class="p-3" style="border-bottom:1px solid #1e3a5f">
|
|
<h3 style="color:{% if run.status == 'draft' %}#94a3b8{% else %}#00d4ff{% endif %};font-weight:bold;font-size:0.9rem">
|
|
Périmètre de la campagne
|
|
</h3>
|
|
<p class="text-xs text-gray-500 mt-1">Cochez les domaines et zones à inclure. Les serveurs hors périmètre seront marqués « Exclu ».</p>
|
|
</div>
|
|
<form method="post" action="/quickwin/{{ run.id }}/apply-scope" id="scope-form" style="padding:16px">
|
|
<input type="hidden" name="scope_domains" id="h-scope-domains" value="">
|
|
<input type="hidden" name="scope_zones" id="h-scope-zones" value="">
|
|
<div style="display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:14px">
|
|
<!-- Domaines -->
|
|
<div>
|
|
<div class="text-xs font-bold text-gray-400 mb-2">DOMAINES</div>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#0d1520;border-radius:6px;padding:8px 10px">
|
|
{% for d in scope.domains %}
|
|
{% set active = scope.dom_active.get(d, 0) %}
|
|
{% set total = scope.dom_counts.get(d, 0) %}
|
|
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;cursor:pointer;font-size:0.82rem;color:#cbd5e1">
|
|
<input type="checkbox" class="scope-dom" value="{{ d }}" {% if active > 0 %}checked{% endif %}>
|
|
{{ d }}
|
|
<span style="color:#4a5568;font-size:0.7rem">({{ total }})</span>
|
|
{% if active > 0 and active < total %}<span style="color:#ffcc00;font-size:0.65rem">{{ active }} actifs</span>{% endif %}
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<!-- Zones -->
|
|
<div>
|
|
<div class="text-xs font-bold text-gray-400 mb-2">ZONES</div>
|
|
<div style="background:#0d1520;border-radius:6px;padding:8px 10px">
|
|
{% for z in scope.zones %}
|
|
{% set active = scope.zone_active.get(z, 0) %}
|
|
{% set total = scope.zone_counts.get(z, 0) %}
|
|
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;cursor:pointer;font-size:0.82rem;color:#cbd5e1">
|
|
<input type="checkbox" class="scope-zone" value="{{ z }}" {% if active > 0 %}checked{% endif %}>
|
|
{{ z }}
|
|
<span style="color:#4a5568;font-size:0.7rem">({{ total }})</span>
|
|
{% if active > 0 and active < total %}<span style="color:#ffcc00;font-size:0.65rem">{{ active }} actifs</span>{% endif %}
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 items-center">
|
|
<button type="submit" class="btn-primary" style="padding:6px 20px;font-size:0.9rem"
|
|
onclick="return prepareScopeForm()">
|
|
Appliquer le périmètre
|
|
</button>
|
|
<span class="text-xs text-gray-500" id="scope-preview"></span>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Step-specific content -->
|
|
|
|
{% if run.status == 'prereq' %}
|
|
<div class="card mb-4" style="border-left:3px solid #00d4ff;padding:16px">
|
|
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:8px">Vérification des prérequis</h3>
|
|
<p class="text-xs text-gray-400 mb-3">Vérifie : résolution DNS, SSH (PSMP/Key), Satellite/YUM, espace disque (<90%)</p>
|
|
<div style="display:flex;gap:16px;margin-bottom:12px">
|
|
<div>
|
|
<span class="badge badge-green">{{ step_hp.prereq_ok }} OK</span>
|
|
<span class="badge badge-red">{{ step_hp.prereq_ko }} KO</span>
|
|
<span class="badge badge-gray">{{ step_hp.prereq_pending }} en attente</span>
|
|
<span class="text-xs text-gray-500 ml-2">(H-Prod)</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mb-3">
|
|
<button id="btn-check-hprod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem"
|
|
onclick="startPrereqStream('hprod')">Lancer check H-Prod</button>
|
|
{% if prod_ok %}
|
|
<button id="btn-check-prod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17"
|
|
onclick="startPrereqStream('prod')">Lancer check Prod</button>
|
|
{% endif %}
|
|
<button id="btn-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
|
onclick="stopPrereqStream()">Arrêter</button>
|
|
</div>
|
|
<!-- Terminal -->
|
|
<div id="prereq-terminal" style="display:none">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
|
<span id="prereq-progress" class="text-xs text-gray-400"></span>
|
|
<span id="prereq-stats" class="text-xs"></span>
|
|
</div>
|
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="prereq-log"></div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if run.status == 'snapshot' %}
|
|
<div class="card mb-4" style="border-left:3px solid #a78bfa;padding:16px">
|
|
<h3 style="color:#a78bfa;font-weight:bold;margin-bottom:8px">Snapshots VM</h3>
|
|
<p class="text-xs text-gray-400 mb-3">Connexion vSphere → recherche VM → snapshot automatique. Les serveurs physiques sont ignorés (vérifier backup Commvault).</p>
|
|
<div style="display:flex;gap:16px;margin-bottom:12px">
|
|
<div>
|
|
<span class="badge badge-green">{{ step_hp.snap_ok }} fait(s)</span>
|
|
<span class="badge badge-gray">{{ step_hp.snap_pending }} en attente</span>
|
|
<span class="text-xs text-gray-500 ml-2">(H-Prod)</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
|
|
<button id="btn-snap-hprod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#a78bfa;color:#0a0e17"
|
|
onclick="startSnapshotStream('hprod')">Prendre Snapshots H-Prod</button>
|
|
{% if prod_ok %}
|
|
<button id="btn-snap-prod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17"
|
|
onclick="startSnapshotStream('prod')">Prendre Snapshots Prod</button>
|
|
{% endif %}
|
|
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark-all" style="display:inline">
|
|
<input type="hidden" name="branch" value="hprod">
|
|
<button class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px">Tout marquer fait (H-Prod)</button>
|
|
</form>
|
|
<button id="btn-snap-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
|
onclick="stopSnapshotStream()">Arrêter</button>
|
|
<span class="text-xs text-gray-500" style="margin-left:8px">Ordre vCenter : H-Prod = Senlis → Nanterre → DR | Prod = Nanterre → Senlis → DR</span>
|
|
</div>
|
|
<!-- Terminal snapshot -->
|
|
<div id="snap-terminal" style="display:none">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
|
<span id="snap-progress" class="text-xs text-gray-400"></span>
|
|
<span id="snap-stats" class="text-xs"></span>
|
|
</div>
|
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="snap-log"></div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if run.status == 'patching' %}
|
|
|
|
{% if prod_ok and stats.prod_total > 0 %}
|
|
{# Alerte validations hors-prod non validées avant de patcher prod #}
|
|
{% if not validations_ok %}
|
|
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:16px;background:#5a1a1a22">
|
|
<h3 style="color:#ff3366;font-weight:bold;margin-bottom:8px">⚠ Validations hors-prod requises avant la production</h3>
|
|
<p class="text-xs text-gray-300 mb-2">Les non-prod suivants ne sont pas validés. Marquer leur validation dans <a href="/patching/validations?status=en_attente" class="text-cyber-accent hover:underline">/patching/validations</a> avant de patcher les prods correspondants.</p>
|
|
<div class="text-xs" style="max-height:180px;overflow-y:auto">
|
|
<table class="w-full table-cyber">
|
|
<thead><tr><th class="text-left p-1">Prod</th><th class="text-left p-1">Hors-prod bloquant</th><th class="p-1">Statut</th></tr></thead>
|
|
<tbody>
|
|
{% for b in validations_blockers %}
|
|
<tr class="border-t border-cyber-border/30">
|
|
<td class="p-1 font-mono text-cyber-green">{{ b.prod_hostname }}</td>
|
|
<td class="p-1 font-mono text-cyber-yellow">{{ b.nonprod_hostname }}</td>
|
|
<td class="p-1 text-center">
|
|
{% if b.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
|
|
{% elif b.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
|
|
{% elif b.status == 'aucun_patching' %}<span class="badge badge-gray">Pas de patching</span>
|
|
{% else %}<span class="badge badge-gray">{{ b.status }}</span>{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-2">{{ validations_blockers|length }} bloquant(s) — vous pouvez continuer mais il est recommandé d'obtenir les validations d'abord.</p>
|
|
</div>
|
|
{% endif %}
|
|
<!-- Prereq + Snapshot Prod (si pas encore faits) -->
|
|
<div class="card mb-4" style="border-left:3px solid #ff8800;padding:16px">
|
|
<h3 style="color:#ff8800;font-weight:bold;margin-bottom:8px">Préparation Production</h3>
|
|
<p class="text-xs text-gray-400 mb-3">Avant de patcher la prod, lancez les checks prereq et snapshots sur les serveurs production.</p>
|
|
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
|
|
<button id="btn-check-prod-p" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ff8800;color:#0a0e17"
|
|
onclick="startPrereqStream('prod')">Check Prereq Prod</button>
|
|
<button id="btn-stop-prereq-prod" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
|
onclick="stopPrereqStream()">Arrêter</button>
|
|
<button id="btn-snap-prod-p" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#a78bfa;color:#0a0e17"
|
|
onclick="startSnapshotStream('prod')">Prendre Snapshots Prod</button>
|
|
<button id="btn-snap-stop-prod" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
|
onclick="stopSnapshotStream()">Arrêter</button>
|
|
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark-all">
|
|
<input type="hidden" name="branch" value="prod">
|
|
<button class="btn-sm" style="background:#a78bfa22;color:#a78bfa;padding:4px 14px">Tout marquer snap OK (prod)</button>
|
|
</form>
|
|
</div>
|
|
<!-- Terminal prereq prod -->
|
|
<div id="prereq-terminal" style="display:none;margin-bottom:12px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
|
<span id="prereq-progress" class="text-xs text-gray-400"></span>
|
|
<span id="prereq-stats" class="text-xs"></span>
|
|
</div>
|
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="prereq-log"></div>
|
|
</div>
|
|
<!-- Terminal snapshot prod -->
|
|
<div id="snap-terminal" style="display:none">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
|
<span id="snap-progress" class="text-xs text-gray-400"></span>
|
|
<span id="snap-stats" class="text-xs"></span>
|
|
</div>
|
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="snap-log"></div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="card mb-4" style="border-left:3px solid #ffcc00;padding:16px">
|
|
<h3 style="color:#ffcc00;font-weight:bold;margin-bottom:8px">Exécution du patching</h3>
|
|
<p class="text-xs text-gray-400 mb-3">Étape 1 : Générer les commandes. Étape 2 : Vérifier. Étape 3 : Exécuter via SSH.</p>
|
|
|
|
<!-- Boutons generation -->
|
|
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
|
|
<form method="post" action="/quickwin/{{ run.id }}/build-commands">
|
|
<input type="hidden" name="branch" value="hprod">
|
|
<button class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17">1. Générer commandes H-Prod</button>
|
|
</form>
|
|
{% if prod_ok %}
|
|
<form method="post" action="/quickwin/{{ run.id }}/build-commands">
|
|
<input type="hidden" name="branch" value="prod">
|
|
<button class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ff8800;color:#0a0e17">1. Générer commandes Prod</button>
|
|
</form>
|
|
{% endif %}
|
|
<button class="btn-sm" style="background:#1e3a5f;color:#ffcc00;padding:4px 14px" onclick="loadCommands('hprod')">Voir commandes H-Prod</button>
|
|
{% if prod_ok %}
|
|
<button class="btn-sm" style="background:#1e3a5f;color:#ff8800;padding:4px 14px" onclick="loadCommands('prod')">Voir commandes Prod</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Tableau commandes (charge en JS) -->
|
|
<div id="cmd-panel" style="display:none;margin-bottom:16px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
<h4 id="cmd-title" class="text-sm font-bold" style="color:#ffcc00"></h4>
|
|
<div class="flex gap-2">
|
|
<button id="btn-exec-patch" class="btn-primary" style="padding:6px 20px;font-size:0.85rem;background:#ff3366;color:#fff"
|
|
onclick="confirmExec()">2. Exécuter les commandes</button>
|
|
<button id="btn-patch-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
|
|
onclick="stopPatchStream()">Arrêter</button>
|
|
</div>
|
|
</div>
|
|
<div style="max-height:250px;overflow-y:auto;background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px">
|
|
<table class="table-cyber w-full" style="font-size:0.75rem">
|
|
<thead><tr>
|
|
<th class="px-2 py-1" style="width:150px">Serveur</th>
|
|
<th class="px-2 py-1">Commande</th>
|
|
</tr></thead>
|
|
<tbody id="cmd-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Terminal patching -->
|
|
<div id="patch-terminal" style="display:none">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
|
<span id="patch-progress" class="text-xs text-gray-400"></span>
|
|
<span id="patch-stats" class="text-xs"></span>
|
|
</div>
|
|
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:400px;overflow-y:auto;color:#8f8" id="patch-log"></div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if run.status == 'result' %}
|
|
<div class="card mb-4" style="border-left:3px solid #00ff88;padding:16px">
|
|
<h3 style="color:#00ff88;font-weight:bold;margin-bottom:8px">Résultats</h3>
|
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px">
|
|
<div class="card p-3 text-center" style="border-color:#00ff88">
|
|
<div class="text-xl font-bold" style="color:#00ff88">{{ stats.patched }}</div>
|
|
<div class="text-xs text-gray-500">Patchés</div>
|
|
</div>
|
|
<div class="card p-3 text-center" style="border-color:#ff3366">
|
|
<div class="text-xl font-bold" style="color:#ff3366">{{ stats.failed }}</div>
|
|
<div class="text-xs text-gray-500">KO</div>
|
|
</div>
|
|
<div class="card p-3 text-center" style="border-color:#94a3b8">
|
|
<div class="text-xl font-bold" style="color:#94a3b8">{{ stats.pending }}</div>
|
|
<div class="text-xs text-gray-500">En attente</div>
|
|
</div>
|
|
<div class="card p-3 text-center" style="border-color:#ff8800">
|
|
<div class="text-xl font-bold" style="color:#ff8800">{{ stats.reboot_count }}</div>
|
|
<div class="text-xs text-gray-500">Reboot</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if run.status == 'completed' %}
|
|
<div class="card mb-4" style="border-left:3px solid #10b981;padding:16px">
|
|
<h3 style="color:#10b981;font-weight:bold;margin-bottom:8px">Campagne terminée</h3>
|
|
<p class="text-xs text-gray-400 mb-3">{{ stats.patched }} patché(s), {{ stats.failed }} KO, {{ stats.reboot_count }} reboot(s).</p>
|
|
<a href="/quickwin/{{ run.id }}/report" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;display:inline-block;text-decoration:none">Télécharger le rapport</a>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Filtres table (contextuels selon l'etape) -->
|
|
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
|
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
|
|
<select name="domain" onchange="this.form.submit()" style="width:160px">
|
|
<option value="">Tous domaines</option>
|
|
{% set doms = entries|map(attribute='domaine')|select('string')|unique|sort %}
|
|
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
|
</select>
|
|
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
|
<select name="prereq_filter" onchange="this.form.submit()" style="width:140px">
|
|
<option value="">Prereq: tous</option>
|
|
<option value="ok" {% if filters.prereq == 'ok' %}selected{% endif %}>Prereq OK</option>
|
|
<option value="ko" {% if filters.prereq == 'ko' %}selected{% endif %}>Prereq KO</option>
|
|
<option value="pending" {% if filters.prereq == 'pending' %}selected{% endif %}>Non vérifié</option>
|
|
</select>
|
|
{% endif %}
|
|
{% if run.status in ('snapshot','patching','result','completed') %}
|
|
<select name="snap_filter" onchange="this.form.submit()" style="width:130px">
|
|
<option value="">Snap: tous</option>
|
|
<option value="ok" {% if filters.snap == 'ok' %}selected{% endif %}>Snap fait</option>
|
|
<option value="pending" {% if filters.snap == 'pending' %}selected{% endif %}>Snap en attente</option>
|
|
</select>
|
|
{% endif %}
|
|
{% if run.status in ('patching','result','completed') %}
|
|
<select name="status" onchange="this.form.submit()" style="width:140px">
|
|
<option value="">Tous statuts</option>
|
|
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>En attente</option>
|
|
<option value="patched" {% if filters.status == 'patched' %}selected{% endif %}>Patché</option>
|
|
<option value="failed" {% if filters.status == 'failed' %}selected{% endif %}>KO</option>
|
|
<option value="excluded" {% if filters.status == 'excluded' %}selected{% endif %}>Exclu</option>
|
|
<option value="skipped" {% if filters.status == 'skipped' %}selected{% endif %}>Ignoré</option>
|
|
</select>
|
|
{% endif %}
|
|
<select name="per_page" onchange="this.form.submit()" style="width:130px">
|
|
<option value="">Par page</option>
|
|
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>{% endfor %}
|
|
</select>
|
|
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
|
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
|
</form>
|
|
|
|
{% if not prod_ok %}
|
|
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:12px 16px">
|
|
<p style="color:#ff3366;font-size:0.85rem;font-weight:600">Hors-production d'abord : {{ stats.hprod_pending }} serveur(s) hprod en attente.</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- ========== MACRO: Entry table ========== -->
|
|
{% macro entry_table(rows, branch_label, branch_color, branch_key, page_num, total_pages, total_count) %}
|
|
<div class="card mb-4">
|
|
<div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
|
|
<h2 class="text-sm font-bold" style="color:{{ branch_color }}">{{ branch_label }} ({{ total_count }})</h2>
|
|
<div class="flex gap-1 items-center">
|
|
<span class="badge badge-green">{{ rows|selectattr('status','eq','patched')|list|length }} OK</span>
|
|
<span class="badge badge-red">{{ rows|selectattr('status','eq','failed')|list|length }} KO</span>
|
|
<span class="badge badge-gray">{{ rows|selectattr('status','eq','pending')|list|length }} en attente</span>
|
|
{% if can_modify %}
|
|
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:2px 10px;font-size:0.7rem;margin-left:8px"
|
|
onclick="removeSelected('{{ branch_key }}')">Supprimer sélection</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table class="table-cyber w-full">
|
|
<thead><tr>
|
|
{% if can_modify %}<th class="px-1 py-2" style="width:28px"><input type="checkbox" class="rm-check-all" data-branch="{{ branch_key }}" title="Tout"></th>{% endif %}
|
|
<th class="px-2 py-2">Serveur</th>
|
|
<th class="px-2 py-2">Domaine</th>
|
|
<th class="px-2 py-2">Env</th>
|
|
<th class="px-2 py-2">Statut</th>
|
|
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
|
<th class="px-2 py-2">Prereq</th>
|
|
{% endif %}
|
|
{% if run.status in ('snapshot','patching','result','completed') %}
|
|
<th class="px-2 py-2">Snap</th>
|
|
{% endif %}
|
|
<th class="px-2 py-2">Exclusions gén.</th>
|
|
<th class="px-2 py-2">Exclusions spéc.</th>
|
|
<th class="px-2 py-2">Packages</th>
|
|
<th class="px-2 py-2">Date patch</th>
|
|
<th class="px-2 py-2">Reboot</th>
|
|
<th class="px-2 py-2">Notes</th>
|
|
{% if run.status == 'patching' %}
|
|
<th class="px-2 py-2">Action</th>
|
|
{% endif %}
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for e in rows %}
|
|
<tr data-id="{{ e.id }}">
|
|
{% if can_modify %}<td class="px-1 py-2"><input type="checkbox" class="rm-check rm-{{ branch_key }}" value="{{ e.id }}"></td>{% endif %}
|
|
<td class="px-2 py-2 font-bold" style="color:{{ branch_color }}">{{ e.hostname }}</td>
|
|
<td class="px-2 py-2 text-gray-400">{{ e.domaine or '?' }}</td>
|
|
<td class="px-2 py-2 text-gray-400">{{ e.environnement or '?' }}</td>
|
|
<td class="px-2 py-2">
|
|
{% if e.status == 'patched' %}<span class="badge badge-green">Patché</span>
|
|
{% elif e.status == 'failed' %}<span class="badge badge-red">KO</span>
|
|
{% elif e.status == 'in_progress' %}<span class="badge badge-yellow">En cours</span>
|
|
{% elif e.status == 'excluded' %}<span class="badge badge-gray">Exclu</span>
|
|
{% elif e.status == 'skipped' %}<span class="badge badge-gray">Ignoré</span>
|
|
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
|
|
</td>
|
|
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
|
<td class="px-2 py-2 text-center">
|
|
{% if e.prereq_ok == true %}<span style="color:#00ff88" title="{{ e.prereq_detail }}">✓</span>
|
|
{% elif e.prereq_ok == false %}<span style="color:#ff3366" title="{{ e.prereq_detail }}">✗</span>
|
|
{% else %}<span style="color:#4a5568">—</span>{% endif %}
|
|
</td>
|
|
{% endif %}
|
|
{% if run.status in ('snapshot','patching','result','completed') %}
|
|
<td class="px-2 py-2 text-center">
|
|
{% if e.snap_done %}<span style="color:#a78bfa">✓</span>
|
|
{% else %}
|
|
{% if run.status == 'snapshot' %}
|
|
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark" style="display:inline">
|
|
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
|
<input type="hidden" name="done" value="true">
|
|
<button class="btn-sm" style="background:#a78bfa22;color:#a78bfa;font-size:0.6rem">Fait</button>
|
|
</form>
|
|
{% else %}<span style="color:#4a5568">—</span>{% endif %}
|
|
{% endif %}
|
|
</td>
|
|
{% endif %}
|
|
<td class="px-2 py-2 text-xs" style="color:#ffcc00;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.general_excludes }}">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="general_excludes">{{ e.general_excludes or '—' }}</span>
|
|
</td>
|
|
<td class="px-2 py-2 text-xs" style="color:#ff8800;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.specific_excludes }}">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="specific_excludes">{{ e.specific_excludes or '—' }}</span>
|
|
</td>
|
|
<td class="px-2 py-2 text-center">{{ e.patch_packages_count or '—' }}</td>
|
|
<td class="px-2 py-2 text-xs text-gray-500">{{ e.patch_date.strftime('%d/%m %H:%M') if e.patch_date else '—' }}</td>
|
|
<td class="px-2 py-2 text-center">{% if e.reboot_required %}<span style="color:#ff3366">OUI</span>{% else %}—{% endif %}</td>
|
|
<td class="px-2 py-2 text-xs text-gray-500">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="notes">{{ e.notes or '—' }}</span>
|
|
</td>
|
|
{% if run.status == 'patching' %}
|
|
<td class="px-2 py-2">
|
|
{% if e.status == 'pending' and e.prereq_ok and e.snap_done %}
|
|
<div class="flex gap-1">
|
|
<form method="post" action="/quickwin/{{ run.id }}/mark-patched" style="display:inline">
|
|
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
|
<input type="hidden" name="patch_status" value="patched">
|
|
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;font-size:0.6rem">OK</button>
|
|
</form>
|
|
<form method="post" action="/quickwin/{{ run.id }}/mark-patched" style="display:inline">
|
|
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
|
<input type="hidden" name="patch_status" value="failed">
|
|
<button class="btn-sm" style="background:#ff336622;color:#ff3366;font-size:0.6rem">KO</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
</td>
|
|
{% endif %}
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not rows %}<tr><td colspan="16" class="px-2 py-6 text-center text-gray-500">Aucun serveur{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% if total_pages > 1 %}
|
|
<div class="flex justify-between items-center p-3 text-sm text-gray-500" style="border-top:1px solid #1e3a5f">
|
|
<span>Page {{ page_num }} / {{ total_pages }} — {{ total_count }} serveur(s)</span>
|
|
<div class="flex gap-2">
|
|
{% if branch_key == 'hp' %}
|
|
{% if page_num > 1 %}<a href="{{ qs(hp=page_num - 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Préc.</a>{% endif %}
|
|
{% if page_num < total_pages %}<a href="{{ qs(hp=page_num + 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Suiv.</a>{% endif %}
|
|
{% else %}
|
|
{% if page_num > 1 %}<a href="{{ qs(hp=hp_page, pp=page_num - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Préc.</a>{% endif %}
|
|
{% if page_num < total_pages %}<a href="{{ qs(hp=hp_page, pp=page_num + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suiv.</a>{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
<!-- H-PROD -->
|
|
{{ entry_table(hprod, "HORS-PRODUCTION", "#00d4ff", "hp", hp_page, hp_total_pages, hprod_total) }}
|
|
|
|
<!-- PROD -->
|
|
{% if prod_ok %}
|
|
{{ entry_table(prod, "PRODUCTION", "#ffcc00", "pr", p_page, p_total_pages, prod_total) }}
|
|
{% endif %}
|
|
|
|
{% if run.notes %}
|
|
<div class="card p-4 mb-4">
|
|
<h3 class="text-xs font-bold text-gray-500 mb-2">NOTES</h3>
|
|
<p class="text-sm text-gray-300">{{ run.notes }}</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Hidden remove form -->
|
|
<form method="post" action="/quickwin/{{ run.id }}/remove-entries" id="remove-form" style="display:none">
|
|
<input type="hidden" name="entry_ids" id="remove-entry-ids" value="">
|
|
</form>
|
|
|
|
<script>
|
|
/* ---- Scope selector: collect checked domains/zones into hidden fields ---- */
|
|
function prepareScopeForm() {
|
|
const doms = [...document.querySelectorAll('.scope-dom:checked')].map(c => c.value);
|
|
const zones = [...document.querySelectorAll('.scope-zone:checked')].map(c => c.value);
|
|
if (!doms.length && !zones.length) {
|
|
alert('Sélectionnez au moins un domaine ou une zone');
|
|
return false;
|
|
}
|
|
document.getElementById('h-scope-domains').value = doms.join(',');
|
|
document.getElementById('h-scope-zones').value = zones.join(',');
|
|
return true;
|
|
}
|
|
/* ---- Scope: live preview of selection ---- */
|
|
document.querySelectorAll('.scope-dom, .scope-zone').forEach(cb => {
|
|
cb.addEventListener('change', function() {
|
|
const dc = document.querySelectorAll('.scope-dom:checked').length;
|
|
const dt = document.querySelectorAll('.scope-dom').length;
|
|
const zc = document.querySelectorAll('.scope-zone:checked').length;
|
|
const zt = document.querySelectorAll('.scope-zone').length;
|
|
const el = document.getElementById('scope-preview');
|
|
if (el) el.textContent = dc + '/' + dt + ' domaines, ' + zc + '/' + zt + ' zones';
|
|
});
|
|
});
|
|
|
|
/* ---- Add panel: select-all + prepare form ---- */
|
|
const addCheckAll = document.getElementById('add-check-all');
|
|
if (addCheckAll) {
|
|
addCheckAll.addEventListener('change', function() {
|
|
document.querySelectorAll('.add-check').forEach(cb => cb.checked = this.checked);
|
|
updateAddCount();
|
|
});
|
|
document.querySelectorAll('.add-check').forEach(cb => {
|
|
cb.addEventListener('change', updateAddCount);
|
|
});
|
|
}
|
|
function updateAddCount() {
|
|
const cnt = document.querySelectorAll('.add-check:checked').length;
|
|
const el = document.getElementById('add-count');
|
|
if (el) el.textContent = cnt;
|
|
}
|
|
function prepareAddForm() {
|
|
const ids = [...document.querySelectorAll('.add-check:checked')].map(cb => cb.value);
|
|
if (!ids.length) { alert('Aucun serveur s\u00e9lectionn\u00e9'); return false; }
|
|
document.getElementById('add-server-ids').value = ids.join(',');
|
|
return true;
|
|
}
|
|
|
|
/* ---- Remove: select-all per branch + submit ---- */
|
|
document.querySelectorAll('.rm-check-all').forEach(masterCb => {
|
|
masterCb.addEventListener('change', function() {
|
|
const branch = this.dataset.branch;
|
|
document.querySelectorAll('.rm-' + branch).forEach(cb => cb.checked = this.checked);
|
|
});
|
|
});
|
|
function removeSelected(branch) {
|
|
const ids = [...document.querySelectorAll('.rm-' + branch + ':checked')].map(cb => cb.value);
|
|
if (!ids.length) { alert('Aucun serveur s\u00e9lectionn\u00e9'); return; }
|
|
if (!confirm('Supprimer ' + ids.length + ' serveur(s) de la campagne ?')) return;
|
|
document.getElementById('remove-entry-ids').value = ids.join(',');
|
|
document.getElementById('remove-form').submit();
|
|
}
|
|
|
|
/* ---- Inline edit ---- */
|
|
document.querySelectorAll('.editable').forEach(el => {
|
|
el.style.cursor = 'pointer';
|
|
el.addEventListener('dblclick', function() {
|
|
const field = this.dataset.field;
|
|
const id = this.dataset.id;
|
|
const current = this.textContent.trim() === '\u2014' ? '' : this.textContent.trim();
|
|
const input = document.createElement('input');
|
|
input.value = current;
|
|
input.style.cssText = 'background:#0a0e17;border:1px solid #00d4ff;color:#fff;padding:2px 6px;border-radius:4px;font-size:0.75rem;width:100%';
|
|
this.textContent = '';
|
|
this.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
const save = () => {
|
|
const val = input.value.trim();
|
|
this.textContent = val || '\u2014';
|
|
fetch('/api/quickwin/entry/update', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({id: parseInt(id), field: field, value: val})
|
|
});
|
|
};
|
|
input.addEventListener('blur', save);
|
|
input.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
|
if (e.key === 'Escape') { this.textContent = current || '\u2014'; }
|
|
});
|
|
});
|
|
});
|
|
|
|
/* ---- SSE Prereq Terminal ---- */
|
|
let prereqSource = null;
|
|
function startPrereqStream(branch) {
|
|
const terminal = document.getElementById('prereq-terminal');
|
|
const log = document.getElementById('prereq-log');
|
|
const progress = document.getElementById('prereq-progress');
|
|
const stats = document.getElementById('prereq-stats');
|
|
const btnStop = document.getElementById('btn-stop');
|
|
|
|
terminal.style.display = 'block';
|
|
log.innerHTML = '';
|
|
const btnStop2 = document.getElementById('btn-stop-prereq-prod');
|
|
if (btnStop) btnStop.style.display = 'inline-block';
|
|
if (btnStop2) btnStop2.style.display = 'inline-block';
|
|
const btnHprod = document.getElementById('btn-check-hprod');
|
|
if (btnHprod) btnHprod.disabled = true;
|
|
const btnProd = document.getElementById('btn-check-prod');
|
|
if (btnProd) btnProd.disabled = true;
|
|
const btnProdP = document.getElementById('btn-check-prod-p');
|
|
if (btnProdP) btnProdP.disabled = true;
|
|
|
|
let okCount = 0, koCount = 0;
|
|
|
|
prereqSource = new EventSource('/quickwin/{{ run.id }}/prereq-stream?branch=' + branch);
|
|
prereqSource.onmessage = function(ev) {
|
|
const d = JSON.parse(ev.data);
|
|
if (d.type === 'start') {
|
|
addLine(log, '>>> Lancement check ' + d.branch + ' (' + d.total + ' serveurs)', '#00d4ff');
|
|
} else if (d.type === 'progress') {
|
|
progress.textContent = d.idx + '/' + d.total + ' — ' + d.hostname + '...';
|
|
} else if (d.type === 'result') {
|
|
const color = d.ok ? '#00ff88' : '#ff3366';
|
|
const icon = d.ok ? '\u2713' : '\u2717';
|
|
let line = icon + ' ' + d.hostname;
|
|
if (d.fqdn) line += ' (' + d.fqdn + ')';
|
|
line += ' DNS:' + (d.dns?'OK':'KO') + ' SSH:' + (d.ssh?'OK':'KO') + ' SAT:' + (d.sat?'OK':'KO') + ' DISK:' + (d.disk?'OK':'KO');
|
|
if (!d.ok && d.detail) line += '\n \u2514 ' + d.detail;
|
|
addLine(log, line, color);
|
|
if (d.ok) okCount++; else koCount++;
|
|
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> — <span style="color:#ff3366">' + koCount + ' KO</span>';
|
|
progress.textContent = d.idx + '/' + d.total;
|
|
} else if (d.type === 'done') {
|
|
addLine(log, '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total, '#00d4ff');
|
|
stopPrereqStream();
|
|
progress.textContent = 'Termin\u00e9';
|
|
}
|
|
};
|
|
prereqSource.onerror = function() {
|
|
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
|
stopPrereqStream();
|
|
};
|
|
}
|
|
function stopPrereqStream() {
|
|
if (prereqSource) { prereqSource.close(); prereqSource = null; }
|
|
const btnStop = document.getElementById('btn-stop');
|
|
if (btnStop) btnStop.style.display = 'none';
|
|
const btnStop2 = document.getElementById('btn-stop-prereq-prod');
|
|
if (btnStop2) btnStop2.style.display = 'none';
|
|
const btnHprod = document.getElementById('btn-check-hprod');
|
|
if (btnHprod) btnHprod.disabled = false;
|
|
const btnProd = document.getElementById('btn-check-prod');
|
|
if (btnProd) btnProd.disabled = false;
|
|
const btnProdP = document.getElementById('btn-check-prod-p');
|
|
if (btnProdP) btnProdP.disabled = false;
|
|
}
|
|
function addLine(container, text, color) {
|
|
const el = document.createElement('div');
|
|
el.style.color = color || '#ccc';
|
|
el.style.whiteSpace = 'pre-wrap';
|
|
el.style.wordBreak = 'break-all';
|
|
el.textContent = text;
|
|
container.appendChild(el);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
/* ---- SSE Snapshot Terminal ---- */
|
|
let snapSource = null;
|
|
function startSnapshotStream(branch) {
|
|
const terminal = document.getElementById('snap-terminal');
|
|
const log = document.getElementById('snap-log');
|
|
const progress = document.getElementById('snap-progress');
|
|
const stats = document.getElementById('snap-stats');
|
|
const btnStop = document.getElementById('btn-snap-stop');
|
|
|
|
terminal.style.display = 'block';
|
|
log.innerHTML = '';
|
|
const btnStop2 = document.getElementById('btn-snap-stop-prod');
|
|
if (btnStop) btnStop.style.display = 'inline-block';
|
|
if (btnStop2) btnStop2.style.display = 'inline-block';
|
|
const btnSnapHprod = document.getElementById('btn-snap-hprod');
|
|
if (btnSnapHprod) btnSnapHprod.disabled = true;
|
|
const btnProd = document.getElementById('btn-snap-prod');
|
|
if (btnProd) btnProd.disabled = true;
|
|
const btnProdP = document.getElementById('btn-snap-prod-p');
|
|
if (btnProdP) btnProdP.disabled = true;
|
|
|
|
let okCount = 0, koCount = 0;
|
|
|
|
snapSource = new EventSource('/quickwin/{{ run.id }}/snapshot-stream?branch=' + branch);
|
|
snapSource.onmessage = function(ev) {
|
|
const d = JSON.parse(ev.data);
|
|
if (d.type === 'start') {
|
|
addLine(log, '>>> Snapshots ' + d.branch + ' : ' + d.vms + ' VMs, ' + d.physical + ' physique(s) ignor\u00e9(s)', '#a78bfa');
|
|
if (d.physical > 0) {
|
|
addLine(log, '\u26a0 ' + d.physical + ' serveur(s) physique(s) : pas de snapshot VM. V\u00e9rifier les backups Commvault.', '#ffcc00');
|
|
}
|
|
} else if (d.type === 'progress') {
|
|
progress.textContent = d.idx + '/' + d.total + ' \u2014 ' + d.hostname + '...';
|
|
} else if (d.type === 'result') {
|
|
const color = d.ok ? '#00ff88' : '#ff3366';
|
|
const icon = d.ok ? '\u2713' : '\u2717';
|
|
let line = icon + ' ' + d.hostname;
|
|
if (d.vcenter) line += ' [' + d.vcenter + ']';
|
|
line += ' \u2014 ' + d.detail;
|
|
addLine(log, line, color);
|
|
if (d.ok) okCount++; else koCount++;
|
|
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> \u2014 <span style="color:#ff3366">' + koCount + ' KO</span>';
|
|
progress.textContent = d.idx + '/' + d.total;
|
|
} else if (d.type === 'done') {
|
|
let summary = '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total + ' VMs';
|
|
if (d.physical > 0) summary += ' (' + d.physical + ' physiques ignor\u00e9s)';
|
|
addLine(log, summary, '#a78bfa');
|
|
stopSnapshotStream();
|
|
progress.textContent = 'Termin\u00e9';
|
|
}
|
|
};
|
|
snapSource.onerror = function() {
|
|
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
|
stopSnapshotStream();
|
|
};
|
|
}
|
|
function stopSnapshotStream() {
|
|
if (snapSource) { snapSource.close(); snapSource = null; }
|
|
const btnStop = document.getElementById('btn-snap-stop');
|
|
if (btnStop) btnStop.style.display = 'none';
|
|
const btnStop2 = document.getElementById('btn-snap-stop-prod');
|
|
if (btnStop2) btnStop2.style.display = 'none';
|
|
const btnSnapHprod = document.getElementById('btn-snap-hprod');
|
|
if (btnSnapHprod) btnSnapHprod.disabled = false;
|
|
const btnProd = document.getElementById('btn-snap-prod');
|
|
if (btnProd) btnProd.disabled = false;
|
|
const btnProdP = document.getElementById('btn-snap-prod-p');
|
|
if (btnProdP) btnProdP.disabled = false;
|
|
}
|
|
|
|
/* ---- Patching: load commands + execute via SSE ---- */
|
|
let patchBranch = 'hprod';
|
|
function loadCommands(branch) {
|
|
patchBranch = branch;
|
|
fetch('/api/quickwin/{{ run.id }}/commands/' + branch)
|
|
.then(r => r.json())
|
|
.then(cmds => {
|
|
const panel = document.getElementById('cmd-panel');
|
|
const tbody = document.getElementById('cmd-tbody');
|
|
const title = document.getElementById('cmd-title');
|
|
title.textContent = cmds.length + ' commande(s) ' + (branch === 'hprod' ? 'H-Prod' : 'Prod');
|
|
tbody.innerHTML = '';
|
|
if (!cmds.length) {
|
|
tbody.innerHTML = '<tr><td colspan="2" class="px-2 py-4 text-center text-gray-500">Aucune commande. G\u00e9n\u00e9rez d\'abord les commandes.</td></tr>';
|
|
}
|
|
cmds.forEach(c => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = '<td class="px-2 py-1 font-bold" style="color:#00d4ff">' + c.hostname + '</td>'
|
|
+ '<td class="px-2 py-1" style="color:#ffcc00;font-family:monospace;word-break:break-all">' + c.command + '</td>';
|
|
tbody.appendChild(tr);
|
|
});
|
|
panel.style.display = 'block';
|
|
});
|
|
}
|
|
function confirmExec() {
|
|
if (!confirm('ATTENTION : Ceci va ex\u00e9cuter yum update sur tous les serveurs ' + patchBranch.toUpperCase() + '.\n\nConfirmer l\'ex\u00e9cution ?')) return;
|
|
startPatchStream(patchBranch);
|
|
}
|
|
let patchSource = null;
|
|
function startPatchStream(branch) {
|
|
const terminal = document.getElementById('patch-terminal');
|
|
const log = document.getElementById('patch-log');
|
|
const progress = document.getElementById('patch-progress');
|
|
const stats = document.getElementById('patch-stats');
|
|
const btnStop = document.getElementById('btn-patch-stop');
|
|
const btnExec = document.getElementById('btn-exec-patch');
|
|
|
|
terminal.style.display = 'block';
|
|
log.innerHTML = '';
|
|
btnStop.style.display = 'inline-block';
|
|
btnExec.disabled = true;
|
|
|
|
let okCount = 0, koCount = 0;
|
|
|
|
patchSource = new EventSource('/quickwin/{{ run.id }}/patch-stream?branch=' + branch);
|
|
patchSource.onmessage = function(ev) {
|
|
const d = JSON.parse(ev.data);
|
|
if (d.type === 'start') {
|
|
addLine(log, '>>> Patching ' + d.branch + ' (' + d.total + ' serveurs)', '#ffcc00');
|
|
} else if (d.type === 'progress') {
|
|
const st = d.status === 'connecting' ? 'Connexion SSH...' : 'Ex\u00e9cution yum...';
|
|
progress.textContent = d.idx + '/' + d.total + ' \u2014 ' + d.hostname + ' \u2014 ' + st;
|
|
if (d.status === 'connecting') {
|
|
addLine(log, '\n[' + d.idx + '/' + d.total + '] ' + d.hostname + ' \u2014 connexion...', '#94a3b8');
|
|
} else {
|
|
addLine(log, ' \u2192 ' + d.command, '#ffcc00');
|
|
}
|
|
} else if (d.type === 'result') {
|
|
const color = d.ok ? '#00ff88' : '#ff3366';
|
|
const icon = d.ok ? '\u2713' : '\u2717';
|
|
let line = ' ' + icon + ' ' + d.hostname;
|
|
if (d.packages) line += ' (' + d.packages + ' paquets)';
|
|
if (d.reboot) line += ' [REBOOT REQUIS]';
|
|
if (d.exit_code !== undefined && d.exit_code !== 0) line += ' (exit ' + d.exit_code + ')';
|
|
addLine(log, line, color);
|
|
if (d.detail) {
|
|
const color2 = d.ok ? '#6b7280' : '#ff6688';
|
|
const lines = d.detail.split('\n').filter(l => l.trim());
|
|
lines.forEach(l => addLine(log, ' \u2502 ' + l.trim(), color2));
|
|
}
|
|
if (d.ok) okCount++; else koCount++;
|
|
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> \u2014 <span style="color:#ff3366">' + koCount + ' KO</span>';
|
|
progress.textContent = d.idx + '/' + d.total;
|
|
} else if (d.type === 'done') {
|
|
addLine(log, '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total, '#ffcc00');
|
|
stopPatchStream();
|
|
progress.textContent = 'Termin\u00e9';
|
|
}
|
|
};
|
|
patchSource.onerror = function() {
|
|
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
|
stopPatchStream();
|
|
};
|
|
}
|
|
function stopPatchStream() {
|
|
if (patchSource) { patchSource.close(); patchSource = null; }
|
|
document.getElementById('btn-patch-stop').style.display = 'none';
|
|
document.getElementById('btn-exec-patch').disabled = false;
|
|
}
|
|
|
|
/* ---- Auto-load commands if redirected with show_cmds ---- */
|
|
(function() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const showBranch = params.get('show_cmds');
|
|
if (showBranch) loadCommands(showBranch);
|
|
})();
|
|
</script>
|
|
{% endblock %}
|