Set-OPS-Public/scripts/inventory_gui.py
Daniel Allaire 88033f37b8
Some checks are pending
verifier / verifier (push) Waiting to run
GUI : la vue Nomenclature, et deux fautes que mes bancs ne voyaient pas
LA VUE. La nomenclature etait le seul registre que le GUI ne savait pas
ecrire du tout : ajouter une fonction exigeait d ouvrir le YAML. Elle a
sa vue, et son formulaire est GENERE depuis le schema. Deuxieme registre
sur six. couverture_gui verifier passe : les 28 champs des plans reels
sont editables.

Elle n est pas un registre comme les autres : elle decrit la REGLE dont
VMID, VLAN, adresse et passerelle se derivent. Chaque fonction montre ce
qu elle derive et les VM qui la portent ; l index est montre mais pas
editable, parce qu il est alloue par le site ; valider_nomenclature
refuse de retirer une fonction encore portee, ou de designer une zone
non declaree.

DEUX FAUTES, ET POURQUOI MES BANCS NE LES VOYAIENT PAS.

Le formulaire des bases, livre la veille, etait casse dans un navigateur.
Il lisait data.schema, or il n existe aucun data global : c est une const
locale de charger(). ReferenceError a l ouverture, et zone morte dans
sauvegarderBases. Je l avais eprouve sous node EN LUI PASSANT data : le
banc reproduisait la fonction, pas sa portee. D ou test_rendu_gui.py, qui
charge le JS entier dans un DOM simule et dessine les douze vues, avec son
controle negatif.

Le schema decrivait reservations comme une table de zones ; le fichier
reel est un bloc plat. P61 comparait des NOMS aplatis, donc ne voyait
rien. Elle compare desormais aussi la FORME.

ECRIRE SANS DEPLACER UN COMMENTAIRE. _fusion_chirurgicale remplace le
bloc entier des qu une valeur change : quinze entrees compactes devenaient
42 lignes, et le commentaire du poste d exploitation se retrouvait en tete
du bloc, ou il affirmait que collab etait le poste d exploitation. Un
commentaire deplace n est pas laid, il est faux. _fusion_table edite les
tables ligne a ligne ; le diff fait trois lignes.

Au passage : sort_keys triait le schema, donc l ordre des cases a l ecran
(reserve_max avant reserve_min) ; et _ecrire_index_nomenclature ecrivait
encore par write_text, oubliee au passage des ecritures atomiques.

LIMITE : deux registres sur six sont generes, et je n ai toujours pas
ouvert cette page dans un navigateur.

make prouver : CONFORME, 60 OK, 0 echec, 1 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:08:45 -04:00

