Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions build/dashboard/mining_dashboard/service/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 32 additions & 27 deletions build/dashboard/mining_dashboard/web/static/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -585,18 +585,20 @@ function XvbTierBlock({ calc, hr, coeffDay, energy, est }) {
</div>`;
}

// 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;
Expand All @@ -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)",
Expand All @@ -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`
<div class="card" id="card-expected-vs-actual">
<h3>Earnings — Expected vs Actual</h3>
<div class="est-scroll">
<table class="est-table">
<table class="est-table eva-table">
<thead><tr>
<th></th>
<th scope="col">Expected</th>
Expand All @@ -670,7 +676,6 @@ function ExpectedVsActualCard({ summary }) {
)}
</tbody>
</table>
</div>
${
anyPartial
? html`<p class="text-muted text-xs">* covers only the payout history on record, not the window's full span.</p>`
Expand Down
23 changes: 23 additions & 0 deletions build/dashboard/mining_dashboard/web/static/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -886,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;
}
Expand Down
46 changes: 27 additions & 19 deletions build/dashboard/mining_dashboard/web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1580,35 +1580,45 @@ 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
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
expected_p2pool = earnings["coeff_day"] * metrics.p2pool_30d * 30
# 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_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 = (
Expand All @@ -1629,8 +1639,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}

Expand Down
27 changes: 17 additions & 10 deletions build/dashboard/tests/frontend/components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1008,23 +1008,30 @@ 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, 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\)/);
});

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 });
Expand All @@ -1039,14 +1046,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\)/);
});
6 changes: 3 additions & 3 deletions build/dashboard/tests/frontend/fixtures/state.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down
Loading