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>
1231 lines
64 KiB
Python
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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())
|