2442 lines
92 KiB
Python
Executable file
2442 lines
92 KiB
Python
Executable file
#!/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 = "") -> 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: 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 run’s 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 · mobile server min · TLS",
|
||
"mm_i1": "Outside-in phase mattermost (default https://mattermost.taler.net)",
|
||
"mm_i2": "Landing SPA, /api/v4/system/ping, X-Version-Id vs mobile min (default ≥10.11.0), /login, TLS",
|
||
"mm_i3": "Newest Android/iOS store apps require server ≥ MATTERMOST_MOBILE_MIN_SERVER — ERROR if older",
|
||
"mm_i4": "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 · min serveur mobile · TLS",
|
||
"mm_i1": "Phase outside-in mattermost (défaut https://mattermost.taler.net)",
|
||
"mm_i2": "SPA, /api/v4/system/ping, X-Version-Id vs min mobile (≥10.11.0), /login, TLS",
|
||
"mm_i3": "Apps store Android/iOS récentes exigent serveur ≥ MATTERMOST_MOBILE_MIN_SERVER — ERROR si plus vieux",
|
||
"mm_i4": "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"
|
||
if "--- ERRORS" in u or "ERRORS ·" in u or "ERRORS (" 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")
|
||
if re.search(r"┌\s*ERROR\b|\[\s*ERROR\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\b|\[\s*WARN\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:
|
||
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, 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 "?"
|
||
# 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"),
|
||
ui(lang, "mm_i4"),
|
||
] + _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()
|
||
# 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'<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();
|
||
// Pure box-drawing / whitespace chrome (no literal box glyphs in source —
|
||
// those used to appear as "weird chars" in Python SyntaxWarning output).
|
||
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; // sp/tab/nl/cr/-
|
||
if (bc >= 0x2500 && bc <= 0x257f) continue; // 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()
|
||
if re.search(r"lefrancpaysan|francpaysan", hostname + "\n" + log_text, re.I):
|
||
import os
|
||
|
||
if not (os.environ.get("TALER_MON_LANG") or "").strip():
|
||
_lang_board = "fr"
|
||
err_nav += aptdeploy_board_html(log_text, lang=_lang_board)
|
||
|
||
if errors and mode == "err":
|
||
items = []
|
||
timeout_items = []
|
||
for i, e in enumerate(errors, 1):
|
||
slug = slug_error(i, e)
|
||
li = (
|
||
f'<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()
|
||
if re.search(r"lefrancpaysan|francpaysan", hostname + "\n" + log_text, re.I):
|
||
import os
|
||
if not (os.environ.get("TALER_MON_LANG") or "").strip():
|
||
lang = "fr"
|
||
scope = monitoring_scope(page_label, hostname, log_text, lang=lang)
|
||
|
||
page_lang = lang
|
||
footer_txt = ui(lang, "footer")
|
||
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()
|