GUI : erreurs de déploiement en langage clair

Quand une action échoue, un encadré résume les tâches en erreur (étape ·
hôte · type · message clair), avec un bouton Copier pour transmettre à un
humain — au lieu d'obliger à lire le dump Ansible.

Backend _extraire_echec : parse fatal:/UNREACHABLE!, privilégie stderr sur le
générique 'non-zero return code', gère no_log (secret masqué) et injoignables.
Marqueur __RESUME__ streamé, rendu côté JS. Éprouvé sur les 4 échecs réels du
from-zero du jour. node --check OK, smoke serveur OK.

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

View file

@ -3,6 +3,13 @@
## 2026-07-07 (soir)
### Ajouté
- **GUI : erreurs de déploiement en LANGAGE CLAIR.** Quand une action (créer/vérifier/déployer)
échoue, le GUI n'oblige plus à lire le dump Ansible : un encadré résume les tâches en erreur —
**étape · hôte · type · message clair** — avec un bouton **« Copier »** (pour transmettre le
résumé à un humain / au mainteneur). Le backend (`_extraire_echec`) parse les `fatal:`/`UNREACHABLE!`,
privilégie la vraie cause (`stderr` plutôt que le générique « non-zero return code »), gère
`no_log` (« sortie masquée — secret ») et les injoignables. Éprouvé sur les 4 échecs réels du
from-zero du jour. `node --check` OK.
- **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,

View file

@ -523,6 +523,41 @@ def ecrire_domaines(path: Path, registre: dict) -> None:
fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def _extraire_echec(ligne: str, tache: str) -> dict | None:
"""D'une ligne Ansible fatal/unreachable, tire un échec en LANGAGE CLAIR
(tâche · hôte · type · message) pour communiquer entre humains."""
m = re.search(r'(?:fatal|failed):\s*\[([^\]]+)\]:\s*(FAILED!|UNREACHABLE!)?\s*=>\s*(\{.*\})', ligne)
if not m:
return None
hote = m.group(1)
typ = "injoignable (SSH)" if m.group(2) == "UNREACHABLE!" else "échec"
detail: dict = {}
try:
detail = json.loads(m.group(3))
except Exception:
pass
if detail.get("censored"):
message = "sortie masquée (no_log) — souvent une tâche qui manipule un secret"
else:
msg = str(detail.get("msg", "")).strip()
# Pour un échec de commande, msg est générique ; la vraie cause est dans stderr.
stderr = ""
for cle in ("stderr", "module_stderr", "stdout"):
valeur = str(detail.get(cle, "")).strip()
if valeur:
lignes = [x for x in valeur.splitlines() if x.strip()]
stderr = lignes[0] if lignes else valeur # l'erreur mène, l'indice suit
break
if msg and msg.lower() not in ("non-zero return code", "module failure", ""):
message = msg
elif stderr:
message = stderr
else:
message = msg or "échec sans message explicite (voir le journal complet)"
message = " ".join(str(message).split()) # une ligne propre
return {"tache": (tache or "?").strip(), "hote": hote, "type": typ, "message": message[:400]}
def donnees_hote(hote: dict) -> dict:
variables: dict = {}
for cle, var, typ in CHAMPS_PROVISION:
@ -872,6 +907,15 @@ HTML = r"""<!doctype html>
.couche-fleche { text-align: center; color: var(--muted); font-size: 14px; line-height: 1.5; }
.legende { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--muted); }
.legende .chip { margin-right: 4px; }
/* Résumé d'erreur en langage clair (console) */
#console-resume { padding: 12px 16px; background: color-mix(in srgb, var(--rouge) 10%, var(--surface)); border-bottom: 1px solid var(--ligne); }
.re-tete { display: flex; align-items: center; gap: 10px; font-weight: 800; color: var(--rouge); font-size: 13px; margin-bottom: 6px; }
.re-tete button { margin-left: auto; }
.re-liste { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 7px; }
.re-liste li { font-size: 12.5px; }
.re-hote { color: var(--teal); font-weight: 700; }
.re-type { color: var(--muted); font-size: 11px; }
.re-msg { background: var(--surface-3); border-radius: 6px; padding: 4px 8px; margin-top: 3px; font-family: ui-monospace, monospace; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; }
</style>
</head>
<body>
@ -939,6 +983,7 @@ HTML = r"""<!doctype html>
<span class="grandit"></span>
<button type="button" id="btn-fermer-console" onclick="fermerConsole()">Fermer</button>
</div>
<div id="console-resume" hidden></div>
<pre id="console-sortie"></pre>
</div>
<script>
@ -1783,6 +1828,7 @@ HTML = r"""<!doctype html>
function ouvrirConsole(titre, hote) {
document.getElementById('console-titre').textContent = titre + ' · ' + hote;
document.getElementById('console-sortie').textContent = '';
const res = document.getElementById('console-resume'); res.hidden = true; res.innerHTML = '';
const etat = document.getElementById('console-etat');
etat.className = 'console-etat actif'; etat.textContent = '● en cours';
document.getElementById('btn-fermer-console').disabled = true;
@ -1795,6 +1841,24 @@ HTML = r"""<!doctype html>
}, 1000);
}
function ecrireConsole(t) { const p = document.getElementById('console-sortie'); p.textContent += t; p.scrollTop = p.scrollHeight; }
let dernierResumeTexte = '';
function afficherResumeErreur(resume) {
const el = document.getElementById('console-resume');
const echecs = (resume && resume.echecs) || [];
if (!echecs.length) { el.hidden = true; return; }
const lignes = echecs.map(e =>
`<li><b>${echapper(e.tache)}</b> <span class="re-hote">${echapper(e.hote)}</span> <span class="re-type">${echapper(e.type)}</span>
<div class="re-msg">${echapper(e.message)}</div></li>`).join('');
dernierResumeTexte = echecs.map(e => ` [${e.type}] ${e.tache} @ ${e.hote} : ${e.message}`).join('\n');
el.innerHTML = `<div class="re-tete"> Échec ${echecs.length} tâche(s) en erreur
<button type="button" class="fantome" onclick="copierResume()" title="Copier un résumé clair à transmettre">Copier</button></div>
<ul class="re-liste">${lignes}</ul>`;
el.hidden = false;
}
function copierResume() {
if (navigator.clipboard) navigator.clipboard.writeText(dernierResumeTexte)
.then(() => message('Résumé copié — à transmettre au mainteneur.', 'ok'), () => {});
}
function finirConsole(rc) {
clearInterval(chronoConsole); chronoConsole = null;
const s = Math.round((Date.now() - consoleDebut) / 1000);
@ -1974,16 +2038,20 @@ HTML = r"""<!doctype html>
}
const lecteur = rep.body.getReader();
const dec = new TextDecoder();
let rc = null;
let rc = null; let brut = '';
while (true) {
const {value, done} = await lecteur.read();
if (done) break;
let texte = dec.decode(value, {stream: true});
brut += texte;
const m = texte.match(/__FIN__ rc=(-?\d+)/);
if (m) { rc = parseInt(m[1], 10); texte = texte.replace(/\n?__FIN__ rc=-?\d+\n?/, ''); }
if (m) { rc = parseInt(m[1], 10); }
texte = texte.replace(/\n?__RESUME__ .*\n?/, '').replace(/\n?__FIN__ rc=-?\d+\n?/, '');
if (texte) ecrireConsole(texte);
}
if (rc === null) rc = 1;
const mr = brut.match(/__RESUME__ (.+)/);
if (rc !== 0 && mr) { try { afficherResumeErreur(JSON.parse(mr[1])); } catch (e) {} }
finirConsole(rc);
if (mode === 'verifier' && rc === 0) { verifie[hote] = true; message('Vérification réussie — déploiement débloqué pour ' + hote + '.', 'ok'); }
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(); }
@ -2197,6 +2265,8 @@ class Gestionnaire(BaseHTTPRequestHandler):
entete = f"$ make {cible} HOTE={hote}\n\n"
journal.write(entete)
self._ecrire_flux(entete)
tache_courante = ""
echecs: list[dict] = []
proc = subprocess.Popen(
["make", cible, f"HOTE={hote}"],
cwd=str(RACINE), env=env,
@ -2206,6 +2276,15 @@ class Gestionnaire(BaseHTTPRequestHandler):
try:
for ligne in proc.stdout:
journal.write(ligne)
if ligne.startswith("TASK ["):
deb, fin = ligne.find("["), ligne.find("]")
if -1 < deb < fin:
tache_courante = ligne[deb + 1:fin]
elif ("FAILED!" in ligne or "UNREACHABLE!" in ligne) and \
("fatal:" in ligne or ligne.lstrip().startswith("failed:")):
echec = _extraire_echec(ligne, tache_courante)
if echec:
echecs.append(echec)
if not self._ecrire_flux(ligne):
# Navigateur déconnecté : on continue à journaliser jusqu'au bout.
for reste in proc.stdout:
@ -2224,6 +2303,8 @@ class Gestionnaire(BaseHTTPRequestHandler):
marquer_serveur_actif(hote)
except Exception:
pass
if rc != 0 and echecs:
self._ecrire_flux("__RESUME__ " + json.dumps({"echecs": echecs[:6]}, ensure_ascii=False) + "\n")
journal.write(f"\n__FIN__ rc={rc}\n")
self._ecrire_flux(f"\n[journal : {os.path.relpath(chemin_log, RACINE)}]\n__FIN__ rc={rc}\n")
finally: