diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbaa05e9..965a8d60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,37 @@ Pithead ships as **one product, one version** — the version lives in the top-l
[`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut
per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
+## [1.16.0] - 2026-08-01
+
+### Added
+
+- **Expected vs actual earnings, at a glance and in both views (#808).** A new dashboard card
+ puts each income stream's estimate beside what the view-only wallets actually confirmed, over
+ the same window: Monero XMR over the trailing 7 days — expected from the 7-day average routed
+ P2Pool hashrate, the hashrate that actually ran the window — with a percent-of-expected; Tari
+ in blocks over 30 days (solo merge-mining pays whole blocks, and a fraction of a block per
+ month is the normal expectation, shown as such); and XvB raffle wins in the window beside XvB's
+ published estimate for the current tier. The card appears in the Simple view too — its first
+ earnings figure. Streams without payout confirmation show the config key to set rather than a
+ zero, and windows that outrun the recorded payout history keep their partial marking. No XvB
+ XMR figure is shown anywhere: a win pays out through ordinary small payouts the payout table
+ cannot attribute, so any such number would be an invention.
+
+### Fixed
+
+- **A local→remote node switch now retires the old node container (#795).** Switching
+ `monero.mode` or `tari.mode` to `remote` dropped the node's compose profile as promised, but
+ compose never removes the running container of a profile-disabled service — it is not an
+ orphan — so the old node kept running, offline and re-syncing, against a remote-mode config.
+ Every `up` now removes the containers of profile-disabled services before recreating anything,
+ which also heals a box already stuck in that state on its next `apply` or `up`. On-disk chain
+ data is untouched, exactly as the apply preview says.
+- **The tari-wallet container can now actually report healthy (#777).** Its liveness probe
+ grepped for the full `minotari_console_wallet` process name, but `ps` truncates the command
+ column at 15 characters, so the check could never match and the container reported unhealthy
+ forever — a permanently-firing container alert for the first user of Tari payout confirmation.
+ The probe now matches the truncated form `ps` actually prints.
+
## [1.15.0] - 2026-08-01
### Added
diff --git a/VERSION b/VERSION
index 141f2e80..15b989e3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.15.0
+1.16.0
diff --git a/build/dashboard/mining_dashboard/service/earnings.py b/build/dashboard/mining_dashboard/service/earnings.py
index 19b01df6..cfdc4c6a 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. 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
@@ -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:
@@ -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
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
+
+
+
+
+
Expected
+
Actual
+
+
+ ${rows.map(
+ (r) => html`
+
+
${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({
Your Stack
<${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/pyproject.toml b/build/dashboard/pyproject.toml
index 56c04769..9adef578 100644
--- a/build/dashboard/pyproject.toml
+++ b/build/dashboard/pyproject.toml
@@ -7,7 +7,7 @@ name = "mining-dashboard"
# Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version
# (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version
# from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only.
-version = "1.15.0"
+version = "1.16.0"
description = "Monitoring dashboard and XvB switching engine for Pithead"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs
index 28742923..4caf471e 100644
--- a/build/dashboard/tests/frontend/components.test.mjs
+++ b/build/dashboard/tests/frontend/components.test.mjs
@@ -491,8 +491,9 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe
partial: { yesterday: false, '7d': false, '30d': false },
};
let html = renderApp({ state: s });
- // One confirmed block per tab, populated from the summary keys through the coin formatters.
- assert.equal(html.split('Confirmed on-chain').length - 1, 2);
+ // One confirmed block per tab (counted by the sub-panel's own class — the phrase also
+ // appears in the #808 card's tooltip prose), populated through the coin formatters.
+ assert.equal(html.split('confirmed-subhead').length - 1, 2);
assert.match(html, /0\.250000 XMR/); // xmr_24h
assert.match(html, /1\.7500 XMR/); // xmr_all
assert.match(html, /4552\.1500 XTM/); // xtm_all
@@ -513,7 +514,8 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe
s.earnings.confirmed = { enabled: false };
s.earnings.tari_confirmed = { enabled: false };
html = renderApp({ state: s });
- assert.doesNotMatch(html, /Confirmed on-chain/);
+ // Scoped to the sub-panel class — the #808 card's tooltip prose reuses the phrase.
+ assert.doesNotMatch(html, /confirmed-subhead/);
});
test('EarningsCard marks running windows the payout history does not fully cover (#787)', () => {
@@ -986,3 +988,65 @@ test('StatsTable renders nothing when a rig reports no metrics (#507)', () => {
assert.equal(render(StatsTable, { stats: [] }), '');
assert.equal(render(StatsTable, { stats: undefined }), '');
});
+
+// --- ExpectedVsActualCard (#808) -------------------------------------------------------
+
+test('ExpectedVsActualCard renders in BOTH views and yields to nothing when empty (#808)', () => {
+ // The base fixture has XvB on (one recorded win) → the card renders, Simple and Advanced.
+ assert.match(renderApp({ ui: { ...UI, view: 'simple' } }), /card-expected-vs-actual/);
+ assert.match(renderApp({ ui: { ...UI, view: 'advanced' } }), /card-expected-vs-actual/);
+ // Nothing to compare on any stream → the card yields entirely (no empty shell).
+ const s = clone();
+ s.earnings_summary = {
+ xmr: { available: false, enabled: false },
+ tari: { available: false, enabled: false },
+ xvb: { enabled: false },
+ };
+ assert.doesNotMatch(renderApp({ state: s }), /card-expected-vs-actual/);
+ // A stale payload without the key (mid-upgrade poll) must not take the App down.
+ delete s.earnings_summary;
+ assert.match(renderApp({ state: s }), /Overview/);
+});
+
+test('ExpectedVsActualCard compares Monero with a percent and marks partial windows (#808)', () => {
+ const s = clone();
+ s.earnings_summary.xmr = {
+ available: true, expected_7d: 0.0123, enabled: true,
+ actual_7d: 0.0101, partial: true, pct: 82,
+ };
+ const out = renderApp({ state: s });
+ assert.match(out, /Monero \(7d\)/);
+ assert.match(out, /0\.012300 XMR/); // expected, formatXmr precision
+ assert.match(out, /0\.010100 XMR \(82%\) \*/); // actual + pct + the partial asterisk
+ assert.match(out, /covers only the payout history on record/); // the footnote appears
+});
+
+test('ExpectedVsActualCard shows the config key when confirmation is off, never a zero (#808)', () => {
+ const s = clone();
+ s.earnings_summary.xmr = { available: true, expected_7d: 0.0123, enabled: false,
+ actual_7d: null, partial: false, pct: null };
+ s.earnings_summary.tari = { available: true, expected_blocks_30d: 0.0052, enabled: false,
+ blocks_30d: null, xtm_30d: null, partial: false };
+ const out = renderApp({ state: s });
+ assert.match(out, /set monero\.view_key/);
+ assert.match(out, /set tari\.view_key/);
+ assert.doesNotMatch(out, /0\.000000 XMR \(/); // no zero-actual masquerading as a figure
+ // Tari expectation keeps two significant digits — a fraction of a block must never read 0.0.
+ assert.match(out, /≈ 0\.0052 blocks/);
+});
+
+test('ExpectedVsActualCard counts Tari blocks and windows XvB wins (#808)', () => {
+ const s = clone();
+ s.earnings_summary.tari = { available: true, expected_blocks_30d: 0.41, enabled: true,
+ blocks_30d: 1, xtm_30d: 12345.0, partial: false };
+ s.earnings_summary.xvb = { enabled: true, wins_30d: 2, last_win_ts: 1735689000,
+ published_day: 0.06 };
+ const out = renderApp({ state: s });
+ assert.match(out, /≈ 0\.41 blocks/);
+ assert.match(out, /1 block · 12345\.0000 XTM/); // singular block, XTM alongside
+ assert.match(out, /2 wins · last /);
+ assert.match(out, /0\.060000 XMR\/day \(XvB est\.\)/);
+ // XvB off → the row disappears (no invented raffle framing on a non-XvB box).
+ s.earnings_summary.xvb = { enabled: false, wins_30d: 0, last_win_ts: 0, published_day: null };
+ assert.doesNotMatch(renderApp({ state: s }), /XvB wins \(30d\)/);
+});
diff --git a/build/dashboard/tests/frontend/fixtures/state.json b/build/dashboard/tests/frontend/fixtures/state.json
index 9a04db01..f4e86c36 100644
--- a/build/dashboard/tests/frontend/fixtures/state.json
+++ b/build/dashboard/tests/frontend/fixtures/state.json
@@ -99,6 +99,30 @@
"tari_reward": 0,
"xvb_day": null
},
+ "earnings_summary": {
+ "tari": {
+ "available": false,
+ "blocks_30d": null,
+ "enabled": false,
+ "expected_blocks_30d": 0.0,
+ "partial": false,
+ "xtm_30d": null
+ },
+ "xmr": {
+ "actual_7d": null,
+ "available": false,
+ "enabled": false,
+ "expected_7d": 0.0,
+ "partial": false,
+ "pct": null
+ },
+ "xvb": {
+ "enabled": true,
+ "last_win_ts": 1735689000,
+ "published_day": null,
+ "wins_30d": 1
+ }
+ },
"egress": {
"components": [
{
diff --git a/build/dashboard/tests/service/test_earnings.py b/build/dashboard/tests/service/test_earnings.py
index a178cb2b..5622c951 100644
--- a/build/dashboard/tests/service/test_earnings.py
+++ b/build/dashboard/tests/service/test_earnings.py
@@ -168,6 +168,7 @@ def test_empty_list_is_on_with_zeros(self):
"xmr_7d": 0.0,
"xmr_30d": 0.0,
"xmr_all": 0.0,
+ "n_30d": 0,
"last_ts": 0,
"since_ts": 0,
"partial": {"yesterday": True, "7d": True, "30d": True},
@@ -187,6 +188,9 @@ 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
+ # The 30d count rides along (#808) — for Tari a payout IS a found block, so this is the
+ # actual the expected-vs-actual card holds against the expected block count.
+ assert s["n_30d"] == 3
assert s["last_ts"] == self.NOW - 3_600
assert s["since_ts"] == self.NOW - 40 * 86_400
diff --git a/build/dashboard/tests/service/test_metrics.py b/build/dashboard/tests/service/test_metrics.py
index 19f8580a..71d7dd1b 100644
--- a/build/dashboard/tests/service/test_metrics.py
+++ b/build/dashboard/tests/service/test_metrics.py
@@ -133,6 +133,22 @@ def test_p2pool_averages_from_history(self):
assert m.p2pool_1h == 900.0
assert m.p2pool_24h == 900.0
+ def test_p2pool_window_averages_use_their_own_windows(self):
+ # The 7d/30d averages (#808) must reach past the 24h window — a sample 3 days old is
+ # invisible to 1h/24h but counted by both long windows; one 40 days old by neither.
+ # A wrong window constant (e.g. 7*3600) fails this: the 3-day sample would drop out.
+ now = time.time()
+ history = [
+ {"timestamp": now - 30, "v": 900, "v_p2pool": 900, "v_xvb": 0},
+ {"timestamp": now - 3 * 86_400, "v": 300, "v_p2pool": 300, "v_xvb": 0},
+ {"timestamp": now - 40 * 86_400, "v": 999, "v_p2pool": 999, "v_xvb": 0},
+ ]
+ m = build_metrics(_data(), _mgr(history=history))
+ assert m.p2pool_1h == 900.0
+ assert m.p2pool_24h == 900.0
+ assert m.p2pool_7d == 600.0 # (900 + 300) / 2 — the 3-day sample is IN
+ assert m.p2pool_30d == 600.0 # the 40-day sample stays OUT
+
def test_xvb_credited_averages_from_stats(self):
# Credited (XvB API avg_1h/24h) is kept independent — controller input + Advanced card (#156).
m = build_metrics(_data(), _mgr(xvb={"avg_1h": 2100, "avg_24h": 2300}))
diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py
index 6a8cedf0..1e541d1f 100644
--- a/build/dashboard/tests/web/test_views.py
+++ b/build/dashboard/tests/web/test_views.py
@@ -9,6 +9,7 @@
import json
import time
from dataclasses import replace
+from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@@ -35,6 +36,7 @@
build_chart,
build_disk_growth,
build_earnings,
+ build_earnings_vs_actual,
build_energy,
build_hashrate,
build_pool_network,
@@ -1686,6 +1688,138 @@ 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
+
+ def test_frontend_fixture_carries_every_payload_key(self):
+ # Drift guard (#808 post-mortem): the frontend render tests run against
+ # tests/frontend/fixtures/state.json, "a real build_state() payload". When a new top-level
+ # key ships without regenerating the fixture, every component gated on that key silently
+ # renders nothing in the whole frontend suite — exactly how the #808 card briefly had zero
+ # render coverage. This pins the fixture's key set to the live contract.
+ fixture = Path(__file__).parent.parent / "frontend" / "fixtures" / "state.json"
+ fixture_keys = set(json.loads(fixture.read_text()))
+ live_keys = set(build_state(_data(), _state_mgr(), "all"))
+ missing = live_keys - fixture_keys
+ assert not missing, (
+ f"frontend fixture is stale — regenerate with tests/frontend/fixtures/_gen_state.py "
+ f"(missing keys: {sorted(missing)})"
+ )
+
+
# --- Host address beside the hostname (Issue #119) ------------------------------------
@@ -1822,6 +1956,7 @@ def test_confirmed_enabled_but_empty(self):
"xmr_7d": 0.0,
"xmr_30d": 0.0,
"xmr_all": 0.0,
+ "n_30d": 0,
"last_ts": 0,
"since_ts": 0,
"partial": {"yesterday": True, "7d": True, "30d": True},
@@ -1842,6 +1977,7 @@ def test_tari_confirmed_enabled_but_empty(self):
"xtm_7d": 0.0,
"xtm_30d": 0.0,
"xtm_all": 0.0,
+ "n_30d": 0,
"last_ts": 0,
"since_ts": 0,
"partial": {"yesterday": True, "7d": True, "30d": True},
diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock
index 5d3316d6..d9c97932 100644
--- a/build/dashboard/uv.lock
+++ b/build/dashboard/uv.lock
@@ -786,7 +786,7 @@ wheels = [
[[package]]
name = "mining-dashboard"
-version = "1.15.0"
+version = "1.16.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
diff --git a/docker-compose.yml b/docker-compose.yml
index 7d4f8958..c5294f18 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -355,8 +355,10 @@ services:
healthcheck:
# Process-liveness (like the Tari node) rather than a gRPC probe: the wallet's own gRPC is a
# long stream, and a liveness ps is enough to catch a crashed daemon. '[m]' stops grep matching
- # its own argv. The long start_period tolerates the first-run blockchain scan.
- test: ["CMD-SHELL", "ps | grep '[m]inotari_console_wallet' || exit 1"]
+ # its own argv. The long start_period tolerates the first-run blockchain scan. The pattern is
+ # capped at 15 chars — procps' ps truncates the CMD column there, so the full binary name
+ # 'minotari_console_wallet' can never match (#777).
+ test: ["CMD-SHELL", "ps | grep '[m]inotari_consol' || exit 1"]
interval: 30s
timeout: 5s
retries: 3
diff --git a/docs/configuration.md b/docs/configuration.md
index 364aa097..b2ff37d7 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -146,7 +146,7 @@ control channel will commit, are unaffected either way.
| `dashboard.timezone` | `auto` | Timezone for the dashboard's timestamps and charts. `auto` = the host machine's timezone (auto-detected, falling back to `Etc/UTC`); set an IANA name (e.g. `America/Chicago`) to override. |
| `dashboard.data_dir` | `auto` | Where the dashboard's database lives. `auto` = `./data/dashboard`, unless the four other `*.data_dir` all point under one parent directory — then the dashboard joins them at `/dashboard`, and the first `upgrade`/`apply` moves data from the old default there automatically (see [Data directories](#data-directories)). |
| `dashboard.check_for_updates` | `true` _(on)_ | The dashboard periodically asks GitHub whether a newer Pithead release exists and, if so, shows a header badge linking to it (e.g. "New release v1.4.0 available"). Notify-only: it never updates anything; you upgrade with `./pithead upgrade` on your own terms. On by default because the check is routed over Tor (the same bridge SOCKS as the XvB fetch, `socks5h` so the DNS lookup goes through Tor too), so GitHub sees a Tor exit, not your IP. It's cached (hourly) and fails silently offline. The same flag also covers the per-worker [RigForge new-release badge](workers.md#rigforge-new-release-badge) — one more hourly, Tor-routed fetch of the latest RigForge release, compared against every rig's reported version. Set to `false` to opt out of both. See [Privacy › Runtime egress](privacy.md#runtime-egress). |
-| `dashboard.hashrate_drop_threshold` | `50` | Percent below the recent normal that counts as a hashrate drop for the `hashrate_loss` alert and its chart marker. `50` = fire when total fleet hashrate falls to half its baseline. Raise it to catch smaller dips, lower it to only flag near-total outages. |
+| `dashboard.hashrate_drop_threshold` | `50` | Percent of the recent baseline the total fleet hashrate must fall below to count as a drop, for the `hashrate_loss` alert and its chart marker. `50` = fire when hashrate falls to half its baseline. Raise it to catch smaller dips, lower it to only flag near-total outages. |
| `dashboard.hashrate_drop_minutes` | `10` | How many minutes the hashrate must stay below the threshold before the drop is reported — the debounce that keeps a brief blip from pinging you. |
| `dashboard.tari_required` | `true` | How much a Tari problem holds up the rest of the stack. Monero is required to mine, so its behavior isn't configurable: a monerod outage always rejects workers (stops `xmrig-proxy` so miners fail over to their backup pools), and the miner is always held until monerod finishes syncing. Tari is only needed for merge-mining, so this one flag decides how much it blocks. `true` (default): a Tari outage also rejects workers, the miner waits for Tari's initial sync too, and a Tari-only (re)sync shows the full-screen Sync view. `false` (non-blocking): keep mining Monero through a Tari outage, start mining as soon as Monero is synced (Tari finishes in the background), and keep the normal dashboard, with a `Tari syncing` indicator, instead of the takeover screen. |
| `dashboard.fail_closed` | `false` _(off)_ | Whether an **unrecoverable** dashboard health failure holds the miner, not just alerts (#490). The dashboard is an observability layer, not the mining datapath (`xmrig-proxy` → `p2pool` → `monerod` runs independently of it), so the default (`false`) is alert-only: a loud Telegram/Healthchecks alert plus a dashboard badge, and mining keeps running. `true` reuses the same hold the sync gate uses (stops `p2pool` and `xmrig-proxy`) the moment one of two narrow conditions hits, and starts them again once it clears — no restart needed: the dashboard's SQLite database failed to self-heal after its own auto-recovery attempt (disk full, permissions — see `db_reset` in [Dashboard › Node status & failover](dashboard.md#node-status--failover)), or the `dashboard` container itself is crash-looping. A transient write blip, a slow query, or a single failed external fetch never trips it — only these two genuinely non-transient failures do. Set it `true` only if a stale dashboard is a bigger risk to you than a held miner; a false positive here idles the fleet. |
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 1755ebed..59241edb 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 most recent win on record landed (which can predate the window). |
+
+Rows degrade honestly rather than guess: a stream with [payout confirmation](#payout-confirmation)
+off shows the config key to set instead of a zero that would read as "earned nothing"; the XvB row
+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.
diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md
index 1423b99b..defe975a 100644
--- a/docs/dev/testing-strategy.md
+++ b/docs/dev/testing-strategy.md
@@ -105,6 +105,7 @@ The deploy-time axes — each changes a real runtime path. Full table and assert
| `/metrics` Prometheus exposition (#379), through Caddy + basic_auth | scrape | 1 ✅ (format) · 4 ▶ (`--check`) |
| `share_stats` series populated on a mining box (#116) | polls land | 1 ✅ (shape) · 4 ▶ (`--check`) |
| Confirmed running earnings (#787): yesterday as a **calendar** day vs the trailing 24h/7d/30d spans, the DST-length day boundary, partial marking when a window outruns the recorded payout history, and one roll-up feeding both the dashboard card and `/earnings` | stored payouts | 1 ✅ (`test_earnings.py`, `test_telegram_commands.py`, `components.test.mjs`) |
+| Expected-vs-actual earnings summary (#808): window-matched 7d/30d hashrate basis, per-stream gates and honest degradation, Tari block counts, XvB win windowing, and the card's render branches in both views | metrics + stored payouts | 1 ✅ (`test_views.py` `TestEarningsVsActual`, `test_metrics.py`, `components.test.mjs`) |
| Dashboard reads correct live state on a real stack | real daemons | 4 ▶ |
### G. CLI lifecycle (`pithead`)
diff --git a/pithead b/pithead
index 45d7328b..9cd93256 100755
--- a/pithead
+++ b/pithead
@@ -176,10 +176,33 @@ resolve_pull_policy() {
else printf 'missing'; fi
}
+# #795: `compose up --remove-orphans` never removes the container of a service whose profile just
+# went inactive — the service is still in the compose file, so compose does not count it as an
+# orphan and leaves it running (a tari local→remote switch left the old minotari_node up, offline
+# and re-syncing, against a remote-mode config). Reconcile from the committed .env instead: every
+# profile-gated service whose profile token is absent gets its container stopped and removed.
+# `compose rm` resolves an explicitly named service whatever the active profiles, and exits 0 when
+# no container exists, so running this before every up is a cheap no-op in the steady state and
+# also heals a box already stuck with a stale node container.
+remove_deactivated_profile_containers() {
+ local profiles gone=()
+ profiles=",$(env_get COMPOSE_PROFILES),"
+ [[ "$profiles" == *,local_node,* ]] || gone+=(monerod)
+ [[ "$profiles" == *,local_tari,* ]] || gone+=(tari)
+ [[ "$profiles" == *,payout_confirm,* ]] || gone+=(wallet-rpc)
+ [[ "$profiles" == *,tari_payout_confirm,* ]] || gone+=(tari-wallet)
+ [ "${#gone[@]}" -eq 0 ] && return 0
+ docker compose rm -sf "${gone[@]}" >/dev/null 2>&1 ||
+ warn "Could not remove the deactivated container(s): ${gone[*]} — remove them manually with 'docker rm -f ${gone[*]}'."
+}
+
# Run `docker compose up` with live output; on failure, explain a bridge-subnet collision (#180) if
# that's what Docker rejected. Returns compose's own exit code.
compose_up_checked() {
local tmp out rc
+ # Deactivated-profile containers go BEFORE the up (#795): the old local node must stop before
+ # p2pool (re)starts against the remote one, not linger beside it.
+ remove_deactivated_profile_containers
tmp="$(mktemp)"
docker compose up --pull "$(resolve_pull_policy)" "$@" 2>&1 | tee "$tmp"
rc=${PIPESTATUS[0]}
@@ -5224,8 +5247,9 @@ apply() {
# commit above (never before the operator said yes) and under the marker, so a failed move is
# retried; the recreate below then mounts the migrated directory.
migrate_dashboard_data
- # Compose recreates only the services whose resolved config changed; --remove-orphans
- # drops monerod when a local→remote switch deactivates the local_node profile.
+ # Compose recreates only the services whose resolved config changed. --remove-orphans covers
+ # services that left the compose file entirely; a profile-deactivated service is NOT an orphan
+ # to compose, so compose_up_checked removes those containers itself before the up (#795).
if ! compose_up_checked -d --remove-orphans; then
warn "Config files were updated but containers were NOT recreated ('docker compose up' failed)."
warn "Fix the cause shown above, then re-run '$0 apply' (it will retry the recreate) — or '$0 up'."
diff --git a/tests/stack/run.sh b/tests/stack/run.sh
index 611bce22..7caa6698 100755
--- a/tests/stack/run.sh
+++ b/tests/stack/run.sh
@@ -3384,6 +3384,39 @@ out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)"
assert_rc "invalid tari.mode rejected" "$?" "1"
assert_contains "invalid tari.mode message" "$out" "tari.mode must be"
+echo "== black-box: profile deactivation removes the old container (#795) =="
+# `compose up --remove-orphans` does not remove a profile-deactivated service's container — the
+# service is still in the compose file, so compose does not count it as an orphan — and a tari
+# local→remote switch left the old node running (offline, re-syncing) against a remote-mode
+# config. Apply must issue an explicit `compose rm -sf` for every profile-gated service whose
+# profile is off, BEFORE the recreate up. Asserted through the docker-stub call log.
+# (1) Baseline, both nodes local: the reconcile list is exactly the (off) payout-confirm wallets —
+# neither node container is touched.
+seed_env
+printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json"
+SWLOG="$V/docker-switch.log"
+: >"$SWLOG"
+out="$(cd "$V" && DOCKER_LOG="$SWLOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)"
+assert_rc "baseline local-nodes apply exits 0" "$?" "0"
+assert_contains "local nodes: only the off payout wallets reconcile" "$(cat "$SWLOG")" "compose rm -sf wallet-rpc tari-wallet"
+# (2) tari local→remote (the #795 reproduction): the tari container joins the removal list. No
+# re-seed — the committed local-mode .env from (1) makes this a real transition.
+printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T","mode":"remote","remote":{"host":"tari.example.com"}}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json"
+: >"$SWLOG"
+out="$(cd "$V" && DOCKER_LOG="$SWLOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)"
+assert_rc "tari local-to-remote switch applies cleanly" "$?" "0"
+assert_contains "switch removes the deactivated tari container" "$(cat "$SWLOG")" "compose rm -sf tari wallet-rpc tari-wallet"
+# The removal must precede the recreate up — the point is the old node never runs beside the
+# remote-mode p2pool, not that it eventually disappears.
+assert_contains "tari removal happens before the recreate up" "$(sed '/compose up --pull never -d --remove-orphans/q' "$SWLOG")" "compose rm -sf tari wallet-rpc tari-wallet"
+# (3) monerod rides the same guard on a monero local→remote switch.
+seed_env
+printf '{ "monero": {"mode":"remote","remote":{"host":"node.example"},"wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json"
+: >"$SWLOG"
+out="$(cd "$V" && DOCKER_LOG="$SWLOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)"
+assert_rc "monero local-to-remote switch applies cleanly" "$?" "0"
+assert_contains "switch removes the deactivated monerod container" "$(cat "$SWLOG")" "compose rm -sf monerod wallet-rpc tari-wallet"
+
echo "== black-box: monero.rpc_lan_access + prep_blocks_threads reflect into .env (#523) =="
# The rendered .env must match the config input. rpc_lan_access gates the monerod RPC bind: default
# (unset) keeps it localhost-only; true opens it to the LAN. prep_blocks_threads overrides the
diff --git a/tests/stack/test_compose.sh b/tests/stack/test_compose.sh
index f04edce6..a16c5dc7 100755
--- a/tests/stack/test_compose.sh
+++ b/tests/stack/test_compose.sh
@@ -283,6 +283,11 @@ jq_assert "exactly 4 service_healthy depends_on edges total (#565)" \
'[.services[] | (.depends_on // {}) | to_entries[] | select(.value.condition == "service_healthy")] | length == 4'
jq_assert "exactly 5 depends_on edges total (#565)" \
'[.services[] | (.depends_on // {}) | to_entries[]] | length == 5'
+# The wallet probe must fit ps's 15-char CMD column — procps truncates CMD there, so the full
+# binary name never matches and the container would report unhealthy forever while the wallet
+# runs fine (#777). Asserted here because tari-wallet only renders under tari_payout_confirm.
+jq_assert "tari-wallet healthcheck pattern survives ps CMD truncation (#777)" \
+ '(.services["tari-wallet"].healthcheck.test | tostring) | contains("[m]inotari_consol") and (contains("[m]inotari_console_wallet") | not)'
# Restore $JSON to the default-profile render for every check below this point.
JSON="$(docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" config --format json 2>/dev/null)"