Compare commits

...

6 commits

Author SHA1 Message Date
Hernâni Marques
78bbd80d1f
landing: central container memory/load stats for all three sites
Collect RSS groups, top processes, and loadavg from inside each podman
container (not host /proc), merge into performance.memory, show compact
labels with cgroup limits and byte tooltips, and cover this in the test suite.
2026-07-17 08:55:00 +02:00
Hernâni Marques
9e0f85024b
landing: harden GOA alt display for all three sites
Ship goa-amount.js inside each landing tree, add an inline fallback when
the asset is not yet deployed, keep CHF free of GOA unit names, fix
exchange amount_full wiring, and add test-landing-stats.sh (local + live).
2026-07-17 08:48:00 +02:00
Hernâni Marques
b65018cd0b
monitoring: final stats dashboard and ladder reserve matching
Print a boxed FINAL STATISTICS block after e2e/ladder (coins, flow,
tendencies, perf, load) and match force-select reserve_pub to the current
withdrawal so cumulative ladder wallets do not reuse stale pubs.
2026-07-17 08:28:00 +02:00
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
Hernâni Marques
746d7a41af
scripts/taler-landing: hernani user systemd timer for central stats
Install path for hernani@koopa: oneshot service plus 2-minute timer that
runs the bank full scan and refreshes exchange/merchant stats via podman.
2026-07-17 07:55:00 +02:00
Hernâni Marques
25695805b4
scripts/taler-landing: full-scan bank collector with GOA alt units
Add a host-side Python collector that lists every bank account, pages
through all ledger rows, and emits stats.json with amount_alt fields
(Kilo/Mega/Peta-GOA) so large ladder withdrawals fit on the landing tiles.
2026-07-17 07:42:00 +02:00
28 changed files with 3831 additions and 182 deletions

View file

@ -103,11 +103,31 @@ taler-wallet-cli exchanges accept-tos https://exchange.hacktivism.ch/
---
## Live stats (in bank container)
## Live stats (central host collector · hernani systemd)
Stats are **not** computed on the laptop or host browser. A small script runs
**inside the bank container**, queries libeufin for the demo funding account
(`explorer`), and writes a public JSON file next to the landing assets.
**Primary:** user **hernani** on koopa runs `taler-landing-stats.timer`
(`scripts/taler-landing/`). That process full-scans **all bank accounts**
(except `exchange` double-count), paginates every ledger, writes
`stats.json` with **GOA alt units** (`amount_alt` / Kilo-GOA / Mega-GOA / …),
and refreshes exchange + merchant stats the same way.
```bash
# on koopa as hernani
./scripts/taler-landing/install-landing-stats-host.sh
sudo loginctl enable-linger hernani
systemctl --user start taler-landing-stats.service
```
See **`scripts/taler-landing/README.md`**.
**Fallback:** in-container `landing-stats.sh` (below) if the host timer is down.
It now also scans admin + all users (skips only `exchange`).
## Live stats (in bank container · fallback)
Stats are **not** computed in the browser. A small script can still run
**inside the bank container**, query libeufin (all accounts), and write a
public JSON file next to the landing assets.
| Piece | Path / role |
|-------|-------------|
@ -131,9 +151,9 @@ Stats are **not** computed on the laptop or host browser. A small script runs
| Env | Default | Meaning |
|-----|---------|---------|
| `TX_DELTA` | `-50000` | per-account ledger window (`GET …/transactions?delta=`) |
| `ACCOUNTS_DELTA` | `-500` | account-list window (`GET /accounts?delta=`) |
| `MAX_SCAN_ACCOUNTS` | `500` | max usernames to scan |
| `TX_DELTA` | `-200000` | per-account ledger window (`GET …/transactions?delta=`) |
| `ACCOUNTS_DELTA` | `-10000` | account-list window (`GET /accounts?delta=`) |
| `MAX_SCAN_ACCOUNTS` | `0` (unlimited) | max usernames to scan; `0` = all |
Older defaults (`TX_DELTA=-100`, `MAX_SCAN_ACCOUNTS=80`) **undercounted** credits/withdraws
and account totals on this stack. Empty accounts often return **HTTP 204** (no body) —

View file

