diff --git a/VERSION b/VERSION index e34208c..ec6d649 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.15.4 +1.18.1 diff --git a/VERSIONS.md b/VERSIONS.md index ee61b4c..bb3d16d 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -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. | diff --git a/check_apt_deploy.sh b/check_apt_deploy.sh index 54d5704..e7bc488 100755 --- a/check_apt_deploy.sh +++ b/check_apt_deploy.sh @@ -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" diff --git a/check_devtesting.sh b/check_devtesting.sh index a4723cb..899d51a 100755 --- a/check_devtesting.sh +++ b/check_devtesting.sh @@ -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,55 +266,68 @@ 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 - [ -n "$amt" ] || continue - rung=$((rung + 1)) - subject="mon-fake-ladder-${ts_base}-r${rung}-$$" - info "rung ${rung}/${n_rungs}" "amount=${amt} subject=${subject}" - if run_fake_incoming "$amt" "$subject" "$credit_payto" "$debit_payto"; then - case "$FI_STATUS" in - ok_bounce) - ok "rung ${rung}" "${amt} · nexus IN + bounce (plumbing OK)" - LADDER_OK=$((LADDER_OK + 1)) - ;; - ok_in) - ok "rung ${rung}" "${amt} · nexus IN recorded" - LADDER_OK=$((LADDER_OK + 1)) - ;; - ok_cli) - ok "rung ${rung}" "${amt} · CLI ok" - LADDER_OK=$((LADDER_OK + 1)) - ;; - *) - warn "rung ${rung}" "${amt} · status=${FI_STATUS}" - LADDER_WARN=$((LADDER_WARN + 1)) - ;; - esac - else - 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}" - break - fi - fi - done <"$LADDER_FILE" + # Load amounts into an array first — never `while read … done /dev/null 2>&1 \ +if [ "${INSIDE_PODMAN:-0}" = "1" ] || [ "${INSIDE_MODE:-}" = "local-podman" ]; then + _use_local_podman=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 - ok "ssh ${SSH_HOST}" "stagepaysan (podman, no sudo)" - # 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 - err "ssh" "stage remote timed out or empty (cap ${STAGE_SSH_T}s · host=${SSH_HOST})" + 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,7 +261,29 @@ REMOTE # Host load as stagepaysan (no container RSS from koopa metrics) set_group load section "inside · load (stagepaysan host)" - LOAD_LINE=$(mon_ssh_bash "$SSH_HOST" 8 <<'EOF' || true + _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 la=os.getloadavg() @@ -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 diff --git a/check_mattermost.sh b/check_mattermost.sh index 650e3b1..a61bed7 100755 --- a/check_mattermost.sh +++ b/check_mattermost.sh @@ -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 diff --git a/host-agent/run-host-report.sh b/host-agent/run-host-report.sh index 8b9ac20..e26113c 100755 --- a/host-agent/run-host-report.sh +++ b/host-agent/run-host-report.sh @@ -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" \ diff --git a/host-agent/update-suite.sh b/host-agent/update-suite.sh index e93aa11..c24f7ce 100755 --- a/host-agent/update-suite.sh +++ b/host-agent/update-suite.sh @@ -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,29 +56,41 @@ _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 - _upd_die "git fetch failed — suite would stay at ${_before} (${_before_ver})" - # fall through only if STRICT=0 -else - case "$SUITE_UPDATE_MODE" in - reset) - if ! git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null \ - && ! git checkout -B "$SUITE_GIT_REF" FETCH_HEAD; then - _upd_die "git checkout $SUITE_GIT_REF failed" - fi - if ! git reset --hard "origin/$SUITE_GIT_REF" 2>/dev/null \ - && ! git reset --hard FETCH_HEAD; then - _upd_die "git reset --hard origin/$SUITE_GIT_REF failed" - fi - ;; - *) - if ! git pull --ff-only origin "$SUITE_GIT_REF" 2>&1; then - _upd_die "git pull --ff-only failed" - fi - ;; - esac -fi +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 + case "$SUITE_UPDATE_MODE" in + reset) + if ! git checkout -B "$SUITE_GIT_REF" "origin/$SUITE_GIT_REF" 2>/dev/null \ + && ! git checkout -B "$SUITE_GIT_REF" FETCH_HEAD; then + _upd_die "git checkout $SUITE_GIT_REF failed" + fi + if ! git reset --hard "origin/$SUITE_GIT_REF" 2>/dev/null \ + && ! git reset --hard FETCH_HEAD; then + _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 + ;; +esac COMMIT=$(git rev-parse HEAD) COMMIT_SHORT=$(git rev-parse --short=12 HEAD) diff --git a/site-gen/console_to_html.py b/site-gen/console_to_html.py index 0874c1e..4aab08d 100755 --- a/site-gen/console_to_html.py +++ b/site-gen/console_to_html.py @@ -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 run’s 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: - return "sum-header" + # 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'{html.escape(warn_lbl)}' + # 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'{html.escape(info_lbl)}' + ) + else: + info_lbl = ui(lang, "info_many", n=n_info) + info_stat = f'{html.escape(info_lbl)}' + + # 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'{html.escape(ok_lbl)}' + ) + else: + ok_lbl = ui(lang, "ok_many", n=n_ok) + ok_stat = f'{html.escape(ok_lbl)}' + filter_chip = ( f'