605 lines
25 KiB
Python
605 lines
25 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
|
|
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,
|
|
est_groupe_operationnel,
|
|
groupes_operationnels_connus,
|
|
)
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
INVENTAIRE_DEFAUT = RACINE / "inventories/production/hosts.yml"
|
|
DOSSIER_PLAYBOOKS_GROUPES = RACINE / "playbooks/groupes"
|
|
FICHIER_DEPENDANCES = RACINE / "docs/dependances-groupes.yml"
|
|
|
|
|
|
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 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)
|
|
hotes.append(
|
|
{
|
|
"nom": nom,
|
|
"etat": etat,
|
|
"adresse_ip": variables.get("ansible_host", ""),
|
|
"utilisateur_ansible": variables.get("ansible_user", "ansible"),
|
|
"vmid": variables.get("proxmox_vmid", ""),
|
|
"groupes": sorted(groupes),
|
|
}
|
|
)
|
|
return hotes
|
|
|
|
|
|
def inventaire_api(path: Path) -> dict:
|
|
data = charger_yaml(path)
|
|
dependencies = charger_dependances(FICHIER_DEPENDANCES)
|
|
return {
|
|
"inventaire": str(path.relative_to(RACINE)),
|
|
"groupes": groupes_disponibles(data),
|
|
"dependances": dependencies,
|
|
"hotes": liste_hotes(data),
|
|
}
|
|
|
|
|
|
def donnees_hote(hote: dict) -> dict:
|
|
variables: dict = {}
|
|
adresse_ip = str(hote.get("adresse_ip", "")).strip()
|
|
utilisateur = str(hote.get("utilisateur_ansible", "")).strip()
|
|
vmid = str(hote.get("vmid", "")).strip()
|
|
if adresse_ip:
|
|
variables["ansible_host"] = adresse_ip
|
|
if utilisateur:
|
|
variables["ansible_user"] = utilisateur
|
|
if vmid:
|
|
variables["proxmox_vmid"] = int(vmid) if vmid.isdigit() else vmid
|
|
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)}")
|
|
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 - Inventaire</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--fond:#f4f5f2;
|
|
--surface:#ffffff;
|
|
--surface2:#f9faf7;
|
|
--texte:#202124;
|
|
--muted:#62675f;
|
|
--ligne:#d8dad2;
|
|
--vert:#2f6f4e;
|
|
--vert-fond:#e8f3ec;
|
|
--bleu:#245f8f;
|
|
--bleu-fond:#e7f0f8;
|
|
--ambre:#8a5b13;
|
|
--ambre-fond:#fff3d8;
|
|
--rouge:#9b2c2c;
|
|
--rouge-fond:#fae7e7;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--fond); color: var(--texte); }
|
|
header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 22px; border-bottom: 1px solid var(--ligne); background: var(--surface); position: sticky; top: 0; z-index: 4; }
|
|
h1 { margin: 0; font-size: 20px; font-weight: 750; letter-spacing: 0; }
|
|
h2 { margin: 0 0 10px; font-size: 15px; font-weight: 750; letter-spacing: 0; }
|
|
h3 { margin: 0; font-size: 15px; font-weight: 750; letter-spacing: 0; }
|
|
main { padding: 18px 22px 28px; display: grid; grid-template-columns: minmax(310px, 380px) minmax(0, 1fr); gap: 18px; align-items: start; }
|
|
button { border: 1px solid var(--ligne); background: var(--surface); color: var(--texte); padding: 8px 11px; border-radius: 6px; cursor: pointer; font-weight: 650; }
|
|
button.primaire { background: var(--vert); color: #fff; border-color: var(--vert); }
|
|
button.danger { color: var(--rouge); }
|
|
input, select { width: 100%; border: 1px solid var(--ligne); border-radius: 5px; padding: 7px 8px; background: #fff; color: var(--texte); font: inherit; }
|
|
label { display: inline-flex; align-items: center; gap: 6px; color: var(--texte); }
|
|
label input { width: auto; }
|
|
.barre { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
|
.meta { color: var(--muted); font-size: 13px; }
|
|
.message { min-height: 22px; color: var(--bleu); font-size: 14px; margin-bottom: 10px; }
|
|
.erreur { color: var(--rouge); }
|
|
.resume { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 7px; }
|
|
.compteur { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--ligne); border-radius: 6px; padding: 3px 8px; background: var(--surface2); color: var(--muted); font-size: 12px; }
|
|
.zone { background: var(--surface); border: 1px solid var(--ligne); border-radius: 8px; padding: 14px; }
|
|
.dependances { position: sticky; top: 84px; max-height: calc(100vh - 110px); overflow: auto; }
|
|
.liste-dependances { display: grid; gap: 8px; }
|
|
.dependance { border: 1px solid var(--ligne); border-radius: 7px; padding: 10px; background: var(--surface2); }
|
|
.dependance.ok { border-color: #b8d9c4; background: var(--vert-fond); }
|
|
.dependance.attente { border-color: #e6cd94; background: var(--ambre-fond); }
|
|
.dependance.bloque { border-color: #e7b6b6; background: var(--rouge-fond); }
|
|
.dep-titre { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
|
|
.dep-nom { font-weight: 750; overflow-wrap: anywhere; }
|
|
.dep-texte { color: var(--muted); font-size: 12px; line-height: 1.35; }
|
|
.badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 7px; font-size: 11px; font-weight: 750; white-space: nowrap; }
|
|
.badge.ok { color: var(--vert); background: var(--vert-fond); }
|
|
.badge.attente { color: var(--ambre); background: var(--ambre-fond); }
|
|
.badge.bloque { color: var(--rouge); background: var(--rouge-fond); }
|
|
.badge.neutre { color: var(--bleu); background: var(--bleu-fond); }
|
|
.hotes { display: grid; gap: 12px; }
|
|
.hote { background: var(--surface); border: 1px solid var(--ligne); border-radius: 8px; padding: 12px; }
|
|
.hote.bloque { border-color: #e7b6b6; }
|
|
.hote-entete { display: grid; grid-template-columns: minmax(160px, 1.2fr) 120px 160px 130px 100px auto; gap: 8px; align-items: end; }
|
|
.champ span { display: block; color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; margin-bottom: 4px; }
|
|
.actions-hote { display: flex; justify-content: flex-end; }
|
|
.groupes-section { margin-top: 12px; display: grid; gap: 8px; }
|
|
.groupes-liste { display: grid; grid-template-columns: repeat(auto-fit, minmax(235px, 1fr)); gap: 7px; }
|
|
.groupe { border: 1px solid var(--ligne); border-radius: 7px; padding: 8px; background: var(--surface2); min-height: 72px; align-items: flex-start; }
|
|
.groupe.selectionne { border-color: #a7c9de; background: var(--bleu-fond); }
|
|
.groupe.ok { border-color: #b8d9c4; background: var(--vert-fond); }
|
|
.groupe.bloque { border-color: #e7b6b6; background: var(--rouge-fond); }
|
|
.groupe.attente { border-color: #e6cd94; background: var(--ambre-fond); }
|
|
.groupe-ligne { display: flex; align-items: center; gap: 7px; font-weight: 700; overflow-wrap: anywhere; }
|
|
.groupe-detail { color: var(--muted); font-size: 12px; line-height: 1.3; margin: 5px 0 0 24px; }
|
|
.vide { color: var(--muted); padding: 16px; border: 1px dashed var(--ligne); border-radius: 8px; background: var(--surface2); }
|
|
@media (max-width: 1120px) {
|
|
main { grid-template-columns: 1fr; }
|
|
.dependances { position: static; max-height: none; }
|
|
.hote-entete { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.actions-hote { justify-content: flex-start; }
|
|
}
|
|
@media (max-width: 680px) {
|
|
header { align-items: flex-start; flex-direction: column; }
|
|
main { padding: 12px; }
|
|
.barre { justify-content: flex-start; }
|
|
.hote-entete { grid-template-columns: 1fr; }
|
|
.groupes-liste { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div>
|
|
<h1>Inventaire Set-OPS</h1>
|
|
<div class="meta" id="inventaire"></div>
|
|
<div class="resume" id="resume"></div>
|
|
</div>
|
|
<div class="barre">
|
|
<button type="button" onclick="ajouterHote()">Ajouter</button>
|
|
<button type="button" onclick="charger()">Recharger</button>
|
|
<button type="button" class="primaire" onclick="sauvegarder()">Sauvegarder</button>
|
|
</div>
|
|
</header>
|
|
<main>
|
|
<aside class="zone dependances">
|
|
<h2>Dépendances</h2>
|
|
<div class="liste-dependances" id="dependances"></div>
|
|
</aside>
|
|
<section>
|
|
<div class="message" id="message"></div>
|
|
<div class="hotes" id="hotes"></div>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
let groupes = [];
|
|
let dependances = {};
|
|
let hotes = [];
|
|
|
|
function message(texte, erreur=false) {
|
|
const el = document.getElementById('message');
|
|
el.textContent = texte;
|
|
el.className = erreur ? 'message erreur' : 'message';
|
|
}
|
|
|
|
async function charger() {
|
|
const reponse = await fetch('/api/inventaire');
|
|
const data = await reponse.json();
|
|
groupes = data.groupes;
|
|
dependances = data.dependances || {};
|
|
hotes = data.hotes;
|
|
document.getElementById('inventaire').textContent = data.inventaire;
|
|
dessiner();
|
|
message('Inventaire chargé.');
|
|
}
|
|
|
|
function ajouterHote() {
|
|
hotes.push({nom:'', etat:'planifie', adresse_ip:'', utilisateur_ansible:'ansible', vmid:'', groupes:['serveurs_debian','serveurs_durcis']});
|
|
dessiner();
|
|
}
|
|
|
|
function supprimerHote(index) {
|
|
hotes.splice(index, 1);
|
|
dessiner();
|
|
}
|
|
|
|
function definir(index, champ, valeur, rafraichir=false) {
|
|
hotes[index][champ] = valeur;
|
|
if (rafraichir) 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();
|
|
dessiner();
|
|
}
|
|
|
|
function groupesActifs() {
|
|
const actifs = new Set();
|
|
hotes.forEach(hote => {
|
|
if (hote.etat !== 'actif') return;
|
|
(hote.groupes || []).forEach(groupe => actifs.add(groupe));
|
|
});
|
|
return actifs;
|
|
}
|
|
|
|
function groupesPlanifies() {
|
|
const planifies = new Set();
|
|
hotes.forEach(hote => (hote.groupes || []).forEach(groupe => planifies.add(groupe)));
|
|
return planifies;
|
|
}
|
|
|
|
function statutDependance(groupe) {
|
|
const requis = ((dependances[groupe] || {}).requiert_groupes_actifs || []);
|
|
if (!requis.length) return 'neutre';
|
|
const actifs = groupesActifs();
|
|
const planifies = groupesPlanifies();
|
|
const manquants = requis.filter(item => !actifs.has(item));
|
|
if (!manquants.length) return 'ok';
|
|
if (manquants.every(item => planifies.has(item))) return 'attente';
|
|
return 'bloque';
|
|
}
|
|
|
|
function manquantsPourGroupe(groupe) {
|
|
const actifs = groupesActifs();
|
|
return ((dependances[groupe] || {}).requiert_groupes_actifs || []).filter(item => !actifs.has(item));
|
|
}
|
|
|
|
function hoteBloque(hote) {
|
|
if (hote.etat !== 'actif') return false;
|
|
return (hote.groupes || []).some(groupe => manquantsPourGroupe(groupe).length);
|
|
}
|
|
|
|
function libelleStatut(statut) {
|
|
return {ok:'actif', attente:'planifié', bloque:'bloqué', neutre:'libre'}[statut] || statut;
|
|
}
|
|
|
|
function dessiner() {
|
|
dessinerResume();
|
|
dessinerDependances();
|
|
dessinerHotes();
|
|
}
|
|
|
|
function dessinerResume() {
|
|
const actifs = hotes.filter(hote => hote.etat === 'actif').length;
|
|
const planifies = hotes.length - actifs;
|
|
const bloques = hotes.filter(hote => hoteBloque(hote)).length;
|
|
document.getElementById('resume').innerHTML = `
|
|
<span class="compteur">${hotes.length} hôtes</span>
|
|
<span class="compteur">${actifs} actifs</span>
|
|
<span class="compteur">${planifies} planifiés</span>
|
|
<span class="compteur">${bloques} bloqués</span>
|
|
`;
|
|
}
|
|
|
|
function dessinerDependances() {
|
|
const cible = document.getElementById('dependances');
|
|
const noms = Object.keys(dependances).sort();
|
|
if (!noms.length) {
|
|
cible.innerHTML = '<div class="vide">Aucune dépendance déclarée.</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 actif';
|
|
const manque = manquants.length ? `manquant: ${manquants.join(', ')}` : 'prérequis satisfaits';
|
|
const surveillance = config.surveillance ? `<div class="dep-texte">${echapper(config.surveillance)}</div>` : '';
|
|
return `
|
|
<div class="dependance ${statut}" title="${echapper(config.raison || '')}">
|
|
<div class="dep-titre">
|
|
<span class="dep-nom">${echapper(groupe)}</span>
|
|
<span class="badge ${statut}">${libelleStatut(statut)}</span>
|
|
</div>
|
|
<div class="dep-texte">${echapper(detail)}</div>
|
|
<div class="dep-texte">${echapper(manque)}</div>
|
|
${surveillance}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function dessinerHotes() {
|
|
const conteneur = document.getElementById('hotes');
|
|
if (!hotes.length) {
|
|
conteneur.innerHTML = '<div class="vide">Aucun hôte dans cet inventaire.</div>';
|
|
return;
|
|
}
|
|
conteneur.innerHTML = hotes.map((hote, index) => {
|
|
const bloque = hoteBloque(hote);
|
|
const statut = hote.etat === 'actif' ? (bloque ? 'bloque' : 'ok') : 'attente';
|
|
return `
|
|
<article class="hote ${bloque ? 'bloque' : ''}">
|
|
<div class="hote-entete">
|
|
<div class="champ"><span>Hôte</span><input value="${echapper(hote.nom || '')}" oninput="definir(${index}, 'nom', this.value)"></div>
|
|
<div class="champ"><span>État</span><select onchange="definir(${index}, 'etat', this.value, true)">
|
|
<option value="actif" ${hote.etat === 'actif' ? 'selected' : ''}>actif</option>
|
|
<option value="planifie" ${hote.etat !== 'actif' ? 'selected' : ''}>planifié</option>
|
|
</select></div>
|
|
<div class="champ"><span>Adresse IP</span><input value="${echapper(hote.adresse_ip || '')}" oninput="definir(${index}, 'adresse_ip', this.value)"></div>
|
|
<div class="champ"><span>Utilisateur</span><input value="${echapper(hote.utilisateur_ansible || '')}" oninput="definir(${index}, 'utilisateur_ansible', this.value)"></div>
|
|
<div class="champ"><span>VMID</span><input value="${echapper(String(hote.vmid || ''))}" oninput="definir(${index}, 'vmid', this.value)"></div>
|
|
<div class="actions-hote"><button type="button" class="danger" onclick="supprimerHote(${index})">Retirer</button></div>
|
|
</div>
|
|
<div class="groupes-section">
|
|
<div><span class="badge ${statut}">${hote.etat === 'actif' ? (bloque ? 'prérequis manquant' : 'déployable') : 'planifié'}</span></div>
|
|
<div class="groupes-liste">${groupes.map(groupe => renduGroupe(index, hote, groupe)).join('')}</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
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';
|
|
const detail = detailGroupe(groupe, hote);
|
|
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(detail)}</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 = [];
|
|
lignes.push(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 echapper(valeur) {
|
|
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
|
|
async function sauvegarder() {
|
|
const reponse = await fetch('/api/inventaire', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({hotes})
|
|
});
|
|
const data = await reponse.json();
|
|
if (!reponse.ok) {
|
|
message(data.erreur || 'Sauvegarde refusée.', true);
|
|
return;
|
|
}
|
|
dependances = data.dependances || {};
|
|
groupes = data.groupes;
|
|
hotes = data.hotes;
|
|
dessiner();
|
|
message('Inventaire sauvegardé.');
|
|
}
|
|
|
|
charger().catch(err => message(err.message, true));
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class Gestionnaire(BaseHTTPRequestHandler):
|
|
inventaire: Path = INVENTAIRE_DEFAUT
|
|
|
|
def repondre(self, status: int, contenu: bytes, content_type: str) -> None:
|
|
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)
|
|
|
|
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 do_GET(self) -> None:
|
|
chemin = urlparse(self.path).path
|
|
try:
|
|
if chemin == "/":
|
|
self.repondre(200, HTML.encode("utf-8"), "text/html; charset=utf-8")
|
|
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
|
|
try:
|
|
if chemin != "/api/inventaire":
|
|
self.repondre_json(404, {"erreur": "Introuvable"})
|
|
return
|
|
longueur = int(self.headers.get("Content-Length", "0"))
|
|
payload = json.loads(self.rfile.read(longueur).decode("utf-8"))
|
|
courant = charger_yaml(self.inventaire)
|
|
groupes_connus = groupes_disponibles(courant)
|
|
dependencies = charger_dependances(FICHIER_DEPENDANCES)
|
|
valider_payload(payload, groupes_connus, dependencies)
|
|
nouveau = construire_inventaire(payload, groupes_connus)
|
|
ecrire_yaml(self.inventaire, nouveau)
|
|
self.repondre_json(200, inventaire_api(self.inventaire))
|
|
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())
|