feat(patching/iexec B1): page wizard step 1 - checks DNS+SSH+Satellite (LAN vpdsiasat2 / DMZ vpdsiasat1 selon domaine), Linux uniquement (Windows skip), sudo -n partout
This commit is contained in:
parent
a5f3a25198
commit
eb2e0dc8ba
@ -593,7 +593,8 @@ async def iexec_page(request: Request, db=Depends(get_db),
|
|||||||
if ids:
|
if ids:
|
||||||
placeholders = ",".join(str(i) for i in ids)
|
placeholders = ",".join(str(i) for i in ids)
|
||||||
rows = db.execute(text(f"""
|
rows = db.execute(text(f"""
|
||||||
SELECT r.id, r.asset_name, r.environnement, r.os, r.os_version,
|
SELECT r.id, r.asset_name, r.environnement, r.domaine, r.os, r.os_version,
|
||||||
|
r.intervenant,
|
||||||
r.is_eligible, r.server_id,
|
r.is_eligible, r.server_id,
|
||||||
s.hostname, vs.effective_excludes
|
s.hostname, vs.effective_excludes
|
||||||
FROM patch_planning_import_rows r
|
FROM patch_planning_import_rows r
|
||||||
@ -611,6 +612,54 @@ async def iexec_page(request: Request, db=Depends(get_db),
|
|||||||
return templates.TemplateResponse("patching_iexec.html", ctx)
|
return templates.TemplateResponse("patching_iexec.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/patching/iexec/check/{row_id}")
|
||||||
|
async def iexec_check(request: Request, row_id: int, db=Depends(get_db)):
|
||||||
|
"""Lance les 3 checks pré-patching (DNS, SSH, Satellite) sur 1 row éligible.
|
||||||
|
Retourne JSON avec le résultat détaillé."""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
|
||||||
|
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||||
|
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT r.id, r.asset_name, r.intervenant, r.environnement, r.domaine,
|
||||||
|
r.is_eligible, r.server_id, s.hostname
|
||||||
|
FROM patch_planning_import_rows r
|
||||||
|
LEFT JOIN servers s ON s.id = r.server_id
|
||||||
|
WHERE r.id = :id
|
||||||
|
"""), {"id": row_id}).fetchone()
|
||||||
|
if not row:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Ligne introuvable"}, status_code=404)
|
||||||
|
if not row.is_eligible:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Ligne non éligible"}, status_code=400)
|
||||||
|
|
||||||
|
# Workflow yum/Satellite = Linux uniquement
|
||||||
|
os_str = str(row.os or "").lower()
|
||||||
|
if "windows" in os_str or os_str.strip() == "win":
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True, "row_id": row_id,
|
||||||
|
"hostname": row.asset_name, "target": None,
|
||||||
|
"overall": "unsupported",
|
||||||
|
"checks": [],
|
||||||
|
"skipped_reason": f"OS '{row.os}' non concerné — workflow Linux uniquement",
|
||||||
|
})
|
||||||
|
|
||||||
|
hostname = (row.hostname or row.asset_name or "").strip()
|
||||||
|
if not hostname:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Pas de hostname"}, status_code=400)
|
||||||
|
|
||||||
|
from ..services.prepatch_check_service import run_all_checks
|
||||||
|
result = run_all_checks(hostname, row={
|
||||||
|
"asset_name": row.asset_name,
|
||||||
|
"intervenant": row.intervenant,
|
||||||
|
"environnement": row.environnement,
|
||||||
|
"domaine": row.domaine,
|
||||||
|
})
|
||||||
|
return JSONResponse({"ok": True, "row_id": row_id, **result})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/patching/import/{import_id}/delete")
|
@router.post("/patching/import/{import_id}/delete")
|
||||||
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
|
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
|
|||||||
282
app/services/prepatch_check_service.py
Normal file
282
app/services/prepatch_check_service.py
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
"""Service pré-patching : vérifications avant lancement du patch.
|
||||||
|
|
||||||
|
Architecture extensible : un dict `CHECKS` mappe `name -> callable`.
|
||||||
|
Chaque check prend `(ctx)` et renvoie un dict :
|
||||||
|
{"name": str, "label": str, "status": "ok"|"warn"|"ko", "message": str, "details": str}
|
||||||
|
|
||||||
|
`ctx` est un dict :
|
||||||
|
{
|
||||||
|
"hostname": str, # nom court ex 'vpdsiawik1'
|
||||||
|
"target": str|None, # FQDN résolu (None si DNS KO)
|
||||||
|
"client": SSHClient|None, # paramiko ouvert ou None
|
||||||
|
"row": dict, # ligne du planning Excel (pour ctxe additionnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Les checks sont indépendants : un check peut tourner même si un autre a échoué.
|
||||||
|
La fonction `run_all_checks(hostname, row)` orchestre l'enchaînement et
|
||||||
|
calcule un verdict global.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from typing import Callable, Dict, List, Any
|
||||||
|
|
||||||
|
from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK
|
||||||
|
|
||||||
|
log = logging.getLogger("patchcenter.prepatch")
|
||||||
|
|
||||||
|
# Timeout par commande SSH
|
||||||
|
EXEC_TIMEOUT = 15
|
||||||
|
|
||||||
|
# Satellites SANEF par zone réseau
|
||||||
|
SATELLITE_LAN = "vpdsiasat2.sanef.groupe"
|
||||||
|
SATELLITE_DMZ = "vpdsiasat1.sanef.groupe"
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_satellite(row: Dict[str, Any]) -> str:
|
||||||
|
"""Renvoie le hostname du Satellite cible selon le domaine.
|
||||||
|
Si la colonne Domaine contient 'DMZ' → vpdsiasat1, sinon vpdsiasat2 (LAN)."""
|
||||||
|
domaine = str(row.get("domaine") or "").upper()
|
||||||
|
if "DMZ" in domaine:
|
||||||
|
return SATELLITE_DMZ
|
||||||
|
return SATELLITE_LAN
|
||||||
|
|
||||||
|
|
||||||
|
def _exec(client, cmd: str) -> Dict[str, Any]:
|
||||||
|
"""Exécute une commande SSH et renvoie {rc, stdout, stderr}."""
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=EXEC_TIMEOUT)
|
||||||
|
out = stdout.read().decode("utf-8", "replace").strip()
|
||||||
|
err = stderr.read().decode("utf-8", "replace").strip()
|
||||||
|
rc = stdout.channel.recv_exit_status()
|
||||||
|
return {"rc": rc, "stdout": out, "stderr": err}
|
||||||
|
except Exception as e:
|
||||||
|
return {"rc": -1, "stdout": "", "stderr": f"exec error: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Checks individuels
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def check_dns(ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Résolution DNS du hostname (nom court → FQDN connu via base ou suffixes)."""
|
||||||
|
hostname = ctx["hostname"]
|
||||||
|
target = ctx.get("target")
|
||||||
|
if target:
|
||||||
|
return {
|
||||||
|
"name": "dns",
|
||||||
|
"label": "Résolution DNS",
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"{hostname} → {target}",
|
||||||
|
"details": "",
|
||||||
|
}
|
||||||
|
# Si _resolve a échoué, on retente directement gethostbyname pour récupérer une IP
|
||||||
|
try:
|
||||||
|
ip = socket.gethostbyname(hostname)
|
||||||
|
return {
|
||||||
|
"name": "dns",
|
||||||
|
"label": "Résolution DNS",
|
||||||
|
"status": "warn",
|
||||||
|
"message": f"{hostname} → {ip} (FQDN non confirmé)",
|
||||||
|
"details": "Aucun FQDN en base et aucun suffixe SANEF ne répond sur :22.",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"name": "dns",
|
||||||
|
"label": "Résolution DNS",
|
||||||
|
"status": "ko",
|
||||||
|
"message": "Impossible de résoudre le hostname",
|
||||||
|
"details": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_ssh(ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Vérifie qu'on a une session SSH ouverte (déjà tentée dans run_all_checks)."""
|
||||||
|
if ctx.get("client") is not None:
|
||||||
|
return {
|
||||||
|
"name": "ssh",
|
||||||
|
"label": "Connexion SSH",
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Connecté à {ctx.get('target')}",
|
||||||
|
"details": "",
|
||||||
|
}
|
||||||
|
if not PARAMIKO_OK:
|
||||||
|
return {
|
||||||
|
"name": "ssh",
|
||||||
|
"label": "Connexion SSH",
|
||||||
|
"status": "ko",
|
||||||
|
"message": "paramiko non disponible côté serveur PatchCenter",
|
||||||
|
"details": "",
|
||||||
|
}
|
||||||
|
if not ctx.get("target"):
|
||||||
|
return {
|
||||||
|
"name": "ssh",
|
||||||
|
"label": "Connexion SSH",
|
||||||
|
"status": "ko",
|
||||||
|
"message": "Pas de cible (DNS KO en amont)",
|
||||||
|
"details": "",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": "ssh",
|
||||||
|
"label": "Connexion SSH",
|
||||||
|
"status": "ko",
|
||||||
|
"message": "Échec connexion SSH",
|
||||||
|
"details": "Vérifier ssh_method/clé/PSMP/mot de passe dans Settings.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_satellite(ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Vérifie :
|
||||||
|
1. la joignabilité du Satellite cible (LAN ou DMZ selon Domaine)
|
||||||
|
2. l'inscription du serveur (subscription-manager identity)
|
||||||
|
3. l'accès aux repos (yum repolist enabled --quiet)
|
||||||
|
Toutes les commandes utilisent sudo -n (non-interactif).
|
||||||
|
"""
|
||||||
|
client = ctx.get("client")
|
||||||
|
sat = _pick_satellite(ctx.get("row") or {})
|
||||||
|
label = f"Satellite ({sat})"
|
||||||
|
if client is None:
|
||||||
|
return {
|
||||||
|
"name": "satellite",
|
||||||
|
"label": label,
|
||||||
|
"status": "ko",
|
||||||
|
"message": "SSH KO en amont",
|
||||||
|
"details": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) Joignabilité réseau du Satellite (HEAD https://<sat>/pub/)
|
||||||
|
r0 = _exec(client,
|
||||||
|
f"sudo -n curl -k -s -o /dev/null -w '%{{http_code}}' "
|
||||||
|
f"--max-time 5 https://{sat}/pub/ 2>&1")
|
||||||
|
http_code = (r0["stdout"] or "").strip()
|
||||||
|
sat_reachable = http_code in ("200", "301", "302", "403")
|
||||||
|
|
||||||
|
# 2) subscription-manager identity
|
||||||
|
r1 = _exec(client, "sudo -n subscription-manager identity 2>&1")
|
||||||
|
sub_ok = (r1["rc"] == 0 and "system identity" in r1["stdout"].lower())
|
||||||
|
|
||||||
|
# 3) yum repolist enabled --quiet
|
||||||
|
r2 = _exec(client, "sudo -n yum repolist enabled --quiet 2>&1 | head -50")
|
||||||
|
repolist_ok = (r2["rc"] == 0 and r2["stdout"].strip() != "")
|
||||||
|
|
||||||
|
details = (
|
||||||
|
f"$ curl https://{sat}/pub/ → http_code={http_code or 'N/A'}\n"
|
||||||
|
f"$ sudo subscription-manager identity →\n{r1['stdout']}\n{r1['stderr']}\n"
|
||||||
|
f"---\n"
|
||||||
|
f"$ sudo yum repolist enabled --quiet (head -50) →\n{r2['stdout']}\n{r2['stderr']}"
|
||||||
|
)[:2500]
|
||||||
|
|
||||||
|
if sat_reachable and sub_ok and repolist_ok:
|
||||||
|
nb = sum(1 for ln in r2["stdout"].splitlines()
|
||||||
|
if ln and not ln.lower().startswith(("repo id", "loaded plugins",
|
||||||
|
"updating subscription",
|
||||||
|
"this system")))
|
||||||
|
return {
|
||||||
|
"name": "satellite",
|
||||||
|
"label": label,
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"{sat} joignable · système enregistré · ~{nb} repo(s) actifs",
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
# Construit message synthétique des KO
|
||||||
|
issues = []
|
||||||
|
if not sat_reachable:
|
||||||
|
issues.append(f"Satellite {sat} injoignable (http={http_code or 'N/A'})")
|
||||||
|
if not sub_ok:
|
||||||
|
issues.append("subscription-manager identity KO")
|
||||||
|
if not repolist_ok:
|
||||||
|
issues.append("yum repolist vide / KO")
|
||||||
|
status = "ko" if (not sat_reachable or not repolist_ok) else "warn"
|
||||||
|
return {
|
||||||
|
"name": "satellite",
|
||||||
|
"label": label,
|
||||||
|
"status": status,
|
||||||
|
"message": " · ".join(issues),
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Registre extensible
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CHECKS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
||||||
|
"dns": check_dns,
|
||||||
|
"ssh": check_ssh,
|
||||||
|
"satellite": check_satellite,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_check(name: str, fn: Callable):
|
||||||
|
"""Enregistre un check supplémentaire (pour extension future)."""
|
||||||
|
CHECKS[name] = fn
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Orchestration
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run_all_checks(hostname: str, row: Dict[str, Any] | None = None,
|
||||||
|
only: List[str] | None = None) -> Dict[str, Any]:
|
||||||
|
"""Exécute la séquence de checks pour 1 serveur.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: nom court ex 'vpdsiawik1'
|
||||||
|
row: dict optionnel d'éléments du planning (pour ctxe additionnel)
|
||||||
|
only: liste de noms de checks à lancer (par défaut tous)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"hostname": str,
|
||||||
|
"target": str|None,
|
||||||
|
"checks": [check_result, ...],
|
||||||
|
"overall": "ok" | "warn" | "ko"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
t0 = time.time()
|
||||||
|
only_set = set(only) if only else None
|
||||||
|
target = _resolve(hostname)
|
||||||
|
client = None
|
||||||
|
if target and PARAMIKO_OK:
|
||||||
|
try:
|
||||||
|
client = _connect(target, hostname)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"_connect raised on {hostname}: {e}")
|
||||||
|
client = None
|
||||||
|
|
||||||
|
ctx = {"hostname": hostname, "target": target, "client": client, "row": row or {}}
|
||||||
|
results = []
|
||||||
|
for name, fn in CHECKS.items():
|
||||||
|
if only_set is not None and name not in only_set:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
r = fn(ctx)
|
||||||
|
except Exception as e:
|
||||||
|
r = {"name": name, "label": name, "status": "ko",
|
||||||
|
"message": f"Exception: {e}", "details": ""}
|
||||||
|
results.append(r)
|
||||||
|
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Verdict global : ok si tous OK ; warn si au moins un warn et aucun ko ; ko sinon
|
||||||
|
statuses = [r["status"] for r in results]
|
||||||
|
if all(s == "ok" for s in statuses):
|
||||||
|
overall = "ok"
|
||||||
|
elif any(s == "ko" for s in statuses):
|
||||||
|
overall = "ko"
|
||||||
|
else:
|
||||||
|
overall = "warn"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hostname": hostname,
|
||||||
|
"target": target,
|
||||||
|
"checks": results,
|
||||||
|
"overall": overall,
|
||||||
|
"duration_ms": int((time.time() - t0) * 1000),
|
||||||
|
}
|
||||||
@ -5,51 +5,177 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-cyber-accent">Pré-patching — workflow iexec</h2>
|
<h2 class="text-xl font-bold text-cyber-accent">Pré-patching — workflow iexec</h2>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
{{ rows|length }} serveur(s) éligible(s) sélectionné(s) sur {{ row_ids|length }} demandés.
|
{{ rows|length }} serveur(s) éligible(s).
|
||||||
|
<span class="text-cyber-yellow">Note : seuls les Linux sont concernés (Windows non géré).</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="javascript:history.back()" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Retour</a>
|
<a href="javascript:history.back()" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Retour</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4 mb-4 border border-cyber-yellow/40">
|
{# ─── Stepper ─── #}
|
||||||
<h3 class="text-sm font-bold text-cyber-yellow mb-2">⚠ Étape B — workflow à implémenter</h3>
|
<div class="flex items-center mb-4 gap-2 text-xs">
|
||||||
<p class="text-xs text-gray-400">
|
<span class="px-3 py-1 rounded bg-cyber-yellow/20 text-cyber-yellow font-bold">1. Vérifications</span>
|
||||||
Les 3 steps planifiés :
|
<span class="text-gray-500">→</span>
|
||||||
</p>
|
<span class="px-3 py-1 rounded bg-cyber-border text-gray-500">2. Snapshot</span>
|
||||||
<ol class="text-xs text-gray-300 list-decimal pl-5 mt-2 space-y-1">
|
<span class="text-gray-500">→</span>
|
||||||
<li><strong>Step 1 — Pré-patching</strong> : vérif résolution DNS · vérif SSH · vérif Satellite (capsule)</li>
|
<span class="px-3 py-1 rounded bg-cyber-border text-gray-500">3. Patch yum</span>
|
||||||
<li><strong>Step 2 — Snapshot</strong> : take snapshot vCenter (avant modif)</li>
|
|
||||||
<li><strong>Step 3 — Patch</strong> : <code>yum update -y --exclude=<effective_excludes></code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-3 mb-4">
|
<div class="card p-3 mb-4">
|
||||||
<h3 class="text-sm font-bold text-cyber-accent mb-2">Serveurs ciblés ({{ rows|length }})</h3>
|
<div class="flex items-center justify-between mb-2">
|
||||||
{% if rows %}
|
<h3 class="text-sm font-bold text-cyber-accent">Step 1 — Vérifications pré-patching</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="btn-run-all" class="btn-primary px-3 py-1 text-xs">Lancer les checks</button>
|
||||||
|
</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">
|
<table class="w-full text-xs">
|
||||||
<thead class="text-cyber-accent border-b border-cyber-border">
|
<thead class="text-cyber-accent border-b border-cyber-border">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left p-1">Asset</th>
|
<th class="text-left p-1">Asset</th>
|
||||||
<th class="text-left p-1">Hostname BDD</th>
|
<th class="text-left p-1">Hostname BDD</th>
|
||||||
<th class="text-left p-1">Env</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">OS</th>
|
||||||
<th class="text-left p-1">Excludes effectifs</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">Satellite</th>
|
||||||
|
<th class="text-left p-1">Verdict</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="check-tbody">
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
<tr class="border-b border-cyber-border/30">
|
<tr class="border-b border-cyber-border/30" data-row-id="{{ r.id }}" data-os="{{ r.os or '' }}">
|
||||||
<td class="p-1 font-mono">{{ r.asset_name }}</td>
|
<td class="p-1 font-mono">{{ r.asset_name }}</td>
|
||||||
<td class="p-1 font-mono">{{ r.hostname or '–' }}</td>
|
<td class="p-1 font-mono">{{ r.hostname or '–' }}</td>
|
||||||
<td class="p-1">{{ r.environnement 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">{{ r.os or '' }}</td>
|
||||||
<td class="p-1 text-cyber-yellow">{{ r.effective_excludes or '(aucun)' }}</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-sat text-gray-500">·</td>
|
||||||
|
<td class="p-1 cell-overall text-gray-500">en attente</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="10" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
|
||||||
<p class="text-xs text-gray-500">Aucune ligne éligible parmi les IDs demandés.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ─── Détails par serveur (panneau pliable) ─── #}
|
||||||
|
<div class="card p-3 mb-4" id="details-card" style="display:none;">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-accent mb-2">Détails du dernier check</h3>
|
||||||
|
<pre id="details-pane" class="bg-cyber-bg p-2 text-[11px] whitespace-pre-wrap overflow-x-auto" style="max-height:400px;"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mt-4">
|
||||||
|
<span id="run-summary" class="text-xs text-gray-400"></span>
|
||||||
|
<button id="btn-step2" class="btn-sm bg-cyber-green/20 text-cyber-green px-4 py-2 text-xs" disabled>
|
||||||
|
→ Step 2 (snapshot) — à brancher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const btnRun = document.getElementById('btn-run-all');
|
||||||
|
const btnStep2 = document.getElementById('btn-step2');
|
||||||
|
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 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 || '';
|
||||||
|
if (!isLinux(osStr)) {
|
||||||
|
tr.querySelector('.cell-dns').innerHTML = statusBadge('unsupported');
|
||||||
|
tr.querySelector('.cell-ssh').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>';
|
||||||
|
return {overall: 'unsupported'};
|
||||||
|
}
|
||||||
|
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>';
|
||||||
|
return {overall: 'ko'};
|
||||||
|
}
|
||||||
|
if (j.overall === 'unsupported') {
|
||||||
|
tr.querySelector('.cell-dns').innerHTML = statusBadge('unsupported');
|
||||||
|
tr.querySelector('.cell-ssh').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>';
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
const byName = {};
|
||||||
|
(j.checks || []).forEach(c => byName[c.name] = c);
|
||||||
|
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-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>';
|
||||||
|
tr._checkData = j;
|
||||||
|
return j;
|
||||||
|
} catch(e) {
|
||||||
|
tr.querySelector('.cell-overall').innerHTML = '<span class="text-cyber-red">err</span>';
|
||||||
|
return {overall: 'ko'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnRun.addEventListener('click', async () => {
|
||||||
|
btnRun.disabled = true; btnStep2.disabled = true;
|
||||||
|
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';
|
||||||
|
btnRun.disabled = false;
|
||||||
|
btnStep2.disabled = (okCount === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user