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>
1541 lines
86 KiB
Python
1541 lines
86 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,
|
||
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 l’inventaire.</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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())
|