From 4e20d9a1e36ef0b62398b2aac2b1b559ec3c5d8d Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Fri, 31 Jul 2026 20:39:14 -0500 Subject: [PATCH 1/5] fix(compose): tari-wallet healthcheck pattern survives ps CMD truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit procps' ps caps the CMD column at 15 characters, so the probe's grep for the 23-character 'minotari_console_wallet' could never match: the wallet container reported unhealthy forever while the wallet ran fine. Match on '[m]inotari_consol' — the truncated form ps actually prints. Closes #777 Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 6 ++++-- tests/stack/test_compose.sh | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7d4f8958..c5294f18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -355,8 +355,10 @@ services: healthcheck: # Process-liveness (like the Tari node) rather than a gRPC probe: the wallet's own gRPC is a # long stream, and a liveness ps is enough to catch a crashed daemon. '[m]' stops grep matching - # its own argv. The long start_period tolerates the first-run blockchain scan. - test: ["CMD-SHELL", "ps | grep '[m]inotari_console_wallet' || exit 1"] + # its own argv. The long start_period tolerates the first-run blockchain scan. The pattern is + # capped at 15 chars — procps' ps truncates the CMD column there, so the full binary name + # 'minotari_console_wallet' can never match (#777). + test: ["CMD-SHELL", "ps | grep '[m]inotari_consol' || exit 1"] interval: 30s timeout: 5s retries: 3 diff --git a/tests/stack/test_compose.sh b/tests/stack/test_compose.sh index f04edce6..a16c5dc7 100755 --- a/tests/stack/test_compose.sh +++ b/tests/stack/test_compose.sh @@ -283,6 +283,11 @@ jq_assert "exactly 4 service_healthy depends_on edges total (#565)" \ '[.services[] | (.depends_on // {}) | to_entries[] | select(.value.condition == "service_healthy")] | length == 4' jq_assert "exactly 5 depends_on edges total (#565)" \ '[.services[] | (.depends_on // {}) | to_entries[]] | length == 5' +# The wallet probe must fit ps's 15-char CMD column — procps truncates CMD there, so the full +# binary name never matches and the container would report unhealthy forever while the wallet +# runs fine (#777). Asserted here because tari-wallet only renders under tari_payout_confirm. +jq_assert "tari-wallet healthcheck pattern survives ps CMD truncation (#777)" \ + '(.services["tari-wallet"].healthcheck.test | tostring) | contains("[m]inotari_consol") and (contains("[m]inotari_console_wallet") | not)' # Restore $JSON to the default-profile render for every check below this point. JSON="$(docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" config --format json 2>/dev/null)" From 1494181add8940e8943faf63bf4bf31e115b6ccb Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Fri, 31 Jul 2026 23:17:03 -0500 Subject: [PATCH 2/5] fix(cli): remove profile-deactivated containers before every compose up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose up --remove-orphans never removes the container of a service whose profile just went inactive — the service is still in the compose file, so compose does not count it as an orphan. A tari local→remote switch therefore left the old minotari_node running (offline, re-syncing) against a remote-mode config; monerod local→remote had the same latent bug, and the apply-path comment claiming --remove-orphans covered it was wrong (disproven empirically against compose v2 / docker 29.0.1). compose_up_checked now reconciles from the committed .env before the up: every profile-gated service (monerod, tari, wallet-rpc, tari-wallet) whose profile token is absent gets 'compose rm -sf'. The command resolves named services regardless of active profiles and exits 0 when nothing runs, so the steady state is a no-op and a box already stuck with a stale node container heals on its next up. Closes #795 Co-Authored-By: Claude Fable 5 --- pithead | 28 ++++++++++++++++++++++++++-- tests/stack/run.sh | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/pithead b/pithead index 45d7328b..9cd93256 100755 --- a/pithead +++ b/pithead @@ -176,10 +176,33 @@ resolve_pull_policy() { else printf 'missing'; fi } +# #795: `compose up --remove-orphans` never removes the container of a service whose profile just +# went inactive — the service is still in the compose file, so compose does not count it as an +# orphan and leaves it running (a tari local→remote switch left the old minotari_node up, offline +# and re-syncing, against a remote-mode config). Reconcile from the committed .env instead: every +# profile-gated service whose profile token is absent gets its container stopped and removed. +# `compose rm` resolves an explicitly named service whatever the active profiles, and exits 0 when +# no container exists, so running this before every up is a cheap no-op in the steady state and +# also heals a box already stuck with a stale node container. +remove_deactivated_profile_containers() { + local profiles gone=() + profiles=",$(env_get COMPOSE_PROFILES)," + [[ "$profiles" == *,local_node,* ]] || gone+=(monerod) + [[ "$profiles" == *,local_tari,* ]] || gone+=(tari) + [[ "$profiles" == *,payout_confirm,* ]] || gone+=(wallet-rpc) + [[ "$profiles" == *,tari_payout_confirm,* ]] || gone+=(tari-wallet) + [ "${#gone[@]}" -eq 0 ] && return 0 + docker compose rm -sf "${gone[@]}" >/dev/null 2>&1 || + warn "Could not remove the deactivated container(s): ${gone[*]} — remove them manually with 'docker rm -f ${gone[*]}'." +} + # Run `docker compose up` with live output; on failure, explain a bridge-subnet collision (#180) if # that's what Docker rejected. Returns compose's own exit code. compose_up_checked() { local tmp out rc + # Deactivated-profile containers go BEFORE the up (#795): the old local node must stop before + # p2pool (re)starts against the remote one, not linger beside it. + remove_deactivated_profile_containers tmp="$(mktemp)" docker compose up --pull "$(resolve_pull_policy)" "$@" 2>&1 | tee "$tmp" rc=${PIPESTATUS[0]} @@ -5224,8 +5247,9 @@ apply() { # commit above (never before the operator said yes) and under the marker, so a failed move is # retried; the recreate below then mounts the migrated directory. migrate_dashboard_data - # Compose recreates only the services whose resolved config changed; --remove-orphans - # drops monerod when a local→remote switch deactivates the local_node profile. + # Compose recreates only the services whose resolved config changed. --remove-orphans covers + # services that left the compose file entirely; a profile-deactivated service is NOT an orphan + # to compose, so compose_up_checked removes those containers itself before the up (#795). if ! compose_up_checked -d --remove-orphans; then warn "Config files were updated but containers were NOT recreated ('docker compose up' failed)." warn "Fix the cause shown above, then re-run '$0 apply' (it will retry the recreate) — or '$0 up'." diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 611bce22..7caa6698 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -3384,6 +3384,39 @@ out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" assert_rc "invalid tari.mode rejected" "$?" "1" assert_contains "invalid tari.mode message" "$out" "tari.mode must be" +echo "== black-box: profile deactivation removes the old container (#795) ==" +# `compose up --remove-orphans` does not remove a profile-deactivated service's container — the +# service is still in the compose file, so compose does not count it as an orphan — and a tari +# local→remote switch left the old node running (offline, re-syncing) against a remote-mode +# config. Apply must issue an explicit `compose rm -sf` for every profile-gated service whose +# profile is off, BEFORE the recreate up. Asserted through the docker-stub call log. +# (1) Baseline, both nodes local: the reconcile list is exactly the (off) payout-confirm wallets — +# neither node container is touched. +seed_env +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" +SWLOG="$V/docker-switch.log" +: >"$SWLOG" +out="$(cd "$V" && DOCKER_LOG="$SWLOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" +assert_rc "baseline local-nodes apply exits 0" "$?" "0" +assert_contains "local nodes: only the off payout wallets reconcile" "$(cat "$SWLOG")" "compose rm -sf wallet-rpc tari-wallet" +# (2) tari local→remote (the #795 reproduction): the tari container joins the removal list. No +# re-seed — the committed local-mode .env from (1) makes this a real transition. +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T","mode":"remote","remote":{"host":"tari.example.com"}}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" +: >"$SWLOG" +out="$(cd "$V" && DOCKER_LOG="$SWLOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" +assert_rc "tari local-to-remote switch applies cleanly" "$?" "0" +assert_contains "switch removes the deactivated tari container" "$(cat "$SWLOG")" "compose rm -sf tari wallet-rpc tari-wallet" +# The removal must precede the recreate up — the point is the old node never runs beside the +# remote-mode p2pool, not that it eventually disappears. +assert_contains "tari removal happens before the recreate up" "$(sed '/compose up --pull never -d --remove-orphans/q' "$SWLOG")" "compose rm -sf tari wallet-rpc tari-wallet" +# (3) monerod rides the same guard on a monero local→remote switch. +seed_env +printf '{ "monero": {"mode":"remote","remote":{"host":"node.example"},"wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" +: >"$SWLOG" +out="$(cd "$V" && DOCKER_LOG="$SWLOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" +assert_rc "monero local-to-remote switch applies cleanly" "$?" "0" +assert_contains "switch removes the deactivated monerod container" "$(cat "$SWLOG")" "compose rm -sf monerod wallet-rpc tari-wallet" + echo "== black-box: monero.rpc_lan_access + prep_blocks_threads reflect into .env (#523) ==" # The rendered .env must match the config input. rpc_lan_access gates the monerod RPC bind: default # (unset) keeps it localhost-only; true opens it to the LAN. prep_blocks_threads overrides the From 64ca5ff61fb58393f55cd31d6dd192bb0a76189f Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 11:00:41 -0500 Subject: [PATCH 3/5] feat(dashboard): expected-vs-actual earnings card, in both views (#808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard computed both halves of "am I earning what this hashrate should?" but never put them side by side: estimates lived in the Advanced-only Earnings tabs, confirmed payouts in other tabs of the same card, XvB wins in a text log — and the Simple view carried no earnings figure at all. One compact card now shows, per stream, the expectation over a trailing window beside what was actually confirmed over that SAME window: - Monero over 7d, expected from the time-weighted routed P2Pool average over the window itself (new Metrics.p2pool_7d/_30d) — not the current 1h figure, so a fleet that changed mid-week is judged against what actually ran — with a percent-of-expected; - Tari over 30d in BLOCKS (solo merge-mining pays whole blocks; each confirmed payout is one), expected = hashrate x window / difficulty, shown to two significant digits because a fraction of a block per month is the normal case; - XvB wins over 30d beside XvB's published tier estimate, deliberately with no ratio — a win pays out through ordinary small payouts the payout table cannot attribute, so an "XvB XMR earned" figure would be an invention. The card carries neither view class (card-simple/card-advanced are disjoint), making it the one earnings surface both views share. Rows degrade honestly: payout confirmation off shows the config key to set, never a zero that reads as "earned nothing"; partial windows keep the confirmed roll-up's asterisk. confirmed_payouts_summary grows per-window counts (n_24h..n_30d) to feed the Tari block count; the summary builder reuses the same build_earnings dict the Earnings card renders, so the two surfaces cannot disagree. Closes #808 Co-Authored-By: Claude Fable 5 --- .../mining_dashboard/service/earnings.py | 11 +- .../mining_dashboard/service/metrics.py | 11 ++ .../web/static/components.mjs | 95 +++++++++++++ build/dashboard/mining_dashboard/web/views.py | 93 +++++++++++-- .../dashboard/tests/service/test_earnings.py | 10 ++ build/dashboard/tests/web/test_views.py | 126 ++++++++++++++++++ docs/dashboard.md | 40 ++++-- 7 files changed, 366 insertions(+), 20 deletions(-) diff --git a/build/dashboard/mining_dashboard/service/earnings.py b/build/dashboard/mining_dashboard/service/earnings.py index 19b01df6..e09151c8 100644 --- a/build/dashboard/mining_dashboard/service/earnings.py +++ b/build/dashboard/mining_dashboard/service/earnings.py @@ -120,7 +120,10 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x confirmed yet" (shows 0.000000). Windows: ``24h``/``7d``/``30d`` are trailing spans from ``now``, ``yesterday`` is the previous - full local day (:func:`previous_local_day`), ``all`` is everything stored. + full local day (:func:`previous_local_day`), ``all`` is everything stored. Each window also + carries its payout **count** (``n_24h`` … ``n_30d``, #808): for solo-merge-mined Tari a payout + IS a found block, so the count is the honest actual to hold against the expected block count — + the all-time count stays ``count``. ``partial`` marks each running window whose span begins before the oldest payout on record (``since_ts``) — the sum then covers only part of the window it is labelled with, so the UI @@ -135,20 +138,25 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x day, week, month = now - 86_400, now - 7 * 86_400, now - 30 * 86_400 y_start, y_end = previous_local_day(now) atomic = dict.fromkeys(("24h", "yesterday", "7d", "30d", "all"), 0) + counts = dict.fromkeys(("24h", "yesterday", "7d", "30d"), 0) for p in payouts: amt = p.get("amount_atomic", 0) or 0 ts = p.get("ts", 0) or 0 atomic["all"] += amt if ts >= month: atomic["30d"] += amt + counts["30d"] += 1 if ts >= week: atomic["7d"] += amt + counts["7d"] += 1 if ts >= day: atomic["24h"] += amt + counts["24h"] += 1 # Half-open [start, end) so a payout landing exactly at midnight belongs to the day it # starts, never to both days. if y_start <= ts < y_end: atomic["yesterday"] += amt + counts["yesterday"] += 1 # Only positive stamps: a row with a missing/zero ts can't date the history, and letting it # win the min would mark every window complete on the strength of a broken row. stamps = [t for t in (p.get("ts", 0) or 0 for p in payouts) if t > 0] @@ -164,4 +172,5 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x "partial": {w: since_ts <= 0 or since_ts > starts[w] for w in RUNNING_WINDOWS}, } summary.update({f"{unit}_{w}": v / divisor for w, v in atomic.items()}) + summary.update({f"n_{w}": v for w, v in counts.items()}) return summary diff --git a/build/dashboard/mining_dashboard/service/metrics.py b/build/dashboard/mining_dashboard/service/metrics.py index 3973c937..11cb5381 100644 --- a/build/dashboard/mining_dashboard/service/metrics.py +++ b/build/dashboard/mining_dashboard/service/metrics.py @@ -109,6 +109,13 @@ class Metrics: # Defaulted so direct Metrics(...) constructors needn't set them. tari_difficulty: float = 0.0 # Tari AUX-chain difficulty (not P2Pool sidechain, not Monero) tari_reward: float = 0.0 # Tari block reward, XTM (collector converts p2pool's µT figure) + # Window-matched routed averages for the expected-vs-actual card (#808): the expectation over + # a trailing 7d/30d window must use the hashrate that actually ran THAT window, not the + # current 1h figure — a fleet that grew or shrank mid-window would otherwise be judged + # against the wrong baseline. History retention (30 days) covers both windows. Defaulted so + # direct Metrics(...) constructors needn't set them. + p2pool_7d: float = 0.0 + p2pool_30d: float = 0.0 def build_metrics(latest_data, state_mgr, history=None): @@ -143,6 +150,8 @@ def build_metrics(latest_data, state_mgr, history=None): # stratum estimate or a total-minus-XvB subtraction (Issue #27). p2pool_1h = _avg_p2pool_over_window(history, 3600) p2pool_24h = _avg_p2pool_over_window(history, 86400) + p2pool_7d = _avg_p2pool_over_window(history, 7 * 86400) + p2pool_30d = _avg_p2pool_over_window(history, 30 * 86400) # The XvB raffle qualifies a tier on BOTH the 1h and 24h credited average, and terminates a win # if the 1h average drops below the round minimum — so Current Tier is the one cleared by the @@ -200,6 +209,8 @@ def build_metrics(latest_data, state_mgr, history=None): total_h15=total_h15, p2pool_1h=p2pool_1h, p2pool_24h=p2pool_24h, + p2pool_7d=p2pool_7d, + p2pool_30d=p2pool_30d, xvb_1h=xvb_1h, xvb_24h=xvb_24h, xvb_routed_1h=xvb_routed_1h, diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index 0bc02ebd..d7c865a1 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -585,6 +585,100 @@ function XvbTierBlock({ calc, hr, coeffDay, energy, est }) { `; } +// Expected vs actual (#808): the comparison the operator otherwise assembles by hand across the +// Earnings tabs, compact enough to be the Simple view's one earnings card. Deliberately carries +// NEITHER view class — card-simple and card-advanced are disjoint (each hides in the other's +// mode), and this is the one earnings surface both views share. +// The server rolls the rows up (build_earnings_vs_actual — window-matched +// hashrate, same confirmed roll-up as the Earnings card); this renders them. Per-stream windows +// match cadence: Monero XMR over 7d with a percent-of-expected, Tari BLOCKS over 30d (solo +// merge-mining pays whole blocks — a count, not a percent), XvB wins over 30d beside XvB's +// published estimate with deliberately NO ratio (a win pays out through ordinary small payouts +// the payout table cannot attribute). A stream with payout confirmation off shows the config key +// to set instead of a zero that would read as "earned nothing"; the card yields to nothing when +// no stream has anything to compare. +function ExpectedVsActualCard({ summary }) { + if (!summary) return null; + const { xmr, tari, xvb } = summary; + if (!xmr.available && !tari.available && !xvb.enabled) return null; + const partialMark = (row, text) => (row.partial ? text + " *" : text); + const anyPartial = (xmr.enabled && xmr.partial) || (tari.enabled && tari.partial); + const rows = []; + rows.push({ + label: "Monero (7d)", + expected: xmr.available ? formatXmr(xmr.expected_7d) : "—", + actual: !xmr.enabled + ? "set monero.view_key" + : partialMark(xmr, formatXmr(xmr.actual_7d) + (xmr.pct !== null ? ` (${xmr.pct}%)` : "")), + dim: !xmr.enabled, + title: + "Confirmed on-chain payouts over the trailing 7 days vs the linear expectation at your " + + "7-day average P2Pool hashrate. P2Pool pays when the pool finds blocks, so a single week " + + "swings with luck — a sustained gap is the signal worth checking, not one short window.", + }); + rows.push({ + label: "Tari (30d)", + // Two significant digits, not a fixed decimal: at real Tari difficulty the expectation is a + // FRACTION of a block per month, and "0.0" would erase exactly the number this row exists + // to show (≈ 0.0052 blocks is the honest, legible form). + expected: tari.available ? `≈ ${Number(tari.expected_blocks_30d.toPrecision(2))} blocks` : "—", + actual: !tari.enabled + ? "set tari.view_key" + : partialMark( + tari, + `${tari.blocks_30d} block${tari.blocks_30d === 1 ? "" : "s"} · ${formatXtm(tari.xtm_30d)}`, + ), + dim: !tari.enabled, + title: + "Tari is merge-mined SOLO: a confirmed payout IS a found block, all at once. Expected is " + + "your 30-day average hashrate × 30 days ÷ the Tari difficulty — at fractions of a block " + + "per month, zero found is the normal case, not a fault.", + }); + if (xvb.enabled) { + rows.push({ + label: "XvB wins (30d)", + expected: xvb.published_day !== null ? formatXmr(xvb.published_day) + "/day (XvB est.)" : "—", + actual: + `${xvb.wins_30d} win${xvb.wins_30d === 1 ? "" : "s"}` + + (xvb.last_win_ts ? ` · last ${formatAgo(xvb.last_win_ts)}` : ""), + dim: false, + title: + "Raffle wins recorded in the last 30 days. A win pays out through ordinary small " + + "payouts that cannot be told apart from P2Pool payouts, so no XvB XMR figure is shown. " + + "The published estimate is XvB's own raffle-wide expectation for your current tier — " + + "not a promise.", + }); + } + return html` +
+

