koopa-admin-log/scripts/taler-bank/landing-stats.sh
Hernâni Marques 48f77139c5
landing: compact GOA alt display and all-account bank fallback
Wire goa-amount.js into bank/exchange/merchant intros, deploy it with the
landings, and deepen the in-container bank stats fallback to scan all
accounts (skip only exchange) when the host timer is down.
2026-07-17 08:12:00 +02:00

623 lines
22 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# Generate public landing stats JSON for bank.hacktivism.ch intro page.
#
# *** Run INSIDE the bank container *** (taler-hacktivism-bank).
# Writes: $LANDING_DIR/stats.json → https://bank.hacktivism.ch/intro/stats.json
#
# Pure bash + curl + awk (no python). Times in Europe/Zurich (CET/CEST).
#
# Documented: configs/bank-landing/README.md
set -euo pipefail
LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}"
# Demo funding account (for recent withdraw list / flow)
BANK_USER="${BANK_USER:-explorer}"
# Admin lists all accounts + can read other accounts' txs
ADMIN_USER="${ADMIN_USER:-admin}"
# Per-account transaction window (libeufin delta). Was -100 → systematically
# undercounted credits/withdraws on active accounts (broken public stats).
# Prefer the host collector (hernani systemd taler-landing-stats) for full
# pagination; these defaults keep the in-container fallback deep enough.
TX_DELTA="${TX_DELTA:--200000}"
# Max accounts to list + scan (was 80 → missed later accounts; bank has 100+).
# 0 or empty → no head limit (scan every listed account).
MAX_SCAN_ACCOUNTS="${MAX_SCAN_ACCOUNTS:-0}"
# Account-list page size for GET /accounts?delta=… (must cover all users)
ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--10000}"
# curl timeout per account (deeper history needs more headroom)
TX_CURL_TIMEOUT="${TX_CURL_TIMEOUT:-45}"
export TZ="${TZ:-Europe/Zurich}"
PASS="${BANK_PASS:-}"
ADMIN_PASS="${BANK_ADMIN_PASS:-}"
detect_bank() {
if [ -n "${BANK_URL:-}" ]; then
echo "${BANK_URL%/}"
return
fi
local port="" conf u code
for conf in /etc/libeufin/bank-overrides.conf /etc/libeufin/libeufin-bank.conf; do
if [ -f "$conf" ]; then
port=$(grep -E '^\s*PORT\s*=' "$conf" 2>/dev/null | tail -1 | awk -F= '{gsub(/ /,"",$2); print $2}')
[ -n "$port" ] && break
fi
done
port="${port:-9012}"
for u in "http://127.0.0.1:${port}" "http://127.0.0.1:9012" "http://127.0.0.1:8080"; do
code=$(curl -sS -m 2 -o /dev/null -w '%{http_code}' "$u/config" 2>/dev/null || echo 000)
if [ "$code" = "200" ]; then
echo "$u"
return
fi
done
echo "http://127.0.0.1:${port}"
}
BANK="$(detect_bank)"
BANK="${BANK%/}"
read_pass_file() {
local f
for f in "$@"; do
if [ -f "$f" ] && [ -r "$f" ]; then
tr -d '\n' <"$f"
return 0
fi
done
return 1
}
if [ -z "$PASS" ]; then
PASS=$(read_pass_file \
"/root/bank-${BANK_USER}-password.txt" \
/root/bank-explorer-password.txt \
"/etc/libeufin/secrets/bank-${BANK_USER}-password.txt" || true)
fi
if [ -z "$ADMIN_PASS" ]; then
ADMIN_PASS=$(read_pass_file \
/root/bank-admin-password.txt \
/etc/libeufin/secrets/bank-admin-password.txt || true)
fi
WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT
OUT="$LANDING_DIR/stats.json"
RUN="$LANDING_DIR/stats-run.json"
TMP="${OUT}.tmp.$$"
mkdir -p "$LANDING_DIR"
now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; }
now_unix() { date +%s; }
now_human() { date +"%Y-%m-%d %H:%M %Z"; }
iso_from_unix() {
local u="$1"
date -d "@${u}" +%Y-%m-%dT%H:%M%z 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' \
|| date -r "${u}" +%Y-%m-%dT%H:%M%z 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' \
|| echo ""
}
# Human CEST/CET label for display: "2026-07-09 20:49 CEST"
human_from_unix() {
local u="$1"
date -d "@${u}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null \
|| date -r "${u}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null \
|| echo ""
}
json_str() {
printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ')"
}
# Public run status for intro page. Never wipe stats.json on failure.
write_run() {
local ok_json="$1" msg="${2:-}"
cat >"$RUN" <<EOF
{
"ok": ${ok_json},
"at": $(json_str "$(now_iso)"),
"at_human": $(json_str "$(now_human)"),
"error": $( [ -n "$msg" ] && json_str "$msg" || echo null )
}
EOF
}
write_err() {
local msg="$1"
write_run false "$msg"
rm -f "$TMP" 2>/dev/null || true
echo "error: $msg (stats.json left unchanged; stats-run.json updated)" >&2
}
token_for() {
local user="$1" pass="$2" out="$3"
echo '{"scope":"readonly"}' >"$WORKDIR/tok-req.json"
curl -sS -m 12 -u "${user}:${pass}" \
-H 'Content-Type: application/json' \
-d @"$WORKDIR/tok-req.json" \
"${BANK}/accounts/${user}/token" >"$out" || true
awk -F'"' '/access_token/ {
for (i=1;i<=NF;i++) if ($i=="access_token") { print $(i+2); exit }
}' "$out" 2>/dev/null || true
}
extract_field() {
# extract "key":"value" or "key": number from a JSON blob (first hit)
local key="$1" file="$2"
awk -v key="$key" '
function between(s, k, p, rest, q2, r) {
p = index(s, "\"" k "\"")
if (p == 0) return ""
rest = substr(s, p + length(k) + 2)
while (rest ~ /^[[:space:]:]/) rest = substr(rest, 2)
if (substr(rest, 1, 1) == "\"") {
rest = substr(rest, 2)
q2 = index(rest, "\"")
if (q2 == 0) return ""
return substr(rest, 1, q2 - 1)
}
r = ""
while (rest ~ /^[0-9]/) {
r = r substr(rest, 1, 1)
rest = substr(rest, 2)
}
return r
}
{ print between($0, key); exit }
' "$file" 2>/dev/null
}
# --- tokens ---
if [ -z "$PASS" ] && [ -z "$ADMIN_PASS" ]; then
write_err "no bank password (explorer or admin) for stats"
exit 1
fi
ADMIN_TOKEN=""
if [ -n "$ADMIN_PASS" ]; then
ADMIN_TOKEN=$(token_for "$ADMIN_USER" "$ADMIN_PASS" "$WORKDIR/admin-tok.json")
fi
USER_TOKEN=""
if [ -n "$PASS" ]; then
USER_TOKEN=$(token_for "$BANK_USER" "$PASS" "$WORKDIR/user-tok.json")
fi
# Prefer admin for everything when available
AUTH_TOKEN="${ADMIN_TOKEN:-$USER_TOKEN}"
if [ -z "$AUTH_TOKEN" ]; then
write_err "token failed (admin/explorer)"
exit 1
fi
# --- account list ---
ACCOUNTS_N=0
ACCOUNTS_USERS=0
: >"$WORKDIR/usernames.txt"
if [ -n "$ADMIN_TOKEN" ]; then
# Use a large negative delta so we list *all* accounts (delta=-80 truncated
# the roster and undercounted bank_accounts + flow).
curl -sS -m 30 -H "Authorization: Bearer ${ADMIN_TOKEN}" \
"${BANK}/accounts?delta=${ACCOUNTS_DELTA}" >"$WORKDIR/accounts.json" || true
# usernames from "username":"..."
tr -d '\n' <"$WORKDIR/accounts.json" \
| sed 's/},{/}\n{/g' \
| while IFS= read -r line; do
u=$(printf '%s' "$line" | sed -n 's/.*"username"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
[ -n "$u" ] && echo "$u"
done >"$WORKDIR/usernames.txt" || true
ACCOUNTS_N=$(grep -cve '^\s*$' "$WORKDIR/usernames.txt" 2>/dev/null || echo 0)
ACCOUNTS_USERS=$(grep -Eve '^(admin|exchange)$' "$WORKDIR/usernames.txt" 2>/dev/null | grep -cve '^\s*$' || echo 0)
else
echo "$BANK_USER" >"$WORKDIR/usernames.txt"
ACCOUNTS_N=1
ACCOUNTS_USERS=1
fi
# --- scan txs: distinguish incoming (credit) vs withdraw (Taler debit) ---
# all-wd.tsv: amount \t t_s \t subject \t username \t reserve
# all-in.tsv: amount \t t_s \t subject \t username
# flow.tsv: kind \t amount_num kind = incoming | withdraw | other_out
: >"$WORKDIR/all-wd.tsv"
: >"$WORKDIR/all-in.tsv"
: >"$WORKDIR/flow.tsv"
SCAN_OK=0
SCAN_EMPTY=0
SCAN_FAIL=0
# Scan ALL accounts except exchange (withdraw debits live on customers + admin
# top-ups; exchange credits would double-count the same GOA).
# Prefer host collect_bank_stats.py (hernani timer) for full pagination.
{
echo "$BANK_USER"
# include admin + every auto-account; drop only exchange
grep -Eve "^(exchange|${BANK_USER})$" "$WORKDIR/usernames.txt" 2>/dev/null || true
} | awk 'NF && !seen[$0]++' >"$WORKDIR/scan-users.all"
if [ -n "${MAX_SCAN_ACCOUNTS}" ] && [ "${MAX_SCAN_ACCOUNTS}" -gt 0 ] 2>/dev/null; then
head -n "$MAX_SCAN_ACCOUNTS" "$WORKDIR/scan-users.all" >"$WORKDIR/scan-users.txt"
else
cp "$WORKDIR/scan-users.all" "$WORKDIR/scan-users.txt"
fi
while IFS= read -r uname; do
[ -n "$uname" ] || continue
case "$uname" in exchange) continue ;; esac
# Safe filename (usernames are mostly [A-Za-z0-9_-])
safe=$(printf '%s' "$uname" | tr -c 'A-Za-z0-9._-' '_')
code=$(curl -sS -m "${TX_CURL_TIMEOUT}" -o "$WORKDIR/tx-${safe}.json" -w '%{http_code}' \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
"${BANK}/accounts/${uname}/transactions?delta=${TX_DELTA}" 2>/dev/null || echo 000)
# 204 / empty body: account with no transactions — normal, not an error
if [ "$code" = "204" ] || [ ! -s "$WORKDIR/tx-${safe}.json" ]; then
SCAN_EMPTY=$((SCAN_EMPTY + 1))
continue
fi
if [ "$code" != "200" ]; then
SCAN_FAIL=$((SCAN_FAIL + 1))
continue
fi
SCAN_OK=$((SCAN_OK + 1))
tr -d '\n' <"$WORKDIR/tx-${safe}.json" \
| sed 's/},{/}\n{/g' \
>"$WORKDIR/tx-lines.txt" 2>/dev/null || continue
awk -v uname="$uname" -v flowf="$WORKDIR/flow.tsv" -v inf="$WORKDIR/all-in.tsv" '
function between(s, key, p, rest, q2, r) {
p = index(s, "\"" key "\"")
if (p == 0) return ""
rest = substr(s, p + length(key) + 2)
while (rest ~ /^[[:space:]:]/) rest = substr(rest, 2)
if (substr(rest, 1, 1) == "\"") {
rest = substr(rest, 2)
q2 = index(rest, "\"")
if (q2 == 0) return ""
return substr(rest, 1, q2 - 1)
}
r = ""
while (rest ~ /^[0-9]/) {
r = r substr(rest, 1, 1)
rest = substr(rest, 2)
}
return r
}
function amt_n(a, p) {
p = index(a, ":")
if (p == 0) return 0
return substr(a, p + 1) + 0
}
{
line = $0
dir = between(line, "direction")
amt = between(line, "amount")
ts = between(line, "t_s")
subj = between(line, "subject")
low = tolower(subj)
if (amt == "") next
n = amt_n(amt)
if (dir == "credit") {
# Incoming: bank credits into customer accounts (admin top-up, transfers in)
print "incoming\t" n >> flowf
printf "%s\t%s\t%s\t%s\n", amt, ts, subj, uname >> inf
} else if (dir == "debit" && index(low, "withdraw") > 0) {
# Withdraw: Taler withdrawal debit → exchange → wallet coins
print "withdraw\t" n >> flowf
res = subj
sub(/^.*[Ww]ithdrawal[ ]+/, "", res)
gsub(/[^A-Za-z0-9]/, "", res)
printf "%s\t%s\t%s\t%s\t%s\n", amt, ts, subj, uname, res
} else if (dir == "debit") {
# Other debits (non-withdraw transfers)
print "other_out\t" n >> flowf
}
}
' "$WORKDIR/tx-lines.txt" >>"$WORKDIR/all-wd.tsv" 2>/dev/null || true
done <"$WORKDIR/scan-users.txt"
# Sum by kind
TOTAL_IN_N=$(awk -F'\t' '$1=="incoming"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0)
TOTAL_WD_FLOW_N=$(awk -F'\t' '$1=="withdraw"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0)
TOTAL_OTHER_OUT_N=$(awk -F'\t' '$1=="other_out"{s+=$2} END{printf "%.8f", s+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0)
N_INCOMING=$(awk -F'\t' '$1=="incoming"{c++} END{print c+0}' "$WORKDIR/flow.tsv" 2>/dev/null || echo 0)
# total out = withdraw + other debits
TOTAL_OUT_N=$(awk -v a="${TOTAL_WD_FLOW_N:-0}" -v b="${TOTAL_OTHER_OUT_N:-0}" 'BEGIN{printf "%.8f", a+b}')
# Sort by t_s descending
sort -t$'\t' -k2,2nr "$WORKDIR/all-wd.tsv" -o "$WORKDIR/all-wd-sorted.tsv" 2>/dev/null \
|| cp "$WORKDIR/all-wd.tsv" "$WORKDIR/all-wd-sorted.tsv"
sort -t$'\t' -k2,2nr "$WORKDIR/all-in.tsv" -o "$WORKDIR/all-in-sorted.tsv" 2>/dev/null \
|| cp "$WORKDIR/all-in.tsv" "$WORKDIR/all-in-sorted.tsv"
# Unique reserves = individual wallet withdraws (each wallet reserve_pub)
WALLETS_N=$(awk -F'\t' '$5!=""{print $5}' "$WORKDIR/all-wd-sorted.tsv" | sort -u | grep -cve '^\s*$' || echo 0)
# Accounts that funded at least one withdraw
ACCOUNTS_WITH_WD=$(awk -F'\t' '$4!=""{print $4}' "$WORKDIR/all-wd-sorted.tsv" | sort -u | grep -cve '^\s*$' || echo 0)
# Aggregates
NOW=$(now_unix)
GEN_ISO=$(now_iso)
GEN_HUMAN=$(date +"%Y-%m-%d %H:%M %Z")
# Build recent withdraws JSON array (up to 10) with CEST times — landing shows all 10
RECENT_WD_N="${RECENT_WD_N:-10}"
: >"$WORKDIR/recent.jsonl"
N_WD=0
TOTAL_WD=0
W24=0; N24=0; W7=0; N7=0
DAY=$((NOW - 86400))
WEEK=$((NOW - 7 * 86400))
LAST_AMT=""; LAST_TS=""; LAST_SUBJ=""
while IFS=$'\t' read -r amt ts subj uname res; do
[ -n "$amt" ] || continue
n=$(printf '%s' "$amt" | awk -F: '{print $2+0}')
N_WD=$((N_WD + 1))
TOTAL_WD=$(awk -v a="$TOTAL_WD" -v b="$n" 'BEGIN{printf "%.8f", a+b}')
ts_n=${ts:-0}
if [ "$ts_n" -ge "$DAY" ] 2>/dev/null; then
W24=$(awk -v a="$W24" -v b="$n" 'BEGIN{printf "%.8f", a+b}')
N24=$((N24 + 1))
fi
if [ "$ts_n" -ge "$WEEK" ] 2>/dev/null; then
W7=$(awk -v a="$W7" -v b="$n" 'BEGIN{printf "%.8f", a+b}')
N7=$((N7 + 1))
fi
if [ -z "$LAST_AMT" ]; then
LAST_AMT=$amt
LAST_TS=$ts_n
LAST_SUBJ=$subj
fi
if [ "$N_WD" -le "$RECENT_WD_N" ]; then
at_h=""; at_iso=""
if [ -n "$ts_n" ] && [ "$ts_n" != "0" ]; then
at_h=$(human_from_unix "$ts_n")
at_iso=$(iso_from_unix "$ts_n")
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$amt" "$ts_n" "$at_h" "$at_iso" "$uname" "$res" >>"$WORKDIR/recent.jsonl"
fi
done <"$WORKDIR/all-wd-sorted.tsv"
fmt_goa() {
awk -v n="$1" 'BEGIN{
if (n+0 == int(n+0)) printf "GOA:%d", int(n+0);
else printf "GOA:%.8g", n+0;
}'
}
TOTAL_AMT=$(fmt_goa "$TOTAL_WD")
TOTAL_IN_AMT=$(fmt_goa "${TOTAL_IN_N:-0}")
TOTAL_OUT_AMT=$(fmt_goa "${TOTAL_OUT_N:-0}")
TOTAL_WD_FLOW_AMT=$(fmt_goa "${TOTAL_WD_FLOW_N:-0}")
TOTAL_OTHER_OUT_AMT=$(fmt_goa "${TOTAL_OTHER_OUT_N:-0}")
W24_AMT=$(fmt_goa "$W24")
W7_AMT=$(fmt_goa "$W7")
LAST_AT_H=""; LAST_AT_ISO=""
if [ -n "${LAST_TS:-}" ] && [ "$LAST_TS" != "0" ]; then
LAST_AT_H=$(human_from_unix "$LAST_TS")
LAST_AT_ISO=$(iso_from_unix "$LAST_TS")
fi
# recent_withdraws JSON
RECENT_JSON="["
first=1
while IFS=$'\t' read -r amt ts_n at_h at_iso uname res; do
[ -n "$amt" ] || continue
if [ "$first" = 1 ]; then first=0; else RECENT_JSON="${RECENT_JSON},"; fi
RECENT_JSON="${RECENT_JSON}
{
\"kind\": \"withdraw\",
\"amount\": $(json_str "$amt"),
\"at\": $(json_str "$at_h"),
\"at_iso\": $(json_str "$at_iso"),
\"at_unix\": ${ts_n:-null},
\"account\": $(json_str "$uname"),
\"reserve\": $(json_str "$res")
}"
done <"$WORKDIR/recent.jsonl"
RECENT_JSON="${RECENT_JSON}
]"
# recent incoming (up to 5)
: >"$WORKDIR/recent-in.jsonl"
N_IN_LIST=0
while IFS=$'\t' read -r amt ts subj uname; do
[ -n "$amt" ] || continue
N_IN_LIST=$((N_IN_LIST + 1))
[ "$N_IN_LIST" -le 5 ] || break
ts_n=${ts:-0}
at_h=""; at_iso=""
if [ -n "$ts_n" ] && [ "$ts_n" != "0" ]; then
at_h=$(human_from_unix "$ts_n")
at_iso=$(iso_from_unix "$ts_n")
fi
printf '%s\t%s\t%s\t%s\t%s\n' "$amt" "$ts_n" "$at_h" "$at_iso" "$uname" >>"$WORKDIR/recent-in.jsonl"
done <"$WORKDIR/all-in-sorted.tsv"
RECENT_IN_JSON="["
first=1
while IFS=$'\t' read -r amt ts_n at_h at_iso uname; do
[ -n "$amt" ] || continue
if [ "$first" = 1 ]; then first=0; else RECENT_IN_JSON="${RECENT_IN_JSON},"; fi
RECENT_IN_JSON="${RECENT_IN_JSON}
{
\"kind\": \"incoming\",
\"amount\": $(json_str "$amt"),
\"at\": $(json_str "$at_h"),
\"at_iso\": $(json_str "$at_iso"),
\"at_unix\": ${ts_n:-null},
\"account\": $(json_str "$uname")
}"
done <"$WORKDIR/recent-in.jsonl"
RECENT_IN_JSON="${RECENT_IN_JSON}
]"
# Balance of explorer (optional display not required in footer)
BALANCE="GOA:0"
if [ -n "${USER_TOKEN:-$AUTH_TOKEN}" ]; then
curl -sS -m 10 -H "Authorization: Bearer ${USER_TOKEN:-$AUTH_TOKEN}" \
"${BANK}/accounts/${BANK_USER}" >"$WORKDIR/acct.json" || true
BALANCE=$(awk -F'"' '/"amount"/ {
for (i=1;i<=NF;i++) if ($i=="amount") { print $(i+2); exit }
}' "$WORKDIR/acct.json" 2>/dev/null || echo "GOA:0")
fi
# Demo block kept for page QR logic only (not shown in footer)
DEMO_URI=""; DEMO_AMT=""; DEMO_CREATED=""; DEMO_WID=""; DEMO_STATUS=""
[ -f "$LANDING_DIR/withdraw.uri" ] && DEMO_URI=$(tr -d '\n' <"$LANDING_DIR/withdraw.uri")
[ -f "$LANDING_DIR/withdraw.amount" ] && DEMO_AMT=$(tr -d '\n' <"$LANDING_DIR/withdraw.amount")
[ -f "$LANDING_DIR/withdraw.created" ] && DEMO_CREATED=$(tr -d '\n' <"$LANDING_DIR/withdraw.created")
if [ -n "$DEMO_URI" ]; then
DEMO_WID=$(basename "$DEMO_URI")
curl -sS -m 8 \
"${BANK}/taler-integration/withdrawal-operation/${DEMO_WID}" \
>"$WORKDIR/demo-wd.json" 2>/dev/null || true
DEMO_STATUS=$(awk -F'"' '/"status"/ {
for (i=1;i<=NF;i++) if ($i=="status") { print $(i+2); exit }
}' "$WORKDIR/demo-wd.json" 2>/dev/null || true)
fi
DEMO_READY=false
case "$DEMO_STATUS" in
pending|selected) DEMO_READY=true ;;
"") [ -n "$DEMO_URI" ] && DEMO_READY=true ;;
esac
[ "$DEMO_STATUS" = "confirmed" ] && DEMO_READY=false
[ "$DEMO_STATUS" = "aborted" ] && DEMO_READY=false
# Sanity: never publish an empty-looking success if admin scan should have accounts
if [ -n "$ADMIN_TOKEN" ] && [ "${ACCOUNTS_N:-0}" = "0" ]; then
write_err "accounts list empty after admin scan"
exit 1
fi
# Live performance probes (same idea as exchange landing-stats)
# Milliseconds as integer, rounded (not truncated) so sub-ms loopback does not
# always show 0 when we fall back to in-container URLs.
measure_ms() {
local url="$1" t
t=$(curl -sS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "")
[ -z "$t" ] && { echo "null"; return; }
awk -v t="$t" 'BEGIN{
ms = (t+0)*1000
if (ms > 0 && ms < 1) ms = 1
printf "%d", int(ms + 0.5)
}'
}
num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; }
# Prefer public URL for real client latency (Caddy → bank); fall back to loopback.
# Integration used to probe only $BANK (127.0.0.1:9012) → ~01 ms and not comparable
# to /config which already used the public host.
BANK_PUBLIC_BASE="${BANK_PUBLIC_URL:-https://bank.hacktivism.ch}"
CONFIG_MS=$(measure_ms "${BANK_PUBLIC_BASE}/config")
CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/config" 2>/dev/null || echo "000")
if [ "$CONFIG_HTTP" != "200" ]; then
CONFIG_MS=$(measure_ms "${BANK}/config")
CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK}/config" 2>/dev/null || echo "000")
fi
INT_MS=$(measure_ms "${BANK_PUBLIC_BASE}/taler-integration/config")
INT_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/taler-integration/config" 2>/dev/null || echo "000")
if [ "$INT_HTTP" != "200" ]; then
INT_MS=$(measure_ms "${BANK}/taler-integration/config")
INT_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK}/taler-integration/config" 2>/dev/null || echo "000")
fi
WEBUI_MS=$(measure_ms "${BANK_PUBLIC_BASE}/webui/")
WEBUI_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BANK_PUBLIC_BASE}/webui/" 2>/dev/null || echo "000")
LOADAVG=""
[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg)
MEM_JSON='"container_rss_human": "—"'
MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}"
if [ -f "$MEM_HELPER" ]; then
# shellcheck disable=SC1090
. "$MEM_HELPER"
mem_snapshot_json || true
fi
cat >"$TMP" <<EOF
{
"ok": true,
"currency": "GOA",
"timezone": $(json_str "$TZ"),
"generated_at": $(json_str "$GEN_ISO"),
"generated_at_human": $(json_str "$GEN_HUMAN"),
"generated_at_unix": $NOW,
"source": "in-container landing-stats.sh",
"bank_url": $(json_str "$BANK"),
"scan": {
"tx_delta": $(json_str "$TX_DELTA"),
"accounts_delta": $(json_str "$ACCOUNTS_DELTA"),
"max_scan_accounts": ${MAX_SCAN_ACCOUNTS:-0},
"accounts_listed": ${ACCOUNTS_N:-0},
"accounts_scanned_ok": ${SCAN_OK:-0},
"accounts_empty_tx": ${SCAN_EMPTY:-0},
"accounts_scan_fail": ${SCAN_FAIL:-0},
"note": "empty_tx includes HTTP 204 (no ledger rows) — normal for new auto-accounts"
},
"bank_accounts": {
"total": ${ACCOUNTS_N:-0},
"users": ${ACCOUNTS_USERS:-0},
"with_withdraws": ${ACCOUNTS_WITH_WD:-0}
},
"wallets": {
"unique_reserves": ${WALLETS_N:-0},
"note": "unique reserve pubs from Taler withdrawals (one per wallet withdraw)"
},
"balance_explorer": $(json_str "$BALANCE"),
"flow": {
"incoming": {
"label": "Incoming bank credits",
"count": ${N_INCOMING:-0},
"amount": $(json_str "$TOTAL_IN_AMT"),
"value": ${TOTAL_IN_N:-0}
},
"withdraw": {
"label": "Taler withdrawals to wallets",
"count": ${N_WD:-0},
"amount": $(json_str "$TOTAL_WD_FLOW_AMT"),
"value": ${TOTAL_WD_FLOW_N:-0}
},
"other_out": {
"label": "Other debits (non-withdraw)",
"amount": $(json_str "$TOTAL_OTHER_OUT_AMT"),
"value": ${TOTAL_OTHER_OUT_N:-0}
},
"total_in": $(json_str "$TOTAL_IN_AMT"),
"total_in_value": ${TOTAL_IN_N:-0},
"total_out": $(json_str "$TOTAL_OUT_AMT"),
"total_out_value": ${TOTAL_OUT_N:-0},
"note": "incoming=credits; withdraw=Taler withdrawal debits; excl. exchange only (admin+explorer+all users scanned)"
},
"withdraws": {
"count": ${N_WD:-0},
"total_amount": $(json_str "$TOTAL_AMT"),
"total_value": ${TOTAL_WD:-0},
"last_amount": $( [ -n "$LAST_AMT" ] && json_str "$LAST_AMT" || echo null ),
"last_at": $( [ -n "$LAST_AT_H" ] && json_str "$LAST_AT_H" || echo null ),
"last_at_iso": $( [ -n "$LAST_AT_ISO" ] && json_str "$LAST_AT_ISO" || echo null ),
"last_at_unix": ${LAST_TS:-null},
"last_subject": $( [ -n "$LAST_SUBJ" ] && json_str "$LAST_SUBJ" || echo null ),
"last_24h": { "count": ${N24:-0}, "amount": $(json_str "$W24_AMT"), "value": ${W24:-0} },
"last_7d": { "count": ${N7:-0}, "amount": $(json_str "$W7_AMT"), "value": ${W7:-0} }
},
"recent_withdraws": $RECENT_JSON,
"recent_incoming": $RECENT_IN_JSON,
"demo": {
"uri": $( [ -n "$DEMO_URI" ] && json_str "$DEMO_URI" || echo null ),
"amount": $( [ -n "$DEMO_AMT" ] && json_str "$DEMO_AMT" || echo null ),
"created": $( [ -n "$DEMO_CREATED" ] && json_str "$DEMO_CREATED" || echo null ),
"withdrawal_id": $( [ -n "$DEMO_WID" ] && json_str "$DEMO_WID" || echo null ),
"status": $( [ -n "$DEMO_STATUS" ] && json_str "$DEMO_STATUS" || echo null ),
"ready": $DEMO_READY
},
"performance": {
"config_http": $(json_str "$CONFIG_HTTP"),
"config_ms": $(num_or_null "$CONFIG_MS"),
"integration_http": $(json_str "$INT_HTTP"),
"integration_ms": $(num_or_null "$INT_MS"),
"webui_http": $(json_str "$WEBUI_HTTP"),
"webui_ms": $(num_or_null "$WEBUI_MS"),
"loadavg": $(json_str "${LOADAVG:-}"),
"memory": {
${MEM_JSON}
}
}
}
EOF
grep -q '"ok": true' "$TMP" || { write_err "tmp json missing ok:true"; exit 1; }
mv -f "$TMP" "$OUT"
write_run true
echo "ok accounts=${ACCOUNTS_N} wallets=${WALLETS_N} withdraws=${N_WD} total=${TOTAL_AMT} tz=${TZ} -> $OUT"