2026-06-24 20:17:46 -04:00
#!/usr/bin/env python3
""" Interface web locale pour gerer un inventaire Set-OPS. """
from __future__ import annotations
from http . server import BaseHTTPRequestHandler , ThreadingHTTPServer
import argparse
import json
import os
import re
import secrets
2026-07-01 17:39:44 -04:00
import socket
2026-06-24 20:17:46 -04:00
import subprocess
import tempfile
import threading
2026-07-01 17:39:44 -04:00
from concurrent . futures import ThreadPoolExecutor
2026-06-24 20:17:46 -04:00
from pathlib import Path
from urllib . parse import urlparse
import yaml
from inventory_rules import (
GROUPE_HOTES_ACTIFS ,
GROUPE_HOTES_PLANIFIES ,
GROUPES_ETAT_HOTE ,
chaine_connexion ,
charger_applications ,
charger_bases_donnees ,
charger_dependances ,
charger_domaines ,
charger_nomenclature ,
charger_serveurs ,
est_groupe_operationnel ,
groupes_operationnels_connus ,
reconcilier_serveur ,
valider_applications ,
valider_bases ,
valider_serveurs ,
)
RACINE = Path ( __file__ ) . resolve ( ) . parents [ 1 ]
INSTANCE = Path ( os . environ . get ( " SETOPS_INSTANCE " ) or ( RACINE / " instance " ) )
2026-06-30 16:08:25 -04:00
def _inventaire ( instance : Path , * noms : str ) - > Path :
""" Inventaire de l ' instance, détection rétro-compatible (premier existant). """
for nom in noms :
p = instance / " inventories " / nom / " hosts.yml "
if p . exists ( ) :
return p
return instance / " inventories " / noms [ 0 ] / " hosts.yml "
# Un seul inventaire par instance (modèle « séparation par instance ») ; on accepte
# l'ancien découpage lab/production en rétro-compatibilité. SETOPS_INVENTAIRE force.
_force = os . environ . get ( " SETOPS_INVENTAIRE " )
INVENTAIRE_DEFAUT = Path ( _force ) if _force else _inventaire ( INSTANCE , " principal " , " production " )
INVENTAIRE_PRODUCTION = INVENTAIRE_DEFAUT . resolve ( )
# Inventaire qui porte la config Proxmox / le clonage (modèle) : lab > principal > production.
INVENTAIRE_MODELE = _inventaire ( INSTANCE , " lab " , " principal " , " production " )
2026-06-24 20:17:46 -04:00
DOSSIER_PLAYBOOKS_GROUPES = RACINE / " playbooks/groupes "
FICHIER_DEPENDANCES = RACINE / " docs/dependances-groupes.yml "
FICHIER_NOMENCLATURE = INSTANCE / " plan/nomenclature.yml "
FICHIER_BASES = INSTANCE / " plan/bases-donnees.yml "
FICHIER_APPLICATIONS = INSTANCE / " plan/applications.yml "
FICHIER_DOMAINES = INSTANCE / " plan/domaines.yml "
FICHIER_SERVEURS = INSTANCE / " plan/serveurs.yml "
2026-06-30 10:07:17 -04:00
# Intrants de base (panneau GUI). Cibles d'ecriture possedees par le GUI.
2026-06-30 16:31:42 -04:00
# Identite : group_vars/all/10-intrants.yml de l'inventaire. Selon l'instance c'est
# un fichier reel (modele « par instance ») ou un symlink vers une source partagee
# (ecrire suit le symlink). Proxmox : group_vars de l'inventaire modele. Les SECRETS
# n'y entrent jamais.
INTRANTS_IDENTITE = INVENTAIRE_DEFAUT . parent / " group_vars/all/10-intrants.yml "
2026-06-30 16:08:25 -04:00
INTRANTS_PROXMOX = INVENTAIRE_MODELE . parent / " group_vars/proxmox.yml "
2026-06-30 10:07:17 -04:00
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 " ) ,
2026-07-05 00:47:44 -04:00
( " fuseau_horaire " , " identite " , " defaut " , " Identité " , " Fuseau horaire " , " str " ) ,
( " organisation " , " identite " , " defaut " , " Identité " , " Organisation (annuaire LDAP, certificats) " , " str " ) ,
( " identite_realm " , " identite " , " defaut " , " Identité " , " Realm SSO (Keycloak) " , " str " ) ,
2026-06-30 10:07:17 -04:00
( " 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 " ) ,
2026-06-30 12:36:51 -04:00
( " proxmox_noeuds " , " proxmox " , " catalogue " , " Proxmox " , " Nœuds disponibles (liste) " , " liste " ) ,
( " proxmox_stockages " , " proxmox " , " catalogue " , " Proxmox " , " Stockages disponibles (liste) " , " liste " ) ,
( " proxmox_ponts " , " proxmox " , " catalogue " , " Proxmox " , " Ponts réseau disponibles (liste) " , " liste " ) ,
2026-06-30 10:07:17 -04:00
]
# 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 " ,
]
2026-06-24 20:17:46 -04:00
# Garde-fous des executions (verifier / deployer) depuis l'interface.
JETON = secrets . token_urlsafe ( 18 )
VERROU = threading . Lock ( )
VERIF_OK : dict [ str , bool ] = { }
MOTIF_HOTE = re . compile ( r " ^[A-Za-z0-9._-] { 1,63}$ " )
# Champs de provisioning persistes comme variables d'hote dans l'inventaire.
# (cle_gui, variable_inventaire, type)
CHAMPS_PROVISION = [
( " adresse_ip " , " ansible_host " , " str " ) ,
( " utilisateur_ansible " , " ansible_user " , " str " ) ,
( " cidr " , " proxmox_cidr " , " int " ) ,
( " passerelle " , " proxmox_passerelle " , " str " ) ,
( " vlan " , " proxmox_vlan " , " int " ) ,
( " pont " , " proxmox_pont " , " str " ) ,
( " dns " , " proxmox_dns " , " str " ) ,
( " vmid " , " proxmox_vmid " , " int " ) ,
( " noeud " , " proxmox_noeud " , " str " ) ,
( " stockage " , " proxmox_stockage " , " str " ) ,
( " disque_taille " , " proxmox_disque_taille " , " str " ) ,
( " memoire " , " proxmox_memoire " , " int " ) ,
( " coeurs " , " proxmox_coeurs " , " int " ) ,
]
def charger_yaml ( path : Path ) - > dict :
if not path . exists ( ) :
return { " all " : { " children " : { } } }
with path . open ( " r " , encoding = " utf-8 " ) as fichier :
data = yaml . safe_load ( fichier ) or { }
if not isinstance ( data , dict ) :
raise ValueError ( f " { path } ne contient pas une table YAML. " )
data . setdefault ( " all " , { } )
data [ " all " ] . setdefault ( " children " , { } )
return data
def ecrire_yaml ( path : Path , data : dict ) - > None :
with path . open ( " w " , encoding = " utf-8 " ) as fichier :
yaml . safe_dump ( data , fichier , default_flow_style = False , sort_keys = False , allow_unicode = True )
def enfants ( data : dict ) - > dict :
return data . setdefault ( " all " , { } ) . setdefault ( " children " , { } )
def groupes_disponibles ( data : dict ) - > list [ str ] :
return groupes_operationnels_connus ( enfants ( data ) , DOSSIER_PLAYBOOKS_GROUPES )
def roles_des_groupes ( ) - > dict :
""" Pour chaque playbook de groupe, extrait les roles appliques (chaine groupe -> roles). """
resultat : dict = { }
for chemin in sorted ( DOSSIER_PLAYBOOKS_GROUPES . glob ( " *.yml " ) ) :
roles : list [ str ] = [ ]
try :
with chemin . open ( " r " , encoding = " utf-8 " ) as fichier :
docs = yaml . safe_load ( fichier ) or [ ]
plays = docs if isinstance ( docs , list ) else [ docs ]
for play in plays :
if not isinstance ( play , dict ) :
continue
for role in play . get ( " roles " , [ ] ) or [ ] :
if isinstance ( role , str ) :
roles . append ( role )
elif isinstance ( role , dict ) :
nom = role . get ( " role " ) or role . get ( " name " )
if nom :
roles . append ( str ( nom ) )
except ( OSError , yaml . YAMLError ) :
pass
resultat [ chemin . stem ] = { " roles " : roles , " stub " : not roles }
return resultat
def hotes_actifs_fichier ( path : Path ) - > set [ str ] :
data = charger_yaml ( path )
return set ( enfants ( data ) . get ( GROUPE_HOTES_ACTIFS , { } ) . get ( " hosts " , { } ) or { } )
def variables_hote ( data : dict , hote : str ) - > dict :
resultat : dict = { }
for groupe_data in enfants ( data ) . values ( ) :
hosts = groupe_data . get ( " hosts " , { } )
valeurs = hosts . get ( hote )
if isinstance ( valeurs , dict ) :
resultat . update ( valeurs )
return resultat
def liste_hotes ( data : dict ) - > list [ dict ] :
noms = set ( )
for groupe_data in enfants ( data ) . values ( ) :
noms . update ( groupe_data . get ( " hosts " , { } ) )
hotes = [ ]
for nom in sorted ( noms ) :
groupes = [ ]
etat = " planifie "
visible = False
for groupe , groupe_data in enfants ( data ) . items ( ) :
if nom not in groupe_data . get ( " hosts " , { } ) :
continue
if groupe == GROUPE_HOTES_ACTIFS :
etat = " actif "
visible = True
elif groupe in GROUPES_ETAT_HOTE :
visible = True
continue
elif est_groupe_operationnel ( groupe ) :
groupes . append ( groupe )
visible = True
if not visible :
continue
variables = variables_hote ( data , nom )
hote = { " nom " : nom , " etat " : etat , " groupes " : sorted ( groupes ) }
for cle , var , _type in CHAMPS_PROVISION :
valeur = variables . get ( var , " " )
hote [ cle ] = " " if valeur is None else valeur
if not str ( hote . get ( " utilisateur_ansible " , " " ) ) . strip ( ) :
hote [ " utilisateur_ansible " ] = " ansible "
hotes . append ( hote )
return hotes
2026-07-01 17:39:44 -04:00
def _joignable ( ip : str , port : int = 22 , timeout : float = 1.5 ) - > bool :
if not ip :
return False
try :
with socket . create_connection ( ( ip , port ) , timeout = timeout ) :
return True
except OSError :
return False
def sonder_hotes ( path : Path ) - > dict :
""" Etat REEL (vs plan declaratif) : la VM de chaque hote repond-elle sur SSH ? """
cibles = [ ( h . get ( " nom " ) , h . get ( " adresse_ip " ) ) for h in liste_hotes ( charger_yaml ( path ) ) ]
with ThreadPoolExecutor ( max_workers = 16 ) as executeur :
resultats = executeur . map ( lambda c : ( c [ 0 ] , _joignable ( c [ 1 ] ) ) , cibles )
return { nom : ok for nom , ok in resultats if nom }
def marquer_serveur_actif ( hote : str ) - > None :
""" Auto-actif : un hote materialise (clone/deploye) passe ' actif ' dans le plan. """
registre = charger_serveurs ( FICHIER_SERVEURS )
serveurs = registre . get ( " serveurs " ) or { }
if hote in serveurs and serveurs [ hote ] . get ( " etat " ) != " actif " :
serveurs [ hote ] [ " etat " ] = " actif "
ecrire_serveurs ( FICHIER_SERVEURS , registre )
2026-06-24 20:17:46 -04:00
def serveurs_reconcilies ( data : dict ) - > list :
""" Serveurs du plan + valeurs derivees + statut de reconciliation (lecture). """
registre = charger_serveurs ( FICHIER_SERVEURS )
nomenclature = charger_nomenclature ( FICHIER_NOMENCLATURE )
hotes = { h [ " nom " ] : h for h in liste_hotes ( data ) if h . get ( " nom " ) }
resultat = [ ]
for nom , srv in ( registre . get ( " serveurs " ) or { } ) . items ( ) :
rec = reconcilier_serveur ( nom , srv , hotes . get ( nom ) , nomenclature )
d = rec [ " derive " ]
statut = " absent " if rec [ " absent_inventaire " ] else ( " divergence " if rec [ " divergences " ] else " reconcilie " )
resultat . append ( {
" nom " : nom , " fonction " : srv . get ( " fonction " , " " ) , " etat " : srv . get ( " etat " , " " ) ,
" noeud " : srv . get ( " noeud " , " " ) , " stockage " : srv . get ( " stockage " , " " ) ,
" disque " : srv . get ( " disque " , " " ) , " memoire " : srv . get ( " memoire " , " " ) , " coeurs " : srv . get ( " coeurs " , " " ) ,
" integrations " : srv . get ( " integrations " , [ ] ) ,
" vmid " : d . get ( " vmid " , " " ) , " adresse_ip " : d . get ( " adresse_ip " , " " ) , " vlan " : d . get ( " vlan " , " " ) ,
" statut " : statut , " divergences " : rec [ " divergences " ] ,
} )
return resultat
def inventaire_api ( path : Path ) - > dict :
data = charger_yaml ( path )
dependencies = charger_dependances ( FICHIER_DEPENDANCES )
return {
" inventaire " : os . path . relpath ( path , RACINE ) ,
2026-06-30 16:20:18 -04:00
" production " : instance_est_production ( path ) ,
2026-06-24 20:17:46 -04:00
" groupes " : groupes_disponibles ( data ) ,
" dependances " : dependencies ,
" nomenclature " : charger_nomenclature ( FICHIER_NOMENCLATURE ) ,
" chaine " : roles_des_groupes ( ) ,
" bases " : charger_bases_donnees ( FICHIER_BASES ) ,
" applications " : charger_applications ( FICHIER_APPLICATIONS ) ,
" domaines " : charger_domaines ( FICHIER_DOMAINES ) ,
" serveurs " : serveurs_reconcilies ( data ) ,
" hotes " : liste_hotes ( data ) ,
2026-06-30 10:07:17 -04:00
" intrants " : intrants_api ( ) ,
2026-06-30 12:36:51 -04:00
" integrations_disponibles " : integrations_disponibles ( ) ,
2026-06-30 10:07:17 -04:00
}
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 { }
2026-06-30 16:20:18 -04:00
def instance_est_production ( path : Path ) - > bool :
""" Cette instance est-elle la PRODUCTION ? (garde-fou de prudence, pas un blocage)
2026-06-30 15:59:37 -04:00
2026-06-30 16:20:18 -04:00
Le déploiement est permis sur toute instance ( lab compris , vers son infra ) ;
ce drapeau ne sert qu ' à exiger une confirmation renforcée côté GUI quand c ' est
la prod . Repère explicite : ` setops_production ` dans ` < inventaire > / group_vars / all / ` .
Rétro - compatibilité : à défaut , ancien repère ( inventaire nommé « production » ) .
2026-06-30 15:59:37 -04:00
"""
base = path . parent / " group_vars " / " all "
fichiers = sorted ( base . glob ( " *.yml " ) ) if base . is_dir ( ) else [ ]
plat = path . parent / " group_vars " / " all.yml "
if plat . exists ( ) :
fichiers . append ( plat )
for f in fichiers :
d = _lire_yaml_dict ( f )
if isinstance ( d , dict ) and " setops_production " in d :
return bool ( d [ " setops_production " ] )
return path . resolve ( ) == INVENTAIRE_PRODUCTION
2026-06-30 10:07:17 -04:00
def intrants_courants ( ) - > dict :
cache : dict = { }
valeurs : dict = { }
2026-06-30 12:36:51 -04:00
for variable , fcle , _classe , _section , _libelle , typ in INTRANTS_SCHEMA :
2026-06-30 10:07:17 -04:00
if fcle not in cache :
cache [ fcle ] = _lire_yaml_dict ( FICHIERS_INTRANTS [ fcle ] )
2026-06-30 12:36:51 -04:00
v = cache [ fcle ] . get ( variable )
if typ == " liste " :
items = v if isinstance ( v , list ) else [ ]
valeurs [ variable ] = [ str ( x ) . strip ( ) for x in items if str ( x ) . strip ( ) ]
else :
valeurs [ variable ] = " " if v is None else v
2026-06-30 10:07:17 -04:00
return valeurs
2026-06-30 12:36:51 -04:00
def integrations_disponibles ( ) - > list :
""" Roles client_* presents dans roles/ : source des integrations selectionnables. """
base = RACINE / " roles "
return sorted ( p . name for p in base . glob ( " client_* " ) if p . is_dir ( ) )
2026-06-30 10:07:17 -04:00
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 ( ) } ,
2026-06-24 20:17:46 -04:00
}
2026-06-30 10:07:17 -04:00
def _coercer_intrant ( typ : str , brut : object ) - > object :
2026-06-30 12:36:51 -04:00
if typ == " liste " :
items = brut if isinstance ( brut , list ) else str ( brut ) . replace ( " \n " , " , " ) . split ( " , " )
# dedoublonne en preservant l'ordre
vus , sortie = set ( ) , [ ]
for x in items :
s = str ( x ) . strip ( )
if s and s not in vus :
vus . add ( s ) ; sortie . append ( s )
return sortie
2026-06-30 10:07:17 -04:00
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/<env>/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 " )
2026-06-24 20:17:46 -04:00
def ecrire_bases ( path : Path , registre : dict ) - > None :
entete = (
" # Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees). \n "
" # Edite par make inventaire-ui ou scripts/bases_donnees.py. \n "
" # ' portee ' (application|groupe|hote) interprete ' consommateur ' ; defaut groupe. \n "
" # ' secret ' nomme une variable Ansible Vault (jamais le mot de passe). \n "
" --- \n "
)
contenu = {
" serveurs_bd " : registre . get ( " serveurs_bd " , { } ) or { } ,
" bases_donnees " : registre . get ( " bases_donnees " , { } ) or { } ,
}
with path . open ( " w " , encoding = " utf-8 " ) as fichier :
fichier . write ( entete )
yaml . safe_dump ( contenu , fichier , default_flow_style = False , sort_keys = False , allow_unicode = True )
def ecrire_applications ( path : Path , registre : dict ) - > None :
entete = (
" # Registre des applications Set-OPS (application = entite pivot). \n "
" # Edite par make inventaire-ui ou scripts/applications.py. \n "
" # Une VM peut porter plusieurs applications ; une base se lie a une application. \n "
" --- \n "
)
with path . open ( " w " , encoding = " utf-8 " ) as fichier :
fichier . write ( entete )
yaml . safe_dump ( { " applications " : registre . get ( " applications " , { } ) or { } } ,
fichier , default_flow_style = False , sort_keys = False , allow_unicode = True )
def ecrire_serveurs ( path : Path , registre : dict ) - > None :
entete = (
" # Registre des serveurs (VM) du plan Set-OPS. \n "
" # Edite par make inventaire-ui ou scripts/serveurs.py. \n "
" # VMID/IP/VLAN/passerelle sont DERIVES de la fonction via instance/plan/nomenclature.yml. \n "
" --- \n "
)
with path . open ( " w " , encoding = " utf-8 " ) as fichier :
fichier . write ( entete )
yaml . safe_dump ( { " serveurs " : registre . get ( " serveurs " , { } ) or { } } ,
fichier , default_flow_style = False , sort_keys = False , allow_unicode = True )
def donnees_hote ( hote : dict ) - > dict :
variables : dict = { }
for cle , var , typ in CHAMPS_PROVISION :
brut = str ( hote . get ( cle , " " ) ) . strip ( )
if not brut :
continue
if typ == " int " and brut . lstrip ( " - " ) . isdigit ( ) :
variables [ var ] = int ( brut )
else :
variables [ var ] = brut
return variables
def construire_inventaire ( payload : dict , groupes_connus : list [ str ] ) - > dict :
enfants_data : dict = {
" modeles_vm " : { " hosts " : { } } ,
GROUPE_HOTES_ACTIFS : { " hosts " : { } } ,
GROUPE_HOTES_PLANIFIES : { " hosts " : { } } ,
}
for groupe in groupes_connus :
enfants_data . setdefault ( groupe , { " hosts " : { } } )
enfants_data . setdefault ( " hotes_proxmox " , { " hosts " : { } } )
for hote in payload . get ( " hotes " , [ ] ) :
nom = str ( hote . get ( " nom " , " " ) ) . strip ( )
if not nom :
continue
variables = donnees_hote ( hote )
groupe_etat = GROUPE_HOTES_ACTIFS if hote . get ( " etat " ) == " actif " else GROUPE_HOTES_PLANIFIES
enfants_data [ groupe_etat ] [ " hosts " ] [ nom ] = variables
for groupe in hote . get ( " groupes " , [ ] ) :
if groupe not in groupes_connus :
continue
enfants_data [ groupe ] [ " hosts " ] [ nom ] = { }
return { " all " : { " children " : enfants_data } }
def valider_dependances_payload ( payload : dict , dependencies : dict ) - > None :
hotes = payload . get ( " hotes " , [ ] )
active_groups : set [ str ] = set ( )
for hote in hotes :
if hote . get ( " etat " ) != " actif " :
continue
active_groups . update ( hote . get ( " groupes " , [ ] ) )
missing : list [ str ] = [ ]
for hote in hotes :
if hote . get ( " etat " ) != " actif " :
continue
nom = str ( hote . get ( " nom " , " " ) ) . strip ( )
for groupe in hote . get ( " groupes " , [ ] ) :
for required_group in dependencies . get ( groupe , { } ) . get ( " requiert_groupes_actifs " , [ ] ) :
if required_group not in active_groups :
missing . append ( f " { nom } : { groupe } requiert { required_group } actif " )
if missing :
raise ValueError ( " Dependances manquantes: " + " ; " . join ( missing ) )
def valider_payload ( payload : dict , groupes_connus : list [ str ] , dependencies : dict ) - > None :
noms : set [ str ] = set ( )
vmids : dict [ str , str ] = { }
for hote in payload . get ( " hotes " , [ ] ) :
nom = str ( hote . get ( " nom " , " " ) ) . strip ( )
if not nom :
raise ValueError ( " Un hote a un nom vide. " )
if nom in noms :
raise ValueError ( f " Hote en double: { nom } " )
noms . add ( nom )
if hote . get ( " etat " ) == " actif " and not str ( hote . get ( " adresse_ip " , " " ) ) . strip ( ) :
raise ValueError ( f " Hote actif sans adresse IP: { nom } " )
groupes = hote . get ( " groupes " , [ ] )
if not groupes :
raise ValueError ( f " Aucun groupe operationnel pour: { nom } " )
inconnus = sorted ( set ( groupes ) - set ( groupes_connus ) )
if inconnus :
raise ValueError ( f " Groupes inconnus pour { nom } : { ' , ' . join ( inconnus ) } " )
vlan = str ( hote . get ( " vlan " , " " ) ) . strip ( )
if vlan and ( not vlan . isdigit ( ) or not 1 < = int ( vlan ) < = 4094 ) :
raise ValueError ( f " VLAN invalide pour { nom } : { vlan } (attendu 1-4094) " )
cidr = str ( hote . get ( " cidr " , " " ) ) . strip ( )
if cidr and ( not cidr . isdigit ( ) or not 0 < = int ( cidr ) < = 32 ) :
raise ValueError ( f " CIDR invalide pour { nom } : { cidr } (attendu 0-32) " )
for cle , libelle in ( ( " memoire " , " Memoire " ) , ( " coeurs " , " Coeurs " ) ) :
valeur = str ( hote . get ( cle , " " ) ) . strip ( )
if valeur and ( not valeur . isdigit ( ) or int ( valeur ) < = 0 ) :
raise ValueError ( f " { libelle } invalide pour { nom } : { valeur } (entier positif attendu) " )
vmid = str ( hote . get ( " vmid " , " " ) ) . strip ( )
if not vmid :
continue
if vmid in vmids :
raise ValueError ( f " VMID en double: { vmid } pour { vmids [ vmid ] } et { nom } " )
vmids [ vmid ] = nom
valider_dependances_payload ( payload , dependencies )
HTML = r """ <!doctype html>
< html lang = " fr " >
< head >
< meta charset = " utf-8 " >
< meta name = " viewport " content = " width=device-width, initial-scale=1 " >
< title > Set - OPS · Votre artisan numérique < / title >
< style >
: root {
color - scheme : dark ;
- - fond : #0c1119;
- - fond - degrade : radial - gradient ( 1100 px 540 px at 100 % - 8 % , #142033 0, transparent 55%), var(--fond);
- - surface : #151d2b;
- - surface - 2 : #1a2333;
- - surface - 3 : #212d41;
- - texte : #e8edf6;
- - muted : #93a1bb;
- - faint : #67738c;
- - ligne : #27324a;
- - ligne - forte : #36445f;
- - primaire : #3b82f6;
- - primaire - fort : #2f6fe0;
- - teal : #2dd4bf;
- - vert : #34d399; --vert-fond:rgba(52,211,153,.13);
- - bleu : #60a5fa; --bleu-fond:rgba(96,165,250,.13);
- - rouge : #f87171; --rouge-fond:rgba(248,113,113,.13);
- - ambre : #fbbf24; --ambre-fond:rgba(251,191,36,.13);
- - ombre : 0 1 px 2 px rgba ( 0 , 0 , 0 , .35 ) , 0 10 px 30 px - 14 px rgba ( 0 , 0 , 0 , .7 ) ;
- - ombre - forte : 0 18 px 50 px - 18 px rgba ( 0 , 0 , 0 , .8 ) ;
- - rayon : 11 px ; - - rayon - sm : 8 px ;
}
* { box - sizing : border - box ; }
body {
margin : 0 ; background : var ( - - fond - degrade ) ; color : var ( - - texte ) ; min - height : 100 vh ;
font - family : ui - sans - serif , system - ui , - apple - system , " Segoe UI " , Roboto , sans - serif ;
font - size : 14 px ; line - height : 1.5 ; - webkit - font - smoothing : antialiased ;
}
: : selection { background : color - mix ( in srgb , var ( - - teal ) 35 % , transparent ) ; }
* { scrollbar - width : thin ; scrollbar - color : var ( - - ligne - forte ) transparent ; }
header {
position : sticky ; top : 0 ; z - index : 20 ;
display : flex ; align - items : center ; gap : 16 px ; flex - wrap : wrap ;
padding : 12 px 22 px ; border - bottom : 1 px solid var ( - - ligne ) ;
background : color - mix ( in srgb , var ( - - surface ) 82 % , transparent ) ;
backdrop - filter : saturate ( 150 % ) blur ( 12 px ) ;
}
. marque { display : flex ; align - items : center ; gap : 9 px ; min - width : 0 ; }
. marque . pastille { width : 9 px ; height : 9 px ; border - radius : 50 % ; background : var ( - - teal ) ; box - shadow : 0 0 0 4 px color - mix ( in srgb , var ( - - teal ) 18 % , transparent ) ; flex : none ; }
. marque . titre { display : flex ; flex - direction : column ; line - height : 1.15 ; min - width : 0 ; }
h1 { margin : 0 ; font - size : 15 px ; font - weight : 800 ; letter - spacing : - .01 em ; white - space : nowrap ; }
. slogan { font - size : 10.5 px ; color : var ( - - muted ) ; font - weight : 600 ; letter - spacing : .02 em ; white - space : nowrap ; }
. chemin { color : var ( - - muted ) ; font - size : 12 px ; font - family : ui - monospace , SFMono - Regular , Menlo , monospace ; white - space : nowrap ; overflow : hidden ; text - overflow : ellipsis ; }
. grandit { flex : 1 1 auto ; min - width : 0 ; }
. resume { display : flex ; gap : 6 px ; flex - wrap : wrap ; }
. chip { display : inline - flex ; align - items : center ; gap : 6 px ; border : 1 px solid var ( - - ligne ) ; border - radius : 999 px ; padding : 3 px 10 px ; background : var ( - - surface - 2 ) ; color : var ( - - muted ) ; font - size : 11.5 px ; font - weight : 650 ; }
. chip b { color : var ( - - texte ) ; font - weight : 800 ; }
. pt { width : 7 px ; height : 7 px ; border - radius : 50 % ; flex : none ; }
. pt . actif { background : var ( - - vert ) ; } . pt . plan { background : var ( - - bleu ) ; } . pt . bloq { background : var ( - - rouge ) ; }
. barre { display : flex ; gap : 8 px ; align - items : center ; flex - wrap : wrap ; }
. recherche { position : relative ; }
. recherche input { padding - left : 30 px ; min - width : 180 px ; }
. recherche : : before { content : " ⌕ " ; position : absolute ; left : 10 px ; top : 50 % ; transform : translateY ( - 50 % ) ; color : var ( - - faint ) ; font - size : 16 px ; }
button {
border : 1 px solid var ( - - ligne - forte ) ; background : var ( - - surface - 2 ) ; color : var ( - - texte ) ;
padding : 8 px 13 px ; border - radius : var ( - - rayon - sm ) ; cursor : pointer ; font : inherit ; font - weight : 700 ;
transition : background .15 s , border - color .15 s , transform .05 s , box - shadow .15 s , opacity .15 s , color .15 s ;
}
button : hover : not ( : disabled ) { border - color : var ( - - teal ) ; }
button : active : not ( : disabled ) { transform : translateY ( 1 px ) ; }
button : disabled { opacity : .4 ; cursor : not - allowed ; }
button . primaire { background : var ( - - primaire ) ; color : #fff; border-color: var(--primaire); box-shadow: var(--ombre); }
button . primaire : hover : not ( : disabled ) { background : var ( - - primaire - fort ) ; border - color : var ( - - primaire - fort ) ; }
button . fantome { background : transparent ; }
button . attention { border - color : var ( - - ambre ) ; color : var ( - - ambre ) ; }
button . danger { color : var ( - - rouge ) ; border - color : transparent ; background : transparent ; }
button . danger : hover : not ( : disabled ) { background : var ( - - rouge - fond ) ; }
input , select {
width : 100 % ; border : 1 px solid var ( - - ligne - forte ) ; border - radius : var ( - - rayon - sm ) ;
padding : 8 px 10 px ; background : var ( - - surface ) ; color : var ( - - texte ) ; font : inherit ;
transition : border - color .15 s , box - shadow .15 s ;
}
input : : placeholder { color : var ( - - faint ) ; }
input : focus , select : focus { outline : none ; border - color : var ( - - teal ) ; box - shadow : 0 0 0 3 px color - mix ( in srgb , var ( - - teal ) 22 % , transparent ) ; }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
main { padding : 16 px 22 px 44 px ; display : grid ; grid - template - columns : minmax ( 0 , 1.6 fr ) minmax ( 420 px , 1 fr ) ; gap : 20 px ; align - items : start ; max - width : 1760 px ; margin : 0 auto ; }
2026-06-30 12:19:26 -04:00
. toasts { position : fixed ; top : 14 px ; right : 14 px ; z - index : 60 ; display : flex ; flex - direction : column ; gap : 8 px ; max - width : min ( 380 px , 92 vw ) ; pointer - events : none ; }
. toast { pointer - events : auto ; background : var ( - - surface - 2 ) ; border : 1 px solid var ( - - ligne - forte ) ; border - left : 3 px solid var ( - - bleu ) ; border - radius : var ( - - rayon - sm ) ; box - shadow : var ( - - ombre - forte ) ; padding : 9 px 12 px ; font - size : 13 px ; font - weight : 600 ; color : var ( - - texte ) ; display : flex ; gap : 9 px ; align - items : flex - start ; animation : toast - in .18 s ease - out ; }
. toast . ok { border - left - color : var ( - - vert ) ; } . toast . erreur { border - left - color : var ( - - rouge ) ; }
. toast . sortant { opacity : 0 ; transform : translateX ( 12 px ) ; transition : opacity .25 s , transform .25 s ; }
. toast . x { margin - left : auto ; cursor : pointer ; opacity : .5 ; font - weight : 800 ; background : none ; border : 0 ; color : inherit ; padding : 0 2 px ; font - size : 15 px ; line - height : 1 ; }
. toast . x : hover { opacity : 1 ; }
@keyframes toast - in { from { opacity : 0 ; transform : translateX ( 12 px ) ; } }
input . invalide , select . invalide { border - color : var ( - - rouge ) ! important ; box - shadow : 0 0 0 1 px var ( - - rouge ) ; }
2026-06-24 20:17:46 -04:00
. section - grille { margin - bottom : 22 px ; }
. section - tete { display : flex ; align - items : center ; gap : 10 px ; margin : 0 0 11 px ; color : var ( - - faint ) ; font - size : 11 px ; font - weight : 800 ; letter - spacing : .09 em ; text - transform : uppercase ; }
. section - tete . compte { color : var ( - - muted ) ; }
. section - tete : : after { content : " " ; height : 1 px ; background : var ( - - ligne ) ; flex : 1 ; }
. grille - cartes { display : grid ; grid - template - columns : repeat ( auto - fill , minmax ( 190 px , 1 fr ) ) ; gap : 12 px ; }
. carte {
position : relative ; text - align : left ; display : flex ; flex - direction : column ; gap : 8 px ;
background : var ( - - surface ) ; border : 1 px solid var ( - - ligne ) ; border - radius : var ( - - rayon ) ;
padding : 12 px 13 px ; cursor : pointer ; font : inherit ; color : var ( - - texte ) ;
transition : border - color .14 s , box - shadow .14 s , transform .05 s , background .14 s ;
}
. carte : : before { content : " " ; position : absolute ; left : 0 ; top : 11 px ; bottom : 11 px ; width : 3 px ; border - radius : 0 3 px 3 px 0 ; background : var ( - - bleu ) ; }
. carte . actif : : before { background : var ( - - vert ) ; }
. carte . bloque : : before { background : var ( - - rouge ) ; }
. carte : hover { border - color : var ( - - ligne - forte ) ; box - shadow : var ( - - ombre ) ; }
. carte . selectionnee { border - color : var ( - - teal ) ; box - shadow : 0 0 0 1 px var ( - - teal ) , var ( - - ombre ) ; background : var ( - - surface - 2 ) ; }
. carte - haut { display : flex ; align - items : center ; justify - content : space - between ; gap : 8 px ; }
. carte - vmid { color : var ( - - faint ) ; font - size : 11 px ; font - family : ui - monospace , SFMono - Regular , Menlo , monospace ; }
. carte - nom { font - weight : 800 ; font - size : 14 px ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. carte - nom . sans { color : var ( - - faint ) ; font - style : italic ; font - weight : 600 ; }
. carte - ip { color : var ( - - muted ) ; font - size : 12 px ; font - family : ui - monospace , SFMono - Regular , Menlo , monospace ; }
. carte - grps { display : flex ; gap : 5 px ; flex - wrap : wrap ; margin - top : 1 px ; }
. tag { font - size : 10.5 px ; font - weight : 700 ; padding : 2 px 7 px ; border - radius : 999 px ; background : var ( - - surface - 3 ) ; color : var ( - - muted ) ; white - space : nowrap ; }
. tag . plus { background : transparent ; color : var ( - - faint ) ; }
. badge - statut { display : inline - flex ; align - items : center ; gap : 5 px ; border - radius : 999 px ; padding : 3 px 9 px ; font - size : 10.5 px ; font - weight : 800 ; }
. badge - statut . ok { color : var ( - - vert ) ; background : var ( - - vert - fond ) ; }
. badge - statut . attente { color : var ( - - bleu ) ; background : var ( - - bleu - fond ) ; }
. badge - statut . bloque { color : var ( - - rouge ) ; background : var ( - - rouge - fond ) ; }
aside { position : sticky ; top : 70 px ; max - height : calc ( 100 vh - 90 px ) ; overflow : auto ; }
. detail { background : var ( - - surface ) ; border : 1 px solid var ( - - ligne ) ; border - radius : var ( - - rayon ) ; box - shadow : var ( - - ombre ) ; overflow : hidden ; }
. detail . bloque { border - color : color - mix ( in srgb , var ( - - rouge ) 50 % , var ( - - ligne ) ) ; }
. detail - tete { display : flex ; align - items : center ; gap : 10 px ; padding : 14 px 16 px 12 px ; border - bottom : 1 px solid var ( - - ligne ) ; flex - wrap : wrap ; }
. detail - nom { flex : 1 1 150 px ; min - width : 0 ; }
. zone - hint { display : inline - block ; margin : 4 px 0 0 8 px ; font - size : 11 px ; font - weight : 700 ; color : var ( - - teal ) ; }
. stats { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 64 px , 1 fr ) ) ; gap : 8 px ; padding : 14 px 16 px ; }
. stat { background : var ( - - surface - 2 ) ; border : 1 px solid var ( - - ligne ) ; border - radius : var ( - - rayon - sm ) ; padding : 9 px 10 px ; min - width : 0 ; }
. stat . v { font - size : 18 px ; font - weight : 800 ; line - height : 1.1 ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. stat . v . vide { color : var ( - - faint ) ; }
. stat . l { font - size : 9.5 px ; text - transform : uppercase ; letter - spacing : .06 em ; color : var ( - - faint ) ; font - weight : 800 ; margin - top : 3 px ; }
. grille { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 135 px , 1 fr ) ) ; gap : 11 px 12 px ; }
. champ { display : flex ; flex - direction : column ; gap : 4 px ; min - width : 0 ; }
. champ > span { color : var ( - - muted ) ; font - size : 11 px ; font - weight : 700 ; }
. champ . unite { color : var ( - - faint ) ; font - weight : 600 ; }
2026-06-30 10:07:17 -04:00
. champ > span . a - aide { cursor : help ; border - bottom : 1 px dotted var ( - - ligne - forte ) ; width : fit - content ; }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
. aide - modele { margin : 0 2 px 14 px ; background : var ( - - surface ) ; border : 1 px solid var ( - - ligne ) ; border - left : 3 px solid var ( - - bleu ) ; border - radius : var ( - - rayon - sm ) ; }
. aide - modele > summary { cursor : pointer ; padding : 10 px 13 px ; font - size : 12.5 px ; font - weight : 750 ; color : var ( - - bleu ) ; list - style : none ; }
. aide - modele > summary : : - webkit - details - marker { display : none ; }
. aide - modele > summary : : before { content : " ⓘ " ; }
. aide - corps { padding : 2 px 15 px 12 px ; color : var ( - - muted ) ; font - size : 12.5 px ; line - height : 1.55 ; }
. aide - corps p { margin : 6 px 0 ; } . aide - corps ul { margin : 6 px 0 ; padding - left : 18 px ; } . aide - corps li { margin : 3 px 0 ; }
. aide - corps b { color : var ( - - texte ) ; }
. aide - ro { border - top : 1 px solid var ( - - ligne ) ; padding - top : 8 px ; }
2026-06-30 12:19:26 -04:00
. detail - nom - txt { font - weight : 800 ; font - size : 17 px ; padding : 4 px 7 px ; display : inline - block ; overflow - wrap : anywhere ; }
2026-06-30 10:07:17 -04:00
#infobulle {
position : fixed ; z - index : 200 ; max - width : 260 px ; pointer - events : none ;
background : var ( - - surface - 3 ) ; color : var ( - - texte ) ;
border : 1 px solid var ( - - ligne - forte ) ; border - radius : var ( - - rayon - sm ) ;
padding : 7 px 10 px ; font - size : 12 px ; font - weight : 600 ; line - height : 1.4 ;
box - shadow : var ( - - ombre ) ; opacity : 0 ; transform : translateY ( 2 px ) ;
transition : opacity .12 s ease , transform .12 s ease ;
}
#infobulle.on { opacity: 1; transform: none; }
2026-06-24 20:17:46 -04:00
. pied { padding : 12 px 16 px ; border - top : 1 px solid var ( - - ligne ) ; display : flex ; gap : 8 px ; align - items : center ; }
. pied . grandit { flex : 1 ; }
. pied . hint - dep { color : var ( - - faint ) ; font - size : 12 px ; font - style : italic ; }
. dep - bloc { margin - top : 4 px ; }
. dep - bloc > summary { list - style : none ; cursor : pointer ; padding : 10 px 13 px ; display : flex ; align - items : center ; justify - content : space - between ; gap : 8 px ; font - weight : 800 ; font - size : 12.5 px ; background : var ( - - surface ) ; border : 1 px solid var ( - - ligne ) ; border - radius : var ( - - rayon - sm ) ; }
. dep - bloc > summary : : - webkit - details - marker { display : none ; }
. dep - bloc > summary : : after { content : " ▾ " ; color : var ( - - faint ) ; }
. dep - bloc [ open ] > summary { border - radius : var ( - - rayon - sm ) var ( - - rayon - sm ) 0 0 ; }
. dep - corps { border : 1 px solid var ( - - ligne ) ; border - top : none ; border - radius : 0 0 var ( - - rayon - sm ) var ( - - rayon - sm ) ; padding : 11 px ; display : grid ; grid - template - columns : repeat ( auto - fill , minmax ( 220 px , 1 fr ) ) ; gap : 8 px ; }
. carte - dep { border : 1 px solid var ( - - ligne ) ; border - radius : var ( - - rayon - sm ) ; padding : 9 px 10 px ; background : var ( - - surface - 2 ) ; }
. carte - dep . ok { border - color : color - mix ( in srgb , var ( - - vert ) 45 % , var ( - - ligne ) ) ; }
. carte - dep . attente { border - color : color - mix ( in srgb , var ( - - ambre ) 45 % , var ( - - ligne ) ) ; }
. carte - dep . bloque { border - color : color - mix ( in srgb , var ( - - rouge ) 50 % , var ( - - ligne ) ) ; }
. carte - dep . haut { display : flex ; align - items : center ; justify - content : space - between ; gap : 8 px ; margin - bottom : 4 px ; }
. carte - dep . nom { font - weight : 800 ; font - size : 12 px ; overflow - wrap : anywhere ; }
. carte - dep . txt { color : var ( - - muted ) ; font - size : 11 px ; line - height : 1.4 ; }
. vide { color : var ( - - muted ) ; padding : 40 px 24 px ; text - align : center ; }
. vide b { color : var ( - - texte ) ; display : block ; margin - bottom : 4 px ; font - size : 15 px ; }
. vue - bascule { display : inline - flex ; border : 1 px solid var ( - - ligne - forte ) ; border - radius : 999 px ; padding : 2 px ; background : var ( - - surface - 3 ) ; gap : 2 px ; }
. vue - bascule button { border : none ; background : transparent ; border - radius : 999 px ; padding : 6 px 13 px ; font - size : 12.5 px ; font - weight : 750 ; color : var ( - - muted ) ; box - shadow : none ; }
. vue - bascule button : hover { color : var ( - - texte ) ; background : transparent ; }
. vue - bascule button . on { background : var ( - - surface ) ; color : var ( - - teal ) ; box - shadow : var ( - - ombre ) ; }
. mono { font - family : ui - monospace , SFMono - Regular , Menlo , monospace ; color : var ( - - teal ) ; font - size : 12 px ; }
. ch - stub { color : var ( - - ambre ) ; font - size : 11.5 px ; font - style : italic ; margin - top : 5 px ; }
. ch - bases { display : grid ; gap : 4 px ; margin : 6 px 0 0 23 px ; }
. ch - base { font - size : 11.5 px ; color : var ( - - muted ) ; overflow - wrap : anywhere ; }
. badge - bd { display : inline - block ; font - size : 10 px ; font - weight : 800 ; padding : 1 px 6 px ; border - radius : 999 px ; background : color - mix ( in srgb , var ( - - bleu ) 18 % , transparent ) ; color : var ( - - bleu ) ; }
. modale { position : fixed ; inset : 0 ; z - index : 50 ; display : flex ; align - items : center ; justify - content : center ; background : rgba ( 0 , 0 , 0 , .55 ) ; }
. modale [ hidden ] { display : none ; }
. modale - boite { background : var ( - - surface ) ; border : 1 px solid var ( - - ligne - forte ) ; border - radius : var ( - - rayon ) ; box - shadow : var ( - - ombre - forte ) ; padding : 18 px 20 px ; width : min ( 440 px , 92 vw ) ; display : grid ; gap : 12 px ; }
. modale - titre { font - weight : 800 ; font - size : 15 px ; }
. modale - texte { color : var ( - - muted ) ; font - size : 13 px ; line - height : 1.5 ; }
. modale - actions { display : flex ; justify - content : flex - end ; gap : 8 px ; margin - top : 4 px ; }
2026-06-30 10:07:17 -04:00
. modale - boite . large { width : min ( 760 px , 94 vw ) ; max - height : 88 vh ; overflow : auto ; }
. intrants - corps { display : grid ; gap : 16 px ; }
. intr - section { display : grid ; gap : 9 px ; }
. intr - tete { font - weight : 800 ; font - size : 13 px ; color : var ( - - bleu ) ; border - bottom : 1 px solid var ( - - ligne ) ; padding - bottom : 5 px ; }
. intr - grille { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 220 px , 1 fr ) ) ; gap : 11 px ; }
. badge - classe { display : inline - block ; font - size : 9.5 px ; font - weight : 800 ; letter - spacing : .04 em ; text - transform : uppercase ; padding : 1 px 6 px ; border - radius : 999 px ; margin - left : 6 px ; 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 ) ; }
2026-06-30 12:36:51 -04:00
. badge - classe . cat { background : color - mix ( in srgb , var ( - - teal ) 20 % , transparent ) ; color : var ( - - teal ) ; }
2026-06-30 16:20:18 -04:00
. badge - prod { display : inline - flex ; align - items : center ; font - size : 11 px ; font - weight : 800 ; letter - spacing : .06 em ; padding : 3 px 9 px ; border - radius : 999 px ; background : var ( - - rouge ) ; color : #fff; }
. badge - lab { display : inline - flex ; align - items : center ; font - size : 11 px ; font - weight : 800 ; letter - spacing : .04 em ; padding : 3 px 9 px ; border - radius : 999 px ; background : color - mix ( in srgb , var ( - - teal ) 22 % , transparent ) ; color : var ( - - teal ) ; }
2026-06-30 12:36:51 -04:00
. integ - cases { display : flex ; flex - wrap : wrap ; gap : 6 px 12 px ; padding : 4 px 2 px ; }
. integ - case { display : inline - flex ; align - items : center ; gap : 5 px ; font - size : 12 px ; font - weight : 600 ; color : var ( - - texte ) ; cursor : pointer ; }
. integ - case input { width : 14 px ; height : 14 px ; flex : none ; accent - color : var ( - - teal ) ; }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
. lien - objet { background : none ; border : 0 ; padding : 0 ; color : var ( - - bleu ) ; font : inherit ; font - weight : 700 ; cursor : pointer ; text - decoration : underline dotted ; }
. lien - objet : hover { color : var ( - - teal ) ; }
2026-06-30 10:07:17 -04:00
. intr - ro { color : var ( - - muted ) ; font - size : 12 px ; line - height : 1.55 ; }
. intr - ro b { color : var ( - - texte ) ; font - weight : 700 ; }
. intr - secrets { display : flex ; flex - wrap : wrap ; gap : 6 px ; margin - top : 6 px ; }
. intr - secrets code { font - size : 11 px ; background : var ( - - surface - 2 ) ; border : 1 px solid var ( - - ligne ) ; border - radius : var ( - - rayon - sm ) ; padding : 1 px 6 px ; color : var ( - - muted ) ; }
2026-06-24 20:17:46 -04:00
. console { position : fixed ; left : 0 ; right : 0 ; bottom : 0 ; height : 44 vh ; background : var ( - - surface ) ; border - top : 1 px solid var ( - - ligne - forte ) ; box - shadow : 0 - 16 px 50 px - 18 px 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 : 12 px ; padding : 9 px 16 px ; border - bottom : 1 px solid var ( - - ligne ) ; background : var ( - - surface - 2 ) ; }
. console - titre { font - weight : 800 ; font - size : 13 px ; }
. console - etat { font - size : 12 px ; font - weight : 800 ; }
. console - etat . actif { color : var ( - - bleu ) ; animation : pulse 1.2 s ease - in - out infinite ; }
. console - etat . ok { color : var ( - - vert ) ; } . console - etat . echec { color : var ( - - rouge ) ; }
@keyframes pulse { 50 % { opacity : .4 ; } }
. console pre { margin : 0 ; padding : 12 px 16 px ; overflow : auto ; flex : 1 ; font - family : ui - monospace , SFMono - Regular , Menlo , monospace ; font - size : 12 px ; line - height : 1.45 ; white - space : pre - wrap ; word - break : break - word ; color : #cdd6e6; }
@media ( max - width : 1000 px ) {
main { grid - template - columns : 1 fr ; }
aside { position : static ; max - height : none ; order : - 1 ; }
}
< / style >
< / head >
< body >
2026-06-30 12:19:26 -04:00
< div class = " toasts " id = " toasts " aria - live = " polite " > < / div >
2026-06-24 20:17:46 -04:00
< header >
< div class = " marque " > < span class = " pastille " > < / span > < div class = " titre " > < h1 > Set - OPS < / h1 > < span class = " slogan " > Votre artisan numérique < / span > < / div > < / div >
< span class = " chemin " id = " chemin " > < / span >
< div class = " grandit " > < / div >
< div class = " resume " id = " resume " > < / div >
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
< 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 " >
2026-06-24 20:17:46 -04:00
< div class = " vue-bascule " >
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
< button type = " button " id = " btn-vue-serveurs " class = " on " onclick = " setVue( ' serveurs ' ) " > Serveurs < / button >
2026-06-24 20:17:46 -04:00
< button type = " button " id = " btn-vue-applications " onclick = " setVue( ' applications ' ) " > Applications < / button >
< button type = " button " id = " btn-vue-bases " onclick = " setVue( ' bases ' ) " > Bases < / button >
< / div >
< span class = " recherche " > < input id = " filtre " type = " search " placeholder = " Filtrer… " oninput = " filtrer(this.value) " > < / span >
2026-06-30 10:07:17 -04:00
< button type = " button " class = " fantome " onclick = " ouvrirIntrants() " > ⚙ Intrants < / button >
2026-06-24 20:17:46 -04:00
< button type = " button " class = " fantome " onclick = " charger() " > Recharger < / button >
2026-06-30 12:19:26 -04:00
< button type = " button " class = " primaire " id = " btn-sauver " onclick = " sauvegarderVue() " > Sauvegarder < / button >
2026-06-24 20:17:46 -04:00
< / div >
< / 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 = " modale " id = " modale " hidden >
< div class = " modale-boite " >
< div class = " modale-titre " id = " modale-titre " > < / div >
< div class = " modale-texte " id = " modale-texte " > < / div >
< label class = " champ " > < span > Mot de passe du vault Ansible ( laisser vide si non requis ) < / span >
< input type = " password " id = " modale-vault " autocomplete = " off " placeholder = " •••••••• " onkeydown = " if(event.key=== ' Enter ' )confirmerModale() " > < / label >
< div class = " modale-actions " >
< button type = " button " onclick = " annulerModale() " > Annuler < / button >
< button type = " button " class = " primaire " onclick = " confirmerModale() " > Confirmer < / button >
< / div >
< / div >
< / div >
2026-06-30 10:07:17 -04:00
< div class = " modale " id = " intrants " hidden >
< div class = " modale-boite large " >
< div class = " modale-titre " > ⚙ Intrants de base de l ' écosystème</div>
< div class = " modale-texte " > Saisis ici les valeurs communes à tout l ' écosystème, depuis un endroit unique.
Les < b > constantes < / b > sont une valeur unique non surchargeable & nbsp ; ; les < b > défauts < / b > resurgissent
comme valeurs proposées là où ils réapparaissent dans les instances déclarées .
Les secrets ne sont jamais saisis ici & nbsp ; : ils restent dans le Vault Ansible ( CLI ) . < / div >
< div class = " intrants-corps " id = " intrants-corps " > < / div >
< div class = " modale-actions " >
< button type = " button " onclick = " fermerIntrants() " > Fermer < / button >
< button type = " button " class = " primaire " onclick = " enregistrerIntrants() " > Enregistrer < / button >
< / div >
< / div >
< / div >
2026-06-24 20:17:46 -04:00
< div class = " console " id = " console " hidden >
< div class = " console-tete " >
< span class = " console-titre " id = " console-titre " > < / span >
< span class = " console-etat " id = " console-etat " > < / span >
< span class = " grandit " > < / span >
< button type = " button " id = " btn-fermer-console " onclick = " fermerConsole() " > Fermer < / button >
< / div >
< pre id = " console-sortie " > < / pre >
< / div >
< script >
const JETON = " __JETON__ " ;
let groupes = [ ] ;
let dependances = { } ;
let nomenclature = { } ;
let chaine = { } ;
let hotes = [ ] ;
let estProduction = false ;
let filtre = ' ' ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
let vuePrincipale = ' serveurs ' ;
2026-06-24 20:17:46 -04:00
let bdServeurs = [ ] ;
let bdApplis = [ ] ;
let basesModifie = false ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
let selApp = null ; / / index de l ' application sélectionnée (vue Applications)
let selServeur = null ; / / index du serveur sélectionné ( vue Serveurs , maître - détail fusionné )
let selBase = null ; / / clé de l ' item sélectionné (vue Bases) : " srv:<i> " ou " bd:<i> "
let hoteOpCourant = ' ' ; / / nom de l ' hôte dont les boutons Vérifier/Déployer sont affichés
2026-06-24 20:17:46 -04:00
let applications = [ ] ;
let applicationsModifie = false ;
let serveurs = [ ] ;
let serveursModifie = false ;
let ongletActif = ' reseau ' ;
let occupe = false ;
let verifie = { } ;
let motVault = ' ' ;
let modaleResolveur = null ;
2026-06-30 10:07:17 -04:00
let intrants = null ;
2026-06-30 12:36:51 -04:00
let integrationsDispo = [ ] ;
2026-07-01 17:39:44 -04:00
let sondes = { } ; / / état RÉEL par hôte : joignable ( SSH ) ou non — distinct du plan
2026-06-30 12:19:26 -04:00
let chronoConsole = null ;
let consoleDebut = 0 ;
2026-06-24 20:17:46 -04:00
2026-06-30 12:19:26 -04:00
let toastSeq = 0 ;
function message ( texte , type = ' ' , opts = { } ) {
const conteneur = document . getElementById ( ' toasts ' ) ;
if ( ! conteneur ) return null ;
const t = document . createElement ( ' div ' ) ;
t . className = ' toast ' + ( type ? ' ' + type : ' ' ) ;
t . id = ' toast- ' + ( + + toastSeq ) ;
const corps = document . createElement ( ' span ' ) ;
corps . textContent = texte ;
t . appendChild ( corps ) ;
const x = document . createElement ( ' button ' ) ;
x . type = ' button ' ; x . className = ' x ' ; x . textContent = ' × ' ;
x . onclick = ( ) = > retirerToast ( t ) ;
t . appendChild ( x ) ;
conteneur . appendChild ( t ) ;
while ( conteneur . children . length > 6 ) conteneur . removeChild ( conteneur . firstChild ) ;
const duree = opts . persistant ? 0 : ( opts . duree != null ? opts . duree : ( type == = ' erreur ' ? 7000 : 3500 ) ) ;
if ( duree > 0 ) setTimeout ( ( ) = > retirerToast ( t ) , duree ) ;
return t ; / / renvoie le nœud pour permettre de le mettre à jour ( opérations longues )
}
function majToast ( t , texte , type , dureeFin ) {
if ( ! t ) return ;
if ( type != = undefined ) t . className = ' toast ' + ( type ? ' ' + type : ' ' ) ;
const corps = t . querySelector ( ' span ' ) ; if ( corps ) corps . textContent = texte ;
if ( dureeFin ) setTimeout ( ( ) = > retirerToast ( t ) , dureeFin ) ;
}
function retirerToast ( t ) {
if ( ! t | | ! t . parentNode ) return ;
t . classList . add ( ' sortant ' ) ;
setTimeout ( ( ) = > { if ( t . parentNode ) t . parentNode . removeChild ( t ) ; } , 260 ) ;
2026-06-24 20:17:46 -04:00
}
async function charger ( ) {
const reponse = await fetch ( ' /api/inventaire ' ) ;
const data = await reponse . json ( ) ;
groupes = data . groupes ;
dependances = data . dependances | | { } ;
nomenclature = data . nomenclature | | { } ;
chaine = data . chaine | | { } ;
2026-06-30 10:07:17 -04:00
intrants = data . intrants | | null ;
2026-06-30 12:36:51 -04:00
integrationsDispo = data . integrations_disponibles | | [ ] ;
2026-06-24 20:17:46 -04:00
chargerBases ( data ) ;
chargerApplications ( data ) ;
chargerServeurs ( data ) ;
hotes = data . hotes ;
estProduction = ! ! data . production ;
2026-06-30 12:19:26 -04:00
verifie = { } ;
2026-06-24 20:17:46 -04:00
document . getElementById ( ' chemin ' ) . textContent = data . inventaire ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
if ( selServeur == null | | selServeur > = serveurs . length ) selServeur = serveurs . length ? 0 : null ;
2026-06-24 20:17:46 -04:00
dessiner ( ) ;
message ( ' Inventaire chargé. ' , ' ok ' ) ;
2026-07-01 17:39:44 -04:00
sonder ( ) ; / / état réel ( joignabilité ) en arrière - plan , non bloquant
}
async function sonder ( ) {
try {
const r = await fetch ( ' /api/sondes ' ) ;
if ( r . ok ) { sondes = await r . json ( ) ; dessiner ( ) ; }
} catch ( e ) { / * silencieux * / }
2026-06-24 20:17:46 -04:00
}
function setOnglet ( o ) { ongletActif = o ; dessinerDetail ( ) ; }
function filtrer ( valeur ) { filtre = ( valeur | | ' ' ) . trim ( ) . toLowerCase ( ) ; dessinerGrilles ( ) ; }
function groupesActifs ( ) { const a = new Set ( ) ; hotes . forEach ( h = > { if ( h . etat == = ' actif ' ) ( h . groupes | | [ ] ) . forEach ( g = > a . add ( g ) ) ; } ) ; return a ; }
function groupesPlanifies ( ) { const p = new Set ( ) ; hotes . forEach ( h = > ( h . groupes | | [ ] ) . forEach ( g = > p . add ( g ) ) ) ; return p ; }
function statutDependance ( groupe ) {
const requis = ( ( dependances [ groupe ] | | { } ) . requiert_groupes_actifs | | [ ] ) ;
if ( ! requis . length ) return ' neutre ' ;
const actifs = groupesActifs ( ) , planifies = groupesPlanifies ( ) ;
const manquants = requis . filter ( i = > ! actifs . has ( i ) ) ;
if ( ! manquants . length ) return ' ok ' ;
if ( manquants . every ( i = > planifies . has ( i ) ) ) return ' attente ' ;
return ' bloque ' ;
}
function manquantsPourGroupe ( groupe ) { const a = groupesActifs ( ) ; return ( ( dependances [ groupe ] | | { } ) . requiert_groupes_actifs | | [ ] ) . filter ( i = > ! a . has ( i ) ) ; }
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 ; }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
function dessiner ( ) { dessinerResume ( ) ; dessinerGrilles ( ) ; dessinerDependances ( ) ; dessinerDetail ( ) ; majIndicateurSauvegarde ( ) ; }
2026-06-24 20:17:46 -04:00
function majIndicateurSauvegarde ( ) {
const b = document . getElementById ( ' btn-sauver ' ) ;
2026-06-30 12:19:26 -04:00
if ( ! b ) return ;
const editable = vuePrincipale == = ' serveurs ' | | vuePrincipale == = ' applications ' | | vuePrincipale == = ' bases ' ;
const sale = ( vuePrincipale == = ' serveurs ' & & serveursModifie )
| | ( vuePrincipale == = ' applications ' & & applicationsModifie )
| | ( vuePrincipale == = ' bases ' & & basesModifie ) ;
b . classList . toggle ( ' attention ' , sale ) ;
b . textContent = sale ? ' Sauvegarder • ' : ' Sauvegarder ' ;
b . disabled = ! editable ;
b . title = editable ? ' Sauvegarder cette vue (Ctrl+S) ' : ' Lecture seule — édite dans Serveurs / Applications / Bases ' ;
2026-06-24 20:17:46 -04:00
}
function dessinerResume ( ) {
const actifs = hotes . filter ( h = > h . etat == = ' actif ' ) . length ;
const bloques = hotes . filter ( h = > hoteBloque ( h ) ) . length ;
2026-07-01 17:52:39 -04:00
const vivantes = hotes . filter ( h = > sondes [ h . nom ] == = true ) . length ;
const sonde = Object . keys ( sondes ) . length > 0 ;
2026-06-24 20:17:46 -04:00
document . getElementById ( ' resume ' ) . innerHTML = `
2026-06-30 16:20:18 -04:00
$ { 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> ' }
2026-06-24 20:17:46 -04:00
< 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 >
2026-07-01 17:52:39 -04:00
$ { bloques ? ` < span class = " chip " > < span class = " pt bloq " > < / span > < b > $ { bloques } < / b > bloqués < / span > ` : ' ' }
$ { sonde ? ` < span class = " chip " title = " État RÉEL : VM joignables (sonde SSH), indépendamment du plan " > < span class = " pt actif " > < / span > < b > $ { vivantes } < / b > vivantes < / span > ` : ' ' } ` ;
2026-06-24 20:17:46 -04:00
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
function correspondServeur ( s ) {
if ( ! filtre ) return true ;
return [ s . nom , s . fonction , s . adresse_ip , s . vmid , s . etat ] . join ( ' ' ) . toLowerCase ( ) . includes ( filtre ) ;
2026-06-24 20:17:46 -04:00
}
function setVue ( v ) {
vuePrincipale = v ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
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 ) ;
2026-06-24 20:17:46 -04:00
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 ' ) ;
dessinerGrilles ( ) ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
dessinerDetail ( ) ; / / le panneau droit reflète la sélection de la vue courante
2026-06-30 12:19:26 -04:00
majIndicateurSauvegarde ( ) ;
2026-06-24 20:17:46 -04:00
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
const BANNIERE_MODELE = ` < details class = " aide-modele " >
< summary > Comment lire ce parc — serveur · hôte · groupe · application · base < / summary >
< div class = " aide-corps " >
< p > < b > Serveur < / b > ( ce que tu écris ici ) → < b > « Appliquer le plan » < / b > → < b > Hôte < / b > ( la VM générée ) . C ' est le même objet, à deux étapes.</p>
< ul >
< li > < b > Serveur / Hôte < / b > — une VM ; elle < b > appartient à des groupes < / b > . < / li >
< li > < b > Groupe < / b > — un rôle appliqué à l ' hôte (ex. <span class= " mono " >serveur_nginx</span>, <span class= " mono " >client_dns</span>).</li>
< 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 >
< / div > < / details > ` ;
2026-06-24 20:17:46 -04:00
function dessinerGrilles ( ) {
if ( vuePrincipale == = ' applications ' ) { dessinerApplications ( ) ; return ; }
if ( vuePrincipale == = ' bases ' ) { dessinerBases ( ) ; return ; }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
dessinerServeurs ( ) ;
2026-06-24 20:17:46 -04:00
}
function basesDeApp ( app , hoteNom ) {
return bdApplis . filter ( a = > {
const p = a . portee | | ' groupe ' ;
return ( p == = ' application ' & & a . consommateur == = app . id )
| | ( p == = ' groupe ' & & a . consommateur == = app . groupe )
| | ( p == = ' hote ' & & a . consommateur == = hoteNom ) ;
} ) ;
}
function chargerBases ( data ) {
const b = data . bases | | { serveurs_bd : { } , bases_donnees : { } } ;
bdServeurs = Object . entries ( b . serveurs_bd | | { } ) . map ( ( [ nom , s ] ) = > ( {
nom , type : s . type | | ' postgres ' , hote : s . hote | | ' ' , port : s . port | | 5432 , groupe : s . groupe | | ' ' } ) ) ;
bdApplis = Object . entries ( b . bases_donnees | | { } ) . map ( ( [ cle , x ] ) = > ( {
cle , serveur : x . serveur | | ' ' , base : x . base | | ' ' , proprietaire : x . proprietaire | | ' ' , secret : x . secret | | ' ' ,
consommateur : x . consommateur | | ' ' , portee : x . portee | | ' groupe ' , usage : x . usage | | ' principale ' } ) ) ;
basesModifie = false ;
}
function chargerApplications ( data ) {
const a = ( data . applications | | { } ) . applications | | { } ;
applications = Object . entries ( a ) . map ( ( [ id , x ] ) = > ( {
id , groupe : x . groupe | | ' ' , hote : x . hote | | ' ' , port : x . port | | ' ' ,
requiert : ( x . requiert | | [ ] ) . join ( ' , ' ) , expose : ( x . expose | | [ ] ) . join ( ' , ' ) } ) ) ;
applicationsModifie = false ;
}
function dsnBase ( a ) {
const s = bdServeurs . find ( x = > x . nom == = a . serveur ) ;
if ( ! s ) return ' (serveur inconnu : ' + echapper ( a . serveur | | ' — ' ) + ' ) ' ;
return ` $ { s . type } : / / $ { a . proprietaire | | ' ? ' } : * * * * @ $ { s . hote | | ' ? ' } : $ { s . port | | ' ? ' } / $ { a . base | | ' ? ' } ` ;
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
function marquerBasesModifie ( ) { basesModifie = true ; majIndicateurSauvegarde ( ) ; }
function selBaseInfo ( ) { if ( ! selBase ) return null ; const p = selBase . split ( ' : ' ) ; return { kind : p [ 0 ] , i : + p [ 1 ] } ; }
function choisirBase ( key ) { selBase = key ; dessiner ( ) ; }
function definirServeurBd ( champ , v ) {
const info = selBaseInfo ( ) ; if ( ! info | | info . kind != = ' srv ' ) return ;
const s = bdServeurs [ info . i ] ; if ( ! s ) return ;
s [ champ ] = v ; marquerBasesModifie ( ) ;
if ( champ == = ' nom ' ) { const t = document . querySelector ( ' #tuile-base-srv- ' + info . i + ' .carte-nom ' ) ; if ( t ) t . textContent = v | | ' (sans nom) ' ; }
}
function definirBaseBd ( champ , v ) {
const info = selBaseInfo ( ) ; if ( ! info | | info . kind != = ' bd ' ) return ;
const a = bdApplis [ info . i ] ; if ( ! a ) return ;
a [ champ ] = v ; marquerBasesModifie ( ) ;
if ( champ == = ' cle ' ) { const t = document . querySelector ( ' #tuile-base-bd- ' + info . i + ' .carte-nom ' ) ; if ( t ) t . textContent = v | | ' (sans id) ' ; }
else if ( champ == = ' serveur ' | | champ == = ' portee ' ) dessinerDetail ( ) ;
}
function ajouterServeurBd ( ) { bdServeurs . unshift ( { nom : ' ' , type : ' postgres ' , hote : ' ' , port : 5432 , groupe : ' ' } ) ; selBase = ' srv:0 ' ; marquerBasesModifie ( ) ; dessiner ( ) ; }
function retirerServeurBd ( ) { const info = selBaseInfo ( ) ; if ( ! info | | info . kind != = ' srv ' ) return ; bdServeurs . splice ( info . i , 1 ) ; selBase = null ; marquerBasesModifie ( ) ; dessiner ( ) ; }
function ajouterBaseBd ( ) { bdApplis . unshift ( { cle : ' ' , serveur : ( bdServeurs [ 0 ] | | { } ) . nom | | ' ' , base : ' ' , proprietaire : ' ' , secret : ' ' , consommateur : ' ' , portee : ' groupe ' , usage : ' principale ' } ) ; selBase = ' bd:0 ' ; marquerBasesModifie ( ) ; dessiner ( ) ; }
function retirerBaseBd ( ) { const info = selBaseInfo ( ) ; if ( ! info | | info . kind != = ' bd ' ) return ; bdApplis . splice ( info . i , 1 ) ; selBase = null ; marquerBasesModifie ( ) ; dessiner ( ) ; }
function carteBaseServeur ( s , i ) {
const key = ' srv: ' + i ;
return ` < button type = " button " class = " carte $ { selBase === key ? ' selectionnee ' : ' ' } " id = " tuile-base-srv-$ {i} " onclick = " choisirBase( ' $ {key} ' ) " >
< div class = " carte-haut " > < span class = " badge-bd " > $ { echapper ( s . type | | ' bd ' ) } < / span > $ { s . port ? ` < span class = " carte-vmid " > : $ { echapper ( s . port ) } < / span > ` : ' ' } < / div >
< div class = " carte-nom $ { s.nom ? ' ' : ' sans ' } " > $ { echapper ( s . nom | | ' (sans nom) ' ) } < / div >
< div class = " carte-ip " > $ { echapper ( s . hote | | ' — hôte ? ' ) } < / div >
< / button > ` ;
}
function carteBaseDonnee ( a , i ) {
const key = ' bd: ' + i ;
return ` < button type = " button " class = " carte $ { selBase === key ? ' selectionnee ' : ' ' } " id = " tuile-base-bd-$ {i} " onclick = " choisirBase( ' $ {key} ' ) " >
< div class = " carte-haut " > < span class = " badge-bd " > $ { echapper ( a . portee | | ' groupe ' ) } < / span > < / div >
< div class = " carte-nom $ { a.cle ? ' ' : ' sans ' } " > $ { echapper ( a . cle | | ' (sans id) ' ) } < / div >
< div class = " carte-ip " > $ { echapper ( a . base | | ' — ' ) } @ $ { echapper ( a . serveur | | ' ? ' ) } < / div >
< / button > ` ;
2026-06-24 20:17:46 -04:00
}
function dessinerBases ( ) {
const cible = document . getElementById ( ' grilles ' ) ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
const srv = bdServeurs . length ? ` < div class = " grille-cartes " > $ { bdServeurs . map ( ( s , i ) = > carteBaseServeur ( s , i ) ) . join ( ' ' ) } < / div > ` : ' <div class= " vide " style= " padding:12px " >Aucun serveur de BD.</div> ' ;
const bd = bdApplis . length ? ` < div class = " grille-cartes " > $ { bdApplis . map ( ( a , i ) = > carteBaseDonnee ( a , i ) ) . join ( ' ' ) } < / div > ` : ' <div class= " vide " style= " padding:12px " >Aucune base.</div> ' ;
cible . innerHTML = ` < div class = " section-grille " > < div class = " section-tete " > Serveurs de bases < span class = " compte " > $ { bdServeurs . length } < / span > < button type = " button " class = " fantome " style = " margin-left:auto " onclick = " ajouterServeurBd() " > + Serveur BD < / button > < / div > $ { srv } < / div >
< div class = " section-grille " > < div class = " section-tete " > Bases applicatives < span class = " compte " > $ { bdApplis . length } < / span > < button type = " button " class = " fantome " style = " margin-left:auto " onclick = " ajouterBaseBd() " > + Base < / button > < / div > $ { bd } < / div > ` ;
}
function inspecteurBase ( ) {
const cible = document . getElementById ( ' detail ' ) ;
const info = selBaseInfo ( ) ;
if ( ! info ) { cible . innerHTML = ' <div class= " detail " ><div class= " vide " ><b>Aucun élément sélectionné</b>Choisis un serveur de base ou une base à gauche.</div></div> ' ; return ; }
if ( info . kind == = ' srv ' ) {
const s = bdServeurs [ info . i ] ;
if ( ! s ) { cible . innerHTML = ' <div class= " detail " ><div class= " vide " >Introuvable.</div></div> ' ; return ; }
const optHote = ' <option value= " " >—</option> ' + hotes . map ( h = > h . nom ) . filter ( Boolean ) . map ( n = > ` < option value = " $ { echapper(n)} " $ { n == = s . hote ? ' selected ' : ' ' } > $ { echapper ( n ) } < / option > ` ) . join ( ' ' ) ;
const optGroupe = ' <option value= " " >—</option> ' + groupes . map ( g = > ` < option value = " $ { echapper(g)} " $ { g == = s . groupe ? ' selected ' : ' ' } > $ { echapper ( g ) } < / option > ` ) . join ( ' ' ) ;
const ici = bdApplis . filter ( a = > a . serveur == = s . nom ) ;
const iciHtml = ici . length ? ` < div class = " ch-bases " > $ { ici . map ( a = > ` < div class = " ch-base " > < span class = " badge-bd " > $ { echapper ( a . portee | | ' groupe ' ) } < / span > < span class = " mono " > $ { echapper ( a . base | | ' ? ' ) } < / span > ( $ { echapper ( a . cle ) } ) < / div > ` ) . join ( ' ' ) } < / div > ` : ' <div class= " ch-stub " >aucune base sur ce serveur</div> ' ;
cible . innerHTML = ` < article class = " detail " >
< div class = " detail-tete " > < div class = " detail-nom " > < span class = " detail-nom-txt " > $ { echapper ( s . nom | | ' (sans nom) ' ) } < / span > < / div > < span class = " grandit " > < / span > < span class = " badge-bd " > $ { echapper ( s . type | | ' bd ' ) } < / span > < button type = " button " class = " danger " title = " Retirer " onclick = " retirerServeurBd() " > ✕ < / button > < / div >
< div class = " grille " >
< label class = " champ " > < span > Nom < / span > < input value = " $ { echapper(s.nom)} " placeholder = " pg-principal " oninput = " definirServeurBd( ' nom ' , this.value) " > < / label >
< label class = " champ " > < span > Type < / span > < input value = " $ { echapper(s.type)} " placeholder = " postgres " oninput = " definirServeurBd( ' type ' , this.value) " > < / label >
< label class = " champ " > < span > Hôte ( VM ) < / span > < select onchange = " definirServeurBd( ' hote ' , this.value) " > $ { optHote } < / select > < / label >
< label class = " champ " > < span > Port < / span > < input type = " number " value = " $ { echapper(s.port)} " oninput = " definirServeurBd( ' port ' , this.value) " > < / label >
< label class = " champ " > < span > Groupe Ansible < / span > < select onchange = " definirServeurBd( ' groupe ' , this.value) " > $ { optGroupe } < / select > < / label >
< / div >
< div class = " section-tete " style = " margin:12px 2px 2px " > Bases hébergées ici < / div > $ { iciHtml }
2026-07-01 15:58:32 -04:00
$ { ( ( ) = > {
const hS = hotes . find ( h = > h . nom == = s . hote ) ;
if ( hS & & hS . etat == = ' actif ' ) { const hiS = hotes . indexOf ( hS ) ;
2026-07-04 14:40:02 -04:00
return ` < div class = " pied " > < span class = " hint-dep " > Déployer applique les rôles sur l ' hôte « $ { echapper(s.hote)} ».</span><span class= " grandit " ></span>$ { estProduction ? ' < span class = " badge-prod " > PROD < / span > ' : ' ' }<button type= " button " class= " $ { estProduction ? ' danger ' : ' primaire ' } " onclick= " lancer( ' pousser ' , $ {hiS} ) " title= " Déployer l ' hôte du serveur de BD " >Déployer</button></div>`; }
2026-07-01 15:58:32 -04:00
return ` < div class = " pied " > < span class = " hint-dep " > $ { s . hote ? ` Active + pousse d ' abord l ' hôte « $ { echapper ( s . hote ) } » . ` : ' Aucun hôte assigné. ' } < / span > < / div > ` ;
} ) ( ) }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
< / article > ` ;
} else {
const a = bdApplis [ info . i ] ;
if ( ! a ) { cible . innerHTML = ' <div class= " detail " ><div class= " vide " >Introuvable.</div></div> ' ; return ; }
const source = a . portee == = ' hote ' ? hotes . map ( h = > h . nom ) . filter ( Boolean ) : a . portee == = ' application ' ? applications . map ( x = > x . id ) : groupes ;
const optConso = ' <option value= " " >—</option> ' + source . map ( v = > ` < option value = " $ { echapper(v)} " $ { v == = a . consommateur ? ' selected ' : ' ' } > $ { echapper ( v ) } < / option > ` ) . join ( ' ' ) ;
const optPortee = [ ' groupe ' , ' application ' , ' hote ' ] . map ( p = > ` < option value = " $ {p} " $ { p == = ( a . portee | | ' groupe ' ) ? ' selected ' : ' ' } > $ { p } < / option > ` ) . join ( ' ' ) ;
const optServeur = ' <option value= " " >—</option> ' + bdServeurs . map ( x = > ` < option value = " $ { echapper(x.nom)} " $ { x . nom == = a . serveur ? ' selected ' : ' ' } > $ { echapper ( x . nom ) } < / option > ` ) . join ( ' ' ) ;
cible . innerHTML = ` < article class = " detail " >
< div class = " detail-tete " > < div class = " detail-nom " > < span class = " detail-nom-txt " > $ { echapper ( a . cle | | ' (sans id) ' ) } < / span > < / div > < span class = " grandit " > < / span > < button type = " button " class = " danger " title = " Retirer " onclick = " retirerBaseBd() " > ✕ < / button > < / div >
< div class = " grille " >
< label class = " champ " > < span > Identifiant < / span > < input value = " $ { echapper(a.cle)} " placeholder = " bd_forgejo " oninput = " definirBaseBd( ' cle ' , this.value) " > < / label >
< label class = " champ " > < span > Portée < / span > < select onchange = " definirBaseBd( ' portee ' , this.value) " > $ { optPortee } < / select > < / label >
< label class = " champ " > < span > Consommateur < / span > < select onchange = " definirBaseBd( ' consommateur ' , this.value) " > $ { optConso } < / select > < / label >
< label class = " champ " > < span > Serveur < / span > < select onchange = " definirBaseBd( ' serveur ' , this.value) " > $ { optServeur } < / select > < / label >
< label class = " champ " > < span > Base < / span > < input value = " $ { echapper(a.base)} " placeholder = " forgejo " oninput = " definirBaseBd( ' base ' , this.value) " > < / label >
< label class = " champ " > < span > Propriétaire < / span > < input value = " $ { echapper(a.proprietaire)} " oninput = " definirBaseBd( ' proprietaire ' , this.value) " > < / label >
< label class = " champ " > < span > Usage < / span > < input value = " $ { echapper(a.usage)} " placeholder = " principale " oninput = " definirBaseBd( ' usage ' , this.value) " > < / label >
< label class = " champ " > < span > Secret ( Vault ) < / span > < input value = " $ { echapper(a.secret)} " placeholder = " vault_bd_app " oninput = " definirBaseBd( ' secret ' , this.value) " > < / label >
< / div >
< div class = " dsn mono " style = " margin-top:8px " > $ { echapper ( dsnBase ( a ) ) } < / div >
2026-07-01 15:58:32 -04:00
$ { ( ( ) = > {
const srv = bdServeurs . find ( x = > x . nom == = a . serveur ) ;
const hB = srv ? hotes . find ( h = > h . nom == = srv . hote ) : null ;
if ( hB & & hB . etat == = ' actif ' ) { const hiB = hotes . indexOf ( hB ) ;
2026-07-04 14:40:02 -04:00
return ` < div class = " pied " > < span class = " hint-dep " > Déployer applique les rôles sur « $ { echapper ( srv . hote ) } » ( le serveur de BD qui héberge cette base ) . < / span > < span class = " grandit " > < / span > $ { estProduction ? ' <span class= " badge-prod " >PROD</span> ' : ' ' } < button type = " button " class = " $ { estProduction ? ' danger ' : ' primaire ' } " onclick = " lancer( ' pousser ' , $ {hiB} ) " title = " Déployer le serveur de BD (crée cette base) " > Déployer < / button > < / div > ` ; }
2026-07-01 15:58:32 -04:00
return ` < div class = " pied " > < span class = " hint-dep " > $ { ( srv & & srv . hote ) ? ` Active + pousse d ' abord l ' hôte « $ { echapper ( srv . hote ) } » . ` : ' Serveur de BD sans hôte assigné. ' } < / span > < / div > ` ;
} ) ( ) }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
< / article > ` ;
}
2026-06-24 20:17:46 -04:00
}
async function sauvegarderBases ( ) {
const sd = { } ;
bdServeurs . forEach ( s = > { if ( String ( s . nom ) . trim ( ) ) sd [ s . nom ] = { type : s . type , hote : s . hote , port : parseInt ( s . port , 10 ) | | s . port , . . . ( s . groupe ? { groupe : s . groupe } : { } ) } ; } ) ;
const bd = { } ;
bdApplis . forEach ( a = > { if ( String ( a . cle ) . trim ( ) ) bd [ a . cle ] = { serveur : a . serveur , base : a . base , proprietaire : a . proprietaire , secret : a . secret , consommateur : a . consommateur , portee : a . portee | | ' groupe ' , usage : a . usage | | ' principale ' } ; } ) ;
const rep = await fetch ( ' /api/bases ' , {
method : ' POST ' , headers : { ' Content-Type ' : ' application/json ' , ' X-Jeton ' : JETON } , body : JSON . stringify ( { serveurs_bd : sd , bases_donnees : bd } )
} ) ;
const data = await rep . json ( ) ;
if ( ! rep . ok ) { message ( data . erreur | | ' Sauvegarde refusée. ' , ' erreur ' ) ; return ; }
chargerBases ( data ) ;
dessinerBases ( ) ;
message ( ' Bases de données sauvegardées. ' , ' ok ' ) ;
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
function marquerApplicationsModifie ( ) { applicationsModifie = true ; majIndicateurSauvegarde ( ) ; }
function choisirApp ( i ) { selApp = i ; dessiner ( ) ; }
function definirApplication ( champ , v ) {
const a = applications [ selApp ] ; if ( ! a ) return ;
a [ champ ] = v ; marquerApplicationsModifie ( ) ;
if ( champ == = ' id ' ) { const t = document . querySelector ( ' #tuile-app- ' + selApp + ' .carte-nom ' ) ; if ( t ) { t . textContent = v | | ' (sans id) ' ; t . className = ' carte-nom ' + ( v ? ' ' : ' sans ' ) ; } }
else if ( champ == = ' hote ' | | champ == = ' groupe ' | | champ == = ' port ' ) dessinerDetail ( ) ;
}
function ajouterApplication ( ) {
applications . unshift ( { id : ' ' , groupe : ( groupes [ 0 ] | | ' ' ) , hote : ' ' , port : ' ' , requiert : ' ' , expose : ' ' } ) ;
selApp = 0 ; marquerApplicationsModifie ( ) ; dessiner ( ) ;
const c = document . querySelector ( ' #detail .grille input ' ) ; if ( c ) c . focus ( ) ;
}
function retirerApplication ( ) {
const a = applications [ selApp ] ; if ( ! a ) return ;
if ( a . id & & ! confirm ( ' Retirer l \' application « ' + a . id + ' » ? ' ) ) return ;
applications . splice ( selApp , 1 ) ;
selApp = applications . length ? Math . min ( selApp , applications . length - 1 ) : null ;
marquerApplicationsModifie ( ) ; dessiner ( ) ;
}
function carteApp ( a , i ) {
const exp = listeDepuisTexte ( a . expose ) ;
return ` < button type = " button " class = " carte $ { i === selApp ? ' selectionnee ' : ' ' } " id = " tuile-app-$ {i} " onclick = " choisirApp($ {i} ) " >
< div class = " carte-haut " > < span class = " badge-bd " > app < / span > $ { a . port ? ` < span class = " carte-vmid " > : $ { echapper ( a . port ) } < / span > ` : ' ' } < / div >
< div class = " carte-nom $ { a.id ? ' ' : ' sans ' } " > $ { echapper ( a . id | | ' (sans id) ' ) } < / div >
< div class = " carte-ip " > $ { echapper ( a . hote | | ' — hôte ? ' ) } < / div >
$ { exp . length ? ` < div class = " carte-grps " > < span class = " tag " > $ { echapper ( exp [ 0 ] ) } < / span > $ { exp . length > 1 ? ` < span class = " tag plus " > + $ { exp . length - 1 } < / span > ` : ' ' } < / div > ` : ' ' }
< / button > ` ;
2026-06-24 20:17:46 -04:00
}
function dessinerApplications ( ) {
const cible = document . getElementById ( ' grilles ' ) ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
const tuiles = applications . length
? ` < div class = " grille-cartes " > $ { applications . map ( ( a , i ) = > carteApp ( a , i ) ) . join ( ' ' ) } < / div > `
: ' <div class= " vide " style= " padding:14px " >Aucune application. Une VM peut en porter plusieurs.</div> ' ;
cible . innerHTML = ` < div class = " section-grille " >
< div class = " section-tete " > Applications < span class = " compte " > $ { applications . length } < / span > < button type = " button " class = " fantome " style = " margin-left:auto " onclick = " ajouterApplication() " > + Application < / button > < / div >
$ { tuiles } < / div > ` ;
}
function inspecteurApplication ( ) {
const cible = document . getElementById ( ' detail ' ) ;
if ( selApp == null | | ! applications [ selApp ] ) {
cible . innerHTML = ' <div class= " detail " ><div class= " vide " ><b>Aucune application sélectionnée</b>Choisis une tuile à gauche, ou ajoute une application.</div></div> ' ;
return ;
}
const a = applications [ selApp ] ;
const optGroupe = groupes . map ( g = > ` < option value = " $ { echapper(g)} " $ { g == = a . groupe ? ' selected ' : ' ' } > $ { echapper ( g ) } < / option > ` ) . join ( ' ' ) ;
const optHote = ' <option value= " " >—</option> ' + hotes . map ( h = > h . nom ) . filter ( Boolean ) . map ( n = > ` < option value = " $ { echapper(n)} " $ { n == = a . hote ? ' selected ' : ' ' } > $ { echapper ( n ) } < / option > ` ) . join ( ' ' ) ;
const dsns = basesDeApp ( a , a . hote ) ;
const liens = dsns . length
? ` < div class = " ch-bases " > $ { dsns . map ( b = > ` < div class = " ch-base " > < span class = " badge-bd " > $ { echapper ( b . portee | | ' groupe ' ) } < / span > < span class = " mono " > $ { echapper ( dsnBase ( b ) ) } < / span > < / div > ` ) . join ( ' ' ) } < / div > `
: ' <div class= " ch-stub " >aucune base liée</div> ' ;
cible . innerHTML = ` < article class = " detail " >
< div class = " detail-tete " > < div class = " detail-nom " > < span class = " detail-nom-txt " > $ { echapper ( a . id | | ' (sans id) ' ) } < / span > < / div >
< span class = " grandit " > < / span >
< button type = " button " class = " danger " title = " Retirer cette application " onclick = " retirerApplication() " > ✕ < / button > < / div >
< div class = " grille " >
< label class = " champ " > < span > Identifiant < / span > < input value = " $ { echapper(a.id)} " placeholder = " boutique " oninput = " definirApplication( ' id ' , this.value) " > < / label >
< label class = " champ " > < span > Groupe ( rôle ) < / span > < select onchange = " definirApplication( ' groupe ' , this.value) " > $ { optGroupe } < / select > < / label >
< label class = " champ " > < span > Hôte ( VM ) < / span > < select onchange = " definirApplication( ' hote ' , this.value) " > $ { optHote } < / select > < / label >
< label class = " champ " > < span > Port < / span > < input type = " number " value = " $ { echapper(a.port)} " placeholder = " 3000 " oninput = " definirApplication( ' port ' , this.value) " > < / label >
< label class = " champ " style = " grid-column:1/-1 " > < span > Requiert ( applis ) < / span > < input value = " $ { echapper(a.requiert)} " placeholder = " pg-principal, … " oninput = " definirApplication( ' requiert ' , this.value) " > < / label >
< label class = " champ " style = " grid-column:1/-1 " > < span > Expose ( FQDN ) < / span > < input value = " $ { echapper(a.expose)} " placeholder = " forge.exemple.ca " oninput = " definirApplication( ' expose ' , this.value) " > < / label >
2026-06-24 20:17:46 -04:00
< / div >
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
< div class = " section-tete " style = " margin:12px 2px 4px " > Bases consommées < / div >
$ { liens }
2026-07-01 15:58:32 -04:00
$ { ( ( ) = > {
const hA = hotes . find ( x = > x . nom == = a . hote ) ;
if ( hA & & hA . etat == = ' actif ' ) {
const hiA = hotes . indexOf ( hA ) ;
2026-07-04 14:40:02 -04:00
return ` < div class = " pied " > < span class = " hint-dep " > Déployer applique les rôles sur l ' hôte porteur « $ { echapper(a.hote)} ».</span><span class= " grandit " ></span>$ { estProduction ? ' < span class = " badge-prod " > PROD < / span > ' : ' ' }<button type= " button " class= " $ { estProduction ? ' danger ' : ' primaire ' } " onclick= " lancer( ' pousser ' , $ {hiA} ) " title= " Déployer l ' hôte porteur ( rend l ' application vivante) " >Déployer</button></div>`;
2026-07-01 15:58:32 -04:00
}
return ` < div class = " pied " > < span class = " hint-dep " > $ { a . hote ? ` Active + pousse d ' abord l ' hôte « $ { echapper ( a . hote ) } » . ` : ' Aucun hôte assigné. ' } < / span > < / div > ` ;
} ) ( ) }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
< / article > ` ;
2026-06-24 20:17:46 -04:00
}
function listeDepuisTexte ( v ) { return String ( v | | ' ' ) . split ( ' , ' ) . map ( s = > s . trim ( ) ) . filter ( Boolean ) ; }
async function sauvegarderApplications ( ) {
const apps = { } ;
applications . forEach ( a = > {
if ( ! String ( a . id ) . trim ( ) ) return ;
const o = { groupe : a . groupe , hote : a . hote } ;
if ( String ( a . port ) . trim ( ) ) o . port = parseInt ( a . port , 10 ) | | a . port ;
const req = listeDepuisTexte ( a . requiert ) ; if ( req . length ) o . requiert = req ;
const exp = listeDepuisTexte ( a . expose ) ; if ( exp . length ) o . expose = exp ;
apps [ a . id ] = o ;
} ) ;
const rep = await fetch ( ' /api/applications ' , {
method : ' POST ' , headers : { ' Content-Type ' : ' application/json ' , ' X-Jeton ' : JETON } , body : JSON . stringify ( { applications : apps } )
} ) ;
const data = await rep . json ( ) ;
if ( ! rep . ok ) { message ( data . erreur | | ' Sauvegarde refusée. ' , ' erreur ' ) ; return ; }
chargerApplications ( data ) ;
dessinerApplications ( ) ;
message ( ' Applications sauvegardées. ' , ' ok ' ) ;
}
function chargerServeurs ( data ) {
serveurs = ( data . serveurs | | [ ] ) . map ( s = > ( { . . . s , integrations : ( s . integrations | | [ ] ) . join ( ' , ' ) } ) ) ;
serveursModifie = false ;
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
function marquerServeursModifie ( ) { serveursModifie = true ; majIndicateurSauvegarde ( ) ; }
function choisirServeur ( i ) { selServeur = i ; dessiner ( ) ; }
function definirServeur ( champ , v ) {
const s = serveurs [ selServeur ] ; if ( ! s ) return ;
s [ champ ] = v ; marquerServeursModifie ( ) ;
if ( champ == = ' nom ' ) { const t = document . querySelector ( ' #tuile-srv- ' + selServeur + ' .carte-nom ' ) ; if ( t ) { t . textContent = v | | ' (sans nom) ' ; t . className = ' carte-nom ' + ( v ? ' ' : ' sans ' ) ; } }
else if ( champ == = ' fonction ' | | champ == = ' etat ' ) dessiner ( ) ;
2026-06-24 20:17:46 -04:00
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
function basculerIntegration ( role , actif ) {
const s = serveurs [ selServeur ] ; if ( ! s ) return ;
const set = new Set ( listeDepuisTexte ( s . integrations ) ) ;
2026-06-30 12:36:51 -04:00
if ( actif ) set . add ( role ) ; else set . delete ( role ) ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
s . integrations = Array . from ( set ) . join ( ' , ' ) ; marquerServeursModifie ( ) ;
}
function ajouterServeur ( ) {
serveurs . unshift ( { nom : ' ' , fonction : ( Object . keys ( nomenclature . fonctions | | { } ) [ 0 ] | | ' ' ) , etat : ' planifie ' , noeud : ' ' , stockage : ' ' , disque : ' ' , memoire : ' ' , coeurs : ' ' , integrations : ' ' , statut : ' absent ' , vmid : ' ' , adresse_ip : ' ' , vlan : ' ' } ) ;
selServeur = 0 ; marquerServeursModifie ( ) ; dessiner ( ) ;
const c = document . querySelector ( ' #detail .grille input ' ) ; if ( c ) c . focus ( ) ;
}
function retirerServeur ( ) {
const s = serveurs [ selServeur ] ; if ( ! s ) return ;
if ( s . nom & & ! confirm ( ' Retirer le serveur « ' + s . nom + ' » du plan ? ' ) ) return ;
serveurs . splice ( selServeur , 1 ) ;
selServeur = serveurs . length ? Math . min ( selServeur , serveurs . length - 1 ) : null ;
marquerServeursModifie ( ) ; dessiner ( ) ;
}
function ouvrirApp ( id ) { const i = applications . findIndex ( a = > a . id == = id ) ; selApp = i > = 0 ? i : null ; setVue ( ' applications ' ) ; }
function ouvrirBaseServeur ( nom ) { const i = bdServeurs . findIndex ( x = > x . nom == = nom ) ; selBase = i > = 0 ? ' srv: ' + i : null ; setVue ( ' bases ' ) ; }
2026-07-01 17:39:44 -04:00
function pointVie ( nom ) {
const v = sondes [ nom ] ;
return v == = true ? ' <span class= " pt actif " title= " VM joignable (SSH) " ></span> '
: v == = false ? ' <span class= " pt bloq " title= " Injoignable " ></span> ' : ' ' ;
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
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 >
2026-07-01 17:39:44 -04:00
< div class = " carte-ip " > $ { pointVie ( s . nom ) } $ { echapper ( s . adresse_ip | | ' — ' ) } < / div >
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
$ { recon ? ` < div class = " carte-grps " > $ { recon } < / div > ` : ' ' }
< / button > ` ;
2026-06-30 12:36:51 -04:00
}
2026-06-24 20:17:46 -04:00
function dessinerServeurs ( ) {
const cible = document . getElementById ( ' grilles ' ) ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
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 > ` ;
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 ( ' ' ) ;
}
function inspecteurServeur ( ) {
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> ' ;
return ;
}
const s = serveurs [ selServeur ] ;
2026-06-30 12:36:51 -04:00
const vi = ( intrants & & intrants . valeurs ) | | { } ;
const libDef = ( d ) = > ' (défaut ' + ( d ? ' : ' + d : ' ' ) + ' ) ' ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
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
? tousRoles . map ( role = > ` < label class = " integ-case " title = " $ { echapper(role)} " > < input type = " checkbox " $ { set . has ( role ) ? ' checked ' : ' ' } onchange = " basculerIntegration( ' $ { echapper(role)} ' , this.checked) " > < span > $ { echapper ( role . replace ( / ^ client_ / , ' ' ) ) } < / span > < / label > ` ) . join ( ' ' )
: ' <span class= " dsn " >aucun rôle client_* disponible</span> ' ;
const info = infoFonction ( s . fonction ) ;
const zoneHint = info ? ` < span class = " zone-hint " title = " Dérivé de la nomenclature " > $ { echapper ( info . libelle ) } · VLAN $ { info . vlan } < / span > ` : ' ' ;
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-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-stub " >aucun serveur de base ici</div> ' ;
hoteOpCourant = s . nom ;
2026-07-01 15:58:32 -04:00
const hi = h ? hotes . indexOf ( h ) : - 1 ;
let boutons = ' ' ;
2026-07-04 14:40:02 -04:00
if ( h ) boutons + = ` < button type = " button " title = " Cloner la VM depuis le golden template (Proxmox) " onclick = " lancer( ' creer ' , $ {hi} ) " > 🖥 Créer la VM < / button > ` ;
2026-06-30 16:20:18 -04:00
if ( h & & h . etat == = ' actif ' & & ! hoteBloque ( h ) ) {
const clsDep = estProduction ? ' danger ' : ' primaire ' ;
2026-07-01 15:58:32 -04:00
boutons + = ` < button type = " button " id = " btn-verifier " onclick = " lancer( ' verifier ' , $ {hi} ) " > Vérifier < / button > `
+ ` < button type = " button " id = " btn-deployer " class = " $ {clsDep} " 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 > ` ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
} else {
2026-07-01 15:58:32 -04:00
pied = ` < div class = " pied " > < span class = " hint-dep " > Applique le plan pour instancier cet hôte ( ⚙ Appliquer le plan ) < / span > < / div > ` ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
}
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> ' ;
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 >
2026-07-01 17:39:44 -04:00
< 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 >
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
< div class = " section-tete " style = " margin:4px 2px 2px " > Identité — saisie du plan ( surcharge les défauts ) < / div >
< div class = " grille " >
< label class = " champ " > < span > Serveur ( fonction - NN ) < / span > < input data - v = " nom " value = " $ { echapper(s.nom)} " placeholder = " web-frontal-03 " oninput = " definirServeur( ' nom ' , this.value) " > < / label >
< label class = " champ " > < span > Fonction < / span > < select onchange = " definirServeur( ' fonction ' , this.value) " > $ { optFonction } < / select > < / label >
< label class = " champ " > < span > État < / span > < select onchange = " definirServeur( ' etat ' , this.value) " > $ { optEtat } < / select > < / label >
< label class = " champ " > < span > Nœud < / span > < select onchange = " definirServeur( ' noeud ' , this.value) " > $ { optDeListe ( vi . proxmox_noeuds , s . noeud , libDef ( vi . proxmox_clone_noeud ) ) } < / select > < / label >
< label class = " champ " > < span > Stockage < / span > < select onchange = " definirServeur( ' stockage ' , this.value) " > $ { optDeListe ( vi . proxmox_stockages , s . stockage , libDef ( vi . proxmox_clone_stockage ) ) } < / select > < / label >
< label class = " champ " > < span > Disque < / span > < input data - v = " disque " value = " $ { echapper(s.disque)} " placeholder = " 16G " oninput = " definirServeur( ' disque ' , this.value) " > < / label >
< label class = " champ " > < span > Mémoire < / span > < input data - v = " memoire " type = " number " value = " $ { echapper(s.memoire)} " placeholder = " 2048 " oninput = " definirServeur( ' memoire ' , this.value) " > < / label >
< label class = " champ " > < span > Cœurs < / span > < input data - v = " coeurs " type = " number " value = " $ { echapper(s.coeurs)} " placeholder = " 2 " oninput = " definirServeur( ' coeurs ' , this.value) " > < / label >
< label class = " champ " style = " grid-column:1/-1 " > < span > Intégrations ( rôles client ) < / span > < div class = " integ-cases " > $ { cases } < / div > < / 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 }
< / article > ` ;
majBoutonsDeploiement ( ) ;
2026-06-24 20:17:46 -04:00
}
2026-06-30 12:19:26 -04:00
function serveurValeurValide ( cle , v ) {
const s = String ( v == null ? ' ' : v ) . trim ( ) ;
if ( s == = ' ' ) return true ; / / vide = dérivé / défaut , autorisé
if ( cle == = ' nom ' ) return / ^ [ a - z0 - 9 ] + ( - [ a - z0 - 9 ] + ) * - \d + $ / . test ( s ) ;
if ( cle == = ' memoire ' ) return / ^ \d + $ / . test ( s ) & & + s > = 64 & & + s < = 1048576 ;
if ( cle == = ' coeurs ' ) return / ^ \d + $ / . test ( s ) & & + s > = 1 & & + s < = 256 ;
if ( cle == = ' disque ' ) return / ^ \d + \s * [ GMgm ] ? $ / . test ( s ) ;
return true ;
}
function validerChampServeur ( el ) {
const ok = serveurValeurValide ( el . getAttribute ( ' data-v ' ) , el . value ) ;
el . classList . toggle ( ' invalide ' , ! ok ) ;
return ok ;
}
2026-06-24 20:17:46 -04:00
async function sauvegarderServeurs ( ) {
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
const invalides = Array . from ( document . querySelectorAll ( ' #detail [data-v] ' ) ) . filter ( el = > ! validerChampServeur ( el ) ) ;
2026-06-30 12:19:26 -04:00
if ( invalides . length ) {
message ( ' Corrige les ' + invalides . length + ' champ(s) en rouge avant de sauvegarder. ' , ' erreur ' ) ;
invalides [ 0 ] . focus ( ) ;
return ;
}
2026-06-24 20:17:46 -04:00
const srv = { } ;
serveurs . forEach ( s = > {
if ( ! String ( s . nom ) . trim ( ) ) return ;
const o = { fonction : s . fonction , etat : s . etat | | ' planifie ' } ;
[ ' noeud ' , ' stockage ' , ' disque ' ] . forEach ( k = > { if ( String ( s [ k ] | | ' ' ) . trim ( ) ) o [ k ] = s [ k ] ; } ) ;
[ ' memoire ' , ' coeurs ' ] . forEach ( k = > { if ( String ( s [ k ] | | ' ' ) . trim ( ) ) o [ k ] = parseInt ( s [ k ] , 10 ) | | s [ k ] ; } ) ;
const integ = listeDepuisTexte ( s . integrations ) ; if ( integ . length ) o . integrations = integ ;
srv [ s . nom ] = o ;
} ) ;
const rep = await fetch ( ' /api/serveurs ' , {
method : ' POST ' , headers : { ' Content-Type ' : ' application/json ' , ' X-Jeton ' : JETON } , body : JSON . stringify ( { serveurs : srv } )
} ) ;
const data = await rep . json ( ) ;
if ( ! rep . ok ) { message ( data . erreur | | ' Sauvegarde refusée. ' , ' erreur ' ) ; return ; }
chargerServeurs ( data ) ; dessinerServeurs ( ) ;
message ( ' Serveurs (plan) sauvegardés. Pense à « Appliquer le plan ». ' , ' ok ' ) ;
}
async function appliquerPlan ( ) {
2026-06-30 12:19:26 -04:00
const t = message ( ' Application du plan… ' , ' ' , { persistant : true } ) ;
try {
const rep = await fetch ( ' /api/instancier ' , { method : ' POST ' , headers : { ' Content-Type ' : ' application/json ' , ' X-Jeton ' : JETON } , body : ' {} ' } ) ;
const data = await rep . json ( ) ;
const derniere = ( data . sortie | | ' ' ) . split ( ' \n ' ) . pop ( ) ;
majToast ( t , ( data . ok ? ' Plan appliqué : ' : ' Échec : ' ) + derniere , data . ok ? ' ok ' : ' erreur ' , data . ok ? 4000 : 8000 ) ;
if ( data . ok ) charger ( ) ;
} catch ( e ) {
majToast ( t , ' Erreur réseau : ' + e , ' erreur ' , 8000 ) ;
}
2026-06-24 20:17:46 -04:00
}
function dessinerDependances ( ) {
const cible = document . getElementById ( ' dependances ' ) ;
const noms = Object . keys ( dependances ) . sort ( ) ;
const bloques = noms . filter ( g = > statutDependance ( g ) == = ' bloque ' ) . length ;
const resume = document . getElementById ( ' dep-resume ' ) ;
resume . textContent = bloques ? bloques + ' bloqués ' : noms . length + ' suivis ' ;
if ( ! noms . length ) { cible . innerHTML = ' <div class= " vide " style= " padding:14px " >Aucune dépendance.</div> ' ; return ; }
cible . innerHTML = noms . map ( groupe = > {
const config = dependances [ groupe ] | | { } ;
const requis = config . requiert_groupes_actifs | | [ ] ;
const statut = statutDependance ( groupe ) ;
const manquants = manquantsPourGroupe ( groupe ) ;
const detail = requis . length ? ` requiert $ { requis . join ( ' , ' ) } ` : ' aucun prérequis ' ;
const manque = requis . length & & manquants . length ? ` manquant : $ { manquants . join ( ' , ' ) } ` : ' ' ;
return `
< div class = " carte-dep $ {statut} " title = " $ { echapper(config.raison || ' ' )} " >
< div class = " haut " > < span class = " nom " > $ { echapper ( groupe ) } < / span > < span class = " chip " > < span class = " pt $ { statut === ' ok ' ? ' actif ' : statut === ' bloque ' ? ' bloq ' : ' plan ' } " > < / span > $ { libelleStatut ( statut ) } < / span > < / div >
< div class = " txt " > $ { echapper ( detail ) } < / div >
$ { manque ? ` < div class = " txt " > $ { echapper ( manque ) } < / div > ` : ' ' }
< / div > ` ;
} ) . join ( ' ' ) ;
}
function tuile ( valeur , libelle ) {
const v = ( valeur == = ' ' | | valeur == null ) ? ' — ' : String ( valeur ) ;
return ` < div class = " stat " > < div class = " v $ { v === ' — ' ? ' vide ' : ' ' } " > $ { echapper ( v ) } < / div > < div class = " l " > $ { echapper ( libelle ) } < / div > < / div > ` ;
}
function dessinerDetail ( ) {
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
/ / Le panneau de droite reflète la sélection de la VUE COURANTE .
if ( vuePrincipale == = ' applications ' ) return inspecteurApplication ( ) ;
if ( vuePrincipale == = ' bases ' ) return inspecteurBase ( ) ;
return inspecteurServeur ( ) ;
2026-06-24 20:17:46 -04:00
}
function majBoutonsDeploiement ( ) {
const bv = document . getElementById ( ' btn-verifier ' ) ;
const bd = document . getElementById ( ' btn-deployer ' ) ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
const hote = hoteOpCourant | | ' ' ;
2026-06-30 12:19:26 -04:00
if ( bv ) { bv . disabled = occupe ; bv . title = occupe ? ' Exécution en cours ' : ' Dry-run (--check --diff), aucune modification ' ; }
if ( bd ) { bd . disabled = occupe | | ! verifie [ hote ] ; bd . title = ! verifie [ hote ] ? ' Vérifiez (dry-run) d \' abord ' : ' Applique les playbooks sur la VM réelle ' ; }
2026-06-24 20:17:46 -04:00
}
function fonctionDe ( nom ) {
const m = ( nom | | ' ' ) . match ( / ^ ( . + ) - ( \d + ) $ / ) ;
return m ? { fonction : m [ 1 ] , seq : parseInt ( m [ 2 ] , 10 ) } : { fonction : nom | | ' ' , seq : null } ;
}
function infoFonction ( fonction ) {
const d = ( nomenclature . fonctions | | { } ) [ fonction ] ;
if ( ! d ) return null ;
const c = ( nomenclature . categories | | { } ) [ d . categorie ] ;
if ( ! c ) return null ;
return { categorie : d . categorie , service : d . service , libelle : c . libelle , vlan : c . vlan , sous_reseau : c . sous_reseau , passerelle : c . passerelle } ;
}
function echapper ( valeur ) {
return String ( valeur ) . replace ( / [ & < > " ' ]/g, c => ( { ' & ' : ' & ' , ' < ' : ' < ' , ' > ' : ' > ' , ' " ' : ' & quot ; ' , " ' " : ' ' ' }[c]));
}
function ouvrirConsole ( titre , hote ) {
document . getElementById ( ' console-titre ' ) . textContent = titre + ' · ' + hote ;
document . getElementById ( ' console-sortie ' ) . textContent = ' ' ;
const etat = document . getElementById ( ' console-etat ' ) ;
etat . className = ' console-etat actif ' ; etat . textContent = ' ● en cours ' ;
document . getElementById ( ' btn-fermer-console ' ) . disabled = true ;
document . getElementById ( ' console ' ) . hidden = false ;
2026-06-30 12:19:26 -04:00
consoleDebut = Date . now ( ) ;
clearInterval ( chronoConsole ) ;
chronoConsole = setInterval ( ( ) = > {
const s = Math . round ( ( Date . now ( ) - consoleDebut ) / 1000 ) ;
etat . textContent = ' ● en cours · ' + s + ' s ' ;
} , 1000 ) ;
2026-06-24 20:17:46 -04:00
}
function ecrireConsole ( t ) { const p = document . getElementById ( ' console-sortie ' ) ; p . textContent + = t ; p . scrollTop = p . scrollHeight ; }
function finirConsole ( rc ) {
2026-06-30 12:19:26 -04:00
clearInterval ( chronoConsole ) ; chronoConsole = null ;
const s = Math . round ( ( Date . now ( ) - consoleDebut ) / 1000 ) ;
2026-06-24 20:17:46 -04:00
const etat = document . getElementById ( ' console-etat ' ) ;
etat . className = ' console-etat ' + ( rc == = 0 ? ' ok ' : ' echec ' ) ;
2026-06-30 12:19:26 -04:00
etat . textContent = ( rc == = 0 ? ' ✓ succès ' : ' ✗ échec (rc= ' + rc + ' ) ' ) + ' · ' + s + ' s ' ;
2026-06-24 20:17:46 -04:00
document . getElementById ( ' btn-fermer-console ' ) . disabled = false ;
}
function fermerConsole ( ) { if ( ! occupe ) document . getElementById ( ' console ' ) . hidden = true ; }
function ouvrirModale ( titre , texte ) {
document . getElementById ( ' modale-titre ' ) . textContent = titre ;
document . getElementById ( ' modale-texte ' ) . textContent = texte ;
const champ = document . getElementById ( ' modale-vault ' ) ;
champ . value = motVault | | ' ' ;
document . getElementById ( ' modale ' ) . hidden = false ;
setTimeout ( ( ) = > champ . focus ( ) , 0 ) ;
return new Promise ( res = > { modaleResolveur = res ; } ) ;
}
function confirmerModale ( ) {
const v = document . getElementById ( ' modale-vault ' ) . value ;
document . getElementById ( ' modale ' ) . hidden = true ;
const r = modaleResolveur ; modaleResolveur = null ;
if ( r ) r ( v ) ;
}
function annulerModale ( ) {
document . getElementById ( ' modale ' ) . hidden = true ;
const r = modaleResolveur ; modaleResolveur = null ;
if ( r ) r ( null ) ;
}
2026-06-30 10:07:17 -04:00
function ouvrirIntrants ( ) {
if ( ! intrants ) { message ( ' Intrants indisponibles (rechargez). ' , ' erreur ' ) ; return ; }
rendreIntrants ( ) ;
document . getElementById ( ' intrants ' ) . hidden = false ;
}
function fermerIntrants ( ) { document . getElementById ( ' intrants ' ) . hidden = true ; }
function rendreIntrants ( ) {
const corps = document . getElementById ( ' intrants-corps ' ) ;
const schema = intrants . schema | | [ ] ;
const valeurs = intrants . valeurs | | { } ;
const sections = [ ] ;
const parSection = { } ;
schema . forEach ( ch = > {
if ( ! parSection [ ch . section ] ) { parSection [ ch . section ] = [ ] ; sections . push ( ch . section ) ; }
parSection [ ch . section ] . push ( ch ) ;
} ) ;
let html = ' ' ;
sections . forEach ( sec = > {
html + = ' <div class= " intr-section " ><div class= " intr-tete " > ' + echapper ( sec ) + ' </div><div class= " intr-grille " > ' ;
parSection [ sec ] . forEach ( ch = > {
const v = valeurs [ ch . variable ] ;
2026-06-30 12:36:51 -04:00
let badge ;
if ( ch . classe == = ' constante ' ) badge = ' <span class= " badge-classe const " title= " Valeur unique, non surchargeable " >Constante</span> ' ;
else if ( ch . classe == = ' catalogue ' ) badge = ' <span class= " badge-classe cat " title= " Liste de valeurs autorisées, proposée dans les menus déroulants " >Catalogue</span> ' ;
else badge = ' <span class= " badge-classe def " title= " Valeur par défaut, surchargeable dans les instances " >Défaut</span> ' ;
2026-06-30 10:07:17 -04:00
const lib = echapper ( ch . libelle ) + badge ;
if ( ch . type == = ' bool ' ) {
const coche = ( v == = true | | v == = ' true ' | | v == = ' True ' ) ? ' checked ' : ' ' ;
html + = ' <label class= " champ " ><span> ' + lib + ' </span> '
+ ' <input type= " checkbox " data-intrant= " ' + echapper ( ch . variable ) + ' " data-type= " bool " ' + coche + ' ></label> ' ;
2026-06-30 12:36:51 -04:00
} else if ( ch . type == = ' liste ' ) {
const txt = Array . isArray ( v ) ? v . join ( ' , ' ) : ( v == null ? ' ' : v ) ;
html + = ' <label class= " champ " style= " grid-column:1/-1 " ><span> ' + lib + ' <span class= " unite " >· séparés par des virgules</span></span> '
+ ' <input type= " text " data-intrant= " ' + echapper ( ch . variable ) + ' " data-type= " liste " '
+ ' placeholder= " ex. asgard, pve-02, pve-03 " value= " ' + echapper ( txt ) + ' " ></label> ' ;
2026-06-30 10:07:17 -04:00
} else {
const t = ch . type == = ' int ' ? ' number ' : ' text ' ;
html + = ' <label class= " champ " ><span> ' + lib + ' </span> '
+ ' <input type= " ' + t + ' " data-intrant= " ' + echapper ( ch . variable ) + ' " data-type= " ' + echapper ( ch . type ) + ' " '
+ ' value= " ' + echapper ( v == null ? ' ' : v ) + ' " ></label> ' ;
}
} ) ;
html + = ' </div></div> ' ;
} ) ;
/ / Nomenclature en lecture seule ( lue ailleurs : vue Nomenclature )
const n = nomenclature | | { } ;
if ( Object . keys ( n ) . length ) {
html + = ' <div class= " intr-section " ><div class= " intr-tete " >Nomenclature (lecture seule)</div> '
+ ' <div class= " intr-ro " > ' ;
if ( n . supernet ) html + = ' Supernet : <b> ' + echapper ( n . supernet ) + ' </b><br> ' ;
if ( n . domaine | | n . domaine_interne ) html + = ' Domaine : <b> ' + echapper ( n . domaine | | n . domaine_interne ) + ' </b><br> ' ;
const nbCat = n . categories ? Object . keys ( n . categories ) . length : 0 ;
const nbFon = n . fonctions ? Object . keys ( n . fonctions ) . length : 0 ;
html + = nbCat + ' catégorie(s), ' + nbFon + ' fonction(s) déclarée(s). ' ;
html + = ' <br><span style= " font-style:italic " >Modifiable dans le plan (nomenclature.yml), pas ici.</span> ' ;
html + = ' </div></div> ' ;
}
/ / Secrets attendus : informatif , jamais saisis ici
const secrets = intrants . secrets_attendus | | [ ] ;
if ( secrets . length ) {
html + = ' <div class= " intr-section " ><div class= " intr-tete " >Secrets attendus (Vault — CLI)</div> '
+ ' <div class= " intr-ro " >Ces clés ne sont jamais saisies ni stockées ici. Édite-les via <b>ansible-vault</b>. '
+ ' <div class= " intr-secrets " > '
+ secrets . map ( s = > ' <code> ' + echapper ( s ) + ' </code> ' ) . join ( ' ' )
+ ' </div></div></div> ' ;
}
const f = intrants . fichiers | | { } ;
const chemins = Object . values ( f ) . map ( x = > echapper ( x ) ) . join ( ' , ' ) ;
if ( chemins ) html + = ' <div class= " intr-ro " style= " margin-top:2px " >Écrit dans : <b> ' + chemins + ' </b></div> ' ;
corps . innerHTML = html ;
}
async function enregistrerIntrants ( ) {
const valeurs = { } ;
document . querySelectorAll ( ' #intrants-corps [data-intrant] ' ) . forEach ( el = > {
const k = el . getAttribute ( ' data-intrant ' ) ;
valeurs [ k ] = el . type == = ' checkbox ' ? el . checked : el . value ;
} ) ;
const ancienDomaine = ( intrants . valeurs | | { } ) . domaine_interne ;
const nouveauDomaine = valeurs . domaine_interne ;
if ( nouveauDomaine != = undefined & & nouveauDomaine != = ancienDomaine ) {
if ( ! confirm ( ' Changer le domaine interne (« ' + ( ancienDomaine | | ' ∅ ' ) + ' » → « ' + ( nouveauDomaine | | ' ∅ ' )
+ ' ») impacte tout l \' écosystème (DNS, certificats, noms d \' hôtes). Continuer ? ' ) ) return ;
}
try {
const reponse = await fetch ( ' /api/intrants ' , {
method : ' POST ' ,
headers : { ' Content-Type ' : ' application/json ' , ' X-Jeton ' : JETON } ,
body : JSON . stringify ( { valeurs } ) ,
} ) ;
if ( ! reponse . ok ) {
let detail = ' ' ;
try { detail = ( await reponse . json ( ) ) . erreur | | ' ' ; } catch ( e ) { detail = await reponse . text ( ) ; }
message ( ' Échec de l \' enregistrement des intrants : ' + detail , ' erreur ' ) ;
return ;
}
const data = await reponse . json ( ) ;
intrants = data . intrants | | intrants ;
nomenclature = data . nomenclature | | nomenclature ;
fermerIntrants ( ) ;
message ( ' Intrants de base enregistrés. ' , ' ok ' ) ;
} catch ( e ) {
message ( ' Erreur réseau : ' + e , ' erreur ' ) ;
}
}
2026-06-24 20:17:46 -04:00
async function lancer ( mode , index ) {
const hote = hotes [ index ] ? hotes [ index ] . nom : ' ' ;
2026-06-30 12:19:26 -04:00
if ( ! hote ) { message ( ' Hôte introuvable. ' , ' erreur ' ) ; return ; }
2026-06-24 20:17:46 -04:00
if ( occupe ) return ;
let vault = ' ' ;
2026-07-04 14:40:02 -04:00
if ( ( mode == = ' deployer ' | | mode == = ' pousser ' ) & & ! verifie [ hote ] ) { message ( ' Vérifiez (dry-run) d \' abord. ' , ' erreur ' ) ; return ; }
2026-07-01 21:18:10 -04:00
if ( mode == = ' creer ' | | mode == = ' deployer ' | | mode == = ' pousser ' | | mode == = ' verifier ' ) {
2026-07-01 15:58:32 -04:00
if ( estProduction & & ( mode == = ' deployer ' | | mode == = ' pousser ' ) ) {
const saisi = prompt ( ' ⚠️ PRODUCTION — action réelle sur « ' + hote + ' ». \n '
2026-06-30 16:20:18 -04:00
+ ' Tape le nom exact de l \' hôte pour confirmer : ' ) ;
if ( saisi == = null ) return ;
if ( saisi . trim ( ) != = hote ) { message ( ' Confirmation annulée : le nom ne concorde pas. ' , ' erreur ' ) ; return ; }
}
2026-07-01 15:58:32 -04:00
const av = estProduction ? ' ⚠️ PRODUCTION. ' : ' ' ;
const txt = mode == = ' creer '
? ' Clone la VM « ' + hote + ' » depuis le golden template sur Proxmox (création réelle). '
: mode == = ' pousser '
? av + ' Déploie l \' hôte « ' + hote + ' » (applique ses playbooks, en root). Non transactionnel. '
2026-07-01 21:18:10 -04:00
: mode == = ' verifier '
? ' Dry-run (--check), AUCUNE modification. Mot de passe vault pour déchiffrer les secrets — laisse VIDE si cet hôte n \' a pas de secrets. '
2026-07-01 15:58:32 -04:00
: av + ' Applique les playbooks de groupes sur la VM réelle, en root (sudo). Non transactionnel. ' ;
2026-07-04 14:40:02 -04:00
const titre = ( mode == = ' creer ' ? ' Créer la VM — ' : mode == = ' pousser ' ? ' Déployer — ' : mode == = ' verifier ' ? ' Vérifier — ' : ' Déployer — ' ) + hote ;
2026-07-01 15:58:32 -04:00
vault = await ouvrirModale ( titre , txt ) ;
2026-06-24 20:17:46 -04:00
if ( vault == = null ) return ;
motVault = vault ;
}
occupe = true ; majBoutonsDeploiement ( ) ;
2026-07-04 14:40:02 -04:00
const titreConsole = { verifier : ' Vérification (dry-run) ' , deployer : ' Déploiement ' , creer : ' Création VM (clone) ' , pousser : ' Déploiement ' } [ mode ] | | mode ;
2026-07-01 15:58:32 -04:00
ouvrirConsole ( titreConsole , hote ) ;
2026-06-24 20:17:46 -04:00
try {
const rep = await fetch ( ' /api/ ' + mode , {
method : ' POST ' , headers : { ' Content-Type ' : ' application/json ' , ' X-Jeton ' : JETON } ,
body : JSON . stringify ( vault ? { hote , vault } : { hote } )
} ) ;
if ( ! rep . ok | | ! rep . body ) {
const d = await rep . json ( ) . catch ( ( ) = > ( { } ) ) ;
ecrireConsole ( ( d . erreur | | ' Refusé. ' ) + ' \n ' ) ; finirConsole ( 1 ) ;
occupe = false ; majBoutonsDeploiement ( ) ; return ;
}
const lecteur = rep . body . getReader ( ) ;
const dec = new TextDecoder ( ) ;
let rc = null ;
while ( true ) {
const { value , done } = await lecteur . read ( ) ;
if ( done ) break ;
let texte = dec . decode ( value , { stream : true } ) ;
const m = texte . match ( / __FIN__ rc = ( - ? \d + ) / ) ;
if ( m ) { rc = parseInt ( m [ 1 ] , 10 ) ; texte = texte . replace ( / \n ? __FIN__ rc = - ? \d + \n ? / , ' ' ) ; }
if ( texte ) ecrireConsole ( texte ) ;
}
if ( rc == = null ) rc = 1 ;
finirConsole ( rc ) ;
if ( mode == = ' verifier ' & & rc == = 0 ) { verifie [ hote ] = true ; message ( ' Vérification réussie — déploiement débloqué pour ' + hote + ' . ' , ' ok ' ) ; }
2026-07-01 17:52:39 -04:00
else if ( mode == = ' creer ' ) { message ( rc == = 0 ? ' VM « ' + hote + ' » créée — hôte marqué actif. ' : ' Échec de la création. ' , rc == = 0 ? ' ok ' : ' erreur ' ) ; if ( rc == = 0 ) appliquerPlan ( ) ; else sonder ( ) ; }
2026-07-04 14:40:02 -04:00
else if ( mode == = ' deployer ' | | mode == = ' pousser ' ) { verifie [ hote ] = false ; message ( rc == = 0 ? ' Déploiement terminé — hôte marqué actif. ' : ' Déploiement en échec. ' , rc == = 0 ? ' ok ' : ' erreur ' ) ; if ( rc == = 0 ) appliquerPlan ( ) ; else sonder ( ) ; }
2026-06-24 20:17:46 -04:00
} catch ( e ) {
ecrireConsole ( ' Erreur client : ' + e . message + ' \n ' ) ; finirConsole ( 1 ) ;
}
occupe = false ; majBoutonsDeploiement ( ) ;
}
2026-06-30 12:19:26 -04:00
function sauvegarderVue ( ) {
if ( occupe ) return ;
if ( vuePrincipale == = ' serveurs ' ) sauvegarderServeurs ( ) ;
else if ( vuePrincipale == = ' applications ' ) sauvegarderApplications ( ) ;
else if ( vuePrincipale == = ' bases ' ) sauvegarderBases ( ) ;
else message ( ' Vue en lecture seule — édite dans Serveurs / Applications / Bases. ' ) ;
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
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 ) ;
2026-06-30 12:19:26 -04:00
}
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
function naviguerServeurs ( delta ) {
const ordre = ordreServeursVisibles ( ) ;
2026-06-30 12:19:26 -04:00
if ( ! ordre . length ) return ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
let pos = ordre . indexOf ( selServeur ) ;
2026-06-30 12:19:26 -04:00
pos = pos < 0 ? ( delta > 0 ? 0 : ordre . length - 1 )
: Math . min ( ordre . length - 1 , Math . max ( 0 , pos + delta ) ) ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
choisirServeur ( ordre [ pos ] ) ;
const c = document . getElementById ( ' tuile-srv- ' + ordre [ pos ] ) ;
2026-06-30 12:19:26 -04:00
if ( c ) c . scrollIntoView ( { block : ' nearest ' , behavior : ' smooth ' } ) ;
2026-06-24 20:17:46 -04:00
}
document . addEventListener ( ' keydown ' , e = > {
if ( e . key == = ' Escape ' & & modaleResolveur ) { annulerModale ( ) ; return ; }
2026-06-30 12:19:26 -04:00
if ( ( e . ctrlKey | | e . metaKey ) & & ( e . key == = ' s ' | | e . key == = ' S ' ) ) { e . preventDefault ( ) ; sauvegarderVue ( ) ; return ; }
const dansSaisie = / ^ ( INPUT | SELECT | TEXTAREA ) $ / . test ( ( document . activeElement | | { } ) . tagName | | ' ' ) ;
if ( dansSaisie | | e . ctrlKey | | e . metaKey | | e . altKey ) return ;
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
const vues = { ' 1 ' : ' serveurs ' , ' 2 ' : ' applications ' , ' 3 ' : ' bases ' } ;
2026-06-30 12:19:26 -04:00
if ( vues [ e . key ] ) { setVue ( vues [ e . key ] ) ; return ; }
if ( e . key == = ' / ' ) { e . preventDefault ( ) ; const f = document . getElementById ( ' filtre ' ) ; if ( f ) f . focus ( ) ; return ; }
GUI : atelier maître-détail unifié + fusion Serveur/Hôte
Toutes les vues adoptent le même motif que l'Inventaire : tuiles à
gauche, détail + saisie à droite, panneau droit contextuel (fin du
panneau figé au changement de vue). Applications et Bases passent en
maître-détail ; liens cliquables entre objets.
Les vues Inventaire (hôtes) et Serveurs (plan) faisaient doublon : elles
sont fusionnées en une vue Serveurs unique — tuile avec statut de
réconciliation, détail réunissant identité éditable, dérivés, groupes,
applications/bases hébergées et Vérifier/Déployer. Navigation clavier et
filtre rebranchés sur les serveurs ; bandeau « Comment lire ce parc ».
Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection
d'hôte, chips de filtre).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:22:11 -04:00
if ( vuePrincipale != = ' serveurs ' ) return ;
if ( e . key == = ' j ' ) { naviguerServeurs ( 1 ) ; return ; }
if ( e . key == = ' k ' ) { naviguerServeurs ( - 1 ) ; return ; }
if ( e . key == = ' v ' ) { const b = document . getElementById ( ' btn-verifier ' ) ; if ( b & & ! b . disabled ) b . click ( ) ; return ; }
if ( e . key == = ' d ' ) { const b = document . getElementById ( ' btn-deployer ' ) ; if ( b & & ! b . disabled ) b . click ( ) ; return ; }
2026-06-30 12:19:26 -04:00
} ) ;
/ / Validation inline des champs du plan ( vue Serveurs ) — liseré rouge avant l ' envoi.
document . addEventListener ( ' input ' , e = > {
if ( e . target & & e . target . matches & & e . target . matches ( ' [data-v] ' ) ) validerChampServeur ( e . target ) ;
} ) ;
/ / Garde - fou : prévenir la perte d ' éditions de plan non sauvegardées.
window . addEventListener ( ' beforeunload ' , e = > {
if ( serveursModifie | | applicationsModifie | | basesModifie ) { e . preventDefault ( ) ; e . returnValue = ' ' ; }
2026-06-24 20:17:46 -04:00
} ) ;
2026-06-30 10:07:17 -04:00
/ / - - - - - - Info - bulles d ' aide au survol de la description d ' un champ - - - - - -
( function ( ) {
const AIDES = {
' Adresse IP ' : " Adresse IPv4 de la VM sur son VLAN. Ex. 10.10.12.11. Souvent dérivée — clic « Proposer ». " ,
' CIDR ' : " Masque réseau en bits. 24 = /24 (255.255.255.0). " ,
' Passerelle ' : " Routeur de sortie du VLAN. En général le .1 du sous-réseau. " ,
' VLAN ' : " Identifiant 802.1Q (1– 4094). Dérivé de la fonction via la nomenclature. " ,
' Pont ' : " Pont réseau Proxmox. Ex. vmbr0. " ,
' DNS ' : " Résolveur DNS de la VM. En général le DNS interne de l ' écosystème. " ,
' Utilisateur Ansible ' : " Compte technique utilisé par Ansible. Par défaut : ansible. " ,
' VMID ' : " Identifiant unique Proxmox. Dérivé (catégorie + service + rang). " ,
' Nœud ' : " Hôte Proxmox cible. Ex. pve-01. " ,
' Stockage ' : " Pool de stockage Proxmox du disque. Ex. local-zfs. " ,
' Disque ' : " Taille du disque système. Ex. 32G. Dérivée des logiciels hébergés + socle. " ,
' Mémoire ' : " RAM en Mo. Dérivée des logiciels hébergés + socle (avec marge). " ,
' Cœurs ' : " vCPU alloués. Dérivés des logiciels hébergés (maximum, pas additif). " ,
' Nom ' : " Identifiant unique du serveur de base, sans espace. " ,
' Type ' : " Moteur de la base. Ex. postgres, redis. " ,
' Hôte ' : " VM qui héberge ce service (nom du serveur). " ,
' Hôte (VM) ' : " VM cible où l ' application est déployée. " ,
' Port ' : " Port d ' écoute du service. " ,
' Groupe Ansible ' : " Groupe/rôle Set-OPS appliqué. Ex. serveur_postgresql. " ,
' Groupe (rôle) ' : " Rôle Set-OPS appliqué à l ' application. " ,
' Identifiant ' : " Clé courte et unique, sans espace. " ,
' Portée ' : " À qui la base est offerte : groupe, hôte ou application. " ,
' Consommateur ' : " L ' entité (groupe / hôte / application) qui utilise la base. " ,
' Usage ' : " Étiquette libre. Ex. principale. " ,
' Serveur ' : " Serveur de base de données qui héberge cette base. " ,
' Base ' : " Nom de la base de données. " ,
' Propriétaire ' : " Rôle SQL propriétaire de la base. " ,
' Secret (Vault) ' : " Nom de la variable Ansible Vault contenant le mot de passe. " ,
' Requiert (applis) ' : " Applications prérequises, séparées par des virgules. " ,
' Expose (FQDN) ' : " Nom(s) public(s) exposé(s). Ex. forge.exemple.ca. " ,
' Serveur (fonction-NN) ' : " Nom = fonction + rang. Ex. web-frontal-03. " ,
} ;
const bulle = document . createElement ( ' div ' ) ;
bulle . id = ' infobulle ' ;
bulle . setAttribute ( ' role ' , ' tooltip ' ) ;
document . body . appendChild ( bulle ) ;
function libelleDe ( el ) {
const n = el . firstChild ;
return ( ( n & & n . nodeType == = 3 ? n . textContent : el . textContent ) | | ' ' ) . trim ( ) ;
}
function aideDe ( el ) {
if ( ! el | | ! el . matches | | ! el . matches ( ' .champ > span ' ) ) return null ;
return AIDES [ libelleDe ( el ) ] | | null ;
}
function placer ( el ) {
const r = el . getBoundingClientRect ( ) ;
bulle . style . left = Math . max ( 8 , Math . min ( r . left , innerWidth - bulle . offsetWidth - 8 ) ) + ' px ' ;
let haut = r . top - bulle . offsetHeight - 8 ;
if ( haut < 8 ) haut = r . bottom + 8 ; / / bascule sous le champ si pas la place
bulle . style . top = haut + ' px ' ;
}
document . addEventListener ( ' mouseover ' , e = > {
const aide = aideDe ( e . target ) ;
if ( ! aide ) return ;
bulle . textContent = aide ;
bulle . classList . add ( ' on ' ) ;
placer ( e . target ) ;
} ) ;
document . addEventListener ( ' mouseout ' , e = > {
if ( e . target & & e . target . matches & & e . target . matches ( ' .champ > span ' ) ) bulle . classList . remove ( ' on ' ) ;
} ) ;
/ / Affordance : souligné pointillé sur les descriptions qui ont une aide .
function marquer ( racine ) {
if ( ! racine . querySelectorAll ) return ;
racine . querySelectorAll ( ' .champ > span ' ) . forEach ( s = > {
if ( AIDES [ libelleDe ( s ) ] ) s . classList . add ( ' a-aide ' ) ;
} ) ;
}
new MutationObserver ( muts = > muts . forEach ( m = >
m . addedNodes . forEach ( n = > { if ( n . nodeType == = 1 ) marquer ( n ) ; } )
) ) . observe ( document . body , { childList : true , subtree : true } ) ;
marquer ( document ) ;
} ) ( ) ;
2026-06-24 20:17:46 -04:00
charger ( ) . catch ( err = > message ( err . message , ' erreur ' ) ) ;
< / script >
< / body >
< / html >
"""
class Gestionnaire ( BaseHTTPRequestHandler ) :
inventaire : Path = INVENTAIRE_DEFAUT
def log_message ( self , * args ) - > None : # silence par defaut
pass
def repondre ( self , status : int , contenu : bytes , content_type : str ) - > None :
try :
self . send_response ( status )
self . send_header ( " Content-Type " , content_type )
self . send_header ( " Content-Length " , str ( len ( contenu ) ) )
self . end_headers ( )
self . wfile . write ( contenu )
except ( BrokenPipeError , ConnectionResetError ) :
pass # le client a ferme la connexion : rien a faire
def repondre_json ( self , status : int , data : dict ) - > None :
self . repondre ( status , json . dumps ( data , ensure_ascii = False ) . encode ( " utf-8 " ) , " application/json; charset=utf-8 " )
def _ecrire_flux ( self , texte : str ) - > bool :
try :
self . wfile . write ( texte . encode ( " utf-8 " , " replace " ) )
self . wfile . flush ( )
return True
except ( BrokenPipeError , ConnectionResetError ) :
return False
def executer_flux ( self , hote : str , mode : str , vault : str | None = None ) - > None :
if not MOTIF_HOTE . match ( hote or " " ) :
self . repondre_json ( 400 , { " erreur " : " Nom d ' hote invalide. " } )
return
2026-07-01 15:58:32 -04:00
tous_hotes = { h . get ( " nom " ) for h in liste_hotes ( charger_yaml ( self . inventaire ) ) }
if hote not in tous_hotes :
self . repondre_json ( 409 , { " erreur " : f " { hote } est inconnu de l ' inventaire (applique le plan d ' abord). " } )
return
# 'creer' (clone) est permis meme sur un hote planifie ; les autres exigent actif.
if mode in ( " verifier " , " deployer " , " pousser " ) and hote not in hotes_actifs_fichier ( self . inventaire ) :
2026-06-24 20:17:46 -04:00
self . repondre_json ( 409 , { " erreur " : f " { hote } n ' est pas un hote actif sauvegarde. " } )
return
if mode == " deployer " and not VERIF_OK . get ( hote ) :
self . repondre_json ( 409 , { " erreur " : " Verification (dry-run) requise avant de deployer. " } )
return
if not VERROU . acquire ( blocking = False ) :
self . repondre_json ( 409 , { " erreur " : " Une execution est deja en cours. " } )
return
fichier_vault = None
try :
2026-07-01 15:58:32 -04:00
cible = { " deployer " : " deployer " , " pousser " : " deployer " ,
" creer " : " creer-vm " } . get ( mode , " verifier-deploiement " )
2026-06-24 20:17:46 -04:00
env = dict ( os . environ , PYTHONUNBUFFERED = " 1 " , ANSIBLE_FORCE_COLOR = " 0 " )
if vault :
fd , fichier_vault = tempfile . mkstemp ( prefix = " setops-vault- " ) # 0600 par defaut
os . write ( fd , str ( vault ) . encode ( " utf-8 " ) )
os . close ( fd )
env [ " ANSIBLE_VAULT_PASSWORD_FILE " ] = fichier_vault
self . send_response ( 200 )
self . send_header ( " Content-Type " , " text/plain; charset=utf-8 " )
self . send_header ( " Cache-Control " , " no-cache " )
self . end_headers ( )
self . _ecrire_flux ( f " $ make { cible } HOTE= { hote } \n \n " )
proc = subprocess . Popen (
[ " make " , cible , f " HOTE= { hote } " ] ,
cwd = str ( RACINE ) , env = env ,
stdin = subprocess . DEVNULL ,
stdout = subprocess . PIPE , stderr = subprocess . STDOUT , text = True , bufsize = 1 ,
)
try :
for ligne in proc . stdout :
if not self . _ecrire_flux ( ligne ) :
proc . terminate ( )
break
finally :
rc = proc . wait ( )
if mode == " verifier " and rc == 0 :
VERIF_OK [ hote ] = True
2026-07-01 15:58:32 -04:00
elif mode in ( " deployer " , " pousser " ) :
2026-06-24 20:17:46 -04:00
VERIF_OK . pop ( hote , None )
2026-07-01 17:39:44 -04:00
# Auto-actif : un hote qu'on vient de materialiser (clone reussi) ou de
# deployer passe 'actif' dans le plan. Reflet fidele : materialise = actif.
if rc == 0 and mode in ( " creer " , " deployer " , " pousser " ) :
try :
marquer_serveur_actif ( hote )
except Exception :
pass
2026-06-24 20:17:46 -04:00
self . _ecrire_flux ( f " \n __FIN__ rc= { rc } \n " )
finally :
if fichier_vault :
try :
os . remove ( fichier_vault )
except OSError :
pass
VERROU . release ( )
def do_GET ( self ) - > None :
chemin = urlparse ( self . path ) . path
try :
if chemin == " / " :
page = HTML . replace ( " __JETON__ " , JETON )
self . repondre ( 200 , page . encode ( " utf-8 " ) , " text/html; charset=utf-8 " )
elif chemin == " /favicon.ico " :
self . repondre ( 204 , b " " , " image/x-icon " )
elif chemin == " /api/inventaire " :
self . repondre_json ( 200 , inventaire_api ( self . inventaire ) )
2026-07-01 17:39:44 -04:00
elif chemin == " /api/sondes " :
self . repondre_json ( 200 , sonder_hotes ( self . inventaire ) )
2026-06-24 20:17:46 -04:00
else :
self . repondre_json ( 404 , { " erreur " : " Introuvable " } )
except Exception as exc :
self . repondre_json ( 500 , { " erreur " : str ( exc ) } )
def do_POST ( self ) - > None :
chemin = urlparse ( self . path ) . path
if self . headers . get ( " X-Jeton " ) != JETON :
self . repondre_json ( 403 , { " erreur " : " Jeton manquant ou invalide. " } )
return
longueur = int ( self . headers . get ( " Content-Length " , " 0 " ) or 0 )
corps = self . rfile . read ( longueur ) . decode ( " utf-8 " ) if longueur else " "
try :
donnees = json . loads ( corps ) if corps else { }
except json . JSONDecodeError :
self . repondre_json ( 400 , { " erreur " : " Corps JSON invalide. " } )
return
try :
if chemin == " /api/inventaire " :
self . repondre_json ( 409 , { " erreur " : " hosts.yml est genere depuis le plan. "
" Edite instance/plan/serveurs.yml et instance/plan/applications.yml "
" (vues Serveurs / Applications), puis « Appliquer le plan ». " } )
elif chemin == " /api/bases " :
valider_bases ( donnees , charger_applications ( FICHIER_APPLICATIONS ) )
ecrire_bases ( FICHIER_BASES , donnees )
self . repondre_json ( 200 , inventaire_api ( self . inventaire ) )
elif chemin == " /api/applications " :
valider_applications ( donnees , charger_domaines ( FICHIER_DOMAINES ) )
ecrire_applications ( FICHIER_APPLICATIONS , donnees )
self . repondre_json ( 200 , inventaire_api ( self . inventaire ) )
elif chemin == " /api/serveurs " :
valider_serveurs ( donnees , charger_nomenclature ( FICHIER_NOMENCLATURE ) )
ecrire_serveurs ( FICHIER_SERVEURS , donnees )
self . repondre_json ( 200 , inventaire_api ( self . inventaire ) )
2026-06-30 10:07:17 -04:00
elif chemin == " /api/intrants " :
ecrire_intrants ( donnees )
self . repondre_json ( 200 , inventaire_api ( self . inventaire ) )
2026-06-24 20:17:46 -04:00
elif chemin == " /api/instancier " :
2026-07-01 17:52:39 -04:00
# Cliquer « Appliquer le plan » EST l'intention explicite : on force
# (le diff-refus est un garde-fou CLI ; ici le clic le confirme). git = filet.
2026-06-24 20:17:46 -04:00
resultat = subprocess . run (
2026-07-01 17:52:39 -04:00
[ " python3 " , str ( RACINE / " scripts/instancier.py " ) , " appliquer " , " --force " ] ,
2026-06-24 20:17:46 -04:00
capture_output = True , text = True , cwd = str ( RACINE ) )
self . repondre_json ( 200 , { " ok " : resultat . returncode == 0 ,
" sortie " : ( resultat . stdout + resultat . stderr ) . strip ( ) } )
elif chemin == " /api/verifier " :
self . executer_flux ( str ( donnees . get ( " hote " , " " ) ) , " verifier " , donnees . get ( " vault " ) )
elif chemin == " /api/deployer " :
self . executer_flux ( str ( donnees . get ( " hote " , " " ) ) , " deployer " , donnees . get ( " vault " ) )
2026-07-01 15:58:32 -04:00
elif chemin == " /api/creer " :
self . executer_flux ( str ( donnees . get ( " hote " , " " ) ) , " creer " , donnees . get ( " vault " ) )
elif chemin == " /api/pousser " :
self . executer_flux ( str ( donnees . get ( " hote " , " " ) ) , " pousser " , donnees . get ( " vault " ) )
2026-06-24 20:17:46 -04:00
else :
self . repondre_json ( 404 , { " erreur " : " Introuvable " } )
except Exception as exc :
self . repondre_json ( 400 , { " erreur " : str ( exc ) } )
def main ( ) - > int :
parser = argparse . ArgumentParser ( description = " Interface web locale pour gerer un inventaire Set-OPS. " )
parser . add_argument ( " --inventaire " , type = Path , default = INVENTAIRE_DEFAUT )
parser . add_argument ( " --hote " , default = " 127.0.0.1 " )
parser . add_argument ( " --port " , type = int , default = 8765 )
args = parser . parse_args ( )
Gestionnaire . inventaire = args . inventaire . resolve ( )
serveur = ThreadingHTTPServer ( ( args . hote , args . port ) , Gestionnaire )
print ( f " Interface inventaire: http:// { args . hote } : { args . port } / " )
print ( f " Inventaire: { Gestionnaire . inventaire } " )
serveur . serve_forever ( )
return 0
if __name__ == " __main__ " :
raise SystemExit ( main ( ) )