diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d3317..f32631f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ ## 2026-07-01 ### Ajouté +- **État RÉEL vs plan dans le GUI (sonde de vie + auto-actif).** Le badge `planifié`/ + `actif` décrit l'*intention* du plan, pas l'existence de la VM — d'où la confusion + « serveur planifié mais vivant ». Deux ajouts : + - **Sonde de vie** : le GUI teste la joignabilité SSH de chaque hôte en arrière-plan + (`/api/sondes`, en parallèle) et affiche un état réel — **● vivante** / **● injoignable** + — sur les tuiles et dans le détail (« Plan : … · Réel : … »), distinct du plan. + - **Auto-actif** : un hôte qu'on **matérialise** (clone `creer` réussi) ou qu'on + **déploie** passe automatiquement `actif` dans le plan (matérialisé = actif). + - **Bouton « Pousser » dans le GUI — le flux devient 100 % cliquable.** Chaque objet du détail se matérialise sur son hôte, en streaming console (avec mot de passe vault + confirmation renforcée en prod) : diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 5ba4561..52dd12c 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -9,9 +9,11 @@ import json import os import re import secrets +import socket import subprocess import tempfile import threading +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from urllib.parse import urlparse @@ -230,6 +232,33 @@ def liste_hotes(data: dict) -> list[dict]: return hotes +def _joignable(ip: str, port: int = 22, timeout: float = 1.5) -> bool: + if not ip: + return False + try: + with socket.create_connection((ip, port), timeout=timeout): + return True + except OSError: + return False + + +def sonder_hotes(path: Path) -> dict: + """Etat REEL (vs plan declaratif) : la VM de chaque hote repond-elle sur SSH ?""" + cibles = [(h.get("nom"), h.get("adresse_ip")) for h in liste_hotes(charger_yaml(path))] + with ThreadPoolExecutor(max_workers=16) as executeur: + resultats = executeur.map(lambda c: (c[0], _joignable(c[1])), cibles) + return {nom: ok for nom, ok in resultats if nom} + + +def marquer_serveur_actif(hote: str) -> None: + """Auto-actif : un hote materialise (clone/deploye) passe 'actif' dans le plan.""" + registre = charger_serveurs(FICHIER_SERVEURS) + serveurs = registre.get("serveurs") or {} + if hote in serveurs and serveurs[hote].get("etat") != "actif": + serveurs[hote]["etat"] = "actif" + ecrire_serveurs(FICHIER_SERVEURS, registre) + + def serveurs_reconcilies(data: dict) -> list: """Serveurs du plan + valeurs derivees + statut de reconciliation (lecture).""" registre = charger_serveurs(FICHIER_SERVEURS) @@ -849,6 +878,7 @@ HTML = r""" let modaleResolveur = null; let intrants = null; let integrationsDispo = []; + let sondes = {}; // état RÉEL par hôte : joignable (SSH) ou non — distinct du plan let chronoConsole = null; let consoleDebut = 0; @@ -903,6 +933,14 @@ HTML = r""" if (selServeur == null || selServeur >= serveurs.length) selServeur = serveurs.length ? 0 : null; dessiner(); message('Inventaire chargé.', 'ok'); + sonder(); // état réel (joignabilité) en arrière-plan, non bloquant + } + + async function sonder() { + try { + const r = await fetch('/api/sondes'); + if (r.ok) { sondes = await r.json(); dessiner(); } + } catch (e) { /* silencieux */ } } function setOnglet(o) { ongletActif = o; dessinerDetail(); } @@ -1271,6 +1309,11 @@ HTML = r""" function ouvrirApp(id) { const i = applications.findIndex(a => a.id === id); selApp = i >= 0 ? i : null; setVue('applications'); } function ouvrirBaseServeur(nom) { const i = bdServeurs.findIndex(x => x.nom === nom); selBase = i >= 0 ? 'srv:' + i : null; setVue('bases'); } + function pointVie(nom) { + const v = sondes[nom]; + return v === true ? ' ' + : v === false ? ' ' : ''; + } function carteServeur(s, i) { const actif = s.etat === 'actif'; const recon = s.statut === 'divergence' ? '⚠ divergent' @@ -1278,7 +1321,7 @@ HTML = r""" return ``; } @@ -1348,6 +1391,7 @@ HTML = r""" ${statBadge}
${tuile(s.vmid, 'VMID')}${tuile(s.adresse_ip, 'IP')}${tuile(s.vlan, 'VLAN')}
+
Plan : ${echapper(s.etat)}  ·  Réel : ${sondes[s.nom] === true ? ' vivante' + (s.adresse_ip ? ' (' + echapper(s.adresse_ip) + ')' : '') : sondes[s.nom] === false ? ' injoignable' : ' non sondé'}
Identité — saisie du plan (surcharge les défauts)
@@ -1902,6 +1946,13 @@ class Gestionnaire(BaseHTTPRequestHandler): VERIF_OK[hote] = True elif mode in ("deployer", "pousser"): VERIF_OK.pop(hote, None) + # Auto-actif : un hote qu'on vient de materialiser (clone reussi) ou de + # deployer passe 'actif' dans le plan. Reflet fidele : materialise = actif. + if rc == 0 and mode in ("creer", "deployer", "pousser"): + try: + marquer_serveur_actif(hote) + except Exception: + pass self._ecrire_flux(f"\n__FIN__ rc={rc}\n") finally: if fichier_vault: @@ -1921,6 +1972,8 @@ class Gestionnaire(BaseHTTPRequestHandler): self.repondre(204, b"", "image/x-icon") elif chemin == "/api/inventaire": self.repondre_json(200, inventaire_api(self.inventaire)) + elif chemin == "/api/sondes": + self.repondre_json(200, sonder_hotes(self.inventaire)) else: self.repondre_json(404, {"erreur": "Introuvable"}) except Exception as exc: