i18n: English default, French for FrancPaysan (v1.1)

This commit is contained in:
Hernâni Marques 2026-07-18 14:57:14 +02:00
parent a2afe690b7
commit 334594c423
No known key found for this signature in database
GPG key ID: CB5738652768F7E9
13 changed files with 483 additions and 86 deletions

View file

@ -8,6 +8,129 @@ 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",
"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é",
"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>.+)$")
@ -192,82 +315,84 @@ def extract_phases(log_text: str) -> str:
return ""
def monitoring_scope(
page_label: str,
hostname: str,
log_text: str = "",
lang: str = "en",
) -> dict[str, object]:
"""
Human-readable description of what this monitoring page covers.
Returned keys: title, summary, items (list[str]), kind
"""
"""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",
"title": "Remote ecosystem surface inventory",
"summary": "Public catalog hosts · DNS / TCP / HTTPS / TLS · Server version · CVE",
"lang": lang,
"title": ui(lang, "surface_title"),
"summary": ui(lang, "surface_summary"),
"items": [
"Outside-in only: no SSH and no podman exec on remote targets",
"Catalog (surface-catalog.conf): taler.net, demo/test, gnunet.org, "
"taler-ops.ch, taler-systems.com, deb.taler.net, ftp.gnu.org, …",
"Per host: DNS resolve, ICMP (info), open ports, protocol probes, "
"TLS cert expiry, Server-header software version, optional CVE check",
f"HTML published only on taler.hacktivism.ch "
f"(this page host: {host})",
"Timer: taler-monitoring-surface.timer (hourly)",
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",
"title": "apt-src merchant deploy tests (podman on koopa)",
"summary": "4 containers · fresh + upgrade tracks · trixie & trixie-testing",
"lang": lang,
"title": ui(lang, "apt_title"),
"summary": ui(lang, "apt_summary"),
"items": [
"koopa-taler-deploy-test-apt-src-trixie (fresh, suite trixie)",
"koopa-taler-deploy-test-apt-src-trixie-testing (fresh, trixie-testing)",
"koopa-taler-deploy-test-apt-src-trixie-upgrade (upgrade track)",
"koopa-taler-deploy-test-apt-src-trixie-testing-upgrade (upgrade track)",
"Checks: packages, taler-merchant-httpd --version / ldd, systemd unit, "
"optional local /config probe — not public nginx/HTTPS",
f"HTML published only on taler.hacktivism.ch (this page host: {host})",
"Timer: taler-monitoring-aptdeploy.timer (4h)",
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"),
],
}
# Default: stack /monitoring pages (bank, exchange, merchant)
phase_txt = phases or "urls · inside · versions"
role = "stack"
if host.startswith("bank."):
if host.startswith("bank.") or "bank." in host:
role = "bank"
focus = "Libeufin bank public HTTPS + container inside checks"
elif host.startswith("exchange.") or host.startswith("stage.exchange."):
focus = ui(lang, "focus_bank")
elif "exchange." in host:
role = "exchange"
focus = "Exchange public HTTPS + container inside checks"
elif host.startswith("taler.") or "merchant" in host or host.startswith("monnaie."):
focus = ui(lang, "focus_exchange")
elif host.startswith("taler.") or "merchant" in host or "monnaie." in host:
role = "merchant"
focus = "Merchant backend / SPA public HTTPS + container inside checks"
focus = ui(lang, "focus_merchant")
else:
focus = "Taler stack public + inside checks"
focus = ui(lang, "focus_stack")
items = [
f"Focus: {focus}",
f"Phases this run: {phase_txt}",
"Typical: public URLs (HTTPS, QR, perf), inside (podman/ssh), package versions",
f"HTML host: {host}",
"Timer: taler-monitoring-hacktivism (path + 4h) · /monitoring on bank, exchange, taler",
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, f"Log header phases= {phases}")
items.insert(2, ui(lang, "stack_i_phases", phases=phases))
return {
"kind": role,
"title": f"Stack monitoring · {host}",
"summary": f"{phase_txt} · public + inside",
"lang": lang,
"title": ui(lang, "stack_title", host=host),
"summary": ui(lang, "stack_summary", phases=phase_txt),
"items": items,
}
@ -289,28 +414,41 @@ def sticky_bar_html(
scope: dict[str, object] | None = None,
) -> str:
level = status_level(n_err, n_warn)
if level == "red":
status_txt = "ERRORS"
elif level == "yellow":
status_txt = "WARNINGS"
lang = "en"
if scope and scope.get("lang"):
lang = str(scope.get("lang"))
else:
status_txt = "OK"
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="Jump to first error">{n_err} error{"s" if n_err != 1 else ""}</a>'
f'title="{html.escape(ui(lang, "jump_first_error"))}">{html.escape(err_lbl)}</a>'
)
else:
err_stat = f'<span class="stat err muted">{n_err} errors</span>'
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="Jump to first warning">{n_warn} warning{"s" if n_warn != 1 else ""}</a>'
f'title="{html.escape(ui(lang, "jump_first_warn"))}">{html.escape(warn_lbl)}</a>'
)
else:
warn_stat = f'<span class="stat warn muted">{n_warn} warnings</span>'
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:
@ -320,7 +458,7 @@ def sticky_bar_html(
)
if scope is None:
scope = monitoring_scope(page_label, hostname, "")
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"))
@ -347,21 +485,21 @@ def sticky_bar_html(
<span class="generated"
data-generated-iso="{html.escape(generated_iso)}"
title="{html.escape(generated_at)}">
generated <time datetime="{html.escape(generated_iso)}">{html.escape(generated_at)}</time>
{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="Aufklappen: was diese Seite prüft">
title="{html.escape(ui(lang, "what_monitors_title"))}">
<span class="scope-chevron" aria-hidden="true"></span>
<span class="scope-toggle-label">Was geprüft wird</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>
source {commit_html}{suite_bit}
{html.escape(ui(lang, "source"))} {commit_html}{suite_bit}
{(" · " + other) if other else ""}
</div>
<div class="scope-body">
@ -531,10 +669,22 @@ STICKY_JS = """
(function () {
function fmtAge(sec) {
if (sec < 0) sec = 0;
if (sec < 60) return sec + "s ago";
if (sec < 3600) return Math.floor(sec / 60) + "m ago";
if (sec < 86400) return Math.floor(sec / 3600) + "h ago";
return Math.floor(sec / 86400) + "d ago";
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]");
@ -546,7 +696,9 @@ STICKY_JS = """
if (isNaN(t)) return;
var sec = Math.floor((Date.now() - t) / 1000);
age.textContent = fmtAge(sec);
age.title = "page age · updates every second";
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);
@ -679,8 +831,15 @@ def build_html(
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
scope = monitoring_scope(page_label, hostname, log_text)
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,
@ -698,7 +857,7 @@ def build_html(
)
return f"""<!DOCTYPE html>
<html lang="en">
<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"/>
@ -775,9 +934,7 @@ footer {{
{"".join(body_lines)}
</div>
<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.
{html.escape(footer_txt)}
</footer>
</main>
{STICKY_JS}
@ -830,7 +987,7 @@ def build_redirect_html(
scope=scope,
)
return f"""<!DOCTYPE html>
<html lang="en">
<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)}"/>
@ -882,6 +1039,11 @@ def main() -> None:
default="/monitoring_err/",
help="URL path for error page (redirect target)",
)
ap.add_argument(
"--lang",
default="",
help="UI language en|fr (default: env TALER_MON_LANG or auto)",
)
ap.add_argument(
"--page-label",
default="monitoring",
@ -889,6 +1051,9 @@ def main() -> None:
)
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")