From 64ca5ff61fb58393f55cd31d6dd192bb0a76189f Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 1 Aug 2026 11:00:41 -0500 Subject: [PATCH] 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.