Compare commits

...

2 commits

Author SHA1 Message Date
a21a6b4e02 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>
2026-06-30 12:19:26 -04:00
441dd284bb Documenter les paramètres de make config
Ajoute docs/config-proxmox.md : référence champ par champ des 16 paramètres
Proxmox non sensibles + les secrets API demandés par l'assistant (sens,
défaut, quoi saisir, constante vs défaut surchargeable), plus l'annexe de
création du token API. Renvois ajoutés depuis make help et QUICKSTART.md.
Ces invites n'étaient expliquées nulle part de façon pérenne.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:19:14 -04:00
5 changed files with 295 additions and 142 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

@ -100,7 +100,7 @@ aide:
@printf '%s\n' ' make creer-vm HOTE=web-frontal-01'
@printf '%s\n' ' Cloner seulement, sans passer par le plan:'
@printf '%s\n' ' make cloner-vm HOTE=web-frontal-01 VMID=95301 VLAN=15 ADRESSE_IP=10.0.2.31 PASSERELLE=10.0.2.1'
@printf '%s\n' ' Configurer Proxmox et le Vault API:'
@printf '%s\n' ' Configurer Proxmox et le Vault API (parametres: docs/config-proxmox.md):'
@printf '%s\n' ' make config'
@printf '%s\n' ''
@printf '%s\n' 'Hotes'

View file

@ -43,6 +43,7 @@ Tu peux aussi le faire dans le GUI plus tard (`make inventaire-ui`).
```bash
make config # renseigne API host/user/port, nœud, stockage, VMID du template...
```
Chaque paramètre demandé est expliqué dans [`docs/config-proxmox.md`](docs/config-proxmox.md).
Place ton **token API** et tes secrets dans le Vault de l'instance
(`instance/inventories/lab/group_vars/proxmox.vault.yml`, à partir du `.example`,
chiffré avec `ansible-vault`). Exporte ton mot de passe Vault, ex. :

92
docs/config-proxmox.md Normal file
View file

