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:
parent
441dd284bb
commit
a21a6b4e02
2 changed files with 201 additions and 141 deletions
33
CHANGELOG.md
33
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**
|
||||
|
|
|
|||
|
|
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 ------
|
||||
|
|
|
|||
Loading…
Reference in a new issue