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)
|
## 2026-07-07 (soir)
|
||||||
|
|
||||||
### Ajouté
|
### 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
|
- **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é**
|
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,
|
(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)
|
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:
|
def donnees_hote(hote: dict) -> dict:
|
||||||
variables: dict = {}
|
variables: dict = {}
|
||||||
for cle, var, typ in CHAMPS_PROVISION:
|
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; }
|
.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 { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||||
.legende .chip { margin-right: 4px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -939,6 +983,7 @@ HTML = r"""<!doctype html>
|
||||||
<span class="grandit"></span>
|
<span class="grandit"></span>
|
||||||
<button type="button" id="btn-fermer-console" onclick="fermerConsole()">Fermer</button>
|
<button type="button" id="btn-fermer-console" onclick="fermerConsole()">Fermer</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="console-resume" hidden></div>
|
||||||
<pre id="console-sortie"></pre>
|
<pre id="console-sortie"></pre>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -1783,6 +1828,7 @@ HTML = r"""<!doctype html>
|
||||||
function ouvrirConsole(titre, hote) {
|
function ouvrirConsole(titre, hote) {
|
||||||
document.getElementById('console-titre').textContent = titre + ' · ' + hote;
|
document.getElementById('console-titre').textContent = titre + ' · ' + hote;
|
||||||
document.getElementById('console-sortie').textContent = '';
|
document.getElementById('console-sortie').textContent = '';
|
||||||
|
const res = document.getElementById('console-resume'); res.hidden = true; res.innerHTML = '';
|
||||||
const etat = document.getElementById('console-etat');
|
const etat = document.getElementById('console-etat');
|
||||||
etat.className = 'console-etat actif'; etat.textContent = '● en cours';
|
etat.className = 'console-etat actif'; etat.textContent = '● en cours';
|
||||||
document.getElementById('btn-fermer-console').disabled = true;
|
document.getElementById('btn-fermer-console').disabled = true;
|
||||||
|
|
@ -1795,6 +1841,24 @@ HTML = r"""<!doctype html>
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
function ecrireConsole(t) { const p = document.getElementById('console-sortie'); p.textContent += t; p.scrollTop = p.scrollHeight; }
|
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) {
|
function finirConsole(rc) {
|
||||||
clearInterval(chronoConsole); chronoConsole = null;
|
clearInterval(chronoConsole); chronoConsole = null;
|
||||||
const s = Math.round((Date.now() - consoleDebut) / 1000);
|
const s = Math.round((Date.now() - consoleDebut) / 1000);
|
||||||
|
|
@ -1974,16 +2038,20 @@ HTML = r"""<!doctype html>
|
||||||
}
|
}
|
||||||
const lecteur = rep.body.getReader();
|
const lecteur = rep.body.getReader();
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
let rc = null;
|
let rc = null; let brut = '';
|
||||||
while (true) {
|
while (true) {
|
||||||
const {value, done} = await lecteur.read();
|
const {value, done} = await lecteur.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
let texte = dec.decode(value, {stream: true});
|
let texte = dec.decode(value, {stream: true});
|
||||||
|
brut += texte;
|
||||||
const m = texte.match(/__FIN__ rc=(-?\d+)/);
|
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 (texte) ecrireConsole(texte);
|
||||||
}
|
}
|
||||||
if (rc === null) rc = 1;
|
if (rc === null) rc = 1;
|
||||||
|
const mr = brut.match(/__RESUME__ (.+)/);
|
||||||
|
if (rc !== 0 && mr) { try { afficherResumeErreur(JSON.parse(mr[1])); } catch (e) {} }
|
||||||
finirConsole(rc);
|
finirConsole(rc);
|
||||||
if (mode === 'verifier' && rc === 0) { verifie[hote] = true; message('Vérification réussie — déploiement débloqué pour ' + hote + '.', 'ok'); }
|
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(); }
|
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"
|
entete = f"$ make {cible} HOTE={hote}\n\n"
|
||||||
journal.write(entete)
|
journal.write(entete)
|
||||||
self._ecrire_flux(entete)
|
self._ecrire_flux(entete)
|
||||||
|
tache_courante = ""
|
||||||
|
echecs: list[dict] = []
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
["make", cible, f"HOTE={hote}"],
|
["make", cible, f"HOTE={hote}"],
|
||||||
cwd=str(RACINE), env=env,
|
cwd=str(RACINE), env=env,
|
||||||
|
|
@ -2206,6 +2276,15 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
||||||
try:
|
try:
|
||||||
for ligne in proc.stdout:
|
for ligne in proc.stdout:
|
||||||
journal.write(ligne)
|
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):
|
if not self._ecrire_flux(ligne):
|
||||||
# Navigateur déconnecté : on continue à journaliser jusqu'au bout.
|
# Navigateur déconnecté : on continue à journaliser jusqu'au bout.
|
||||||
for reste in proc.stdout:
|
for reste in proc.stdout:
|
||||||
|
|
@ -2224,6 +2303,8 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
||||||
marquer_serveur_actif(hote)
|
marquer_serveur_actif(hote)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
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")
|
self._ecrire_flux(f"\n[journal : {os.path.relpath(chemin_log, RACINE)}]\n__FIN__ rc={rc}\n")
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue