GUI : fluidité d'exploitation et vue Inventaire en lecture seule

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-06-30 12:19:26 -04:00
parent 441dd284bb
commit a21a6b4e02
2 changed files with 201 additions and 141 deletions

View file

@ -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**

View file

@ -556,6 +556,14 @@ HTML = r"""<!doctype html>
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"""<!doctype html>
.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"""<!doctype html>
.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"""<!doctype html>
}
#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"""<!doctype html>
</style>
</head>
<body>
<div class="toasts" id="toasts" aria-live="polite"></div>
<header>
<div class="marque"><span class="pastille"></span><div class="titre"><h1>Set-OPS</h1><span class="slogan">Votre artisan numérique</span></div></div>
<span class="chemin" id="chemin"></span>
<div class="grandit"></div>
<div class="resume" id="resume"></div>
<div class="barre">
<div class="barre" title="Raccourcis clavier — 1-5 : changer de vue · j/k : naviguer les hôtes · v : vérifier · d : déployer · / : filtrer · Ctrl+S : sauvegarder la vue">
<div class="vue-bascule">
<button type="button" id="btn-vue-cartes" class="on" onclick="setVue('cartes')">Inventaire</button>
<button type="button" id="btn-vue-serveurs" onclick="setVue('serveurs')">Serveurs</button>
@ -734,10 +738,9 @@ HTML = r"""<!doctype html>
<button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button>
</div>
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
<button type="button" class="fantome" onclick="ajouterHote()">+ Hôte</button>
<button type="button" class="fantome" onclick="ouvrirIntrants()"> Intrants</button>
<button type="button" class="fantome" onclick="charger()">Recharger</button>
<button type="button" class="primaire" id="btn-sauver" onclick="sauvegarder()">Sauvegarder</button>
<button type="button" class="primaire" id="btn-sauver" onclick="sauvegarderVue()">Sauvegarder</button>
</div>
</header>
<main>
@ -808,12 +811,13 @@ HTML = r"""<!doctype html>
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"""<!doctype html>
],
};
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"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
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>Serveur (fonction-NN)</span><input data-v="nom" 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="noeud-1" oninput="definirServeur(${i}, 'noeud', this.value)"></label>
<label class="champ"><span>Stockage</span><input value="${echapper(s.stockage)}" placeholder="stockage-1" 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>Disque</span><input data-v="disque" value="${echapper(s.disque)}" placeholder="16G" oninput="definirServeur(${i}, 'disque', this.value)"></label>
<label class="champ"><span>Mémoire</span><input data-v="memoire" type="number" value="${echapper(s.memoire)}" placeholder="2048" oninput="definirServeur(${i}, 'memoire', this.value)"></label>
<label class="champ"><span>Cœurs</span><input data-v="coeurs" 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="client_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>
@ -1282,7 +1287,28 @@ HTML = r"""<!doctype html>
</div>`;
}
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"""<!doctype html>
}
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"""<!doctype html>
}).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 ? ` <span class="unite">· ${echapper(opts.unite)}</span>` : '';
return `<label class="champ"><span>${echapper(libelle)}${unite}</span>
<input ${attrs} oninput="definir(${index}, '${cle}', this.value)"></label>`;
<div class="champ-val ${v === '' ? 'vide' : ''}">${echapper(v)}</div></label>`;
}
function tuile(valeur, libelle) {
@ -1353,7 +1379,7 @@ HTML = r"""<!doctype html>
function dessinerDetail() {
const cible = document.getElementById('detail');
if (selection < 0 || !hotes[selection]) {
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun hôte sélectionné</b>Choisissez une carte, ou ajoutez un hôte.</div></div>';
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun hôte sélectionné</b>Choisissez une carte pour l\'inspecter.</div></div>';
return;
}
const index = selection, h = hotes[index];
@ -1374,22 +1400,21 @@ HTML = r"""<!doctype html>
const onglet = ONGLETS[ongletActif];
let corps;
if (onglet) {
corps = `<div class="grille">${onglet.map(([c, l, o]) => champ(index, c, l, o)).join('')}</div>`;
corps = `<div class="grille">${onglet.map(([c, l, o]) => champLecture(h, c, l, o)).join('')}</div>`;
} else if (ongletActif === 'chaine') {
corps = renduChaine(h);
} else {
corps = `<div class="groupes-liste">${groupes.map(g => renduGroupe(index, h, g)).join('')}</div>`;
const gs = h.groupes || [];
corps = gs.length
? `<div class="groupes-liste">${gs.map(g => renduGroupe(h, g)).join('')}</div>`
: '<div class="vide" style="padding:14px">Aucun groupe rattaché à cet hôte.</div>';
}
cible.innerHTML = `
<article class="detail ${hoteBloque(h) ? 'bloque' : ''}">
<div class="detail-tete">
<div class="detail-nom"><input value="${echapper(h.nom || '')}" placeholder="fonction-NN (ex. web-frontal-03)" oninput="definirNom(${index}, this.value)" onchange="autoProposer(${index})">${zoneHint}</div>
<button type="button" class="fantome" title="Proposer VMID, VLAN, IP et passerelle depuis la nomenclature" onclick="proposer(${index})"> Proposer</button>
<div class="etat-bascule">
<button type="button" class="${actif ? 'on actif' : ''}" onclick="definirEtat(${index}, 'actif')">actif</button>
<button type="button" class="${!actif ? 'on' : ''}" onclick="definirEtat(${index}, 'planifie')">planifié</button>
</div>
<button type="button" class="danger" title="Retirer" onclick="supprimerHote(${index})"></button>
<div class="detail-nom"><span class="detail-nom-txt">${echapper(h.nom || '(sans nom)')}</span>${zoneHint}</div>
<span class="grandit"></span>
<span class="badge-statut ${st}" title="État défini dans le plan (vue Serveurs)">${libelleStatut(st)}</span>
</div>
<div class="stats">
${tuile(h.vmid, 'VMID')}
@ -1414,26 +1439,22 @@ HTML = r"""<!doctype html>
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 `
<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>
<div class="groupe ${statut}" title="${echapper(titreGroupe(groupe))}">
<span class="groupe-ligne"><span>${echapper(groupe)}</span></span>
<span class="groupe-detail">${echapper(detailGroupe(groupe, hote))}</span>
</label>`;
</div>`;
}
function detailGroupe(groupe, hote) {
@ -1465,32 +1486,6 @@ HTML = r"""<!doctype html>
if (!c) return null;
return {categorie: d.categorie, service: d.service, libelle: c.libelle, vlan: c.vlan, sous_reseau: c.sous_reseau, passerelle: c.passerelle};
}
function prochainSeqLibre(fonction) {
const pris = new Set();
hotes.forEach(h => { const x = fonctionDe(h.nom); if (x.fonction === fonction && x.seq != null) pris.add(x.seq); });
let s = 1; while (pris.has(s)) s++; return s;
}
function proposer(index, silencieux=false) {
const h = hotes[index];
let {fonction, seq} = fonctionDe(h.nom);
const info = infoFonction(fonction);
if (!info) { if (!silencieux) message('Fonction inconnue de la nomenclature : ' + (fonction || '(vide)'), 'erreur'); return; }
if (seq == null) { seq = prochainSeqLibre(fonction); h.nom = fonction + '-' + String(seq).padStart(2, '0'); selectionNom = h.nom; }
if (seq > 9) { if (!silencieux) message('Séquence > 9 pour ' + fonction + ' : hors plage d\'adressage, à saisir à la main.', 'erreur'); return; }
const base3 = info.sous_reseau.split('.').slice(0, 3).join('.');
h.vmid = '9' + info.categorie + info.service + String(seq).padStart(2, '0');
h.vlan = String(info.vlan);
h.adresse_ip = base3 + '.' + (info.service * 10 + seq);
h.passerelle = info.passerelle;
h.cidr = String(nomenclature.cidr_hote || 24);
marquerModifie(); dessiner();
if (!silencieux) message('Valeurs proposées pour ' + h.nom + ' (' + info.libelle + ', VLAN ' + info.vlan + ').', 'ok');
}
function autoProposer(index) {
const h = hotes[index];
if (!String(h.vmid || '').trim() && infoFonction(fonctionDe(h.nom).fonction)) proposer(index, true);
}
function echapper(valeur) {
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
@ -1502,12 +1497,20 @@ HTML = r"""<!doctype html>
etat.className = 'console-etat actif'; etat.textContent = '● en cours';
document.getElementById('btn-fermer-console').disabled = true;
document.getElementById('console').hidden = false;
consoleDebut = Date.now();
clearInterval(chronoConsole);
chronoConsole = setInterval(() => {
const s = Math.round((Date.now() - consoleDebut) / 1000);
etat.textContent = '● en cours · ' + s + 's';
}, 1000);
}
function ecrireConsole(t) { const p = document.getElementById('console-sortie'); p.textContent += t; p.scrollTop = p.scrollHeight; }
function finirConsole(rc) {
clearInterval(chronoConsole); chronoConsole = null;
const s = Math.round((Date.now() - consoleDebut) / 1000);
const etat = document.getElementById('console-etat');
etat.className = 'console-etat ' + (rc === 0 ? 'ok' : 'echec');
etat.textContent = rc === 0 ? '✓ succès' : '✗ échec (rc=' + rc + ')';
etat.textContent = (rc === 0 ? '✓ succès' : '✗ échec (rc=' + rc + ')') + ' · ' + s + 's';
document.getElementById('btn-fermer-console').disabled = false;
}
function fermerConsole() { if (!occupe) document.getElementById('console').hidden = true; }
@ -1636,8 +1639,7 @@ HTML = r"""<!doctype html>
async function lancer(mode, index) {
const hote = hotes[index] ? hotes[index].nom : '';
if (!hote) { message('Nommez l\'hôte d\'abord.', 'erreur'); return; }
if (modifie) { message('Sauvegardez avant d\'exécuter.', 'erreur'); return; }
if (!hote) { message('Hôte introuvable.', 'erreur'); return; }
if (occupe) return;
let vault = '';
if (mode === 'deployer') {
@ -1680,27 +1682,52 @@ HTML = r"""<!doctype html>
occupe = false; majBoutonsDeploiement();
}
async function sauvegarder() {
const reponse = await fetch('/api/inventaire', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({hotes})
});
const data = await reponse.json();
if (!reponse.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
dependances = data.dependances || {};
nomenclature = data.nomenclature || {};
chaine = data.chaine || {};
groupes = data.groupes;
hotes = data.hotes;
estProduction = !!data.production;
modifie = false; verifie = {};
restaurerSelection();
dessiner();
message('Inventaire sauvegardé.', 'ok');
function sauvegarderVue() {
if (occupe) return;
if (vuePrincipale === 'serveurs') sauvegarderServeurs();
else if (vuePrincipale === 'applications') sauvegarderApplications();
else if (vuePrincipale === 'bases') sauvegarderBases();
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 naviguerHotes(delta) {
const ordre = ordreHotesVisibles();
if (!ordre.length) return;
let pos = ordre.indexOf(selection);
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]);
if (c) c.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modaleResolveur) { annulerModale(); return; }
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (!occupe) sauvegarder(); }
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': 'chaine', '4': 'applications', '5': '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; }
});
// Validation inline des champs du plan (vue Serveurs) liseré rouge avant l'envoi.
document.addEventListener('input', e => {
if (e.target && e.target.matches && e.target.matches('[data-v]')) validerChampServeur(e.target);
});
// Garde-fou : prévenir la perte d'éditions de plan non sauvegardées.
window.addEventListener('beforeunload', e => {
if (serveursModifie || applicationsModifie || basesModifie) { e.preventDefault(); e.returnValue = ''; }
});
// ------ Info-bulles d'aide au survol de la description d'un champ ------