Compare commits

..

No commits in common. "7b3a345b7de1f2b2b3021dfc7a39315b9fec7ff1" and "b4f5f060ccb07bfe405b82d7515cfe67b4bee975" have entirely different histories.

10 changed files with 182 additions and 703 deletions

View file

@ -1 +1 @@
1.18.1 1.15.4

View file

@ -17,14 +17,6 @@ Git tags: `vMAJOR.FEATURE.FIX` (e.g. `v1.8.0`). File `VERSION` omits the `v` pre
| Tag | Date (UTC) | Notes | | 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.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.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. | | **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() { check_ldd_deep() {
local name=$1 local name=$1
local out missing local out
out=$("$PODMAN_BIN" exec "$name" bash -lc ' out=$("$PODMAN_BIN" exec "$name" bash -lc '
set +e set +e
bin=$(command -v taler-merchant-httpd) bin=$(command -v taler-merchant-httpd)
@ -147,48 +147,15 @@ check_ldd_deep() {
info "ldd" "$line" info "ldd" "$line"
done <<<"$out" done <<<"$out"
if echo "$out" | grep -q 'not found'; then if echo "$out" | grep -q 'not found'; then
missing=$(echo "$out" | grep 'not found' | tr '\n' ' ' | head -c 280) err "ldd" "shared library missing in $name" "see ldd lines above"
err "ldd" "shared library missing in $name" "${missing:-not found (see ldd INFO above)}"
return 1 return 1
fi fi
return 0 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() { check_systemd() {
local name=$1 local name=$1
local out active_httpd before_httpd detail local out active_httpd
out=$("$PODMAN_BIN" exec "$name" bash -lc ' out=$("$PODMAN_BIN" exec "$name" bash -lc '
set +e set +e
echo "system: $(systemctl is-system-running 2>&1)" echo "system: $(systemctl is-system-running 2>&1)"
@ -196,50 +163,17 @@ check_systemd() {
echo "target_active: $(systemctl is-active taler-merchant.target 2>&1)" 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_active: $(systemctl is-active taler-merchant-httpd.service 2>&1)"
echo "httpd_enabled: $(systemctl is-enabled taler-merchant-httpd.service 2>&1)" echo "httpd_enabled: $(systemctl is-enabled taler-merchant-httpd.service 2>&1)"
# unit properties help when is-active is only "activating"/"failed" systemctl start taler-merchant.target 2>&1 | tail -5
echo "httpd_Result: $(systemctl show -p Result --value taler-merchant-httpd.service 2>&1)" sleep 2
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_target: $(systemctl is-active taler-merchant.target 2>&1)"
echo "after_start_httpd: $(systemctl is-active taler-merchant-httpd.service 2>&1)" echo "after_start_httpd: $(systemctl is-active taler-merchant-httpd.service 2>&1)"
echo "after_httpd_Result: $(systemctl show -p Result --value taler-merchant-httpd.service 2>&1)" systemctl --failed --no-legend 2>/dev/null | grep -i taler || echo "failed_taler: none"
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") ' 2>/dev/null || echo "systemd probe failed")
while IFS= read -r line; do while IFS= read -r line; do
[ -n "$line" ] || continue [ -n "$line" ] || continue
info "systemd" "$line" info "systemd" "$line"
done <<<"$out" 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) active_httpd=$(echo "$out" | sed -n 's/^after_start_httpd: //p' | tail -1)
# export for caller board # export for caller board
APT_LAST_HTTPD="${active_httpd:-?}" APT_LAST_HTTPD="${active_httpd:-?}"
@ -247,17 +181,7 @@ check_systemd() {
ok "systemd httpd" "taler-merchant-httpd active after start taler-merchant.target" ok "systemd httpd" "taler-merchant-httpd active after start taler-merchant.target"
return 0 return 0
fi fi
detail=$(_apt_systemd_diagnose "$out") err "systemd httpd" "taler-merchant-httpd not active" "after_start_httpd=${active_httpd:-?} · see systemd lines"
# 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 return 1
} }
@ -297,21 +221,18 @@ check_merchant_basics() {
done <<<"$out" done <<<"$out"
if echo "$out" | grep -qi 'error while loading shared libraries'; then if echo "$out" | grep -qi 'error while loading shared libraries'; then
local lib_err err "httpd" "shared library error on --version" "see basics/ldd"
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 return 1
fi fi
set +e set +e
local ver_out ec "$PODMAN_BIN" exec "$name" taler-merchant-httpd --version >/dev/null 2>&1
ver_out=$("$PODMAN_BIN" exec "$name" taler-merchant-httpd --version 2>&1) local ec=$?
ec=$?
set -e set -e
if [ "$ec" -eq 0 ]; then if [ "$ec" -eq 0 ]; then
ok "httpd --version" "exit 0" ok "httpd --version" "exit 0"
else else
err "httpd --version" "exit $ec" "$(printf '%s' "$ver_out" | tr '\n' ' ' | head -c 240)" err "httpd --version" "exit $ec"
return 1 return 1
fi fi
@ -387,10 +308,9 @@ check_one() {
if ! check_systemd "$name"; then if ! check_systemd "$name"; then
c_fail=1 c_fail=1
httpd="${APT_LAST_HTTPD:-FAIL}" httpd="${APT_LAST_HTTPD:-FAIL}"
note="${note:+$note; }httpd=${httpd}"
# deeper ldd if httpd failed # deeper ldd if httpd failed
if [ "$ldd" != "FAIL" ]; then if [ "$ldd" != "FAIL" ]; then
check_ldd_deep "$name" || { ldd="FAIL"; c_fail=1; note="${note:+$note; }ldd"; } check_ldd_deep "$name" || { ldd="FAIL"; c_fail=1; }
fi fi
else else
httpd="active" httpd="active"

View file

@ -49,20 +49,19 @@ fi
: "${SSH_CMD_TIMEOUT:=60}" : "${SSH_CMD_TIMEOUT:=60}"
ssh_dt() { 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" local target="$DEVTESTING_SSH"
if ! ssh -G "$target" >/dev/null 2>&1; then if ! ssh -G "$target" >/dev/null 2>&1; then
target="devtesting@rusty.taler-ops.ch" target="devtesting@rusty.taler-ops.ch"
fi fi
if command -v timeout >/dev/null 2>&1; then if command -v timeout >/dev/null 2>&1; then
timeout "${SSH_CMD_TIMEOUT}" ssh -n \ timeout "${SSH_CMD_TIMEOUT}" ssh \
-o BatchMode=yes \ -o BatchMode=yes \
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \ -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
-o StrictHostKeyChecking=accept-new \ -o StrictHostKeyChecking=accept-new \
"$target" "$@" "$target" "$@"
else else
ssh -n \ ssh \
-o BatchMode=yes \ -o BatchMode=yes \
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \ -o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
-o StrictHostKeyChecking=accept-new \ -o StrictHostKeyChecking=accept-new \
@ -255,7 +254,7 @@ else:
lines = ["CHF:%s" % fmt(a) for a in amts] lines = ["CHF:%s" % fmt(a) for a in amts]
out.write_text("\n".join(lines) + "\n") out.write_text("\n".join(lines) + "\n")
PY PY
n_rungs_plan=$(grep -cE '^CHF:' "$LADDER_FILE" || echo 0) n_rungs=$(grep -cE '^CHF:' "$LADDER_FILE" || echo 0)
ladder_sum=$(python3 -c ' ladder_sum=$(python3 -c '
from decimal import Decimal from decimal import Decimal
s=Decimal(0) s=Decimal(0)
@ -266,22 +265,14 @@ for ln in open("'"$LADDER_FILE"'"):
print(s) print(s)
' 2>/dev/null || echo "?") ' 2>/dev/null || echo "?")
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 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 funding" "each rung uses geniban debit payto — no real bank withdraw; volume covers max rung CHF:${DEVTESTING_LADDER_MAX}" 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_OK=0
LADDER_FAIL=0 LADDER_FAIL=0
LADDER_WARN=0 LADDER_WARN=0
rung=0 rung=0
# Load amounts into an array first — never `while read … done <file` around ssh: while IFS= read -r amt || [ -n "$amt" ]; do
# 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 [ -n "$amt" ] || continue
rung=$((rung + 1)) rung=$((rung + 1))
subject="mon-fake-ladder-${ts_base}-r${rung}-$$" subject="mon-fake-ladder-${ts_base}-r${rung}-$$"
@ -309,25 +300,20 @@ print(s)
fail "rung ${rung}" "${amt} · ${FI_STATUS}" "$(printf '%s' "$FI_FLAT" | head -c 180)" fail "rung ${rung}" "${amt} · ${FI_STATUS}" "$(printf '%s' "$FI_FLAT" | head -c 180)"
LADDER_FAIL=$((LADDER_FAIL + 1)) LADDER_FAIL=$((LADDER_FAIL + 1))
if [ "${DEVTESTING_LADDER_STOP_ON_FAIL}" = "1" ]; then if [ "${DEVTESTING_LADDER_STOP_ON_FAIL}" = "1" ]; then
warn "ladder stop" "DEVTESTING_LADDER_STOP_ON_FAIL=1 after rung ${rung}/${n_rungs}" warn "ladder stop" "DEVTESTING_LADDER_STOP_ON_FAIL=1 after rung ${rung}"
break break
fi fi
fi fi
done done <"$LADDER_FILE"
fi rm -f "$LADDER_FILE"
info "ladder summary" "ok=${LADDER_OK} warn=${LADDER_WARN} fail=${LADDER_FAIL} / planned=${n_rungs} · max=CHF:${DEVTESTING_LADDER_MAX}" info "ladder summary" "ok=${LADDER_OK} warn=${LADDER_WARN} fail=${LADDER_FAIL} / planned=${n_rungs} · max=CHF:${DEVTESTING_LADDER_MAX}"
if [ "$n_rungs" -eq 0 ]; then if [ "$LADDER_FAIL" -gt 0 ] && [ "$LADDER_OK" -eq 0 ]; then
:
elif [ "$LADDER_FAIL" -gt 0 ] && [ "$LADDER_OK" -eq 0 ]; then
fail "ladder" "all rungs failed" fail "ladder" "all rungs failed"
elif [ "$LADDER_FAIL" -gt 0 ]; then elif [ "$LADDER_FAIL" -gt 0 ]; then
warn "ladder" "partial failures fail=${LADDER_FAIL} ok=${LADDER_OK}/${n_rungs}" warn "ladder" "partial failures fail=${LADDER_FAIL} ok=${LADDER_OK}"
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 else
ok "ladder complete" "ok=${LADDER_OK}/${n_rungs} through max CHF:${DEVTESTING_LADDER_MAX}" ok "ladder complete" "ok=${LADDER_OK} through max CHF:${DEVTESTING_LADDER_MAX}"
fi fi
else else
info "ladder" "skipped (DEVTESTING_LADDER=0)" info "ladder" "skipped (DEVTESTING_LADDER=0)"

View file

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

View file

@ -5,20 +5,6 @@
# Override: MATTERMOST_PUBLIC=https://mattermost.example.org # Override: MATTERMOST_PUBLIC=https://mattermost.example.org
# MATTERMOST_HOST=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 # Outside-only (no SSH). Phase name: mattermost
# #
set -euo pipefail set -euo pipefail
@ -29,20 +15,6 @@ source "$ROOT/lib.sh"
set_area mattermost set_area mattermost
section "mattermost · public chat (outside-in)" 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 if [ -n "${MATTERMOST_PUBLIC:-}" ]; then
BASE="${MATTERMOST_PUBLIC%/}" BASE="${MATTERMOST_PUBLIC%/}"
elif [ -n "${MATTERMOST_HOST:-}" ]; then elif [ -n "${MATTERMOST_HOST:-}" ]; then
@ -55,7 +27,6 @@ HOST=${HOST#http://}
HOST=${HOST%%/*} HOST=${HOST%%/*}
info "target" "$BASE" 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) tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT trap 'rm -rf "$tmp"' EXIT
@ -75,139 +46,30 @@ else
fail "landing" "$BASE/ → HTTP $code (want 200 HTML Mattermost SPA)" fail "landing" "$BASE/ → HTTP $code (want 200 HTML Mattermost SPA)"
fi fi
# --- Official Mattermost health API + server version (X-Version-Id) --- # --- Official Mattermost health API ---
set_group api set_group api
# Capture body + headers (version is in X-Version-Id, not always in JSON) code=$(mm_get "$BASE/api/v4/system/ping" "$tmp/ping.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 if [ "$code" != "200" ]; then
fail "system/ping" "$BASE/api/v4/system/ping → HTTP $code (want 200 JSON)" fail "system/ping" "$BASE/api/v4/system/ping → HTTP $code (want 200 JSON)"
else else
# Ping returns JSON object (fields vary by MM version)
if python3 - "$tmp/ping.json" <<'PY' 2>/dev/null if python3 - "$tmp/ping.json" <<'PY' 2>/dev/null
import json, sys import json, sys
with open(sys.argv[1]) as f: p = sys.argv[1]
with open(p) as f:
d = json.load(f) d = json.load(f)
sys.exit(0 if isinstance(d, dict) else 2) if not isinstance(d, dict):
sys.exit(2)
# older builds may include status; newer still return object with backends
sys.exit(0)
PY PY
then then
ok "system/ping" "$BASE/api/v4/system/ping → HTTP 200 JSON${MM_SERVER_VER:+ · server ${MM_SERVER_VER}}" ok "system/ping" "$BASE/api/v4/system/ping → HTTP 200 JSON"
else else
fail "system/ping" "$BASE/api/v4/system/ping → HTTP 200 but not valid JSON object" fail "system/ping" "$BASE/api/v4/system/ping → HTTP 200 but not valid JSON object"
fi fi
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) --- # --- Login SPA (must be served for users) ---
set_group login set_group login
code=$(mm_get "$BASE/login" "$tmp/login.html") code=$(mm_get "$BASE/login" "$tmp/login.html")
@ -247,9 +109,5 @@ else
info "tls cert" "openssl not available — skip expiry check" info "tls cert" "openssl not available — skip expiry check"
fi fi
info "hint" "surface catalog also lists $HOST; client floors: mobile/desktop/support ≥${MATTERMOST_MOBILE_MIN_SERVER}/${MATTERMOST_DESKTOP_MIN_SERVER}/${MATTERMOST_SUPPORT_MIN_SERVER}" info "hint" "surface catalog also lists $HOST; this phase is Mattermost-specific (SPA + /api/v4/system/ping)"
# 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 exit 0

View file

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

View file

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

View file

@ -4,9 +4,8 @@ from __future__ import annotations
import argparse import argparse
import html import html
import os
import re import re
import sys import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -70,25 +69,16 @@ def ui(lang: str, key: str, **kwargs) -> str:
"jump_first_warn": "Jump to first warning", "jump_first_warn": "Jump to first warning",
"filter_errors": "Filter: errors + a few dimmed context lines", "filter_errors": "Filter: errors + a few dimmed context lines",
"filter_warns": "Filter: warnings + 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_clear": "Clear filter · show all lines",
"filter_active_err": "Filter: errors + context", "filter_active_err": "Filter: errors + context",
"filter_active_warn": "Filter: warnings + 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", "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": "Monitoring env context",
"env_context_title": "Expand: agent env, suite pin, domain hosts & flags (from run header)", "env_context_title": "Expand: agent env, suite pin, domain hosts & flags (from run header)",
"env_context_empty": "No run header in log", "env_context_empty": "No run header in log",
"generated": "generated", "generated": "generated",
"version": "version", "version": "version",
"version_tree_link": "open this runs git tree (commit)", "version_tree_link": "open this runs git tree (commit)",
"suite_version_title": "taler-monitoring suite version · exact run tree",
"source": "source", "source": "source",
"what_monitors": "What this monitors", "what_monitors": "What this monitors",
@ -148,18 +138,16 @@ def ui(lang: str, key: str, **kwargs) -> str:
"stage_i6": "devtesting: fake-franken CHF via rusty.taler-ops.ch (geniban + fake-incoming)", "stage_i6": "devtesting: fake-franken CHF via rusty.taler-ops.ch (geniban + fake-incoming)",
"pages_section_lead": "Monitoring pages themselves (suite):", "pages_section_lead": "Monitoring pages themselves (suite):",
"mm_title": "Mattermost chat health", "mm_title": "Mattermost chat health",
"mm_summary": "mattermost.taler.net · SPA + ping · client floors (mobile/desktop/support) · TLS", "mm_summary": "mattermost.taler.net · SPA + /api/v4/system/ping · TLS",
"mm_i1": "Outside-in phase mattermost (default https://mattermost.taler.net)", "mm_i1": "Outside-in phase mattermost (default https://mattermost.taler.net)",
"mm_i2": "Landing SPA, /api/v4/system/ping, X-Version-Id vs client mins (mobile/desktop/support, default ≥10.11.0)", "mm_i2": "Landing SPA, /api/v4/system/ping JSON, /login, TLS certificate expiry",
"mm_i3": "ERROR if server too old for current Android/iOS store or Desktop apps; WARN for web on EOL server", "mm_i3": "Reported on taler-monitoring-surface HTML (no separate /taler-monitoring-mattermost* page)",
"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_title": "Mail (MX / SMTP / IMAP)",
"mail_summary": "firefly.gnunet.org + anastasis.taler-systems.com · mail-catalog.conf", "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_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_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)", "mail_i3": "Reported on taler-monitoring-surface HTML (no separate /taler-monitoring-mail* page)",
"footer": "Console-style render of taler-monitoring output. Sticky bar: green = clean · yellow = warnings · red = errors. Click error / warning / info (blue) / ok counts to filter the log (match lines plus dimmed neighbours). Suite version sits under the status pill. Monitoring env context is collapsed by default.", "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.",
"redirect_fail": "has failures.", "redirect_fail": "has failures.",
"redirect_see": "See {link} for the full console log, error index, and sticky status bar.", "redirect_see": "See {link} for the full console log, error index, and sticky status bar.",
"ago_s": "{n}s ago", "ago_s": "{n}s ago",
@ -180,25 +168,16 @@ def ui(lang: str, key: str, **kwargs) -> str:
"jump_first_warn": "Aller au premier avertissement", "jump_first_warn": "Aller au premier avertissement",
"filter_errors": "Filtrer : erreurs + quelques lignes de contexte grisées", "filter_errors": "Filtrer : erreurs + quelques lignes de contexte grisées",
"filter_warns": "Filtrer : avertissements + 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_clear": "Effacer le filtre · tout afficher",
"filter_active_err": "Filtre : erreurs + contexte", "filter_active_err": "Filtre : erreurs + contexte",
"filter_active_warn": "Filtre : avertissements + 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", "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": "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_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", "env_context_empty": "Pas d'en-tête d'exécution dans le journal",
"generated": "généré", "generated": "généré",
"version": "version", "version": "version",
"version_tree_link": "ouvrir l'arbre git de cette exécution (commit)", "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", "source": "source",
"what_monitors": "Ce que cette page contrôle", "what_monitors": "Ce que cette page contrôle",
@ -258,18 +237,16 @@ def ui(lang: str, key: str, **kwargs) -> str:
"pages_i6": "Hôte de cette page : {host} · libellé : {label}", "pages_i6": "Hôte de cette page : {host} · libellé : {label}",
"pages_section_lead": "Les pages de monitoring elles-mêmes (suite) :", "pages_section_lead": "Les pages de monitoring elles-mêmes (suite) :",
"mm_title": "Santé du chat Mattermost", "mm_title": "Santé du chat Mattermost",
"mm_summary": "mattermost.taler.net · SPA + ping · seuils clients (mobile/desktop/support) · TLS", "mm_summary": "mattermost.taler.net · SPA + /api/v4/system/ping · TLS",
"mm_i1": "Phase outside-in mattermost (défaut https://mattermost.taler.net)", "mm_i1": "Phase outside-in mattermost (défaut https://mattermost.taler.net)",
"mm_i2": "SPA, /api/v4/system/ping, X-Version-Id vs seuils clients (mobile/desktop/support, défaut ≥10.11.0)", "mm_i2": "SPA d'accueil, /api/v4/system/ping JSON, /login, expiration du certificat TLS",
"mm_i3": "ERROR si serveur trop vieux pour apps store Android/iOS ou Desktop ; WARN web sur serveur EOL", "mm_i3": "Rapporté dans le HTML taler-monitoring-surface (pas de page /taler-monitoring-mattermost*)",
"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_title": "Mail (MX / SMTP / IMAP)",
"mail_summary": "firefly.gnunet.org + anastasis.taler-systems.com · mail-catalog.conf", "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_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_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*)", "mail_i3": "Rapporté dans le HTML taler-monitoring-surface (pas de page /taler-monitoring-mail*)",
"footer": "Rendu console de taler-monitoring. Barre collante : vert = OK · jaune = avertissements · rouge = erreurs. Compteurs erreur / avertissement / info (bleu) / ok = filtres. Version de la suite sous le badge d'état. Contexte d'env. replié par défaut.", "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.",
"redirect_fail": "a des échecs.", "redirect_fail": "a des échecs.",
"redirect_see": "Voir {link} pour le journal console complet, l'index d'erreurs et la barre de statut.", "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_s": "il y a {n}s",
@ -312,26 +289,14 @@ def is_numbered_check_line(line: str) -> bool:
def classify(line: str) -> str: def classify(line: str) -> str:
u = line.upper() u = line.upper()
low = line.lower() low = line.lower()
# Real SUMMARY *box* only (╔/║/╚ frames or bare [ SUMMARY ] / [ RESUME ]). # SUMMARY box header only when the word SUMMARY is present (not phase section() boxes).
# Never match free text like "perf summary" / "ladder summary" (those are INFO checks). # Phase section() also uses ╔═║╚ with blue BG_SEC — those must stay class "section".
if re.search(r"\bSUMMARY\b", u) or re.search(r"\bRESUME\b", u): if "SUMMARY" in u:
if re.search(r"[╔║╚]", line) or re.match(
r"^\s*\[\s*(SUMMARY|RESUME)\s*\]", u
):
return "sum-header" return "sum-header"
# else fall through to badge classifiers (INFO/OK/…)
# Double-line box frames from section() → section (no sum-* paint) # Double-line box frames from section() → section (no sum-* paint)
if re.match(r"^\s*[╔╚].*═", line) or re.match(r"^\s*║\s", line): if re.match(r"^\s*[╔╚].*═", line) or re.match(r"^\s*║\s", line):
return "section" return "section"
# EN + FR section headers (i18n_tag ERROR→ERREUR, ERRORS→ERREURS) if "--- ERRORS" in u or "ERRORS ·" in u or "ERRORS (" in u:
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" return "meta"
if "numbered checks" in low: if "numbered checks" in low:
return "meta" return "meta"
@ -358,15 +323,14 @@ def classify(line: str) -> str:
return "sum-block" return "sum-block"
return "blocker" return "blocker"
# Only real check badges (not free-text "ERROR" inside commit messages, # Only real check badges (not free-text "ERROR" inside commit messages,
# and not SUMMARY count rows like " [ ERROR ] 3"). # 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\b|\[\s*ERROR\b", u):
if re.search(r"\s*(?:ERROR|ERREUR)\b|\[\s*(?:ERROR|ERREUR)\b", u):
if not is_numbered_check_line(line): if not is_numbered_check_line(line):
return "sum-err" return "sum-err"
return "error" return "error"
if re.search(r"\bERROR monpages\b", line, re.I): if re.search(r"\bERROR monpages\b", line, re.I):
return "error" return "error"
if re.search(r"\s*(?:WARN|AVERT)\b|\[\s*(?:WARN|AVERT)\b", u): if re.search(r"\s*WARN\b|\[\s*WARN\b", u):
if not is_numbered_check_line(line): if not is_numbered_check_line(line):
return "sum-warn" return "sum-warn"
return "warn" return "warn"
@ -517,25 +481,11 @@ def extract_errors(lines: list[str]) -> list[str]:
timeout_detail = "" timeout_detail = ""
in_block = False in_block = False
for ln in lines: for ln in lines:
u = ln.upper() if "--- ERRORS" in ln or "ERRORS (failed" in ln or "RUN TIMEOUT · extraordinary" in ln:
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 in_block = True
continue continue
if in_block: if in_block:
if ( if ln.strip().startswith("---") or "[ SUMMARY" in ln or "totals:" in ln:
ln.strip().startswith("---")
or "[ SUMMARY" in ln
or "[ RESUME" in u
or "totals:" in ln.lower()
or "totaux :" in ln.lower()
):
in_block = False in_block = False
continue continue
m = ERR_BULLET_RE.match(ln) m = ERR_BULLET_RE.match(ln)
@ -545,15 +495,10 @@ def extract_errors(lines: list[str]) -> list[str]:
timeout_detail = body timeout_detail = body
else: else:
errs.append(body) errs.append(body)
elif ("ERROR" in u or "ERREUR" in u) and ln.strip() and not is_run_timeout_text(ln): elif "ERROR" in ln.upper() and ln.strip() and not is_run_timeout_text(ln):
errs.append(ln.strip()) errs.append(ln.strip())
# Only real check badges (not INFO text mentioning thresholds like "… ERROR (0=disable)") # Only real check badges (not INFO text mentioning thresholds like "… ERROR (0=disable)")
# FR: i18n_tag ERROR→ERREUR (v1.18.1) if re.search(r"\s*ERROR\b|\[\s*ERROR\b", ln, re.I) and "--- ERRORS" not in ln:
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 is_run_timeout_text(ln):
if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln: if "run.timeout-01" in ln.lower() or "RUN_TIMEOUT exceeded" in ln:
timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip() timeout_detail = re.sub(r"\s+", " ", strip_ansi(ln)).strip()
@ -562,10 +507,8 @@ def extract_errors(lines: list[str]) -> list[str]:
continue continue
if classify(ln) not in ("error", "blocker"): if classify(ln) not in ("error", "blocker"):
continue continue
body = re.sub( body = re.sub(r"^.*\bERROR\b\s*\]?\s*", "", ln, flags=re.I).strip(" ·")
r"^.*\b(?:ERROR|ERREUR)\b\s*[\]┐]?\s*", "", ln, flags=re.I if body and body not in errs and "failed — see" not in body.lower():
).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( if re.search(
r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.", r"#\d+|www\.|e2e\.|auth401\.|versions\.|inside\.|surface\.|aptdeploy\.",
body, body,
@ -591,18 +534,12 @@ def extract_errors(lines: list[str]) -> list[str]:
return out return out
def count_status( def count_status(lines: list[str]) -> tuple[int, int, int | None, int | None]:
lines: list[str], """Return (error_count, warn_count, first_error_idx, first_warn_idx)."""
) -> 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_err = 0
n_warn = 0 n_warn = 0
n_info = 0
n_ok = 0
first_err: int | None = None first_err: int | None = None
first_warn: int | None = None first_warn: int | None = None
first_info: int | None = None
first_ok: int | None = None
for i, ln in enumerate(lines): for i, ln in enumerate(lines):
kind = classify(ln) kind = classify(ln)
if kind in ("error", "blocker"): if kind in ("error", "blocker"):
@ -615,15 +552,7 @@ def count_status(
n_warn += 1 n_warn += 1
if first_warn is None: if first_warn is None:
first_warn = i first_warn = i
elif kind == "info": return n_err, n_warn, first_err, first_warn
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: def slug_error(i: int, text: str) -> str:
@ -768,8 +697,6 @@ def monitoring_scope(
ui(lang, "mm_i1"), ui(lang, "mm_i1"),
ui(lang, "mm_i2"), ui(lang, "mm_i2"),
ui(lang, "mm_i3"), ui(lang, "mm_i3"),
ui(lang, "mm_i4"),
ui(lang, "mm_i5"),
] + _pages_items(lang, host, label) ] + _pages_items(lang, host, label)
return { return {
"kind": "mattermost", "kind": "mattermost",
@ -1038,19 +965,6 @@ def is_log_noise_line(ln: str) -> bool:
return True return True
if "ihr branch ist" in low or "bereits auf" in low: if "ihr branch ist" in low or "bereits auf" in low:
return True 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 return False
@ -1071,8 +985,6 @@ def sticky_bar_html(
scope: dict[str, object] | None = None, scope: dict[str, object] | None = None,
suite_version: str = "", suite_version: str = "",
suite_version_url: str = "", suite_version_url: str = "",
n_info: int = 0,
n_ok: int = 0,
) -> str: ) -> str:
level = status_level(n_err, n_warn) level = status_level(n_err, n_warn)
lang = "en" lang = "en"
@ -1120,30 +1032,6 @@ def sticky_bar_html(
warn_lbl = ui(lang, "warn_many", n=n_warn) warn_lbl = ui(lang, "warn_many", n=n_warn)
warn_stat = f'<span class="stat warn muted">{html.escape(warn_lbl)}</span>' 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 = ( filter_chip = (
f'<span class="filter-chip" id="filter-chip" hidden>' f'<span class="filter-chip" id="filter-chip" hidden>'
f'<span class="filter-chip-label" id="filter-chip-label"></span>' f'<span class="filter-chip-label" id="filter-chip-label"></span>'
@ -1156,8 +1044,6 @@ def sticky_bar_html(
filter_i18n = ( filter_i18n = (
f'data-i18n-filter-err="{html.escape(ui(lang, "filter_active_err"))}" ' 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-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"))}"' f'data-i18n-filter-clear="{html.escape(ui(lang, "filter_clear"))}"'
) )
@ -1180,24 +1066,22 @@ def sticky_bar_html(
f" · <code>{html.escape(suite_path)}</code>" if suite_path else "" f" · <code>{html.escape(suite_path)}</code>" if suite_path else ""
) )
# Suite version tag under status pill (not the blue filter badge) — v1.16.0 # Version badge (linked to Forgejo tag when available)
ver = (suite_version or "").strip() ver = (suite_version or "").strip()
ver_url = (suite_version_url or "").strip() ver_url = (suite_version_url or "").strip()
if ver: 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: if ver_url:
version_tag = ( # href is usually src/commit/<sha> (exact run tree); label stays vX.Y.Z
f'<a class="{ver_cls}" href="{html.escape(ver_url)}" ' version_html = (
f'title="{ver_title}" rel="noopener noreferrer">{html.escape(ver)}</a>' 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>'
) )
else: else:
version_tag = f'<span class="{ver_cls}" title="{ver_title}">{html.escape(ver)}</span>' version_html = f'<span class="version-link muted">{html.escape(ver)}</span>'
else: else:
version_tag = "" version_html = ""
# Localize page label for sticky host title (monitoring → surveillance in FR) # Localize page label for sticky host title (monitoring → surveillance in FR)
pl = (page_label or "monitoring").lower().replace("_", "-") pl = (page_label or "monitoring").lower().replace("_", "-")
if "surface" in pl: if "surface" in pl:
@ -1208,28 +1092,22 @@ def sticky_bar_html(
pl_disp = ui(lang, "page_label_monitoring") pl_disp = ui(lang, "page_label_monitoring")
mode_key = f"mode_{mode}" if mode in ("ok", "err", "redirect") else "" 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() mode_disp = ui(lang, mode_key) if mode_key and ui(lang, mode_key) != mode_key else mode.upper()
# Compact sticky row: status stack (pill + suite version under OK/WARN/ERRORS) # Compact sticky row always visible; "Was geprüft wird" expands inside sticky-bar
return f""" return f"""
<div class="sticky-bar sticky-{level}" id="status-bar" role="status" <div class="sticky-bar sticky-{level}" id="status-bar" role="status"
data-errors="{n_err}" data-warnings="{n_warn}" data-info="{n_info}" data-ok="{n_ok}" data-errors="{n_err}" data-warnings="{n_warn}" data-level="{level}"
data-level="{level}"
data-scope-kind="{scope_kind}" {filter_i18n}> data-scope-kind="{scope_kind}" {filter_i18n}>
<div class="sticky-bar-row primary"> <div class="sticky-bar-row primary">
<span class="status-stack">
<span class="status-pill sticky-{level}">{html.escape(status_txt)}</span> <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="host">{html.escape(pl_disp)} · {html.escape(hostname)}</span>
<span class="sep">·</span> <span class="sep">·</span>
{err_stat} {err_stat}
<span class="sep">·</span> <span class="sep">·</span>
{warn_stat} {warn_stat}
<span class="sep">·</span>
{info_stat}
<span class="sep">·</span>
{ok_stat}
{filter_chip} {filter_chip}
<span class="sep">·</span> <span class="sep">·</span>
{version_html}
<span class="sep">·</span>
<span class="generated" <span class="generated"
data-generated-iso="{html.escape(generated_iso)}" data-generated-iso="{html.escape(generated_iso)}"
title="{html.escape(generated_at)}"> title="{html.escape(generated_at)}">
@ -1300,18 +1178,6 @@ STICKY_CSS = """
.status-pill.sticky-green { color: var(--ok); border-color: #1f6b3a; background: #0a1a10; } .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-yellow { color: var(--warn); border-color: #a68b2d; background: #1a1608; }
.status-pill.sticky-red { color: var(--err); border-color: #a33; background: #1a0a0a; } .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; } .host { font-weight: 600; color: #eee; font-size: 13px; }
.sep { color: var(--dim); } .sep { color: var(--dim); }
.stat { .stat {
@ -1320,8 +1186,6 @@ STICKY_CSS = """
} }
.stat.err { color: var(--err); border-color: #522; background: #1a0a0a; } .stat.err { color: var(--err); border-color: #522; background: #1a0a0a; }
.stat.warn { color: var(--warn); border-color: #664; background: #1a1608; } .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; } .stat.muted { opacity: 0.55; font-weight: 600; }
a.stat:hover { text-decoration: underline; filter: brightness(1.15); } a.stat:hover { text-decoration: underline; filter: brightness(1.15); }
a.stat[aria-pressed="true"] { a.stat[aria-pressed="true"] {
@ -1337,8 +1201,6 @@ a.stat[aria-pressed="true"] {
.filter-chip[hidden] { display: none !important; } .filter-chip[hidden] { display: none !important; }
body.filter-error .filter-chip { border-color: #a33; background: #1a0a0a; color: var(--err); } 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-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 { .filter-clear {
font: inherit; font-size: 11px; font-weight: 700; font: inherit; font-size: 11px; font-weight: 700;
cursor: pointer; color: inherit; cursor: pointer; color: inherit;
@ -1349,9 +1211,7 @@ body.filter-ok .filter-chip { border-color: #1f6b3a; background: #0a1a10; color:
/* Console filter: sticky + overviews stay; match lines + dimmed neighbours (v1.13.11+) /* 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 */ v1.15.2: stamp out black voids hide blanks, zero-size non-matches, compact console */
body.filter-error #mon-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; padding: 6px 8px;
min-height: 0; min-height: 0;
display: flex; display: flex;
@ -1359,9 +1219,7 @@ body.filter-ok #mon-console {
gap: 1px; gap: 1px;
} }
body.filter-error #mon-console .line:not(.error):not(.blocker):not(.filter-ctx), 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; display: none !important;
height: 0 !important; height: 0 !important;
margin: 0 !important; margin: 0 !important;
@ -1373,19 +1231,13 @@ body.filter-ok #mon-console .line:not(.ok):not(.filter-ctx) {
/* empty / whitespace-only lines never take space in filter mode */ /* empty / whitespace-only lines never take space in filter mode */
body.filter-error #mon-console .line:empty, body.filter-error #mon-console .line:empty,
body.filter-warn #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-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; display: none !important;
} }
/* Context lines: grayed / muted so the match still stands out */ /* Context lines: grayed / muted so the warn|error still stands out */
body.filter-error #mon-console .line.filter-ctx, 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; opacity: 0.55;
color: #8a8a8a !important; color: #8a8a8a !important;
filter: grayscale(0.85); filter: grayscale(0.85);
@ -1395,25 +1247,19 @@ body.filter-ok #mon-console .line.filter-ctx {
background: transparent !important; background: transparent !important;
} }
body.filter-error #mon-console .line.filter-ctx a.jump, 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; color: #6a6a6a !important;
} }
/* Match lines stay tight */ /* Match lines stay tight */
body.filter-error #mon-console .line.error, body.filter-error #mon-console .line.error,
body.filter-error #mon-console .line.blocker, 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; margin: 1px 0 !important;
padding: 3px 6px !important; padding: 3px 6px !important;
} }
/* Compact ellipsis between distant clusters (not a black slab) */ /* Compact ellipsis between distant clusters (not a black slab) */
body.filter-error #mon-console .filter-gap, 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; display: block;
color: #666; color: #666;
text-align: center; text-align: center;
@ -1427,14 +1273,10 @@ body.filter-ok #mon-console .filter-gap {
background: transparent; background: transparent;
height: auto; height: auto;
} }
body:not(.filter-error):not(.filter-warn):not(.filter-info):not(.filter-ok) #mon-console .filter-gap { display: none !important; } body:not(.filter-error):not(.filter-warn) #mon-console .filter-gap { display: none !important; }
body.filter-error .env-context, body.filter-error .env-context,
body.filter-warn .env-context, body.filter-warn .env-context { display: none !important; }
body.filter-info .env-context, body.filter-warn .err-top { display: none; }
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 */ /* secondary jump anchors: never affect layout */
#mon-console .anchor-only { #mon-console .anchor-only {
display: inline; display: inline;
@ -1684,8 +1526,6 @@ STICKY_JS = """
function currentFilter() { function currentFilter() {
if (document.body.classList.contains("filter-error")) return "error"; if (document.body.classList.contains("filter-error")) return "error";
if (document.body.classList.contains("filter-warn")) return "warn"; 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 ""; return "";
} }
@ -1711,8 +1551,6 @@ STICKY_JS = """
return el.classList.contains("error") || el.classList.contains("blocker"); return el.classList.contains("error") || el.classList.contains("blocker");
} }
if (mode === "warn") return el.classList.contains("warn"); 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; return false;
} }
function isBlankLine(el) { function isBlankLine(el) {
@ -1729,19 +1567,9 @@ STICKY_JS = """
return true; return true;
} }
var t = (el.textContent || "").replace(/\u00a0/g, " ").trim(); var t = (el.textContent || "").replace(/\u00a0/g, " ").trim();
// Skip pure chrome lines (spaces/tabs/newlines/hyphen + Unicode box-drawing). if (/^[\-\s]+$/.test(t)) return true;
// 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 false;
} }
return true;
}
var keep = new Array(lines.length); var keep = new Array(lines.length);
var i, j, k, n, idx; var i, j, k, n, idx;
for (i = 0; i < lines.length; i++) keep[i] = 0; for (i = 0; i < lines.length; i++) keep[i] = 0;
@ -1790,14 +1618,11 @@ STICKY_JS = """
function setFilter(mode, opts) { function setFilter(mode, opts) {
opts = opts || {}; opts = opts || {};
document.body.classList.remove("filter-error", "filter-warn", "filter-info", "filter-ok"); document.body.classList.remove("filter-error", "filter-warn");
var errA = document.querySelector('a.stat.err[data-filter="error"]'); var errA = document.querySelector('a.stat.err[data-filter="error"]');
var warnA = document.querySelector('a.stat.warn[data-filter="warn"]'); var warnA = document.querySelector('a.stat.warn[data-filter="warn"]');
var infoA = document.querySelector('a.stat.info[data-filter="info"]'); if (errA) errA.setAttribute("aria-pressed", "false");
var okA = document.querySelector('a.stat.ok[data-filter="ok"]'); if (warnA) warnA.setAttribute("aria-pressed", "false");
[errA, warnA, infoA, okA].forEach(function (a) {
if (a) a.setAttribute("aria-pressed", "false");
});
if (mode === "error") { if (mode === "error") {
document.body.classList.add("filter-error"); document.body.classList.add("filter-error");
@ -1805,19 +1630,11 @@ STICKY_JS = """
} else if (mode === "warn") { } else if (mode === "warn") {
document.body.classList.add("filter-warn"); document.body.classList.add("filter-warn");
if (warnA) warnA.setAttribute("aria-pressed", "true"); if (warnA) warnA.setAttribute("aria-pressed", "true");
} else if (mode === "info") {
document.body.classList.add("filter-info");
if (infoA) infoA.setAttribute("aria-pressed", "true");
} else if (mode === "ok") {
document.body.classList.add("filter-ok");
if (okA) okA.setAttribute("aria-pressed", "true");
} }
if (mode === "error" || mode === "warn" || mode === "info" || mode === "ok") { // Mark dimmed neighbours (or clear when filter off)
markFilterContext(mode); if (mode === "error" || mode === "warn") markFilterContext(mode);
} else { else clearFilterContext();
clearFilterContext();
}
if (chip && chipLabel && bar) { if (chip && chipLabel && bar) {
if (mode === "error") { if (mode === "error") {
@ -1826,12 +1643,6 @@ STICKY_JS = """
} else if (mode === "warn") { } else if (mode === "warn") {
chip.hidden = false; chip.hidden = false;
chipLabel.textContent = bar.getAttribute("data-i18n-filter-warn") || "Filter: warnings + context"; 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 { } else {
chip.hidden = true; chip.hidden = true;
chipLabel.textContent = ""; chipLabel.textContent = "";
@ -1840,28 +1651,24 @@ STICKY_JS = """
if (opts.updateHash !== false) { if (opts.updateHash !== false) {
var want = mode === "error" ? "#filter-error" 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 (want) {
if (location.hash !== want) { if (location.hash !== want) {
try { history.replaceState(null, "", want); } catch (e) { location.hash = want; } try { history.replaceState(null, "", want); } catch (e) { location.hash = want; }
} }
} else if (/^#(filter-error|filter-warn|filter-info|filter-ok|first-error|first-warn)$/.test(location.hash || "")) { } else if (location.hash === "#filter-error" || location.hash === "#filter-warn"
|| location.hash === "#first-error" || location.hash === "#first-warn") {
try { history.replaceState(null, "", location.pathname + location.search); } catch (e) {} try { history.replaceState(null, "", location.pathname + location.search); } catch (e) {}
} }
} }
if (opts.scrollFirst && mode) { if (opts.scrollFirst && mode) {
var sel = mode === "error" ? ".line.error, .line.blocker" var id = mode === "error" ? "first-error" : "first-warn";
: mode === "warn" ? ".line.warn" var el = document.getElementById(id);
: 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) { if (el && el.scrollIntoView) {
setTimeout(function () { el.scrollIntoView({ block: "start", behavior: "smooth" }); }, 30); setTimeout(function () { el.scrollIntoView({ block: "start", behavior: "smooth" }); }, 30);
} else if (consoleEl) { } else if (consoleEl) {
var sel = mode === "error" ? ".line.error, .line.blocker" : ".line.warn";
var first = consoleEl.querySelector(sel); var first = consoleEl.querySelector(sel);
if (first && first.scrollIntoView) { if (first && first.scrollIntoView) {
setTimeout(function () { first.scrollIntoView({ block: "start", behavior: "smooth" }); }, 30); setTimeout(function () { first.scrollIntoView({ block: "start", behavior: "smooth" }); }, 30);
@ -1879,9 +1686,7 @@ STICKY_JS = """
a.addEventListener("click", function (ev) { a.addEventListener("click", function (ev) {
ev.preventDefault(); ev.preventDefault();
var mode = a.getAttribute("data-filter"); var mode = a.getAttribute("data-filter");
if (mode === "error" || mode === "warn" || mode === "info" || mode === "ok") { if (mode === "error" || mode === "warn") toggleFilter(mode);
toggleFilter(mode);
}
}); });
}); });
if (clearBtn) { if (clearBtn) {
@ -1896,10 +1701,6 @@ STICKY_JS = """
setFilter("error", { updateHash: false, scrollFirst: h === "first-error" }); setFilter("error", { updateHash: false, scrollFirst: h === "first-error" });
} else if (h === "filter-warn" || h === "first-warn") { } else if (h === "filter-warn" || h === "first-warn") {
setFilter("warn", { updateHash: false, scrollFirst: 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()) { } else if (currentFilter()) {
// keep filter if hash is something else (e.g. err-slug) // keep filter if hash is something else (e.g. err-slug)
} }
@ -1938,9 +1739,8 @@ def build_html(
# Split monitoring env context (collapsible, default closed) from check log # Split monitoring env context (collapsible, default closed) from check log
env_lines, body_raw, env_hint = extract_env_context(raw_lines) env_lines, body_raw, env_hint = extract_env_context(raw_lines)
# Sticky counts from full log; first-error anchors index into body only # Sticky counts from full log; first-error anchors index into body only
n_err, n_warn, n_info, n_ok, _, _, _, _ = count_status(raw_lines) n_err, n_warn, _, _ = count_status(raw_lines)
_e2, _w2, _i2, _o2, first_err_i, first_warn_i, _fi, _fo = count_status(body_raw) _, _, first_err_i, first_warn_i = count_status(body_raw)
del _e2, _w2, _i2, _o2, _fi, _fo
errors = extract_errors(raw_lines) errors = extract_errors(raw_lines)
err_slugs: dict[str, str] = {} err_slugs: dict[str, str] = {}
@ -2057,8 +1857,6 @@ def build_html(
generated_iso=generated_iso, generated_iso=generated_iso,
n_err=n_err, n_err=n_err,
n_warn=n_warn, n_warn=n_warn,
n_info=n_info,
n_ok=n_ok,
hostname=hostname, hostname=hostname,
page_label=page_label, page_label=page_label,
mode=mode, mode=mode,
@ -2429,8 +2227,7 @@ def main() -> None:
for l in log_text.splitlines() for l in log_text.splitlines()
if not is_log_noise_line(strip_ansi(l)) if not is_log_noise_line(strip_ansi(l))
] ]
n_err, n_warn, n_info, n_ok, _, _, _, _ = count_status(raw_for_counts) n_err, n_warn, _, _ = count_status(raw_for_counts)
# n_info/n_ok used by sticky filters when build_html is called below
if args.mode == "redirect": if args.mode == "redirect":
html_out = build_redirect_html( html_out = build_redirect_html(
@ -2465,12 +2262,7 @@ def main() -> None:
) )
atomic_write_text(args.out, html_out, encoding="utf-8") atomic_write_text(args.out, html_out, encoding="utf-8")
# stderr only — stdout is often teed into the suite mon log print(f"wrote {args.out} (errors={n_err} warnings={n_warn} level={status_level(n_err, n_warn)})")
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__": if __name__ == "__main__":

View file

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