#!/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)", "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)", "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{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 = ""
if errors and mode == "err":
items = []
timeout_items = []
for i, e in enumerate(errors, 1):
slug = slug_error(i, e)
li = (
f'Whole-run wall clock exceeded. " 'Jump to detail →
\n' '{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}