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 8f6de3ebe2 Construire l'ecosysteme de services et outiller l'inventaire
Registres (source unique):
- docs/nomenclature.yml: domaines, VMID, plan d'adressage 10.1.0.0/16 segmente.
- docs/bases-donnees.yml: bases applicatives (1 appli -> 1 base -> 1 owner -> 1 DSN).

Roles de service:
- Reseau/edge/PKI/mail: nginx, step_ca, sendmail.
- Donnees/identite: postgresql (consommateur du registre BD), redis, openldap, keycloak.
- Observabilite: prometheus, loki, grafana.
- Supervision: icinga (coeur; Icinga Web 2 differe). Forge: forgejo.

Integrations clientes:
- clients_metriques, clients_journaux, clients_pki, clients_ldap, clients_smtp.

Inventaire et outillage:
- make inventaire-ui: refonte (cartes, theme sombre, onglets, vue Chaine VM->groupes->
  playbooks->roles), saisie du provisioning, auto-proposition depuis la nomenclature,
  deploiement securise (verifier/deployer, jeton anti-CSRF, verrou, mot de passe vault),
  robustesse reseau (connexions fermees, favicon).
- Makefile: cible verifier-deploiement; detection d'un group_vars de production chiffre.
- Scission serveurs_web -> serveurs_web_frontaux/dorsaux; migration de l'adressage vers
  10.1.x; retrait des hotes de test; planification des hotes; requirements.yml.

Chaque role valide en --syntax-check et ansible-lint (profil production).
Secrets references depuis Ansible Vault (jamais en clair); roles non testes live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:06:11 -04:00

1231 lines
64 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 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,
charger_dependances,
charger_nomenclature,
est_groupe_operationnel,
groupes_operationnels_connus,
)
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"
# 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 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(),
"hotes": liste_hotes(data),
}
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; }
.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-chaine" onclick="setVue('chaine')">Chaîne</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 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 || {};
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-chaine').classList.toggle('on', v === 'chaine');
dessinerGrilles();
}
function dessinerGrilles() {
document.getElementById('chips').style.display = vuePrincipale === 'chaine' ? 'none' : '';
if (vuePrincipale === 'chaine') { dessinerArbre(); 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 renduChaine(hote) {
const grps = hote.groupes || [];
if (!grps.length) return '<div class="vide" style="padding:18px">Aucun groupe opérationnel.</div>';
return 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>';
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}
</div>`;
}).join('');
}
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 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 = infoDomaine(domaineDe(h.nom).domaine);
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="domaine-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 domaineDe(nom) {
const m = (nom || '').match(/^(.+)-(\d+)$/);
return m ? {domaine: m[1], seq: parseInt(m[2], 10)} : {domaine: nom || '', seq: null};
}
function infoDomaine(domaine) {
const d = (nomenclature.domaines || {})[domaine];
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(domaine) {
const pris = new Set();
hotes.forEach(h => { const x = domaineDe(h.nom); if (x.domaine === domaine && 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 {domaine, seq} = domaineDe(h.nom);
const info = infoDomaine(domaine);
if (!info) { if (!silencieux) message('Domaine inconnu de la nomenclature : ' + (domaine || '(vide)'), 'erreur'); return; }
if (seq == null) { seq = prochainSeqLibre(domaine); h.nom = domaine + '-' + String(seq).padStart(2, '0'); selectionNom = h.nom; }
if (seq > 9) { if (!silencieux) message('Séquence > 9 pour ' + domaine + ' : 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() && infoDomaine(domaineDe(h.nom).domaine)) 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/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())