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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.15.0
1.16.0
8 changes: 7 additions & 1 deletion build/dashboard/mining_dashboard/service/earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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
Expand All @@ -135,12 +138,14 @@ 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)
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
n_30d += 1
if ts >= week:
atomic["7d"] += amt
if ts >= day:
Expand All @@ -164,4 +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["n_30d"] = n_30d
return summary
11 changes: 11 additions & 0 deletions build/dashboard/mining_dashboard/service/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions build/dashboard/mining_dashboard/web/static/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,100 @@ 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.
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`
<div class="card" id="card-expected-vs-actual">
<h3>Earnings — Expected vs Actual</h3>
<div class="est-scroll">
<table class="est-table">
<thead><tr>
<th></th>
<th scope="col">Expected</th>
<th scope="col">Actual</th>
</tr></thead>
<tbody>
${rows.map(
(r) => html`
<tr title=${r.title}>
<th scope="row">${r.label}</th>
<td class="c-accent">${r.expected}</td>
<td class=${r.dim ? "text-muted" : ""}>${r.actual}</td>
</tr>`,
)}
</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>`
: null
}
</div>`;
}

// 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
Expand Down Expand Up @@ -1129,6 +1223,7 @@ function DashboardView({
<div class="grid">
<div class="grid-section-label">Your Stack</div>
<${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} />
Expand Down
93 changes: 82 additions & 11 deletions build/dashboard/mining_dashboard/web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion build/dashboard/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading