console : une navigation par portee, des tableaux, les gestes en tete de fiche

Lot 1 de la refonte (affichage seul, aucune route ni pouvoir touche) :
- en-tete d identite colore par portee, PROD avec le nom ;
- navigation propre au locataire et au site, encadre Cette console peut ;
- parc en tableau groupe par categorie ; noeuds de la fabric et machines du site ;
- Verifier puis Deployer en tete de fiche ; assistants hors portee replies ;
- bandeau Plan modifie a la place du point sur le bouton.
Rapport de preuves du jour versionne, carte a 53 pieces d audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-11 14:43:01 -04:00
parent 412133bd76
commit 493a5dcbeb
4 changed files with 641 additions and 166 deletions

View file

@ -1,5 +1,40 @@
# CHANGELOG — Set-OPS
## 2026-10-11 (127) — Deux consoles servaient la même page ; chacune dit maintenant qui elle est
**94 preuves.** `make verifier` passe, le banc de rendu (`test_rendu_gui.py`) aussi, et les
deux consoles ont été rendues sur leurs vraies charges puis regardées en capture. Seul
l'affichage change : aucune route, aucun pouvoir, aucun geste n'est ajouté ni retiré.
### Un site se voyait offrir le plan d'un locataire
La console du site et celle du locataire partageaient dix boutons de vue. Le site y
trouvait la nomenclature, les intrants et un bouton « Sauvegarder » grisé, pour un plan dont
il ne peut rien écrire ; ses pouvoirs et leurs raisons n'existaient qu'en infobulle. La page
dessine désormais sa navigation selon la portée — Exploiter / Le plan / Lire pour un
locataire, Le terrain / Gestes / Plan en lecture pour un site — et un encadré « Cette console
peut » dit les quatre pouvoirs en clair. L'en-tête porte une couleur d'identité (sarcelle,
ambre, gris, rouge) qui ne sert qu'à dire qui parle, jamais un état.
### La mosaïque cachait les zones, la fiche cachait les gestes
Le parc est un tableau groupé par catégorie et VLAN ; plan, réel, inventaire et prérequis y
restent quatre colonnes distinctes, et `j`/`k` suit l'ordre affiché. Au site, l'inventaire
servait les hyperviseurs comme des machines « planifiées » : ils sont montrés comme les nœuds
qu'ils sont, avec les machines qu'ils portent. Dans la fiche, Vérifier puis Déployer passent
en tête, numérotés dans l'ordre que `majBoutonsDeploiement` imposait déjà. Les assistants
qu'une console ne peut pas conduire sont repliés, toujours lisibles ; les boutons suivent la
nature de l'étape. Le travail non sauvegardé se dit dans un bandeau en tête de vue, avec
l'étape suivante, au lieu d'un « • » sur un bouton parmi douze.
### Les rapports de preuves sont versionnés
`preuve-2026-10-11.md` rejoint `docs/audit/`, comme ses prédécesseurs, et la carte compte
53 pièces d'audit (P48).
**Limite.** Les étapes d'un assistant ne sont pas encore groupées en phases : il faudrait les
déclarer dans le registre, les deviner aux noms des cibles serait fragile.
## 2026-10-11 (126) — Le site savait créer une machine de locataire ; son assistant ne l'offrait qu'en bloc
**94 preuves.** `make prouver` est conforme, `make test` passe et `runbooks.py verifier`

View file

@ -0,0 +1,130 @@
# Preuve de conformite — Set-OPS — 2026-10-11
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (94 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 3 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 122 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 35 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 4 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 78 scripts expliques et atteignables, 151 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (148 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 50 document(s) declarent leur lecteur (48 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 74 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 174 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 102 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (140 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 277 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (94 preuves, 69 roles, 42 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e9215a2` (publie le 2026-10-07). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. |
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». |
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. |
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 145 etapes, 150 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-10-11._

View file

@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous.
| rôles | 69 | `roles/*/` |
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 46 | `docs/*.md` |
| pièces d'audit | 52 | `docs/audit/*` |
| pièces d'audit | 53 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |

View file

@ -2072,46 +2072,156 @@ HTML = r"""<!doctype html>
.rb-nature.destructif{background:#3a1c1c;color:#f08a8a}
.rb-meta{font-size:11.5px;opacity:.6;margin-top:4px}
.rb-but{font-size:12.5px;line-height:1.5;opacity:.88}
/* --- LOT 1 (2026-10-11) : UNE CONSOLE PAR PORTEE, UNE SEULE GRAMMAIRE -------------
La couleur d'identite ne sert qu'a dire QUI parle : bande, etiquette de portee, vue
active. Jamais un etat. Les etats gardent leurs couleurs, les memes partout : vert =
ca marche, ambre = ecart a regarder, rouge = ca empeche, bleu = pas encore. */
:root { --identite: var(--muted); }
body[data-portee="tenant"] { --identite: var(--teal); }
body[data-portee="site"] { --identite: #e0a83a; }
body[data-portee="poste"] { --identite: #a5b4cb; }
body[data-portee="orphelin"] { --identite: var(--rouge); }
header { border-top: 3px solid var(--identite); gap: 14px; }
body[data-portee="site"] header { background: color-mix(in srgb, #1a170f 86%, transparent); }
.sep { width: 1px; height: 26px; background: var(--ligne-forte); flex: none; }
.identite { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.identite-haut { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.portee-etiq { font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; padding: 3px 8px; border-radius: 5px; white-space: nowrap;
color: var(--identite); background: color-mix(in srgb, var(--identite) 16%, transparent); }
.ident-nom { font-weight: 800; font-size: 15px; white-space: nowrap; }
.cadre { display: flex; align-items: stretch; min-height: calc(100vh - 64px); }
.cadre > main { flex: 1 1 auto; min-width: 0; max-width: none; margin: 0; }
#navigation { flex: 0 0 224px; border-right: 1px solid var(--ligne); background: color-mix(in srgb, var(--fond) 70%, var(--surface)); }
.nav-interne { padding: 16px 10px 20px; display: flex; flex-direction: column; gap: 18px; position: sticky; top: 64px; max-height: calc(100vh - 64px); overflow: auto; }
.nav-groupe { display: flex; flex-direction: column; gap: 2px; }
.nav-titre { font-size: 10.5px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--faint); padding: 0 10px; margin: 0 0 3px; }
.nav-sous { font-size: 11px; color: var(--faint); padding: 0 10px; margin: -1px 0 5px; line-height: 1.35; }
.nav-lien { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 0 10px; border-radius: 7px; border: 0; background: transparent;
color: var(--muted); font: inherit; font-size: 13.5px; font-weight: 600; text-align: left; cursor: pointer; box-shadow: none; }
.nav-lien:hover:not(:disabled) { background: var(--surface-2); color: var(--texte); border: 0; }
.nav-lien.on { background: color-mix(in srgb, var(--identite) 13%, var(--surface)); color: var(--texte); box-shadow: inset 2px 0 0 var(--identite); }
.nav-lien .n { margin-left: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: var(--faint); font-weight: 500; }
.pouvoirs { border: 1px solid var(--ligne); border-radius: var(--rayon-sm); background: var(--surface); padding: 11px 12px; display: flex; flex-direction: column; gap: 7px; font-size: 12px; }
.pouvoir { display: flex; gap: 7px; align-items: flex-start; color: var(--texte); line-height: 1.35; }
.pouvoir.non { color: var(--faint); cursor: help; }
.pouvoir .i { flex: none; width: 14px; text-align: center; font-weight: 800; }
.pouvoir .i.oui { color: var(--vert); }
.raccourcis { font-size: 11px; color: var(--faint); padding: 0 10px; line-height: 1.5; }
.bandeau-plan { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 14px; padding: 9px 12px; border-radius: 9px;
background: color-mix(in srgb, var(--ambre) 9%, var(--surface)); border: 1px solid color-mix(in srgb, var(--ambre) 40%, var(--ligne)); color: #f3dca0; font-size: 13px; }
.bandeau-plan[hidden] { display: none; }
.vue-tete { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin: 0 0 14px; }
.vue-titre { margin: 0; font-size: 21px; font-weight: 750; letter-spacing: -.01em; }
.vue-sous { font-size: 13px; color: var(--muted); }
.etiq { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 5px; font-size: 11.5px; font-weight: 700; white-space: nowrap; }
.e-actif { background: var(--vert-fond); color: var(--vert); }
.e-plan { color: var(--bleu); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bleu) 45%, transparent); }
.e-recon { color: var(--muted); background: var(--surface-3); }
.e-div { background: var(--ambre-fond); color: var(--ambre); }
.e-bloq { background: var(--rouge-fond); color: var(--rouge); }
.reel { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted); }
.pt.ns { background: var(--faint); }
.tableau { background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon); overflow-x: auto; margin-bottom: 18px; }
.tableau-corps { min-width: 720px; }
.rang { display: grid; grid-template-columns: 1.35fr 1fr .75fr .95fr .9fr 1.7fr; align-items: center; gap: 10px; width: 100%; min-height: 42px; padding: 0 14px;
border: 0; border-bottom: 1px solid var(--ligne); border-radius: 0; background: transparent; color: var(--texte); font: inherit; font-size: 13px; font-weight: 500; text-align: left; cursor: pointer; box-shadow: none; }
.rang:hover:not(:disabled) { background: var(--surface-2); border-color: var(--ligne); }
.rang.tete { min-height: 34px; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: var(--faint); cursor: default; }
.rang.tete:hover { background: transparent; }
.rang.selectionnee { background: color-mix(in srgb, var(--identite) 10%, var(--surface)); box-shadow: inset 3px 0 0 var(--identite); }
.rang .carte-nom { font-size: 13.5px; }
.rang .ip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; color: var(--muted); }
.rang .rendus { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rang.site { grid-template-columns: 1.3fr 1fr .6fr .8fr .9fr 2fr; }
.groupe-tete { display: flex; align-items: center; gap: 10px; min-height: 32px; padding: 0 14px; background: var(--surface-2); border-bottom: 1px solid var(--ligne);
color: var(--muted); font-size: 11.5px; font-weight: 700; letter-spacing: .03em; }
.groupe-tete .mono { color: var(--faint); font-weight: 500; }
.noeuds { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; margin-bottom: 18px; }
.noeud { background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon); padding: 11px 13px; display: flex; flex-direction: column; gap: 8px; }
.noeud-tete { display: flex; align-items: center; gap: 8px; }
.puces { display: flex; flex-wrap: wrap; gap: 5px; }
.puce { display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 5px; background: var(--surface-3); color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.puce.socle { background: transparent; box-shadow: inset 0 0 0 1px var(--ligne-forte); color: var(--faint); }
.gestes { margin: 14px 16px 4px; border: 1px solid var(--ligne-forte); border-radius: var(--rayon-sm); background: var(--surface-2); padding: 12px 13px; display: flex; flex-direction: column; gap: 11px; }
.gestes-tete { display: flex; align-items: center; gap: 8px; }
.geste { display: flex; align-items: center; gap: 12px; }
.geste .num { width: 24px; height: 24px; border-radius: 50%; flex: none; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 800; background: var(--surface-3); color: var(--muted); }
.geste .quoi { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.geste .quoi b { font-size: 13.5px; }
.geste .quoi span { font-size: 12px; color: var(--faint); }
.gestes-note { font-size: 12px; color: var(--faint); border-top: 1px solid var(--ligne); padding-top: 9px; }
button.prod { background: #7f1d1d; border-color: #b23b3b; color: #fff; }
button.prod:hover:not(:disabled) { background: #8f2424; border-color: #c94a4a; }
.section-fiche { padding: 12px 16px 2px; }
.section-fiche > .section-tete { margin-bottom: 7px; }
.rb-autres { margin: -6px 0 14px; font-size: 12.5px; color: var(--muted); }
.rb-autres > summary { cursor: pointer; padding: 4px 2px; }
.rb-autres .rb-liste { margin-top: 8px; }
.rb-entete { display: flex; flex-direction: column; gap: 6px; margin: 0 0 12px; }
.rb-progres { display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--muted); }
.rb-barre { flex: 0 1 300px; height: 6px; border-radius: 3px; background: var(--surface-3); display: flex; overflow: hidden; }
.rb-barre .ok { background: var(--vert); } .rb-barre .ko { background: var(--rouge); }
.rb-etape .rb-num { width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; opacity: 1; padding: 0; font-size: 12px; background: var(--surface-3); color: var(--muted); }
.rb-etape.faite .rb-num { background: var(--vert-fond); color: var(--vert); }
.rb-etape.ratee .rb-num { background: var(--rouge-fond); color: var(--rouge); }
.rb-etape.ratee { background: color-mix(in srgb, var(--rouge) 6%, #10161d); }
.rb-etape.bloquee { opacity: .55; }
.noeud.selectionnee { border-color: var(--identite); box-shadow: 0 0 0 1px var(--identite); }
.rb-etape button.destructif { background: transparent; border-color: #b23b3b; color: var(--rouge); }
@media (max-width: 1000px) {
.cadre { flex-direction: column; }
#navigation { flex: none; border-right: 0; border-bottom: 1px solid var(--ligne); }
.nav-interne { position: static; max-height: none; flex-direction: row; flex-wrap: wrap; }
.nav-groupe { flex: 1 1 200px; }
.pouvoirs { flex: 1 1 100%; }
}
</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>
<span id="portee" style="font-size:11.5px;font-weight:800;letter-spacing:.02em;padding:3px 9px;border-radius:999px;white-space:nowrap"
title="Ce que cette console peut faire — dérivé de ce que sa machine porte, pas d'un réglage"></span>
<span class="sep"></span>
<!-- QUI PARLE. La portee se lit d'abord, en couleur ; le titre complet reste en infobulle. -->
<div class="identite">
<div class="identite-haut">
<span id="portee" class="portee-etiq"
title="Ce que cette console peut faire — dérivé de ce que sa machine porte, pas d'un réglage"></span>
<span id="ident-nom" class="ident-nom"></span>
<span id="ident-prod"></span>
</div>
<span class="chemin" id="chemin"></span>
</div>
<div class="grandit"></div>
<div class="resume" id="resume"></div>
<div class="barre" title="Raccourcis clavier — 1-3 : changer de vue · j/k : naviguer les serveurs · v : vérifier · d : déployer · / : filtrer · Ctrl+S : sauvegarder la vue">
<div class="vue-bascule">
<button type="button" id="btn-vue-serveurs" class="on" onclick="setVue('serveurs')">Serveurs</button>
<button type="button" id="btn-vue-applications" onclick="setVue('applications')">Applications</button>
<button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button>
<button type="button" id="btn-vue-domaines" onclick="setVue('domaines')">Domaines</button>
<button type="button" id="btn-vue-nomenclature" onclick="setVue('nomenclature')">Nomenclature</button>
<button type="button" id="btn-vue-integrations" onclick="setVue('integrations')">Intégrations</button>
<button type="button" id="btn-vue-flux" onclick="setVue('flux')">Flux</button>
<button type="button" id="btn-vue-couches" onclick="setVue('couches')">Couches</button>
<button type="button" id="btn-vue-reseau" onclick="setVue('reseau')">Réseau</button>
<button type="button" id="btn-vue-assistants" onclick="setVue('assistants')" title="Les runbooks de construction : l'ordre des gestes, et pourquoi celui-là">Assistants</button>
</div>
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
<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="sauvegarderVue()">Sauvegarder</button>
</div>
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer ( / )" aria-label="Filtrer la vue" oninput="filtrer(this.value)"></span>
<button type="button" class="fantome" onclick="charger()">Recharger</button>
</header>
<main>
<section>
<div id="grilles"></div>
<details class="dep-bloc" id="panneau-dep">
<summary>Dépendances causales <span id="dep-resume" class="chip"></span></summary>
<div class="dep-corps" id="dependances"></div>
</details>
</section>
<aside><div id="detail"></div></aside>
</main>
<div class="cadre">
<!-- LA NAVIGATION SE DESSINE SELON LA PORTEE (dessinerNavigation). Un site et un
locataire n'ont pas les memes vues : les leur montrer toutes, c'etait proposer
des formulaires de plan a une console qui ne peut pas en ecrire un. -->
<nav id="navigation" aria-label="Vues de la console"></nav>
<main>
<section>
<!-- LE TRAVAIL NON SAUVEGARDE SE VOIT EN TETE DE LA VUE QUI L'A PRODUIT, avec
l'etape suivante. C'etait un « • » sur un bouton parmi douze. -->
<div class="bandeau-plan" id="bandeau-plan" hidden>
<span class="pt" style="background:var(--ambre)"></span>
<span id="bandeau-texte" class="grandit">Plan modifié.</span>
<button type="button" class="primaire" id="btn-sauver" onclick="sauvegarderVue()">Sauvegarder <span style="opacity:.7;font-weight:600">Ctrl+S</span></button>
<button type="button" onclick="appliquerPlan()" title="Régénère l'inventaire depuis le plan sauvegardé">Appliquer le plan</button>
</div>
<div id="grilles"></div>
<details class="dep-bloc" id="panneau-dep">
<summary>Prérequis entre groupes <span id="dep-resume" class="chip"></span></summary>
<div class="dep-corps" id="dependances"></div>
</details>
</section>
<aside><div id="detail"></div></aside>
</main>
</div>
<div class="modale" id="modale" hidden>
<div class="modale-boite">
<div class="modale-titre" id="modale-titre"></div>
@ -2275,20 +2385,16 @@ let selMachine = null;
contexte = ctx;
const badge = document.getElementById('portee');
if (!badge) return;
// LES COULEURS DU THEME, PAS LES MIENNES (corrige le 2026-09-16). Ma premiere
// version posait un texte sombre sur un fond clair — illisible sur une console qui
// est en thème sombre. Vu sur une capture, pas dans le code : une couleur ne se
// relit pas, elle se regarde.
const couleurs = {
site: ['#e0a83a', 'rgba(185, 134, 11, .18)'],
tenant: ['var(--teal)', 'var(--surface-3)'],
poste: ['var(--muted)', 'var(--surface-3)'],
orphelin: ['var(--rouge)', 'var(--surface-3)'],
};
const [fg, bg] = couleurs[ctx.portee] || couleurs.poste;
badge.textContent = ctx.titre;
badge.style.color = fg;
badge.style.background = bg;
// LA COULEUR VIENT DU THEME (`body[data-portee]` → `--identite`), pas d'ici. Le
// 2026-09-16, une couleur posee en dur donnait un texte sombre sur fond clair dans
// une console sombre : une couleur ne se relit pas, elle se regarde.
const etiquettes = {site: 'Site · hébergeur', tenant: 'Locataire',
poste: 'Poste du mainteneur', orphelin: 'Sans pouvoir'};
badge.textContent = etiquettes[ctx.portee] || ctx.portee;
badge.title = ctx.titre;
const nom = document.getElementById('ident-nom');
if (nom) nom.textContent = ctx.portee === 'site' ? (ctx.fabric || '')
: (ctx.ecosysteme || ctx.fabric || '');
document.body.dataset.portee = ctx.portee;
// Ce que la portée interdit ne se propose pas. Le serveur refuse de toute façon
// (POUVOIR_REQUIS) ; ceci évite d'aller au refus pour l'apprendre.
@ -2300,10 +2406,93 @@ let selMachine = null;
};
verrou(document.getElementById('btn-sauver'), ctx.peut.editer_le_plan,
ctx.raison.editer_le_plan);
dessinerNavigation();
document.querySelectorAll('button[onclick^="ouvrirIntrants"]').forEach(
b => verrou(b, ctx.peut.editer_le_plan, ctx.raison.editer_le_plan));
}
// LES VUES DE CETTE CONSOLE, SELON CE QU'ELLE EST (2026-10-11).
//
// Un locataire EXPLOITE son parc, ECRIT son plan et LIT ce qui en derive. Un site
// tient son TERRAIN, conduit ses GESTES et LIT son plan, dont les adresses sont
// ecrites. Les dix boutons d'avant etaient les memes pour les deux : le site se voyait
// offrir la nomenclature et les intrants d'un plan qu'il ne peut pas ecrire.
// Rien n'est retire : une vue sans objet ici (nomenclature vide d'un site) n'est pas
// proposee, et les gestes hors de portee restent dits dans « Cette console peut ».
function groupesDeNavigation() {
const n = (x) => (x == null ? '' : String(x));
const nbRb = (typeof runbooks !== 'undefined' && runbooks.length)
? runbooks.filter(rb => rb.conduisible).length : '';
const aNomenclature = Object.keys((nomenclature || {}).fonctions || {}).length > 0;
if (peut('editer_le_plan')) {
return [
{titre: 'Exploiter', vues: [
['serveurs', 'Parc', n(serveurs.length)],
['assistants', 'Assistants', n(nbRb)]]},
{titre: 'Le plan', sous: 'Ce que tu écris ; « Appliquer le plan » en dérive l\'inventaire.', vues: [
['applications', 'Applications', n(applications.length)],
['bases', 'Bases', n(bdServeurs.length + bdApplis.length)],
['domaines', 'Domaines', n(domaines.length)],
['nomenclature', 'Nomenclature', ''],
['integrations', 'Intégrations', ''],
['@intrants', 'Intrants de base', '']]},
{titre: 'Lire — dérivé', vues: [
['flux', 'Flux', n(fluxRegistre.length)],
['couches', 'Couches de déploiement', n(couchesRegistre.length)],
['reseau', 'Réseau & fédération', '']]},
];
}
const lecture = [
['applications', 'Applications', n(applications.length)],
['bases', 'Bases', n(bdServeurs.length + bdApplis.length)],
['domaines', 'Domaines', n(domaines.length)]];
if (aNomenclature) lecture.push(['nomenclature', 'Nomenclature', '']);
lecture.push(['integrations', 'Intégrations', ''],
['flux', 'Flux', n(fluxRegistre.length)],
['couches', 'Couches de déploiement', n(couchesRegistre.length)]);
return [
{titre: 'Le terrain', vues: [
['serveurs', 'Machines & fabric', n(hotes.length)],
['reseau', 'Réseau & frontière', '']]},
{titre: 'Gestes', vues: [['assistants', 'Assistants', n(nbRb)]]},
{titre: 'Plan — lecture', sous: 'Ses adresses y sont écrites ; il se lit ici et s\'édite dans son dépôt.', vues: lecture},
];
}
const LIBELLES_POUVOIRS = [
['editer_le_plan', 'Éditer le plan'],
['configurer', 'Configurer : vérifier, déployer'],
['materialiser', 'Matérialiser : créer, détruire des VM'],
['fabric', 'Lire la fabric'],
];
function dessinerNavigation() {
const nav = document.getElementById('navigation');
if (!nav) return;
const groupes = groupesDeNavigation().map(g => {
const liens = g.vues.map(([vue, libelle, compte]) => {
const action = vue === '@intrants' ? 'ouvrirIntrants()' : `setVue('${vue}')`;
const on = vue === vuePrincipale ? ' on' : '';
const courant = on ? ' aria-current="page"' : '';
return `<button type="button" class="nav-lien${on}"${courant} onclick="${action}">${echapper(libelle)}`
+ (compte !== '' ? `<span class="n">${echapper(compte)}</span>` : '') + '</button>';
}).join('');
return `<div class="nav-groupe"><p class="nav-titre">${echapper(g.titre)}</p>`
+ (g.sous ? `<p class="nav-sous">${echapper(g.sous)}</p>` : '') + liens + '</div>';
}).join('');
// CE QUE CETTE CONSOLE PEUT, EN CLAIR. Les raisons ne vivaient qu'en infobulle sur
// des boutons grises : on decouvrait l'interdit en survolant, au hasard.
const pouvoirs = contexte && contexte.peut ? LIBELLES_POUVOIRS.map(([p, libelle]) => {
const oui = !!contexte.peut[p];
return `<div class="pouvoir${oui ? '' : ' non'}"${oui ? '' : ` title="${echapper(pourquoiPas(p))}"`}>`
+ `<span class="i${oui ? ' oui' : ''}">${oui ? '✓' : '—'}</span><span>${echapper(libelle)}</span></div>`;
}).join('') : '';
nav.innerHTML = '<div class="nav-interne">' + groupes
+ (pouvoirs ? `<div class="pouvoirs"><div class="section-tete" style="margin:0 0 2px">Cette console peut</div>${pouvoirs}`
+ '<div class="aide-ro" style="font-size:11px;color:var(--faint)">Survoler une ligne grisée pour savoir pourquoi.</div></div>' : '')
+ '<div class="raccourcis">Raccourcis : 1-3 vues · j/k serveur suivant · v vérifier · d déployer · / filtrer · Ctrl+S sauvegarder</div></div>';
}
async function charger() {
try {
const ctxRep = await fetch('/api/contexte');
@ -2363,21 +2552,29 @@ let selMachine = null;
function hoteBloque(h) { if (!h || h.etat !== 'actif') return false; return (h.groupes || []).some(g => manquantsPourGroupe(g).length); }
function libelleStatut(s) { return {ok:'actif', attente:'planifié', bloque:'bloqué', neutre:'libre'}[s] || s; }
function dessiner() { dessinerResume(); dessinerGrilles(); dessinerDependances(); dessinerDetail(); majIndicateurSauvegarde(); }
function dessiner() { dessinerResume(); dessinerNavigation(); dessinerGrilles(); dessinerDependances(); dessinerDetail(); majIndicateurSauvegarde(); }
// LE BANDEAU « PLAN MODIFIE » remplace le « • » du bouton global : il dit QUELLE vue
// porte du travail non sauvegarde, et l'etape qui suit. Il ne parait que la ou l'on
// peut ecrire — une console qui lit son plan n'a rien a sauvegarder.
const NOMS_VUES_EDITABLES = {serveurs: 'serveurs', integrations: 'intégrations', applications: 'applications',
bases: 'bases', domaines: 'domaines', nomenclature: 'nomenclature'};
function majIndicateurSauvegarde() {
const b = document.getElementById('btn-sauver');
if (!b) return;
const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'integrations' || vuePrincipale === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines' || vuePrincipale === 'nomenclature';
const editable = vuePrincipale in NOMS_VUES_EDITABLES;
const sale = ((vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') && serveursModifie)
|| (vuePrincipale === 'applications' && applicationsModifie)
|| (vuePrincipale === 'bases' && basesModifie)
|| (vuePrincipale === 'domaines' && domainesModifie)
|| (vuePrincipale === 'nomenclature' && nomModifie);
b.classList.toggle('attention', sale);
b.textContent = sale ? 'Sauvegarder •' : 'Sauvegarder';
b.disabled = !editable;
b.disabled = !editable || !peut('editer_le_plan');
b.title = editable ? 'Sauvegarder cette vue (Ctrl+S)' : 'Lecture seule — édite dans Serveurs / Applications / Bases';
const bandeau = document.getElementById('bandeau-plan');
if (bandeau) bandeau.hidden = !(sale && editable && peut('editer_le_plan'));
const texte = document.getElementById('bandeau-texte');
if (texte && sale) texte.innerHTML = '<b>Plan modifié</b> — ' + echapper(NOMS_VUES_EDITABLES[vuePrincipale] || '')
+ ' pas encore sauvegardé. Ensuite : appliquer le plan pour régénérer l\'inventaire.';
}
function dessinerResume() {
@ -2385,8 +2582,13 @@ let selMachine = null;
const bloques = hotes.filter(h => hoteBloque(h)).length;
const vivantes = hotes.filter(h => sondes[h.nom] === true).length;
const sonde = Object.keys(sondes).length > 0;
// PROD OU BAC A SABLE fait partie de l'IDENTITE : on le lit avec le nom, pas parmi
// les compteurs.
const prod = document.getElementById('ident-prod');
if (prod) prod.innerHTML = estProduction
? '<span class="badge-prod" title="Instance de PRODUCTION : déploiement sous confirmation renforcée">PROD</span>'
: '<span class="badge-lab" title="Instance bac à sable (setops_production: false)">bac à sable</span>';
document.getElementById('resume').innerHTML = `
${estProduction ? '<span class="badge-prod" title="Instance de PRODUCTION : déploiement sous confirmation renforcée">PROD</span>' : '<span class="badge-lab" title="Instance bac à sable (setops_production: false)">bac à sable</span>'}
<span class="chip"><b>${hotes.length}</b> hôtes</span>
<span class="chip"><span class="pt actif"></span><b>${actifs}</b> actifs</span>
<span class="chip"><span class="pt plan"></span><b>${hotes.length - actifs}</b> planifiés</span>
@ -2404,15 +2606,7 @@ let selMachine = null;
if (v === 'serveurs' && selServeur == null && serveurs.length) selServeur = 0;
if (v === 'applications' && selApp == null && applications.length) selApp = 0;
if (v === 'bases' && selBase == null) selBase = bdServeurs.length ? 'srv:0' : (bdApplis.length ? 'bd:0' : null);
document.getElementById('btn-vue-serveurs').classList.toggle('on', v === 'serveurs');
document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications');
document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases');
document.getElementById('btn-vue-domaines').classList.toggle('on', v === 'domaines');
document.getElementById('btn-vue-integrations').classList.toggle('on', v === 'integrations');
document.getElementById('btn-vue-flux').classList.toggle('on', v === 'flux');
document.getElementById('btn-vue-couches').classList.toggle('on', v === 'couches');
document.getElementById('btn-vue-reseau').classList.toggle('on', v === 'reseau');
document.getElementById('btn-vue-assistants').classList.toggle('on', v === 'assistants');
dessinerNavigation();
dessinerGrilles();
dessinerDetail(); // le panneau droit reflète la sélection de la vue courante
majIndicateurSauvegarde();
@ -2428,7 +2622,7 @@ let selMachine = null;
<li><b>Application</b> — un service qui <b>tourne sur</b> un hôte, <b>via</b> un groupe, et <b>expose</b> un FQDN.</li>
<li><b>Base</b> — une donnée <b>hébergée sur</b> un hôte et <b>consommée par</b> une application.</li>
</ul>
<p class="aide-ro">Saisis à <b>gauche</b> (tuiles), édite à <b>droite</b> ; les dérivés (VMID/IP/VLAN/groupes) se calculent à « Appliquer le plan ».</p>
<p class="aide-ro">Choisis à <b>gauche</b> (une ligne par serveur), édite à <b>droite</b> ; les dérivés (VMID/IP/VLAN/groupes) se calculent à « Appliquer le plan ».</p>
</div></details>`;
function dessinerGrilles() {
@ -3588,18 +3782,58 @@ let selMachine = null;
return v === true ? '<span class="pt actif" title="VM joignable (SSH)"></span> '
: v === false ? '<span class="pt bloq" title="Injoignable"></span> ' : '';
}
function carteServeur(s, i) {
const actif = s.etat === 'actif';
const recon = s.statut === 'divergence' ? '<span class="tag plus" title="Le plan diverge de l\'inventaire généré">⚠ divergent</span>'
: s.statut === 'absent' ? '<span class="tag" title="Déclaré, pas encore instancié">○ non instancié</span>' : '';
return `<button type="button" class="carte ${actif ? 'actif' : 'attente'} ${i === selServeur ? 'selectionnee' : ''}" id="tuile-srv-${i}" onclick="choisirServeur(${i})">
<div class="carte-haut"><span class="badge-statut ${actif ? 'ok' : 'attente'}">${actif ? 'actif' : 'planifié'}</span>${s.vmid ? `<span class="carte-vmid">#${echapper(s.vmid)}</span>` : ''}</div>
<div class="carte-nom ${s.nom ? '' : 'sans'}">${echapper(s.nom || '(sans nom)')}</div>
<div class="carte-ip">${pointVie(s.nom)}${echapper(s.adresse_ip || '—')}</div>
${recon ? `<div class="carte-grps">${recon}</div>` : ''}
// L'ETAT REEL, EN MOTS. Quatre axes ne se fondent jamais : plan, reel, inventaire,
// prerequis. Une seule pastille pour les quatre faisait lire « vert » la ou le plan
// etait actif et la machine morte.
function reelDe(nom) {
const v = sondes[nom];
if (v === true) return '<span class="reel"><span class="pt actif"></span>joignable</span>';
if (v === false) return '<span class="reel" style="color:var(--rouge)"><span class="pt bloq"></span>injoignable</span>';
return '<span class="reel"><span class="pt ns"></span>non sondé</span>';
}
function etiquettePlan(etat) {
return etat === 'actif' ? '<span class="etiq e-actif">actif</span>' : '<span class="etiq e-plan">planifié</span>';
}
function etiquetteInventaire(statut) {
return statut === 'divergence' ? '<span class="etiq e-div" title="Le plan diverge de l\'inventaire généré">divergent</span>'
: statut === 'absent' ? '<span class="etiq e-plan" title="Déclaré, pas encore instancié">non instancié</span>'
: '<span class="etiq e-recon">réconcilié</span>';
}
// LE PARC SE LIT PAR CATEGORIE DE LA NOMENCLATURE, pas en mosaique : la zone porte le
// VLAN, et c'est elle qu'on cherche quand un flux ne passe pas. L'ordre d'affichage
// est aussi celui de j/k — une seule source pour les deux.
function serveursEnGroupes() {
const groupes = {};
serveurs.forEach((s, i) => {
if (!correspondServeur(s)) return;
const info = infoFonction(s.fonction);
const cle = info ? String(info.categorie) : '~';
if (!groupes[cle]) groupes[cle] = {cle, libelle: info ? info.libelle : 'Sans catégorie',
vlan: info ? info.vlan : '', items: []};
groupes[cle].items.push([s, i]);
});
return Object.values(groupes)
.sort((a, b) => (a.cle === '~') - (b.cle === '~') || Number(a.cle) - Number(b.cle))
.map(g => ({...g, items: g.items.sort(([a], [b]) => String(a.nom).localeCompare(String(b.nom)))}));
}
function rangServeur(s, i) {
const h = hotes.find(x => x.nom === s.nom);
const rendus = applications.filter(a => a.hote === s.nom).map(a => a.id).filter(Boolean);
const plan = etiquettePlan(s.etat) + (h && hoteBloque(h) ? ' <span class="etiq e-bloq" title="Un prérequis manque">bloqué</span>' : '');
return `<button type="button" class="rang ${i === selServeur ? 'selectionnee' : ''}" id="tuile-srv-${i}" onclick="choisirServeur(${i})">
<span style="display:flex;flex-direction:column;min-width:0"><span class="carte-nom ${s.nom ? '' : 'sans'}">${echapper(s.nom || '(sans nom)')}</span>${s.vmid ? `<span class="carte-vmid">#${echapper(s.vmid)}</span>` : ''}</span>
<span class="ip">${echapper(s.adresse_ip || '—')}</span>
<span>${plan}</span>
<span>${reelDe(s.nom)}</span>
<span>${etiquetteInventaire(s.statut)}</span>
<span class="rendus" title="${echapper(rendus.join(', '))}">${echapper(rendus.join(' · ') || '—')}</span>
</button>`;
}
// LA CONSOLE D'UN SITE DESSINE SES MACHINES (2026-09-20).
// Garde pour compatibilite : d'autres vues peuvent encore demander une carte.
function carteServeur(s, i) { return rangServeur(s, i); }
// LA CONSOLE D'UN SITE DESSINE SES MACHINES (2026-09-20), ET SA FABRIC (2026-10-11).
//
// `inventaire_api_du_site` sert volontairement `serveurs: {}` — « on ne fabrique pas
// un faux plan de tenant pour remplir l'ecran ». C'est juste : un site declare ses
@ -3608,68 +3842,112 @@ let selMachine = null;
// locataire sans serveurs — « + Serveur », `make serveurs-bootstrap » — et les douze
// machines du site, servies dans `hotes`, n'etaient dessinees nulle part.
//
// Un site a douze machines et sa console n'en montrait aucune : c'est le defaut du
// 2026-09-16 qui revient par une autre porte. La premiere fois, l'inventaire etait
// vide ; cette fois il est plein et c'est la VUE qui regarde au mauvais endroit.
// L'inventaire du site porte aussi ses HYPERVISEURS : ils ne sont pas au plan des
// machines, ce sont les noeuds qui les portent. On les montre comme tels, avec ce
// qu'ils portent, plutot que melanges aux machines comme des VM « planifiees ».
function dessinerMachinesDuSite() {
const cible = document.getElementById('grilles');
const nom = (contexte && contexte.fabric) || 'ce site';
const entete = `<div class="section-grille"><div class="section-tete">Machines de ${echapper(nom)}
<span class="compte">${hotes.length}</span></div>
<div class="hint-dep">Ces machines sont déclarées dans le plan du site — <code>ip</code>,
<code>vmid</code> et <code>noeud</code> y sont <b>écrits</b>, pas dérivés d'un seed&nbsp;:
un site est le terrain, il n'a rien dont les faire descendre. Cette console ne les édite
donc pas&nbsp;; elle les <b>matérialise</b> et leur applique des rôles — voir la vue
<b>Assistants</b>.</div></div>`;
const entete = `<div class="vue-tete"><div style="display:flex;flex-direction:column;gap:2px">
<h2 class="vue-titre">Machines de ${echapper(nom)}</h2>
<span class="vue-sous">Déclarées dans le plan du site : <code>ip</code>, <code>vmid</code> et <code>noeud</code>
y sont <b>écrits</b>, pas dérivés d'un seed. Cette console ne les édite pas ; elle les
<b>matérialise</b> et leur applique des rôles — vue <b>Assistants</b>.</span></div></div>`;
if (!hotes.length) {
cible.innerHTML = entete + '<div class="vide" style="padding:14px">Cet inventaire dynamique ne rend aucune machine. Ce n\'est pas un plan vide : c\'est une mesure vide, et elle mérite un regard — <span class="mono">make site-inventaire</span>.</div>';
return;
}
const declares = {};
serveurs.forEach(s => { declares[s.nom] = s; });
const vus = hotes.map((h, i) => [h, i]).filter(([h]) =>
!filtre || (h.nom || '').toLowerCase().includes(filtre));
const sections = [['Actives', vus.filter(([h]) => h.etat === 'actif')],
['Planifiées', vus.filter(([h]) => h.etat !== 'actif')]];
cible.innerHTML = entete + sections.filter(([, l]) => l.length).map(([t, l]) =>
`<div class="section-grille"><div class="section-tete">${t} <span class="compte">${l.length}</span></div>
<div class="grille-cartes">${l.map(([h, i]) => carteMachineDuSite(h, i)).join('')}</div></div>`).join('');
const machines = vus.filter(([h]) => declares[h.nom]);
const noeuds = vus.filter(([h]) => !declares[h.nom]);
const portes = {};
serveurs.forEach(s => { if (s.noeud) (portes[s.noeud] = portes[s.noeud] || []).push(s.nom); });
const blocNoeuds = noeuds.length ? `<div class="section-tete">Nœuds de la fabric <span class="compte">${noeuds.length}</span></div>
<div class="noeuds">${noeuds.map(([h, i]) => {
const p = portes[h.nom] || [];
return `<button type="button" class="noeud ${i === selMachine ? 'selectionnee' : ''}" id="tuile-mach-${i}" onclick="choisirMachine(${i})" style="text-align:left;font:inherit;color:inherit;cursor:pointer">
<span class="noeud-tete"><b>${echapper(h.nom)}</b><span class="vue-sous" style="font-size:12px">nœud</span>
<span class="grandit"></span>${reelDe(h.nom)}</span>
<span class="puces">${p.map(m => `<span class="puce">${echapper(m)}</span>`).join('') || '<span class="vue-sous" style="font-size:12px">aucune machine du site</span>'}</span>
</button>`; }).join('')}</div>` : '';
const parReseau = {};
machines.forEach(([h, i]) => {
const r = (declares[h.nom] || {}).fonction || 'sans réseau';
(parReseau[r] = parReseau[r] || []).push([h, i]);
});
const rangs = Object.keys(parReseau).sort().map(r => {
const pref = (parReseau[r][0][0].adresse_ip || '').split('.').slice(0, 3).join('.');
return `<div class="groupe-tete">${echapper(r)}${pref ? `<span class="mono">${echapper(pref)}.0/24</span>` : ''}</div>`
+ parReseau[r].sort(([a], [b]) => a.nom.localeCompare(b.nom)).map(([h, i]) => carteMachineDuSite(h, i)).join('');
}).join('');
const blocMachines = machines.length ? `<div class="section-tete">Machines <span class="compte">${machines.length}</span></div>
<div class="tableau"><div class="tableau-corps">
<div class="rang site tete"><span>Machine</span><span>Adresse</span><span>VMID</span><span>Nœud</span><span>Réel</span><span>Rôles</span></div>
${rangs}</div></div>` : '';
cible.innerHTML = entete + blocNoeuds + blocMachines;
}
// LE MEME GABARIT QUE LES SERVEURS, et pas un second qui lui ressemblerait : deux
// cartes qui se ressemblent finissent par ne plus se ressembler.
// rangs qui se ressemblent finissent par ne plus se ressembler.
function carteMachineDuSite(h, i) {
const actif = h.etat === 'actif';
const roles = (h.groupes || []).length;
return `<button type="button" class="carte ${actif ? 'actif' : 'attente'} ${i === selMachine ? 'selectionnee' : ''}" id="tuile-mach-${i}" onclick="choisirMachine(${i})">
<div class="carte-haut"><span class="badge-statut ${actif ? 'ok' : 'attente'}">${actif ? 'actif' : 'planifié'}</span>${h.vmid ? `<span class="carte-vmid">#${echapper(h.vmid)}</span>` : ''}</div>
<div class="carte-nom">${echapper(h.nom)}</div>
<div class="carte-ip">${pointVie(h.nom)}${echapper(h.adresse_ip || '—')}</div>
${roles ? `<div class="carte-grps"><span class="tag">${roles} rôle${roles > 1 ? 's' : ''}</span></div>` : ''}
const s = serveurs.find(x => x.nom === h.nom) || {};
const roles = (h.groupes || []).filter(g => g.startsWith('serveur_') && g !== 'serveur_debian' && g !== 'serveur_durci')
.map(g => g.replace(/^serveur_/, ''));
return `<button type="button" class="rang site ${i === selMachine ? 'selectionnee' : ''}" id="tuile-mach-${i}" onclick="choisirMachine(${i})">
<span class="carte-nom">${echapper(h.nom)}</span>
<span class="ip">${echapper(h.adresse_ip || '—')}</span>
<span class="ip">${echapper(h.vmid || s.vmid || '—')}</span>
<span>${echapper(s.noeud || h.noeud || '—')}</span>
<span>${reelDe(h.nom)}</span>
<span class="rendus" title="${echapper((h.groupes || []).join(', '))}">${echapper(roles.join(' · ') || '—')}</span>
</button>`;
}
function choisirMachine(i) { selMachine = i; dessinerMachinesDuSite(); dessinerDetail(); }
function ouvrirAssistant(id) { rbSel = id; setVue('assistants'); }
function inspecteurMachineDuSite() {
const cible = document.getElementById('detail');
const h = hotes[selMachine];
if (!h) {
cible.innerHTML = `<div class="detail"><div class="section-tete">Console du SITE</div>
<div class="hint-dep">Choisis une machine à gauche. Cette console <b>matérialise</b> le
cible.innerHTML = `<div class="detail"><div class="section-fiche"><div class="section-tete">Console du SITE</div>
<div class="hint-dep">Choisis une machine ou un nœud à gauche. Cette console <b>matérialise</b> le
terrain — créer et détruire des VM, poser le réseau et la frontière — et n'entre
chez aucun locataire : elle n'a la voûte d'aucun d'eux.</div>
<div class="hint-dep" style="margin-top:10px">Les séquences qui lui reviennent sont
dans la vue <b>Assistants</b>.</div></div>`;
<div class="hint-dep" style="margin:10px 0 12px">Les séquences qui lui reviennent sont
dans la vue <b>Assistants</b>.</div></div></div>`;
return;
}
const ligne = (c, v) => v ? `<div><span class="aide-ro">${c}</span> <span class="mono">${echapper(String(v))}</span></div>` : '';
cible.innerHTML = `<div class="detail"><div class="section-tete">${echapper(h.nom)}</div>
${ligne('état', h.etat)}${ligne('adresse', h.adresse_ip)}${ligne('VMID', h.vmid)}
${ligne('VLAN', h.vlan)}${ligne('nœud', h.noeud)}
<div class="section-tete" style="margin-top:12px">Rôles</div>
<div>${(h.groupes || []).map(g => `<span class="chip">${echapper(g)}</span>`).join(' ') || '<span class="aide-ro">aucun</span>'}</div>
<div class="hint-dep" style="margin-top:12px">Lecture seule. Le plan de ce site vit dans
son dépôt&nbsp;; cette console ne l'édite pas. Pour lui appliquer un rôle&nbsp;:
<code>make site-appliquer GROUPE=…</code>, ou la vue <b>Assistants</b>.</div></div>`;
const s = serveurs.find(x => x.nom === h.nom);
const tuileEcrite = (v, l) => tuile(v, l);
const socle = ['serveur_debian', 'serveur_durci'];
const roles = (h.groupes || []).filter(g => !socle.includes(g) && !g.startsWith('client_'));
const clients = (h.groupes || []).filter(g => g.startsWith('client_'));
const presents = (h.groupes || []).filter(g => socle.includes(g));
cible.innerHTML = `<div class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(h.nom)}</span>
${s ? `<span class="zone-hint">réseau ${echapper(s.fonction || '—')}</span>` : '<span class="zone-hint">nœud de la fabric</span>'}</div>
<span class="grandit"></span>${s ? etiquettePlan(h.etat) : ''}</div>
<div class="stats">${tuileEcrite(h.adresse_ip, 'adresse · écrite')}${s ? tuileEcrite(h.vmid || s.vmid, 'VMID · écrit') + tuileEcrite(s.noeud || h.noeud, 'nœud · écrit') : ''}</div>
<div class="dsn" style="padding:0 16px 4px"><b>Réel</b> : ${reelDe(h.nom)}</div>
<div class="section-fiche"><div class="section-tete">Rôles</div>
<div class="puces">${roles.map(g => `<span class="puce">${echapper(g)}</span>`).join('')}
${presents.map(g => `<span class="puce socle" title="Le socle, posé sur toute machine du site">${echapper(g)}</span>`).join('')}
${roles.length || presents.length ? '' : '<span class="aide-ro">aucun</span>'}</div>
${clients.length ? `<div class="hint-dep" style="margin-top:8px">Intégrations : ${clients.map(g => echapper(g.replace(/^client_/, ''))).join(' · ')}</div>` : ''}</div>
<div class="gestes">
<div class="gestes-tete"><span class="section-tete" style="margin:0">Gestes sur cette machine</span></div>
<div class="geste"><div class="quoi"><b>Appliquer un rôle</b><span>Simulation d'abord, toujours, puis l'application.</span></div>
<button type="button" onclick="ouvrirAssistant('site-tenir')">Ouvrir l'assistant</button></div>
<div class="gestes-note">« Tenir un site en état » : <code>make site-appliquer GROUPE=…</code>, <code>site-decrire</code>.
Le plan de ce site vit dans son dépôt ; cette console ne l'édite pas.</div>
</div>
</div>`;
}
function dessinerServeurs() {
@ -3677,30 +3955,30 @@ let selMachine = null;
// pourtant des machines. On dessine CE QU'ELLE A.
if (!peut('editer_le_plan')) return dessinerMachinesDuSite();
const cible = document.getElementById('grilles');
const entete = BANNIERE_MODELE + `<div class="section-grille"><div class="section-tete">Serveurs <span class="compte">${serveurs.length}</span>
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterServeur()">+ Serveur</button>
<button type="button" class="fantome" onclick="appliquerPlan()">⚙ Appliquer le plan</button></div></div>`;
const entete = `<div class="vue-tete"><div style="display:flex;flex-direction:column;gap:2px">
<h2 class="vue-titre">Parc</h2>
<span class="vue-sous">Les serveurs du plan, à côté de ce que la flotte montre vraiment.</span></div>
<span class="grandit"></span>
<button type="button" class="fantome" onclick="ajouterServeur()">+ Serveur</button>
<button type="button" class="fantome" onclick="appliquerPlan()">⚙ Appliquer le plan</button></div>` + BANNIERE_MODELE;
if (!serveurs.length) { cible.innerHTML = entete + '<div class="vide" style="padding:14px">Aucun serveur. « + Serveur » ou <span class="mono">make serveurs-bootstrap</span>.</div>'; return; }
const idx = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s));
const sections = [['Actifs', idx.filter(([s]) => s.etat === 'actif')], ['Planifiés', idx.filter(([s]) => s.etat !== 'actif')]];
cible.innerHTML = entete + sections.filter(([, l]) => l.length).map(([t, l]) =>
`<div class="section-grille"><div class="section-tete">${t} <span class="compte">${l.length}</span></div><div class="grille-cartes">${l.map(([s, i]) => carteServeur(s, i)).join('')}</div></div>`).join('');
const groupes = serveursEnGroupes();
if (!groupes.length) { cible.innerHTML = entete + '<div class="vide" style="padding:14px">Aucun serveur ne correspond au filtre.</div>'; return; }
cible.innerHTML = entete + `<div class="tableau"><div class="tableau-corps">
<div class="rang tete"><span>Serveur</span><span>Adresse</span><span>Plan</span><span>Réel</span><span>Inventaire</span><span>Services rendus</span></div>
${groupes.map(g => `<div class="groupe-tete">${echapper(g.libelle)}${g.vlan ? `<span class="mono">VLAN ${echapper(g.vlan)}</span>` : ''}<span class="compte">${g.items.length}</span></div>`
+ g.items.map(([s, i]) => rangServeur(s, i)).join('')).join('')}
</div></div>`;
}
function inspecteurServeur() {
if (!peut('editer_le_plan')) return inspecteurMachineDuSite();
const cible = document.getElementById('detail');
if (selServeur == null || !serveurs[selServeur]) {
hoteOpCourant = '';
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun serveur sélectionné</b>Choisis une tuile à gauche, ou ajoute un serveur.</div></div>';
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun serveur sélectionné</b>Choisis une ligne à gauche, ou ajoute un serveur.</div></div>';
return;
}
const s = serveurs[selServeur];
const vi = (intrants && intrants.valeurs) || {};
const libDef = (d) => '(défaut' + (d ? ' : ' + d : '') + ')';
const optFonction = Object.keys(nomenclature.fonctions || {}).map(f => `<option value="${echapper(f)}" ${f === s.fonction ? 'selected' : ''}>${echapper(f)}</option>`).join('');
const optEtat = ['planifie', 'actif'].map(e => `<option value="${e}" ${e === s.etat ? 'selected' : ''}>${e}</option>`).join('');
const optDeListe = (liste, sel, lib) => `<option value="" ${sel ? '' : 'selected'}>${echapper(lib)}</option>`
+ [...new Set([...(liste || []), ...(sel ? [sel] : [])])].map(v => `<option value="${echapper(v)}" ${v === sel ? 'selected' : ''}>${echapper(v)}</option>`).join('');
const set = new Set(listeDepuisTexte(s.integrations));
const tousRoles = [...new Set([...integrationsDispo, ...set])];
const cases = tousRoles.length
@ -3721,54 +3999,63 @@ let selMachine = null;
const h = hotes.find(x => x.nom === s.nom);
const apps = applications.filter(a => a.hote === s.nom);
const appsHtml = apps.length
? `<div class="ch-bases">${apps.map(a => `<div class="ch-base"><span class="badge-bd">app</span> <button type="button" class="lien-objet" onclick="ouvrirApp('${echapper(a.id)}')">${echapper(a.id || '(sans id)')}</button>${a.expose ? ` <span class="mono">→ ${echapper(listeDepuisTexte(a.expose).join(', '))}</span>` : ''}</div>`).join('')}</div>`
? `<div class="ch-bases" style="margin-left:0">${apps.map(a => `<div class="ch-base"><span class="badge-bd">app</span> <button type="button" class="lien-objet" onclick="ouvrirApp('${echapper(a.id)}')">${echapper(a.id || '(sans id)')}</button>${a.expose ? ` <span class="mono">→ ${echapper(listeDepuisTexte(a.expose).join(', '))}</span>` : ''}</div>`).join('')}</div>`
: '<div class="ch-stub">aucune application sur cet hôte</div>';
const bds = bdServeurs.filter(x => x.hote === s.nom);
const bdHtml = bds.length
? `<div class="ch-bases">${bds.map(x => `<div class="ch-base"><span class="badge-bd">${echapper(x.type)}</span> <button type="button" class="lien-objet" onclick="ouvrirBaseServeur('${echapper(x.nom)}')">${echapper(x.nom)}</button> <span class="mono">:${echapper(x.port)}</span></div>`).join('')}</div>`
? `<div class="ch-bases" style="margin-left:0">${bds.map(x => `<div class="ch-base"><span class="badge-bd">${echapper(x.type)}</span> <button type="button" class="lien-objet" onclick="ouvrirBaseServeur('${echapper(x.nom)}')">${echapper(x.nom)}</button> <span class="mono">:${echapper(x.port)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucun serveur de base ici</div>';
hoteOpCourant = s.nom;
const hi = h ? hotes.indexOf(h) : -1;
let boutons = '';
// CE QUE LA PORTEE INTERDIT NE SE PROPOSE PAS COMME LE RESTE. Le bouton demeure —
// le retirer ferait croire que le geste n'existe pas — mais il est inerte et porte
// sa raison en infobulle. Meme geste que les integrations universelles, affichees
// en lecture seule plutot qu'omises.
const inerte = (pouvoir) => peut(pouvoir) ? ''
: ` disabled title="${echapper(pourquoiPas(pouvoir))}" style="opacity:.45"`;
if (h) boutons += `<button type="button"${peut('materialiser')
? ' title="Cloner la VM depuis le golden template (Proxmox)"' : inerte('materialiser')} onclick="lancer('creer', ${hi})">🖥 Créer la VM</button>`;
if (h && h.etat === 'actif' && !hoteBloque(h)) {
const clsDep = estProduction ? 'danger' : 'primaire';
boutons += `<button type="button" id="btn-verifier"${inerte('configurer')} onclick="lancer('verifier', ${hi})">Vérifier</button>`
+ `<button type="button" id="btn-deployer" class="${clsDep}"${inerte('configurer')} onclick="lancer('deployer', ${hi})">Déployer</button>`;
}
let pied;
if (boutons) {
const bloque = h && h.etat === 'actif' && hoteBloque(h);
const hint = bloque ? `<span class="hint-dep">Prérequis manquant : ${[...new Set((h.groupes || []).flatMap(g => manquantsPourGroupe(g)))].join(', ')}</span>` : '';
pied = `<div class="pied">${estProduction ? '<span class="badge-prod">PROD</span>' : ''}${hint}<span class="grandit"></span>${boutons}</div>`;
// LES GESTES EN TETE DE FICHE, DANS LEUR ORDRE (2026-10-11). Ils etaient en pied,
// sous le formulaire : on les cherchait. L'ordre Verifier → Deployer est celui que
// `majBoutonsDeploiement` impose deja ; la fiche le montre au lieu de le cacher.
let gestes = '';
if (h) {
const bloque = h.etat === 'actif' && hoteBloque(h);
const creer = `<div class="geste"><span class="num">⊕</span><div class="quoi"><b>Créer la VM</b><span>Cloner depuis le gabarit doré (Proxmox).</span></div>
<button type="button"${peut('materialiser') ? ' title="Cloner la VM depuis le golden template (Proxmox)"' : inerte('materialiser')} onclick="lancer('creer', ${hi})">🖥 Créer la VM</button></div>`;
let suite = '';
if (h.etat === 'actif' && !bloque) {
const clsDep = estProduction ? 'prod' : 'primaire';
suite = `<div class="geste"><span class="num">1</span><div class="quoi"><b>Vérifier</b><span>Simulation <code>--check --diff</code>, n'écrit rien.</span></div>
<button type="button" id="btn-verifier"${inerte('configurer')} onclick="lancer('verifier', ${hi})">Vérifier</button></div>
<div class="geste"><span class="num">2</span><div class="quoi"><b>Déployer</b><span>S'ouvre après une vérification réussie dans cette session.</span></div>
<button type="button" id="btn-deployer" class="${clsDep}"${inerte('configurer')} onclick="lancer('deployer', ${hi})">Déployer</button></div>`;
} else if (bloque) {
suite = `<div class="gestes-note" style="border:0;padding:0;color:var(--rouge)">Prérequis manquant : ${echapper([...new Set((h.groupes || []).flatMap(g => manquantsPourGroupe(g)))].join(', '))}</div>`;
} else {
suite = '<div class="gestes-note" style="border:0;padding:0">Planifié : passe-le à « actif » au plan pour le vérifier et le déployer.</div>';
}
gestes = `<div class="gestes">
<div class="gestes-tete"><span class="section-tete" style="margin:0">Gestes sur ce serveur</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}</div>
${suite}
${peut('materialiser') ? creer : `<div class="gestes-note" title="${echapper(pourquoiPas('materialiser'))}">Créer la VM relève de la console du site : celle-ci n'a pas sa voûte.</div>`}
</div>`;
} else {
pied = `<div class="pied"><span class="hint-dep">Applique le plan pour instancier cet hôte (⚙ Appliquer le plan)</span></div>`;
gestes = `<div class="gestes"><div class="gestes-note" style="border:0;padding:0">Applique le plan pour instancier cet hôte (⚙ Appliquer le plan).</div></div>`;
}
const statBadge = s.statut === 'divergence' ? '<span class="badge-statut bloque">divergent</span>'
: s.statut === 'absent' ? '<span class="badge-statut attente">non instancié</span>'
: '<span class="badge-statut ok">réconcilié</span>';
const statBadge = etiquetteInventaire(s.statut);
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(s.nom || '(sans nom)')}</span>${zoneHint}</div>
<span class="grandit"></span>${statBadge}
<button type="button" class="danger" title="Retirer ce serveur" onclick="retirerServeur()">✕</button></div>
<div class="stats">${tuile(s.vmid, 'VMID')}${tuile(s.adresse_ip, 'IP')}${tuile(s.vlan, 'VLAN')}</div>
<div class="dsn" style="padding:5px 2px"><b>Plan</b> : ${echapper(s.etat)} &nbsp;·&nbsp; <b>Réel</b> : ${sondes[s.nom] === true ? '<span class="pt actif"></span> vivante' + (s.adresse_ip ? ' (' + echapper(s.adresse_ip) + ')' : '') : sondes[s.nom] === false ? '<span class="pt bloq"></span> injoignable' : '<span class="pt plan"></span> non sondé'}</div>
<div class="section-tete" style="margin:4px 2px 2px">Identité — saisie du plan (surcharge les défauts)</div>
${formulaireDepuisSchema('serveurs', s, 'definirServeur', s.nom, "definirServeur.bind(null,'nom')", 'nom')}
<div class="grille">
<label class="champ" style="grid-column:1/-1"><span>Intégrations (rôles client)</span><div class="integ-cases">${univ}${exempts}${cases}</div><span class="dsn" style="font-size:11px">Les ✓ sont la politique des rôles : elles s'appliquent à tout hôte et ne se décochent pas.</span></label>
</div>
<div class="section-tete" style="margin:12px 2px 2px">Applications ici</div>${appsHtml}
<div class="section-tete" style="margin:12px 2px 2px">Bases ici</div>${bdHtml}
<div class="dsn" style="padding:6px 2px;color:#94a3b8">VMID / IP / VLAN sont <b>dérivés</b>. Sauvegarde (Ctrl+S) puis « Appliquer le plan » pour régénérer l'inventaire.</div>
${pied}
<span class="grandit"></span>${etiquettePlan(s.etat)} ${statBadge}
<button type="button" class="danger" title="Retirer ce serveur" aria-label="Retirer ce serveur du plan" onclick="retirerServeur()">✕</button></div>
${gestes}
<div class="stats">${tuile(s.vmid, 'VMID · dérivé')}${tuile(s.adresse_ip, 'IP · dérivée')}${tuile(s.vlan, 'VLAN · dérivé')}</div>
<div class="dsn" style="padding:0 16px 4px"><b>Plan</b> : ${echapper(s.etat)} &nbsp;·&nbsp; <b>Réel</b> : ${reelDe(s.nom)}</div>
<div class="section-fiche"><div class="section-tete">Ce qu'il rend</div>${appsHtml}${bdHtml}</div>
<div class="section-fiche"><div class="section-tete">Intégrations</div>
<div class="integ-cases">${univ}${exempts}${cases}</div>
<span class="dsn" style="font-size:11px">Les ✓ sont la politique des rôles : elles s'appliquent à tout hôte et ne se décochent pas.</span></div>
<div class="section-fiche"><div class="section-tete">Identité au plan <span class="compte">vide = défaut</span></div>
${formulaireDepuisSchema('serveurs', s, 'definirServeur', s.nom, "definirServeur.bind(null,'nom')", 'nom')}</div>
<div class="dsn" style="padding:10px 16px 16px;color:#94a3b8">VMID / IP / VLAN sont <b>dérivés</b>. Sauvegarde (Ctrl+S) puis « Appliquer le plan » pour régénérer l'inventaire.</div>
</article>`;
majBoutonsDeploiement();
}
@ -4208,21 +4495,42 @@ let selMachine = null;
if (!runbooks.length) {
g.innerHTML = '<div class="hint-dep">Chargement des assistants…</div>';
chargerRunbooks().then(() => {
dessinerNavigation();
if (vuePrincipale === 'assistants') { dessinerAssistants(); dessinerDetail(); }
});
return;
}
const puces = runbooks.map(rb => {
// CE QUE CETTE CONSOLE CONDUIT D'ABORD, LE RESTE REPLIE (2026-10-11). Les dix-sept
// runbooks s'affichaient pele-mele, les hors-portee simplement plus pales. Ils restent
// lisibles — savoir chez qui un geste se fait fait partie du metier — mais ne
// disputent plus la place a ceux qu'on peut lancer.
if (!rbSel || !runbooks.some(x => x.id === rbSel)) rbSel = (runbooks.find(x => x.conduisible) || runbooks[0]).id;
const puce = rb => {
const n = rb.etapes.length;
return '<button type="button" class="rb-puce' + (rb.id === rbSel ? ' on' : '')
+ (rb.conduisible ? '' : ' hors') + '" onclick="choisirRunbook(\'' + rb.id + '\')"'
+ ' title="' + echapper(rb.conduisible ? rb.but : rb.raison) + '">'
+ echapper(rb.titre) + '<span class="chip">' + n + '</span></button>';
}).join('');
};
const ici = runbooks.filter(x => x.conduisible), ailleurs = runbooks.filter(x => !x.conduisible);
const puces = ici.map(puce).join('');
const autres = ailleurs.length
? '<details class="rb-autres"' + (ailleurs.some(x => x.id === rbSel) ? ' open' : '') + '><summary>'
+ ailleurs.length + ' assistant(s) relèvent d\'une autre console — à lire, pas à lancer ici</summary>'
+ '<div class="rb-liste">' + ailleurs.map(puce).join('') + '</div></details>'
: '';
const rb = rbCourant();
let corps = '';
if (rb) {
corps = rb.etapes.map((e, i) => {
const faites = rb.etapes.filter((e, i) => rbEtat(rb.id, i) === 'ok').length;
const ratees = rb.etapes.filter((e, i) => rbEtat(rb.id, i) === 'echec').length;
const total = rb.etapes.length || 1;
corps += '<div class="rb-entete"><h2 class="vue-titre">' + echapper(rb.titre) + '</h2>'
+ '<div class="vue-sous">' + echapper(rb.but) + '</div>'
+ '<div class="rb-progres"><div class="rb-barre"><span class="ok" style="width:' + (100 * faites / total) + '%"></span>'
+ '<span class="ko" style="width:' + (100 * ratees / total) + '%"></span></div>'
+ faites + ' réussie(s) · ' + ratees + ' en échec · ' + (rb.etapes.length - faites - ratees) + ' à faire</div></div>';
corps += rb.etapes.map((e, i) => {
const etat = rbEtat(rb.id, i);
const bloquee = etapeBloquee(rb, i);
const cls = etat === 'ok' ? ' faite' : etat === 'echec' ? ' ratee' : bloquee ? ' bloquee' : '';
@ -4231,7 +4539,7 @@ let selMachine = null;
const meta = [e.duree ? 'durée observée ' + e.duree : '',
e.facultative ? 'facultative' : ''].filter(Boolean).join(' · ');
return '<div class="rb-etape' + cls + '">'
+ '<div class="rb-num">' + (i + 1) + '</div>'
+ '<div class="rb-num">' + (etat === 'ok' ? '✓' : etat === 'echec' ? '✗' : (i + 1)) + '</div>'
+ '<div><div><span class="rb-cible">make ' + echapper(e.cible)
+ (vars ? ' ' + echapper(vars) : '') + (fixes ? ' ' + echapper(fixes) : '')
+ '</span><span class="rb-nature ' + e.nature + '">' + e.nature + '</span></div>'
@ -4243,7 +4551,7 @@ let selMachine = null;
+ (e.conduisible === false ? '<div class="rb-meta">⊘ hors de portée de cette console — '
+ echapper(e.raison || '') + '</div>' : '')
+ '</div>'
+ '<div><button type="button" class="' + (e.nature === 'destructif' ? '' : 'primaire')
+ '<div><button type="button" class="' + (e.nature === 'destructif' ? 'destructif' : e.nature === 'ecriture' ? 'primaire' : '')
+ '" ' + (bloquee ? 'disabled' : '') + ' onclick="lancerEtape(' + i + ')">'
+ (e.nature === 'mesure' ? 'Mesurer' : e.nature === 'destructif' ? 'Détruire…' : 'Lancer')
+ '</button></div></div>';
@ -4253,7 +4561,7 @@ let selMachine = null;
? '<div class="hint-dep" style="border-color:#8a3b3b">Le registre des runbooks porte '
+ rbEcarts.length + ' écart(s) avec le Makefile : <code>python3 scripts/runbooks.py verifier</code></div>'
: '';
g.innerHTML = alerte + '<div class="rb-liste">' + puces + '</div>' + corps;
g.innerHTML = alerte + '<div class="rb-liste">' + puces + '</div>' + autres + corps;
}
function choisirRunbook(id) { rbSel = id; dessinerAssistants(); dessinerDetail(); }
@ -4263,9 +4571,10 @@ let selMachine = null;
const rb = rbCourant();
if (!rb) { d.innerHTML = '<div class="detail"><div class="hint-dep">Aucun assistant.</div></div>'; return; }
const compte = rb.etapes.filter((e, i) => rbEtat(rb.id, i) === 'ok').length;
d.innerHTML = '<div class="detail"><div class="section-tete">' + echapper(rb.titre) + '</div>'
+ '<div class="rb-but">' + echapper(rb.but) + '</div>'
+ '<div class="hint-dep" style="margin-top:10px">Portée&nbsp;: <code>' + echapper(rb.portee)
// Le titre et le but sont deja en tete de la vue : ce panneau dit le reste — la
// portee, ce qui est fait, ce qui se lit ailleurs, et d'ou viennent les libelles.
d.innerHTML = '<div class="detail"><div class="section-fiche" style="padding-bottom:16px"><div class="section-tete">Cet assistant</div>'
+ '<div class="hint-dep">Portée&nbsp;: <code>' + echapper(rb.portee)
+ '</code> · ' + compte + '/' + rb.etapes.length + ' étape(s) réussie(s) dans cette session.</div>'
+ (function () {
const fermees = rb.etapes.filter(e => e.conduisible === false);
@ -4280,7 +4589,7 @@ let selMachine = null;
+ '<div class="hint-dep" style="margin-top:10px">Les libellés viennent du <code>Makefile</code>, '
+ 'lus à chaque affichage&nbsp;; l\'ordre et le « pourquoi » viennent de '
+ '<code>docs/runbooks-construction.yml</code>. Une étape d\'écriture attend que la '
+ 'précédente ait réussi&nbsp;; une mesure reste toujours offerte.</div></div>';
+ 'précédente ait réussi&nbsp;; une mesure reste toujours offerte.</div></div></div>';
}
// La modale d'etape : les champs que le REGISTRE declare, et rien d'autre.
@ -4395,9 +4704,10 @@ let selMachine = null;
else message('Vue en lecture seule (Flux / Couches) — édite dans Serveurs / Applications / Bases / Domaines.');
}
// j/k SUIT L'ORDRE AFFICHE — par categorie, puis par nom. Deux ordres pour une meme
// liste, c'est un j qui saute une ligne qu'on a sous les yeux.
function ordreServeursVisibles() {
const vis = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s));
return [...vis.filter(([s]) => s.etat === 'actif'), ...vis.filter(([s]) => s.etat !== 'actif')].map(([, i]) => i);
return serveursEnGroupes().flatMap(g => g.items.map(([, i]) => i));
}
function naviguerServeurs(delta) {
const ordre = ordreServeursVisibles();