From e2d9f745e09b7db154742b856447622d8d6aef70 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 15:03:18 -0500 Subject: [PATCH 1/3] =?UTF-8?q?fix(dashboard):=20expected-vs-actual=20card?= =?UTF-8?q?=20=E2=80=94=20one=2030d=20window,=20combined=20Monero+XvB,=20n?= =?UTF-8?q?o=20scrolling=20(#817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator feedback on the live card: - ONE shared trailing 30d window for every row — 7d-beside-30d read as inconsistent. Metrics drops the now-unconsumed p2pool_7d. - Monero and XvB become ONE combined row on BOTH sides: an XvB win pays out through ordinary payouts the payout table cannot attribute, so the confirmed actual always contains win XMR — a P2Pool-only expectation overshoots on every winning box. Expected now folds XvB's published per-day estimate (x30, only when fresh; includes_xvb drives the label), and pct compares like with like. The wins row keeps only its count + recency; its XMR lives in the combined row. - The card never scrolls in either view: the est-scroll wrapper is gone, and the table wraps via eva-table (table-layout fixed, because WebKit's auto layout ignores overflow-wrap in min-content sizing and overflowed anyway — caught in the live harness). - Grid-wide: .grid { align-items: start } — every short card previously stretched to its row's tallest neighbour, rendering mostly-empty bordered boxes across the Advanced grid. Verified in the running frontend at 1400px and 375px: no card overflow, no body scroll, cards hug content. Closes #817 Co-Authored-By: Claude Fable 5 --- .../mining_dashboard/service/metrics.py | 13 ++-- .../web/static/components.mjs | 59 ++++++++++--------- .../mining_dashboard/web/static/dashboard.css | 18 ++++++ build/dashboard/mining_dashboard/web/views.py | 43 ++++++++------ .../tests/frontend/components.test.mjs | 25 ++++---- .../tests/frontend/fixtures/state.json | 6 +- build/dashboard/tests/service/test_metrics.py | 11 ++-- build/dashboard/tests/web/test_views.py | 56 +++++++++++++----- docs/dashboard.md | 25 ++++---- docs/dev/testing-strategy.md | 2 +- 10 files changed, 157 insertions(+), 101 deletions(-) diff --git a/build/dashboard/mining_dashboard/service/metrics.py b/build/dashboard/mining_dashboard/service/metrics.py index 11cb5381..a91ee90c 100644 --- a/build/dashboard/mining_dashboard/service/metrics.py +++ b/build/dashboard/mining_dashboard/service/metrics.py @@ -109,12 +109,11 @@ 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 + # Window-matched routed average for the expected-vs-actual card (#808, one shared 30d window + # since #817): the expectation over the trailing 30d 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 it + # exactly. Defaulted so direct Metrics(...) constructors needn't set it. p2pool_30d: float = 0.0 @@ -150,7 +149,6 @@ 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 @@ -209,7 +207,6 @@ 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, diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index d7c865a1..7c5e6c72 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -585,18 +585,20 @@ 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. +// Expected vs actual (#808, reshaped by #817): 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. ONE shared 30d window for every +// stream (#817). Monero and XvB are ONE combined row: a win pays out through ordinary small +// payouts the payout table cannot attribute, so the confirmed actual already contains XvB XMR — +// the expectation folds XvB's published estimate in so pct compares like with like. Tari stays +// BLOCKS (solo merge-mining pays whole blocks — a count, not a percent); the XvB row keeps only +// its win count, its XMR lives in the combined row by construction. 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. The table wraps +// (eva-table) instead of panning — this card never scrolls in either view (#817). function ExpectedVsActualCard({ summary }) { if (!summary) return null; const { xmr, tari, xvb } = summary; @@ -605,16 +607,22 @@ function ExpectedVsActualCard({ summary }) { const anyPartial = (xmr.enabled && xmr.partial) || (tari.enabled && tari.partial); const rows = []; rows.push({ - label: "Monero (7d)", - expected: xmr.available ? formatXmr(xmr.expected_7d) : "—", + label: xmr.includes_xvb ? "Monero + XvB (30d)" : "Monero (30d)", + expected: xmr.available ? formatXmr(xmr.expected_30d) : "—", actual: !xmr.enabled ? "set monero.view_key" - : partialMark(xmr, formatXmr(xmr.actual_7d) + (xmr.pct !== null ? ` (${xmr.pct}%)` : "")), + : partialMark(xmr, formatXmr(xmr.actual_30d) + (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.", + title: xmr.includes_xvb + ? "Confirmed on-chain payouts over the trailing 30 days vs the P2Pool linear expectation " + + "at your 30-day average hashrate PLUS XvB's published estimate for your tier — combined " + + "on both sides, because an XvB win pays out through ordinary payouts that cannot be " + + "told apart from P2Pool payouts. Payouts swing with luck; a sustained gap is the " + + "signal worth checking, not one window." + : "Confirmed on-chain payouts over the trailing 30 days vs the linear expectation at " + + "your 30-day average P2Pool hashrate. Any XvB win payouts land in the actual too — " + + "they cannot be told apart from P2Pool payouts. Payouts swing with luck; a sustained " + + "gap is the signal worth checking, not one window.", }); rows.push({ label: "Tari (30d)", @@ -637,23 +645,21 @@ function ExpectedVsActualCard({ summary }) { if (xvb.enabled) { rows.push({ label: "XvB wins (30d)", - expected: xvb.published_day !== null ? formatXmr(xvb.published_day) + "/day (XvB est.)" : "—", + expected: "—", 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.", + "Raffle wins recorded in the last 30 days (last-win recency can predate the window). " + + "A win's XMR arrives through ordinary payouts, so its value is counted in the " + + "Monero + XvB row above — this row tracks only that wins keep landing.", }); } return html`

Earnings — Expected vs Actual

-
- +
@@ -670,7 +676,6 @@ function ExpectedVsActualCard({ summary }) { )}
Expected
-
${ anyPartial ? html`

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

` diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index c0131de4..17bcdade 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -95,6 +95,9 @@ body { grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; margin-bottom: 20px; + /* Cards hug their content instead of stretching to the row's tallest neighbour (#817) — the + * default stretch rendered short cards as mostly-empty bordered boxes across the grid. */ + align-items: start; } /* Section label inside the Advanced card grid (#159): the grid itself has no visual grouping, so * these mark the "mine" -> "the world" transition the layout comment describes. Spans the full @@ -488,6 +491,21 @@ tr:last-child td { .est-scroll { overflow-x: auto; } +/* Expected-vs-actual card (#808/#817): three labelled rows, not a shared-precision numeric + * grid — values wrap instead of panning (this card must never scroll in either view) and the + * table fills the card. table-layout fixed, because in auto layout a cell's min-content width + * ignores overflow-wrap break opportunities (WebKit) and the table overflows anyway. */ +.eva-table { + width: 100%; + table-layout: fixed; +} +.eva-table thead th:first-child { + width: 26%; /* the row-label column; the two value columns split the rest */ +} +.eva-table td { + white-space: normal; + overflow-wrap: anywhere; +} .est-heading { margin: 14px 0 6px 0; font-size: 0.7rem; diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py index fb233231..5a91b3c6 100644 --- a/build/dashboard/mining_dashboard/web/views.py +++ b/build/dashboard/mining_dashboard/web/views.py @@ -1580,17 +1580,19 @@ def build_earnings_vs_actual(metrics, earnings, raffle_wins, now=None): 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. + Every stream shares ONE trailing 30d window (#817 — mixed windows read as inconsistent, and + 30d is the shortest span that means something for all three). **Monero + XvB** is ONE + combined row: an XvB win pays out through ordinary small payouts the payout table cannot + attribute, so the confirmed actual already contains XvB XMR — comparing it against a + P2Pool-only expectation overshoots on a winning box (#817). Both sides count XvB instead: + expected = the P2Pool linear model + XvB's published per-day estimate × 30 (folded only when + fresh; ``includes_xvb`` tells the client which label to draw), actual = every confirmed + payout; ``pct`` compares like with like, and ``partial`` carries the confirmed window's + may-be-incomplete flag. **Tari** compares BLOCK COUNTS: 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** keeps only its win count and last-win + recency — its XMR value lives in the combined row by construction. 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 @@ -1598,17 +1600,22 @@ def build_earnings_vs_actual(metrics, earnings, raffle_wins, now=None): 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 + expected_p2pool = earnings["coeff_day"] * metrics.p2pool_30d * 30 + expected_xvb = (earnings["xvb_day"] or 0.0) * 30 if metrics.xvb_enabled else 0.0 xmr = { - "available": expected_xmr > 0, - "expected_7d": expected_xmr, + "available": expected_p2pool > 0, + "expected_30d": expected_p2pool + expected_xvb, + # True when XvB's published estimate is folded into expected — drives the row label. The + # actual ALWAYS contains any win payouts; when XvB is on but the published figure is + # stale, the tooltip owns the asymmetry rather than a fabricated estimate filling it. + "includes_xvb": expected_xvb > 0, "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")), + "actual_30d": conf.get("xmr_30d") if conf.get("enabled") else None, + "partial": bool((conf.get("partial") or {}).get("30d")), "pct": None, } if xmr["available"] and xmr["enabled"]: - xmr["pct"] = round((xmr["actual_7d"] or 0.0) / expected_xmr * 100) + xmr["pct"] = round((xmr["actual_30d"] or 0.0) / xmr["expected_30d"] * 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 = ( @@ -1629,8 +1636,6 @@ def build_earnings_vs_actual(metrics, earnings, raffle_wins, now=None): "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} diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs index 4caf471e..d60c0f3f 100644 --- a/build/dashboard/tests/frontend/components.test.mjs +++ b/build/dashboard/tests/frontend/components.test.mjs @@ -1008,23 +1008,28 @@ test('ExpectedVsActualCard renders in BOTH views and yields to nothing when empt assert.match(renderApp({ state: s }), /Overview/); }); -test('ExpectedVsActualCard compares Monero with a percent and marks partial windows (#808)', () => { +test('ExpectedVsActualCard compares combined Monero+XvB with a percent and partial marks (#817)', () => { const s = clone(); s.earnings_summary.xmr = { - available: true, expected_7d: 0.0123, enabled: true, - actual_7d: 0.0101, partial: true, pct: 82, + available: true, expected_30d: 0.0123, includes_xvb: true, enabled: true, + actual_30d: 0.0101, partial: true, pct: 82, }; const out = renderApp({ state: s }); - assert.match(out, /Monero \(7d\)/); + assert.match(out, /Monero \+ XvB \(30d\)/); // combined label when the estimate is folded 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 + // The card never pans (#817): wrapping table, no scroll wrapper around it. + assert.match(out, /eva-table/); + // Without a fresh published estimate the label honestly drops the "+ XvB". + s.earnings_summary.xmr.includes_xvb = false; + assert.match(renderApp({ state: s }), /Monero \(30d\)/); }); 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.xmr = { available: true, expected_30d: 0.0123, includes_xvb: false, + enabled: false, actual_30d: 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 }); @@ -1039,14 +1044,14 @@ 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 }; + s.earnings_summary.xvb = { enabled: true, wins_30d: 2, last_win_ts: 1735689000 }; 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\.\)/); + // Since #817 the wins row carries NO XMR figure — its value lives in the combined row. + assert.doesNotMatch(out, /XMR\/day/); // 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 }; + s.earnings_summary.xvb = { enabled: false, wins_30d: 0, last_win_ts: 0 }; 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 f4e86c36..1c97da9f 100644 --- a/build/dashboard/tests/frontend/fixtures/state.json +++ b/build/dashboard/tests/frontend/fixtures/state.json @@ -109,17 +109,17 @@ "xtm_30d": null }, "xmr": { - "actual_7d": null, + "actual_30d": null, "available": false, "enabled": false, - "expected_7d": 0.0, + "expected_30d": 0.0, + "includes_xvb": false, "partial": false, "pct": null }, "xvb": { "enabled": true, "last_win_ts": 1735689000, - "published_day": null, "wins_30d": 1 } }, diff --git a/build/dashboard/tests/service/test_metrics.py b/build/dashboard/tests/service/test_metrics.py index 71d7dd1b..aca2e243 100644 --- a/build/dashboard/tests/service/test_metrics.py +++ b/build/dashboard/tests/service/test_metrics.py @@ -133,10 +133,10 @@ 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. + def test_p2pool_30d_average_uses_its_own_window(self): + # The 30d average (#808) must reach past the 24h window — a sample 3 days old is + # invisible to 1h/24h but counted by the 30d window; one 40 days old by neither. + # A wrong window constant (e.g. 30*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}, @@ -146,8 +146,7 @@ def test_p2pool_window_averages_use_their_own_windows(self): 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 + assert m.p2pool_30d == 600.0 # (900 + 300) / 2 — 3-day IN, 40-day OUT def test_xvb_credited_averages_from_stats(self): # Credited (XvB API avg_1h/24h) is kept independent — controller input + Advanced card (#156). diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index 1e541d1f..cbbee902 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -1706,38 +1706,62 @@ def _summary_earnings(**over): 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. + def test_combined_expected_folds_xvb_and_uses_the_window_average(self): + # ONE combined row (#817): expected = P2Pool linear (coeff_day × 30d-average × 30 — the + # WINDOW's average, not the current 1h figure) + XvB's published per-day estimate × 30, + # because the confirmed actual inevitably contains XvB win payouts. pct compares the + # combined actual against the combined expectation. e = _summary_earnings( coeff_day=1e-8, - confirmed={"enabled": True, "xmr_7d": 0.28, "partial": {"7d": False}}, + xvb_day=0.001, + confirmed={"enabled": True, "xmr_30d": 0.28, "partial": {"30d": False}}, ) - s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), e, [], now=self.NOW) + s = build_earnings_vs_actual(_metrics(p2pool_30d=8000.0), e, [], now=self.NOW) + expected = 1e-8 * 8000.0 * 30 + 0.001 * 30 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"]["includes_xvb"] is True + assert s["xmr"]["expected_30d"] == pytest.approx(expected) + assert s["xmr"]["actual_30d"] == 0.28 + assert s["xmr"]["pct"] == round(0.28 / expected * 100) assert s["xmr"]["partial"] is False + def test_combined_row_without_a_fresh_xvb_estimate_stays_p2pool_only(self): + # XvB on but no fresh published figure (#712) -> nothing is fabricated: expected stays + # P2Pool-only and includes_xvb False (the client label drops "+ XvB"; the tooltip owns + # the fact that win payouts still land in the actual). XvB disabled behaves the same. + e = _summary_earnings( + coeff_day=1e-8, + confirmed={"enabled": True, "xmr_30d": 0.1, "partial": {}}, + ) + s = build_earnings_vs_actual(_metrics(p2pool_30d=8000.0), e, [], now=self.NOW) + assert s["xmr"]["includes_xvb"] is False + assert s["xmr"]["expected_30d"] == pytest.approx(1e-8 * 8000.0 * 30) + s = build_earnings_vs_actual( + _metrics(p2pool_30d=8000.0, xvb_enabled=False), + _summary_earnings(coeff_day=1e-8, xvb_day=0.001), + [], + now=self.NOW, + ) + assert s["xmr"]["includes_xvb"] is False # disabled XvB never folds its estimate in + 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) + on = _summary_earnings(confirmed={"enabled": True, "xmr_30d": 0.5, "partial": {}}) + s = build_earnings_vs_actual(_metrics(p2pool_30d=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) + s = build_earnings_vs_actual(_metrics(p2pool_30d=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 + assert s["xmr"]["actual_30d"] 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}}, + confirmed={"enabled": True, "xmr_30d": 0.1, "partial": {"30d": True}}, ) - s = build_earnings_vs_actual(_metrics(p2pool_7d=8000.0), e, [], now=self.NOW) + s = build_earnings_vs_actual(_metrics(p2pool_30d=8000.0), e, [], now=self.NOW) assert s["xmr"]["partial"] is True def test_tari_compares_block_counts_over_30d(self): @@ -1788,8 +1812,8 @@ def test_xvb_counts_wins_in_the_trailing_30d_only(self): 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 + # No published_day here since #817 — the estimate lives in the combined row's expected. + assert "published_day" not in s["xvb"] s = build_earnings_vs_actual( _metrics(xvb_enabled=False), _summary_earnings(), [], now=self.NOW ) diff --git a/docs/dashboard.md b/docs/dashboard.md index 59241edb..efb05dfe 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -262,25 +262,28 @@ The summary panel pulls the key numbers together: ### 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: +— the comparison you'd otherwise assemble by hand from the Earnings tabs. Every row shares one +trailing **30-day** window: | 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 most recent win on record landed (which can predate the window). | +| **Monero + XvB (30d)** | The P2Pool linear estimate at your **30-day average** routed hashrate — the hashrate that actually ran the window — **plus** XvB's published per-day estimate for your current tier, when XvB is on and the estimate is fresh (the label drops "+ XvB" otherwise). | All confirmed on-chain payouts over the window ([payout confirmation](#payout-confirmation)), with a percent-of-expected. | +| **Tari (30d)** | Expected **blocks** (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)** | — | Raffle wins recorded in the window, and how long ago the most recent win on record landed (which can predate the window). | + +Monero and XvB share one row **on both sides** deliberately: an XvB win pays out through ordinary +small payouts that can't be told apart from P2Pool payouts, so the confirmed actual always +contains the wins' XMR — a P2Pool-only expectation would overshoot on every winning box. Folding +XvB's published estimate into the expected side keeps the percent comparing like with like; the +wins row tracks only that wins keep landing. 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. +Payouts 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 stretch is not. ### Workers Alive diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md index defe975a..863f8ae6 100644 --- a/docs/dev/testing-strategy.md +++ b/docs/dev/testing-strategy.md @@ -105,7 +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`) | +| Expected-vs-actual earnings summary (#808/#817): one shared 30d window, the combined Monero+XvB row (estimate folded on the expected side because win payouts are inseparable on the actual side), 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 c01c49d4f8660ea6b318f3925a543c974c92e064 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 15:05:13 -0500 Subject: [PATCH 2/3] fix(dashboard): define the justify-between utility the Worker Inspect header always used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header markup (flex items-center justify-between) predates this fix, but the utility class was never defined in dashboard.css — the flex row packed left and the close button sat beside the title instead of the top-right corner. Co-Authored-By: Claude Fable 5 --- build/dashboard/mining_dashboard/web/static/dashboard.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index 17bcdade..e6b03348 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -904,6 +904,11 @@ button.upgrade-btn { .items-center { align-items: center; } +/* #817: the Worker Inspect header always used this class, but it was never defined — the flex + * row packed left and the ✕ close button sat beside the title instead of the top-right corner. */ +.justify-between { + justify-content: space-between; +} .text-center { text-align: center; } From dc9f694fe28c0781eed195d1d37565cc31b40d04 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 15:16:42 -0500 Subject: [PATCH 3/3] fix(dashboard): clamp a negative published XvB estimate out of the fold (#817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifier findings on the combined row: xvb_day is upstream-published — a hostile/corrupt negative dragged the combined expectation toward (or past) zero while 'available' stayed True, inverting pct or zeroing the denominator. It now folds as 0 (a negative estimate is meaningless) and the label honestly stays P2Pool-only. Tests: the negative-fold guard, plus the frontend assertion that the scroll wrapper stays gone (scoped to the card's own markup). Co-Authored-By: Claude Fable 5 --- build/dashboard/mining_dashboard/web/views.py | 5 ++++- build/dashboard/tests/frontend/components.test.mjs | 4 +++- build/dashboard/tests/web/test_views.py | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py index 5a91b3c6..0466bf8b 100644 --- a/build/dashboard/mining_dashboard/web/views.py +++ b/build/dashboard/mining_dashboard/web/views.py @@ -1601,7 +1601,10 @@ def build_earnings_vs_actual(metrics, earnings, raffle_wins, now=None): conf = earnings["confirmed"] tari_conf = earnings["tari_confirmed"] expected_p2pool = earnings["coeff_day"] * metrics.p2pool_30d * 30 - expected_xvb = (earnings["xvb_day"] or 0.0) * 30 if metrics.xvb_enabled else 0.0 + # Clamped: xvb_day is upstream-published (XvB's API); a hostile/corrupt negative would drag + # the combined expectation to <= 0 while `available` stays True — an inverted pct at best, a + # zero denominator at worst. A negative estimate is meaningless, so it folds as 0. + expected_xvb = max(0.0, earnings["xvb_day"] or 0.0) * 30 if metrics.xvb_enabled else 0.0 xmr = { "available": expected_p2pool > 0, "expected_30d": expected_p2pool + expected_xvb, diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs index d60c0f3f..74094224 100644 --- a/build/dashboard/tests/frontend/components.test.mjs +++ b/build/dashboard/tests/frontend/components.test.mjs @@ -1019,8 +1019,10 @@ test('ExpectedVsActualCard compares combined Monero+XvB with a percent and parti 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 - // The card never pans (#817): wrapping table, no scroll wrapper around it. + // The card never pans (#817): wrapping table, and the scroll wrapper must stay gone — + // scoped to this card's own markup, since other cards legitimately keep est-scroll. assert.match(out, /eva-table/); + assert.doesNotMatch(cardSlice(out, 'card-expected-vs-actual'), /est-scroll/); // Without a fresh published estimate the label honestly drops the "+ XvB". s.earnings_summary.xmr.includes_xvb = false; assert.match(renderApp({ state: s }), /Monero \(30d\)/); diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index cbbee902..9ffabf69 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -1744,6 +1744,20 @@ def test_combined_row_without_a_fresh_xvb_estimate_stays_p2pool_only(self): ) assert s["xmr"]["includes_xvb"] is False # disabled XvB never folds its estimate in + def test_negative_published_estimate_never_folds(self): + # xvb_day is upstream-published — a hostile/corrupt negative must not drag the combined + # expectation toward (or past) zero while available stays True: it folds as 0, the label + # stays P2Pool-only, and pct keeps a positive denominator. + e = _summary_earnings( + coeff_day=1e-8, + xvb_day=-5.0, + confirmed={"enabled": True, "xmr_30d": 0.1, "partial": {}}, + ) + s = build_earnings_vs_actual(_metrics(p2pool_30d=8000.0), e, [], now=self.NOW) + assert s["xmr"]["includes_xvb"] is False + assert s["xmr"]["expected_30d"] == pytest.approx(1e-8 * 8000.0 * 30) + assert s["xmr"]["pct"] is not None and s["xmr"]["pct"] > 0 + 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