From a21a6b4e02843e879bbf892369da053f98ef1a0e Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 30 Jun 2026 12:19:26 -0400 Subject: [PATCH] =?UTF-8?q?GUI=20:=20fluidit=C3=A9=20d'exploitation=20et?= =?UTF-8?q?=20vue=20Inventaire=20en=20lecture=20seule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications empilées (toasts) au lieu d'une bannière écrasée, durée d'exécution en direct sur les opérations longues, navigation clavier (1-5 / j-k / v / d / Ctrl+S contextuel), validation inline des champs du plan (vue Serveurs), garde-fou beforeunload, bouton Sauvegarder contextuel (fin de l'impasse 409). Convertit le détail de la vue Inventaire (cartes) en inspection lecture seule : la vue annonçait « généré depuis le plan » tout en exposant une surface d'édition non persistable. Retire les contrôles orphelins (+ Hôte, ✕, bascule d'état, ✨ Proposer, champs et groupes éditables) et le code mort associé ; conserve Vérifier / Déployer et les onglets. CSS inutilisé nettoyé. L'édition reste dans Serveurs / Applications / Bases. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 33 +++++ scripts/inventory_gui.py | 309 +++++++++++++++++++++------------------ 2 files changed, 201 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55c3588..9dfe0d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ ## 2026-06-30 ### 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 + écrasée ; les erreurs restent plus longtemps, les opérations longues mettent à + jour leur propre toast. + - **Durée d'exécution** affichée en direct dans la console (Vérifier / Déployer) et + suivi vivant de « Appliquer le plan ». + - **Navigation clavier** : `1-5` changent de vue, `j/k` parcourent les hôtes, + `v`/`d` vérifient/déploient l'hôte sélectionné, `/` cible le filtre, `Ctrl+S` + sauvegarde la vue éditable courante (ignorés pendant la saisie). + - **Validation inline** des champs du plan (vue Serveurs) : nom `fonction-NN`, + mémoire, cœurs, disque — liseré rouge et blocage avant l'envoi. + - **Garde-fou anti-perte** : confirmation `beforeunload` si des éditions de plan ne + sont pas sauvegardées. + - Le bouton **Sauvegarder** de l'en-tête devient contextuel (sauve la vue éditable, + désactivé en lecture seule) — fin de l'impasse 409 ; suppression du code mort. + +### Modifié +- **Vue Inventaire (cartes) : détail converti en inspection lecture seule.** La vue + affichait « généré depuis le plan (lecture seule) » tout en exposant une surface + d'édition (+ Hôte, ✕, bascule actif/planifié, ✨ Proposer, champs et cases de + groupes éditables) qui ne pouvait pas être persistée (l'inventaire est généré ; + `/api/inventaire` renvoie 409). Ces contrôles orphelins sont retirés : nom, champs + Réseau/Proxmox et groupes en lecture seule, état affiché en badge. **Vérifier / + Déployer** et les onglets d'inspection sont conservés. L'édition reste dans les vues + **Serveurs / Applications / Bases**. Code mort supprimé (`ajouterHote`, + `supprimerHote`, `definir`, `definirNom`, `definirEtat`, `basculerGroupe`, + `proposer`, `autoProposer`, `prochainSeqLibre`, `marquerModifie`, état `modifie`). +- **Référence des paramètres de `make config`** (`docs/config-proxmox.md`). + Explique un par un les 16 paramètres Proxmox non sensibles + les secrets API + demandés par l'assistant : sens, valeur par défaut, quoi saisir, et quels champs + sont des constantes vs des défauts surchargeables par hôte. Renvois ajoutés depuis + `make help` et `QUICKSTART.md`. Comble un trou : ces invites n'étaient expliquées + nulle part de façon pérenne (impératif « exploitable sans IA »). - **Panneau « Intrants de base » dans le GUI.** Un bouton ⚙ Intrants ouvre une fenêtre unique pour saisir les valeurs communes à tout l'écosystème, avec une distinction visible entre **constantes** (valeur unique, non surchargeable) et **défauts** diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index f94a630..fafb439 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -556,6 +556,14 @@ HTML = r""" 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); } + .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; } + .toast { pointer-events: auto; background: var(--surface-2); border: 1px solid var(--ligne-forte); border-left: 3px solid var(--bleu); border-radius: var(--rayon-sm); box-shadow: var(--ombre-forte); padding: 9px 12px; font-size: 13px; font-weight: 600; color: var(--texte); display: flex; gap: 9px; align-items: flex-start; animation: toast-in .18s ease-out; } + .toast.ok { border-left-color: var(--vert); } .toast.erreur { border-left-color: var(--rouge); } + .toast.sortant { opacity: 0; transform: translateX(12px); transition: opacity .25s, transform .25s; } + .toast .x { margin-left: auto; cursor: pointer; opacity: .5; font-weight: 800; background: none; border: 0; color: inherit; padding: 0 2px; font-size: 15px; line-height: 1; } + .toast .x:hover { opacity: 1; } + @keyframes toast-in { from { opacity: 0; transform: translateX(12px); } } + input.invalide, select.invalide { border-color: var(--rouge) !important; box-shadow: 0 0 0 1px var(--rouge); } .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; } @@ -601,13 +609,7 @@ HTML = r""" .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; } @@ -623,6 +625,9 @@ 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; } + .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; } #infobulle { position: fixed; z-index: 200; max-width: 260px; pointer-events: none; background: var(--surface-3); color: var(--texte); @@ -633,14 +638,12 @@ HTML = r""" } #infobulle.on { opacity: 1; transform: none; } .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 { display: block; border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 8px 10px; background: var(--surface-2); transition: border-color .12s, background .12s; } .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; } @@ -720,12 +723,13 @@ HTML = r""" +

