This repository has been archived on 2026-06-26. You can view files and clone it, but cannot push or open issues or pull requests.
Set-OPS/scripts/inventory_gui.py
Daniel Allaire 1aa3da8de5 Corriger l'erreur de syntaxe JS qui cassait tout le GUI
dessinerServeurs contenait une apostrophe mal echappee (l\\'inventaire)
qui, une fois rendue, terminait prematurement la chaine JS : une seule
erreur de syntaxe fait tomber tout le script -> page inutilisable.
Remplace par l'apostrophe typographique (sans echappement).

Garde-fou : scripts/verifier_gui.py extrait le JS embarque et le passe a
`node --check` (saute si node absent), integre a make inventaire-verifier.
py_compile ne voyait pas le JS ; ce trou est desormais ferme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:28:52 -04:00

1541 lines
86 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
"""Interface web locale pour gerer un inventaire Set-OPS."""
from __future__ import annotations
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import argparse
import json
import os
import re
import secrets
import subprocess
import tempfile
import threading
from pathlib import Path
from urllib.parse import urlparse
import yaml
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,
)
RACINE = Path(__file__).resolve().parents[1]
INVENTAIRE_DEFAUT = RACINE / "inventories/production/hosts.yml"
INVENTAIRE_PRODUCTION = (RACINE / "inventories/production/hosts.yml").resolve()
DOSSIER_PLAYBOOKS_GROUPES = RACINE / "playbooks/groupes"
FICHIER_DEPENDANCES = RACINE / "docs/dependances-groupes.yml"
FICHIER_NOMENCLATURE = RACINE / "docs/nomenclature.yml"
FICHIER_BASES = RACINE / "docs/bases-donnees.yml"
FICHIER_APPLICATIONS = RACINE / "docs/applications.yml"
FICHIER_DOMAINES = RACINE / "docs/domaines.yml"
FICHIER_SERVEURS = RACINE / "docs/serveurs.yml"
# 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 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", ""),
"vmid": d.get("vmid", ""), "adresse_ip": d.get("adresse_ip", ""), "vlan": d.get("vlan", ""),
"statut": statut, "divergences": rec["divergences"],
})
return resultat
def inventaire_api(path: Path) -> dict:
data = charger_yaml(path)
dependencies = charger_dependances(FICHIER_DEPENDANCES)
return {
"inventaire": str(path.relative_to(RACINE)),
"production": path.resolve() == INVENTAIRE_PRODUCTION,
"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),
}
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 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, 1fr) minmax(330px, 380px); gap: 20px; align-items: start; max-width: 1640px; margin: 0 auto; }
.message { min-height: 18px; font-size: 13px; font-weight: 650; color: var(--bleu); margin: 0 2px 12px; }
.message.erreur { color: var(--rouge); } .message.ok { color: var(--vert); }
.chips-filtre { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 16px; }
.chip-f { padding: 6px 13px; border-radius: 999px; border: 1px solid var(--ligne-forte); background: var(--surface-2); color: var(--muted); font-weight: 700; font-size: 12.5px; cursor: pointer; display: inline-flex; align-items: center; gap: 7px; transition: all .14s; }
.chip-f:hover { border-color: var(--ligne-forte); color: var(--texte); }
.chip-f .n { font-size: 11px; opacity: .8; }
.chip-f.on { color: #07101f; border-color: transparent; }
.chip-f.on.tous { background: var(--teal); }
.chip-f.on.actif { background: var(--vert); }
.chip-f.on.planifie { background: var(--bleu); }
.chip-f.on.bloque { background: 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; }
.detail-nom input { font-weight: 800; font-size: 17px; border-color: transparent; background: transparent; padding: 4px 7px; }
.detail-nom input:hover { background: var(--surface-2); border-color: var(--ligne); }
.zone-hint { display: inline-block; margin: 4px 0 0 8px; font-size: 11px; font-weight: 700; color: var(--teal); }
.etat-bascule { display: inline-flex; border: 1px solid var(--ligne-forte); border-radius: 999px; padding: 2px; background: var(--surface-3); gap: 2px; flex: none; }
.etat-bascule button { border: none; background: transparent; border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 750; color: var(--muted); box-shadow: none; }
.etat-bascule button.on { background: var(--surface); color: var(--texte); }
.etat-bascule button.on.actif { color: var(--vert); }
.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; }
.onglets { display: flex; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--ligne); }
.onglet { padding: 9px 12px; background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-weight: 750; font-size: 13px; cursor: pointer; border-radius: 0; }
.onglet:hover { color: var(--texte); border-color: transparent; }
.onglet.actif { color: var(--texte); border-bottom-color: var(--teal); }
.onglet-corps { padding: 14px 16px; }
.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; }
.groupes-liste { display: grid; gap: 7px; }
.groupe { display: block; border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 8px 10px; background: var(--surface-2); cursor: pointer; transition: border-color .12s, background .12s; }
.groupe:hover { border-color: var(--ligne-forte); }
.groupe.selectionne { border-color: color-mix(in srgb, var(--bleu) 55%, var(--ligne)); background: var(--bleu-fond); }
.groupe.ok { border-color: color-mix(in srgb, var(--vert) 55%, var(--ligne)); background: var(--vert-fond); }
.groupe.attente { border-color: color-mix(in srgb, var(--ambre) 55%, var(--ligne)); background: var(--ambre-fond); }
.groupe.bloque { border-color: color-mix(in srgb, var(--rouge) 60%, var(--ligne)); background: var(--rouge-fond); }
.groupe-ligne { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 13px; overflow-wrap: anywhere; }
.groupe-ligne input { width: 15px; height: 15px; flex: none; accent-color: var(--teal); }
.groupe-detail { color: var(--muted); font-size: 11px; line-height: 1.35; margin: 4px 0 0 23px; }
.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); }
.arbre-vm { background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon); margin-bottom: 10px; box-shadow: var(--ombre); }
.arbre-vm > summary { list-style: none; cursor: pointer; padding: 11px 14px; display: flex; align-items: center; gap: 10px; font-size: 14px; }
.arbre-vm > summary::-webkit-details-marker { display: none; }
.arbre-vm > summary::before { content: "\25B8"; color: var(--faint); }
.arbre-vm[open] > summary::before { content: "\25BE"; }
.arbre-vm .ch-meta { color: var(--muted); font-size: 12px; margin-left: auto; }
.arbre-corps { padding: 2px 16px 14px 32px; display: grid; gap: 9px; }
.ch-grp { border-left: 2px solid var(--ligne-forte); padding: 3px 0 3px 12px; }
.ch-grp-tete { display: flex; align-items: center; gap: 7px; font-weight: 700; font-size: 13px; flex-wrap: wrap; }
.ch-fleche { color: var(--faint); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--teal); font-size: 12px; }
.ch-roles { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.ch-stub { color: var(--ambre); font-size: 11.5px; font-style: italic; margin-top: 5px; }
.bases-liste { display: grid; gap: 10px; }
.base-ligne { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)) auto; gap: 9px 11px; align-items: end; background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 11px 12px; box-shadow: var(--ombre); }
.base-ligne .danger { align-self: end; }
.base-ligne .dsn { grid-column: 1 / -1; color: var(--muted); font-size: 11.5px; padding-top: 2px; overflow-wrap: anywhere; }
.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; }
.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; }
}
</style>
</head>
<body>
<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">
<div class="vue-bascule">
<button type="button" id="btn-vue-cartes" class="on" onclick="setVue('cartes')">Inventaire</button>
<button type="button" id="btn-vue-serveurs" onclick="setVue('serveurs')">Serveurs</button>
<button type="button" id="btn-vue-chaine" onclick="setVue('chaine')">Chaîne</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>
</div>
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
<button type="button" class="fantome" onclick="ajouterHote()">+ Hôte</button>
<button type="button" class="fantome" onclick="charger()">Recharger</button>
<button type="button" class="primaire" id="btn-sauver" onclick="sauvegarder()">Sauvegarder</button>
</div>
</header>
<main>
<section>
<div class="message" id="message"></div>
<div class="chips-filtre" id="chips"></div>
<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="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>
<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 filtreEtat = 'tous';
let vuePrincipale = 'cartes';
let bdServeurs = [];
let bdApplis = [];
let basesModifie = false;
let applications = [];
let applicationsModifie = false;
let serveurs = [];
let selection = -1;
let selectionNom = null;
let ongletActif = 'reseau';
let modifie = false;
let occupe = false;
let verifie = {};
let motVault = '';
let modaleResolveur = null;
const ONGLETS = {
reseau: [
['adresse_ip', 'Adresse IP', {placeholder:'192.168.12.101'}],
['cidr', 'CIDR', {type:'number', placeholder:'24', min:0, max:32}],
['passerelle', 'Passerelle', {placeholder:'192.168.12.1'}],
['vlan', 'VLAN', {type:'number', placeholder:'12', min:1, max:4094}],
['pont', 'Pont', {placeholder:'vmbr0'}],
['dns', 'DNS', {placeholder:'192.168.12.1'}],
['utilisateur_ansible', 'Utilisateur Ansible', {placeholder:'ansible'}],
],
proxmox: [
['vmid', 'VMID', {type:'number', placeholder:'95301'}],
['noeud', 'Nœud', {placeholder:'pve-01'}],
['stockage', 'Stockage', {placeholder:'local-zfs'}],
['disque_taille', 'Disque', {placeholder:'32G', unite:'taille'}],
['memoire', 'Mémoire', {type:'number', placeholder:'2048', unite:'Mo'}],
['coeurs', 'Cœurs', {type:'number', placeholder:'2', min:1}],
],
};
function message(texte, type='') {
const el = document.getElementById('message');
el.textContent = texte;
el.className = 'message' + (type ? ' ' + type : '');
}
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 || {};
chargerBases(data);
chargerApplications(data);
serveurs = data.serveurs || [];
hotes = data.hotes;
estProduction = !!data.production;
modifie = false; verifie = {};
document.getElementById('chemin').textContent = data.inventaire;
restaurerSelection();
dessiner();
message('Inventaire chargé.', 'ok');
}
function restaurerSelection() {
if (selectionNom != null) {
const i = hotes.findIndex(h => h.nom === selectionNom);
selection = i >= 0 ? i : (hotes.length ? 0 : -1);
} else {
selection = hotes.length ? 0 : -1;
}
selectionNom = selection >= 0 ? hotes[selection].nom : null;
}
function selectionner(index) {
selection = index;
selectionNom = hotes[index] ? hotes[index].nom : null;
dessiner();
}
function marquerModifie() { modifie = true; verifie = {}; majIndicateurSauvegarde(); majBoutonsDeploiement(); }
function ajouterHote() {
hotes.unshift({nom:'', etat:'planifie', utilisateur_ansible:'ansible', groupes:['serveurs_debian','serveurs_durcis']});
selection = 0; selectionNom = ''; filtre = ''; filtreEtat = 'tous';
document.getElementById('filtre').value = '';
marquerModifie(); dessiner();
const champNom = document.querySelector('.detail-nom input');
if (champNom) champNom.focus();
}
function supprimerHote(index) {
const nom = hotes[index] && hotes[index].nom;
if (nom && !confirm('Retirer ' + nom + ' de l\'inventaire ?')) return;
hotes.splice(index, 1);
if (selection >= hotes.length) selection = hotes.length - 1;
selectionNom = selection >= 0 ? hotes[selection].nom : null;
marquerModifie(); dessiner();
}
function definir(index, champ, valeur) { hotes[index][champ] = valeur; marquerModifie(); }
function definirNom(index, valeur) {
hotes[index].nom = valeur; selectionNom = valeur;
const c = document.getElementById('carte-' + index);
if (c) { const n = c.querySelector('.carte-nom'); if (n) { n.textContent = valeur || '(sans nom)'; n.className = 'carte-nom' + (valeur ? '' : ' sans'); } }
marquerModifie();
}
function definirEtat(index, etat) { hotes[index].etat = etat; marquerModifie(); dessiner(); }
function basculerGroupe(index, groupe, actif) {
const ensemble = new Set(hotes[index].groupes || []);
if (actif) ensemble.add(groupe); else ensemble.delete(groupe);
hotes[index].groupes = Array.from(ensemble).sort();
marquerModifie(); dessiner();
}
function setOnglet(o) { ongletActif = o; dessinerDetail(); }
function setFiltreEtat(e) { filtreEtat = e; dessinerChips(); dessinerGrilles(); }
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 statutHote(h) { return h.etat === 'actif' ? (hoteBloque(h) ? 'bloque' : 'ok') : 'attente'; }
function libelleStatut(s) { return {ok:'actif', attente:'planifié', bloque:'bloqué', neutre:'libre'}[s] || s; }
function abrege(g) { return g.replace(/^serveurs_/, '').replace(/^clients_/, 'cli·'); }
function dessiner() { dessinerResume(); dessinerChips(); dessinerGrilles(); dessinerDependances(); dessinerDetail(); majIndicateurSauvegarde(); }
function majIndicateurSauvegarde() {
const b = document.getElementById('btn-sauver');
if (b) { b.classList.toggle('attention', modifie); b.textContent = modifie ? 'Sauvegarder •' : 'Sauvegarder'; }
}
function dessinerResume() {
const actifs = hotes.filter(h => h.etat === 'actif').length;
const bloques = hotes.filter(h => hoteBloque(h)).length;
document.getElementById('resume').innerHTML = `
<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>` : ''}`;
}
function dessinerChips() {
const n = {tous: hotes.length, actif: 0, planifie: 0, bloque: 0};
hotes.forEach(h => { if (h.etat === 'actif') n.actif++; else n.planifie++; if (hoteBloque(h)) n.bloque++; });
const defs = [['tous', 'Tous'], ['actif', 'Actifs'], ['planifie', 'Planifiés'], ['bloque', 'Bloqués']];
document.getElementById('chips').innerHTML = defs.map(([cle, lib]) =>
`<button type="button" class="chip-f ${cle} ${filtreEtat === cle ? 'on' : ''}" onclick="setFiltreEtat('${cle}')">${lib}<span class="n">${n[cle]}</span></button>`
).join('');
}
function correspond(h) {
if (filtreEtat === 'actif' && h.etat !== 'actif') return false;
if (filtreEtat === 'planifie' && h.etat === 'actif') return false;
if (filtreEtat === 'bloque' && !hoteBloque(h)) return false;
if (filtre && ![h.nom, h.adresse_ip, h.vmid, ...(h.groupes || [])].join(' ').toLowerCase().includes(filtre)) return false;
return true;
}
function carte(h, index) {
const st = statutHote(h);
const cls = st === 'bloque' ? 'bloque' : (h.etat === 'actif' ? 'actif' : 'attente');
const grps = h.groupes || [];
const tags = grps.slice(0, 3).map(g => `<span class="tag">${echapper(abrege(g))}</span>`).join('')
+ (grps.length > 3 ? `<span class="tag plus">+${grps.length - 3}</span>` : '');
return `
<button type="button" class="carte ${cls} ${index === selection ? 'selectionnee' : ''}" id="carte-${index}" onclick="selectionner(${index})">
<div class="carte-haut">
<span class="badge-statut ${st}">${libelleStatut(st)}</span>
${h.vmid ? `<span class="carte-vmid">#${echapper(h.vmid)}</span>` : ''}
</div>
<div class="carte-nom ${h.nom ? '' : 'sans'}">${echapper(h.nom || '(sans nom)')}</div>
<div class="carte-ip">${echapper(h.adresse_ip || '')}</div>
${tags ? `<div class="carte-grps">${tags}</div>` : ''}
</button>`;
}
function setVue(v) {
vuePrincipale = v;
document.getElementById('btn-vue-cartes').classList.toggle('on', v === 'cartes');
document.getElementById('btn-vue-serveurs').classList.toggle('on', v === 'serveurs');
document.getElementById('btn-vue-chaine').classList.toggle('on', v === 'chaine');
document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications');
document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases');
dessinerGrilles();
}
function dessinerGrilles() {
document.getElementById('chips').style.display = vuePrincipale === 'cartes' ? '' : 'none';
if (vuePrincipale === 'serveurs') { dessinerServeurs(); return; }
if (vuePrincipale === 'chaine') { dessinerArbre(); return; }
if (vuePrincipale === 'applications') { dessinerApplications(); return; }
if (vuePrincipale === 'bases') { dessinerBases(); return; }
const cible = document.getElementById('grilles');
if (!hotes.length) { cible.innerHTML = '<div class="vide"><b>Inventaire vide</b>Ajoutez un hôte pour commencer.</div>'; return; }
const visibles = hotes.map((h, i) => [h, i]).filter(([h]) => correspond(h));
if (!visibles.length) { cible.innerHTML = '<div class="vide">Aucun hôte ne correspond au filtre.</div>'; return; }
const sections = [['Actifs', visibles.filter(([h]) => h.etat === 'actif')], ['Planifiés', visibles.filter(([h]) => h.etat !== 'actif')]];
cible.innerHTML = sections.filter(([, l]) => l.length).map(([titre, liste]) => `
<div class="section-grille">
<div class="section-tete">${titre} <span class="compte">${liste.length}</span></div>
<div class="grille-cartes">${liste.map(([h, i]) => carte(h, i)).join('')}</div>
</div>`).join('');
}
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 lignesDsn(liste, etiquette) {
return liste.length ? `<div class="ch-bases">${liste.map(a => `
<div class="ch-base"><span class="badge-bd">${echapper(etiquette(a))}</span> <span class="mono">${echapper(dsnBase(a))}</span></div>`).join('')}</div>` : '';
}
function renduChaine(hote) {
const grps = hote.groupes || [];
const apps = applications.filter(a => a.hote === hote.nom);
if (!grps.length && !apps.length) return '<div class="vide" style="padding:18px">Aucun groupe opérationnel ni application.</div>';
const grpHtml = grps.map(g => {
const c = chaine[g] || {roles: [], stub: true};
const corps = (c.roles && c.roles.length)
? `<div class="ch-roles">${c.roles.map(r => `<span class="tag">${echapper(r)}</span>`).join('')}</div>`
: '<div class="ch-stub">stub — aucun rôle rattaché</div>';
const bg = bdApplis.filter(a => (a.portee || 'groupe') === 'groupe' && a.consommateur === g);
return `<div class="ch-grp">
<div class="ch-grp-tete"><span>${echapper(g)}</span><span class="ch-fleche">→</span><span class="mono">${echapper(g)}.yml</span></div>
${corps}
${lignesDsn(bg, a => 'BD partagée ' + (a.usage || ''))}
</div>`;
}).join('');
const ligneLien = (etiquette, valeur) => `<div class="ch-base"><span class="badge-bd">${etiquette}</span> <span class="mono">${echapper(valeur)}</span></div>`;
const appHtml = apps.map(app => {
const dsns = basesDeApp(app, hote.nom);
const liens = [];
if (String(app.expose || '').trim()) liens.push(ligneLien('expose', app.expose));
if (String(app.requiert || '').trim()) liens.push(ligneLien('requiert', app.requiert));
dsns.forEach(a => liens.push(`<div class="ch-base"><span class="badge-bd">BD ${echapper((a.portee || 'groupe') + ' · ' + (a.usage || ''))}</span> <span class="mono">${echapper(dsnBase(a))}</span></div>`));
const corps = liens.length ? `<div class="ch-bases">${liens.join('')}</div>` : '<div class="ch-stub">aucun lien</div>';
const port = String(app.port || '').trim() ? ` <span class="ch-meta">:${echapper(app.port)}</span>` : '';
return `<div class="ch-grp">
<div class="ch-grp-tete"><span class="badge-bd">app</span><span>${echapper(app.id)}</span>${port}<span class="ch-fleche">→</span><span class="mono">${echapper(app.groupe)}</span></div>
${corps}
</div>`;
}).join('');
const appSection = apps.length ? `<div class="section-tete" style="margin:6px 2px 2px">Applications sur cet hôte <span class="compte">${apps.length}</span></div>${appHtml}` : '';
return grpHtml + appSection;
}
function dessinerArbre() {
const cible = document.getElementById('grilles');
const visibles = hotes.map((h, i) => [h, i]).filter(([h]) => correspond(h));
if (!visibles.length) { cible.innerHTML = '<div class="vide">Aucun hôte.</div>'; return; }
cible.innerHTML = visibles.map(([h]) => {
const st = statutHote(h);
const cls = st === 'bloque' ? 'bloq' : (h.etat === 'actif' ? 'actif' : 'plan');
return `<details class="arbre-vm">
<summary><span class="pt ${cls}"></span><b>${echapper(h.nom || '(sans nom)')}</b>
<span class="badge-statut ${st}">${libelleStatut(st)}</span>
<span class="ch-meta">${(h.groupes || []).length} groupes</span></summary>
<div class="arbre-corps">${renduChaine(h)}</div>
</details>`;
}).join('');
}
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 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;
const b = document.getElementById('btn-sauver-bases');
if (b) { b.classList.toggle('attention', true); b.textContent = 'Sauvegarder les bases •'; }
}
function definirServeurBd(i, champ, v) { bdServeurs[i][champ] = v; marquerBasesModifie(); }
function definirBaseBd(i, champ, v) { bdApplis[i][champ] = v; marquerBasesModifie(); if (champ === 'serveur' || champ === 'portee') dessinerBases(); }
function ajouterServeurBd() { bdServeurs.push({nom: '', type: 'postgres', hote: '', port: 5432, groupe: ''}); marquerBasesModifie(); dessinerBases(); }
function retirerServeurBd(i) { bdServeurs.splice(i, 1); marquerBasesModifie(); dessinerBases(); }
function ajouterBaseBd() { bdApplis.push({cle: '', serveur: (bdServeurs[0] || {}).nom || '', base: '', proprietaire: '', secret: '', consommateur: '', portee: 'groupe', usage: 'principale'}); marquerBasesModifie(); dessinerBases(); }
function retirerBaseBd(i) { bdApplis.splice(i, 1); marquerBasesModifie(); dessinerBases(); }
function dessinerBases() {
const cible = document.getElementById('grilles');
const serveurs = bdServeurs.length ? bdServeurs.map((s, i) => `
<div class="base-ligne">
<label class="champ"><span>Nom</span><input value="${echapper(s.nom)}" oninput="definirServeurBd(${i}, 'nom', this.value)"></label>
<label class="champ"><span>Type</span><input value="${echapper(s.type)}" oninput="definirServeurBd(${i}, 'type', this.value)"></label>
<label class="champ"><span>Hôte</span><input value="${echapper(s.hote)}" oninput="definirServeurBd(${i}, 'hote', this.value)"></label>
<label class="champ"><span>Port</span><input type="number" value="${echapper(s.port)}" oninput="definirServeurBd(${i}, 'port', this.value)"></label>
<label class="champ"><span>Groupe Ansible</span><input value="${echapper(s.groupe)}" placeholder="serveurs_postgresql" oninput="definirServeurBd(${i}, 'groupe', this.value)"></label>
<button type="button" class="danger" title="Retirer" onclick="retirerServeurBd(${i})">✕</button>
</div>`).join('') : '<div class="vide" style="padding:14px">Aucun serveur de BD.</div>';
const sourceConso = (portee) => portee === 'hote' ? hotes.map(h => h.nom).filter(Boolean)
: portee === 'application' ? applications.map(a => a.id)
: groupes;
const optConso = (portee, sel) => '<option value="">—</option>' + sourceConso(portee).map(v => `<option value="${echapper(v)}" ${v === sel ? 'selected' : ''}>${echapper(v)}</option>`).join('');
const optPortee = (sel) => ['groupe', 'application', 'hote'].map(p => `<option value="${p}" ${p === sel ? 'selected' : ''}>${p}</option>`).join('');
const applis = bdApplis.length ? bdApplis.map((a, i) => `
<div class="base-ligne">
<label class="champ"><span>Identifiant</span><input value="${echapper(a.cle)}" oninput="definirBaseBd(${i}, 'cle', this.value)"></label>
<label class="champ"><span>Portée</span><select onchange="definirBaseBd(${i}, 'portee', this.value)">${optPortee(a.portee || 'groupe')}</select></label>
<label class="champ"><span>Consommateur</span><select onchange="definirBaseBd(${i}, 'consommateur', this.value)">${optConso(a.portee || 'groupe', a.consommateur)}</select></label>
<label class="champ"><span>Usage</span><input value="${echapper(a.usage)}" placeholder="principale" oninput="definirBaseBd(${i}, 'usage', this.value)"></label>
<label class="champ"><span>Serveur</span><select onchange="definirBaseBd(${i}, 'serveur', this.value)">
${bdServeurs.map(s => `<option value="${echapper(s.nom)}" ${s.nom === a.serveur ? 'selected' : ''}>${echapper(s.nom)}</option>`).join('')}
</select></label>
<label class="champ"><span>Base</span><input value="${echapper(a.base)}" oninput="definirBaseBd(${i}, 'base', this.value)"></label>
<label class="champ"><span>Propriétaire</span><input value="${echapper(a.proprietaire)}" oninput="definirBaseBd(${i}, 'proprietaire', this.value)"></label>
<label class="champ"><span>Secret (Vault)</span><input value="${echapper(a.secret)}" placeholder="vault_bd_app" oninput="definirBaseBd(${i}, 'secret', this.value)"></label>
<button type="button" class="danger" title="Retirer" onclick="retirerBaseBd(${i})">✕</button>
<div class="dsn mono">${echapper(a.portee || 'groupe')} · ${echapper(a.consommateur || '')} · ${echapper(dsnBase(a))}</div>
</div>`).join('') : '<div class="vide" style="padding:14px">Aucune base applicative.</div>';
cible.innerHTML = `
<div class="section-grille">
<div class="section-tete">Serveurs de bases de données <button type="button" class="fantome" style="margin-left:auto" onclick="ajouterServeurBd()">+ Serveur</button></div>
<div class="bases-liste">${serveurs}</div>
</div>
<div class="section-grille">
<div class="section-tete">Bases applicatives <button type="button" class="fantome" style="margin-left:auto" onclick="ajouterBaseBd()">+ Base</button></div>
<div class="bases-liste">${applis}</div>
</div>
<div style="margin:4px 2px 20px"><button type="button" class="primaire ${basesModifie ? 'attention' : ''}" id="btn-sauver-bases" onclick="sauvegarderBases()">Sauvegarder les bases${basesModifie ? '' : ''}</button></div>`;
}
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;
const b = document.getElementById('btn-sauver-applications');
if (b) { b.classList.add('attention'); b.textContent = 'Sauvegarder les applications •'; }
}
function definirApplication(i, champ, v) { applications[i][champ] = v; marquerApplicationsModifie(); if (champ === 'hote' || champ === 'groupe') dessinerApplications(); }
function ajouterApplication() { applications.push({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: ''}); marquerApplicationsModifie(); dessinerApplications(); }
function retirerApplication(i) { applications.splice(i, 1); marquerApplicationsModifie(); dessinerApplications(); }
function dessinerApplications() {
const cible = document.getElementById('grilles');
const optGroupe = (sel) => groupes.map(g => `<option value="${echapper(g)}" ${g === sel ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const optHote = (sel) => '<option value="">—</option>' + hotes.map(h => h.nom).filter(Boolean).map(n => `<option value="${echapper(n)}" ${n === sel ? 'selected' : ''}>${echapper(n)}</option>`).join('');
const lignes = applications.length ? applications.map((a, i) => {
const dsns = basesDeApp(a, a.hote);
const resume = dsns.length ? dsns.map(b => `<span class="mono">${echapper((b.portee || 'groupe'))}:${echapper(dsnBase(b))}</span>`).join('<br>') : '<span class="ch-stub">aucune base liée</span>';
return `<div class="base-ligne">
<label class="champ"><span>Identifiant</span><input value="${echapper(a.id)}" placeholder="boutique" oninput="definirApplication(${i}, 'id', this.value)"></label>
<label class="champ"><span>Groupe (rôle)</span><select onchange="definirApplication(${i}, 'groupe', this.value)">${optGroupe(a.groupe)}</select></label>
<label class="champ"><span>Hôte (VM)</span><select onchange="definirApplication(${i}, 'hote', this.value)">${optHote(a.hote)}</select></label>
<label class="champ"><span>Port</span><input type="number" value="${echapper(a.port)}" placeholder="3000" oninput="definirApplication(${i}, 'port', this.value)"></label>
<label class="champ"><span>Requiert (applis)</span><input value="${echapper(a.requiert)}" placeholder="pg-principal, …" oninput="definirApplication(${i}, 'requiert', this.value)"></label>
<label class="champ"><span>Expose (FQDN)</span><input value="${echapper(a.expose)}" placeholder="forge.alliance-boreale.ca" oninput="definirApplication(${i}, 'expose', this.value)"></label>
<button type="button" class="danger" title="Retirer" onclick="retirerApplication(${i})">✕</button>
<div class="dsn">${resume}</div>
</div>`;
}).join('') : '<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>
<div class="bases-liste">${lignes}</div>
</div>
<div style="margin:4px 2px 20px"><button type="button" class="primaire ${applicationsModifie ? 'attention' : ''}" id="btn-sauver-applications" onclick="sauvegarderApplications()">Sauvegarder les applications${applicationsModifie ? '' : ''}</button></div>`;
}
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 dessinerServeurs() {
const cible = document.getElementById('grilles');
if (!serveurs.length) { cible.innerHTML = '<div class="vide"><b>Aucun serveur dans le plan</b>Lance « make serveurs-bootstrap » pour générer docs/serveurs.yml depuis linventaire.</div>'; return; }
const clsStatut = (s) => s === 'reconcilie' ? 'actif' : (s === 'divergence' ? 'bloque' : 'attente');
const libStatut = (s) => s === 'reconcilie' ? 'réconcilié' : (s === 'divergence' ? 'divergence' : 'absent inv.');
const lignes = serveurs.map(s => `
<div class="base-ligne">
<label class="champ"><span>Serveur</span><span class="mono">${echapper(s.nom)}</span></label>
<label class="champ"><span>Fonction</span><span>${echapper(s.fonction)}</span></label>
<label class="champ"><span>État</span><span>${echapper(s.etat || '')}</span></label>
<label class="champ"><span>VMID (dérivé)</span><span class="mono">${echapper(s.vmid)}</span></label>
<label class="champ"><span>IP (dérivée)</span><span class="mono">${echapper(s.adresse_ip)}</span></label>
<label class="champ"><span>VLAN</span><span class="mono">${echapper(s.vlan)}</span></label>
<label class="champ"><span>Placement</span><span>${echapper([s.noeud, s.stockage].filter(Boolean).join(' / ') || '')}</span></label>
<label class="champ"><span>Taille</span><span>${echapper([s.disque, s.memoire && (s.memoire + 'Mo'), s.coeurs && (s.coeurs + 'c')].filter(Boolean).join(' · ') || '')}</span></label>
<span class="badge-statut ${clsStatut(s.statut)}">${libStatut(s.statut)}</span>
${(s.divergences && s.divergences.length) ? `<div class="dsn mono">${s.divergences.map(echapper).join(' ; ')}</div>` : ''}
</div>`).join('');
const nbDiv = serveurs.filter(s => s.statut !== 'reconcilie').length;
cible.innerHTML = `
<div class="section-grille">
<div class="section-tete">Serveurs du plan <span class="compte">${serveurs.length}</span>${nbDiv ? ' · ' + nbDiv + ' non réconcilié(s)' : ' · tous réconciliés'}</div>
<div class="bases-liste">${lignes}</div>
<div class="dsn" style="padding:8px 2px;color:#94a3b8">Lecture seule (Phase 2). VMID / IP / VLAN sont <b>dérivés</b> de la fonction via la nomenclature ; « réconcilié » = identique à l'inventaire. La génération de l'inventaire depuis le plan viendra en Phase 3.</div>
</div>`;
}
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 champ(index, cle, libelle, opts={}) {
const h = hotes[index];
const type = opts.type || 'text';
const attrs = [`type="${type}"`, `value="${echapper(h[cle] != null ? h[cle] : '')}"`,
opts.placeholder ? `placeholder="${echapper(opts.placeholder)}"` : '',
opts.min != null ? `min="${opts.min}"` : '', opts.max != null ? `max="${opts.max}"` : '',
type === 'number' ? 'inputmode="numeric"' : ''].filter(Boolean).join(' ');
const unite = opts.unite ? ` <span class="unite">· ${echapper(opts.unite)}</span>` : '';
return `<label class="champ"><span>${echapper(libelle)}${unite}</span>
<input ${attrs} oninput="definir(${index}, '${cle}', this.value)"></label>`;
}
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() {
const cible = document.getElementById('detail');
if (selection < 0 || !hotes[selection]) {
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun hôte sélectionné</b>Choisissez une carte, ou ajoutez un hôte.</div></div>';
return;
}
const index = selection, h = hotes[index];
const actif = h.etat === 'actif';
const info = infoFonction(fonctionDe(h.nom).fonction);
const zoneHint = info ? `<span class="zone-hint" title="Dérivé de la nomenclature">${echapper(info.libelle)} · VLAN ${info.vlan}</span>` : '';
const st = statutHote(h);
const libelle = actif ? (hoteBloque(h) ? 'prérequis manquant' : 'déployable') : 'planifié';
let pied;
if (actif && !hoteBloque(h) && estProduction) {
pied = `<div class="pied"><span class="grandit"></span>
<button type="button" id="btn-verifier" onclick="lancer('verifier', ${index})">Vérifier</button>
<button type="button" id="btn-deployer" class="primaire" onclick="lancer('deployer', ${index})">Déployer</button></div>`;
} else {
const txt = !actif ? 'Hôte actif requis pour déployer' : (!estProduction ? 'Déploiement : inventaire production requis' : 'Prérequis manquant');
pied = `<div class="pied"><span class="hint-dep">${txt}</span><span class="grandit"></span><span class="badge-statut ${st}">${libelle}</span></div>`;
}
const onglet = ONGLETS[ongletActif];
let corps;
if (onglet) {
corps = `<div class="grille">${onglet.map(([c, l, o]) => champ(index, c, l, o)).join('')}</div>`;
} else if (ongletActif === 'chaine') {
corps = renduChaine(h);
} else {
corps = `<div class="groupes-liste">${groupes.map(g => renduGroupe(index, h, g)).join('')}</div>`;
}
cible.innerHTML = `
<article class="detail ${hoteBloque(h) ? 'bloque' : ''}">
<div class="detail-tete">
<div class="detail-nom"><input value="${echapper(h.nom || '')}" placeholder="fonction-NN (ex. web-frontal-03)" oninput="definirNom(${index}, this.value)" onchange="autoProposer(${index})">${zoneHint}</div>
<button type="button" class="fantome" title="Proposer VMID, VLAN, IP et passerelle depuis la nomenclature" onclick="proposer(${index})">✨ Proposer</button>
<div class="etat-bascule">
<button type="button" class="${actif ? 'on actif' : ''}" onclick="definirEtat(${index}, 'actif')">actif</button>
<button type="button" class="${!actif ? 'on' : ''}" onclick="definirEtat(${index}, 'planifie')">planifié</button>
</div>
<button type="button" class="danger" title="Retirer" onclick="supprimerHote(${index})">✕</button>
</div>
<div class="stats">
${tuile(h.vmid, 'VMID')}
${tuile(h.memoire ? h.memoire + ' Mo' : '', 'Mémoire')}
${tuile(h.coeurs, 'Cœurs')}
${tuile(h.disque_taille, 'Disque')}
${tuile((h.groupes || []).length, 'Groupes')}
</div>
<div class="onglets">
<button type="button" class="onglet ${ongletActif === 'reseau' ? 'actif' : ''}" onclick="setOnglet('reseau')">Réseau</button>
<button type="button" class="onglet ${ongletActif === 'proxmox' ? 'actif' : ''}" onclick="setOnglet('proxmox')">Proxmox</button>
<button type="button" class="onglet ${ongletActif === 'groupes' ? 'actif' : ''}" onclick="setOnglet('groupes')">Groupes</button>
<button type="button" class="onglet ${ongletActif === 'chaine' ? 'actif' : ''}" onclick="setOnglet('chaine')">Chaîne</button>
</div>
<div class="onglet-corps">${corps}</div>
${pied}
</article>`;
majBoutonsDeploiement();
}
function majBoutonsDeploiement() {
const bv = document.getElementById('btn-verifier');
const bd = document.getElementById('btn-deployer');
const hote = selection >= 0 && hotes[selection] ? (hotes[selection].nom || '') : '';
if (bv) { bv.disabled = occupe || modifie; bv.title = modifie ? 'Sauvegardez d\'abord' : (occupe ? 'Exécution en cours' : 'Dry-run (--check --diff), aucune modification'); }
if (bd) { bd.disabled = occupe || modifie || !verifie[hote]; bd.title = modifie ? 'Sauvegardez d\'abord' : (!verifie[hote] ? 'Vérifiez (dry-run) d\'abord' : 'Applique les playbooks sur la VM réelle'); }
}
function renduGroupe(index, hote, groupe) {
const selectionne = (hote.groupes || []).includes(groupe);
const manquants = manquantsPourGroupe(groupe);
const config = dependances[groupe] || {};
let statut = selectionne ? 'selectionne' : '';
if (selectionne && hote.etat === 'actif' && manquants.length) statut = 'bloque';
else if (selectionne && hote.etat === 'actif') statut = 'ok';
else if (selectionne && hote.etat !== 'actif' && (config.requiert_groupes_actifs || []).length) statut = 'attente';
return `
<label class="groupe ${statut}" title="${echapper(titreGroupe(groupe))}">
<span class="groupe-ligne">
<input type="checkbox" ${selectionne ? 'checked' : ''} onchange="basculerGroupe(${index}, '${groupe}', this.checked)">
<span>${echapper(groupe)}</span>
</span>
<span class="groupe-detail">${echapper(detailGroupe(groupe, hote))}</span>
</label>`;
}
function detailGroupe(groupe, hote) {
const requis = ((dependances[groupe] || {}).requiert_groupes_actifs || []);
if (!requis.length) return 'aucun prérequis actif';
const manquants = manquantsPourGroupe(groupe);
if (!manquants.length) return `prérequis actifs : ${requis.join(', ')}`;
if (hote.etat === 'actif') return `manquant : ${manquants.join(', ')}`;
return `requerra : ${requis.join(', ')}`;
}
function titreGroupe(groupe) {
const config = dependances[groupe] || {};
const requis = config.requiert_groupes_actifs || [];
const lignes = [requis.length ? `Prérequis actif : ${requis.join(', ')}` : 'Aucun prérequis actif déclaré.'];
if (config.raison) lignes.push(config.raison);
if (config.surveillance) lignes.push(config.surveillance);
return lignes.join('\n');
}
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 prochainSeqLibre(fonction) {
const pris = new Set();
hotes.forEach(h => { const x = fonctionDe(h.nom); if (x.fonction === fonction && x.seq != null) pris.add(x.seq); });
let s = 1; while (pris.has(s)) s++; return s;
}
function proposer(index, silencieux=false) {
const h = hotes[index];
let {fonction, seq} = fonctionDe(h.nom);
const info = infoFonction(fonction);
if (!info) { if (!silencieux) message('Fonction inconnue de la nomenclature : ' + (fonction || '(vide)'), 'erreur'); return; }
if (seq == null) { seq = prochainSeqLibre(fonction); h.nom = fonction + '-' + String(seq).padStart(2, '0'); selectionNom = h.nom; }
if (seq > 9) { if (!silencieux) message('Séquence > 9 pour ' + fonction + ' : hors plage d\'adressage, à saisir à la main.', 'erreur'); return; }
const base3 = info.sous_reseau.split('.').slice(0, 3).join('.');
h.vmid = '9' + info.categorie + info.service + String(seq).padStart(2, '0');
h.vlan = String(info.vlan);
h.adresse_ip = base3 + '.' + (info.service * 10 + seq);
h.passerelle = info.passerelle;
h.cidr = String(nomenclature.cidr_hote || 24);
marquerModifie(); dessiner();
if (!silencieux) message('Valeurs proposées pour ' + h.nom + ' (' + info.libelle + ', VLAN ' + info.vlan + ').', 'ok');
}
function autoProposer(index) {
const h = hotes[index];
if (!String(h.vmid || '').trim() && infoFonction(fonctionDe(h.nom).fonction)) proposer(index, true);
}
function echapper(valeur) {
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function ouvrirConsole(titre, hote) {
document.getElementById('console-titre').textContent = titre + ' · ' + hote;
document.getElementById('console-sortie').textContent = '';
const 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;
}
function ecrireConsole(t) { const p = document.getElementById('console-sortie'); p.textContent += t; p.scrollTop = p.scrollHeight; }
function finirConsole(rc) {
const etat = document.getElementById('console-etat');
etat.className = 'console-etat ' + (rc === 0 ? 'ok' : 'echec');
etat.textContent = rc === 0 ? '✓ succès' : '✗ échec (rc=' + rc + ')';
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);
}
async function lancer(mode, index) {
const hote = hotes[index] ? hotes[index].nom : '';
if (!hote) { message('Nommez l\'hôte d\'abord.', 'erreur'); return; }
if (modifie) { message('Sauvegardez avant d\'exécuter.', 'erreur'); return; }
if (occupe) return;
let vault = '';
if (mode === 'deployer') {
if (!verifie[hote]) { message('Vérifiez (dry-run) d\'abord.', 'erreur'); return; }
vault = await ouvrirModale('Déployer ' + hote,
'Applique les playbooks de groupes sur la VM réelle, en root (sudo). Action non transactionnelle : pas de rollback automatique en cas d\'échec.');
if (vault === null) return;
motVault = vault;
}
occupe = true; majBoutonsDeploiement();
ouvrirConsole(mode === 'deployer' ? 'Déploiement' : 'Vérification (dry-run)', 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;
while (true) {
const {value, done} = await lecteur.read();
if (done) break;
let texte = dec.decode(value, {stream: true});
const m = texte.match(/__FIN__ rc=(-?\d+)/);
if (m) { rc = parseInt(m[1], 10); texte = texte.replace(/\n?__FIN__ rc=-?\d+\n?/, ''); }
if (texte) ecrireConsole(texte);
}
if (rc === null) rc = 1;
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 === 'deployer') { verifie[hote] = false; message(rc === 0 ? 'Déploiement terminé.' : 'Déploiement en échec.', rc === 0 ? 'ok' : 'erreur'); }
} catch (e) {
ecrireConsole('Erreur client : ' + e.message + '\n'); finirConsole(1);
}
occupe = false; majBoutonsDeploiement();
}
async function sauvegarder() {
const reponse = await fetch('/api/inventaire', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({hotes})
});
const data = await reponse.json();
if (!reponse.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
dependances = data.dependances || {};
nomenclature = data.nomenclature || {};
chaine = data.chaine || {};
groupes = data.groupes;
hotes = data.hotes;
estProduction = !!data.production;
modifie = false; verifie = {};
restaurerSelection();
dessiner();
message('Inventaire sauvegardé.', 'ok');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modaleResolveur) { annulerModale(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (!occupe) sauvegarder(); }
});
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
if self.inventaire != INVENTAIRE_PRODUCTION:
self.repondre_json(409, {"erreur": "Deploiement reserve a l'inventaire production."})
return
if 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
try:
cible = "deployer" if mode == "deployer" else "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()
self._ecrire_flux(f"$ make {cible} HOTE={hote}\n\n")
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:
if not self._ecrire_flux(ligne):
proc.terminate()
break
finally:
rc = proc.wait()
if mode == "verifier" and rc == 0:
VERIF_OK[hote] = True
elif mode == "deployer":
VERIF_OK.pop(hote, None)
self._ecrire_flux(f"\n__FIN__ rc={rc}\n")
finally:
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))
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":
courant = charger_yaml(self.inventaire)
groupes_connus = groupes_disponibles(courant)
dependencies = charger_dependances(FICHIER_DEPENDANCES)
valider_payload(donnees, groupes_connus, dependencies)
nouveau = construire_inventaire(donnees, groupes_connus)
ecrire_yaml(self.inventaire, nouveau)
VERIF_OK.clear()
self.repondre_json(200, inventaire_api(self.inventaire))
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/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"))
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())