From 4518e3e44eb68a0fd9f2b4d31a1dfc691072ce36 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 23 Jun 2026 22:23:36 -0400 Subject: [PATCH] Rework GUI 2-3/3 : l'UI edite le plan, Inventaire en lecture seule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vue Serveurs editable : fonction/etat/placement/integrations, VMID/IP/VLAN derives en direct, + Serveur, Sauvegarder (POST /api/serveurs) et « Appliquer le plan » (POST /api/instancier -> instancier appliquer). - API serveurs_reconcilies expose 'integrations'. - Vue Inventaire en lecture seule : banniere + POST /api/inventaire refuse (409) car hosts.yml est genere. Tout passe par le plan puis Appliquer. Garde-fou node --check a chaque etape (corrige aussi un piege \n en raw string). La bascule meta-classe est complete : on edite le plan, l'UI genere l'inventaire. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + scripts/inventory_gui.py | 111 +++++++++++++++++++++++++++++---------- 2 files changed, 83 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e9310..bef459b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2026-06-23 ### Modifié +- **Rework GUI : l'UI édite le plan (branche `bascule-inventaire-genere`).** La vue **Serveurs** devient éditable (fonction / état / placement Proxmox / intégrations `clients_*`, avec VMID/IP/VLAN dérivés en direct), avec « + Serveur », « Sauvegarder les serveurs » (`POST /api/serveurs`) et **« Appliquer le plan → hosts.yml »** (`POST /api/instancier` → `instancier appliquer`). La vue **Inventaire** passe en **lecture seule** : bandeau explicite + l'écriture directe (`POST /api/inventaire`) est refusée (409) puisque `hosts.yml` est désormais généré. Tout passe par le plan (`serveurs.yml` / `applications.yml`) puis « Appliquer ». Garde-fou JS `node --check` exécuté à chaque étape. - **Bascule méta-classe (branche `bascule-inventaire-genere`).** `make instancier-appliquer` régénère `inventories/production/hosts.yml` **depuis le plan** (refuse si le diff n'est pas vide, sauf `FORCE=1` pour un changement intentionnel ; git sert de filet). Effectuée avec diff vide vérifié : `hosts.yml` est désormais un artefact généré, sémantiquement identique à l'ancien (vérifié par `make inventaire-verifier` complet + chargement GUI). À ce stade le GUI édite encore `hosts.yml` directement (rework à venir) : éditer le **plan** (`serveurs.yml`/`applications.yml`) puis `make instancier-appliquer`. - **Phase 2 (début) — vue Chaîne holistique.** Dans `make inventaire-ui`, la vue Chaîne montre désormais, pour chaque application d'un hôte, l'ensemble de ses liens : `expose` (FQDN publics), `requiert` (applications dont elle dépend) et ses bases (DSN), plus son port. On voit d'un coup d'œil comment VM, applications, bases et domaines s'articulent. - **Phase 1b — clarté de nommage.** La clé `domaines:` de `docs/nomenclature.yml` (familles fonctionnelles : `infra-pki`, `web-frontal`…) est renommée **`fonctions:`** pour lever la collision avec les domaines DNS (`docs/domaines.yml`). L'auto-proposition du GUI suit (`fonctionDe` / `infoFonction`, `nomenclature.fonctions`, libellés « Fonction inconnue… », placeholder `fonction-NN`). Aucun rôle ni inventaire impacté : la nomenclature n'est consommée qu'au moment de la planification. diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 573dc5b..cebd259 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -187,6 +187,7 @@ def serveurs_reconcilies(data: dict) -> list: "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", ""), + "integrations": srv.get("integrations", []), "vmid": d.get("vmid", ""), "adresse_ip": d.get("adresse_ip", ""), "vlan": d.get("vlan", ""), "statut": statut, "divergences": rec["divergences"], }) @@ -652,6 +653,7 @@ HTML = r""" let applications = []; let applicationsModifie = false; let serveurs = []; + let serveursModifie = false; let selection = -1; let selectionNom = null; let ongletActif = 'reseau'; @@ -696,7 +698,7 @@ HTML = r""" chaine = data.chaine || {}; chargerBases(data); chargerApplications(data); - serveurs = data.serveurs || []; + chargerServeurs(data); hotes = data.hotes; estProduction = !!data.production; modifie = false; verifie = {}; @@ -850,7 +852,8 @@ HTML = r""" const visibles = hotes.map((h, i) => [h, i]).filter(([h]) => correspond(h)); if (!visibles.length) { cible.innerHTML = '
Aucun hôte ne correspond au filtre.
'; 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]) => ` + const banniere = '
🔒 Inventaire généré depuis le plan (lecture seule). Pour modifier : vues Serveurs et Applications, puis « Appliquer le plan ».
'; + cible.innerHTML = banniere + sections.filter(([, l]) => l.length).map(([titre, liste]) => `
${titre} ${liste.length}
${liste.map(([h, i]) => carte(h, i)).join('')}
@@ -1072,33 +1075,88 @@ HTML = r""" message('Applications sauvegardées.', 'ok'); } + function chargerServeurs(data) { + serveurs = (data.serveurs || []).map(s => ({...s, integrations: (s.integrations || []).join(', ')})); + serveursModifie = false; + } + + function deriveServeur(nom, fonction) { + const info = infoFonction(fonction); + const seq = fonctionDe(nom).seq; + if (!info || seq == null) return {vmid: '?', ip: '?', vlan: '?'}; + const base3 = info.sous_reseau.split('.').slice(0, 3).join('.'); + return {vmid: '9' + info.categorie + info.service + String(seq).padStart(2, '0'), ip: base3 + '.' + (info.service * 10 + seq), vlan: String(info.vlan)}; + } + + function marquerServeursModifie() { + serveursModifie = true; + const b = document.getElementById('btn-sauver-serveurs'); + if (b) { b.classList.add('attention'); b.textContent = 'Sauvegarder les serveurs •'; } + } + function definirServeur(i, champ, v) { serveurs[i][champ] = v; marquerServeursModifie(); if (champ === 'nom' || champ === 'fonction') dessinerServeurs(); } + function ajouterServeur() { serveurs.push({nom: '', fonction: (Object.keys(nomenclature.fonctions || {})[0] || ''), etat: 'planifie', noeud: '', stockage: '', disque: '', memoire: '', coeurs: '', integrations: ''}); marquerServeursModifie(); dessinerServeurs(); } + function retirerServeur(i) { serveurs.splice(i, 1); marquerServeursModifie(); dessinerServeurs(); } + function dessinerServeurs() { const cible = document.getElementById('grilles'); - if (!serveurs.length) { cible.innerHTML = '
Aucun serveur dans le planLance « make serveurs-bootstrap » pour générer docs/serveurs.yml depuis l’inventaire.
'; 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 => ` -
- - - - - - - - - ${libStatut(s.statut)} - ${(s.divergences && s.divergences.length) ? `
${s.divergences.map(echapper).join(' ; ')}
` : ''} -
`).join(''); - const nbDiv = serveurs.filter(s => s.statut !== 'reconcilie').length; + const optFonction = (sel) => Object.keys(nomenclature.fonctions || {}).map(f => ``).join(''); + const optEtat = (sel) => ['planifie', 'actif'].map(e => ``).join(''); + const lignes = serveurs.length ? serveurs.map((s, i) => { + const d = deriveServeur(s.nom, s.fonction); + return `
+ + + + + + + + + + +
dérivé → vmid ${echapper(d.vmid)} · ip ${echapper(d.ip)} · vlan ${echapper(d.vlan)}
+
`; + }).join('') : '
Aucun serveur. Utilise « + Serveur » ou make serveurs-bootstrap.
'; cible.innerHTML = `
-
Serveurs du plan ${serveurs.length}${nbDiv ? ' · ' + nbDiv + ' non réconcilié(s)' : ' · tous réconciliés'}
+
Serveurs du plan ${serveurs.length}
${lignes}
-
Lecture seule (Phase 2). VMID / IP / VLAN sont dérivés 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.
+
+ + +
+
VMID / IP / VLAN sont dérivés de la fonction. « Appliquer le plan » régénère l'inventaire (refuse si conflit ; git est le filet).
`; } + async function sauvegarderServeurs() { + const srv = {}; + serveurs.forEach(s => { + if (!String(s.nom).trim()) return; + const o = {fonction: s.fonction, etat: s.etat || 'planifie'}; + ['noeud', 'stockage', 'disque'].forEach(k => { if (String(s[k] || '').trim()) o[k] = s[k]; }); + ['memoire', 'coeurs'].forEach(k => { if (String(s[k] || '').trim()) o[k] = parseInt(s[k], 10) || s[k]; }); + const integ = listeDepuisTexte(s.integrations); if (integ.length) o.integrations = integ; + srv[s.nom] = o; + }); + const rep = await fetch('/api/serveurs', { + method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs: srv}) + }); + const data = await rep.json(); + if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; } + chargerServeurs(data); dessinerServeurs(); + message('Serveurs (plan) sauvegardés. Pense à « Appliquer le plan ».', 'ok'); + } + + async function appliquerPlan() { + message('Application du plan…'); + const rep = await fetch('/api/instancier', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: '{}'}); + const data = await rep.json(); + const derniere = (data.sortie || '').split('\n').pop(); + message((data.ok ? 'Plan appliqué : ' : 'Échec : ') + derniere, data.ok ? 'ok' : 'erreur'); + if (data.ok) charger(); + } + function dessinerDependances() { const cible = document.getElementById('dependances'); const noms = Object.keys(dependances).sort(); @@ -1510,14 +1568,9 @@ class Gestionnaire(BaseHTTPRequestHandler): 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)) + self.repondre_json(409, {"erreur": "hosts.yml est genere depuis le plan. " + "Edite docs/serveurs.yml et docs/applications.yml " + "(vues Serveurs / Applications), puis « Appliquer le plan »."}) elif chemin == "/api/bases": valider_bases(donnees, charger_applications(FICHIER_APPLICATIONS)) ecrire_bases(FICHIER_BASES, donnees)