From 6c1d26ebf724839ab057da194b6a49bfd8374a70 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 30 Jun 2026 10:07:17 -0400 Subject: [PATCH] =?UTF-8?q?GUI=20:=20panneau=20Intrants=20de=20base,=20inf?= =?UTF-8?q?o-bulles,=20source=20d'identit=C3=A9=20partag=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau panneau « ⚙ Intrants » pour saisir d'un endroit unique les valeurs communes à l'écosystème, avec distinction constantes (non surchargeables) vs défauts (surchargeables dans les instances). Les secrets ne sont jamais saisis ni affichés (garde INTRANTS_CLES_INTERDITES + filtrage par schéma) ; un domaine_interne vide est refusé et son changement demande confirmation. Ajoute aussi les info-bulles d'aide au survol des champs. domaine_interne/chezlepro_timezone consolidés en une source partagée (inventories/partage/intrants-identite.yml). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 41 +++- docs/intrants-base-gui-conception.md | 94 ++++++++ docs/intrants-communs.md | 98 ++++++++ scripts/inventory_gui.py | 335 +++++++++++++++++++++++++++ 4 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 docs/intrants-base-gui-conception.md create mode 100644 docs/intrants-communs.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da9c46..55c3588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,45 @@ # CHANGELOG — Set-OPS -## 2026-06-25 +## 2026-06-30 + +### Ajouté +- **Panneau « Intrants de base » dans le GUI.** Un bouton ⚙ Intrants ouvre une fenêtre + unique pour saisir les valeurs communes à tout l'écosystème, avec une distinction + visible entre **constantes** (valeur unique, non surchargeable) et **défauts** + (valeurs proposées, surchargeables dans les instances). Les secrets ne sont **jamais** + saisis ni affichés ici (garde-fou `INTRANTS_CLES_INTERDITES` + filtrage par schéma) : + ils restent dans le Vault Ansible, édités en CLI ; le panneau les liste seulement à + titre informatif. Un changement de `domaine_interne` (clé de voûte) demande une + confirmation explicite ; un `domaine_interne` vide est refusé côté serveur. La + nomenclature reste en lecture seule (modifiable dans le plan). Voir + `docs/intrants-communs.md` et `docs/intrants-base-gui-conception.md`. +- **Source unique d'identité partagée.** `domaine_interne` et `chezlepro_timezone` + vivent désormais dans `inventories/partage/intrants-identite.yml`, référencé par + symlink depuis chaque environnement (`group_vars/all/10-intrants.yml`) — fin de la + duplication lab/production. +- **Info-bulles d'aide sur les champs du GUI.** Survoler la description d'un champ à + saisir affiche une bulle avec des instructions sommaires. + +## 2026-06-28 + +### Ajouté +- **Document de présentation de l'écosystème Chezlepro** (`docs/ecosysteme-chezlepro.md`). + Description vulgarisée à destination client/partenaire : les piliers de l'écosystème + souverain, le modèle reproductible (plan → génération → clonage → conformité), et un + inventaire des **mesures de renforcement** réellement en place (SSH durci, fail2ban, + sysctl, nftables, AppArmor, auditd, mises à jour automatiques, gestion des secrets, + garde-fous destructifs, sécurité par l'architecture). Honnête sur le statut + « défini/validé vs déployé ». + +### Ajouté +- **Dimensionnement dérivé des ressources VM.** Les cœurs/RAM/disque d'une VM sont + désormais **estimés depuis les logiciels hébergés + le socle SE**, au lieu d'hériter + des specs du golden template (CPU/RAM identiques pour toutes les VM auparavant). + Chaque rôle déclare son empreinte (`roles//meta/empreinte.yml`) ; le + générateur somme par hôte (marge + arrondis) et écrit `proxmox_coeurs`/ + `proxmox_memoire`/`proxmox_disque_taille` ; le clonage passe `cores`/`memory` à + Proxmox (`omit` si absent → aucune régression). Override par hôte possible dans le + plan (`serveurs.yml`). Voir `docs/dimensionnement-ressources.md`. ### Corrigé - **`make instancier-appliquer FORCE=1` n'honorait pas `--force`.** La recette Makefile lançait `instancier.py appliquer` sans relayer `FORCE` ; le message « Utilise FORCE=1 » était donc trompeur (un changement de plan intentionnel restait bloqué). La recette passe désormais `$(if $(FORCE),--force)`. Trouvé en dogfooding. diff --git a/docs/intrants-base-gui-conception.md b/docs/intrants-base-gui-conception.md new file mode 100644 index 0000000..82945bc --- /dev/null +++ b/docs/intrants-base-gui-conception.md @@ -0,0 +1,94 @@ +# Note de conception — panneau « Intrants de base » du GUI + +But : saisir depuis **un endroit unique** les [intrants communs](intrants-communs.md) +de l'écosystème, en distinguant **constantes** et **défauts surchargeables**. + +> Extension assumée du plan de contrôle (gelé — cf. [`positionnement.md`](positionnement.md)), +> au même titre que le [dimensionnement](dimensionnement-ressources.md). Décidée le +> 2026-06-26. + +## 1. Décisions cadre (validées) + +1. **Secrets : hors périmètre.** Le GUI n'affiche ni ne stocke aucun secret. Les + `vault_*` et tokens Proxmox restent édités via Ansible Vault en ligne de commande. + Le panneau peut, au plus, afficher une **liste de rappel en lecture seule** des + secrets attendus (sans valeur). +2. **Nomenclature : lecture seule** dans le panneau. L'édition de `supernet`/VLAN/ + catégories reste dans `plan/nomenclature.yml` (autorité unique du plan réseau). +3. **Conception avant code** (cette note). + +## 2. Modèle : constante vs défaut surchargeable + +- **Constante** — une seule valeur, pas de surcharge. Éditée *uniquement* au panneau ; + apparaît en **lecture seule** dans les formulaires d'hôte (contexte). +- **Défaut surchargeable** — valeur globale qui **ressurgit** comme défaut là où + l'intrant réapparaît (hôte / groupe). Le formulaire d'hôte la montre **pré-remplie + et marquée « hérité »** ; toute saisie locale devient une **surcharge** (badge + « surchargé » + bouton « rétablir le défaut »). + +Classification de départ : voir [`intrants-communs.md` §2](intrants-communs.md). +Résumé : *Constantes* = `domaine_interne`, nomenclature, accès Proxmox, golden +template, secrets. *Défauts* = timezone, nœud/stockage/pont Proxmox, DNS internes, +politiques de durcissement, relais SMTP, `ciuser`/compte `ansible`. + +## 3. Persistance — fichiers cibles, sans perte de commentaires + +Contrainte : pas de dépendance non-stdlib (souveraineté) → pas de round-trip YAML +préservant les commentaires (ruamel). Solution : **fichiers possédés par le GUI**, +réécrits en bloc avec un **en-tête généré** (comme `hosts.genere.yml`), à côté des +fichiers tenus à la main. + +| Domaine | Fichier possédé par le GUI | Tenu à la main (coexiste) | +| --- | --- | --- | +| Identité + politiques (défauts) | `group_vars/all/10-intrants.yml` | `group_vars/all/00-instance.yml` | +| Proxmox (constantes + défauts) | `group_vars/proxmox/10-intrants.yml` | `group_vars/proxmox/00-base.yml` | +| Template/durcissement (défauts) | `group_vars/modeles_vm/10-intrants.yml` | `group_vars/modeles_vm/00-base.yml` | + +> Migration légère : convertir `group_vars/all.yml` → répertoire `group_vars/all/` +> (Ansible le supporte nativement). Idem `proxmox.yml`, `modeles_vm.yml`. Les valeurs +> existantes sont scindées : commentaires/contexte dans `00-*`, valeurs pilotées par +> le GUI dans `10-intrants.yml`. La précédence Ansible reste identique. + +Les **surcharges par hôte** continuent de vivre dans `plan/serveurs.yml` (déjà +supporté) ; par groupe, dans le `group_vars//` correspondant. + +## 4. Mécanique d'héritage (côté générateur) + +- Le panneau écrit les **défauts** dans les `10-intrants.yml`. +- `instancier.py` / les formulaires lisent ces défauts pour **pré-remplir** et + marquer « hérité » ; une valeur présente dans `serveurs.yml` (hôte) **prime** et + s'affiche « surchargé » (même logique `setdefault` que le dimensionnement). +- Les **constantes** ne sont jamais réémises par hôte : un seul point de vérité. + +## 5. Écran (UI) + +- Bouton **« Intrants de base »** dans l'en-tête du GUI → panneau modal/plein écran. +- Sections repliables par domaine : **Identité**, **Proxmox**, **Politiques de + durcissement**, **Services centraux**, **Nomenclature (lecture seule)**, + **Secrets attendus (lecture seule)**. +- Chaque champ porte un badge **Constante** / **Défaut**, et l'info-bulle d'aide + (réutilise le mécanisme `AIDES` déjà en place). +- Bandeau d'avertissement sur `domaine_interne` (changement à fort impact : re-dérive + zones, FQDN, base DN…) → confirmation explicite. + +## 6. Garde-fous + +- `--syntax-check` + `ansible-inventory --list` doivent rester verts après écriture. +- Discipline **diff-vide** : `make instancier` montre l'impact, application via FORCE + si intentionnel. +- Aucune valeur secrète n'entre dans un fichier écrit par le GUI (vérifié par un test). + +## 7. Périmètre proposé (MVP → suite) + +- **MVP** : `domaine_interne` (constante, avec garde-fou), `chezlepro_timezone` + (défaut), accès Proxmox (constantes), golden template (constantes), placement + Proxmox par défaut (nœud/stockage/pont), + nomenclature et secrets en lecture seule. +- **Suite** : politiques de durcissement (défauts par groupe), DNS internes, relais + SMTP, endpoints services centraux. + +## 8. Points ouverts à confirmer +- OK pour la **migration `group_vars/*.yml` → répertoires** (§3) ? (alternative : + réécrire les fichiers existants en bloc, au prix des commentaires). +- Le panneau affiche-t-il la **liste de rappel des secrets attendus** (lecture seule), + ou on n'en parle pas du tout dans le GUI ? +- Périmètre MVP (§7) suffisant pour une première itération ? diff --git a/docs/intrants-communs.md b/docs/intrants-communs.md new file mode 100644 index 0000000..738fe29 --- /dev/null +++ b/docs/intrants-communs.md @@ -0,0 +1,98 @@ +# Intrants communs de l'écosystème + +Recensement des **intrants communs** : les valeurs partagées par tout l'écosystème +(par opposition aux valeurs propres à un seul hôte). Objectif : les saisir **une +fois**, depuis un endroit unique, puis les laisser se **dériver** ou se **propager**. + +> Concept lié : [`meta-classe.md`](meta-classe.md) (une définition instancie la flotte) +> et [`dimensionnement-ressources.md`](dimensionnement-ressources.md). + +## 1. Recensement par domaine + +### A. Identité de l'instance — `group_vars//all.yml` +- **`domaine_interne`** — domaine DNS interne (ex. `chezlepro.internal`). **Keystone** : + zones DNS, FQDN, base DN LDAP, expéditeurs courriel, URL AC en dérivent. +- `chezlepro_timezone` — fuseau (ex. `America/Toronto`). +- `setops_plan_dir` — chemin du plan. + +### B. Réseau & nomenclature — `plan/nomenclature.yml` +- `supernet`, `cidr_hote`, `reservations`, `categories` (VLAN/sous-réseau/passerelle), + `fonctions` (catégorie + service). + +### C. Hyperviseur Proxmox — `group_vars/proxmox.yml` (+ `proxmox.vault.yml`) +- `proxmox_api_host`, `proxmox_api_user`, `proxmox_api_port`, `proxmox_validate_certs`. +- 🔒 `proxmox_api_token_id`, `proxmox_api_token_secret` *(Vault)*. +- Golden template : `proxmox_clone_vmid_modele`, `proxmox_clone_source_nom`. +- Placement par défaut : `proxmox_clone_noeud`, `proxmox_clone_stockage`, + `proxmox_clone_pont`, format, complet, timeout, disque, interface, démarrer. + +### D. Identité initiale des VM — `group_vars/modeles_vm.yml`, `creer-vm` +- **Clé publique SSH** (cloud-init) — accès admin de toute la flotte. +- Compte technique `ansible` (`sudo_ansible_admin_user`) + sudo NOPASSWD, `ciuser`. +- Politiques SSH communes (port, password auth off, root login off, grace/auth tries). + +### E. Socle durci commun — `group_vars/modeles_vm.yml` +nftables baseline · fail2ban SSH · auditd · AppArmor · sysctl · unattended-upgrades · +journald (rétention) · core_dumps · systemd_ssh_auto. + +### F. Endpoints des services centraux (les rôles `client_*` en dérivent) +- DNS interne (`client_dns_nameservers`, zone = `domaine_interne`). +- AC/PKI (`client_pki_ca_url` → infra-pki, provisioner). +- IdM/LDAP (`client_ldap_uri` → idm, base DN dérivé). +- Relais courriel (`client_smtp_relais` → infra-mail, port 25). +- Métriques (node_exporter `:9100`). +- Journaux (`client_journal_loki_url` → obs). +- Temps (NTP) : **implicite** (pool Debian par défaut — aucun intrant configuré). + +### G. Dépôts APT tiers communs +- Smallstep (step-cli) — `client_pki` ↔ `serveur_step_ca`. +- Grafana (alloy/loki/grafana) — `client_journal` ↔ `serveur_loki`/`serveur_grafana`. + +### H. Secrets Vault communs 🔒 — `proxmox.vault.yml` + vault d'instance +`proxmox_api_token_id/secret`, `vault_step_ca_password`, `vault_step_ca_fingerprint`, +`vault_step_ca_provisioner_password`, `vault_openldap_admin`, `vault_ldap_sssd`, +`vault_keycloak_admin`, `vault_postgresql_keycloak`, `vault_bd_keycloak`, +`vault_forgejo_admin`, `vault_forgejo_secret_key`, `vault_forgejo_internal_token`, +`vault_bd_forgejo`, `vault_grafana_admin`, `vault_redis`, `vault_bd_icingadb`. +Mot de passe du Vault lui-même : saisi au déploiement. + +### I. Exposition publique — `plan/domaines.yml` +domaines publics, `edge`, autorité DNS, FQDN exposés. + +## 2. Classification proposée : constante vs défaut surchargeable + +> **Proposition à valider.** « Constante » = une seule valeur, pas de surcharge +> (diverger casserait le modèle). « Défaut surchargeable » = valeur globale qui +> ressurgit comme défaut là où l'intrant réapparaît (hôte / groupe / application). + +| Intrant | Classe | Surcharge où ? | +| --- | --- | --- | +| `domaine_interne` | **Constante** | — | +| Nomenclature (supernet, CIDR, catégories, fonctions) | **Constante** | — (le plan réseau est la loi) | +| Accès Proxmox (API host/user/port/token) | **Constante** | — (un seul cluster) | +| Golden template (vmid_modele, source_nom) | **Constante** | — | +| Secrets Vault | **Constante** 🔒 | — (gérés à part, jamais en clair) | +| `chezlepro_timezone` | Défaut | par hôte (rare) | +| `proxmox_clone_noeud` / `stockage` / `pont` | Défaut | par hôte (`serveurs.yml`) | +| DNS internes (`client_dns_nameservers`) | Défaut | par hôte / groupe | +| Politiques durcissement (SSH, nftables, fail2ban, journald…) | Défaut | par hôte / groupe | +| Relais SMTP, TLS internes | Défaut | par hôte / groupe | +| `ciuser`, compte `ansible` | Défaut | rarement surchargé | + +## 3. Saisie unique dans le GUI (cible) + +Un panneau « Intrants de base » dans le GUI : +- édite les **constantes** et les **défauts** au même endroit ; +- pour un **défaut**, la valeur globale apparaît pré-remplie (et signalée comme + « héritée ») là où l'intrant réapparaît dans le plan, surchargeable sur place ; +- les **secrets** ne sont **jamais** stockés en clair par le GUI (cf. §H) — il en + gère au plus les *références*, l'édition réelle restant côté Vault. + +Persistance : les fichiers existants (`group_vars/all.yml`, `proxmox.yml`, +`modeles_vm.yml`, `plan/nomenclature.yml`). À détailler dans la note de conception +de la fonctionnalité GUI. + +## 4. Incohérences repérées (à corriger) +- `chezlepro_timezone` défini en **lab** seulement, absent de **production**. +- `group_vars/serveur_debian.yml` **référencé** (commentaire de prod `all.yml`) mais + **absent** des deux environnements. diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 459353d..f94a630 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -48,6 +48,42 @@ FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml" FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml" FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml" +# Intrants de base (panneau GUI). Cibles d'ecriture possedees par le GUI. +# Identite : source UNIQUE partagee par tous les envs (symlink depuis chaque +# group_vars/all/). Proxmox : fichier lab (le clonage le lit en dur), cles gerees +# mises a jour, les autres cles preservees. Les SECRETS n'y entrent jamais. +INTRANTS_IDENTITE = INSTANCE / "inventories/partage/intrants-identite.yml" +INTRANTS_PROXMOX = INSTANCE / "inventories/lab/group_vars/proxmox.yml" +FICHIERS_INTRANTS = {"identite": INTRANTS_IDENTITE, "proxmox": INTRANTS_PROXMOX} + +# (variable, fichier, classe, section, libelle, type) +INTRANTS_SCHEMA = [ + ("domaine_interne", "identite", "constante", "Identité", "Domaine DNS interne", "str"), + ("chezlepro_timezone", "identite", "defaut", "Identité", "Fuseau horaire", "str"), + ("proxmox_api_host", "proxmox", "constante", "Proxmox", "Hôte API Proxmox", "str"), + ("proxmox_api_user", "proxmox", "constante", "Proxmox", "Utilisateur API", "str"), + ("proxmox_api_port", "proxmox", "constante", "Proxmox", "Port API", "str"), + ("proxmox_validate_certs", "proxmox", "constante", "Proxmox", "Valider les certificats TLS", "bool"), + ("proxmox_clone_source_nom", "proxmox", "constante", "Proxmox", "Nom du golden template", "str"), + ("proxmox_clone_vmid_modele", "proxmox", "constante", "Proxmox", "VMID du golden template", "int"), + ("proxmox_clone_noeud", "proxmox", "defaut", "Proxmox", "Nœud Proxmox (défaut)", "str"), + ("proxmox_clone_stockage", "proxmox", "defaut", "Proxmox", "Stockage (défaut)", "str"), + ("proxmox_clone_pont", "proxmox", "defaut", "Proxmox", "Pont réseau (défaut)", "str"), +] +# Cles secretes : interdites a l'ecriture par le GUI (garde-fou). +INTRANTS_CLES_INTERDITES = { + "proxmox_api_token_id", "proxmox_api_token_secret", "ansible_become_pass", "ansible_password", +} +# Rappel en lecture seule (jamais de valeur) : secrets a fournir via Ansible Vault. +SECRETS_ATTENDUS = [ + "proxmox_api_token_id", "proxmox_api_token_secret", + "vault_step_ca_password", "vault_step_ca_fingerprint", "vault_step_ca_provisioner_password", + "vault_openldap_admin", "vault_ldap_sssd", "vault_keycloak_admin", + "vault_postgresql_keycloak", "vault_bd_keycloak", "vault_forgejo_admin", + "vault_forgejo_secret_key", "vault_forgejo_internal_token", "vault_bd_forgejo", + "vault_grafana_admin", "vault_redis", "vault_bd_icingadb", +] + # Garde-fous des executions (verifier / deployer) depuis l'interface. JETON = secrets.token_urlsafe(18) VERROU = threading.Lock() @@ -210,9 +246,86 @@ def inventaire_api(path: Path) -> dict: "domaines": charger_domaines(FICHIER_DOMAINES), "serveurs": serveurs_reconcilies(data), "hotes": liste_hotes(data), + "intrants": intrants_api(), } +def _lire_yaml_dict(path: Path) -> dict: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as fichier: + data = yaml.safe_load(fichier) or {} + return data if isinstance(data, dict) else {} + + +def intrants_courants() -> dict: + cache: dict = {} + valeurs: dict = {} + for variable, fcle, *_ in INTRANTS_SCHEMA: + if fcle not in cache: + cache[fcle] = _lire_yaml_dict(FICHIERS_INTRANTS[fcle]) + v = cache[fcle].get(variable, "") + valeurs[variable] = "" if v is None else v + return valeurs + + +def intrants_api() -> dict: + schema = [{"variable": v, "fichier": f, "classe": c, "section": s, + "libelle": l, "type": t} for (v, f, c, s, l, t) in INTRANTS_SCHEMA] + return { + "schema": schema, + "valeurs": intrants_courants(), + "secrets_attendus": SECRETS_ATTENDUS, + "fichiers": {k: os.path.relpath(v, RACINE) for k, v in FICHIERS_INTRANTS.items()}, + } + + +def _coercer_intrant(typ: str, brut: object) -> object: + s = str(brut).strip() + if typ == "bool": + return s.lower() in ("true", "1", "oui", "yes", "on") + if typ == "int": + return int(s) if s.lstrip("-").isdigit() else s + return s + + +def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None: + interdits = sorted(set(valeurs) & INTRANTS_CLES_INTERDITES) + if interdits: + raise ValueError(f"Refus d'ecrire des cles sensibles via le GUI: {', '.join(interdits)}") + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as fichier: + fichier.write(entete) + yaml.safe_dump(valeurs, fichier, default_flow_style=False, sort_keys=True, allow_unicode=True) + + +def ecrire_intrants(payload: dict) -> None: + valeurs = payload.get("valeurs", {}) or {} + if not str(valeurs.get("domaine_interne", "")).strip(): + raise ValueError("domaine_interne ne peut pas etre vide (cle de voute de l'ecosysteme).") + types = {v: t for (v, f, c, s, l, t) in INTRANTS_SCHEMA} + fichier_de = {v: f for (v, f, c, s, l, t) in INTRANTS_SCHEMA} + par_fichier: dict = {} + for variable, valeur in valeurs.items(): + if variable not in fichier_de: + continue + par_fichier.setdefault(fichier_de[variable], {})[variable] = _coercer_intrant(types[variable], valeur) + + if "identite" in par_fichier: + _ecrire_intrants_fichier( + INTRANTS_IDENTITE, par_fichier["identite"], + "# Intrants d'IDENTITE de l'instance — SOURCE UNIQUE, partagee par tous les envs.\n" + "# Edite par le panneau « Intrants de base » du GUI (make inventaire-ui).\n" + "# Reference via symlink depuis inventories//group_vars/all/10-intrants.yml.\n---\n") + if "proxmox" in par_fichier: + fusion = _lire_yaml_dict(INTRANTS_PROXMOX) # preserve les cles non gerees + fusion.update(par_fichier["proxmox"]) + _ecrire_intrants_fichier( + INTRANTS_PROXMOX, fusion, + "# Parametres Proxmox non sensibles (les secrets vont dans proxmox.vault.yml).\n" + "# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n---\n") + + def ecrire_bases(path: Path, registre: dict) -> None: entete = ( "# Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees).\n" @@ -509,6 +622,16 @@ HTML = r""" .champ { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .champ > span { color: var(--muted); font-size: 11px; font-weight: 700; } .champ .unite { color: var(--faint); font-weight: 600; } + .champ > span.a-aide { cursor: help; border-bottom: 1px dotted var(--ligne-forte); width: fit-content; } + #infobulle { + position: fixed; z-index: 200; max-width: 260px; pointer-events: none; + background: var(--surface-3); color: var(--texte); + border: 1px solid var(--ligne-forte); border-radius: var(--rayon-sm); + padding: 7px 10px; font-size: 12px; font-weight: 600; line-height: 1.4; + box-shadow: var(--ombre); opacity: 0; transform: translateY(2px); + transition: opacity .12s ease, transform .12s ease; + } + #infobulle.on { opacity: 1; transform: none; } .groupes-liste { display: grid; gap: 7px; } .groupe { display: block; border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 8px 10px; background: var(--surface-2); cursor: pointer; transition: border-color .12s, background .12s; } .groupe:hover { border-color: var(--ligne-forte); } @@ -569,6 +692,18 @@ HTML = r""" .modale-titre { font-weight: 800; font-size: 15px; } .modale-texte { color: var(--muted); font-size: 13px; line-height: 1.5; } .modale-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } + .modale-boite.large { width: min(760px, 94vw); max-height: 88vh; overflow: auto; } + .intrants-corps { display: grid; gap: 16px; } + .intr-section { display: grid; gap: 9px; } + .intr-tete { font-weight: 800; font-size: 13px; color: var(--bleu); border-bottom: 1px solid var(--ligne); padding-bottom: 5px; } + .intr-grille { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 11px; } + .badge-classe { display: inline-block; font-size: 9.5px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; padding: 1px 6px; border-radius: 999px; margin-left: 6px; vertical-align: middle; } + .badge-classe.const { background: color-mix(in srgb, var(--ambre) 20%, transparent); color: var(--ambre); } + .badge-classe.def { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); } + .intr-ro { color: var(--muted); font-size: 12px; line-height: 1.55; } + .intr-ro b { color: var(--texte); font-weight: 700; } + .intr-secrets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } + .intr-secrets code { font-size: 11px; background: var(--surface-2); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 1px 6px; color: var(--muted); } .console { position: fixed; left: 0; right: 0; bottom: 0; height: 44vh; background: var(--surface); border-top: 1px solid var(--ligne-forte); box-shadow: 0 -16px 50px -18px rgba(0,0,0,.8); z-index: 40; display: flex; flex-direction: column; } .console[hidden] { display: none; } .console-tete { display: flex; align-items: center; gap: 12px; padding: 9px 16px; border-bottom: 1px solid var(--ligne); background: var(--surface-2); } @@ -600,6 +735,7 @@ HTML = r""" + @@ -628,6 +764,20 @@ HTML = r""" +