@ -0,0 +1,92 @@
# `make config` — référence des paramètres Proxmox
`make config` lance `scripts/config_proxmox.py`, l'assistant interactif qui écrit
la connexion au cluster Proxmox et les valeurs de clonage par défaut. Il pose
**16 paramètres non sensibles** puis propose de saisir les **secrets API**.
- Non sensibles → `instance/inventories/lab/group_vars/proxmox.yml`
- Secrets API → `instance/inventories/lab/group_vars/proxmox.vault.yml` (chiffré
par `ansible-vault`)
À chaque invite, la valeur courante (ou le défaut) est affichée entre crochets :
**appuyer sur Entrée conserve cette valeur**. On peut donc relancer `make config`
sans tout retaper.
> Ces paramètres sont des **intrants communs** de l'écosystème (cf.
> [`intrants-communs.md`](intrants-communs.md) §C). L'accès au cluster et le golden
> template sont des **constantes** ; le placement par défaut est **surchargeable
> par hôte** dans `instance/plan/serveurs.yml`.
---
## 1. Accès au cluster Proxmox *(constantes — un seul cluster)*
| Invite | Variable | Défaut | Sens / quoi saisir |
| --- | --- | --- | --- |
| Hôte API Proxmox | `proxmox_api_host` | *(vide)* | Nom DNS ou IP du nœud qui répond à l'API. Ex. `asgard`. |
| Utilisateur API Proxmox | `proxmox_api_user` | *(vide)* | Utilisateur **avec le realm**. Ex. `ansible@pve` (realm PVE) ou `root@pam`. |
| Port API Proxmox | `proxmox_api_port` | *(vide)* | Port HTTPS de l'API. Quasi toujours `8006`. |
| Valider les certificats TLS | `proxmox_validate_certs` | `non` | `oui` si le cluster a un certificat de confiance ; `non` pour un certificat auto-signé (cas usuel en lab). |
## 2. Golden template *(constantes — le modèle cloné)*
| Invite | Variable | Défaut | Sens / quoi saisir |
| --- | --- | --- | --- |
| VMID du modèle Debian 13 | `proxmox_clone_vmid_modele` | `9000` | VMID de la VM-modèle existante à cloner pour chaque nouvelle VM. |
| Nom logique du modèle | `proxmox_clone_source_nom` | `modele-debian13` | Nom de référence du template (lisibilité ; doit correspondre au modèle). |
> Le golden template est l'**actif central** : il est cloné pour chaque VM, jamais
> jeté ni reconstruit à la légère.
## 3. Placement par défaut des clones *(défauts surchargeables par hôte)*
Ces valeurs s'appliquent à toute VM clonée, **sauf** si l'hôte les surcharge dans
`instance/plan/serveurs.yml`.
| Invite | Variable | Défaut | Sens / quoi saisir |
| --- | --- | --- | --- |
| Nœud Proxmox par défaut | `proxmox_clone_noeud` | *(vide)* | Nœud du cluster où créer la VM. Ex. `asgard`. |
| Stockage Proxmox par défaut | `proxmox_clone_stockage` | *(vide)* | Datastore qui héberge le disque. Ex. `local-zfs`, `TrueNAS`. |
| Pont Proxmox | `proxmox_clone_pont` | `vmbr0` | Bridge réseau de la NIC. Ex. `vmbr0`, `vmbr1`. |
| Format disque par défaut | `proxmox_clone_format` | *(vide)* | `qcow2`, `raw`, … ou **vide** pour laisser le stockage décider (recommandé : ZFS/LVM imposent leur format). |
| Clone complet | `proxmox_clone_complet` | `oui` | `oui` = clone **indépendant** (autonome) ; `non` = clone **lié** (dépend du modèle, plus léger mais fragile). Garder `oui`. |
| Timeout opérations Proxmox | `proxmox_clone_timeout` | `600` | Secondes avant d'abandonner une opération longue (clone, redimensionnement). |
| Disque principal | `proxmox_clone_disque` | `scsi0` | Bus + index du disque système. `scsi0` est le standard Set-OPS. |
| Interface réseau | `proxmox_clone_interface` | `net0` | Identifiant de la NIC virtuelle. |
| Pare-feu interface Proxmox | `proxmox_clone_parefeu_interface` | `non` | Active le pare-feu Proxmox **au niveau de la NIC**. Laisser `non` : le filtrage se fait dans l'invité (nftables), pas chez l'hyperviseur. |
| Démarrer le clone après création | `proxmox_clone_demarrer` | `oui` | `oui` = booter la VM dès la création (nécessaire pour qu'Ansible la joigne ensuite). |
## 4. Secrets API Proxmox 🔒 *(constantes, chiffrées)*
L'assistant demande ensuite : « Configurer les secrets API Proxmox maintenant ».
Si `oui` :
| Invite | Variable | Défaut | Sens / quoi saisir |
| --- | --- | --- | --- |
| Token ID Proxmox | `proxmox_api_token_id` | `set-ops` | Identifiant du **token API** créé côté Proxmox pour l'utilisateur API. |
| Token secret Proxmox | `proxmox_api_token_secret` | *(aucun)* | Secret du token. **Saisie masquée**, écrite uniquement dans le Vault chiffré. |
- Si `proxmox.vault.yml` existe déjà, l'assistant ouvre directement
`ansible-vault edit` (pas de re-saisie en clair).
- Un secret vide ⇒ le Vault **n'est pas créé**.
- Ces deux clés ne transitent **jamais** par le GUI ni par aucun fichier en clair.
---
## Comment créer le token API côté Proxmox
Dans l'interface Proxmox (ou en CLI `pveum`) :
1. Créer l'utilisateur API (ex. `ansible@pve`) et lui donner les droits requis
(rôle avec `VM.Allocate`, `VM.Clone`, `VM.Config.*`, `Datastore.AllocateSpace`,
`SDN.Use`/réseau selon le cluster).
2. Créer un **token API** pour cet utilisateur → noter le **Token ID** et le
**secret** (affiché une seule fois).
3. Renseigner Token ID + secret dans `make config`.
## Voir aussi
- [`intrants-communs.md`](intrants-communs.md) — tous les intrants communs de l'écosystème.
- [`nomenclature-vm.md`](nomenclature-vm.md) — réseau, VLAN, fonctions.
- [`vm-lifecycle.md`](vm-lifecycle.md) — cycle de vie d'une VM (création → conformité).
- `make help` — liste des cibles.

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