taler-monitoring/site-gen/console_to_html.py
Hernâni Marques 8c99e13af1
release 1.18.9: FP domains default UI language fr globally
Workstation TALER_MON_LANG=en no longer locks English on *lefrancpaysan*;
only --lang / TALER_MON_LANG_SET=1 overrides. host-agent + HTML chrome.
2026-07-19 11:04:44 +02:00

2483 lines
94 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Convert taler-monitoring console log to a console-feel HTML page."""
from __future__ import annotations
import argparse
import html
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
# Display timestamps in Central European time (CEST/CET · MESZ/MEZ), same as `date` on CH/DE hosts.
try:
from zoneinfo import ZoneInfo
MON_TZ = ZoneInfo("Europe/Zurich")
except Exception: # pragma: no cover
MON_TZ = timezone.utc
def format_generated_now() -> tuple[str, str]:
"""Return (display_local, iso_with_offset) for sticky bar + <time datetime>."""
now = datetime.now(MON_TZ)
# ISO with offset so JS Date.parse / age stays correct
generated_iso = now.isoformat(timespec="seconds")
# Human: 2026-07-19 02:41:34 CEST (MESZ in German speech = CEST)
tz_name = now.tzname() or "CEST"
generated_at = now.strftime("%Y-%m-%d %H:%M:%S") + f" {tz_name}"
return generated_at, generated_iso
def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
"""Write file atomically (tmp + replace) so monpages never curls a half-written page."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
try:
tmp.write_text(text, encoding=encoding)
os.replace(tmp, path)
finally:
if tmp.exists():
try:
tmp.unlink()
except OSError:
pass
def ui_lang(explicit: str = "", host_hint: str = "") -> str:
"""en (default) or fr.
- TALER_MON_LANG_SET=1 → honour TALER_MON_LANG / explicit only (no host auto).
- Else: any *lefrancpaysan* / *francpaysan* host or log hint → fr
(workstation env TALER_MON_LANG=en must not force English on FP domains).
- Else: explicit / env / en.
"""
import os
env_lang = (os.environ.get("TALER_MON_LANG") or "").strip().lower()
lang = (explicit or env_lang or "").strip().lower()
locked = (os.environ.get("TALER_MON_LANG_SET") or "").strip() == "1"
is_fp = bool(
re.search(r"lefrancpaysan|francpaysan", host_hint or "", re.I)
)
def _norm(v: str) -> str:
if v in ("fr", "fra", "french"):
return "fr"
if v in ("en", "eng", "english"):
return "en"
return ""
if locked:
return _norm(lang) or "en"
if is_fp:
return "fr"
return _norm(lang) or "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: errors + a few dimmed context lines",
"filter_warns": "Filter: warnings + a few dimmed context lines",
"filter_infos": "Filter: info lines + a few dimmed context lines",
"filter_oks": "Filter: OK lines + a few dimmed context lines",
"filter_clear": "Clear filter · show all lines",
"filter_active_err": "Filter: errors + context",
"filter_active_warn": "Filter: warnings + context",
"filter_active_info": "Filter: info + context",
"filter_active_ok": "Filter: OK only + context",
"filter_show_all": "Show all",
"info_one": "{n} info",
"info_many": "{n} info",
"ok_one": "{n} ok",
"ok_many": "{n} ok",
"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 runs git tree (commit)",
"suite_version_title": "taler-monitoring suite version · exact run tree",
"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}",
"page_label_monitoring": "monitoring",
"page_label_surface": "surface",
"page_label_aptdeploy": "aptdeploy",
"mode_ok": "OK",
"mode_err": "ERR",
"mode_redirect": "REDIR",
"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 + ping · client floors (mobile/desktop/support) · TLS",
"mm_i1": "Outside-in phase mattermost (default https://mattermost.taler.net)",
"mm_i2": "Landing SPA, /api/v4/system/ping, X-Version-Id vs client mins (mobile/desktop/support, default ≥10.11.0)",
"mm_i3": "ERROR if server too old for current Android/iOS store or Desktop apps; WARN for web on EOL server",
"mm_i4": "Env: MATTERMOST_MOBILE/DESKTOP/SUPPORT_MIN_SERVER, MATTERMOST_CLIENT_CHECK=0 to skip",
"mm_i5": "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 / info (blue) / ok counts to filter the log (match lines plus dimmed neighbours). Suite version sits under the status pill. Monitoring env context is collapsed by default.",
"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 : erreurs + quelques lignes de contexte grisées",
"filter_warns": "Filtrer : avertissements + quelques lignes de contexte grisées",
"filter_infos": "Filtrer : infos + quelques lignes de contexte grisées",
"filter_oks": "Filtrer : lignes OK + quelques lignes de contexte grisées",
"filter_clear": "Effacer le filtre · tout afficher",
"filter_active_err": "Filtre : erreurs + contexte",
"filter_active_warn": "Filtre : avertissements + contexte",
"filter_active_info": "Filtre : infos + contexte",
"filter_active_ok": "Filtre : OK seulement + contexte",
"filter_show_all": "Tout afficher",
"info_one": "{n} info",
"info_many": "{n} infos",
"ok_one": "{n} ok",
"ok_many": "{n} ok",
"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)",
"suite_version_title": "version de la suite taler-monitoring · arbre exact de l'exécution",
"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 + interne",
"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}",
"page_label_monitoring": "surveillance",
"page_label_surface": "surface",
"page_label_aptdeploy": "aptdeploy",
"mode_ok": "OK",
"mode_err": "ERR",
"mode_redirect": "REDIR",
"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 + ping · seuils clients (mobile/desktop/support) · TLS",
"mm_i1": "Phase outside-in mattermost (défaut https://mattermost.taler.net)",
"mm_i2": "SPA, /api/v4/system/ping, X-Version-Id vs seuils clients (mobile/desktop/support, défaut ≥10.11.0)",
"mm_i3": "ERROR si serveur trop vieux pour apps store Android/iOS ou Desktop ; WARN web sur serveur EOL",
"mm_i4": "Env : MATTERMOST_MOBILE/DESKTOP/SUPPORT_MIN_SERVER, MATTERMOST_CLIENT_CHECK=0 pour ignorer",
"mm_i5": "Rapporté sur la page surface HTML",
"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 erreur / avertissement / info (bleu) / ok = filtres. Version de la suite sous le badge d'état. Contexte d'env. replié par défaut.",
"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<body>.+)$")
TID_RE = re.compile(r"\b(?P<tid>[a-z0-9_.-]+-\d+)\b", re.I)
def strip_ansi(s: str) -> str:
return ANSI_RE.sub("", s)
def is_numbered_check_line(line: str) -> bool:
"""True for real suite checks (global #NNN and/or tid like www.merchant-09).
SUMMARY box rows look like `` [ WARN ] 2`` / `` [ WARN ] warnings only``
and must NOT count as warn/error (v1.13.12).
"""
if re.search(r"#\d{3}\b", line):
return True
if TID_RE.search(line):
return True
return False
def classify(line: str) -> str:
u = line.upper()
low = line.lower()
# Real SUMMARY *box* only (╔/║/╚ frames or bare [ SUMMARY ] / [ RESUME ]).
# Never match free text like "perf summary" / "ladder summary" (those are INFO checks).
if re.search(r"\bSUMMARY\b", u) or re.search(r"\bRESUME\b", u):
if re.search(r"[╔║╚]", line) or re.match(
r"^\s*\[\s*(SUMMARY|RESUME)\s*\]", u
):
return "sum-header"
# else fall through to badge classifiers (INFO/OK/…)
# Double-line box frames from section() → section (no sum-* paint)
if re.match(r"^\s*[╔╚].*═", line) or re.match(r"^\s*║\s", line):
return "section"
# EN + FR section headers (i18n_tag ERROR→ERREUR, ERRORS→ERREURS)
if (
"--- ERRORS" in u
or "--- ERREURS" in u
or "ERRORS ·" in u
or "ERREURS ·" in u
or "ERRORS (" in u
or "ERREURS (" in u
):
return "meta"
if "numbered checks" in low:
return "meta"
if "totals:" in low or "totaux :" in low or low.strip().startswith("totaux"):
return "sum-totals"
# SUMMARY verdict chrome (not a check) — keep distinct for HTML boxes
if "warnings only" in low or "avertissements seulement" in low:
return "sum-verdict-warn"
if "all clear" in low or "tout est clair" in low:
return "sum-verdict-ok"
if "failed — see" in low or "failed - see" in low or "echec — voir" in low or "échec — voir" in low:
return "sum-verdict-err"
# host-agent tees converter "wrote … (errors=N …)" into the same log
if line.lstrip().lower().startswith("wrote ") and "errors=" in low:
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:
# SUMMARY may print " [ BLOCK ] 1" without a check id
if not is_numbered_check_line(line) and re.search(
r"[\[┌]\s*BLOCK(?:ER)?\s*[\]┐]", u
):
return "sum-block"
return "blocker"
# Only real check badges (not free-text "ERROR" inside commit messages,
# and not SUMMARY count rows like " [ ERROR ] 3").
# French console (i18n_tag): ERROR→ERREUR, WARN→AVERT (v1.18.1).
if re.search(r"\s*(?:ERROR|ERREUR)\b|\[\s*(?:ERROR|ERREUR)\b", u):
if not is_numbered_check_line(line):
return "sum-err"
return "error"
if re.search(r"\bERROR monpages\b", line, re.I):
return "error"
if re.search(r"\s*(?:WARN|AVERT)\b|\[\s*(?:WARN|AVERT)\b", u):
if not is_numbered_check_line(line):
return "sum-warn"
return "warn"
if re.search(r"\s*INFO\b|\[\s*INFO\b", u):
if not is_numbered_check_line(line):
return "sum-info"
return "info"
if re.search(r"\s*OK\b|\[\s*OK\b", u) or (
(" OK" in u or u.strip().startswith("OK")) and is_numbered_check_line(line)
):
if re.search(r"\s*OK\b|\[\s*OK\b", u) and not is_numbered_check_line(line):
return "sum-ok"
return "ok"
if re.search(r"[\[┌]\s*BLOCK\b", u) and not is_numbered_check_line(line):
return "sum-block"
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'<section class="apt-board empty" id="container-overview">\n'
f"<h2>{html.escape(title)}</h2>\n"
f"<p class=\"muted\">{html.escape(ui(lang, 'apt_board_empty'))}</p>\n"
f"</section>\n"
)
head = (
"<tr><th>short</th><th>suite</th><th>mode</th><th>podman</th>"
"<th>merchant</th><th>httpd</th><th>ldd</th><th>verdict</th><th>note</th></tr>"
)
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'<tr class="{vclass}">'
f'<td class="short">{html.escape(r.get("short", ""))}</td>'
f'<td>{html.escape(r.get("suite", ""))}</td>'
f'<td>{html.escape(r.get("mode", ""))}</td>'
f'<td>{html.escape(r.get("state", ""))}</td>'
f'<td class="mono">{html.escape(r.get("merchant", ""))}</td>'
f'<td>{html.escape(r.get("httpd", ""))}</td>'
f'<td>{html.escape(r.get("ldd", ""))}</td>'
f'<td class="verdict">{html.escape(r.get("verdict", ""))}</td>'
f'<td class="note">{html.escape(r.get("note", ""))}</td>'
f"</tr>"
)
summary = f"{len(rows)} containers · {n_ok} OK · {n_err} ERROR · {n_miss} missing"
return (
f'<section class="apt-board" id="container-overview">\n'
f"<h2>{html.escape(title)}</h2>\n"
f'<p class="board-summary">{html.escape(summary)}</p>\n'
f'<div class="board-scroll"><table class="apt-board-table">\n'
f"<thead>{head}</thead>\n<tbody>\n"
+ "\n".join(body_parts)
+ "\n</tbody></table></div>\n</section>\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
if "warnings only" in low or "all clear" in low:
return True
if "avertissements seulement" in low or "tout est clair" in low:
return True
if "totaux" in low and not is_numbered_check_line(ln):
return True
# SUMMARY count rows without #NNN / tid (e.g. " [ ERROR ] 3")
if re.search(r"[\[\u250c]\s*(ERROR|WARN|OK|INFO|BLOCK|ERREUR|AVERT)", ln, re.I) and not is_numbered_check_line(
ln
):
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:
u = ln.upper()
if (
"--- ERRORS" in u
or "--- ERREURS" in u
or "ERRORS (FAILED" in u
or "ERREURS (CONTROLES" in u
or "ERREURS (FAILED" in u
or "RUN TIMEOUT · EXTRAORDINARY" in u
):
in_block = True
continue
if in_block:
if (
ln.strip().startswith("---")
or "[ SUMMARY" in ln
or "[ RESUME" in u
or "totals:" in ln.lower()
or "totaux :" in ln.lower()
):
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 u or "ERREUR" in u) 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)")
# FR: i18n_tag ERROR→ERREUR (v1.18.1)
if (
re.search(r"\s*(?:ERROR|ERREUR)\b|\[\s*(?:ERROR|ERREUR)\b", ln, re.I)
and "--- ERRORS" not in u
and "--- ERREURS" not in u
):
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"^.*\b(?:ERROR|ERREUR)\b\s*[\]┐]?\s*", "", ln, flags=re.I
).strip(" ·")
if body and body not in errs and "failed — see" not in body.lower() and "echec — voir" 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, int, int | None, int | None, int | None, int | None]:
"""Return (n_err, n_warn, n_info, n_ok, first_err, first_warn, first_info, first_ok)."""
n_err = 0
n_warn = 0
n_info = 0
n_ok = 0
first_err: int | None = None
first_warn: int | None = None
first_info: int | None = None
first_ok: 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
elif kind == "info":
n_info += 1
if first_info is None:
first_info = i
elif kind == "ok":
n_ok += 1
if first_ok is None:
first_ok = i
return n_err, n_warn, n_info, n_ok, first_err, first_warn, first_info, first_ok
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:
# Drop blank log lines entirely — they only create black voids in the console
# (especially visible under warn/error filter). Anchors still attach to next real line.
if not (ln or "").strip():
return ""
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'<a class="jump" href="#{html.escape(slug)}">{html.escape(tid)}</a>',
)
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 (inline, zero layout impact)
spans = "".join(
f'<span id="{html.escape(x)}" class="anchor-only"></span>'
for x in extra_ids[1:]
)
return f'{spans}<div{id_attr} class="line {kind}">{esc}</div>\n'
return f'<div{id_attr} class="line {kind}">{esc}</div>\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 "?"
# Resolve lang again with host/log hint (*lefrancpaysan* → fr unless LANG_SET=1)
_ex = lang if lang in ("en", "fr") else ""
lang = ui_lang(explicit=_ex, host_hint=host + " " + log_text)
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"),
ui(lang, "mm_i4"),
ui(lang, "mm_i5"),
] + _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 <details>: collapsed by default (no open attr)
return f"""<details class="env-context" id="mon-env-context">
<summary class="env-context-summary" title="{title}">
<span class="env-context-chevron" aria-hidden="true"></span>
<span class="env-context-label">{label}</span>
<span class="env-context-hint">{hint_esc}</span>
</summary>
<div class="env-context-body" role="region" aria-label="{label}">
{body} </div>
</details>
"""
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
# Converter / host-agent noise that tee appends into the run log after SUMMARY
if "syntaxwarning" in low or "invalid escape sequence" in low:
return True
if "console_to_html.py:" in low:
return True
if re.match(r"^\s*if\s*\(/\^\[╔", s):
return True
if low.lstrip().startswith("wrote ") and "errors=" in low:
return True
if re.match(r"^html\s+\S+", low) and ("" in s or "->" in s):
return True
if "public url (expected)" 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 = "",
n_info: int = 0,
n_ok: int = 0,
) -> 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(host_hint=hostname or "")
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'<a class="stat err" href="#filter-error" data-filter="error" '
f'role="button" aria-pressed="false" '
f'data-first="{html.escape(err_href)}" '
f'title="{html.escape(ui(lang, "filter_errors"))}">{html.escape(err_lbl)}</a>'
)
else:
err_lbl = ui(lang, "error_many", n=n_err)
err_stat = f'<span class="stat err muted">{html.escape(err_lbl)}</span>'
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'<a class="stat warn" href="#filter-warn" data-filter="warn" '
f'role="button" aria-pressed="false" '
f'data-first="{html.escape(warn_href)}" '
f'title="{html.escape(ui(lang, "filter_warns"))}">{html.escape(warn_lbl)}</a>'
)
else:
warn_lbl = ui(lang, "warn_many", n=n_warn)
warn_stat = f'<span class="stat warn muted">{html.escape(warn_lbl)}</span>'
# Blue badge = INFO filter (v1.16.0)
if n_info:
info_lbl = ui(lang, "info_one", n=n_info) if n_info == 1 else ui(lang, "info_many", n=n_info)
info_stat = (
f'<a class="stat info" href="#filter-info" data-filter="info" '
f'role="button" aria-pressed="false" '
f'title="{html.escape(ui(lang, "filter_infos"))}">{html.escape(info_lbl)}</a>'
)
else:
info_lbl = ui(lang, "info_many", n=n_info)
info_stat = f'<span class="stat info muted">{html.escape(info_lbl)}</span>'
# Green count badge = OK-only filter
if n_ok:
ok_lbl = ui(lang, "ok_one", n=n_ok) if n_ok == 1 else ui(lang, "ok_many", n=n_ok)
ok_stat = (
f'<a class="stat ok" href="#filter-ok" data-filter="ok" '
f'role="button" aria-pressed="false" '
f'title="{html.escape(ui(lang, "filter_oks"))}">{html.escape(ok_lbl)}</a>'
)
else:
ok_lbl = ui(lang, "ok_many", n=n_ok)
ok_stat = f'<span class="stat ok muted">{html.escape(ok_lbl)}</span>'
filter_chip = (
f'<span class="filter-chip" id="filter-chip" hidden>'
f'<span class="filter-chip-label" id="filter-chip-label"></span>'
f'<button type="button" class="filter-clear" id="filter-clear" '
f'title="{html.escape(ui(lang, "filter_clear"))}">'
f'{html.escape(ui(lang, "filter_show_all"))}</button>'
f"</span>"
)
# 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-info="{html.escape(ui(lang, "filter_active_info"))}" '
f'data-i18n-filter-ok="{html.escape(ui(lang, "filter_active_ok"))}" '
f'data-i18n-filter-clear="{html.escape(ui(lang, "filter_clear"))}"'
)
other = ""
if link_other:
other = (
f'<a class="nav-link" href="{html.escape(link_other)}">'
f"{html.escape(link_other)}</a>"
)
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"<li>{html.escape(str(it))}</li>" for it in (scope.get("items") or [])
)
suite_bit = (
f" · <code>{html.escape(suite_path)}</code>" if suite_path else ""
)
# Suite version tag under status pill (not the blue filter badge) — v1.16.0
ver = (suite_version or "").strip()
ver_url = (suite_version_url or "").strip()
if ver:
ver_cls = "suite-ver-tag" + ("" if level == "green" else " dim")
ver_title = html.escape(
ui(lang, "suite_version_title") + " · " + ver
+ (" · " + ui(lang, "version_tree_link") if ver_url else "")
)
if ver_url:
version_tag = (
f'<a class="{ver_cls}" href="{html.escape(ver_url)}" '
f'title="{ver_title}" rel="noopener noreferrer">{html.escape(ver)}</a>'
)
else:
version_tag = f'<span class="{ver_cls}" title="{ver_title}">{html.escape(ver)}</span>'
else:
version_tag = ""
# Localize page label for sticky host title (monitoring → surveillance in FR)
pl = (page_label or "monitoring").lower().replace("_", "-")
if "surface" in pl:
pl_disp = ui(lang, "page_label_surface")
elif "aptdeploy" in pl or "apt-deploy" in pl:
pl_disp = ui(lang, "page_label_aptdeploy")
else:
pl_disp = ui(lang, "page_label_monitoring")
mode_key = f"mode_{mode}" if mode in ("ok", "err", "redirect") else ""
mode_disp = ui(lang, mode_key) if mode_key and ui(lang, mode_key) != mode_key else mode.upper()
# Compact sticky row: status stack (pill + suite version under OK/WARN/ERRORS)
return f"""
<div class="sticky-bar sticky-{level}" id="status-bar" role="status"
data-errors="{n_err}" data-warnings="{n_warn}" data-info="{n_info}" data-ok="{n_ok}"
data-level="{level}"
data-scope-kind="{scope_kind}" {filter_i18n}>
<div class="sticky-bar-row primary">
<span class="status-stack">
<span class="status-pill sticky-{level}">{html.escape(status_txt)}</span>
{version_tag}
</span>
<span class="host">{html.escape(pl_disp)} · {html.escape(hostname)}</span>
<span class="sep">·</span>
{err_stat}
<span class="sep">·</span>
{warn_stat}
<span class="sep">·</span>
{info_stat}
<span class="sep">·</span>
{ok_stat}
{filter_chip}
<span class="sep">·</span>
<span class="generated"
data-generated-iso="{html.escape(generated_iso)}"
title="{html.escape(generated_at)}">
{html.escape(ui(lang, "generated"))} <time datetime="{html.escape(generated_iso)}">{html.escape(generated_at)}</time>
<span class="age" id="generated-age"></span>
</span>
<button type="button" class="scope-toggle" id="scope-toggle"
aria-expanded="false" aria-controls="sticky-scope-panel"
title="{html.escape(ui(lang, "what_monitors_title"))}">
<span class="scope-chevron" aria-hidden="true"></span>
<span class="scope-toggle-label">{html.escape(ui(lang, "what_monitors"))}</span>
<span class="scope-toggle-hint">{scope_summary}</span>
</button>
</div>
<div class="sticky-scope-panel" id="sticky-scope-panel" hidden>
<div class="sticky-bar-row secondary">
<span class="badge mode-{html.escape(mode)}">{html.escape(mode_disp)}</span>
{html.escape(ui(lang, "source"))} {commit_html}{suite_bit}
{(" · " + other) if other else ""}
</div>
<div class="scope-body">
<h3 class="scope-title">{scope_title}</h3>
<p class="scope-lead">{scope_summary}</p>
<ul class="scope-list">
{lis}
</ul>
</div>
</div>
</div>
"""
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; }
.status-stack {
display: inline-flex; flex-direction: column; align-items: flex-start;
gap: 2px; margin-right: 2px;
}
.suite-ver-tag {
display: inline-block; font-size: 10px; font-weight: 700;
letter-spacing: 0.04em; padding: 0 6px; border-radius: 2px;
color: var(--ok); border: 1px solid #1f6b3a; background: #0a1a10;
text-decoration: none; line-height: 1.4;
}
.suite-ver-tag.dim { color: #9ab; border-color: #345; background: #0a1018; opacity: 0.9; }
.suite-ver-tag:hover { filter: brightness(1.15); text-decoration: underline; }
.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.info { color: var(--info); border-color: #234; background: #0a1520; }
.stat.ok { color: var(--ok); border-color: #1f6b3a; background: #0a1a10; }
.stat.muted { opacity: 0.55; font-weight: 600; }
a.stat:hover { text-decoration: underline; filter: brightness(1.15); }
a.stat[aria-pressed="true"] {
box-shadow: 0 0 0 1px currentColor inset;
filter: brightness(1.2);
}
.filter-chip {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600;
padding: 1px 4px 1px 8px; border-radius: 3px;
border: 1px solid #456; background: #0a1520; color: #9cf;
}
.filter-chip[hidden] { display: none !important; }
body.filter-error .filter-chip { border-color: #a33; background: #1a0a0a; color: var(--err); }
body.filter-warn .filter-chip { border-color: #a68b2d; background: #1a1608; color: var(--warn); }
body.filter-info .filter-chip { border-color: #246; background: #0a1520; color: var(--info); }
body.filter-ok .filter-chip { border-color: #1f6b3a; background: #0a1a10; color: var(--ok); }
.filter-clear {
font: inherit; font-size: 11px; font-weight: 700;
cursor: pointer; color: inherit;
background: #0006; border: 1px solid #555; border-radius: 3px;
padding: 1px 8px;
}
.filter-clear:hover { border-color: #aaa; background: #222; }
/* Console filter: sticky + overviews stay; match lines + dimmed neighbours (v1.13.11+)
v1.15.2: stamp out black voids — hide blanks, zero-size non-matches, compact console */
body.filter-error #mon-console,
body.filter-warn #mon-console,
body.filter-info #mon-console,
body.filter-ok #mon-console {
padding: 6px 8px;
min-height: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
body.filter-error #mon-console .line:not(.error):not(.blocker):not(.filter-ctx),
body.filter-warn #mon-console .line:not(.warn):not(.filter-ctx),
body.filter-info #mon-console .line:not(.info):not(.filter-ctx),
body.filter-ok #mon-console .line:not(.ok):not(.filter-ctx) {
display: none !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
overflow: hidden !important;
line-height: 0 !important;
}
/* empty / whitespace-only lines never take space in filter mode */
body.filter-error #mon-console .line:empty,
body.filter-warn #mon-console .line:empty,
body.filter-info #mon-console .line:empty,
body.filter-ok #mon-console .line:empty,
body.filter-error #mon-console .line.line-blank,
body.filter-warn #mon-console .line.line-blank,
body.filter-info #mon-console .line.line-blank,
body.filter-ok #mon-console .line.line-blank {
display: none !important;
}
/* Context lines: grayed / muted so the match still stands out */
body.filter-error #mon-console .line.filter-ctx,
body.filter-warn #mon-console .line.filter-ctx,
body.filter-info #mon-console .line.filter-ctx,
body.filter-ok #mon-console .line.filter-ctx {
opacity: 0.55;
color: #8a8a8a !important;
filter: grayscale(0.85);
margin: 0 !important;
padding: 1px 4px !important;
border: none !important;
background: transparent !important;
}
body.filter-error #mon-console .line.filter-ctx a.jump,
body.filter-warn #mon-console .line.filter-ctx a.jump,
body.filter-info #mon-console .line.filter-ctx a.jump,
body.filter-ok #mon-console .line.filter-ctx a.jump {
color: #6a6a6a !important;
}
/* Match lines stay tight */
body.filter-error #mon-console .line.error,
body.filter-error #mon-console .line.blocker,
body.filter-warn #mon-console .line.warn,
body.filter-info #mon-console .line.info,
body.filter-ok #mon-console .line.ok {
margin: 1px 0 !important;
padding: 3px 6px !important;
}
/* Compact ellipsis between distant clusters (not a black slab) */
body.filter-error #mon-console .filter-gap,
body.filter-warn #mon-console .filter-gap,
body.filter-info #mon-console .filter-gap,
body.filter-ok #mon-console .filter-gap {
display: block;
color: #666;
text-align: center;
padding: 0;
margin: 2px 0;
letter-spacing: 0.25em;
font-size: 10px;
line-height: 1.2;
user-select: none;
border: none;
background: transparent;
height: auto;
}
body:not(.filter-error):not(.filter-warn):not(.filter-info):not(.filter-ok) #mon-console .filter-gap { display: none !important; }
body.filter-error .env-context,
body.filter-warn .env-context,
body.filter-info .env-context,
body.filter-ok .env-context { display: none !important; }
body.filter-warn .err-top,
body.filter-info .err-top,
body.filter-ok .err-top { display: none; }
/* secondary jump anchors: never affect layout */
#mon-console .anchor-only {
display: inline;
width: 0;
height: 0;
overflow: hidden;
position: absolute;
}
/* v1.12.0: collapsible monitoring env context (default closed) */
.env-context {
margin: 0 0 12px;
max-width: 1200px;
border: 1px solid #333;
border-radius: 4px;
background: #0a0a0a;
}
.env-context-summary {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
list-style: none;
padding: 8px 12px;
font-size: 12px;
color: #ccc;
user-select: none;
}
.env-context-summary::-webkit-details-marker { display: none; }
.env-context-summary:hover { background: #121212; color: #fff; }
.env-context[open] .env-context-summary {
border-bottom: 1px solid #2a2a2a;
background: #101010;
}
.env-context-chevron {
display: inline-block;
width: 0; height: 0;
border-left: 5px solid #888;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.12s ease;
flex-shrink: 0;
}
.env-context[open] .env-context-chevron {
transform: rotate(90deg);
border-left-color: #ccc;
}
.env-context-label {
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
font-size: 11px;
color: #9ab;
white-space: nowrap;
}
.env-context-hint {
color: #777;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.env-context-body {
padding: 8px 12px 10px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
max-height: min(50vh, 28rem);
overflow: auto;
}
.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 = """
<script>
(function () {
function fmtAge(sec) {
if (sec < 0) sec = 0;
var lang = (document.documentElement.getAttribute("lang") || "en").toLowerCase();
var n;
if (sec < 60) {
n = sec;
return lang === "fr" ? ("il y a " + n + "s") : (n + "s ago");
}
if (sec < 3600) {
n = Math.floor(sec / 60);
return lang === "fr" ? ("il y a " + n + " min") : (n + "m ago");
}
if (sec < 86400) {
n = Math.floor(sec / 3600);
return lang === "fr" ? ("il y a " + n + " h") : (n + "h ago");
}
n = Math.floor(sec / 86400);
return lang === "fr" ? ("il y a " + n + " j") : (n + "d ago");
}
function tick() {
var el = document.querySelector("[data-generated-iso]");
var age = document.getElementById("generated-age");
if (!el || !age) return;
var iso = el.getAttribute("data-generated-iso");
if (!iso) return;
var t = Date.parse(iso);
if (isNaN(t)) return;
var sec = Math.floor((Date.now() - t) / 1000);
age.textContent = fmtAge(sec);
age.title = (document.documentElement.getAttribute("lang") || "en").toLowerCase() === "fr"
? "âge de la page · mise à jour chaque seconde"
: "page age · updates every second";
}
tick();
setInterval(tick, 1000);
// Sticky-bar collapsible: what this page monitors
var btn = document.getElementById("scope-toggle");
var panel = document.getElementById("sticky-scope-panel");
if (btn && panel) {
var key = "taler-mon-scope-open:" + (location.pathname || "");
function setOpen(open) {
btn.setAttribute("aria-expanded", open ? "true" : "false");
if (open) panel.removeAttribute("hidden");
else panel.setAttribute("hidden", "");
try { localStorage.setItem(key, open ? "1" : "0"); } catch (e) {}
}
btn.addEventListener("click", function () {
setOpen(btn.getAttribute("aria-expanded") !== "true");
});
try {
if (localStorage.getItem(key) === "1") setOpen(true);
} catch (e) {}
}
// v1.11.0: click error/warn stats → filter log; sticky + overviews stay
// v1.13.11: dimmed neighbour context; v1.15.2: stamp out black voids, compact layout
var bar = document.getElementById("status-bar");
var chip = document.getElementById("filter-chip");
var chipLabel = document.getElementById("filter-chip-label");
var clearBtn = document.getElementById("filter-clear");
var consoleEl = document.getElementById("mon-console");
// useful neighbours each side (skip blanks / SUMMARY chrome / pure box frames)
var FILTER_CTX = 2;
function currentFilter() {
if (document.body.classList.contains("filter-error")) return "error";
if (document.body.classList.contains("filter-warn")) return "warn";
if (document.body.classList.contains("filter-info")) return "info";
if (document.body.classList.contains("filter-ok")) return "ok";
return "";
}
function clearFilterContext() {
if (!consoleEl) return;
consoleEl.querySelectorAll(".line.filter-ctx").forEach(function (el) {
el.classList.remove("filter-ctx");
});
consoleEl.querySelectorAll(".filter-gap").forEach(function (el) {
el.parentNode && el.parentNode.removeChild(el);
});
document.body.classList.remove("filter-compact");
}
function markFilterContext(mode) {
clearFilterContext();
if (!consoleEl || !mode) return;
document.body.classList.add("filter-compact");
var lines = Array.prototype.slice.call(consoleEl.querySelectorAll(".line"));
if (!lines.length) return;
function isMatch(el) {
if (mode === "error") {
return el.classList.contains("error") || el.classList.contains("blocker");
}
if (mode === "warn") return el.classList.contains("warn");
if (mode === "info") return el.classList.contains("info");
if (mode === "ok") return el.classList.contains("ok");
return false;
}
function isBlankLine(el) {
var t = (el.textContent || "").replace(/\u00a0/g, " ").trim();
return t.length === 0;
}
function isLowValue(el) {
if (isBlankLine(el)) return true;
if (el.classList.contains("sum-header") || el.classList.contains("sum-ok")
|| el.classList.contains("sum-warn") || el.classList.contains("sum-err")
|| el.classList.contains("sum-info") || el.classList.contains("sum-block")
|| el.classList.contains("sum-totals") || el.classList.contains("sum-verdict-ok")
|| el.classList.contains("sum-verdict-warn") || el.classList.contains("sum-verdict-err")) {
return true;
}
var t = (el.textContent || "").replace(/\u00a0/g, " ").trim();
// Skip pure chrome lines (spaces/tabs/newlines/hyphen + Unicode box-drawing).
// Do NOT use a JS character-class regex with box glyphs here: when this
// script is embedded in a Python string, Python emits SyntaxWarning for
// sequences like \\- and the glyphs show up as noise in mon logs.
if (!t.length) return true;
for (var bi = 0; bi < t.length; bi++) {
var bc = t.charCodeAt(bi);
if (bc === 32 || bc === 9 || bc === 10 || bc === 13 || bc === 0x2d) continue;
if (bc >= 0x2500 && bc <= 0x257f) continue; // U+2500U+257F box drawing
return false;
}
return true;
}
var keep = new Array(lines.length);
var i, j, k, n, idx;
for (i = 0; i < lines.length; i++) keep[i] = 0;
for (i = 0; i < lines.length; i++) {
if (!isMatch(lines[i])) continue;
keep[i] = 1;
n = 0;
idx = i - 1;
while (idx >= 0 && n < FILTER_CTX) {
if (keep[idx] === 1) { idx--; continue; }
if (isLowValue(lines[idx])) { idx--; continue; }
keep[idx] = 2;
n++;
idx--;
}
n = 0;
idx = i + 1;
while (idx < lines.length && n < FILTER_CTX) {
if (keep[idx] === 1) { idx++; continue; }
if (isLowValue(lines[idx])) { idx++; continue; }
keep[idx] = 2;
n++;
idx++;
}
}
for (j = 0; j < lines.length; j++) {
if (keep[j] === 2) lines[j].classList.add("filter-ctx");
if (isBlankLine(lines[j])) {
keep[j] = 0;
lines[j].classList.add("line-blank");
}
}
var prevVisible = -2;
for (k = 0; k < lines.length; k++) {
if (keep[k] === 0) continue;
if (prevVisible >= 0 && k > prevVisible + 1) {
var gap = document.createElement("div");
gap.className = "filter-gap";
gap.setAttribute("aria-hidden", "true");
gap.textContent = "· · ·";
lines[k].parentNode.insertBefore(gap, lines[k]);
}
prevVisible = k;
}
}
function setFilter(mode, opts) {
opts = opts || {};
document.body.classList.remove("filter-error", "filter-warn", "filter-info", "filter-ok");
var errA = document.querySelector('a.stat.err[data-filter="error"]');
var warnA = document.querySelector('a.stat.warn[data-filter="warn"]');
var infoA = document.querySelector('a.stat.info[data-filter="info"]');
var okA = document.querySelector('a.stat.ok[data-filter="ok"]');
[errA, warnA, infoA, okA].forEach(function (a) {
if (a) a.setAttribute("aria-pressed", "false");
});
if (mode === "error") {
document.body.classList.add("filter-error");
if (errA) errA.setAttribute("aria-pressed", "true");
} else if (mode === "warn") {
document.body.classList.add("filter-warn");
if (warnA) warnA.setAttribute("aria-pressed", "true");
} else if (mode === "info") {
document.body.classList.add("filter-info");
if (infoA) infoA.setAttribute("aria-pressed", "true");
} else if (mode === "ok") {
document.body.classList.add("filter-ok");
if (okA) okA.setAttribute("aria-pressed", "true");
}
if (mode === "error" || mode === "warn" || mode === "info" || mode === "ok") {
markFilterContext(mode);
} else {
clearFilterContext();
}
if (chip && chipLabel && bar) {
if (mode === "error") {
chip.hidden = false;
chipLabel.textContent = bar.getAttribute("data-i18n-filter-err") || "Filter: errors + context";
} else if (mode === "warn") {
chip.hidden = false;
chipLabel.textContent = bar.getAttribute("data-i18n-filter-warn") || "Filter: warnings + context";
} else if (mode === "info") {
chip.hidden = false;
chipLabel.textContent = bar.getAttribute("data-i18n-filter-info") || "Filter: info + context";
} else if (mode === "ok") {
chip.hidden = false;
chipLabel.textContent = bar.getAttribute("data-i18n-filter-ok") || "Filter: OK only + context";
} else {
chip.hidden = true;
chipLabel.textContent = "";
}
}
if (opts.updateHash !== false) {
var want = mode === "error" ? "#filter-error"
: mode === "warn" ? "#filter-warn"
: mode === "info" ? "#filter-info"
: mode === "ok" ? "#filter-ok" : "";
if (want) {
if (location.hash !== want) {
try { history.replaceState(null, "", want); } catch (e) { location.hash = want; }
}
} else if (/^#(filter-error|filter-warn|filter-info|filter-ok|first-error|first-warn)$/.test(location.hash || "")) {
try { history.replaceState(null, "", location.pathname + location.search); } catch (e) {}
}
}
if (opts.scrollFirst && mode) {
var sel = mode === "error" ? ".line.error, .line.blocker"
: mode === "warn" ? ".line.warn"
: mode === "info" ? ".line.info"
: ".line.ok";
var id = mode === "error" ? "first-error" : mode === "warn" ? "first-warn" : "";
var el = id ? document.getElementById(id) : null;
if (el && el.scrollIntoView) {
setTimeout(function () { el.scrollIntoView({ block: "start", behavior: "smooth" }); }, 30);
} else if (consoleEl) {
var first = consoleEl.querySelector(sel);
if (first && first.scrollIntoView) {
setTimeout(function () { first.scrollIntoView({ block: "start", behavior: "smooth" }); }, 30);
}
}
}
}
function toggleFilter(mode) {
if (currentFilter() === mode) setFilter("", { updateHash: true });
else setFilter(mode, { updateHash: true, scrollFirst: true });
}
document.querySelectorAll("a.stat[data-filter]").forEach(function (a) {
a.addEventListener("click", function (ev) {
ev.preventDefault();
var mode = a.getAttribute("data-filter");
if (mode === "error" || mode === "warn" || mode === "info" || mode === "ok") {
toggleFilter(mode);
}
});
});
if (clearBtn) {
clearBtn.addEventListener("click", function () {
setFilter("", { updateHash: true });
});
}
function applyHash() {
var h = (location.hash || "").replace(/^#/, "");
if (h === "filter-error" || h === "first-error") {
setFilter("error", { updateHash: false, scrollFirst: h === "first-error" });
} else if (h === "filter-warn" || h === "first-warn") {
setFilter("warn", { updateHash: false, scrollFirst: h === "first-warn" });
} else if (h === "filter-info") {
setFilter("info", { updateHash: false, scrollFirst: true });
} else if (h === "filter-ok") {
setFilter("ok", { updateHash: false, scrollFirst: true });
} else if (currentFilter()) {
// keep filter if hash is something else (e.g. err-slug)
}
}
applyHash();
window.addEventListener("hashchange", applyHash);
})();
</script>
"""
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()
if not is_log_noise_line(strip_ansi(l))
]
while raw_lines and not raw_lines[-1].strip():
raw_lines.pop()
# Split monitoring env context (collapsible, default closed) from check log
env_lines, body_raw, env_hint = extract_env_context(raw_lines)
# Sticky counts from full log; first-error anchors index into body only
n_err, n_warn, n_info, n_ok, _, _, _, _ = count_status(raw_lines)
_e2, _w2, _i2, _o2, first_err_i, first_warn_i, _fi, _fo = count_status(body_raw)
del _e2, _w2, _i2, _o2, _fi, _fo
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(body_raw):
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(host_hint=hostname + "\n" + log_text)
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'<li id="{html.escape(slug)}">'
f'<a href="#{html.escape(slug)}-line">{html.escape(e)}</a></li>'
)
if is_run_timeout_text(e):
timeout_items.append(li)
else:
items.append(li)
if timeout_items:
err_nav += (
'<section class="err-top extraordinary" id="run-timeout-top">\n'
"<h2>EXTRAORDINARY · RUN TIMEOUT</h2>\n"
"<p>Whole-run wall clock exceeded. "
'<a href="#err-run-timeout-line">Jump to detail →</a></p>\n'
'<ol class="err-list">\n'
+ "\n".join(timeout_items[:1])
+ "\n</ol>\n</section>\n"
)
rest = items if timeout_items else (timeout_items + items)
if rest or not timeout_items:
err_nav += (
'<section class="err-top">\n'
f"<h2>ERRORS ({len(errors)}) — jump to detail</h2>\n"
'<ol class="err-list">\n'
+ "\n".join(rest if rest else items)
+ "\n</ol>\n</section>\n"
)
commit_short = commit[:12] if commit else "unknown"
commit_html = (
f'<a href="{html.escape(commit_url)}">{html.escape(commit_short)}</a>'
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(host_hint=hostname + "\n" + log_text)
scope = monitoring_scope(page_label, hostname, log_text, lang=lang)
page_lang = lang
footer_txt = ui(lang, "footer")
env_html = env_context_html(
env_lines, tid_to_slug=tid_to_slug, lang=lang, hint=env_hint
)
bar = sticky_bar_html(
generated_at=generated_at,
generated_iso=generated_iso,
n_err=n_err,
n_warn=n_warn,
n_info=n_info,
n_ok=n_ok,
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"""<!DOCTYPE html>
<html lang="{html.escape(lang if "lang" in dir() else "en")}">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="generated" content="{html.escape(generated_iso)}"/>
<meta name="taler-mon-page" content="complete"/>
<meta name="taler-mon-kind" content="{html.escape(kind)}"/>
<title>{html.escape(title)}</title>
<style>
:root {{
--bg: #0c0c0c;
--fg: #c8c8c8;
--dim: #666;
--ok: #3ddc84;
--err: #ff5c5c;
--warn: #e6c07b;
--info: #61afef;
--blocker: #c678dd;
--sec: #56b6c2;
--border: #222;
--panel: #121212;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, Menlo, Consolas, monospace;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0; background: var(--bg); color: var(--fg);
font-size: 13px; line-height: 1.45;
}}
{STICKY_CSS}
main {{ padding: 12px 14px 48px; max-width: 1200px; }}
.err-top {{
background: var(--panel); border: 1px solid #422;
padding: 10px 12px; margin-bottom: 14px; border-radius: 4px;
}}
.err-top.extraordinary {{
border-color: #a44; background: #1a0808;
box-shadow: 0 0 0 1px #622 inset;
}}
.err-top.extraordinary h2 {{ color: #ff8a8a; }}
.err-top.extraordinary p {{ margin: 0 0 8px; color: var(--fg); font-size: 12px; }}
.err-top.extraordinary p a {{ color: #ff8a8a; font-weight: 600; }}
.err-top h2 {{ margin: 0 0 8px; font-size: 13px; color: var(--err); }}
.err-list {{ margin: 0; padding-left: 1.2em; }}
.err-list li {{ margin: 4px 0; }}
.err-list a {{ color: var(--err); text-decoration: none; }}
.err-list a:hover {{ text-decoration: underline; }}
#err-run-timeout-line {{
outline: 1px solid #a44; background: #1a0808;
padding: 4px 6px; margin: 4px 0; border-radius: 3px;
}}
/* aptdeploy container overview board */
.apt-board {{
background: var(--panel); border: 1px solid #345;
padding: 10px 12px; margin-bottom: 14px; border-radius: 4px;
}}
.apt-board h2 {{ margin: 0 0 6px; font-size: 13px; color: var(--info); }}
.apt-board .board-summary {{ margin: 0 0 8px; color: var(--dim); font-size: 12px; }}
.apt-board.empty p.muted {{ color: var(--dim); margin: 0; font-size: 12px; }}
.board-scroll {{ overflow-x: auto; }}
.apt-board-table {{
width: 100%; border-collapse: collapse; font-size: 12px;
min-width: 720px;
}}
.apt-board-table th {{
text-align: left; color: #9ab; font-weight: 700; font-size: 11px;
border-bottom: 1px solid var(--border); padding: 4px 8px;
}}
.apt-board-table td {{
padding: 5px 8px; border-bottom: 1px solid #1a1a1a; vertical-align: top;
}}
.apt-board-table td.mono {{ font-variant-numeric: tabular-nums; }}
.apt-board-table td.short {{ font-weight: 700; color: #eee; }}
.apt-board-table td.verdict {{ font-weight: 800; letter-spacing: 0.04em; }}
.apt-board-table tr.v-ok td.verdict {{ color: var(--ok); }}
.apt-board-table tr.v-err td.verdict {{ color: var(--err); }}
.apt-board-table tr.v-miss td.verdict {{ color: var(--warn); }}
.apt-board-table tr.v-err {{ background: #1a0a0a; }}
.apt-board-table tr.v-ok {{ background: #0a120e; }}
.apt-board-table tr.v-miss {{ background: #1a1608; }}
.apt-board-table td.note {{ color: var(--dim); max-width: 14rem; }}
.console {{
background: #0a0a0a; border: 1px solid var(--border);
border-radius: 4px; padding: 8px 10px;
white-space: pre-wrap; word-break: break-word;
}}
.line {{ padding: 1px 0; }}
.line.ok {{ color: var(--ok); }}
.line.error {{ color: var(--err); }}
.line.warn {{ color: var(--warn); }}
.line.info {{ color: var(--info); }}
.line.blocker {{ color: var(--blocker); }}
/* Phase section titles (╔═║╚ from section()) — light chrome, NOT sum-* paint */
.line.section {{
color: #9cf;
margin-top: 10px;
font-weight: 700;
background: transparent;
border-left: 3px solid #345;
padding: 2px 0 2px 8px;
}}
.line.meta {{ color: var(--dim); }}
.line.plain {{ color: var(--fg); }}
/* SUMMARY box — full-width solid backgrounds matching terminal ROW_* tints */
.line.sum-header,
.line.sum-ok,
.line.sum-err,
.line.sum-warn,
.line.sum-info,
.line.sum-block,
.line.sum-totals,
.line.sum-verdict-ok,
.line.sum-verdict-warn,
.line.sum-verdict-err {{
display: block;
box-sizing: border-box;
width: 100%;
margin: 2px 0;
padding: 6px 10px;
border-radius: 3px;
font-weight: 800;
letter-spacing: 0.02em;
}}
.line.sum-header {{
color: #e8ffe8;
background: #145214; /* solid green header (not blue) */
border: 1px solid #2a8a2a;
border-left: 6px solid #3d3;
margin-top: 14px;
text-shadow: 0 1px 0 #0008;
}}
.line.sum-ok {{
color: #d4ffd4;
background: #0f3d0f;
border: 1px solid #1a6b1a;
border-left: 6px solid var(--ok);
}}
.line.sum-err {{
color: #ffe0e0;
background: #4a1010;
border: 1px solid #822;
border-left: 6px solid var(--err);
}}
.line.sum-warn {{
color: #1a1400;
background: #e6c200; /* bright gold — clearly not blue */
border: 1px solid #a68b00;
border-left: 6px solid #8a7010;
}}
.line.sum-info {{
color: #e0f2ff;
background: #0c2a4a; /* navy INFO row only */
border: 1px solid #1a4a7a;
border-left: 6px solid var(--info);
}}
.line.sum-block {{
color: #ffe0ff;
background: #3a1040;
border: 1px solid #6a2a70;
border-left: 6px solid var(--blocker);
}}
.line.sum-totals {{
color: #f0f0f0;
background: #2a2a2a;
border: 1px solid #555;
border-left: 6px solid #888;
margin-top: 6px;
}}
.line.sum-verdict-ok {{
color: #d4ffd4;
background: #0f3d0f;
border: 2px solid #2a8a2a;
margin-bottom: 10px;
}}
.line.sum-verdict-warn {{
color: #1a1400;
background: #e6c200;
border: 2px solid #8a7010;
margin-bottom: 10px;
}}
.line.sum-verdict-err {{
color: #ffe0e0;
background: #4a1010;
border: 2px solid #a33;
margin-bottom: 10px;
}}
a.jump {{ color: inherit; text-decoration: underline dotted; }}
footer {{
margin-top: 18px; color: var(--dim); font-size: 12px;
border-top: 1px solid var(--border); padding-top: 10px;
}}
</style>
</head>
<body>
<!-- taler-mon:top -->
{bar}
<main>
{err_nav}
{env_html}
<div class="console" id="mon-console">
{"".join(body_lines)}
</div>
<footer id="mon-footer">
{html.escape(footer_txt)}
</footer>
</main>
<!-- taler-mon:bottom:complete -->
{STICKY_JS}
</body>
</html>
"""
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'<a href="{html.escape(commit_url)}">{html.escape(commit_short)}</a>'
if commit_url
else html.escape(commit_short)
)
if not generated_at:
generated_at, generated_iso = format_generated_now()
# 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"""<!DOCTYPE html>
<html lang="{html.escape(lang if "lang" in dir() else "en")}">
<head>
<meta charset="utf-8"/>
<meta http-equiv="refresh" content="2; url={html.escape(link)}"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="generated" content="{html.escape(generated_iso)}"/>
<meta name="taler-mon-page" content="incomplete"/>
<meta name="taler-mon-kind" content="{html.escape(kind)}"/>
<title>{html.escape(page_label)} · errors · {html.escape(hostname)}</title>
<style>
:root {{
--bg: #0c0c0c; --fg: #c8c8c8; --dim: #666;
--ok: #3ddc84; --err: #ff5c5c; --warn: #e6c07b; --info: #61afef; --border: #222;
font-family: ui-monospace, Menlo, Consolas, monospace;
}}
body {{ margin: 0; background: var(--bg); color: var(--fg); font-size: 13px; }}
{STICKY_CSS}
.box {{
max-width: 520px; margin: 12vh auto; padding: 20px;
border: 1px solid #422; background: #121212; border-radius: 4px;
}}
.box a {{ color: var(--err); }}
</style>
</head>
<body>
<!-- taler-mon:top -->
{bar}
<div class="box">
<p><strong>{html.escape(page_label)}</strong> has failures.</p>
<p>See <a href="{html.escape(link)}">{html.escape(link)}</a> for the full console log,
error index, and sticky status bar.</p>
<p style="color:#666;font-size:12px">generated {html.escape(generated_at)} · source {c}</p>
</div>
<!-- taler-mon:bottom:incomplete -->
{STICKY_JS}
</body>
</html>
"""
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 ""
generated_at, generated_iso = format_generated_now()
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()
if not is_log_noise_line(strip_ansi(l))
]
n_err, n_warn, n_info, n_ok, _, _, _, _ = count_status(raw_for_counts)
# n_info/n_ok used by sticky filters when build_html is called below
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 "",
)
atomic_write_text(args.out, html_out, encoding="utf-8")
# stderr only — stdout is often teed into the suite mon log
print(
f"wrote {args.out} (errors={n_err} warnings={n_warn} "
f"level={status_level(n_err, n_warn)})",
file=sys.stderr,
)
if __name__ == "__main__":
main()