@ -0,0 +1,169 @@
/* Compact GOA / CUR:amount display using currency alt_unit_names.
* Large values "1.23 Mega-GOA" (title = full GOA:n) so landing tiles fit.
*/
(function (global) {
"use strict";
var DEFAULT_ALT = {
"24": "Yotta-GOA",
"21": "Zetta-GOA",
"18": "Exa-GOA",
"15": "Peta-GOA",
"12": "Tera-GOA",
"9": "Giga-GOA",
"6": "Mega-GOA",
"3": "Kilo-GOA",
"0": "GOA",
"-1": "Deci-GOA",
"-2": "Centi-GOA",
"-3": "Milli-GOA",
"-6": "Micro-GOA",
"-7": "Deci-Micro-GOA",
"-8": "Atomic-GOA"
};
var THRESHOLD = 1000;
function parseAmount(s) {
if (s == null || s === "") return { cur: "GOA", val: 0 };
if (typeof s === "number") return { cur: "GOA", val: s };
var t = String(s).trim();
var i = t.indexOf(":");
if (i < 0) return { cur: "GOA", val: parseFloat(t) || 0 };
return {
cur: t.slice(0, i) || "GOA",
val: parseFloat(t.slice(i + 1)) || 0
};
}
function fmtCoeff(v) {
if (!isFinite(v)) return "?";
var a = Math.abs(v);
if (Math.abs(a - Math.round(a)) < 1e-9) return String(Math.round(v));
var s = v.toFixed(4).replace(/\.?0+$/, "");
return s;
}
function baseStr(cur, val) {
if (!isFinite(val)) return String(cur) + ":?";
if (Math.abs(val - Math.round(val)) < 1e-9) return cur + ":" + Math.round(val);
return cur + ":" + String(val).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
}
/**
* @param {string|number} amount
* @param {object} [altMap]
* @returns {{ display: string, title: string, raw: string, usedAlt: boolean }}
*/
function format(amount, altMap) {
if (amount == null || amount === "") {
return { display: "—", title: "", raw: "", usedAlt: false };
}
// Prefer server-provided alt if caller passes object {amount, amount_alt}
if (typeof amount === "object" && amount) {
if (amount.amount_alt) {
var rawO = amount.amount || amount.amount_full || String(amount.amount_alt);
return {
display: String(amount.amount_alt),
title: amount.amount_full || rawO,
raw: String(rawO),
usedAlt: true
};
}
amount = amount.amount || amount.amount_full || "";
}
var alt = altMap || global.__GOA_ALT_UNITS || DEFAULT_ALT;
var p = parseAmount(amount);
var raw = baseStr(p.cur, p.val);
// Foreign currencies must not pick up Kilo-GOA / Mega-GOA labels
var baseName = (alt && alt["0"]) || p.cur;
var curU = String(p.cur || "").toUpperCase();
var baseU = String(baseName || "").toUpperCase();
if (curU && curU !== "GOA" && (baseU === "GOA" || baseU.indexOf("GOA") !== -1)) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
if (p.val === 0) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
if (Math.abs(p.val) < THRESHOLD) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
var scales = [];
Object.keys(alt || {}).forEach(function (k) {
var n = parseInt(k, 10);
if (!isNaN(n)) scales.push({ sc: n, name: String(alt[k]) });
});
scales.sort(function (a, b) { return b.sc - a.sc; });
var abs = Math.abs(p.val);
var chosen = null;
for (var i = 0; i < scales.length; i++) {
var unit = Math.pow(10, scales[i].sc);
if (!(unit > 0)) continue;
var coeff = abs / unit;
if (coeff >= 1) {
chosen = {
sc: scales[i].sc,
name: scales[i].name,
coeff: p.val >= 0 ? coeff : -coeff
};
break;
}
}
if (!chosen || chosen.sc === 0) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
var short = fmtCoeff(chosen.coeff) + " " + chosen.name;
return {
display: short,
title: short + " (" + raw + ")",
raw: raw,
usedAlt: true
};
}
function setText(el, amount, altMap) {
if (!el) return;
var f = format(amount, altMap);
el.textContent = f.display;
if (f.title && f.title !== f.display) el.setAttribute("title", f.title);
else el.removeAttribute("title");
}
function setById(id, amount, altMap) {
setText(document.getElementById(id), amount, altMap);
}
/** Prefer amount_alt from stats row, else format amount. */
function pick(rowOrAmount, altMap) {
if (rowOrAmount && typeof rowOrAmount === "object") {
if (rowOrAmount.amount_alt) {
return format({
amount: rowOrAmount.amount,
amount_alt: rowOrAmount.amount_alt,
amount_full: rowOrAmount.amount_full
}, altMap);
}
return format(rowOrAmount.amount || rowOrAmount.total_amount, altMap);
}
return format(rowOrAmount, altMap);
}
function rememberAlt(stats) {
if (stats && stats.alt_unit_names && typeof stats.alt_unit_names === "object") {
global.__GOA_ALT_UNITS = stats.alt_unit_names;
}
}
global.GoaAmount = {
format: format,
setText: setText,
setById: setById,
pick: pick,
rememberAlt: rememberAlt,
DEFAULT_ALT: DEFAULT_ALT,
THRESHOLD: THRESHOLD
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1491,6 +1491,34 @@ tw run-until-done &amp;&amp; tw balance</pre>
</footer>
</main>
<script src="/intro/goa-amount.js"></script>
<script>
/* Fallback if goa-amount.js not yet deployed (uses amount_alt from stats.json) */
(function () {
if (window.GoaAmount) return;
window.GoaAmount = {
rememberAlt: function () {},
format: function (a) {
var s = a == null || a === "" ? "—" : String(a);
return { display: s, title: s === "—" ? "" : s, raw: s, usedAlt: false };
},
pick: function (row) {
if (row && typeof row === "object") {
if (row.amount_alt) {
return {
display: String(row.amount_alt),
title: String(row.amount_full || row.amount || row.amount_alt),
raw: String(row.amount || ""),
usedAlt: true
};
}
return this.format(row.amount || row.total_amount || "—");
}
return this.format(row);
}
};
})();
</script>
<script>
(function () {
var WEBUI = "https://bank.hacktivism.ch/webui/";
@ -1936,16 +1964,34 @@ tw run-until-done &amp;&amp; tw balance</pre>
var el = document.getElementById(id);
if (el) el.textContent = v == null || v === "" ? "—" : String(v);
}
function setAmt(id, rowOrAmt) {
var el = document.getElementById(id);
if (!el) return;
if (window.GoaAmount) {
var f = GoaAmount.pick(rowOrAmt, d.alt_unit_names);
el.textContent = f.display;
if (f.title && f.title !== f.display) el.setAttribute("title", f.title);
else el.removeAttribute("title");
} else {
var raw = rowOrAmt && typeof rowOrAmt === "object"
? (rowOrAmt.amount_alt || rowOrAmt.amount || rowOrAmt.total_amount || "—")
: (rowOrAmt == null || rowOrAmt === "" ? "—" : String(rowOrAmt));
el.textContent = raw;
}
}
if (window.GoaAmount) GoaAmount.rememberAlt(d);
var flow = d.flow || {};
var fin = flow.incoming || {};
var fwd = flow.withdraw || {};
set("st-accounts", ba.total != null ? ba.total : "—");
set("st-wallets", wl.unique_reserves != null ? wl.unique_reserves : "—");
set("st-incoming", fin.amount || flow.total_in || "—");
set("st-withdraw", fwd.amount || w.total_amount || "—");
set("st-24h", h24.amount || "GOA:0");
set("st-7d", h7.amount || "GOA:0");
set("shared-balance", d.balance_explorer || "—");
setAmt("st-incoming", fin.amount_alt ? fin : (fin.amount || flow.total_in || "—"));
setAmt("st-withdraw", fwd.amount_alt ? fwd : (fwd.amount || w.total_amount || "—"));
setAmt("st-24h", h24.amount_alt ? h24 : (h24.amount || "GOA:0"));
setAmt("st-7d", h7.amount_alt ? h7 : (h7.amount || "GOA:0"));
setAmt("shared-balance", d.balance_explorer_alt
? { amount: d.balance_explorer, amount_alt: d.balance_explorer_alt, amount_full: d.balance_explorer_full }
: (d.balance_explorer || "—"));
var list = document.getElementById("st-recent");
if (list) {
@ -1962,7 +2008,13 @@ tw run-until-done &amp;&amp; tw balance</pre>
var li = document.createElement("li");
var amt = document.createElement("span");
amt.className = "amt";
amt.textContent = row.amount || "?";
if (window.GoaAmount) {
var af = GoaAmount.pick(row, d.alt_unit_names);
amt.textContent = af.display;
if (af.title && af.title !== af.display) amt.setAttribute("title", af.title);
} else {
amt.textContent = row.amount_alt || row.amount || "?";
}
var meta = document.createElement("span");
meta.className = "meta";
var when = fmtCest(row.at_unix, row.at || row.at_iso) || "";
@ -1985,20 +2037,33 @@ tw run-until-done &amp;&amp; tw balance</pre>
if (http && http !== "200") s += " (" + http + ")";
return s;
}
function setMem(id, human, n, bytes) {
var el = document.getElementById(id);
if (!el) return;
if (!human && human !== 0) {
el.textContent = "—";
el.removeAttribute("title");
return;
}
var h = String(human);
if (n != null && n !== "" && Number(n) > 0) h += " · " + n + "p";
el.textContent = h;
if (bytes != null && bytes !== "" && bytes !== 0) {
el.setAttribute("title", Number(bytes).toLocaleString("en-US") + " B");
} else {
el.removeAttribute("title");
}
}
var p = d.performance || {};
set("st-config-ms", msLabel(p.config_ms, p.config_http));
set("st-int-ms", msLabel(p.integration_ms, p.integration_http));
set("st-webui-ms", msLabel(p.webui_ms, p.webui_http));
set("st-load", p.loadavg || "—");
var mem = p.memory || {};
set("st-mem-ctr", mem.container_rss_human || "—");
set("st-mem-pg", mem.postgres_rss_human
? mem.postgres_rss_human + (mem.postgres_n ? " · " + mem.postgres_n + "p" : "")
: "—");
set("st-mem-java", mem.java_rss_human
? mem.java_rss_human + (mem.java_n ? " · " + mem.java_n + "p" : "")
: "—");
set("st-mem-nginx", mem.nginx_rss_human || "—");
setMem("st-mem-ctr", mem.container_rss_label || mem.container_rss_human || "—", null, mem.container_rss_bytes);
setMem("st-mem-pg", mem.postgres_rss_human, mem.postgres_n, mem.postgres_rss_bytes);
setMem("st-mem-java", mem.java_rss_human, mem.java_n, mem.java_rss_bytes);
setMem("st-mem-nginx", mem.nginx_rss_human, mem.nginx_n, mem.nginx_rss_bytes);
var topBox = document.getElementById("st-mem-top");
if (topBox) {
var tops = mem.top || [];
@ -2010,10 +2075,16 @@ tw run-until-done &amp;&amp; tw balance</pre>
'<th style="text-align:left;padding:0.25rem;border-bottom:1px solid var(--border)">Comm</th>' +
'<th style="text-align:left;padding:0.25rem;border-bottom:1px solid var(--border)">Command</th></tr></thead><tbody>';
tops.forEach(function (t) {
th += '<tr><td style="padding:0.25rem;color:#5eead4">' + (t.rss_human || "?") +
var cmd = t.cmd || "";
var cmdFull = t.cmd_full || cmd;
if (cmd.length > 64) cmd = cmd.slice(0, 61) + "…";
th += '<tr><td style="padding:0.25rem;color:#5eead4" title="' +
(t.rss_bytes != null ? Number(t.rss_bytes).toLocaleString("en-US") + " B" : "") + '">' +
(t.rss_human || "?") +
'</td><td style="padding:0.25rem">' + (t.comm || "") +
'</td><td style="padding:0.25rem;word-break:break-all;color:var(--muted)">' +
(t.cmd || "") + "</td></tr>";
'</td><td style="padding:0.25rem;word-break:break-all;color:var(--muted)" title="' +
String(cmdFull).replace(/"/g, "&quot;") + '">' +
cmd + "</td></tr>";
});
th += "</tbody></table>";
topBox.innerHTML = th;
@ -2021,7 +2092,8 @@ tw run-until-done &amp;&amp; tw balance</pre>
}
var pf = document.getElementById("st-perf-foot");
if (pf) {
pf.textContent = "Container RSS + process groups · " +
pf.textContent = "Container RSS + process groups" +
(mem.source ? " · " + mem.source : "") + " · " +
(d.generated_at_human || d.generated_at || "");
}
return fetch("stats-run.json", { cache: "no-store" })

View file

@ -0,0 +1,169 @@
/* Compact GOA / CUR:amount display using currency alt_unit_names.
* Large values "1.23 Mega-GOA" (title = full GOA:n) so landing tiles fit.
*/
(function (global) {
"use strict";
var DEFAULT_ALT = {
"24": "Yotta-GOA",
"21": "Zetta-GOA",
"18": "Exa-GOA",
"15": "Peta-GOA",
"12": "Tera-GOA",
"9": "Giga-GOA",
"6": "Mega-GOA",
"3": "Kilo-GOA",
"0": "GOA",
"-1": "Deci-GOA",
"-2": "Centi-GOA",
"-3": "Milli-GOA",
"-6": "Micro-GOA",
"-7": "Deci-Micro-GOA",
"-8": "Atomic-GOA"
};
var THRESHOLD = 1000;
function parseAmount(s) {
if (s == null || s === "") return { cur: "GOA", val: 0 };
if (typeof s === "number") return { cur: "GOA", val: s };
var t = String(s).trim();
var i = t.indexOf(":");
if (i < 0) return { cur: "GOA", val: parseFloat(t) || 0 };
return {
cur: t.slice(0, i) || "GOA",
val: parseFloat(t.slice(i + 1)) || 0
};
}
function fmtCoeff(v) {
if (!isFinite(v)) return "?";
var a = Math.abs(v);
if (Math.abs(a - Math.round(a)) < 1e-9) return String(Math.round(v));
var s = v.toFixed(4).replace(/\.?0+$/, "");
return s;
}
function baseStr(cur, val) {
if (!isFinite(val)) return String(cur) + ":?";
if (Math.abs(val - Math.round(val)) < 1e-9) return cur + ":" + Math.round(val);
return cur + ":" + String(val).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
}
/**
* @param {string|number} amount
* @param {object} [altMap]
* @returns {{ display: string, title: string, raw: string, usedAlt: boolean }}
*/
function format(amount, altMap) {
if (amount == null || amount === "") {
return { display: "—", title: "", raw: "", usedAlt: false };
}
// Prefer server-provided alt if caller passes object {amount, amount_alt}
if (typeof amount === "object" && amount) {
if (amount.amount_alt) {
var rawO = amount.amount || amount.amount_full || String(amount.amount_alt);
return {
display: String(amount.amount_alt),
title: amount.amount_full || rawO,
raw: String(rawO),
usedAlt: true
};
}
amount = amount.amount || amount.amount_full || "";
}
var alt = altMap || global.__GOA_ALT_UNITS || DEFAULT_ALT;
var p = parseAmount(amount);
var raw = baseStr(p.cur, p.val);
// Foreign currencies must not pick up Kilo-GOA / Mega-GOA labels
var baseName = (alt && alt["0"]) || p.cur;
var curU = String(p.cur || "").toUpperCase();
var baseU = String(baseName || "").toUpperCase();
if (curU && curU !== "GOA" && (baseU === "GOA" || baseU.indexOf("GOA") !== -1)) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
if (p.val === 0) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
if (Math.abs(p.val) < THRESHOLD) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
var scales = [];
Object.keys(alt || {}).forEach(function (k) {
var n = parseInt(k, 10);
if (!isNaN(n)) scales.push({ sc: n, name: String(alt[k]) });
});
scales.sort(function (a, b) { return b.sc - a.sc; });
var abs = Math.abs(p.val);
var chosen = null;
for (var i = 0; i < scales.length; i++) {
var unit = Math.pow(10, scales[i].sc);
if (!(unit > 0)) continue;
var coeff = abs / unit;
if (coeff >= 1) {
chosen = {
sc: scales[i].sc,
name: scales[i].name,
coeff: p.val >= 0 ? coeff : -coeff
};
break;
}
}
if (!chosen || chosen.sc === 0) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
var short = fmtCoeff(chosen.coeff) + " " + chosen.name;
return {
display: short,
title: short + " (" + raw + ")",
raw: raw,
usedAlt: true
};
}
function setText(el, amount, altMap) {
if (!el) return;
var f = format(amount, altMap);
el.textContent = f.display;
if (f.title && f.title !== f.display) el.setAttribute("title", f.title);
else el.removeAttribute("title");
}
function setById(id, amount, altMap) {
setText(document.getElementById(id), amount, altMap);
}
/** Prefer amount_alt from stats row, else format amount. */
function pick(rowOrAmount, altMap) {
if (rowOrAmount && typeof rowOrAmount === "object") {
if (rowOrAmount.amount_alt) {
return format({
amount: rowOrAmount.amount,
amount_alt: rowOrAmount.amount_alt,
amount_full: rowOrAmount.amount_full
}, altMap);
}
return format(rowOrAmount.amount || rowOrAmount.total_amount, altMap);
}
return format(rowOrAmount, altMap);
}
function rememberAlt(stats) {
if (stats && stats.alt_unit_names && typeof stats.alt_unit_names === "object") {
global.__GOA_ALT_UNITS = stats.alt_unit_names;
}
}
global.GoaAmount = {
format: format,
setText: setText,
setById: setById,
pick: pick,
rememberAlt: rememberAlt,
DEFAULT_ALT: DEFAULT_ALT,
THRESHOLD: THRESHOLD
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -444,12 +444,60 @@ sudo apt-get install -y taler-wallet-cli</pre>
</p>
</footer>
</main>
<script src="/intro/goa-amount.js"></script>
<script>
/* Fallback if goa-amount.js not yet deployed (uses amount_alt from stats.json) */
(function () {
if (window.GoaAmount) return;
window.GoaAmount = {
rememberAlt: function () {},
format: function (a) {
var s = a == null || a === "" ? "—" : String(a);
return { display: s, title: s === "—" ? "" : s, raw: s, usedAlt: false };
},
pick: function (row) {
if (row && typeof row === "object") {
if (row.amount_alt) {
return {
display: String(row.amount_alt),
title: String(row.amount_full || row.amount || row.amount_alt),
raw: String(row.amount || ""),
usedAlt: true
};
}
return this.format(row.amount || row.total_amount || "—");
}
return this.format(row);
}
};
})();
</script>
<script>
(function () {
function set(id, v) {
var el = document.getElementById(id);
if (el) el.textContent = v == null || v === "" ? "—" : String(v);
}
function setAmt(id, amount, altKey, d) {
var el = document.getElementById(id);
if (!el) return;
var row = { amount: amount };
if (altKey && d && d[altKey]) {
row.amount_alt = d[altKey];
var fullKey = String(altKey).replace(/_alt$/, "_full");
if (d[fullKey]) row.amount_full = d[fullKey];
}
if (window.GoaAmount) {
var f = (row.amount_alt || (d && d.alt_unit_names))
? GoaAmount.pick(row, d && d.alt_unit_names)
: GoaAmount.format(amount, d && d.alt_unit_names);
el.textContent = f.display;
if (f.title && f.title !== f.display) el.setAttribute("title", f.title);
else el.removeAttribute("title");
} else {
el.textContent = row.amount_alt || amount || "—";
}
}
function msLabel(ms, http) {
if (ms == null || ms === "") return "—";
var s = ms + " ms";
@ -460,12 +508,13 @@ sudo apt-get install -y taler-wallet-cli</pre>
.then(function (r) { if (!r.ok) throw new Error("x"); return r.json(); })
.then(function (d) {
if (!d || !d.ok) throw new Error("bad");
if (window.GoaAmount) GoaAmount.rememberAlt(d);
set("st-coins", d.known_coins);
set("st-coins-live", d.coins_live);
set("st-coins-spent", d.coins_spent);
set("st-remaining", d.coins_remaining_amount);
setAmt("st-remaining", d.coins_remaining_amount, "coins_remaining_amount_alt", d);
set("st-withdraw", d.withdraw_ops);
set("st-withdraw-amt", d.withdraw_amount);
setAmt("st-withdraw-amt", d.withdraw_amount, "withdraw_amount_alt", d);
set("st-refresh", d.refresh_ops);
set("st-coin-dep", d.coin_deposits);
set("st-batch-dep", d.batch_deposits);
@ -477,7 +526,7 @@ sudo apt-get install -y taler-wallet-cli</pre>
set("st-denoms-wd", d.denoms_withdrawable);
set("st-reserves", d.reserves);
set("st-wire-in-n", d.wire_in_count);
set("st-wire-in-amt", d.wire_in_amount);
setAmt("st-wire-in-amt", d.wire_in_amount, "wire_in_amount_alt", d);
set("st-wire-out", d.wire_out);
var ladder = document.getElementById("st-ladder");
@ -486,7 +535,12 @@ sudo apt-get install -y taler-wallet-cli</pre>
(d.denom_ladder || []).forEach(function (x) {
var s = document.createElement("span");
s.className = "pill";
s.textContent = x.value + (x.keys > 1 ? " ×" + x.keys : "");
var lab = x.value_alt || x.value || "?";
if (window.GoaAmount && !x.value_alt) {
lab = GoaAmount.format(x.value, d.alt_unit_names).display;
}
s.textContent = lab + (x.keys > 1 ? " ×" + x.keys : "");
if (x.value && lab !== x.value) s.title = x.value;
ladder.appendChild(s);
});
if (!(d.denom_ladder || []).length) ladder.textContent = "—";
@ -499,7 +553,11 @@ sudo apt-get install -y taler-wallet-cli</pre>
wrap.hidden = false;
var html = "<table><thead><tr><th>Value</th><th>Coins</th><th>Live</th></tr></thead><tbody>";
by.forEach(function (r) {
html += "<tr><td>" + r.value + "</td><td>" + r.coins + "</td><td>" + r.live + "</td></tr>";
var v = r.value_alt || r.value || "?";
if (window.GoaAmount && !r.value_alt) {
v = GoaAmount.format(r.value, d.alt_unit_names).display;
}
html += "<tr><td title=\"" + (r.value || "") + "\">" + v + "</td><td>" + r.coins + "</td><td>" + r.live + "</td></tr>";
});
html += "</tbody></table>";
box.innerHTML = html;
@ -511,19 +569,30 @@ sudo apt-get install -y taler-wallet-cli</pre>
(d.generated_at_human || d.generated_at || "");
}
function setMem(id, human, n, bytes) {
var el = document.getElementById(id);
if (!el) return;
if (!human && human !== 0) {
el.textContent = "—";
el.removeAttribute("title");
return;
}
var h = String(human);
if (n != null && n !== "" && Number(n) > 0) h += " · " + n + "p";
el.textContent = h;
if (bytes != null && bytes !== "" && Number(bytes) > 0) {
el.setAttribute("title", Number(bytes).toLocaleString("en-US") + " B");
} else el.removeAttribute("title");
}
var p = d.performance || {};
set("st-keys-ms", msLabel(p.keys_ms, p.keys_http));
set("st-config-ms", msLabel(p.config_ms, p.config_http));
set("st-load", p.loadavg || "—");
var mem = p.memory || {};
set("st-mem-ctr", mem.container_rss_human || "—");
set("st-mem-pg", mem.postgres_rss_human
? mem.postgres_rss_human + (mem.postgres_n ? " · " + mem.postgres_n + "p" : "")
: "—");
set("st-mem-taler", mem.taler_rss_human
? mem.taler_rss_human + (mem.taler_n ? " · " + mem.taler_n + "p" : "")
: "—");
set("st-mem-nginx", mem.nginx_rss_human || "—");
setMem("st-mem-ctr", mem.container_rss_label || mem.container_rss_human || "—", null, mem.container_rss_bytes);
setMem("st-mem-pg", mem.postgres_rss_human, mem.postgres_n, mem.postgres_rss_bytes);
setMem("st-mem-taler", mem.taler_rss_human, mem.taler_n, mem.taler_rss_bytes);
setMem("st-mem-nginx", mem.nginx_rss_human, mem.nginx_n, mem.nginx_rss_bytes);
var topBox = document.getElementById("st-mem-top");
if (topBox) {
var tops = mem.top || [];
@ -532,9 +601,14 @@ sudo apt-get install -y taler-wallet-cli</pre>
} else {
var th = "<table><thead><tr><th>RSS</th><th>Comm</th><th>Command</th></tr></thead><tbody>";
tops.forEach(function (t) {
th += "<tr><td>" + (t.rss_human || "?") + "</td><td>" +
(t.comm || "") + "</td><td style=\"font-size:0.72rem;word-break:break-all\">" +
(t.cmd || "") + "</td></tr>";
var cmd = t.cmd || "";
var cmdFull = t.cmd_full || cmd;
if (cmd.length > 64) cmd = cmd.slice(0, 61) + "…";
th += "<tr><td title=\"" + (t.rss_bytes != null ? Number(t.rss_bytes).toLocaleString("en-US") + " B" : "") + "\">" +
(t.rss_human || "?") + "</td><td>" +
(t.comm || "") + "</td><td style=\"font-size:0.72rem;word-break:break-all\" title=\"" +
String(cmdFull).replace(/"/g, "&quot;") + "\">" +
cmd + "</td></tr>";
});
th += "</tbody></table>";
topBox.innerHTML = th;
@ -542,7 +616,8 @@ sudo apt-get install -y taler-wallet-cli</pre>
}
var pf = document.getElementById("st-perf-foot");
if (pf) {
pf.textContent = "Container RSS + process groups · " +
pf.textContent = "Container RSS + process groups" +
(mem.source ? " · " + mem.source : "") + " · " +
(d.generated_at_human || d.generated_at || "");
}
return fetch("/intro/stats-run.json", { cache: "no-store" })

View file

@ -0,0 +1,169 @@
/* Compact GOA / CUR:amount display using currency alt_unit_names.
* Large values "1.23 Mega-GOA" (title = full GOA:n) so landing tiles fit.
*/
(function (global) {
"use strict";
var DEFAULT_ALT = {
"24": "Yotta-GOA",
"21": "Zetta-GOA",
"18": "Exa-GOA",
"15": "Peta-GOA",
"12": "Tera-GOA",
"9": "Giga-GOA",
"6": "Mega-GOA",
"3": "Kilo-GOA",
"0": "GOA",
"-1": "Deci-GOA",
"-2": "Centi-GOA",
"-3": "Milli-GOA",
"-6": "Micro-GOA",
"-7": "Deci-Micro-GOA",
"-8": "Atomic-GOA"
};
var THRESHOLD = 1000;
function parseAmount(s) {
if (s == null || s === "") return { cur: "GOA", val: 0 };
if (typeof s === "number") return { cur: "GOA", val: s };
var t = String(s).trim();
var i = t.indexOf(":");
if (i < 0) return { cur: "GOA", val: parseFloat(t) || 0 };
return {
cur: t.slice(0, i) || "GOA",
val: parseFloat(t.slice(i + 1)) || 0
};
}
function fmtCoeff(v) {
if (!isFinite(v)) return "?";
var a = Math.abs(v);
if (Math.abs(a - Math.round(a)) < 1e-9) return String(Math.round(v));
var s = v.toFixed(4).replace(/\.?0+$/, "");
return s;
}
function baseStr(cur, val) {
if (!isFinite(val)) return String(cur) + ":?";
if (Math.abs(val - Math.round(val)) < 1e-9) return cur + ":" + Math.round(val);
return cur + ":" + String(val).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
}
/**
* @param {string|number} amount
* @param {object} [altMap]
* @returns {{ display: string, title: string, raw: string, usedAlt: boolean }}
*/
function format(amount, altMap) {
if (amount == null || amount === "") {
return { display: "—", title: "", raw: "", usedAlt: false };
}
// Prefer server-provided alt if caller passes object {amount, amount_alt}
if (typeof amount === "object" && amount) {
if (amount.amount_alt) {
var rawO = amount.amount || amount.amount_full || String(amount.amount_alt);
return {
display: String(amount.amount_alt),
title: amount.amount_full || rawO,
raw: String(rawO),
usedAlt: true
};
}
amount = amount.amount || amount.amount_full || "";
}
var alt = altMap || global.__GOA_ALT_UNITS || DEFAULT_ALT;
var p = parseAmount(amount);
var raw = baseStr(p.cur, p.val);
// Foreign currencies must not pick up Kilo-GOA / Mega-GOA labels
var baseName = (alt && alt["0"]) || p.cur;
var curU = String(p.cur || "").toUpperCase();
var baseU = String(baseName || "").toUpperCase();
if (curU && curU !== "GOA" && (baseU === "GOA" || baseU.indexOf("GOA") !== -1)) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
if (p.val === 0) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
if (Math.abs(p.val) < THRESHOLD) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
var scales = [];
Object.keys(alt || {}).forEach(function (k) {
var n = parseInt(k, 10);
if (!isNaN(n)) scales.push({ sc: n, name: String(alt[k]) });
});
scales.sort(function (a, b) { return b.sc - a.sc; });
var abs = Math.abs(p.val);
var chosen = null;
for (var i = 0; i < scales.length; i++) {
var unit = Math.pow(10, scales[i].sc);
if (!(unit > 0)) continue;
var coeff = abs / unit;
if (coeff >= 1) {
chosen = {
sc: scales[i].sc,
name: scales[i].name,
coeff: p.val >= 0 ? coeff : -coeff
};
break;
}
}
if (!chosen || chosen.sc === 0) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
var short = fmtCoeff(chosen.coeff) + " " + chosen.name;
return {
display: short,
title: short + " (" + raw + ")",
raw: raw,
usedAlt: true
};
}
function setText(el, amount, altMap) {
if (!el) return;
var f = format(amount, altMap);
el.textContent = f.display;
if (f.title && f.title !== f.display) el.setAttribute("title", f.title);
else el.removeAttribute("title");
}
function setById(id, amount, altMap) {
setText(document.getElementById(id), amount, altMap);
}
/** Prefer amount_alt from stats row, else format amount. */
function pick(rowOrAmount, altMap) {
if (rowOrAmount && typeof rowOrAmount === "object") {
if (rowOrAmount.amount_alt) {
return format({
amount: rowOrAmount.amount,
amount_alt: rowOrAmount.amount_alt,
amount_full: rowOrAmount.amount_full
}, altMap);
}
return format(rowOrAmount.amount || rowOrAmount.total_amount, altMap);
}
return format(rowOrAmount, altMap);
}
function rememberAlt(stats) {
if (stats && stats.alt_unit_names && typeof stats.alt_unit_names === "object") {
global.__GOA_ALT_UNITS = stats.alt_unit_names;
}
}
global.GoaAmount = {
format: format,
setText: setText,
setById: setById,
pick: pick,
rememberAlt: rememberAlt,
DEFAULT_ALT: DEFAULT_ALT,
THRESHOLD: THRESHOLD
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -811,19 +811,63 @@ sudo apt-get install -y taler-wallet-cli</pre>
</p>
</footer>
</main>
<script src="/intro/goa-amount.js"></script>
<script>
/* Fallback if goa-amount.js not yet deployed (uses amount_alt from stats.json) */
(function () {
if (window.GoaAmount) return;
window.GoaAmount = {
rememberAlt: function () {},
format: function (a) {
var s = a == null || a === "" ? "—" : String(a);
return { display: s, title: s === "—" ? "" : s, raw: s, usedAlt: false };
},
pick: function (row) {
if (row && typeof row === "object") {
if (row.amount_alt) {
return {
display: String(row.amount_alt),
title: String(row.amount_full || row.amount || row.amount_alt),
raw: String(row.amount || ""),
usedAlt: true
};
}
return this.format(row.amount || row.amount_paid_sum || row.amount_sum || "—");
}
return this.format(row);
}
};
})();
</script>
<script>
(function () {
function set(id, v) {
var el = document.getElementById(id);
if (el) el.textContent = v == null || v === "" ? "—" : String(v);
}
function setAmt(id, rowOrAmt, altMap) {
var el = document.getElementById(id);
if (!el) return;
if (window.GoaAmount) {
var f = GoaAmount.pick(rowOrAmt, altMap);
el.textContent = f.display;
if (f.title && f.title !== f.display) el.setAttribute("title", f.title);
else el.removeAttribute("title");
} else {
var raw = rowOrAmt && typeof rowOrAmt === "object"
? (rowOrAmt.amount_alt || rowOrAmt.amount_paid_sum_alt || rowOrAmt.amount_sum_alt
|| rowOrAmt.amount_paid_sum || rowOrAmt.amount_sum || rowOrAmt.amount || "—")
: (rowOrAmt == null || rowOrAmt === "" ? "—" : String(rowOrAmt));
el.textContent = raw;
}
}
function msLabel(ms, http) {
if (ms == null || ms === "") return "—";
var s = ms + " ms";
if (http && http !== "200") s += " (" + http + ")";
return s;
}
function fillCurrency(prefix, c) {
function fillCurrency(prefix, c, altMap) {
if (!c) {
set(prefix + "-paid-amt", "—");
set(prefix + "-all-amt", "—");
@ -833,8 +877,16 @@ sudo apt-get install -y taler-wallet-cli</pre>
set(prefix + "-unpaid", "—");
return;
}
set(prefix + "-paid-amt", c.amount_paid_sum || "—");
set(prefix + "-all-amt", c.amount_sum || "—");
setAmt(prefix + "-paid-amt", {
amount: c.amount_paid_sum,
amount_alt: c.amount_paid_sum_alt,
amount_full: c.amount_paid_sum_full
}, altMap);
setAmt(prefix + "-all-amt", {
amount: c.amount_sum,
amount_alt: c.amount_sum_alt,
amount_full: c.amount_sum_full
}, altMap);
set(prefix + "-orders", c.contracts);
set(prefix + "-paid", c.paid);
set(prefix + "-wired", c.wired);
@ -851,6 +903,7 @@ sudo apt-get install -y taler-wallet-cli</pre>
.then(function (r) { if (!r.ok) throw new Error("http"); return r.json(); })
.then(function (d) {
if (!d || !d.ok) throw new Error("bad");
if (window.GoaAmount) GoaAmount.rememberAlt(d);
set("st-instances", d.instances);
set("st-orders", d.orders);
set("st-paid", d.paid);
@ -862,8 +915,8 @@ sudo apt-get install -y taler-wallet-cli</pre>
(d.by_currency || []).forEach(function (c) {
by[(c.currency || "").toUpperCase()] = c;
});
fillCurrency("goa", by.GOA);
fillCurrency("chf", by.CHF);
fillCurrency("goa", by.GOA, d.alt_unit_names);
fillCurrency("chf", by.CHF, d.alt_unit_names);
function fillActivityList(el, acts) {
if (!el) return;
@ -878,10 +931,15 @@ sudo apt-get install -y taler-wallet-cli</pre>
var li = document.createElement("li");
li.className = "act-item";
// no merchant names / ids — amount, optional summary, time only
var amtShow = a.amount_alt || a.amount || "—";
if (window.GoaAmount && !a.amount_alt) {
amtShow = GoaAmount.format(a.amount, d.alt_unit_names).display;
}
var amtTitle = a.amount_full || a.amount || "";
li.innerHTML =
'<span class="act-kind ' + esc(kind) + '">' + esc(kind) + "</span>" +
'<div class="act-main">' +
'<div class="act-amt">' + esc(a.amount || "—") + "</div>" +
'<div class="act-amt" title="' + esc(amtTitle) + '">' + esc(amtShow) + "</div>" +
(a.summary
? '<div class="act-sum">' + esc(a.summary) + "</div>"
: "") +
@ -915,20 +973,31 @@ sudo apt-get install -y taler-wallet-cli</pre>
foot.textContent = (d.dual_currency ? "Dual-currency · " : "") +
(d.generated_at_human || d.generated_at || "");
}
function setMem(id, human, n, bytes) {
var el = document.getElementById(id);
if (!el) return;
if (!human && human !== 0) {
el.textContent = "—";
el.removeAttribute("title");
return;
}
var h = String(human);
if (n != null && n !== "" && Number(n) > 0) h += " · " + n + "p";
el.textContent = h;
if (bytes != null && bytes !== "" && Number(bytes) > 0) {
el.setAttribute("title", Number(bytes).toLocaleString("en-US") + " B");
} else el.removeAttribute("title");
}
var p = d.performance || {};
set("st-config-ms", msLabel(p.config_ms, p.config_http));
set("st-terms-ms", msLabel(p.terms_ms, p.terms_http));
set("st-webui-ms", msLabel(p.webui_ms, p.webui_http));
set("st-load", p.loadavg || "—");
var mem = p.memory || {};
set("st-mem-ctr", mem.container_rss_human || "—");
set("st-mem-pg", mem.postgres_rss_human
? mem.postgres_rss_human + (mem.postgres_n ? " · " + mem.postgres_n + "p" : "")
: "—");
set("st-mem-taler", mem.taler_rss_human
? mem.taler_rss_human + (mem.taler_n ? " · " + mem.taler_n + "p" : "")
: "—");
set("st-mem-nginx", mem.nginx_rss_human || "—");
setMem("st-mem-ctr", mem.container_rss_label || mem.container_rss_human || "—", null, mem.container_rss_bytes);
setMem("st-mem-pg", mem.postgres_rss_human, mem.postgres_n, mem.postgres_rss_bytes);
setMem("st-mem-taler", mem.taler_rss_human, mem.taler_n, mem.taler_rss_bytes);
setMem("st-mem-nginx", mem.nginx_rss_human, mem.nginx_n, mem.nginx_rss_bytes);
var topBox = document.getElementById("st-mem-top");
if (topBox) {
var tops = mem.top || [];
@ -940,10 +1009,16 @@ sudo apt-get install -y taler-wallet-cli</pre>
'<th style="text-align:left;padding:0.25rem;border-bottom:1px solid var(--border)">Comm</th>' +
'<th style="text-align:left;padding:0.25rem;border-bottom:1px solid var(--border)">Command</th></tr></thead><tbody>';
tops.forEach(function (t) {
th += '<tr><td style="padding:0.25rem;color:#5eead4">' + (t.rss_human || "?") +
var cmd = t.cmd || "";
var cmdFull = t.cmd_full || cmd;
if (cmd.length > 64) cmd = cmd.slice(0, 61) + "…";
th += '<tr><td style="padding:0.25rem;color:#5eead4" title="' +
(t.rss_bytes != null ? Number(t.rss_bytes).toLocaleString("en-US") + " B" : "") + '">' +
(t.rss_human || "?") +
'</td><td style="padding:0.25rem">' + (t.comm || "") +
'</td><td style="padding:0.25rem;word-break:break-all;color:var(--muted)">' +
(t.cmd || "") + "</td></tr>";
'</td><td style="padding:0.25rem;word-break:break-all;color:var(--muted)" title="' +
String(cmdFull).replace(/"/g, "&quot;") + '">' +
cmd + "</td></tr>";
});
th += "</tbody></table>";
topBox.innerHTML = th;
@ -951,7 +1026,8 @@ sudo apt-get install -y taler-wallet-cli</pre>
}
var pf = document.getElementById("st-perf-foot");
if (pf) {
pf.textContent = "Container RSS + process groups · " +
pf.textContent = "Container RSS + process groups" +
(mem.source ? " · " + mem.source : "") + " · " +
(d.generated_at_human || d.generated_at || "");
}
return fetch("/intro/stats-run.json", { cache: "no-store" })

View file

@ -0,0 +1,169 @@
/* Compact GOA / CUR:amount display using currency alt_unit_names.
* Large values "1.23 Mega-GOA" (title = full GOA:n) so landing tiles fit.
*/
(function (global) {
"use strict";
var DEFAULT_ALT = {
"24": "Yotta-GOA",
"21": "Zetta-GOA",
"18": "Exa-GOA",
"15": "Peta-GOA",
"12": "Tera-GOA",
"9": "Giga-GOA",
"6": "Mega-GOA",
"3": "Kilo-GOA",
"0": "GOA",
"-1": "Deci-GOA",
"-2": "Centi-GOA",
"-3": "Milli-GOA",
"-6": "Micro-GOA",
"-7": "Deci-Micro-GOA",
"-8": "Atomic-GOA"
};
var THRESHOLD = 1000;
function parseAmount(s) {
if (s == null || s === "") return { cur: "GOA", val: 0 };
if (typeof s === "number") return { cur: "GOA", val: s };
var t = String(s).trim();
var i = t.indexOf(":");
if (i < 0) return { cur: "GOA", val: parseFloat(t) || 0 };
return {
cur: t.slice(0, i) || "GOA",
val: parseFloat(t.slice(i + 1)) || 0
};
}
function fmtCoeff(v) {
if (!isFinite(v)) return "?";
var a = Math.abs(v);
if (Math.abs(a - Math.round(a)) < 1e-9) return String(Math.round(v));
var s = v.toFixed(4).replace(/\.?0+$/, "");
return s;
}
function baseStr(cur, val) {
if (!isFinite(val)) return String(cur) + ":?";
if (Math.abs(val - Math.round(val)) < 1e-9) return cur + ":" + Math.round(val);
return cur + ":" + String(val).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
}
/**
* @param {string|number} amount
* @param {object} [altMap]
* @returns {{ display: string, title: string, raw: string, usedAlt: boolean }}
*/
function format(amount, altMap) {
if (amount == null || amount === "") {
return { display: "—", title: "", raw: "", usedAlt: false };
}
// Prefer server-provided alt if caller passes object {amount, amount_alt}
if (typeof amount === "object" && amount) {
if (amount.amount_alt) {
var rawO = amount.amount || amount.amount_full || String(amount.amount_alt);
return {
display: String(amount.amount_alt),
title: amount.amount_full || rawO,
raw: String(rawO),
usedAlt: true
};
}
amount = amount.amount || amount.amount_full || "";
}
var alt = altMap || global.__GOA_ALT_UNITS || DEFAULT_ALT;
var p = parseAmount(amount);
var raw = baseStr(p.cur, p.val);
// Foreign currencies must not pick up Kilo-GOA / Mega-GOA labels
var baseName = (alt && alt["0"]) || p.cur;
var curU = String(p.cur || "").toUpperCase();
var baseU = String(baseName || "").toUpperCase();
if (curU && curU !== "GOA" && (baseU === "GOA" || baseU.indexOf("GOA") !== -1)) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
if (p.val === 0) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
if (Math.abs(p.val) < THRESHOLD) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
var scales = [];
Object.keys(alt || {}).forEach(function (k) {
var n = parseInt(k, 10);
if (!isNaN(n)) scales.push({ sc: n, name: String(alt[k]) });
});
scales.sort(function (a, b) { return b.sc - a.sc; });
var abs = Math.abs(p.val);
var chosen = null;
for (var i = 0; i < scales.length; i++) {
var unit = Math.pow(10, scales[i].sc);
if (!(unit > 0)) continue;
var coeff = abs / unit;
if (coeff >= 1) {
chosen = {
sc: scales[i].sc,
name: scales[i].name,
coeff: p.val >= 0 ? coeff : -coeff
};
break;
}
}
if (!chosen || chosen.sc === 0) {
return { display: raw, title: raw, raw: raw, usedAlt: false };
}
var short = fmtCoeff(chosen.coeff) + " " + chosen.name;
return {
display: short,
title: short + " (" + raw + ")",
raw: raw,
usedAlt: true
};
}
function setText(el, amount, altMap) {
if (!el) return;
var f = format(amount, altMap);
el.textContent = f.display;
if (f.title && f.title !== f.display) el.setAttribute("title", f.title);
else el.removeAttribute("title");
}
function setById(id, amount, altMap) {
setText(document.getElementById(id), amount, altMap);
}
/** Prefer amount_alt from stats row, else format amount. */
function pick(rowOrAmount, altMap) {
if (rowOrAmount && typeof rowOrAmount === "object") {
if (rowOrAmount.amount_alt) {
return format({
amount: rowOrAmount.amount,
amount_alt: rowOrAmount.amount_alt,
amount_full: rowOrAmount.amount_full
}, altMap);
}
return format(rowOrAmount.amount || rowOrAmount.total_amount, altMap);
}
return format(rowOrAmount, altMap);
}
function rememberAlt(stats) {
if (stats && stats.alt_unit_names && typeof stats.alt_unit_names === "object") {
global.__GOA_ALT_UNITS = stats.alt_unit_names;
}
}
global.GoaAmount = {
format: format,
setText: setText,
setById: setById,
pick: pick,
rememberAlt: rememberAlt,
DEFAULT_ALT: DEFAULT_ALT,
THRESHOLD: THRESHOLD
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -30,6 +30,22 @@
if (el) el.textContent = v == null || v === "" ? "—" : String(v);
}
function setAmt(id, rowOrAmt, altMap) {
var el = document.getElementById(id);
if (!el) return;
if (window.GoaAmount) {
var f = GoaAmount.pick(rowOrAmt, altMap);
el.textContent = f.display;
if (f.title && f.title !== f.display) el.setAttribute("title", f.title);
else el.removeAttribute("title");
} else {
var raw = rowOrAmt && typeof rowOrAmt === "object"
? (rowOrAmt.amount_alt || rowOrAmt.amount || "—")
: (rowOrAmt == null || rowOrAmt === "" ? "—" : String(rowOrAmt));
el.textContent = raw;
}
}
function showFallback(msg) {
var box = document.getElementById("stats");
var foot = document.getElementById("st-foot");
@ -68,6 +84,7 @@
})
.then(function (d) {
if (!d || !d.ok) throw new Error("bad stats");
if (window.GoaAmount) GoaAmount.rememberAlt(d);
var w = d.withdraws || {};
var h24 = w.last_24h || {};
var h7 = w.last_7d || {};
@ -78,10 +95,10 @@
var fwd = flow.withdraw || {};
set("st-accounts", ba.total != null ? ba.total : "—");
set("st-wallets", wl.unique_reserves != null ? wl.unique_reserves : "—");
set("st-incoming", fin.amount || flow.total_in || "—");
set("st-withdraw", fwd.amount || w.total_amount || "—");
set("st-24h", h24.amount || "GOA:0");
set("st-7d", h7.amount || "GOA:0");
setAmt("st-incoming", fin.amount_alt ? fin : (fin.amount || flow.total_in || "—"), d.alt_unit_names);
setAmt("st-withdraw", fwd.amount_alt ? fwd : (fwd.amount || w.total_amount || "—"), d.alt_unit_names);
setAmt("st-24h", h24.amount_alt ? h24 : (h24.amount || "GOA:0"), d.alt_unit_names);
setAmt("st-7d", h7.amount_alt ? h7 : (h7.amount || "GOA:0"), d.alt_unit_names);
var list = document.getElementById("st-recent");
if (list) {
@ -97,7 +114,13 @@
var li = document.createElement("li");
var amt = document.createElement("span");
amt.className = "amt";
amt.textContent = row.amount || "?";
if (window.GoaAmount) {
var af = GoaAmount.pick(row, d.alt_unit_names);
amt.textContent = af.display;
if (af.title && af.title !== af.display) amt.setAttribute("title", af.title);
} else {
amt.textContent = row.amount_alt || row.amount || "?";
}
var meta = document.createElement("span");
meta.className = "meta";
meta.textContent = fmtCest(row.at_unix, row.at || row.at_iso) || "";
@ -112,7 +135,8 @@
foot.className = "stats-foot";
foot.innerHTML =
'Source: <a href="' + BANK_INTRO + '">bank.hacktivism.ch</a>' +
(d.generated_at_human ? " · " + d.generated_at_human : "");
(d.generated_at_human ? " · " + d.generated_at_human : "") +
(d.source ? " · " + d.source : "");
}
})
.catch(function () {

View file

@ -0,0 +1,30 @@
[Unit]
Description=Taler landing stats (bank full scan + exchange/merchant)
Documentation=file:%h/src/koopa/koopa-admin-log/scripts/taler-landing/README.md
After=network-online.target
Wants=network-online.target
# Prefer after containers are up (ignore if unit names differ)
After=container-taler-hacktivism-bank.service container-taler-hacktivism.service
After=taler-bank-apps.service taler-merchant-apps.service
[Service]
Type=oneshot
Nice=10
TimeoutStartSec=480
# Do not keep "active" after run — timer may fire again cleanly
RemainAfterExit=no
Environment=TZ=Europe/Zurich
Environment=ADMIN_LOG=%h/src/koopa/koopa-admin-log
Environment=SECRETS_BANK=%h/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank
Environment=BANK_URL=http://127.0.0.1:9012
Environment=BANK_PUBLIC_URL=https://bank.hacktivism.ch
Environment=EXCHANGE_CONFIG_URL=https://exchange.hacktivism.ch/config
# All accounts except exchange (double-count of withdraw credits on exchange ledger)
Environment=SCAN_SKIP=exchange
Environment=TX_PAGE=500
Environment=MAX_TX_PAGES=0
Environment=ACCOUNTS_DELTA=-10000
ExecStart=%h/.local/bin/collect-landing-stats.sh
[Install]
WantedBy=default.target

View file

@ -0,0 +1,13 @@
[Unit]
Description=Timer · Taler landing stats (every 2 min)
Documentation=file:%h/src/koopa/koopa-admin-log/scripts/taler-landing/README.md
[Timer]
OnBootSec=90s
OnUnitActiveSec=2min
AccuracySec=20s
Persistent=true
Unit=taler-landing-stats.service
[Install]
WantedBy=timers.target

View file

@ -9,6 +9,7 @@
| `taler-shared/` | host **ensure-taler-apps** (post-container app start + auto-confirm) |
| `taler-sanity/` | host root checks (stack, settlement, helpers) |
| `taler-monitoring/` | **outside-in** public URL walk (`/config` → keys/terms/integration/webui) |
| `taler-landing/` | **hernani user systemd**: central landing `stats.json` (full bank scan + alt units) |
| `monitoring/` | host `/home/hernani/scripts` (tor relay stats) |
| `nym/` | **koopa-nym** build/up/status (nym.com nym-node) |
| `taler-wallet-cli/` | thin wrappers; **benchmarks live in** `../benchmarks/` |
@ -24,6 +25,11 @@ starts, **`taler-merchant-apps.service`** / **`taler-bank-apps.service`** run
`taler-shared/install-ensure-taler-apps.sh`). That runs the in-container
`start_base` + `start_*.sh` (and bank auto-confirm **2 s**).
Landing **stats.json** (bank / exchange / merchant intros) is refreshed by
**`taler-landing-stats.timer`** as user **hernani** (install:
`taler-landing/install-landing-stats-host.sh`). Full bank ledger scan + GOA
alt-unit fields; see `taler-landing/README.md`.
## Manual start model (all three)
1. **root** runs `/root/start_base_services_for_taler_*.sh`

View file

@ -16,13 +16,16 @@ BANK_USER="${BANK_USER:-explorer}"
ADMIN_USER="${ADMIN_USER:-admin}"
# Per-account transaction window (libeufin delta). Was -100 → systematically
# undercounted credits/withdraws on active accounts (broken public stats).
TX_DELTA="${TX_DELTA:--50000}"
# 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+).
MAX_SCAN_ACCOUNTS="${MAX_SCAN_ACCOUNTS:-500}"
# 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:--500}"
ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--10000}"
# curl timeout per account (deeper history needs more headroom)
TX_CURL_TIMEOUT="${TX_CURL_TIMEOUT:-25}"
TX_CURL_TIMEOUT="${TX_CURL_TIMEOUT:-45}"
export TZ="${TZ:-Europe/Zurich}"
PASS="${BANK_PASS:-}"
@ -218,15 +221,23 @@ fi
SCAN_OK=0
SCAN_EMPTY=0
SCAN_FAIL=0
# Scan customer accounts only (not exchange/admin — exchange credits would double-count)
# 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"
grep -Eve "^(admin|exchange|${BANK_USER})$" "$WORKDIR/usernames.txt" 2>/dev/null || true
} | awk 'NF && !seen[$0]++' | head -n "$MAX_SCAN_ACCOUNTS" >"$WORKDIR/scan-users.txt"
# 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 admin|exchange) continue ;; esac
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}' \
@ -568,7 +579,7 @@ cat >"$TMP" <<EOF
"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. admin+exchange accounts"
"note": "incoming=credits; withdraw=Taler withdrawal debits; excl. exchange only (admin+explorer+all users scanned)"
},
"withdraws": {
"count": ${N_WD:-0},

View file

@ -0,0 +1,112 @@
# taler-landing — central stats (hernani user systemd)
Public landing numbers on
- https://bank.hacktivism.ch/intro/ → `stats.json`
- https://exchange.hacktivism.ch/intro/ → `stats.json`
- https://taler.hacktivism.ch/intro/ → `stats.json`
are produced by **one host process** as user **`hernani`**, not by three unrelated in-container crons.
## Why host / user systemd
| Concern | Approach |
|--------|----------|
| **All accounts / all money** | Python full scan + tx pagination (not capped at 80 accounts) |
| **High ladder GOA** | `amount_alt` / UI alt units (Kilo-/Mega-/Peta-GOA) — tiles stay short |
| **No root cron** | `systemctl --user` as hernani + `loginctl enable-linger` |
| **Failure safety** | failed run only updates `stats-run.json`; last good `stats.json` stays |
## Install (on koopa as hernani)
```bash
cd ~/src/koopa/koopa-admin-log
./scripts/taler-landing/install-landing-stats-host.sh
# timer without login session:
sudo loginctl enable-linger hernani
# run once:
systemctl --user start taler-landing-stats.service
journalctl --user -u taler-landing-stats.service -n 50 --no-pager
systemctl --user list-timers 'taler-landing-stats*'
```
Units:
- `configs/systemd/user/taler-landing-stats.service` — oneshot collector
- `configs/systemd/user/taler-landing-stats.timer` — every **2 minutes** after boot
Installed paths:
| Path | Role |
|------|------|
| `~/.local/bin/collect-landing-stats.sh` | orchestrator |
| `~/.local/lib/taler-landing/*.py` | bank scan + alt enrich |
| `~/.local/state/taler-landing-stats/` | logs |
| `~/.config/systemd/user/taler-landing-stats.*` | user units |
## What the collector does
1. **Bank**`collect_bank_stats.py`
- admin token → list **all** accounts
- for each account (except `SCAN_SKIP`, default **`exchange`**) page through **all** transactions
- sum credits / Taler withdraws / other debits
- emit `amount` + `amount_alt` / `amount_full`
- `podman cp``taler-hacktivism-bank:/var/www/bank-landing/stats.json`
2. **Exchange** — run `landing-stats-exchange.sh` inside exchange container, then **enrich** alt fields on the host and write back.
3. **Merchant** — same for merchant container.
4. **Resources (all three)**`collect_container_resources.sh` runs
`mem-snapshot` **inside** each podman container (not host `/proc`):
container RSS (+ cgroup limit label), postgres/java/taler/nginx groups,
top-10 processes, loadavg. Merged into `performance.memory` /
`performance.loadavg` via `merge_resources.py`.
`exchange` is skipped in the bank **flow** scan so the same GOA is not counted once as customer withdraw and again as exchange credit. **Admin**, **explorer**, and every auto-account are included.
## Secrets
Preferred order:
1. `~/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-admin-password.txt`
2. `~/.config/taler-landing/bank-*-password.txt`
3. `podman exec taler-hacktivism-bank cat /root/bank-admin-password.txt`
Explorer password optional (shared-pool balance).
## Env overrides
| Env | Default | Meaning |
|-----|---------|---------|
| `BANK_URL` | `http://127.0.0.1:9012` | libeufin loopback |
| `SCAN_SKIP` | `exchange` | usernames excluded from flow |
| `TX_PAGE` | `500` | transactions page size |
| `MAX_TX_PAGES` | `0` | `0` = unlimited pages / account |
| `ACCOUNTS_DELTA` | `-10000` | account list window |
| `ADMIN_LOG` | `%h/src/koopa/koopa-admin-log` | refresh in-container scripts |
## UI alt names
`configs/shared/goa-amount.js` formats large amounts as e.g. `1.23 Mega-GOA` (tooltip = full `GOA:…`). Landings load it as `/intro/goa-amount.js` (deploy via `deploy-landings.sh`).
## Legacy in-container bank cron
`scripts/taler-bank/landing-stats.sh` remains a **fallback** inside the bank container. Once the hernani timer is healthy, disable the old in-container minutely cron to avoid races:
```bash
podman exec taler-hacktivism-bank crontab -l # inspect
# remove landing-stats.sh line if present
```
## Manual run
```bash
~/.local/bin/collect-landing-stats.sh
# or from checkout:
./scripts/taler-landing/collect-landing-stats.sh
curl -sS https://bank.hacktivism.ch/intro/stats.json | python3 -m json.tool | head
```

View file

@ -0,0 +1,287 @@
#!/usr/bin/env bash
# Central landing-stats collector for hernani@koopa (user systemd timer).
#
# - Bank: full account+ledger scan via collect_bank_stats.py → bank container
# - Exchange / merchant: existing in-container scripts, then amount_alt enrich
# - All three: container RSS / loadavg / top procs via mem-snapshot (podman exec)
# - Never wipes a good stats.json on failure (writes stats-run.json only)
#
# Install: scripts/taler-landing/install-landing-stats-host.sh
set -euo pipefail
export TZ="${TZ:-Europe/Zurich}"
export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin${PATH:+:$PATH}"
log() { printf '%s %s\n' "$(date -Iseconds)" "$*"; }
ROOT="$(cd "$(dirname "$0")" && pwd)"
# When installed to ~/.local/bin, libs live in ~/.local/lib/taler-landing
if [ -f "$ROOT/collect_bank_stats.py" ]; then
LIB="$ROOT"
elif [ -f "${HOME}/.local/lib/taler-landing/collect_bank_stats.py" ]; then
LIB="${HOME}/.local/lib/taler-landing"
elif [ -f "${HOME}/src/koopa/koopa-admin-log/scripts/taler-landing/collect_bank_stats.py" ]; then
LIB="${HOME}/src/koopa/koopa-admin-log/scripts/taler-landing"
else
LIB="$ROOT"
fi
ADMIN_LOG="${ADMIN_LOG:-${HOME}/src/koopa/koopa-admin-log}"
SECRETS_BANK="${SECRETS_BANK:-${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank}"
BANK_CTR="${BANK_CTR:-taler-hacktivism-bank}"
EX_CTR="${EX_CTR:-taler-hacktivism-exchange-ansible}"
MER_CTR="${MER_CTR:-taler-hacktivism}"
BANK_URL="${BANK_URL:-http://127.0.0.1:9012}"
BANK_PUBLIC_URL="${BANK_PUBLIC_URL:-https://bank.hacktivism.ch}"
EXCHANGE_CONFIG_URL="${EXCHANGE_CONFIG_URL:-https://exchange.hacktivism.ch/config}"
BANK_LANDING_IN="${BANK_LANDING_IN:-/var/www/bank-landing}"
EX_LANDING_IN="${EX_LANDING_IN:-/var/www/exchange-landing}"
MER_LANDING_IN="${MER_LANDING_IN:-/var/www/merchant-landing}"
WORKDIR="${LANDING_STATS_WORKDIR:-${XDG_RUNTIME_DIR:-/tmp}/taler-landing-stats}"
mkdir -p "$WORKDIR"
LOG_DIR="${LANDING_STATS_LOGDIR:-${HOME}/.local/state/taler-landing-stats}"
mkdir -p "$LOG_DIR"
PY="${PYTHON:-python3}"
ec_bank=0
ec_ex=0
ec_mer=0
# Resolve helper scripts (checkout or ~/.local/lib)
COLLECT_RES="$LIB/collect_container_resources.sh"
MERGE_RES="$LIB/merge_resources.py"
if [ ! -f "$COLLECT_RES" ] && [ -f "$ROOT/collect_container_resources.sh" ]; then
COLLECT_RES="$ROOT/collect_container_resources.sh"
fi
if [ ! -f "$MERGE_RES" ] && [ -f "$ROOT/merge_resources.py" ]; then
MERGE_RES="$ROOT/merge_resources.py"
fi
MEM_SRC="${MEM_SNAPSHOT_SRC:-$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh}"
merge_container_resources() {
local label="$1" ctr="$2" stats_file="$3"
[ -f "$stats_file" ] || return 0
if ! ctr_running "$ctr"; then
log "WARN: $label resources: $ctr not running"
return 1
fi
if [ ! -x "$COLLECT_RES" ] && [ -f "$COLLECT_RES" ]; then
chmod +x "$COLLECT_RES" 2>/dev/null || true
fi
if [ ! -f "$COLLECT_RES" ] || [ ! -f "$MERGE_RES" ]; then
log "WARN: $label resources: helpers missing ($COLLECT_RES / $MERGE_RES)"
return 1
fi
local resf="$WORKDIR/${label}-resources.json"
set +e
ADMIN_LOG="$ADMIN_LOG" MEM_SNAPSHOT_SRC="$MEM_SRC" \
bash "$COLLECT_RES" "$ctr" "$resf" >>"$LOG_DIR/${label}-resources.log" 2>&1
local ec=$?
set -e
if [ "$ec" -ne 0 ] || [ ! -s "$resf" ]; then
log "WARN: $label resources: collect failed (ec=$ec)"
return 1
fi
if ! "$PY" -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d.get("ok") and d.get("memory") else 1)' "$resf" 2>/dev/null; then
log "WARN: $label resources: bad payload"
return 1
fi
"$PY" "$MERGE_RES" "$stats_file" "$resf" >>"$LOG_DIR/${label}-resources.log" 2>&1
log "$label: resources merged (RSS + loadavg from $ctr)"
return 0
}
read_pass() {
local name="$1" f
for f in \
"${SECRETS_BANK}/${name}" \
"${HOME}/.config/taler-landing/${name}" \
"/run/user/$(id -u)/taler-landing/${name}"
do
if [ -r "$f" ]; then
tr -d '\n' <"$f"
return 0
fi
done
# container (hernani can podman exec root files inside owned containers)
if podman inspect -f '{{.State.Running}}' "$BANK_CTR" 2>/dev/null | grep -qx true; then
if podman exec "$BANK_CTR" test -r "/root/${name}" 2>/dev/null; then
podman exec "$BANK_CTR" cat "/root/${name}" 2>/dev/null | tr -d '\n'
return 0
fi
fi
return 1
}
ctr_running() {
podman inspect -f '{{.State.Running}}' "$1" 2>/dev/null | grep -qx true
}
publish_json() {
local ctr="$1" dest_dir="$2" src_stats="$3" src_run="$4"
if ! ctr_running "$ctr"; then
log "WARN: $ctr not running — skip publish $dest_dir"
return 1
fi
podman exec "$ctr" mkdir -p "$dest_dir" 2>/dev/null || true
if [ -f "$src_stats" ]; then
podman cp "$src_stats" "${ctr}:${dest_dir}/stats.json"
fi
if [ -f "$src_run" ]; then
podman cp "$src_run" "${ctr}:${dest_dir}/stats-run.json"
fi
}
# ---------------------------------------------------------------------------
# Bank — full scan (all accounts / all money except SCAN_SKIP)
# ---------------------------------------------------------------------------
collect_bank() {
log "bank: collect full stats via $LIB/collect_bank_stats.py"
local admin_pass explorer_pass
admin_pass="$(read_pass bank-admin-password.txt || true)"
explorer_pass="$(read_pass bank-explorer-password.txt || true)"
if [ -z "$admin_pass" ]; then
log "ERROR: bank admin password not found (secrets or container)"
printf '%s\n' '{"ok":false,"error":"no admin password","at_human":"'"$(date +"%Y-%m-%d %H:%M %Z")"'"}' \
>"$WORKDIR/bank-stats-run.json"
publish_json "$BANK_CTR" "$BANK_LANDING_IN" "" "$WORKDIR/bank-stats-run.json" || true
return 1
fi
# optional demo files from container
local demo_dir=""
if ctr_running "$BANK_CTR"; then
demo_dir="$WORKDIR/demo"
mkdir -p "$demo_dir"
podman cp "${BANK_CTR}:${BANK_LANDING_IN}/withdraw.uri" "$demo_dir/withdraw.uri" 2>/dev/null || true
podman cp "${BANK_CTR}:${BANK_LANDING_IN}/withdraw.amount" "$demo_dir/withdraw.amount" 2>/dev/null || true
podman cp "${BANK_CTR}:${BANK_LANDING_IN}/withdraw.created" "$demo_dir/withdraw.created" 2>/dev/null || true
fi
set +e
BANK_URL="$BANK_URL" \
BANK_PUBLIC_URL="$BANK_PUBLIC_URL" \
EXCHANGE_CONFIG_URL="$EXCHANGE_CONFIG_URL" \
BANK_ADMIN_PASS="$admin_pass" \
BANK_EXPLORER_PASS="$explorer_pass" \
DEMO_DIR="${demo_dir}" \
SCAN_SKIP="${SCAN_SKIP:-exchange}" \
TX_PAGE="${TX_PAGE:-500}" \
MAX_TX_PAGES="${MAX_TX_PAGES:-0}" \
ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--10000}" \
"$PY" "$LIB/collect_bank_stats.py" \
--out "$WORKDIR/bank-stats.json" \
--run-out "$WORKDIR/bank-stats-run.json" \
>>"$LOG_DIR/bank.log" 2>&1
ec_bank=$?
set -e
if [ "$ec_bank" -eq 0 ] && [ -f "$WORKDIR/bank-stats.json" ]; then
# Always attach in-container RSS/loadavg (not host /proc)
merge_container_resources bank "$BANK_CTR" "$WORKDIR/bank-stats.json" || true
publish_json "$BANK_CTR" "$BANK_LANDING_IN" \
"$WORKDIR/bank-stats.json" "$WORKDIR/bank-stats-run.json"
log "bank: OK → ${BANK_CTR}:${BANK_LANDING_IN}/stats.json"
else
log "ERROR: bank collect failed (ec=$ec_bank) — previous stats.json kept"
[ -f "$WORKDIR/bank-stats-run.json" ] || \
printf '%s\n' '{"ok":false,"error":"collect failed","at_human":"'"$(date +"%Y-%m-%d %H:%M %Z")"'"}' \
>"$WORKDIR/bank-stats-run.json"
publish_json "$BANK_CTR" "$BANK_LANDING_IN" "" "$WORKDIR/bank-stats-run.json" || true
fi
return "$ec_bank"
}
# ---------------------------------------------------------------------------
# Exchange / merchant — in-container generators + host enrich
# ---------------------------------------------------------------------------
run_incontainer_stats() {
local label="$1" ctr="$2" script_candidates="$3" landing="$4"
local script="" s
if ! ctr_running "$ctr"; then
log "WARN: $label: $ctr not running"
return 1
fi
# install latest script from admin-log if present
local host_src=""
case "$label" in
exchange)
host_src="$ADMIN_LOG/scripts/taler-exchange/landing-stats-exchange.sh"
;;
merchant)
host_src="$ADMIN_LOG/scripts/taler-merchant/landing-stats-merchant.sh"
;;
esac
if [ -n "$host_src" ] && [ -f "$host_src" ]; then
podman cp "$host_src" "${ctr}:/usr/local/bin/$(basename "$host_src")"
podman exec "$ctr" chmod 755 "/usr/local/bin/$(basename "$host_src")" 2>/dev/null || true
script="/usr/local/bin/$(basename "$host_src")"
else
for s in $script_candidates; do
if podman exec "$ctr" test -x "$s" 2>/dev/null || podman exec "$ctr" test -f "$s" 2>/dev/null; then
script="$s"
break
fi
done
fi
if [ -z "$script" ]; then
log "WARN: $label: no landing-stats script in $ctr"
return 1
fi
log "$label: run $script inside $ctr"
set +e
podman exec \
-e LANDING_DIR="$landing" \
-e TZ=Europe/Zurich \
-e PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$ctr" bash "$script" >>"$LOG_DIR/${label}.log" 2>&1
local ec=$?
set -e
if [ "$ec" -ne 0 ]; then
log "ERROR: $label stats failed (ec=$ec)"
return "$ec"
fi
# enrich with alt units + ensure resources (RSS/loadavg) on host
podman cp "${ctr}:${landing}/stats.json" "$WORKDIR/${label}-stats.json" 2>/dev/null || return 0
set +e
"$PY" "$LIB/enrich_stats_alt.py" "$WORKDIR/${label}-stats.json" \
--exchange-config "$EXCHANGE_CONFIG_URL" >>"$LOG_DIR/${label}.log" 2>&1
set -e
# Prefer fresh container snapshot (also fills gaps if in-container mem helper missing)
merge_container_resources "$label" "$ctr" "$WORKDIR/${label}-stats.json" || true
podman cp "$WORKDIR/${label}-stats.json" "${ctr}:${landing}/stats.json"
log "$label: OK + alt + resources → ${ctr}:${landing}/stats.json"
return 0
}
collect_exchange() {
run_incontainer_stats exchange "$EX_CTR" \
"/usr/local/bin/landing-stats-exchange.sh /usr/local/bin/landing-stats.sh" \
"$EX_LANDING_IN"
}
collect_merchant() {
run_incontainer_stats merchant "$MER_CTR" \
"/usr/local/bin/landing-stats-merchant.sh /usr/local/bin/landing-stats.sh" \
"$MER_LANDING_IN"
}
# ---------------------------------------------------------------------------
main() {
log "=== taler-landing-stats start (user=$(id -un) lib=$LIB) ==="
collect_bank || ec_bank=$?
collect_exchange || ec_ex=$?
collect_merchant || ec_mer=$?
log "=== done bank=$ec_bank exchange=$ec_ex merchant=$ec_mer ==="
# non-zero if bank failed (primary public flow numbers); soft on ex/mer
if [ "$ec_bank" -ne 0 ]; then
return 1
fi
return 0
}
main "$@"

View file

@ -0,0 +1,813 @@
#!/usr/bin/env python3
"""Full-scan bank landing stats (all accounts, all ledger money).
Run on koopa as hernani (or anywhere with admin API access). Writes stats.json
compatible with bank.hacktivism.ch/intro/ plus amount_alt fields for compact UI.
Env (selected):
BANK_URL default http://127.0.0.1:9012
BANK_ADMIN_USER default admin
BANK_ADMIN_PASS or BANK_ADMIN_PASS_FILE / pass via --admin-pass-file
BANK_EXPLORER_USER default explorer
BANK_EXPLORER_PASS optional (balance_explorer)
EXCHANGE_CONFIG_URL for alt_unit_names (default https://exchange.hacktivism.ch/config)
OUT output path (default stdout if -)
SCAN_SKIP comma usernames excluded from flow (default: exchange)
TX_PAGE page size for transactions delta (default 500)
MAX_TX_PAGES 0 = unlimited pages per account (default 0)
ACCOUNTS_DELTA GET /accounts?delta= (default -10000)
RECENT_WD_N recent withdraws (default 10)
TZ default Europe/Zurich
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from zoneinfo import ZoneInfo
# local import (same directory)
sys.path.insert(0, str(Path(__file__).resolve().parent))
from goa_amounts import ( # noqa: E402
DEFAULT_ALT,
enrich_stats_tree,
format_amount_alt,
load_alt_from_config,
parse_amount,
)
RESERVE_RE = re.compile(r"(?i)withdrawal\s+([A-Za-z0-9]+)")
def env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def read_pass_file(path: str) -> str:
p = Path(path)
if p.is_file():
return p.read_text(encoding="utf-8", errors="replace").strip()
return ""
def http_json(
url: str,
*,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
data: Optional[bytes] = None,
timeout: float = 30.0,
auth: Optional[Tuple[str, str]] = None,
) -> Tuple[int, Any, bytes]:
h = dict(headers or {})
if auth:
import base64
token = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode("ascii")
h["Authorization"] = f"Basic {token}"
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
code = getattr(resp, "status", 200) or 200
except urllib.error.HTTPError as e:
body = e.read() if e.fp else b""
code = e.code
except Exception as e:
return 0, None, str(e).encode()
if not body:
return code, None, body
try:
return code, json.loads(body.decode("utf-8", errors="replace")), body
except Exception:
return code, None, body
def measure_ms(url: str, timeout: float = 8.0) -> Tuple[Optional[int], str]:
t0 = time.perf_counter()
code = "000"
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp.read()
code = str(getattr(resp, "status", 200) or 200)
except urllib.error.HTTPError as e:
code = str(e.code)
except Exception:
return None, "000"
ms = int(round((time.perf_counter() - t0) * 1000))
if ms == 0:
ms = 1
return ms, code
def get_token(bank: str, user: str, password: str) -> str:
code, data, _ = http_json(
f"{bank}/accounts/{user}/token",
method="POST",
headers={"Content-Type": "application/json"},
data=b'{"scope":"readonly"}',
auth=(user, password),
timeout=15,
)
if code not in (200, 201) or not isinstance(data, dict):
raise RuntimeError(f"token failed for {user}: HTTP {code}")
tok = data.get("access_token") or data.get("token") or ""
if not tok:
raise RuntimeError(f"token empty for {user}")
return str(tok)
def list_all_accounts(bank: str, token: str, delta: int) -> List[str]:
"""List accounts; page with start if needed."""
names: List[str] = []
seen: Set[str] = set()
start: Optional[int] = None
pages = 0
max_pages = int(env("MAX_ACCOUNT_PAGES", "50") or "50")
while pages < max_pages:
pages += 1
q = f"delta={delta}"
if start is not None:
q += f"&start={start}"
code, data, raw = http_json(
f"{bank}/accounts?{q}",
headers={"Authorization": f"Bearer {token}"},
timeout=45,
)
if code == 204 or not data:
break
if code != 200:
raise RuntimeError(f"list accounts HTTP {code}: {raw[:200]!r}")
batch: List[Dict[str, Any]] = []
if isinstance(data, dict):
if isinstance(data.get("accounts"), list):
batch = data["accounts"]
elif isinstance(data.get("users"), list):
batch = data["users"]
elif isinstance(data, list):
batch = data
if not batch:
# flat object with username? try regex fallback
text = raw.decode("utf-8", errors="replace")
for m in re.finditer(r'"username"\s*:\s*"([^"]+)"', text):
u = m.group(1)
if u not in seen:
seen.add(u)
names.append(u)
break
min_row = None
for item in batch:
if not isinstance(item, dict):
continue
u = item.get("username") or item.get("name") or ""
if u and u not in seen:
seen.add(u)
names.append(str(u))
rid = item.get("row_id") or item.get("rowId")
if rid is not None:
try:
rid_i = int(rid)
min_row = rid_i if min_row is None else min(min_row, rid_i)
except Exception:
pass
if len(batch) < abs(delta):
break
if min_row is None:
break
# next older page
start = min_row
# avoid infinite loop on same start
if pages > 1 and len(names) == len(seen):
# if no growth and full page, still advance
pass
return names
def iter_transactions(
bank: str,
token: str,
username: str,
page: int,
max_pages: int,
) -> List[Dict[str, Any]]:
"""Return all transactions for account (paginated)."""
out: List[Dict[str, Any]] = []
start: Optional[int] = None
pages = 0
safety = max_pages if max_pages > 0 else 10_000
while pages < safety:
pages += 1
q = f"delta=-{abs(page)}"
if start is not None:
q += f"&start={start}"
code, data, raw = http_json(
f"{bank}/accounts/{urllib.parse.quote(username)}/transactions?{q}",
headers={"Authorization": f"Bearer {token}"},
timeout=float(env("TX_CURL_TIMEOUT", "45") or "45"),
)
if code in (204, 404) or not data:
break
if code != 200:
# soft-fail one account
break
txs: List[Dict[str, Any]] = []
if isinstance(data, dict) and isinstance(data.get("transactions"), list):
txs = data["transactions"]
elif isinstance(data, list):
txs = data
else:
# tolerate raw array-ish
break
if not txs:
break
min_row = None
for tx in txs:
if not isinstance(tx, dict):
continue
out.append(tx)
rid = tx.get("row_id") or tx.get("rowId")
if rid is not None:
try:
rid_i = int(rid)
min_row = rid_i if min_row is None else min(min_row, rid_i)
except Exception:
pass
if len(txs) < abs(page):
break
if min_row is None:
break
start = min_row
return out
def tx_fields(tx: Dict[str, Any]) -> Tuple[str, str, int, str]:
"""direction, amount, unix_ts, subject"""
direction = str(tx.get("direction") or "").lower()
amount = str(tx.get("amount") or "")
subject = str(tx.get("subject") or tx.get("description") or "")
ts = 0
when = tx.get("when") or tx.get("date") or tx.get("timestamp")
if isinstance(when, dict):
# Taler AbsoluteTime: t_s seconds or t_ms
if "t_s" in when:
try:
ts = int(when["t_s"])
except Exception:
ts = 0
elif "t_ms" in when:
try:
ts = int(int(when["t_ms"]) / 1000)
except Exception:
ts = 0
elif isinstance(when, (int, float)):
ts = int(when)
if ts > 10_000_000_000: # ms
ts //= 1000
elif tx.get("t_s") is not None:
try:
ts = int(tx["t_s"])
except Exception:
ts = 0
return direction, amount, ts, subject
def reserve_from_subject(subject: str) -> str:
m = RESERVE_RE.search(subject or "")
if m:
return re.sub(r"[^A-Za-z0-9]", "", m.group(1))
# fallback: last token
parts = (subject or "").split()
if parts:
return re.sub(r"[^A-Za-z0-9]", "", parts[-1])
return ""
def now_parts(tz_name: str) -> Tuple[int, str, str]:
tz = ZoneInfo(tz_name)
dt = datetime.now(tz)
unix = int(dt.timestamp())
iso = dt.strftime("%Y-%m-%dT%H:%M%z")
# +0200 → +02:00
if len(iso) >= 5 and iso[-5] in "+-" and ":" not in iso[-5:]:
iso = iso[:-2] + ":" + iso[-2:]
human = dt.strftime("%Y-%m-%d %H:%M %Z")
return unix, iso, human
def human_from_unix(ts: int, tz_name: str) -> Tuple[str, str]:
if not ts:
return "", ""
tz = ZoneInfo(tz_name)
dt = datetime.fromtimestamp(ts, tz)
iso = dt.strftime("%Y-%m-%dT%H:%M%z")
if len(iso) >= 5 and iso[-5] in "+-" and ":" not in iso[-5:]:
iso = iso[:-2] + ":" + iso[-2:]
human = dt.strftime("%Y-%m-%d %H:%M %Z")
return human, iso
def main() -> int:
ap = argparse.ArgumentParser(description="Collect full bank landing stats")
ap.add_argument("--bank", default=env("BANK_URL", "http://127.0.0.1:9012"))
ap.add_argument("--admin-user", default=env("BANK_ADMIN_USER", "admin"))
ap.add_argument("--admin-pass", default=env("BANK_ADMIN_PASS", ""))
ap.add_argument("--admin-pass-file", default=env("BANK_ADMIN_PASS_FILE", ""))
ap.add_argument("--explorer-user", default=env("BANK_EXPLORER_USER", "explorer"))
ap.add_argument("--explorer-pass", default=env("BANK_EXPLORER_PASS", ""))
ap.add_argument("--explorer-pass-file", default=env("BANK_EXPLORER_PASS_FILE", ""))
ap.add_argument(
"--exchange-config",
default=env("EXCHANGE_CONFIG_URL", "https://exchange.hacktivism.ch/config"),
)
ap.add_argument("--out", default=env("OUT", "-"))
ap.add_argument("--run-out", default=env("RUN_OUT", ""))
ap.add_argument(
"--skip",
default=env("SCAN_SKIP", "exchange"),
help="comma usernames excluded from flow scan (default: exchange)",
)
ap.add_argument("--tx-page", type=int, default=int(env("TX_PAGE", "500") or "500"))
ap.add_argument(
"--max-tx-pages",
type=int,
default=int(env("MAX_TX_PAGES", "0") or "0"),
help="0 = unlimited pages per account",
)
ap.add_argument(
"--accounts-delta",
type=int,
default=int(env("ACCOUNTS_DELTA", "-10000") or "-10000"),
)
ap.add_argument("--recent", type=int, default=int(env("RECENT_WD_N", "10") or "10"))
ap.add_argument("--public-base", default=env("BANK_PUBLIC_URL", "https://bank.hacktivism.ch"))
args = ap.parse_args()
tz_name = env("TZ", "Europe/Zurich") or "Europe/Zurich"
os.environ["TZ"] = tz_name
admin_pass = args.admin_pass or (
read_pass_file(args.admin_pass_file) if args.admin_pass_file else ""
)
explorer_pass = args.explorer_pass or (
read_pass_file(args.explorer_pass_file) if args.explorer_pass_file else ""
)
bank = args.bank.rstrip("/")
skip = {s.strip() for s in (args.skip or "").split(",") if s.strip()}
def write_run(ok: bool, err: Optional[str] = None) -> None:
if not args.run_out:
return
unix, iso, human = now_parts(tz_name)
payload = {
"ok": ok,
"at": iso,
"at_human": human,
"error": err,
}
Path(args.run_out).parent.mkdir(parents=True, exist_ok=True)
Path(args.run_out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
try:
if not admin_pass:
raise RuntimeError("no admin password (BANK_ADMIN_PASS or --admin-pass-file)")
token = get_token(bank, args.admin_user, admin_pass)
alt = load_alt_from_config(args.exchange_config)
if not alt:
alt = dict(DEFAULT_ALT)
accounts = list_all_accounts(bank, token, args.accounts_delta)
if not accounts:
raise RuntimeError("accounts list empty")
# ALL accounts except skip set (default: only exchange — avoid double-count)
scan_users = [u for u in accounts if u not in skip]
# ensure explorer is included when present
if args.explorer_user not in skip and args.explorer_user in accounts:
if args.explorer_user not in scan_users:
scan_users.append(args.explorer_user)
withdraws: List[Dict[str, Any]] = []
incomings: List[Dict[str, Any]] = []
total_in = Decimal(0)
total_wd = Decimal(0)
total_other = Decimal(0)
n_incoming = 0
scan_ok = 0
scan_empty = 0
scan_fail = 0
for uname in scan_users:
try:
txs = iter_transactions(
bank, token, uname, args.tx_page, args.max_tx_pages
)
except Exception:
scan_fail += 1
continue
if not txs:
scan_empty += 1
continue
scan_ok += 1
for tx in txs:
direction, amount, ts, subject = tx_fields(tx)
if not amount:
continue
try:
_cur, n = parse_amount(amount)
except Exception:
continue
low = (subject or "").lower()
if direction == "credit":
total_in += n
n_incoming += 1
incomings.append(
{
"kind": "incoming",
"amount": amount if ":" in amount else f"GOA:{amount}",
"at_unix": ts,
"account": uname,
"subject": subject,
}
)
elif direction == "debit" and "withdraw" in low:
total_wd += n
res = reserve_from_subject(subject)
withdraws.append(
{
"kind": "withdraw",
"amount": amount if ":" in amount else f"GOA:{amount}",
"at_unix": ts,
"account": uname,
"subject": subject,
"reserve": res,
}
)
elif direction == "debit":
total_other += n
withdraws.sort(key=lambda r: r.get("at_unix") or 0, reverse=True)
incomings.sort(key=lambda r: r.get("at_unix") or 0, reverse=True)
now_u, gen_iso, gen_human = now_parts(tz_name)
day = now_u - 86400
week = now_u - 7 * 86400
w24 = Decimal(0)
w7 = Decimal(0)
n24 = 0
n7 = 0
for w in withdraws:
ts = int(w.get("at_unix") or 0)
try:
_c, n = parse_amount(w.get("amount"))
except Exception:
n = Decimal(0)
if ts >= day:
w24 += n
n24 += 1
if ts >= week:
w7 += n
n7 += 1
wallets = sorted(
{w.get("reserve") for w in withdraws if w.get("reserve")}
)
accounts_with_wd = sorted(
{w.get("account") for w in withdraws if w.get("account")}
)
users_n = sum(1 for u in accounts if u not in ("admin", "exchange"))
def pack_amt(d: Decimal) -> Dict[str, Any]:
f = format_amount_alt(f"GOA:{d}", alt)
return {
"amount": f["amount"],
"amount_alt": f["amount_alt"],
"amount_full": f["amount_full"],
"value": f["value"],
"value_str": f["value_str"],
}
fin = pack_amt(total_in)
fwd = pack_amt(total_wd)
foth = pack_amt(total_other)
fout = pack_amt(total_wd + total_other)
p24 = pack_amt(w24)
p7 = pack_amt(w7)
last = withdraws[0] if withdraws else None
last_amt = None
last_at = last_iso = last_subj = None
last_unix = None
last_alt = None
if last:
lf = format_amount_alt(last["amount"], alt)
last_amt = lf["amount"]
last_alt = lf["amount_alt"]
last_unix = last.get("at_unix")
last_at, last_iso = human_from_unix(int(last_unix or 0), tz_name)
last_subj = last.get("subject")
recent = []
for w in withdraws[: max(0, args.recent)]:
f = format_amount_alt(w["amount"], alt)
at_h, at_iso = human_from_unix(int(w.get("at_unix") or 0), tz_name)
recent.append(
{
"kind": "withdraw",
"amount": f["amount"],
"amount_alt": f["amount_alt"],
"amount_full": f["amount_full"],
"at": at_h,
"at_iso": at_iso,
"at_unix": w.get("at_unix") or None,
"account": w.get("account"),
"reserve": w.get("reserve") or "",
}
)
recent_in = []
for row in incomings[:5]:
f = format_amount_alt(row["amount"], alt)
at_h, at_iso = human_from_unix(int(row.get("at_unix") or 0), tz_name)
recent_in.append(
{
"kind": "incoming",
"amount": f["amount"],
"amount_alt": f["amount_alt"],
"amount_full": f["amount_full"],
"at": at_h,
"at_iso": at_iso,
"at_unix": row.get("at_unix") or None,
"account": row.get("account"),
}
)
# explorer balance
balance = "GOA:0"
bal_alt = "GOA:0"
bal_full = "GOA:0"
if explorer_pass:
try:
etok = get_token(bank, args.explorer_user, explorer_pass)
code, data, _ = http_json(
f"{bank}/accounts/{args.explorer_user}",
headers={"Authorization": f"Bearer {etok}"},
timeout=12,
)
if code == 200 and isinstance(data, dict):
balance = str(
data.get("balance", {}).get("amount")
if isinstance(data.get("balance"), dict)
else data.get("amount") or data.get("balance") or "GOA:0"
)
# sometimes balance is object with amount
if isinstance(data.get("balance"), dict) and data["balance"].get(
"amount"
):
balance = str(data["balance"]["amount"])
bf = format_amount_alt(balance, alt)
balance, bal_alt, bal_full = (
bf["amount"],
bf["amount_alt"],
bf["amount_full"],
)
except Exception:
pass
else:
# try admin-read of explorer account
try:
code, data, _ = http_json(
f"{bank}/accounts/{args.explorer_user}",
headers={"Authorization": f"Bearer {token}"},
timeout=12,
)
if code == 200 and isinstance(data, dict):
if isinstance(data.get("balance"), dict):
balance = str(data["balance"].get("amount") or "GOA:0")
elif data.get("amount"):
balance = str(data["amount"])
bf = format_amount_alt(balance, alt)
balance, bal_alt, bal_full = (
bf["amount"],
bf["amount_alt"],
bf["amount_full"],
)
except Exception:
pass
pub = args.public_base.rstrip("/")
config_ms, config_http = measure_ms(f"{pub}/config")
int_ms, int_http = measure_ms(f"{pub}/taler-integration/config")
webui_ms, webui_http = measure_ms(f"{pub}/webui/")
if config_http != "200":
config_ms, config_http = measure_ms(f"{bank}/config")
if int_http != "200":
int_ms, int_http = measure_ms(f"{bank}/taler-integration/config")
# loadavg filled later by host merge from *inside* the bank container
# (host /proc would be wrong when this script runs on koopa outside podman)
loadavg = ""
total_pack = pack_amt(total_wd)
stats = {
"ok": True,
"currency": "GOA",
"timezone": tz_name,
"generated_at": gen_iso,
"generated_at_human": gen_human,
"generated_at_unix": now_u,
"source": "host collect_bank_stats.py (hernani systemd)",
"bank_url": bank,
"scan": {
"tx_page": args.tx_page,
"max_tx_pages": args.max_tx_pages,
"accounts_delta": args.accounts_delta,
"skip_users": sorted(skip),
"accounts_listed": len(accounts),
"accounts_scanned_ok": scan_ok,
"accounts_empty_tx": scan_empty,
"accounts_scan_fail": scan_fail,
"accounts_scanned": len(scan_users),
"note": "all accounts except skip_users; full tx pagination; empty includes HTTP 204",
},
"bank_accounts": {
"total": len(accounts),
"users": users_n,
"with_withdraws": len(accounts_with_wd),
},
"wallets": {
"unique_reserves": len(wallets),
"note": "unique reserve pubs from Taler withdrawals (one per wallet withdraw)",
},
"balance_explorer": balance,
"balance_explorer_alt": bal_alt,
"balance_explorer_full": bal_full,
"flow": {
"incoming": {
"label": "Incoming bank credits",
"count": n_incoming,
"amount": fin["amount"],
"amount_alt": fin["amount_alt"],
"amount_full": fin["amount_full"],
"value": fin["value"],
"value_str": fin["value_str"],
},
"withdraw": {
"label": "Taler withdrawals to wallets",
"count": len(withdraws),
"amount": fwd["amount"],
"amount_alt": fwd["amount_alt"],
"amount_full": fwd["amount_full"],
"value": fwd["value"],
"value_str": fwd["value_str"],
},
"other_out": {
"label": "Other debits (non-withdraw)",
"amount": foth["amount"],
"amount_alt": foth["amount_alt"],
"amount_full": foth["amount_full"],
"value": foth["value"],
"value_str": foth["value_str"],
},
"total_in": fin["amount"],
"total_in_alt": fin["amount_alt"],
"total_in_value": fin["value"],
"total_out": fout["amount"],
"total_out_alt": fout["amount_alt"],
"total_out_value": fout["value"],
"note": "incoming=credits; withdraw=Taler withdrawal debits; skip_users excluded (default exchange)",
},
"withdraws": {
"count": len(withdraws),
"total_amount": total_pack["amount"],
"total_amount_alt": total_pack["amount_alt"],
"total_amount_full": total_pack["amount_full"],
"total_value": total_pack["value"],
"total_value_str": total_pack["value_str"],
"last_amount": last_amt,
"last_amount_alt": last_alt,
"last_at": last_at,
"last_at_iso": last_iso,
"last_at_unix": last_unix,
"last_subject": last_subj,
"last_24h": {
"count": n24,
"amount": p24["amount"],
"amount_alt": p24["amount_alt"],
"amount_full": p24["amount_full"],
"value": p24["value"],
},
"last_7d": {
"count": n7,
"amount": p7["amount"],
"amount_alt": p7["amount_alt"],
"amount_full": p7["amount_full"],
"value": p7["value"],
},
},
"recent_withdraws": recent,
"recent_incoming": recent_in,
"demo": {
"uri": None,
"amount": None,
"created": None,
"withdrawal_id": None,
"status": None,
"ready": False,
},
"performance": {
"config_http": config_http,
"config_ms": config_ms,
"integration_http": int_http,
"integration_ms": int_ms,
"webui_http": webui_http,
"webui_ms": webui_ms,
"loadavg": loadavg,
"memory": {
"container_rss_human": "",
"container_rss_label": "",
"note": "filled by collect_container_resources.sh (in-container /proc + cgroup)",
},
},
"alt_unit_names": alt,
}
# pull demo withdraw files + memory from bank container if OUT is path and caller merged
# demo files optional via DEMO_DIR
demo_dir = env("DEMO_DIR", "")
if demo_dir:
dpath = Path(demo_dir)
uri = (dpath / "withdraw.uri").read_text().strip() if (dpath / "withdraw.uri").is_file() else ""
amt = (
(dpath / "withdraw.amount").read_text().strip()
if (dpath / "withdraw.amount").is_file()
else ""
)
created = (
(dpath / "withdraw.created").read_text().strip()
if (dpath / "withdraw.created").is_file()
else ""
)
if uri:
wid = uri.rstrip("/").split("/")[-1]
stats["demo"] = {
"uri": uri,
"amount": amt or None,
"created": created or None,
"withdrawal_id": wid,
"status": None,
"ready": True,
}
stats = enrich_stats_tree(stats, alt)
text = json.dumps(stats, indent=2, ensure_ascii=False) + "\n"
if args.out == "-" or not args.out:
sys.stdout.write(text)
else:
outp = Path(args.out)
outp.parent.mkdir(parents=True, exist_ok=True)
tmp = outp.with_suffix(outp.suffix + f".tmp.{os.getpid()}")
tmp.write_text(text, encoding="utf-8")
tmp.replace(outp)
write_run(True, None)
print(
f"ok accounts={len(accounts)} scanned={scan_ok} withdraws={len(withdraws)} "
f"total={total_pack['amount']} alt={total_pack['amount_alt']}",
file=sys.stderr,
)
return 0
except Exception as e:
write_run(False, str(e))
print(f"error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Snapshot loadavg + RSS groups inside a podman container (for landing stats).
# Usage: collect_container_resources.sh CONTAINER [OUT.json]
# Prints JSON to stdout (and writes OUT when given).
set -euo pipefail
CTR="${1:-}"
OUT="${2:-}"
if [ -z "$CTR" ]; then
echo "usage: $0 CONTAINER [OUT.json]" >&2
exit 2
fi
ADMIN_LOG="${ADMIN_LOG:-${HOME}/src/koopa/koopa-admin-log}"
MEM_SRC="${MEM_SNAPSHOT_SRC:-$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh}"
MEM_DST="${MEM_SNAPSHOT_DST:-/usr/local/lib/landing-mem-snapshot.sh}"
if ! podman inspect -f '{{.State.Running}}' "$CTR" 2>/dev/null | grep -qx true; then
echo "{\"ok\":false,\"error\":\"container not running: $CTR\"}"
exit 1
fi
if [ -f "$MEM_SRC" ]; then
podman exec "$CTR" mkdir -p "$(dirname "$MEM_DST")" 2>/dev/null || true
podman cp "$MEM_SRC" "${CTR}:${MEM_DST}"
fi
# Run emit inside container (needs /proc + cgroup of that container)
set +e
raw=$(podman exec "$CTR" bash -c '
set -e
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
if [ ! -f /usr/local/lib/landing-mem-snapshot.sh ]; then
echo "{\"ok\":false,\"error\":\"mem-snapshot helper missing\"}"
exit 1
fi
# shellcheck disable=SC1091
. /usr/local/lib/landing-mem-snapshot.sh
if ! declare -F mem_snapshot_emit >/dev/null 2>&1; then
# older helper without emit — synthesize
mem_snapshot_json
loadavg=""
[ -r /proc/loadavg ] && loadavg=$(awk "{print \$1\",\"\$2\",\"\$3}" /proc/loadavg)
printf "{\"ok\":true,\"source\":\"mem-snapshot-legacy\",\"loadavg\":%s,\"memory\":{%s}}\n" \
"\"$loadavg\"" "$MEM_JSON"
else
mem_snapshot_emit
fi
' 2>/tmp/landing-mem-err.$$)
ec=$?
set -e
if [ "$ec" -ne 0 ] || [ -z "$raw" ]; then
err=$(tr '\n' ' ' </tmp/landing-mem-err.$$ 2>/dev/null | head -c 200 || true)
rm -f /tmp/landing-mem-err.$$
printf '{"ok":false,"error":"podman exec failed: %s"}\n' "${err//\"/\'}"
exit 1
fi
rm -f /tmp/landing-mem-err.$$
# Validate JSON
if ! printf '%s' "$raw" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then
printf '{"ok":false,"error":"invalid json from container"}\n'
exit 1
fi
if [ -n "$OUT" ]; then
printf '%s\n' "$raw" >"$OUT"
fi
printf '%s\n' "$raw"

39
scripts/taler-landing/deploy-landings.sh Normal file → Executable file
View file

@ -14,10 +14,47 @@ ROOT=$(cd "$(dirname "$0")/../.." && pwd)
# allow override when files already on host /tmp
SRC_EX="${SRC_EX:-$ROOT/configs/exchange-landing}"
SRC_MER="${SRC_MER:-$ROOT/configs/merchant-landing}"
SRC_BANK="${SRC_BANK:-$ROOT/configs/bank-landing}"
SRC_QR="${SRC_QR:-$ROOT/configs/bank-landing/qrcode.min.js}"
SRC_GOA_AMT="${SRC_GOA_AMT:-$ROOT/configs/shared/goa-amount.js}"
C_EX=taler-hacktivism-exchange-ansible
C_MER=taler-hacktivism
C_BANK="${C_BANK:-taler-hacktivism-bank}"
# Prefer per-landing copy, then shared (keeps bank/exchange/merchant in sync)
resolve_goa_amount() {
local local_copy="$1"
if [ -f "$local_copy" ]; then
printf '%s' "$local_copy"
elif [ -f "$SRC_GOA_AMT" ]; then
printf '%s' "$SRC_GOA_AMT"
else
printf ''
fi
}
copy_goa_amount() {
local ctr="$1" dest="$2" src="$3"
src=$(resolve_goa_amount "$src")
if [ -n "$src" ] && [ -f "$src" ]; then
podman cp "$src" "${ctr}:${dest}/goa-amount.js"
echo " goa-amount.js ← $src"
else
echo " WARN: goa-amount.js missing for $ctr"
fi
}
echo "=== bank landing → $C_BANK :9013 (html + goa-amount) ==="
if podman inspect -f '{{.State.Running}}' "$C_BANK" 2>/dev/null | grep -qx true; then
podman exec "$C_BANK" mkdir -p /var/www/bank-landing
if [ -f "$SRC_BANK/index.html" ]; then
podman cp "$SRC_BANK/index.html" "$C_BANK:/var/www/bank-landing/index.html"
fi
copy_goa_amount "$C_BANK" /var/www/bank-landing "$SRC_BANK/goa-amount.js"
else
echo "WARN: $C_BANK not running — skip bank landing files"
fi
echo "=== exchange landing → $C_EX :9014 ==="
# nginx may be missing on exchange image
@ -30,6 +67,7 @@ podman cp "$SRC_EX/index.html" "$C_EX:/var/www/exchange-landing/index.html"
if [ -f "$SRC_QR" ]; then
podman cp "$SRC_QR" "$C_EX:/var/www/exchange-landing/qrcode.min.js"
fi
copy_goa_amount "$C_EX" /var/www/exchange-landing "$SRC_EX/goa-amount.js"
podman cp "$SRC_EX/nginx-landing.conf" "$C_EX:/etc/nginx/sites-available/exchange-landing"
podman exec "$C_EX" bash -c '
ln -sfn /etc/nginx/sites-available/exchange-landing /etc/nginx/sites-enabled/exchange-landing
@ -50,6 +88,7 @@ podman exec "$C_EX" bash -c '
echo "=== merchant landing → $C_MER :9015 ==="
podman exec "$C_MER" mkdir -p /var/www/merchant-landing
podman cp "$SRC_MER/index.html" "$C_MER:/var/www/merchant-landing/index.html"
copy_goa_amount "$C_MER" /var/www/merchant-landing "$SRC_MER/goa-amount.js"
podman cp "$SRC_MER/nginx-landing.conf" "$C_MER:/etc/nginx/sites-available/merchant-landing"
podman exec "$C_MER" bash -c '
ln -sfn /etc/nginx/sites-available/merchant-landing /etc/nginx/sites-enabled/merchant-landing

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Add amount_alt / amount_full fields to a landing stats.json in place."""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from goa_amounts import DEFAULT_ALT, enrich_stats_tree, load_alt_from_config # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("path", help="stats.json path")
ap.add_argument(
"--exchange-config",
default="https://exchange.hacktivism.ch/config",
)
ap.add_argument("-o", "--out", default="", help="default: overwrite path")
args = ap.parse_args()
p = Path(args.path)
data = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(data, dict) or not data.get("ok"):
print("skip: not ok stats", file=sys.stderr)
return 0
alt = load_alt_from_config(args.exchange_config) or dict(DEFAULT_ALT)
enrich_stats_tree(data, alt)
out = Path(args.out) if args.out else p
tmp = out.with_suffix(out.suffix + f".tmp.{os.getpid()}")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
tmp.replace(out)
print(f"enriched {out}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""GOA (and generic CUR:amount) alt-unit formatting for landing stats.
Matches exchange currency_specification.alt_unit_names:
0GOA, 3Kilo-GOA, 6Mega-GOA, and fractional scales.
Display prefers compact alt names for large values so landing tiles fit.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any, Dict, Optional, Tuple
# Default GOA ladder (same shape as exchange /config)
DEFAULT_ALT: Dict[str, str] = {
"24": "Yotta-GOA",
"21": "Zetta-GOA",
"18": "Exa-GOA",
"15": "Peta-GOA",
"12": "Tera-GOA",
"9": "Giga-GOA",
"6": "Mega-GOA",
"3": "Kilo-GOA",
"0": "GOA",
"-1": "Deci-GOA",
"-2": "Centi-GOA",
"-3": "Milli-GOA",
"-6": "Micro-GOA",
"-7": "Deci-Micro-GOA",
"-8": "Atomic-GOA",
}
# Use alt unit when |value| >= this (base units). Below: keep compact CUR:n.
ALT_THRESHOLD = Decimal("1000")
def parse_amount(s: Any) -> Tuple[str, Decimal]:
"""Parse 'GOA:12.5' or bare number → (currency, value)."""
if s is None:
return "GOA", Decimal(0)
if isinstance(s, (int, float, Decimal)):
return "GOA", Decimal(str(s))
text = str(s).strip()
if not text:
return "GOA", Decimal(0)
if ":" in text:
cur, rest = text.split(":", 1)
return (cur or "GOA").strip(), Decimal(rest.strip() or "0")
return "GOA", Decimal(text)
def fmt_coeff(v: Decimal) -> str:
if v == v.to_integral_value():
return format(int(v), "d")
# up to 4 significant fractional digits, strip trailing zeros
q = v.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
s = format(q, "f").rstrip("0").rstrip(".")
return s or "0"
def fmt_base(cur: str, val: Decimal) -> str:
if val == val.to_integral_value():
return f"{cur}:{int(val)}"
# preserve up to 8 fractional digits (Taler style)
s = format(val, "f").rstrip("0").rstrip(".")
return f"{cur}:{s}"
def format_amount_alt(
amount: Any,
alt: Optional[Dict[str, str]] = None,
*,
threshold: Decimal = ALT_THRESHOLD,
with_base: bool = False,
) -> Dict[str, Any]:
"""Return display fields for one amount.
Keys:
amount canonical CUR:n
amount_alt short display (e.g. "1.23 Mega-GOA")
amount_full "1.23 Mega-GOA (GOA:1230000)" when alt used
value float for JSON (may lose precision above 2^53 also value_str)
value_str exact decimal string
"""
alt = alt or DEFAULT_ALT
try:
cur, val = parse_amount(amount)
except (InvalidOperation, ValueError, ArithmeticError):
raw = str(amount or "")
return {
"amount": raw,
"amount_alt": raw or "",
"amount_full": raw or "",
"value": 0.0,
"value_str": "0",
}
base = fmt_base(cur, val)
value_str = format(val, "f").rstrip("0").rstrip(".") if val != val.to_integral_value() else str(int(val))
try:
value_f = float(val)
except Exception:
value_f = 0.0
base_name = alt.get("0") or cur
# Never rename foreign currencies with a GOA alt map (merchant dual CHF+GOA).
cur_u = (cur or "").upper()
base_u = str(base_name).upper()
if cur_u and cur_u != "GOA" and (base_u == "GOA" or base_u.endswith("-GOA") or "GOA" in base_u):
return {
"amount": base,
"amount_alt": base,
"amount_full": base,
"value": value_f,
"value_str": value_str,
}
if val == 0:
# Keep canonical CUR:0 (avoids "0 GOA" for empty foreign balances)
return {
"amount": base,
"amount_alt": base,
"amount_full": base,
"value": 0.0,
"value_str": "0",
}
scales = []
for k, name in alt.items():
try:
scales.append((int(k), str(name)))
except Exception:
continue
scales.sort(key=lambda x: -x[0])
absval = abs(val)
# Below threshold: short base form (saves noise on small demo amounts)
if absval < threshold:
# Prefer "GOA:12.5" style (matches existing landings) when small
return {
"amount": base,
"amount_alt": base,
"amount_full": base,
"value": value_f,
"value_str": value_str,
}
chosen = None
for sc, name in scales:
unit = Decimal(10) ** sc
if unit <= 0:
continue
coeff = absval / unit
if coeff >= 1:
chosen = (sc, name, coeff if val >= 0 else -coeff)
break
if chosen is None or chosen[0] == 0:
return {
"amount": base,
"amount_alt": base,
"amount_full": base,
"value": value_f,
"value_str": value_str,
}
sc, name, coeff = chosen
short = f"{fmt_coeff(coeff)} {name}"
full = f"{short} ({base})" if with_base or True else short
return {
"amount": base,
"amount_alt": short,
"amount_full": full,
"value": value_f,
"value_str": value_str,
}
def attach_alt(obj: Dict[str, Any], amount_key: str = "amount", alt: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Mutate obj: ensure amount_alt / amount_full from amount_key."""
if not isinstance(obj, dict):
return obj
src = obj.get(amount_key)
if src is None:
return obj
f = format_amount_alt(src, alt)
obj[amount_key] = f["amount"]
obj["amount_alt"] = f["amount_alt"]
obj["amount_full"] = f["amount_full"]
if "value" not in obj:
obj["value"] = f["value"]
obj["value_str"] = f["value_str"]
return obj
def load_alt_from_config(url: str, timeout: float = 8.0) -> Dict[str, str]:
"""GET exchange/bank /config → alt_unit_names (fallback DEFAULT_ALT)."""
import json
import urllib.request
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
except Exception:
return dict(DEFAULT_ALT)
au = None
cs = data.get("currency_specification")
if isinstance(cs, dict):
au = cs.get("alt_unit_names")
if not au and isinstance(data.get("currencies"), dict):
for _code, spec in data["currencies"].items():
if isinstance(spec, dict) and spec.get("alt_unit_names"):
au = spec["alt_unit_names"]
break
if not isinstance(au, dict) or "0" not in au:
return dict(DEFAULT_ALT)
return {str(k): str(v) for k, v in au.items()}
def enrich_stats_tree(data: Dict[str, Any], alt: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Walk common landing stats.json shapes and add amount_alt fields."""
alt = alt or DEFAULT_ALT
if not isinstance(data, dict):
return data
# bank-style
for path in (
("flow", "incoming"),
("flow", "withdraw"),
("flow", "other_out"),
("withdraws",),
("withdraws", "last_24h"),
("withdraws", "last_7d"),
):
cur: Any = data
ok = True
for p in path:
if not isinstance(cur, dict) or p not in cur:
ok = False
break
cur = cur[p]
if ok and isinstance(cur, dict):
if "amount" in cur:
attach_alt(cur, "amount", alt)
if "total_amount" in cur:
f = format_amount_alt(cur["total_amount"], alt)
cur["total_amount"] = f["amount"]
cur["total_amount_alt"] = f["amount_alt"]
cur["total_amount_full"] = f["amount_full"]
if "last_amount" in cur and cur["last_amount"]:
f = format_amount_alt(cur["last_amount"], alt)
cur["last_amount"] = f["amount"]
cur["last_amount_alt"] = f["amount_alt"]
if isinstance(data.get("flow"), dict):
fl = data["flow"]
for k in ("total_in", "total_out"):
if fl.get(k):
f = format_amount_alt(fl[k], alt)
fl[k] = f["amount"]
fl[f"{k}_alt"] = f["amount_alt"]
fl[f"{k}_full"] = f["amount_full"]
if data.get("balance_explorer"):
f = format_amount_alt(data["balance_explorer"], alt)
data["balance_explorer"] = f["amount"]
data["balance_explorer_alt"] = f["amount_alt"]
data["balance_explorer_full"] = f["amount_full"]
for key in ("recent_withdraws", "recent_incoming", "recent_activity"):
rows = data.get(key)
if isinstance(rows, list):
for row in rows:
if isinstance(row, dict) and row.get("amount"):
attach_alt(row, "amount", alt)
# exchange-style top-level amounts
for k in (
"wire_in_amount",
"withdraw_amount",
"coins_remaining_amount",
):
if data.get(k):
f = format_amount_alt(data[k], alt)
data[k] = f["amount"]
data[f"{k}_alt"] = f["amount_alt"]
data[f"{k}_full"] = f["amount_full"]
for key in ("by_denom", "denom_ladder"):
rows = data.get(key)
if isinstance(rows, list):
for row in rows:
if isinstance(row, dict) and row.get("value"):
f = format_amount_alt(row["value"], alt)
row["value"] = f["amount"]
row["value_alt"] = f["amount_alt"]
# merchant dual currency
for block in data.get("by_currency") or []:
if not isinstance(block, dict):
continue
for k in ("amount_sum", "amount_paid_sum"):
if block.get(k):
f = format_amount_alt(block[k], alt)
block[k] = f["amount"]
block[f"{k}_alt"] = f["amount_alt"]
block[f"{k}_full"] = f["amount_full"]
for block in data.get("recent_activity_by_currency") or []:
if not isinstance(block, dict):
continue
for row in block.get("items") or []:
if isinstance(row, dict) and row.get("amount"):
attach_alt(row, "amount", alt)
data["alt_unit_names"] = alt
return data

View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Install central landing-stats collector as hernani user systemd timer.
#
# On koopa:
# cd ~/src/koopa/koopa-admin-log
# ./scripts/taler-landing/install-landing-stats-host.sh
# systemctl --user start taler-landing-stats.service # one-shot now
# systemctl --user status taler-landing-stats.timer
#
# Requires: linger enabled for hernani (loginctl enable-linger hernani)
# so the timer runs without an interactive login.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
ADMIN_LOG="${ADMIN_LOG:-$ROOT}"
SRC="$ADMIN_LOG/scripts/taler-landing"
UNIT_SRC="$ADMIN_LOG/configs/systemd/user"
BIN_DST="${HOME}/.local/bin"
LIB_DST="${HOME}/.local/lib/taler-landing"
UNIT_DST="${HOME}/.config/systemd/user"
STATE_DST="${HOME}/.local/state/taler-landing-stats"
mkdir -p "$BIN_DST" "$LIB_DST" "$UNIT_DST" "$STATE_DST"
install -m 0755 "$SRC/collect-landing-stats.sh" "$BIN_DST/collect-landing-stats.sh"
install -m 0755 "$SRC/collect_bank_stats.py" "$LIB_DST/collect_bank_stats.py"
install -m 0755 "$SRC/enrich_stats_alt.py" "$LIB_DST/enrich_stats_alt.py"
install -m 0755 "$SRC/merge_resources.py" "$LIB_DST/merge_resources.py"
install -m 0755 "$SRC/collect_container_resources.sh" "$LIB_DST/collect_container_resources.sh"
install -m 0755 "$SRC/test-landing-stats.sh" "$BIN_DST/test-landing-stats.sh" 2>/dev/null || \
install -m 0755 "$SRC/test-landing-stats.sh" "$LIB_DST/test-landing-stats.sh"
install -m 0644 "$SRC/goa_amounts.py" "$LIB_DST/goa_amounts.py"
# mem-snapshot helper lives in taler-shared (copied into containers at collect time)
if [ -f "$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh" ]; then
install -m 0644 "$ADMIN_LOG/scripts/taler-shared/mem-snapshot.sh" \
"$LIB_DST/mem-snapshot.sh"
fi
# Wrapper always uses installed lib next to itself when LIB discovery works;
# also point ADMIN_LOG for in-container script refresh.
install -m 0644 "$UNIT_SRC/taler-landing-stats.service" "$UNIT_DST/"
install -m 0644 "$UNIT_SRC/taler-landing-stats.timer" "$UNIT_DST/"
# Rewrite ExecStart to installed binary if unit uses %h path — units already do.
systemctl --user daemon-reload
systemctl --user enable --now taler-landing-stats.timer
echo "Installed (user=$(id -un)):"
echo " $BIN_DST/collect-landing-stats.sh"
echo " $LIB_DST/{collect_bank_stats,enrich_stats_alt,goa_amounts}.py"
echo " $UNIT_DST/taler-landing-stats.{service,timer} (timer enabled)"
echo " logs: $STATE_DST/"
echo
if ! loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
echo "NOTE: enable linger so the timer survives logout:"
echo " sudo loginctl enable-linger $(id -un)"
echo
fi
echo "Run once now:"
echo " systemctl --user start taler-landing-stats.service"
echo " journalctl --user -u taler-landing-stats.service -n 40 --no-pager"
echo
echo "Optional secrets (if not readable via podman exec bank /root/…):"
echo " mkdir -p ~/.config/taler-landing"
echo " cp …/bank-admin-password.txt ~/.config/taler-landing/"
echo " cp …/bank-explorer-password.txt ~/.config/taler-landing/"
echo " chmod 600 ~/.config/taler-landing/*"

View file

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Merge container resource snapshot into landing stats.json performance block.
Usage:
merge_resources.py STATS.json RESOURCES.json [-o OUT.json]
RESOURCES shape (from mem_snapshot_emit / collect_container_resources.sh):
{ "ok": true, "loadavg": "0.1,0.2,0.3", "memory": { ... } }
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict
def compact_memory(mem: Dict[str, Any]) -> Dict[str, Any]:
"""Ensure human labels + short top cmd for UI width."""
if not isinstance(mem, dict):
return mem
# Prefer explicit label (with limit); else human
if not mem.get("container_rss_label"):
h = mem.get("container_rss_human") or ""
lim = mem.get("cgroup_limit_human")
if lim:
mem["container_rss_label"] = f"{h} / {lim}"
else:
mem["container_rss_label"] = h
tops = mem.get("top")
if isinstance(tops, list):
for t in tops:
if not isinstance(t, dict):
continue
cmd = str(t.get("cmd") or "")
if len(cmd) > 72:
t["cmd_full"] = cmd
t["cmd"] = cmd[:69] + ""
# ensure human present
if not t.get("rss_human") and t.get("rss_bytes") is not None:
try:
b = int(t["rss_bytes"])
if b < 1024:
t["rss_human"] = f"{b} B"
elif b < 1048576:
t["rss_human"] = f"{b/1024:.1f} KiB"
elif b < 1073741824:
t["rss_human"] = f"{b/1048576:.1f} MiB"
else:
t["rss_human"] = f"{b/1073741824:.2f} GiB"
except Exception:
pass
return mem
def merge(stats: Dict[str, Any], resources: Dict[str, Any]) -> Dict[str, Any]:
perf = stats.setdefault("performance", {})
if not isinstance(perf, dict):
perf = {}
stats["performance"] = perf
if resources.get("loadavg"):
perf["loadavg"] = resources["loadavg"]
perf["loadavg_source"] = "container"
mem = resources.get("memory")
if isinstance(mem, dict) and mem:
perf["memory"] = compact_memory(dict(mem))
perf["memory"]["source"] = resources.get("source") or "mem-snapshot"
return stats
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("stats")
ap.add_argument("resources")
ap.add_argument("-o", "--out", default="")
args = ap.parse_args()
stats = json.loads(Path(args.stats).read_text(encoding="utf-8"))
resources = json.loads(Path(args.resources).read_text(encoding="utf-8"))
if not isinstance(stats, dict) or not stats.get("ok"):
print("skip: stats not ok", file=sys.stderr)
return 1
if not isinstance(resources, dict) or resources.get("ok") is False:
print("skip: resources not ok", file=sys.stderr)
return 1
merge(stats, resources)
out = Path(args.out) if args.out else Path(args.stats)
tmp = out.with_suffix(out.suffix + f".tmp.{os.getpid()}")
tmp.write_text(json.dumps(stats, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
tmp.replace(out)
mem = (stats.get("performance") or {}).get("memory") or {}
print(
f"merged memory container={mem.get('container_rss_human')} "
f"pg={mem.get('postgres_rss_human')} loadavg={stats.get('performance', {}).get('loadavg')}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,249 @@
#!/usr/bin/env bash
# Outside-in checks for bank / exchange / merchant landing stats + alt UI assets.
# Usage: ./test-landing-stats.sh
# Exit 0 only if all critical checks pass.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
PY="${PYTHON:-python3}"
BANK="${BANK_PUBLIC:-https://bank.hacktivism.ch}"
EX="${EXCHANGE_PUBLIC:-https://exchange.hacktivism.ch}"
MER="${MERCHANT_PUBLIC:-https://taler.hacktivism.ch}"
fail=0
pass=0
note() { printf ' %s\n' "$*"; }
ok() { pass=$((pass + 1)); printf ' [OK] %s\n' "$*"; }
bad() { fail=$((fail + 1)); printf ' [FAIL] %s\n' "$*"; }
http_code() {
curl -skS -m 15 -o /tmp/landing-test.body -w '%{http_code}' "$1" 2>/dev/null || echo 000
}
echo "=== local repo checks ==="
for f in \
configs/shared/goa-amount.js \
configs/bank-landing/goa-amount.js \
configs/exchange-landing/goa-amount.js \
configs/merchant-landing/goa-amount.js \
configs/bank-landing/index.html \
configs/exchange-landing/index.html \
configs/merchant-landing/index.html \
scripts/taler-landing/goa_amounts.py \
scripts/taler-landing/collect_bank_stats.py
do
if [ -f "$ROOT/$f" ]; then ok "file $f"
else bad "missing $f"
fi
done
for html in bank-landing exchange-landing merchant-landing; do
if grep -q 'goa-amount.js' "$ROOT/configs/$html/index.html" \
&& grep -q 'GoaAmount' "$ROOT/configs/$html/index.html"; then
ok "$html index wires GoaAmount"
else
bad "$html index missing goa-amount / GoaAmount"
fi
if grep -q 'Fallback if goa-amount.js' "$ROOT/configs/$html/index.html"; then
ok "$html has offline fallback"
else
bad "$html missing offline GoaAmount fallback"
fi
done
echo
echo "=== unit: goa_amounts enrich (all three shapes) ==="
if "$PY" - <<PY
import sys
sys.path.insert(0, "$ROOT/scripts/taler-landing")
from goa_amounts import enrich_stats_tree, DEFAULT_ALT
bank = {
"ok": True,
"balance_explorer": "GOA:40614.67",
"withdraws": {"total_amount": "GOA:120944.66", "last_24h": {"amount": "GOA:9494.66"}},
"flow": {"withdraw": {"amount": "GOA:120944.66"}, "incoming": {"amount": "GOA:1000"}},
"recent_withdraws": [{"amount": "GOA:4503599627370495"}],
}
enrich_stats_tree(bank, DEFAULT_ALT)
assert "Kilo-GOA" in bank["withdraws"]["total_amount_alt"]
assert "Peta-GOA" in bank["recent_withdraws"][0]["amount_alt"]
ex = {
"ok": True,
"wire_in_amount": "GOA:1267008731299764.85",
"withdraw_amount": "GOA:1783661.57",
"coins_remaining_amount": "GOA:0",
"by_denom": [{"value": "GOA:1000"}],
}
enrich_stats_tree(ex, DEFAULT_ALT)
assert "Peta-GOA" in ex["wire_in_amount_alt"]
assert "Mega-GOA" in ex["withdraw_amount_alt"]
assert ex["coins_remaining_amount_alt"] in ("GOA:0", "0 GOA")
mer = {
"ok": True,
"by_currency": [
{"currency": "GOA", "amount_sum": "GOA:294.05", "amount_paid_sum": "GOA:294.05"},
{"currency": "CHF", "amount_sum": "CHF:64000", "amount_paid_sum": "CHF:64000"},
],
"recent_activity_by_currency": [
{"currency": "GOA", "items": [{"amount": "GOA:2500"}]},
],
}
enrich_stats_tree(mer, DEFAULT_ALT)
# CHF must NOT become Kilo-GOA
assert "GOA" not in mer["by_currency"][1]["amount_paid_sum_alt"] or mer["by_currency"][1]["amount_paid_sum_alt"].startswith("CHF")
assert mer["by_currency"][1]["amount_paid_sum_alt"].startswith("CHF")
assert "Kilo-GOA" in mer["recent_activity_by_currency"][0]["items"][0]["amount_alt"]
print("unit ok")
PY
then ok "enrich shapes bank/exchange/merchant"
else bad "enrich unit failed"
fi
echo
echo "=== unit: merge_resources + mem labels ==="
if "$PY" - <<PY
import json, sys, tempfile, os
sys.path.insert(0, "$ROOT/scripts/taler-landing")
# import as script module path
import importlib.util
spec = importlib.util.spec_from_file_location("merge_resources", "$ROOT/scripts/taler-landing/merge_resources.py")
mr = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mr)
stats = {"ok": True, "performance": {"config_ms": 12, "memory": {"container_rss_human": "—"}}}
res = {
"ok": True,
"loadavg": "0.5,0.4,0.3",
"source": "mem-snapshot",
"memory": {
"container_rss_bytes": 726663168,
"container_rss_human": "693.4 MiB",
"cgroup_limit_bytes": 2147483648,
"cgroup_limit_human": "2.00 GiB",
"postgres_rss_human": "281.1 MiB",
"postgres_n": 17,
"java_rss_human": "353.7 MiB",
"java_n": 2,
"top": [{"rss_bytes": 367312896, "rss_human": "350.3 MiB", "comm": "java",
"cmd": "java -classpath /usr/lib/libeufin-bank-all.jar " + ("x" * 120)}],
},
}
mr.merge(stats, res)
mem = stats["performance"]["memory"]
assert mem["container_rss_label"].startswith("693.4 MiB")
assert "/ 2.00 GiB" in mem["container_rss_label"]
assert stats["performance"]["loadavg"] == "0.5,0.4,0.3"
assert len(mem["top"][0]["cmd"]) <= 75
assert mem["top"][0].get("cmd_full")
print("merge ok", mem["container_rss_label"])
PY
then ok "merge_resources compact labels"
else bad "merge_resources unit failed"
fi
# mem-snapshot syntax
if bash -n "$ROOT/scripts/taler-shared/mem-snapshot.sh" \
&& bash -n "$ROOT/scripts/taler-landing/collect_container_resources.sh"; then
ok "mem-snapshot + collect_container_resources bash -n"
else
bad "bash -n resources helpers"
fi
echo
echo "=== public HTTPS (live stack) ==="
for name_base in "bank|$BANK" "exchange|$EX" "merchant|$MER"; do
name="${name_base%%|*}"
base="${name_base#*|}"
echo "-- $name ($base) --"
code=$(http_code "$base/intro/")
if [ "$code" = "200" ]; then ok "$name /intro/ HTTP 200"
else bad "$name /intro/ HTTP $code"
fi
# live HTML may lag deploy — warn only if missing script after we expect it
if grep -q 'goa-amount.js' /tmp/landing-test.body 2>/dev/null; then
ok "$name live HTML references goa-amount.js"
else
note "[WARN] $name live HTML has no goa-amount.js yet (deploy pending)"
fi
code=$(http_code "$base/intro/stats.json")
if [ "$code" = "200" ] && "$PY" -c 'import json,sys; d=json.load(open("/tmp/landing-test.body")); sys.exit(0 if d.get("ok") else 1)'; then
gen=$("$PY" -c 'import json; d=json.load(open("/tmp/landing-test.body")); print(d.get("generated_at_human") or d.get("generated_at") or "?")')
ok "$name stats.json ok (gen=$gen)"
# enrich live payload
if "$PY" "$ROOT/scripts/taler-landing/enrich_stats_alt.py" /tmp/landing-test.body -o /tmp/landing-test.enriched.json >/dev/null 2>&1; then
ok "$name live stats enrichable with amount_alt"
else
bad "$name live stats enrich failed"
fi
# resources / memory block
if "$PY" - <<'PY'
import json, sys
d = json.load(open("/tmp/landing-test.body"))
p = d.get("performance") or {}
mem = p.get("memory") or {}
fail = []
if not p:
fail.append("no performance")
# latency probes differ per site but config_ms is common
if p.get("config_ms") is None and p.get("keys_ms") is None:
fail.append("no latency ms")
ctr = mem.get("container_rss_human") or ""
if not ctr or ctr in ("—", "-", "0"):
fail.append("container_rss_human missing")
if not isinstance(mem.get("top"), list) or len(mem.get("top") or []) < 1:
fail.append("top processes empty")
# role groups should exist as keys
for k in ("postgres_rss_human", "nginx_rss_human"):
if k not in mem:
fail.append("missing " + k)
if fail:
print("resources issues:", ", ".join(fail))
sys.exit(1)
print("resources ok container=", ctr, "top_n=", len(mem.get("top") or []),
"loadavg=", p.get("loadavg"))
sys.exit(0)
PY
then ok "$name performance.memory present (RSS + top)"
else bad "$name performance.memory incomplete"
fi
# merge dry-run on live stats (re-apply compact labels)
if "$PY" "$ROOT/scripts/taler-landing/merge_resources.py" /tmp/landing-test.body \
<("$PY" -c 'import json; d=json.load(open("/tmp/landing-test.body")); p=d.get("performance") or {}; print(json.dumps({"ok":True,"loadavg":p.get("loadavg") or "","memory":p.get("memory") or {}}))') \
-o /tmp/landing-test.resmerged.json 2>/dev/null; then
ok "$name resource merge on live payload"
else
# fallback without process substitution for macOS bash
"$PY" -c 'import json; d=json.load(open("/tmp/landing-test.body")); p=d.get("performance") or {}; json.dump({"ok":True,"loadavg":p.get("loadavg") or "","memory":p.get("memory") or {}}, open("/tmp/landing-test.res.json","w"))'
if "$PY" "$ROOT/scripts/taler-landing/merge_resources.py" /tmp/landing-test.body /tmp/landing-test.res.json -o /tmp/landing-test.resmerged.json 2>/dev/null; then
ok "$name resource merge on live payload"
else
bad "$name resource merge failed"
fi
fi
else
bad "$name stats.json HTTP $code / not ok"
fi
code=$(http_code "$base/intro/stats-run.json")
if [ "$code" = "200" ]; then ok "$name stats-run.json HTTP 200"
else bad "$name stats-run.json HTTP $code"
fi
code=$(http_code "$base/intro/goa-amount.js")
if [ "$code" = "200" ] && grep -q 'GoaAmount' /tmp/landing-test.body; then
ok "$name goa-amount.js live"
else
note "[WARN] $name goa-amount.js not live yet (HTTP $code) — deploy-landings.sh needed"
fi
done
echo
echo "=== result: pass=$pass fail=$fail ==="
if [ "$fail" -gt 0 ]; then
exit 1
fi
exit 0

View file

@ -126,6 +126,20 @@ Also: coin counts (in circulation / spent), status histogram, Δ vs previous sna
History TSV: `$METRICS_DIR/coins-history.tsv`. Map loaded from
`${EXCHANGE_PUBLIC}/config``currency_specification.alt_unit_names`.
### Final statistics dashboard (e2e · ladder)
At the end of **e2e** and **ladder**, a boxed **FINAL STATISTICS** block prints:
| Section | Content |
|---------|---------|
| **Coins** | in circulation / spent counts + amounts + denoms (alt names) |
| **Money flow** | withdrawn events/amount, spent (paid), net (wd spent) |
| **Tendency** | coin/amount trend over snaps (↑↓→), slope, last history rows |
| **Performance** | min / p50 / avg / max per phase; first-half vs second-half latency |
| **Load** | host loadavg + mem before/after; per-container RSS/CPU; tendencies |
Successful withdraw/pay steps append to `flow-withdrawn.txt` / `flow-spent.txt` (ladder also uses its TSVs).
## Load / memory (inside · e2e · ladder)
Host **loadavg**, **RAM used/avail**, and per-container **RSS / CPU / block I/O**

View file

@ -152,18 +152,14 @@ e2e_finish() {
fi
E2E_REPORTED=1
print_balances
# Final coin inventory + host/container load
# Final coin inventory + host/container load + statistics dashboard
if [ -n "${METRICS_DIR:-}" ]; then
section "metrics · e2e coins final"
metrics_report_coins "e2e-end" || true
fi
if [ -n "${METRICS_DIR:-}" ] && [ "${METRICS_LOAD:-1}" != "0" ]; then
metrics_report_load "${METRICS_DIR}/load-after.json" "e2e-end" || true
if [ -f "${METRICS_DIR}/load-before.json" ] && [ -f "${METRICS_DIR}/load-after.json" ]; then
section "metrics · e2e load delta"
metrics_print_load_delta "${METRICS_DIR}/load-before.json" "${METRICS_DIR}/load-after.json" || true
if [ "${METRICS_LOAD:-1}" != "0" ]; then
metrics_report_load "${METRICS_DIR}/load-after.json" "e2e-end" || true
fi
metrics_print_overall "e2e overall" || true
metrics_print_overall "e2e final" || true
fi
summary || true
return "$code"
@ -798,6 +794,7 @@ else:
done
metrics_report_coins "after-ATM-${tag}" || true
if [ "$ok_bal" = "1" ]; then
metrics_record_flow withdrawn "$WITHDRAW_AMT" || true
return 0
fi
# Bank side often already confirmed — treat as timing lag, not hard fail
@ -897,6 +894,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p
' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then
ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)"
metrics_report_coins "after-pay-${tag}" || true
metrics_record_flow spent "$PAY_AMT" || true
return 0
fi
done
@ -1017,6 +1015,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p
' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then
ok "shop $pname" "payment settled ($pamt · order $OID)"
metrics_report_coins "after-shop-${tag}" || true
metrics_record_flow spent "$pamt" || true
return 0
fi
fi
@ -1024,6 +1023,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p
|| grep -qiE 'done|paid|success|Payment' "$SCRATCH/pay-$tag.out" 2>/dev/null; then
ok "shop $pname" "payment settled via wallet tx ($pamt · order $OID)"
metrics_report_coins "after-shop-${tag}" || true
metrics_record_flow spent "$pamt" || true
return 0
fi
done

View file

@ -483,58 +483,138 @@ for AMT in "$@"; do
-H "Authorization: Bearer ${TOK}" -H 'Content-Type: application/json' -d '{}' \
"${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm"
}
extract_rpub() {
# Prefer *last* match (current withdraw), not first (stale from older accepts/tx).
python3 -c '
import re, sys, json
paths = sys.argv[1:]
found = []
blob = ""
for p in paths:
try:
blob += open(p, errors="replace").read() + "\n"
except Exception:
pass
for pat in (
r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]+)\"",
r"\"reservePub\"\s*:\s*\"([A-Z0-9]+)\"",
r"reserve_pub[\"\s:=]+([A-Z0-9]{40,})",
r"reservePub[\"\s:=]+([A-Z0-9]{40,})",
r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})",
):
found.extend(m.group(1) for m in re.finditer(pat, blob, re.I))
def walk(o):
# Collect reserve_pub candidates for *this* withdrawal (WID + amount).
# Cumulative wallets re-print old reserves in accept/tx dumps — never trust a single "last" blindly.
# Prints unique pubs one per line, preferred order first.
extract_rpubs_for_wid() {
python3 - "$WID" "$AMT" "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json" "$SCRATCH/used-rpubs.txt" <<'PY'
import json, re, sys
from pathlib import Path
wid = sys.argv[1]
amt = sys.argv[2]
paths = sys.argv[3:5]
used_path = sys.argv[5]
used = set()
if Path(used_path).is_file():
used = {ln.strip() for ln in open(used_path) if ln.strip()}
def walk_collect(o, bag, ctx=None):
ctx = dict(ctx or {})
if isinstance(o, dict):
for k, v in o.items():
if k.lower() in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40:
found.append(v)
walk(v)
kl = str(k).lower()
if kl in ("withdrawal_id", "withdraw_id", "wopid", "id") and isinstance(v, str):
ctx["id"] = v
if kl in ("amount", "rawamount", "instructedamount") and isinstance(v, str):
ctx["amount"] = v
if kl in ("taler_withdraw_uri", "talerwithdrawuri", "uri") and isinstance(v, str):
ctx["uri"] = v
if kl in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40:
bag.append((v, dict(ctx)))
walk_collect(v, bag, ctx)
elif isinstance(o, list):
for i in o:
walk(i)
for line in blob.splitlines():
line = line.strip()
if not line.startswith("{"):
continue
walk_collect(i, bag, ctx)
raw_pubs = [] # ordered as found
scored = [] # (score, pub) higher better
blob_all = ""
for p in paths:
try:
walk(json.loads(line))
blob_all += open(p, errors="replace").read() + "\n"
except Exception:
pass
if found:
print(found[-1])
' "$@" 2>/dev/null || true
# 1) full JSON objects in files
for p in paths:
try:
t = open(p, errors="replace").read()
except Exception:
continue
# whole-file JSON
for m in re.finditer(r"\{", t):
try:
o = json.loads(t[m.start():])
except Exception:
continue
bag = []
walk_collect(o, bag)
for pub, ctx in bag:
raw_pubs.append(pub)
score = 0
cid = str(ctx.get("id") or "")
camt = str(ctx.get("amount") or "")
curi = str(ctx.get("uri") or "")
if wid and wid in cid:
score += 100
if wid and wid in curi:
score += 80
if amt and (camt == amt or camt.endswith(amt.split(":", 1)[-1])):
score += 40
if pub in used:
score -= 200
scored.append((score, pub))
# 2) regex fallback on accept file only (more current)
try:
acc = open(paths[0], errors="replace").read()
except Exception:
acc = ""
for pat in (
r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]{40,})\"",
r"\"reservePub\"\s*:\s*\"([A-Z0-9]{40,})\"",
r"reserve_pub[\"\s:=]+([A-Z0-9]{40,})",
):
for m in re.finditer(pat, acc, re.I):
pub = m.group(1)
raw_pubs.append(pub)
score = 10
# proximity to WID in accept output
window = acc[max(0, m.start() - 400) : m.end() + 400]
if wid and wid in window:
score += 100
if amt and amt in window:
score += 30
if pub in used:
score -= 200
scored.append((score, pub))
# prefer high score, then later occurrence
order = []
seen = set()
for score, pub in sorted(scored, key=lambda x: (-x[0],), reverse=False):
# sort by score desc: use reverse sorted
pass
for score, pub in sorted(scored, key=lambda x: x[0], reverse=True):
if pub in seen or pub in used:
continue
seen.add(pub)
order.append(pub)
# append unused raw in reverse (newest-ish)
for pub in reversed(raw_pubs):
if pub in seen or pub in used:
continue
seen.add(pub)
order.append(pub)
for pub in order:
print(pub)
PY
}
mark_rpub_used() {
local p="$1"
[ -n "$p" ] || return 0
mkdir -p "$SCRATCH" 2>/dev/null || true
grep -qxF "$p" "$SCRATCH/used-rpubs.txt" 2>/dev/null || echo "$p" >>"$SCRATCH/used-rpubs.txt"
}
force_select_if_needed() {
local st_now="$1"
[ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0
local rpub epayto code_fs
# current accept only first — avoid reusing reserve from previous rungs via tx dump
rpub=$(extract_rpub "$SCRATCH/accept-$tag.out")
if [ -z "$rpub" ]; then
wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true
rpub=$(extract_rpub "$SCRATCH/tx-$tag.json")
fi
local rpub epayto code_fs any=0
# refresh tx dump each try (wallet may attach reserve late)
wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true
epayto=$(curl -sS -m 10 "${EX%/}/keys" 2>/dev/null | python3 -c '
import json,sys
d=json.load(sys.stdin)
@ -546,31 +626,46 @@ for a in acc:
else:
if acc: print(acc[0].get("payto_uri") or "")
' 2>/dev/null || true)
if [ -n "$rpub" ] && [ -n "$epayto" ]; then
if [ -z "$epayto" ]; then
warn bank "force-select skipped" "problem: exchange payto empty from /keys"
return 0
fi
# Try candidates until bank leaves pending (200/204) or we exhaust
while IFS= read -r rpub; do
[ -n "$rpub" ] || continue
any=1
code_fs=$(curl -sS -m 12 -o "$SCRATCH/force-sel-$tag.json" -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' \
-d "{\"reserve_pub\":\"${rpub}\",\"selected_exchange\":\"${epayto}\"}" \
"${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || echo "000")
# 409: conflict (wrong/stale reserve, or already bound) — log body once, keep polling
if [ "$code_fs" = "409" ]; then
if [ "${FORCE_SEL_409_LOGGED:-0}" != "1" ]; then
info "force-select" "HTTP 409 rpub=${rpub:0:12}… body=$(tr '\n' ' ' <"$SCRATCH/force-sel-$tag.json" | head -c 160)"
FORCE_SEL_409_LOGGED=1
fi
else
info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}"
FORCE_SEL_409_LOGGED=0
if [ "$code_fs" = "200" ] || [ "$code_fs" = "204" ]; then
info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… (ok for WID ${WID:0:8})"
mark_rpub_used "$rpub"
return 0
fi
else
_rpub_empty=no
[ -z "$rpub" ] && _rpub_empty=yes
_epayto_empty=no
[ -z "$epayto" ] && _epayto_empty=yes
if [ "$code_fs" = "409" ]; then
# 5114 = this reserve already bound to another op — not "out of money"
info "force-select" "HTTP 409 rpub=${rpub:0:12}… (stale/used reserve — not balance; trying next)"
mark_rpub_used "$rpub"
continue
fi
info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… body=$(tr '\n' ' ' <"$SCRATCH/force-sel-$tag.json" 2>/dev/null | head -c 120)"
# other errors: still try next candidate
done < <(extract_rpubs_for_wid)
if [ "$any" != "1" ]; then
warn bank "force-select skipped" \
"problem: cannot move bank pending->selected (reserve_pub empty=${_rpub_empty}, exchange payto empty=${_epayto_empty})"
"problem: no reserve_pub for this withdraw (WID=${WID:0:8}…); wallet may not have selected yet"
fi
}
# Short bounded select assist (≤8s) — not a hang path; helps attach fresh reserve_pub
if command -v perl >/dev/null 2>&1 && [ -f "$CLI_JS" ]; then
perl -e 'alarm shift; exec @ARGV' 8 \
node "$CLI_JS" --wallet-db="$WDB" --no-throttle run-until-done \
>"$SCRATCH/select-$tag.out" 2>&1 || true
fi
wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true
t0=$(now_ms)
conf_ok=0
st=""
@ -598,9 +693,9 @@ else:
break
;;
esac
# force-select while pending (no run-until-done); avoid spam on repeated 409
# force-select while pending — try alternate rpubs on 5114 (not out-of-money)
if [ "$st" = "pending" ] || [ -z "$st" ]; then
if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 5)) -eq 0 ]; then
if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 3)) -eq 0 ]; then
force_select_if_needed "$st"
fi
fi
@ -610,10 +705,10 @@ else:
st=$(printf '%s' "${st:-}" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ "$conf_ok" != "1" ]; then
note="${note:-confirm timeout last=${st:-empty}}"
# Soft: not confirmed after polls — WARN and continue (pending/select lag or force-select issues)
# Soft: not confirmed — usually stale reserve_pub (5114), NOT empty pool balance
status="SKIP_CONFIRM"
warn bank "confirm $AMT skipped" \
"problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed); ladder continues. detail: $note"
"problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed). Usually reserve_pub mismatch (5114), not out-of-money — mint/accept already OK. detail: $note"
ms_total=$(elapsed_ms "$t_rung")
echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV"
continue
@ -664,6 +759,7 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1)
OK_N=$((OK_N + 1))
echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV"
metrics_report_coins "ladder-r${rung}-after-${tag}" || true
metrics_record_flow withdrawn "$AMT" || true
elif echo "$xfer" | grep -qi True; then
status="OK_BANK"
note="bank transfer_done avail=${after} $xfer (no run-until-done)"
@ -671,6 +767,7 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1)
OK_N=$((OK_N + 1))
echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV"
metrics_report_coins "ladder-r${rung}-after-${tag}" || true
metrics_record_flow withdrawn "$AMT" || true
else
note="no coins / no transfer_done avail=${after} $xfer"
err wallet "settle $AMT" "$note"
@ -897,6 +994,7 @@ sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="p
PAY_OK_N=$((PAY_OK_N + 1))
echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV"
metrics_report_coins "after-pay-${ptag}" || true
metrics_record_flow spent "$PAMT" || true
else
pnote="not settled order=$OID avail=${CUR}:${after}"
if [ "$IS_PMAX" = "1" ]; then
@ -1048,7 +1146,10 @@ for k, v in (rep.get("timing") or {}).items():
json.dump(out, open(sys.argv[2], "w"), indent=2)
PY
fi
metrics_print_overall "ladder overall" || true
export METRICS_WITHDRAW_TSV="$TSV"
export METRICS_PAY_TSV="$PAY_TSV"
metrics_report_coins "ladder-end" || true
metrics_print_overall "ladder final" || true
# Keep scratch if LADDER_REPORT_DIR set; else copy key files to /tmp
if [ -z "${LADDER_REPORT_DIR:-}" ]; then

View file

@ -976,44 +976,433 @@ for role in ("bank","exchange","merchant"):
PY
}
# Record a successful withdraw/pay amount for end statistics (one amount per line).
# $1=withdrawn|spent $2=amount (CUR:n)
metrics_record_flow() {
local kind="$1" amt="$2"
[ -n "$amt" ] || return 0
mkdir -p "${METRICS_DIR}" 2>/dev/null || true
case "$kind" in
withdrawn|withdraw) printf '%s\n' "$amt" >>"${METRICS_DIR}/flow-withdrawn.txt" ;;
spent|pay) printf '%s\n' "$amt" >>"${METRICS_DIR}/flow-spent.txt" ;;
*) return 1 ;;
esac
}
# Overall end-of-run statistics block
# Args via env / files:
# METRICS_DIR, optional: WITHDRAW_REPORT, PAY_REPORT, phase timings JSON files
# Files under METRICS_DIR:
# coins-final.json, coins-history.tsv, perf-summary.json,
# load-before/after.json, flow-withdrawn.txt, flow-spent.txt,
# optional ladder TSV via METRICS_WITHDRAW_TSV / METRICS_PAY_TSV
metrics_print_overall() {
local title="${1:-overall statistics}"
section "metrics · $title"
if [ -f "${METRICS_DIR}/load-before.json" ]; then
info "load BEFORE" ""
metrics_print_load "${METRICS_DIR}/load-before.json" "before"
fi
if [ -f "${METRICS_DIR}/load-after.json" ]; then
info "load AFTER" ""
metrics_print_load "${METRICS_DIR}/load-after.json" "after"
metrics_print_load_delta "${METRICS_DIR}/load-before.json" "${METRICS_DIR}/load-after.json"
fi
if [ -f "${METRICS_DIR}/coins-final.json" ]; then
info "coins final" "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("summary","?"))' "${METRICS_DIR}/coins-final.json" 2>/dev/null || echo n/a)"
fi
if [ -f "${METRICS_DIR}/coins-history.tsv" ]; then
echo " --- coins after each withdraw ---"
# header + rows
if [ -s "${METRICS_DIR}/coins-history.tsv" ]; then
column -t -s $'\t' "${METRICS_DIR}/coins-history.tsv" 2>/dev/null \
|| cat "${METRICS_DIR}/coins-history.tsv"
fi
fi
if [ -f "${METRICS_DIR}/perf-summary.json" ]; then
info "performance" ""
python3 - "${METRICS_DIR}/perf-summary.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
for k,v in d.items():
if isinstance(v, dict) and "n" in v:
print(f" {k:14} n={v.get('n')} min={v.get('min_ms')}ms p50={v.get('p50_ms')}ms avg={v.get('avg_ms')}ms max={v.get('max_ms')}ms")
else:
print(f" {k}: {v}")
PY
fi
# free-form extras from caller
section "statistics · $title"
metrics_print_final_stats || true
[ -n "${METRICS_EXTRA_LINES:-}" ] && printf '%s\n' "$METRICS_EXTRA_LINES"
}
# Rich final dashboard: coins, withdrawn/spent, performance, tendencies
metrics_print_final_stats() {
python3 - \
"${METRICS_DIR:-/tmp}" \
"${ALT_UNITS_FILE:-}" \
"${METRICS_WITHDRAW_TSV:-}" \
"${METRICS_PAY_TSV:-}" \
"${CUR:-GOA}" \
<<'PY'
import csv, json, os, sys
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
mdir = Path(sys.argv[1])
alt_path = sys.argv[2] or ""
wd_tsv = sys.argv[3] or ""
pay_tsv = sys.argv[4] or ""
cur = sys.argv[5] or "GOA"
alt = {}
if alt_path and Path(alt_path).is_file():
try:
alt = json.load(open(alt_path))
except Exception:
alt = {}
if not alt:
alt = {"0": cur}
def D(x, default=Decimal(0)):
try:
return Decimal(str(x))
except Exception:
return default
def parse_amt(s):
s = str(s or "").strip()
if not s:
return cur, Decimal(0)
if ":" in s:
c, v = s.split(":", 1)
try:
return c, Decimal(v)
except InvalidOperation:
return c, Decimal(0)
try:
return cur, Decimal(s)
except InvalidOperation:
return cur, Decimal(0)
def fmt_num(v: Decimal) -> str:
if v == v.to_integral():
return format(int(v), "d")
s = format(v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP), "f")
return s.rstrip("0").rstrip(".")
def fmt_amt(c, v: Decimal) -> str:
return "%s:%s" % (c, fmt_num(v))
def fmt_alt(c, v: Decimal) -> str:
base_s = fmt_amt(c, v)
base_name = alt.get("0") or c
if v == 0:
return "0 %s (%s)" % (base_name, base_s)
scales = []
for k, name in alt.items():
try:
scales.append((int(k), str(name)))
except Exception:
pass
scales.sort(key=lambda x: -x[0])
absval = abs(v)
for sc, name in scales:
unit = Decimal(10) ** sc
coeff = absval / unit
if coeff >= 1:
if sc == 0:
return "%s %s" % (fmt_num(v), name)
return "%s %s (%s)" % (fmt_num(coeff if v >= 0 else -coeff), name, base_s)
return "%s %s (%s)" % (fmt_num(v), base_name, base_s)
def sum_amount_file(path: Path):
total = Decimal(0)
ccy = cur
n = 0
if not path.is_file():
return ccy, total, n
for line in path.read_text().splitlines():
line = line.strip()
if not line:
continue
c, v = parse_amt(line)
ccy = c
total += v
n += 1
return ccy, total, n
def sum_tsv_amounts(path, amount_col="amount", status_ok=None):
total = Decimal(0)
ccy = cur
n = 0
if not path or not Path(path).is_file():
return ccy, total, n
with open(path, newline="") as f:
for row in csv.DictReader(f, delimiter="\t"):
st = (row.get("status") or "").upper()
if status_ok is not None and st not in status_ok:
continue
c, v = parse_amt(row.get(amount_col) or "")
if v <= 0:
continue
ccy = c
total += v
n += 1
return ccy, total, n
def trend_label(delta, eps=Decimal("0.01")):
if delta > eps:
return "↑ rising"
if delta < -eps:
return "↓ falling"
return "→ stable"
def half_trend(values):
"""Compare avg of second half vs first half of a list of numbers."""
xs = [float(x) for x in values if x is not None]
if len(xs) < 4:
if len(xs) < 2:
return "n/a (few samples)", None, None
a, b = xs[0], xs[-1]
return trend_label(Decimal(str(b - a))), a, b
mid = len(xs) // 2
first = sum(xs[:mid]) / mid
second = sum(xs[mid:]) / (len(xs) - mid)
d = second - first
lab = "↑ slowing (worse)" if d > 0.05 * max(abs(first), 1) else (
"↓ faster (better)" if d < -0.05 * max(abs(first), 1) else "→ steady"
)
return lab, first, second
print("")
print("╔══════════════════════════════════════════════════════════════╗")
print("║ FINAL STATISTICS ║")
print("╚══════════════════════════════════════════════════════════════╝")
# --- coins snapshot ---
coins = {}
cf = mdir / "coins-final.json"
if cf.is_file():
try:
coins = json.load(open(cf))
except Exception:
coins = {}
print("")
print("── Coins (wallet) ─────────────────────────────────────────────")
if coins.get("ok"):
circ_n = coins.get("in_circulation") or 0
spent_n = coins.get("spent") or 0
total_n = coins.get("total_coins") or 0
amt_alt = coins.get("amount_in_circulation_alt") or coins.get("amount_in_circulation_s") or "?"
spent_alt = coins.get("amount_spent_alt") or coins.get("amount_spent_s") or "0"
print(" total coins %s" % total_n)
print(" in circulation %s coins · %s" % (circ_n, amt_alt))
print(" spent (in dump) %s coins · %s" % (spent_n, spent_alt))
circ = coins.get("by_denom_circulation") or []
if circ:
print(" denoms in circ:")
for x in circ:
print(
" · %s × %s = %s"
% (
x.get("denom_alt") or x.get("denom"),
x.get("count"),
x.get("amount_alt") or x.get("amount"),
)
)
spent = coins.get("by_denom_spent") or []
if spent:
print(" denoms spent:")
for x in spent:
print(
" · %s × %s = %s"
% (
x.get("denom_alt") or x.get("denom"),
x.get("count"),
x.get("amount_alt") or x.get("amount"),
)
)
else:
print(" (no coins-final.json — run with wallet snaps)")
# --- withdrawn / spent flows ---
print("")
print("── Money flow (this run) ──────────────────────────────────────")
# Prefer ladder TSV if provided; else flow-*.txt from e2e
wd_ok = {"OK", "OK_BANK", "ZERO_REJECT", "ZERO_SKIP", "SKIP_DENOM", "CEILING_REJECT"}
pay_ok = {"OK", "ZERO_SKIP", "CEILING_SKIP", "CEILING_REJECT"}
c1, wd_sum, wd_n = sum_tsv_amounts(wd_tsv, "amount", None)
# count successful withdraws by status prefix OK or soft
if wd_tsv and Path(wd_tsv).is_file():
c1, wd_sum, wd_n = Decimal(0), Decimal(0), 0
ccy = cur
with open(wd_tsv, newline="") as f:
for row in csv.DictReader(f, delimiter="\t"):
st = (row.get("status") or "").upper()
# count value when mint/settle produced money in wallet
if st in ("OK", "OK_BANK"):
c, v = parse_amt(row.get("amount"))
ccy, wd_sum, wd_n = c, wd_sum + v, wd_n + 1
c1 = ccy
else:
c1, wd_sum, wd_n = sum_amount_file(mdir / "flow-withdrawn.txt")
if pay_tsv and Path(pay_tsv).is_file():
c2, pay_sum, pay_n = Decimal(0), Decimal(0), 0
ccy = cur
with open(pay_tsv, newline="") as f:
for row in csv.DictReader(f, delimiter="\t"):
st = (row.get("status") or "").upper()
if st == "OK":
c, v = parse_amt(row.get("amount"))
ccy, pay_sum, pay_n = c, pay_sum + v, pay_n + 1
c2 = ccy
else:
c2, pay_sum, pay_n = sum_amount_file(mdir / "flow-spent.txt")
print(" withdrawn %s events · %s" % (wd_n, fmt_alt(c1, wd_sum)))
print(" spent (paid) %s events · %s" % (pay_n, fmt_alt(c2, pay_sum)))
net = wd_sum - pay_sum
print(" net (wd spent) %s %s" % (fmt_alt(c1, net), trend_label(net)))
if coins.get("ok"):
# circulation amount vs net flow
circ_map = coins.get("amount_in_circulation") or {}
circ_v = Decimal(0)
for _c, vs in circ_map.items():
circ_v += D(vs)
print(" wallet circ now %s" % fmt_alt(cur, circ_v))
print(" residual check wallet_circ vs net: Δ %s"
% fmt_alt(cur, circ_v - net))
# --- coin history tendency ---
print("")
print("── Tendency · coins over run ─────────────────────────────────")
hist = mdir / "coins-history.tsv"
if hist.is_file() and hist.stat().st_size > 0:
rows = list(csv.DictReader(open(hist), delimiter="\t"))
if rows:
def circ_amt(row):
# amount_circ column may be "GOA:20" or alt form — try numeric from total/in_circ
a = row.get("amount_circ") or "0"
if ":" in a and " " not in a:
return parse_amt(a)[1]
# try extract GOA: from parentheses
import re
m = re.search(r"\(([^)]+:[0-9.]+)\)", a)
if m:
return parse_amt(m.group(1))[1]
return D(row.get("in_circ") or 0)
first, last = rows[0], rows[-1]
c0, c1_ = D(first.get("in_circ") or 0), D(last.get("in_circ") or 0)
a0, a1 = circ_amt(first), circ_amt(last)
print(" samples %d snaps (%s → %s)"
% (len(rows), first.get("label"), last.get("label")))
print(" coins in circ %s → %s (Δ %+d) %s"
% (fmt_num(c0), fmt_num(c1_), int(c1_ - c0), trend_label(c1_ - c0)))
print(" amount in circ %s → %s %s"
% (fmt_alt(cur, a0), fmt_alt(cur, a1), trend_label(a1 - a0)))
# simple linear slope on coin count
if len(rows) >= 3:
ys = [float(D(r.get("in_circ") or 0)) for r in rows]
n = len(ys)
xs = list(range(n))
xm, ym = sum(xs) / n, sum(ys) / n
num = sum((x - xm) * (y - ym) for x, y in zip(xs, ys))
den = sum((x - xm) ** 2 for x in xs) or 1
slope = num / den
print(" trend slope %+.3f coins/snap %s"
% (slope, "↑ accumulating" if slope > 0.05 else ("↓ draining" if slope < -0.05 else "→ flat")))
print(" history (label · in_circ · amount):")
for r in rows[-12:]: # last 12
print(" · %-28s circ=%-4s %s"
% (r.get("label", "?")[:28], r.get("in_circ"), r.get("amount_circ")))
if len(rows) > 12:
print(" · … (%d earlier snaps)" % (len(rows) - 12))
else:
print(" (empty history)")
else:
print(" (no coins-history.tsv)")
# --- performance ---
print("")
print("── Performance indicators ───────────────────────────────────")
perf = {}
pf = mdir / "perf-summary.json"
if pf.is_file():
try:
perf = json.load(open(pf))
except Exception:
perf = {}
def print_bucket(name, v):
if not isinstance(v, dict) or not v.get("n"):
return
print(
" %-14s n=%s min=%sms p50=%sms avg=%sms max=%sms"
% (name, v.get("n"), v.get("min_ms"), v.get("p50_ms"), v.get("avg_ms"), v.get("max_ms"))
)
if perf:
for k, v in perf.items():
print_bucket(k, v)
else:
print(" (no perf-summary.json)")
# per-rung timing tendency from ladder TSV
for label, path, cols in (
("withdraw rungs", wd_tsv, ("ms_total", "ms_mint", "ms_settle")),
("pay rungs", pay_tsv, ("ms_total", "ms_order", "ms_handle", "ms_settle")),
):
if not path or not Path(path).is_file():
continue
with open(path, newline="") as f:
rrows = list(csv.DictReader(f, delimiter="\t"))
if len(rrows) < 2:
continue
print(" tendency · %s:" % label)
for col in cols:
vals = []
for r in rrows:
try:
vals.append(int(r.get(col) or 0))
except Exception:
pass
vals = [v for v in vals if v > 0]
if len(vals) < 2:
continue
lab, a, b = half_trend(vals)
if a is None:
print(" %-12s %s" % (col, lab))
else:
print(" %-12s first-half avg=%.0fms → second-half avg=%.0fms %s"
% (col, a, b, lab))
# --- load ---
print("")
print("── Host / container load ────────────────────────────────────")
lb, la = mdir / "load-before.json", mdir / "load-after.json"
def load_brief(path, tag):
if not path.is_file():
print(" %s: (missing)" % tag)
return None
try:
d = json.load(open(path))
except Exception:
print(" %s: (unreadable)" % tag)
return None
if not d.get("ok"):
print(" %s: %s" % (tag, d.get("reason", "?")))
return d
h = d.get("host") or {}
mem = h.get("memory") or {}
la_ = h.get("loadavg") or []
avail = mem.get("mem_available_b")
tot = mem.get("mem_total_b")
def gi(b):
return "?" if b is None else "%.2f GiB" % (b / 1024 / 1024 / 1024)
used = (tot - avail) if (tot is not None and avail is not None) else None
print(" %s loadavg=%s mem_used=%s / %s"
% (tag, la_, gi(used), gi(tot)))
for role in ("bank", "exchange", "merchant"):
c = (d.get("taler") or {}).get(role) or {}
if not c.get("running"):
continue
pr = c.get("processes") or {}
st = c.get("podman_stats") or {}
rss = pr.get("rss_total_b")
rss_s = "?" if rss is None else "%.2f GiB" % (rss / 1024 / 1024 / 1024)
print(" %-8s rss=%s cpu=%s procs=%s"
% (role, rss_s, st.get("cpu_pct") or "?", pr.get("proc_total")))
return d
db = load_brief(lb, "before")
da = load_brief(la, "after")
if db and da and db.get("ok") and da.get("ok"):
bla = (db.get("host") or {}).get("loadavg") or [0]
ala = (da.get("host") or {}).get("loadavg") or [0]
if bla and ala:
dload = float(ala[0]) - float(bla[0])
print(" loadavg1 tendency %.2f → %.2f (Δ %+.2f) %s"
% (float(bla[0]), float(ala[0]), dload,
"↑ higher load" if dload > 0.1 else ("↓ lower load" if dload < -0.1 else "→ steady")))
bm = (db.get("host") or {}).get("memory") or {}
am = (da.get("host") or {}).get("memory") or {}
if bm.get("mem_available_b") is not None and am.get("mem_available_b") is not None:
dmem = (am["mem_available_b"] - bm["mem_available_b"]) / 1024 / 1024 / 1024
print(" mem_avail tendency %.2f → %.2f GiB (Δ %+.3f) %s"
% (bm["mem_available_b"] / 1024**3, am["mem_available_b"] / 1024**3, dmem,
"↑ more free" if dmem > 0.05 else ("↓ less free" if dmem < -0.05 else "→ steady")))
print("")
print("──────────────────────────────────────────────────────────────")
PY
}

View file

@ -1,7 +1,17 @@
#!/bin/bash
# Memory snapshot for landing-stats (source after json_str is defined).
# Memory snapshot for landing-stats.
# Sets MEM_JSON (fields to embed inside performance.memory).
# Also: mem_snapshot_emit → full JSON object on stdout (host collector).
# IMPORTANT: no pipelines around the /proc loop (bash subshell loses counters).
#
# json_str may already be defined by the caller (landing-stats.sh); provide a
# safe default so this file is usable stand-alone via podman exec.
if ! declare -F json_str >/dev/null 2>&1; then
json_str() {
printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ')"
}
fi
mem_fmt_bytes() {
awk -v b="${1:-0}" 'BEGIN{
@ -92,21 +102,39 @@ mem_snapshot_json() {
rm -f "$topf"
lim_json="null"
lim_human_json="null"
if [[ "$limit_b" =~ ^[0-9]+$ ]] && [ "$limit_b" -gt 0 ]; then
lim_json=$limit_b
lim_human_json=$(json_str "$(mem_fmt_bytes "$limit_b")")
fi
cg_json="null"
if [[ "$cgroup_b" =~ ^[0-9]+$ ]]; then
cg_json=$cgroup_b
fi
# Compact container label: "693.4 MiB" or "693.4 MiB / 2.00 GiB" when capped
local ctr_human
ctr_human=$(mem_fmt_bytes "$total_b")
if [ "$lim_human_json" != "null" ]; then
# lim_human_json is a quoted string already
:
fi
MEM_JSON="
\"container_rss_bytes\": ${total_b},
\"container_rss_human\": $(json_str "$(mem_fmt_bytes "$total_b")"),
\"container_rss_human\": $(json_str "$ctr_human"),
\"container_rss_label\": $(json_str "$(
if [ "$lim_json" != "null" ]; then
printf '%s / %s' "$ctr_human" "$(mem_fmt_bytes "$limit_b")"
else
printf '%s' "$ctr_human"
fi
)"),
\"proc_sum_rss_bytes\": ${sum_b},
\"proc_sum_rss_human\": $(json_str "$(mem_fmt_bytes "$sum_b")"),
\"cgroup_bytes\": ${cg_json},
\"cgroup_limit_bytes\": ${lim_json},
\"cgroup_limit_human\": ${lim_human_json},
\"postgres_rss_bytes\": ${postgres_b},
\"postgres_rss_human\": $(json_str "$(mem_fmt_bytes "$postgres_b")"),
\"postgres_n\": ${n_pg},
@ -128,3 +156,15 @@ mem_snapshot_json() {
\"top\": ${top_json}
"
}
# Full JSON for host collector: { ok, loadavg, memory: {…} }
mem_snapshot_emit() {
mem_snapshot_json || return 1
local loadavg=""
if [ -r /proc/loadavg ]; then
loadavg=$(awk '{print $1","$2","$3}' /proc/loadavg)
fi
printf '{\n "ok": true,\n "source": "mem-snapshot",\n "loadavg": %s,\n "memory": {\n%s\n }\n}\n' \
"$(json_str "$loadavg")" \
"$MEM_JSON"
}