GUI vue Serveurs : Nœud/Stockage en déroulantes, Intégrations en cases à cocher

Les champs libres deviennent des sélections alimentées par les paramètres
globaux : Nœud et Stockage puisent dans de nouveaux catalogues
proxmox_noeuds / proxmox_stockages (vide = défaut global), Intégrations
liste les rôles client_* disponibles (scan roles/client_*). Les catalogues
(Nœuds/Stockages/Ponts) sont des intrants de classe « Catalogue » éditables
dans le panneau Intrants ; ajout du type `liste` (chaîne virgulée → liste
YAML dédoublonnée) et de integrations_disponibles dans l'API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-06-30 12:36:51 -04:00
parent a21a6b4e02
commit 738fd2b88c
2 changed files with 74 additions and 9 deletions

View file

@ -19,6 +19,17 @@
- Le bouton **Sauvegarder** de l'en-tête devient contextuel (sauve la vue éditable,
désactivé en lecture seule) — fin de l'impasse 409 ; suppression du code mort.
### Ajouté
- **Vue Serveurs : champs alimentés par les paramètres globaux.** À `+ Serveur`,
**Nœud** et **Stockage** deviennent des listes déroulantes (catalogues
`proxmox_noeuds` / `proxmox_stockages`, vide = défaut global) et **Intégrations**
une rangée de cases à cocher des rôles `client_*` disponibles (au lieu d'une saisie
texte). Les catalogues Nœuds / Stockages / Ponts sont de nouveaux intrants
(classe « Catalogue ») éditables dans le panneau « Intrants de base » et stockés
dans `group_vars/proxmox.yml` ; l'API expose `integrations_disponibles` (scan de
`roles/client_*`). Le type `liste` (chaîne virgulée → liste YAML dédoublonnée) est
ajouté au schéma des intrants.
### Modifié
- **Vue Inventaire (cartes) : détail converti en inspection lecture seule.** La vue
affichait « généré depuis le plan (lecture seule) » tout en exposant une surface

View file

@ -69,6 +69,9 @@ INTRANTS_SCHEMA = [
("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"),
("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"),
]
# Cles secretes : interdites a l'ecriture par le GUI (garde-fou).
INTRANTS_CLES_INTERDITES = {
@ -247,6 +250,7 @@ def inventaire_api(path: Path) -> dict:
"serveurs": serveurs_reconcilies(data),
"hotes": liste_hotes(data),
"intrants": intrants_api(),
"integrations_disponibles": integrations_disponibles(),
}
@ -261,14 +265,24 @@ def _lire_yaml_dict(path: Path) -> dict:
def intrants_courants() -> dict:
cache: dict = {}
valeurs: dict = {}
for variable, fcle, *_ in INTRANTS_SCHEMA:
for variable, fcle, _classe, _section, _libelle, typ in INTRANTS_SCHEMA:
if fcle not in cache:
cache[fcle] = _lire_yaml_dict(FICHIERS_INTRANTS[fcle])
v = cache[fcle].get(variable, "")
valeurs[variable] = "" if v is None else v
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
return valeurs
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())
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]
@ -281,6 +295,15 @@ def intrants_api() -> dict:
def _coercer_intrant(typ: str, brut: object) -> object:
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
s = str(brut).strip()
if typ == "bool":
return s.lower() in ("true", "1", "oui", "yes", "on")
@ -703,6 +726,10 @@ HTML = r"""<!doctype html>
.badge-classe { display: inline-block; font-size: 9.5px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; padding: 1px 6px; border-radius: 999px; margin-left: 6px; vertical-align: middle; }
.badge-classe.const { background: color-mix(in srgb, var(--ambre) 20%, transparent); color: var(--ambre); }
.badge-classe.def { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
.badge-classe.cat { background: color-mix(in srgb, var(--teal) 20%, transparent); color: var(--teal); }
.integ-cases { display: flex; flex-wrap: wrap; gap: 6px 12px; padding: 4px 2px; }
.integ-case { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: var(--texte); cursor: pointer; }
.integ-case input { width: 14px; height: 14px; flex: none; accent-color: var(--teal); }
.intr-ro { color: var(--muted); font-size: 12px; line-height: 1.55; }
.intr-ro b { color: var(--texte); font-weight: 700; }
.intr-secrets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
@ -816,6 +843,7 @@ HTML = r"""<!doctype html>
let motVault = '';
let modaleResolveur = null;
let intrants = null;
let integrationsDispo = [];
let chronoConsole = null;
let consoleDebut = 0;
@ -879,6 +907,7 @@ HTML = r"""<!doctype html>
nomenclature = data.nomenclature || {};
chaine = data.chaine || {};
intrants = data.intrants || null;
integrationsDispo = data.integrations_disponibles || [];
chargerBases(data);
chargerApplications(data);
chargerServeurs(data);
@ -1252,6 +1281,12 @@ HTML = r"""<!doctype html>
if (b) { b.classList.add('attention'); b.textContent = 'Sauvegarder les serveurs •'; }
}
function definirServeur(i, champ, v) { serveurs[i][champ] = v; marquerServeursModifie(); if (champ === 'nom' || champ === 'fonction') dessinerServeurs(); }
function basculerIntegration(i, role, actif) {
const set = new Set(listeDepuisTexte(serveurs[i].integrations));
if (actif) set.add(role); else set.delete(role);
serveurs[i].integrations = Array.from(set).join(', ');
marquerServeursModifie();
}
function ajouterServeur() { serveurs.push({nom: '', fonction: (Object.keys(nomenclature.fonctions || {})[0] || ''), etat: 'planifie', noeud: '', stockage: '', disque: '', memoire: '', coeurs: '', integrations: ''}); marquerServeursModifie(); dessinerServeurs(); }
function retirerServeur(i) { serveurs.splice(i, 1); marquerServeursModifie(); dessinerServeurs(); }
@ -1259,18 +1294,31 @@ HTML = r"""<!doctype html>
const cible = document.getElementById('grilles');
const optFonction = (sel) => Object.keys(nomenclature.fonctions || {}).map(f => `<option value="${echapper(f)}" ${f === sel ? 'selected' : ''}>${echapper(f)}</option>`).join('');
const optEtat = (sel) => ['planifie', 'actif'].map(e => `<option value="${e}" ${e === sel ? 'selected' : ''}>${e}</option>`).join('');
const vi = (intrants && intrants.valeurs) || {};
const libDef = (d) => '(défaut' + (d ? ' : ' + d : '') + ')';
const optDeListe = (liste, sel, libelleDefaut) => {
const vals = [...new Set([...(liste || []), ...(sel ? [sel] : [])])];
return `<option value="" ${sel ? '' : 'selected'}>${echapper(libelleDefaut)}</option>`
+ vals.map(v => `<option value="${echapper(v)}" ${v === sel ? 'selected' : ''}>${echapper(v)}</option>`).join('');
};
const casesInteg = (i, txt) => {
const set = new Set(listeDepuisTexte(txt));
const tous = [...new Set([...integrationsDispo, ...set])];
if (!tous.length) return '<span class="dsn">aucun rôle client_* disponible</span>';
return tous.map(role => `<label class="integ-case" title="${echapper(role)}"><input type="checkbox" ${set.has(role) ? 'checked' : ''} onchange="basculerIntegration(${i}, '${echapper(role)}', this.checked)"><span>${echapper(role.replace(/^client_/, ''))}</span></label>`).join('');
};
const lignes = serveurs.length ? serveurs.map((s, i) => {
const d = deriveServeur(s.nom, s.fonction);
return `<div class="base-ligne">
<label class="champ"><span>Serveur (fonction-NN)</span><input data-v="nom" value="${echapper(s.nom)}" placeholder="web-frontal-03" oninput="definirServeur(${i}, 'nom', this.value)"></label>
<label class="champ"><span>Fonction</span><select onchange="definirServeur(${i}, 'fonction', this.value)">${optFonction(s.fonction)}</select></label>
<label class="champ"><span>État</span><select onchange="definirServeur(${i}, 'etat', this.value)">${optEtat(s.etat)}</select></label>
<label class="champ"><span>Nœud</span><input value="${echapper(s.noeud)}" placeholder="noeud-1" oninput="definirServeur(${i}, 'noeud', this.value)"></label>
<label class="champ"><span>Stockage</span><input value="${echapper(s.stockage)}" placeholder="stockage-1" oninput="definirServeur(${i}, 'stockage', this.value)"></label>
<label class="champ"><span>Nœud</span><select onchange="definirServeur(${i}, '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(${i}, '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(${i}, 'disque', this.value)"></label>
<label class="champ"><span>Mémoire</span><input data-v="memoire" type="number" value="${echapper(s.memoire)}" placeholder="2048" oninput="definirServeur(${i}, 'memoire', this.value)"></label>
<label class="champ"><span>Cœurs</span><input data-v="coeurs" type="number" value="${echapper(s.coeurs)}" placeholder="2" oninput="definirServeur(${i}, 'coeurs', this.value)"></label>
<label class="champ"><span>Intégrations</span><input value="${echapper(s.integrations)}" placeholder="client_dns, …" oninput="definirServeur(${i}, 'integrations', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Intégrations (rôles client)</span><div class="integ-cases">${casesInteg(i, s.integrations)}</div></label>
<button type="button" class="danger" title="Retirer" onclick="retirerServeur(${i})"></button>
<div class="dsn mono">dérivé vmid ${echapper(d.vmid)} · ip ${echapper(d.ip)} · vlan ${echapper(d.vlan)}</div>
</div>`;
@ -1558,14 +1606,20 @@ HTML = r"""<!doctype html>
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];
const badge = ch.classe === 'constante'
? '<span class="badge-classe const" title="Valeur unique, non surchargeable">Constante</span>'
: '<span class="badge-classe def" title="Valeur par défaut, surchargeable dans les instances">Défaut</span>';
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>';
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>';
} 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>';
} else {
const t = ch.type === 'int' ? 'number' : 'text';
html += '<label class="champ"><span>' + lib + '</span>'