#!/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",
"filter_errors": "Filter: show all errors (hide other log lines)",
"filter_warns": "Filter: show all warnings (hide other log lines)",
"filter_clear": "Clear filter · show all lines",
"filter_active_err": "Filter: errors only",
"filter_active_warn": "Filter: warnings only",
"filter_show_all": "Show all",
"env_context": "Monitoring env context",
"env_context_title": "Expand: agent env, suite pin, domain hosts & flags (from run header)",
"env_context_empty": "No run header in log",
"generated": "generated",
"version": "version",
"version_tree_link": "open this run’s git tree (commit)",
"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": "Suite name taler-monitoring ≠ URL. Only three public families (bare + slash each):",
"pages_i2": "1) /monitoring(+_err) on landing hosts · 2) /taler-monitoring-surface(+_err) · 3) /taler-monitoring-aptdeploy(+_err) on taler.hacktivism.ch",
"pages_i3": "No /taler-monitoring page; no /taler-monitoring-mail|mattermost (folded into surface)",
"pages_i4": "monpages: existence + content markers; bare → slash redir (not merchant code 21)",
"pages_i5": "Host-agent: staging → DEPLOY_WWW_ROOT; reverse-proxy handles only those three families",
"pages_i6": "This page host: {host} · path label: {label}",
"stage_title": "mytops stage monitoring · {host}",
"stage_summary": "betel only · CHF stage stack · not lifeline/prod · not GOA",
"stage_i1": "Host: betel (stage). Public mon HTML: stage.my.taler-ops.ch + stage.taler-ops.ch /monitoring/",
"stage_i2": "Focus: stage merchant ({host}) + exchange.stage.taler-ops.ch; bank optional (CHECK_BANK)",
"stage_i3": "Phases: {phases} · suite auto-upgrade each timer run",
"stage_i4": "Not GOA: no surface/aptdeploy/mail pages · no koopa-admin-log",
"stage_i5": "Timer: taler-monitoring-mytops-stage.timer (4h) · user taler-monitoring",
"stage_i6": "devtesting: fake-franken CHF via rusty.taler-ops.ch (geniban + fake-incoming)",
"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. Click error/warning counts to filter the log. Monitoring env context (agent header) is collapsed by default. 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",
"filter_errors": "Filtrer : toutes les erreurs (masquer le reste du journal)",
"filter_warns": "Filtrer : tous les avertissements (masquer le reste du journal)",
"filter_clear": "Effacer le filtre · tout afficher",
"filter_active_err": "Filtre : erreurs seulement",
"filter_active_warn": "Filtre : avertissements seulement",
"filter_show_all": "Tout afficher",
"env_context": "Contexte d'env. de monitoring",
"env_context_title": "Déplier : env agent, pin de suite, hôtes domaine et flags (en-tête d'exécution)",
"env_context_empty": "Pas d'en-tête d'exécution dans le journal",
"generated": "généré",
"version": "version",
"version_tree_link": "ouvrir l'arbre git de cette exécution (commit)",
"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": "Nom de suite taler-monitoring ≠ URL. Trois familles publiques seulement (sans et avec /) :",
"pages_i2": "1) /monitoring(+_err) · 2) /taler-monitoring-surface(+_err) · 3) /taler-monitoring-aptdeploy(+_err) sur taler.hacktivism.ch",
"pages_i3": "Pas de page /taler-monitoring ; pas de /taler-monitoring-mail|mattermost (dans surface)",
"pages_i4": "monpages : existence + marqueurs ; bare → slash (pas code 21 merchant)",
"pages_i5": "Host-agent : staging → DEPLOY_WWW_ROOT ; reverse-proxy seulement ces trois familles",
"stage_title": "Surveillance mytops stage · {host}",
"stage_summary": "betel uniquement · pile CHF stage · pas lifeline/prod · pas GOA",
"stage_i1": "Hôte : betel (stage). HTML mon : stage.my.taler-ops.ch + stage.taler-ops.ch /monitoring/",
"stage_i2": "Focus : merchant stage ({host}) + exchange.stage ; bank optionnel (CHECK_BANK)",
"stage_i3": "Phases : {phases} · auto-upgrade suite à chaque minuterie",
"stage_i4": "Pas GOA : pas de pages surface/aptdeploy/mail · pas de koopa-admin-log",
"stage_i5": "Minuterie : taler-monitoring-mytops-stage.timer (4 h) · user taler-monitoring",
"stage_i6": "devtesting : fake-franken CHF via rusty.taler-ops.ch (geniban + fake-incoming)",
"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. Compteurs = filtre. Contexte d'env. (en-tête agent) replié par défaut. 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"
# host-agent tees converter "wrote … (errors=N …)" into the same log
if line.lstrip().lower().startswith("wrote ") and "errors=" in line.lower():
return "meta"
# git pull / reset noise (commit subjects may contain the word ERROR)
if re.match(r"\s*HEAD is now at\b", line):
return "meta"
if "BLOCKER" in u or "┌ BLOCKER" in u:
return "blocker"
# Only real check badges (not free-text "ERROR" inside commit messages)
if re.search(r"┌\s*ERROR\b|\[\s*ERROR\b", u):
return "error"
if re.search(r"\bERROR monpages\b", line, re.I):
return "error"
if re.search(r"┌\s*WARN\b|\[\s*WARN\b", u):
return "warn"
if re.search(r"┌\s*INFO\b|\[\s*INFO\b", 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 = (
"
short
suite
mode
podman
"
"
merchant
httpd
ldd
verdict
note
"
)
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'
\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
# console_to_html "wrote … (errors=N warnings=M level=…)" is teed into the
# host-agent log; "errors=" uppercases to "ERRORS=" and must not count as ERROR.
if low.lstrip().startswith("wrote ") and "errors=" in low:
return True
if "level=red" in low and "wrote " 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())
# Only real check badges (not INFO text mentioning thresholds like "… ERROR (0=disable)")
if re.search(r"┌\s*ERROR\b|\[\s*ERROR\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
if classify(ln) not in ("error", "blocker"):
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"
# mytops stage (betel) — not GOA sticky text
if re.search(r"stage\.(my\.)?taler-ops\.ch|stage\.taler-ops\.ch|mytops-stage", host + " " + log_text, re.I):
items = [
ui(lang, "stage_i1"),
ui(lang, "stage_i2", host=host),
ui(lang, "stage_i3", phases=phase_txt),
ui(lang, "stage_i4"),
ui(lang, "stage_i5"),
ui(lang, "stage_i6"),
ui(lang, "pages_i6", host=host, label=label),
]
return {
"kind": "stage",
"lang": lang,
"title": ui(lang, "stage_title", host=host),
"summary": ui(lang, "stage_summary"),
"items": items,
}
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 is_env_context_start(ln: str) -> bool:
"""Host-agent / suite banner that opens the monitoring env header."""
s = ln.strip()
if re.match(r"^={4,}\s*.+\s*={4,}$", s):
return True
if s.startswith("env_file="):
return True
return False
def is_env_context_line(ln: str) -> bool:
"""
Lines belonging to the pre-check monitoring env header
(agent banner, suite pin, target domain / flags / timeout).
"""
s = ln.rstrip("\n")
if not s.strip():
return True # blank lines inside header block
t = s.strip()
if is_env_context_start(s):
return True
# agent header keys
if t.startswith(
(
"env_file=",
"domain=",
"hosts=",
"flags ",
"suite ",
"using suite",
"commit=",
"target domain=",
"note:",
"WARN: Forgejo",
"WARN suite-update",
)
):
return True
if t.startswith(" dir=") or t.startswith("dir="):
return True
# indented target-domain block from taler-monitoring.sh
if re.match(
r"^\s+(bank|exchange|merchant|currency|phases|flags|access|run_timeout|progress)\b",
s,
):
return True
# re-applying overlay chatter still part of startup
if "suite-overlay" in t.lower() and (
t.startswith("WARN") or t.startswith(" ") or "re-applying" in t.lower()
):
return True
return False
def is_env_context_end(ln: str) -> bool:
"""First line that is clearly past the env header (phases / checks start)."""
s = ln.strip()
if not s:
return False
# box headers / section titles for real check phases
if s.startswith("╔") or s.startswith("║") or s.startswith("╚"):
return True
if s.startswith("┌") or s.startswith("└") or s.startswith("["):
return True
if re.match(r"^==\s+", s) or re.match(r"^--\s+", s):
return True
if "numbered checks" in s.lower():
return True
if re.match(r"^\s*\d+%\s", s): # bare progress
return True
return False
def extract_env_context(lines: list[str]) -> tuple[list[str], list[str], str]:
"""
Split leading monitoring env header from the rest of the log.
Returns (env_lines, body_lines, hint_summary).
"""
if not lines:
return [], [], ""
# Find start: first env-context start within the first ~40 lines
start = None
for i, ln in enumerate(lines[:40]):
if is_env_context_start(ln):
start = i
break
if start is None:
# no banner — maybe only "target domain=" block at top (CLI without agent)
for i, ln in enumerate(lines[:15]):
if ln.strip().startswith("target domain=") or ln.strip().startswith(
"env_file="
):
start = i
break
if start is None:
return [], lines, ""
end = start
for j in range(start, len(lines)):
ln = lines[j]
if j > start and is_env_context_end(ln):
end = j
break
if j > start and not is_env_context_line(ln) and ln.strip():
# non-empty line that doesn't look like env → end before it
end = j
break
end = j + 1
else:
end = len(lines)
env = lines[start:end]
# trim trailing blanks from env
while env and not env[-1].strip():
env.pop()
body = lines[:start] + lines[end:]
# hint: suite + domain + phases
hint_parts: list[str] = []
for ln in env:
t = ln.strip()
if t.startswith("suite "):
hint_parts.append(t.split(" ")[0][:48])
elif t.startswith("domain="):
hint_parts.append(t.replace("domain=", "").split("·")[0].strip()[:40])
elif t.startswith("target domain="):
if not any("domain" in h for h in hint_parts):
hint_parts.append(t.split("=", 1)[1].strip()[:40])
elif re.match(r"^={4,}", t):
# agent label between equals
m = re.match(r"^={4,}\s*(.+?)\s*={4,}$", t)
if m:
lab = m.group(1)
# drop timestamp prefix if present
lab2 = re.sub(r"^\d{4}-\d{2}-\d{2}T\S+\s+", "", lab)
hint_parts.insert(0, lab2[:40])
hint = " · ".join(hint_parts[:3]) if hint_parts else f"{len(env)} lines"
return env, body, hint
def env_context_html(
env_lines: list[str],
*,
tid_to_slug: dict[str, str],
lang: str,
hint: str,
) -> str:
"""Collapsible monitoring env context (default closed)."""
if not env_lines:
return ""
body = "".join(render_line(ln, tid_to_slug, None) for ln in env_lines)
label = html.escape(ui(lang, "env_context"))
title = html.escape(ui(lang, "env_context_title"))
hint_esc = html.escape(hint or "")
# native : collapsed by default (no open attr)
return f"""{label}{hint_esc}
{body}
"""
def is_log_noise_line(ln: str) -> bool:
"""Drop git/update chatter that must not appear in public mon HTML."""
s = ln.strip()
if not s:
return False
low = s.lower()
if s.startswith("HEAD is now at"):
return True
if s.startswith("Your branch is") or s.startswith("branch '") or s.startswith('branch "'):
return True
if "set up to track" in low or "fast-forward" in low:
return True
if re.match(r"^M\t", s) or re.match(r"^M\s+\S", s):
return True # git status "M path"
if low.startswith("from https://") or low.startswith("from git@"):
return True
if "aktualisierung erzwungen" in low or "force update" in low:
return True
if low.startswith("fetch origin") or low.startswith("already at ") or low.startswith("upgraded "):
# keep "suite vX @ commit" lines from agent header; drop raw fetch chatter
if low.startswith("already at ") or low.startswith("upgraded "):
return True
if low.startswith("fetch origin"):
return True
if "ihr branch ist" in low or "bereits auf" in low:
return True
return False
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")
# Click error/warn stats → filter console to matching lines only (v1.11.0).
# Hash #filter-error / #filter-warn; #first-error / #first-warn also activate filter.
if n_err:
err_lbl = ui(lang, "error_one", n=n_err) if n_err == 1 else ui(lang, "error_many", n=n_err)
err_href = first_err_href or "#filter-error"
# Prefer filter hash; keep first-error as secondary scroll target via JS
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 n_warn:
warn_lbl = ui(lang, "warn_one", n=n_warn) if n_warn == 1 else ui(lang, "warn_many", n=n_warn)
warn_href = first_warn_href or "#filter-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)}'
filter_chip = (
f''
f''
f''
f""
)
# data attributes for JS i18n
filter_i18n = (
f'data-i18n-filter-err="{html.escape(ui(lang, "filter_active_err"))}" '
f'data-i18n-filter-warn="{html.escape(ui(lang, "filter_active_warn"))}" '
f'data-i18n-filter-clear="{html.escape(ui(lang, "filter_clear"))}"'
)
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:
# href is usually src/commit/ (exact run tree); label stays vX.Y.Z
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"""