@@ -319,6 +373,15 @@ export class WorkerInspect extends Component {
<${StatusLine} result=${upgResult} />
${detail.rigforge ? html`<${StatsTable} stats=${detail.rigforge.stats} />` : null}
+
Hashrate${chartLoading ? html` refreshing…` : null}
+ <${WorkerChartCard}
+ chart=${{
+ hashrate: detail.hashrate_history?.hashrate || [],
+ markers: buildChartMarkers(detail.hashrate_history?.markers),
+ }}
+ range=${chartRange}
+ onRange=${(r) => this.setChartRange(r)} />
+
Edit config
${
canEdit
diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py
index d4ddbb60..9f1fd8c9 100644
--- a/build/dashboard/mining_dashboard/web/views.py
+++ b/build/dashboard/mining_dashboard/web/views.py
@@ -710,6 +710,10 @@ def build_pool_network(data, metrics):
p2p = data.get("pool", {}).get("p2p", {})
network = data.get("network", {})
s_addr = stratum.get("wallet", "Unknown")
+ # Relative, matching the cadence card's "Since Pool's Last Block": a bare HH:MM:SS with no
+ # date or timezone cue two cards away from a real duration reads as a duration.
+ last_block_ts = local_pool.get("last_block_ts", 0)
+ last_blk = f"{format_duration(time.time() - last_block_ts)} ago" if last_block_ts else "Never"
return {
"stratum": {
@@ -736,7 +740,7 @@ def build_pool_network(data, metrics):
"pplns_win": f"{metrics.pplns_window} ({format_duration(metrics.pplns_window * metrics.block_time)})",
"pplns_wgt": local_pool.get("pplns_weight", 0),
"blocks": local_pool.get("blocks_found", 0),
- "last_blk": format_time_abs(local_pool.get("last_block_ts", 0)),
+ "last_blk": last_blk,
"peers": f"{p2p.get('out_peers', 0)} / {p2p.get('in_peers', 0)}",
"uptime": format_duration(p2p.get("uptime", 0)),
},
@@ -1779,7 +1783,12 @@ def build_earnings_vs_actual(
"pct": None,
}
if xmr["available"] and xmr["enabled"]:
- xmr["pct"] = round((xmr["actual_30d"] or 0.0) / xmr["expected_30d"] * 100)
+ pct = round((xmr["actual_30d"] or 0.0) / xmr["expected_30d"] * 100)
+ # Withheld past 999%: a near-zero expectation (a box idle for most of the window that
+ # still confirmed normal payouts) turns the ratio into a five-digit figure that reads
+ # as a bug, not a comparison. pct is None only here once available+enabled hold, so
+ # the client's tooltip can own the explanation without an extra flag.
+ xmr["pct"] = pct if pct <= 999 else None
# 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 = (
@@ -1840,10 +1849,16 @@ def build_xvb_calc(metrics, state_mgr, realization=None):
cumulative forecast) and ``players_avg`` (which also makes a single-qualifier artifact like
Mega's self-evident). ``realized_reward_year`` scales the published figure by this wallet's
measured win realization (``realization``, from ``xvb_realization``) — None when unmeasured,
- so the client falls back to the study band; face value shows only in its own column. Returns ``{"enabled": False}`` alone when
- XvB is off — there is no tier to calculate."""
- if not metrics.xvb_enabled:
- return {"enabled": False}
+ so the client falls back to the study band; face value shows only in its own column.
+
+ Published with XvB DISABLED too (#938): the table is the enable/don't-enable decision aid, so
+ hiding it behind the flag defeated its purpose. Everything here is computable from local
+ config plus the cached public feeds; disabling XvB stops the fetches (the egress rule, #726),
+ so on a box that never enabled XvB the odds and reward columns are honestly empty and on a
+ just-disabled box they age out through the same staleness rule as always. The live-credit
+ context goes quiet on its own: ``build_state`` computes ``realization`` only while enabled,
+ and ``Metrics`` reports current/target tier as "Disabled" — the client keys every
+ live-donation surface (and the current/target cards here) off ``enabled``."""
tiers = state_mgr.get_tiers()
round_state = state_mgr.get_xvb_round_stats()
round_types = (
@@ -1869,7 +1884,7 @@ def _odds_day(key):
estimates_stale = xvb_stats_are_stale(est_state)
estimates_available = bool(estimates) and not estimates_stale
return {
- "enabled": True,
+ "enabled": metrics.xvb_enabled,
# Ascending tier table for the client's what-if; names via get_tier_info so they read
# exactly like the tier strings everywhere else (threshold already embedded in the name).
# expected_reward_year is XvB's own figure for the tier, or None when unavailable/stale.
@@ -1958,14 +1973,43 @@ def _egress_badge(summary):
}
-def build_worker_detail(name, data, state_mgr):
+def build_worker_hashrate_history(state_mgr, worker, range_arg, window=None):
+ """Per-worker hashrate-over-time chart (#1013): ``worker_history.h15`` for one rig, same
+ range/window/downsampling idiom as the telemetry backbone's other gauge series (``_gauge_series``
+ — reused as-is, not reimplemented). Markers (#1015) are the SAME range/window slice of this rig's
+ change history — config applies and rig upgrades (#1014) — so a step in the line has a visible
+ cause; kept as raw tokens (status/type/changes/reason), not display strings, so the client builds
+ the tooltip label the same way it already builds the history table's Outcome column."""
+ hashrate = [
+ {"x": p["x"], "y": p["h15"]}
+ for p in _gauge_series(
+ state_mgr.get_worker_history(name=worker), range_arg, window, ("h15",)
+ )
+ ]
+ markers = [
+ {
+ "x": int(row["ts"] * 1000),
+ "status": row.get("status"),
+ "type": row.get("type", "apply"),
+ "changes": row.get("changes") or {},
+ "reason": row.get("reason"),
+ }
+ for row in _filter_events(
+ state_mgr.get_worker_config_history(worker, limit=200), range_arg, window
+ )
+ ]
+ return {"hashrate": hashrate, "markers": markers}
+
+
+def build_worker_detail(name, data, state_mgr, range_arg="all", window=None):
"""Per-worker Inspect payload (#185): the rig's current enriched telemetry, the writable config
the dashboard last applied (the editor prefill — the rig's feed does not expose the writable
config values, so Pithead's own last-applied record is the honest source), and the change history
(each row's ``changes`` is a diff by construction, since we only ever record deltas we authored).
``hashrate_by_config`` (#492) is that same version timeline with each version's measured
hashrate (worker_history) aggregated over its active window, so an operator can compare config
- versions empirically.
+ versions empirically. ``hashrate_history`` (#1013) is the same rig's hashrate as a chartable
+ time series, honoring the same ``range_arg``/``window`` ``/api/state`` already uses.
``editable`` is whether the worker has an operator-set ``host`` in ``dashboard.workers[]`` — the
precondition for the host-side write path. The rig's token is masked out of this container (#440),
@@ -2000,6 +2044,7 @@ def build_worker_detail(name, data, state_mgr):
"last_applied": state_mgr.get_last_applied_worker_config(name),
"history": history,
"hashrate_by_config": hashrate_by_config,
+ "hashrate_history": build_worker_hashrate_history(state_mgr, name, range_arg, window),
}
diff --git a/build/dashboard/tests/client/test_monero_client.py b/build/dashboard/tests/client/test_monero_client.py
index 8c25e1a3..453bbba8 100644
--- a/build/dashboard/tests/client/test_monero_client.py
+++ b/build/dashboard/tests/client/test_monero_client.py
@@ -76,6 +76,7 @@ def test_syncing(self):
"target": 100,
"percent": 50,
"db_size": 85_000_000_000,
+ "synchronized": False,
}
def test_synced_via_flag(self):
@@ -89,16 +90,39 @@ def test_synced_via_flag(self):
"database_size": 200_000_000_000,
}
)
- assert client.get_sync_status() == {"is_syncing": False, "db_size": 200_000_000_000}
+ assert client.get_sync_status() == {
+ "is_syncing": False,
+ "db_size": 200_000_000_000,
+ "synchronized": True,
+ }
def test_synced_via_zero_target(self):
# Synced monerod reports target_height: 0.
client = self._client_with_info({"status": "OK", "height": 100, "target_height": 0})
- assert client.get_sync_status() == {"is_syncing": False, "db_size": 0}
+ assert client.get_sync_status() == {
+ "is_syncing": False,
+ "db_size": 0,
+ "synchronized": False,
+ }
def test_synced_when_height_reaches_target(self):
client = self._client_with_info({"status": "OK", "height": 100, "target_height": 100})
- assert client.get_sync_status() == {"is_syncing": False, "db_size": 0}
+ assert client.get_sync_status() == {
+ "is_syncing": False,
+ "db_size": 0,
+ "synchronized": False,
+ }
+
+ def test_stranded_node_reads_synced_but_carries_the_false_flag(self):
+ # The #972 strand: after a tor restart a peerless monerod can report target_height 0
+ # (stale) with synchronized false — the height math calls it "synced", so the raw
+ # `synchronized` passthrough is the ONLY signal the peer-loss detector gets.
+ client = self._client_with_info(
+ {"status": "OK", "synchronized": False, "height": 100, "target_height": 0}
+ )
+ status = client.get_sync_status()
+ assert status["is_syncing"] is False
+ assert status["synchronized"] is False
def test_unreachable_returns_none(self):
client = self._client_with_info(None)
diff --git a/build/dashboard/tests/client/test_xmrig_client.py b/build/dashboard/tests/client/test_xmrig_client.py
index 1dae2e1d..1cec84f9 100644
--- a/build/dashboard/tests/client/test_xmrig_client.py
+++ b/build/dashboard/tests/client/test_xmrig_client.py
@@ -484,8 +484,10 @@ def test_parse_worker_control_status_absent_is_none():
def test_parse_worker_control_status_non_terminal_is_none():
- # 'accepted' (queued, outcome not yet observed) and 'running' are in-flight — never reconciled.
- for status in ("accepted", "running", "unknown", ""):
+ # 'accepted' (queued, outcome not yet observed) and 'running' are this dashboard's own
+ # in-flight placeholders; 'started' is rigforge#320's own in-flight upgrade marker — all
+ # never reconciled.
+ for status in ("accepted", "running", "started", "unknown", ""):
block = {**RIGFORGE_BLOCK, "control": {"change_id": "abc123", "status": status}}
assert parse_worker_control_status({"rigforge": block}) is None
@@ -516,3 +518,20 @@ def test_parse_worker_control_status_reason_defaults_none():
block = {**RIGFORGE_BLOCK, "control": {"change_id": "abc123", "status": "applied"}}
ctrl = parse_worker_control_status({"rigforge": block})
assert ctrl == {"change_id": "abc123", "status": "applied", "reason": None}
+
+
+def test_parse_worker_control_status_full_terminal_vocabulary():
+ # #1009: applied/rejected/rolled_back/failed from a control-apply, plus noop (already on
+ # target)/throttled (the rig's own anti-beacon window) from a control-upgrade (rigforge#320) —
+ # the SAME vocabulary pithead's own control_worker_apply/control_worker_upgrade poll cases
+ # accept (#1001). Each must parse through, not just the three pre-#1009 members.
+ for status in ("applied", "rejected", "rolled_back", "failed", "noop", "throttled"):
+ block = {
+ **RIGFORGE_BLOCK,
+ "control": {"change_id": "abc123", "status": status, "reason": "rig-supplied"},
+ }
+ assert parse_worker_control_status({"rigforge": block}) == {
+ "change_id": "abc123",
+ "status": status,
+ "reason": "rig-supplied",
+ }
diff --git a/build/dashboard/tests/frontend/chart.test.mjs b/build/dashboard/tests/frontend/chart.test.mjs
index fc8238c0..600e771d 100644
--- a/build/dashboard/tests/frontend/chart.test.mjs
+++ b/build/dashboard/tests/frontend/chart.test.mjs
@@ -9,7 +9,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { withAlpha, padYAxis, eventColors, donationSeries } from '../../mining_dashboard/web/static/chart.mjs';
+import { withAlpha, padYAxis, eventColors, donationSeries, workerMarkerStyle } from '../../mining_dashboard/web/static/chart.mjs';
test('withAlpha: appends an 8-bit alpha to a #rrggbb hex', () => {
assert.equal(withAlpha('#58a6ff', '26'), '#58a6ff26');
@@ -84,3 +84,28 @@ test('donationSeries: tolerates a missing history (XvB off / no samples yet)', (
assert.deepEqual(donationSeries(undefined), []);
assert.deepEqual(donationSeries([]), []);
});
+
+test('workerMarkerStyle: upgrade markers are triangles, config-apply markers are diamonds (#1015)', () => {
+ const c = { accent: '#58a6ff', ticks: '#8b949e' };
+ const markers = [
+ { kind: 'apply', quiet: false },
+ { kind: 'upgrade', quiet: false },
+ ];
+ const style = workerMarkerStyle(markers, c);
+ assert.deepEqual(style.pointStyle, ['rectRot', 'triangle']);
+});
+
+test('workerMarkerStyle: a quiet outcome (nothing changed) renders muted, not accent', () => {
+ const c = { accent: '#58a6ff', ticks: '#8b949e' };
+ const markers = [
+ { kind: 'apply', quiet: false },
+ { kind: 'apply', quiet: true },
+ ];
+ const style = workerMarkerStyle(markers, c);
+ assert.deepEqual(style.color, [c.accent, c.ticks]);
+});
+
+test('workerMarkerStyle: tolerates a missing marker list', () => {
+ const c = { accent: '#58a6ff', ticks: '#8b949e' };
+ assert.deepEqual(workerMarkerStyle(undefined, c), { pointStyle: [], color: [] });
+});
diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs
index 5a4be0c0..32651a21 100644
--- a/build/dashboard/tests/frontend/components.test.mjs
+++ b/build/dashboard/tests/frontend/components.test.mjs
@@ -261,17 +261,21 @@ test('EarningsCard leads with solo time-to-block + per-block reward, day as avg
assert.match(up, /16\.1000 XTM/); // the long-run daily average figure still shown
assert.match(up, /483\.0000 XTM/); // ... spanned to month
assert.match(up, /5876\.5000 XTM/); // ... and year, same shared precision
- // Merge-mining inactive/syncing: the rows stay, the figures degrade to "—".
+ // Merge-mining inactive/syncing: the estimates degrade to "—", but a KNOWN per-block
+ // reward keeps showing — it is a fact about the chain (the Tari Merge-Mining card prints
+ // the same figure), not a function of this box's hashrate or channel state.
const off = clone();
off.earnings.available = true;
off.earnings.tari_available = false;
+ off.earnings.tari_reward = 10_709;
const down = renderApp({ state: off });
assert.match(down, /Est\. Time to Tari Block/);
assert.match(down, /—/);
+ assert.match(down, /10709\.0000 XTM/);
assert.doesNotMatch(down, /NaN/);
});
-test('EarningsCard renders the XvB tier (raffle) block when XvB is on, hides it when off (#118)', () => {
+test('EarningsCard renders the XvB tier (raffle) block on and off — off drops the live-credit cards (#938)', () => {
const s = clone();
s.earnings.available = true;
s.xvb_calc = {
@@ -297,10 +301,18 @@ test('EarningsCard renders the XvB tier (raffle) block when XvB is on, hides it
assert.match(up, /Hashrate Cost/);
assert.match(up, /1\.00 kH\/s/);
assert.match(up, /not an XMR payout/); // the required labelling rides on the card
- // XvB disabled → the whole block disappears; the XMR calculator stays.
- s.xvb_calc = { enabled: false };
+ assert.doesNotMatch(up, /id="xvb-disabled-note"/); // the off-explainer only shows off
+ // XvB disabled (#938): the decision aid stays — what-if cards, table, note — but the
+ // live-credit cards (Current/Target tier) stand down and the off-explainer appears.
+ s.xvb_calc = { ...s.xvb_calc, enabled: false, current_tier: 'Disabled', target_tier: 'Disabled' };
const off = renderApp({ state: s });
- assert.doesNotMatch(off, /XvB Tier \(raffle\)/);
+ assert.match(off, /XvB Tier \(raffle\)/);
+ assert.match(off, /Sustainable Tier/);
+ assert.match(off, /Hashrate Cost/);
+ assert.match(off, /id="xvb-disabled-note"/);
+ assert.match(off, /not an XMR payout/);
+ assert.doesNotMatch(off, /Current Tier/);
+ assert.doesNotMatch(off, /Target Tier/);
assert.match(off, /Your P2Pool Hashrate/);
});
@@ -331,14 +343,27 @@ test('EarningsCard splits into Monero / Tari / XvB tabs, Monero active by defaul
assert.match(html, /XvB Tier \(raffle\)/);
});
-test('EarningsCard drops the XvB tab entirely when XvB is disabled (#118)', () => {
+test('EarningsCard keeps the XvB tab when disabled; only a tier-less payload drops it (#938)', () => {
const s = clone();
s.earnings.available = true;
+ // Disabled with a tier table (what the server now always sends): the tab stays — it holds
+ // the enable/don't-enable decision aid.
+ s.xvb_calc = {
+ enabled: false, max_fraction: 0.85,
+ tiers: [{ name: 'Donor (1.00 kH/s+)', threshold: 1000 }],
+ current_tier: 'Disabled', target_tier: 'Disabled',
+ note: 'raffle status', mode_note: null,
+ };
+ let html = renderApp({ state: s });
+ assert.match(html, /id="etab-xvb"/);
+ assert.match(html, /id="epanel-xvb"/);
+ assert.match(html, /XvB Tier \(raffle\)/);
+ // A pre-#938 disabled payload carries no tiers — nothing to price, so no tab either.
s.xvb_calc = { enabled: false };
- const html = renderApp({ state: s });
+ html = renderApp({ state: s });
assert.match(html, /id="etab-monero"/);
assert.match(html, /id="etab-tari"/);
- assert.doesNotMatch(html, /id="etab-xvb"/); // no XvB tab
+ assert.doesNotMatch(html, /id="etab-xvb"/);
assert.doesNotMatch(html, /id="epanel-xvb"/);
assert.doesNotMatch(html, /XvB Tier \(raffle\)/);
});
@@ -1066,6 +1091,26 @@ test('ExpectedVsActualCard compares combined Monero+XvB with a percent and parti
assert.match(renderApp({ state: s }), /Monero \(30d\)/);
});
+test('ExpectedVsActualCard drops the percent when the server withholds it, tooltip explains (#992)', () => {
+ // A near-zero expectation makes the server withhold pct (views.py caps at 999%) — the
+ // actual figure stands alone and the row tooltip owns the missing percent.
+ const s = clone();
+ s.earnings_summary.xmr = {
+ available: true, expected_30d: 1e-6, includes_xvb: false, enabled: true,
+ actual_30d: 0.28, partial: false, pct: null,
+ xvb_realization_pct: null, xvb_wins_measured: null,
+ };
+ const out = renderApp({ state: s });
+ assert.match(out, /0\.280000 XMR/);
+ assert.doesNotMatch(out, /0\.280000 XMR \(/); // no percent appended
+ assert.match(out, /No percentage shown: the expected figure is near zero/);
+ // A present percent keeps the old rendering and drops the explanation.
+ s.earnings_summary.xmr.pct = 82;
+ const withPct = renderApp({ state: s });
+ assert.match(withPct, /\(82%\)/);
+ assert.doesNotMatch(withPct, /No percentage shown/);
+});
+
test('ExpectedVsActualCard shows the config key when confirmation is off, never a zero (#808)', () => {
const s = clone();
s.earnings_summary.xmr = { available: true, expected_30d: 0.0123, includes_xvb: false,
diff --git a/build/dashboard/tests/frontend/dashboard.test.mjs b/build/dashboard/tests/frontend/dashboard.test.mjs
new file mode 100644
index 00000000..a5c2d80c
--- /dev/null
+++ b/build/dashboard/tests/frontend/dashboard.test.mjs
@@ -0,0 +1,364 @@
+// Tier-1 tests for the dashboard entry point (mining_dashboard/web/static/dashboard.js).
+//
+// Run with Node's built-in test runner (CI runs exactly this):
+// node --test build/dashboard/tests/frontend/
+//
+// dashboard.js owns the client's refresh loop and UI-state dispatch: the 30s poll with the
+// Tor-hang abort (#382), the connected/disconnected flag, preference seeding from the URL and
+// localStorage, and the handler wiring the App receives. initDashboard() names its browser seams
+// (DOM, storage, fetch, history, timer, render) as injectable parameters, so these tests drive the
+// real loop with fakes and observe it through the props handed to renderApp — no DOM, no npm deps.
+// What the App *paints* from those props is components.test.mjs's job; the normalize* helpers'
+// own semantics are logic.test.mjs's. Here we pin only what dashboard.js itself does.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ FETCH_TIMEOUT_MS, initDashboard, loadSeries, REFRESH_MS, windowFromUrl,
+} from '../../mining_dashboard/web/static/dashboard.js';
+import { SERIES_KEYS } from '../../mining_dashboard/web/static/logic.mjs';
+
+// --- fakes ------------------------------------------------------------------------------------
+
+// Build an injectable environment. `responses` is a queue of response factories, one per expected
+// fetch — a test that polls more than it queued fails loudly instead of hanging.
+function makeEnv({ href = 'http://pithead.test/', stored = {}, responses = [] } = {}) {
+ const fetches = []; // { url, opts } per fetch, in order
+ const paints = []; // the full props object of every render, in order
+ const urls = []; // history.replaceState rewrites, in order
+ const doc = {
+ title: '',
+ documentElement: {
+ attrs: {},
+ setAttribute(name, value) { this.attrs[name] = value; },
+ },
+ getElementById: () => null,
+ };
+ let interval = null;
+ const env = {
+ doc,
+ href,
+ storage: {
+ getItem: (k) => (k in stored ? stored[k] : null),
+ setItem: (k, v) => { stored[k] = String(v); },
+ },
+ fetchFn: (url, opts) => {
+ fetches.push({ url, opts });
+ assert.ok(responses.length > 0, `unexpected fetch: ${url}`);
+ return responses.shift()(url, opts);
+ },
+ replaceUrl: (u) => urls.push(u),
+ schedule: (fn, ms) => { interval = { fn, ms }; },
+ renderApp: (props) => paints.push(props),
+ };
+ return {
+ env, fetches, paints, urls, doc, stored,
+ interval: () => interval,
+ last: () => paints[paints.length - 1],
+ };
+}
+
+const ok = (body) => () => Promise.resolve({ ok: true, json: async () => body });
+const httpError = (status) => () => Promise.resolve({ ok: false, status });
+const aborted = () => () => Promise.reject(new DOMException('signal timed out', 'TimeoutError'));
+
+// A poll that hangs like a dropped Tor circuit: never settles on its own; the test fires the
+// abort the same way AbortSignal.timeout eventually would.
+function hangingPoll() {
+ let reject;
+ const promise = new Promise((_, rej) => { reject = rej; });
+ return {
+ response: () => promise,
+ abort: () => reject(new DOMException('signal timed out', 'TimeoutError')),
+ };
+}
+
+// --- pure helpers -----------------------------------------------------------------------------
+
+test('the poll timeout aborts before the next tick would fire (#382)', () => {
+ // The whole point of the abort: a hung fetch must reject before the 30s interval ticks
+ // again, or `inflight` latches and the page freezes on stale data with no banner.
+ assert.ok(FETCH_TIMEOUT_MS > 0);
+ assert.ok(FETCH_TIMEOUT_MS < REFRESH_MS);
+});
+
+test('windowFromUrl: accepts only a sane from/to pair', () => {
+ const parse = (qs) => windowFromUrl(new URLSearchParams(qs));
+ assert.deepEqual(parse('from=100&to=200'), { from: 100, to: 200 });
+ assert.deepEqual(parse('from=100.5&to=200.25'), { from: 100.5, to: 200.25 });
+ assert.equal(parse(''), null); // no zoom in the URL
+ assert.equal(parse('from=100'), null); // half a window
+ assert.equal(parse('to=100'), null);
+ assert.equal(parse('from=200&to=100'), null); // inverted
+ assert.equal(parse('from=100&to=100'), null); // empty span
+ assert.equal(parse('from=0&to=100'), null); // epoch-0 start is garbage, not a window
+ assert.equal(parse('from=abc&to=100'), null);
+});
+
+test('loadSeries: garbage or missing persisted JSON falls back to all-visible', () => {
+ const allOn = Object.fromEntries(SERIES_KEYS.map((k) => [k, true]));
+ assert.deepEqual(loadSeries(null), allOn);
+ assert.deepEqual(loadSeries('{not json'), allOn);
+ assert.deepEqual(loadSeries('"a string"'), allOn);
+});
+
+test('loadSeries: a persisted hidden series stays hidden', () => {
+ const hidden = SERIES_KEYS[0];
+ const out = loadSeries(JSON.stringify({ [hidden]: false }));
+ assert.equal(out[hidden], false);
+ for (const k of SERIES_KEYS.slice(1)) assert.equal(out[k], true);
+});
+
+// --- boot -------------------------------------------------------------------------------------
+
+test('boot: paints the loading shell, then the first poll; refresh scheduled at 30s', async () => {
+ const t = makeEnv({ responses: [ok({ page_title: 'Pithead — mining', workers: [] })] });
+ const d = initDashboard(t.env);
+ // The very first paint happens before any data arrives: state is still null.
+ assert.equal(t.paints[0].state, null);
+ assert.equal(t.paints[0].connected, true);
+ await d.firstLoad;
+ assert.equal(t.last().state.page_title, 'Pithead — mining');
+ assert.equal(t.last().connected, true);
+ assert.equal(t.doc.title, 'Pithead — mining'); // server-provided page title applied
+ assert.equal(t.interval().ms, REFRESH_MS);
+ assert.equal(t.interval().fn, d.tick); // the interval drives the same tick
+});
+
+test('poll request: default query, fetch marker header, and an abort signal (#382)', async () => {
+ const t = makeEnv({ responses: [ok({})] });
+ await initDashboard(t.env).firstLoad;
+ const { url, opts } = t.fetches[0];
+ assert.equal(url, '/api/state?range=all&avg=10m');
+ assert.equal(opts.headers['X-Requested-With'], 'fetch');
+ assert.ok(opts.signal instanceof AbortSignal); // the Tor-hang abort is actually wired
+ assert.equal(t.doc.title, ''); // no page_title in the payload → title left alone
+});
+
+test('boot: ui state seeds from the URL and persisted preferences', async () => {
+ const t = makeEnv({
+ href: 'http://pithead.test/?range=24h',
+ stored: {
+ dashboardView: 'advanced',
+ dashboardTheme: 'dark',
+ dashboardAvgWindow: '1h',
+ dashboardSort: '2:desc',
+ dashboardCalcHint: 'dismissed',
+ },
+ responses: [ok({})],
+ });
+ const d = initDashboard(t.env);
+ const ui = t.paints[0].ui;
+ assert.equal(ui.range, '24h');
+ assert.equal(ui.view, 'advanced');
+ assert.equal(ui.theme, 'dark');
+ assert.equal(ui.avg, '1h');
+ assert.equal(ui.sortIndex, 2);
+ assert.equal(ui.sortAsc, false);
+ assert.equal(ui.hintDismissed, true);
+ assert.equal(ui.inspectWorker, null);
+ // The persisted theme is re-asserted on before the first paint.
+ assert.equal(t.doc.documentElement.attrs['data-theme'], 'dark');
+ await d.firstLoad;
+ assert.equal(t.fetches[0].url, '/api/state?range=24h&avg=1h');
+});
+
+test('boot: garbage persisted preferences are routed through their normalizers', async () => {
+ // logic.test.mjs owns each normalizer's semantics; this pins that dashboard.js actually
+ // feeds every storage key through the right one instead of trusting localStorage.
+ const t = makeEnv({
+ stored: {
+ dashboardView: 'wat',
+ dashboardTheme: 'blorp',
+ dashboardAvgWindow: '7h',
+ dashboardSort: '99:asc',
+ dashboardSeries: '{broken',
+ },
+ responses: [ok({})],
+ });
+ initDashboard(t.env);
+ const ui = t.paints[0].ui;
+ assert.equal(ui.view, 'simple');
+ assert.equal(ui.theme, 'auto');
+ assert.equal(ui.avg, '10m');
+ assert.equal(ui.sortIndex, null);
+ assert.equal(ui.hintDismissed, false);
+ for (const k of SERIES_KEYS) assert.equal(ui.series[k], true);
+});
+
+test('boot: a shareable zoom URL (?from=&to=) wins over the preset range', async () => {
+ const t = makeEnv({ href: 'http://pithead.test/?from=1000&to=2000', responses: [ok({})] });
+ await initDashboard(t.env).firstLoad;
+ assert.equal(t.fetches[0].url, '/api/state?from=1000&to=2000&avg=10m');
+});
+
+// --- the refresh loop -------------------------------------------------------------------------
+
+test('poll failure: disconnected banner state, last snapshot kept, recovery on next tick', async () => {
+ const t = makeEnv({ responses: [ok({ page_title: 'live' }), aborted(), ok({ page_title: 'back' })] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ const snapshot = t.last().state;
+ await d.tick(); // this poll dies (Tor circuit dropped, request aborted)
+ assert.equal(t.last().connected, false);
+ assert.equal(t.last().state, snapshot); // stale data stays on screen, flagged by the banner
+ await d.tick(); // the next 30s tick reaches the server again
+ assert.equal(t.last().connected, true);
+ assert.equal(t.last().state.page_title, 'back');
+});
+
+test('poll failure: a non-2xx response counts as disconnected', async () => {
+ const t = makeEnv({ responses: [httpError(502)] });
+ await initDashboard(t.env).firstLoad;
+ assert.equal(t.last().connected, false);
+ assert.equal(t.last().state, null); // still the loading shell — no made-up data
+});
+
+test('a hung poll cannot stack fetches; the abort unblocks the loop (#382)', async () => {
+ const hang = hangingPoll();
+ const t = makeEnv({ responses: [hang.response, ok({ page_title: 'recovered' })] });
+ const d = initDashboard(t.env);
+ // The 30s interval fires while the first poll is still hanging: the inflight guard drops
+ // the tick instead of stacking a second fetch.
+ await t.interval().fn();
+ assert.equal(t.fetches.length, 1);
+ // The abort rejects the hung fetch — without it, `inflight` would stay latched and every
+ // later tick would no-op forever: frozen page, no banner (the original #382).
+ hang.abort();
+ await d.firstLoad;
+ assert.equal(t.last().connected, false);
+ await d.tick();
+ assert.equal(t.last().state.page_title, 'recovered');
+ assert.equal(t.last().connected, true);
+});
+
+// --- handlers, as handed to the App -----------------------------------------------------------
+
+test('onRange: exits any zoom, rewrites the URL, and refetches the new range', async () => {
+ const t = makeEnv({
+ href: 'http://pithead.test/?from=1000&to=2000',
+ responses: [ok({}), ok({}), ok({})],
+ });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ assert.ok(t.last().ui.window); // zoomed via the URL
+ await t.last().onRange('1h');
+ assert.equal(t.last().ui.range, '1h');
+ assert.equal(t.last().ui.window, null); // picking a preset exits the zoom
+ assert.deepEqual(t.urls, ['?range=1h']);
+ assert.equal(t.fetches[1].url, '/api/state?range=1h&avg=10m');
+ await t.last().onRange('all');
+ assert.deepEqual(t.urls, ['?range=1h', '/']); // "all" restores the bare path
+});
+
+test('onZoom/onResetZoom: pin and release the manual window, with shareable URLs', async () => {
+ const t = makeEnv({ responses: [ok({}), ok({}), ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ await t.last().onZoom(1000.4, 2000.6);
+ assert.deepEqual(t.last().ui.window, { from: 1000.4, to: 2000.6 }); // exact, for the fetch
+ assert.deepEqual(t.urls, ['?from=1000&to=2001']); // rounded, for the shareable URL
+ assert.equal(t.fetches[1].url, '/api/state?from=1000.4&to=2000.6&avg=10m');
+ await t.last().onResetZoom();
+ assert.equal(t.last().ui.window, null);
+ assert.deepEqual(t.urls, ['?from=1000&to=2001', '/']); // back on range "all" → bare path
+ assert.equal(t.fetches[2].url, '/api/state?range=all&avg=10m');
+});
+
+test('onSort: first click ascends, same column flips, new column restarts ascending', async () => {
+ const t = makeEnv({ responses: [ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ const polls = t.fetches.length;
+ t.last().onSort(3);
+ assert.equal(t.last().ui.sortIndex, 3);
+ assert.equal(t.last().ui.sortAsc, true);
+ t.last().onSort(3);
+ assert.equal(t.last().ui.sortAsc, false); // same column toggles direction
+ t.last().onSort(1);
+ assert.equal(t.last().ui.sortIndex, 1);
+ assert.equal(t.last().ui.sortAsc, true); // a new column restarts ascending
+ assert.equal(t.fetches.length, polls); // client-side sort only — no refetch
+});
+
+test('onView: persists the view; visiting Advanced retires the calculators hint (#425)', async () => {
+ const t = makeEnv({ responses: [ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ assert.equal(t.last().ui.hintDismissed, false);
+ t.last().onView('advanced');
+ assert.equal(t.last().ui.view, 'advanced');
+ assert.equal(t.stored.dashboardView, 'advanced');
+ assert.equal(t.last().ui.hintDismissed, true); // the hint's discoverability job is done
+ assert.equal(t.stored.dashboardCalcHint, 'dismissed');
+});
+
+test('onView: switching to a non-Advanced view leaves the hint alone', async () => {
+ const t = makeEnv({ responses: [ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ t.last().onView('config');
+ assert.equal(t.last().ui.view, 'config');
+ assert.equal(t.last().ui.hintDismissed, false);
+ assert.equal('dashboardCalcHint' in t.stored, false);
+});
+
+test('onDismissHint: dismisses the hint without leaving Simple view', async () => {
+ const t = makeEnv({ responses: [ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ t.last().onDismissHint();
+ assert.equal(t.last().ui.hintDismissed, true);
+ assert.equal(t.stored.dashboardCalcHint, 'dismissed');
+ assert.equal(t.last().ui.view, 'simple');
+});
+
+test('onTheme: persists and re-applies the theme to ', async () => {
+ const t = makeEnv({ responses: [ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ t.last().onTheme('dark');
+ assert.equal(t.last().ui.theme, 'dark');
+ assert.equal(t.stored.dashboardTheme, 'dark');
+ assert.equal(t.doc.documentElement.attrs['data-theme'], 'dark');
+});
+
+test('onToggleSeries: flips one series, persists the set, and never refetches', async () => {
+ const key = SERIES_KEYS[0];
+ const t = makeEnv({ responses: [ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ const polls = t.fetches.length;
+ t.last().onToggleSeries(key);
+ assert.equal(t.last().ui.series[key], false);
+ assert.equal(JSON.parse(t.stored.dashboardSeries)[key], false); // survives reload
+ t.last().onToggleSeries(key);
+ assert.equal(t.last().ui.series[key], true);
+ assert.equal(t.fetches.length, polls); // visibility only — no round-trip
+});
+
+test('onAvgWindow: clamps, persists, and refetches — the series lives on the server (#168)', async () => {
+ const t = makeEnv({ responses: [ok({}), ok({}), ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ await t.last().onAvgWindow('1h');
+ assert.equal(t.last().ui.avg, '1h');
+ assert.equal(t.stored.dashboardAvgWindow, '1h');
+ assert.equal(t.fetches[1].url, '/api/state?range=all&avg=1h');
+ await t.last().onAvgWindow('bogus'); // garbage clamps to the default window
+ assert.equal(t.last().ui.avg, '10m');
+ assert.equal(t.fetches[2].url, '/api/state?range=all&avg=10m');
+});
+
+test('onInspect/onCloseInspect: transient worker-panel state — no fetch, nothing persisted', async () => {
+ const t = makeEnv({ responses: [ok({})] });
+ const d = initDashboard(t.env);
+ await d.firstLoad;
+ const polls = t.fetches.length;
+ t.last().onInspect('miner-1');
+ assert.equal(t.last().ui.inspectWorker, 'miner-1');
+ t.last().onCloseInspect();
+ assert.equal(t.last().ui.inspectWorker, null);
+ assert.equal(t.fetches.length, polls);
+ assert.deepEqual(Object.keys(t.stored), []); // transient: nothing written to storage
+});
diff --git a/build/dashboard/tests/frontend/fixtures/_gen_state.py b/build/dashboard/tests/frontend/fixtures/_gen_state.py
index af5fe245..81dc5297 100644
--- a/build/dashboard/tests/frontend/fixtures/_gen_state.py
+++ b/build/dashboard/tests/frontend/fixtures/_gen_state.py
@@ -8,6 +8,7 @@
import json
import os
+import sys
import time
from pathlib import Path
from unittest.mock import MagicMock
@@ -122,7 +123,10 @@ def main():
"update": {"available": True, "latest": "v9.9.9", "url": "https://example/releases/v9.9.9"},
}
state = views.build_state(data, _state_mgr(), "all")
- out = Path(__file__).with_name("state.json")
+ # Optional output path so the drift guard (tests/web/test_views.py) can regenerate to a temp
+ # file and diff the shape without clobbering the checked-in fixture. This script patches
+ # time.time process-wide, so callers run it as a subprocess, never import it.
+ out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).with_name("state.json")
out.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
print(f"wrote {out} ({out.stat().st_size} bytes)")
diff --git a/build/dashboard/tests/frontend/logic.test.mjs b/build/dashboard/tests/frontend/logic.test.mjs
index 620f88c3..3e91df83 100644
--- a/build/dashboard/tests/frontend/logic.test.mjs
+++ b/build/dashboard/tests/frontend/logic.test.mjs
@@ -297,6 +297,20 @@ test('computeEarnings: Tari figures are null when merge-mining is unavailable (#
assert.ok(est.day > 0); // the XMR estimate is unaffected
});
+test('computeEarnings: a known per-block reward survives a dead channel and a zero hashrate (#992)', () => {
+ // The reward is a chain fact the TariCard prints on the same page — only the estimates
+ // (time-to-block, per-day averages) depend on tari_available and the what-if hashrate.
+ const earnings = { available: true, coeff_day: 1e-7, pool_difficulty: 1,
+ tari_available: false, tari_coeff_day: 0, tari_difficulty: 0,
+ tari_reward: 10_709 };
+ const est = computeEarnings(50_000, earnings);
+ assert.equal(est.tariRewardPerBlock, 10_709);
+ assert.equal(est.tariTimeToBlockSec, null); // the time estimate stays gated
+ assert.equal(est.tariDay, null);
+ // Hashrate-independent: the zero-hashrate early return keeps the known reward too.
+ assert.equal(computeEarnings(0, earnings).tariRewardPerBlock, 10_709);
+});
+
test('computeEarnings: no time-to-share when share difficulty is unknown', () => {
const est = computeEarnings(50_000, { available: true, coeff_day: 1e-7, pool_difficulty: 0 });
assert.equal(est.timeToShareSec, null);
@@ -339,14 +353,17 @@ test('computeXvbTier: between tiers picks the lower one', () => {
assert.equal(computeXvbTier(60_000, XVB_CALC).threshold, 10_000);
});
-test('computeXvbTier: null when disabled, calc missing, empty tiers, or bad hashrate', () => {
- assert.equal(computeXvbTier(15_000, { ...XVB_CALC, enabled: false }), null);
+test('computeXvbTier: null when calc missing, empty tiers, or bad hashrate', () => {
assert.equal(computeXvbTier(15_000, null), null);
assert.equal(computeXvbTier(15_000, { ...XVB_CALC, tiers: [] }), null);
assert.equal(computeXvbTier(0, XVB_CALC), null);
assert.equal(computeXvbTier(null, XVB_CALC), null);
});
+test('computeXvbTier: still computes with XvB disabled — the what-if is the decision aid (#938)', () => {
+ assert.equal(computeXvbTier(15_000, { ...XVB_CALC, enabled: false }).threshold, 10_000);
+});
+
// --- xvbDecisionRows (#872) — the per-tier decision table's pure math -------------------
const _CALC = {
@@ -400,7 +417,15 @@ test('xvbDecisionRows: no coeff (network stats down) -> no cost, no net, never a
assert.equal(whale.cost, null);
assert.equal(whale.net, null);
assert.equal(whale.mode, 'none');
- assert.equal(xvbDecisionRows({ enabled: false }, 1e-7, 200_000).length, 0);
+ assert.equal(xvbDecisionRows(null, 1e-7, 200_000).length, 0);
+});
+
+test('xvbDecisionRows: XvB disabled still prices the table (#938)', () => {
+ // The rows are the enable/don't-enable comparison, so the flag doesn't empty them —
+ // a disabled payload with tiers prices identically to an enabled one.
+ const rows = xvbDecisionRows({ ..._CALC, enabled: false }, 1e-7, 200_000);
+ assert.equal(rows.length, 2);
+ assert.ok(Math.abs(rows[1].cost - 3.65) < 1e-9);
});
test('formatXmr: precision scales with magnitude; "—" for null/invalid', () => {
assert.equal(formatXmr(2.5), '2.5000 XMR'); // >= 1 -> 4 dp
diff --git a/build/dashboard/tests/frontend/workerlogic.test.mjs b/build/dashboard/tests/frontend/workerlogic.test.mjs
index 227962e2..eef14147 100644
--- a/build/dashboard/tests/frontend/workerlogic.test.mjs
+++ b/build/dashboard/tests/frontend/workerlogic.test.mjs
@@ -8,9 +8,11 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
+ buildChartMarkers,
buildFields,
buildTableChanges,
jsonSyntaxError,
+ markerLabel,
parseJsonChanges,
} from "../../mining_dashboard/web/static/workerlogic.mjs";
@@ -112,3 +114,65 @@ test("jsonSyntaxError: live check used for inline feedback while typing", () =>
assert.equal(jsonSyntaxError('{"a": 1}'), null);
assert.match(jsonSyntaxError("{not json"), /Not valid JSON/);
});
+
+// --- markerLabel / buildChartMarkers (#1015) ------------------------------------------------
+
+test("markerLabel: an applied config change lists its changed keys", () => {
+ const label = markerLabel({ type: "apply", status: "applied", changes: { DONATION: 3 } });
+ assert.equal(label, "Applied: DONATION");
+});
+
+test("markerLabel: a rejected/rolled_back apply carries its reason, not the changed keys", () => {
+ assert.equal(
+ markerLabel({ type: "apply", status: "rejected", reason: "bad value", changes: { a: 1 } }),
+ "Apply rejected — bad value",
+ );
+ assert.equal(
+ markerLabel({ type: "apply", status: "rolled_back", reason: "miner did not return live" }),
+ "Apply rolled_back — miner did not return live",
+ );
+});
+
+test("markerLabel: an applied upgrade names the version it moved to", () => {
+ const label = markerLabel({ type: "upgrade", status: "applied", changes: { version: "v1.12.0" } });
+ assert.equal(label, "Upgraded to v1.12.0");
+});
+
+test("markerLabel: upgrade noop/throttled read calm, not as a fault", () => {
+ assert.equal(
+ markerLabel({ type: "upgrade", status: "noop", changes: { version: "v1.12.0" } }),
+ "Upgrade to v1.12.0: rig already current",
+ );
+ assert.equal(
+ markerLabel({
+ type: "upgrade",
+ status: "throttled",
+ changes: { version: "v1.12.0" },
+ reason: "retry after the window",
+ }),
+ "Upgrade to v1.12.0: throttled — retry after the window",
+ );
+});
+
+test("buildChartMarkers: maps each row to a chart point, quiet only for a non-applied outcome", () => {
+ const rows = [
+ { x: 1000, status: "applied", type: "apply", changes: { a: 1 } },
+ { x: 2000, status: "rejected", type: "apply", changes: {}, reason: "bad" },
+ { x: 3000, status: "applied", type: "upgrade", changes: { version: "v2" } },
+ ];
+ const pts = buildChartMarkers(rows);
+ assert.deepEqual(
+ pts.map((p) => [p.x, p.y, p.kind, p.quiet]),
+ [
+ [1000, 0.5, "apply", false],
+ [2000, 0.5, "apply", true],
+ [3000, 0.5, "upgrade", false],
+ ],
+ );
+ assert.equal(pts[0].label, "Applied: a");
+});
+
+test("buildChartMarkers: tolerates a missing/empty marker list", () => {
+ assert.deepEqual(buildChartMarkers(undefined), []);
+ assert.deepEqual(buildChartMarkers([]), []);
+});
diff --git a/build/dashboard/tests/frontend/workerview.test.mjs b/build/dashboard/tests/frontend/workerview.test.mjs
index ba7fb4b9..de301155 100644
--- a/build/dashboard/tests/frontend/workerview.test.mjs
+++ b/build/dashboard/tests/frontend/workerview.test.mjs
@@ -26,6 +26,7 @@ const DETAIL = {
writable_keys: ["DONATION", "max_temp_c", "token"],
last_applied: { DONATION: 5, max_temp_c: 70, token: SENTINEL },
history: [],
+ hashrate_history: { hashrate: [], markers: [] },
};
// WorkerInspect is never mounted here (no DOM/jsdom — this repo's frontend tests deliberately run
@@ -177,6 +178,44 @@ test("the fill button is a no-op when the file picker is dismissed with no file"
assert.equal(inst.state.editText, before);
});
+// --- Change history (#1014) -------------------------------------------------------------------
+
+test("a config-apply history row lists its changed keys", () => {
+ const detail = {
+ ...DETAIL,
+ history: [
+ {
+ applied_at: "2026-07-16 12:00",
+ status: "applied",
+ type: "apply",
+ changes: { DONATION: 3 },
+ reason: null,
+ },
+ ],
+ };
+ const out = renderToString(readyInstance(detail).render());
+ assert.match(out, /DONATION/);
+ assert.doesNotMatch(out, /upgrade →/);
+});
+
+test("a rig-upgrade history row shows the version it moved to, not the literal key 'version'", () => {
+ const detail = {
+ ...DETAIL,
+ history: [
+ {
+ applied_at: "2026-07-16 12:00",
+ status: "applied",
+ type: "upgrade",
+ changes: { version: "v1.12.0" },
+ reason: null,
+ },
+ ],
+ };
+ const out = renderToString(readyInstance(detail).render());
+ assert.match(out, /upgrade → v1\.12\.0/);
+ assert.doesNotMatch(out, />version); // never the raw changed-key name for an upgrade row
+});
+
// --- Hashrate by config (#492) ----------------------------------------------------------------
test("renders one row per config version with its aggregated hashrate", () => {
@@ -234,6 +273,88 @@ test("no applied config versions yet falls back to an explanatory message", () =
assert.match(out, /No applied config changes to correlate hashrate against yet/);
});
+// --- Hashrate chart (#1013/#1015) -------------------------------------------------------------
+
+test("a rig with no hashrate history yet renders an honest empty state, not a broken chart", () => {
+ const out = renderToString(readyInstance(DETAIL).render());
+ assert.match(out, /No hashrate history for this rig yet/);
+ // The range control still renders (the operator can still try a wider range).
+ assert.match(out, /24 Hr/);
+ assert.match(out, />All);
+});
+
+test("a rig with samples renders the range control and the chart canvas, not the empty state", () => {
+ const detail = {
+ ...DETAIL,
+ hashrate_history: { hashrate: [{ x: 1000, y: 500 }], markers: [] },
+ };
+ const out = renderToString(readyInstance(detail).render());
+ assert.doesNotMatch(out, /No hashrate history for this rig yet/);
+ assert.match(out, /