Compare commits
No commits in common. "78bbd80d1fb604bea9e578e7eb3df8d4dff80734" and "51785031d649130ea3c8e0c8f5e02e29124d1728" have entirely different histories.
78bbd80d1f
...
51785031d6
28 changed files with 180 additions and 3829 deletions
|
|
@ -103,31 +103,11 @@ taler-wallet-cli exchanges accept-tos https://exchange.hacktivism.ch/
|
|||
|
||||
---
|
||||
|
||||
## Live stats (central host collector · hernani systemd)
|
||||
## Live stats (in bank container)
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
| Piece | Path / role |
|
||||
|-------|-------------|
|
||||
|
|
@ -151,9 +131,9 @@ public JSON file next to the landing assets.
|
|||
|
||||
| Env | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
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) —
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
/* 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);
|
||||
|
|
@ -1491,34 +1491,6 @@ tw run-until-done && 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/";
|
||||
|
|
@ -1964,34 +1936,16 @@ tw run-until-done && 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 : "—");
|
||||
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 || "—"));
|
||||
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 || "—");
|
||||
|
||||
var list = document.getElementById("st-recent");
|
||||
if (list) {
|
||||
|
|
@ -2008,13 +1962,7 @@ tw run-until-done && tw balance</pre>
|
|||
var li = document.createElement("li");
|
||||
var amt = document.createElement("span");
|
||||
amt.className = "amt";
|
||||
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 || "?";
|
||||
}
|
||||
amt.textContent = row.amount || "?";
|
||||
var meta = document.createElement("span");
|
||||
meta.className = "meta";
|
||||
var when = fmtCest(row.at_unix, row.at || row.at_iso) || "";
|
||||
|
|
@ -2037,33 +1985,20 @@ tw run-until-done && 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 || {};
|
||||
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);
|
||||
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 || "—");
|
||||
var topBox = document.getElementById("st-mem-top");
|
||||
if (topBox) {
|
||||
var tops = mem.top || [];
|
||||
|
|
@ -2075,16 +2010,10 @@ tw run-until-done && 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) {
|
||||
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 || "?") +
|
||||
th += '<tr><td style="padding:0.25rem;color:#5eead4">' + (t.rss_human || "?") +
|
||||
'</td><td style="padding:0.25rem">' + (t.comm || "") +
|
||||
'</td><td style="padding:0.25rem;word-break:break-all;color:var(--muted)" title="' +
|
||||
String(cmdFull).replace(/"/g, """) + '">' +
|
||||
cmd + "</td></tr>";
|
||||
'</td><td style="padding:0.25rem;word-break:break-all;color:var(--muted)">' +
|
||||
(t.cmd || "") + "</td></tr>";
|
||||
});
|
||||
th += "</tbody></table>";
|
||||
topBox.innerHTML = th;
|
||||
|
|
@ -2092,8 +2021,7 @@ tw run-until-done && tw balance</pre>
|
|||
}
|
||||
var pf = document.getElementById("st-perf-foot");
|
||||
if (pf) {
|
||||
pf.textContent = "Container RSS + process groups" +
|
||||
(mem.source ? " · " + mem.source : "") + " · " +
|
||||
pf.textContent = "Container RSS + process groups · " +
|
||||
(d.generated_at_human || d.generated_at || "");
|
||||
}
|
||||
return fetch("stats-run.json", { cache: "no-store" })
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
/* 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);
|
||||
|
|
@ -444,60 +444,12 @@ 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";
|
||||
|
|
@ -508,13 +460,12 @@ 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);
|
||||
setAmt("st-remaining", d.coins_remaining_amount, "coins_remaining_amount_alt", d);
|
||||
set("st-remaining", d.coins_remaining_amount);
|
||||
set("st-withdraw", d.withdraw_ops);
|
||||
setAmt("st-withdraw-amt", d.withdraw_amount, "withdraw_amount_alt", d);
|
||||
set("st-withdraw-amt", d.withdraw_amount);
|
||||
set("st-refresh", d.refresh_ops);
|
||||
set("st-coin-dep", d.coin_deposits);
|
||||
set("st-batch-dep", d.batch_deposits);
|
||||
|
|
@ -526,7 +477,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);
|
||||
setAmt("st-wire-in-amt", d.wire_in_amount, "wire_in_amount_alt", d);
|
||||
set("st-wire-in-amt", d.wire_in_amount);
|
||||
set("st-wire-out", d.wire_out);
|
||||
|
||||
var ladder = document.getElementById("st-ladder");
|
||||
|
|
@ -535,12 +486,7 @@ sudo apt-get install -y taler-wallet-cli</pre>
|
|||
(d.denom_ladder || []).forEach(function (x) {
|
||||
var s = document.createElement("span");
|
||||
s.className = "pill";
|
||||
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;
|
||||
s.textContent = x.value + (x.keys > 1 ? " ×" + x.keys : "");
|
||||
ladder.appendChild(s);
|
||||
});
|
||||
if (!(d.denom_ladder || []).length) ladder.textContent = "—";
|
||||
|
|
@ -553,11 +499,7 @@ 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) {
|
||||
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 += "<tr><td>" + r.value + "</td><td>" + r.coins + "</td><td>" + r.live + "</td></tr>";
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
box.innerHTML = html;
|
||||
|
|
@ -569,30 +511,19 @@ 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 || {};
|
||||
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);
|
||||
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 || "—");
|
||||
var topBox = document.getElementById("st-mem-top");
|
||||
if (topBox) {
|
||||
var tops = mem.top || [];
|
||||
|
|
@ -601,14 +532,9 @@ 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) {
|
||||
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, """) + "\">" +
|
||||
cmd + "</td></tr>";
|
||||
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>";
|
||||
});
|
||||
th += "</tbody></table>";
|
||||
topBox.innerHTML = th;
|
||||
|
|
@ -616,8 +542,7 @@ sudo apt-get install -y taler-wallet-cli</pre>
|
|||
}
|
||||
var pf = document.getElementById("st-perf-foot");
|
||||
if (pf) {
|
||||
pf.textContent = "Container RSS + process groups" +
|
||||
(mem.source ? " · " + mem.source : "") + " · " +
|
||||
pf.textContent = "Container RSS + process groups · " +
|
||||
(d.generated_at_human || d.generated_at || "");
|
||||
}
|
||||
return fetch("/intro/stats-run.json", { cache: "no-store" })
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
/* 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);
|
||||
|
|
@ -811,63 +811,19 @@ 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, altMap) {
|
||||
function fillCurrency(prefix, c) {
|
||||
if (!c) {
|
||||
set(prefix + "-paid-amt", "—");
|
||||
set(prefix + "-all-amt", "—");
|
||||
|
|
@ -877,16 +833,8 @@ sudo apt-get install -y taler-wallet-cli</pre>
|
|||
set(prefix + "-unpaid", "—");
|
||||
return;
|
||||
}
|
||||
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 + "-paid-amt", c.amount_paid_sum || "—");
|
||||
set(prefix + "-all-amt", c.amount_sum || "—");
|
||||
set(prefix + "-orders", c.contracts);
|
||||
set(prefix + "-paid", c.paid);
|
||||
set(prefix + "-wired", c.wired);
|
||||
|
|
@ -903,7 +851,6 @@ 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);
|
||||
|
|
@ -915,8 +862,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, d.alt_unit_names);
|
||||
fillCurrency("chf", by.CHF, d.alt_unit_names);
|
||||
fillCurrency("goa", by.GOA);
|
||||
fillCurrency("chf", by.CHF);
|
||||
|
||||
function fillActivityList(el, acts) {
|
||||
if (!el) return;
|
||||
|
|
@ -931,15 +878,10 @@ 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" title="' + esc(amtTitle) + '">' + esc(amtShow) + "</div>" +
|
||||
'<div class="act-amt">' + esc(a.amount || "—") + "</div>" +
|
||||
(a.summary
|
||||
? '<div class="act-sum">' + esc(a.summary) + "</div>"
|
||||
: "") +
|
||||
|
|
@ -973,31 +915,20 @@ 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 || {};
|
||||
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);
|
||||
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 || "—");
|
||||
var topBox = document.getElementById("st-mem-top");
|
||||
if (topBox) {
|
||||
var tops = mem.top || [];
|
||||
|
|
@ -1009,16 +940,10 @@ 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) {
|
||||
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 || "?") +
|
||||
th += '<tr><td style="padding:0.25rem;color:#5eead4">' + (t.rss_human || "?") +
|
||||
'</td><td style="padding:0.25rem">' + (t.comm || "") +
|
||||
'</td><td style="padding:0.25rem;word-break:break-all;color:var(--muted)" title="' +
|
||||
String(cmdFull).replace(/"/g, """) + '">' +
|
||||
cmd + "</td></tr>";
|
||||
'</td><td style="padding:0.25rem;word-break:break-all;color:var(--muted)">' +
|
||||
(t.cmd || "") + "</td></tr>";
|
||||
});
|
||||
th += "</tbody></table>";
|
||||
topBox.innerHTML = th;
|
||||
|
|
@ -1026,8 +951,7 @@ sudo apt-get install -y taler-wallet-cli</pre>
|
|||
}
|
||||
var pf = document.getElementById("st-perf-foot");
|
||||
if (pf) {
|
||||
pf.textContent = "Container RSS + process groups" +
|
||||
(mem.source ? " · " + mem.source : "") + " · " +
|
||||
pf.textContent = "Container RSS + process groups · " +
|
||||
(d.generated_at_human || d.generated_at || "");
|
||||
}
|
||||
return fetch("/intro/stats-run.json", { cache: "no-store" })
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
/* 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);
|
||||
|
|
@ -30,22 +30,6 @@
|
|||
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");
|
||||
|
|
@ -84,7 +68,6 @@
|
|||
})
|
||||
.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 || {};
|
||||
|
|
@ -95,10 +78,10 @@
|
|||
var fwd = flow.withdraw || {};
|
||||
set("st-accounts", ba.total != null ? ba.total : "—");
|
||||
set("st-wallets", wl.unique_reserves != null ? wl.unique_reserves : "—");
|
||||
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);
|
||||
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");
|
||||
|
||||
var list = document.getElementById("st-recent");
|
||||
if (list) {
|
||||
|
|
@ -114,13 +97,7 @@
|
|||
var li = document.createElement("li");
|
||||
var amt = document.createElement("span");
|
||||
amt.className = "amt";
|
||||
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 || "?";
|
||||
}
|
||||
amt.textContent = row.amount || "?";
|
||||
var meta = document.createElement("span");
|
||||
meta.className = "meta";
|
||||
meta.textContent = fmtCest(row.at_unix, row.at || row.at_iso) || "";
|
||||
|
|
@ -135,8 +112,7 @@
|
|||
foot.className = "stats-foot";
|
||||
foot.innerHTML =
|
||||
'Source: <a href="' + BANK_INTRO + '">bank.hacktivism.ch</a>' +
|
||||
(d.generated_at_human ? " · " + d.generated_at_human : "") +
|
||||
(d.source ? " · " + d.source : "");
|
||||
(d.generated_at_human ? " · " + d.generated_at_human : "");
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
[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
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
| `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/` |
|
||||
|
|
@ -25,11 +24,6 @@ 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`
|
||||
|
|
|
|||
|
|
@ -16,16 +16,13 @@ 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).
|
||||
# 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}"
|
||||
TX_DELTA="${TX_DELTA:--50000}"
|
||||
# Max accounts to list + scan (was 80 → missed later accounts; bank has 100+).
|
||||
# 0 or empty → no head limit (scan every listed account).
|
||||
MAX_SCAN_ACCOUNTS="${MAX_SCAN_ACCOUNTS:-0}"
|
||||
MAX_SCAN_ACCOUNTS="${MAX_SCAN_ACCOUNTS:-500}"
|
||||
# Account-list page size for GET /accounts?delta=… (must cover all users)
|
||||
ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--10000}"
|
||||
ACCOUNTS_DELTA="${ACCOUNTS_DELTA:--500}"
|
||||
# curl timeout per account (deeper history needs more headroom)
|
||||
TX_CURL_TIMEOUT="${TX_CURL_TIMEOUT:-45}"
|
||||
TX_CURL_TIMEOUT="${TX_CURL_TIMEOUT:-25}"
|
||||
export TZ="${TZ:-Europe/Zurich}"
|
||||
|
||||
PASS="${BANK_PASS:-}"
|
||||
|
|
@ -221,23 +218,15 @@ fi
|
|||
SCAN_OK=0
|
||||
SCAN_EMPTY=0
|
||||
SCAN_FAIL=0
|
||||
# Scan ALL accounts except exchange (withdraw debits live on customers + admin
|
||||
# top-ups; exchange credits would double-count the same GOA).
|
||||
# Prefer host collect_bank_stats.py (hernani timer) for full pagination.
|
||||
# Scan customer accounts only (not exchange/admin — exchange credits would double-count)
|
||||
{
|
||||
echo "$BANK_USER"
|
||||
# include admin + every auto-account; drop only exchange
|
||||
grep -Eve "^(exchange|${BANK_USER})$" "$WORKDIR/usernames.txt" 2>/dev/null || true
|
||||
} | awk 'NF && !seen[$0]++' >"$WORKDIR/scan-users.all"
|
||||
if [ -n "${MAX_SCAN_ACCOUNTS}" ] && [ "${MAX_SCAN_ACCOUNTS}" -gt 0 ] 2>/dev/null; then
|
||||
head -n "$MAX_SCAN_ACCOUNTS" "$WORKDIR/scan-users.all" >"$WORKDIR/scan-users.txt"
|
||||
else
|
||||
cp "$WORKDIR/scan-users.all" "$WORKDIR/scan-users.txt"
|
||||
fi
|
||||
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"
|
||||
|
||||
while IFS= read -r uname; do
|
||||
[ -n "$uname" ] || continue
|
||||
case "$uname" in exchange) continue ;; esac
|
||||
case "$uname" in admin|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}' \
|
||||
|
|
@ -579,7 +568,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. exchange only (admin+explorer+all users scanned)"
|
||||
"note": "incoming=credits; withdraw=Taler withdrawal debits; excl. admin+exchange accounts"
|
||||
},
|
||||
"withdraws": {
|
||||
"count": ${N_WD:-0},
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
#!/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 "$@"
|
||||
|
|
@ -1,813 +0,0 @@
|
|||
#!/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())
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
#!/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
Executable file → Normal file
39
scripts/taler-landing/deploy-landings.sh
Executable file → Normal file
|
|
@ -14,47 +14,10 @@ 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
|
||||
|
|
@ -67,7 +30,6 @@ 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
|
||||
|
|
@ -88,7 +50,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
#!/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())
|
||||
|
|
@ -1,318 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""GOA (and generic CUR:amount) alt-unit formatting for landing stats.
|
||||
|
||||
Matches exchange currency_specification.alt_unit_names:
|
||||
0→GOA, 3→Kilo-GOA, 6→Mega-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
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
#!/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/*"
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
#!/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())
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -126,20 +126,6 @@ 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**
|
||||
|
|
|
|||
|
|
@ -152,14 +152,18 @@ e2e_finish() {
|
|||
fi
|
||||
E2E_REPORTED=1
|
||||
print_balances
|
||||
# Final coin inventory + host/container load + statistics dashboard
|
||||
# Final coin inventory + host/container load
|
||||
if [ -n "${METRICS_DIR:-}" ]; then
|
||||
section "metrics · e2e coins final"
|
||||
metrics_report_coins "e2e-end" || true
|
||||
if [ "${METRICS_LOAD:-1}" != "0" ]; then
|
||||
metrics_report_load "${METRICS_DIR}/load-after.json" "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
|
||||
fi
|
||||
metrics_print_overall "e2e final" || true
|
||||
metrics_print_overall "e2e overall" || true
|
||||
fi
|
||||
summary || true
|
||||
return "$code"
|
||||
|
|
@ -794,7 +798,6 @@ 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
|
||||
|
|
@ -894,7 +897,6 @@ 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
|
||||
|
|
@ -1015,7 +1017,6 @@ 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
|
||||
|
|
@ -1023,7 +1024,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -483,138 +483,58 @@ for AMT in "$@"; do
|
|||
-H "Authorization: Bearer ${TOK}" -H 'Content-Type: application/json' -d '{}' \
|
||||
"${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm"
|
||||
}
|
||||
# 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():
|
||||
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_collect(i, bag, ctx)
|
||||
|
||||
raw_pubs = [] # ordered as found
|
||||
scored = [] # (score, pub) higher better
|
||||
blob_all = ""
|
||||
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_all += open(p, errors="replace").read() + "\n"
|
||||
blob += open(p, errors="replace").read() + "\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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*:\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,})",
|
||||
):
|
||||
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:
|
||||
found.extend(m.group(1) for m in re.finditer(pat, blob, re.I))
|
||||
def walk(o):
|
||||
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)
|
||||
elif isinstance(o, list):
|
||||
for i in o:
|
||||
walk(i)
|
||||
for line in blob.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
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"
|
||||
try:
|
||||
walk(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
if found:
|
||||
print(found[-1])
|
||||
' "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
force_select_if_needed() {
|
||||
local st_now="$1"
|
||||
[ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0
|
||||
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
|
||||
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
|
||||
epayto=$(curl -sS -m 10 "${EX%/}/keys" 2>/dev/null | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
|
|
@ -626,46 +546,31 @@ for a in acc:
|
|||
else:
|
||||
if acc: print(acc[0].get("payto_uri") or "")
|
||||
' 2>/dev/null || true)
|
||||
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
|
||||
if [ -n "$rpub" ] && [ -n "$epayto" ]; then
|
||||
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")
|
||||
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
|
||||
# 409: conflict (wrong/stale reserve, or already bound) — log body once, keep polling
|
||||
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
|
||||
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
|
||||
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
|
||||
else
|
||||
_rpub_empty=no
|
||||
[ -z "$rpub" ] && _rpub_empty=yes
|
||||
_epayto_empty=no
|
||||
[ -z "$epayto" ] && _epayto_empty=yes
|
||||
warn bank "force-select skipped" \
|
||||
"problem: no reserve_pub for this withdraw (WID=${WID:0:8}…); wallet may not have selected yet"
|
||||
"problem: cannot move bank pending->selected (reserve_pub empty=${_rpub_empty}, exchange payto empty=${_epayto_empty})"
|
||||
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=""
|
||||
|
|
@ -693,9 +598,9 @@ else:
|
|||
break
|
||||
;;
|
||||
esac
|
||||
# force-select while pending — try alternate rpubs on 5114 (not out-of-money)
|
||||
# force-select while pending (no run-until-done); avoid spam on repeated 409
|
||||
if [ "$st" = "pending" ] || [ -z "$st" ]; then
|
||||
if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 3)) -eq 0 ]; then
|
||||
if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 5)) -eq 0 ]; then
|
||||
force_select_if_needed "$st"
|
||||
fi
|
||||
fi
|
||||
|
|
@ -705,10 +610,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 — usually stale reserve_pub (5114), NOT empty pool balance
|
||||
# Soft: not confirmed after polls — WARN and continue (pending/select lag or force-select issues)
|
||||
status="SKIP_CONFIRM"
|
||||
warn bank "confirm $AMT skipped" \
|
||||
"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"
|
||||
"problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed); ladder continues. 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
|
||||
|
|
@ -759,7 +664,6 @@ 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)"
|
||||
|
|
@ -767,7 +671,6 @@ 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"
|
||||
|
|
@ -994,7 +897,6 @@ 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
|
||||
|
|
@ -1146,10 +1048,7 @@ for k, v in (rep.get("timing") or {}).items():
|
|||
json.dump(out, open(sys.argv[2], "w"), indent=2)
|
||||
PY
|
||||
fi
|
||||
export METRICS_WITHDRAW_TSV="$TSV"
|
||||
export METRICS_PAY_TSV="$PAY_TSV"
|
||||
metrics_report_coins "ladder-end" || true
|
||||
metrics_print_overall "ladder final" || true
|
||||
metrics_print_overall "ladder overall" || true
|
||||
|
||||
# Keep scratch if LADDER_REPORT_DIR set; else copy key files to /tmp
|
||||
if [ -z "${LADDER_REPORT_DIR:-}" ]; then
|
||||
|
|
|
|||
|
|
@ -976,433 +976,44 @@ 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
|
||||
# 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
|
||||
# Args via env / files:
|
||||
# METRICS_DIR, optional: WITHDRAW_REPORT, PAY_REPORT, phase timings JSON files
|
||||
metrics_print_overall() {
|
||||
local title="${1:-overall statistics}"
|
||||
section "statistics · $title"
|
||||
metrics_print_final_stats || true
|
||||
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
|
||||
[ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,7 @@
|
|||
#!/bin/bash
|
||||
# Memory snapshot for landing-stats.
|
||||
# Memory snapshot for landing-stats (source after json_str is defined).
|
||||
# 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{
|
||||
|
|
@ -102,39 +92,21 @@ 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 "$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
|
||||
)"),
|
||||
\"container_rss_human\": $(json_str "$(mem_fmt_bytes "$total_b")"),
|
||||
\"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},
|
||||
|
|
@ -156,15 +128,3 @@ 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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue