1151 lines
41 KiB
Python
Executable file
1151 lines
41 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 re
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
def ui_lang(explicit: str = "") -> str:
|
|
"""en (default) or fr. Env: TALER_MON_LANG; auto fr for lefrancpaysan hosts."""
|
|
import os
|
|
lang = (explicit or os.environ.get("TALER_MON_LANG") or "").strip().lower()
|
|
if lang in ("fr", "fra", "french"):
|
|
return "fr"
|
|
if lang in ("en", "eng", "english"):
|
|
return "en"
|
|
return "en"
|
|
|
|
|
|
def ui(lang: str, key: str, **kwargs) -> str:
|
|
"""UI chrome strings for sticky bar (English default; French for FrancPaysan)."""
|
|
en = {
|
|
"errors": "ERRORS",
|
|
"warnings": "WARNINGS",
|
|
"ok": "OK",
|
|
"error_one": "{n} error",
|
|
"error_many": "{n} errors",
|
|
"warn_one": "{n} warning",
|
|
"warn_many": "{n} warnings",
|
|
"jump_first_error": "Jump to first error",
|
|
"jump_first_warn": "Jump to first warning",
|
|
"generated": "generated",
|
|
"version": "version",
|
|
|
|
"source": "source",
|
|
"what_monitors": "What this monitors",
|
|
"what_monitors_title": "Expand: what this page checks",
|
|
"surface_title": "Remote ecosystem surface inventory",
|
|
"surface_summary": "Public catalog hosts · DNS / TCP / HTTPS / TLS · Server version · 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, deb.taler.net, ftp.gnu.org, …",
|
|
"surface_i3": "Per host: DNS resolve, ICMP (info), open ports, protocol probes, TLS cert expiry, Server-header software version, optional CVE check",
|
|
"surface_i4": "HTML published only on taler.hacktivism.ch (this page host: {host})",
|
|
"surface_i5": "Timer: taler-monitoring-surface.timer (hourly)",
|
|
"apt_title": "apt-src merchant deploy tests (podman on koopa)",
|
|
"apt_summary": "4 containers · fresh + upgrade tracks · trixie & trixie-testing",
|
|
"apt_i1": "koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)",
|
|
"apt_i2": "koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)",
|
|
"apt_i3": "koopa-taler-deploy-test-apt-src-trixie-upgrade (upgrade track)",
|
|
"apt_i4": "koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (upgrade track)",
|
|
"apt_i5": "Checks: packages, taler-merchant-httpd --version / ldd, systemd unit, optional local /config probe — not public nginx/HTTPS",
|
|
"apt_i6": "HTML published only on taler.hacktivism.ch (this page host: {host})",
|
|
"apt_i7": "Timer: taler-monitoring-aptdeploy.timer (4h)",
|
|
"focus_bank": "Libeufin bank public HTTPS + container inside checks",
|
|
"focus_exchange": "Exchange public HTTPS + container inside checks",
|
|
"focus_merchant": "Merchant backend / SPA public HTTPS + container inside checks",
|
|
"focus_stack": "Taler stack public + inside checks",
|
|
"stack_title": "Stack monitoring · {host}",
|
|
"stack_summary": "{phases} · public + inside",
|
|
"stack_i1": "Focus: {focus}",
|
|
"stack_i2": "Phases this run: {phases}",
|
|
"stack_i3": "Typical: public URLs (HTTPS, QR, perf), inside (podman/ssh), package versions",
|
|
"stack_i4": "HTML host: {host}",
|
|
"stack_i5": "Timer: host-agent path + 4h · /monitoring on bank, exchange, merchant fronts",
|
|
"stack_i_phases": "Log header phases= {phases}",
|
|
"footer": "Console-style render of taler-monitoring output. Sticky bar: green = clean · yellow = warnings · red = errors. Commit pins the exact tree used for this run.",
|
|
"redirect_fail": "has failures.",
|
|
"redirect_see": "See {link} for the full console log, error index, and sticky status bar.",
|
|
"ago_s": "{n}s ago",
|
|
"ago_m": "{n}m ago",
|
|
"ago_h": "{n}h ago",
|
|
"ago_d": "{n}d ago",
|
|
"age_title": "page age · updates every second",
|
|
}
|
|
fr = {
|
|
"errors": "ERREURS",
|
|
"warnings": "AVERTISSEMENTS",
|
|
"ok": "OK",
|
|
"error_one": "{n} erreur",
|
|
"error_many": "{n} erreurs",
|
|
"warn_one": "{n} avertissement",
|
|
"warn_many": "{n} avertissements",
|
|
"jump_first_error": "Aller à la première erreur",
|
|
"jump_first_warn": "Aller au premier avertissement",
|
|
"generated": "généré",
|
|
"version": "version",
|
|
|
|
"source": "source",
|
|
"what_monitors": "Ce que cette page contrôle",
|
|
"what_monitors_title": "Déplier : ce que cette page vérifie",
|
|
"surface_title": "Inventaire de surface de l'écosystème (distant)",
|
|
"surface_summary": "Hôtes du catalogue public · DNS / TCP / HTTPS / TLS · version serveur · 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, taler-systems.com, deb.taler.net, ftp.gnu.org, …",
|
|
"surface_i3": "Par hôte : résolution DNS, ICMP (info), ports ouverts, sondes de protocole, expiration du certificat TLS, version logicielle (en-tête Server), CVE optionnel",
|
|
"surface_i4": "HTML publié uniquement sur taler.hacktivism.ch (hôte de cette page : {host})",
|
|
"surface_i5": "Minuterie : taler-monitoring-surface.timer (horaire)",
|
|
"apt_title": "Tests de déploiement apt-src marchand (podman sur koopa)",
|
|
"apt_summary": "4 conteneurs · pistes fresh + upgrade · trixie et trixie-testing",
|
|
"apt_i1": "koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)",
|
|
"apt_i2": "koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)",
|
|
"apt_i3": "koopa-taler-deploy-test-apt-src-trixie-upgrade (piste upgrade)",
|
|
"apt_i4": "koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (piste upgrade)",
|
|
"apt_i5": "Contrôles : paquets, taler-merchant-httpd --version / ldd, unité systemd, sonde locale /config optionnelle — pas de nginx/HTTPS public",
|
|
"apt_i6": "HTML publié uniquement sur taler.hacktivism.ch (hôte de cette page : {host})",
|
|
"apt_i7": "Minuterie : taler-monitoring-aptdeploy.timer (4 h)",
|
|
"focus_bank": "Banque Libeufin HTTPS public + contrôles inside conteneur",
|
|
"focus_exchange": "Exchange HTTPS public + contrôles inside conteneur",
|
|
"focus_merchant": "Backend marchand / SPA HTTPS public + contrôles inside conteneur",
|
|
"focus_stack": "Pile Taler public + inside",
|
|
"stack_title": "Surveillance de pile · {host}",
|
|
"stack_summary": "{phases} · public + inside",
|
|
"stack_i1": "Focus : {focus}",
|
|
"stack_i2": "Phases de cette exécution : {phases}",
|
|
"stack_i3": "Typique : URL publiques (HTTPS, QR, perf), inside (podman/ssh), versions des paquets",
|
|
"stack_i4": "Hôte HTML : {host}",
|
|
"stack_i5": "Minuterie : host-agent path + 4 h · /monitoring sur bank, exchange, fronts marchands",
|
|
"stack_i_phases": "En-tête de journal phases= {phases}",
|
|
"footer": "Rendu console de taler-monitoring. Barre collante : vert = OK · jaune = avertissements · rouge = erreurs. Le commit fixe l'arbre exact de cette exécution.",
|
|
"redirect_fail": "a des échecs.",
|
|
"redirect_see": "Voir {link} pour le journal console complet, l'index d'erreurs et la barre de statut.",
|
|
"ago_s": "il y a {n}s",
|
|
"ago_m": "il y a {n} min",
|
|
"ago_h": "il y a {n} h",
|
|
"ago_d": "il y a {n} j",
|
|
"age_title": "âge de la page · mise à jour chaque seconde",
|
|
}
|
|
table = fr if lang == "fr" else en
|
|
s = table.get(key) or en.get(key) or key
|
|
try:
|
|
return s.format(**kwargs)
|
|
except Exception:
|
|
return s
|
|
|
|
|
|
# ANSI strip
|
|
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
ERR_BULLET_RE = re.compile(r"^\s*[•·*-]\s+(?P<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 classify(line: str) -> str:
|
|
u = line.upper()
|
|
# section / summary headers before badge matching (they contain the word ERROR)
|
|
if "SUMMARY" in u or "--- ERRORS" in u or "ERRORS ·" in u or "ERRORS (" in u:
|
|
return "meta"
|
|
if "numbered checks" in line.lower() or "totals:" in line.lower():
|
|
return "meta"
|
|
if "BLOCKER" in u or "┌ BLOCKER" in u:
|
|
return "blocker"
|
|
if "ERROR" in u or "┌ ERROR" in u:
|
|
return "error"
|
|
if "WARN" in u or "┌ WARN" in u:
|
|
return "warn"
|
|
if "INFO" in u or "┌ INFO" in u:
|
|
return "info"
|
|
if " OK" in u or "[OK" in u or "┌ OK" in u or u.strip().startswith("OK"):
|
|
return "ok"
|
|
if u.strip().startswith("-- ") or u.strip().startswith("=="):
|
|
return "section"
|
|
return "plain"
|
|
|
|
|
|
def is_run_timeout_text(text: str) -> bool:
|
|
u = text.upper()
|
|
return "RUN.TIMEOUT" in u or "RUN_TIMEOUT" in u or "RUN TIMEOUT" in u
|
|
|
|
|
|
def is_summary_error_line(ln: str) -> bool:
|
|
"""Skip summary/header lines that contain ERROR but are not checks."""
|
|
low = ln.lower()
|
|
if "failed — see" in low or "failed - see" in low:
|
|
return True
|
|
if "errors · failed" in low or "errors (failed" in low:
|
|
return True
|
|
if "--- errors" in low:
|
|
return True
|
|
if "┌ summary" in low or "[ summary" in low:
|
|
return True
|
|
if "totals:" in low:
|
|
return True
|
|
# header-ish RUN_TIMEOUT= without error badge
|
|
if is_run_timeout_text(ln) and "ERROR" not in ln.upper() and "run.timeout" not in low:
|
|
return True
|
|
return False
|
|
|
|
|
|
def extract_errors(lines: list[str]) -> list[str]:
|
|
errs: list[str] = []
|
|
timeout_detail = ""
|
|
in_block = False
|
|
for ln in lines:
|
|
if "--- ERRORS" in ln or "ERRORS (failed" in ln or "RUN TIMEOUT · extraordinary" in ln:
|
|
in_block = True
|
|
continue
|
|
if in_block:
|
|
if ln.strip().startswith("---") or "[ SUMMARY" in ln or "totals:" in ln:
|
|
in_block = False
|
|
continue
|
|
m = ERR_BULLET_RE.match(ln)
|
|
if m:
|
|
body = m.group("body").strip()
|
|
if is_run_timeout_text(body):
|
|
timeout_detail = body
|
|
else:
|
|
errs.append(body)
|
|
elif "ERROR" in ln.upper() and ln.strip() and not is_run_timeout_text(ln):
|
|
errs.append(ln.strip())
|
|
if re.search(r"\bERROR\b", ln, re.I) and "--- ERRORS" not in ln:
|
|
if is_run_timeout_text(ln):
|
|
if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln:
|
|
timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip()
|
|
continue
|
|
if is_summary_error_line(ln):
|
|
continue
|
|
body = re.sub(r"^.*\bERROR\b\s*\]?\s*", "", ln, flags=re.I).strip(" ·")
|
|
if body and body not in errs and "failed — see" not in body.lower():
|
|
if re.search(
|
|
r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.",
|
|
body,
|
|
):
|
|
errs.append(body)
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for e in errs:
|
|
if e in seen or is_run_timeout_text(e):
|
|
continue
|
|
seen.add(e)
|
|
out.append(e)
|
|
if timeout_detail or any(
|
|
is_run_timeout_text(l) and ("ERROR" in l.upper() or "run.timeout" in l.lower())
|
|
for l in lines
|
|
):
|
|
# only if real timeout ERROR badge, not mere RUN_TIMEOUT= header
|
|
if timeout_detail or any(
|
|
"run.timeout-01" in l.lower() or "RUN_TIMEOUT exceeded" in l for l in lines
|
|
):
|
|
canon = timeout_detail or "run.timeout-01 [run] RUN_TIMEOUT exceeded"
|
|
out = [canon] + out
|
|
return out
|
|
|
|
|
|
def count_status(lines: list[str]) -> tuple[int, int, int | None, int | None]:
|
|
"""Return (error_count, warn_count, first_error_idx, first_warn_idx)."""
|
|
n_err = 0
|
|
n_warn = 0
|
|
first_err: int | None = None
|
|
first_warn: int | None = None
|
|
for i, ln in enumerate(lines):
|
|
kind = classify(ln)
|
|
if kind in ("error", "blocker"):
|
|
if is_summary_error_line(ln):
|
|
continue
|
|
n_err += 1
|
|
if first_err is None:
|
|
first_err = i
|
|
elif kind == "warn":
|
|
n_warn += 1
|
|
if first_warn is None:
|
|
first_warn = i
|
|
return n_err, n_warn, first_err, first_warn
|
|
|
|
|
|
def slug_error(i: int, text: str) -> str:
|
|
if is_run_timeout_text(text):
|
|
return "err-run-timeout"
|
|
m = TID_RE.search(text)
|
|
if m:
|
|
return "err-" + re.sub(r"[^a-z0-9_-]+", "-", m.group("tid").lower())
|
|
return f"err-{i:03d}"
|
|
|
|
|
|
def render_line(ln: str, err_slugs: dict[str, str], extra_ids: list[str] | None = None) -> str:
|
|
kind = classify(ln)
|
|
esc = html.escape(ln)
|
|
for tid, slug in err_slugs.items():
|
|
if tid in ln:
|
|
esc = esc.replace(
|
|
html.escape(tid),
|
|
f'<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
|
|
spans = "".join(f'<span id="{html.escape(x)}"></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 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:
|
|
return {
|
|
"kind": "surface",
|
|
"lang": lang,
|
|
"title": ui(lang, "surface_title"),
|
|
"summary": ui(lang, "surface_summary"),
|
|
"items": [
|
|
ui(lang, "surface_i1"),
|
|
ui(lang, "surface_i2"),
|
|
ui(lang, "surface_i3"),
|
|
ui(lang, "surface_i4", host=host),
|
|
ui(lang, "surface_i5"),
|
|
],
|
|
}
|
|
|
|
if "aptdeploy" in label or "apt-deploy" in label or "apt_src" in label:
|
|
return {
|
|
"kind": "aptdeploy",
|
|
"lang": lang,
|
|
"title": ui(lang, "apt_title"),
|
|
"summary": ui(lang, "apt_summary"),
|
|
"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"),
|
|
],
|
|
}
|
|
|
|
phase_txt = phases or "urls · inside · versions"
|
|
role = "stack"
|
|
if host.startswith("bank.") or "bank." in host:
|
|
role = "bank"
|
|
focus = ui(lang, "focus_bank")
|
|
elif "exchange." in host:
|
|
role = "exchange"
|
|
focus = ui(lang, "focus_exchange")
|
|
elif host.startswith("taler.") or "merchant" in host or "monnaie." in host:
|
|
role = "merchant"
|
|
focus = ui(lang, "focus_merchant")
|
|
else:
|
|
focus = ui(lang, "focus_stack")
|
|
|
|
items = [
|
|
ui(lang, "stack_i1", focus=focus),
|
|
ui(lang, "stack_i2", phases=phase_txt),
|
|
ui(lang, "stack_i3"),
|
|
ui(lang, "stack_i4", host=host),
|
|
ui(lang, "stack_i5"),
|
|
]
|
|
if phases:
|
|
items.insert(2, ui(lang, "stack_i_phases", phases=phases))
|
|
|
|
return {
|
|
"kind": role,
|
|
"lang": lang,
|
|
"title": ui(lang, "stack_title", host=host),
|
|
"summary": ui(lang, "stack_summary", phases=phase_txt),
|
|
"items": items,
|
|
}
|
|
|
|
|
|
def sticky_bar_html(
|
|
*,
|
|
generated_at: str,
|
|
generated_iso: str,
|
|
n_err: int,
|
|
n_warn: int,
|
|
hostname: str,
|
|
page_label: str,
|
|
mode: str,
|
|
commit_html: str,
|
|
suite_path: str,
|
|
link_other: str | None,
|
|
first_err_href: str | None,
|
|
first_warn_href: str | None,
|
|
scope: dict[str, object] | None = None,
|
|
suite_version: str = "",
|
|
suite_version_url: str = "",
|
|
) -> str:
|
|
level = status_level(n_err, n_warn)
|
|
lang = "en"
|
|
if scope and scope.get("lang"):
|
|
lang = str(scope.get("lang"))
|
|
else:
|
|
lang = ui_lang()
|
|
# auto-fr for FP hostnames
|
|
if lang == "en" and re.search(r"lefrancpaysan|francpaysan", hostname or "", re.I):
|
|
lang = "fr"
|
|
|
|
if level == "red":
|
|
status_txt = ui(lang, "errors")
|
|
elif level == "yellow":
|
|
status_txt = ui(lang, "warnings")
|
|
else:
|
|
status_txt = ui(lang, "ok")
|
|
|
|
if first_err_href and n_err:
|
|
err_lbl = ui(lang, "error_one", n=n_err) if n_err == 1 else ui(lang, "error_many", n=n_err)
|
|
err_stat = (
|
|
f'<a class="stat err" href="{html.escape(first_err_href)}" '
|
|
f'title="{html.escape(ui(lang, "jump_first_error"))}">{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 first_warn_href and n_warn:
|
|
warn_lbl = ui(lang, "warn_one", n=n_warn) if n_warn == 1 else ui(lang, "warn_many", n=n_warn)
|
|
warn_stat = (
|
|
f'<a class="stat warn" href="{html.escape(first_warn_href)}" '
|
|
f'title="{html.escape(ui(lang, "jump_first_warn"))}">{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>'
|
|
|
|
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 ""
|
|
)
|
|
|
|
# Version badge (linked to Forgejo tag when available)
|
|
ver = (suite_version or "").strip()
|
|
ver_url = (suite_version_url or "").strip()
|
|
if ver:
|
|
if ver_url:
|
|
version_html = (
|
|
f'<a class="version-link" href="{html.escape(ver_url)}" '
|
|
f'title="{html.escape(ui(lang, "version"))} {html.escape(ver)}" '
|
|
f'rel="noopener noreferrer">{html.escape(ver)}</a>'
|
|
)
|
|
else:
|
|
version_html = f'<span class="version-link muted">{html.escape(ver)}</span>'
|
|
else:
|
|
version_html = ""
|
|
# Compact sticky row always visible; "Was geprüft wird" expands inside sticky-bar
|
|
return f"""
|
|
<div class="sticky-bar sticky-{level}" id="status-bar" role="status"
|
|
data-errors="{n_err}" data-warnings="{n_warn}" data-level="{level}"
|
|
data-scope-kind="{scope_kind}">
|
|
<div class="sticky-bar-row primary">
|
|
<span class="status-pill sticky-{level}">{html.escape(status_txt)}</span>
|
|
<span class="host">{html.escape(page_label)} · {html.escape(hostname)}</span>
|
|
<span class="sep">·</span>
|
|
{err_stat}
|
|
<span class="sep">·</span>
|
|
{warn_stat}
|
|
<span class="sep">·</span>
|
|
{version_html}
|
|
<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.upper())}</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; }
|
|
.host { font-weight: 600; color: #eee; font-size: 13px; }
|
|
.sep { color: var(--dim); }
|
|
.stat {
|
|
font-weight: 700; font-size: 12px; text-decoration: none;
|
|
padding: 1px 8px; border-radius: 3px; border: 1px solid transparent;
|
|
}
|
|
.stat.err { color: var(--err); border-color: #522; background: #1a0a0a; }
|
|
.stat.warn { color: var(--warn); border-color: #664; background: #1a1608; }
|
|
.stat.muted { opacity: 0.55; font-weight: 600; }
|
|
a.stat:hover { text-decoration: underline; filter: brightness(1.15); }
|
|
.version-link {
|
|
font-weight: 700; font-size: 12px; color: var(--info);
|
|
text-decoration: none; padding: 1px 8px; border-radius: 3px;
|
|
border: 1px solid #234; background: #0a1520;
|
|
}
|
|
.version-link:hover { text-decoration: underline; filter: brightness(1.15); }
|
|
.version-link.muted { opacity: 0.7; color: #8ab; }
|
|
.generated { color: var(--dim); font-size: 12px; }
|
|
.generated time { color: #bbb; }
|
|
.generated .age { color: #888; margin-left: 4px; }
|
|
.generated .age:not(:empty)::before { content: "("; }
|
|
.generated .age:not(:empty)::after { content: ")"; }
|
|
.badge {
|
|
display: inline-block; padding: 1px 8px; border-radius: 3px;
|
|
font-size: 11px; font-weight: 700; margin-right: 4px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.badge.mode-err { color: var(--err); border-color: #522; background: #1a0a0a; }
|
|
.badge.mode-ok { color: var(--ok); border-color: #143; background: #0a1a10; }
|
|
.badge.mode-redirect { color: var(--warn); border-color: #664; background: #1a1608; }
|
|
/* collapsible block inside sticky-bar */
|
|
.scope-toggle {
|
|
margin-left: auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
max-width: min(42rem, 100%);
|
|
cursor: pointer;
|
|
font: inherit;
|
|
font-size: 12px;
|
|
color: #ddd;
|
|
background: #0006;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
padding: 4px 10px;
|
|
text-align: left;
|
|
}
|
|
.scope-toggle:hover { border-color: #888; background: #111a; color: #fff; }
|
|
.scope-toggle:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; }
|
|
.scope-toggle[aria-expanded="true"] {
|
|
border-color: #777;
|
|
background: #141414;
|
|
}
|
|
.scope-chevron {
|
|
display: inline-block;
|
|
width: 0; height: 0;
|
|
border-left: 5px solid #aaa;
|
|
border-top: 4px solid transparent;
|
|
border-bottom: 4px solid transparent;
|
|
transition: transform 0.12s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
.scope-toggle[aria-expanded="true"] .scope-chevron {
|
|
transform: rotate(90deg);
|
|
border-left-color: #eee;
|
|
}
|
|
.scope-toggle-label {
|
|
font-weight: 700;
|
|
letter-spacing: 0.03em;
|
|
text-transform: uppercase;
|
|
font-size: 11px;
|
|
white-space: nowrap;
|
|
}
|
|
.scope-toggle-hint {
|
|
color: #888;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
min-width: 0;
|
|
}
|
|
.sticky-scope-panel {
|
|
margin-top: 8px;
|
|
max-width: 1200px;
|
|
border: 1px solid #333;
|
|
border-radius: 4px;
|
|
background: #080808e6;
|
|
padding: 8px 10px 10px;
|
|
}
|
|
.sticky-scope-panel[hidden] { display: none !important; }
|
|
.scope-body { padding: 4px 4px 2px 6px; }
|
|
.scope-title {
|
|
margin: 4px 0 4px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #eee;
|
|
}
|
|
.scope-lead {
|
|
margin: 0 0 6px;
|
|
font-size: 12px;
|
|
color: #999;
|
|
}
|
|
.scope-list {
|
|
margin: 0;
|
|
padding-left: 1.15em;
|
|
color: #b0b0b0;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
.scope-list li { margin: 3px 0; }
|
|
#first-error, #first-warn,
|
|
[id^="err-"][id$="-line"] {
|
|
scroll-margin-top: 120px;
|
|
}
|
|
@media (max-width: 720px) {
|
|
.scope-toggle { margin-left: 0; width: 100%; }
|
|
.scope-toggle-hint { display: none; }
|
|
}
|
|
"""
|
|
|
|
STICKY_JS = """
|
|
<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) {}
|
|
}
|
|
})();
|
|
</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()]
|
|
while raw_lines and not raw_lines[-1].strip():
|
|
raw_lines.pop()
|
|
|
|
n_err, n_warn, first_err_i, first_warn_i = count_status(raw_lines)
|
|
errors = extract_errors(raw_lines)
|
|
|
|
err_slugs: dict[str, str] = {}
|
|
for i, e in enumerate(errors, 1):
|
|
slug = slug_error(i, e)
|
|
err_slugs[e] = slug
|
|
m = TID_RE.search(e)
|
|
if m:
|
|
err_slugs[m.group("tid")] = slug
|
|
|
|
tid_to_slug = {
|
|
k: v for k, v in err_slugs.items() if re.match(r"^[a-z0-9_.-]+-\d+$", k, re.I)
|
|
}
|
|
|
|
# Build body with anchors for first error/warn + per-error slugs
|
|
body_lines: list[str] = []
|
|
claimed_err: set[str] = set()
|
|
for idx, ln in enumerate(raw_lines):
|
|
extra: list[str] = []
|
|
if first_err_i is not None and idx == first_err_i:
|
|
extra.append("first-error")
|
|
if first_warn_i is not None and idx == first_warn_i:
|
|
extra.append("first-warn")
|
|
# per-error jump targets (all modes)
|
|
if classify(ln) in ("error", "blocker") and not is_summary_error_line(ln):
|
|
for i, e in enumerate(errors, 1):
|
|
slug = slug_error(i, e)
|
|
if slug in claimed_err:
|
|
continue
|
|
tid_m = TID_RE.search(e)
|
|
if is_run_timeout_text(e):
|
|
if "run.timeout-01" in ln and "ERROR" in ln.upper():
|
|
extra.append(f"{slug}-line")
|
|
claimed_err.add(slug)
|
|
break
|
|
continue
|
|
key = tid_m.group(1) if tid_m else e[:40]
|
|
if key and key in ln:
|
|
extra.append(f"{slug}-line")
|
|
claimed_err.add(slug)
|
|
break
|
|
body_lines.append(render_line(ln, tid_to_slug, extra or None))
|
|
|
|
err_nav = ""
|
|
if errors and mode == "err":
|
|
items = []
|
|
timeout_items = []
|
|
for i, e in enumerate(errors, 1):
|
|
slug = slug_error(i, e)
|
|
li = (
|
|
f'<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")
|
|
bar = sticky_bar_html(
|
|
generated_at=generated_at,
|
|
generated_iso=generated_iso,
|
|
n_err=n_err,
|
|
n_warn=n_warn,
|
|
hostname=hostname,
|
|
page_label=page_label,
|
|
mode=mode,
|
|
commit_html=commit_html,
|
|
suite_path=suite_path,
|
|
link_other=link_other,
|
|
first_err_href=first_err_href,
|
|
first_warn_href=first_warn_href,
|
|
scope=scope,
|
|
suite_version=suite_version,
|
|
suite_version_url=suite_version_url,
|
|
)
|
|
|
|
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)}"/>
|
|
<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;
|
|
}}
|
|
.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); }}
|
|
.line.section {{ color: var(--sec); margin-top: 8px; font-weight: 600; }}
|
|
.line.meta {{ color: var(--dim); }}
|
|
.line.plain {{ color: var(--fg); }}
|
|
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>
|
|
{bar}
|
|
<main>
|
|
{err_nav}
|
|
<div class="console">
|
|
{"".join(body_lines)}
|
|
</div>
|
|
<footer>
|
|
{html.escape(footer_txt)}
|
|
</footer>
|
|
</main>
|
|
{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:
|
|
now = datetime.now(timezone.utc)
|
|
generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ")
|
|
# Redirect pages exist only when the run failed → treat as red if n_err unknown
|
|
if n_err <= 0:
|
|
n_err = max(n_err, 1)
|
|
scope = monitoring_scope(page_label, hostname, log_text)
|
|
bar = sticky_bar_html(
|
|
generated_at=generated_at,
|
|
generated_iso=generated_iso,
|
|
n_err=n_err,
|
|
n_warn=n_warn,
|
|
hostname=hostname,
|
|
page_label=page_label,
|
|
mode="redirect",
|
|
commit_html=c,
|
|
suite_path="",
|
|
link_other=link,
|
|
first_err_href=link,
|
|
first_warn_href=None,
|
|
scope=scope,
|
|
suite_version=suite_version,
|
|
suite_version_url=suite_version_url,
|
|
)
|
|
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)}"/>
|
|
<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>
|
|
{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>
|
|
{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 ""
|
|
now = datetime.now(timezone.utc)
|
|
generated_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
generated_at = now.strftime("%Y-%m-%d %H:%M:%SZ")
|
|
title = args.title or f"{args.page_label} {args.mode} · {args.hostname}"
|
|
|
|
raw_for_counts = [strip_ansi(l).rstrip("\n") for l in log_text.splitlines()]
|
|
n_err, n_warn, _, _ = count_status(raw_for_counts)
|
|
|
|
if args.mode == "redirect":
|
|
html_out = build_redirect_html(
|
|
hostname=args.hostname,
|
|
commit=args.commit,
|
|
commit_url=args.commit_url,
|
|
path_err=args.path_err,
|
|
page_label=args.page_label,
|
|
generated_at=generated_at,
|
|
generated_iso=generated_iso,
|
|
n_err=n_err,
|
|
n_warn=n_warn,
|
|
log_text=log_text,
|
|
suite_version=getattr(args, "suite_version", "") or "",
|
|
suite_version_url=getattr(args, "suite_version_url", "") or "",
|
|
)
|
|
else:
|
|
html_out = build_html(
|
|
title=title,
|
|
hostname=args.hostname,
|
|
mode=args.mode,
|
|
log_text=log_text,
|
|
commit=args.commit,
|
|
commit_url=args.commit_url,
|
|
suite_path=args.suite_path,
|
|
generated_at=generated_at,
|
|
generated_iso=generated_iso,
|
|
link_other=args.link_other or None,
|
|
page_label=args.page_label,
|
|
suite_version=getattr(args, "suite_version", "") or "",
|
|
suite_version_url=getattr(args, "suite_version_url", "") or "",
|
|
)
|
|
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
args.out.write_text(html_out, encoding="utf-8")
|
|
print(f"wrote {args.out} (errors={n_err} warnings={n_warn} level={status_level(n_err, n_warn)})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|