Set-OPS

Votre artisan numérique
-
+
@@ -734,10 +738,9 @@ HTML = r"""
- - +
@@ -808,12 +811,13 @@ HTML = r""" let selection = -1; let selectionNom = null; let ongletActif = 'reseau'; - let modifie = false; let occupe = false; let verifie = {}; let motVault = ''; let modaleResolveur = null; let intrants = null; + let chronoConsole = null; + let consoleDebut = 0; const ONGLETS = { reseau: [ @@ -835,10 +839,36 @@ HTML = r""" ], }; - function message(texte, type='') { - const el = document.getElementById('message'); - el.textContent = texte; - el.className = 'message' + (type ? ' ' + type : ''); + let toastSeq = 0; + function message(texte, type='', opts={}) { + const conteneur = document.getElementById('toasts'); + if (!conteneur) return null; + const t = document.createElement('div'); + t.className = 'toast' + (type ? ' ' + type : ''); + t.id = 'toast-' + (++toastSeq); + const corps = document.createElement('span'); + corps.textContent = texte; + t.appendChild(corps); + const x = document.createElement('button'); + x.type = 'button'; x.className = 'x'; x.textContent = '×'; + x.onclick = () => retirerToast(t); + t.appendChild(x); + conteneur.appendChild(t); + while (conteneur.children.length > 6) conteneur.removeChild(conteneur.firstChild); + const duree = opts.persistant ? 0 : (opts.duree != null ? opts.duree : (type === 'erreur' ? 7000 : 3500)); + if (duree > 0) setTimeout(() => retirerToast(t), duree); + return t; // renvoie le nœud pour permettre de le mettre à jour (opérations longues) + } + function majToast(t, texte, type, dureeFin) { + if (!t) return; + if (type !== undefined) t.className = 'toast' + (type ? ' ' + type : ''); + const corps = t.querySelector('span'); if (corps) corps.textContent = texte; + if (dureeFin) setTimeout(() => retirerToast(t), dureeFin); + } + function retirerToast(t) { + if (!t || !t.parentNode) return; + t.classList.add('sortant'); + setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 260); } async function charger() { @@ -854,7 +884,7 @@ HTML = r""" chargerServeurs(data); hotes = data.hotes; estProduction = !!data.production; - modifie = false; verifie = {}; + verifie = {}; document.getElementById('chemin').textContent = data.inventaire; restaurerSelection(); dessiner(); @@ -877,40 +907,6 @@ HTML = r""" dessiner(); } - function marquerModifie() { modifie = true; verifie = {}; majIndicateurSauvegarde(); majBoutonsDeploiement(); } - - function ajouterHote() { - hotes.unshift({nom:'', etat:'planifie', utilisateur_ansible:'ansible', groupes:['serveur_debian','serveur_durci']}); - 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(); } @@ -936,7 +932,15 @@ HTML = r""" function majIndicateurSauvegarde() { const b = document.getElementById('btn-sauver'); - if (b) { b.classList.toggle('attention', modifie); b.textContent = modifie ? 'Sauvegarder •' : 'Sauvegarder'; } + if (!b) return; + const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'applications' || vuePrincipale === 'bases'; + const sale = (vuePrincipale === 'serveurs' && serveursModifie) + || (vuePrincipale === 'applications' && applicationsModifie) + || (vuePrincipale === 'bases' && basesModifie); + b.classList.toggle('attention', sale); + b.textContent = sale ? 'Sauvegarder •' : 'Sauvegarder'; + b.disabled = !editable; + b.title = editable ? 'Sauvegarder cette vue (Ctrl+S)' : 'Lecture seule — édite dans Serveurs / Applications / Bases'; } function dessinerResume() { @@ -992,6 +996,7 @@ HTML = r""" document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications'); document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases'); dessinerGrilles(); + majIndicateurSauvegarde(); } function dessinerGrilles() { @@ -1257,14 +1262,14 @@ HTML = r""" 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)}
@@ -1282,7 +1287,28 @@ HTML = r"""
`; } + function serveurValeurValide(cle, v) { + const s = String(v == null ? '' : v).trim(); + if (s === '') return true; // vide = dérivé / défaut, autorisé + if (cle === 'nom') return /^[a-z0-9]+(-[a-z0-9]+)*-\d+$/.test(s); + if (cle === 'memoire') return /^\d+$/.test(s) && +s >= 64 && +s <= 1048576; + if (cle === 'coeurs') return /^\d+$/.test(s) && +s >= 1 && +s <= 256; + if (cle === 'disque') return /^\d+\s*[GMgm]?$/.test(s); + return true; + } + function validerChampServeur(el) { + const ok = serveurValeurValide(el.getAttribute('data-v'), el.value); + el.classList.toggle('invalide', !ok); + return ok; + } + async function sauvegarderServeurs() { + const invalides = Array.from(document.querySelectorAll('#grilles [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(); + return; + } const srv = {}; serveurs.forEach(s => { if (!String(s.nom).trim()) return; @@ -1302,12 +1328,16 @@ HTML = r""" } 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(); + const t = message('Application du plan…', '', {persistant: true}); + try { + 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(); + majToast(t, (data.ok ? 'Plan appliqué : ' : 'Échec : ') + derniere, data.ok ? 'ok' : 'erreur', data.ok ? 4000 : 8000); + if (data.ok) charger(); + } catch (e) { + majToast(t, 'Erreur réseau : ' + e, 'erreur', 8000); + } } function dessinerDependances() { @@ -1333,16 +1363,12 @@ HTML = r""" }).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(' '); + 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 ``; +
${echapper(v)}
`; } function tuile(valeur, libelle) { @@ -1353,7 +1379,7 @@ HTML = r""" function dessinerDetail() { const cible = document.getElementById('detail'); if (selection < 0 || !hotes[selection]) { - cible.innerHTML = '
Aucun hôte sélectionnéChoisissez une carte, ou ajoutez un hôte.
'; + cible.innerHTML = '
Aucun hôte sélectionnéChoisissez une carte pour l\'inspecter.
'; return; } const index = selection, h = hotes[index]; @@ -1374,22 +1400,21 @@ HTML = r""" const onglet = ONGLETS[ongletActif]; let corps; if (onglet) { - corps = `
${onglet.map(([c, l, o]) => champ(index, c, l, o)).join('')}
`; + corps = `
${onglet.map(([c, l, o]) => champLecture(h, c, l, o)).join('')}
`; } else if (ongletActif === 'chaine') { corps = renduChaine(h); } else { - corps = `
${groupes.map(g => renduGroupe(index, h, g)).join('')}
`; + const gs = h.groupes || []; + corps = gs.length + ? `
${gs.map(g => renduGroupe(h, g)).join('')}
` + : '
Aucun groupe rattaché à cet hôte.
'; } cible.innerHTML = `
-
${zoneHint}
- -
- - -
- +
${echapper(h.nom || '(sans nom)')}${zoneHint}
+ + ${libelleStatut(st)}
${tuile(h.vmid, 'VMID')} @@ -1414,26 +1439,22 @@ HTML = r""" 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'); } + 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'; } } - function renduGroupe(index, hote, groupe) { - const selectionne = (hote.groupes || []).includes(groupe); + function renduGroupe(hote, 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'; + let statut = 'selectionne'; + if (hote.etat === 'actif' && manquants.length) statut = 'bloque'; + else if (hote.etat === 'actif') statut = 'ok'; + else if ((config.requiert_groupes_actifs || []).length) statut = 'attente'; return ` -