From 5176d714e9d3a6a8c841c9991c9ac42267ec6695 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 30 Jun 2026 15:22:11 -0400 Subject: [PATCH] =?UTF-8?q?GUI=20:=20atelier=20ma=C3=AEtre-d=C3=A9tail=20u?= =?UTF-8?q?nifi=C3=A9=20+=20fusion=20Serveur/H=C3=B4te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toutes les vues adoptent le même motif que l'Inventaire : tuiles à gauche, détail + saisie à droite, panneau droit contextuel (fin du panneau figé au changement de vue). Applications et Bases passent en maître-détail ; liens cliquables entre objets. Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles sont fusionnées en une vue Serveurs unique — tuile avec statut de réconciliation, détail réunissant identité éditable, dérivés, groupes, applications/bases hébergées et Vérifier/Déployer. Navigation clavier et filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ». Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection d'hôte, chips de filtre). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 17 + scripts/inventory_gui.py | 666 +++++++++++++++++++-------------------- 2 files changed, 341 insertions(+), 342 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f67cc31..612b8aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## 2026-06-30 +### Modifié +- **GUI refondu en atelier maître-détail unifié.** Toutes les vues suivent le même + motif : tuiles à gauche, **détail + saisie à droite**, le panneau droit reflétant + la sélection de la vue courante (fin du panneau « figé » au changement de vue). + Les vues **Applications** et **Bases** passent de tableaux pleine largeur à ce + motif ; la saisie se fait dans le panneau droit, avec **liens cliquables** entre + objets (serveur → application → base). Le panneau droit est élargi (~38 %). +- **Fusion Serveur/Hôte.** Les vues « Inventaire » (hôtes, lecture seule) et + « Serveurs » (plan) faisaient doublon : elles sont fusionnées en **une seule vue + Serveurs**. Sa tuile porte le statut de réconciliation (réconcilié / divergent / + non instancié) ; son détail réunit l'**identité éditable** (plan), les **dérivés** + (VMID/IP/VLAN), les **groupes**, les **applications et bases hébergées**, et + **Vérifier/Déployer**. La navigation clavier (`1-3`, `j/k`, `v/d`, `/`) et le + filtre opèrent désormais sur les serveurs. Un bandeau « Comment lire ce parc » + explicite le modèle serveur·hôte·groupe·application·base. Code mort retiré + (`carte`, `renduChaine`, `ONGLETS`, `champLecture`, sélection d'hôte, etc.). + ### Ajouté - **Fluidité d'exploitation du GUI** (sans dépendance, stdlib pure) : - **Notifications empilées (toasts)** auto-effaçables au lieu d'une bannière unique diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 0ddabc6..a512754 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -576,7 +576,7 @@ HTML = r""" 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; } + main { padding: 16px 22px 44px; display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(420px, 1fr); gap: 20px; align-items: start; max-width: 1760px; 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); } .toasts { position: fixed; top: 14px; right: 14px; z-index: 60; display: flex; flex-direction: column; gap: 8px; max-width: min(380px, 92vw); pointer-events: none; } @@ -648,6 +648,14 @@ HTML = r""" .champ > span { color: var(--muted); font-size: 11px; font-weight: 700; } .champ .unite { color: var(--faint); font-weight: 600; } .champ > span.a-aide { cursor: help; border-bottom: 1px dotted var(--ligne-forte); width: fit-content; } + .aide-modele { margin: 0 2px 14px; background: var(--surface); border: 1px solid var(--ligne); border-left: 3px solid var(--bleu); border-radius: var(--rayon-sm); } + .aide-modele > summary { cursor: pointer; padding: 10px 13px; font-size: 12.5px; font-weight: 750; color: var(--bleu); list-style: none; } + .aide-modele > summary::-webkit-details-marker { display: none; } + .aide-modele > summary::before { content: "ⓘ "; } + .aide-corps { padding: 2px 15px 12px; color: var(--muted); font-size: 12.5px; line-height: 1.55; } + .aide-corps p { margin: 6px 0; } .aide-corps ul { margin: 6px 0; padding-left: 18px; } .aide-corps li { margin: 3px 0; } + .aide-corps b { color: var(--texte); } + .aide-ro { border-top: 1px solid var(--ligne); padding-top: 8px; } .champ .champ-val { font-size: 13px; font-weight: 600; color: var(--texte); padding: 5px 2px; min-height: 18px; overflow-wrap: anywhere; } .champ .champ-val.vide { color: var(--faint); } .detail-nom-txt { font-weight: 800; font-size: 17px; padding: 4px 7px; display: inline-block; overflow-wrap: anywhere; } @@ -723,6 +731,8 @@ HTML = r""" .integ-cases { display: flex; flex-wrap: wrap; gap: 6px 12px; padding: 4px 2px; } .integ-case { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: var(--texte); cursor: pointer; } .integ-case input { width: 14px; height: 14px; flex: none; accent-color: var(--teal); } + .lien-objet { background: none; border: 0; padding: 0; color: var(--bleu); font: inherit; font-weight: 700; cursor: pointer; text-decoration: underline dotted; } + .lien-objet:hover { color: var(--teal); } .intr-ro { color: var(--muted); font-size: 12px; line-height: 1.55; } .intr-ro b { color: var(--texte); font-weight: 700; } .intr-secrets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } @@ -749,10 +759,9 @@ HTML = r"""
-
+
- - +
@@ -818,17 +827,18 @@ HTML = r""" let hotes = []; let estProduction = false; let filtre = ''; - let filtreEtat = 'tous'; - let vuePrincipale = 'cartes'; + let vuePrincipale = 'serveurs'; let bdServeurs = []; let bdApplis = []; let basesModifie = false; + let selApp = null; // index de l'application sélectionnée (vue Applications) + let selServeur = null; // index du serveur sélectionné (vue Serveurs, maître-détail fusionné) + let selBase = null; // clé de l'item sélectionné (vue Bases) : "srv:" ou "bd:" + let hoteOpCourant = ''; // nom de l'hôte dont les boutons Vérifier/Déployer sont affichés let applications = []; let applicationsModifie = false; let serveurs = []; let serveursModifie = false; - let selection = -1; - let selectionNom = null; let ongletActif = 'reseau'; let occupe = false; let verifie = {}; @@ -839,26 +849,6 @@ HTML = r""" let chronoConsole = null; let consoleDebut = 0; - 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}], - ], - }; - let toastSeq = 0; function message(texte, type='', opts={}) { const conteneur = document.getElementById('toasts'); @@ -907,29 +897,12 @@ HTML = r""" estProduction = !!data.production; verifie = {}; document.getElementById('chemin').textContent = data.inventaire; - restaurerSelection(); + if (selServeur == null || selServeur >= serveurs.length) selServeur = serveurs.length ? 0 : null; 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 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; } @@ -945,11 +918,9 @@ HTML = r""" } 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 dessiner() { dessinerResume(); dessinerGrilles(); dessinerDependances(); dessinerDetail(); majIndicateurSauvegarde(); } function majIndicateurSauvegarde() { const b = document.getElementById('btn-sauver'); @@ -974,67 +945,41 @@ HTML = r""" ${bloques ? `${bloques} bloqués` : ''}`; } - 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]) => - `` - ).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 => `${echapper(abrege(g))}`).join('') - + (grps.length > 3 ? `+${grps.length - 3}` : ''); - return ` - `; + function correspondServeur(s) { + if (!filtre) return true; + return [s.nom, s.fonction, s.adresse_ip, s.vmid, s.etat].join(' ').toLowerCase().includes(filtre); } function setVue(v) { vuePrincipale = v; - document.getElementById('btn-vue-cartes').classList.toggle('on', v === 'cartes'); + if (v === 'serveurs' && selServeur == null && serveurs.length) selServeur = 0; + if (v === 'applications' && selApp == null && applications.length) selApp = 0; + if (v === 'bases' && selBase == null) selBase = bdServeurs.length ? 'srv:0' : (bdApplis.length ? 'bd:0' : null); document.getElementById('btn-vue-serveurs').classList.toggle('on', v === 'serveurs'); document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications'); document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases'); dessinerGrilles(); + dessinerDetail(); // le panneau droit reflète la sélection de la vue courante majIndicateurSauvegarde(); } + const BANNIERE_MODELE = `
+ Comment lire ce parc — serveur · hôte · groupe · application · base +
+

Serveur (ce que tu écris ici) → « Appliquer le plan »Hôte (la VM générée). C'est le même objet, à deux étapes.

+
    +
  • Serveur / Hôte — une VM ; elle appartient à des groupes.
  • +
  • Groupe — un rôle appliqué à l'hôte (ex. serveur_nginx, client_dns).
  • +
  • Application — un service qui tourne sur un hôte, via un groupe, et expose un FQDN.
  • +
  • Base — une donnée hébergée sur un hôte et consommée par une application.
  • +
+

Saisis à gauche (tuiles), édite à droite ; les dérivés (VMID/IP/VLAN/groupes) se calculent à « Appliquer le plan ».

+
`; + function dessinerGrilles() { - document.getElementById('chips').style.display = vuePrincipale === 'cartes' ? '' : 'none'; - if (vuePrincipale === 'serveurs') { dessinerServeurs(); return; } if (vuePrincipale === 'applications') { dessinerApplications(); return; } if (vuePrincipale === 'bases') { dessinerBases(); return; } - const cible = document.getElementById('grilles'); - if (!hotes.length) { cible.innerHTML = '
Inventaire videAjoutez un hôte pour commencer.
'; return; } - 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')]]; - 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('')}
-
`).join(''); + dessinerServeurs(); } function basesDeApp(app, hoteNom) { @@ -1046,45 +991,6 @@ HTML = r""" }); } - function lignesDsn(liste, etiquette) { - return liste.length ? `
${liste.map(a => ` -
${echapper(etiquette(a))} ${echapper(dsnBase(a))}
`).join('')}
` : ''; - } - - function renduChaine(hote) { - const grps = hote.groupes || []; - const apps = applications.filter(a => a.hote === hote.nom); - if (!grps.length && !apps.length) return '
Aucun groupe opérationnel ni application.
'; - const grpHtml = grps.map(g => { - const c = chaine[g] || {roles: [], stub: true}; - const corps = (c.roles && c.roles.length) - ? `
${c.roles.map(r => `${echapper(r)}`).join('')}
` - : '
stub — aucun rôle rattaché
'; - const bg = bdApplis.filter(a => (a.portee || 'groupe') === 'groupe' && a.consommateur === g); - return `
-
${echapper(g)}${echapper(g)}.yml
- ${corps} - ${lignesDsn(bg, a => 'BD partagée ' + (a.usage || ''))} -
`; - }).join(''); - const ligneLien = (etiquette, valeur) => `
${etiquette} ${echapper(valeur)}
`; - const appHtml = apps.map(app => { - const dsns = basesDeApp(app, hote.nom); - const liens = []; - if (String(app.expose || '').trim()) liens.push(ligneLien('expose', app.expose)); - if (String(app.requiert || '').trim()) liens.push(ligneLien('requiert', app.requiert)); - dsns.forEach(a => liens.push(`
BD ${echapper((a.portee || 'groupe') + ' · ' + (a.usage || ''))} ${echapper(dsnBase(a))}
`)); - const corps = liens.length ? `
${liens.join('')}
` : '
aucun lien
'; - const port = String(app.port || '').trim() ? ` :${echapper(app.port)}` : ''; - return `
-
app${echapper(app.id)}${port}${echapper(app.groupe)}
- ${corps} -
`; - }).join(''); - const appSection = apps.length ? `
Applications sur cet hôte ${apps.length}
${appHtml}` : ''; - return grpHtml + appSection; - } - function chargerBases(data) { const b = data.bases || {serveurs_bd: {}, bases_donnees: {}}; bdServeurs = Object.entries(b.serveurs_bd || {}).map(([nom, s]) => ({ @@ -1109,59 +1015,96 @@ HTML = r""" return `${s.type}://${a.proprietaire || '?'}:****@${s.hote || '?'}:${s.port || '?'}/${a.base || '?'}`; } - function marquerBasesModifie() { - basesModifie = true; - const b = document.getElementById('btn-sauver-bases'); - if (b) { b.classList.toggle('attention', true); b.textContent = 'Sauvegarder les bases •'; } + function marquerBasesModifie() { basesModifie = true; majIndicateurSauvegarde(); } + function selBaseInfo() { if (!selBase) return null; const p = selBase.split(':'); return {kind: p[0], i: +p[1]}; } + function choisirBase(key) { selBase = key; dessiner(); } + function definirServeurBd(champ, v) { + const info = selBaseInfo(); if (!info || info.kind !== 'srv') return; + const s = bdServeurs[info.i]; if (!s) return; + s[champ] = v; marquerBasesModifie(); + if (champ === 'nom') { const t = document.querySelector('#tuile-base-srv-' + info.i + ' .carte-nom'); if (t) t.textContent = v || '(sans nom)'; } } - function definirServeurBd(i, champ, v) { bdServeurs[i][champ] = v; marquerBasesModifie(); } - function definirBaseBd(i, champ, v) { bdApplis[i][champ] = v; marquerBasesModifie(); if (champ === 'serveur' || champ === 'portee') dessinerBases(); } - function ajouterServeurBd() { bdServeurs.push({nom: '', type: 'postgres', hote: '', port: 5432, groupe: ''}); marquerBasesModifie(); dessinerBases(); } - function retirerServeurBd(i) { bdServeurs.splice(i, 1); marquerBasesModifie(); dessinerBases(); } - function ajouterBaseBd() { bdApplis.push({cle: '', serveur: (bdServeurs[0] || {}).nom || '', base: '', proprietaire: '', secret: '', consommateur: '', portee: 'groupe', usage: 'principale'}); marquerBasesModifie(); dessinerBases(); } - function retirerBaseBd(i) { bdApplis.splice(i, 1); marquerBasesModifie(); dessinerBases(); } + function definirBaseBd(champ, v) { + const info = selBaseInfo(); if (!info || info.kind !== 'bd') return; + const a = bdApplis[info.i]; if (!a) return; + a[champ] = v; marquerBasesModifie(); + if (champ === 'cle') { const t = document.querySelector('#tuile-base-bd-' + info.i + ' .carte-nom'); if (t) t.textContent = v || '(sans id)'; } + else if (champ === 'serveur' || champ === 'portee') dessinerDetail(); + } + function ajouterServeurBd() { bdServeurs.unshift({nom: '', type: 'postgres', hote: '', port: 5432, groupe: ''}); selBase = 'srv:0'; marquerBasesModifie(); dessiner(); } + function retirerServeurBd() { const info = selBaseInfo(); if (!info || info.kind !== 'srv') return; bdServeurs.splice(info.i, 1); selBase = null; marquerBasesModifie(); dessiner(); } + function ajouterBaseBd() { bdApplis.unshift({cle: '', serveur: (bdServeurs[0] || {}).nom || '', base: '', proprietaire: '', secret: '', consommateur: '', portee: 'groupe', usage: 'principale'}); selBase = 'bd:0'; marquerBasesModifie(); dessiner(); } + function retirerBaseBd() { const info = selBaseInfo(); if (!info || info.kind !== 'bd') return; bdApplis.splice(info.i, 1); selBase = null; marquerBasesModifie(); dessiner(); } + function carteBaseServeur(s, i) { + const key = 'srv:' + i; + return ``; + } + function carteBaseDonnee(a, i) { + const key = 'bd:' + i; + return ``; + } function dessinerBases() { const cible = document.getElementById('grilles'); - const serveurs = bdServeurs.length ? bdServeurs.map((s, i) => ` -
- - - - - - -
`).join('') : '
Aucun serveur de BD.
'; - const sourceConso = (portee) => portee === 'hote' ? hotes.map(h => h.nom).filter(Boolean) - : portee === 'application' ? applications.map(a => a.id) - : groupes; - const optConso = (portee, sel) => '' + sourceConso(portee).map(v => ``).join(''); - const optPortee = (sel) => ['groupe', 'application', 'hote'].map(p => ``).join(''); - const applis = bdApplis.length ? bdApplis.map((a, i) => ` -
- - - - - - - - - -
${echapper(a.portee || 'groupe')} · ${echapper(a.consommateur || '—')} · ${echapper(dsnBase(a))}
-
`).join('') : '
Aucune base applicative.
'; - cible.innerHTML = ` -
-
Serveurs de bases de données
-
${serveurs}
-
-
-
Bases applicatives
-
${applis}
-
-
`; + const srv = bdServeurs.length ? `
${bdServeurs.map((s, i) => carteBaseServeur(s, i)).join('')}
` : '
Aucun serveur de BD.
'; + const bd = bdApplis.length ? `
${bdApplis.map((a, i) => carteBaseDonnee(a, i)).join('')}
` : '
Aucune base.
'; + cible.innerHTML = `
Serveurs de bases ${bdServeurs.length}
${srv}
+
Bases applicatives ${bdApplis.length}
${bd}
`; + } + function inspecteurBase() { + const cible = document.getElementById('detail'); + const info = selBaseInfo(); + if (!info) { cible.innerHTML = '
Aucun élément sélectionnéChoisis un serveur de base ou une base à gauche.
'; return; } + if (info.kind === 'srv') { + const s = bdServeurs[info.i]; + if (!s) { cible.innerHTML = '
Introuvable.
'; return; } + const optHote = '' + hotes.map(h => h.nom).filter(Boolean).map(n => ``).join(''); + const optGroupe = '' + groupes.map(g => ``).join(''); + const ici = bdApplis.filter(a => a.serveur === s.nom); + const iciHtml = ici.length ? `
${ici.map(a => `
${echapper(a.portee || 'groupe')} ${echapper(a.base || '?')} (${echapper(a.cle)})
`).join('')}
` : '
aucune base sur ce serveur
'; + cible.innerHTML = `
+
${echapper(s.nom || '(sans nom)')}
${echapper(s.type || 'bd')}
+
+ + + + + +
+
Bases hébergées ici
${iciHtml} +
Sauvegarde (Ctrl+S) puis « Appliquer le plan ».
+
`; + } else { + const a = bdApplis[info.i]; + if (!a) { cible.innerHTML = '
Introuvable.
'; return; } + const source = a.portee === 'hote' ? hotes.map(h => h.nom).filter(Boolean) : a.portee === 'application' ? applications.map(x => x.id) : groupes; + const optConso = '' + source.map(v => ``).join(''); + const optPortee = ['groupe', 'application', 'hote'].map(p => ``).join(''); + const optServeur = '' + bdServeurs.map(x => ``).join(''); + cible.innerHTML = `
+
${echapper(a.cle || '(sans id)')}
+
+ + + + + + + + +
+
${echapper(dsnBase(a))}
+
Sauvegarde (Ctrl+S) puis « Appliquer le plan ».
+
`; + } } async function sauvegarderBases() { @@ -1179,39 +1122,74 @@ HTML = r""" message('Bases de données sauvegardées.', 'ok'); } - function marquerApplicationsModifie() { - applicationsModifie = true; - const b = document.getElementById('btn-sauver-applications'); - if (b) { b.classList.add('attention'); b.textContent = 'Sauvegarder les applications •'; } + function marquerApplicationsModifie() { applicationsModifie = true; majIndicateurSauvegarde(); } + function choisirApp(i) { selApp = i; dessiner(); } + function definirApplication(champ, v) { + const a = applications[selApp]; if (!a) return; + a[champ] = v; marquerApplicationsModifie(); + if (champ === 'id') { const t = document.querySelector('#tuile-app-' + selApp + ' .carte-nom'); if (t) { t.textContent = v || '(sans id)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } } + else if (champ === 'hote' || champ === 'groupe' || champ === 'port') dessinerDetail(); + } + function ajouterApplication() { + applications.unshift({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: ''}); + selApp = 0; marquerApplicationsModifie(); dessiner(); + const c = document.querySelector('#detail .grille input'); if (c) c.focus(); + } + function retirerApplication() { + const a = applications[selApp]; if (!a) return; + if (a.id && !confirm('Retirer l\'application « ' + a.id + ' » ?')) return; + applications.splice(selApp, 1); + selApp = applications.length ? Math.min(selApp, applications.length - 1) : null; + marquerApplicationsModifie(); dessiner(); } - function definirApplication(i, champ, v) { applications[i][champ] = v; marquerApplicationsModifie(); if (champ === 'hote' || champ === 'groupe') dessinerApplications(); } - function ajouterApplication() { applications.push({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: ''}); marquerApplicationsModifie(); dessinerApplications(); } - function retirerApplication(i) { applications.splice(i, 1); marquerApplicationsModifie(); dessinerApplications(); } + function carteApp(a, i) { + const exp = listeDepuisTexte(a.expose); + return ``; + } function dessinerApplications() { const cible = document.getElementById('grilles'); - const optGroupe = (sel) => groupes.map(g => ``).join(''); - const optHote = (sel) => '' + hotes.map(h => h.nom).filter(Boolean).map(n => ``).join(''); - const lignes = applications.length ? applications.map((a, i) => { - const dsns = basesDeApp(a, a.hote); - const resume = dsns.length ? dsns.map(b => `${echapper((b.portee || 'groupe'))}:${echapper(dsnBase(b))}`).join('
') : 'aucune base liée'; - return `
- - - - - - - -
${resume}
-
`; - }).join('') : '
Aucune application. Une VM peut en porter plusieurs.
'; - cible.innerHTML = ` -
-
Applications ${applications.length}
-
${lignes}
+ const tuiles = applications.length + ? `
${applications.map((a, i) => carteApp(a, i)).join('')}
` + : '
Aucune application. Une VM peut en porter plusieurs.
'; + cible.innerHTML = `
+
Applications ${applications.length}
+ ${tuiles}
`; + } + function inspecteurApplication() { + const cible = document.getElementById('detail'); + if (selApp == null || !applications[selApp]) { + cible.innerHTML = '
Aucune application sélectionnéeChoisis une tuile à gauche, ou ajoute une application.
'; + return; + } + const a = applications[selApp]; + const optGroupe = groupes.map(g => ``).join(''); + const optHote = '' + hotes.map(h => h.nom).filter(Boolean).map(n => ``).join(''); + const dsns = basesDeApp(a, a.hote); + const liens = dsns.length + ? `
${dsns.map(b => `
${echapper(b.portee || 'groupe')} ${echapper(dsnBase(b))}
`).join('')}
` + : '
aucune base liée
'; + cible.innerHTML = `
+
${echapper(a.id || '(sans id)')}
+ +
+
+ + + + + +
-
`; +
Bases consommées
+ ${liens} +
Sauvegarde (Ctrl+S) puis « Appliquer le plan » pour régénérer.
+
`; } function listeDepuisTexte(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); } @@ -1249,64 +1227,128 @@ HTML = r""" 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 marquerServeursModifie() { serveursModifie = true; majIndicateurSauvegarde(); } + function choisirServeur(i) { selServeur = i; dessiner(); } + function definirServeur(champ, v) { + const s = serveurs[selServeur]; if (!s) return; + s[champ] = v; marquerServeursModifie(); + if (champ === 'nom') { const t = document.querySelector('#tuile-srv-' + selServeur + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } } + else if (champ === 'fonction' || champ === 'etat') dessiner(); } - function definirServeur(i, champ, v) { serveurs[i][champ] = v; marquerServeursModifie(); if (champ === 'nom' || champ === 'fonction') dessinerServeurs(); } - function basculerIntegration(i, role, actif) { - const set = new Set(listeDepuisTexte(serveurs[i].integrations)); + function basculerIntegration(role, actif) { + const s = serveurs[selServeur]; if (!s) return; + const set = new Set(listeDepuisTexte(s.integrations)); if (actif) set.add(role); else set.delete(role); - serveurs[i].integrations = Array.from(set).join(', '); - marquerServeursModifie(); + s.integrations = Array.from(set).join(', '); marquerServeursModifie(); } - 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 ajouterServeur() { + serveurs.unshift({nom: '', fonction: (Object.keys(nomenclature.fonctions || {})[0] || ''), etat: 'planifie', noeud: '', stockage: '', disque: '', memoire: '', coeurs: '', integrations: '', statut: 'absent', vmid: '', adresse_ip: '', vlan: ''}); + selServeur = 0; marquerServeursModifie(); dessiner(); + const c = document.querySelector('#detail .grille input'); if (c) c.focus(); + } + function retirerServeur() { + const s = serveurs[selServeur]; if (!s) return; + if (s.nom && !confirm('Retirer le serveur « ' + s.nom + ' » du plan ?')) return; + serveurs.splice(selServeur, 1); + selServeur = serveurs.length ? Math.min(selServeur, serveurs.length - 1) : null; + marquerServeursModifie(); dessiner(); + } + function ouvrirApp(id) { const i = applications.findIndex(a => a.id === id); selApp = i >= 0 ? i : null; setVue('applications'); } + function ouvrirBaseServeur(nom) { const i = bdServeurs.findIndex(x => x.nom === nom); selBase = i >= 0 ? 'srv:' + i : null; setVue('bases'); } + function carteServeur(s, i) { + const actif = s.etat === 'actif'; + const recon = s.statut === 'divergence' ? '⚠ divergent' + : s.statut === 'absent' ? '○ non instancié' : ''; + return ``; + } function dessinerServeurs() { const cible = document.getElementById('grilles'); - const optFonction = (sel) => Object.keys(nomenclature.fonctions || {}).map(f => ``).join(''); - const optEtat = (sel) => ['planifie', 'actif'].map(e => ``).join(''); + const entete = BANNIERE_MODELE + `
Serveurs ${serveurs.length} + +
`; + if (!serveurs.length) { cible.innerHTML = entete + '
Aucun serveur. « + Serveur » ou make serveurs-bootstrap.
'; return; } + const idx = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s)); + const sections = [['Actifs', idx.filter(([s]) => s.etat === 'actif')], ['Planifiés', idx.filter(([s]) => s.etat !== 'actif')]]; + cible.innerHTML = entete + sections.filter(([, l]) => l.length).map(([t, l]) => + `
${t} ${l.length}
${l.map(([s, i]) => carteServeur(s, i)).join('')}
`).join(''); + } + function inspecteurServeur() { + const cible = document.getElementById('detail'); + if (selServeur == null || !serveurs[selServeur]) { + hoteOpCourant = ''; + cible.innerHTML = '
Aucun serveur sélectionnéChoisis une tuile à gauche, ou ajoute un serveur.
'; + return; + } + const s = serveurs[selServeur]; const vi = (intrants && intrants.valeurs) || {}; const libDef = (d) => '(défaut' + (d ? ' : ' + d : '') + ')'; - const optDeListe = (liste, sel, libelleDefaut) => { - const vals = [...new Set([...(liste || []), ...(sel ? [sel] : [])])]; - return `` - + vals.map(v => ``).join(''); - }; - const casesInteg = (i, txt) => { - const set = new Set(listeDepuisTexte(txt)); - const tous = [...new Set([...integrationsDispo, ...set])]; - if (!tous.length) return 'aucun rôle client_* disponible'; - return tous.map(role => ``).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}
-
${lignes}
-
- - -
-
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).
-
`; + const optFonction = Object.keys(nomenclature.fonctions || {}).map(f => ``).join(''); + const optEtat = ['planifie', 'actif'].map(e => ``).join(''); + const optDeListe = (liste, sel, lib) => `` + + [...new Set([...(liste || []), ...(sel ? [sel] : [])])].map(v => ``).join(''); + const set = new Set(listeDepuisTexte(s.integrations)); + const tousRoles = [...new Set([...integrationsDispo, ...set])]; + const cases = tousRoles.length + ? tousRoles.map(role => ``).join('') + : 'aucun rôle client_* disponible'; + const info = infoFonction(s.fonction); + const zoneHint = info ? `${echapper(info.libelle)} · VLAN ${info.vlan}` : ''; + const h = hotes.find(x => x.nom === s.nom); + const grpHtml = h && (h.groupes || []).length + ? `
${h.groupes.map(g => renduGroupe(h, g)).join('')}
` + : `
${s.statut === 'absent' ? 'Pas encore instancié — « Appliquer le plan ».' : 'Aucun groupe.'}
`; + const apps = applications.filter(a => a.hote === s.nom); + const appsHtml = apps.length + ? `
${apps.map(a => `
app ${a.expose ? ` → ${echapper(listeDepuisTexte(a.expose).join(', '))}` : ''}
`).join('')}
` + : '
aucune application sur cet hôte
'; + const bds = bdServeurs.filter(x => x.hote === s.nom); + const bdHtml = bds.length + ? `
${bds.map(x => `
${echapper(x.type)} :${echapper(x.port)}
`).join('')}
` + : '
aucun serveur de base ici
'; + hoteOpCourant = s.nom; + let pied; + if (h && h.etat === 'actif' && !hoteBloque(h) && estProduction) { + const hi = hotes.indexOf(h); + pied = `
+ +
`; + } else { + const txt = !h ? 'Applique le plan pour instancier cet hôte' : (h.etat !== 'actif' ? 'Hôte actif requis pour déployer' : (!estProduction ? 'Déploiement : inventaire production requis' : 'Prérequis manquant')); + pied = `
${txt}
`; + } + const statBadge = s.statut === 'divergence' ? 'divergent' + : s.statut === 'absent' ? 'non instancié' + : 'réconcilié'; + cible.innerHTML = `
+
${echapper(s.nom || '(sans nom)')}${zoneHint}
+ ${statBadge} +
+
${tuile(s.vmid, 'VMID')}${tuile(s.adresse_ip, 'IP')}${tuile(s.vlan, 'VLAN')}
+
Identité — saisie du plan (surcharge les défauts)
+
+ + + + + + + + + +
+
Groupes (dérivés)
${grpHtml} +
Applications ici
${appsHtml} +
Bases ici
${bdHtml} +
VMID / IP / VLAN sont dérivés. Sauvegarde (Ctrl+S) puis « Appliquer le plan » pour régénérer l'inventaire.
+ ${pied} +
`; + majBoutonsDeploiement(); } function serveurValeurValide(cle, v) { @@ -1325,7 +1367,7 @@ HTML = r""" } async function sauvegarderServeurs() { - const invalides = Array.from(document.querySelectorAll('#grilles [data-v]')).filter(el => !validerChampServeur(el)); + const invalides = Array.from(document.querySelectorAll('#detail [data-v]')).filter(el => !validerChampServeur(el)); if (invalides.length) { message('Corrige les ' + invalides.length + ' champ(s) en rouge avant de sauvegarder.', 'erreur'); invalides[0].focus(); @@ -1385,82 +1427,22 @@ HTML = r""" }).join(''); } - function champLecture(h, cle, libelle, opts={}) { - const brut = h[cle]; - const v = (brut === '' || brut == null) ? '—' : String(brut); - const unite = opts.unite ? ` · ${echapper(opts.unite)}` : ''; - return ``; - } - function tuile(valeur, libelle) { const v = (valeur === '' || valeur == null) ? '—' : String(valeur); return `
${echapper(v)}
${echapper(libelle)}
`; } function dessinerDetail() { - const cible = document.getElementById('detail'); - if (selection < 0 || !hotes[selection]) { - cible.innerHTML = '
Aucun hôte sélectionnéChoisissez une carte pour l\'inspecter.
'; - return; - } - const index = selection, h = hotes[index]; - const actif = h.etat === 'actif'; - const info = infoFonction(fonctionDe(h.nom).fonction); - const zoneHint = info ? `${echapper(info.libelle)} · VLAN ${info.vlan}` : ''; - const st = statutHote(h); - const libelle = actif ? (hoteBloque(h) ? 'prérequis manquant' : 'déployable') : 'planifié'; - let pied; - if (actif && !hoteBloque(h) && estProduction) { - pied = `
- -
`; - } else { - const txt = !actif ? 'Hôte actif requis pour déployer' : (!estProduction ? 'Déploiement : inventaire production requis' : 'Prérequis manquant'); - pied = `
${txt}${libelle}
`; - } - const onglet = ONGLETS[ongletActif]; - let corps; - if (onglet) { - corps = `
${onglet.map(([c, l, o]) => champLecture(h, c, l, o)).join('')}
`; - } else if (ongletActif === 'chaine') { - corps = renduChaine(h); - } else { - const gs = h.groupes || []; - corps = gs.length - ? `
${gs.map(g => renduGroupe(h, g)).join('')}
` - : '
Aucun groupe rattaché à cet hôte.
'; - } - cible.innerHTML = ` -
-
-
${echapper(h.nom || '(sans nom)')}${zoneHint}
- - ${libelleStatut(st)} -
-
- ${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')} -
-
- - - - -
-
${corps}
- ${pied} -
`; - majBoutonsDeploiement(); + // Le panneau de droite reflète la sélection de la VUE COURANTE. + if (vuePrincipale === 'applications') return inspecteurApplication(); + if (vuePrincipale === 'bases') return inspecteurBase(); + return inspecteurServeur(); } function majBoutonsDeploiement() { const bv = document.getElementById('btn-verifier'); const bd = document.getElementById('btn-deployer'); - const hote = selection >= 0 && hotes[selection] ? (hotes[selection].nom || '') : ''; + const hote = hoteOpCourant || ''; if (bv) { bv.disabled = occupe; bv.title = occupe ? 'Exécution en cours' : 'Dry-run (--check --diff), aucune modification'; } if (bd) { bd.disabled = occupe || !verifie[hote]; bd.title = !verifie[hote] ? 'Vérifiez (dry-run) d\'abord' : 'Applique les playbooks sur la VM réelle'; } } @@ -1718,18 +1700,18 @@ HTML = r""" else message('Vue en lecture seule — édite dans Serveurs / Applications / Bases.'); } - function ordreHotesVisibles() { - const vis = hotes.map((h, i) => [h, i]).filter(([h]) => correspond(h)); - return [...vis.filter(([h]) => h.etat === 'actif'), ...vis.filter(([h]) => h.etat !== 'actif')].map(([, i]) => i); + function ordreServeursVisibles() { + const vis = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s)); + return [...vis.filter(([s]) => s.etat === 'actif'), ...vis.filter(([s]) => s.etat !== 'actif')].map(([, i]) => i); } - function naviguerHotes(delta) { - const ordre = ordreHotesVisibles(); + function naviguerServeurs(delta) { + const ordre = ordreServeursVisibles(); if (!ordre.length) return; - let pos = ordre.indexOf(selection); + let pos = ordre.indexOf(selServeur); pos = pos < 0 ? (delta > 0 ? 0 : ordre.length - 1) : Math.min(ordre.length - 1, Math.max(0, pos + delta)); - selectionner(ordre[pos]); - const c = document.getElementById('carte-' + ordre[pos]); + choisirServeur(ordre[pos]); + const c = document.getElementById('tuile-srv-' + ordre[pos]); if (c) c.scrollIntoView({block: 'nearest', behavior: 'smooth'}); } @@ -1738,14 +1720,14 @@ HTML = r""" if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); sauvegarderVue(); return; } const dansSaisie = /^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement || {}).tagName || ''); if (dansSaisie || e.ctrlKey || e.metaKey || e.altKey) return; - const vues = {'1': 'cartes', '2': 'serveurs', '3': 'applications', '4': 'bases'}; + const vues = {'1': 'serveurs', '2': 'applications', '3': 'bases'}; if (vues[e.key]) { setVue(vues[e.key]); return; } if (e.key === '/') { e.preventDefault(); const f = document.getElementById('filtre'); if (f) f.focus(); return; } - if (vuePrincipale !== 'cartes') return; - if (e.key === 'j') { naviguerHotes(1); return; } - if (e.key === 'k') { naviguerHotes(-1); return; } - if (e.key === 'v') { const b = document.getElementById('btn-verifier'); if (b && !b.disabled) lancer('verifier', selection); return; } - if (e.key === 'd') { const b = document.getElementById('btn-deployer'); if (b && !b.disabled) lancer('deployer', selection); return; } + if (vuePrincipale !== 'serveurs') return; + if (e.key === 'j') { naviguerServeurs(1); return; } + if (e.key === 'k') { naviguerServeurs(-1); return; } + if (e.key === 'v') { const b = document.getElementById('btn-verifier'); if (b && !b.disabled) b.click(); return; } + if (e.key === 'd') { const b = document.getElementById('btn-deployer'); if (b && !b.disabled) b.click(); return; } }); // Validation inline des champs du plan (vue Serveurs) — liseré rouge avant l'envoi.