Earnings — Expected vs Actual

+
+ + + + + + + + ${rows.map( + (r) => html` + + + + + `, + )} + +
ExpectedActual
${r.label}${r.expected}${r.actual}
+
+ ${ + anyPartial + ? html`

* covers only the payout history on record, not the window's full span.

` + : null + } +
`; +} + // P2Pool earnings calculator (Issue #12). A power-user card (Advanced view) over the metrics // layer that estimates XMR from *P2Pool mining only* — explicitly not XvB — plus the Tari the // same hashrate merge-mines alongside it (#117; "—" while merge-mining is inactive). The server @@ -1129,6 +1223,7 @@ function DashboardView({
<${Overview} state=${state} /> + <${ExpectedVsActualCard} summary=${state.earnings_summary} /> <${NodeStats} state=${state} /> <${XvBStats} state=${state} /> <${EarningsCard} earnings=${state.earnings} xvb=${state.xvb_calc} energy=${state.energy} /> diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py index 4b8407a0..fb233231 100644 --- a/build/dashboard/mining_dashboard/web/views.py +++ b/build/dashboard/mining_dashboard/web/views.py @@ -42,6 +42,7 @@ from mining_dashboard.service.earnings import ( ATOMIC_PER_XMR, MICRO_PER_XTM, + SECONDS_PER_DAY, confirmed_payouts_summary, tari_seconds_to_block_per_hs, xmr_per_hs_day, @@ -1570,6 +1571,70 @@ def build_earnings(data, metrics, payouts=None, tari_payouts=None, xvb_day=None) } +def build_earnings_vs_actual(metrics, earnings, raffle_wins, now=None): + """Expected-vs-actual summary — one row per income stream, for both views (#808). + + The comparison the operator otherwise assembles by hand across the Earnings tabs: the linear + expectation over a trailing window beside what the view-only wallets confirmed over the SAME + window (#381/#462). Expected uses the time-weighted routed P2Pool average over the window + itself (``metrics.p2pool_7d``/``p2pool_30d``), not the current 1h figure — a fleet that grew + or shrank mid-window would otherwise be judged against the wrong baseline. + + Per-stream windows match each stream's cadence. **Monero** compares XMR over 7d — P2Pool pays + whenever the pool finds blocks, so a week is long enough to mean something and short enough to + act on; ``pct`` is the actual as a percent of expected, and ``partial`` carries the confirmed + window's may-be-incomplete flag so a short history is never presented as a full week. + **Tari** compares BLOCK COUNTS over 30d: solo merge-mining pays whole blocks, so the honest + unit is blocks (expected = hashrate × window ÷ aux difficulty; actual = confirmed payout + count, each payout being a found block), with the XTM sum alongside — no percent, a count that + small is luck either way. **XvB** shows wins in the trailing 30d beside XvB's published + per-day estimate for the current tier — deliberately NO ratio: a win pays out through ordinary + small payouts the payout table cannot attribute, so actual XvB XMR is not separable from + P2Pool XMR, and the published figure is XvB's own raffle-wide expectation, not a promise. + + Raw numbers out; the client formats. ``actual``/``blocks``/``xtm`` are None while the matching + payout-confirmation feature is off — the card then hints at the view key instead of showing a + zero that would read as "earned nothing".""" + now = now if now is not None else time.time() + conf = earnings["confirmed"] + tari_conf = earnings["tari_confirmed"] + expected_xmr = earnings["coeff_day"] * metrics.p2pool_7d * 7 + xmr = { + "available": expected_xmr > 0, + "expected_7d": expected_xmr, + "enabled": bool(conf.get("enabled")), + "actual_7d": conf.get("xmr_7d") if conf.get("enabled") else None, + "partial": bool((conf.get("partial") or {}).get("7d")), + "pct": None, + } + if xmr["available"] and xmr["enabled"]: + xmr["pct"] = round((xmr["actual_7d"] or 0.0) / expected_xmr * 100) + # Expected Tari blocks over the window: hashrate × seconds ÷ difficulty (hashes-per-block). + # Gated on tari_mining like the calculator, so a dead merge-mine channel shows "—", not 0. + expected_blocks = ( + metrics.p2pool_30d * 30 * SECONDS_PER_DAY / metrics.tari_difficulty + if metrics.tari_mining and metrics.tari_difficulty > 0 + else 0.0 + ) + tari = { + "available": expected_blocks > 0, + "expected_blocks_30d": expected_blocks, + "enabled": bool(tari_conf.get("enabled")), + "blocks_30d": tari_conf.get("n_30d") if tari_conf.get("enabled") else None, + "xtm_30d": tari_conf.get("xtm_30d") if tari_conf.get("enabled") else None, + "partial": bool((tari_conf.get("partial") or {}).get("30d")), + } + stamps = [w.get("ts", 0) or 0 for w in (raffle_wins or [])] + xvb = { + "enabled": metrics.xvb_enabled, + "wins_30d": sum(1 for t in stamps if t >= now - 30 * SECONDS_PER_DAY), + "last_win_ts": max(stamps, default=0), + # XvB's published per-day estimate for the current tier — None unless fresh (#712). + "published_day": earnings["xvb_day"], + } + return {"xmr": xmr, "tari": tari, "xvb": xvb} + + # XvB tier calculator copy (#118). A tier is RAFFLE status, never an XMR payout, and the winner is # drawn at random among qualifiers — donating above the threshold buys zero extra win chance, so # the honest framing is cost, not reward. @@ -1762,6 +1827,18 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH mode_tok, p2p_tok, xvb_tok = _mode_palette(metrics.mode) pool_net = build_pool_network(data, metrics) + # Built once, consumed twice: the Earnings card reads the full payload, the expected-vs-actual + # summary (#808) rolls the same figures up — one build, so the two can't disagree. + earnings = build_earnings( + data, + metrics, + payouts=monero_payouts, + tari_payouts=( + state_mgr.get_payouts("tari") if config.TARI_PAYOUT_CONFIRM_ENABLED else None + ), + xvb_day=xvb_current_tier_reward_day(metrics, state_mgr), + ) + egress = egress_posture_from_config() # per-component egress route + privacy roll-up (#170) topology = ( topology_from_config() @@ -1809,17 +1886,11 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH "raffle_eligible": build_raffle_eligibility(metrics), "raffle_wins": build_raffle_log(raffle_wins), "proxy_workers": metrics.workers_online, - # Confirmed payouts (#381): pass the stored list when the feature is on, else None (feature - # off → earnings shows only the estimate). config read at call time so tests can flip it. - "earnings": build_earnings( - data, - metrics, - payouts=monero_payouts, - tari_payouts=( - state_mgr.get_payouts("tari") if config.TARI_PAYOUT_CONFIRM_ENABLED else None - ), - xvb_day=xvb_current_tier_reward_day(metrics, state_mgr), - ), + # Confirmed payouts (#381): the stored list rides in when the feature is on, else None + # (feature off → earnings shows only the estimate). Built above, before the payload. + "earnings": earnings, + # Expected vs actual, one row per stream (#808) — the Simple view's earnings figure. + "earnings_summary": build_earnings_vs_actual(metrics, earnings, raffle_wins), "xvb_calc": build_xvb_calc(metrics, state_mgr), # On a backup stack, the XvB controller state last pulled from the primary (#249) — held as # standby, adopted only at failover. None on a single stack (nothing pulls). Inspectable so diff --git a/build/dashboard/tests/service/test_earnings.py b/build/dashboard/tests/service/test_earnings.py index a178cb2b..b301e721 100644 --- a/build/dashboard/tests/service/test_earnings.py +++ b/build/dashboard/tests/service/test_earnings.py @@ -168,6 +168,10 @@ def test_empty_list_is_on_with_zeros(self): "xmr_7d": 0.0, "xmr_30d": 0.0, "xmr_all": 0.0, + "n_24h": 0, + "n_yesterday": 0, + "n_7d": 0, + "n_30d": 0, "last_ts": 0, "since_ts": 0, "partial": {"yesterday": True, "7d": True, "30d": True}, @@ -187,6 +191,11 @@ def test_trailing_windows_bucket_by_age(self): assert s["xmr_7d"] == 3.0 assert s["xmr_30d"] == 7.0 assert s["xmr_all"] == 15.0 + # Per-window counts mirror the sums (#808) — for Tari a payout IS a found block, so the + # count is the actual the expected-vs-actual card holds against the expected block count. + assert s["n_24h"] == 1 + assert s["n_7d"] == 2 + assert s["n_30d"] == 3 assert s["last_ts"] == self.NOW - 3_600 assert s["since_ts"] == self.NOW - 40 * 86_400 @@ -202,6 +211,7 @@ def test_yesterday_is_the_previous_calendar_day(self): ] s = confirmed_payouts_summary(payouts, now=self.NOW) assert s["xmr_yesterday"] == 6.0 + assert s["n_yesterday"] == 2 # The trailing 24h is a different span and deliberately disagrees: it reaches back into # yesterday noon and includes today's midnight payout. assert s["xmr_24h"] == 12.0 diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index 6a8cedf0..31d099c0 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -35,6 +35,7 @@ build_chart, build_disk_growth, build_earnings, + build_earnings_vs_actual, build_energy, build_hashrate, build_pool_network, @@ -1686,6 +1687,123 @@ def test_dash_fallbacks_when_unavailable(self): assert c["last_block"] == "Never" +# --- Expected vs actual earnings summary (#808) --------------------------------------- + + +def _summary_earnings(**over): + """A minimal build_earnings-shaped dict — only the keys build_earnings_vs_actual reads.""" + e = { + "coeff_day": 0.0, + "confirmed": {"enabled": False}, + "tari_confirmed": {"enabled": False}, + "xvb_day": None, + } + e.update(over) + return e + + +class TestEarningsVsActual: + NOW = 1_760_000_000 + + def test_xmr_expected_uses_the_window_average_and_pct_rounds(self): + # Expected = coeff_day × 7d-average hashrate × 7 — the WINDOW's average (p2pool_7d), not + # the current 1h figure, so a fleet that changed mid-window is judged against what ran. + e = _summary_earnings( + coeff_day=1e-8, + confirmed={"enabled": True, "xmr_7d": 0.28, "partial": {"7d": False}}, + ) + s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), e, [], now=self.NOW) + assert s["xmr"]["available"] is True + assert s["xmr"]["expected_7d"] == pytest.approx(1e-8 * 8000.0 * 7) # 0.00056·1000=0.56e-3 + assert s["xmr"]["actual_7d"] == 0.28 + assert s["xmr"]["pct"] == round(0.28 / (1e-8 * 8000.0 * 7) * 100) + assert s["xmr"]["partial"] is False + + def test_xmr_row_degrades_honestly(self): + # Estimate unavailable (no network figures) -> not available, and no pct even with + # confirmed payouts on; confirmation off -> actual/pct None, never a zero that would + # read as "earned nothing". + on = _summary_earnings(confirmed={"enabled": True, "xmr_7d": 0.5, "partial": {}}) + s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), on, [], now=self.NOW) + assert s["xmr"]["available"] is False and s["xmr"]["pct"] is None + off = _summary_earnings(coeff_day=1e-8) + s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), off, [], now=self.NOW) + assert s["xmr"]["enabled"] is False + assert s["xmr"]["actual_7d"] is None and s["xmr"]["pct"] is None + + def test_xmr_partial_flag_rides_the_confirmed_window(self): + e = _summary_earnings( + coeff_day=1e-8, + confirmed={"enabled": True, "xmr_7d": 0.1, "partial": {"7d": True}}, + ) + s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), e, [], now=self.NOW) + assert s["xmr"]["partial"] is True + + def test_tari_compares_block_counts_over_30d(self): + # Expected blocks = 30d-average hashrate × 30 days ÷ aux difficulty; actual = the + # confirmed payout count (solo merge-mining: a payout IS a found block), XTM alongside. + e = _summary_earnings( + tari_confirmed={ + "enabled": True, + "n_30d": 1, + "xtm_30d": 12_345.0, + "partial": {"30d": True}, + } + ) + m = _metrics(p2pool_30d=10_000.0, tari_difficulty=4.0e12, tari_mining=True) + s = build_earnings_vs_actual(m, e, [], now=self.NOW) + assert s["tari"]["available"] is True + assert s["tari"]["expected_blocks_30d"] == pytest.approx(10_000.0 * 30 * 86_400 / 4.0e12) + assert s["tari"]["blocks_30d"] == 1 + assert s["tari"]["xtm_30d"] == 12_345.0 + assert s["tari"]["partial"] is True + + def test_tari_gates_on_mining_and_difficulty(self): + # A dead merge-mine channel (tari_mining False) or missing difficulty -> unavailable, + # mirroring the calculator's gate, so no phantom expectation is shown. + e = _summary_earnings() + off = _metrics(p2pool_30d=10_000.0, tari_difficulty=4.0e12, tari_mining=False) + assert build_earnings_vs_actual(off, e, [], now=self.NOW)["tari"]["available"] is False + nodiff = _metrics(p2pool_30d=10_000.0, tari_difficulty=0.0, tari_mining=True) + assert build_earnings_vs_actual(nodiff, e, [], now=self.NOW)["tari"]["available"] is False + # Confirmation off -> counts None, not 0. + s = build_earnings_vs_actual( + _metrics(p2pool_30d=10_000.0, tari_difficulty=4.0e12, tari_mining=True), + e, + [], + now=self.NOW, + ) + assert s["tari"]["blocks_30d"] is None and s["tari"]["xtm_30d"] is None + + def test_xvb_counts_wins_in_the_trailing_30d_only(self): + wins = [ + {"ts": self.NOW - 40 * 86_400}, # outside the window + {"ts": self.NOW - 10 * 86_400}, + {"ts": self.NOW - 86_400}, + ] + s = build_earnings_vs_actual( + _metrics(), _summary_earnings(xvb_day=0.004), wins, now=self.NOW + ) + assert s["xvb"]["enabled"] is True + assert s["xvb"]["wins_30d"] == 2 + assert s["xvb"]["last_win_ts"] == self.NOW - 86_400 + # XvB's published figure passes through untouched — and stays None when not fresh (#712). + assert s["xvb"]["published_day"] == 0.004 + s = build_earnings_vs_actual( + _metrics(xvb_enabled=False), _summary_earnings(), [], now=self.NOW + ) + assert s["xvb"]["enabled"] is False and s["xvb"]["wins_30d"] == 0 + + def test_rides_build_state_end_to_end(self, monkeypatch): + # The summary must reach the top-level payload the client polls, built from the SAME + # earnings dict the Earnings card receives — one build, so the two cannot disagree. + monkeypatch.setattr(views.config, "PAYOUT_CONFIRM_ENABLED", False) + monkeypatch.setattr(views.config, "TARI_PAYOUT_CONFIRM_ENABLED", False) + st = build_state(_data(), _state_mgr(), "all") + assert set(st["earnings_summary"]) == {"xmr", "tari", "xvb"} + assert st["earnings_summary"]["xmr"]["enabled"] is False + + # --- Host address beside the hostname (Issue #119) ------------------------------------ @@ -1822,6 +1940,10 @@ def test_confirmed_enabled_but_empty(self): "xmr_7d": 0.0, "xmr_30d": 0.0, "xmr_all": 0.0, + "n_24h": 0, + "n_yesterday": 0, + "n_7d": 0, + "n_30d": 0, "last_ts": 0, "since_ts": 0, "partial": {"yesterday": True, "7d": True, "30d": True}, @@ -1842,6 +1964,10 @@ def test_tari_confirmed_enabled_but_empty(self): "xtm_7d": 0.0, "xtm_30d": 0.0, "xtm_all": 0.0, + "n_24h": 0, + "n_yesterday": 0, + "n_7d": 0, + "n_30d": 0, "last_ts": 0, "since_ts": 0, "partial": {"yesterday": True, "7d": True, "30d": True}, diff --git a/docs/dashboard.md b/docs/dashboard.md index 1755ebed..a6684d32 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -259,6 +259,29 @@ The summary panel pulls the key numbers together: | **Tari Mining** | Whether merge-mining of Tari is active and healthy. | | **Wallet XMR / Wallet TARI** | Your configured Monero and Tari payout addresses, one card each. | +### Earnings — Expected vs Actual + +One compact table, shown in **both** views, that answers "am I earning what this hashrate should?" +— the comparison you'd otherwise assemble by hand from the Earnings tabs. One row per income +stream, each over the window that suits how that stream pays: + +| Row | Expected | Actual | +|---|---|---| +| **Monero (7d)** | The linear estimate over the trailing 7 days, at your **7-day average** routed P2Pool hashrate — the hashrate that actually ran the window, so a fleet that grew or shrank mid-week is judged against what really ran. | Confirmed on-chain payouts over the same 7 days ([payout confirmation](#payout-confirmation)), with a percent-of-expected. | +| **Tari (30d)** | Expected **blocks** over the trailing 30 days (hashrate × window ÷ Tari difficulty). Tari is merge-mined solo, so blocks are the honest unit — at fractions of a block per month, zero found is the normal case, not a fault. | Blocks found (each confirmed Tari payout is one solo-found block) and the XTM they paid. | +| **XvB wins (30d)** | XvB's published per-day estimate for your current tier — XvB's own raffle-wide expectation, not a promise. | Raffle wins recorded in the window, and how long ago the last one landed. | + +Rows degrade honestly rather than guess: a stream with [payout confirmation](#payout-confirmation) +off shows the config key to set instead of a zero that would read as "earned nothing"; the XvB row +disappears when XvB is off; a `*` marks a window that reaches back past the oldest recorded payout. +The XvB row deliberately shows **no XMR figure and no percent**: a win pays out through ordinary +small payouts that can't be told apart from P2Pool payouts, so any "XvB XMR earned" number would be +an invention. + +Short windows swing with mining luck — P2Pool pays when the pool finds blocks, and solo Tari blocks +are rarer still. A sustained gap between expected and actual is the signal worth checking (workers +offline, a misconfigured payout address); a single quiet week is not. + ### Workers Alive A live table of every connected rig: worker name, IP, uptime, and per-worker hashrate over the 1m @@ -374,14 +397,15 @@ sync gaps, or other noise the average doesn't separate out. ### Simple vs. Advanced view A **Simple / Advanced** toggle sits above the chart. **Simple** (the default) shows the chart, the -Overview summary, and the worker table. **Advanced** swaps the Overview for cards that break out the -same data in more detail: **My P2Pool Node Stats**, **Global P2Pool Stats**, **XvB Donation Stats**, -**XMR Network**, **Tari Merge-Mining**, and the **P2Pool Earnings (estimated)** calculator below. The -choice is remembered across reloads. - -The earnings estimates and the XvB tier calculator live only in Advanced view. Simple view shows a -one-time banner pointing there; it goes away once you dismiss it or open Advanced view, and stays -away across reloads. +Overview summary, the [Earnings — Expected vs Actual](#earnings--expected-vs-actual) table, and the +worker table. **Advanced** swaps the Overview for cards that break out the same data in more +detail: **My P2Pool Node Stats**, **Global P2Pool Stats**, **XvB Donation Stats**, **XMR Network**, +**Tari Merge-Mining**, and the **P2Pool Earnings (estimated)** calculator below. The +expected-vs-actual table stays in both views. The choice is remembered across reloads. + +The what-if earnings calculator and the XvB tier calculator live only in Advanced view. Simple view +shows a one-time banner pointing there; it goes away once you dismiss it or open Advanced view, and +stays away across reloads. From 20a568a45fa81b198d3e2cfc3adb0ece11af8588 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 11:33:32 -0500 Subject: [PATCH 4/5] =?UTF-8?q?test(dashboard):=20close=20the=20#808=20rev?= =?UTF-8?q?iew=20gaps=20=E2=80=94=20fixture=20drift,=20window=20wiring,=20?= =?UTF-8?q?count=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-v1.16.0 repo review riders: - Regenerate the frontend fixture (state.json) — it predated #808's earnings_summary key, so ExpectedVsActualCard's null-guard silently skipped the card in EVERY frontend render test; add four node tests covering its real branches (both views, empty-yield, stale-payload survival, percent + partial asterisk + footnote, view-key hints, two-sig-fig Tari blocks, XvB win row and its gating). - Standing drift guard: a pytest pins the fixture's top-level key set to the live build_state() contract, so a new payload key without a fixture regen fails loudly instead of silently disabling coverage. - Assert build_metrics wires p2pool_7d/p2pool_30d with their OWN windows (a 3-day-old sample is in, a 40-day-old one is out) — a wrong window constant previously passed every test. - Trim the unconsumed n_24h/n_yesterday/n_7d payload keys — n_30d (the Tari block count) is the only consumer; the sibling assertion counting confirmed sub-panels is scoped to the panel class, not tooltip prose. - testing-strategy scenario F gains the #808 catalog row; two doc wording fixes (hashrate_drop_threshold phrasing, last-win recency). Co-Authored-By: Claude Fable 5 --- .../mining_dashboard/service/earnings.py | 17 ++--- .../tests/frontend/components.test.mjs | 70 ++++++++++++++++++- .../tests/frontend/fixtures/state.json | 24 +++++++ .../dashboard/tests/service/test_earnings.py | 10 +-- build/dashboard/tests/service/test_metrics.py | 16 +++++ build/dashboard/tests/web/test_views.py | 22 ++++-- docs/configuration.md | 2 +- docs/dashboard.md | 2 +- docs/dev/testing-strategy.md | 1 + 9 files changed, 135 insertions(+), 29 deletions(-) diff --git a/build/dashboard/mining_dashboard/service/earnings.py b/build/dashboard/mining_dashboard/service/earnings.py index e09151c8..cfdc4c6a 100644 --- a/build/dashboard/mining_dashboard/service/earnings.py +++ b/build/dashboard/mining_dashboard/service/earnings.py @@ -120,10 +120,10 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x confirmed yet" (shows 0.000000). Windows: ``24h``/``7d``/``30d`` are trailing spans from ``now``, ``yesterday`` is the previous - full local day (:func:`previous_local_day`), ``all`` is everything stored. Each window also - carries its payout **count** (``n_24h`` … ``n_30d``, #808): for solo-merge-mined Tari a payout - IS a found block, so the count is the honest actual to hold against the expected block count — - the all-time count stays ``count``. + full local day (:func:`previous_local_day`), ``all`` is everything stored. The 30d window also + carries its payout **count** (``n_30d``, #808): for solo-merge-mined Tari a payout IS a found + block, so that count is the honest actual to hold against the expected block count. Only the + consumed window gets a count — the all-time count stays ``count``. ``partial`` marks each running window whose span begins before the oldest payout on record (``since_ts``) — the sum then covers only part of the window it is labelled with, so the UI @@ -138,25 +138,22 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x day, week, month = now - 86_400, now - 7 * 86_400, now - 30 * 86_400 y_start, y_end = previous_local_day(now) atomic = dict.fromkeys(("24h", "yesterday", "7d", "30d", "all"), 0) - counts = dict.fromkeys(("24h", "yesterday", "7d", "30d"), 0) + n_30d = 0 for p in payouts: amt = p.get("amount_atomic", 0) or 0 ts = p.get("ts", 0) or 0 atomic["all"] += amt if ts >= month: atomic["30d"] += amt - counts["30d"] += 1 + n_30d += 1 if ts >= week: atomic["7d"] += amt - counts["7d"] += 1 if ts >= day: atomic["24h"] += amt - counts["24h"] += 1 # Half-open [start, end) so a payout landing exactly at midnight belongs to the day it # starts, never to both days. if y_start <= ts < y_end: atomic["yesterday"] += amt - counts["yesterday"] += 1 # Only positive stamps: a row with a missing/zero ts can't date the history, and letting it # win the min would mark every window complete on the strength of a broken row. stamps = [t for t in (p.get("ts", 0) or 0 for p in payouts) if t > 0] @@ -172,5 +169,5 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x "partial": {w: since_ts <= 0 or since_ts > starts[w] for w in RUNNING_WINDOWS}, } summary.update({f"{unit}_{w}": v / divisor for w, v in atomic.items()}) - summary.update({f"n_{w}": v for w, v in counts.items()}) + summary["n_30d"] = n_30d return summary diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs index 28742923..4caf471e 100644 --- a/build/dashboard/tests/frontend/components.test.mjs +++ b/build/dashboard/tests/frontend/components.test.mjs @@ -491,8 +491,9 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe partial: { yesterday: false, '7d': false, '30d': false }, }; let html = renderApp({ state: s }); - // One confirmed block per tab, populated from the summary keys through the coin formatters. - assert.equal(html.split('Confirmed on-chain').length - 1, 2); + // One confirmed block per tab (counted by the sub-panel's own class — the phrase also + // appears in the #808 card's tooltip prose), populated through the coin formatters. + assert.equal(html.split('confirmed-subhead').length - 1, 2); assert.match(html, /0\.250000 XMR/); // xmr_24h assert.match(html, /1\.7500 XMR/); // xmr_all assert.match(html, /4552\.1500 XTM/); // xtm_all @@ -513,7 +514,8 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe s.earnings.confirmed = { enabled: false }; s.earnings.tari_confirmed = { enabled: false }; html = renderApp({ state: s }); - assert.doesNotMatch(html, /Confirmed on-chain/); + // Scoped to the sub-panel class — the #808 card's tooltip prose reuses the phrase. + assert.doesNotMatch(html, /confirmed-subhead/); }); test('EarningsCard marks running windows the payout history does not fully cover (#787)', () => { @@ -986,3 +988,65 @@ test('StatsTable renders nothing when a rig reports no metrics (#507)', () => { assert.equal(render(StatsTable, { stats: [] }), ''); assert.equal(render(StatsTable, { stats: undefined }), ''); }); + +// --- ExpectedVsActualCard (#808) ------------------------------------------------------- + +test('ExpectedVsActualCard renders in BOTH views and yields to nothing when empty (#808)', () => { + // The base fixture has XvB on (one recorded win) → the card renders, Simple and Advanced. + assert.match(renderApp({ ui: { ...UI, view: 'simple' } }), /card-expected-vs-actual/); + assert.match(renderApp({ ui: { ...UI, view: 'advanced' } }), /card-expected-vs-actual/); + // Nothing to compare on any stream → the card yields entirely (no empty shell). + const s = clone(); + s.earnings_summary = { + xmr: { available: false, enabled: false }, + tari: { available: false, enabled: false }, + xvb: { enabled: false }, + }; + assert.doesNotMatch(renderApp({ state: s }), /card-expected-vs-actual/); + // A stale payload without the key (mid-upgrade poll) must not take the App down. + delete s.earnings_summary; + assert.match(renderApp({ state: s }), /Overview/); +}); + +test('ExpectedVsActualCard compares Monero with a percent and marks partial windows (#808)', () => { + const s = clone(); + s.earnings_summary.xmr = { + available: true, expected_7d: 0.0123, enabled: true, + actual_7d: 0.0101, partial: true, pct: 82, + }; + const out = renderApp({ state: s }); + assert.match(out, /Monero \(7d\)/); + assert.match(out, /0\.012300 XMR/); // expected, formatXmr precision + assert.match(out, /0\.010100 XMR \(82%\) \*/); // actual + pct + the partial asterisk + assert.match(out, /covers only the payout history on record/); // the footnote appears +}); + +test('ExpectedVsActualCard shows the config key when confirmation is off, never a zero (#808)', () => { + const s = clone(); + s.earnings_summary.xmr = { available: true, expected_7d: 0.0123, enabled: false, + actual_7d: null, partial: false, pct: null }; + s.earnings_summary.tari = { available: true, expected_blocks_30d: 0.0052, enabled: false, + blocks_30d: null, xtm_30d: null, partial: false }; + const out = renderApp({ state: s }); + assert.match(out, /set monero\.view_key/); + assert.match(out, /set tari\.view_key/); + assert.doesNotMatch(out, /0\.000000 XMR \(/); // no zero-actual masquerading as a figure + // Tari expectation keeps two significant digits — a fraction of a block must never read 0.0. + assert.match(out, /≈ 0\.0052 blocks/); +}); + +test('ExpectedVsActualCard counts Tari blocks and windows XvB wins (#808)', () => { + const s = clone(); + s.earnings_summary.tari = { available: true, expected_blocks_30d: 0.41, enabled: true, + blocks_30d: 1, xtm_30d: 12345.0, partial: false }; + s.earnings_summary.xvb = { enabled: true, wins_30d: 2, last_win_ts: 1735689000, + published_day: 0.06 }; + const out = renderApp({ state: s }); + assert.match(out, /≈ 0\.41 blocks/); + assert.match(out, /1 block · 12345\.0000 XTM/); // singular block, XTM alongside + assert.match(out, /2 wins · last /); + assert.match(out, /0\.060000 XMR\/day \(XvB est\.\)/); + // XvB off → the row disappears (no invented raffle framing on a non-XvB box). + s.earnings_summary.xvb = { enabled: false, wins_30d: 0, last_win_ts: 0, published_day: null }; + assert.doesNotMatch(renderApp({ state: s }), /XvB wins \(30d\)/); +}); diff --git a/build/dashboard/tests/frontend/fixtures/state.json b/build/dashboard/tests/frontend/fixtures/state.json index 9a04db01..f4e86c36 100644 --- a/build/dashboard/tests/frontend/fixtures/state.json +++ b/build/dashboard/tests/frontend/fixtures/state.json @@ -99,6 +99,30 @@ "tari_reward": 0, "xvb_day": null }, + "earnings_summary": { + "tari": { + "available": false, + "blocks_30d": null, + "enabled": false, + "expected_blocks_30d": 0.0, + "partial": false, + "xtm_30d": null + }, + "xmr": { + "actual_7d": null, + "available": false, + "enabled": false, + "expected_7d": 0.0, + "partial": false, + "pct": null + }, + "xvb": { + "enabled": true, + "last_win_ts": 1735689000, + "published_day": null, + "wins_30d": 1 + } + }, "egress": { "components": [ { diff --git a/build/dashboard/tests/service/test_earnings.py b/build/dashboard/tests/service/test_earnings.py index b301e721..5622c951 100644 --- a/build/dashboard/tests/service/test_earnings.py +++ b/build/dashboard/tests/service/test_earnings.py @@ -168,9 +168,6 @@ def test_empty_list_is_on_with_zeros(self): "xmr_7d": 0.0, "xmr_30d": 0.0, "xmr_all": 0.0, - "n_24h": 0, - "n_yesterday": 0, - "n_7d": 0, "n_30d": 0, "last_ts": 0, "since_ts": 0, @@ -191,10 +188,8 @@ def test_trailing_windows_bucket_by_age(self): assert s["xmr_7d"] == 3.0 assert s["xmr_30d"] == 7.0 assert s["xmr_all"] == 15.0 - # Per-window counts mirror the sums (#808) — for Tari a payout IS a found block, so the - # count is the actual the expected-vs-actual card holds against the expected block count. - assert s["n_24h"] == 1 - assert s["n_7d"] == 2 + # The 30d count rides along (#808) — for Tari a payout IS a found block, so this is the + # actual the expected-vs-actual card holds against the expected block count. assert s["n_30d"] == 3 assert s["last_ts"] == self.NOW - 3_600 assert s["since_ts"] == self.NOW - 40 * 86_400 @@ -211,7 +206,6 @@ def test_yesterday_is_the_previous_calendar_day(self): ] s = confirmed_payouts_summary(payouts, now=self.NOW) assert s["xmr_yesterday"] == 6.0 - assert s["n_yesterday"] == 2 # The trailing 24h is a different span and deliberately disagrees: it reaches back into # yesterday noon and includes today's midnight payout. assert s["xmr_24h"] == 12.0 diff --git a/build/dashboard/tests/service/test_metrics.py b/build/dashboard/tests/service/test_metrics.py index 19f8580a..71d7dd1b 100644 --- a/build/dashboard/tests/service/test_metrics.py +++ b/build/dashboard/tests/service/test_metrics.py @@ -133,6 +133,22 @@ def test_p2pool_averages_from_history(self): assert m.p2pool_1h == 900.0 assert m.p2pool_24h == 900.0 + def test_p2pool_window_averages_use_their_own_windows(self): + # The 7d/30d averages (#808) must reach past the 24h window — a sample 3 days old is + # invisible to 1h/24h but counted by both long windows; one 40 days old by neither. + # A wrong window constant (e.g. 7*3600) fails this: the 3-day sample would drop out. + now = time.time() + history = [ + {"timestamp": now - 30, "v": 900, "v_p2pool": 900, "v_xvb": 0}, + {"timestamp": now - 3 * 86_400, "v": 300, "v_p2pool": 300, "v_xvb": 0}, + {"timestamp": now - 40 * 86_400, "v": 999, "v_p2pool": 999, "v_xvb": 0}, + ] + m = build_metrics(_data(), _mgr(history=history)) + assert m.p2pool_1h == 900.0 + assert m.p2pool_24h == 900.0 + assert m.p2pool_7d == 600.0 # (900 + 300) / 2 — the 3-day sample is IN + assert m.p2pool_30d == 600.0 # the 40-day sample stays OUT + def test_xvb_credited_averages_from_stats(self): # Credited (XvB API avg_1h/24h) is kept independent — controller input + Advanced card (#156). m = build_metrics(_data(), _mgr(xvb={"avg_1h": 2100, "avg_24h": 2300})) diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index 31d099c0..1e541d1f 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -9,6 +9,7 @@ import json import time from dataclasses import replace +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -1803,6 +1804,21 @@ def test_rides_build_state_end_to_end(self, monkeypatch): assert set(st["earnings_summary"]) == {"xmr", "tari", "xvb"} assert st["earnings_summary"]["xmr"]["enabled"] is False + def test_frontend_fixture_carries_every_payload_key(self): + # Drift guard (#808 post-mortem): the frontend render tests run against + # tests/frontend/fixtures/state.json, "a real build_state() payload". When a new top-level + # key ships without regenerating the fixture, every component gated on that key silently + # renders nothing in the whole frontend suite — exactly how the #808 card briefly had zero + # render coverage. This pins the fixture's key set to the live contract. + fixture = Path(__file__).parent.parent / "frontend" / "fixtures" / "state.json" + fixture_keys = set(json.loads(fixture.read_text())) + live_keys = set(build_state(_data(), _state_mgr(), "all")) + missing = live_keys - fixture_keys + assert not missing, ( + f"frontend fixture is stale — regenerate with tests/frontend/fixtures/_gen_state.py " + f"(missing keys: {sorted(missing)})" + ) + # --- Host address beside the hostname (Issue #119) ------------------------------------ @@ -1940,9 +1956,6 @@ def test_confirmed_enabled_but_empty(self): "xmr_7d": 0.0, "xmr_30d": 0.0, "xmr_all": 0.0, - "n_24h": 0, - "n_yesterday": 0, - "n_7d": 0, "n_30d": 0, "last_ts": 0, "since_ts": 0, @@ -1964,9 +1977,6 @@ def test_tari_confirmed_enabled_but_empty(self): "xtm_7d": 0.0, "xtm_30d": 0.0, "xtm_all": 0.0, - "n_24h": 0, - "n_yesterday": 0, - "n_7d": 0, "n_30d": 0, "last_ts": 0, "since_ts": 0, diff --git a/docs/configuration.md b/docs/configuration.md index 364aa097..b2ff37d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -146,7 +146,7 @@ control channel will commit, are unaffected either way. | `dashboard.timezone` | `auto` | Timezone for the dashboard's timestamps and charts. `auto` = the host machine's timezone (auto-detected, falling back to `Etc/UTC`); set an IANA name (e.g. `America/Chicago`) to override. | | `dashboard.data_dir` | `auto` | Where the dashboard's database lives. `auto` = `./data/dashboard`, unless the four other `*.data_dir` all point under one parent directory — then the dashboard joins them at `/dashboard`, and the first `upgrade`/`apply` moves data from the old default there automatically (see [Data directories](#data-directories)). | | `dashboard.check_for_updates` | `true` _(on)_ | The dashboard periodically asks GitHub whether a newer Pithead release exists and, if so, shows a header badge linking to it (e.g. "New release v1.4.0 available"). Notify-only: it never updates anything; you upgrade with `./pithead upgrade` on your own terms. On by default because the check is routed over Tor (the same bridge SOCKS as the XvB fetch, `socks5h` so the DNS lookup goes through Tor too), so GitHub sees a Tor exit, not your IP. It's cached (hourly) and fails silently offline. The same flag also covers the per-worker [RigForge new-release badge](workers.md#rigforge-new-release-badge) — one more hourly, Tor-routed fetch of the latest RigForge release, compared against every rig's reported version. Set to `false` to opt out of both. See [Privacy › Runtime egress](privacy.md#runtime-egress). | -| `dashboard.hashrate_drop_threshold` | `50` | Percent below the recent normal that counts as a hashrate drop for the `hashrate_loss` alert and its chart marker. `50` = fire when total fleet hashrate falls to half its baseline. Raise it to catch smaller dips, lower it to only flag near-total outages. | +| `dashboard.hashrate_drop_threshold` | `50` | Percent of the recent baseline the total fleet hashrate must fall below to count as a drop, for the `hashrate_loss` alert and its chart marker. `50` = fire when hashrate falls to half its baseline. Raise it to catch smaller dips, lower it to only flag near-total outages. | | `dashboard.hashrate_drop_minutes` | `10` | How many minutes the hashrate must stay below the threshold before the drop is reported — the debounce that keeps a brief blip from pinging you. | | `dashboard.tari_required` | `true` | How much a Tari problem holds up the rest of the stack. Monero is required to mine, so its behavior isn't configurable: a monerod outage always rejects workers (stops `xmrig-proxy` so miners fail over to their backup pools), and the miner is always held until monerod finishes syncing. Tari is only needed for merge-mining, so this one flag decides how much it blocks. `true` (default): a Tari outage also rejects workers, the miner waits for Tari's initial sync too, and a Tari-only (re)sync shows the full-screen Sync view. `false` (non-blocking): keep mining Monero through a Tari outage, start mining as soon as Monero is synced (Tari finishes in the background), and keep the normal dashboard, with a `Tari syncing` indicator, instead of the takeover screen. | | `dashboard.fail_closed` | `false` _(off)_ | Whether an **unrecoverable** dashboard health failure holds the miner, not just alerts (#490). The dashboard is an observability layer, not the mining datapath (`xmrig-proxy` → `p2pool` → `monerod` runs independently of it), so the default (`false`) is alert-only: a loud Telegram/Healthchecks alert plus a dashboard badge, and mining keeps running. `true` reuses the same hold the sync gate uses (stops `p2pool` and `xmrig-proxy`) the moment one of two narrow conditions hits, and starts them again once it clears — no restart needed: the dashboard's SQLite database failed to self-heal after its own auto-recovery attempt (disk full, permissions — see `db_reset` in [Dashboard › Node status & failover](dashboard.md#node-status--failover)), or the `dashboard` container itself is crash-looping. A transient write blip, a slow query, or a single failed external fetch never trips it — only these two genuinely non-transient failures do. Set it `true` only if a stale dashboard is a bigger risk to you than a held miner; a false positive here idles the fleet. | diff --git a/docs/dashboard.md b/docs/dashboard.md index a6684d32..59241edb 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -269,7 +269,7 @@ stream, each over the window that suits how that stream pays: |---|---|---| | **Monero (7d)** | The linear estimate over the trailing 7 days, at your **7-day average** routed P2Pool hashrate — the hashrate that actually ran the window, so a fleet that grew or shrank mid-week is judged against what really ran. | Confirmed on-chain payouts over the same 7 days ([payout confirmation](#payout-confirmation)), with a percent-of-expected. | | **Tari (30d)** | Expected **blocks** over the trailing 30 days (hashrate × window ÷ Tari difficulty). Tari is merge-mined solo, so blocks are the honest unit — at fractions of a block per month, zero found is the normal case, not a fault. | Blocks found (each confirmed Tari payout is one solo-found block) and the XTM they paid. | -| **XvB wins (30d)** | XvB's published per-day estimate for your current tier — XvB's own raffle-wide expectation, not a promise. | Raffle wins recorded in the window, and how long ago the last one landed. | +| **XvB wins (30d)** | XvB's published per-day estimate for your current tier — XvB's own raffle-wide expectation, not a promise. | Raffle wins recorded in the window, and how long ago the most recent win on record landed (which can predate the window). | Rows degrade honestly rather than guess: a stream with [payout confirmation](#payout-confirmation) off shows the config key to set instead of a zero that would read as "earned nothing"; the XvB row diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md index 1423b99b..defe975a 100644 --- a/docs/dev/testing-strategy.md +++ b/docs/dev/testing-strategy.md @@ -105,6 +105,7 @@ The deploy-time axes — each changes a real runtime path. Full table and assert | `/metrics` Prometheus exposition (#379), through Caddy + basic_auth | scrape | 1 ✅ (format) · 4 ▶ (`--check`) | | `share_stats` series populated on a mining box (#116) | polls land | 1 ✅ (shape) · 4 ▶ (`--check`) | | Confirmed running earnings (#787): yesterday as a **calendar** day vs the trailing 24h/7d/30d spans, the DST-length day boundary, partial marking when a window outruns the recorded payout history, and one roll-up feeding both the dashboard card and `/earnings` | stored payouts | 1 ✅ (`test_earnings.py`, `test_telegram_commands.py`, `components.test.mjs`) | +| Expected-vs-actual earnings summary (#808): window-matched 7d/30d hashrate basis, per-stream gates and honest degradation, Tari block counts, XvB win windowing, and the card's render branches in both views | metrics + stored payouts | 1 ✅ (`test_views.py` `TestEarningsVsActual`, `test_metrics.py`, `components.test.mjs`) | | Dashboard reads correct live state on a real stack | real daemons | 4 ▶ | ### G. CLI lifecycle (`pithead`) From b26d91c15489ac230dc32d8a1e9b907cf6f3612b Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 11:47:21 -0500 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.16.0=20prep=20?= =?UTF-8?q?=E2=80=94=20version=20bump=20+=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ VERSION | 2 +- build/dashboard/pyproject.toml | 2 +- build/dashboard/uv.lock | 2 +- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbaa05e9..965a8d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,37 @@ Pithead ships as **one product, one version** — the version lives in the top-l [`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md). +## [1.16.0] - 2026-08-01 + +### Added + +- **Expected vs actual earnings, at a glance and in both views (#808).** A new dashboard card + puts each income stream's estimate beside what the view-only wallets actually confirmed, over + the same window: Monero XMR over the trailing 7 days — expected from the 7-day average routed + P2Pool hashrate, the hashrate that actually ran the window — with a percent-of-expected; Tari + in blocks over 30 days (solo merge-mining pays whole blocks, and a fraction of a block per + month is the normal expectation, shown as such); and XvB raffle wins in the window beside XvB's + published estimate for the current tier. The card appears in the Simple view too — its first + earnings figure. Streams without payout confirmation show the config key to set rather than a + zero, and windows that outrun the recorded payout history keep their partial marking. No XvB + XMR figure is shown anywhere: a win pays out through ordinary small payouts the payout table + cannot attribute, so any such number would be an invention. + +### Fixed + +- **A local→remote node switch now retires the old node container (#795).** Switching + `monero.mode` or `tari.mode` to `remote` dropped the node's compose profile as promised, but + compose never removes the running container of a profile-disabled service — it is not an + orphan — so the old node kept running, offline and re-syncing, against a remote-mode config. + Every `up` now removes the containers of profile-disabled services before recreating anything, + which also heals a box already stuck in that state on its next `apply` or `up`. On-disk chain + data is untouched, exactly as the apply preview says. +- **The tari-wallet container can now actually report healthy (#777).** Its liveness probe + grepped for the full `minotari_console_wallet` process name, but `ps` truncates the command + column at 15 characters, so the check could never match and the container reported unhealthy + forever — a permanently-firing container alert for the first user of Tari payout confirmation. + The probe now matches the truncated form `ps` actually prints. + ## [1.15.0] - 2026-08-01 ### Added diff --git a/VERSION b/VERSION index 141f2e80..15b989e3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.15.0 +1.16.0 diff --git a/build/dashboard/pyproject.toml b/build/dashboard/pyproject.toml index 56c04769..9adef578 100644 --- a/build/dashboard/pyproject.toml +++ b/build/dashboard/pyproject.toml @@ -7,7 +7,7 @@ name = "mining-dashboard" # Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version # (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version # from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only. -version = "1.15.0" +version = "1.16.0" description = "Monitoring dashboard and XvB switching engine for Pithead" readme = "README.md" requires-python = ">=3.11" diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock index 5d3316d6..d9c97932 100644 --- a/build/dashboard/uv.lock +++ b/build/dashboard/uv.lock @@ -786,7 +786,7 @@ wheels = [ [[package]] name = "mining-dashboard" -version = "1.15.0" +version = "1.16.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },