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:
parent
3cc00ad660
commit
e8aba1ab74
2 changed files with 90 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue