Compare commits

..

10 commits

10 changed files with 703 additions and 182 deletions

View file

@ -1 +1 @@
1.15.4
1.18.1

View file

@ -17,6 +17,14 @@ Git tags: `vMAJOR.FEATURE.FIX` (e.g. `v1.8.0`). File `VERSION` omits the `v` pre
| Tag | Date (UTC) | Notes |
|-----|------------|--------|
| **v1.18.1** | 2026-07-19 | **Bugfix:** (1) mon HTML sticky/err counts honor French badges **ERREUR** / **AVERT** (were plain → 0 errors on FP pages); (2) `stage-lfp` + `INSIDE_PODMAN=1` uses **host-podman** (no empty `INSIDE_SSH` fail); (3) reaffirm filter JS has no box-glyph/`\\-` regex (charCodeAt only — kills SyntaxWarning “geheimsprache” noise). |
| **v1.18.0** | 2026-07-19 | **Feature:** mattermost — client compatibility matrix: **mobile**, **desktop**, **vendor support** floors (default ≥10.11.0, ERROR) + **web** WARN on EOL; env `MATTERMOST_DESKTOP/SUPPORT_MIN_SERVER`, `MATTERMOST_CLIENT_CHECK`. |
| **v1.17.0** | 2026-07-19 | **Feature:** mattermost phase — check **server version** (`X-Version-Id`) against **mobile store min** (default **≥10.11.0**); ERROR when too old (newest Android/iOS apps break); env `MATTERMOST_MOBILE_MIN_SERVER` / `MATTERMOST_MOBILE_CHECK=0`. |
| **v1.16.0** | 2026-07-19 | **Feature:** mon sticky — blue **info** + green **ok** filter badges; suite **version tag under status pill** (not blue badge); filters `#filter-info` / `#filter-ok`. |
| **v1.15.8** | 2026-07-19 | **Bugfix:** mon HTML filter JS — drop literal box-drawing / `\-` regex that caused Python SyntaxWarning (and weird glyphs in suite logs); use Unicode code-point loop instead. |
| **v1.15.7** | 2026-07-19 | **Bugfix:** mon HTML — do not paint «perf/ladder summary» as SUMMARY header (restore 2 INFO lines); drop converter SyntaxWarning/noise from public log; «wrote» on stderr; host-agent PYTHONWARNINGS for convert. |
| **v1.15.6** | 2026-07-19 | **Bugfix:** aptdeploy ERROR jump list is self-contained — systemd httpd failures include before/after state, Result/SubState, failed units, and journal snippets (no more vague «see systemd lines»); ldd/--version errors embed concrete missing libs / exit output. |
| **v1.15.5** | 2026-07-19 | **Bugfix:** devtesting CHF ladder — `ssh` was draining ladder stdin (only rung 1/23 ran → progress 45/98 then snap 52/52); use `ssh -n` + `mapfile`; incomplete ladder is WARN not false complete. |
| **v1.15.4** | 2026-07-19 | **Bugfix:** French sticky titles — page label *surveillance*, stack summary *public + interne*, SUMMARY verdict/totals via i18n (`tout est clair`, `totaux :`). |
| **v1.15.3** | 2026-07-19 | **Bugfix:** console language auto-**fr** for `*lefrancpaysan*` domains again — env `TALER_MON_LANG=en` no longer locks English; only `--lang` / `TALER_MON_LANG_SET=1` is explicit. |
| **v1.15.2** | 2026-07-19 | **Bugfix:** warn/error filter — drop blank log lines from HTML; compact console (no black voids above/mid/below); context skips SUMMARY chrome & pure box frames; tighter filter-gap. |

View file

@ -127,7 +127,7 @@ _merchant_ver() {
check_ldd_deep() {
local name=$1
local out
local out missing
out=$("$PODMAN_BIN" exec "$name" bash -lc '
set +e
bin=$(command -v taler-merchant-httpd)
@ -147,15 +147,48 @@ check_ldd_deep() {
info "ldd" "$line"
done <<<"$out"
if echo "$out" | grep -q 'not found'; then
err "ldd" "shared library missing in $name" "see ldd lines above"
missing=$(echo "$out" | grep 'not found' | tr '\n' ' ' | head -c 280)
err "ldd" "shared library missing in $name" "${missing:-not found (see ldd INFO above)}"
return 1
fi
return 0
}
# Build a single-line diagnosis for err() so jump lists are self-contained
# (operators should not need to hunt nearby INFO lines).
_apt_systemd_diagnose() {
local out="$1"
local before after failed journal status_line result
before=$(echo "$out" | sed -n 's/^httpd_active: //p' | tail -1)
after=$(echo "$out" | sed -n 's/^after_start_httpd: //p' | tail -1)
failed=$(echo "$out" | sed -n 's/^failed_taler: //p' | grep -v '^none$' | tr '\n' ';' | sed 's/;$//')
[ -z "$failed" ] && failed=$(echo "$out" | grep -E '^\s*●\s+taler-' | head -3 | tr '\n' ';' | sed 's/;$//')
result=$(echo "$out" | sed -n 's/^httpd_Result: //p' | tail -1)
[ -z "$result" ] && result=$(echo "$out" | sed -n 's/^ *Result: //p' | head -1)
status_line=$(echo "$out" | sed -n 's/^httpd_status_line: //p' | tail -1)
journal=$(echo "$out" | sed -n 's/^httpd_journal: //p' | tr '\n' ' ' | head -c 220)
# fallback: first non-meta journal-ish line
if [ -z "$journal" ]; then
journal=$(echo "$out" | grep -E 'taler-merchant-httpd\[[0-9]+\]|Failed|error|Error|NRestarts|Main PID' | head -3 | tr '\n' ' ' | head -c 220)
fi
local parts=()
[ -n "$before" ] && parts+=("before=${before}")
[ -n "$after" ] && parts+=("after_start=${after}")
[ -n "$result" ] && parts+=("Result=${result}")
[ -n "$status_line" ] && parts+=("status=${status_line}")
[ -n "$failed" ] && [ "$failed" != "none" ] && parts+=("failed_units=${failed}")
[ -n "$journal" ] && parts+=("journal=${journal}")
if [ "${#parts[@]}" -eq 0 ]; then
printf '%s' "no status/journal captured (podman exec empty?)"
return
fi
local IFS=' · '
printf '%s' "${parts[*]}"
}
check_systemd() {
local name=$1
local out active_httpd
local out active_httpd before_httpd detail
out=$("$PODMAN_BIN" exec "$name" bash -lc '
set +e
echo "system: $(systemctl is-system-running 2>&1)"
@ -163,17 +196,50 @@ check_systemd() {
echo "target_active: $(systemctl is-active taler-merchant.target 2>&1)"
echo "httpd_active: $(systemctl is-active taler-merchant-httpd.service 2>&1)"
echo "httpd_enabled: $(systemctl is-enabled taler-merchant-httpd.service 2>&1)"
systemctl start taler-merchant.target 2>&1 | tail -5
sleep 2
# unit properties help when is-active is only "activating"/"failed"
echo "httpd_Result: $(systemctl show -p Result --value taler-merchant-httpd.service 2>&1)"
echo "httpd_SubState: $(systemctl show -p SubState --value taler-merchant-httpd.service 2>&1)"
echo "httpd_NRestarts: $(systemctl show -p NRestarts --value taler-merchant-httpd.service 2>&1)"
echo "httpd_ExecMainStatus: $(systemctl show -p ExecMainStatus --value taler-merchant-httpd.service 2>&1)"
echo "httpd_ExecMainCode: $(systemctl show -p ExecMainCode --value taler-merchant-httpd.service 2>&1)"
# one-line status (Active: failed / activating (start) …)
systemctl status taler-merchant-httpd.service --no-pager -l 2>&1 | head -12 | while IFS= read -r sl; do
echo "httpd_status: $sl"
done
# compact single-line for err detail
st1=$(systemctl status taler-merchant-httpd.service --no-pager -l 2>&1 | sed -n "s/^ *Active: //p" | head -1)
echo "httpd_status_line: ${st1:-?}"
systemctl start taler-merchant.target 2>&1 | tail -8 | while IFS= read -r sl; do
echo "start_target: $sl"
done
# wait longer: "activating" often clears only after dbinit
sleep 5
echo "after_start_target: $(systemctl is-active taler-merchant.target 2>&1)"
echo "after_start_httpd: $(systemctl is-active taler-merchant-httpd.service 2>&1)"
systemctl --failed --no-legend 2>/dev/null | grep -i taler || echo "failed_taler: none"
echo "after_httpd_Result: $(systemctl show -p Result --value taler-merchant-httpd.service 2>&1)"
echo "after_httpd_SubState: $(systemctl show -p SubState --value taler-merchant-httpd.service 2>&1)"
# journal: last failure reasons (self-contained for ERROR jump list)
journalctl -u taler-merchant-httpd.service -n 12 --no-pager -o cat 2>/dev/null \
| sed "/^$/d" | tail -8 | while IFS= read -r jl; do
echo "httpd_journal: $jl"
done
journalctl -u taler-merchant-dbinit.service -n 6 --no-pager -o cat 2>/dev/null \
| sed "/^$/d" | tail -4 | while IFS= read -r jl; do
echo "dbinit_journal: $jl"
done
ft=$(systemctl --failed --no-legend 2>/dev/null | grep -i taler || true)
if [ -n "$ft" ]; then
echo "$ft" | while IFS= read -r fl; do echo "failed_taler: $fl"; done
else
echo "failed_taler: none"
fi
' 2>/dev/null || echo "systemd probe failed")
while IFS= read -r line; do
[ -n "$line" ] || continue
info "systemd" "$line"
done <<<"$out"
before_httpd=$(echo "$out" | sed -n 's/^httpd_active: //p' | tail -1)
active_httpd=$(echo "$out" | sed -n 's/^after_start_httpd: //p' | tail -1)
# export for caller board
APT_LAST_HTTPD="${active_httpd:-?}"
@ -181,7 +247,17 @@ check_systemd() {
ok "systemd httpd" "taler-merchant-httpd active after start taler-merchant.target"
return 0
fi
err "systemd httpd" "taler-merchant-httpd not active" "after_start_httpd=${active_httpd:-?} · see systemd lines"
detail=$(_apt_systemd_diagnose "$out")
# Prefer after_* Result if present
local after_result after_sub
after_result=$(echo "$out" | sed -n 's/^after_httpd_Result: //p' | tail -1)
after_sub=$(echo "$out" | sed -n 's/^after_httpd_SubState: //p' | tail -1)
[ -n "$after_result" ] && [ "$after_result" != "success" ] && detail="${detail} · after_Result=${after_result}"
[ -n "$after_sub" ] && detail="${detail} · after_SubState=${after_sub}"
# Human one-liner for jump list (no "see …" without content)
err "systemd httpd" \
"taler-merchant-httpd not active (container=${name})" \
"${detail}"
return 1
}
@ -221,18 +297,21 @@ check_merchant_basics() {
done <<<"$out"
if echo "$out" | grep -qi 'error while loading shared libraries'; then
err "httpd" "shared library error on --version" "see basics/ldd"
local lib_err
lib_err=$(echo "$out" | grep -i 'error while loading shared libraries' | head -1 | tr '\n' ' ' | head -c 240)
err "httpd" "shared library error on --version" "${lib_err:-see basics INFO lines}"
return 1
fi
set +e
"$PODMAN_BIN" exec "$name" taler-merchant-httpd --version >/dev/null 2>&1
local ec=$?
local ver_out ec
ver_out=$("$PODMAN_BIN" exec "$name" taler-merchant-httpd --version 2>&1)
ec=$?
set -e
if [ "$ec" -eq 0 ]; then
ok "httpd --version" "exit 0"
else
err "httpd --version" "exit $ec"
err "httpd --version" "exit $ec" "$(printf '%s' "$ver_out" | tr '\n' ' ' | head -c 240)"
return 1
fi
@ -308,9 +387,10 @@ check_one() {
if ! check_systemd "$name"; then
c_fail=1
httpd="${APT_LAST_HTTPD:-FAIL}"
note="${note:+$note; }httpd=${httpd}"
# deeper ldd if httpd failed
if [ "$ldd" != "FAIL" ]; then
check_ldd_deep "$name" || { ldd="FAIL"; c_fail=1; }
check_ldd_deep "$name" || { ldd="FAIL"; c_fail=1; note="${note:+$note; }ldd"; }
fi
else
httpd="active"

View file

@ -49,19 +49,20 @@ fi
: "${SSH_CMD_TIMEOUT:=60}"
ssh_dt() {
# Prefer Host alias; fall back to explicit user@host if config missing
# Prefer Host alias; fall back to explicit user@host if config missing.
# Always -n: do not read local stdin (ladder while-read must not be drained by ssh).
local target="$DEVTESTING_SSH"
if ! ssh -G "$target" >/dev/null 2>&1; then
target="devtesting@rusty.taler-ops.ch"
fi
if command -v timeout >/dev/null 2>&1; then
timeout "${SSH_CMD_TIMEOUT}" ssh \
timeout "${SSH_CMD_TIMEOUT}" ssh -n \
-o BatchMode=yes \
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
-o StrictHostKeyChecking=accept-new \
"$target" "$@"
else
ssh \
ssh -n \
-o BatchMode=yes \
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
-o StrictHostKeyChecking=accept-new \
@ -254,7 +255,7 @@ else:
lines = ["CHF:%s" % fmt(a) for a in amts]
out.write_text("\n".join(lines) + "\n")
PY
n_rungs=$(grep -cE '^CHF:' "$LADDER_FILE" || echo 0)
n_rungs_plan=$(grep -cE '^CHF:' "$LADDER_FILE" || echo 0)
ladder_sum=$(python3 -c '
from decimal import Decimal
s=Decimal(0)
@ -265,14 +266,22 @@ for ln in open("'"$LADDER_FILE"'"):
print(s)
' 2>/dev/null || echo "?")
info "ladder plan" "steps=${n_rungs} max=CHF:${DEVTESTING_LADDER_MAX} min=CHF:${DEVTESTING_LADDER_MIN} sum≈CHF:${ladder_sum} (synthetic debit geniban each rung)"
info "ladder plan" "steps=${n_rungs_plan} max=CHF:${DEVTESTING_LADDER_MAX} min=CHF:${DEVTESTING_LADDER_MIN} sum≈CHF:${ladder_sum} (synthetic debit geniban each rung)"
info "ladder funding" "each rung uses geniban debit payto — no real bank withdraw; volume covers max rung CHF:${DEVTESTING_LADDER_MAX}"
LADDER_OK=0
LADDER_FAIL=0
LADDER_WARN=0
rung=0
while IFS= read -r amt || [ -n "$amt" ]; do
# Load amounts into an array first — never `while read … done <file` around ssh:
# without ssh -n, OpenSSH drains the ladder file as remote stdin (only rung 1 ran).
mapfile -t LADDER_AMTS < <(grep -E '^CHF:' "$LADDER_FILE" || true)
rm -f "$LADDER_FILE"
n_rungs=${#LADDER_AMTS[@]}
if [ "$n_rungs" -eq 0 ]; then
fail "ladder" "no CHF: amounts generated"
else
for amt in "${LADDER_AMTS[@]}"; do
[ -n "$amt" ] || continue
rung=$((rung + 1))
subject="mon-fake-ladder-${ts_base}-r${rung}-$$"
@ -300,20 +309,25 @@ print(s)
fail "rung ${rung}" "${amt} · ${FI_STATUS}" "$(printf '%s' "$FI_FLAT" | head -c 180)"
LADDER_FAIL=$((LADDER_FAIL + 1))
if [ "${DEVTESTING_LADDER_STOP_ON_FAIL}" = "1" ]; then
warn "ladder stop" "DEVTESTING_LADDER_STOP_ON_FAIL=1 after rung ${rung}"
warn "ladder stop" "DEVTESTING_LADDER_STOP_ON_FAIL=1 after rung ${rung}/${n_rungs}"
break
fi
fi
done <"$LADDER_FILE"
rm -f "$LADDER_FILE"
done
fi
info "ladder summary" "ok=${LADDER_OK} warn=${LADDER_WARN} fail=${LADDER_FAIL} / planned=${n_rungs} · max=CHF:${DEVTESTING_LADDER_MAX}"
if [ "$LADDER_FAIL" -gt 0 ] && [ "$LADDER_OK" -eq 0 ]; then
if [ "$n_rungs" -eq 0 ]; then
:
elif [ "$LADDER_FAIL" -gt 0 ] && [ "$LADDER_OK" -eq 0 ]; then
fail "ladder" "all rungs failed"
elif [ "$LADDER_FAIL" -gt 0 ]; then
warn "ladder" "partial failures fail=${LADDER_FAIL} ok=${LADDER_OK}"
warn "ladder" "partial failures fail=${LADDER_FAIL} ok=${LADDER_OK}/${n_rungs}"
elif [ "$LADDER_OK" -lt "$n_rungs" ]; then
# e.g. stdin-eaten loop used to stop after 1 rung with ok=1 fail=0
warn "ladder incomplete" "only ${LADDER_OK}/${n_rungs} rungs ran (ok) — not through max CHF:${DEVTESTING_LADDER_MAX}"
else
ok "ladder complete" "ok=${LADDER_OK} through max CHF:${DEVTESTING_LADDER_MAX}"
ok "ladder complete" "ok=${LADDER_OK}/${n_rungs} through max CHF:${DEVTESTING_LADDER_MAX}"
fi
else
info "ladder" "skipped (DEVTESTING_LADDER=0)"

View file

@ -29,12 +29,14 @@ if [ -z "$PROFILE" ]; then
fi
fi
# Resolve host-podman vs ssh before first check ID
# Resolve host-podman vs ssh before first check ID.
# stage-lfp on the FP host itself uses local podman (INSIDE_PODMAN=1 /
# INSIDE_MODE=local-podman); laptop → stage still uses INSIDE_SSH.
_use_local_podman=0
if [ "$PROFILE" != "stage-lfp" ]; then
if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then
if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then
_use_local_podman=1
elif command -v podman >/dev/null 2>&1 \
elif [ "$PROFILE" != "stage-lfp" ]; then
if command -v podman >/dev/null 2>&1 \
&& podman ps --format '{{.Names}}' 2>/dev/null | grep -qE 'taler-hacktivism'; then
_use_local_podman=1
fi
@ -52,7 +54,7 @@ section "inside · collect (${PROFILE} · access=${INSIDE_ACCESS})"
info "flags" "INSIDE_ACCESS=${INSIDE_ACCESS} INSIDE_PODMAN=${INSIDE_PODMAN:-0} INSIDE_MODE=${INSIDE_MODE:-} LOCAL_STACK=${LOCAL_STACK:-0} SKIP_SSH=${SKIP_SSH:-0} KOOPA_SSH=${KOOPA_SSH:-} INSIDE_SSH=${INSIDE_SSH:-}"
# ---------------------------------------------------------------------------
# stage-lfp: low-priv stagepaysan on the FP stage host (INSIDE_SSH)
# stage-lfp: stagepaysan podman (host-local or SSH INSIDE_SSH from laptop)
# ---------------------------------------------------------------------------
if [ "$PROFILE" = "stage-lfp" ]; then
SSH_HOST="${INSIDE_SSH:-}"
@ -69,15 +71,23 @@ if [ "$PROFILE" = "stage-lfp" ]; then
STAGE_SSH_T="${INSIDE_SSH_TIMEOUT:-${SSH_CMD_TIMEOUT:-24}}"
if [ "${STAGE_SSH_T}" -lt 24 ] 2>/dev/null; then STAGE_SSH_T=24; fi
if ! mon_ssh_ok "$SSH_HOST"; then
err "ssh" "cannot reach ${SSH_HOST} (stagepaysan low-priv) — set INSIDE_SSH= or SKIP"
if [ "$_use_local_podman" = "1" ]; then
if ! command -v podman >/dev/null 2>&1; then
err "host" "INSIDE_PODMAN/host-podman but podman missing"
summary
exit 1
fi
ok "host→container" "podman exec on this host (INSIDE_ACCESS=host-podman · no SSH)"
elif ! mon_ssh_ok "$SSH_HOST"; then
err "ssh" "cannot reach ${SSH_HOST:-?} (stagepaysan low-priv) — set INSIDE_SSH= or INSIDE_PODMAN=1"
summary
exit 1
else
ok "ssh ${SSH_HOST}" "stagepaysan (podman, no sudo)"
fi
# Inject names/ports into remote (ssh bash -s does not inherit local env).
RAW=$(
# Inject names/ports (ssh bash -s does not inherit local env; local bash does).
_stage_lfp_script() {
{
printf 'BANK_CTR=%q; EX_CTR=%q; MER_CTR=%q\n' "$BANK_CTR" "$EX_CTR" "$MER_CTR"
printf 'BANK_PORT=%q; EX_PORT=%q; MER_PORT=%q\n' "$BANK_PORT" "$EX_PORT" "$MER_PORT"
@ -173,11 +183,20 @@ fi
echo DONE
REMOTE
} | mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T}" || true
)
}
}
if [ "$_use_local_podman" = "1" ]; then
RAW=$(_stage_lfp_script | bash || true)
else
RAW=$(_stage_lfp_script | mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T}" || true)
fi
if [ -z "$RAW" ] || ! echo "$RAW" | grep -q '^E|'; then
if [ "$_use_local_podman" = "1" ]; then
err "host" "stage collect timed out or empty (local podman)"
else
err "ssh" "stage remote timed out or empty (cap ${STAGE_SSH_T}s · host=${SSH_HOST})"
fi
summary
exit 1
fi
@ -189,7 +208,7 @@ REMOTE
IFS='|' read -r _ comp level key detail <<<"$line"
case "$comp" in
bank|exchange|merchant|caddy) _g="$comp" ;;
*) _g="ssh" ;;
*) _g="$INSIDE_ACCESS" ;;
esac
if [ "$_g" != "$_last_inside_grp" ]; then
set_group "$_g"
@ -242,6 +261,28 @@ REMOTE
# Host load as stagepaysan (no container RSS from koopa metrics)
set_group load
section "inside · load (stagepaysan host)"
_load_py() {
python3 - <<'PY'
import os
la=os.getloadavg()
print("loadavg=%.2f,%.2f,%.2f" % la)
try:
with open("/proc/meminfo") as f:
d={}
for line in f:
k,v=line.split(":")[0], line.split(":")[1].strip().split()[0]
d[k]=int(v)
total=d.get("MemTotal",0)/1024/1024
avail=d.get("MemAvailable",0)/1024/1024
used=total-avail
print("mem_used=%.2fGiB avail=%.2fGiB total=%.2fGiB" % (used, avail, total))
except Exception:
print("mem=?")
PY
}
if [ "$_use_local_podman" = "1" ]; then
LOAD_LINE=$(_load_py || true)
else
LOAD_LINE=$(mon_ssh_bash "$SSH_HOST" 8 <<'EOF' || true
python3 - <<'PY'
import os
@ -262,6 +303,7 @@ except Exception:
PY
EOF
)
fi
if [ -n "$LOAD_LINE" ]; then
info "stage host" "$(echo "$LOAD_LINE" | tr '\n' ' ')"
else
@ -270,7 +312,8 @@ EOF
set_group disk
section "inside · disk free space (stage host + containers)"
_disk_raw=$(mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T:-24}" <<'DISK' || true
_disk_script() {
cat <<'DISK'
set +e
echo "###HOST###"
df -Pk / /var /home /tmp /mnt/data 2>/dev/null || df -Pk
@ -280,9 +323,16 @@ for c in $(podman ps --format '{{.Names}}' 2>/dev/null); do
podman exec "$c" df -Pk / /var /tmp 2>/dev/null || podman exec "$c" df -Pk 2>/dev/null
done
DISK
)
}
if [ "$_use_local_podman" = "1" ]; then
_disk_raw=$(_disk_script | bash || true)
_disk_label="host"
else
_disk_raw=$(_disk_script | mon_ssh_bash "$SSH_HOST" "${STAGE_SSH_T:-24}" || true)
_disk_label="ssh:${SSH_HOST}"
fi
_host_df=$(printf '%s\n' "$_disk_raw" | sed -n '/^###HOST###$/,/^###CTRS###$/p' | sed '1d;$d')
mon_disk_check_remote_text "ssh:${SSH_HOST}" "$_host_df" || true
mon_disk_check_remote_text "$_disk_label" "$_host_df" || true
_ctr=""; _buf=""
while IFS= read -r _line || [ -n "$_line" ]; do
case "$_line" in

View file

@ -5,6 +5,20 @@
# Override: MATTERMOST_PUBLIC=https://mattermost.example.org
# MATTERMOST_HOST=mattermost.example.org
#
# Client compatibility (feature) — server min for current *official* clients:
# Android / iOS store apps → default ≥ 10.11.0 (Play/App Store + mobile docs 2026)
# Desktop (Win/Mac/Linux) → default ≥ 10.11.0 (current Desktop / ESR pairing)
# Vendor support floor → default ≥ 10.11.0 (ESR; older = unsupported)
# Web SPA → served by this server (always loads) but WARN if
# server is below support floor (security/EOL).
#
# Env:
# MATTERMOST_CLIENT_CHECK=1|0 master switch (default 1)
# MATTERMOST_MOBILE_CHECK=1|0 alias / legacy (if set 0 and CLIENT unset → off)
# MATTERMOST_MOBILE_MIN_SERVER default 10.11.0
# MATTERMOST_DESKTOP_MIN_SERVER default 10.11.0
# MATTERMOST_SUPPORT_MIN_SERVER default 10.11.0 (vendor-supported floor)
#
# Outside-only (no SSH). Phase name: mattermost
#
set -euo pipefail
@ -15,6 +29,20 @@ source "$ROOT/lib.sh"
set_area mattermost
section "mattermost · public chat (outside-in)"
# Defaults: current official clients + vendor ESR floor (2026-07 docs).
: "${MATTERMOST_MOBILE_MIN_SERVER:=10.11.0}"
: "${MATTERMOST_DESKTOP_MIN_SERVER:=10.11.0}"
: "${MATTERMOST_SUPPORT_MIN_SERVER:=10.11.0}"
# Master switch: CLIENT_CHECK, else legacy MOBILE_CHECK, else on
if [ -n "${MATTERMOST_CLIENT_CHECK:-}" ]; then
:
elif [ -n "${MATTERMOST_MOBILE_CHECK:-}" ]; then
MATTERMOST_CLIENT_CHECK="${MATTERMOST_MOBILE_CHECK}"
else
MATTERMOST_CLIENT_CHECK=1
fi
: "${MATTERMOST_MOBILE_CHECK:=${MATTERMOST_CLIENT_CHECK}}"
if [ -n "${MATTERMOST_PUBLIC:-}" ]; then
BASE="${MATTERMOST_PUBLIC%/}"
elif [ -n "${MATTERMOST_HOST:-}" ]; then
@ -27,6 +55,7 @@ HOST=${HOST#http://}
HOST=${HOST%%/*}
info "target" "$BASE"
info "client policy" "mobile≥${MATTERMOST_MOBILE_MIN_SERVER} · desktop≥${MATTERMOST_DESKTOP_MIN_SERVER} · support≥${MATTERMOST_SUPPORT_MIN_SERVER} (CLIENT_CHECK=${MATTERMOST_CLIENT_CHECK})"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
@ -46,30 +75,139 @@ else
fail "landing" "$BASE/ → HTTP $code (want 200 HTML Mattermost SPA)"
fi
# --- Official Mattermost health API ---
# --- Official Mattermost health API + server version (X-Version-Id) ---
set_group api
code=$(mm_get "$BASE/api/v4/system/ping" "$tmp/ping.json")
# Capture body + headers (version is in X-Version-Id, not always in JSON)
hdr_file="$tmp/ping.hdr"
code=$(curl -skS -L --max-redirs 5 -m "${TIMEOUT:-12}" \
-D "$hdr_file" -o "$tmp/ping.json" -w '%{http_code}' \
"$BASE/api/v4/system/ping" 2>/dev/null || echo 000)
MM_SERVER_VER=""
if [ -f "$hdr_file" ]; then
# X-Version-Id: 9.5.3.8427860509.c8cabb… or 10.11.0.…
MM_SERVER_VER=$(grep -i '^x-version-id:' "$hdr_file" | head -1 \
| sed 's/^[Xx]-[Vv]ersion-[Ii]d:[[:space:]]*//' | tr -d '\r' \
| sed 's/^\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/')
fi
if [ "$code" != "200" ]; then
fail "system/ping" "$BASE/api/v4/system/ping → HTTP $code (want 200 JSON)"
else
# Ping returns JSON object (fields vary by MM version)
if python3 - "$tmp/ping.json" <<'PY' 2>/dev/null
import json, sys
p = sys.argv[1]
with open(p) as f:
with open(sys.argv[1]) as f:
d = json.load(f)
if not isinstance(d, dict):
sys.exit(2)
# older builds may include status; newer still return object with backends
sys.exit(0)
sys.exit(0 if isinstance(d, dict) else 2)
PY
then
ok "system/ping" "$BASE/api/v4/system/ping → HTTP 200 JSON"
ok "system/ping" "$BASE/api/v4/system/ping → HTTP 200 JSON${MM_SERVER_VER:+ · server ${MM_SERVER_VER}}"
else
fail "system/ping" "$BASE/api/v4/system/ping → HTTP 200 but not valid JSON object"
fi
fi
# --- Server version vs official client families (mobile / desktop / support / web) ---
set_group clients
mm_ver_ge() {
# $1=server $2=need → 0 if server >= need
python3 - "$1" "$2" <<'PY'
import sys
def parse(v: str):
parts = []
for p in v.strip().split(".")[:3]:
try:
parts.append(int(p))
except ValueError:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return tuple(parts)
sys.exit(0 if parse(sys.argv[1]) >= parse(sys.argv[2]) else 1)
PY
}
# One policy line: id|min_env_default|severity(error|warn)|human label
# severity: error → fail; warn → warn
_mm_client_policies() {
printf '%s\n' \
"mobile|${MATTERMOST_MOBILE_MIN_SERVER}|error|Android/iOS store apps (Mattermost Mobile)" \
"desktop|${MATTERMOST_DESKTOP_MIN_SERVER}|error|Desktop app (Windows/macOS/Linux official)" \
"support|${MATTERMOST_SUPPORT_MIN_SERVER}|error|vendor-supported server floor (ESR/current)" \
"web|${MATTERMOST_SUPPORT_MIN_SERVER}|warn|browser webapp (loads with server, but EOL server is insecure/unsupported)"
}
if [ "${MATTERMOST_CLIENT_CHECK}" = "0" ]; then
info "client compat" "skipped (MATTERMOST_CLIENT_CHECK=0 / MATTERMOST_MOBILE_CHECK=0)"
elif [ -z "$MM_SERVER_VER" ]; then
warn "server version" "could not parse X-Version-Id from /api/v4/system/ping — cannot judge client compatibility"
else
info "server version" "X-Version-Id → ${MM_SERVER_VER}"
while IFS='|' read -r cid cmin csever clabel; do
[ -n "$cid" ] || continue
if mm_ver_ge "$MM_SERVER_VER" "$cmin"; then
ok "${cid} compat" "server ${MM_SERVER_VER}${cmin} · ${clabel} OK"
else
detail="server ${MM_SERVER_VER} < ${cmin} · ${clabel}"
hint="upgrade Mattermost server · set MATTERMOST_${cid^^}_MIN_SERVER= or MATTERMOST_CLIENT_CHECK=0"
# Fix hint for support/web keys
case "$cid" in
mobile) hint="upgrade server · MATTERMOST_MOBILE_MIN_SERVER= / MATTERMOST_CLIENT_CHECK=0" ;;
desktop) hint="upgrade server · MATTERMOST_DESKTOP_MIN_SERVER= / MATTERMOST_CLIENT_CHECK=0" ;;
support) hint="server below vendor support floor · MATTERMOST_SUPPORT_MIN_SERVER= / MATTERMOST_CLIENT_CHECK=0" ;;
web) hint="web SPA is served by this old server (works) but platform is unsupported · upgrade recommended" ;;
esac
if [ "$csever" = "error" ]; then
fail "${cid} compat" "$detail" "$hint"
else
warn "${cid} compat" "$detail · $hint"
fi
fi
done < <(_mm_client_policies)
fi
# Client config (optional): BuildDate / advertised Android/iOS min if server sets it
set_group client-config
code=$(mm_get "$BASE/api/v4/config/client?format=old" "$tmp/client.json")
if [ "$code" = "200" ] && [ -s "$tmp/client.json" ]; then
build=$(python3 - "$tmp/client.json" <<'PY' 2>/dev/null
import json, sys
with open(sys.argv[1]) as f:
d = json.load(f)
print(d.get("BuildDate") or d.get("Version") or "")
PY
)
and_min=$(python3 - "$tmp/client.json" <<'PY' 2>/dev/null
import json, sys
with open(sys.argv[1]) as f:
d = json.load(f)
print((d.get("AndroidMinVersion") or "").strip())
PY
)
ios_min=$(python3 - "$tmp/client.json" <<'PY' 2>/dev/null
import json, sys
with open(sys.argv[1]) as f:
d = json.load(f)
print((d.get("IosMinVersion") or d.get("IOSMinVersion") or "").strip())
PY
)
if [ -n "$build" ]; then
info "client config" "BuildDate/Version=${build}${and_min:+ · AndroidMinVersion=${and_min}}${ios_min:+ · IosMinVersion=${ios_min}}"
else
info "client config" "HTTP 200 (no BuildDate field)"
fi
if [ -n "$and_min" ] && [ "$and_min" != "0.0.0" ]; then
info "android min (server)" "server advertises AndroidMinVersion=${and_min}"
fi
if [ -n "$ios_min" ] && [ "$ios_min" != "0.0.0" ]; then
info "ios min (server)" "server advertises IosMinVersion=${ios_min}"
fi
else
info "client config" "HTTP ${code:-?} — optional"
fi
# --- Login SPA (must be served for users) ---
set_group login
code=$(mm_get "$BASE/login" "$tmp/login.html")
@ -109,5 +247,9 @@ else
info "tls cert" "openssl not available — skip expiry check"
fi
info "hint" "surface catalog also lists $HOST; this phase is Mattermost-specific (SPA + /api/v4/system/ping)"
info "hint" "surface catalog also lists $HOST; client floors: mobile/desktop/support ≥${MATTERMOST_MOBILE_MIN_SERVER}/${MATTERMOST_DESKTOP_MIN_SERVER}/${MATTERMOST_SUPPORT_MIN_SERVER}"
# Non-zero when any fail/err so parent phase + mon pages mark ERROR
if [ "${FAIL_N:-0}" -gt 0 ]; then
exit 1
fi
exit 0

View file

@ -353,7 +353,13 @@ htmlify_host() {
mkdir -p "$HTML_BASE/$host/${HTML_OK_DIR}" "$HTML_BASE/$host/${HTML_ERR_DIR}"
if [ -n "$SITE_GEN" ] && [ -f "$SITE_GEN/console_to_html.py" ]; then
python3 "$SITE_GEN/console_to_html.py" \
# PYTHONWARNINGS: never let SyntaxWarning leak into the suite log (tee).
# stderr → agent log file only, not the mon console stream.
_mon_html_py() {
PYTHONWARNINGS=ignore::SyntaxWarning \
python3 "$SITE_GEN/console_to_html.py" "$@" 2>>"${LOG_DIR:-/tmp}/console_to_html.err"
}
_mon_html_py \
--lang "${TALER_MON_LANG:-en}" \
--log "$LOG" \
--out "$mon_err" \
@ -368,7 +374,7 @@ htmlify_host() {
--path-err "$HTML_URL_ERR" \
--link-other "$HTML_URL_OK"
if [ "$ec" -eq 0 ]; then
python3 "$SITE_GEN/console_to_html.py" \
_mon_html_py \
--lang "${TALER_MON_LANG:-en}" \
--log "$LOG" \
--out "$mon" \
@ -385,7 +391,7 @@ htmlify_host() {
rm -rf "$HTML_BASE/$host/${HTML_ERR_DIR}"
echo "html $host${HTML_URL_OK} only (clean · ${COMMIT_SHORT:-?})"
else
python3 "$SITE_GEN/console_to_html.py" \
_mon_html_py \
--lang "${TALER_MON_LANG:-en}" \
--log "$LOG" \
--out "$mon" \

View file

@ -8,7 +8,8 @@
# SUITE_GIT_URL default https://git.hacktivism.ch/hernani/taler-monitoring.git
# SUITE_GIT_REF default main
# SUITE_DIR default ~/src/taler-monitoring
# SUITE_UPDATE_MODE reset (default) | pull
# SUITE_UPDATE_MODE reset (default) | pull | keep
# keep = no fetch/reset (local deploy / unpushed fixes)
# SUITE_UPDATE_STRICT 1 (default) = fetch/reset failure aborts the agent run
# 0 = warn and continue on old tree (emergency only)
#
@ -55,11 +56,16 @@ _before_ver=$(git describe --tags --exact-match 2>/dev/null \
|| git describe --tags --abbrev=0 2>/dev/null \
|| echo unknown)
echo "fetch origin ($SUITE_GIT_REF) + tags …"
if ! git fetch --tags --force --prune origin 2>&1; then
case "$SUITE_UPDATE_MODE" in
keep|skip|off|none)
echo "suite update skipped (SUITE_UPDATE_MODE=$SUITE_UPDATE_MODE) — keep local tree ${_before} (${_before_ver})"
;;
*)
echo "fetch origin ($SUITE_GIT_REF) + tags …"
if ! git fetch --tags --force --prune origin 2>&1; then
_upd_die "git fetch failed — suite would stay at ${_before} (${_before_ver})"
# fall through only if STRICT=0
else
else
case "$SUITE_UPDATE_MODE" in
reset)
if ! git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null \
@ -71,13 +77,20 @@ else
_upd_die "git reset --hard origin/$SUITE_GIT_REF failed"
fi
;;
*)
pull)
if ! git pull --ff-only origin "$SUITE_GIT_REF" 2>&1; then
_upd_die "git pull --ff-only failed"
fi
;;
*)
if ! git pull --ff-only origin "$SUITE_GIT_REF" 2>&1; then
_upd_die "git pull --ff-only failed (mode=$SUITE_UPDATE_MODE)"
fi
;;
esac
fi
fi
;;
esac
COMMIT=$(git rev-parse HEAD)
COMMIT_SHORT=$(git rev-parse --short=12 HEAD)

View file

@ -4,8 +4,9 @@ from __future__ import annotations
import argparse
import html
import re
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
@ -69,16 +70,25 @@ def ui(lang: str, key: str, **kwargs) -> str:
"jump_first_warn": "Jump to first warning",
"filter_errors": "Filter: errors + a few dimmed context lines",
"filter_warns": "Filter: warnings + a few dimmed context lines",
"filter_infos": "Filter: info lines + a few dimmed context lines",
"filter_oks": "Filter: OK lines + a few dimmed context lines",
"filter_clear": "Clear filter · show all lines",
"filter_active_err": "Filter: errors + context",
"filter_active_warn": "Filter: warnings + context",
"filter_active_info": "Filter: info + context",
"filter_active_ok": "Filter: OK only + context",
"filter_show_all": "Show all",
"info_one": "{n} info",
"info_many": "{n} info",
"ok_one": "{n} ok",
"ok_many": "{n} ok",
"env_context": "Monitoring env context",
"env_context_title": "Expand: agent env, suite pin, domain hosts & flags (from run header)",
"env_context_empty": "No run header in log",
"generated": "generated",
"version": "version",
"version_tree_link": "open this runs git tree (commit)",
"suite_version_title": "taler-monitoring suite version · exact run tree",
"source": "source",
"what_monitors": "What this monitors",
@ -138,16 +148,18 @@ def ui(lang: str, key: str, **kwargs) -> str:
"stage_i6": "devtesting: fake-franken CHF via rusty.taler-ops.ch (geniban + fake-incoming)",
"pages_section_lead": "Monitoring pages themselves (suite):",
"mm_title": "Mattermost chat health",
"mm_summary": "mattermost.taler.net · SPA + /api/v4/system/ping · TLS",
"mm_summary": "mattermost.taler.net · SPA + ping · client floors (mobile/desktop/support) · TLS",
"mm_i1": "Outside-in phase mattermost (default https://mattermost.taler.net)",
"mm_i2": "Landing SPA, /api/v4/system/ping JSON, /login, TLS certificate expiry",
"mm_i3": "Reported on taler-monitoring-surface HTML (no separate /taler-monitoring-mattermost* page)",
"mm_i2": "Landing SPA, /api/v4/system/ping, X-Version-Id vs client mins (mobile/desktop/support, default ≥10.11.0)",
"mm_i3": "ERROR if server too old for current Android/iOS store or Desktop apps; WARN for web on EOL server",
"mm_i4": "Env: MATTERMOST_MOBILE/DESKTOP/SUPPORT_MIN_SERVER, MATTERMOST_CLIENT_CHECK=0 to skip",
"mm_i5": "Reported on taler-monitoring-surface HTML (no separate /taler-monitoring-mattermost* page)",
"mail_title": "Mail (MX / SMTP / IMAP)",
"mail_summary": "firefly.gnunet.org + anastasis.taler-systems.com · mail-catalog.conf",
"mail_i1": "Outside-in phase mail: MX, SMTP/IMAP ports and handshakes, SPF/DMARC",
"mail_i2": "Mail hosts: firefly.gnunet.org (taler.net/gnunet.org) and anastasis.taler-systems.com",
"mail_i3": "Reported on taler-monitoring-surface HTML (no separate /taler-monitoring-mail* page)",
"footer": "Console-style render of taler-monitoring output. Sticky bar: green = clean · yellow = warnings · red = errors. Click error/warning counts to filter the log (match lines plus a few dimmed neighbours for context). Monitoring env context (agent header) is collapsed by default. Commit pins the exact tree used for this run.",
"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",
@ -168,16 +180,25 @@ def ui(lang: str, key: str, **kwargs) -> str:
"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",
@ -237,16 +258,18 @@ def ui(lang: str, key: str, **kwargs) -> str:
"pages_i6": "Hôte de cette page : {host} · libellé : {label}",
"pages_section_lead": "Les pages de monitoring elles-mêmes (suite) :",
"mm_title": "Santé du chat Mattermost",
"mm_summary": "mattermost.taler.net · SPA + /api/v4/system/ping · TLS",
"mm_summary": "mattermost.taler.net · SPA + ping · seuils clients (mobile/desktop/support) · TLS",
"mm_i1": "Phase outside-in mattermost (défaut https://mattermost.taler.net)",
"mm_i2": "SPA d'accueil, /api/v4/system/ping JSON, /login, expiration du certificat TLS",
"mm_i3": "Rapporté dans le HTML taler-monitoring-surface (pas de page /taler-monitoring-mattermost*)",
"mm_i2": "SPA, /api/v4/system/ping, X-Version-Id vs seuils clients (mobile/desktop/support, défaut ≥10.11.0)",
"mm_i3": "ERROR si serveur trop vieux pour apps store Android/iOS ou Desktop ; WARN web sur serveur EOL",
"mm_i4": "Env : MATTERMOST_MOBILE/DESKTOP/SUPPORT_MIN_SERVER, MATTERMOST_CLIENT_CHECK=0 pour ignorer",
"mm_i5": "Rapporté sur la page surface HTML",
"mail_title": "Mail (MX / SMTP / IMAP)",
"mail_summary": "firefly.gnunet.org + anastasis.taler-systems.com · mail-catalog.conf",
"mail_i1": "Phase outside-in mail : MX, ports SMTP/IMAP et handshakes, SPF/DMARC",
"mail_i2": "Hôtes mail : firefly.gnunet.org (taler.net/gnunet.org) et anastasis.taler-systems.com",
"mail_i3": "Rapporté dans le HTML taler-monitoring-surface (pas de page /taler-monitoring-mail*)",
"footer": "Rendu console de taler-monitoring. Barre collante : vert = OK · jaune = avertissements · rouge = erreurs. Compteurs = filtre. Contexte d'env. (en-tête agent) replié par défaut. Le commit fixe l'arbre exact de cette exécution.",
"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",
@ -289,14 +312,26 @@ def is_numbered_check_line(line: str) -> bool:
def classify(line: str) -> str:
u = line.upper()
low = line.lower()
# SUMMARY box header only when the word SUMMARY is present (not phase section() boxes).
# Phase section() also uses ╔═║╚ with blue BG_SEC — those must stay class "section".
if "SUMMARY" in u:
# 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:
# EN + FR section headers (i18n_tag ERROR→ERREUR, ERRORS→ERREURS)
if (
"--- ERRORS" in u
or "--- ERREURS" in u
or "ERRORS ·" in u
or "ERREURS ·" in u
or "ERRORS (" in u
or "ERREURS (" in u
):
return "meta"
if "numbered checks" in low:
return "meta"
@ -323,14 +358,15 @@ def classify(line: str) -> str:
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):
# and not SUMMARY count rows like " [ ERROR ] 3").
# French console (i18n_tag): ERROR→ERREUR, WARN→AVERT (v1.18.1).
if re.search(r"\s*(?:ERROR|ERREUR)\b|\[\s*(?:ERROR|ERREUR)\b", u):
if not is_numbered_check_line(line):
return "sum-err"
return "error"
if re.search(r"\bERROR monpages\b", line, re.I):
return "error"
if re.search(r"\s*WARN\b|\[\s*WARN\b", u):
if re.search(r"\s*(?:WARN|AVERT)\b|\[\s*(?:WARN|AVERT)\b", u):
if not is_numbered_check_line(line):
return "sum-warn"
return "warn"
@ -481,11 +517,25 @@ def extract_errors(lines: list[str]) -> 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:
u = ln.upper()
if (
"--- ERRORS" in u
or "--- ERREURS" in u
or "ERRORS (FAILED" in u
or "ERREURS (CONTROLES" in u
or "ERREURS (FAILED" in u
or "RUN TIMEOUT · EXTRAORDINARY" in u
):
in_block = True
continue
if in_block:
if ln.strip().startswith("---") or "[ SUMMARY" in ln or "totals:" in ln:
if (
ln.strip().startswith("---")
or "[ SUMMARY" in ln
or "[ RESUME" in u
or "totals:" in ln.lower()
or "totaux :" in ln.lower()
):
in_block = False
continue
m = ERR_BULLET_RE.match(ln)
@ -495,10 +545,15 @@ def extract_errors(lines: list[str]) -> list[str]:
timeout_detail = body
else:
errs.append(body)
elif "ERROR" in ln.upper() and ln.strip() and not is_run_timeout_text(ln):
elif ("ERROR" in u or "ERREUR" in u) and ln.strip() and not is_run_timeout_text(ln):
errs.append(ln.strip())
# Only real check badges (not INFO text mentioning thresholds like "… ERROR (0=disable)")
if re.search(r"\s*ERROR\b|\[\s*ERROR\b", ln, re.I) and "--- ERRORS" not in ln:
# FR: i18n_tag ERROR→ERREUR (v1.18.1)
if (
re.search(r"\s*(?:ERROR|ERREUR)\b|\[\s*(?:ERROR|ERREUR)\b", ln, re.I)
and "--- ERRORS" not in u
and "--- ERREURS" not in u
):
if is_run_timeout_text(ln):
if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln:
timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip()
@ -507,8 +562,10 @@ def extract_errors(lines: list[str]) -> list[str]:
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():
body = re.sub(
r"^.*\b(?:ERROR|ERREUR)\b\s*[\]┐]?\s*", "", ln, flags=re.I
).strip(" ·")
if body and body not in errs and "failed — see" not in body.lower() and "echec — voir" not in body.lower():
if re.search(
r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.",
body,
@ -534,12 +591,18 @@ def extract_errors(lines: list[str]) -> list[str]:
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)."""
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"):
@ -552,7 +615,15 @@ def count_status(lines: list[str]) -> tuple[int, int, int | None, int | None]:
n_warn += 1
if first_warn is None:
first_warn = i
return n_err, n_warn, first_err, first_warn
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:
@ -697,6 +768,8 @@ def monitoring_scope(
ui(lang, "mm_i1"),
ui(lang, "mm_i2"),
ui(lang, "mm_i3"),
ui(lang, "mm_i4"),
ui(lang, "mm_i5"),
] + _pages_items(lang, host, label)
return {
"kind": "mattermost",
@ -965,6 +1038,19 @@ def is_log_noise_line(ln: str) -> bool:
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
@ -985,6 +1071,8 @@ def sticky_bar_html(
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"
@ -1032,6 +1120,30 @@ def sticky_bar_html(
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>'
@ -1044,6 +1156,8 @@ def sticky_bar_html(
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"))}"'
)
@ -1066,22 +1180,24 @@ def sticky_bar_html(
f" · <code>{html.escape(suite_path)}</code>" if suite_path else ""
)
# Version badge (linked to Forgejo tag when available)
# 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:
# href is usually src/commit/<sha> (exact run tree); label stays vX.Y.Z
version_html = (
f'<a class="version-link" href="{html.escape(ver_url)}" '
f'title="{html.escape(ui(lang, "version"))} {html.escape(ver)}'
f' · {html.escape(ui(lang, "version_tree_link"))}" '
f'rel="noopener noreferrer">{html.escape(ver)}</a>'
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_html = f'<span class="version-link muted">{html.escape(ver)}</span>'
version_tag = f'<span class="{ver_cls}" title="{ver_title}">{html.escape(ver)}</span>'
else:
version_html = ""
version_tag = ""
# Localize page label for sticky host title (monitoring → surveillance in FR)
pl = (page_label or "monitoring").lower().replace("_", "-")
if "surface" in pl:
@ -1092,21 +1208,27 @@ def sticky_bar_html(
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 always visible; "Was geprüft wird" expands inside sticky-bar
# 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-level="{level}"
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}
{filter_chip}
<span class="sep">·</span>
{version_html}
{info_stat}
<span class="sep">·</span>
{ok_stat}
{filter_chip}
<span class="sep">·</span>
<span class="generated"
data-generated-iso="{html.escape(generated_iso)}"
@ -1178,6 +1300,18 @@ STICKY_CSS = """
.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 {
@ -1186,6 +1320,8 @@ STICKY_CSS = """
}
.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"] {
@ -1201,6 +1337,8 @@ a.stat[aria-pressed="true"] {
.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;
@ -1211,7 +1349,9 @@ body.filter-warn .filter-chip { border-color: #a68b2d; background: #1a1608; colo
/* 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-warn #mon-console,
body.filter-info #mon-console,
body.filter-ok #mon-console {
padding: 6px 8px;
min-height: 0;
display: flex;
@ -1219,7 +1359,9 @@ body.filter-warn #mon-console {
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-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;
@ -1231,13 +1373,19 @@ body.filter-warn #mon-console .line:not(.warn):not(.filter-ctx) {
/* 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-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 warn|error still stands out */
/* 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-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);
@ -1247,19 +1395,25 @@ body.filter-warn #mon-console .line.filter-ctx {
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-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-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-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;
@ -1273,10 +1427,14 @@ body.filter-warn #mon-console .filter-gap {
background: transparent;
height: auto;
}
body:not(.filter-error):not(.filter-warn) #mon-console .filter-gap { display: none !important; }
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 { display: none !important; }
body.filter-warn .err-top { display: none; }
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;
@ -1526,6 +1684,8 @@ STICKY_JS = """
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 "";
}
@ -1551,6 +1711,8 @@ STICKY_JS = """
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) {
@ -1567,9 +1729,19 @@ STICKY_JS = """
return true;
}
var t = (el.textContent || "").replace(/\u00a0/g, " ").trim();
if (/^[\-\s]+$/.test(t)) return true;
// Skip pure chrome lines (spaces/tabs/newlines/hyphen + Unicode box-drawing).
// Do NOT use a JS character-class regex with box glyphs here: when this
// script is embedded in a Python string, Python emits SyntaxWarning for
// sequences like \\- and the glyphs show up as noise in mon logs.
if (!t.length) return true;
for (var bi = 0; bi < t.length; bi++) {
var bc = t.charCodeAt(bi);
if (bc === 32 || bc === 9 || bc === 10 || bc === 13 || bc === 0x2d) continue;
if (bc >= 0x2500 && bc <= 0x257f) continue; // U+2500U+257F box drawing
return false;
}
return true;
}
var keep = new Array(lines.length);
var i, j, k, n, idx;
for (i = 0; i < lines.length; i++) keep[i] = 0;
@ -1618,11 +1790,14 @@ STICKY_JS = """
function setFilter(mode, opts) {
opts = opts || {};
document.body.classList.remove("filter-error", "filter-warn");
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"]');
if (errA) errA.setAttribute("aria-pressed", "false");
if (warnA) warnA.setAttribute("aria-pressed", "false");
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");
@ -1630,11 +1805,19 @@ STICKY_JS = """
} 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");
}
// Mark dimmed neighbours (or clear when filter off)
if (mode === "error" || mode === "warn") markFilterContext(mode);
else clearFilterContext();
if (mode === "error" || mode === "warn" || mode === "info" || mode === "ok") {
markFilterContext(mode);
} else {
clearFilterContext();
}
if (chip && chipLabel && bar) {
if (mode === "error") {
@ -1643,6 +1826,12 @@ STICKY_JS = """
} 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 = "";
@ -1651,24 +1840,28 @@ STICKY_JS = """
if (opts.updateHash !== false) {
var want = mode === "error" ? "#filter-error"
: mode === "warn" ? "#filter-warn" : "";
: 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 (location.hash === "#filter-error" || location.hash === "#filter-warn"
|| location.hash === "#first-error" || location.hash === "#first-warn") {
} 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 id = mode === "error" ? "first-error" : "first-warn";
var el = document.getElementById(id);
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 sel = mode === "error" ? ".line.error, .line.blocker" : ".line.warn";
var first = consoleEl.querySelector(sel);
if (first && first.scrollIntoView) {
setTimeout(function () { first.scrollIntoView({ block: "start", behavior: "smooth" }); }, 30);
@ -1686,7 +1879,9 @@ STICKY_JS = """
a.addEventListener("click", function (ev) {
ev.preventDefault();
var mode = a.getAttribute("data-filter");
if (mode === "error" || mode === "warn") toggleFilter(mode);
if (mode === "error" || mode === "warn" || mode === "info" || mode === "ok") {
toggleFilter(mode);
}
});
});
if (clearBtn) {
@ -1701,6 +1896,10 @@ STICKY_JS = """
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)
}
@ -1739,8 +1938,9 @@ def build_html(
# 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, _, _ = count_status(raw_lines)
_, _, first_err_i, first_warn_i = count_status(body_raw)
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] = {}
@ -1857,6 +2057,8 @@ def build_html(
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,
@ -2227,7 +2429,8 @@ def main() -> None:
for l in log_text.splitlines()
if not is_log_noise_line(strip_ansi(l))
]
n_err, n_warn, _, _ = count_status(raw_for_counts)
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(
@ -2262,7 +2465,12 @@ def main() -> None:
)
atomic_write_text(args.out, html_out, encoding="utf-8")
print(f"wrote {args.out} (errors={n_err} warnings={n_warn} level={status_level(n_err, n_warn)})")
# 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__":

View file

@ -100,7 +100,7 @@ Phases:
surface REMOTE-ONLY public inventory (NOT in default/all/full):
ecosystem hosts (taler.net, gnunet.org, taler-systems.com, mattermost, …)
or -d DOMAIN → that domains surface; port/protocol/TLS/CVE (OSV)
mattermost Mattermost chat health (default mattermost.taler.net; SPA + /api/v4/system/ping)
mattermost Mattermost chat health (SPA + ping + client floors: mobile/desktop/support ≥10.11)
mail MX/SMTP/IMAP for Taler mail (firefly, pixel/TSA, catalogued domains)
monpages public monitoring HTML via FQDN (obligatory ERROR; GOA full inventory, FP only FP)
uses MON_HOSTS + HTML_URL_OK (same as host-agent); v1.3.1+