GUI : état réel (sonde de vie) + auto-actif

Le badge planifié/actif = intention du plan, pas existence de la VM.
- Sonde de vie : /api/sondes teste la joignabilité SSH de chaque hôte
  (parallèle, non bloquant) → point ● vivante/injoignable sur les tuiles
  et dans le détail (« Plan : … · Réel : … »).
- Auto-actif : un hôte cloné (creer) ou déployé passe actif dans le plan
  automatiquement (matérialisé = actif).

Répond au constat « serveur planifié alors qu'il est vivant ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-01 17:39:44 -04:00
parent 7a98678bd9
commit 347e739818
2 changed files with 63 additions and 1 deletions

View file

@ -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) :

View file

@ -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"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
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 ? '<span class="pt actif" title="VM joignable (SSH)"></span> '
: v === false ? '<span class="pt bloq" title="Injoignable"></span> ' : '';
}
function carteServeur(s, i) {
const actif = s.etat === 'actif';
const recon = s.statut === 'divergence' ? '<span class="tag plus" title="Le plan diverge de l\'inventaire généré">⚠ divergent</span>'
@ -1278,7 +1321,7 @@ HTML = r"""<!doctype html>
return `<button type="button" class="carte ${actif ? 'actif' : 'attente'} ${i === selServeur ? 'selectionnee' : ''}" id="tuile-srv-${i}" onclick="choisirServeur(${i})">
<div class="carte-haut"><span class="badge-statut ${actif ? 'ok' : 'attente'}">${actif ? 'actif' : 'planifié'}</span>${s.vmid ? `<span class="carte-vmid">#${echapper(s.vmid)}</span>` : ''}</div>
<div class="carte-nom ${s.nom ? '' : 'sans'}">${echapper(s.nom || '(sans nom)')}</div>
<div class="carte-ip">${echapper(s.adresse_ip || '')}</div>
<div class="carte-ip">${pointVie(s.nom)}${echapper(s.adresse_ip || '')}</div>
${recon ? `<div class="carte-grps">${recon}</div>` : ''}
</button>`;
}
@ -1348,6 +1391,7 @@ HTML = r"""<!doctype html>
<span class="grandit"></span>${statBadge}
<button type="button" class="danger" title="Retirer ce serveur" onclick="retirerServeur()"></button></div>
<div class="stats">${tuile(s.vmid, 'VMID')}${tuile(s.adresse_ip, 'IP')}${tuile(s.vlan, 'VLAN')}</div>
<div class="dsn" style="padding:5px 2px"><b>Plan</b> : ${echapper(s.etat)} &nbsp;·&nbsp; <b>Réel</b> : ${sondes[s.nom] === true ? '<span class="pt actif"></span> vivante' + (s.adresse_ip ? ' (' + echapper(s.adresse_ip) + ')' : '') : sondes[s.nom] === false ? '<span class="pt bloq"></span> injoignable' : '<span class="pt plan"></span> non sondé'}</div>
<div class="section-tete" style="margin:4px 2px 2px">Identité saisie du plan (surcharge les défauts)</div>
<div class="grille">
<label class="champ"><span>Serveur (fonction-NN)</span><input data-v="nom" value="${echapper(s.nom)}" placeholder="web-frontal-03" oninput="definirServeur('nom', this.value)"></label>
@ -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: