diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36497a33..c4152a0c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,30 @@ per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
## [Unreleased]
+## [1.13.0] - 2026-07-22
+
+### Added
+
+- Mine cart train (#748): a pixel-art strip under the hero band retelling the chart window as
+ cargo — one cart per interval, token coins for what landed (orange ɱ per share interval, a
+ pair for a found block, a purple gem per confirmed Tari payout, a blue X per XvB raffle win),
+ with a tipple, a sleeping cat, and drifting clouds. Static under reduced motion. `/api/state`
+ gains a `payouts` key: confirmed payouts per chain as `{x, amount}` timeline points, gated on
+ the payout-confirmation flags.
+
+### Changed
+
+- Earnings calculator standardized (#748): every tab presents its estimate in one
+ Day / Month / Year table with a shared-precision coin column and a `≈` fiat column once the
+ price is known. Tari gains month/year spans, XvB gains the published current-tier reward
+ table, Energy gains Revenue (est.) / Power Cost / Net columns. Net figures carry the one
+ judgment colour — green in profit, red in loss.
+- Dashboard panel round (#748): stat-heavy cards open collapsed to their headline stats with a
+ "Show all" toggle, the Advanced grid gains Your Stack / The Wider Pool section labels, the
+ workers table gains tabular numerics + row hover with offline-red scoped to the rig name,
+ chart active chips move from green to accent, and the topology diagram gains zone tints and
+ marching-ants on live routes.
+
## [1.12.0] - 2026-07-22
### Added
diff --git a/VERSION b/VERSION
index 0eed1a29..feaae22b 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.12.0
+1.13.0
diff --git a/build/dashboard/mining_dashboard/web/static/chart.mjs b/build/dashboard/mining_dashboard/web/static/chart.mjs
index 9b3dee8f..570db891 100644
--- a/build/dashboard/mining_dashboard/web/static/chart.mjs
+++ b/build/dashboard/mining_dashboard/web/static/chart.mjs
@@ -464,7 +464,7 @@ export class ChartCard extends Component {
return html`
- Range:
+ Range:
${RANGES.map(
// Real buttons like the Avg/legend siblings (#657); the ?range= deep link
// survives because setRange writes it via history.replaceState.
@@ -481,7 +481,7 @@ export class ChartCard extends Component {
}
`;
+// Standardized Day/Month/Year estimate table — the one shape every earnings tab presents its
+// estimate in: coin column in accent, a ≈-fiat column appearing once the coin's price is known
+// (the same visibility gate the old per-cell fiat cards used). Replaces per-tab stat-grids whose
+// two-column flow paired unrelated cells (XMR/year beside ≈/day). The coin triplet shares one
+// precision (coinTriplet) so the rows align.
+const EstTable = ({ unit, day, month, year, price, currency, title }) => {
+ const coins = coinTriplet([day, month, year], unit);
+ const haveFiat = Number.isFinite(price) && price > 0;
+ const rows = [
+ ["Day", day, coins[0]],
+ ["Month", month, coins[1]],
+ ["Year", year, coins[2]],
+ ];
+ return html`
+
+
+
+
+
${unit}
+ ${haveFiat ? html`
≈ ${currency || "USD"}
` : null}
+
+
+ ${rows.map(
+ ([label, raw, coin]) => html`
+
+
${label}
+
${coin}
+ ${haveFiat ? html`
${formatFiatAmount(coinFiat(raw, price))}
` : null}
+
`,
+ )}
+
+
+
`;
+};
+
+// Sign colour for a net figure — the one judgment colour in the calculator: green when the
+// operation profits, red when it loses. Everything that is a plain estimate stays accent/plain.
+const netCls = (v) => (v !== null && Number.isFinite(v) && v < 0 ? "c-bad" : "c-ok");
+
const SharesStat = ({ sw, label = "Share in Window" }) => html`
${label}
${sw.count}
`;
+// Progressive disclosure for a busy stat-grid card ("show more" pattern). A card splits its
+// StatCards into `headline` (always visible — the figures an operator actually glances at) and
+// `detail` (behind the toggle, collapsed by default); both share one `stat-grid` so the layout is
+// identical to the old flat list once expanded. Expansion is persisted per card via
+// loadPref/savePref — the same helpers dashboardEarningsTab already uses — keyed on `prefKey` so
+// each card remembers its own state independently across a reload. The toggle is a real
<${GlobalStats} state=${state} />
<${NetworkCard} state=${state} />
<${ComponentHealth} topology=${state.topology} egress=${state.egress} />
@@ -1053,6 +1164,7 @@ export function App({
? html`<${SyncView} sync=${state.sync} />`
: html`<${Fragment}>
<${HeroBand} state=${state} />
+ <${MineCartTrain} chart=${state.chart} blocks=${state.blocks} payouts=${state.payouts} />
<${DashboardView} state=${state} ui=${ui} onRange=${onRange} onSort=${onSort}
onView=${onView} onZoom=${onZoom} onResetZoom=${onResetZoom}
onToggleSeries=${onToggleSeries} onAvgWindow=${onAvgWindow}
diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css
index 74c0284d..c0131de4 100644
--- a/build/dashboard/mining_dashboard/web/static/dashboard.css
+++ b/build/dashboard/mining_dashboard/web/static/dashboard.css
@@ -96,6 +96,18 @@ body {
gap: 20px;
margin-bottom: 20px;
}
+/* Section label inside the Advanced card grid (#159): the grid itself has no visual grouping, so
+ * these mark the "mine" -> "the world" transition the layout comment describes. Spans the full
+ * grid row; typography matches .est-heading (the other muted section marker in this stylesheet). */
+.grid-section-label {
+ grid-column: 1 / -1;
+ margin-top: 6px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
.card {
background: var(--card);
border: 1px solid var(--border);
@@ -164,6 +176,11 @@ body {
padding: 16px 18px;
text-align: center;
}
+/* Total Hashrate (always first — heroKpis, logic.mjs) gets an accent cue so the strip reads with
+ * a clear headline instead of six equally-weighted numbers. */
+.hero-band .hero-kpi:first-child {
+ border-top: 2px solid var(--accent);
+}
.hero-value {
font-size: 1.6rem;
font-weight: 700;
@@ -178,6 +195,18 @@ body {
color: var(--text-muted);
}
+/* Mine cart train (pixel art) under the hero band: purely decorative — the chart above shows
+ * the same data — so it stays short and quiet. The canvas draws at a 2× logical scale; height
+ * matches the renderer's 44-logical-px strip. */
+.minecart-strip {
+ margin: -4px 0 16px 0;
+}
+.minecart-strip canvas {
+ display: block;
+ width: 100%;
+ height: 88px;
+}
+
/* Typography */
h2 {
margin: 0;
@@ -241,6 +270,26 @@ h3 {
.col-span-2 {
grid-column: span 2;
}
+/* Progressive disclosure ("show more") toggle for a stat-grid card: a real , muted like
+ * the other secondary controls (.egress-details summary) so it doesn't compete with the stats
+ * themselves, but left-aligned under the grid rather than centred like a primary action. */
+.more-stats-toggle {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-family: inherit;
+ font-size: 0.8rem;
+ cursor: pointer;
+ padding: 8px 0 0 0;
+ text-align: left;
+}
+.more-stats-toggle:hover {
+ color: var(--text);
+}
+.more-stats-toggle:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
/* Earnings calculator what-if input (Issue #12) */
.earnings-subtitle {
@@ -406,6 +455,47 @@ td {
tr:last-child td {
border-bottom: none;
}
+
+/* Standardized estimate tables: the one Day/Month/Year shape every earnings tab uses.
+ * Builds on the generic table/th/td rules above (borders, muted uppercase headers) and only
+ * overrides typography and alignment: numeric columns right-aligned with tabular figures so
+ * the shared-precision rows line up, label column flush left, digits never break mid-number
+ * (the .est-scroll wrapper pans instead on narrow screens). */
+.est-table {
+ margin-bottom: 12px;
+}
+.est-table th {
+ font-size: 0.7rem;
+ text-align: right;
+ padding: 4px 0 4px 12px;
+}
+.est-table tbody th,
+.est-table thead th:first-child {
+ text-align: left;
+ padding-left: 0;
+}
+.est-table td {
+ text-align: right;
+ font-weight: 600;
+ font-size: 0.95rem;
+ padding: 5px 0 5px 12px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.est-table tr:last-child th {
+ border-bottom: none; /* match the generic tr:last-child td rule for the row-label column */
+}
+.est-scroll {
+ overflow-x: auto;
+}
+.est-heading {
+ margin: 14px 0 6px 0;
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+
/* Pool-wide proxy share totals under the Workers table (Issue #82). */
.proxy-totals {
margin-top: 12px;
@@ -433,6 +523,42 @@ tr:last-child td {
.table-scroll td {
white-space: nowrap;
}
+/* Numeric columns (1m / 10m / Accepted / Rejected — WORKER_COLUMNS indices 3-6, logic.mjs)
+ * right-align with tabular figures, the same treatment as .est-table td, so the digits line up
+ * instead of ragging left like the text columns beside them. */
+.table-scroll th:nth-child(4),
+.table-scroll th:nth-child(5),
+.table-scroll th:nth-child(6),
+.table-scroll th:nth-child(7),
+.table-scroll td:nth-child(4),
+.table-scroll td:nth-child(5),
+.table-scroll td:nth-child(6),
+.table-scroll td:nth-child(7) {
+ text-align: right;
+}
+.table-scroll td:nth-child(4),
+.table-scroll td:nth-child(5),
+.table-scroll td:nth-child(6),
+.table-scroll td:nth-child(7) {
+ font-variant-numeric: tabular-nums;
+}
+/* Row hover (Workers Alive): a quiet raised-surface cue that a row is under the pointer. */
+.table-scroll tbody tr:hover td {
+ background: var(--elevated);
+}
+/* An offline row's status-bad class colours the whole
(color is inherited down every td) —
+ * that read as eight cells of red text. Reset the row to normal text colour and let only the name
+ * cell (which already carries the worker name + status context) carry the ok/bad tint. */
+.table-scroll tbody tr.status-ok > td,
+.table-scroll tbody tr.status-bad > td {
+ color: var(--text);
+}
+.table-scroll tbody tr.status-ok > td:first-child {
+ color: var(--ok);
+}
+.table-scroll tbody tr.status-bad > td:first-child {
+ color: var(--bad);
+}
/* The audit trail's title + its hour/day/month grouping select (#530), side by side. */
.card-header-row {
@@ -577,14 +703,22 @@ button.upgrade-btn {
border-color: var(--text-muted);
color: var(--text);
}
+/* Accent, not --ok: --ok is the app-wide "healthy" signal (status-ok, badge-ok, the sync
+ * gauge) — reusing it for "this range/window is selected" collided with that meaning. Accent
+ * matches .btn-toggle.active, the sibling Simple/Advanced control. */
.btn-range.active {
- background-color: var(--ok);
+ background-color: var(--accent);
color: #fff;
- border-color: var(--ok);
+ border-color: var(--accent);
}
.btn-reset {
margin-left: 8px;
}
+/* "Range:" / "Avg:" prefixes (chart.mjs): full text colour, not text-muted, so the two control
+ * rows read as separate labelled groups instead of blurring into one muted line. */
+.chart-control-label {
+ color: var(--text);
+}
/* Series show/hide toggles (Issue #47): a quiet legend; click a chip to toggle that series.
* A hidden series is dimmed + struck through. Swatch colours track the theme palette. */
@@ -846,6 +980,7 @@ button.upgrade-btn {
gap: 12px;
background: var(--card);
border: 1px solid var(--border);
+ border-left: 3px solid var(--accent);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 15px;
@@ -1066,10 +1201,21 @@ button.upgrade-btn {
fill: var(--text);
font-size: 11px;
}
+/* Tor hub: tint the fill with the same green as its stroke (instead of the flat --bg every other
+ * node uses) so the trust anchor reads as a distinct zone at a glance, not just a thicker border. */
.topo-hub rect {
+ fill: var(--ok);
+ fill-opacity: 0.12;
stroke: var(--ok);
stroke-width: 1.5;
}
+/* Host-zone nodes (xmrig-proxy/caddy/dashboard/p2pool/monerod/tari/docker): a faint accent tint —
+ * "this stack" — same zone-tint treatment as the Tor hub above. Excludes .topo-hub (tor) and
+ * .topo-ext (lan/internet, which stay the deliberately hollow/dashed "external" look below). */
+.topo-node:not(.topo-hub):not(.topo-ext) rect {
+ fill: var(--accent);
+ fill-opacity: 0.06;
+}
.topo-ext rect {
fill: transparent;
stroke-dasharray: 3 2;
@@ -1077,6 +1223,22 @@ button.upgrade-btn {
.topo-ext text {
fill: var(--text-muted);
}
+/* Marching ants on active, non-muted edges (Tor/clearnet — never the grey local/inactive routes):
+ * a moving dash pattern reads as "live traffic" versus the static routes. Dashes alone (no motion)
+ * render for prefers-reduced-motion, so the distinction still exists without the animation. */
+.topo-edge-ants {
+ stroke-dasharray: 6 4;
+}
+@media (prefers-reduced-motion: no-preference) {
+ .topo-edge-ants {
+ animation: topo-ants-dash 0.8s linear infinite;
+ }
+}
+@keyframes topo-ants-dash {
+ to {
+ stroke-dashoffset: -10;
+ }
+}
/* The per-component egress list, demoted to an expandable drawer under the topology map. */
.egress-details {
margin-top: 0.6rem;
diff --git a/build/dashboard/mining_dashboard/web/static/logic.mjs b/build/dashboard/mining_dashboard/web/static/logic.mjs
index 4f617194..f9921cdf 100644
--- a/build/dashboard/mining_dashboard/web/static/logic.mjs
+++ b/build/dashboard/mining_dashboard/web/static/logic.mjs
@@ -248,6 +248,8 @@ export function computeEarnings(hashrateHs, earnings) {
tariTimeToBlockSec: null,
tariRewardPerBlock: null,
xvbDay: null,
+ xvbMonth: null,
+ xvbYear: null,
};
}
const day = hashrateHs * earnings.coeff_day;
@@ -274,8 +276,12 @@ export function computeEarnings(hashrateHs, earnings) {
tariTimeToBlockSec,
tariRewardPerBlock: earnings.tari_available ? earnings.tari_reward : null,
// Current-tier XvB expected reward, XMR/day (#712). A fixed published figure, NOT scaled by the
- // what-if hashrate (unlike day/tariDay); null unless the server sent a fresh estimate.
+ // what-if hashrate (unlike day/tariDay); null unless the server sent a fresh estimate. Month/
+ // year are the same day/month/year spans every other estimate gets, so the XvB tab can show
+ // the standardized table.
xvbDay: Number.isFinite(earnings.xvb_day) ? earnings.xvb_day : null,
+ xvbMonth: Number.isFinite(earnings.xvb_day) ? earnings.xvb_day * DAYS_PER_MONTH : null,
+ xvbYear: Number.isFinite(earnings.xvb_day) ? earnings.xvb_day * DAYS_PER_YEAR : null,
};
}
@@ -311,13 +317,31 @@ export function xvbTierComparison(tier, coeffDay) {
return { expected, cost, net };
}
-// Format a client-computed coin amount. More decimal places for small amounts (a day's earnings can
-// be a tiny fraction) so the figure isn't rounded to "0.0000"; "—" for the null/invalid case.
+// Decimal places for a coin amount: more for small amounts (a day's earnings can be a tiny
+// fraction) so the figure isn't rounded to "0.0000".
+function coinDp(value) {
+ return value >= 1 ? 4 : value >= 0.001 ? 6 : 8;
+}
+
+// Format a client-computed coin amount; "—" for the null/invalid case.
function formatCoin(value, unit) {
if (value === null || value === undefined || !Number.isFinite(value)) return "—";
if (value === 0) return "0 " + unit;
- const dp = value >= 1 ? 4 : value >= 0.001 ? 6 : 8;
- return value.toFixed(dp) + " " + unit;
+ return value.toFixed(coinDp(value)) + " " + unit;
+}
+
+// Format a Day/Month/Year coin triplet with ONE shared precision, picked from the smallest
+// positive value (the day figure). Per-value precision made the column ragged — "0.00092945"
+// above "0.027884" above "0.339250" — so the standardized estimate tables share the dp and the
+// rows align. Nulls still render "—" (the table can hold a computable day beside a null month).
+export function coinTriplet(values, unit) {
+ const positive = values.filter((v) => Number.isFinite(v) && v > 0);
+ const dp = positive.length ? coinDp(Math.min(...positive)) : 4;
+ return values.map((v) => {
+ if (v === null || v === undefined || !Number.isFinite(v)) return "—";
+ if (v === 0) return "0 " + unit;
+ return v.toFixed(dp) + " " + unit;
+ });
}
export function formatXmr(xmr) {
@@ -365,6 +389,9 @@ export function computeEnergy(energy, est) {
costDay: null,
costMonth: null,
costYear: null,
+ grossDay: null,
+ grossMonth: null,
+ grossYear: null,
netDay: null,
netMonth: null,
netYear: null,
@@ -398,6 +425,11 @@ export function computeEnergy(energy, est) {
costDay,
costMonth: span(costDay, DAYS_PER_MONTH),
costYear: span(costDay, DAYS_PER_YEAR),
+ // Gross revenue (the sum the net starts from), surfaced so the Energy tab can show
+ // Revenue → Cost → Net instead of leaving the revenue side implicit.
+ grossDay,
+ grossMonth: span(grossDay, DAYS_PER_MONTH),
+ grossYear: span(grossDay, DAYS_PER_YEAR),
netDay,
netMonth: span(netDay, DAYS_PER_MONTH),
netYear: span(netDay, DAYS_PER_YEAR),
@@ -414,6 +446,14 @@ export function formatFiat(value, currency) {
return sign + (currency || "USD") + " " + Math.abs(value).toFixed(2);
}
+// Bare fiat amount for estimate-table cells, where the column header already names the
+// currency — repeating "USD" in every cell is noise. Same 2-dp + sign rules as formatFiat.
+export function formatFiatAmount(value) {
+ if (value === null || value === undefined || !Number.isFinite(value)) return "—";
+ const sign = value < 0 ? "-" : "";
+ return sign + Math.abs(value).toFixed(2);
+}
+
// Fiat value of a coin estimate (#520 price feed): null unless both the estimate and a positive
// price exist, so the coin tabs only grow fiat rows once a price is configured or fetched.
export function coinFiat(value, price) {
@@ -447,9 +487,10 @@ export function priceSourceLabel(energy) {
}
// Format a power draw / energy figure with its unit (W, kWh); "—" for the null/invalid case.
+// An empty unit gives the bare number — for table cells whose column header names the unit.
export function formatUnit(value, unit, dp = 1) {
if (value === null || value === undefined || !Number.isFinite(value)) return "—";
- return value.toFixed(dp) + " " + unit;
+ return value.toFixed(dp) + (unit ? " " + unit : "");
}
// Format the expected time-to-share (seconds) for display; "—" when not computable. Reuses the
diff --git a/build/dashboard/mining_dashboard/web/static/minecart.mjs b/build/dashboard/mining_dashboard/web/static/minecart.mjs
new file mode 100644
index 00000000..e0bf73cd
--- /dev/null
+++ b/build/dashboard/mining_dashboard/web/static/minecart.mjs
@@ -0,0 +1,380 @@
+import { Component, html } from "./preact.mjs";
+
+// Mine cart train (pixel art). A horizontal strip under the hero band: each cart is one
+// interval of recent history, and what landed in it rides in the cart as a token coin poking
+// over the rim — a Monero-orange ɱ coin for a P2Pool share, a PAIR of them for a found block,
+// a purple Tari gem-coin for a confirmed Tari payout, a blue X coin for an XvB raffle win.
+// The newest interval sits under the tipple chute on the right; completed carts roll left.
+// Pure presentation over the same /api/state chart history the Chart.js card reads — no new
+// data, no server involvement. Canvas 2D, drawn at a fixed logical pixel grid and integer-
+// scaled (imageSmoothingEnabled = false) so it stays crisp pixel art at any DPR.
+
+// ---- sprite sheets ------------------------------------------------------------------------
+// Char-map sprites: each string is a row, each char a palette key, "." transparent. The
+// palette maps chars to concrete colours at draw time (theme/event dependent).
+
+const CART_W = 22; // logical px, cart body incl. wheels
+const CART_H = 16;
+const GAP = 4; // coupling gap between carts
+const STRIP_H = 56; // logical strip height incl. sky, tipple and rail
+const CAT_ZONE = 44; // left margin reserved for the sleeping cat + its grass mound
+
+// Cart body (side view): rim, sloped hull, two wheels. "b" body, "r" rim, "w" wheel,
+// "h" wheel hub, "s" shadow under hull.
+const CART = [
+ "rrrrrrrrrrrrrrrrrrrrrr",
+ "rbbbbbbbbbbbbbbbbbbbbr",
+ ".bbbbbbbbbbbbbbbbbbbb.",
+ ".bbbbbbbbbbbbbbbbbbbb.",
+ "..bbbbbbbbbbbbbbbbbb..",
+ "..bbbbbbbbbbbbbbbbbb..",
+ "...bbbbbbbbbbbbbbbb...",
+ "...ssssssssssssssss...",
+ "....ww..........ww....",
+ "...wwww........wwww...",
+ "...whhw........whhw...",
+ "....ww..........ww....",
+];
+
+// Big token coin (12×11) that pokes out of a cart: dark rim, bright face, a shaded crescent on
+// the lower-right for the 3D read. Drawn BEFORE the cart so the hull occludes its lower third —
+// the coin sits IN the cart, sticking out. Symbols are overlaid from SYMBOLS at draw time.
+const COIN = [
+ "...rrrrrr...",
+ "..rffffffr..",
+ ".rfffffffdr.",
+ "rffffffffddr",
+ "rffffffffddr",
+ "rffffffffddr",
+ "rffffffffddr",
+ "rfffffffdddr",
+ ".rffffffddr.",
+ "..rffffddr..",
+ "...rrrrrr...",
+];
+
+// 5×5 coin symbols ("x" = symbol pixel), centred on the coin face: Monero's M, Tari's gem,
+// XvB's X.
+const SYMBOLS = {
+ xmr: ["x...x", "xx.xx", "x.x.x", "x...x", "x...x"],
+ tari: ["..x..", ".xxx.", "xxxxx", ".xxx.", "..x.."],
+ xvb: ["x...x", ".x.x.", "..x..", ".x.x.", "x...x"],
+};
+
+// The tipple (coal chute) that loads the newest cart: legs, hopper, spout.
+const TIPPLE = [
+ "..tttttttttt..",
+ ".tttttttttttt.",
+ ".tttttttttttt.",
+ "..tttttttttt..",
+ "...tttttttt...",
+ "....tttttt....",
+ ".....tttt.....",
+ ".....t..t.....",
+ ".....t..t.....",
+];
+
+// Sleeping cat, curled up facing left: ears, closed eye, lighter muzzle/belly patch, wrapped
+// tail. "m" fur, "l" light fur, "e" closed-eye line, "n" nose.
+const CAT = [
+ "..............",
+ "...mm....mm...",
+ "..mmmmmmmmmm..",
+ ".mmemmmmmmmmm.",
+ ".mlnlmmmmmmmm.",
+ ".mllmmmmmmmmm.",
+ "..mmmmmmmmmt..",
+ "...ttttttttt..",
+];
+
+// Puffy cloud, drawn in a soft translucent tone; drifts slowly across the sky.
+const CLOUD = ["....cccccc......", "..cccccccccc.c..", ".cccccccccccccc.", "..cccccccccccc.."];
+
+// ---- data adapter -------------------------------------------------------------------------
+// Bucket the /api/state chart payload + top-level blocks into carts. Pure and unit-tested.
+// The 12 carts span whatever window the server sent (the chart's selected range), so each cart
+// is 1/12 of the visible history — 5-minute carts on the 1h range, 2-hour carts on 24h — and
+// the train never goes sparse under the server's per-range downsampling. Sources (three
+// separate marker arrays; there is no unified event stream server-side):
+// chart.p2pool {x: ms, y: H/s}, y:null gap markers → window bounds only
+// chart.shares {x: ms, c: count} → orange ɱ coin
+// chart.raffle {x: ms, label} → XvB blue X coin
+// blocks (top-level) {x: ms, height} → a PAIR of ɱ coins
+// payouts.tari {x: ms, amount} confirmed payouts → Tari purple gem coin
+// A confirmed Tari coinbase payout IS a solo-found Tari block (the wallet's proof it landed),
+// so the purple coin marks real Tari income, not a guess.
+export function cartsFromState(chart, blocks, count = 12, payouts = null) {
+ // Valid-sample timestamps bound the window; outage gap markers carry y:null and don't count.
+ const ts = ((chart && chart.p2pool) || [])
+ .filter((p) => Number.isFinite(p.y))
+ .map((p) => p.x / 1000);
+ if (!ts.length) return [];
+ const events = [];
+ for (const s of (chart && chart.shares) || [])
+ events.push({ t: s.x / 1000, kind: "share", n: s.c || 1 });
+ for (const r of (chart && chart.raffle) || []) events.push({ t: r.x / 1000, kind: "xvb", n: 1 });
+ for (const b of blocks || []) events.push({ t: b.x / 1000, kind: "block", n: 1 });
+ for (const p of (payouts && payouts.tari) || [])
+ events.push({ t: p.x / 1000, kind: "tari", n: 1 });
+
+ const t0all = ts[0];
+ const bucketSec = Math.max(ts[ts.length - 1] - t0all, 60) / count;
+ const carts = [];
+ for (let i = 0; i < count; i++) {
+ const t0 = t0all + i * bucketSec;
+ const t1 = t0 + bucketSec;
+ const evs = events.filter((e) => e.t >= t0 && (i === count - 1 ? e.t <= t1 : e.t < t1));
+ carts.push({
+ t0,
+ t1,
+ shares: evs.filter((e) => e.kind === "share").reduce((a, e) => a + e.n, 0),
+ blocks: evs.filter((e) => e.kind === "block").length,
+ wins: evs.filter((e) => e.kind === "xvb").length,
+ tari: evs.filter((e) => e.kind === "tari").length,
+ // The newest (rightmost) cart is still loading under the chute.
+ loading: i === count - 1,
+ });
+ }
+ return carts;
+}
+
+// One-line human summary for a cart (canvas tooltip). Times in the operator's locale.
+export function cartTitle(cart) {
+ const hm = (t) =>
+ new Date(t * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ const what = [];
+ if (cart.blocks) what.push(cart.blocks + " block" + (cart.blocks > 1 ? "s" : "") + " FOUND");
+ if (cart.tari) what.push(cart.tari + " Tari payout" + (cart.tari > 1 ? "s" : ""));
+ if (cart.wins) what.push(cart.wins + " XvB raffle win" + (cart.wins > 1 ? "s" : ""));
+ if (cart.shares) what.push(cart.shares + " share" + (cart.shares > 1 ? "s" : ""));
+ return (
+ hm(cart.t0) +
+ "–" +
+ hm(cart.t1) +
+ " — " +
+ (what.length ? what.join(", ") : "hauling") +
+ (cart.loading ? " (loading)" : "")
+ );
+}
+
+// ---- renderer -----------------------------------------------------------------------------
+
+// The tipple drip's rock colour. Fixed pigment (not a theme var): the scene should read the
+// same in both themes, like the chart series colours do.
+const DRIP = "#8a7a66";
+const COINS = {
+ // r rim (dark), f face, d depth crescent, s symbol pixels. Monero coins are Monero ORANGE
+ // (the brand pigment), Tari purple, XvB blue — same fixed-pigment rule as the rest of the
+ // scene. A found block shows as a PAIR of Monero coins rather than a brighter tint.
+ xmr: { r: "#a63d0e", f: "#f26822", d: "#c85417", s: "#fff3ec" },
+ tari: { r: "#5e2ea6", f: "#9d5cf5", d: "#7a3fd4", s: "#f4edff" },
+ xvb: { r: "#1f5fa8", f: "#58a6ff", d: "#3b7fd4", s: "#eef6ff" },
+};
+// Scenery pigments — soft pastel greens for grass, warm grey cat with a cream belly.
+const GRASS = { a: "#6aa84f", b: "#8fce6e" };
+const CAT_PAL = { m: "#8d7b70", l: "#e8d8c3", e: "#4a3f38", n: "#c97f6f", t: "#7a675c" };
+
+function themePalette() {
+ const css = getComputedStyle(document.documentElement);
+ const v = (name, fallback) => (css.getPropertyValue(name) || fallback).trim();
+ return {
+ // The physical objects use fixed pigments — a white cart hull on a white light-theme page
+ // disappears (the ore/coins/cat were always fixed for the same reason). Warm iron + timber
+ // keeps the Ghibli-ish warmth in both themes.
+ b: "#6b625a", // cart hull — weathered iron
+ r: "#494138", // rim
+ s: "#00000055", // hull shadow
+ w: "#4a4440", // wheels
+ h: "#a89680", // hubs
+ t: "#7a5c43", // tipple timber
+ rail: "#8a8178",
+ // Only the sky is theme-aware: clouds at the muted text tone, low alpha (vars are 6-digit
+ // hex) — soft against either background.
+ cloud: v("--text-muted", "#8b949e") + "40",
+ };
+}
+
+function drawSprite(ctx, sprite, palette, x, y) {
+ for (let row = 0; row < sprite.length; row++) {
+ const line = sprite[row];
+ for (let col = 0; col < line.length; col++) {
+ const c = line[col];
+ if (c === ".") continue;
+ const color = palette[c];
+ if (!color) continue;
+ ctx.fillStyle = color;
+ ctx.fillRect(x + col, y + row, 1, 1);
+ }
+ }
+}
+
+// Draw the whole scene onto a canvas at logical scale 1 (the caller scales the canvas).
+// `tick` is a monotonically increasing frame counter (4 ticks/s): fast cycles drive the coin
+// shine and chute drip, slow division drives the cloud drift and the cat's sleepy "z"s.
+// Static (tick 0) under prefers-reduced-motion.
+export function drawTrain(ctx, carts, widthPx, tick = 0) {
+ const pal = themePalette();
+ const phase = (tick % 4) / 4;
+ ctx.clearRect(0, 0, widthPx, STRIP_H);
+ const railY = STRIP_H - 4;
+
+ // Sky: two clouds drifting slowly right-to-left, wrapping. Translucent so they sit behind
+ // everything and stay soft in both themes.
+ const cloudPal = { c: pal.cloud };
+ const drift = Math.floor(tick / 3) % (widthPx + 40);
+ drawSprite(ctx, CLOUD, cloudPal, widthPx - drift, 1);
+ drawSprite(ctx, CLOUD, cloudPal, ((widthPx * 0.45 - drift * 0.6 + widthPx) % widthPx) - 8, 7);
+
+ // Rails + ties, full width.
+ ctx.fillStyle = pal.rail;
+ ctx.fillRect(0, railY, widthPx, 1);
+ ctx.fillRect(0, railY + 2, widthPx, 1);
+ for (let x = 2; x < widthPx; x += 6) ctx.fillRect(x, railY + 1, 2, 1);
+
+ // Grass tufts along the track — little two-blade sprouts, alternating greens.
+ for (let x = 7; x < widthPx - 10; x += 23) {
+ ctx.fillStyle = (x / 23) % 2 ? GRASS.a : GRASS.b;
+ ctx.fillRect(x, railY - 2, 1, 2);
+ ctx.fillRect(x + 2, railY - 1, 1, 1);
+ ctx.fillRect(x - 1, railY - 1, 1, 1);
+ }
+
+ // The sleeping cat on its grass mound, left end of the track.
+ const moundY = railY - 3;
+ ctx.fillStyle = GRASS.a;
+ ctx.fillRect(6, moundY + 1, 30, 2);
+ ctx.fillStyle = GRASS.b;
+ ctx.fillRect(9, moundY, 24, 1);
+ drawSprite(ctx, CAT, CAT_PAL, 13, moundY - CAT.length + 1);
+ // Sleepy z's rising every few seconds; two sizes for depth.
+ const zStep = Math.floor(tick / 2) % 6;
+ if (zStep < 3) {
+ ctx.fillStyle = pal.r;
+ ctx.fillRect(30, moundY - CAT.length - 2 - zStep, 2, 1);
+ ctx.fillRect(31, moundY - CAT.length - 1 - zStep, 1, 1);
+ if (zStep > 0) ctx.fillRect(34, moundY - CAT.length - 4 - zStep, 1, 1);
+ }
+
+ // Right-align the train: newest cart under the tipple at the right edge; never roll into
+ // the cat's corner.
+ const tippleX = widthPx - TIPPLE[0].length - 2;
+ const cartY = railY - CART_H + 2;
+ let x = widthPx - CART_W - 4;
+ for (let i = carts.length - 1; i >= 0 && x > CAT_ZONE; i--) {
+ const cart = carts[i];
+ // Token coins riding IN the cart, poking out over the rim: a Monero-orange ɱ coin per
+ // share interval, a PAIR of them for a found block, Tari gem-coin purple, XvB X-coin blue.
+ // No pile, no sparkle — the coins bob gently instead (1px, phase-offset per cart, like
+ // the track is rumbling). Drawn before the hull so the cart occludes their lower third.
+ const coinKinds = [];
+ if (cart.blocks || cart.shares) coinKinds.push("xmr");
+ if (cart.tari) coinKinds.push("tari");
+ if (cart.wins) coinKinds.push("xvb");
+ // A block shows as a PAIR of ɱ coins — but only when the pair fits: two slots, and a
+ // same-interval Tari/XvB coin must never be squeezed out by the second ɱ (the tooltip
+ // always tells the full story either way).
+ if (cart.blocks && coinKinds.length === 1) coinKinds.push("xmr");
+ const two = coinKinds.length > 1;
+ coinKinds.slice(0, 2).forEach((kindName, ci) => {
+ const cp = COINS[kindName];
+ const bob = (Math.floor(tick / 2) + i + ci) % 2;
+ const cx = two ? x + ci * 9 : x + 5;
+ const cy = cartY - 7 + bob;
+ drawSprite(ctx, COIN, { r: cp.r, f: cp.f, d: cp.d }, cx, cy);
+ drawSprite(ctx, SYMBOLS[kindName], { x: cp.s }, cx + 3, cy + 3);
+ });
+ drawSprite(ctx, CART, pal, x, cartY);
+ x -= CART_W + GAP;
+ }
+
+ // Tipple over the newest cart, with a falling rock-drip while loading.
+ drawSprite(ctx, TIPPLE, pal, tippleX, cartY - TIPPLE.length - 4);
+ const last = carts[carts.length - 1];
+ if (last && last.loading) {
+ const dripY = cartY - 4 + Math.floor(phase * 6);
+ ctx.fillStyle = DRIP;
+ ctx.fillRect(tippleX + 6, dripY, 1, 2);
+ ctx.fillRect(tippleX + 7, (dripY + 3) % (cartY - 1), 1, 1);
+ }
+}
+
+// ---- component ----------------------------------------------------------------------------
+
+const SCALE = 2; // css px per logical pixel — chunky enough to read as pixel art
+
+// Decorative strip (aria-hidden: everything it shows, the chart above already shows) with a
+// per-cart hover tooltip as a bonus. Redraws on every state refresh; a 250 ms four-step tick
+// drives the chute drip and ore twinkle, skipped entirely under prefers-reduced-motion (the
+// dashboard's first animation — keep it polite).
+export class MineCartTrain extends Component {
+ componentDidMount() {
+ this.reduced =
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ this.tick = 0;
+ this.onResize = () => this.draw();
+ window.addEventListener("resize", this.onResize);
+ this.draw();
+ if (!this.reduced) {
+ this.timer = setInterval(() => {
+ this.tick++;
+ this.draw();
+ }, 250);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.timer) clearInterval(this.timer);
+ window.removeEventListener("resize", this.onResize);
+ }
+
+ componentDidUpdate() {
+ this.draw();
+ }
+
+ draw() {
+ const canvas = this.base && this.base.querySelector ? this.base.querySelector("canvas") : null;
+ if (!canvas) return;
+ const cssW = canvas.clientWidth;
+ const cssH = STRIP_H * SCALE;
+ const dpr = window.devicePixelRatio || 1;
+ if (canvas.width !== Math.round(cssW * dpr)) {
+ canvas.width = Math.round(cssW * dpr);
+ canvas.height = Math.round(cssH * dpr);
+ }
+ // Cart count adapts to the width so the train fills the strip on any screen — each cart is
+ // 1/count of the loaded window, and the tooltip states its exact interval. The cat's corner
+ // is reserved out of the budget.
+ const logicalW = cssW / SCALE;
+ const count = Math.max(6, Math.min(24, Math.floor((logicalW - CAT_ZONE - 8) / (CART_W + GAP))));
+ this.carts = cartsFromState(this.props.chart, this.props.blocks, count, this.props.payouts);
+ if (!this.carts.length) return;
+ const ctx = canvas.getContext("2d");
+ ctx.setTransform(SCALE * dpr, 0, 0, SCALE * dpr, 0, 0);
+ ctx.imageSmoothingEnabled = false;
+ drawTrain(ctx, this.carts, logicalW, this.reduced ? 0 : this.tick);
+ }
+
+ // Map the hover position back to a cart (they're right-aligned) and surface its summary as
+ // a native tooltip.
+ onMove(e) {
+ if (!this.carts || !this.carts.length) return;
+ const canvas = e.currentTarget;
+ const logicalX = (e.clientX - canvas.getBoundingClientRect().left) / SCALE;
+ const w = canvas.clientWidth / SCALE;
+ const fromRight = w - 4 - logicalX;
+ const idx = this.carts.length - 1 - Math.floor(fromRight / (CART_W + GAP));
+ const cart = this.carts[idx];
+ canvas.title = cart ? cartTitle(cart) : "";
+ }
+
+ render({ chart }) {
+ // Nothing to haul (fresh start, no history yet) → no strip at all, not an empty track.
+ const hasData = chart && (chart.p2pool || []).some((p) => Number.isFinite(p.y));
+ if (!hasData) return null;
+ return html`
+
+
+
`;
+ }
+}
diff --git a/build/dashboard/mining_dashboard/web/static/topology.mjs b/build/dashboard/mining_dashboard/web/static/topology.mjs
index f10cbf35..549dc37e 100644
--- a/build/dashboard/mining_dashboard/web/static/topology.mjs
+++ b/build/dashboard/mining_dashboard/web/static/topology.mjs
@@ -130,12 +130,16 @@ export class StackTopology extends Component {
const key = e.leak ? "clearnet" : e.route;
const color = ROUTE_COLOR[key] || ROUTE_COLOR.local;
const dash = e.kind === "internal" ? "3 3" : e.blocked_by_firewall ? "2 3" : null;
+ // Marching ants (dashboard.css .topo-edge-ants) mark a live route — Tor or clearnet — so it
+ // reads as "active traffic" against the static grey local/inactive edges. Skipped whenever a
+ // dash pattern is already spoken for (internal mesh, firewall-blocked) so it can't collide.
+ const ants = !dash && key !== "local" && key !== "inactive";
const note = e.leak ? " — LEAK" : e.blocked_by_firewall ? " — firewall-blocked" : "";
const from = byId[e.from]?.label || e.from;
const to = byId[e.to]?.label || e.to;
const tip = `${from} → ${to}: ${e.label} · ${ROUTE_NAME[key] || key}${note}`;
return html`
- {
assert.match(html, /P2Pool Earnings \(estimated\)/);
});
+// --- Progressive disclosure ("show more") on stat-grid cards ---------------------------
+//
+// Global P2Pool Stats, My P2Pool Node Stats and XMR Network share the MoreStats pattern
+// (components.mjs): headline stats always show, the rest sits collapsed behind a real
+// until toggled. Several sibling cards reuse the same labels (Overview and My P2Pool Node Stats
+// both have a "Mining Mode" stat, for instance), so `cardSlice` scopes assertions to one card's
+// own markup — from its id up to the next card's id — rather than matching against the whole page.
+function cardSlice(html, id) {
+ const marker = `id="${id}"`;
+ const start = html.indexOf(marker);
+ assert.notEqual(start, -1, `missing ${id}`);
+ const next = html.indexOf('id="card-', start + marker.length);
+ return next === -1 ? html.slice(start) : html.slice(start, next);
+}
+
+test('Global P2Pool Stats collapses to the headline stats by default (progressive disclosure)', () => {
+ const card = cardSlice(renderApp(), 'card-global');
+ // Headline: the pool's own money/health figures.
+ assert.match(card, /Pool Hashrate/);
+ assert.match(card, /Blocks Found/);
+ assert.match(card, /
Last Block<\/h5>/);
+ // Detail (sidechain internals, peers, uptime, ...) stays out of the DOM until expanded.
+ assert.doesNotMatch(card, /Sidechain Height/);
+ assert.doesNotMatch(card, /PPLNS Window/);
+ assert.doesNotMatch(card, /PPLNS Weight/);
+ assert.doesNotMatch(card, /
Uptime<\/h5>/);
+ assert.match(card, /class="more-stats-toggle" aria-expanded="false"/);
+ assert.match(card, /Show all \(12\)/);
+});
+
+test('My P2Pool Node Stats collapses to the headline stats by default', () => {
+ const card = cardSlice(renderApp(), 'card-mynode');
+ assert.match(card, /Total Hashrate/);
+ assert.match(card, /P2Pool 1h Avg/);
+ assert.match(card, /P2Pool 24h Avg/);
+ assert.match(card, /Shares \(OK\/Err\)/);
+ assert.doesNotMatch(card, /Stratum \(15m/);
+ assert.doesNotMatch(card, /Connections/);
+ assert.doesNotMatch(card, /Effort/);
+ assert.doesNotMatch(card, /Reward Share/);
+ assert.doesNotMatch(card, /Total Shares/);
+ assert.match(card, /class="more-stats-toggle" aria-expanded="false"/);
+ assert.match(card, /Show all \(12\)/);
+});
+
+test('XMR Network collapses to the headline stats by default', () => {
+ const card = cardSlice(renderApp(), 'card-network');
+ assert.match(card, /Block Height/);
+ assert.match(card, /Difficulty/);
+ assert.match(card, /Reward/);
+ assert.doesNotMatch(card, /Node Mode/);
+ assert.doesNotMatch(card, /DB Size/);
+ assert.doesNotMatch(card, /Current Block Hash/);
+ assert.doesNotMatch(card, /Network Time/);
+ assert.match(card, /class="more-stats-toggle" aria-expanded="false"/);
+ assert.match(card, /Show all \(7\)/);
+});
+
+test('MoreStats expands to show every stat when toggled, and persists the choice per card, independently of siblings', () => {
+ // There is no localStorage under node --test (see logic.test.mjs's loadPref/savePref test) —
+ // stub a minimal one so MoreStats's loadPref/savePref calls (the same helpers
+ // dashboardEarningsTab already uses) have somewhere to read from, exactly as a real browser
+ // would provide. Only dashboardCardNetwork is pre-seeded "expanded"; dashboardCardGlobal is
+ // absent, so it must still fall back to collapsed — expansion is per-card, not global.
+ const store = new Map([['dashboardCardNetwork', 'expanded']]);
+ globalThis.localStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+ };
+ try {
+ const html = renderApp();
+ const network = cardSlice(html, 'card-network');
+ assert.match(network, /Node Mode/); // detail now visible
+ assert.match(network, /Current Block Hash/);
+ assert.match(network, /class="more-stats-toggle" aria-expanded="true"/);
+ assert.match(network, /Show less/);
+ const global = cardSlice(html, 'card-global');
+ assert.doesNotMatch(global, /Sidechain Height/);
+ assert.match(global, /class="more-stats-toggle" aria-expanded="false"/);
+ assert.match(global, /Show all \(12\)/);
+ } finally {
+ delete globalThis.localStorage;
+ }
+});
+
test('Simple view shows the calculators hint until dismissed, never elsewhere (#425)', () => {
// Fresh browser on the default Simple view: the pointer to the Advanced-only calculators shows.
const simple = renderApp({ ui: { ...UI, view: 'simple', hintDismissed: false } });
@@ -171,8 +256,11 @@ test('EarningsCard leads with solo time-to-block + per-block reward, day as avg
assert.match(up, /Est\. Time to Tari Block/);
assert.match(up, /XTM per Block/);
assert.match(up, /10709\.0000 XTM/); // the full block reward
- assert.match(up, /XTM \/ day \(avg\)/); // per-day kept, but labelled an average
- assert.match(up, /16\.1000 XTM/); // the long-run average figure still shown
+ // Per-day/month/year kept, in the standardized table under a not-steady-income heading.
+ assert.match(up, /Long-run Average — not steady income/);
+ 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 "—".
const off = clone();
off.earnings.available = true;
@@ -233,9 +321,12 @@ test('EarningsCard splits into Monero / Tari / XvB tabs, Monero active by defaul
assert.match(html, /id="epanel-xvb"[^>]*hidden>/);
// The shared what-if input sits above the tab strip, so it drives all three tabs.
assert.match(html, /id="whatif-hr"/);
- // The Monero panel carries the XMR figures, the Tari panel the solo time-to-block, the XvB
- // panel the tier block — all present in the DOM (inactive ones just hidden).
- assert.match(html, /XMR \/ day/);
+ // The Monero panel carries the XMR estimate table, the Tari panel the solo time-to-block,
+ // the XvB panel the tier block — all present in the DOM (inactive ones just hidden).
+ assert.match(html, /class="est-table"/);
+ assert.match(html, /scope="row">Day);
+ assert.match(html, /scope="row">Month);
+ assert.match(html, /scope="row">Year);
assert.match(html, /Est\. Time to Tari Block/);
assert.match(html, /XvB Tier \(raffle\)/);
});
@@ -277,16 +368,19 @@ test('EarningsCard Energy tab shows cost then net as prices are set (#260)', ()
s.earnings.coeff_day = 1e-8; // small XMR/H/s/day so gross stays sane
s.energy.cost_per_kwh = 0.2;
s.energy.currency = 'EUR';
- // Only the electricity price set → power cost shows, net profit still gated on xmr_price.
+ // Only the electricity price set → the Power Cost column shows, revenue/net still gated on
+ // xmr_price (with the hint naming the config key to set).
let html = renderApp({ state: s });
- assert.match(html, /Power cost \/ day/);
- assert.match(html, /set xmr_price/);
- assert.doesNotMatch(html, /Net \/ day/);
- // Both prices set → net profit appears, labelled P2Pool-only since tari_price is still unset.
+ assert.match(html, /Power Cost/);
+ assert.match(html, /dashboard\.energy\.xmr_price/);
+ assert.doesNotMatch(html, /scope="col"[^>]*>Net);
+ assert.doesNotMatch(html, /Revenue \(est\.\)/);
+ // Both prices set → the Revenue and Net columns appear, labelled P2Pool-only since
+ // tari_price is still unset.
s.energy.xmr_price = 150;
html = renderApp({ state: s });
- assert.match(html, /Net \/ day/);
- assert.match(html, /Net \/ year/);
+ assert.match(html, /Revenue \(est\.\)/);
+ assert.match(html, /scope="col"[^>]*>Net);
assert.match(html, /P2Pool XMR only, after power/);
assert.doesNotMatch(html, /P2Pool \+ Tari, after power/);
});
@@ -301,7 +395,7 @@ test('EarningsCard Energy tab folds Tari into net profit once tari_price is set
s.energy.xmr_price = 150;
s.energy.tari_price = 2;
const html = renderApp({ state: s });
- assert.match(html, /Net \/ day/);
+ assert.match(html, /scope="col"[^>]*>Net);
assert.match(html, /P2Pool \+ Tari, after power/);
assert.doesNotMatch(html, /P2Pool XMR only, after power/);
});
@@ -315,7 +409,7 @@ test('EarningsCard Energy tab keeps the P2Pool-only label when tari_price is set
s.energy.xmr_price = 150;
s.energy.tari_price = 2;
const html = renderApp({ state: s });
- assert.match(html, /Net \/ day/);
+ assert.match(html, /scope="col"[^>]*>Net);
assert.match(html, /P2Pool XMR only, after power/);
assert.doesNotMatch(html, /P2Pool \+ Tari, after power/);
});
@@ -328,7 +422,7 @@ test('EarningsCard Energy tab folds the current-tier XvB estimate into net, labe
s.energy.cost_per_kwh = 0.2;
s.energy.xmr_price = 150;
const html = renderApp({ state: s });
- assert.match(html, /Net \/ day/);
+ assert.match(html, /scope="col"[^>]*>Net);
assert.match(html, /P2Pool \+ XvB \(est\.\), after power/);
// Tooltip drops the "Excludes XvB" clause and states it's an estimate.
assert.match(html, /XvB is an estimate/);
@@ -360,23 +454,85 @@ test('EarningsCard grows fiat rows + a price provenance line once a price is kno
s.earnings.coeff_day = 1e-8;
s.earnings.tari_available = true;
s.earnings.tari_coeff_day = 1e-6;
- // No price anywhere → no fiat rows, no provenance line (nothing to attribute).
+ // No price anywhere → no fiat columns, no provenance line (nothing to attribute).
let html = renderApp({ state: s });
- assert.doesNotMatch(html, /≈ \/ day/);
+ assert.doesNotMatch(html, /≈ USD/);
assert.doesNotMatch(html, /id="earnings-price-source"/);
- // Static prices set → Monero + Tari tabs grow ≈-fiat rows, XvB gets its fiat mirror line,
- // and the footer attributes the prices to config.json.
+ // Static prices set → the Monero + Tari estimate tables grow a ≈-fiat column, XvB gets its
+ // fiat mirror line, and the footer attributes the prices to config.json.
s.energy.xmr_price = 150;
s.energy.tari_price = 2;
html = renderApp({ state: s });
- assert.match(html, /≈ \/ day/); // Monero tab fiat rows
- assert.match(html, /≈ per Block/); // Tari tab fiat rows
+ assert.match(html, /≈ USD/); // fiat column header in the estimate tables
+ assert.match(html, /≈ per Block/); // Tari tab per-block fiat card
assert.match(html, /id="xvb-fiat-line"/); // XvB tab fiat mirror
assert.match(html, /id="earnings-price-source"/);
assert.match(html, /USD 150\.00/); // the XMR price in use, stated
assert.match(html, /static, set in config\.json/);
});
+test('EarningsCard shows Confirmed on-chain under the estimates on both tabs when payout confirmation is on (#381)', () => {
+ const s = clone();
+ s.earnings.available = true;
+ s.earnings.tari_available = true;
+ s.earnings.tari_coeff_day = 2e-3;
+ s.earnings.confirmed = {
+ enabled: true, count: 3, xmr_24h: 0.25, xmr_7d: 0.75, xmr_all: 1.75,
+ last_ts: Math.floor(Date.now() / 1000) - 3600,
+ };
+ s.earnings.tari_confirmed = {
+ enabled: true, count: 1, xtm_24h: 0, xtm_7d: 4552.15, xtm_all: 4552.15,
+ last_ts: Math.floor(Date.now() / 1000) - 7200,
+ };
+ 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);
+ assert.match(html, /0\.250000 XMR/); // xmr_24h
+ assert.match(html, /1\.7500 XMR/); // xmr_all
+ assert.match(html, /4552\.1500 XTM/); // xtm_all
+ assert.match(html, /Last payout/);
+ // Estimates first, confirmed reality after — on the Tari tab the block follows the
+ // Long-run Average table, mirroring the Monero tab's order.
+ const tari = html.slice(html.indexOf('id="epanel-tari"'), html.indexOf('id="epanel-xvb"'));
+ assert.ok(tari.indexOf('Long-run Average') < tari.indexOf('Confirmed on-chain'));
+ // Confirmation off (the default) -> no confirmed block anywhere, estimates stand alone.
+ s.earnings.confirmed = { enabled: false };
+ s.earnings.tari_confirmed = { enabled: false };
+ html = renderApp({ state: s });
+ assert.doesNotMatch(html, /Confirmed on-chain/);
+});
+
+test('EarningsCard XvB tab shows the published current-tier reward as a day/month/year table', () => {
+ const s = clone();
+ s.earnings.available = true;
+ s.earnings.xvb_day = 0.002; // fresh published estimate → the standardized table appears
+ let html = renderApp({ state: s });
+ assert.match(html, /Current Tier Expected Reward — published by XvB/);
+ assert.match(html, /0\.002000 XMR/); // day
+ assert.match(html, /0\.060000 XMR/); // month, same shared precision
+ assert.match(html, /0\.730000 XMR/); // year
+ // No fresh estimate → no table, nothing fabricated.
+ s.earnings.xvb_day = null;
+ html = renderApp({ state: s });
+ assert.doesNotMatch(html, /Current Tier Expected Reward/);
+});
+
+test('EarningsCard Energy tab: revenue in accent, net carries the green/red sign colour', () => {
+ const s = clone();
+ s.earnings.available = true;
+ s.earnings.coeff_day = 1e-8;
+ s.energy.cost_per_kwh = 0.2;
+ s.energy.xmr_price = 150;
+ // Fixture fleet earns less than power costs at these figures → net is negative → c-bad.
+ let html = renderApp({ state: s });
+ assert.match(html, /class="c-bad"[^>]*>-/);
+ // A richer rate flips the net positive → c-ok, revenue stays accent either way.
+ s.earnings.coeff_day = 1e-5;
+ html = renderApp({ state: s });
+ assert.match(html, /class="c-ok"/);
+ assert.match(html, /class="c-accent"/);
+});
+
test('EarningsCard provenance line reflects the live price feed (#520)', () => {
const s = clone();
s.earnings.available = true;
@@ -692,6 +848,15 @@ test('ComponentHealth shows a Tor-only summary, the topology nodes, and the egre
assert.match(html, /xmrig-proxy/);
});
+test('StackTopology marks live routes with marching ants, never a dashed edge', () => {
+ const html = renderApp();
+ // The fixture carries tor-routed edges -> at least one ants-marked path...
+ assert.match(html, /class="topo-edge-ants"/);
+ // ...but never on an edge whose dash pattern is already spoken for (internal mesh /
+ // firewall-blocked), where the animation would fight the static dashes.
+ assert.doesNotMatch(html, /class="topo-edge-ants"[^>]*stroke-dasharray="[^"]/);
+});
+
test('ComponentHealth flips to a warning summary when the posture leaks', () => {
const s = clone();
s.topology.summary.level = 'warn';
diff --git a/build/dashboard/tests/frontend/fixtures/_gen_state.py b/build/dashboard/tests/frontend/fixtures/_gen_state.py
index af03e3be..83de5d39 100644
--- a/build/dashboard/tests/frontend/fixtures/_gen_state.py
+++ b/build/dashboard/tests/frontend/fixtures/_gen_state.py
@@ -92,6 +92,9 @@ def _state_mgr():
}
]
sm.is_db_healthy.return_value = True
+ # Embedded directly in the payload (#249) — an unpinned MagicMock here is not JSON
+ # serializable, unlike the list-shaped methods that iterate as empty. No standby held.
+ sm.get_xvb_standby.return_value = None
return sm
diff --git a/build/dashboard/tests/frontend/fixtures/state.json b/build/dashboard/tests/frontend/fixtures/state.json
index d8c67535..9a04db01 100644
--- a/build/dashboard/tests/frontend/fixtures/state.json
+++ b/build/dashboard/tests/frontend/fixtures/state.json
@@ -27,6 +27,7 @@
"variant": "ok"
}
],
+ "blocks": [],
"cadence": {
"available": false,
"last_block": "Never",
@@ -47,6 +48,7 @@
"y": 8100
}
],
+ "payouts": [],
"raffle": [
{
"label": "XvB raffle win \u2014 donor_whale round at 4.20 MH/s",
@@ -76,6 +78,7 @@
},
"control_enabled": false,
"db_healthy": true,
+ "disk_growth": [],
"earnings": {
"available": false,
"block_reward": "0.0000 XMR",
@@ -93,7 +96,8 @@
"enabled": false
},
"tari_difficulty": 0.0,
- "tari_reward": 0
+ "tari_reward": 0,
+ "xvb_day": null
},
"egress": {
"components": [
@@ -174,6 +178,14 @@
{
"route": "inactive",
"to": "price feed (coingecko.com)"
+ },
+ {
+ "route": "inactive",
+ "to": "alert sinks (webhook / ntfy)"
+ },
+ {
+ "route": "inactive",
+ "to": "XvB standby pull (backup \u2190 primary)"
}
],
"firewalled": false,
@@ -265,6 +277,10 @@
"ts": "Never"
},
"page_title": "Pithead Dashboard",
+ "payouts": {
+ "monero": [],
+ "tari": []
+ },
"pool": {
"blocks": 0,
"diff": "0.00 M",
@@ -460,6 +476,20 @@
"route": "inactive",
"to": "tor"
},
+ {
+ "from": "dashboard",
+ "kind": "egress",
+ "label": "alert sinks",
+ "route": "inactive",
+ "to": "tor"
+ },
+ {
+ "from": "dashboard",
+ "kind": "egress",
+ "label": "XvB standby",
+ "route": "inactive",
+ "to": "tor"
+ },
{
"from": "tor",
"kind": "p2p",
@@ -717,5 +747,7 @@
"threshold": 1000000.0
}
]
- }
+ },
+ "xvb_history": [],
+ "xvb_standby": null
}
diff --git a/build/dashboard/tests/frontend/logic.test.mjs b/build/dashboard/tests/frontend/logic.test.mjs
index 7c8f7803..2a3d8f16 100644
--- a/build/dashboard/tests/frontend/logic.test.mjs
+++ b/build/dashboard/tests/frontend/logic.test.mjs
@@ -18,7 +18,7 @@ import {
AVG_WINDOWS, DEFAULT_AVG_WINDOW, normalizeAvgWindow,
heroKpis, raffleCls,
parseHashrate, fmtHashrate, computeEarnings, computeXvbTier, xvbTierComparison, formatXmr, formatXtm, formatTimeToShare, formatAgo,
- computeEnergy, formatFiat, formatUnit,
+ computeEnergy, formatFiat, formatFiatAmount, formatUnit, coinTriplet,
coinFiat, formatFiatPrice, priceSourceLabel,
DAYS_PER_MONTH, DAYS_PER_YEAR,
bandBorderWidth, uptimeCell,
@@ -248,7 +248,8 @@ test('computeEarnings: returns nulls when unavailable or hashrate is non-positiv
const ok = { available: true, coeff_day: 1e-7, pool_difficulty: 1 };
const allNull = { day: null, month: null, year: null, timeToShareSec: null,
tariDay: null, tariMonth: null, tariYear: null,
- tariTimeToBlockSec: null, tariRewardPerBlock: null, xvbDay: null };
+ tariTimeToBlockSec: null, tariRewardPerBlock: null,
+ xvbDay: null, xvbMonth: null, xvbYear: null };
assert.deepEqual(computeEarnings(0, ok), allNull);
assert.deepEqual(computeEarnings(null, ok), allNull);
// available:false (network stats missing) -> graceful "—" path even with a valid hashrate.
@@ -635,6 +636,84 @@ test('computeEnergy: tari_price set but Tari not merge-mining (tariDay null) ->
assert.equal(en.includesTari, false);
});
+// --- Standardized estimate tables --------------------------------------------------------
+
+test('coinTriplet: one shared precision across a Day/Month/Year column, from the smallest value', () => {
+ // Day figure (smallest) is < 0.001 -> 8 dp for all three rows, so the column aligns.
+ assert.deepEqual(
+ coinTriplet([0.00092945, 0.0278835, 0.33925025], 'XMR'),
+ ['0.00092945 XMR', '0.02788350 XMR', '0.33925025 XMR'],
+ );
+ // Day >= 1 -> 4 dp everywhere.
+ assert.deepEqual(
+ coinTriplet([505.54, 15166.2, 184522.1], 'XTM'),
+ ['505.5400 XTM', '15166.2000 XTM', '184522.1000 XTM'],
+ );
+ // Nulls render "—" without disturbing the shared precision of the rest.
+ assert.deepEqual(coinTriplet([null, 2.5, null], 'XMR'), ['—', '2.5000 XMR', '—']);
+ // All-null (estimate unavailable) -> three dashes, no NaN.
+ assert.deepEqual(coinTriplet([null, null, null], 'XMR'), ['—', '—', '—']);
+ assert.deepEqual(coinTriplet([0, 0, 0], 'XMR'), ['0 XMR', '0 XMR', '0 XMR']);
+});
+
+test('formatFiatAmount: bare 2-dp amount for table cells, sign kept, "—" for null', () => {
+ assert.equal(formatFiatAmount(12.345), '12.35');
+ assert.equal(formatFiatAmount(-3.2), '-3.20');
+ assert.equal(formatFiatAmount(0), '0.00');
+ assert.equal(formatFiatAmount(null), '—');
+ assert.equal(formatFiatAmount(Infinity), '—');
+});
+
+test('formatUnit: empty unit gives the bare number (table cells whose header names the unit)', () => {
+ assert.equal(formatUnit(6.43, ''), '6.4');
+ assert.equal(formatUnit(6.43, 'kWh'), '6.4 kWh');
+});
+
+test('computeEarnings: xvb day/month/year are plain spans of the published figure', () => {
+ const est = computeEarnings(50_000, {
+ available: true, coeff_day: 1e-7, pool_difficulty: 1, xvb_day: 0.02,
+ });
+ assert.equal(est.xvbDay, 0.02);
+ assert.equal(est.xvbMonth, 0.02 * DAYS_PER_MONTH);
+ assert.equal(est.xvbYear, 0.02 * DAYS_PER_YEAR);
+ // No fresh estimate -> the whole triplet is null, never fabricated.
+ const none = computeEarnings(50_000, { available: true, coeff_day: 1e-7, pool_difficulty: 1 });
+ assert.equal(none.xvbDay, null);
+ assert.equal(none.xvbMonth, null);
+ assert.equal(none.xvbYear, null);
+});
+
+test('computeEnergy: gross revenue surfaced as day/month/year (the sum the net starts from)', () => {
+ const en = computeEnergy(
+ { available: true, total_watts: 1000, cost_per_kwh: 0.2, xmr_price: 150 },
+ { day: 0.1 },
+ );
+ assert.equal(en.grossDay, 0.1 * 150);
+ assert.equal(en.grossMonth, en.grossDay * DAYS_PER_MONTH);
+ assert.equal(en.grossYear, en.grossDay * DAYS_PER_YEAR);
+ assert.ok(Math.abs(en.grossDay - en.costDay - en.netDay) < 1e-9);
+ // Gates mirror net: no XMR price -> no gross (cost can still stand alone).
+ const noPrice = computeEnergy(
+ { available: true, total_watts: 1000, cost_per_kwh: 0.2 },
+ { day: 0.1 },
+ );
+ assert.equal(noPrice.grossDay, null);
+ assert.equal(noPrice.grossMonth, null);
+ assert.equal(noPrice.grossYear, null);
+ assert.ok(noPrice.costDay > 0);
+ // The other direction: xmr_price set but no cost_per_kwh -> gross stands alone, net nulls
+ // (net needs BOTH sides; a gross-only net would silently read as free electricity).
+ const noCost = computeEnergy(
+ { available: true, total_watts: 1000, xmr_price: 150 },
+ { day: 0.1 },
+ );
+ assert.ok(noCost.grossDay > 0);
+ assert.equal(noCost.costDay, null);
+ assert.equal(noCost.netDay, null);
+ assert.equal(noCost.netMonth, null);
+ assert.equal(noCost.netYear, null);
+});
+
// --- XvB folded into net profit (#712) ---------------------------------------------------
test('computeEnergy: xvbDay grows net by xvbDay*xmr_price and sets includesXvb', () => {
diff --git a/build/dashboard/tests/frontend/minecart.test.mjs b/build/dashboard/tests/frontend/minecart.test.mjs
new file mode 100644
index 00000000..a596b8d4
--- /dev/null
+++ b/build/dashboard/tests/frontend/minecart.test.mjs
@@ -0,0 +1,103 @@
+// Mine cart train — the pure data adapter (cartsFromState) and tooltip text. The canvas
+// renderer is browser-only (verified visually); the bucketing that decides what each cart
+// shows is the logic worth pinning.
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+ cartsFromState, cartTitle,
+} from '../../mining_dashboard/web/static/minecart.mjs';
+
+// Minimal chart payload: n points at 60s spacing (x in ms, like the server sends).
+const T0 = 1_700_000_000_000;
+function mkChart(n, { shares = [], raffle = [] } = {}) {
+ const p2pool = [];
+ for (let i = 0; i < n; i++) p2pool.push({ x: T0 + i * 60_000, y: 1000 });
+ return { t0: T0, chart: { p2pool, shares, raffle } };
+}
+
+test('cartsFromState: spans the loaded window with `count` equal carts', () => {
+ const { chart } = mkChart(120); // 119 minutes of history
+ const carts = cartsFromState(chart, [], 12);
+ assert.equal(carts.length, 12);
+ // Contiguous, equal buckets covering first->last point.
+ for (let i = 1; i < carts.length; i++) assert.equal(carts[i].t0, carts[i - 1].t1);
+ assert.ok(Math.abs((carts[0].t1 - carts[0].t0) - (119 * 60) / 12) < 1e-9);
+ // Only the newest cart is still loading under the chute.
+ assert.deepEqual(carts.map((c) => c.loading), [...Array(11).fill(false), true]);
+});
+
+test('cartsFromState: null gap markers are skipped, empty payload yields no carts', () => {
+ const { chart } = mkChart(10);
+ chart.p2pool[4] = { x: chart.p2pool[4].x, y: null }; // outage break marker
+ const carts = cartsFromState(chart, [], 5);
+ assert.equal(carts.length, 5); // survives, no NaN
+ for (const c of carts) assert.ok(Number.isFinite(c.t0) && Number.isFinite(c.t1));
+ assert.deepEqual(cartsFromState({ p2pool: [] }, []), []);
+ assert.deepEqual(cartsFromState(null, []), []);
+ // All-gap history (every sample an outage marker) -> no window, no carts.
+ const gaps = mkChart(4).chart;
+ for (const p of gaps.p2pool) p.y = null;
+ assert.deepEqual(cartsFromState(gaps, []), []);
+});
+
+test('cartsFromState: each event series lands its count in the right cart', () => {
+ const { chart } = mkChart(120, {
+ shares: [{ x: T0 + 10 * 60_000, c: 2 }, { x: T0 + 70 * 60_000, c: 1 }],
+ raffle: [{ x: T0 + 70 * 60_000, label: 'win' }],
+ });
+ const blocks = [{ x: T0 + 110 * 60_000, height: 1 }];
+ const carts = cartsFromState(chart, blocks, 12);
+ assert.equal(carts[1].shares, 2); // minute 10 -> bucket 1
+ assert.equal(carts[7].shares, 1); // minute 70 held a share AND a raffle win —
+ assert.equal(carts[7].wins, 1); // both counted, coins coexist in one cart
+ assert.equal(carts[11].blocks, 1); // minute 110 -> newest bucket
+ assert.equal(carts[0].shares + carts[0].blocks + carts[0].wins, 0); // quiet cart stays quiet
+});
+
+test('cartsFromState: confirmed Tari payouts count in their cart, alongside other events', () => {
+ const { chart } = mkChart(120, { shares: [{ x: T0 + 30 * 60_000, c: 1 }] });
+ const payouts = { tari: [{ x: T0 + 30 * 60_000, amount: 4552e6 }, { x: T0 + 90 * 60_000, amount: 1 }] };
+ const blocks = [{ x: T0 + 90 * 60_000, height: 7 }];
+ const carts = cartsFromState(chart, blocks, 12, payouts);
+ assert.equal(carts[3].tari, 1); // minute 30: payout and share share a cart
+ assert.equal(carts[3].shares, 1);
+ assert.equal(carts[9].tari, 1); // minute 90: payout and block share a cart
+ assert.equal(carts[9].blocks, 1);
+ // No payouts key at all (older server) -> same as before, no crash.
+ assert.equal(cartsFromState(chart, blocks, 12).length, 12);
+});
+
+test('cartsFromState: an event on the exact last timestamp lands in the newest cart', () => {
+ const { t0, chart } = mkChart(60);
+ const lastX = t0 + 59 * 60_000;
+ const carts = cartsFromState(chart, [{ x: lastX, height: 9 }], 6);
+ assert.equal(carts[5].blocks, 1); // inclusive right edge on the loading cart
+});
+
+test('cartsFromState: single-point history yields carts without NaN (fresh dashboard start)', () => {
+ // One poll of data: the window collapses to a point, so bucketSec floors at 60/count.
+ // An event at that instant lands in cart 0; every cart stays well-formed.
+ const { chart } = mkChart(1);
+ const carts = cartsFromState(chart, [{ x: T0, height: 1 }], 12);
+ assert.equal(carts.length, 12);
+ for (const c of carts) assert.ok(Number.isFinite(c.t0) && Number.isFinite(c.t1));
+ assert.equal(carts[0].blocks, 1);
+});
+
+test('cartTitle: says what the cart hauled, plurals and all', () => {
+ const s = cartTitle({ t0: 0, t1: 600, shares: 2, blocks: 1, wins: 0, tari: 0, loading: true });
+ assert.match(s, /1 block FOUND/);
+ assert.match(s, /2 shares/);
+ assert.match(s, /\(loading\)/);
+ const rich = cartTitle({ t0: 0, t1: 600, shares: 1, blocks: 2, wins: 2, tari: 2, loading: false });
+ assert.match(rich, /2 blocks FOUND/);
+ assert.match(rich, /2 Tari payouts/);
+ assert.match(rich, /2 XvB raffle wins/);
+ assert.match(rich, /1 share(?!s)/);
+ const one = cartTitle({ t0: 0, t1: 600, shares: 0, blocks: 0, wins: 1, tari: 1, loading: false });
+ assert.match(one, /1 Tari payout(?!s)/);
+ assert.match(one, /1 XvB raffle win(?!s)/);
+ const quiet = cartTitle({ t0: 0, t1: 600, shares: 0, blocks: 0, wins: 0, tari: 0, loading: false });
+ assert.match(quiet, /hauling/);
+});
diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py
index 71946a4c..3a17bdd8 100644
--- a/build/dashboard/tests/web/test_views.py
+++ b/build/dashboard/tests/web/test_views.py
@@ -1485,6 +1485,79 @@ def test_build_blocks_range_filters_old_rows(self):
def test_build_blocks_empty(self):
assert build_blocks([], "all") == []
+ def _payout_mgr(self, rows_by_chain):
+ mgr = MagicMock()
+ mgr.get_payouts.side_effect = lambda chain: rows_by_chain.get(chain, [])
+ return mgr
+
+ def test_build_payouts_shape_ms_epoch_and_atomic_amount(self, monkeypatch):
+ monkeypatch.setattr(views.config, "PAYOUT_CONFIRM_ENABLED", True)
+ monkeypatch.setattr(views.config, "TARI_PAYOUT_CONFIRM_ENABLED", True)
+ now = time.time()
+ mgr = self._payout_mgr(
+ {
+ "monero": [{"ts": now - 60, "amount_atomic": 12_345}],
+ "tari": [{"ts": now - 120, "amount_atomic": 999}],
+ }
+ )
+ out = views.build_payouts(mgr, "all")
+ assert out["monero"] == [{"x": int((now - 60) * 1000), "amount": 12_345}]
+ assert out["tari"] == [{"x": int((now - 120) * 1000), "amount": 999}]
+
+ def test_build_payouts_range_filters_like_blocks(self, monkeypatch):
+ monkeypatch.setattr(views.config, "PAYOUT_CONFIRM_ENABLED", True)
+ monkeypatch.setattr(views.config, "TARI_PAYOUT_CONFIRM_ENABLED", True)
+ now = time.time()
+ mgr = self._payout_mgr(
+ {
+ "tari": [
+ {"ts": now - 8 * 24 * 3600, "amount_atomic": 1},
+ {"ts": now - 60, "amount_atomic": 2},
+ ]
+ }
+ )
+ assert len(views.build_payouts(mgr, "1w")["tari"]) == 1
+
+ def test_build_payouts_gates_per_chain_and_none_mgr(self, monkeypatch):
+ # A disabled chain stays an empty list even when rows exist — the payload never leaks
+ # payout data while the operator has confirmation off for that chain.
+ monkeypatch.setattr(views.config, "PAYOUT_CONFIRM_ENABLED", False)
+ monkeypatch.setattr(views.config, "TARI_PAYOUT_CONFIRM_ENABLED", True)
+ now = time.time()
+ mgr = self._payout_mgr(
+ {
+ "monero": [{"ts": now, "amount_atomic": 1}],
+ "tari": [{"ts": now, "amount_atomic": 2}],
+ }
+ )
+ out = views.build_payouts(mgr, "all")
+ assert out["monero"] == []
+ assert len(out["tari"]) == 1
+ mgr.get_payouts.assert_called_once_with("tari") # disabled chain never queried
+ assert views.build_payouts(None, "all") == {"monero": [], "tari": []}
+
+ def test_payouts_ride_build_state_end_to_end(self, monkeypatch):
+ # The wiring, not just the builder: with confirmation on, a stored payout row surfaces
+ # in the top-level payload the client polls (the mine cart train reads state.payouts).
+ # The shared _state_mgr() MagicMock auto-mocks get_payouts, so point it at real rows.
+ monkeypatch.setattr(views.config, "PAYOUT_CONFIRM_ENABLED", False)
+ monkeypatch.setattr(views.config, "TARI_PAYOUT_CONFIRM_ENABLED", True)
+ sm = _state_mgr()
+ sm.get_payouts.return_value = [
+ {"chain": "tari", "txid": "t1", "height": 9, "ts": time.time(), "amount_atomic": 4552}
+ ]
+ st = build_state(_data(), sm, "all")
+ assert st["payouts"]["monero"] == []
+ assert len(st["payouts"]["tari"]) == 1
+ assert st["payouts"]["tari"][0]["amount"] == 4552
+ # Only x + amount ship — no txid in the payload the browser sees.
+ assert set(st["payouts"]["tari"][0]) == {"x", "amount"}
+ # The earnings card's confirmed summaries gate on the SAME flags in the same request —
+ # the timeline (mine cart) and the card can never disagree about whether payout
+ # confirmation is on for a chain.
+ assert st["earnings"]["tari_confirmed"]["enabled"] is True
+ assert st["earnings"]["confirmed"] == {"enabled": False}
+
def test_build_disk_growth_shape_and_ms_epoch(self):
now = time.time()
rows = [
@@ -1789,26 +1862,8 @@ def test_confirmed_disabled_by_default(self):
e = build_earnings(self._NET, _metrics())
assert e["confirmed"] == {"enabled": False}
- def test_confirmed_totals_windowed(self):
- # #381: 24h / 7d / all-time XMR sums from stored confirmed payouts, atomic→XMR at the edge.
- now = 1_000_000.0
- payouts = [
- {"txid": "a", "ts": now - 100, "amount_atomic": 250_000_000_000}, # in 24h
- {"txid": "b", "ts": now - 3 * 86_400, "amount_atomic": 500_000_000_000}, # in 7d
- {
- "txid": "c",
- "ts": now - 30 * 86_400,
- "amount_atomic": 1_000_000_000_000,
- }, # all-time only
- ]
- from mining_dashboard.web.views import _confirmed_payouts_summary
-
- s = _confirmed_payouts_summary(payouts, now=now)
- assert s["enabled"] is True and s["count"] == 3
- assert s["xmr_24h"] == pytest.approx(0.25)
- assert s["xmr_7d"] == pytest.approx(0.75)
- assert s["xmr_all"] == pytest.approx(1.75)
- assert s["last_ts"] == now - 100
+ # ponytail: the 24h/7d/all windowing math is proven once, in TestConfirmedPayoutsSummary —
+ # this class only asserts build_earnings passes payouts through (enabled/empty/disabled).
def test_confirmed_enabled_but_empty(self):
# Feature on, nothing confirmed yet → enabled with zeroed totals (shows 0.000000, not "—").
@@ -1827,23 +1882,6 @@ def test_tari_confirmed_disabled_by_default(self):
e = build_earnings(self._NET, _metrics())
assert e["tari_confirmed"] == {"enabled": False}
- def test_tari_confirmed_totals_windowed_in_xtm(self):
- # #462: XTM sums (microTari ÷1e6) with xtm_* keys, distinct from the monero xmr_* block.
- now = 1_000_000.0
- tari_payouts = [
- {"txid": "a", "ts": now - 100, "amount_atomic": 250_000}, # in 24h
- {"txid": "b", "ts": now - 3 * 86_400, "amount_atomic": 500_000}, # in 7d
- {"txid": "c", "ts": now - 30 * 86_400, "amount_atomic": 1_000_000}, # all-time only
- ]
- from mining_dashboard.web.views import MICRO_PER_XTM, _confirmed_payouts_summary
-
- s = _confirmed_payouts_summary(tari_payouts, now=now, divisor=MICRO_PER_XTM, unit="xtm")
- assert s["enabled"] is True and s["count"] == 3
- assert s["xtm_24h"] == pytest.approx(0.25)
- assert s["xtm_7d"] == pytest.approx(0.75)
- assert s["xtm_all"] == pytest.approx(1.75)
- assert s["last_ts"] == now - 100
-
def test_tari_confirmed_enabled_but_empty(self):
e = build_earnings(self._NET, _metrics(), tari_payouts=[])
assert e["tari_confirmed"] == {
@@ -2056,6 +2094,7 @@ def test_has_all_sections(self):
"share_stats",
"reject_pct_24h",
"blocks",
+ "payouts",
"disk_growth",
"xvb_history",
"egress",
diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock
index 43afbe2e..f4f3e535 100644
--- a/build/dashboard/uv.lock
+++ b/build/dashboard/uv.lock
@@ -782,7 +782,7 @@ wheels = [
[[package]]
name = "mining-dashboard"
-version = "1.12.0"
+version = "1.13.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
diff --git a/docs/configuration.md b/docs/configuration.md
index d031f357..e62d6c40 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -153,8 +153,8 @@ control channel will commit, are unaffected either way.
| `workers.api_port` | `8080` | TCP port the worker xmrig API listens on. Change only if your miners expose the API on a non-standard port. |
| `workers.list` | `[]` | Per-worker overrides for the worker-API probe, a list of `{name, host?, port?, token?, watts?, control_port?}` objects. Only needed when one rig doesn't match the fleet defaults above — a different API port, an API on another interface (NAT / multi-homed), or its own token. Each field is optional bar `name` (the rig's stratum name). **Merge rule:** a per-worker field beats the fleet default (`workers.api_port`, `workers.api_auth`/`workers.api_token`), which beats the built-in default; unlisted rigs inherit the fleet defaults untouched. A per-worker `token` forces token-auth for that one rig, whatever the fleet `api_auth` mode. `watts` is a manual power-draw estimate for the [energy calculator](dashboard.md#energy--profit), used only for a rig whose enriched feed reports no measured watts. `control_port` (default `8082`) is the rig's writable control API port, used by [Worker Inspect](dashboard.md#worker-inspect) to push config changes — set it alongside `host` + `token` to make a rig editable. Matched by `name` first, then by connecting IP against an operator-set `host`; duplicate names → first-declared wins, so a renamed rig needs its entry updated. `host` **must** be operator-set here and is never derived from a miner-advertised value: the dashboard never sends a configured token to a miner-controlled host ([SSRF guard](workers.md#per-worker-overrides)). The standard fleet needs no entries. See [Connecting Miners › Per-worker overrides](workers.md#per-worker-overrides). `dashboard.workers` is a deprecated alias for this key (#506): still read if `workers.list` is unset or empty, and the next `apply` migrates it here in place, keeping the old file as `config.json.bak-workers` (#679). Populating both is refused at apply (an empty array beside the populated key is allowed — it is the schema default), and the old location is removed in v1.9. |
| `dashboard.energy.cost_per_kwh` | `0` _(off)_ | Your electricity price per kWh, for the dashboard's [energy & profit](dashboard.md#energy--profit) tab. `0` or unset shows fleet power draw and efficiency but hides the cost and profit math. Any positive number adds power cost per day/month/year; combine with `xmr_price` for net profit. In the `currency` label below. |
-| `dashboard.energy.xmr_price` | `0` _(off)_ | The fiat price of 1 XMR, in your `currency`, for the net-profit figure (`P2Pool XMR earnings × this − power cost`) and the ≈-fiat rows on the earnings card. `0` or unset hides net profit (energy cost still shows if `cost_per_kwh` is set). By default no price is fetched — fetching an exchange rate is network egress this stack doesn't do unbidden — so you supply the price yourself, or opt into the Tor-routed `price_feed` below. This is the base gate for net profit: it's P2Pool XMR only unless a Tari price is also known (#520). XvB (raffle status, not a per-day income estimate) is always excluded. |
-| `dashboard.energy.tari_price` | `0` _(off)_ | The fiat price of 1 XTM, in your `currency` (#520). Once both this and `xmr_price` are set, net profit folds in the estimated Tari merge-mining revenue (same what-if Tari/day estimate the Tari tab shows) — the card's heading and Net/day tooltip say "P2Pool + Tari" so the figure is never silently P2Pool-only. `0` or unset (or Tari not currently merge-mining) keeps net profit P2Pool XMR only. Supplied by you, or fetched live via `price_feed` below. |
+| `dashboard.energy.xmr_price` | `0` _(off)_ | The fiat price of 1 XMR, in your `currency`, for the revenue and net-profit figures (`P2Pool XMR earnings × this − power cost`) and the ≈-fiat columns on the earnings card. `0` or unset hides revenue and net profit (energy cost still shows if `cost_per_kwh` is set); the reverse also holds — revenue and net stay hidden until `cost_per_kwh` is set as well. By default no price is fetched — fetching an exchange rate is network egress this stack doesn't do unbidden — so you supply the price yourself, or opt into the Tor-routed `price_feed` below. This is the base gate for net profit: it's P2Pool XMR only unless a Tari price is also known (#520), plus the current-tier XvB expected reward (an estimate, #712) while XvB's published figure is fresh. |
+| `dashboard.energy.tari_price` | `0` _(off)_ | The fiat price of 1 XTM, in your `currency` (#520). Once both this and `xmr_price` are set, net profit folds in the estimated Tari merge-mining revenue (same what-if Tari/day estimate the Tari tab shows) — the card's heading and Net column tooltip say "P2Pool + Tari" so the figure is never silently P2Pool-only. `0` or unset (or Tari not currently merge-mining) keeps net profit P2Pool XMR only. Supplied by you, or fetched live via `price_feed` below. |
| `dashboard.energy.price_feed` | `false` | Fetch the XMR and XTM spot prices live from CoinGecko instead of the static numbers above (#520). **Opt-in, and always over Tor** (`socks5h`, the same route as the update check) — CoinGecko sees a Tor exit, never your IP ([Privacy › Runtime egress](privacy.md#runtime-egress)). Fetches both prices in your `currency` every 15 minutes; until the first fetch lands (and on any failure) the static `xmr_price` / `tari_price` stand, and the card's `Prices:` line always states which source is in use and how fresh it is. |
| `dashboard.energy.currency` | `USD` | Display label for `cost_per_kwh`, `xmr_price` and `tari_price` (e.g. `USD`, `EUR`) — and, with `price_feed` on, the currency the live prices are fetched in (any currency CoinGecko quotes; an unsupported label makes the fetch fail silently and the static prices stand). No conversion happens between the static fields, so set them all in the same currency. |
| `healthchecks.ping_url` | _(blank)_ | The full ping URL from Healthchecks.io (e.g. `https://hc-ping.com/`) — the optional [dead-man's switch](monitoring.md) that alerts you when your host stops responding. **Setting it turns the monitor on; blank keeps it off.** Always pinged over Tor (every 60s), so it must be Tor-reachable (see [Monitoring › Privacy note](monitoring.md#privacy-note)). Treated as a secret — stored in the owner-only `.env`. |
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 7f0ec18e..f7674468 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -136,6 +136,29 @@ A strip of headline KPIs sits below the top bar:
| **XvB Tier** | The donation tier you're currently holding. |
| **Mining Mode** | What your hashrate is routed to right now: P2Pool, XvB, or a split. |
+### Mine cart train
+
+A pixel-art train runs under the hero band on every view. It is the chart's history retold as
+cargo: the strip splits the loaded chart window into equal intervals, one cart each (the newest
+sits under the tipple chute on the right), and what landed in an interval rides in its cart as
+a token coin poking over the rim:
+
+| In the cart | Means |
+|---|---|
+| Empty cart | Plain hashing, nothing landed in this interval. |
+| One orange ɱ coin | One or more P2Pool shares found. |
+| A pair of orange ɱ coins | A block found. |
+| Purple gem coin | A confirmed Tari payout — for solo merge-mining, the wallet's proof of a found Tari block. Needs [payout confirmation](#payout-confirmation) on for Tari. |
+| Blue X coin | An XvB raffle win. |
+
+Hover a cart for its exact interval and haul ("14:20–14:30 — 2 shares"). A cat sleeps by the
+track; clouds drift past; the coins bob as the track rumbles. The train is decorative: shares,
+blocks, and raffle wins all appear on the chart above with precise timestamps. The one thing
+the train alone marks is the Tari gem coin — Tari payouts stay off the chart (see
+[Payout confirmation](#payout-confirmation)), and the cart's hover tooltip is their timeline.
+All motion stops when your system asks for reduced motion, and the strip disappears while
+there is no history to show.
+
### Node status & failover
If a local node becomes unreachable, a red `monerod DOWN` or `Tari DOWN` badge appears in the top
@@ -167,7 +190,9 @@ needed.
The database also keeps three smaller series: pool block-found events, hourly monerod-DB-size and
host-disk-usage samples, and XvB-credited scalar samples taken roughly every 5 minutes. `/api/state`
serves them as `blocks`, `disk_growth`, and `xvb_history`, range-filtered the same way as
-`share_stats`. No chart reads them yet — this is persistence and API exposure only.
+`share_stats`. The [mine cart train](#mine-cart-train) reads `blocks` and the chart's donation
+overlay reads `xvb_history`; only `disk_growth` is persistence and API exposure alone, no
+renderer yet.
While a node is down, the dashboard rejects workers so they fail over to the backup pools you've
configured, rather than sitting idle on a stack that can't mine. A sustained outage stops the
@@ -371,10 +396,17 @@ figures.
The card is split into tabs — **Monero**, **Tari**, **XvB**, and **Energy** — driven by one
**what-if hashrate** input that sits above the tabs, so switching tabs keeps the value you entered.
-Monero holds the XMR/day·month·year figures, time-to-share, and block reward; Tari holds the solo
-time-to-block, per-block reward, and per-day average; XvB holds the tier/cost block and the
-per-tier payout comparison. The XvB tab appears only when XvB is enabled, and the Energy tab only
-when the fleet reports power (see [Energy & profit](#energy--profit)).
+Monero holds the XMR estimate, time-to-share, and block reward; Tari holds the solo time-to-block,
+per-block reward, and long-run average; XvB holds the tier/cost block, the current tier's published
+expected reward, and the per-tier payout comparison. The XvB tab appears only when XvB is enabled,
+and the Energy tab only when the fleet reports power (see [Energy & profit](#energy--profit)).
+
+Every tab presents its rate estimate in the same **Day / Month / Year** table: the coin figure,
+plus a `≈` fiat column once that coin's price is known (see *Prices* under
+[Energy & profit](#energy--profit)). The three rows of a table share one precision — picked from
+the day figure, the smallest — so the column aligns. Colours follow one rule across the card: coin
+estimates in the accent colour, fiat mirrors plain, and **net** figures green in profit and red in
+loss — the only judgment colour in the calculator.
It is scoped to P2Pool — **not** an XvB calculator:
@@ -398,10 +430,10 @@ It is scoped to P2Pool — **not** an XvB calculator:
| Field | Meaning |
|---|---|
| **Your P2Pool Hashrate** | The hashrate the estimate is based on. Defaults to your **P2Pool 1h average** (the same figure the header shows, excluding any XvB-donated portion); type a different value (e.g. `50k`, `1.2 MH/s`) to see a **what-if** projection if you added or removed P2Pool hashpower. |
-| **XMR / day · month · year** | Expected Monero earned over each horizon, computed as `hashrate × block reward ÷ network difficulty`, the standard variance-free mining expectation. P2Pool's zero-fee PPLNS payout makes this the right long-run expectation. |
+| **XMR Day / Month / Year** | Expected Monero earned over each horizon, computed as `hashrate × block reward ÷ network difficulty`, the standard variance-free mining expectation. P2Pool's zero-fee PPLNS payout makes this the right long-run expectation. |
| **Est. Time to Tari Block** | Expected time for your hashrate to solo-find one Tari block: `network difficulty ÷ hashrate`. This is the honest headline for solo merge-mining — the reward lands here, all at once. `—` while merge-mining is inactive or Tari is still syncing. |
| **XTM per Block** | The full Tari block reward paid when you find a block — you get all of it at once, not spread over time. |
-| **XTM / day (avg)** | The Tari block reward spread across the expected time-to-block — a **long-run average**, not steady income. `—` while merge-mining is inactive or Tari is still syncing. |
+| **Long-run Average (XTM)** | The Tari tab's Day / Month / Year table: the per-block reward spread across the expected time-to-block — a **long-run average**, not steady income, and headed as such. `—` while merge-mining is inactive or Tari is still syncing. |
| **Time / Share** | How long, on average, that hashrate takes to find one P2Pool (sidechain) share. |
| **XMR Block Reward** | The current Monero block reward, for context. |
@@ -422,14 +454,15 @@ estimate: add `"watts": ` to its `workers.list[]` descriptor and it coun
total, marked *estimated*. A worker with neither a measured nor a configured draw is left out and
the **Fleet Power** figure turns amber to show the total is a lower bound, not a fabricated zero.
-The tab always shows fleet watts, H/s-per-watt, and energy use (kWh per day/month/year, a naive
-extrapolation of the current draw). Three prices add the rest, and each is optional:
+The tab always shows fleet watts and H/s-per-watt, plus the same Day / Month / Year table as the
+other tabs — here its columns grow as prices are set. **kWh** (a naive extrapolation of the
+current draw) is always there; three optional prices add the money columns:
| Config | Adds |
|---|---|
-| `dashboard.energy.cost_per_kwh` | **Power cost** per day/month/year (`kWh × price`). |
-| `dashboard.energy.xmr_price` | **Net profit** per day/month/year, P2Pool XMR earnings × your XMR price, minus power cost. Also values the current-tier XvB expected reward (an estimate) into that net when XvB has a fresh figure. |
-| `dashboard.energy.tari_price` | Folds Tari merge-mining earnings into that same net profit, at your Tari price. Requires `xmr_price` to be set too. |
+| `dashboard.energy.cost_per_kwh` | The **Power Cost** column (`kWh × price`). |
+| `dashboard.energy.xmr_price` | The **Revenue (est.)** and **Net** columns: P2Pool XMR earnings × your XMR price, gross and then minus power cost. Needs `cost_per_kwh` set too — without a power cost there is no net for revenue to lead into. Also values the current-tier XvB expected reward (an estimate) into both when XvB has a fresh figure. |
+| `dashboard.energy.tari_price` | Folds Tari merge-mining earnings into that same revenue and net, at your Tari price. Requires `xmr_price` to be set too. |
All three are in your `dashboard.energy.currency` label (e.g. `USD`, `EUR`) — a label only, no
conversion happens. Leave `cost_per_kwh` unset and the tab shows only draw and efficiency; set it
@@ -443,7 +476,7 @@ the **XvB** raffle's expected reward for the tier you currently hold, valued at
whole net is already probabilistic, so it stays one figure — but the XvB slice is an **estimate**:
it is XvB's published expected reward for your current tier (the lower of your credited 1h and 24h
averages), and the raffle draw is random among qualifiers, so it is not a payout you are owed. The
-card's heading and the Net/day tooltip label it `XvB (est.)` and say exactly what the figure counts,
+card's heading and the Net column's tooltip label it `XvB (est.)` and say exactly what the figure counts,
so it is never silently partial. XvB folds in only while its published estimate is fresh (the same
staleness rule as the *XvB Donation Stats* card) and you clear a donor tier; otherwise it is left
out rather than guessed, and the label reverts to P2Pool (and Tari, if priced) alone.
@@ -458,11 +491,11 @@ Prices come from one of two places, and the card always says which:
[Privacy › Runtime egress](privacy.md#runtime-egress)). The static numbers remain the fallback
until the first fetch lands; on a failed fetch the last good prices stand and their age is shown.
-Once a price is known (either way), the earnings card also grows **≈-fiat rows**: the Monero tab
-shows the fiat value of the XMR/day/month/year estimates, the Tari tab of the per-block reward and
-XTM/day average, and the XvB tab a fiat mirror of the tier comparison. A `Prices:` line at the foot
-of the card states the exact prices in use and their source — live feed (with age) or config.json —
-so no fiat figure is ever unattributed.
+Once a price is known (either way), each estimate table grows its **`≈` fiat column**: the
+Monero tab's Day / Month / Year figures, the Tari tab's Long-run Average (plus a per-block fiat
+card), the XvB tab's published-reward table and a fiat mirror of the tier comparison. A
+`Prices:` line at the foot of the card states the exact prices in use and their source — live
+feed (with age) or config.json — so no fiat figure is ever unattributed.
### Payout confirmation
@@ -481,7 +514,8 @@ landed — onto the hashrate chart, on the same marker row as the event diamonds
Toggle it from the chart legend like any other series. When XvB is on, a dashed **XvB donation %**
line overlays the chart on its own right-side 0–100% axis, drawn from the recorded donation history,
so you can line a payout up against how much hashrate you were donating when it arrived. Tari
-payouts are solo and lumpy, so they stay in the earnings card and off the chart.
+payouts are solo and lumpy, so they stay in the earnings card and off the chart — the
+[mine cart train](#mine-cart-train) marks each one with a purple gem coin instead.
The dashboard polls the wallet on a slow cadence (about every 5 minutes) and records each confirmed
payout to a small local table, so a restart never re-alerts. Coinbase outputs become spendable only
diff --git a/tests/integration/lib.sh b/tests/integration/lib.sh
index c5f34055..a218276b 100644
--- a/tests/integration/lib.sh
+++ b/tests/integration/lib.sh
@@ -478,23 +478,60 @@ wait_monero_synced() { wait_for "${1:-300}" 10 "Monero sync complete" _pred_mone
wait_miner_running() { wait_for "${1:-180}" 5 "miner released" _pred_miner_running; }
wait_tari_synced() { wait_for "${1:-300}" 10 "Tari sync complete" _pred_tari_synced; }
wait_pool_ready() { wait_for "${1:-180}" 5 "pool type determinate (${2})" _pred_pool_ready "$2"; }
-# Assert a pool switch settled, WITHOUT flaking red on peer luck (#687). p2pool infers its sidechain
-# from connected peers' ports, so a freshly-switched chain (nano over Tor is the slowest to populate)
-# can read "Unknown" past the wait window. A bare assert_eq after `wait_pool_ready … || true` then
-# fires cold and fails on a timing state, not a bug. Three-way verdict, same as assert_scenario's
-# #454 handling: determinate match → pass; still Unknown/empty → peer-timing WARN; a WRONG determinate
-# type (Main when we set Mini) → fail. The 420s window is Tor-realistic; the wait polls, so a fast
-# bench that classifies in seconds pays nothing.
+
+# Ground truth for the sidechain axis (#746): the rendered P2POOL_FLAGS in the box's .env carry
+# --mini / --nano (main carries neither). The dashboard classifies the sidechain by counting
+# connected peers' ports, so right after a pool SWITCH p2pool runs the NEW flags while the
+# classifier can still report the OLD sidechain until enough new-chain peers connect over Tor —
+# determinate, wrong, and transient. The flags tell a real render bug apart from that lag.
+pool_flags_correct() { #
+ local flags
+ flags="$(rx "grep -E '^P2POOL_FLAGS=' .env 2>/dev/null | head -n1 | cut -d= -f2-")"
+ case "$1" in
+ Mini) [[ "$flags" == *"--mini"* ]] ;;
+ Nano) [[ "$flags" == *"--nano"* ]] ;;
+ *) [[ "$flags" != *"--mini"* && "$flags" != *"--nano"* ]] ;;
+ esac
+}
+
+# Shared pool-type verdict (#454/#687/#746), used by assert_scenario and assert_pool_switched:
+# determinate match → pass; Unknown/empty → peer-timing WARN (nano/Tor is slow to populate);
+# determinate-but-wrong with CORRECT P2POOL_FLAGS → classifier-lag WARN (#746, the post-switch
+# stale read); wrong type AND wrong flags → a real config/render bug → FAIL. The mismatch path
+# now checks the actual flags, so this is a stronger check than the old hard-fail, not a looser one.
+assert_pool_type() { #