3780 lines
221 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Interface web locale pour gerer un inventaire Set-OPS."""
from __future__ import annotations
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import argparse
import json
import os
import re
import secrets
import socket
import subprocess
import tempfile
import time
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from urllib.parse import urlparse
import yaml
import devis_reseau
from inventory_rules import instance_courante # noqa: E402
from inventory_rules import (
ecriture_atomique,
GROUPE_HOTES_ACTIFS,
GROUPE_HOTES_PLANIFIES,
GROUPES_ETAT_HOTE,
chaine_connexion,
charger_applications,
charger_bases_donnees,
charger_dependances,
charger_domaines,
charger_nomenclature,
charger_serveurs,
est_groupe_operationnel,
groupes_operationnels_connus,
integrations_universelles,
reconcilier_serveur,
valider_applications,
catalogue_liens,
valider_bases,
valider_domaines,
valider_serveurs,
)
RACINE = Path(__file__).resolve().parents[1]
INSTANCE = instance_courante()
def _inventaire(instance: Path, *noms: str) -> Path:
"""Inventaire de l'instance, détection rétro-compatible (premier existant)."""
for nom in noms:
p = instance / "inventories" / nom / "hosts.yml"
if p.exists():
return p
# Aucun hosts.yml encore généré : viser le répertoire d'inventaire déjà présent
# (ses group_vars y sont), sinon le premier nom par défaut.
for nom in noms:
if (instance / "inventories" / nom).is_dir():
return instance / "inventories" / nom / "hosts.yml"
return instance / "inventories" / noms[0] / "hosts.yml"
# Un seul inventaire par instance (modèle « séparation par instance ») ; on accepte
# l'ancien découpage lab/production en rétro-compatibilité. SETOPS_INVENTAIRE force.
_force = os.environ.get("SETOPS_INVENTAIRE")
INVENTAIRE_DEFAUT = Path(_force) if _force else _inventaire(INSTANCE, "principal", "production")
INVENTAIRE_PRODUCTION = INVENTAIRE_DEFAUT.resolve()
# Inventaire qui porte la config Proxmox / le clonage (modèle) : lab > principal > production.
INVENTAIRE_MODELE = _inventaire(INSTANCE, "lab", "principal", "production")
DOSSIER_PLAYBOOKS_GROUPES = RACINE / "playbooks/groupes"
FICHIER_DEPENDANCES = RACINE / "docs/dependances-groupes.yml"
FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml"
FICHIER_BASES = INSTANCE / "plan/bases-donnees.yml"
FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml"
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
# Intrants de base (panneau GUI). Cibles d'ecriture possedees par le GUI.
# Identite : group_vars/all/10-intrants.yml de l'inventaire. Selon l'instance c'est
# un fichier reel (modele « par instance ») ou un symlink vers une source partagee
# (ecrire suit le symlink). Proxmox : group_vars de l'inventaire modele. Les SECRETS
# n'y entrent jamais.
INTRANTS_IDENTITE = INVENTAIRE_DEFAUT.parent / "group_vars/all/10-intrants.yml"
INTRANTS_PROXMOX = INVENTAIRE_MODELE.parent / "group_vars/proxmox.yml"
# Le seed reseau (`index`) vit dans la nomenclature (le plan reseau) — d'ou tout
# l'adressage se derive. Le panneau Intrants le lit/ecrit la, chirurgicalement, sans
# reformater le fichier (cf. _ecrire_index_nomenclature).
# Frontiere nord/sud (OPNsense) : parametres NON sensibles du pare-feu de bordure.
# La cle/secret d'API n'y entrent JAMAIS — voute uniquement (cf. INTRANTS_CLES_INTERDITES).
# Fabric physique : `underlay.yml`, monte par symlink a la racine du moteur depuis le
# depot de l'HEBERGEUR (ses switches, ses cables). Distinct de l'instance : il ne suit
# pas `make instance-utiliser`. Imbrique sous `underlay:` et riche en commentaires :
# on l'ecrit chirurgicalement, jamais par un safe_dump qui les effacerait.
FICHIER_UNDERLAY = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml"))
def _depot_hebergeur() -> Path | None:
"""Depot de l'HEBERGEUR, derive du symlink `underlay.yml` qui le designe deja.
Un hebergeur sert PLUSIEURS tenants et n'a qu'une frontiere : ses intrants ne
peuvent pas vivre chez le tenant actif, sinon basculer sur un invite les ferait
disparaitre du panneau. None si aucun underlay n'est monte.
"""
if not FICHIER_UNDERLAY.exists():
return None
return FICHIER_UNDERLAY.resolve().parent
def _fichier_frontiere() -> Path:
"""Les intrants de la frontiere — a la RACINE du depot de site depuis le 2026-08-22.
La frontiere est un objet du MONDE PHYSIQUE : ses reglages vivent avec `underlay.yml`
et `proxmox-hebergeur.yml`, pas dans les group_vars d'un tenant. Range chez un
locataire, ce fichier a garde une adresse d'API perimee sans que personne la voie —
et l'applicateur restait suspendu dessus, sans message.
Les anciens emplacements restent lus, pour un site pas encore migre.
"""
base = _depot_hebergeur()
if base:
racine = base / "opnsense.yml"
if racine.is_file():
return racine
for nom in ("principal", "production", "lab"):
p = base / "inventories" / nom / "group_vars" / "opnsense.yml"
if p.is_file():
return p
return racine # a creer la, pas ailleurs
return INVENTAIRE_DEFAUT.parent / "group_vars/opnsense.yml"
def _fichier_proxmox_hebergeur() -> Path:
"""proxmox-hebergeur.yml de l'HEBERGEUR ; repli sur le proxmox.yml du tenant.
Meme raisonnement que la frontiere et l'underlay (D-14) : un cluster appartient a
l'hebergeur, pas au tenant qui y loge. Recopie chez chaque tenant, l'inventaire du
cluster avait deja diverge — deux listes de stockages contradictoires pour le meme
materiel. Le repli garde un depot sans underlay monte parfaitement utilisable.
"""
base = _depot_hebergeur()
if base:
return base / "proxmox-hebergeur.yml"
return INTRANTS_PROXMOX
INTRANTS_FRONTIERE = _fichier_frontiere()
INTRANTS_PROXMOX_HEBERGEUR = _fichier_proxmox_hebergeur()
FICHIERS_INTRANTS = {"identite": INTRANTS_IDENTITE, "proxmox": INTRANTS_PROXMOX,
"proxmox_hebergeur": INTRANTS_PROXMOX_HEBERGEUR,
"reseau": FICHIER_NOMENCLATURE, "frontiere": INTRANTS_FRONTIERE,
"fabric": FICHIER_UNDERLAY}
# Intrants de fabric -> chemin dans underlay.yml. Seules les valeurs PLATES sont ici :
# `reseaux` et `hotes` sont des listes de tables, hors de portee du panneau (cf. la vue
# dediee, a venir). Un intrant absent d'ici s'edite encore dans le YAML.
CHEMINS_UNDERLAY = {
"underlay_routeur": ("routeur",),
"underlay_dialecte": ("dialecte",),
"underlay_stp_mode": ("stp", "mode"),
"underlay_stp_topologie": ("stp", "topologie"),
}
# (variable, fichier, classe, section, libelle, type, proprietaire)
#
# `proprietaire` repond a une question que le panneau posait sans y repondre : en
# ouvrant l'ecran on trouvait, cote a cote et sans distinction, des valeurs du TENANT
# (son domaine, son realm, son modele) et des valeurs de l'HEBERGEUR (son cluster, sa
# frontiere, sa fabric). Ce sont deux proprietaires, deux depots, deux cycles de vie.
INTRANTS_SCHEMA = [
("index", "reseau", "constante", "Réseau", "Index d'instance dans la fédération (seed : dérive supernet 10.<index>, VLAN 1000+index×10+zone, VMID)", "int", "tenant"),
("domaine_interne", "identite", "constante", "Identité", "Domaine DNS interne", "str", "tenant"),
("fuseau_horaire", "identite", "defaut", "Identité", "Fuseau horaire", "str", "tenant"),
("organisation", "identite", "defaut", "Identité", "Organisation (annuaire LDAP, certificats)", "str", "tenant"),
("identite_realm", "identite", "defaut", "Identité", "Realm SSO (Keycloak)", "str", "tenant"),
("nftables_admin_ssh", "identite", "catalogue", "Sécurité", "Sources SSH d'administration toujours autorisées (garde anti-lockout du pare-feu)", "liste", "tenant"),
("proxmox_api_host", "proxmox_hebergeur", "constante", "Cluster", "Hôte API Proxmox", "str", "hebergeur"),
("proxmox_api_user", "proxmox_hebergeur", "constante", "Cluster", "Utilisateur API", "str", "hebergeur"),
("proxmox_api_port", "proxmox_hebergeur", "constante", "Cluster", "Port API", "str", "hebergeur"),
("proxmox_validate_certs", "proxmox_hebergeur", "constante", "Cluster", "Valider les certificats TLS", "bool", "hebergeur"),
("proxmox_clone_source_nom", "proxmox", "constante", "Proxmox", "Nom du golden template", "str", "tenant"),
("proxmox_clone_vmid_modele", "proxmox", "constante", "Proxmox", "VMID du golden template", "int", "tenant"),
("proxmox_clone_noeud", "proxmox", "defaut", "Proxmox", "Nœud Proxmox (défaut)", "str", "tenant"),
("proxmox_clone_stockage", "proxmox", "defaut", "Proxmox", "Stockage (défaut)", "str", "tenant"),
# REPLI, pas un reglage de la flotte. Chaque VM du plan recoit le VNet DERIVE de sa
# zone (`instancier` pose `proxmox_pont` par hote) ; cette valeur n'est consultee que
# si l'hote n'en porte pas — c'est-a-dire jamais dans le flux normal. L'intitule le
# dit, sans quoi on la corrige, on redeploie, et rien ne change : `vmbr3` y est reste
# faux dix jours sans que rien ne bronche (2026-08-13).
("proxmox_clone_pont", "proxmox", "defaut", "Proxmox",
"Pont réseau — clones manuels seulement (les VM du plan reçoivent le VNet de leur zone)",
"str", "tenant"),
("proxmox_noeuds", "proxmox_hebergeur", "catalogue", "Cluster", "Nœuds disponibles (liste)", "liste", "hebergeur"),
("proxmox_stockages", "proxmox_hebergeur", "catalogue", "Cluster", "Stockages disponibles (liste)", "liste", "hebergeur"),
("proxmox_ponts", "proxmox_hebergeur", "catalogue", "Cluster", "Ponts réseau disponibles (liste)", "liste", "hebergeur"),
("opnsense_api_url", "frontiere", "constante", "Frontière", "URL de gestion de la frontière OPNsense (API REST)", "str", "hebergeur"),
("opnsense_api_verifier_certs", "frontiere", "defaut", "Frontière", "Valider le certificat TLS de la frontière", "bool", "hebergeur"),
("opnsense_wan_ip", "frontiere", "constante", "Frontière", "Adresse publique du WAN de la frontière (face Internet de l'écosystème)", "str", "hebergeur"),
("opnsense_if_wan", "frontiere", "defaut", "Frontière", "Interface WAN — IDENTIFIANT OPNsense (wan), pas le libellé ni le périphérique", "str", "hebergeur"),
("opnsense_if_transit", "frontiere", "defaut", "Frontière", "Interface de transit — IDENTIFIANT OPNsense (opt1, opt2…), pas « igb1 » ni le libellé affiché", "str", "hebergeur"),
("opnsense_if_gestion", "frontiere", "defaut", "Frontière", "Interface où arrivent les plans d'administration attachés à la frontière — IDENTIFIANT OPNsense (lan). Une règle SSH posée ailleurs ne peut jamais correspondre", "str", "hebergeur"),
("underlay_routeur", "fabric", "constante", "Fabric", "Switch qui porte le routage (SVI, ACL, routes) — les autres restent en L2 pur", "str", "hebergeur"),
("underlay_dialecte", "fabric", "constante", "Fabric", "Dialecte de CLI du commutateur (cisco | binardat)", "str", "hebergeur"),
("underlay_stp_mode", "fabric", "defaut", "Fabric", "Mode de spanning-tree (rstp | mstp | pvst)", "str", "hebergeur"),
("underlay_stp_topologie", "fabric", "defaut", "Fabric", "Topologie de câblage (etoile | anneau | maille)", "str", "hebergeur"),
# `opnsense_prochain_saut` n'est PLUS un intrant : il derive du reseau de transit de
# l'underlay (`passerelle` du reseau portant `passerelle_sortie`). Le saisir ici en
# doublon ouvrirait la porte a deux valeurs contradictoires pour un seul lien.
]
# Cles secretes : interdites a l'ecriture par le GUI (garde-fou).
INTRANTS_CLES_INTERDITES = {
"proxmox_api_token_id", "proxmox_api_token_secret", "ansible_become_pass", "ansible_password",
# Frontiere : les identifiants d'API ne transitent jamais par le panneau.
"opnsense_api_key", "opnsense_api_secret",
}
# Rappel en lecture seule (jamais de valeur) : secrets a fournir via Ansible Vault.
def secrets_attendus() -> list[str]:
"""Noms des secrets a fournir par la voute — DERIVES, jamais tenus a la main.
`voute.secrets_exiges()` les recense depuis le plan, les roles des groupes actifs et
les group_vars de l'instance ; `voute.SECRETS_HORS_MOTIF` ajoute ceux qui ne portent
pas le prefixe `vault_` (les jetons Proxmox). C'est la MEME source que la preuve P18.
Une liste ecrite a la main vivait ici et avait diverge : elle annoncait
`vault_ldap_sssd`, qu'aucun role ne consomme. Un operateur qui suivait le panneau
creait donc un secret inutile. Le recensement par motif textuel a lui aussi sa
limite : il a un temps reclame `vault_step_ca_fingerprint` a cause d'un defaut mort
dans `client_pki`, qui derive en realite l'empreinte a chaud depuis l'AC.
Depot public nu (aucune instance) : liste vide plutot qu'une erreur d'affichage.
"""
try:
import voute
return sorted(set(voute.secrets_exiges()) | voute.SECRETS_HORS_MOTIF)
except Exception:
return []
# Garde-fous des executions (verifier / deployer) depuis l'interface.
JETON = secrets.token_urlsafe(18)
VERROU = threading.Lock()
VERIF_OK: dict[str, bool] = {}
MOTIF_HOTE = re.compile(r"^[A-Za-z0-9._-]{1,63}$")
# Champs de provisioning persistes comme variables d'hote dans l'inventaire.
# (cle_gui, variable_inventaire, type)
CHAMPS_PROVISION = [
("adresse_ip", "ansible_host", "str"),
("utilisateur_ansible", "ansible_user", "str"),
("cidr", "proxmox_cidr", "int"),
("passerelle", "proxmox_passerelle", "str"),
("vlan", "proxmox_vlan", "int"),
("pont", "proxmox_pont", "str"),
("dns", "proxmox_dns", "str"),
("vmid", "proxmox_vmid", "int"),
("noeud", "proxmox_noeud", "str"),
("stockage", "proxmox_stockage", "str"),
("disque_taille", "proxmox_disque_taille", "str"),
("memoire", "proxmox_memoire", "int"),
("coeurs", "proxmox_coeurs", "int"),
]
def charger_yaml(path: Path) -> dict:
if not path.exists():
return {"all": {"children": {}}}
with path.open("r", encoding="utf-8") as fichier:
data = yaml.safe_load(fichier) or {}
if not isinstance(data, dict):
raise ValueError(f"{path} ne contient pas une table YAML.")
data.setdefault("all", {})
data["all"].setdefault("children", {})
return data
def ecrire_yaml(path: Path, data: dict) -> None:
with ecriture_atomique(path) as fichier:
yaml.safe_dump(data, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def enfants(data: dict) -> dict:
return data.setdefault("all", {}).setdefault("children", {})
def groupes_disponibles(data: dict) -> list[str]:
return groupes_operationnels_connus(enfants(data), DOSSIER_PLAYBOOKS_GROUPES)
def roles_des_groupes() -> dict:
"""Pour chaque playbook de groupe, extrait les roles appliques (chaine groupe -> roles)."""
resultat: dict = {}
for chemin in sorted(DOSSIER_PLAYBOOKS_GROUPES.glob("*.yml")):
roles: list[str] = []
try:
with chemin.open("r", encoding="utf-8") as fichier:
docs = yaml.safe_load(fichier) or []
plays = docs if isinstance(docs, list) else [docs]
for play in plays:
if not isinstance(play, dict):
continue
for role in play.get("roles", []) or []:
if isinstance(role, str):
roles.append(role)
elif isinstance(role, dict):
nom = role.get("role") or role.get("name")
if nom:
roles.append(str(nom))
except (OSError, yaml.YAMLError):
pass
resultat[chemin.stem] = {"roles": roles, "stub": not roles}
return resultat
def hotes_actifs_fichier(path: Path) -> set[str]:
data = charger_yaml(path)
return set(enfants(data).get(GROUPE_HOTES_ACTIFS, {}).get("hosts", {}) or {})
def variables_hote(data: dict, hote: str) -> dict:
resultat: dict = {}
for groupe_data in enfants(data).values():
hosts = groupe_data.get("hosts", {})
valeurs = hosts.get(hote)
if isinstance(valeurs, dict):
resultat.update(valeurs)
return resultat
def liste_hotes(data: dict) -> list[dict]:
noms = set()
for groupe_data in enfants(data).values():
noms.update(groupe_data.get("hosts", {}))
hotes = []
for nom in sorted(noms):
groupes = []
etat = "planifie"
visible = False
for groupe, groupe_data in enfants(data).items():
if nom not in groupe_data.get("hosts", {}):
continue
if groupe == GROUPE_HOTES_ACTIFS:
etat = "actif"
visible = True
elif groupe in GROUPES_ETAT_HOTE:
visible = True
continue
elif est_groupe_operationnel(groupe):
groupes.append(groupe)
visible = True
if not visible:
continue
variables = variables_hote(data, nom)
hote = {"nom": nom, "etat": etat, "groupes": sorted(groupes)}
for cle, var, _type in CHAMPS_PROVISION:
valeur = variables.get(var, "")
hote[cle] = "" if valeur is None else valeur
if not str(hote.get("utilisateur_ansible", "")).strip():
hote["utilisateur_ansible"] = "ansible"
hotes.append(hote)
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)
nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE)
hotes = {h["nom"]: h for h in liste_hotes(data) if h.get("nom")}
resultat = []
for nom, srv in (registre.get("serveurs") or {}).items():
rec = reconcilier_serveur(nom, srv, hotes.get(nom), nomenclature)
d = rec["derive"]
statut = "absent" if rec["absent_inventaire"] else ("divergence" if rec["divergences"] else "reconcilie")
resultat.append({
"nom": nom, "fonction": srv.get("fonction", ""), "etat": srv.get("etat", ""),
"noeud": srv.get("noeud", ""), "stockage": srv.get("stockage", ""),
"disque": srv.get("disque", ""), "memoire": srv.get("memoire", ""), "coeurs": srv.get("coeurs", ""),
"integrations": srv.get("integrations", []),
"vmid": d.get("vmid", ""), "adresse_ip": d.get("adresse_ip", ""), "vlan": d.get("vlan", ""),
"statut": statut, "divergences": rec["divergences"],
})
return resultat
def flux_api() -> list:
"""Matrice des flux réseau (meta/flux.yml de tous les rôles) — lecture seule."""
try:
import resoudre_flux
flux = resoudre_flux.charger_flux()
except Exception:
return []
lignes = []
for role in sorted(flux):
for fl in flux[role]:
pair = fl.get("pair")
pair = ", ".join(str(p) for p in pair) if isinstance(pair, list) else str(pair)
port = fl.get("port")
port = ", ".join(str(p) for p in port) if isinstance(port, list) else str(port)
lignes.append({
"role": role, "sens": fl.get("sens", ""), "port": port,
"protocole": fl.get("protocole", ""), "pair": pair,
"chiffrement": fl.get("chiffrement", ""), "raison": fl.get("raison", ""),
})
return lignes
def couches_api() -> list:
"""Ordre de déploiement en couches (couches-deploiement.yml + graphe) — lecture seule."""
try:
import orchestrer
return [{"nom": nom, "groupes": list(groupes)} for nom, groupes in orchestrer.ordre_deploiement()]
except Exception:
return []
def inventaire_api(path: Path) -> dict:
data = charger_yaml(path)
dependencies = charger_dependances(FICHIER_DEPENDANCES)
return {
"flux": flux_api(),
"couches": couches_api(),
"inventaire": os.path.relpath(path, RACINE),
"production": instance_est_production(path),
"groupes": groupes_disponibles(data),
"dependances": dependencies,
"nomenclature": charger_nomenclature(FICHIER_NOMENCLATURE),
"chaine": roles_des_groupes(),
"bases": charger_bases_donnees(FICHIER_BASES),
"applications": charger_applications(FICHIER_APPLICATIONS),
"domaines": charger_domaines(FICHIER_DOMAINES),
"serveurs": serveurs_reconcilies(data),
"hotes": liste_hotes(data),
"intrants": intrants_api(),
"integrations_disponibles": integrations_disponibles(),
"integrations_universelles": integrations_universelles(RACINE),
"liens_acceptes": catalogue_liens(RACINE),
# LE SCHEMA VOYAGE AVEC LES DONNEES. Le navigateur genere ses formulaires depuis
# lui plutot que de les porter en dur : un champ ajoute au plan apparait a l'ecran
# sans toucher au JS. Genere par `make schema`, garde par P61.
"schema": schema_du_plan(),
}
def schema_du_plan() -> dict:
"""Le JSON Schema des registres, tel que `make schema` l'a ecrit.
Lu depuis le fichier plutot que recalcule : c'est l'artefact VERSIONNE qui fait foi,
et P61 refuse qu'il soit perime. Recalculer ici laisserait l'ecran afficher une forme
que personne n'a relue.
"""
f = RACINE / "docs" / "audit" / "schema-plan.json"
if not f.is_file():
return {}
try:
return json.loads(f.read_text(encoding="utf-8")).get("registres", {})
except (OSError, json.JSONDecodeError):
return {}
def _lire_yaml_dict(path: Path) -> dict:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as fichier:
data = yaml.safe_load(fichier) or {}
return data if isinstance(data, dict) else {}
def instance_est_production(path: Path) -> bool:
"""Cette instance est-elle la PRODUCTION ? (garde-fou de prudence, pas un blocage)
Le déploiement est permis sur toute instance (lab compris, vers son infra) ;
ce drapeau ne sert qu'à exiger une confirmation renforcée côté GUI quand c'est
la prod. Repère explicite : `setops_production` dans `<inventaire>/group_vars/all/`.
Rétro-compatibilité : à défaut, ancien repère (inventaire nommé « production »).
"""
base = path.parent / "group_vars" / "all"
fichiers = sorted(base.glob("*.yml")) if base.is_dir() else []
plat = path.parent / "group_vars" / "all.yml"
if plat.exists():
fichiers.append(plat)
for f in fichiers:
d = _lire_yaml_dict(f)
if isinstance(d, dict) and "setops_production" in d:
return bool(d["setops_production"])
return path.resolve() == INVENTAIRE_PRODUCTION
def _lire_fabric_a_plat() -> dict:
"""Les intrants PLATS de underlay.yml, aplatis dans l'espace de noms du panneau."""
data = _lire_yaml_dict(FICHIER_UNDERLAY).get("underlay") or {}
plat = {}
for variable, chemin in CHEMINS_UNDERLAY.items():
noeud = data
for clef in chemin:
noeud = (noeud or {}).get(clef) if isinstance(noeud, dict) else None
plat[variable] = noeud
return plat
def intrants_courants() -> dict:
cache: dict = {}
valeurs: dict = {}
for variable, fcle, _classe, _section, _libelle, typ, _prop in INTRANTS_SCHEMA:
if fcle not in cache:
cache[fcle] = (_lire_fabric_a_plat() if fcle == "fabric"
else _lire_yaml_dict(FICHIERS_INTRANTS[fcle]))
v = cache[fcle].get(variable)
if typ == "liste":
items = v if isinstance(v, list) else []
valeurs[variable] = [str(x).strip() for x in items if str(x).strip()]
else:
valeurs[variable] = "" if v is None else v
return valeurs
# --- Couverture du schema du plan par le GUI -------------------------------------
#
# Ce que les fonctions de sauvegarde JS ecrivent REELLEMENT, registre par registre.
# Source unique verifiee par `scripts/couverture_gui.py` : tout champ present dans
# un plan reel (instance ou modele) mais absent d'ici signale un trou de l'interface
# — l'operateur devrait editer le YAML a la main, ce que la doctrine refuse.
#
# A TENIR A JOUR avec sauvegarderServeurs / sauvegarderApplications /
# sauvegarderBases / sauvegarderDomaines. Le verificateur controle aussi que chaque
# champ declare ici apparait bien dans le source du GUI (garde anti-derive).
# CE QUE LE GUI SAIT ECRIRE — ET D'OU IL LE TIENT (2026-09-08).
#
# Cette table etait tenue A LA MAIN : quelqu'un devait penser a l'allonger quand un champ
# arrivait au plan, et P19 confrontait la liste au reel. Une copie gardee reste une copie.
#
# `bases_donnees` en est SORTIE la premiere : son formulaire est desormais GENERE depuis
# `docs/audit/schema-plan.json` (`make schema`), donc la liste s'en deduit au lieu d'etre
# recopiee. Les registres restants gardent leur declaration manuelle jusqu'a ce que leur
# formulaire passe au generateur — on ne bascule pas six vues d'un coup.
#
# P19 lit `champs_ecrits_par_gui()` et non plus cette constante : le jour ou la table sera
# vide, elle gardera un mecanisme au lieu d'une liste.
CHAMPS_ECRITS_A_LA_MAIN = {
"serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire",
"coeurs", "integrations"},
"applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"},
"serveurs_bd": {"type", "hote", "port", "groupe"},
"domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"},
# nomenclature : plus AUCUN champ a la main non plus — elle est passee au generateur
# le 2026-09-08, et c'etait le dernier registre que le GUI ne savait pas ecrire du
# tout. Ajouter une fonction n'exige plus d'editer le YAML.
"nomenclature": set(),
}
# Les registres dont le formulaire est GENERE : leurs champs viennent du schema.
REGISTRES_GENERES = {"bases_donnees", "nomenclature"}
def champs_ecrits_par_gui() -> dict:
"""Ce que le GUI sait ecrire : declare a la main, plus derive du schema.
Une seule fonction repond a la question, pour que P19 n'ait pas a savoir lequel des
deux mecanismes couvre quel registre.
"""
def _noms(props: dict) -> set:
"""Les champs decrits, TABLES ET BLOCS IMBRIQUES COMPRIS.
Les plans portent `categorie` et `service` a plat, sous la table `fonctions`.
Ne rendre que les proprietes de premier niveau ferait dire au GUI qu'il ne sait
pas les ecrire, alors que le formulaire genere les edite.
"""
vus = set()
for nom, spec in (props or {}).items():
vus.add(nom)
for sous in (spec.get("properties"), (spec.get("additionalProperties") or {}).get("properties")
if isinstance(spec.get("additionalProperties"), dict) else None):
if isinstance(sous, dict):
vus |= _noms(sous)
return vus
resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()}
for nom, reg in (schema_du_plan() or {}).items():
if nom in REGISTRES_GENERES:
resultat[nom] = _noms((reg.get("entite") or {}).get("properties") or {})
return resultat
def integrations_disponibles() -> list:
"""Roles client_* SELECTIONNABLES : ceux qui restent un choix.
Les universelles en sont exclues : on ne coche pas ce qui s'applique a tout le
monde. Le GUI les affiche quand meme, en lecture seule (voir /api/etat) — sinon
l'operateur lirait « aucune supervision » sur une flotte entierement supervisee.
"""
base = RACINE / "roles"
universelles = set(integrations_universelles(RACINE))
return sorted(p.name for p in base.glob("client_*")
if p.is_dir() and p.name not in universelles)
def intrants_api() -> dict:
schema = [{"variable": v, "fichier": f, "classe": c, "section": s,
"libelle": l, "type": t, "proprietaire": p} for (v, f, c, s, l, t, p) in INTRANTS_SCHEMA]
return {
"schema": schema,
"valeurs": intrants_courants(),
"secrets_attendus": secrets_attendus(),
"fichiers": {k: os.path.relpath(v, RACINE) for k, v in FICHIERS_INTRANTS.items()},
}
def _coercer_intrant(typ: str, brut: object) -> object:
if typ == "liste":
items = brut if isinstance(brut, list) else str(brut).replace("\n", ",").split(",")
# dedoublonne en preservant l'ordre
vus, sortie = set(), []
for x in items:
s = str(x).strip()
if s and s not in vus:
vus.add(s); sortie.append(s)
return sortie
s = str(brut).strip()
if typ == "bool":
return s.lower() in ("true", "1", "oui", "yes", "on")
if typ == "int":
return int(s) if s.lstrip("-").isdigit() else s
return s
_REFERENCE_VOUTE = re.compile(r"^\{\{\s*vault_[A-Za-z0-9_]+\s*\}\}$")
def _est_reference_voute(valeur: object) -> bool:
"""Vrai si la valeur est une simple REFERENCE de voute (`{{ vault_xxx }}`).
Un fichier d'intrants peut porter, a cote de ses parametres anodins, des renvois
par NOM vers la voute (cas de group_vars/opnsense.yml). Ce n'est pas un secret :
c'est un pointeur. La fusion « preserve les cles non gerees » les relit donc du
disque, et le garde-fou ne doit pas les confondre avec une valeur saisie.
"""
return bool(_REFERENCE_VOUTE.match(str(valeur).strip()))
_CLE_RACINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):")
def _rendre_cle(cle: str, valeur: object) -> list[str]:
"""Les lignes que PyYAML ecrirait pour cette seule clef (listes en bloc incluses)."""
texte = yaml.safe_dump({cle: valeur}, default_flow_style=False,
allow_unicode=True, sort_keys=False)
return texte.rstrip("\n").split("\n")
_ENTREE_TABLE = re.compile(r"^(\s+)([^\s#:][^:]*):(\s*)(.*)$")
def _fusion_table(bloc: list[str], entrees: dict) -> list[str]:
"""Met a jour une table clef->mapping LIGNE A LIGNE, sans la reecrire.
POURQUOI PAS `_fusion_chirurgicale` SEULE (mesure du 2026-09-08). Elle remplace le
BLOC entier des qu'une valeur change. Sur `fonctions:` de la nomenclature, ajouter
une seule entree a produit deux degats mesures :
- les quinze entrees compactes `{ categorie: 6, service: 2 }` sont devenues
quarante-deux lignes — le fichier cesse d'etre une table lisible d'un coup d'oeil ;
- le commentaire « LE POSTE D'EXPLOITATION », qui coiffait `ops:`, s'est retrouve
en TETE du bloc. Un commentaire deplace ne se contente pas d'etre laid : il
explique desormais la mauvaise ligne. Il affirmait que `collab` etait le poste
d'exploitation.
Ici on ne touche qu'aux LIGNES concernees. Une entree inchangee n'est pas relue, un
commentaire garde son voisin, et le style compact survit parce qu'on ne le refait pas.
"""
tete, corps = bloc[0], bloc[1:]
sortie, vues = [tete], set()
flow, colonne = 0, 0
i = 0
while i < len(corps):
ligne = corps[i]
m = _ENTREE_TABLE.match(ligne)
if not m:
sortie.append(ligne)
i += 1
continue
indent, cle, ecart, reste = m.group(1), m.group(2).strip(), m.group(3), m.group(4)
# L'entree s'etend sur les lignes PLUS indentees qui suivent immediatement.
fin = i
j = i + 1
while j < len(corps):
suite = corps[j]
if not suite.strip() or suite.strip().startswith("#"):
break
creux = len(suite) - len(suite.lstrip())
if creux <= len(indent):
break
fin = j
j += 1
vues.add(cle)
if reste.startswith("{"):
flow += 1
colonne = max(colonne, len(indent) + len(cle) + 1 + len(ecart))
try:
ancien = (yaml.safe_load("\n".join(l[len(indent):] for l in corps[i:fin + 1])) or {}).get(cle)
except yaml.YAMLError:
ancien = object() # illisible : on la considere changee
if cle not in entrees:
i = fin + 1 # entree retiree : ses lignes disparaissent, pas ses voisines
continue
if ancien == entrees[cle]:
sortie.extend(corps[i:fin + 1])
elif reste.startswith("{"):
sortie.append(f"{indent}{cle}:{ecart}{_rendre_flow(entrees[cle])}")
else:
# L'entree tenait sur plusieurs lignes : on la refait en bloc, indentee
# comme ses voisines.
sortie.extend(indent + l for l in _rendre_cle(cle, entrees[cle]))
i = fin + 1
# Les entrees NEUVES vont a la fin du bloc, dans le style dominant.
neuves = [c for c in entrees if c not in vues]
if neuves:
indent = " "
for ligne in corps:
m = _ENTREE_TABLE.match(ligne)
if m:
indent = m.group(1)
break
while sortie and not sortie[-1].strip():
sortie.pop()
for cle in neuves:
if flow:
ecart = " " * max(1, colonne - len(indent) - len(str(cle)) - 1)
sortie.append(f"{indent}{cle}:{ecart}{_rendre_flow(entrees[cle])}")
else:
sortie.extend(indent + l for l in _rendre_cle(cle, entrees[cle]))
return sortie
def _rendre_flow(valeur: object) -> str:
"""La forme compacte `{ a: 1, b: 2 }` — celle que le fichier employait deja."""
rendu = yaml.safe_dump(valeur, default_flow_style=True, allow_unicode=True,
sort_keys=False, width=10 ** 6).strip()
if rendu.startswith("{") and rendu.endswith("}"):
rendu = "{ " + rendu[1:-1].strip() + " }"
return rendu
def _fusion_chirurgicale(texte: str, valeurs: dict, tables: set | None = None) -> str:
"""Pose les valeurs dans le texte existant SANS reformater ce qui n'a pas change.
POURQUOI (mesure du 2026-08-18). Ces fichiers portent la memoire ECRITE des
decisions : pourquoi `dns_amorcage` pointe sur un resolveur public, pourquoi le
plan de gestion est reste en 10.0.0.0/24, pourquoi la frontiere ecoute sur `lan`.
Chaque enregistrement du panneau les repassait au `safe_dump` : clefs retriees,
et TOUS les commentaires effaces. Un enregistrement de 13:48 a ainsi emporte
94 lignes de commentaire dans quatre fichiers (129 -> 35, le reste etant l'entete
que le panneau reecrit) — dont celle qui disait pourquoi la valeur qu'on venait
de changer avait ete choisie.
Le depot connaissait deja le geste juste : `_ecrire_intrants_fabric` et
`_ecrire_index_nomenclature` remplacent la ligne sans toucher au reste. Ce qui
manquait ici, c'est de savoir remplacer une valeur de LISTE, qui tient sur
plusieurs lignes.
Trois regles :
- une clef dont la valeur ne change pas n'est PAS reecrite (zero bruit au diff) ;
- les commentaires INTERNES a un bloc remplace sont conserves, jamais juges : une
explication devenue fausse se corrige a la main, elle ne se supprime pas toute
seule ;
- une clef absente du fichier est ajoutee a la fin, jamais inseree au hasard.
"""
actuel = yaml.safe_load(texte)
actuel = actuel if isinstance(actuel, dict) else {}
lignes = texte.split("\n")
sortie: list[str] = []
vues: set[str] = set()
i = 0
while i < len(lignes):
m = _CLE_RACINE.match(lignes[i])
cle = m.group(1) if m else None
if cle is None or cle not in valeurs:
sortie.append(lignes[i])
i += 1
continue
# Le bloc de valeur : la ligne de clef, puis tout ce qui est indente ou item de
# liste. Les commentaires et lignes vides qui SUIVENT la derniere ligne de valeur
# appartiennent a la clef suivante — on les laisse ou ils sont.
fin = i
j = i + 1
while j < len(lignes):
l = lignes[j]
if l.startswith((" ", "\t", "- ")) or l == "-":
fin = j
elif l.strip().startswith("#") or not l.strip():
pass # peut etre interne au bloc : on tranche avec `fin`
else:
break
j += 1
vues.add(cle)
if cle in actuel and actuel[cle] == valeurs[cle]:
sortie.extend(lignes[i:fin + 1]) # inchangee : pas un octet touche
elif cle in (tables or set()) and isinstance(valeurs[cle], dict):
# Table clef->mapping : on edite les lignes concernees, pas le bloc.
sortie.extend(_fusion_table(lignes[i:fin + 1], valeurs[cle]))
else:
internes = [l for l in lignes[i + 1:fin + 1] if l.strip().startswith("#")]
rendu = _rendre_cle(cle, valeurs[cle])
sortie.extend([rendu[0]] + internes + rendu[1:])
i = fin + 1
ajouts = [c for c in valeurs if c not in vues]
if ajouts:
while sortie and not sortie[-1].strip():
sortie.pop()
for cle in ajouts:
sortie.extend(_rendre_cle(cle, valeurs[cle]))
sortie.append("")
return "\n".join(sortie)
def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None:
# Une cle sensible n'est toleree que sous forme de reference de voute preservee.
# Toute VALEUR reelle sur une de ces cles fait echouer l'ecriture.
interdits = sorted(cle for cle in set(valeurs) & INTRANTS_CLES_INTERDITES
if not _est_reference_voute(valeurs[cle]))
if interdits:
raise ValueError(f"Refus d'ecrire des cles sensibles via le GUI: {', '.join(interdits)}")
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
# Fichier existant : on pose les valeurs DANS son texte (commentaires preserves).
path.write_text(_fusion_chirurgicale(path.read_text(encoding="utf-8"), valeurs),
encoding="utf-8")
return
with ecriture_atomique(path) as fichier:
fichier.write(entete)
yaml.safe_dump(valeurs, fichier, default_flow_style=False, sort_keys=True, allow_unicode=True)
def ecrire_intrants(payload: dict) -> None:
valeurs = payload.get("valeurs", {}) or {}
# Garde-fou en entree : le panneau n'affiche jamais ces cles, mais une requete
# forgee pourrait les soumettre. On refuse bruyamment plutot que d'ignorer.
soumis = sorted(set(valeurs) & INTRANTS_CLES_INTERDITES)
if soumis:
raise ValueError(f"Cles sensibles soumises au panneau (voute uniquement): {', '.join(soumis)}")
if not str(valeurs.get("domaine_interne", "")).strip():
raise ValueError("domaine_interne ne peut pas etre vide (cle de voute de l'ecosysteme).")
types = {v: t for (v, f, c, s, l, t, p) in INTRANTS_SCHEMA}
fichier_de = {v: f for (v, f, c, s, l, t, p) in INTRANTS_SCHEMA}
par_fichier: dict = {}
for variable, valeur in valeurs.items():
if variable not in fichier_de:
continue
par_fichier.setdefault(fichier_de[variable], {})[variable] = _coercer_intrant(types[variable], valeur)
if "identite" in par_fichier:
# Fusion, comme les trois autres fichiers. C'etait la SEULE branche a ecrire
# par-dessus le disque, et elle perdait donc en silence tout intrant absent du
# schema du panneau. Mesure du 2026-08-10, au pre-vol de l'epreuve de
# portabilite : un enregistrement du panneau a supprime `dns_amorcage` et
# `amorcage_acces_courriel` de Technolibre. Le premier laisse une VM naitre sans
# resolution — `apt` ne peut alors rien installer ; le second arrete le
# deploiement sur la garde de `amorcage_acces` (D-72).
#
# Le meme geste sur Chezlepro aurait mange les memes cles. Un panneau qui ne
# connait pas une valeur ne doit pas avoir le droit de la detruire.
fusion = _lire_yaml_dict(INTRANTS_IDENTITE) # preserve les cles non gerees
fusion.update(par_fichier["identite"])
_ecrire_intrants_fichier(
INTRANTS_IDENTITE, fusion,
"# Intrants d'IDENTITE de l'instance — SOURCE UNIQUE, partagee par tous les envs.\n"
"# Edite par le panneau « Intrants de base » du GUI (make inventaire-ui).\n"
"# Reference via symlink depuis inventories/<env>/group_vars/all/10-intrants.yml.\n---\n")
if "proxmox" in par_fichier:
fusion = _lire_yaml_dict(INTRANTS_PROXMOX) # preserve les cles non gerees
fusion.update(par_fichier["proxmox"])
_ecrire_intrants_fichier(
INTRANTS_PROXMOX, fusion,
"# Parametres Proxmox non sensibles. Les secrets (jetons d'API) vont dans la\n"
"# voute UNIQUE de l'instance : group_vars/all/vault.yml.\n"
"# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n---\n")
if "proxmox_hebergeur" in par_fichier:
fusion = _lire_yaml_dict(INTRANTS_PROXMOX_HEBERGEUR)
fusion.update(par_fichier["proxmox_hebergeur"])
_ecrire_intrants_fichier(
INTRANTS_PROXMOX_HEBERGEUR, fusion,
"# Le CLUSTER, vu par l'HEBERGEUR — pas par un tenant.\n"
"#\n"
"# API du cluster, noeuds, stockages, ponts : du materiel possede par\n"
"# l'hebergeur. Recopiees dans le group_vars de chaque tenant, ces valeurs\n"
"# avaient deja diverge — deux inventaires contradictoires du meme cluster.\n"
"#\n"
"# Ce fichier vit dans le depot de l'HEBERGEUR, a cote d'underlay.yml (D-14),\n"
"# et se trouve par derivation du symlink qui designe deja l'hebergeur (D-17).\n"
"#\n"
"# Chez le TENANT restent son golden template et ses defauts de placement :\n"
"# des CHOIX faits a l'interieur de ce que l'hebergeur offre ici.\n"
"#\n"
"# Les secrets (jeton d'API) n'entrent jamais ici : voute de l'instance.\n---\n")
if "frontiere" in par_fichier:
# Fusion (comme Proxmox) : preserve les cles non gerees par le panneau,
# en particulier les references de voute `{{ vault_opnsense_api_* }}`.
fusion = _lire_yaml_dict(INTRANTS_FRONTIERE)
fusion.update(par_fichier["frontiere"])
_ecrire_intrants_fichier(
INTRANTS_FRONTIERE, fusion,
"# Frontiere nord/sud (OPNsense) — parametres NON sensibles.\n"
"# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n"
"#\n"
"# Les identifiants d'API ne figurent ici que par REFERENCE de nom : leurs\n"
"# valeurs vivent dans la voute unique de l'instance (ansible-vault edit\n"
"# group_vars/all/vault.yml), sous vault_opnsense_api_key / _api_secret.\n"
"# Les obtenir dans OPNsense : System > Access > Users > <utilisateur> >\n"
"# API keys > « + ». Le secret n'est affiche qu'a la creation.\n"
"#\n"
"# Le boitier est HORS FLOTTE Ansible : on ne fait que piloter son API REST\n"
"# depuis le controleur. Cf. docs/frontiere-opnsense.md.\n"
"#\n"
"# Les interfaces se nomment par leur IDENTIFIANT OPNsense (wan, opt1...), celui\n"
"# que parle l'API REST — ni le peripherique FreeBSD (igb1), ni le libelle affiche.\n"
"# `opnsense_prochain_saut` n'est pas ici : il DERIVE du reseau de transit de\n"
"# l'underlay (underlay.yml, cle `passerelle_sortie`). Source unique.\n---\n")
if "fabric" in par_fichier:
_ecrire_intrants_fabric(par_fichier["fabric"])
if "reseau" in par_fichier and "index" in par_fichier["reseau"]:
_ecrire_index_nomenclature(int(par_fichier["reseau"]["index"]))
def _ecrire_intrants_fabric(valeurs: dict) -> None:
"""Met a jour les cles PLATES de underlay.yml SANS reformater le fichier.
Meme motif que `_ecrire_index_nomenclature` : ce fichier porte des commentaires qui
expliquent des decisions d'architecture (pourquoi un seul routeur, pourquoi le
transit vit dans l'underlay...). Un `safe_dump` les effacerait toutes. On remplace
donc la ligne existante, en respectant son indentation, et on n'invente rien : une
clef absente du fichier n'est PAS creee — elle n'a pas de place evidente ou aller.
"""
if not FICHIER_UNDERLAY.exists():
return
texte = FICHIER_UNDERLAY.read_text(encoding="utf-8")
for variable, chemin in CHEMINS_UNDERLAY.items():
if variable not in valeurs:
continue
val = str(valeurs[variable]).strip()
if not val:
continue
feuille = chemin[-1]
# Indentation attendue : 2 espaces a la racine du bloc `underlay`, 4 si imbrique.
indent = " " * len(chemin)
motif = re.compile(rf"(?m)^{indent}{re.escape(feuille)}:[ \t]*\S.*$")
remplacement = f"{indent}{feuille}: {val}"
texte, n = motif.subn(remplacement, texte, count=1)
if not n:
raise ValueError(f"underlay.yml : clef '{feuille}' introuvable — "
f"l'ajouter au fichier avant de la regler ici.")
FICHIER_UNDERLAY.write_text(texte, encoding="utf-8")
def valider_nomenclature(donnees: dict, serveurs: dict) -> None:
"""Refuse une nomenclature qui casserait la derivation, AVANT d'ecrire.
La nomenclature n'est pas un registre comme les autres : elle ne decrit pas des
objets, elle decrit la REGLE dont VMID, VLAN, adresse et passerelle se derivent.
Une fonction retiree ne laisse pas un trou local — elle prive de tout adressage
chaque VM qui la porte, et `deriver_nomenclature` rend `None` SANS RIEN DIRE.
Trois refus, dans l'ordre du degat :
- une fonction encore portee par une VM ne se retire pas ;
- une fonction ne peut designer qu'une zone declaree ;
- `index` ne s'ecrit pas ici (il est ALLOUE par le site, cf. l'entete du fichier).
"""
categories = donnees.get("categories") or {}
fonctions = donnees.get("fonctions") or {}
if not isinstance(categories, dict) or not isinstance(fonctions, dict):
raise ValueError("`categories` et `fonctions` doivent etre des tables.")
connues = {str(c) for c in categories}
for nom, f in fonctions.items():
if not isinstance(f, dict):
raise ValueError(f"Fonction '{nom}': entree invalide.")
cat = f.get("categorie")
if cat in (None, ""):
raise ValueError(f"Fonction '{nom}': 'categorie' requise.")
if str(cat) not in connues:
raise ValueError(f"Fonction '{nom}': zone {cat} non declaree dans `categories` "
f"— la VM n'aurait ni VLAN ni adresse.")
if f.get("service") in (None, ""):
raise ValueError(f"Fonction '{nom}': 'service' requis.")
portees = {}
for nom_vm, srv in (serveurs or {}).items():
if isinstance(srv, dict) and srv.get("fonction"):
portees.setdefault(str(srv["fonction"]), []).append(nom_vm)
for fonction, vms in sorted(portees.items()):
if fonction not in fonctions:
raise ValueError(f"Fonction '{fonction}' retiree, mais encore portee par "
f"{len(vms)} VM ({', '.join(sorted(vms)[:4])}) — "
f"deplacer ces VM d'abord.")
zones_utilisees = {str(f.get("categorie")) for f in fonctions.values() if isinstance(f, dict)}
for zone in sorted(zones_utilisees - connues):
raise ValueError(f"Zone {zone} utilisee mais non declaree.")
def ecrire_nomenclature(path: Path, donnees: dict) -> None:
"""Ecrit la nomenclature SANS effacer ses commentaires.
POURQUOI PAS `safe_dump`. Ce fichier porte dix-neuf lignes qui disent pourquoi
`index` EST RECU ET PAS DECIDE, et pourquoi le modele des zones est commun a toute
la flotte. Un `safe_dump` les emporterait toutes — c'est exactement l'incident du
2026-08-18 (94 lignes de commentaire perdues en un enregistrement). On repasse donc
par `_fusion_chirurgicale`, qui pose les valeurs dans le texte existant.
`index` n'est jamais du lot : il s'ecrit par `_ecrire_index_nomenclature`, depuis le
panneau Intrants, parce qu'il vient du site et non de cette vue.
"""
texte = path.read_text(encoding="utf-8") if path.exists() else "---\n"
valeurs = {cle: donnees[cle] for cle in ("cidr_hote", "reservations", "categories", "fonctions")
if cle in donnees}
with ecriture_atomique(path) as fichier:
fichier.write(_fusion_chirurgicale(texte, valeurs,
tables={"categories", "fonctions"}))
def _ecrire_index_nomenclature(index: int) -> None:
"""Met a jour `index:` dans plan/nomenclature.yml SANS reformater le reste.
Le fichier garde ses commentaires et son formatage compact (l'adressage se derive
de ce seul seed). Remplacement de la ligne existante, ou insertion en tete a defaut.
"""
path = FICHIER_NOMENCLATURE
texte = path.read_text(encoding="utf-8") if path.exists() else "---\n"
nouveau, remplace = re.subn(r"(?m)^index:.*$", f"index: {index}", texte)
if not remplace:
nouveau = re.sub(r"(?m)^(---\n)", rf"\g<1>index: {index}\n", texte, count=1) or texte
# Atomique comme les autres : cette ecriture-ci avait ete oubliee au passage du
# 2026-09-06, et une coupure ici laisserait le seed a moitie ecrit — c'est-a-dire
# tout l'adressage de l'ecosysteme.
with ecriture_atomique(path) as fichier:
fichier.write(nouveau)
def ecrire_bases(path: Path, registre: dict) -> None:
entete = (
"# Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees).\n"
"# Edite par make inventaire-ui ou scripts/bases_donnees.py.\n"
"# 'portee' (application|groupe|hote) interprete 'consommateur' ; defaut groupe.\n"
"# 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n"
"---\n"
)
contenu = {
"serveurs_bd": registre.get("serveurs_bd", {}) or {},
"bases_donnees": registre.get("bases_donnees", {}) or {},
}
with ecriture_atomique(path) as fichier:
fichier.write(entete)
yaml.safe_dump(contenu, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def ecrire_applications(path: Path, registre: dict) -> None:
entete = (
"# Registre des applications Set-OPS (application = entite pivot).\n"
"# Edite par make inventaire-ui ou scripts/applications.py.\n"
"# Une VM peut porter plusieurs applications ; une base se lie a une application.\n"
"# 'liens' = bindings declaratifs [{vers, role}] resolus par scripts/instancier.py ;\n"
"# les roles acceptes sont ceux de roles/<groupe>/meta/liens.yml.\n"
"---\n"
)
with ecriture_atomique(path) as fichier:
fichier.write(entete)
yaml.safe_dump({"applications": registre.get("applications", {}) or {}},
fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def ecrire_serveurs(path: Path, registre: dict) -> None:
entete = (
"# Registre des serveurs (VM) du plan Set-OPS.\n"
"# Edite par make inventaire-ui ou scripts/serveurs.py.\n"
"# VMID/IP/VLAN/passerelle sont DERIVES de la fonction via instance/plan/nomenclature.yml.\n"
"---\n"
)
with ecriture_atomique(path) as fichier:
fichier.write(entete)
yaml.safe_dump({"serveurs": registre.get("serveurs", {}) or {}},
fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def ecrire_domaines(path: Path, registre: dict) -> None:
entete = (
"# Registre des domaines publics (zones DNS de l'instance).\n"
"# Edite par make inventaire-ui ou scripts/domaines.py.\n"
"# autorite : primaire-cache | auto-heberge | delegue (label descriptif).\n"
"---\n"
)
with ecriture_atomique(path) as fichier:
fichier.write(entete)
yaml.safe_dump({"domaines_publics": registre.get("domaines_publics", {}) or {}},
fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def _extraire_echec(ligne: str, tache: str) -> dict | None:
"""D'une ligne Ansible fatal/unreachable, tire un échec en LANGAGE CLAIR
(tâche · hôte · type · message) — pour communiquer entre humains."""
m = re.search(r'(?:fatal|failed):\s*\[([^\]]+)\]:\s*(FAILED!|UNREACHABLE!)?\s*=>\s*(\{.*\})', ligne)
if not m:
return None
hote = m.group(1)
typ = "injoignable (SSH)" if m.group(2) == "UNREACHABLE!" else "échec"
detail: dict = {}
try:
detail = json.loads(m.group(3))
except Exception:
pass
if detail.get("censored"):
message = "sortie masquée (no_log) — souvent une tâche qui manipule un secret"
else:
msg = str(detail.get("msg", "")).strip()
# Pour un échec de commande, msg est générique ; la vraie cause est dans stderr.
stderr = ""
for cle in ("stderr", "module_stderr", "stdout"):
valeur = str(detail.get(cle, "")).strip()
if valeur:
lignes = [x for x in valeur.splitlines() if x.strip()]
stderr = lignes[0] if lignes else valeur # l'erreur mène, l'indice suit
break
if msg and msg.lower() not in ("non-zero return code", "module failure", ""):
message = msg
elif stderr:
message = stderr
else:
message = msg or "échec sans message explicite (voir le journal complet)"
message = " ".join(str(message).split()) # une ligne propre
return {"tache": (tache or "?").strip(), "hote": hote, "type": typ, "message": message[:400]}
def donnees_hote(hote: dict) -> dict:
variables: dict = {}
for cle, var, typ in CHAMPS_PROVISION:
brut = str(hote.get(cle, "")).strip()
if not brut:
continue
if typ == "int" and brut.lstrip("-").isdigit():
variables[var] = int(brut)
else:
variables[var] = brut
return variables
def construire_inventaire(payload: dict, groupes_connus: list[str]) -> dict:
enfants_data: dict = {
"modeles_vm": {"hosts": {}},
GROUPE_HOTES_ACTIFS: {"hosts": {}},
GROUPE_HOTES_PLANIFIES: {"hosts": {}},
}
for groupe in groupes_connus:
enfants_data.setdefault(groupe, {"hosts": {}})
enfants_data.setdefault("hotes_proxmox", {"hosts": {}})
for hote in payload.get("hotes", []):
nom = str(hote.get("nom", "")).strip()
if not nom:
continue
variables = donnees_hote(hote)
groupe_etat = GROUPE_HOTES_ACTIFS if hote.get("etat") == "actif" else GROUPE_HOTES_PLANIFIES
enfants_data[groupe_etat]["hosts"][nom] = variables
for groupe in hote.get("groupes", []):
if groupe not in groupes_connus:
continue
enfants_data[groupe]["hosts"][nom] = {}
return {"all": {"children": enfants_data}}
def valider_dependances_payload(payload: dict, dependencies: dict) -> None:
hotes = payload.get("hotes", [])
active_groups: set[str] = set()
for hote in hotes:
if hote.get("etat") != "actif":
continue
active_groups.update(hote.get("groupes", []))
missing: list[str] = []
for hote in hotes:
if hote.get("etat") != "actif":
continue
nom = str(hote.get("nom", "")).strip()
for groupe in hote.get("groupes", []):
for required_group in dependencies.get(groupe, {}).get("requiert_groupes_actifs", []):
if required_group not in active_groups:
missing.append(f"{nom}: {groupe} requiert {required_group} actif")
if missing:
raise ValueError("Dependances manquantes: " + "; ".join(missing))
def valider_payload(payload: dict, groupes_connus: list[str], dependencies: dict) -> None:
noms: set[str] = set()
vmids: dict[str, str] = {}
for hote in payload.get("hotes", []):
nom = str(hote.get("nom", "")).strip()
if not nom:
raise ValueError("Un hote a un nom vide.")
if nom in noms:
raise ValueError(f"Hote en double: {nom}")
noms.add(nom)
if hote.get("etat") == "actif" and not str(hote.get("adresse_ip", "")).strip():
raise ValueError(f"Hote actif sans adresse IP: {nom}")
groupes = hote.get("groupes", [])
if not groupes:
raise ValueError(f"Aucun groupe operationnel pour: {nom}")
inconnus = sorted(set(groupes) - set(groupes_connus))
if inconnus:
raise ValueError(f"Groupes inconnus pour {nom}: {', '.join(inconnus)}")
vlan = str(hote.get("vlan", "")).strip()
if vlan and (not vlan.isdigit() or not 1 <= int(vlan) <= 4094):
raise ValueError(f"VLAN invalide pour {nom}: {vlan} (attendu 1-4094)")
cidr = str(hote.get("cidr", "")).strip()
if cidr and (not cidr.isdigit() or not 0 <= int(cidr) <= 32):
raise ValueError(f"CIDR invalide pour {nom}: {cidr} (attendu 0-32)")
for cle, libelle in (("memoire", "Memoire"), ("coeurs", "Coeurs")):
valeur = str(hote.get(cle, "")).strip()
if valeur and (not valeur.isdigit() or int(valeur) <= 0):
raise ValueError(f"{libelle} invalide pour {nom}: {valeur} (entier positif attendu)")
vmid = str(hote.get("vmid", "")).strip()
if not vmid:
continue
if vmid in vmids:
raise ValueError(f"VMID en double: {vmid} pour {vmids[vmid]} et {nom}")
vmids[vmid] = nom
valider_dependances_payload(payload, dependencies)
HTML = r"""<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Set-OPS · Votre artisan numérique</title>
<style>
:root {
color-scheme: dark;
--fond:#0c1119;
--fond-degrade:radial-gradient(1100px 540px at 100% -8%, #142033 0, transparent 55%), var(--fond);
--surface:#151d2b;
--surface-2:#1a2333;
--surface-3:#212d41;
--texte:#e8edf6;
--muted:#93a1bb;
--faint:#67738c;
--ligne:#27324a;
--ligne-forte:#36445f;
--primaire:#3b82f6;
--primaire-fort:#2f6fe0;
--teal:#2dd4bf;
--vert:#34d399; --vert-fond:rgba(52,211,153,.13);
--bleu:#60a5fa; --bleu-fond:rgba(96,165,250,.13);
--rouge:#f87171; --rouge-fond:rgba(248,113,113,.13);
--ambre:#fbbf24; --ambre-fond:rgba(251,191,36,.13);
--ombre:0 1px 2px rgba(0,0,0,.35), 0 10px 30px -14px rgba(0,0,0,.7);
--ombre-forte:0 18px 50px -18px rgba(0,0,0,.8);
--rayon:11px; --rayon-sm:8px;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--fond-degrade); color: var(--texte); min-height: 100vh;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased;
}
::selection { background: color-mix(in srgb, var(--teal) 35%, transparent); }
* { scrollbar-width: thin; scrollbar-color: var(--ligne-forte) transparent; }
header {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding: 12px 22px; border-bottom: 1px solid var(--ligne);
background: color-mix(in srgb, var(--surface) 82%, transparent);
backdrop-filter: saturate(150%) blur(12px);
}
.marque { display: flex; align-items: center; gap: 9px; min-width: 0; }
.marque .pastille { width: 9px; height: 9px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px color-mix(in srgb, var(--teal) 18%, transparent); flex: none; }
.marque .titre { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
h1 { margin: 0; font-size: 15px; font-weight: 800; letter-spacing: -.01em; white-space: nowrap; }
.slogan { font-size: 10.5px; color: var(--muted); font-weight: 600; letter-spacing: .02em; white-space: nowrap; }
.chemin { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.grandit { flex: 1 1 auto; min-width: 0; }
.resume { display: flex; gap: 6px; flex-wrap: wrap; }
.chip { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--ligne); border-radius: 999px; padding: 3px 10px; background: var(--surface-2); color: var(--muted); font-size: 11.5px; font-weight: 650; }
.chip b { color: var(--texte); font-weight: 800; }
.pt { width: 7px; height: 7px; border-radius: 50%; flex: none; }
.pt.actif { background: var(--vert); } .pt.plan { background: var(--bleu); } .pt.bloq { background: var(--rouge); }
.barre { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.recherche { position: relative; }
.recherche input { padding-left: 30px; min-width: 180px; }
.recherche::before { content: "⌕"; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--faint); font-size: 16px; }
button {
border: 1px solid var(--ligne-forte); background: var(--surface-2); color: var(--texte);
padding: 8px 13px; border-radius: var(--rayon-sm); cursor: pointer; font: inherit; font-weight: 700;
transition: background .15s, border-color .15s, transform .05s, box-shadow .15s, opacity .15s, color .15s;
}
button:hover:not(:disabled) { border-color: var(--teal); }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { opacity: .4; cursor: not-allowed; }
button.primaire { background: var(--primaire); color: #fff; border-color: var(--primaire); box-shadow: var(--ombre); }
button.primaire:hover:not(:disabled) { background: var(--primaire-fort); border-color: var(--primaire-fort); }
button.fantome { background: transparent; }
button.attention { border-color: var(--ambre); color: var(--ambre); }
button.danger { color: var(--rouge); border-color: transparent; background: transparent; }
button.danger:hover:not(:disabled) { background: var(--rouge-fond); }
input, select {
width: 100%; border: 1px solid var(--ligne-forte); border-radius: var(--rayon-sm);
padding: 8px 10px; background: var(--surface); color: var(--texte); font: inherit;
transition: border-color .15s, box-shadow .15s;
}
input::placeholder { color: var(--faint); }
input:focus, select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px color-mix(in srgb, var(--teal) 22%, transparent); }
main { padding: 16px 22px 44px; display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(420px, 1fr); gap: 20px; align-items: start; max-width: 1760px; margin: 0 auto; }
.toasts { position: fixed; top: 14px; right: 14px; z-index: 60; display: flex; flex-direction: column; gap: 8px; max-width: min(380px, 92vw); pointer-events: none; }
.toast { pointer-events: auto; background: var(--surface-2); border: 1px solid var(--ligne-forte); border-left: 3px solid var(--bleu); border-radius: var(--rayon-sm); box-shadow: var(--ombre-forte); padding: 9px 12px; font-size: 13px; font-weight: 600; color: var(--texte); display: flex; gap: 9px; align-items: flex-start; animation: toast-in .18s ease-out; }
.toast.ok { border-left-color: var(--vert); } .toast.erreur { border-left-color: var(--rouge); }
.toast.sortant { opacity: 0; transform: translateX(12px); transition: opacity .25s, transform .25s; }
.toast .x { margin-left: auto; cursor: pointer; opacity: .5; font-weight: 800; background: none; border: 0; color: inherit; padding: 0 2px; font-size: 15px; line-height: 1; }
.toast .x:hover { opacity: 1; }
@keyframes toast-in { from { opacity: 0; transform: translateX(12px); } }
input.invalide, select.invalide { border-color: var(--rouge) !important; box-shadow: 0 0 0 1px var(--rouge); }
.section-grille { margin-bottom: 22px; }
.section-tete { display: flex; align-items: center; gap: 10px; margin: 0 0 11px; color: var(--faint); font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
.section-tete .compte { color: var(--muted); }
.section-tete::after { content: ""; height: 1px; background: var(--ligne); flex: 1; }
.grille-cartes { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
.carte {
position: relative; text-align: left; display: flex; flex-direction: column; gap: 8px;
background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon);
padding: 12px 13px; cursor: pointer; font: inherit; color: var(--texte);
transition: border-color .14s, box-shadow .14s, transform .05s, background .14s;
}
.carte::before { content: ""; position: absolute; left: 0; top: 11px; bottom: 11px; width: 3px; border-radius: 0 3px 3px 0; background: var(--bleu); }
.carte.actif::before { background: var(--vert); }
.carte.bloque::before { background: var(--rouge); }
.carte:hover { border-color: var(--ligne-forte); box-shadow: var(--ombre); }
.carte.selectionnee { border-color: var(--teal); box-shadow: 0 0 0 1px var(--teal), var(--ombre); background: var(--surface-2); }
.carte-haut { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.carte-vmid { color: var(--faint); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.carte-nom { font-weight: 800; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.carte-nom.sans { color: var(--faint); font-style: italic; font-weight: 600; }
.carte-ip { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.carte-grps { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 1px; }
.tag { font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 999px; background: var(--surface-3); color: var(--muted); white-space: nowrap; }
.tag.plus { background: transparent; color: var(--faint); }
.badge-statut { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 9px; font-size: 10.5px; font-weight: 800; }
.badge-statut.ok { color: var(--vert); background: var(--vert-fond); }
.badge-statut.attente { color: var(--bleu); background: var(--bleu-fond); }
.badge-statut.bloque { color: var(--rouge); background: var(--rouge-fond); }
aside { position: sticky; top: 70px; max-height: calc(100vh - 90px); overflow: auto; }
.detail { background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon); box-shadow: var(--ombre); overflow: hidden; }
.detail.bloque { border-color: color-mix(in srgb, var(--rouge) 50%, var(--ligne)); }
.detail-tete { display: flex; align-items: center; gap: 10px; padding: 14px 16px 12px; border-bottom: 1px solid var(--ligne); flex-wrap: wrap; }
.detail-nom { flex: 1 1 150px; min-width: 0; }
.zone-hint { display: inline-block; margin: 4px 0 0 8px; font-size: 11px; font-weight: 700; color: var(--teal); }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); gap: 8px; padding: 14px 16px; }
.stat { background: var(--surface-2); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 9px 10px; min-width: 0; }
.stat .v { font-size: 18px; font-weight: 800; line-height: 1.1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat .v.vide { color: var(--faint); }
.stat .l { font-size: 9.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 800; margin-top: 3px; }
.grille { display: grid; grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: 11px 12px; }
.champ { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.champ > span { color: var(--muted); font-size: 11px; font-weight: 700; }
.champ .unite { color: var(--faint); font-weight: 600; }
.champ > span.a-aide { cursor: help; border-bottom: 1px dotted var(--ligne-forte); width: fit-content; }
.aide-modele { margin: 0 2px 14px; background: var(--surface); border: 1px solid var(--ligne); border-left: 3px solid var(--bleu); border-radius: var(--rayon-sm); }
.aide-modele > summary { cursor: pointer; padding: 10px 13px; font-size: 12.5px; font-weight: 750; color: var(--bleu); list-style: none; }
.aide-modele > summary::-webkit-details-marker { display: none; }
.aide-modele > summary::before { content: "ⓘ "; }
.aide-corps { padding: 2px 15px 12px; color: var(--muted); font-size: 12.5px; line-height: 1.55; }
.aide-corps p { margin: 6px 0; } .aide-corps ul { margin: 6px 0; padding-left: 18px; } .aide-corps li { margin: 3px 0; }
.aide-corps b { color: var(--texte); }
.aide-ro { border-top: 1px solid var(--ligne); padding-top: 8px; }
.detail-nom-txt { font-weight: 800; font-size: 17px; padding: 4px 7px; display: inline-block; overflow-wrap: anywhere; }
#infobulle {
position: fixed; z-index: 200; max-width: 260px; pointer-events: none;
background: var(--surface-3); color: var(--texte);
border: 1px solid var(--ligne-forte); border-radius: var(--rayon-sm);
padding: 7px 10px; font-size: 12px; font-weight: 600; line-height: 1.4;
box-shadow: var(--ombre); opacity: 0; transform: translateY(2px);
transition: opacity .12s ease, transform .12s ease;
}
#infobulle.on { opacity: 1; transform: none; }
.pied { padding: 12px 16px; border-top: 1px solid var(--ligne); display: flex; gap: 8px; align-items: center; }
.pied .grandit { flex: 1; }
.pied .hint-dep { color: var(--faint); font-size: 12px; font-style: italic; }
.dep-bloc { margin-top: 4px; }
.dep-bloc > summary { list-style: none; cursor: pointer; padding: 10px 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 800; font-size: 12.5px; background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); }
.dep-bloc > summary::-webkit-details-marker { display: none; }
.dep-bloc > summary::after { content: "▾"; color: var(--faint); }
.dep-bloc[open] > summary { border-radius: var(--rayon-sm) var(--rayon-sm) 0 0; }
.dep-corps { border: 1px solid var(--ligne); border-top: none; border-radius: 0 0 var(--rayon-sm) var(--rayon-sm); padding: 11px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
.carte-dep { border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 9px 10px; background: var(--surface-2); }
.carte-dep.ok { border-color: color-mix(in srgb, var(--vert) 45%, var(--ligne)); }
.carte-dep.attente { border-color: color-mix(in srgb, var(--ambre) 45%, var(--ligne)); }
.carte-dep.bloque { border-color: color-mix(in srgb, var(--rouge) 50%, var(--ligne)); }
.carte-dep .haut { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.carte-dep .nom { font-weight: 800; font-size: 12px; overflow-wrap: anywhere; }
.carte-dep .txt { color: var(--muted); font-size: 11px; line-height: 1.4; }
.vide { color: var(--muted); padding: 40px 24px; text-align: center; }
.vide b { color: var(--texte); display: block; margin-bottom: 4px; font-size: 15px; }
.vue-bascule { display: inline-flex; border: 1px solid var(--ligne-forte); border-radius: 999px; padding: 2px; background: var(--surface-3); gap: 2px; }
.vue-bascule button { border: none; background: transparent; border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 750; color: var(--muted); box-shadow: none; }
.vue-bascule button:hover { color: var(--texte); background: transparent; }
.vue-bascule button.on { background: var(--surface); color: var(--teal); box-shadow: var(--ombre); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--teal); font-size: 12px; }
.ch-stub { color: var(--ambre); font-size: 11.5px; font-style: italic; margin-top: 5px; }
.ch-bases { display: grid; gap: 4px; margin: 6px 0 0 23px; }
.ch-base { font-size: 11.5px; color: var(--muted); overflow-wrap: anywhere; }
.badge-bd { display: inline-block; font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
.modale { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.55); }
.modale[hidden] { display: none; }
.modale-boite { background: var(--surface); border: 1px solid var(--ligne-forte); border-radius: var(--rayon); box-shadow: var(--ombre-forte); padding: 18px 20px; width: min(440px, 92vw); display: grid; gap: 12px; }
.modale-titre { font-weight: 800; font-size: 15px; }
.modale-texte { color: var(--muted); font-size: 13px; line-height: 1.5; }
.modale-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.modale-boite.large { width: min(760px, 94vw); max-height: 88vh; overflow: auto; }
.intrants-corps { display: grid; gap: 16px; }
.intr-section { display: grid; gap: 9px; }
.intr-tete { font-weight: 800; font-size: 13px; color: var(--bleu); border-bottom: 1px solid var(--ligne); padding-bottom: 5px; }
.intr-grille { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 11px; }
.badge-classe { display: inline-block; font-size: 9.5px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; padding: 1px 6px; border-radius: 999px; margin-left: 6px; vertical-align: middle; }
.badge-classe.const { background: color-mix(in srgb, var(--ambre) 20%, transparent); color: var(--ambre); }
.badge-classe.def { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
.badge-classe.cat { background: color-mix(in srgb, var(--teal) 20%, transparent); color: var(--teal); }
.badge-prod { display: inline-flex; align-items: center; font-size: 11px; font-weight: 800; letter-spacing: .06em; padding: 3px 9px; border-radius: 999px; background: var(--rouge); color: #fff; }
.badge-lab { display: inline-flex; align-items: center; font-size: 11px; font-weight: 800; letter-spacing: .04em; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--teal) 22%, transparent); color: var(--teal); }
.integ-cases { display: flex; flex-wrap: wrap; gap: 6px 12px; padding: 4px 2px; }
.integ-case { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: var(--texte); cursor: pointer; }
.integ-univ { color: var(--vert); font-weight: 700; cursor: help; }
.integ-exempt { color: var(--faint); text-decoration: line-through; cursor: help; }
.integ-case input { width: 14px; height: 14px; flex: none; accent-color: var(--teal); }
/* Matrice serveurs x integrations */
.mx-cadre { overflow: auto; max-height: calc(100vh - 210px); }
.mx { border-collapse: separate; border-spacing: 0; font-size: 12px; width: max-content; min-width: 100%; }
.mx th, .mx td { border-bottom: 1px solid var(--ligne); padding: 5px 8px; }
.mx thead th, .mx tfoot td, .mx tfoot th { position: sticky; background: var(--surface-2); z-index: 2; }
.mx thead th { top: 0; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: var(--muted); text-align: center; white-space: nowrap; }
.mx thead th.mx-t-univ { color: var(--vert); }
.mx tfoot td, .mx tfoot th { bottom: 0; font-variant-numeric: tabular-nums; text-align: center; color: var(--muted); font-weight: 700; }
.mx .mx-plein { color: var(--vert); }
.mx .mx-h { position: sticky; left: 0; background: var(--surface); text-align: left; font-weight: 700; color: var(--texte); white-space: nowrap; cursor: pointer; z-index: 1; }
.mx thead .mx-coin, .mx tfoot .mx-coin { left: 0; z-index: 3; }
.mx .mx-h:hover { color: var(--teal); }
.mx .mx-fn { display: block; font-size: 10px; font-weight: 500; color: var(--faint); }
.mx tr.mx-sel .mx-h { box-shadow: inset 3px 0 0 var(--teal); }
.mx .mx-c { text-align: center; }
.mx .mx-c input { width: 14px; height: 14px; accent-color: var(--teal); cursor: pointer; }
.mx .mx-univ { color: var(--vert); font-weight: 800; cursor: help; }
.mx .mx-exempt { color: var(--faint); cursor: help; }
.mx-leg { margin-top: 10px; }
.mx-leg code { font-size: 12px; }
.lien-objet { background: none; border: 0; padding: 0; color: var(--bleu); font: inherit; font-weight: 700; cursor: pointer; text-decoration: underline dotted; }
.lien-objet:hover { color: var(--teal); }
.intr-proprio { margin-left: 8px; font-size: 10px; font-weight: 800; letter-spacing: .06em;
text-transform: uppercase; padding: 2px 8px; border-radius: 999px; cursor: help; }
.intr-proprio.heb { background: color-mix(in srgb, var(--ambre) 20%, transparent); color: var(--ambre); }
.intr-proprio.ten { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
.intr-ro { color: var(--muted); font-size: 12px; line-height: 1.55; }
.intr-ro b { color: var(--texte); font-weight: 700; }
.intr-secrets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.intr-secrets code { font-size: 11px; background: var(--surface-2); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 1px 6px; color: var(--muted); }
.console { position: fixed; left: 0; right: 0; bottom: 0; height: 44vh; background: var(--surface); border-top: 1px solid var(--ligne-forte); box-shadow: 0 -16px 50px -18px rgba(0,0,0,.8); z-index: 40; display: flex; flex-direction: column; }
.console[hidden] { display: none; }
.console-tete { display: flex; align-items: center; gap: 12px; padding: 9px 16px; border-bottom: 1px solid var(--ligne); background: var(--surface-2); }
.console-titre { font-weight: 800; font-size: 13px; }
.console-etat { font-size: 12px; font-weight: 800; }
.console-etat.actif { color: var(--bleu); animation: pulse 1.2s ease-in-out infinite; }
.console-etat.ok { color: var(--vert); } .console-etat.echec { color: var(--rouge); }
@keyframes pulse { 50% { opacity: .4; } }
.console pre { margin: 0; padding: 12px 16px; overflow: auto; flex: 1; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; color: #cdd6e6; }
@media (max-width: 1000px) {
main { grid-template-columns: 1fr; }
aside { position: static; max-height: none; order: -1; }
}
/* Vues lecture seule : Flux + Couches */
.tbl-lecture { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.tbl-lecture th { text-align: left; padding: 7px 10px; color: var(--muted); font-weight: 750; border-bottom: 1px solid var(--ligne-forte); position: sticky; top: 0; background: var(--surface); }
.tbl-lecture td { padding: 6px 10px; border-bottom: 1px solid var(--ligne); vertical-align: top; }
.tbl-lecture tr:hover td { background: var(--surface-2); }
.flux-raison { color: var(--muted); max-width: 44ch; }
.sens { font-size: 11px; font-weight: 750; padding: 1px 7px; border-radius: 999px; }
.sens-ingress { background: color-mix(in srgb, var(--bleu) 16%, transparent); color: var(--bleu); }
.sens-egress { background: color-mix(in srgb, var(--ambre) 18%, transparent); color: var(--ambre); }
.chip.chi-tls-requis { background: color-mix(in srgb, var(--teal) 22%, transparent); color: var(--teal); }
.chip.chi-tls { background: color-mix(in srgb, var(--teal) 13%, transparent); color: var(--teal); }
.chip.chi-starttls { background: color-mix(in srgb, var(--ambre) 18%, transparent); color: var(--ambre); }
.chip.chi-ssh { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
.chip.chi-tls-cible { background: color-mix(in srgb, var(--ambre) 12%, transparent); color: var(--muted); }
.chip.chi-clair { background: var(--surface-3); color: var(--muted); }
.couches-pile { display: flex; flex-direction: column; gap: 0; padding: 6px; }
.couche { border: 1px solid var(--ligne-forte); border-radius: 10px; padding: 9px 12px; background: var(--surface-2); }
.couche-tete { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.couche-idx { display: inline-flex; width: 20px; height: 20px; align-items: center; justify-content: center; border-radius: 999px; background: var(--teal); color: var(--surface); font-size: 11px; font-weight: 800; }
.couche-groupes { display: flex; flex-wrap: wrap; gap: 5px; }
.couche-fleche { text-align: center; color: var(--muted); font-size: 14px; line-height: 1.5; }
.legende { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--muted); }
.legende .chip { margin-right: 4px; }
/* Résumé d'erreur en langage clair (console) */
#console-resume { padding: 12px 16px; background: color-mix(in srgb, var(--rouge) 10%, var(--surface)); border-bottom: 1px solid var(--ligne); }
.re-tete { display: flex; align-items: center; gap: 10px; font-weight: 800; color: var(--rouge); font-size: 13px; margin-bottom: 6px; }
.re-tete button { margin-left: auto; }
.re-liste { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 7px; }
.re-liste li { font-size: 12.5px; }
.re-hote { color: var(--teal); font-weight: 700; }
.re-type { color: var(--muted); font-size: 11px; }
.re-msg { background: var(--surface-3); border-radius: 6px; padding: 4px 8px; margin-top: 3px; font-family: ui-monospace, monospace; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; }
/* Devis réseau (config switch) */
.devis-reseau { margin: 8px 0 0; padding: 12px 14px; background: var(--surface-3); border-radius: 8px; font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.5; white-space: pre; overflow: auto; max-height: calc(100vh - 210px); }
</style>
</head>
<body>
<div class="toasts" id="toasts" aria-live="polite"></div>
<header>
<div class="marque"><span class="pastille"></span><div class="titre"><h1>Set-OPS</h1><span class="slogan">Votre artisan numérique</span></div></div>
<span class="chemin" id="chemin"></span>
<div class="grandit"></div>
<div class="resume" id="resume"></div>
<div class="barre" title="Raccourcis clavier — 1-3 : changer de vue · j/k : naviguer les serveurs · v : vérifier · d : déployer · / : filtrer · Ctrl+S : sauvegarder la vue">
<div class="vue-bascule">
<button type="button" id="btn-vue-serveurs" class="on" onclick="setVue('serveurs')">Serveurs</button>
<button type="button" id="btn-vue-applications" onclick="setVue('applications')">Applications</button>
<button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button>
<button type="button" id="btn-vue-domaines" onclick="setVue('domaines')">Domaines</button>
<button type="button" id="btn-vue-nomenclature" onclick="setVue('nomenclature')">Nomenclature</button>
<button type="button" id="btn-vue-integrations" onclick="setVue('integrations')">Intégrations</button>
<button type="button" id="btn-vue-flux" onclick="setVue('flux')">Flux</button>
<button type="button" id="btn-vue-couches" onclick="setVue('couches')">Couches</button>
<button type="button" id="btn-vue-reseau" onclick="setVue('reseau')">Réseau</button>
</div>
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
<button type="button" class="fantome" onclick="ouvrirIntrants()">⚙ Intrants</button>
<button type="button" class="fantome" onclick="charger()">Recharger</button>
<button type="button" class="primaire" id="btn-sauver" onclick="sauvegarderVue()">Sauvegarder</button>
</div>
</header>
<main>
<section>
<div id="grilles"></div>
<details class="dep-bloc" id="panneau-dep">
<summary>Dépendances causales <span id="dep-resume" class="chip"></span></summary>
<div class="dep-corps" id="dependances"></div>
</details>
</section>
<aside><div id="detail"></div></aside>
</main>
<div class="modale" id="modale" hidden>
<div class="modale-boite">
<div class="modale-titre" id="modale-titre"></div>
<div class="modale-texte" id="modale-texte"></div>
<label class="champ"><span>Mot de passe du vault Ansible (laisser vide si non requis)</span>
<input type="password" id="modale-vault" autocomplete="off" placeholder="••••••••" onkeydown="if(event.key==='Enter')confirmerModale()"></label>
<div class="modale-actions">
<button type="button" onclick="annulerModale()">Annuler</button>
<button type="button" class="primaire" onclick="confirmerModale()">Confirmer</button>
</div>
</div>
</div>
<div class="modale" id="intrants" hidden>
<div class="modale-boite large">
<div class="modale-titre">⚙ Intrants de base de l'écosystème</div>
<div class="modale-texte">Saisis ici les valeurs communes à tout l'écosystème, depuis un endroit unique.
Les <b>constantes</b> sont une valeur unique non surchargeable&nbsp;; les <b>défauts</b> resurgissent
comme valeurs proposées là où ils réapparaissent dans les instances déclarées.
Les secrets ne sont jamais saisis ici&nbsp;: ils restent dans le Vault Ansible (CLI).</div>
<div class="intrants-corps" id="intrants-corps"></div>
<div class="modale-actions">
<button type="button" onclick="fermerIntrants()">Fermer</button>
<button type="button" class="primaire" onclick="enregistrerIntrants()">Enregistrer</button>
</div>
</div>
</div>
<div class="console" id="console" hidden>
<div class="console-tete">
<span class="console-titre" id="console-titre"></span>
<span class="console-etat" id="console-etat"></span>
<span class="grandit"></span>
<button type="button" id="btn-fermer-console" onclick="fermerConsole()">Fermer</button>
</div>
<div id="console-resume" hidden></div>
<pre id="console-sortie"></pre>
</div>
<script>
const JETON = "__JETON__";
let groupes = [];
let dependances = {};
let nomenclature = {};
// LE SCHEMA DES REGISTRES, a portee globale. Les formulaires generes le lisaient dans
// `data.schema` — or il n'existe AUCUN `data` global ici : c'est une const locale de
// `charger()`. Le rendu levait donc `ReferenceError` dans un navigateur, et
// `sauvegarderBases` pire encore (zone morte : `const data` y est declare plus bas,
// dans la meme fonction). Eprouver le rendu sous node en LUI FOURNISSANT `data` ne
// pouvait pas le voir : le banc reproduisait la fonction, pas sa portee.
let schemaPlan = {};
let chaine = {};
let hotes = [];
let estProduction = false;
let filtre = '';
let vuePrincipale = 'serveurs';
let fluxRegistre = []; // matrice des flux réseau (lecture seule)
let couchesRegistre = []; // ordre de déploiement en couches (lecture seule)
let bdServeurs = [];
let bdApplis = [];
let basesModifie = false;
let selApp = null; // index de l'application sélectionnée (vue Applications)
let selServeur = null; // index du serveur sélectionné (vue Serveurs, maître-détail fusionné)
let selBase = null; // clé de l'item sélectionné (vue Bases) : "srv:<i>" ou "bd:<i>"
let hoteOpCourant = ''; // nom de l'hôte dont les boutons Vérifier/Déployer sont affichés
let applications = [];
let applicationsModifie = false;
let nomEdit = {}; // nomenclature en cours d'édition (copie de travail)
let selNom = null; // 'cat:<clef>' ou 'fon:<clef>' — l'entrée sélectionnée
let nomModifie = false;
let domaines = []; // registre des domaines (éditable)
let selDomaine = null; // index du domaine sélectionné
let domainesModifie = false;
const AUTORITES_DNS = ['primaire-cache', 'auto-heberge', 'delegue'];
let serveurs = [];
let serveursModifie = false;
let ongletActif = 'reseau';
let occupe = false;
let verifie = {};
let motVault = '';
let modaleResolveur = null;
let intrants = null;
let integrationsDispo = [];
let integrationsUniv = {};
let liensAcceptes = {};
let sondes = {}; // état RÉEL par hôte : joignable (SSH) ou non — distinct du plan
let chronoConsole = null;
let consoleDebut = 0;
let toastSeq = 0;
function message(texte, type='', opts={}) {
const conteneur = document.getElementById('toasts');
if (!conteneur) return null;
const t = document.createElement('div');
t.className = 'toast' + (type ? ' ' + type : '');
t.id = 'toast-' + (++toastSeq);
const corps = document.createElement('span');
corps.textContent = texte;
t.appendChild(corps);
const x = document.createElement('button');
x.type = 'button'; x.className = 'x'; x.textContent = '×';
x.onclick = () => retirerToast(t);
t.appendChild(x);
conteneur.appendChild(t);
while (conteneur.children.length > 6) conteneur.removeChild(conteneur.firstChild);
const duree = opts.persistant ? 0 : (opts.duree != null ? opts.duree : (type === 'erreur' ? 7000 : 3500));
if (duree > 0) setTimeout(() => retirerToast(t), duree);
return t; // renvoie le nœud pour permettre de le mettre à jour (opérations longues)
}
function majToast(t, texte, type, dureeFin) {
if (!t) return;
if (type !== undefined) t.className = 'toast' + (type ? ' ' + type : '');
const corps = t.querySelector('span'); if (corps) corps.textContent = texte;
if (dureeFin) setTimeout(() => retirerToast(t), dureeFin);
}
function retirerToast(t) {
if (!t || !t.parentNode) return;
t.classList.add('sortant');
setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 260);
}
async function charger() {
const reponse = await fetch('/api/inventaire');
const data = await reponse.json();
groupes = data.groupes;
dependances = data.dependances || {};
nomenclature = data.nomenclature || {};
schemaPlan = data.schema || {};
chaine = data.chaine || {};
fluxRegistre = data.flux || [];
couchesRegistre = data.couches || [];
intrants = data.intrants || null;
integrationsDispo = data.integrations_disponibles || [];
integrationsUniv = data.integrations_universelles || {};
liensAcceptes = data.liens_acceptes || {};
chargerBases(data);
chargerApplications(data);
chargerDomaines(data);
chargerNomEdit(data);
chargerServeurs(data);
hotes = data.hotes;
estProduction = !!data.production;
verifie = {};
document.getElementById('chemin').textContent = data.inventaire;
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(); }
function filtrer(valeur) { filtre = (valeur || '').trim().toLowerCase(); dessinerGrilles(); }
function groupesActifs() { const a = new Set(); hotes.forEach(h => { if (h.etat === 'actif') (h.groupes || []).forEach(g => a.add(g)); }); return a; }
function groupesPlanifies() { const p = new Set(); hotes.forEach(h => (h.groupes || []).forEach(g => p.add(g))); return p; }
function statutDependance(groupe) {
const requis = ((dependances[groupe] || {}).requiert_groupes_actifs || []);
if (!requis.length) return 'neutre';
const actifs = groupesActifs(), planifies = groupesPlanifies();
const manquants = requis.filter(i => !actifs.has(i));
if (!manquants.length) return 'ok';
if (manquants.every(i => planifies.has(i))) return 'attente';
return 'bloque';
}
function manquantsPourGroupe(groupe) { const a = groupesActifs(); return ((dependances[groupe] || {}).requiert_groupes_actifs || []).filter(i => !a.has(i)); }
function hoteBloque(h) { if (!h || h.etat !== 'actif') return false; return (h.groupes || []).some(g => manquantsPourGroupe(g).length); }
function libelleStatut(s) { return {ok:'actif', attente:'planifié', bloque:'bloqué', neutre:'libre'}[s] || s; }
function dessiner() { dessinerResume(); dessinerGrilles(); dessinerDependances(); dessinerDetail(); majIndicateurSauvegarde(); }
function majIndicateurSauvegarde() {
const b = document.getElementById('btn-sauver');
if (!b) return;
const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'integrations' || vuePrincipale === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines' || vuePrincipale === 'nomenclature';
const sale = ((vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') && serveursModifie)
|| (vuePrincipale === 'applications' && applicationsModifie)
|| (vuePrincipale === 'bases' && basesModifie)
|| (vuePrincipale === 'domaines' && domainesModifie)
|| (vuePrincipale === 'nomenclature' && nomModifie);
b.classList.toggle('attention', sale);
b.textContent = sale ? 'Sauvegarder •' : 'Sauvegarder';
b.disabled = !editable;
b.title = editable ? 'Sauvegarder cette vue (Ctrl+S)' : 'Lecture seule — édite dans Serveurs / Applications / Bases';
}
function dessinerResume() {
const actifs = hotes.filter(h => h.etat === 'actif').length;
const bloques = hotes.filter(h => hoteBloque(h)).length;
const vivantes = hotes.filter(h => sondes[h.nom] === true).length;
const sonde = Object.keys(sondes).length > 0;
document.getElementById('resume').innerHTML = `
${estProduction ? '<span class="badge-prod" title="Instance de PRODUCTION : déploiement sous confirmation renforcée">PROD</span>' : '<span class="badge-lab" title="Instance bac à sable (setops_production: false)">bac à sable</span>'}
<span class="chip"><b>${hotes.length}</b> hôtes</span>
<span class="chip"><span class="pt actif"></span><b>${actifs}</b> actifs</span>
<span class="chip"><span class="pt plan"></span><b>${hotes.length - actifs}</b> planifiés</span>
${bloques ? `<span class="chip"><span class="pt bloq"></span><b>${bloques}</b> bloqués</span>` : ''}
${sonde ? `<span class="chip" title="État RÉEL : VM joignables (sonde SSH), indépendamment du plan"><span class="pt actif"></span><b>${vivantes}</b> vivantes</span>` : ''}`;
}
function correspondServeur(s) {
if (!filtre) return true;
return [s.nom, s.fonction, s.adresse_ip, s.vmid, s.etat].join(' ').toLowerCase().includes(filtre);
}
function setVue(v) {
vuePrincipale = v;
if (v === 'serveurs' && selServeur == null && serveurs.length) selServeur = 0;
if (v === 'applications' && selApp == null && applications.length) selApp = 0;
if (v === 'bases' && selBase == null) selBase = bdServeurs.length ? 'srv:0' : (bdApplis.length ? 'bd:0' : null);
document.getElementById('btn-vue-serveurs').classList.toggle('on', v === 'serveurs');
document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications');
document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases');
document.getElementById('btn-vue-domaines').classList.toggle('on', v === 'domaines');
document.getElementById('btn-vue-integrations').classList.toggle('on', v === 'integrations');
document.getElementById('btn-vue-flux').classList.toggle('on', v === 'flux');
document.getElementById('btn-vue-couches').classList.toggle('on', v === 'couches');
document.getElementById('btn-vue-reseau').classList.toggle('on', v === 'reseau');
dessinerGrilles();
dessinerDetail(); // le panneau droit reflète la sélection de la vue courante
majIndicateurSauvegarde();
}
const BANNIERE_MODELE = `<details class="aide-modele">
<summary>Comment lire ce parc — serveur · hôte · groupe · application · base</summary>
<div class="aide-corps">
<p><b>Serveur</b> (ce que tu écris ici) → <b>« Appliquer le plan »</b> → <b>Hôte</b> (la VM générée). C'est le même objet, à deux étapes.</p>
<ul>
<li><b>Serveur / Hôte</b> — une VM ; elle <b>appartient à des groupes</b>.</li>
<li><b>Groupe</b> — un rôle appliqué à l'hôte (ex. <span class="mono">serveur_nginx</span>, <span class="mono">client_dns</span>).</li>
<li><b>Application</b> — un service qui <b>tourne sur</b> un hôte, <b>via</b> un groupe, et <b>expose</b> un FQDN.</li>
<li><b>Base</b> — une donnée <b>hébergée sur</b> un hôte et <b>consommée par</b> une application.</li>
</ul>
<p class="aide-ro">Saisis à <b>gauche</b> (tuiles), édite à <b>droite</b> ; les dérivés (VMID/IP/VLAN/groupes) se calculent à « Appliquer le plan ».</p>
</div></details>`;
function dessinerGrilles() {
if (vuePrincipale === 'applications') { dessinerApplications(); return; }
if (vuePrincipale === 'bases') { dessinerBases(); return; }
if (vuePrincipale === 'domaines') { dessinerDomaines(); return; }
if (vuePrincipale === 'nomenclature') { dessinerNomenclature(); return; }
if (vuePrincipale === 'integrations') { dessinerIntegrations(); return; }
if (vuePrincipale === 'flux') { dessinerFlux(); return; }
if (vuePrincipale === 'couches') { dessinerCouches(); return; }
if (vuePrincipale === 'reseau') { dessinerReseau(); return; }
dessinerServeurs();
}
function legendeLecture() {
const d = document.getElementById('detail');
if (vuePrincipale === 'flux') {
d.innerHTML = `<div class="detail"><div class="section-tete">Chiffrement</div>
<div class="legende">
<div><span class="chip chi-tls-requis">tls-requis</span> chiffré + pair vérifié</div>
<div><span class="chip chi-tls">tls</span> chiffré</div>
<div><span class="chip chi-starttls">starttls</span> mise à niveau opportuniste</div>
<div><span class="chip chi-ssh">ssh</span> transport SSH</div>
<div><span class="chip chi-tls-cible">tls-cible</span> TLS visé (roadmap)</div>
<div><span class="chip chi-clair">clair</span> non chiffré (local / edge)</div></div>
<div class="hint-dep" style="margin-top:10px">Lecture seule. Éditer les <code>meta/flux.yml</code> des rôles, puis <code>make flux</code>.</div></div>`;
} else if (vuePrincipale === 'reseau') {
d.innerHTML = `<div class="detail"><div class="section-tete">Réseau &amp; fédération</div>
<div class="hint-dep">Chaque instance fédérée dérive son adressage de son seed <code>index</code> (VLAN/VNI = 1000 + index×10 + zone, unique dans la fédération). La vue liste la flotte, signale toute collision d'index, et génère les devis d'infrastructure : commutateurs (<code>make devis-reseau</code>), frontière nord/sud (<code>make devis-opnsense</code>), SDN EVPN (<code>make devis-sdn</code>) et pare-feu est-ouest Proxmox (<code>make devis-proxmox-fw</code>).</div>
<div class="hint-dep" style="margin-top:10px">Lecture seule, régénérés du plan — <code>make devis-reseau</code> / <code>make devis-opnsense</code>. Le <strong>dialecte de CLI</strong> et le <strong>mode de routage</strong> (commutateur ou SDN EVPN) se règlent dans la section <em>Fabric</em> du panneau « Intrants de base ». En mode SDN, les commutateurs ne portent ni VLAN tenant, ni SVI, ni ACL : le routage et le filtrage inter-zone vivent sur les hyperviseurs.</div>
<div class="hint-dep" style="margin-top:10px">Ce qui reste à nommer à la main : les <strong>ports physiques</strong> (<code>&lt;PORT-VERS-…&gt;</code>) et, en SDN, le <strong>nœud de sortie EVPN</strong>. Rien d'autre — adresses, VLAN et routes se dérivent.</div></div>`;
} else {
d.innerHTML = `<div class="detail"><div class="section-tete">Reconstruction</div>
<div class="hint-dep">L'ordre part du socle et remonte : chaque couche suppose la précédente debout. Le tri intra-couche vient du graphe des dépendances.</div>
<div class="hint-dep" style="margin-top:10px">Lecture seule. Éditer <code>docs/couches-deploiement.yml</code> ; généré dans <code>playbooks/site.yml</code> par <code>make site</code>.</div>
<div class="hint-dep" style="margin-top:10px"><strong>Vérifier ce qui a été remonté.</strong> Un déploiement vert ne dit pas que le service rend son service. Les <strong>devis de service</strong> lisent le système en marche et le comparent à ce que le plan dérive — ils n'écrivent rien et sortent en erreur s'il y a un écart :
<code>make identite-plan</code>, <code>make certificats-plan</code>, <code>make expositions-plan</code>, <code>make postgresql-plan</code>, <code>make courriel-plan</code>.
Détail dans <code>docs/devis-services.md</code>.</div></div>`;
}
}
function dessinerFlux() {
const cible = document.getElementById('grilles');
if (!fluxRegistre.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun flux (meta/flux.yml).</div>'; return; }
const lignes = fluxRegistre.map(f => `<tr>
<td class="mono">${echapper(f.role)}</td>
<td><span class="sens sens-${echapper(f.sens)}">${echapper(f.sens)}</span></td>
<td class="mono">${echapper(f.port)}/${echapper(f.protocole)}</td>
<td class="mono">${echapper(f.pair)}</td>
<td><span class="chip chi-${echapper(f.chiffrement)}">${echapper(f.chiffrement)}</span></td>
<td class="flux-raison">${echapper(f.raison)}</td></tr>`).join('');
cible.innerHTML = `<div class="section-grille">
<div class="section-tete">Flux réseau — matrice d'audit <span class="compte">${fluxRegistre.length}</span>
<span class="hint-dep" style="margin-left:auto">ingress = le rôle écoute · egress = il se connecte · source de nftables + audit</span></div>
<table class="tbl-lecture"><thead><tr><th>Rôle</th><th>Sens</th><th>Port</th><th>Pair</th><th>Chiffrement</th><th>Raison</th></tr></thead>
<tbody>${lignes}</tbody></table></div>`;
}
let devisReseauTexte = '';
let devisFrontiereTexte = '';
function tableauFlotte(inst, coll, fmodeles, findexPris) {
if (!inst.length) return '<div class="vide" style="padding:14px">Aucune instance découverte (dépôts frères avec plan/nomenclature.yml).</div>';
const banniere = Object.keys(coll || {}).length
? `<div class="hint-dep" style="color:var(--danger);border:1px solid var(--danger);border-radius:8px;padding:8px 10px;margin-bottom:8px">⚠ COLLISION d'index entre instances fédérées : ${Object.entries(coll).map(([i, n]) => 'index ' + i + ' → ' + n.map(echapper).join(', ')).join(' ; ')} — mêmes VLAN/VMID sur le trunk.</div>`
: '';
const lignes = inst.map(i => {
const vlan = i.vlans ? (i.vlans[0] + '-' + i.vlans[1]) : '—';
const fed = i.federe ? '<span class="chip">fédérée</span>' : '<span class="chip" style="opacity:.6">local</span>';
const prod = i.production ? '<span class="chip chi-tls-requis">prod</span>' : '';
const action = i.actif
? '<span class="chip chi-tls-requis">active</span>'
: `<button type="button" class="fantome" onclick="basculerInstance('${echapper(i.nom)}', ${i.production ? 'true' : 'false'})">Activer</button>`;
return `<tr>
<td class="mono">${i.actif ? '★ ' : ''}${echapper(i.nom)}</td>
<td class="mono">${i.index}</td>
<td class="mono">${vlan}</td>
<td>${fed} ${prod}</td>
<td>${action}</td></tr>`;
}).join('');
const optModeles = (fmodeles || []).map(m => `<option value="${echapper(m)}">${echapper(m)}</option>`).join('');
const creer = (fmodeles || []).length ? `
<div class="section-tete" style="margin-top:14px">Créer une instance depuis un modèle</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end">
<label class="champ" style="max-width:14rem"><span>Modèle</span><select id="ci-modele">${optModeles}</select></label>
<label class="champ" style="max-width:12rem"><span>Nom (dépôt frère)</span><input id="ci-nom" placeholder="OPS-ClientX"></label>
<label class="champ" style="max-width:9rem"><span>Index</span><input id="ci-index" type="number" placeholder="libre" title="Index unique ; ${(findexPris||[]).length ? 'déjà pris : ' + (findexPris||[]).join(', ') : 'aucun pris'}"></label>
<button type="button" class="primaire" onclick="creerInstance()">Créer</button>
</div>
<div class="hint-dep" style="margin-top:4px">Copie le modèle vers <code>../&lt;nom&gt;</code> et fixe l'index. Ne bascule pas : reste à renseigner intrants + voûte, puis « Activer ».${(findexPris||[]).length ? ' Index déjà pris : ' + (findexPris||[]).join(', ') + '.' : ''}</div>` : '';
return `${banniere}<table class="tbl-lecture"><thead><tr><th>Instance</th><th>Index</th><th>VLAN</th><th>Statut</th><th>Active</th></tr></thead><tbody>${lignes}</tbody></table>
<div class="hint-dep" style="margin-top:6px">★ = active (symlink <code>instance</code>). « Activer » bascule toutes les vues et les déploiements vers cette instance.</div>
${creer}`;
}
async function creerInstance() {
const modele = (document.getElementById('ci-modele') || {}).value || '';
const nom = ((document.getElementById('ci-nom') || {}).value || '').trim();
const index = ((document.getElementById('ci-index') || {}).value || '').trim();
if (!nom) { message('Donne un nom de dépôt (ex. OPS-ClientX).', 'erreur'); return; }
if (!confirm(`Créer l'instance « ${nom} » depuis le modèle « ${modele} »${index ? ' (index ' + index + ')' : ''} ?\\n\\nCopie le modèle vers ../${nom}. L'instance active n'est PAS changée.`)) return;
try {
const rep = await fetch('/api/instance-creer', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
body: JSON.stringify({nom, modele, index})});
const data = await rep.json();
if (!rep.ok) { message(data.erreur || 'Création refusée.', 'erreur'); return; }
message('Instance « ' + nom + ' » créée. Renseigne intrants + voûte, puis « Activer ».', 'ok');
dessinerReseau();
} catch (e) { message('Erreur : ' + e.message, 'erreur'); }
}
async function basculerInstance(nom, estProd) {
const avert = estProd
? `\\n\\n⚠ « ${nom} » est une instance de PRODUCTION. Déployer y touchera l'infra réelle.`
: '';
if (!confirm(`Basculer l'instance active vers « ${nom} » ?\\n\\nToutes les vues et les déploiements viseront alors « ${nom} ».${avert}`)) return;
try {
const rep = await fetch('/api/instance-utiliser', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
body: JSON.stringify({nom})});
const data = await rep.json();
if (!rep.ok) { message(data.erreur || 'Bascule refusée.', 'erreur'); return; }
message('Instance active : ' + nom + ' — rechargement…', 'ok');
setTimeout(() => location.reload(), 500);
} catch (e) { message('Erreur : ' + e.message, 'erreur'); }
}
async function dessinerReseau() {
const cible = document.getElementById('grilles');
cible.innerHTML = '<div class="vide" style="padding:14px">Lecture de la fédération…</div>';
try {
const [ri, rd, rf] = await Promise.all([
fetch('/api/instances', {headers: {'X-Jeton': JETON}}),
fetch('/api/devis-reseau', {headers: {'X-Jeton': JETON}}),
fetch('/api/devis-opnsense', {headers: {'X-Jeton': JETON}}),
]);
const di = await ri.json(), data = await rd.json(), dfr = await rf.json();
if (!rd.ok) { cible.innerHTML = '<div class="vide" style="padding:14px">' + echapper(data.erreur || 'Indisponible.') + '</div>'; return; }
devisReseauTexte = data.config || '';
const flotte = `<div class="section-grille"><div class="section-tete">Flotte d'instances <span class="compte">${(di.instances || []).length}</span></div>${tableauFlotte(di.instances || [], di.collisions || {}, di.modeles || [], di.index_pris || [])}</div>`;
const devis = devisReseauTexte
? `<div class="section-grille" style="margin-top:12px"><div class="section-tete">Devis config réseau — switches (instances fédérées)
<button type="button" class="fantome" style="margin-left:auto" onclick="copierDevis()">Copier</button></div>
<pre class="devis-reseau">${echapper(devisReseauTexte)}</pre></div>`
: '<div class="hint-dep" style="margin-top:12px">Aucune instance fédérée : pas de devis convergé à générer.</div>';
devisFrontiereTexte = dfr.config || '';
const frontiere = devisFrontiereTexte
? `<div class="section-grille" style="margin-top:12px"><div class="section-tete">Devis config réseau — frontière nord/sud (OPNsense)
<button type="button" class="fantome" style="margin-left:auto" onclick="copierDevisFrontiere()">Copier</button></div>
<pre class="devis-reseau">${echapper(devisFrontiereTexte)}</pre></div>`
: `<div class="hint-dep" style="margin-top:12px">Devis frontière indisponible${dfr.erreur ? ' : ' + echapper(dfr.erreur) : ''}.</div>`;
cible.innerHTML = flotte + devis + frontiere;
} catch (e) {
cible.innerHTML = '<div class="vide" style="padding:14px">Erreur : ' + echapper(e.message) + '</div>';
}
}
function copierDevis() {
if (navigator.clipboard) navigator.clipboard.writeText(devisReseauTexte)
.then(() => message('Devis réseau copié — prêt à coller sur le switch.', 'ok'), () => {});
}
function copierDevisFrontiere() {
if (navigator.clipboard) navigator.clipboard.writeText(devisFrontiereTexte)
.then(() => message('Devis frontière copié.', 'ok'), () => {});
}
function dessinerCouches() {
const cible = document.getElementById('grilles');
if (!couchesRegistre.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Ordre de déploiement indisponible.</div>'; return; }
const blocs = couchesRegistre.map((c, i) => `<div class="couche">
<div class="couche-tete"><span class="couche-idx">${i}</span><b>${echapper(c.nom)}</b><span class="compte">${c.groupes.length}</span></div>
<div class="couche-groupes">${c.groupes.map(g => `<span class="chip mono">${echapper(g)}</span>`).join('')}</div></div>`
).join('<div class="couche-fleche">↓</div>');
cible.innerHTML = `<div class="section-grille">
<div class="section-tete">Ordre de déploiement — couches <span class="compte">${couchesRegistre.length}</span>
<span class="hint-dep" style="margin-left:auto">make deployer-tout / myDay déroule dans cet ordre · tri topo intra-couche</span></div>
<div class="couches-pile">${blocs}</div></div>`;
}
// --- Matrice serveurs x integrations -------------------------------------
// Pourquoi cette vue : la fiche de detail montre les integrations d'UN serveur.
// Savoir qui n'a pas de sauvegarde demandait donc d'ouvrir chaque fiche — et un
// trou (deux serveurs sans supervision) est reste invisible jusqu'a ce qu'un
// devis de pare-feu l'enumere. Ici il se lit dans une colonne.
function servicesRendus(nom) {
return new Set(applications.filter(a => a.hote === nom).map(a => a.groupe));
}
function estExempt(role, rendus) {
const p = integrationsUniv[role];
return !!(p && p.sauf_role && rendus.has(p.sauf_role));
}
function basculerIntegrationDe(i, role, actif) {
const s = serveurs[i]; if (!s) return;
const set = new Set(listeDepuisTexte(s.integrations));
if (actif) set.add(role); else set.delete(role);
s.integrations = Array.from(set).join(', ');
marquerServeursModifie(); dessinerIntegrations();
}
function dessinerIntegrations() {
const cible = document.getElementById('grilles');
const univ = Object.keys(integrationsUniv).sort();
// Colonnes facultatives : celles proposees + toute valeur deja presente dans le
// plan (un role retire de roles/ ne doit pas faire disparaitre sa colonne).
const dansPlan = new Set();
serveurs.forEach(s => listeDepuisTexte(s.integrations).forEach(r => dansPlan.add(r)));
const opt = [...new Set([...integrationsDispo, ...dansPlan])].filter(r => !integrationsUniv[r]).sort();
const colonnes = [...univ, ...opt];
const lignes = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s));
if (!colonnes.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun rôle client_* dans roles/.</div>'; return; }
if (!lignes.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun serveur ne correspond au filtre.</div>'; return; }
const couvert = {}; colonnes.forEach(r => couvert[r] = 0);
const corps = lignes.map(([s, i]) => {
const rendus = servicesRendus(s.nom);
const set = new Set(listeDepuisTexte(s.integrations));
const cellules = colonnes.map(role => {
if (integrationsUniv[role]) {
if (estExempt(role, rendus)) {
return `<td class="mx-c mx-exempt" title="Exempté : cet hôte rend ${echapper(integrationsUniv[role].sauf_role)} — il ne s'intègre pas à lui-même">—</td>`;
}
couvert[role]++;
return `<td class="mx-c mx-univ" title="${echapper(integrationsUniv[role].raison || '')}">✓</td>`;
}
if (set.has(role)) couvert[role]++;
return `<td class="mx-c"><input type="checkbox" ${set.has(role) ? 'checked' : ''}`
+ ` onchange="basculerIntegrationDe(${i}, '${echapper(role)}', this.checked)"`
+ ` title="${echapper(role)} sur ${echapper(s.nom)}"></td>`;
}).join('');
const sel = i === selServeur ? ' class="mx-sel"' : '';
return `<tr${sel}><th class="mx-h" onclick="setVue('serveurs'); choisirServeur(${i})" title="Ouvrir la fiche de ${echapper(s.nom)}">`
+ `${echapper(s.nom || '(sans nom)')}<span class="mx-fn">${echapper(s.fonction || '')}</span></th>${cellules}</tr>`;
}).join('');
const n = lignes.length;
const tetes = colonnes.map(role => {
const u = !!integrationsUniv[role];
return `<th class="mx-t${u ? ' mx-t-univ' : ''}" title="${u ? 'Politique du rôle — s\'applique à tout hôte' : 'Facultative — un choix, serveur par serveur'}">`
+ `<span>${echapper(role.replace(/^client_/, ''))}</span></th>`;
}).join('');
const pieds = colonnes.map(role => {
const c = couvert[role];
const cls = integrationsUniv[role] ? 'mx-p mx-univ' : (c === n ? 'mx-p mx-plein' : 'mx-p');
return `<td class="${cls}">${c}/${n}</td>`;
}).join('');
cible.innerHTML = `<div class="section-grille">
<div class="section-tete">Intégrations — matrice <span class="compte">${n} × ${colonnes.length}</span>
<span class="hint-dep" style="margin-left:auto">Les colonnes ✓ sont la politique des rôles ; les cases sont les choix, éditables ici</span></div>
<div class="mx-cadre"><table class="mx">
<thead><tr><th class="mx-t mx-coin">serveur</th>${tetes}</tr></thead>
<tbody>${corps}</tbody>
<tfoot><tr><th class="mx-h mx-coin">couverture</th>${pieds}</tr></tfoot>
</table></div></div>`;
}
function legendeIntegrations() {
const univ = Object.entries(integrationsUniv).sort();
const lignes = univ.map(([role, p]) => `<div class="mx-leg"><span class="mx-univ">✓</span> <code>${echapper(role)}</code>
<div class="hint-dep">${echapper(p.raison || '')}${p.sauf_role ? ` <b>Exemption</b> : l'hôte qui rend <code>${echapper(p.sauf_role)}</code>.` : ''}</div></div>`).join('');
document.getElementById('detail').innerHTML = `<div class="detail">
<div class="section-tete">Deux genres d'intégration</div>
<div class="hint-dep"><b>Universelle</b> — aucun choix de cible : un seul Prometheus, un seul Loki, une seule AC.
Déclarée <b>une fois par le rôle</b> (<code>roles/&lt;rôle&gt;/meta/integration.yml</code>), elle s'applique à tout hôte
et ne se décoche pas. Le plan <b>refuse</b> de la redire : deux sources finiraient par diverger.</div>
${lignes}
<div class="hint-dep" style="margin-top:12px"><b>Facultative</b> — un vrai choix, serveur par serveur, écrit dans
<code>plan/serveurs.yml</code>. Coche dans la matrice, puis <b>Sauvegarder</b>.</div>
<div class="hint-dep" style="margin-top:12px">La ligne <b>couverture</b> ne juge pas : une colonne à <code>7/14</code>
peut être exactement juste. Elle rend le motif <em>visible</em> — c'est au lecteur de savoir si les sept manquants
sont des décisions ou des oublis.</div></div>`;
}
function basesDeApp(app, hoteNom) {
return bdApplis.filter(a => {
const p = a.portee || 'groupe';
return (p === 'application' && a.consommateur === app.id)
|| (p === 'groupe' && a.consommateur === app.groupe)
|| (p === 'hote' && a.consommateur === hoteNom);
});
}
function chargerBases(data) {
const b = data.bases || {serveurs_bd: {}, bases_donnees: {}};
bdServeurs = Object.entries(b.serveurs_bd || {}).map(([nom, s]) => ({
nom, type: s.type || 'postgres', hote: s.hote || '', port: s.port || 5432, groupe: s.groupe || ''}));
bdApplis = Object.entries(b.bases_donnees || {}).map(([cle, x]) => ({
cle, serveur: x.serveur || '', base: x.base || '', proprietaire: x.proprietaire || '', secret: x.secret || '',
consommateur: x.consommateur || '', portee: x.portee || 'groupe', usage: x.usage || 'principale'}));
basesModifie = false;
}
function chargerDomaines(data) {
const dp = ((data.domaines || {}).domaines_publics) || {};
domaines = Object.entries(dp).map(([nom, x]) => ({
nom, autorite: x.autorite || 'auto-heberge', edge: x.edge || 'serveur_nginx',
dnssec: !!x.dnssec, secondaires: (x.secondaires || []).join(', '), mail: x.mail || ''}));
domainesModifie = false;
}
function marquerDomainesModifie() { domainesModifie = true; majIndicateurSauvegarde(); }
function choisirDomaine(i) { selDomaine = i; dessiner(); }
function definirDomaine(champ, v) {
const d = domaines[selDomaine]; if (!d) return;
d[champ] = v; marquerDomainesModifie();
if (champ === 'nom') { const t = document.querySelector('#tuile-dom-' + selDomaine + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
else if (champ === 'autorite' || champ === 'edge') dessinerDetail();
}
function ajouterDomaine() {
const edgeDefaut = groupes.includes('serveur_nginx') ? 'serveur_nginx' : (groupes[0] || '');
domaines.unshift({nom: '', autorite: 'auto-heberge', edge: edgeDefaut, dnssec: false, secondaires: '', mail: ''});
selDomaine = 0; marquerDomainesModifie(); dessiner();
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
}
function retirerDomaine() {
const d = domaines[selDomaine]; if (!d) return;
if (d.nom && !confirm('Retirer le domaine « ' + d.nom + ' » ?')) return;
domaines.splice(selDomaine, 1);
selDomaine = domaines.length ? Math.min(selDomaine, domaines.length - 1) : null;
marquerDomainesModifie(); dessiner();
}
function carteDomaine(d, i) {
return `<button type="button" class="carte ${i === selDomaine ? 'selectionnee' : ''}" id="tuile-dom-${i}" onclick="choisirDomaine(${i})">
<div class="carte-haut"><span class="badge-bd">zone</span>${d.dnssec ? '<span class="carte-vmid">dnssec</span>' : ''}</div>
<div class="carte-nom ${d.nom ? '' : 'sans'}">${echapper(d.nom || '(sans nom)')}</div>
<div class="carte-ip">${echapper(d.autorite || '—')}</div>
</button>`;
}
// ---- VUE NOMENCLATURE ---------------------------------------------------------
//
// Le dernier registre que le GUI ne savait pas ecrire du tout (2026-09-08). Il n'est
// pas un registre comme les autres : il ne decrit pas des objets, il decrit la REGLE
// dont VMID, VLAN, adresse et passerelle se derivent. D'ou deux partis pris :
//
// - `index` est montre mais PAS editable ici. Le fichier le dit lui-meme : il est
// RECU du site, pas decide par le tenant, et `make underlay` refuse tout ecart.
// Il s'ecrit par le panneau Intrants, qui est le chemin de l'intrant recu.
// - chaque fonction affiche CE QU'ELLE DERIVE (zone, VLAN, 3e octet) et combien de
// VM la portent : sans ca, changer un chiffre ici est un geste a l'aveugle.
function chargerNomEdit(data) {
// Copie de travail : editer `nomenclature` en place ferait suivre les indices
// derives de la vue Serveurs a des valeurs non encore enregistrees.
nomEdit = JSON.parse(JSON.stringify(data.nomenclature || {}));
nomModifie = false;
}
function marquerNomModifie() { nomModifie = true; majIndicateurSauvegarde(); }
function selNomInfo() { if (!selNom) return null; const i = selNom.indexOf(':'); return {kind: selNom.slice(0, i), cle: selNom.slice(i + 1)}; }
function choisirNom(k) { selNom = k; dessiner(); }
function definirNom(chemin, v) {
// UN SEUL setter pour toute la profondeur : le generateur transmet une clef
// POINTEE (`reservations.passerelle`, `fonctions.idm.service`).
const parts = String(chemin).split('.');
let o = nomEdit;
for (let i = 0; i < parts.length - 1; i++) {
if (o[parts[i]] == null || typeof o[parts[i]] !== 'object') o[parts[i]] = {};
o = o[parts[i]];
}
const feuille = parts[parts.length - 1];
// Les entiers restent des ENTIERS : le plan est relu par le moteur, pas par un
// navigateur, et `categorie: "6"` ne derive pas la meme chose que `categorie: 6`.
o[feuille] = (v !== '' && v != null && String(v).match(/^-?\d+$/)) ? parseInt(v, 10) : v;
marquerNomModifie();
if (chemin.startsWith('fonctions.') || chemin.startsWith('categories.')) dessiner();
}
function renommerNom(table, ancienne, nouvelle) {
nouvelle = String(nouvelle || '').trim();
if (!nouvelle || nouvelle === ancienne) return;
const t = nomEdit[table] || {};
if (Object.prototype.hasOwnProperty.call(t, nouvelle)) { message('« ' + nouvelle + ' » existe déjà.', 'erreur'); return; }
// On preserve l'ORDRE : le fichier est lu par des humains, et un renommage ne doit
// pas remonter la clef en tete.
const refait = {};
Object.keys(t).forEach(k => { refait[k === ancienne ? nouvelle : k] = t[k]; });
nomEdit[table] = refait;
selNom = (table === 'categories' ? 'cat:' : 'fon:') + nouvelle;
marquerNomModifie(); dessiner();
}
function ajouterZone() {
const cats = nomEdit.categories || (nomEdit.categories = {});
// La prochaine zone LIBRE, pas « la derniere + 1 » : un trou se rebouche.
let n = 1; while (cats[n] || cats[String(n)]) n++;
cats[n] = {libelle: ''};
selNom = 'cat:' + n; marquerNomModifie(); dessiner();
}
function ajouterFonction() {
const f = nomEdit.fonctions || (nomEdit.fonctions = {});
let nom = 'nouvelle', i = 2; while (f[nom]) nom = 'nouvelle-' + (i++);
const premiereZone = Object.keys(nomEdit.categories || {})[0];
f[nom] = {categorie: premiereZone != null ? parseInt(premiereZone, 10) : 1, service: 1};
selNom = 'fon:' + nom; marquerNomModifie(); dessiner();
}
function retirerNom() {
const info = selNomInfo(); if (!info) return;
const table = info.kind === 'cat' ? 'categories' : 'fonctions';
if (info.kind === 'fon' && vmsPortant(info.cle).length) {
message('« ' + info.cle + ' » est encore portée par ' + vmsPortant(info.cle).length + ' VM.', 'erreur');
return;
}
if (info.kind === 'cat' && fonctionsDansZone(info.cle).length) {
message('Zone ' + info.cle + ' encore utilisée par ' + fonctionsDansZone(info.cle).length + ' fonction(s).', 'erreur');
return;
}
delete (nomEdit[table] || {})[info.cle];
selNom = null; marquerNomModifie(); dessiner();
}
function vmsPortant(fonction) { return hotes.filter(h => fonctionDe(h.nom).fonction === fonction); }
function fonctionsDansZone(zone) {
return Object.entries(nomEdit.fonctions || {}).filter(([, f]) => String(f.categorie) === String(zone)).map(([n]) => n);
}
function deriveDe(fonction) {
// Miroir de `inventory_rules.deriver_nomenclature`, cote ecran. Il ne DECIDE rien :
// il montre ce que le moteur derivera, pour que le chiffre saisi ait un sens visible.
const f = (nomEdit.fonctions || {})[fonction];
if (!f) return null;
const c = (nomEdit.categories || {})[f.categorie] || (nomEdit.categories || {})[String(f.categorie)];
const idx = (nomEdit.index != null) ? nomEdit.index : 9;
const cat = Number(f.categorie);
if (!c || isNaN(cat)) return null;
return {libelle: c.libelle || '?', vlan: 1000 + idx * 10 + cat,
base3: '10.' + idx + '.' + (15 + cat), bloc: (Number(f.service) * 10)};
}
function carteZone(cle) {
const c = (nomEdit.categories || {})[cle] || {};
const sel = selNom === 'cat:' + cle ? ' on' : '';
const n = fonctionsDansZone(cle).length;
return `<button type="button" class="carte${sel}" onclick="choisirNom('cat:${echapper(cle)}')">
<div class="carte-nom">${echapper(c.libelle || '(sans libellé)')}</div>
<div class="carte-ip">zone ${echapper(cle)} · ${n} fonction(s)</div></button>`;
}
function carteFonction(nom) {
const sel = selNom === 'fon:' + nom ? ' on' : '';
const d = deriveDe(nom);
const nb = vmsPortant(nom).length;
return `<button type="button" class="carte${sel}" onclick="choisirNom('fon:${echapper(nom)}')">
<div class="carte-nom">${echapper(nom)}</div>
<div class="carte-ip">${d ? echapper(d.libelle) + ' · VLAN ' + d.vlan : '<zone inconnue>'} · ${nb} VM</div></button>`;
}
function dessinerNomenclature() {
const cible = document.getElementById('grilles');
const reg = schemaPlan.nomenclature;
if (!reg) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun schéma — lancer <code>make schema</code>.</div>'; return; }
const props = (reg.entite || {}).properties || {};
// Le seed, montre et NON editable ici (il vient du site).
const idx = nomEdit.index == null ? '—' : nomEdit.index;
const seed = `<label class="champ" title="${echapper((props.index || {}).description || '')}">
<span>${echapper((props.index || {}).title || 'Index')}</span>
<input value="${echapper(idx)}" disabled></label>
<div class="ch-stub" style="grid-column:1/-1">L'index est <b>alloué par le site</b>, pas décidé ici
(<code>make underlay</code> refuse tout écart). Il s'édite dans le panneau <b>Intrants</b>.</div>`;
const cidr = props.cidr_hote ? champDepuisSchema('cidr_hote', props.cidr_hote, nomEdit.cidr_hote, nomEdit, 'definirNom') : '';
const resv = props.reservations ? champDepuisSchema('reservations', props.reservations, nomEdit.reservations, nomEdit, 'definirNom') : '';
const zones = Object.keys(nomEdit.categories || {});
const fons = Object.keys(nomEdit.fonctions || {});
cible.innerHTML = `
<div class="section-grille">
<div class="section-tete">Modèle d'adressage</div>
<div class="grille">${seed}${cidr}${resv}</div>
</div>
<div class="section-grille">
<div class="section-tete">Zones de sécurité <span class="compte">${zones.length}</span>
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterZone()">+ Zone</button></div>
${zones.length ? `<div class="grille-cartes">${zones.map(carteZone).join('')}</div>`
: '<div class="vide" style="padding:14px">Aucune zone. Sans zone déclarée, aucune fonction ne dérive d\'adresse.</div>'}
</div>
<div class="section-grille">
<div class="section-tete">Fonctions <span class="compte">${fons.length}</span>
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterFonction()">+ Fonction</button></div>
${fons.length ? `<div class="grille-cartes">${fons.map(carteFonction).join('')}</div>`
: '<div class="vide" style="padding:14px">Aucune fonction déclarée.</div>'}
</div>`;
}
function inspecteurNomenclature() {
const cible = document.getElementById('detail');
const info = selNomInfo();
const reg = schemaPlan.nomenclature || {};
const props = (reg.entite || {}).properties || {};
if (!info) {
cible.innerHTML = '<div class="detail"><div class="vide"><b>Nomenclature</b>'
+ 'Le modèle dont tout l\'adressage dérive. Choisis une zone ou une fonction.</div></div>';
return;
}
const table = info.kind === 'cat' ? 'categories' : 'fonctions';
const entree = (nomEdit[table] || {})[info.cle];
if (!entree) { selNom = null; cible.innerHTML = ''; return; }
const specEntree = (props[table] || {}).additionalProperties || {};
const corps = sousFormulaireDepuisSchema(specEntree, entree, table + '.' + info.cle, 'definirNom');
let consequence = '';
if (info.kind === 'fon') {
const d = deriveDe(info.cle);
const vms = vmsPortant(info.cle);
consequence = `<div class="section-tete" style="margin:12px 2px 4px">Ce que cette fonction dérive</div>`
+ (d ? `<div class="intr-ro">Zone <b>${echapper(d.libelle)}</b> · VLAN <b>${d.vlan}</b>
· sous-réseau <b>${echapper(d.base3)}.0/${echapper(nomEdit.cidr_hote || 24)}</b>
· bloc d'hôtes <b>.${d.bloc}x</b></div>`
: '<div class="intr-ro">Zone inconnue — aucune adresse ne se dérive.</div>')
+ `<div class="section-tete" style="margin:12px 2px 4px">VM portant cette fonction <span class="compte">${vms.length}</span></div>`
+ (vms.length ? `<div class="ch-bases">${vms.map(h => `<div class="ch-base"><span class="mono">${echapper(h.nom)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucune — cette fonction peut être retirée</div>');
} else {
const fs = fonctionsDansZone(info.cle);
consequence = `<div class="section-tete" style="margin:12px 2px 4px">Fonctions dans cette zone <span class="compte">${fs.length}</span></div>`
+ (fs.length ? `<div class="ch-bases">${fs.map(f => `<div class="ch-base"><span class="mono">${echapper(f)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucune — cette zone peut être retirée</div>');
}
const libelleCle = info.kind === 'cat' ? 'Numéro de zone' : 'Nom de la fonction';
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(info.cle)}</span></div>
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer" onclick="retirerNom()">✕</button></div>
<div class="grille">
<label class="champ" style="grid-column:1/-1"><span>${libelleCle}</span>
<input value="${echapper(info.cle)}" onchange="renommerNom('${table}', '${echapper(info.cle)}', this.value)"></label>
</div>
${corps}
${consequence}
</article>`;
}
async function sauvegarderNomenclature() {
const rep = await fetch('/api/nomenclature', {method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
body: JSON.stringify(nomEdit)});
const data2 = await rep.json();
if (!rep.ok) { message(data2.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
schemaPlan = data2.schema || schemaPlan;
nomenclature = data2.nomenclature || nomenclature;
chargerNomEdit(data2); dessiner();
message('Nomenclature sauvegardée.', 'ok');
}
function dessinerDomaines() {
const cible = document.getElementById('grilles');
const tuiles = domaines.length
? `<div class="grille-cartes">${domaines.map((d, i) => carteDomaine(d, i)).join('')}</div>`
: '<div class="vide" style="padding:14px">Aucun domaine. Une zone DNS = parent des expositions (FQDN) derrière l\'edge.</div>';
cible.innerHTML = `<div class="section-grille">
<div class="section-tete">Domaines <span class="compte">${domaines.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterDomaine()">+ Domaine</button></div>
${tuiles}</div>`;
}
function expositionsSous(zone) {
if (!zone) return [];
return applications.flatMap(a => listeDepuisTexte(a.expose)).filter(f => f === zone || f.endsWith('.' + zone));
}
function inspecteurDomaine() {
const cible = document.getElementById('detail');
if (selDomaine == null || !domaines[selDomaine]) {
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun domaine sélectionné</b>Choisis une tuile, ou ajoute un domaine.</div></div>';
return;
}
const d = domaines[selDomaine];
const optAut = AUTORITES_DNS.map(a => `<option value="${a}" ${a === d.autorite ? 'selected' : ''}>${a}</option>`).join('');
const grpEdge = groupes.filter(g => g.startsWith('serveur_nginx'));
if (d.edge && !grpEdge.includes(d.edge)) grpEdge.push(d.edge);
const optEdge = grpEdge.map(g => `<option value="${echapper(g)}" ${g === d.edge ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const fqdns = expositionsSous(d.nom);
const liens = fqdns.length
? `<div class="ch-bases">${fqdns.map(f => `<div class="ch-base"><span class="mono">${echapper(f)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucune exposition sous cette zone</div>';
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(d.nom || '(sans nom)')}</span></div>
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer ce domaine" onclick="retirerDomaine()">✕</button></div>
<div class="grille">
<label class="champ" style="grid-column:1/-1"><span>Zone DNS</span><input value="${echapper(d.nom)}" placeholder="exemple.ca" oninput="definirDomaine('nom', this.value)"></label>
<label class="champ"><span>Autorité</span><select onchange="definirDomaine('autorite', this.value)">${optAut}</select></label>
<label class="champ"><span>Edge</span><select onchange="definirDomaine('edge', this.value)">${optEdge}</select></label>
<label class="champ"><span>DNSSEC</span><select onchange="definirDomaine('dnssec', this.value === 'oui')"><option value="non" ${d.dnssec ? '' : 'selected'}>non</option><option value="oui" ${d.dnssec ? 'selected' : ''}>oui</option></select></label>
<label class="champ"><span>Mail (optionnel)</span><input value="${echapper(d.mail)}" placeholder="—" oninput="definirDomaine('mail', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Secondaires (DNS)</span><input value="${echapper(d.secondaires)}" placeholder="ns2.exemple.ca, …" oninput="definirDomaine('secondaires', this.value)"></label>
</div>
<div class="section-tete" style="margin:12px 2px 4px">Expositions sous cette zone</div>
${liens}
</article>`;
}
async function sauvegarderDomaines() {
const dp = {};
domaines.forEach(d => {
if (!String(d.nom).trim()) return;
const o = {autorite: d.autorite, edge: d.edge, secondaires: listeDepuisTexte(d.secondaires), dnssec: !!d.dnssec};
if (String(d.mail).trim()) o.mail = d.mail;
dp[d.nom] = o;
});
const rep = await fetch('/api/domaines', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({domaines_publics: dp})
});
const data = await rep.json();
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
chargerDomaines(data); dessinerDomaines();
message('Domaines sauvegardés.', 'ok');
}
function chargerApplications(data) {
const a = (data.applications || {}).applications || {};
applications = Object.entries(a).map(([id, x]) => ({
id, groupe: x.groupe || '', hote: x.hote || '', port: x.port || '',
requiert: (x.requiert || []).join(', '), expose: (x.expose || []).join(', '),
websocket: !!x.websocket,
liens: (x.liens || []).map(l => ({vers: l.vers || '', role: l.role || ''}))}));
applicationsModifie = false;
}
// ---- FORMULAIRES GENERES DEPUIS LE SCHEMA -------------------------------------
//
// Le schema (docs/audit/schema-plan.json, `make schema`) decrit la FORME des
// registres ; ces trois fonctions la rendent. Un champ ajoute au plan apparait donc
// a l'ecran sans qu'on touche a ce fichier — c'est tout l'objet.
//
// Ce qu'elles ne font PAS : valider. La coherence reste aux `valider_*` du serveur,
// qui refusent la sauvegarde. Un formulaire genere ne saurait pas dire qu'un
// consommateur designe une application inexistante.
function sourceDeValeurs(nom) {
// Les listes fermees, resolues a l'execution. C'est ce qui rend l'hote fantome
// IMPOSSIBLE A SAISIR, au lieu d'etre refuse apres coup.
if (nom === 'serveurs') return hotes.map(h => h.nom).filter(Boolean);
if (nom === 'applications') return applications.map(x => x.id).filter(Boolean);
if (nom === 'groupes_operationnels') return groupes;
if (nom === 'serveurs_bd') return bdServeurs.map(x => x.nom).filter(Boolean);
if (nom === 'nomenclature.fonctions') return Object.keys((data.nomenclature || {}).fonctions || {});
// La zone se choisit dans les zones DECLAREES — et on la lit dans la copie en
// cours d'edition, pour qu'une zone ajoutee a l'instant soit offerte tout de suite.
// `{v, t}` : la valeur ecrite au plan reste le numero, l'ecran montre le libelle.
if (nom === 'nomenclature.categories') {
const cats = (nomEdit.categories || {});
return Object.keys(cats).map(k => ({v: k, t: k + ' — ' + ((cats[k] || {}).libelle || '?')}));
}
return [];
}
function champDepuisSchema(cle, spec, valeur, entite, surChangement) {
const titre = echapper(spec.title || cle);
const aide = spec.description ? ` title="${echapper(spec.description)}"` : '';
// Marqueur discret : le schema SAIT ce qui est requis, le formulaire ecrit a la
// main ne le montrait pas. Une asterisque plutot que le mot « requis », qui se
// collait au libelle et se lisait « Base requis ».
const req = (spec['x-requis'] ? '<abbr class="dsn" title="Champ requis" style="text-decoration:none"> *</abbr>' : '');
const appel = (expr) => `${surChangement}('${cle}', ${expr})`;
// Liste fermee : soit fixe (enum), soit alimentee a l'execution, soit dependante
// d'un autre champ de la meme entite.
let options = null;
if (spec.enum) options = spec.enum;
else if (spec['x-source-valeurs']) options = sourceDeValeurs(spec['x-source-valeurs']);
else if (spec['x-source-selon']) {
const s = spec['x-source-selon'];
const pivot = (entite || {})[s.champ];
options = sourceDeValeurs((s.cas || {})[pivot] || s.defaut);
}
if (options) {
// Une option est soit une valeur nue, soit {v: valeur ecrite, t: texte affiche}.
const opts = ['<option value="">—</option>'].concat(options.map(o => {
const v = (o && typeof o === 'object') ? o.v : o;
const txt = (o && typeof o === 'object') ? o.t : o;
return `<option value="${echapper(v)}"${String(v) === String(valeur == null ? '' : valeur) ? ' selected' : ''}>${echapper(txt)}</option>`;
})).join('');
return `<label class="champ"${aide}><span>${titre}${req}</span><select onchange="${appel('this.value')}">${opts}</select></label>`;
}
if (spec.type === 'object' && spec.properties) {
// BLOC FIXE (`sous_champs` au schema) : ses cases une fois, sans « ajouter ».
// La clef transmise au setter est POINTEE (`reservations.passerelle`) — c'est ce
// qui permet a un seul setter de servir toute la profondeur.
const sousReq = new Set(spec.required || []);
const cases = Object.keys(spec.properties).map(sc => champDepuisSchema(
cle + '.' + sc,
Object.assign({}, spec.properties[sc], {'x-requis': sousReq.has(sc)}),
(valeur || {})[sc], valeur, surChangement)).join('');
return `<div style="grid-column:1/-1"${aide}><div class="section-tete">${titre}</div>`
+ `<div class="grille">${cases}</div></div>`;
}
if (spec.type === 'boolean') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="checkbox"${valeur ? ' checked' : ''} onchange="${appel('this.checked')}"></label>`;
}
if (spec.type === 'integer') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="number" value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
if (spec.type === 'array') {
// Une liste de textes se saisit separee par des virgules : le YAML reste une
// liste, la saisie reste lisible.
const v = Array.isArray(valeur) ? valeur.join(', ') : '';
return `<label class="champ"${aide}><span>${titre}${req}</span><input value="${echapper(v)}" placeholder="a, b" oninput="${appel('this.value.split(\',\').map(x=>x.trim()).filter(Boolean)')}"></label>`;
}
return `<label class="champ"${aide}><span>${titre}${req}</span><input value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) {
const reg = schemaPlan[nomRegistre] || null;
if (!reg) return '<div class="vide">Aucun schéma pour ce registre — lancer <code>make schema</code>.</div>';
const props = (reg.entite || {}).properties || {};
const requis = new Set((reg.entite || {}).required || []);
const morceaux = [];
if (reg['x-clef'] && surChangementCle) {
const k = reg['x-clef'];
morceaux.push(`<label class="champ" title="${echapper(k.description || '')}"><span>${echapper(k.title)}</span>` +
`<input value="${echapper(cleCourante == null ? '' : cleCourante)}" oninput="(${surChangementCle})(this.value)"></label>`);
}
Object.keys(props).forEach(cle => {
const spec = Object.assign({}, props[cle], {'x-requis': requis.has(cle)});
morceaux.push(champDepuisSchema(cle, spec, (entite || {})[cle], entite, surChangement));
});
return `<div class="grille">${morceaux.join('')}</div>`;
}
function sousFormulaireDepuisSchema(specEntree, entree, prefixe, surChangement) {
// Une ligne de table (`entree` au schema) : les memes cases qu'une entite, mais
// sous une clef pointee, pour que le setter sache ou poser la valeur.
const props = (specEntree || {}).properties || {};
const requis = new Set((specEntree || {}).required || []);
const cases = Object.keys(props).map(c => champDepuisSchema(
prefixe + '.' + c, Object.assign({}, props[c], {'x-requis': requis.has(c)}),
(entree || {})[c], entree, surChangement)).join('');
return `<div class="grille">${cases}</div>`;
}
function dsnBase(a) {
const s = bdServeurs.find(x => x.nom === a.serveur);
if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')';
return `${s.type}://${a.proprietaire || '?'}:****@${s.hote || '?'}:${s.port || '?'}/${a.base || '?'}`;
}
function marquerBasesModifie() { basesModifie = true; majIndicateurSauvegarde(); }
function selBaseInfo() { if (!selBase) return null; const p = selBase.split(':'); return {kind: p[0], i: +p[1]}; }
function choisirBase(key) { selBase = key; dessiner(); }
function definirServeurBd(champ, v) {
const info = selBaseInfo(); if (!info || info.kind !== 'srv') return;
const s = bdServeurs[info.i]; if (!s) return;
s[champ] = v; marquerBasesModifie();
if (champ === 'nom') { const t = document.querySelector('#tuile-base-srv-' + info.i + ' .carte-nom'); if (t) t.textContent = v || '(sans nom)'; }
}
function definirBaseBd(champ, v) {
const info = selBaseInfo(); if (!info || info.kind !== 'bd') return;
const a = bdApplis[info.i]; if (!a) return;
a[champ] = v; marquerBasesModifie();
if (champ === 'cle') { const t = document.querySelector('#tuile-base-bd-' + info.i + ' .carte-nom'); if (t) t.textContent = v || '(sans id)'; }
else if (champ === 'serveur' || champ === 'portee') dessinerDetail();
}
function ajouterServeurBd() { bdServeurs.unshift({nom: '', type: 'postgres', hote: '', port: 5432, groupe: ''}); selBase = 'srv:0'; marquerBasesModifie(); dessiner(); }
function retirerServeurBd() { const info = selBaseInfo(); if (!info || info.kind !== 'srv') return; bdServeurs.splice(info.i, 1); selBase = null; marquerBasesModifie(); dessiner(); }
function ajouterBaseBd() { bdApplis.unshift({cle: '', serveur: (bdServeurs[0] || {}).nom || '', base: '', proprietaire: '', secret: '', consommateur: '', portee: 'groupe', usage: 'principale'}); selBase = 'bd:0'; marquerBasesModifie(); dessiner(); }
function retirerBaseBd() { const info = selBaseInfo(); if (!info || info.kind !== 'bd') return; bdApplis.splice(info.i, 1); selBase = null; marquerBasesModifie(); dessiner(); }
function carteBaseServeur(s, i) {
const key = 'srv:' + i;
return `<button type="button" class="carte ${selBase === key ? 'selectionnee' : ''}" id="tuile-base-srv-${i}" onclick="choisirBase('${key}')">
<div class="carte-haut"><span class="badge-bd">${echapper(s.type || 'bd')}</span>${s.port ? `<span class="carte-vmid">:${echapper(s.port)}</span>` : ''}</div>
<div class="carte-nom ${s.nom ? '' : 'sans'}">${echapper(s.nom || '(sans nom)')}</div>
<div class="carte-ip">${echapper(s.hote || '— hôte ?')}</div>
</button>`;
}
function carteBaseDonnee(a, i) {
const key = 'bd:' + i;
return `<button type="button" class="carte ${selBase === key ? 'selectionnee' : ''}" id="tuile-base-bd-${i}" onclick="choisirBase('${key}')">
<div class="carte-haut"><span class="badge-bd">${echapper(a.portee || 'groupe')}</span></div>
<div class="carte-nom ${a.cle ? '' : 'sans'}">${echapper(a.cle || '(sans id)')}</div>
<div class="carte-ip">${echapper(a.base || '—')} @ ${echapper(a.serveur || '?')}</div>
</button>`;
}
function dessinerBases() {
const cible = document.getElementById('grilles');
const srv = bdServeurs.length ? `<div class="grille-cartes">${bdServeurs.map((s, i) => carteBaseServeur(s, i)).join('')}</div>` : '<div class="vide" style="padding:12px">Aucun serveur de BD.</div>';
const bd = bdApplis.length ? `<div class="grille-cartes">${bdApplis.map((a, i) => carteBaseDonnee(a, i)).join('')}</div>` : '<div class="vide" style="padding:12px">Aucune base.</div>';
cible.innerHTML = `<div class="section-grille"><div class="section-tete">Serveurs de bases <span class="compte">${bdServeurs.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterServeurBd()">+ Serveur BD</button></div>${srv}</div>
<div class="section-grille"><div class="section-tete">Bases applicatives <span class="compte">${bdApplis.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterBaseBd()">+ Base</button></div>${bd}</div>`;
}
function inspecteurBase() {
const cible = document.getElementById('detail');
const info = selBaseInfo();
if (!info) { cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun élément sélectionné</b>Choisis un serveur de base ou une base à gauche.</div></div>'; return; }
if (info.kind === 'srv') {
const s = bdServeurs[info.i];
if (!s) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; }
const optHote = '<option value="">—</option>' + hotes.map(h => h.nom).filter(Boolean).map(n => `<option value="${echapper(n)}" ${n === s.hote ? 'selected' : ''}>${echapper(n)}</option>`).join('');
const optGroupe = '<option value="">—</option>' + groupes.map(g => `<option value="${echapper(g)}" ${g === s.groupe ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const ici = bdApplis.filter(a => a.serveur === s.nom);
const iciHtml = ici.length ? `<div class="ch-bases">${ici.map(a => `<div class="ch-base"><span class="badge-bd">${echapper(a.portee || 'groupe')}</span> <span class="mono">${echapper(a.base || '?')}</span> (${echapper(a.cle)})</div>`).join('')}</div>` : '<div class="ch-stub">aucune base sur ce serveur</div>';
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(s.nom || '(sans nom)')}</span></div><span class="grandit"></span><span class="badge-bd">${echapper(s.type || 'bd')}</span><button type="button" class="danger" title="Retirer" onclick="retirerServeurBd()">✕</button></div>
<div class="grille">
<label class="champ"><span>Nom</span><input value="${echapper(s.nom)}" placeholder="pg-principal" oninput="definirServeurBd('nom', this.value)"></label>
<label class="champ"><span>Type</span><input value="${echapper(s.type)}" placeholder="postgres" oninput="definirServeurBd('type', this.value)"></label>
<label class="champ"><span>Hôte (VM)</span><select onchange="definirServeurBd('hote', this.value)">${optHote}</select></label>
<label class="champ"><span>Port</span><input type="number" value="${echapper(s.port)}" oninput="definirServeurBd('port', this.value)"></label>
<label class="champ"><span>Groupe Ansible</span><select onchange="definirServeurBd('groupe', this.value)">${optGroupe}</select></label>
</div>
<div class="section-tete" style="margin:12px 2px 2px">Bases hébergées ici</div>${iciHtml}
${(() => {
const hS = hotes.find(h => h.nom === s.hote);
if (hS && hS.etat === 'actif') { const hiS = hotes.indexOf(hS);
return `<div class="pied"><span class="hint-dep">Déployer applique les rôles sur l'hôte « ${echapper(s.hote)} ».</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiS})" title="Déployer l'hôte du serveur de BD">Déployer</button></div>`; }
return `<div class="pied"><span class="hint-dep">${s.hote ? `Active + pousse d'abord l'hôte « ${echapper(s.hote)} ».` : 'Aucun hôte assigné.'}</span></div>`;
})()}
</article>`;
} else {
const a = bdApplis[info.i];
if (!a) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; }
// FORMULAIRE GENERE DEPUIS LE SCHEMA (2026-09-08) — plus aucun champ en dur.
// Les huit `<label>` qui vivaient ici sont derives de docs/audit/schema-plan.json :
// libelles, types, listes fermees, et la source CONDITIONNELLE du consommateur
// (elle depend de la portee) que le schema declare desormais au lieu de la laisser
// vivre dans ce fichier. Un champ ajoute au registre apparait sans toucher au JS,
// et P61 refuse qu'un champ du plan manque au schema.
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(a.cle || '(sans id)')}</span></div><span class="grandit"></span><button type="button" class="danger" title="Retirer" onclick="retirerBaseBd()">✕</button></div>
${formulaireDepuisSchema('bases_donnees', a, 'definirBaseBd', a.cle, "definirBaseBd.bind(null,'cle')")}
<div class="dsn mono" style="margin-top:8px">${echapper(dsnBase(a))}</div>
${(() => {
const srv = bdServeurs.find(x => x.nom === a.serveur);
const hB = srv ? hotes.find(h => h.nom === srv.hote) : null;
if (hB && hB.etat === 'actif') { const hiB = hotes.indexOf(hB);
return `<div class="pied"><span class="hint-dep">Déployer applique les rôles sur « ${echapper(srv.hote)} » (le serveur de BD qui héberge cette base).</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiB})" title="Déployer le serveur de BD (crée cette base)">Déployer</button></div>`; }
return `<div class="pied"><span class="hint-dep">${(srv && srv.hote) ? `Active + pousse d'abord l'hôte « ${echapper(srv.hote)} ».` : 'Serveur de BD sans hôte assigné.'}</span></div>`;
})()}
</article>`;
}
}
async function sauvegarderBases() {
const sd = {};
bdServeurs.forEach(s => { if (String(s.nom).trim()) sd[s.nom] = {type: s.type, hote: s.hote, port: parseInt(s.port, 10) || s.port, ...(s.groupe ? {groupe: s.groupe} : {})}; });
const bd = {};
// SAUVEGARDE DERIVEE DU SCHEMA (2026-09-08). Les sept champs etaient enumeres ici
// en dur : un champ ajoute au registre serait apparu au formulaire genere et
// aurait disparu SILENCIEUSEMENT a l'enregistrement — le pire des deux mondes.
// Les valeurs par defaut viennent du schema (`default`), plus du JS.
const propsBd = ((schemaPlan.bases_donnees || {}).entite || {}).properties || {};
bdApplis.forEach(a => {
if (!String(a.cle).trim()) return;
const e = {};
Object.keys(propsBd).forEach(cle => {
const v = a[cle];
e[cle] = (v === undefined || v === '' || v === null) ? (propsBd[cle].default ?? v) : v;
});
bd[a.cle] = e;
});
const rep = await fetch('/api/bases', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs_bd: sd, bases_donnees: bd})
});
const data = await rep.json();
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
chargerBases(data);
dessinerBases();
message('Bases de données sauvegardées.', 'ok');
}
function marquerApplicationsModifie() { applicationsModifie = true; majIndicateurSauvegarde(); }
function choisirApp(i) { selApp = i; dessiner(); }
function definirApplication(champ, v) {
const a = applications[selApp]; if (!a) return;
a[champ] = v; marquerApplicationsModifie();
if (champ === 'id') { const t = document.querySelector('#tuile-app-' + selApp + ' .carte-nom'); if (t) { t.textContent = v || '(sans id)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
else if (champ === 'groupe') {
const ok = Object.keys(liensAcceptes[a.groupe] || {});
(a.liens || []).forEach(l => { if (l.role && !ok.includes(l.role)) l.role = ''; });
dessinerDetail();
}
else if (champ === 'hote' || champ === 'port') dessinerDetail();
}
function ajouterApplication() {
applications.unshift({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: '', websocket: false, liens: []});
selApp = 0; marquerApplicationsModifie(); dessiner();
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
}
function retirerApplication() {
const a = applications[selApp]; if (!a) return;
if (a.id && !confirm('Retirer l\'application « ' + a.id + ' » ?')) return;
applications.splice(selApp, 1);
selApp = applications.length ? Math.min(selApp, applications.length - 1) : null;
marquerApplicationsModifie(); dessiner();
}
function ajouterLien() {
const a = applications[selApp]; if (!a) return;
if (!a.liens) a.liens = [];
const roles = Object.keys(liensAcceptes[a.groupe] || {});
a.liens.push({vers: '', role: roles.length === 1 ? roles[0] : ''});
marquerApplicationsModifie(); dessinerDetail();
}
function retirerLien(i) {
const a = applications[selApp]; if (!a || !a.liens) return;
a.liens.splice(i, 1); marquerApplicationsModifie(); dessinerDetail();
}
function definirLien(i, champ, v) {
const a = applications[selApp]; if (!a || !a.liens || !a.liens[i]) return;
a.liens[i][champ] = v; marquerApplicationsModifie();
if (champ === 'role') dessinerDetail();
}
function blocLiens(a) {
const accepte = liensAcceptes[a.groupe] || {};
const roles = Object.keys(accepte);
if (!roles.length) {
return `<div class="ch-stub">Le rôle « ${echapper(a.groupe || '—')} » n'accepte aucun lien
(aucun <span class="mono">roles/${echapper(a.groupe || '?')}/meta/liens.yml</span>).</div>`;
}
const cibles = applications.map(x => x.id).filter(x => x && x !== a.id);
const lignes = (a.liens || []).map((l, i) => {
const spec = accepte[l.role] || {};
const vars = Object.keys(spec.variables || {});
const optV = '<option value="">— cible —</option>' + cibles.map(c =>
`<option value="${echapper(c)}" ${c === l.vers ? 'selected' : ''}>${echapper(c)}</option>`).join('');
const optR = '<option value="">— rôle —</option>' + roles.map(r =>
`<option value="${echapper(r)}" ${r === l.role ? 'selected' : ''}>${echapper(r)}</option>`).join('');
const inconnue = l.vers && !cibles.includes(l.vers);
return `<div class="ch-base" style="gap:8px;align-items:center">
<select style="max-width:12rem" onchange="definirLien(${i}, 'role', this.value)">${optR}</select>
<span class="mono">→</span>
<select style="max-width:12rem" onchange="definirLien(${i}, 'vers', this.value)">${optV}</select>
${inconnue ? `<span class="tag" style="color:#b45309">cible « ${echapper(l.vers)} » inconnue</span>` : ''}
${vars.length ? `<span class="tag" title="Variables injectées sur cette application">${echapper(vars.join(', '))}</span>` : ''}
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer ce lien" onclick="retirerLien(${i})">✕</button>
</div>`;
}).join('');
return `${lignes || '<div class="ch-stub">aucun lien déclaré</div>'}
<div style="margin-top:6px"><button type="button" class="fantome" onclick="ajouterLien()">+ Lien</button></div>`;
}
function carteApp(a, i) {
const exp = listeDepuisTexte(a.expose);
return `<button type="button" class="carte ${i === selApp ? 'selectionnee' : ''}" id="tuile-app-${i}" onclick="choisirApp(${i})">
<div class="carte-haut"><span class="badge-bd">app</span>${a.port ? `<span class="carte-vmid">:${echapper(a.port)}</span>` : ''}</div>
<div class="carte-nom ${a.id ? '' : 'sans'}">${echapper(a.id || '(sans id)')}</div>
<div class="carte-ip">${echapper(a.hote || '— hôte ?')}</div>
${exp.length ? `<div class="carte-grps"><span class="tag">${echapper(exp[0])}</span>${exp.length > 1 ? `<span class="tag plus">+${exp.length - 1}</span>` : ''}</div>` : ''}
</button>`;
}
function dessinerApplications() {
const cible = document.getElementById('grilles');
const tuiles = applications.length
? `<div class="grille-cartes">${applications.map((a, i) => carteApp(a, i)).join('')}</div>`
: '<div class="vide" style="padding:14px">Aucune application. Une VM peut en porter plusieurs.</div>';
cible.innerHTML = `<div class="section-grille">
<div class="section-tete">Applications <span class="compte">${applications.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterApplication()">+ Application</button></div>
${tuiles}</div>`;
}
function inspecteurApplication() {
const cible = document.getElementById('detail');
if (selApp == null || !applications[selApp]) {
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucune application sélectionnée</b>Choisis une tuile à gauche, ou ajoute une application.</div></div>';
return;
}
const a = applications[selApp];
const optGroupe = groupes.map(g => `<option value="${echapper(g)}" ${g === a.groupe ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const optHote = '<option value="">—</option>' + hotes.map(h => h.nom).filter(Boolean).map(n => `<option value="${echapper(n)}" ${n === a.hote ? 'selected' : ''}>${echapper(n)}</option>`).join('');
const dsns = basesDeApp(a, a.hote);
const liens = dsns.length
? `<div class="ch-bases">${dsns.map(b => `<div class="ch-base"><span class="badge-bd">${echapper(b.portee || 'groupe')}</span> <span class="mono">${echapper(dsnBase(b))}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucune base liée</div>';
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(a.id || '(sans id)')}</span></div>
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer cette application" onclick="retirerApplication()">✕</button></div>
<div class="grille">
<label class="champ"><span>Identifiant</span><input value="${echapper(a.id)}" placeholder="boutique" oninput="definirApplication('id', this.value)"></label>
<label class="champ"><span>Groupe (rôle)</span><select onchange="definirApplication('groupe', this.value)">${optGroupe}</select></label>
<label class="champ"><span>Hôte (VM)</span><select onchange="definirApplication('hote', this.value)">${optHote}</select></label>
<label class="champ"><span>Port</span><input type="number" value="${echapper(a.port)}" placeholder="3000" oninput="definirApplication('port', this.value)"></label>
<label class="champ"><span>WebSocket</span><span style="display:flex;align-items:center;gap:8px;height:2.1rem"><input type="checkbox" ${a.websocket ? 'checked' : ''} onchange="definirApplication('websocket', this.checked)"><span class="hint-dep">vhost compatible WebSocket (Collabora…)</span></span></label>
<label class="champ" style="grid-column:1/-1"><span>Requiert (applis)</span><input value="${echapper(a.requiert)}" placeholder="pg-principal, …" oninput="definirApplication('requiert', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Expose (FQDN)</span><input value="${echapper(a.expose)}" placeholder="forge.exemple.ca" oninput="definirApplication('expose', this.value)"></label>
</div>
<div class="section-tete" style="margin:12px 2px 4px">Liens (bindings)
<span class="compte">${(a.liens || []).length}</span></div>
${blocLiens(a)}
<div class="section-tete" style="margin:12px 2px 4px">Bases consommées</div>
${liens}
${(() => {
const hA = hotes.find(x => x.nom === a.hote);
if (hA && hA.etat === 'actif') {
const hiA = hotes.indexOf(hA);
return `<div class="pied"><span class="hint-dep">Déployer applique les rôles sur l'hôte porteur « ${echapper(a.hote)} ».</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiA})" title="Déployer l'hôte porteur (rend l'application vivante)">Déployer</button></div>`;
}
return `<div class="pied"><span class="hint-dep">${a.hote ? `Active + pousse d'abord l'hôte « ${echapper(a.hote)} ».` : 'Aucun hôte assigné.'}</span></div>`;
})()}
</article>`;
}
function listeDepuisTexte(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); }
async function sauvegarderApplications() {
const apps = {};
applications.forEach(a => {
if (!String(a.id).trim()) return;
const o = {groupe: a.groupe, hote: a.hote};
if (String(a.port).trim()) o.port = parseInt(a.port, 10) || a.port;
const req = listeDepuisTexte(a.requiert); if (req.length) o.requiert = req;
const exp = listeDepuisTexte(a.expose); if (exp.length) o.expose = exp;
if (a.websocket) o.websocket = true;
const lns = (a.liens || [])
.filter(l => String(l.vers).trim() && String(l.role).trim())
.map(l => ({vers: l.vers, role: l.role}));
if (lns.length) o.liens = lns;
apps[a.id] = o;
});
const rep = await fetch('/api/applications', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({applications: apps})
});
const data = await rep.json();
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
chargerApplications(data);
dessinerApplications();
message('Applications sauvegardées.', 'ok');
}
function chargerServeurs(data) {
serveurs = (data.serveurs || []).map(s => ({...s, integrations: (s.integrations || []).join(', ')}));
serveursModifie = false;
}
function marquerServeursModifie() { serveursModifie = true; majIndicateurSauvegarde(); }
function choisirServeur(i) { selServeur = i; dessiner(); }
function definirServeur(champ, v) {
const s = serveurs[selServeur]; if (!s) return;
s[champ] = v; marquerServeursModifie();
if (champ === 'nom') { const t = document.querySelector('#tuile-srv-' + selServeur + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
else if (champ === 'fonction' || champ === 'etat') dessiner();
}
function basculerIntegration(role, actif) {
const s = serveurs[selServeur]; if (!s) return;
const set = new Set(listeDepuisTexte(s.integrations));
if (actif) set.add(role); else set.delete(role);
s.integrations = Array.from(set).join(', '); marquerServeursModifie();
}
function ajouterServeur() {
serveurs.unshift({nom: '', fonction: (Object.keys(nomenclature.fonctions || {})[0] || ''), etat: 'planifie', noeud: '', stockage: '', disque: '', memoire: '', coeurs: '', integrations: '', statut: 'absent', vmid: '', adresse_ip: '', vlan: ''});
selServeur = 0; marquerServeursModifie(); dessiner();
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
}
function retirerServeur() {
const s = serveurs[selServeur]; if (!s) return;
if (s.nom && !confirm('Retirer le serveur « ' + s.nom + ' » du plan ?')) return;
serveurs.splice(selServeur, 1);
selServeur = serveurs.length ? Math.min(selServeur, serveurs.length - 1) : null;
marquerServeursModifie(); dessiner();
}
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>'
: s.statut === 'absent' ? '<span class="tag" title="Déclaré, pas encore instancié">○ non instancié</span>' : '';
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">${pointVie(s.nom)}${echapper(s.adresse_ip || '—')}</div>
${recon ? `<div class="carte-grps">${recon}</div>` : ''}
</button>`;
}
function dessinerServeurs() {
const cible = document.getElementById('grilles');
const entete = BANNIERE_MODELE + `<div class="section-grille"><div class="section-tete">Serveurs <span class="compte">${serveurs.length}</span>
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterServeur()">+ Serveur</button>
<button type="button" class="fantome" onclick="appliquerPlan()">⚙ Appliquer le plan</button></div></div>`;
if (!serveurs.length) { cible.innerHTML = entete + '<div class="vide" style="padding:14px">Aucun serveur. « + Serveur » ou <span class="mono">make serveurs-bootstrap</span>.</div>'; return; }
const idx = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s));
const sections = [['Actifs', idx.filter(([s]) => s.etat === 'actif')], ['Planifiés', idx.filter(([s]) => s.etat !== 'actif')]];
cible.innerHTML = entete + sections.filter(([, l]) => l.length).map(([t, l]) =>
`<div class="section-grille"><div class="section-tete">${t} <span class="compte">${l.length}</span></div><div class="grille-cartes">${l.map(([s, i]) => carteServeur(s, i)).join('')}</div></div>`).join('');
}
function inspecteurServeur() {
const cible = document.getElementById('detail');
if (selServeur == null || !serveurs[selServeur]) {
hoteOpCourant = '';
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun serveur sélectionné</b>Choisis une tuile à gauche, ou ajoute un serveur.</div></div>';
return;
}
const s = serveurs[selServeur];
const vi = (intrants && intrants.valeurs) || {};
const libDef = (d) => '(défaut' + (d ? ' : ' + d : '') + ')';
const optFonction = Object.keys(nomenclature.fonctions || {}).map(f => `<option value="${echapper(f)}" ${f === s.fonction ? 'selected' : ''}>${echapper(f)}</option>`).join('');
const optEtat = ['planifie', 'actif'].map(e => `<option value="${e}" ${e === s.etat ? 'selected' : ''}>${e}</option>`).join('');
const optDeListe = (liste, sel, lib) => `<option value="" ${sel ? '' : 'selected'}>${echapper(lib)}</option>`
+ [...new Set([...(liste || []), ...(sel ? [sel] : [])])].map(v => `<option value="${echapper(v)}" ${v === sel ? 'selected' : ''}>${echapper(v)}</option>`).join('');
const set = new Set(listeDepuisTexte(s.integrations));
const tousRoles = [...new Set([...integrationsDispo, ...set])];
const cases = tousRoles.length
? tousRoles.map(role => `<label class="integ-case" title="${echapper(role)}"><input type="checkbox" ${set.has(role) ? 'checked' : ''} onchange="basculerIntegration('${echapper(role)}', this.checked)"><span>${echapper(role.replace(/^client_/, ''))}</span></label>`).join('')
: '<span class="dsn">aucun rôle client_* disponible</span>';
// Integrations universelles : montrees, jamais cochables. Sans cet affichage,
// un plan silencieux se lirait « cet hote n'est pas supervise » — l'inverse
// exact de la politique. L'exemption `sauf_role` s'evalue ici comme au moteur.
const rendus = new Set(applications.filter(a => a.hote === s.nom).map(a => a.groupe));
const univ = Object.entries(integrationsUniv)
.filter(([role, p]) => !(p.sauf_role && rendus.has(p.sauf_role)))
.map(([role, p]) => `<span class="integ-case integ-univ" title="${echapper(p.raison || '')}">✓ ${echapper(role.replace(/^client_/, ''))}</span>`).join('');
const exempts = Object.entries(integrationsUniv)
.filter(([role, p]) => p.sauf_role && rendus.has(p.sauf_role))
.map(([role, p]) => `<span class="integ-case integ-exempt" title="Exempté : cet hôte rend le service ${echapper(p.sauf_role)}">— ${echapper(role.replace(/^client_/, ''))}</span>`).join('');
const info = infoFonction(s.fonction);
const zoneHint = info ? `<span class="zone-hint" title="Dérivé de la nomenclature">${echapper(info.libelle)} · VLAN ${info.vlan}</span>` : '';
const h = hotes.find(x => x.nom === s.nom);
const apps = applications.filter(a => a.hote === s.nom);
const appsHtml = apps.length
? `<div class="ch-bases">${apps.map(a => `<div class="ch-base"><span class="badge-bd">app</span> <button type="button" class="lien-objet" onclick="ouvrirApp('${echapper(a.id)}')">${echapper(a.id || '(sans id)')}</button>${a.expose ? ` <span class="mono">→ ${echapper(listeDepuisTexte(a.expose).join(', '))}</span>` : ''}</div>`).join('')}</div>`
: '<div class="ch-stub">aucune application sur cet hôte</div>';
const bds = bdServeurs.filter(x => x.hote === s.nom);
const bdHtml = bds.length
? `<div class="ch-bases">${bds.map(x => `<div class="ch-base"><span class="badge-bd">${echapper(x.type)}</span> <button type="button" class="lien-objet" onclick="ouvrirBaseServeur('${echapper(x.nom)}')">${echapper(x.nom)}</button> <span class="mono">:${echapper(x.port)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucun serveur de base ici</div>';
hoteOpCourant = s.nom;
const hi = h ? hotes.indexOf(h) : -1;
let boutons = '';
if (h) boutons += `<button type="button" title="Cloner la VM depuis le golden template (Proxmox)" onclick="lancer('creer', ${hi})">🖥 Créer la VM</button>`;
if (h && h.etat === 'actif' && !hoteBloque(h)) {
const clsDep = estProduction ? 'danger' : 'primaire';
boutons += `<button type="button" id="btn-verifier" onclick="lancer('verifier', ${hi})">Vérifier</button>`
+ `<button type="button" id="btn-deployer" class="${clsDep}" onclick="lancer('deployer', ${hi})">Déployer</button>`;
}
let pied;
if (boutons) {
const bloque = h && h.etat === 'actif' && hoteBloque(h);
const hint = bloque ? `<span class="hint-dep">Prérequis manquant : ${[...new Set((h.groupes || []).flatMap(g => manquantsPourGroupe(g)))].join(', ')}</span>` : '';
pied = `<div class="pied">${estProduction ? '<span class="badge-prod">PROD</span>' : ''}${hint}<span class="grandit"></span>${boutons}</div>`;
} else {
pied = `<div class="pied"><span class="hint-dep">Applique le plan pour instancier cet hôte (⚙ Appliquer le plan)</span></div>`;
}
const statBadge = s.statut === 'divergence' ? '<span class="badge-statut bloque">divergent</span>'
: s.statut === 'absent' ? '<span class="badge-statut attente">non instancié</span>'
: '<span class="badge-statut ok">réconcilié</span>';
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(s.nom || '(sans nom)')}</span>${zoneHint}</div>
<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>
<label class="champ"><span>Fonction</span><select onchange="definirServeur('fonction', this.value)">${optFonction}</select></label>
<label class="champ"><span>État</span><select onchange="definirServeur('etat', this.value)">${optEtat}</select></label>
<label class="champ"><span>Nœud</span><select onchange="definirServeur('noeud', this.value)">${optDeListe(vi.proxmox_noeuds, s.noeud, libDef(vi.proxmox_clone_noeud))}</select></label>
<label class="champ"><span>Stockage</span><select onchange="definirServeur('stockage', this.value)">${optDeListe(vi.proxmox_stockages, s.stockage, libDef(vi.proxmox_clone_stockage))}</select></label>
<label class="champ"><span>Disque</span><input data-v="disque" value="${echapper(s.disque)}" placeholder="16G" oninput="definirServeur('disque', this.value)"></label>
<label class="champ"><span>Mémoire</span><input data-v="memoire" type="number" value="${echapper(s.memoire)}" placeholder="2048" oninput="definirServeur('memoire', this.value)"></label>
<label class="champ"><span>Cœurs</span><input data-v="coeurs" type="number" value="${echapper(s.coeurs)}" placeholder="2" oninput="definirServeur('coeurs', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Intégrations (rôles client)</span><div class="integ-cases">${univ}${exempts}${cases}</div><span class="dsn" style="font-size:11px">Les ✓ sont la politique des rôles : elles s'appliquent à tout hôte et ne se décochent pas.</span></label>
</div>
<div class="section-tete" style="margin:12px 2px 2px">Applications ici</div>${appsHtml}
<div class="section-tete" style="margin:12px 2px 2px">Bases ici</div>${bdHtml}
<div class="dsn" style="padding:6px 2px;color:#94a3b8">VMID / IP / VLAN sont <b>dérivés</b>. Sauvegarde (Ctrl+S) puis « Appliquer le plan » pour régénérer l'inventaire.</div>
${pied}
</article>`;
majBoutonsDeploiement();
}
function serveurValeurValide(cle, v) {
const s = String(v == null ? '' : v).trim();
if (s === '') return true; // vide = dérivé / défaut, autorisé
if (cle === 'nom') return /^[a-z0-9]+(-[a-z0-9]+)*-\d+$/.test(s);
if (cle === 'memoire') return /^\d+$/.test(s) && +s >= 64 && +s <= 1048576;
if (cle === 'coeurs') return /^\d+$/.test(s) && +s >= 1 && +s <= 256;
if (cle === 'disque') return /^\d+\s*[GMgm]?$/.test(s);
return true;
}
function validerChampServeur(el) {
const ok = serveurValeurValide(el.getAttribute('data-v'), el.value);
el.classList.toggle('invalide', !ok);
return ok;
}
async function sauvegarderServeurs() {
const invalides = Array.from(document.querySelectorAll('#detail [data-v]')).filter(el => !validerChampServeur(el));
if (invalides.length) {
message('Corrige les ' + invalides.length + ' champ(s) en rouge avant de sauvegarder.', 'erreur');
invalides[0].focus();
return;
}
const srv = {};
serveurs.forEach(s => {
if (!String(s.nom).trim()) return;
const o = {fonction: s.fonction, etat: s.etat || 'planifie'};
['noeud', 'stockage', 'disque'].forEach(k => { if (String(s[k] || '').trim()) o[k] = s[k]; });
['memoire', 'coeurs'].forEach(k => { if (String(s[k] || '').trim()) o[k] = parseInt(s[k], 10) || s[k]; });
const integ = listeDepuisTexte(s.integrations); if (integ.length) o.integrations = integ;
srv[s.nom] = o;
});
const rep = await fetch('/api/serveurs', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs: srv})
});
const data = await rep.json();
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
chargerServeurs(data); dessinerServeurs();
message('Serveurs (plan) sauvegardés. Pense à « Appliquer le plan ».', 'ok');
}
async function appliquerPlan() {
const t = message('Application du plan…', '', {persistant: true});
try {
const rep = await fetch('/api/instancier', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: '{}'});
const data = await rep.json();
const derniere = (data.sortie || '').split('\n').pop();
majToast(t, (data.ok ? 'Plan appliqué : ' : 'Échec : ') + derniere, data.ok ? 'ok' : 'erreur', data.ok ? 4000 : 8000);
if (data.ok) charger();
} catch (e) {
majToast(t, 'Erreur réseau : ' + e, 'erreur', 8000);
}
}
function dessinerDependances() {
const cible = document.getElementById('dependances');
const noms = Object.keys(dependances).sort();
const bloques = noms.filter(g => statutDependance(g) === 'bloque').length;
const resume = document.getElementById('dep-resume');
resume.textContent = bloques ? bloques + ' bloqués' : noms.length + ' suivis';
if (!noms.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucune dépendance.</div>'; return; }
cible.innerHTML = noms.map(groupe => {
const config = dependances[groupe] || {};
const requis = config.requiert_groupes_actifs || [];
const statut = statutDependance(groupe);
const manquants = manquantsPourGroupe(groupe);
const detail = requis.length ? `requiert ${requis.join(', ')}` : 'aucun prérequis';
const manque = requis.length && manquants.length ? `manquant : ${manquants.join(', ')}` : '';
return `
<div class="carte-dep ${statut}" title="${echapper(config.raison || '')}">
<div class="haut"><span class="nom">${echapper(groupe)}</span><span class="chip"><span class="pt ${statut === 'ok' ? 'actif' : statut === 'bloque' ? 'bloq' : 'plan'}"></span>${libelleStatut(statut)}</span></div>
<div class="txt">${echapper(detail)}</div>
${manque ? `<div class="txt">${echapper(manque)}</div>` : ''}
</div>`;
}).join('');
}
function tuile(valeur, libelle) {
const v = (valeur === '' || valeur == null) ? '—' : String(valeur);
return `<div class="stat"><div class="v ${v === '—' ? 'vide' : ''}">${echapper(v)}</div><div class="l">${echapper(libelle)}</div></div>`;
}
function dessinerDetail() {
// Le panneau de droite reflète la sélection de la VUE COURANTE.
if (vuePrincipale === 'flux' || vuePrincipale === 'couches' || vuePrincipale === 'reseau') return legendeLecture();
if (vuePrincipale === 'integrations') return legendeIntegrations();
if (vuePrincipale === 'applications') return inspecteurApplication();
if (vuePrincipale === 'bases') return inspecteurBase();
if (vuePrincipale === 'domaines') return inspecteurDomaine();
if (vuePrincipale === 'nomenclature') return inspecteurNomenclature();
return inspecteurServeur();
}
function majBoutonsDeploiement() {
const bv = document.getElementById('btn-verifier');
const bd = document.getElementById('btn-deployer');
const hote = hoteOpCourant || '';
if (bv) { bv.disabled = occupe; bv.title = occupe ? 'Exécution en cours' : 'Dry-run (--check --diff), aucune modification'; }
if (bd) { bd.disabled = occupe || !verifie[hote]; bd.title = !verifie[hote] ? 'Vérifiez (dry-run) d\'abord' : 'Applique les playbooks sur la VM réelle'; }
}
function fonctionDe(nom) {
const m = (nom || '').match(/^(.+)-(\d+)$/);
return m ? {fonction: m[1], seq: parseInt(m[2], 10)} : {fonction: nom || '', seq: null};
}
function infoFonction(fonction) {
const d = (nomenclature.fonctions || {})[fonction];
if (!d) return null;
const c = (nomenclature.categories || {})[d.categorie];
if (!c) return null;
// VLAN derive du seed index (miroir de inventory_rules.vlan_de) : 1000 + index*10 + zone.
const idx = (nomenclature.index != null) ? nomenclature.index : 9;
const vlan = 1000 + idx * 10 + Number(d.categorie);
return {categorie: d.categorie, service: d.service, libelle: c.libelle, vlan};
}
function echapper(valeur) {
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function ouvrirConsole(titre, hote) {
document.getElementById('console-titre').textContent = titre + ' · ' + hote;
document.getElementById('console-sortie').textContent = '';
const res = document.getElementById('console-resume'); res.hidden = true; res.innerHTML = '';
const etat = document.getElementById('console-etat');
etat.className = 'console-etat actif'; etat.textContent = '● en cours';
document.getElementById('btn-fermer-console').disabled = true;
document.getElementById('console').hidden = false;
consoleDebut = Date.now();
clearInterval(chronoConsole);
chronoConsole = setInterval(() => {
const s = Math.round((Date.now() - consoleDebut) / 1000);
etat.textContent = '● en cours · ' + s + 's';
}, 1000);
}
function ecrireConsole(t) { const p = document.getElementById('console-sortie'); p.textContent += t; p.scrollTop = p.scrollHeight; }
let dernierResumeTexte = '';
function afficherResumeErreur(resume) {
const el = document.getElementById('console-resume');
const echecs = (resume && resume.echecs) || [];
if (!echecs.length) { el.hidden = true; return; }
const lignes = echecs.map(e =>
`<li><b>${echapper(e.tache)}</b> — <span class="re-hote">${echapper(e.hote)}</span> <span class="re-type">${echapper(e.type)}</span>
<div class="re-msg">${echapper(e.message)}</div></li>`).join('');
dernierResumeTexte = echecs.map(e => `• [${e.type}] ${e.tache} @ ${e.hote} : ${e.message}`).join('\n');
el.innerHTML = `<div class="re-tete">❌ Échec — ${echecs.length} tâche(s) en erreur
<button type="button" class="fantome" onclick="copierResume()" title="Copier un résumé clair à transmettre">Copier</button></div>
<ul class="re-liste">${lignes}</ul>`;
el.hidden = false;
}
function copierResume() {
if (navigator.clipboard) navigator.clipboard.writeText(dernierResumeTexte)
.then(() => message('Résumé copié — à transmettre au mainteneur.', 'ok'), () => {});
}
function finirConsole(rc) {
clearInterval(chronoConsole); chronoConsole = null;
const s = Math.round((Date.now() - consoleDebut) / 1000);
const etat = document.getElementById('console-etat');
etat.className = 'console-etat ' + (rc === 0 ? 'ok' : 'echec');
etat.textContent = (rc === 0 ? '✓ succès' : '✗ échec (rc=' + rc + ')') + ' · ' + s + 's';
document.getElementById('btn-fermer-console').disabled = false;
}
function fermerConsole() { if (!occupe) document.getElementById('console').hidden = true; }
function ouvrirModale(titre, texte) {
document.getElementById('modale-titre').textContent = titre;
document.getElementById('modale-texte').textContent = texte;
const champ = document.getElementById('modale-vault');
champ.value = motVault || '';
document.getElementById('modale').hidden = false;
setTimeout(() => champ.focus(), 0);
return new Promise(res => { modaleResolveur = res; });
}
function confirmerModale() {
const v = document.getElementById('modale-vault').value;
document.getElementById('modale').hidden = true;
const r = modaleResolveur; modaleResolveur = null;
if (r) r(v);
}
function annulerModale() {
document.getElementById('modale').hidden = true;
const r = modaleResolveur; modaleResolveur = null;
if (r) r(null);
}
function ouvrirIntrants() {
if (!intrants) { message('Intrants indisponibles (rechargez).', 'erreur'); return; }
rendreIntrants();
document.getElementById('intrants').hidden = false;
}
function fermerIntrants() { document.getElementById('intrants').hidden = true; }
function rendreIntrants() {
const corps = document.getElementById('intrants-corps');
const schema = intrants.schema || [];
const valeurs = intrants.valeurs || {};
const sections = [];
const parSection = {};
const proprioDe = {};
schema.forEach(ch => {
if (!parSection[ch.section]) { parSection[ch.section] = []; sections.push(ch.section); }
parSection[ch.section].push(ch);
proprioDe[ch.section] = ch.proprietaire || 'tenant';
});
let html = '';
sections.forEach(sec => {
// Le panneau melangeait deux proprietaires sans le dire. Un tenant et son
// hebergeur n'ont ni le meme depot, ni le meme cycle de vie : editer une
// section « hébergeur » touche TOUS les tenants du cluster.
const heb = proprioDe[sec] === 'hebergeur';
const chip = heb
? '<span class="intr-proprio heb" title="Propriété de l\'HÉBERGEUR — écrit hors du dépôt du tenant ; la modification vaut pour tous ses tenants">hébergeur</span>'
: '<span class="intr-proprio ten" title="Propriété du TENANT — écrit dans son dépôt ; n\'affecte que lui">tenant</span>';
html += '<div class="intr-section"><div class="intr-tete">' + echapper(sec) + chip + '</div><div class="intr-grille">';
parSection[sec].forEach(ch => {
const v = valeurs[ch.variable];
let badge;
if (ch.classe === 'constante') badge = '<span class="badge-classe const" title="Valeur unique, non surchargeable">Constante</span>';
else if (ch.classe === 'catalogue') badge = '<span class="badge-classe cat" title="Liste de valeurs autorisées, proposée dans les menus déroulants">Catalogue</span>';
else badge = '<span class="badge-classe def" title="Valeur par défaut, surchargeable dans les instances">Défaut</span>';
const lib = echapper(ch.libelle) + badge;
if (ch.type === 'bool') {
const coche = (v === true || v === 'true' || v === 'True') ? ' checked' : '';
html += '<label class="champ"><span>' + lib + '</span>'
+ '<input type="checkbox" data-intrant="' + echapper(ch.variable) + '" data-type="bool"' + coche + '></label>';
} else if (ch.type === 'liste') {
const txt = Array.isArray(v) ? v.join(', ') : (v == null ? '' : v);
html += '<label class="champ" style="grid-column:1/-1"><span>' + lib + ' <span class="unite">· séparés par des virgules</span></span>'
+ '<input type="text" data-intrant="' + echapper(ch.variable) + '" data-type="liste"'
+ ' placeholder="ex. asgard, pve-02, pve-03" value="' + echapper(txt) + '"></label>';
} else {
const t = ch.type === 'int' ? 'number' : 'text';
html += '<label class="champ"><span>' + lib + '</span>'
+ '<input type="' + t + '" data-intrant="' + echapper(ch.variable) + '" data-type="' + echapper(ch.type) + '"'
+ ' value="' + echapper(v == null ? '' : v) + '"></label>';
}
});
html += '</div></div>';
});
// Nomenclature en lecture seule (lue ailleurs : vue Nomenclature)
const n = nomenclature || {};
if (Object.keys(n).length) {
html += '<div class="intr-section"><div class="intr-tete">Nomenclature (lecture seule)</div>'
+ '<div class="intr-ro">';
if (n.supernet) html += 'Supernet&nbsp;: <b>' + echapper(n.supernet) + '</b><br>';
if (n.domaine || n.domaine_interne) html += 'Domaine&nbsp;: <b>' + echapper(n.domaine || n.domaine_interne) + '</b><br>';
const nbCat = n.categories ? Object.keys(n.categories).length : 0;
const nbFon = n.fonctions ? Object.keys(n.fonctions).length : 0;
html += nbCat + ' catégorie(s), ' + nbFon + ' fonction(s) déclarée(s).';
html += '<br><span style="font-style:italic">Modifiable dans le plan (nomenclature.yml), pas ici.</span>';
html += '</div></div>';
}
// Secrets attendus : informatif, jamais saisis ici
const secrets = intrants.secrets_attendus || [];
if (secrets.length) {
html += '<div class="intr-section"><div class="intr-tete">Secrets attendus (Vault — CLI)</div>'
+ '<div class="intr-ro">Ces clés ne sont jamais saisies ni stockées ici. Édite-les via <b>ansible-vault</b>.'
+ '<div class="intr-secrets">'
+ secrets.map(s => '<code>' + echapper(s) + '</code>').join('')
+ '</div></div></div>';
}
const f = intrants.fichiers || {};
const chemins = Object.values(f).map(x => echapper(x)).join(', ');
if (chemins) html += '<div class="intr-ro" style="margin-top:2px">Écrit dans&nbsp;: <b>' + chemins + '</b></div>';
corps.innerHTML = html;
}
async function enregistrerIntrants() {
const valeurs = {};
document.querySelectorAll('#intrants-corps [data-intrant]').forEach(el => {
const k = el.getAttribute('data-intrant');
valeurs[k] = el.type === 'checkbox' ? el.checked : el.value;
});
const ancienDomaine = (intrants.valeurs || {}).domaine_interne;
const nouveauDomaine = valeurs.domaine_interne;
if (nouveauDomaine !== undefined && nouveauDomaine !== ancienDomaine) {
if (!confirm('Changer le domaine interne (« ' + (ancienDomaine || '∅') + ' » → « ' + (nouveauDomaine || '∅')
+ ' ») impacte tout l\'écosystème (DNS, certificats, noms d\'hôtes). Continuer ?')) return;
}
try {
const reponse = await fetch('/api/intrants', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
body: JSON.stringify({valeurs}),
});
if (!reponse.ok) {
let detail = '';
try { detail = (await reponse.json()).erreur || ''; } catch (e) { detail = await reponse.text(); }
message('Échec de l\'enregistrement des intrants : ' + detail, 'erreur');
return;
}
const data = await reponse.json();
intrants = data.intrants || intrants;
nomenclature = data.nomenclature || nomenclature;
fermerIntrants();
message('Intrants de base enregistrés.', 'ok');
} catch (e) {
message('Erreur réseau : ' + e, 'erreur');
}
}
async function lancer(mode, index) {
const hote = hotes[index] ? hotes[index].nom : '';
if (!hote) { message('Hôte introuvable.', 'erreur'); return; }
if (occupe) return;
let vault = '';
if ((mode === 'deployer' || mode === 'pousser') && !verifie[hote]) { message('Vérifiez (dry-run) d\'abord.', 'erreur'); return; }
if (mode === 'creer' || mode === 'deployer' || mode === 'pousser' || mode === 'verifier') {
if (estProduction && (mode === 'deployer' || mode === 'pousser')) {
const saisi = prompt('⚠️ PRODUCTION — action réelle sur « ' + hote + ' ».\n'
+ 'Tape le nom exact de l\'hôte pour confirmer :');
if (saisi === null) return;
if (saisi.trim() !== hote) { message('Confirmation annulée : le nom ne concorde pas.', 'erreur'); return; }
}
const av = estProduction ? '⚠️ PRODUCTION. ' : '';
const txt = mode === 'creer'
? 'Clone la VM « ' + hote + ' » depuis le golden template sur Proxmox (création réelle).'
: mode === 'pousser'
? av + 'Déploie l\'hôte « ' + hote + ' » (applique ses playbooks, en root). Non transactionnel.'
: mode === 'verifier'
? 'Dry-run (--check), AUCUNE modification. Mot de passe vault pour déchiffrer les secrets — laisse VIDE si cet hôte n\'a pas de secrets.'
: av + 'Applique les playbooks de groupes sur la VM réelle, en root (sudo). Non transactionnel.';
const titre = (mode === 'creer' ? 'Créer la VM — ' : mode === 'pousser' ? 'Déployer — ' : mode === 'verifier' ? 'Vérifier — ' : 'Déployer — ') + hote;
vault = await ouvrirModale(titre, txt);
if (vault === null) return;
motVault = vault;
}
occupe = true; majBoutonsDeploiement();
const titreConsole = {verifier: 'Vérification (dry-run)', deployer: 'Déploiement', creer: 'Création VM (clone)', pousser: 'Déploiement'}[mode] || mode;
ouvrirConsole(titreConsole, hote);
try {
const rep = await fetch('/api/' + mode, {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
body: JSON.stringify(vault ? {hote, vault} : {hote})
});
if (!rep.ok || !rep.body) {
const d = await rep.json().catch(() => ({}));
ecrireConsole((d.erreur || 'Refusé.') + '\n'); finirConsole(1);
occupe = false; majBoutonsDeploiement(); return;
}
const lecteur = rep.body.getReader();
const dec = new TextDecoder();
let rc = null; let brut = '';
while (true) {
const {value, done} = await lecteur.read();
if (done) break;
let texte = dec.decode(value, {stream: true});
brut += texte;
const m = texte.match(/__FIN__ rc=(-?\d+)/);
if (m) { rc = parseInt(m[1], 10); }
texte = texte.replace(/\n?__RESUME__ .*\n?/, '').replace(/\n?__FIN__ rc=-?\d+\n?/, '');
if (texte) ecrireConsole(texte);
}
if (rc === null) rc = 1;
const mr = brut.match(/__RESUME__ (.+)/);
if (rc !== 0 && mr) { try { afficherResumeErreur(JSON.parse(mr[1])); } catch (e) {} }
finirConsole(rc);
if (mode === 'verifier' && rc === 0) { verifie[hote] = true; message('Vérification réussie — déploiement débloqué pour ' + hote + '.', 'ok'); }
else if (mode === 'creer') { message(rc === 0 ? 'VM « ' + hote + ' » créée — hôte marqué actif.' : 'Échec de la création.', rc === 0 ? 'ok' : 'erreur'); if (rc === 0) appliquerPlan(); else sonder(); }
else if (mode === 'deployer' || mode === 'pousser') { verifie[hote] = false; message(rc === 0 ? 'Déploiement terminé — hôte marqué actif.' : 'Déploiement en échec.', rc === 0 ? 'ok' : 'erreur'); if (rc === 0) appliquerPlan(); else sonder(); }
} catch (e) {
ecrireConsole('Erreur client : ' + e.message + '\n'); finirConsole(1);
}
occupe = false; majBoutonsDeploiement();
}
function sauvegarderVue() {
if (occupe) return;
if (vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') sauvegarderServeurs();
else if (vuePrincipale === 'applications') sauvegarderApplications();
else if (vuePrincipale === 'bases') sauvegarderBases();
else if (vuePrincipale === 'domaines') sauvegarderDomaines();
else if (vuePrincipale === 'nomenclature') sauvegarderNomenclature();
else message('Vue en lecture seule (Flux / Couches) — édite dans Serveurs / Applications / Bases / Domaines.');
}
function ordreServeursVisibles() {
const vis = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s));
return [...vis.filter(([s]) => s.etat === 'actif'), ...vis.filter(([s]) => s.etat !== 'actif')].map(([, i]) => i);
}
function naviguerServeurs(delta) {
const ordre = ordreServeursVisibles();
if (!ordre.length) return;
let pos = ordre.indexOf(selServeur);
pos = pos < 0 ? (delta > 0 ? 0 : ordre.length - 1)
: Math.min(ordre.length - 1, Math.max(0, pos + delta));
choisirServeur(ordre[pos]);
const c = document.getElementById('tuile-srv-' + ordre[pos]);
if (c) c.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modaleResolveur) { annulerModale(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); sauvegarderVue(); return; }
const dansSaisie = /^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement || {}).tagName || '');
if (dansSaisie || e.ctrlKey || e.metaKey || e.altKey) return;
const vues = {'1': 'serveurs', '2': 'applications', '3': 'bases'};
if (vues[e.key]) { setVue(vues[e.key]); return; }
if (e.key === '/') { e.preventDefault(); const f = document.getElementById('filtre'); if (f) f.focus(); return; }
if (vuePrincipale !== 'serveurs') return;
if (e.key === 'j') { naviguerServeurs(1); return; }
if (e.key === 'k') { naviguerServeurs(-1); return; }
if (e.key === 'v') { const b = document.getElementById('btn-verifier'); if (b && !b.disabled) b.click(); return; }
if (e.key === 'd') { const b = document.getElementById('btn-deployer'); if (b && !b.disabled) b.click(); return; }
});
// Validation inline des champs du plan (vue Serveurs) — liseré rouge avant l'envoi.
document.addEventListener('input', e => {
if (e.target && e.target.matches && e.target.matches('[data-v]')) validerChampServeur(e.target);
});
// Garde-fou : prévenir la perte d'éditions de plan non sauvegardées.
window.addEventListener('beforeunload', e => {
if (serveursModifie || applicationsModifie || basesModifie || domainesModifie) { e.preventDefault(); e.returnValue = ''; }
});
// ------ Info-bulles d'aide au survol de la description d'un champ ------
(function () {
const AIDES = {
'Adresse IP': "Adresse IPv4 de la VM sur son VLAN. Ex. 10.10.12.11. Souvent dérivée — clic « Proposer ».",
'CIDR': "Masque réseau en bits. 24 = /24 (255.255.255.0).",
'Passerelle': "Routeur de sortie du VLAN. En général le .1 du sous-réseau.",
'VLAN': "Identifiant 802.1Q (1–4094). Dérivé de la fonction via la nomenclature.",
'Pont': "Pont réseau Proxmox. Ex. vmbr0.",
'DNS': "Résolveur DNS de la VM. En général le DNS interne de l'écosystème.",
'Utilisateur Ansible': "Compte technique utilisé par Ansible. Par défaut : ansible.",
'VMID': "Identifiant unique Proxmox. Dérivé (catégorie + service + rang).",
'Nœud': "Hôte Proxmox cible. Ex. pve-01.",
'Stockage': "Pool de stockage Proxmox du disque. Ex. local-zfs.",
'Disque': "Taille du disque système. Ex. 32G. Dérivée des logiciels hébergés + socle.",
'Mémoire': "RAM en Mo. Dérivée des logiciels hébergés + socle (avec marge).",
'Cœurs': "vCPU alloués. Dérivés des logiciels hébergés (maximum, pas additif).",
'Nom': "Identifiant unique du serveur de base, sans espace.",
'Type': "Moteur de la base. Ex. postgres, redis.",
'Hôte': "VM qui héberge ce service (nom du serveur).",
'Hôte (VM)': "VM cible où l'application est déployée.",
'Port': "Port d'écoute du service.",
'Groupe Ansible': "Groupe/rôle Set-OPS appliqué. Ex. serveur_postgresql.",
'Groupe (rôle)': "Rôle Set-OPS appliqué à l'application.",
'Identifiant': "Clé courte et unique, sans espace.",
'Portée': "À qui la base est offerte : groupe, hôte ou application.",
'Consommateur': "L'entité (groupe / hôte / application) qui utilise la base.",
'Usage': "Étiquette libre. Ex. principale.",
'Serveur': "Serveur de base de données qui héberge cette base.",
'Base': "Nom de la base de données.",
'Propriétaire': "Rôle SQL propriétaire de la base.",
'Secret (Vault)': "Nom de la variable Ansible Vault contenant le mot de passe.",
'Requiert (applis)': "Applications prérequises, séparées par des virgules.",
'Expose (FQDN)': "Nom(s) public(s) exposé(s). Ex. forge.exemple.ca.",
'Serveur (fonction-NN)': "Nom = fonction + rang. Ex. web-frontal-03.",
};
const bulle = document.createElement('div');
bulle.id = 'infobulle';
bulle.setAttribute('role', 'tooltip');
document.body.appendChild(bulle);
function libelleDe(el) {
const n = el.firstChild;
return ((n && n.nodeType === 3 ? n.textContent : el.textContent) || '').trim();
}
function aideDe(el) {
if (!el || !el.matches || !el.matches('.champ > span')) return null;
return AIDES[libelleDe(el)] || null;
}
function placer(el) {
const r = el.getBoundingClientRect();
bulle.style.left = Math.max(8, Math.min(r.left, innerWidth - bulle.offsetWidth - 8)) + 'px';
let haut = r.top - bulle.offsetHeight - 8;
if (haut < 8) haut = r.bottom + 8; // bascule sous le champ si pas la place
bulle.style.top = haut + 'px';
}
document.addEventListener('mouseover', e => {
const aide = aideDe(e.target);
if (!aide) return;
bulle.textContent = aide;
bulle.classList.add('on');
placer(e.target);
});
document.addEventListener('mouseout', e => {
if (e.target && e.target.matches && e.target.matches('.champ > span')) bulle.classList.remove('on');
});
// Affordance : souligné pointillé sur les descriptions qui ont une aide.
function marquer(racine) {
if (!racine.querySelectorAll) return;
racine.querySelectorAll('.champ > span').forEach(s => {
if (AIDES[libelleDe(s)]) s.classList.add('a-aide');
});
}
new MutationObserver(muts => muts.forEach(m =>
m.addedNodes.forEach(n => { if (n.nodeType === 1) marquer(n); })
)).observe(document.body, { childList: true, subtree: true });
marquer(document);
})();
charger().catch(err => message(err.message, 'erreur'));
</script>
</body>
</html>
"""
def basculer_instance(nom: str) -> str:
"""Repointe le symlink `instance/` vers un depot frere. Renvoie la nouvelle cible.
Memes garde-fous que `make instance-utiliser`, plus une validation STRICTE : `nom`
doit etre une instance DECOUVERTE (dossier frere avec plan/nomenclature.yml), ce
qui interdit toute traversee de chemin (pas de '/', '..', cible arbitraire).
"""
import instances as _inst
connues = {i["nom"] for i in _inst.decouvrir()}
if nom not in connues:
raise ValueError(f"Instance inconnue : {nom!r} (attendu : {', '.join(sorted(connues)) or 'aucune'}).")
cible = RACINE.parent / nom
if not cible.is_dir():
raise ValueError(f"Dossier introuvable : ../{nom}")
lien = RACINE / "instance"
if lien.exists() and not lien.is_symlink():
raise ValueError("'instance' existe et n'est pas un symlink — refus.")
if lien.is_symlink() or lien.exists():
lien.unlink()
lien.symlink_to(f"../{nom}")
return os.readlink(lien)
class Gestionnaire(BaseHTTPRequestHandler):
# Inventaire de l'instance ACTIVE, resolu DYNAMIQUEMENT depuis le symlink
# `instance/` a chaque acces : ainsi la bascule d'instance (POST
# /api/instance-utiliser) prend effet sans redemarrer le serveur. Les chemins
# de plan (FICHIER_*) sont deja relatifs au symlink et suivent de meme.
# SETOPS_INVENTAIRE force un inventaire fixe (CI / usage explicite).
@property
def inventaire(self) -> Path:
force = os.environ.get("SETOPS_INVENTAIRE")
return Path(force) if force else _inventaire(INSTANCE, "principal", "production")
def log_message(self, *args) -> None: # silence par defaut
pass
def repondre(self, status: int, contenu: bytes, content_type: str) -> None:
try:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(contenu)))
self.end_headers()
self.wfile.write(contenu)
except (BrokenPipeError, ConnectionResetError):
pass # le client a ferme la connexion : rien a faire
def repondre_json(self, status: int, data: dict) -> None:
self.repondre(status, json.dumps(data, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def _ecrire_flux(self, texte: str) -> bool:
try:
self.wfile.write(texte.encode("utf-8", "replace"))
self.wfile.flush()
return True
except (BrokenPipeError, ConnectionResetError):
return False
def executer_flux(self, hote: str, mode: str, vault: str | None = None) -> None:
if not MOTIF_HOTE.match(hote or ""):
self.repondre_json(400, {"erreur": "Nom d'hote invalide."})
return
tous_hotes = {h.get("nom") for h in liste_hotes(charger_yaml(self.inventaire))}
if hote not in tous_hotes:
self.repondre_json(409, {"erreur": f"{hote} est inconnu de l'inventaire (applique le plan d'abord)."})
return
# 'creer' (clone) est permis meme sur un hote planifie ; les autres exigent actif.
if mode in ("verifier", "deployer", "pousser") and hote not in hotes_actifs_fichier(self.inventaire):
self.repondre_json(409, {"erreur": f"{hote} n'est pas un hote actif sauvegarde."})
return
if mode == "deployer" and not VERIF_OK.get(hote):
self.repondre_json(409, {"erreur": "Verification (dry-run) requise avant de deployer."})
return
if not VERROU.acquire(blocking=False):
self.repondre_json(409, {"erreur": "Une execution est deja en cours."})
return
fichier_vault = None
journal = None
try:
cible = {"deployer": "deployer", "pousser": "deployer",
"creer": "creer-vm"}.get(mode, "verifier-deploiement")
env = dict(os.environ, PYTHONUNBUFFERED="1", ANSIBLE_FORCE_COLOR="0")
if vault:
# LE MOT DE PASSE SAISI S'AJOUTE AUX CLES, IL NE LES REMPLACE PAS.
#
# Verifie empiriquement le 2026-09-08, contre deux voutes jetables a mots
# de passe distincts : `ANSIBLE_VAULT_PASSWORD_FILE` et
# `ANSIBLE_VAULT_IDENTITY_LIST` se CUMULENT — Ansible essaie tous les
# secrets, et un PASSWORD_FILE errone n'empeche rien.
#
# Ca compte depuis « une voute, une cle » (2026-08-28) : creer une VM
# ouvre DEUX voutes dans la meme execution — celle du tenant et
# `underlay.vault.yml`. Un seul mot de passe ne pourrait pas les ouvrir
# toutes les deux. Ce qui sauve ce chemin, c'est que `make` calcule
# `ANSIBLE_VAULT_IDENTITY_LIST` par-dessus (Makefile, `?=`), et que les
# deux coexistent dans l'environnement de la recette.
#
# NE PAS « SIMPLIFIER » EN RETIRANT L'UN DES DEUX. Le PASSWORD_FILE sert
# le cas ou la cle n'est pas sur la machine (un runner qui n'a que la
# sienne) ; l'IDENTITY_LIST sert le poste de l'exploitant, qui les a
# toutes. Chacun couvre ce que l'autre ne couvre pas.
fd, fichier_vault = tempfile.mkstemp(prefix="setops-vault-") # 0600 par defaut
os.write(fd, str(vault).encode("utf-8"))
os.close(fd)
env["ANSIBLE_VAULT_PASSWORD_FILE"] = fichier_vault
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
# Persistance : un journal horodaté par exécution, en plus du streaming live.
dossier_logs = self.inventaire.parents[2] / "logs"
dossier_logs.mkdir(parents=True, exist_ok=True)
chemin_log = dossier_logs / f"{hote}-{cible}-{time.strftime('%Y%m%dT%H%M%S')}.log"
journal = open(chemin_log, "w", encoding="utf-8")
entete = f"$ make {cible} HOTE={hote}\n\n"
journal.write(entete)
self._ecrire_flux(entete)
tache_courante = ""
echecs: list[dict] = []
proc = subprocess.Popen(
["make", cible, f"HOTE={hote}"],
cwd=str(RACINE), env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
)
try:
for ligne in proc.stdout:
journal.write(ligne)
if ligne.startswith("TASK ["):
deb, fin = ligne.find("["), ligne.find("]")
if -1 < deb < fin:
tache_courante = ligne[deb + 1:fin]
elif ("FAILED!" in ligne or "UNREACHABLE!" in ligne) and \
("fatal:" in ligne or ligne.lstrip().startswith("failed:")):
echec = _extraire_echec(ligne, tache_courante)
if echec:
echecs.append(echec)
if not self._ecrire_flux(ligne):
# Navigateur déconnecté : on continue à journaliser jusqu'au bout.
for reste in proc.stdout:
journal.write(reste)
break
finally:
rc = proc.wait()
if mode == "verifier" and rc == 0:
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
if rc != 0 and echecs:
self._ecrire_flux("__RESUME__ " + json.dumps({"echecs": echecs[:6]}, ensure_ascii=False) + "\n")
journal.write(f"\n__FIN__ rc={rc}\n")
self._ecrire_flux(f"\n[journal : {os.path.relpath(chemin_log, RACINE)}]\n__FIN__ rc={rc}\n")
finally:
if journal:
try:
journal.close()
except OSError:
pass
if fichier_vault:
try:
os.remove(fichier_vault)
except OSError:
pass
VERROU.release()
def do_GET(self) -> None:
chemin = urlparse(self.path).path
try:
if chemin == "/":
page = HTML.replace("__JETON__", JETON)
self.repondre(200, page.encode("utf-8"), "text/html; charset=utf-8")
elif chemin == "/favicon.ico":
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))
elif chemin == "/api/devis-reseau":
tenants = devis_reseau.decouvrir()
self.repondre_json(200, {
"config": devis_reseau.generer(tenants) if tenants else "",
"tenants": [{"nom": nom, "index": n["index"]} for nom, _, n in tenants],
})
elif chemin == "/api/devis-opnsense":
# Import paresseux : ce module lit l'underlay et les inventaires de tous
# les tenants ; une erreur ici ne doit pas empecher le reste du panneau.
try:
import devis_opnsense as _dop
tenants = _dop.decouvrir()
self.repondre_json(200, {
"config": _dop.rendre(_dop.construire(tenants)) if tenants else "",
})
except Exception as exc:
self.repondre_json(200, {"config": "", "erreur": str(exc)})
elif chemin == "/api/instances":
import instances as _inst
import instance_creer as _ic
liste = _inst.decouvrir()
self.repondre_json(200, {
"instances": liste,
"collisions": {str(k): v for k, v in _inst.collisions(liste).items()},
"modeles": sorted(_ic.modeles_disponibles()),
"index_pris": sorted(_ic.index_federes_utilises()),
})
else:
self.repondre_json(404, {"erreur": "Introuvable"})
except Exception as exc:
self.repondre_json(500, {"erreur": str(exc)})
def do_POST(self) -> None:
chemin = urlparse(self.path).path
if self.headers.get("X-Jeton") != JETON:
self.repondre_json(403, {"erreur": "Jeton manquant ou invalide."})
return
longueur = int(self.headers.get("Content-Length", "0") or 0)
corps = self.rfile.read(longueur).decode("utf-8") if longueur else ""
try:
donnees = json.loads(corps) if corps else {}
except json.JSONDecodeError:
self.repondre_json(400, {"erreur": "Corps JSON invalide."})
return
try:
if chemin == "/api/inventaire":
self.repondre_json(409, {"erreur": "hosts.yml est genere depuis le plan. "
"Edite instance/plan/serveurs.yml et instance/plan/applications.yml "
"(vues Serveurs / Applications), puis « Appliquer le plan »."})
elif chemin == "/api/bases":
valider_bases(donnees, charger_applications(FICHIER_APPLICATIONS))
ecrire_bases(FICHIER_BASES, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/applications":
valider_applications(donnees, charger_domaines(FICHIER_DOMAINES),
charger_serveurs(FICHIER_SERVEURS))
ecrire_applications(FICHIER_APPLICATIONS, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/serveurs":
valider_serveurs(donnees, charger_nomenclature(FICHIER_NOMENCLATURE))
ecrire_serveurs(FICHIER_SERVEURS, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/nomenclature":
valider_nomenclature(donnees, charger_serveurs(FICHIER_SERVEURS))
ecrire_nomenclature(FICHIER_NOMENCLATURE, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/domaines":
valider_domaines(donnees)
ecrire_domaines(FICHIER_DOMAINES, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/intrants":
ecrire_intrants(donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/instance-utiliser":
cible = basculer_instance(str(donnees.get("nom", "")).strip())
self.repondre_json(200, {"ok": True, "cible": cible})
elif chemin == "/api/instance-creer":
import instance_creer as _ic
brut_idx = str(donnees.get("index", "")).strip()
_ic.creer(str(donnees.get("nom", "")).strip(),
str(donnees.get("modele", "")).strip(),
int(brut_idx) if brut_idx else None)
self.repondre_json(200, {"ok": True, "nom": str(donnees.get("nom", "")).strip()})
elif chemin == "/api/instancier":
# Cliquer « Appliquer le plan » EST l'intention explicite : on force
# (le diff-refus est un garde-fou CLI ; ici le clic le confirme). git = filet.
resultat = subprocess.run(
["python3", str(RACINE / "scripts/instancier.py"), "appliquer", "--force"],
capture_output=True, text=True, cwd=str(RACINE))
self.repondre_json(200, {"ok": resultat.returncode == 0,
"sortie": (resultat.stdout + resultat.stderr).strip()})
elif chemin == "/api/verifier":
self.executer_flux(str(donnees.get("hote", "")), "verifier", donnees.get("vault"))
elif chemin == "/api/deployer":
self.executer_flux(str(donnees.get("hote", "")), "deployer", donnees.get("vault"))
elif chemin == "/api/creer":
self.executer_flux(str(donnees.get("hote", "")), "creer", donnees.get("vault"))
elif chemin == "/api/pousser":
self.executer_flux(str(donnees.get("hote", "")), "pousser", donnees.get("vault"))
else:
self.repondre_json(404, {"erreur": "Introuvable"})
except Exception as exc:
self.repondre_json(400, {"erreur": str(exc)})
def main() -> int:
parser = argparse.ArgumentParser(description="Interface web locale pour gerer un inventaire Set-OPS.")
parser.add_argument("--inventaire", type=Path, default=INVENTAIRE_DEFAUT)
parser.add_argument("--hote", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
serveur = ThreadingHTTPServer((args.hote, args.port), Gestionnaire)
print(f"Interface inventaire: http://{args.hote}:{args.port}/")
print(f"Inventaire: {Gestionnaire.inventaire}")
serveur.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())