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
|
## 2026-06-30
|
||||||
|
|
||||||
### Ajouté
|
### 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
|
- **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
|
unique pour saisir les valeurs communes à tout l'écosystème, avec une distinction
|
||||||
visible entre **constantes** (valeur unique, non surchargeable) et **défauts**
|
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; }
|
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 { 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); }
|
.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; }
|
.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; }
|
.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.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-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 { 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); }
|
.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; }
|
.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 { 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; }
|
.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 > span { color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||||
.champ .unite { color: var(--faint); font-weight: 600; }
|
.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 > 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 {
|
#infobulle {
|
||||||
position: fixed; z-index: 200; max-width: 260px; pointer-events: none;
|
position: fixed; z-index: 200; max-width: 260px; pointer-events: none;
|
||||||
background: var(--surface-3); color: var(--texte);
|
background: var(--surface-3); color: var(--texte);
|
||||||
|
|
@ -633,14 +638,12 @@ HTML = r"""<!doctype html>
|
||||||
}
|
}
|
||||||
#infobulle.on { opacity: 1; transform: none; }
|
#infobulle.on { opacity: 1; transform: none; }
|
||||||
.groupes-liste { display: grid; gap: 7px; }
|
.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 { 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:hover { border-color: var(--ligne-forte); }
|
|
||||||
.groupe.selectionne { border-color: color-mix(in srgb, var(--bleu) 55%, var(--ligne)); background: var(--bleu-fond); }
|
.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.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.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.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 { 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; }
|
.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 { padding: 12px 16px; border-top: 1px solid var(--ligne); display: flex; gap: 8px; align-items: center; }
|
||||||
.pied .grandit { flex: 1; }
|
.pied .grandit { flex: 1; }
|
||||||
|
|
@ -720,12 +723,13 @@ HTML = r"""<!doctype html>
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||||
<header>
|
<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>
|
<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>
|
<span class="chemin" id="chemin"></span>
|
||||||
<div class="grandit"></div>
|
<div class="grandit"></div>
|
||||||
<div class="resume" id="resume"></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">
|
<div class="vue-bascule">
|
||||||
<button type="button" id="btn-vue-cartes" class="on" onclick="setVue('cartes')">Inventaire</button>
|
<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>
|
<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>
|
<button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
|
<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="ouvrirIntrants()">⚙ Intrants</button>
|
||||||
<button type="button" class="fantome" onclick="charger()">Recharger</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|
@ -808,12 +811,13 @@ HTML = r"""<!doctype html>
|
||||||
let selection = -1;
|
let selection = -1;
|
||||||
let selectionNom = null;
|
let selectionNom = null;
|
||||||
let ongletActif = 'reseau';
|
let ongletActif = 'reseau';
|
||||||
let modifie = false;
|
|
||||||
let occupe = false;
|
let occupe = false;
|
||||||
let verifie = {};
|
let verifie = {};
|
||||||
let motVault = '';
|
let motVault = '';
|
||||||
let modaleResolveur = null;
|
let modaleResolveur = null;
|
||||||
let intrants = null;
|
let intrants = null;
|
||||||
|
let chronoConsole = null;
|
||||||
|
let consoleDebut = 0;
|
||||||
|
|
||||||
const ONGLETS = {
|
const ONGLETS = {
|
||||||
reseau: [
|
reseau: [
|
||||||
|
|
@ -835,10 +839,36 @@ HTML = r"""<!doctype html>
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function message(texte, type='') {
|
let toastSeq = 0;
|
||||||
const el = document.getElementById('message');
|
function message(texte, type='', opts={}) {
|
||||||
el.textContent = texte;
|
const conteneur = document.getElementById('toasts');
|
||||||
el.className = 'message' + (type ? ' ' + type : '');
|
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() {
|
async function charger() {
|
||||||
|
|
@ -854,7 +884,7 @@ HTML = r"""<!doctype html>
|
||||||
chargerServeurs(data);
|
chargerServeurs(data);
|
||||||
hotes = data.hotes;
|
hotes = data.hotes;
|
||||||
estProduction = !!data.production;
|
estProduction = !!data.production;
|
||||||
modifie = false; verifie = {};
|
verifie = {};
|
||||||
document.getElementById('chemin').textContent = data.inventaire;
|
document.getElementById('chemin').textContent = data.inventaire;
|
||||||
restaurerSelection();
|
restaurerSelection();
|
||||||
dessiner();
|
dessiner();
|
||||||
|
|
@ -877,40 +907,6 @@ HTML = r"""<!doctype html>
|
||||||
dessiner();
|
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 setOnglet(o) { ongletActif = o; dessinerDetail(); }
|
||||||
function setFiltreEtat(e) { filtreEtat = e; dessinerChips(); dessinerGrilles(); }
|
function setFiltreEtat(e) { filtreEtat = e; dessinerChips(); dessinerGrilles(); }
|
||||||
function filtrer(valeur) { filtre = (valeur || '').trim().toLowerCase(); dessinerGrilles(); }
|
function filtrer(valeur) { filtre = (valeur || '').trim().toLowerCase(); dessinerGrilles(); }
|
||||||
|
|
@ -936,7 +932,15 @@ HTML = r"""<!doctype html>
|
||||||
|
|
||||||
function majIndicateurSauvegarde() {
|
function majIndicateurSauvegarde() {
|
||||||
const b = document.getElementById('btn-sauver');
|
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() {
|
function dessinerResume() {
|
||||||
|
|
@ -992,6 +996,7 @@ HTML = r"""<!doctype html>
|
||||||
document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications');
|
document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications');
|
||||||
document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases');
|
document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases');
|
||||||
dessinerGrilles();
|
dessinerGrilles();
|
||||||
|
majIndicateurSauvegarde();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dessinerGrilles() {
|
function dessinerGrilles() {
|
||||||
|
|
@ -1257,14 +1262,14 @@ HTML = r"""<!doctype html>
|
||||||
const lignes = serveurs.length ? serveurs.map((s, i) => {
|
const lignes = serveurs.length ? serveurs.map((s, i) => {
|
||||||
const d = deriveServeur(s.nom, s.fonction);
|
const d = deriveServeur(s.nom, s.fonction);
|
||||||
return `<div class="base-ligne">
|
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>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>É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>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>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>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 type="number" value="${echapper(s.memoire)}" placeholder="2048" oninput="definirServeur(${i}, 'memoire', 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 type="number" value="${echapper(s.coeurs)}" placeholder="2" oninput="definirServeur(${i}, 'coeurs', 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>
|
<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>
|
<button type="button" class="danger" title="Retirer" onclick="retirerServeur(${i})">✕</button>
|
||||||
<div class="dsn mono">dérivé → vmid ${echapper(d.vmid)} · ip ${echapper(d.ip)} · vlan ${echapper(d.vlan)}</div>
|
<div 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>`;
|
</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() {
|
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 = {};
|
const srv = {};
|
||||||
serveurs.forEach(s => {
|
serveurs.forEach(s => {
|
||||||
if (!String(s.nom).trim()) return;
|
if (!String(s.nom).trim()) return;
|
||||||
|
|
@ -1302,12 +1328,16 @@ HTML = r"""<!doctype html>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function appliquerPlan() {
|
async function appliquerPlan() {
|
||||||
message('Application du plan…');
|
const t = message('Application du plan…', '', {persistant: true});
|
||||||
const rep = await fetch('/api/instancier', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: '{}'});
|
try {
|
||||||
const data = await rep.json();
|
const rep = await fetch('/api/instancier', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: '{}'});
|
||||||
const derniere = (data.sortie || '').split('\n').pop();
|
const data = await rep.json();
|
||||||
message((data.ok ? 'Plan appliqué : ' : 'Échec : ') + derniere, data.ok ? 'ok' : 'erreur');
|
const derniere = (data.sortie || '').split('\n').pop();
|
||||||
if (data.ok) charger();
|
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() {
|
function dessinerDependances() {
|
||||||
|
|
@ -1333,16 +1363,12 @@ HTML = r"""<!doctype html>
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function champ(index, cle, libelle, opts={}) {
|
function champLecture(h, cle, libelle, opts={}) {
|
||||||
const h = hotes[index];
|
const brut = h[cle];
|
||||||
const type = opts.type || 'text';
|
const v = (brut === '' || brut == null) ? '—' : String(brut);
|
||||||
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(' ');
|
|
||||||
const unite = opts.unite ? ` <span class="unite">· ${echapper(opts.unite)}</span>` : '';
|
const unite = opts.unite ? ` <span class="unite">· ${echapper(opts.unite)}</span>` : '';
|
||||||
return `<label class="champ"><span>${echapper(libelle)}${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) {
|
function tuile(valeur, libelle) {
|
||||||
|
|
@ -1353,7 +1379,7 @@ HTML = r"""<!doctype html>
|
||||||
function dessinerDetail() {
|
function dessinerDetail() {
|
||||||
const cible = document.getElementById('detail');
|
const cible = document.getElementById('detail');
|
||||||
if (selection < 0 || !hotes[selection]) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const index = selection, h = hotes[index];
|
const index = selection, h = hotes[index];
|
||||||
|
|
@ -1374,22 +1400,21 @@ HTML = r"""<!doctype html>
|
||||||
const onglet = ONGLETS[ongletActif];
|
const onglet = ONGLETS[ongletActif];
|
||||||
let corps;
|
let corps;
|
||||||
if (onglet) {
|
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') {
|
} else if (ongletActif === 'chaine') {
|
||||||
corps = renduChaine(h);
|
corps = renduChaine(h);
|
||||||
} else {
|
} 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 = `
|
cible.innerHTML = `
|
||||||
<article class="detail ${hoteBloque(h) ? 'bloque' : ''}">
|
<article class="detail ${hoteBloque(h) ? 'bloque' : ''}">
|
||||||
<div class="detail-tete">
|
<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>
|
<div class="detail-nom"><span class="detail-nom-txt">${echapper(h.nom || '(sans nom)')}</span>${zoneHint}</div>
|
||||||
<button type="button" class="fantome" title="Proposer VMID, VLAN, IP et passerelle depuis la nomenclature" onclick="proposer(${index})">✨ Proposer</button>
|
<span class="grandit"></span>
|
||||||
<div class="etat-bascule">
|
<span class="badge-statut ${st}" title="État défini dans le plan (vue Serveurs)">${libelleStatut(st)}</span>
|
||||||
<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>
|
</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
${tuile(h.vmid, 'VMID')}
|
${tuile(h.vmid, 'VMID')}
|
||||||
|
|
@ -1414,26 +1439,22 @@ HTML = r"""<!doctype html>
|
||||||
const bv = document.getElementById('btn-verifier');
|
const bv = document.getElementById('btn-verifier');
|
||||||
const bd = document.getElementById('btn-deployer');
|
const bd = document.getElementById('btn-deployer');
|
||||||
const hote = selection >= 0 && hotes[selection] ? (hotes[selection].nom || '') : '';
|
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 (bv) { bv.disabled = occupe; bv.title = 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 (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) {
|
function renduGroupe(hote, groupe) {
|
||||||
const selectionne = (hote.groupes || []).includes(groupe);
|
|
||||||
const manquants = manquantsPourGroupe(groupe);
|
const manquants = manquantsPourGroupe(groupe);
|
||||||
const config = dependances[groupe] || {};
|
const config = dependances[groupe] || {};
|
||||||
let statut = selectionne ? 'selectionne' : '';
|
let statut = 'selectionne';
|
||||||
if (selectionne && hote.etat === 'actif' && manquants.length) statut = 'bloque';
|
if (hote.etat === 'actif' && manquants.length) statut = 'bloque';
|
||||||
else if (selectionne && hote.etat === 'actif') statut = 'ok';
|
else if (hote.etat === 'actif') statut = 'ok';
|
||||||
else if (selectionne && hote.etat !== 'actif' && (config.requiert_groupes_actifs || []).length) statut = 'attente';
|
else if ((config.requiert_groupes_actifs || []).length) statut = 'attente';
|
||||||
return `
|
return `
|
||||||
<label class="groupe ${statut}" title="${echapper(titreGroupe(groupe))}">
|
<div class="groupe ${statut}" title="${echapper(titreGroupe(groupe))}">
|
||||||
<span class="groupe-ligne">
|
<span class="groupe-ligne"><span>${echapper(groupe)}</span></span>
|
||||||
<input type="checkbox" ${selectionne ? 'checked' : ''} onchange="basculerGroupe(${index}, '${groupe}', this.checked)">
|
|
||||||
<span>${echapper(groupe)}</span>
|
|
||||||
</span>
|
|
||||||
<span class="groupe-detail">${echapper(detailGroupe(groupe, hote))}</span>
|
<span class="groupe-detail">${echapper(detailGroupe(groupe, hote))}</span>
|
||||||
</label>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailGroupe(groupe, hote) {
|
function detailGroupe(groupe, hote) {
|
||||||
|
|
@ -1465,32 +1486,6 @@ HTML = r"""<!doctype html>
|
||||||
if (!c) return null;
|
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};
|
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) {
|
function echapper(valeur) {
|
||||||
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
}
|
}
|
||||||
|
|
@ -1502,12 +1497,20 @@ HTML = r"""<!doctype html>
|
||||||
etat.className = 'console-etat actif'; etat.textContent = '● en cours';
|
etat.className = 'console-etat actif'; etat.textContent = '● en cours';
|
||||||
document.getElementById('btn-fermer-console').disabled = true;
|
document.getElementById('btn-fermer-console').disabled = true;
|
||||||
document.getElementById('console').hidden = false;
|
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 ecrireConsole(t) { const p = document.getElementById('console-sortie'); p.textContent += t; p.scrollTop = p.scrollHeight; }
|
||||||
function finirConsole(rc) {
|
function finirConsole(rc) {
|
||||||
|
clearInterval(chronoConsole); chronoConsole = null;
|
||||||
|
const s = Math.round((Date.now() - consoleDebut) / 1000);
|
||||||
const etat = document.getElementById('console-etat');
|
const etat = document.getElementById('console-etat');
|
||||||
etat.className = 'console-etat ' + (rc === 0 ? 'ok' : 'echec');
|
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;
|
document.getElementById('btn-fermer-console').disabled = false;
|
||||||
}
|
}
|
||||||
function fermerConsole() { if (!occupe) document.getElementById('console').hidden = true; }
|
function fermerConsole() { if (!occupe) document.getElementById('console').hidden = true; }
|
||||||
|
|
@ -1636,8 +1639,7 @@ HTML = r"""<!doctype html>
|
||||||
|
|
||||||
async function lancer(mode, index) {
|
async function lancer(mode, index) {
|
||||||
const hote = hotes[index] ? hotes[index].nom : '';
|
const hote = hotes[index] ? hotes[index].nom : '';
|
||||||
if (!hote) { message('Nommez l\'hôte d\'abord.', 'erreur'); return; }
|
if (!hote) { message('Hôte introuvable.', 'erreur'); return; }
|
||||||
if (modifie) { message('Sauvegardez avant d\'exécuter.', 'erreur'); return; }
|
|
||||||
if (occupe) return;
|
if (occupe) return;
|
||||||
let vault = '';
|
let vault = '';
|
||||||
if (mode === 'deployer') {
|
if (mode === 'deployer') {
|
||||||
|
|
@ -1680,27 +1682,52 @@ HTML = r"""<!doctype html>
|
||||||
occupe = false; majBoutonsDeploiement();
|
occupe = false; majBoutonsDeploiement();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sauvegarder() {
|
function sauvegarderVue() {
|
||||||
const reponse = await fetch('/api/inventaire', {
|
if (occupe) return;
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({hotes})
|
if (vuePrincipale === 'serveurs') sauvegarderServeurs();
|
||||||
});
|
else if (vuePrincipale === 'applications') sauvegarderApplications();
|
||||||
const data = await reponse.json();
|
else if (vuePrincipale === 'bases') sauvegarderBases();
|
||||||
if (!reponse.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
|
else message('Vue en lecture seule — édite dans Serveurs / Applications / Bases.');
|
||||||
dependances = data.dependances || {};
|
}
|
||||||
nomenclature = data.nomenclature || {};
|
|
||||||
chaine = data.chaine || {};
|
function ordreHotesVisibles() {
|
||||||
groupes = data.groupes;
|
const vis = hotes.map((h, i) => [h, i]).filter(([h]) => correspond(h));
|
||||||
hotes = data.hotes;
|
return [...vis.filter(([h]) => h.etat === 'actif'), ...vis.filter(([h]) => h.etat !== 'actif')].map(([, i]) => i);
|
||||||
estProduction = !!data.production;
|
}
|
||||||
modifie = false; verifie = {};
|
function naviguerHotes(delta) {
|
||||||
restaurerSelection();
|
const ordre = ordreHotesVisibles();
|
||||||
dessiner();
|
if (!ordre.length) return;
|
||||||
message('Inventaire sauvegardé.', 'ok');
|
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 => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape' && modaleResolveur) { annulerModale(); return; }
|
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 ------
|
// ------ Info-bulles d'aide au survol de la description d'un champ ------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue