Rework GUI 2-3/3 : l'UI edite le plan, Inventaire en lecture seule
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
6040dfb8ff
commit
4518e3e44e
2 changed files with 83 additions and 29 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"""<!doctype html>
|
|||
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"""<!doctype html>
|
|||
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"""<!doctype html>
|
|||
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]) => `
|
||||
const banniere = '<div class="dsn" style="margin:0 2px 12px;padding:10px 12px;background:#1e293b;border-left:3px solid #38bdf8;border-radius:6px;color:#cbd5e1">🔒 Inventaire <b>généré depuis le plan</b> (lecture seule). Pour modifier : vues <b>Serveurs</b> et <b>Applications</b>, puis <b>« Appliquer le plan »</b>.</div>';
|
||||
cible.innerHTML = banniere + 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>
|
||||
|
|
@ -1072,33 +1075,88 @@ HTML = r"""<!doctype html>
|
|||
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 = '<div class="vide"><b>Aucun serveur dans le plan</b>Lance « make serveurs-bootstrap » pour générer docs/serveurs.yml depuis l’inventaire.</div>'; return; }
|
||||
const clsStatut = (s) => s === 'reconcilie' ? 'actif' : (s === 'divergence' ? 'bloque' : 'attente');
|
||||
const libStatut = (s) => s === 'reconcilie' ? 'réconcilié' : (s === 'divergence' ? 'divergence' : 'absent inv.');
|
||||
const lignes = serveurs.map(s => `
|
||||
<div class="base-ligne">
|
||||
<label class="champ"><span>Serveur</span><span class="mono">${echapper(s.nom)}</span></label>
|
||||
<label class="champ"><span>Fonction</span><span>${echapper(s.fonction)}</span></label>
|
||||
<label class="champ"><span>État</span><span>${echapper(s.etat || '—')}</span></label>
|
||||
<label class="champ"><span>VMID (dérivé)</span><span class="mono">${echapper(s.vmid)}</span></label>
|
||||
<label class="champ"><span>IP (dérivée)</span><span class="mono">${echapper(s.adresse_ip)}</span></label>
|
||||
<label class="champ"><span>VLAN</span><span class="mono">${echapper(s.vlan)}</span></label>
|
||||
<label class="champ"><span>Placement</span><span>${echapper([s.noeud, s.stockage].filter(Boolean).join(' / ') || '—')}</span></label>
|
||||
<label class="champ"><span>Taille</span><span>${echapper([s.disque, s.memoire && (s.memoire + 'Mo'), s.coeurs && (s.coeurs + 'c')].filter(Boolean).join(' · ') || '—')}</span></label>
|
||||
<span class="badge-statut ${clsStatut(s.statut)}">${libStatut(s.statut)}</span>
|
||||
${(s.divergences && s.divergences.length) ? `<div class="dsn mono">${s.divergences.map(echapper).join(' ; ')}</div>` : ''}
|
||||
</div>`).join('');
|
||||
const nbDiv = serveurs.filter(s => s.statut !== 'reconcilie').length;
|
||||
const optFonction = (sel) => Object.keys(nomenclature.fonctions || {}).map(f => `<option value="${echapper(f)}" ${f === sel ? 'selected' : ''}>${echapper(f)}</option>`).join('');
|
||||
const optEtat = (sel) => ['planifie', 'actif'].map(e => `<option value="${e}" ${e === sel ? 'selected' : ''}>${e}</option>`).join('');
|
||||
const lignes = serveurs.length ? serveurs.map((s, i) => {
|
||||
const d = deriveServeur(s.nom, s.fonction);
|
||||
return `<div class="base-ligne">
|
||||
<label class="champ"><span>Serveur (fonction-NN)</span><input value="${echapper(s.nom)}" placeholder="web-frontal-03" oninput="definirServeur(${i}, 'nom', this.value)"></label>
|
||||
<label class="champ"><span>Fonction</span><select onchange="definirServeur(${i}, 'fonction', this.value)">${optFonction(s.fonction)}</select></label>
|
||||
<label class="champ"><span>État</span><select onchange="definirServeur(${i}, 'etat', this.value)">${optEtat(s.etat)}</select></label>
|
||||
<label class="champ"><span>Nœud</span><input value="${echapper(s.noeud)}" placeholder="asgard" oninput="definirServeur(${i}, 'noeud', this.value)"></label>
|
||||
<label class="champ"><span>Stockage</span><input value="${echapper(s.stockage)}" placeholder="TrueNAS" oninput="definirServeur(${i}, 'stockage', this.value)"></label>
|
||||
<label class="champ"><span>Disque</span><input value="${echapper(s.disque)}" placeholder="16G" oninput="definirServeur(${i}, 'disque', this.value)"></label>
|
||||
<label class="champ"><span>Mémoire</span><input type="number" value="${echapper(s.memoire)}" placeholder="2048" oninput="definirServeur(${i}, 'memoire', this.value)"></label>
|
||||
<label class="champ"><span>Cœurs</span><input type="number" value="${echapper(s.coeurs)}" placeholder="2" oninput="definirServeur(${i}, 'coeurs', this.value)"></label>
|
||||
<label class="champ"><span>Intégrations</span><input value="${echapper(s.integrations)}" placeholder="clients_dns, …" oninput="definirServeur(${i}, 'integrations', this.value)"></label>
|
||||
<button type="button" class="danger" title="Retirer" onclick="retirerServeur(${i})">✕</button>
|
||||
<div class="dsn mono">dérivé → vmid ${echapper(d.vmid)} · ip ${echapper(d.ip)} · vlan ${echapper(d.vlan)}</div>
|
||||
</div>`;
|
||||
}).join('') : '<div class="vide" style="padding:14px">Aucun serveur. Utilise « + Serveur » ou make serveurs-bootstrap.</div>';
|
||||
cible.innerHTML = `
|
||||
<div class="section-grille">
|
||||
<div class="section-tete">Serveurs du plan <span class="compte">${serveurs.length}</span>${nbDiv ? ' · ' + nbDiv + ' non réconcilié(s)' : ' · tous réconciliés'}</div>
|
||||
<div class="section-tete">Serveurs du plan <span class="compte">${serveurs.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterServeur()">+ Serveur</button></div>
|
||||
<div class="bases-liste">${lignes}</div>
|
||||
<div class="dsn" style="padding:8px 2px;color:#94a3b8">Lecture seule (Phase 2). VMID / IP / VLAN sont <b>dérivés</b> de la fonction via la nomenclature ; « réconcilié » = identique à l'inventaire. La génération de l'inventaire depuis le plan viendra en Phase 3.</div>
|
||||
<div style="margin:4px 2px 8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="primaire ${serveursModifie ? 'attention' : ''}" id="btn-sauver-serveurs" onclick="sauvegarderServeurs()">Sauvegarder les serveurs${serveursModifie ? ' •' : ''}</button>
|
||||
<button type="button" class="fantome" onclick="appliquerPlan()">⚙ Appliquer le plan → hosts.yml</button>
|
||||
</div>
|
||||
<div class="dsn" style="padding:6px 2px;color:#94a3b8">VMID / IP / VLAN sont <b>dérivés</b> de la fonction. « Appliquer le plan » régénère l'inventaire (refuse si conflit ; git est le filet).</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
Reference in a new issue