Nouvel onglet lecture seule affichant le devis make devis-reseau (VLANs+SVIs+ACLs) via GET /api/devis-reseau, avec bouton Copier. node --check OK, endpoint eprouve (2 tenants, 121 lignes). Un operateur genere et transmet la config sans CLI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2455 lines
144 KiB
Python
2455 lines
144 KiB
Python
#!/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 (
|
||
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,
|
||
reconcilier_serveur,
|
||
valider_applications,
|
||
valider_bases,
|
||
valider_domaines,
|
||
valider_serveurs,
|
||
)
|
||
|
||
RACINE = Path(__file__).resolve().parents[1]
|
||
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
|
||
|
||
|
||
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
|
||
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"
|
||
FICHIERS_INTRANTS = {"identite": INTRANTS_IDENTITE, "proxmox": INTRANTS_PROXMOX}
|
||
|
||
# (variable, fichier, classe, section, libelle, type)
|
||
INTRANTS_SCHEMA = [
|
||
("domaine_interne", "identite", "constante", "Identité", "Domaine DNS interne", "str"),
|
||
("fuseau_horaire", "identite", "defaut", "Identité", "Fuseau horaire", "str"),
|
||
("organisation", "identite", "defaut", "Identité", "Organisation (annuaire LDAP, certificats)", "str"),
|
||
("identite_realm", "identite", "defaut", "Identité", "Realm SSO (Keycloak)", "str"),
|
||
("nftables_admin_ssh", "identite", "catalogue", "Sécurité", "Sources SSH d'administration toujours autorisées (garde anti-lockout du pare-feu)", "liste"),
|
||
("proxmox_api_host", "proxmox", "constante", "Proxmox", "Hôte API Proxmox", "str"),
|
||
("proxmox_api_user", "proxmox", "constante", "Proxmox", "Utilisateur API", "str"),
|
||
("proxmox_api_port", "proxmox", "constante", "Proxmox", "Port API", "str"),
|
||
("proxmox_validate_certs", "proxmox", "constante", "Proxmox", "Valider les certificats TLS", "bool"),
|
||
("proxmox_clone_source_nom", "proxmox", "constante", "Proxmox", "Nom du golden template", "str"),
|
||
("proxmox_clone_vmid_modele", "proxmox", "constante", "Proxmox", "VMID du golden template", "int"),
|
||
("proxmox_clone_noeud", "proxmox", "defaut", "Proxmox", "Nœud Proxmox (défaut)", "str"),
|
||
("proxmox_clone_stockage", "proxmox", "defaut", "Proxmox", "Stockage (défaut)", "str"),
|
||
("proxmox_clone_pont", "proxmox", "defaut", "Proxmox", "Pont réseau (défaut)", "str"),
|
||
("proxmox_noeuds", "proxmox", "catalogue", "Proxmox", "Nœuds disponibles (liste)", "liste"),
|
||
("proxmox_stockages", "proxmox", "catalogue", "Proxmox", "Stockages disponibles (liste)", "liste"),
|
||
("proxmox_ponts", "proxmox", "catalogue", "Proxmox", "Ponts réseau disponibles (liste)", "liste"),
|
||
]
|
||
# 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",
|
||
}
|
||
# Rappel en lecture seule (jamais de valeur) : secrets a fournir via Ansible Vault.
|
||
SECRETS_ATTENDUS = [
|
||
# Accès Proxmox (proxmox.vault.yml)
|
||
"proxmox_api_token_id", "proxmox_api_token_secret",
|
||
# PKI (step-ca)
|
||
"vault_step_ca_password", "vault_step_ca_provisioner_password",
|
||
# Identité (LDAP / Keycloak)
|
||
"vault_openldap_admin", "vault_ldap_sssd", "vault_keycloak_admin",
|
||
# Bases de données
|
||
"vault_postgresql_keycloak", "vault_bd_keycloak", "vault_bd_forgejo",
|
||
"vault_bd_icingadb", "vault_bd_nextcloud",
|
||
# Forge (Forgejo)
|
||
"vault_forgejo_admin", "vault_forgejo_secret_key", "vault_forgejo_internal_token",
|
||
# Observabilité / cache
|
||
"vault_grafana_admin", "vault_redis",
|
||
# Applications (admin)
|
||
"vault_nextcloud_admin",
|
||
# Secrets OIDC des clients SSO (un par app fédérée)
|
||
"vault_grafana_oidc", "vault_forgejo_oidc", "vault_icingaweb2_oidc",
|
||
"vault_nextcloud_oidc", "vault_oauth2_cookie",
|
||
# Sauvegarde (restic hors-nœud)
|
||
"vault_restic_password", "vault_backup_ssh_privkey",
|
||
]
|
||
|
||
# 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 path.open("w", encoding="utf-8") 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(),
|
||
}
|
||
|
||
|
||
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 intrants_courants() -> dict:
|
||
cache: dict = {}
|
||
valeurs: dict = {}
|
||
for variable, fcle, _classe, _section, _libelle, typ in INTRANTS_SCHEMA:
|
||
if fcle not in cache:
|
||
cache[fcle] = _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
|
||
|
||
|
||
def integrations_disponibles() -> list:
|
||
"""Roles client_* presents dans roles/ : source des integrations selectionnables."""
|
||
base = RACINE / "roles"
|
||
return sorted(p.name for p in base.glob("client_*") if p.is_dir())
|
||
|
||
|
||
def intrants_api() -> dict:
|
||
schema = [{"variable": v, "fichier": f, "classe": c, "section": s,
|
||
"libelle": l, "type": t} for (v, f, c, s, l, t) 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
|
||
|
||
|
||
def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None:
|
||
interdits = sorted(set(valeurs) & INTRANTS_CLES_INTERDITES)
|
||
if interdits:
|
||
raise ValueError(f"Refus d'ecrire des cles sensibles via le GUI: {', '.join(interdits)}")
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("w", encoding="utf-8") 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 {}
|
||
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) in INTRANTS_SCHEMA}
|
||
fichier_de = {v: f for (v, f, c, s, l, t) 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:
|
||
_ecrire_intrants_fichier(
|
||
INTRANTS_IDENTITE, par_fichier["identite"],
|
||
"# 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 vont dans proxmox.vault.yml).\n"
|
||
"# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n---\n")
|
||
|
||
|
||
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 path.open("w", encoding="utf-8") 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"
|
||
"---\n"
|
||
)
|
||
with path.open("w", encoding="utf-8") 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 path.open("w", encoding="utf-8") 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 path.open("w", encoding="utf-8") 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-case input { width: 14px; height: 14px; flex: none; accent-color: var(--teal); }
|
||
.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-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-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 ; 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 : 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 = {};
|
||
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 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 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 || {};
|
||
chaine = data.chaine || {};
|
||
fluxRegistre = data.flux || [];
|
||
couchesRegistre = data.couches || [];
|
||
intrants = data.intrants || null;
|
||
integrationsDispo = data.integrations_disponibles || [];
|
||
chargerBases(data);
|
||
chargerApplications(data);
|
||
chargerDomaines(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 === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines';
|
||
const sale = (vuePrincipale === 'serveurs' && serveursModifie)
|
||
|| (vuePrincipale === 'applications' && applicationsModifie)
|
||
|| (vuePrincipale === 'bases' && basesModifie)
|
||
|| (vuePrincipale === 'domaines' && domainesModifie);
|
||
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-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 === '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">Devis réseau</div>
|
||
<div class="hint-dep">VLANs + SVIs (passerelles) + ACLs d'isolation inter-tenant, dérivés des nomenclatures <code>ip-miroir</code> (VLAN = 1000 + index×10 + zone, unique sur le trunk).</div>
|
||
<div class="hint-dep" style="margin-top:10px">Lecture seule, régénéré du plan — <code>make devis-reseau</code>. À adapter à ta plateforme (NX-OS/IOS-XE, HSRP, route par défaut vers OPNsense, port de trunk).</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>`;
|
||
}
|
||
}
|
||
|
||
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 = '';
|
||
async function dessinerReseau() {
|
||
const cible = document.getElementById('grilles');
|
||
cible.innerHTML = '<div class="vide" style="padding:14px">Génération du devis…</div>';
|
||
try {
|
||
const rep = await fetch('/api/devis-reseau', {headers: {'X-Jeton': JETON}});
|
||
const data = await rep.json();
|
||
if (!rep.ok) { cible.innerHTML = '<div class="vide" style="padding:14px">' + echapper(data.erreur || 'Indisponible.') + '</div>'; return; }
|
||
devisReseauTexte = data.config || '';
|
||
if (!devisReseauTexte) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucune instance fédérée (schéma ip-miroir) trouvée.</div>'; return; }
|
||
const tenants = (data.tenants || []).map(t => echapper(t.nom) + ' (idx ' + t.index + ')').join(' · ');
|
||
cible.innerHTML = `<div class="section-grille">
|
||
<div class="section-tete">Devis config réseau — switches
|
||
<span class="hint-dep" style="margin-left:auto">${tenants}</span>
|
||
<button type="button" class="fantome" style="margin-left:10px" onclick="copierDevis()">Copier</button></div>
|
||
<pre class="devis-reseau">${echapper(devisReseauTexte)}</pre></div>`;
|
||
} 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 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>`;
|
||
}
|
||
|
||
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>`;
|
||
}
|
||
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(', ')}));
|
||
applicationsModifie = false;
|
||
}
|
||
|
||
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; }
|
||
const source = a.portee === 'hote' ? hotes.map(h => h.nom).filter(Boolean) : a.portee === 'application' ? applications.map(x => x.id) : groupes;
|
||
const optConso = '<option value="">—</option>' + source.map(v => `<option value="${echapper(v)}" ${v === a.consommateur ? 'selected' : ''}>${echapper(v)}</option>`).join('');
|
||
const optPortee = ['groupe', 'application', 'hote'].map(p => `<option value="${p}" ${p === (a.portee || 'groupe') ? 'selected' : ''}>${p}</option>`).join('');
|
||
const optServeur = '<option value="">—</option>' + bdServeurs.map(x => `<option value="${echapper(x.nom)}" ${x.nom === a.serveur ? 'selected' : ''}>${echapper(x.nom)}</option>`).join('');
|
||
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>
|
||
<div class="grille">
|
||
<label class="champ"><span>Identifiant</span><input value="${echapper(a.cle)}" placeholder="bd_forgejo" oninput="definirBaseBd('cle', this.value)"></label>
|
||
<label class="champ"><span>Portée</span><select onchange="definirBaseBd('portee', this.value)">${optPortee}</select></label>
|
||
<label class="champ"><span>Consommateur</span><select onchange="definirBaseBd('consommateur', this.value)">${optConso}</select></label>
|
||
<label class="champ"><span>Serveur</span><select onchange="definirBaseBd('serveur', this.value)">${optServeur}</select></label>
|
||
<label class="champ"><span>Base</span><input value="${echapper(a.base)}" placeholder="forgejo" oninput="definirBaseBd('base', this.value)"></label>
|
||
<label class="champ"><span>Propriétaire</span><input value="${echapper(a.proprietaire)}" oninput="definirBaseBd('proprietaire', this.value)"></label>
|
||
<label class="champ"><span>Usage</span><input value="${echapper(a.usage)}" placeholder="principale" oninput="definirBaseBd('usage', this.value)"></label>
|
||
<label class="champ"><span>Secret (Vault)</span><input value="${echapper(a.secret)}" placeholder="vault_bd_app" oninput="definirBaseBd('secret', this.value)"></label>
|
||
</div>
|
||
<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 = {};
|
||
bdApplis.forEach(a => { if (String(a.cle).trim()) bd[a.cle] = {serveur: a.serveur, base: a.base, proprietaire: a.proprietaire, secret: a.secret, consommateur: a.consommateur, portee: a.portee || 'groupe', usage: a.usage || 'principale'}; });
|
||
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 === 'hote' || champ === 'groupe' || champ === 'port') dessinerDetail();
|
||
}
|
||
function ajouterApplication() {
|
||
applications.unshift({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: ''});
|
||
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 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" 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">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;
|
||
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>';
|
||
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)} · <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">${cases}</div></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 === 'applications') return inspecteurApplication();
|
||
if (vuePrincipale === 'bases') return inspecteurBase();
|
||
if (vuePrincipale === 'domaines') return inspecteurDomaine();
|
||
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;
|
||
return {categorie: d.categorie, service: d.service, libelle: c.libelle, vlan: c.vlan, sous_reseau: c.sous_reseau, passerelle: c.passerelle};
|
||
}
|
||
function echapper(valeur) {
|
||
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 = {};
|
||
schema.forEach(ch => {
|
||
if (!parSection[ch.section]) { parSection[ch.section] = []; sections.push(ch.section); }
|
||
parSection[ch.section].push(ch);
|
||
});
|
||
let html = '';
|
||
sections.forEach(sec => {
|
||
html += '<div class="intr-section"><div class="intr-tete">' + echapper(sec) + '</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 : <b>' + echapper(n.supernet) + '</b><br>';
|
||
if (n.domaine || n.domaine_interne) html += 'Domaine : <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 : <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') sauvegarderServeurs();
|
||
else if (vuePrincipale === 'applications') sauvegarderApplications();
|
||
else if (vuePrincipale === 'bases') sauvegarderBases();
|
||
else if (vuePrincipale === 'domaines') sauvegarderDomaines();
|
||
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>
|
||
"""
|
||
|
||
|
||
class Gestionnaire(BaseHTTPRequestHandler):
|
||
inventaire: Path = INVENTAIRE_DEFAUT
|
||
|
||
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:
|
||
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],
|
||
})
|
||
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))
|
||
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/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/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()
|
||
|
||
Gestionnaire.inventaire = args.inventaire.resolve()
|
||
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())
|