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

View file

@ -100,7 +100,7 @@ aide:
@printf '%s\n' ' make creer-vm HOTE=web-frontal-01' @printf '%s\n' ' make creer-vm HOTE=web-frontal-01'
@printf '%s\n' ' Cloner seulement, sans passer par le plan:' @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' ' 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' ' make config'
@printf '%s\n' '' @printf '%s\n' ''
@printf '%s\n' 'Hotes' @printf '%s\n' 'Hotes'

View file

@ -43,6 +43,7 @@ Tu peux aussi le faire dans le GUI plus tard (`make inventaire-ui`).
```bash ```bash
make config # renseigne API host/user/port, nœud, stockage, VMID du template... 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 Place ton **token API** et tes secrets dans le Vault de l'instance
(`instance/inventories/lab/group_vars/proxmox.vault.yml`, à partir du `.example`, (`instance/inventories/lab/group_vars/proxmox.vault.yml`, à partir du `.example`,
chiffré avec `ansible-vault`). Exporte ton mot de passe Vault, ex. : 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; } 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});
try {
const rep = await fetch('/api/instancier', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: '{}'}); const rep = await fetch('/api/instancier', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: '{}'});
const data = await rep.json(); const data = await rep.json();
const derniere = (data.sortie || '').split('\n').pop(); const derniere = (data.sortie || '').split('\n').pop();
message((data.ok ? 'Plan appliqué : ' : 'Échec : ') + derniere, data.ok ? 'ok' : 'erreur'); majToast(t, (data.ok ? 'Plan appliqué : ' : 'Échec : ') + derniere, data.ok ? 'ok' : 'erreur', data.ok ? 4000 : 8000);
if (data.ok) charger(); 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 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'; 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 ------