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 ## 2026-07-01
### Ajouté ### 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 - **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 du détail se matérialise sur son hôte, en streaming console (avec mot de passe vault
+ confirmation renforcée en prod) : + confirmation renforcée en prod) :

View file

@ -9,9 +9,11 @@ import json
import os import os
import re import re
import secrets import secrets
import socket
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@ -230,6 +232,33 @@ def liste_hotes(data: dict) -> list[dict]:
return hotes 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: def serveurs_reconcilies(data: dict) -> list:
"""Serveurs du plan + valeurs derivees + statut de reconciliation (lecture).""" """Serveurs du plan + valeurs derivees + statut de reconciliation (lecture)."""
registre = charger_serveurs(FICHIER_SERVEURS) registre = charger_serveurs(FICHIER_SERVEURS)
@ -849,6 +878,7 @@ HTML = r"""<!doctype html>
let modaleResolveur = null; let modaleResolveur = null;
let intrants = null; let intrants = null;
let integrationsDispo = []; let integrationsDispo = [];
let sondes = {}; // état RÉEL par hôte : joignable (SSH) ou non distinct du plan
let chronoConsole = null; let chronoConsole = null;
let consoleDebut = 0; let consoleDebut = 0;
@ -903,6 +933,14 @@ HTML = r"""<!doctype html>
if (selServeur == null || selServeur >= serveurs.length) selServeur = serveurs.length ? 0 : null; if (selServeur == null || selServeur >= serveurs.length) selServeur = serveurs.length ? 0 : null;
dessiner(); dessiner();
message('Inventaire chargé.', 'ok'); 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(); } 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 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 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) { function carteServeur(s, i) {
const actif = s.etat === 'actif'; 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>' 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})"> 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-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-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>` : ''} ${recon ? `<div class="carte-grps">${recon}</div>` : ''}
</button>`; </button>`;
} }
@ -1348,6 +1391,7 @@ HTML = r"""<!doctype html>
<span class="grandit"></span>${statBadge} <span class="grandit"></span>${statBadge}
<button type="button" class="danger" title="Retirer ce serveur" onclick="retirerServeur()"></button></div> <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="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="section-tete" style="margin:4px 2px 2px">Identité saisie du plan (surcharge les défauts)</div>
<div class="grille"> <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> <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 VERIF_OK[hote] = True
elif mode in ("deployer", "pousser"): elif mode in ("deployer", "pousser"):
VERIF_OK.pop(hote, None) 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") self._ecrire_flux(f"\n__FIN__ rc={rc}\n")
finally: finally:
if fichier_vault: if fichier_vault:
@ -1921,6 +1972,8 @@ class Gestionnaire(BaseHTTPRequestHandler):
self.repondre(204, b"", "image/x-icon") self.repondre(204, b"", "image/x-icon")
elif chemin == "/api/inventaire": elif chemin == "/api/inventaire":
self.repondre_json(200, inventaire_api(self.inventaire)) self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/sondes":
self.repondre_json(200, sonder_hotes(self.inventaire))
else: else:
self.repondre_json(404, {"erreur": "Introuvable"}) self.repondre_json(404, {"erreur": "Introuvable"})
except Exception as exc: except Exception as exc: