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
17 changes: 7 additions & 10 deletions build/dashboard/mining_dashboard/service/earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +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. 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``.
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 @@ -138,25 +138,22 @@ 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)
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
counts["30d"] += 1
n_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]
Expand All @@ -172,5 +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.update({f"n_{w}": v for w, v in counts.items()})
summary["n_30d"] = n_30d
return summary
70 changes: 67 additions & 3 deletions build/dashboard/tests/frontend/components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand 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)', () => {
Expand Down Expand Up @@ -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\)/);
});
24 changes: 24 additions & 0 deletions build/dashboard/tests/frontend/fixtures/state.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
10 changes: 2 additions & 8 deletions build/dashboard/tests/service/test_earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,6 @@ 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,
Expand All @@ -191,10 +188,8 @@ 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
# 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
Expand All @@ -211,7 +206,6 @@ 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
Expand Down
16 changes: 16 additions & 0 deletions build/dashboard/tests/service/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}))
Expand Down
22 changes: 16 additions & 6 deletions build/dashboard/tests/web/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import time
from dataclasses import replace
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -1803,6 +1804,21 @@ def test_rides_build_state_end_to_end(self, monkeypatch):
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) ------------------------------------

Expand Down Expand Up @@ -1940,9 +1956,6 @@ 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,
Expand All @@ -1964,9 +1977,6 @@ 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,
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<that parent>/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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ stream, each over the window that suits how that stream pays:
|---|---|---|
| **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. |
| **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
Expand Down
1 change: 1 addition & 0 deletions docs/dev/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down