#!/usr/bin/env python3 """Convert taler-monitoring console log to a console-feel HTML page.""" from __future__ import annotations import argparse import html import re from datetime import datetime, timezone from pathlib import Path def ui_lang(explicit: str = "") -> str: """en (default) or fr. Env: TALER_MON_LANG; auto fr for lefrancpaysan hosts.""" import os lang = (explicit or os.environ.get("TALER_MON_LANG") or "").strip().lower() if lang in ("fr", "fra", "french"): return "fr" if lang in ("en", "eng", "english"): return "en" return "en" def ui(lang: str, key: str, **kwargs) -> str: """UI chrome strings for sticky bar (English default; French for FrancPaysan).""" en = { "errors": "ERRORS", "warnings": "WARNINGS", "ok": "OK", "error_one": "{n} error", "error_many": "{n} errors", "warn_one": "{n} warning", "warn_many": "{n} warnings", "jump_first_error": "Jump to first error", "jump_first_warn": "Jump to first warning", "generated": "generated", "version": "version", "source": "source", "what_monitors": "What this monitors", "what_monitors_title": "Expand: what this page checks", "surface_title": "Remote ecosystem surface inventory", "surface_summary": "Public catalog · DNS/TCP/HTTPS/TLS · nmap OS/service · versions · CVE", "surface_i1": "Outside-in only: no SSH and no podman exec on remote targets", "surface_i2": "Catalog (surface-catalog.conf): taler.net, demo/test, gnunet.org, taler-ops.ch, taler-systems.com, firefly, anastasis, …", "surface_i3": "Per host: DNS, ports, protocol probes, TLS, Server-header version, nmap fingerprint (SURFACE_NMAP), optional CVE", "surface_i4": "Also runs mattermost + mail (firefly.gnunet.org, anastasis.taler-systems.com) + versions into this single HTML page", "surface_i5": "HTML only /taler-monitoring-surface(+_err)/ on taler.hacktivism.ch · timer hourly", "apt_title": "apt-src merchant deploy tests (podman on koopa)", "apt_summary": "4 containers · fresh + upgrade tracks · trixie & trixie-testing", "apt_i1": "koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)", "apt_i2": "koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)", "apt_i3": "koopa-taler-deploy-test-apt-src-trixie-upgrade (upgrade track)", "apt_i4": "koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (upgrade track)", "apt_i5": "Checks: packages, taler-merchant-httpd --version / ldd, systemd unit, optional local /config probe — not public nginx/HTTPS", "apt_i6": "HTML published only on taler.hacktivism.ch (this page host: {host})", "apt_i7": "Timer: taler-monitoring-aptdeploy.timer (4h)", "apt_i8": "Top board: per-container podman state, merchant version, httpd, ldd, OK/ERROR (see CONTAINER OVERVIEW)", "apt_board_title": "Container overview — active pods & failures", "apt_board_empty": "No board · lines in log (re-run aptdeploy with suite ≥ v1.10.0).", "focus_bank": "Libeufin bank public HTTPS + container inside checks", "focus_exchange": "Exchange public HTTPS + container inside checks", "focus_merchant": "Merchant backend / SPA public HTTPS + container inside checks", "focus_stack": "Taler stack public + inside checks", "stack_title": "Stack monitoring · {host}", "stack_summary": "{phases} · public + inside", "stack_i1": "Focus: {focus}", "stack_i2": "Phases this run: {phases}", "stack_i3": "Typical: public URLs (HTTPS, QR, perf), inside (podman/ssh), package versions", "stack_i4": "HTML host: {host}", "stack_i5": "Timer: host-agent path + 4h · /monitoring on bank, exchange, merchant fronts", "stack_i_phases": "Log header phases= {phases}", "pages_title": "Monitoring pages (suite HTML)", "pages_summary": "Public sticky-bar reports · phase monpages · part of taler-monitoring", "pages_i1": "These HTML reports are generated by taler-monitoring (site-gen/console_to_html.py + host-agent)", "pages_i2": "Landing stacks (9): /monitoring(+_err)/ on bank/exchange/merchant fronts (GOA + FP stage + FP prod)", "pages_i3": "GOA: /monitoring/ on bank+exchange+taler; surface + aptdeploy on taler.hacktivism.ch — mail/mattermost folded into surface", "pages_i4": "Phase monpages: outside-in check (existence + top/bottom content markers; not merchant JSON code 21)", "pages_i5": "Host-agent: staging → DEPLOY_WWW_ROOT; reverse-proxy must serve /monitoring* and /taler-monitoring-surface*", "pages_i6": "This page host: {host} · path label: {label}", "pages_section_lead": "Monitoring pages themselves (suite):", "mm_title": "Mattermost chat health", "mm_summary": "mattermost.taler.net · SPA + /api/v4/system/ping · TLS", "mm_i1": "Outside-in phase mattermost (default https://mattermost.taler.net)", "mm_i2": "Landing SPA, /api/v4/system/ping JSON, /login, TLS certificate expiry", "mm_i3": "Reported on taler-monitoring-surface HTML (no separate /taler-monitoring-mattermost* page)", "mail_title": "Mail (MX / SMTP / IMAP)", "mail_summary": "firefly.gnunet.org + anastasis.taler-systems.com · mail-catalog.conf", "mail_i1": "Outside-in phase mail: MX, SMTP/IMAP ports and handshakes, SPF/DMARC", "mail_i2": "Mail hosts: firefly.gnunet.org (taler.net/gnunet.org) and anastasis.taler-systems.com", "mail_i3": "Reported on taler-monitoring-surface HTML (no separate /taler-monitoring-mail* page)", "footer": "Console-style render of taler-monitoring output. Sticky bar: green = clean · yellow = warnings · red = errors. Commit pins the exact tree used for this run.", "redirect_fail": "has failures.", "redirect_see": "See {link} for the full console log, error index, and sticky status bar.", "ago_s": "{n}s ago", "ago_m": "{n}m ago", "ago_h": "{n}h ago", "ago_d": "{n}d ago", "age_title": "page age · updates every second", } fr = { "errors": "ERREURS", "warnings": "AVERTISSEMENTS", "ok": "OK", "error_one": "{n} erreur", "error_many": "{n} erreurs", "warn_one": "{n} avertissement", "warn_many": "{n} avertissements", "jump_first_error": "Aller à la première erreur", "jump_first_warn": "Aller au premier avertissement", "generated": "généré", "version": "version", "source": "source", "what_monitors": "Ce que cette page contrôle", "what_monitors_title": "Déplier : ce que cette page vérifie", "surface_title": "Inventaire de surface de l'écosystème (distant)", "surface_summary": "Catalogue public · DNS/TCP/HTTPS/TLS · nmap OS/service · versions · CVE", "surface_i1": "Uniquement de l'extérieur : pas de SSH ni de podman exec sur les cibles distantes", "surface_i2": "Catalogue (surface-catalog.conf) : taler.net, demo/test, gnunet.org, taler-ops.ch, firefly, anastasis, …", "surface_i3": "Par hôte : DNS, ports, protocoles, TLS, en-tête Server, nmap (SURFACE_NMAP), CVE optionnel", "surface_i4": "Inclut aussi mattermost + mail (firefly.gnunet.org, anastasis.taler-systems.com) + versions dans cette seule page HTML", "surface_i5": "HTML uniquement /taler-monitoring-surface(+_err)/ sur taler.hacktivism.ch · minuterie horaire", "apt_title": "Tests de déploiement apt-src marchand (podman sur koopa)", "apt_summary": "4 conteneurs · pistes fresh + upgrade · trixie et trixie-testing", "apt_i1": "koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)", "apt_i2": "koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)", "apt_i3": "koopa-taler-deploy-test-apt-src-trixie-upgrade (piste upgrade)", "apt_i4": "koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (piste upgrade)", "apt_i5": "Contrôles : paquets, taler-merchant-httpd --version / ldd, unité systemd, sonde locale /config optionnelle — pas de nginx/HTTPS public", "apt_i6": "HTML publié uniquement sur taler.hacktivism.ch (hôte de cette page : {host})", "apt_i7": "Minuterie : taler-monitoring-aptdeploy.timer (4 h)", "apt_i8": "Tableau haut : état podman, version merchant, httpd, ldd, OK/ERROR par conteneur", "apt_board_title": "Vue conteneurs — pods actifs et erreurs", "apt_board_empty": "Pas de lignes board · dans le journal (relancer aptdeploy suite ≥ v1.10.0).", "focus_bank": "Banque Libeufin HTTPS public + contrôles inside conteneur", "focus_exchange": "Exchange HTTPS public + contrôles inside conteneur", "focus_merchant": "Backend marchand / SPA HTTPS public + contrôles inside conteneur", "focus_stack": "Pile Taler public + inside", "stack_title": "Surveillance de pile · {host}", "stack_summary": "{phases} · public + inside", "stack_i1": "Focus : {focus}", "stack_i2": "Phases de cette exécution : {phases}", "stack_i3": "Typique : URL publiques (HTTPS, QR, perf), inside (podman/ssh), versions des paquets", "stack_i4": "Hôte HTML : {host}", "stack_i5": "Minuterie : host-agent path + 4 h · /monitoring sur bank, exchange, fronts marchands", "stack_i_phases": "En-tête de journal phases= {phases}", "pages_title": "Pages de monitoring (HTML de la suite)", "pages_summary": "Rapports publics sticky-bar · phase monpages · partie de taler-monitoring", "pages_i1": "Ces rapports HTML sont générés par taler-monitoring (site-gen/console_to_html.py + host-agent)", "pages_i2": "Piles landing (9) : /monitoring(+_err)/ sur bank/exchange/merchant (GOA + FP stage + FP prod)", "pages_i3": "GOA : /monitoring/ bank+exchange+taler ; surface + aptdeploy sur taler.hacktivism.ch — mail/mattermost dans surface", "pages_i4": "Phase monpages : existence + marqueurs haut/bas (pas JSON merchant code 21)", "pages_i5": "Host-agent : staging → DEPLOY_WWW_ROOT ; le reverse-proxy sert /monitoring* et /taler-monitoring-surface*", "pages_i6": "Hôte de cette page : {host} · libellé : {label}", "pages_section_lead": "Les pages de monitoring elles-mêmes (suite) :", "mm_title": "Santé du chat Mattermost", "mm_summary": "mattermost.taler.net · SPA + /api/v4/system/ping · TLS", "mm_i1": "Phase outside-in mattermost (défaut https://mattermost.taler.net)", "mm_i2": "SPA d'accueil, /api/v4/system/ping JSON, /login, expiration du certificat TLS", "mm_i3": "Rapporté dans le HTML taler-monitoring-surface (pas de page /taler-monitoring-mattermost*)", "mail_title": "Mail (MX / SMTP / IMAP)", "mail_summary": "firefly.gnunet.org + anastasis.taler-systems.com · mail-catalog.conf", "mail_i1": "Phase outside-in mail : MX, ports SMTP/IMAP et handshakes, SPF/DMARC", "mail_i2": "Hôtes mail : firefly.gnunet.org (taler.net/gnunet.org) et anastasis.taler-systems.com", "mail_i3": "Rapporté dans le HTML taler-monitoring-surface (pas de page /taler-monitoring-mail*)", "footer": "Rendu console de taler-monitoring. Barre collante : vert = OK · jaune = avertissements · rouge = erreurs. Le commit fixe l'arbre exact de cette exécution.", "redirect_fail": "a des échecs.", "redirect_see": "Voir {link} pour le journal console complet, l'index d'erreurs et la barre de statut.", "ago_s": "il y a {n}s", "ago_m": "il y a {n} min", "ago_h": "il y a {n} h", "ago_d": "il y a {n} j", "age_title": "âge de la page · mise à jour chaque seconde", } table = fr if lang == "fr" else en s = table.get(key) or en.get(key) or key try: return s.format(**kwargs) except Exception: return s # ANSI strip ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") ERR_BULLET_RE = re.compile(r"^\s*[•·*-]\s+(?P.+)$") TID_RE = re.compile(r"\b(?P[a-z0-9_.-]+-\d+)\b", re.I) def strip_ansi(s: str) -> str: return ANSI_RE.sub("", s) def classify(line: str) -> str: u = line.upper() # section / summary headers before badge matching (they contain the word ERROR) if "SUMMARY" in u or "--- ERRORS" in u or "ERRORS ·" in u or "ERRORS (" in u: return "meta" if "numbered checks" in line.lower() or "totals:" in line.lower(): return "meta" if "BLOCKER" in u or "┌ BLOCKER" in u: return "blocker" if "ERROR" in u or "┌ ERROR" in u: return "error" if "WARN" in u or "┌ WARN" in u: return "warn" if "INFO" in u or "┌ INFO" in u: return "info" if " OK" in u or "[OK" in u or "┌ OK" in u or u.strip().startswith("OK"): return "ok" if u.strip().startswith("-- ") or u.strip().startswith("=="): return "section" return "plain" def is_run_timeout_text(text: str) -> bool: u = text.upper() return "RUN.TIMEOUT" in u or "RUN_TIMEOUT" in u or "RUN TIMEOUT" in u def extract_aptdeploy_board(log_text: str) -> list[dict[str, str]]: """Parse `board · short=… suite=… verdict=…` lines from check_apt_deploy.""" rows: list[dict[str, str]] = [] seen: set[str] = set() for ln in strip_ansi(log_text or "").splitlines(): if "board ·" not in ln and " board · " not in ln: # also match "board ·" after tid if not re.search(r"\bboard\b.*\bshort=", ln): continue # detail after last "board ·" or "board " m = re.search(r"\bboard\b\s*·\s*(.+)$", ln) if not m: m = re.search(r"\bboard\b\s+(.+)$", ln) if not m: continue detail = m.group(1).strip() if "short=" not in detail: continue rec: dict[str, str] = {} for part in detail.split(): if "=" in part: k, v = part.split("=", 1) rec[k] = v short = rec.get("short", "") if not short or short in seen: continue seen.add(short) rows.append(rec) return rows def aptdeploy_board_html(log_text: str, lang: str = "en") -> str: """Compact HTML table: which containers are active and which fail.""" rows = extract_aptdeploy_board(log_text) title = ui(lang, "apt_board_title") if not rows: return ( f'
\n' f"

