GUI : éditeur Domaines (6e onglet éditable)

Le registre des domaines publics — seul sans éditeur — a désormais son onglet :
ajouter/retirer une zone, autorité (sélecteur), edge, DNSSEC, mail, secondaires,
et affichage des expositions (FQDN) sous la zone. Backend : route /api/domaines,
ecrire_domaines, validation valider_domaines (rejette une autorité inconnue).

Éprouvé : round-trip backend (valide/écrit/rejette interne), node --check, smoke serveur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-07 13:08:15 -04:00
parent b4ca67289a
commit 3cc00ad660
2 changed files with 136 additions and 4 deletions

View file

@ -3,6 +3,13 @@
## 2026-07-07 (soir)
### Ajouté
- **GUI : éditeur « Domaines » (6ᵉ onglet éditable).** Le registre des domaines publics — le seul
sans édition dans le GUI — a désormais son onglet : ajouter/retirer une zone DNS, **autorité**
(sélecteur `primaire-cache`/`auto-heberge`/`delegue`), **edge**, **DNSSEC**, **mail**, secondaires,
et affichage des **expositions (FQDN) sous la zone**. Backend : route `/api/domaines`,
`ecrire_domaines`, validation `valider_domaines` (rejette une autorité inconnue). Éprouvé :
round-trip backend OK, `node --check` OK, smoke serveur OK. (Le `autorite: interne` périmé des
instances Chezlepro/Technolibre a été corrigé en `auto-heberge` au passage.)
- **GUI : persistance des journaux d'exécution.** Chaque action lancée depuis l'interface
(créer-vm / vérifier / déployer) écrit désormais, **en plus du streaming live**, un journal
horodaté `<instance>/logs/<hôte>-<action>-<date>.log` (gitignoré). Bonus : si le navigateur se

View file

@ -36,6 +36,7 @@ from inventory_rules import (
reconcilier_serveur,
valider_applications,
valider_bases,
valider_domaines,
valider_serveurs,
)
@ -509,6 +510,19 @@ def ecrire_serveurs(path: Path, registre: dict) -> None:
fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def ecrire_domaines(path: Path, registre: dict) -> None:
entete = (
"# Registre des domaines publics (zones DNS de l'instance).\n"
"# Edite par make inventaire-ui ou scripts/domaines.py.\n"
"# autorite : primaire-cache | auto-heberge | delegue (label descriptif).\n"
"---\n"
)
with path.open("w", encoding="utf-8") as fichier:
fichier.write(entete)
yaml.safe_dump({"domaines_publics": registre.get("domaines_publics", {}) 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:
@ -872,6 +886,7 @@ HTML = r"""<!doctype html>
<button type="button" id="btn-vue-serveurs" class="on" onclick="setVue('serveurs')">Serveurs</button>
<button type="button" id="btn-vue-applications" onclick="setVue('applications')">Applications</button>
<button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button>
<button type="button" id="btn-vue-domaines" onclick="setVue('domaines')">Domaines</button>
<button type="button" id="btn-vue-flux" onclick="setVue('flux')">Flux</button>
<button type="button" id="btn-vue-couches" onclick="setVue('couches')">Couches</button>
</div>
@ -947,6 +962,10 @@ HTML = r"""<!doctype html>
let hoteOpCourant = ''; // nom de l'hôte dont les boutons Vérifier/Déployer sont affichés
let applications = [];
let applicationsModifie = false;
let domaines = []; // registre des domaines (éditable)
let selDomaine = null; // index du domaine sélectionné
let domainesModifie = false;
const AUTORITES_DNS = ['primaire-cache', 'auto-heberge', 'delegue'];
let serveurs = [];
let serveursModifie = false;
let ongletActif = 'reseau';
@ -1005,6 +1024,7 @@ HTML = r"""<!doctype html>
integrationsDispo = data.integrations_disponibles || [];
chargerBases(data);
chargerApplications(data);
chargerDomaines(data);
chargerServeurs(data);
hotes = data.hotes;
estProduction = !!data.production;
@ -1046,10 +1066,11 @@ HTML = r"""<!doctype html>
function majIndicateurSauvegarde() {
const b = document.getElementById('btn-sauver');
if (!b) return;
const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'applications' || vuePrincipale === 'bases';
const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines';
const sale = (vuePrincipale === 'serveurs' && serveursModifie)
|| (vuePrincipale === 'applications' && applicationsModifie)
|| (vuePrincipale === 'bases' && basesModifie);
|| (vuePrincipale === 'bases' && basesModifie)
|| (vuePrincipale === 'domaines' && domainesModifie);
b.classList.toggle('attention', sale);
b.textContent = sale ? 'Sauvegarder •' : 'Sauvegarder';
b.disabled = !editable;
@ -1083,6 +1104,7 @@ HTML = r"""<!doctype html>
document.getElementById('btn-vue-serveurs').classList.toggle('on', v === 'serveurs');
document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications');
document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases');
document.getElementById('btn-vue-domaines').classList.toggle('on', v === 'domaines');
document.getElementById('btn-vue-flux').classList.toggle('on', v === 'flux');
document.getElementById('btn-vue-couches').classList.toggle('on', v === 'couches');
dessinerGrilles();
@ -1106,6 +1128,7 @@ HTML = r"""<!doctype html>
function dessinerGrilles() {
if (vuePrincipale === 'applications') { dessinerApplications(); return; }
if (vuePrincipale === 'bases') { dessinerBases(); return; }
if (vuePrincipale === 'domaines') { dessinerDomaines(); return; }
if (vuePrincipale === 'flux') { dessinerFlux(); return; }
if (vuePrincipale === 'couches') { dessinerCouches(); return; }
dessinerServeurs();
@ -1179,6 +1202,102 @@ HTML = r"""<!doctype html>
basesModifie = false;
}
function chargerDomaines(data) {
const dp = ((data.domaines || {}).domaines_publics) || {};
domaines = Object.entries(dp).map(([nom, x]) => ({
nom, autorite: x.autorite || 'auto-heberge', edge: x.edge || 'serveur_nginx',
dnssec: !!x.dnssec, secondaires: (x.secondaires || []).join(', '), mail: x.mail || ''}));
domainesModifie = false;
}
function marquerDomainesModifie() { domainesModifie = true; majIndicateurSauvegarde(); }
function choisirDomaine(i) { selDomaine = i; dessiner(); }
function definirDomaine(champ, v) {
const d = domaines[selDomaine]; if (!d) return;
d[champ] = v; marquerDomainesModifie();
if (champ === 'nom') { const t = document.querySelector('#tuile-dom-' + selDomaine + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
else if (champ === 'autorite' || champ === 'edge') dessinerDetail();
}
function ajouterDomaine() {
const edgeDefaut = groupes.includes('serveur_nginx') ? 'serveur_nginx' : (groupes[0] || '');
domaines.unshift({nom: '', autorite: 'auto-heberge', edge: edgeDefaut, dnssec: false, secondaires: '', mail: ''});
selDomaine = 0; marquerDomainesModifie(); dessiner();
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
}
function retirerDomaine() {
const d = domaines[selDomaine]; if (!d) return;
if (d.nom && !confirm('Retirer le domaine « ' + d.nom + ' » ?')) return;
domaines.splice(selDomaine, 1);
selDomaine = domaines.length ? Math.min(selDomaine, domaines.length - 1) : null;
marquerDomainesModifie(); dessiner();
}
function carteDomaine(d, i) {
return `<button type="button" class="carte ${i === selDomaine ? 'selectionnee' : ''}" id="tuile-dom-${i}" onclick="choisirDomaine(${i})">
<div class="carte-haut"><span class="badge-bd">zone</span>${d.dnssec ? '<span class="carte-vmid">dnssec</span>' : ''}</div>
<div class="carte-nom ${d.nom ? '' : 'sans'}">${echapper(d.nom || '(sans nom)')}</div>
<div class="carte-ip">${echapper(d.autorite || '')}</div>
</button>`;
}
function dessinerDomaines() {
const cible = document.getElementById('grilles');
const tuiles = domaines.length
? `<div class="grille-cartes">${domaines.map((d, i) => carteDomaine(d, i)).join('')}</div>`
: '<div class="vide" style="padding:14px">Aucun domaine. Une zone DNS = parent des expositions (FQDN) derrière l\'edge.</div>';
cible.innerHTML = `<div class="section-grille">
<div class="section-tete">Domaines <span class="compte">${domaines.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterDomaine()">+ Domaine</button></div>
${tuiles}</div>`;
}
function expositionsSous(zone) {
if (!zone) return [];
return applications.flatMap(a => listeDepuisTexte(a.expose)).filter(f => f === zone || f.endsWith('.' + zone));
}
function inspecteurDomaine() {
const cible = document.getElementById('detail');
if (selDomaine == null || !domaines[selDomaine]) {
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun domaine sélectionné</b>Choisis une tuile, ou ajoute un domaine.</div></div>';
return;
}
const d = domaines[selDomaine];
const optAut = AUTORITES_DNS.map(a => `<option value="${a}" ${a === d.autorite ? 'selected' : ''}>${a}</option>`).join('');
const grpEdge = groupes.filter(g => g.startsWith('serveur_nginx'));
if (d.edge && !grpEdge.includes(d.edge)) grpEdge.push(d.edge);
const optEdge = grpEdge.map(g => `<option value="${echapper(g)}" ${g === d.edge ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const fqdns = expositionsSous(d.nom);
const liens = fqdns.length
? `<div class="ch-bases">${fqdns.map(f => `<div class="ch-base"><span class="mono">${echapper(f)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucune exposition sous cette zone</div>';
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(d.nom || '(sans nom)')}</span></div>
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer ce domaine" onclick="retirerDomaine()"></button></div>
<div class="grille">
<label class="champ" style="grid-column:1/-1"><span>Zone DNS</span><input value="${echapper(d.nom)}" placeholder="exemple.ca" oninput="definirDomaine('nom', this.value)"></label>
<label class="champ"><span>Autorité</span><select onchange="definirDomaine('autorite', this.value)">${optAut}</select></label>
<label class="champ"><span>Edge</span><select onchange="definirDomaine('edge', this.value)">${optEdge}</select></label>
<label class="champ"><span>DNSSEC</span><select onchange="definirDomaine('dnssec', this.value === 'oui')"><option value="non" ${d.dnssec ? '' : 'selected'}>non</option><option value="oui" ${d.dnssec ? 'selected' : ''}>oui</option></select></label>
<label class="champ"><span>Mail (optionnel)</span><input value="${echapper(d.mail)}" placeholder="" oninput="definirDomaine('mail', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Secondaires (DNS)</span><input value="${echapper(d.secondaires)}" placeholder="ns2.exemple.ca, …" oninput="definirDomaine('secondaires', this.value)"></label>
</div>
<div class="section-tete" style="margin:12px 2px 4px">Expositions sous cette zone</div>
${liens}
</article>`;
}
async function sauvegarderDomaines() {
const dp = {};
domaines.forEach(d => {
if (!String(d.nom).trim()) return;
const o = {autorite: d.autorite, edge: d.edge, secondaires: listeDepuisTexte(d.secondaires), dnssec: !!d.dnssec};
if (String(d.mail).trim()) o.mail = d.mail;
dp[d.nom] = o;
});
const rep = await fetch('/api/domaines', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({domaines_publics: dp})
});
const data = await rep.json();
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
chargerDomaines(data); dessinerDomaines();
message('Domaines sauvegardés.', 'ok');
}
function chargerApplications(data) {
const a = (data.applications || {}).applications || {};
applications = Object.entries(a).map(([id, x]) => ({
@ -1634,6 +1753,7 @@ HTML = r"""<!doctype html>
if (vuePrincipale === 'flux' || vuePrincipale === 'couches') return legendeLecture();
if (vuePrincipale === 'applications') return inspecteurApplication();
if (vuePrincipale === 'bases') return inspecteurBase();
if (vuePrincipale === 'domaines') return inspecteurDomaine();
return inspecteurServeur();
}
@ -1879,7 +1999,8 @@ HTML = r"""<!doctype html>
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.');
else if (vuePrincipale === 'domaines') sauvegarderDomaines();
else message('Vue en lecture seule (Flux / Couches) — édite dans Serveurs / Applications / Bases / Domaines.');
}
function ordreServeursVisibles() {
@ -1919,7 +2040,7 @@ HTML = r"""<!doctype html>
// 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 = ''; }
if (serveursModifie || applicationsModifie || basesModifie || domainesModifie) { e.preventDefault(); e.returnValue = ''; }
});
// ------ Info-bulles d'aide au survol de la description d'un champ ------
@ -2164,6 +2285,10 @@ class Gestionnaire(BaseHTTPRequestHandler):
valider_serveurs(donnees, charger_nomenclature(FICHIER_NOMENCLATURE))
ecrire_serveurs(FICHIER_SERVEURS, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/domaines":
valider_domaines(donnees)
ecrire_domaines(FICHIER_DOMAINES, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/intrants":
ecrire_intrants(donnees)
self.repondre_json(200, inventaire_api(self.inventaire))