{html.escape(title)}

\n" f"

{html.escape(ui(lang, 'apt_board_empty'))}

\n" f"
\n" ) head = ( "shortsuitemodepodman" "merchanthttpdlddverdictnote" ) body_parts = [] n_ok = n_err = n_miss = 0 for r in rows: v = (r.get("verdict") or "?").upper() if v == "OK": n_ok += 1 vclass = "v-ok" elif v == "MISSING": n_miss += 1 vclass = "v-miss" else: n_err += 1 vclass = "v-err" body_parts.append( f'' f'{html.escape(r.get("short", ""))}' f'{html.escape(r.get("suite", ""))}' f'{html.escape(r.get("mode", ""))}' f'{html.escape(r.get("state", ""))}' f'{html.escape(r.get("merchant", ""))}' f'{html.escape(r.get("httpd", ""))}' f'{html.escape(r.get("ldd", ""))}' f'{html.escape(r.get("verdict", ""))}' f'{html.escape(r.get("note", ""))}' f"" ) summary = f"{len(rows)} containers · {n_ok} OK · {n_err} ERROR · {n_miss} missing" return ( f'
\n' f"

{html.escape(title)}

\n" f'

{html.escape(summary)}

\n' f'
\n' f"{head}\n\n" + "\n".join(body_parts) + "\n
\n
\n" ) def is_summary_error_line(ln: str) -> bool: """Skip summary/header lines that contain ERROR but are not checks.""" low = ln.lower() if "failed — see" in low or "failed - see" in low: return True if "errors · failed" in low or "errors (failed" in low: return True if "--- errors" in low: return True if "┌ summary" in low or "[ summary" in low: return True if "totals:" in low: return True # header-ish RUN_TIMEOUT= without error badge if is_run_timeout_text(ln) and "ERROR" not in ln.upper() and "run.timeout" not in low: return True return False def extract_errors(lines: list[str]) -> list[str]: errs: list[str] = [] timeout_detail = "" in_block = False for ln in lines: if "--- ERRORS" in ln or "ERRORS (failed" in ln or "RUN TIMEOUT · extraordinary" in ln: in_block = True continue if in_block: if ln.strip().startswith("---") or "[ SUMMARY" in ln or "totals:" in ln: in_block = False continue m = ERR_BULLET_RE.match(ln) if m: body = m.group("body").strip() if is_run_timeout_text(body): timeout_detail = body else: errs.append(body) elif "ERROR" in ln.upper() and ln.strip() and not is_run_timeout_text(ln): errs.append(ln.strip()) if re.search(r"\bERROR\b", ln, re.I) and "--- ERRORS" not in ln: if is_run_timeout_text(ln): if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln: timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip() continue if is_summary_error_line(ln): continue body = re.sub(r"^.*\bERROR\b\s*\]?\s*", "", ln, flags=re.I).strip(" ·") if body and body not in errs and "failed — see" not in body.lower(): if re.search( r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.", body, ): errs.append(body) seen: set[str] = set() out: list[str] = [] for e in errs: if e in seen or is_run_timeout_text(e): continue seen.add(e) out.append(e) if timeout_detail or any( is_run_timeout_text(l) and ("ERROR" in l.upper() or "run.timeout" in l.lower()) for l in lines ): # only if real timeout ERROR badge, not mere RUN_TIMEOUT= header if timeout_detail or any( "run.timeout-01" in l.lower() or "RUN_TIMEOUT exceeded" in l for l in lines ): canon = timeout_detail or "run.timeout-01 [run] RUN_TIMEOUT exceeded" out = [canon] + out return out def count_status(lines: list[str]) -> tuple[int, int, int | None, int | None]: """Return (error_count, warn_count, first_error_idx, first_warn_idx).""" n_err = 0 n_warn = 0 first_err: int | None = None first_warn: int | None = None for i, ln in enumerate(lines): kind = classify(ln) if kind in ("error", "blocker"): if is_summary_error_line(ln): continue n_err += 1 if first_err is None: first_err = i elif kind == "warn": n_warn += 1 if first_warn is None: first_warn = i return n_err, n_warn, first_err, first_warn def slug_error(i: int, text: str) -> str: if is_run_timeout_text(text): return "err-run-timeout" m = TID_RE.search(text) if m: return "err-" + re.sub(r"[^a-z0-9_-]+", "-", m.group("tid").lower()) return f"err-{i:03d}" def render_line(ln: str, err_slugs: dict[str, str], extra_ids: list[str] | None = None) -> str: kind = classify(ln) esc = html.escape(ln) for tid, slug in err_slugs.items(): if tid in ln: esc = esc.replace( html.escape(tid), f'{html.escape(tid)}', ) id_attr = "" if extra_ids: id_attr = f' id="{" ".join(extra_ids)}"' # invalid multi-id; use first only # HTML allows one id — join carefully id_attr = f' id="{html.escape(extra_ids[0])}"' if len(extra_ids) > 1: # secondary anchors as empty spans prepended spans = "".join(f'' for x in extra_ids[1:]) return f'{spans}{esc}\n' return f'{esc}\n' def status_level(n_err: int, n_warn: int) -> str: if n_err > 0: return "red" if n_warn > 0: return "yellow" return "green" def extract_phases(log_text: str) -> str: """Best-effort phases string from host-agent / taler-monitoring log header.""" for pat in ( r"phases=([a-z0-9 _.-]+)", r"· phases=([a-z0-9 _.-]+)", r"phases\s{2,}([a-z0-9 _.-]+)", r"^\s*phases\s+([a-z0-9 _.-]+)\s*$", ): m = re.search(pat, log_text, re.I | re.M) if m: return re.sub(r"\s+", " ", m.group(1)).strip(" ·") return "" def _pages_items(lang: str, host: str, label: str) -> list[str]: """Dedicated section: monitoring HTML pages are part of taler-monitoring.""" return [ ui(lang, "pages_section_lead"), ui(lang, "pages_i1"), ui(lang, "pages_i2"), ui(lang, "pages_i3"), ui(lang, "pages_i4"), ui(lang, "pages_i5"), ui(lang, "pages_i6", host=host, label=label), ] def monitoring_scope( page_label: str, hostname: str, log_text: str = "", lang: str = "en", ) -> dict[str, object]: """Human-readable description of what this page monitors (en default; fr for FP).""" label = (page_label or "monitoring").lower().replace("_", "-") phases = extract_phases(log_text) host = hostname or "?" # Auto-fr for FrancPaysan hosts if lang not forced if lang == "en" and re.search(r"lefrancpaysan|francpaysan", host + " " + log_text, re.I): import os if not (os.environ.get("TALER_MON_LANG") or "").strip(): lang = "fr" if "surface" in label: items = [ ui(lang, "surface_i1"), ui(lang, "surface_i2"), ui(lang, "surface_i3"), ui(lang, "surface_i4", host=host), ui(lang, "surface_i5"), ] + _pages_items(lang, host, label) return { "kind": "surface", "lang": lang, "title": ui(lang, "surface_title"), "summary": ui(lang, "surface_summary"), "items": items, } if "aptdeploy" in label or "apt-deploy" in label or "apt_src" in label: items = [ ui(lang, "apt_i1"), ui(lang, "apt_i2"), ui(lang, "apt_i3"), ui(lang, "apt_i4"), ui(lang, "apt_i5"), ui(lang, "apt_i6", host=host), ui(lang, "apt_i7"), ui(lang, "apt_i8"), ] + _pages_items(lang, host, label) # Prefer live board verdicts in the scope list when log has board · lines board = extract_aptdeploy_board(log_text or "") if board: live = [] for r in board: v = r.get("verdict", "?") mark = "OK" if v.upper() == "OK" else ("MISS" if v.upper() == "MISSING" else "ERR") live.append( f"{mark} {r.get('short', '?')}: pod={r.get('state', '?')} " f"merchant={r.get('merchant', '?')} httpd={r.get('httpd', '?')} " f"ldd={r.get('ldd', '?')}" ) items = live + items return { "kind": "aptdeploy", "lang": lang, "title": ui(lang, "apt_title"), "summary": ui(lang, "apt_summary"), "items": items, } if "mattermost" in label: items = [ ui(lang, "mm_i1"), ui(lang, "mm_i2"), ui(lang, "mm_i3"), ] + _pages_items(lang, host, label) return { "kind": "mattermost", "lang": lang, "title": ui(lang, "mm_title"), "summary": ui(lang, "mm_summary"), "items": items, } if label in ("taler-monitoring-mail", "mail") or label.endswith("-mail"): items = [ ui(lang, "mail_i1"), ui(lang, "mail_i2"), ui(lang, "mail_i3"), ] + _pages_items(lang, host, label) return { "kind": "mail", "lang": lang, "title": ui(lang, "mail_title"), "summary": ui(lang, "mail_summary"), "items": items, } phase_txt = phases or "urls · inside · versions" role = "stack" if host.startswith("bank.") or "bank." in host: role = "bank" focus = ui(lang, "focus_bank") elif "exchange." in host: role = "exchange" focus = ui(lang, "focus_exchange") elif host.startswith("taler.") or "merchant" in host or "monnaie." in host: role = "merchant" focus = ui(lang, "focus_merchant") else: focus = ui(lang, "focus_stack") items = [ ui(lang, "stack_i1", focus=focus), ui(lang, "stack_i2", phases=phase_txt), ui(lang, "stack_i3"), ui(lang, "stack_i4", host=host), ui(lang, "stack_i5"), ] if phases: items.insert(2, ui(lang, "stack_i_phases", phases=phases)) items = items + _pages_items(lang, host, label) return { "kind": role, "lang": lang, "title": ui(lang, "stack_title", host=host), "summary": ui(lang, "stack_summary", phases=phase_txt), "items": items, } def sticky_bar_html( *, generated_at: str, generated_iso: str, n_err: int, n_warn: int, hostname: str, page_label: str, mode: str, commit_html: str, suite_path: str, link_other: str | None, first_err_href: str | None, first_warn_href: str | None, scope: dict[str, object] | None = None, suite_version: str = "", suite_version_url: str = "", ) -> str: level = status_level(n_err, n_warn) lang = "en" if scope and scope.get("lang"): lang = str(scope.get("lang")) else: lang = ui_lang() # auto-fr for FP hostnames if lang == "en" and re.search(r"lefrancpaysan|francpaysan", hostname or "", re.I): lang = "fr" if level == "red": status_txt = ui(lang, "errors") elif level == "yellow": status_txt = ui(lang, "warnings") else: status_txt = ui(lang, "ok") if first_err_href and n_err: err_lbl = ui(lang, "error_one", n=n_err) if n_err == 1 else ui(lang, "error_many", n=n_err) err_stat = ( f'{html.escape(err_lbl)}' ) else: err_lbl = ui(lang, "error_many", n=n_err) err_stat = f'{html.escape(err_lbl)}' if first_warn_href and n_warn: warn_lbl = ui(lang, "warn_one", n=n_warn) if n_warn == 1 else ui(lang, "warn_many", n=n_warn) warn_stat = ( f'{html.escape(warn_lbl)}' ) else: warn_lbl = ui(lang, "warn_many", n=n_warn) warn_stat = f'{html.escape(warn_lbl)}' other = "" if link_other: other = ( f'' f"{html.escape(link_other)}" ) if scope is None: scope = monitoring_scope(page_label, hostname, "", lang=ui_lang()) scope_title = html.escape(str(scope.get("title") or "Monitoring")) scope_summary = html.escape(str(scope.get("summary") or "")) scope_kind = html.escape(str(scope.get("kind") or "monitoring")) lis = "\n".join( f"
  • {html.escape(str(it))}
  • " for it in (scope.get("items") or []) ) suite_bit = ( f" · {html.escape(suite_path)}" if suite_path else "" ) # Version badge (linked to Forgejo tag when available) ver = (suite_version or "").strip() ver_url = (suite_version_url or "").strip() if ver: if ver_url: version_html = ( f'{html.escape(ver)}' ) else: version_html = f'{html.escape(ver)}' else: version_html = "" # Compact sticky row always visible; "Was geprüft wird" expands inside sticky-bar return f""" """ STICKY_CSS = """ .sticky-bar { position: sticky; top: 0; z-index: 100; border-bottom: 2px solid var(--border); padding: 10px 14px 8px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); box-shadow: 0 2px 12px #0008; } .sticky-bar.sticky-green { background: linear-gradient(180deg, #0d1f14f2, #0a1410ee); border-bottom-color: #1f6b3a; } .sticky-bar.sticky-yellow { background: linear-gradient(180deg, #2a2210f2, #1a160cee); border-bottom-color: #a68b2d; } .sticky-bar.sticky-red { background: linear-gradient(180deg, #2a1010f2, #1a0808ee); border-bottom-color: #a33; } .sticky-bar-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; max-width: 1200px; } .sticky-bar-row.secondary { margin-top: 8px; font-size: 12px; color: var(--dim); } .sticky-bar-row.secondary a { color: var(--info); text-decoration: none; } .sticky-bar-row.secondary a:hover { text-decoration: underline; } .status-pill { display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: 0.06em; padding: 2px 10px; border-radius: 3px; border: 1px solid; } .status-pill.sticky-green { color: var(--ok); border-color: #1f6b3a; background: #0a1a10; } .status-pill.sticky-yellow { color: var(--warn); border-color: #a68b2d; background: #1a1608; } .status-pill.sticky-red { color: var(--err); border-color: #a33; background: #1a0a0a; } .host { font-weight: 600; color: #eee; font-size: 13px; } .sep { color: var(--dim); } .stat { font-weight: 700; font-size: 12px; text-decoration: none; padding: 1px 8px; border-radius: 3px; border: 1px solid transparent; } .stat.err { color: var(--err); border-color: #522; background: #1a0a0a; } .stat.warn { color: var(--warn); border-color: #664; background: #1a1608; } .stat.muted { opacity: 0.55; font-weight: 600; } a.stat:hover { text-decoration: underline; filter: brightness(1.15); } .version-link { font-weight: 700; font-size: 12px; color: var(--info); text-decoration: none; padding: 1px 8px; border-radius: 3px; border: 1px solid #234; background: #0a1520; } .version-link:hover { text-decoration: underline; filter: brightness(1.15); } .version-link.muted { opacity: 0.7; color: #8ab; } .generated { color: var(--dim); font-size: 12px; } .generated time { color: #bbb; } .generated .age { color: #888; margin-left: 4px; } .generated .age:not(:empty)::before { content: "("; } .generated .age:not(:empty)::after { content: ")"; } .badge { display: inline-block; padding: 1px 8px; border-radius: 3px; font-size: 11px; font-weight: 700; margin-right: 4px; border: 1px solid var(--border); } .badge.mode-err { color: var(--err); border-color: #522; background: #1a0a0a; } .badge.mode-ok { color: var(--ok); border-color: #143; background: #0a1a10; } .badge.mode-redirect { color: var(--warn); border-color: #664; background: #1a1608; } /* collapsible block inside sticky-bar */ .scope-toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; max-width: min(42rem, 100%); cursor: pointer; font: inherit; font-size: 12px; color: #ddd; background: #0006; border: 1px solid #444; border-radius: 4px; padding: 4px 10px; text-align: left; } .scope-toggle:hover { border-color: #888; background: #111a; color: #fff; } .scope-toggle:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; } .scope-toggle[aria-expanded="true"] { border-color: #777; background: #141414; } .scope-chevron { display: inline-block; width: 0; height: 0; border-left: 5px solid #aaa; border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform 0.12s ease; flex-shrink: 0; } .scope-toggle[aria-expanded="true"] .scope-chevron { transform: rotate(90deg); border-left-color: #eee; } .scope-toggle-label { font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; font-size: 11px; white-space: nowrap; } .scope-toggle-hint { color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .sticky-scope-panel { margin-top: 8px; max-width: 1200px; border: 1px solid #333; border-radius: 4px; background: #080808e6; padding: 8px 10px 10px; } .sticky-scope-panel[hidden] { display: none !important; } .scope-body { padding: 4px 4px 2px 6px; } .scope-title { margin: 4px 0 4px; font-size: 13px; font-weight: 600; color: #eee; } .scope-lead { margin: 0 0 6px; font-size: 12px; color: #999; } .scope-list { margin: 0; padding-left: 1.15em; color: #b0b0b0; font-size: 12px; line-height: 1.5; } .scope-list li { margin: 3px 0; } #first-error, #first-warn, [id^="err-"][id$="-line"] { scroll-margin-top: 120px; } @media (max-width: 720px) { .scope-toggle { margin-left: 0; width: 100%; } .scope-toggle-hint { display: none; } } """ STICKY_JS = """ """ def build_html( *, title: str, hostname: str, mode: str, log_text: str, commit: str, commit_url: str, suite_path: str, generated_at: str, generated_iso: str, link_other: str | None, page_label: str = "monitoring", suite_version: str = "", suite_version_url: str = "", ) -> str: raw_lines = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] while raw_lines and not raw_lines[-1].strip(): raw_lines.pop() n_err, n_warn, first_err_i, first_warn_i = count_status(raw_lines) errors = extract_errors(raw_lines) err_slugs: dict[str, str] = {} for i, e in enumerate(errors, 1): slug = slug_error(i, e) err_slugs[e] = slug m = TID_RE.search(e) if m: err_slugs[m.group("tid")] = slug tid_to_slug = { k: v for k, v in err_slugs.items() if re.match(r"^[a-z0-9_.-]+-\d+$", k, re.I) } # Build body with anchors for first error/warn + per-error slugs body_lines: list[str] = [] claimed_err: set[str] = set() for idx, ln in enumerate(raw_lines): extra: list[str] = [] if first_err_i is not None and idx == first_err_i: extra.append("first-error") if first_warn_i is not None and idx == first_warn_i: extra.append("first-warn") # per-error jump targets (all modes) if classify(ln) in ("error", "blocker") and not is_summary_error_line(ln): for i, e in enumerate(errors, 1): slug = slug_error(i, e) if slug in claimed_err: continue tid_m = TID_RE.search(e) if is_run_timeout_text(e): if "run.timeout-01" in ln and "ERROR" in ln.upper(): extra.append(f"{slug}-line") claimed_err.add(slug) break continue key = tid_m.group(1) if tid_m else e[:40] if key and key in ln: extra.append(f"{slug}-line") claimed_err.add(slug) break body_lines.append(render_line(ln, tid_to_slug, extra or None)) err_nav = "" # Aptdeploy: container board first (active pods + per-pod verdict) _is_apt = "aptdeploy" in (page_label or "").lower() or "apt-deploy" in ( page_label or "" ).lower() if _is_apt: _lang_board = ui_lang() if re.search(r"lefrancpaysan|francpaysan", hostname + "\n" + log_text, re.I): import os if not (os.environ.get("TALER_MON_LANG") or "").strip(): _lang_board = "fr" err_nav += aptdeploy_board_html(log_text, lang=_lang_board) if errors and mode == "err": items = [] timeout_items = [] for i, e in enumerate(errors, 1): slug = slug_error(i, e) li = ( f'
  • ' f'{html.escape(e)}
  • ' ) if is_run_timeout_text(e): timeout_items.append(li) else: items.append(li) if timeout_items: err_nav += ( '
    \n' "

    EXTRAORDINARY · RUN TIMEOUT

    \n" "

    Whole-run wall clock exceeded. " 'Jump to detail →

    \n' '
      \n' + "\n".join(timeout_items[:1]) + "\n
    \n
    \n" ) rest = items if timeout_items else (timeout_items + items) if rest or not timeout_items: err_nav += ( '
    \n' f"

    ERRORS ({len(errors)}) — jump to detail

    \n" '
      \n' + "\n".join(rest if rest else items) + "\n
    \n
    \n" ) commit_short = commit[:12] if commit else "unknown" commit_html = ( f'{html.escape(commit_short)}' if commit_url else html.escape(commit_short) ) first_err_href = "#first-error" if first_err_i is not None else None first_warn_href = "#first-warn" if first_warn_i is not None else None lang = ui_lang() if re.search(r"lefrancpaysan|francpaysan", hostname + "\n" + log_text, re.I): import os if not (os.environ.get("TALER_MON_LANG") or "").strip(): lang = "fr" scope = monitoring_scope(page_label, hostname, log_text, lang=lang) page_lang = lang footer_txt = ui(lang, "footer") bar = sticky_bar_html( generated_at=generated_at, generated_iso=generated_iso, n_err=n_err, n_warn=n_warn, hostname=hostname, page_label=page_label, mode=mode, commit_html=commit_html, suite_path=suite_path, link_other=link_other, first_err_href=first_err_href, first_warn_href=first_warn_href, scope=scope, suite_version=suite_version, suite_version_url=suite_version_url, ) pl = (page_label or "").lower() if "aptdeploy" in pl or "apt-deploy" in pl: kind = "aptdeploy" elif "surface" in pl: kind = "surface" else: kind = "monitoring" return f""" {html.escape(title)} {bar}
    {err_nav}
    {"".join(body_lines)}
    {html.escape(footer_txt)}
    {STICKY_JS} """ def build_redirect_html( *, hostname: str, commit: str, commit_url: str, path_err: str = "/monitoring_err/", page_label: str = "monitoring", generated_at: str = "", generated_iso: str = "", n_err: int = 0, n_warn: int = 0, log_text: str = "", suite_version: str = "", suite_version_url: str = "", ) -> str: commit_short = commit[:12] if commit else "unknown" link = path_err if path_err.endswith("/") else path_err + "/" c = ( f'{html.escape(commit_short)}' if commit_url else html.escape(commit_short) ) if not generated_at: now = datetime.now(timezone.utc) generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") # Redirect pages exist only when the run failed → treat as red if n_err unknown if n_err <= 0: n_err = max(n_err, 1) scope = monitoring_scope(page_label, hostname, log_text) bar = sticky_bar_html( generated_at=generated_at, generated_iso=generated_iso, n_err=n_err, n_warn=n_warn, hostname=hostname, page_label=page_label, mode="redirect", commit_html=c, suite_path="", link_other=link, first_err_href=link, first_warn_href=None, scope=scope, suite_version=suite_version, suite_version_url=suite_version_url, ) kind = "surface" if "surface" in (page_label or "") else "monitoring" return f""" {html.escape(page_label)} · errors · {html.escape(hostname)} {bar}

    {html.escape(page_label)} has failures.

    See {html.escape(link)} for the full console log, error index, and sticky status bar.

    generated {html.escape(generated_at)} · source {c}

    {STICKY_JS} """ def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--log", required=True, type=Path) ap.add_argument("--out", required=True, type=Path) ap.add_argument("--hostname", required=True) ap.add_argument("--mode", choices=("ok", "err", "redirect"), required=True) ap.add_argument("--commit", default="") ap.add_argument("--commit-url", default="") ap.add_argument("--suite-path", default=".") ap.add_argument("--title", default="") ap.add_argument("--link-other", default="") ap.add_argument( "--path-err", default="/monitoring_err/", help="URL path for error page (redirect target)", ) ap.add_argument( "--suite-version", default="", help="Release tag shown in sticky bar (e.g. v1.3.0)", ) ap.add_argument( "--suite-version-url", default="", help="URL to tag on Forgejo (src/tag/...)", ) ap.add_argument( "--lang", default="", help="UI language en|fr (default: env TALER_MON_LANG or auto)", ) ap.add_argument( "--page-label", default="monitoring", help="Short name shown in titles/redirect box", ) args = ap.parse_args() if getattr(args, "lang", ""): import os os.environ["TALER_MON_LANG"] = args.lang log_text = args.log.read_text(errors="replace") if args.log.is_file() else "" now = datetime.now(timezone.utc) generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ") generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ") title = args.title or f"{args.page_label} {args.mode} · {args.hostname}" raw_for_counts = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()] n_err, n_warn, _, _ = count_status(raw_for_counts) if args.mode == "redirect": html_out = build_redirect_html( hostname=args.hostname, commit=args.commit, commit_url=args.commit_url, path_err=args.path_err, page_label=args.page_label, generated_at=generated_at, generated_iso=generated_iso, n_err=n_err, n_warn=n_warn, log_text=log_text, suite_version=getattr(args, "suite_version", "") or "", suite_version_url=getattr(args, "suite_version_url", "") or "", ) else: html_out = build_html( title=title, hostname=args.hostname, mode=args.mode, log_text=log_text, commit=args.commit, commit_url=args.commit_url, suite_path=args.suite_path, generated_at=generated_at, generated_iso=generated_iso, link_other=args.link_other or None, page_label=args.page_label, suite_version=getattr(args, "suite_version", "") or "", suite_version_url=getattr(args, "suite_version_url", "") or "", ) args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(html_out, encoding="utf-8") print(f"wrote {args.out} (errors={n_err} warnings={n_warn} level={status_level(n_err, n_warn)})") if __name__ == "__main__": main()