diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c96541..e94e9c88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,6 +235,12 @@ jobs: run: make lint-sh - name: Run pithead test suite run: bash tests/stack/run.sh + - name: Test-inventory drift check + # The inventory generator is grep-based; it exits non-zero when any suite it + # enumerates counts zero — i.e. a suite moved or changed shape and the inventory + # would silently under-count (#981). Output discarded: the generated file is + # git-ignored and read on demand via `make test-inventory`. + run: bash tests/inventory.sh > /dev/null - name: Run integration harness self-test # Pure-logic checks for the tests/integration/ harness (config rendering, matrix # coverage, redaction). The LIVE matrix (tests/integration/run.sh) needs a real test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb2becfb..5bc45d7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,11 @@ runs `ruff` (plus a few hygiene hooks) on your changed files. If you change depe (`lychee`) runs on a weekly schedule, not per-PR. - **test-dashboard** — the dashboard `pytest` suite (must stay ≥ the **80% total coverage gate**). CI also runs **`make test-patch-coverage`** (`diff-cover`): new/changed lines must be **≥ 90%** - covered vs `origin/develop`, the ratchet that stops coverage rotting at the margin. + covered vs `origin/develop`, the ratchet that stops coverage rotting at the margin. The gate + says so explicitly when a diff has nothing it measures (shell/docs-only PRs pass loudly), and + fails if a changed dashboard Python file is missing from `coverage.xml` entirely — the + silent no-op it used to be. Run it right after `make test-dashboard`, so `coverage.xml` + is fresh. - **test-frontend** — the frontend logic tests (`node --test`); uses the same Node that the lint surfaces already require. - **test-stack** — the `pithead` shell test suite. diff --git a/Makefile b/Makefile index 866df71d..6d9ee854 100644 --- a/Makefile +++ b/Makefile @@ -10,9 +10,8 @@ test-dashboard: ## Dashboard unit/component tests with coverage gate (deps from test-frontend: ## Frontend logic tests with Node's built-in runner (#632; same invocation as CI) node --test build/dashboard/tests/frontend/*.test.mjs -test-patch-coverage: ## diff-cover (#286): new/changed lines must be >=90% covered (run after test-dashboard) - cd build/dashboard && uv run --locked --extra test \ - diff-cover coverage.xml --compare-branch=origin/$${GITHUB_BASE_REF:-develop} --fail-under=90 +test-patch-coverage: ## diff-cover (#286) minus its vacuous pass (#1000): >=90% on changed lines (run after test-dashboard) + bash scripts/patch-coverage.sh test-stack: ## pithead shell test suite bash tests/stack/run.sh diff --git a/build/dashboard/Dockerfile b/build/dashboard/Dockerfile index 0301345a..c39bed75 100644 --- a/build/dashboard/Dockerfile +++ b/build/dashboard/Dockerfile @@ -50,8 +50,10 @@ RUN python -m pytest --cov=mining_dashboard --cov-report=term-missing --cov-fail # ========================================================================== FROM base AS production COPY --from=build /app/.venv /app/.venv -COPY entrypoint.sh . -RUN chmod +x entrypoint.sh +# entrypoint runs the app; healthcheck.sh (#904) HEADs /api/state with the venv's own python3 +# (the slim image ships no curl/wget). +COPY entrypoint.sh healthcheck.sh ./ +RUN chmod +x entrypoint.sh healthcheck.sh # Stack version, baked at build so the running container is self-describing (Issue #58). # PITHEAD_VERSION comes from the top-level VERSION file (the source of truth); the git args diff --git a/build/dashboard/README.md b/build/dashboard/README.md index 40cff61c..d163ae0d 100644 --- a/build/dashboard/README.md +++ b/build/dashboard/README.md @@ -29,10 +29,13 @@ Testing: the Python API, where the logic and formatting live, is unit-tested. Th tests run under Node's built-in runner (`node --test build/dashboard/tests/frontend/`) — no `package.json`/`node_modules`/build step, so the repo stays Node-free. They cover the pure logic (`logic.test.mjs`: worker sort, tooltip formatting, hero-KPI selection), the chart helpers -(`chart.test.mjs`: `withAlpha`, `padYAxis`), the topology geometry (`topology.test.mjs`), and +(`chart.test.mjs`: `withAlpha`, `padYAxis`), the topology geometry (`topology.test.mjs`), component rendering (`components.test.mjs`: every card driven through `App` against a real -`build_state()` fixture, via a DOM-free vnode walker). The DOM-bound wiring (Chart.js canvas, -the SVG topology component) needs a browser and is left to a manual smoke test. +`build_state()` fixture, via a DOM-free vnode walker), and the entry point +(`dashboard.test.mjs`: `initDashboard()` takes its browser seams — DOM, storage, fetch, history, +timer, render — as parameters with real defaults, so the poll loop, its hang-abort, and the +preference wiring run against fakes). The DOM-bound wiring (Chart.js canvas, the SVG topology +component) needs a browser and is left to a manual smoke test. ## Layout diff --git a/build/dashboard/healthcheck.sh b/build/dashboard/healthcheck.sh new file mode 100644 index 00000000..0aa05ca6 --- /dev/null +++ b/build/dashboard/healthcheck.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Dashboard HTTP liveness (#904). +# +# HEAD /api/state against the app's fixed loopback bind (main.py: 127.0.0.1:8000). aiohttp +# serves HEAD on every GET route, so a 200 proves the web server is listening AND build_state +# assembles — the exact "container Up while Caddy serves 502s" state the v1.8.1 one-click +# incident exposed (#622). The slim image ships no curl/wget; python3 (the app's own venv, +# already on PATH) probes with stdlib urllib, which raises — exiting non-zero — on any +# connect failure or non-2xx status. +exec python3 -c 'import urllib.request as u; u.urlopen(u.Request("http://127.0.0.1:8000/api/state", method="HEAD"), timeout=5)' diff --git a/build/dashboard/mining_dashboard/client/monero/monero_client.py b/build/dashboard/mining_dashboard/client/monero/monero_client.py index c4ead287..300f8ec0 100644 --- a/build/dashboard/mining_dashboard/client/monero/monero_client.py +++ b/build/dashboard/mining_dashboard/client/monero/monero_client.py @@ -78,6 +78,12 @@ def get_sync_status(self): `db_size` is monerod's on-disk database size in bytes (from get_info, available even under restricted RPC). The UI shows it next to the configured pruned/full mode so a config/DB mismatch is visible at a glance (Issue #32). + + `synchronized` is monerod's raw network-sync verdict, passed through for the peer-loss + detector (#972): after a tor restart a stranded node can read as "synced" here (stale + target_height 0) while `synchronized` is false. Only this RPC path sets the key — the + log-scrape fallback and remote nodes have no verdict, and the detector treats absence + as no verdict. """ info = self.get_info() if info is None: @@ -86,12 +92,13 @@ def get_sync_status(self): height = int(info.get("height", 0) or 0) target = int(info.get("target_height", 0) or 0) db_size = int(info.get("database_size", 0) or 0) + synchronized = bool(info.get("synchronized", False)) # `synchronized` is monerod's authoritative "caught up" flag; once synced it also # reports target_height: 0. Trust it over the height comparison (mirrors how the # Tari client trusts initial_sync_achieved). - if info.get("synchronized") or target == 0 or height >= target: - return {"is_syncing": False, "db_size": db_size} + if synchronized or target == 0 or height >= target: + return {"is_syncing": False, "db_size": db_size, "synchronized": synchronized} percent = int((height / target) * 100) return { @@ -100,4 +107,5 @@ def get_sync_status(self): "target": target, "percent": percent, "db_size": db_size, + "synchronized": synchronized, } diff --git a/build/dashboard/mining_dashboard/client/xmrig_client.py b/build/dashboard/mining_dashboard/client/xmrig_client.py index 17b57606..650fa6ff 100644 --- a/build/dashboard/mining_dashboard/client/xmrig_client.py +++ b/build/dashboard/mining_dashboard/client/xmrig_client.py @@ -79,16 +79,20 @@ def parse_rigforge(payload): } -# Terminal control-apply outcomes (pithead control_worker_apply / rigforge#236). "accepted" and -# "running" are non-terminal — never reconciled from a read poll, only ever written by the host -# runner itself while a change is still in flight. -_CONTROL_TERMINAL = ("applied", "rejected", "rolled_back") +# Terminal control outcomes the rig may mirror: applied/rejected/rolled_back/failed from a +# control-apply (pithead control_worker_apply / rigforge#236), plus noop/throttled from a +# control-upgrade (rigforge#320, v1.12.0). "started" (rigforge#320's in-flight upgrade marker) and +# "accepted"/"running" (this dashboard's own still-polling placeholders) are non-terminal — never +# reconciled from a read poll, only ever written while a change is still in flight. Mirrors +# pithead's own control_worker_apply/control_worker_upgrade poll cases (#1001) so the mirror-side +# and poll-side vocabularies can't drift apart again. +_CONTROL_TERMINAL = ("applied", "rejected", "rolled_back", "failed", "noop", "throttled") def parse_worker_control_status(payload): - """The rig's last control-apply outcome, mirrored read-only into the SAME enriched feed body - under ``rigforge.control`` (#579) — no new port, no token, it rides the poll that already - fetches the ``rigforge`` block for :func:`parse_rigforge`. + """The rig's last control-apply/control-upgrade outcome, mirrored read-only into the SAME + enriched feed body under ``rigforge.control`` (#579, rigforge#346) — no new port, no token, it + rides the poll that already fetches the ``rigforge`` block for :func:`parse_rigforge`. The host runner's synchronous ``/status`` poll after a worker-apply is capped at 20s (rigforge#236's auto-rollback can take minutes); a change still mid-flight past that deadline @@ -97,10 +101,12 @@ def parse_worker_control_status(payload): mirrors its own last outcome into the already-open, unauthenticated read feed so the next routine poll can catch up. - Returns ``{"change_id", "status", "reason"}`` only for a TERMINAL outcome - (``applied``/``rejected``/``rolled_back``); ``None`` for a still-in-flight change, a malformed - block, or a rig that doesn't mirror this yet (older RigForge, plain xmrig) — so a #185 history - row is never force-terminaled on bad or absent data. + Returns ``{"change_id", "status", "reason"}`` only for a TERMINAL outcome: applied / rejected / + rolled_back / failed (rigforge#236), or noop (already on the target)/throttled (the rig's own + anti-beacon window — retry-later, not a fault) from rigforge#320. Returns ``None`` for a + still-in-flight change (``started``/``accepted``/``running``), a malformed block, or a rig that + doesn't mirror this yet (older RigForge, plain xmrig) — so a #185 history row is never + force-terminaled on bad or absent data. """ rf = payload.get("rigforge") if isinstance(payload, dict) else None ctrl = rf.get("control") if isinstance(rf, dict) else None diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py index 443aefed..d6f1e3b1 100644 --- a/build/dashboard/mining_dashboard/config/config.py +++ b/build/dashboard/mining_dashboard/config/config.py @@ -412,6 +412,11 @@ def _nonneg(v): # How long a preview/commit POST waits for the host-side runner's result before returning 202 and # leaving the client to poll /api/control/result. The systemd path unit fires within seconds. CONTROL_WAIT_S = float(os.environ.get("CONTROL_WAIT_S", 30)) +# A worker-upgrade POST never waits inline (a rig rebuild can run minutes) — it returns 202 at +# once and a background task records the terminal outcome once the host runner writes it. This +# bounds that background wait; matches the client's own polling budget (workerview.mjs +# UPGRADE_POLL_MAX = 150 * 2s = 300s), well past the host's own ~90s dial+poll cap. +CONTROL_WORKER_UPGRADE_WAIT_S = float(os.environ.get("CONTROL_WORKER_UPGRADE_WAIT_S", 300)) GITHUB_RELEASES_API = os.environ.get( "GITHUB_RELEASES_API", "https://api.github.com/repos/p2pool-starter-stack/pithead/releases/latest", @@ -503,6 +508,13 @@ def _nonneg(v): NODE_DOWN_AFTER_SEC = int(os.environ.get("NODE_DOWN_AFTER_SEC", 90)) NODE_RECOVERY_AFTER_SEC = int(os.environ.get("NODE_RECOVERY_AFTER_SEC", 60)) +# Peer-loss staleness (#972): a reachable monerod reporting `synchronized: false` must persist +# this long before the out-of-sync alert fires. 10 minutes rides out normal tip-lag blips AND +# the coupled monerod restart after a tor recreate (compose depends_on restart / tor_heal), so +# only a node that genuinely failed to re-peer alarms. Env-only (tests/mini-stack), not a +# config.json knob — nobody should have to tune a detector. +NODE_STALE_AFTER_SEC = int(os.environ.get("NODE_STALE_AFTER_SEC", 600)) + # --- Healthchecks.io dead-man's switch (Issue #79) --- # Optional external liveness monitor. Set a ping URL and the dashboard loop pings it every cycle; # if the whole host dies (power loss, kernel panic, NIC death) the dashboard dies with it, the pings diff --git a/build/dashboard/mining_dashboard/service/alert_service.py b/build/dashboard/mining_dashboard/service/alert_service.py index a40a68b1..42d585f0 100644 --- a/build/dashboard/mining_dashboard/service/alert_service.py +++ b/build/dashboard/mining_dashboard/service/alert_service.py @@ -61,6 +61,10 @@ class AlertService: flag per node (#31). Tari is only alerted when it's treated as required; a non-blocking Tari going down isn't operator-critical (we keep mining Monero), matching the worker-rejection rule. + - **node out of sync / back in sync** — the debounced peer-loss strand (#972): monerod + reachable and healthy-looking but reporting ``synchronized: false`` past the stale + threshold (a tor restart kills its SOCKS peers and it doesn't re-dial). Rides the + ``node_down``/``node_recovered`` toggles — same conversation, different failure mode. - **sync finished** — the sync gate's ``miner_released`` latch flipping open once (#35). - **worker offline / back online / joined / left** — a debounced :class:`WorkerPresenceMonitor` over the live worker rows (offline keys off the same DOWN status the dashboard shows; joined / @@ -184,6 +188,7 @@ def __init__( self.host_label = "" if host_label in (None, "", "Unknown Host") else host_label # None = "not yet observed": the first cycle seeds the baseline without emitting. self._prev_monero_down = None + self._prev_monero_stale = None self._prev_tari_down = None self._prev_released = None self._prev_disk_level = None @@ -232,6 +237,7 @@ def evaluate( self, *, monero_down, + monero_stale=False, tari_down, tari_required, miner_released, @@ -273,6 +279,7 @@ def evaluate( # --- Node down / recovered (consume NodeHealthMonitor edges) --- alerts += self._node_edges("Monero", monero_down, "_prev_monero_down") + alerts += self._stale_edges(monero_stale) if tari_required: alerts += self._node_edges("Tari", tari_down, "_prev_tari_down") else: @@ -380,6 +387,35 @@ def _node_edges(self, label, down, attr): ) ] + def _stale_edges(self, stale): + """Monero node reachable but OUT of sync (#972): the debounced ``synchronized: false`` + strand a tor restart leaves behind. Distinct from node-down — the node answers its RPC + and every container reads healthy while mining sits on a stale tip. Rides the + node_down/node_recovered toggles: same conversation, different failure mode.""" + prev = self._prev_monero_stale + self._prev_monero_stale = stale + if prev is None or stale == prev: + return [] + if stale: + self._record_incident(self.EVT_NODE_DOWN) + return [ + ( + self.EVT_NODE_DOWN, + self._fmt( + "\U0001f534 ⛓️ Monero node is OUT OF SYNC — reachable but reporting " + "not-synchronized (peers usually die like this after a Tor restart). " + "Mining sits on a stale tip until it re-peers: run " + "'./pithead restart monerod'." + ), + ) + ] + return [ + ( + self.EVT_NODE_RECOVERED, + self._fmt("\U0001f7e2 ⛓️ Monero node is back in sync with the network."), + ) + ] + def _disk_edges(self, disk_percent): """Alert on the data disk crossing the dashboard's own warn/critical thresholds (#138).""" level = ( diff --git a/build/dashboard/mining_dashboard/service/data_service.py b/build/dashboard/mining_dashboard/service/data_service.py index 6a6c64d5..9e5a2e1b 100644 --- a/build/dashboard/mining_dashboard/service/data_service.py +++ b/build/dashboard/mining_dashboard/service/data_service.py @@ -52,6 +52,7 @@ HOST_IP, MONERO_CLEARNET_SYNC, MONERO_WALLET_ADDRESS, + NODE_STALE_AFTER_SEC, PAYOUT_CONFIRM_ENABLED, REJECT_WORKERS_CONTAINER, SYNC_GATE_CONTAINERS, @@ -636,6 +637,11 @@ def __init__(self, state_manager, proxy_client, xvb_client): self.docker_control = DockerControl() self.monero_health = NodeHealthMonitor() self.tari_health = NodeHealthMonitor() + # Peer-loss staleness (#972): the same debounce machine, fed monerod's own + # `synchronized` flag instead of reachability. "Ever synchronized" plays the ever-up + # guard, so a node mid-initial-sync (synchronized false for days) never alarms; only a + # node that WAS in sync and stayed out for NODE_STALE_AFTER_SEC trips `down` (= stale). + self.monero_sync_stale = NodeHealthMonitor(down_after=NODE_STALE_AFTER_SEC) # Healthchecks.io dead-man's switch (Issue #79). Disabled by default — when off this is # a no-op. When on, each cycle pings a unique URL; the alert fires externally on the @@ -1512,6 +1518,17 @@ async def run(self): monero_sync["down"] = monero_down tari_sync["down"] = tari_down + # 3b. Peer-loss staleness (#972): monerod can survive a tor restart with + # every SOCKS peer dead — reachable, healthcheck green, height creeping, + # but `synchronized: false` for hours. The RPC path is the only one that + # carries the flag; absence (log-scrape fallback, remote node) is no + # verdict, so the monitor isn't fed and its streaks stay put. + monero_reports_synced = monero_sync.get("synchronized") + if monero_reports_synced is not None: + self.monero_sync_stale.update(monero_reports_synced) + monero_stale = self.monero_sync_stale.down + monero_sync["stale"] = monero_stale + # 4. Sync gate (Issue #35): hold p2pool + xmrig-proxy until the required # chain(s) first sync, then release. monerod must be synced; Tari must be # synced too unless it's non-blocking. #31's runtime failover only applies @@ -1567,6 +1584,9 @@ async def run(self): ) await self.alert_service.process( monero_down=monero_down, + # Debounced "reachable but out of sync" (#972) — the 0-peer strand + # after a tor restart that node-down can't see. + monero_stale=monero_stale, tari_down=tari_down, tari_required=TARI_REQUIRED, miner_released=self.miner_released, diff --git a/build/dashboard/mining_dashboard/service/storage_service.py b/build/dashboard/mining_dashboard/service/storage_service.py index a9e4cfee..35948439 100644 --- a/build/dashboard/mining_dashboard/service/storage_service.py +++ b/build/dashboard/mining_dashboard/service/storage_service.py @@ -51,6 +51,12 @@ # Table names carrying a per-table write-health signal (see __init__ / _table_write_ok below). _TELEMETRY_TABLES = ("blocks", "xvb_history", "network_history", "disk_growth", "worker_history") +# The full terminal vocabulary the rig's control mirror can report (#1009) — applied/rejected/ +# rolled_back/failed from a control-apply, plus noop (already on target)/throttled (retry-later) +# from a control-upgrade (rigforge#320). Mirrors xmrig_client._CONTROL_TERMINAL and pithead's own +# control_worker_apply/control_worker_upgrade poll cases (#1001) — one vocabulary, three places. +_RECONCILE_TERMINAL = ("applied", "rejected", "rolled_back", "failed", "noop", "throttled") + class StateManager: """ @@ -323,14 +329,16 @@ def _create_tables(self): # config history on the rig, so Pithead owns it: one row per change the dashboard applied, # with the writable-key `changes` we sent (each row IS a diff from the prior state, by # construction — we only ever record deltas we authored) and the rig's terminal outcome. - # Additive, forward-only (mirrors events / payouts) — no _migrate_db change needed. `changes` - # holds only the writable allowlist keys; NO secret ever lands here (the rig token stays - # host-side, #440). change_id is the rig's 16-hex id, or NULL for a request that never reached - # a rig (rejected host-side). + # `changes` holds only the writable allowlist keys; NO secret ever lands here (the rig token + # stays host-side, #440). change_id is the rig's 16-hex id, or NULL for a request that never + # reached a rig (rejected host-side). `type` distinguishes a config apply from a one-click + # rig upgrade (#1014) — an upgrade's `changes` carries `{"version": ...}` instead of a + # writable-key diff, and get_last_applied_worker_config must never merge that into the + # config-editor prefill. New column, existing installs migrated in _migrate_db (below). self._conn.execute( "CREATE TABLE IF NOT EXISTS worker_config " "(id INTEGER PRIMARY KEY AUTOINCREMENT, worker TEXT, change_id TEXT, ts REAL, " - "status TEXT, changes TEXT, reason TEXT)" + "status TEXT, changes TEXT, reason TEXT, type TEXT DEFAULT 'apply')" ) # v1.7 telemetry backbone (#196 Wave-0 proposal): five independent, additive time-series # tables. Each has its own retention (see the RETENTION_SEC constants above) and is @@ -449,6 +457,14 @@ def _migrate_db(self): # DBs; harmless no-op on fresh ones. self._conn.execute("DROP TABLE IF EXISTS workers") + # worker_config.type (#1014): every pre-existing row was written before rig upgrades were + # recorded at all, so it was necessarily a config apply — backfill 'apply', matching the + # column's own DEFAULT for any row a future ALTER-less write path might still hit. + cursor.execute("PRAGMA table_info(worker_config)") + if "type" not in {info[1] for info in cursor.fetchall()}: + self.logger.info("Migrating DB: Adding type column to worker_config") + self._conn.execute("ALTER TABLE worker_config ADD COLUMN type TEXT DEFAULT 'apply'") + def load(self): """ Loads state from SQLite into memory on startup. @@ -836,16 +852,19 @@ def add_worker_config_version( changes: dict[str, Any], reason: str | None, ts: float | None = None, + change_type: str = "apply", ) -> None: - """Record one applied/attempted worker config change (#185). ``changes`` is the writable-key - delta the dashboard sent (stored as JSON — no secret ever lands here). Forward-only.""" + """Record one applied/attempted worker change (#185): a config apply, or (``change_type= + "upgrade"``, #1014) a one-click RigForge upgrade attempt — ``changes`` then carries + ``{"version": ...}`` instead of a writable-key diff. Stored as JSON — no secret ever lands + here. Forward-only.""" try: with self._db_lock: if not self._conn: return self._conn.execute( - "INSERT INTO worker_config (worker, change_id, ts, status, changes, reason) " - "VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO worker_config (worker, change_id, ts, status, changes, reason, type) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", ( worker, change_id, @@ -853,6 +872,7 @@ def add_worker_config_version( status, json.dumps(changes), reason, + change_type, ), ) self._conn.commit() @@ -860,14 +880,16 @@ def add_worker_config_version( self._db_error("Worker Config Write Error", e) def get_worker_config_history(self, worker: str, limit: int = 50) -> list[dict[str, Any]]: - """The change history for ``worker``, newest first, with ``changes`` parsed back to a dict.""" + """The change history for ``worker``, newest first, with ``changes`` parsed back to a dict. + ``type`` is ``"apply"`` or ``"upgrade"`` (#1014); a row from before that column existed + reads back ``"apply"`` too (the migration backfills it, same as a fresh insert's default).""" try: with self._db_lock: if not self._conn: return [] cursor = self._conn.cursor() cursor.execute( - "SELECT change_id, ts, status, changes, reason FROM worker_config " + "SELECT change_id, ts, status, changes, reason, type FROM worker_config " "WHERE worker = ? ORDER BY ts DESC, id DESC LIMIT ?", (worker, limit), ) @@ -878,6 +900,7 @@ def get_worker_config_history(self, worker: str, limit: int = 50) -> list[dict[s d["changes"] = json.loads(d["changes"]) if d["changes"] else {} except (TypeError, ValueError): d["changes"] = {} + d["type"] = d.get("type") or "apply" out.append(d) return out except sqlite3.Error as e: @@ -893,10 +916,12 @@ def reconcile_worker_config_status( row ``accepted`` forever otherwise — nothing re-polls it. This is the reconciler: called from the dashboard's regular per-rig read poll (data_service.py), never a new dial. The ``WHERE status = 'accepted'`` is the whole safety property — a row already terminal - (``applied``/``rejected``/``rolled_back``) is never touched, even by a stale or duplicate - report for the same ``change_id``. + (applied/rejected/rolled_back/failed/noop/throttled) is never touched, even by a stale or + duplicate report for the same ``change_id``. ``status`` is recorded as-is: it becomes the + row's outcome verbatim, and the frontend's ``STATUS_META`` already renders every member of + this vocabulary (``workerview.mjs``). """ - if status not in ("applied", "rejected", "rolled_back") or not change_id: + if status not in _RECONCILE_TERMINAL or not change_id: return try: with self._db_lock: @@ -973,10 +998,16 @@ def get_audit_events(self, limit: int = 1000) -> list[dict[str, Any]]: def get_last_applied_worker_config(self, worker: str) -> dict[str, Any]: """The merged writable config the dashboard last successfully applied to ``worker`` — the best prefill for the editor, since the rig's enriched feed does not expose the writable config - values (#185). Later applied changes lay over earlier ones (last write wins per key).""" + values (#185). Later applied changes lay over earlier ones (last write wins per key). + Upgrade rows (#1014) are excluded — their ``changes`` is a ``{"version": ...}`` marker, not + writable-key config, and must never leak into the editor prefill.""" merged: dict[str, Any] = {} for row in reversed(self.get_worker_config_history(worker, limit=200)): - if row.get("status") == "applied" and isinstance(row.get("changes"), dict): + if ( + row.get("status") == "applied" + and row.get("type", "apply") == "apply" + and isinstance(row.get("changes"), dict) + ): merged.update(row["changes"]) return merged diff --git a/build/dashboard/mining_dashboard/service/tor_heal.py b/build/dashboard/mining_dashboard/service/tor_heal.py index d069f8ed..ba42cf1d 100644 --- a/build/dashboard/mining_dashboard/service/tor_heal.py +++ b/build/dashboard/mining_dashboard/service/tor_heal.py @@ -33,6 +33,13 @@ The restart goes through the same start/stop-only docker-control proxy as the #31 failover. The manual leg is ``./pithead restart tor``. Real stuck-guard recovery is tier 4 (the live bench). + +A successful tor restart is followed by a monerod restart when the node is local (#972): the tor +restart kills every SOCKS connection, and monerod keeps its dead peer sockets — bench-observed at +0 in / 0 out peers for ~6 hours while every healthcheck stayed green. Compose couples the same +restart for its own operations (``depends_on: tor: restart: true``); this healer bypasses compose, +so it couples it here. Best-effort: a failed monerod cycle is logged and never refunds the tor +attempt (the tor restart DID happen) — the out-of-sync alert is the backstop. """ import asyncio @@ -41,7 +48,12 @@ import requests -from mining_dashboard.config.config import TOR_AUTO_HEAL, TOR_SOCKS_PROXY +from mining_dashboard.config.config import ( + LOCAL_MONERO_HOST, + MONERO_NODE_HOST, + TOR_AUTO_HEAL, + TOR_SOCKS_PROXY, +) from mining_dashboard.helper.http import bounded_get logger = logging.getLogger("TorHeal") @@ -74,9 +86,23 @@ class TorEgressHealer: """ CONTAINER = "tor" + MONEROD = "monerod" - def __init__(self, docker_control, enabled=None, probe=None, notify=None, clock=time.monotonic): + def __init__( + self, + docker_control, + enabled=None, + probe=None, + notify=None, + clock=time.monotonic, + restart_monerod=None, + ): self.enabled = TOR_AUTO_HEAL if enabled is None else enabled + # Cycle monerod after a successful tor restart (#972) — only when the node is local + # (a remote monerod has no container here and keeps its own tor). + if restart_monerod is None: + restart_monerod = MONERO_NODE_HOST == LOCAL_MONERO_HOST + self._restart_monerod = restart_monerod self._docker = docker_control self._probe = probe or self._probe_egress self._notify = notify # optional async callable(text) — the Telegram one-off @@ -197,6 +223,26 @@ async def check(self): "tor restart could not be issued via docker-control (unreachable) — " "the attempt was refunded and will be retried on the next probe (#424)." ) + elif self._restart_monerod: + # The tor restart just killed every SOCKS connection; monerod holds its + # dead peer sockets and can sit at 0 in / 0 out peers for hours while + # looking healthy (#972). Cycle it so it re-dials through the fresh tor. + # monerod's stop_grace_period is 1m, so the stop timeout matches it and the + # HTTP timeout outlasts the stop (#234's lesson). + logger.warning( + "Restarting monerod alongside tor so it re-dials its peers through " + "the fresh Tor (#972)." + ) + m_stopped = await self._docker.stop( + self.MONEROD, stop_timeout=60, request_timeout=90 + ) + m_started = await self._docker.start(self.MONEROD, request_timeout=60) + if not (m_stopped and m_started): + logger.warning( + "monerod restart alongside tor could not be issued — if the node " + "stays out of sync, restart it manually: './pithead restart " + "monerod' (#972)." + ) elif action == "exhausted": if not self._warned_exhausted: self._warned_exhausted = True diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py index bc07ca35..7c18f705 100644 --- a/build/dashboard/mining_dashboard/web/server.py +++ b/build/dashboard/mining_dashboard/web/server.py @@ -1,3 +1,4 @@ +import asyncio import logging import math import mimetypes @@ -254,32 +255,55 @@ async def handle_backup_download(request): ) -def _record_worker_result(state_mgr, worker, changes, res): - """Log a worker-apply outcome to the per-worker config history (#185). Only terminal-ish - statuses are kept; ``changes`` carries no secret (the rig token stays host-side).""" +# Terminal-ish outcomes worth a history row — shared by worker-apply and worker-upgrade (#1014). +# noop/throttled are upgrade-only (the host runner's control_worker_upgrade case statement); the +# rest are common to both actions. "accepted" is genuinely non-terminal (still running on the rig +# past the wait budget) but IS the runner's final write for that request id, so it's recorded too +# — the alternative is a change the operator made that the dashboard never mentions again. +_RECORDABLE_WORKER_STATUSES = ( + "applied", + "rejected", + "rolled_back", + "accepted", + "failed", + "noop", + "throttled", +) + + +def _record_worker_result(state_mgr, worker, changes, res, change_type="apply"): + """Log a worker-apply or worker-upgrade outcome to the per-worker config history (#185/#1014). + Only terminal-ish statuses are kept; ``changes`` carries no secret (the rig token stays + host-side). ``worker``/``changes`` come from the caller's own request, never ``res`` — the + host runner omits ``worker`` from a pre-dial reject, so this stays correct either way.""" status = res.get("status", "unknown") - if status in ("applied", "rejected", "rolled_back", "accepted", "failed"): + if status in _RECORDABLE_WORKER_STATUSES: state_mgr.add_worker_config_version( worker, res.get("change_id"), status, changes, res.get("reason") or res.get("error"), + change_type=change_type, ) async def handle_worker_detail(request): """Per-worker inspect data (#185): the rig's current enriched telemetry, the writable config the dashboard last applied (the prefill — the rig's feed does not expose the writable config values), - and the change history with per-change diffs.""" + the change history with per-change diffs, and (#1013) its own hashrate-over-time chart — same + ``range``/``from``/``to`` query params as ``/api/state``, so the per-rig chart can offer its own + range control.""" name = request.query.get("name", "") if not name: raise web.HTTPBadRequest(text="'name' is required.") app = request.app data = app["latest_data"] or {} state_mgr = app["state_manager"] + range_arg = request.query.get("range", "all") + window = parse_window(request.query.get("from"), request.query.get("to")) try: - return web.json_response(build_worker_detail(name, data, state_mgr)) + return web.json_response(build_worker_detail(name, data, state_mgr, range_arg, window)) except Exception: logger.exception("Error building worker detail") return web.json_response({"error": "Failed to build worker detail."}, status=500) @@ -324,6 +348,25 @@ async def handle_worker_apply(request): return web.json_response({"id": rid, **res}) +async def _finalize_worker_upgrade(state_mgr, worker, version, rid): + """The server-side half of recording a worker-upgrade attempt (#1014): ``handle_worker_upgrade`` + returns 202 before any result exists (a rig rebuild can take minutes, so it never waits inline + like ``handle_worker_apply`` does), so this runs as its own background task and records the + terminal outcome once the host runner writes it — independent of whether the operator's browser + tab stays open to see it land. One row per upgrade attempt, same shape as an apply's (#185).""" + try: + res = await control_service.wait_result( + rid, + done=lambda r: r.get("status") != "running", + timeout_s=config.CONTROL_WORKER_UPGRADE_WAIT_S, + ) + except Exception: + logger.exception("Error waiting for worker-upgrade result (id=%s)", rid) + return + if res is not None: + _record_worker_result(state_mgr, worker, {"version": version}, res, change_type="upgrade") + + async def handle_worker_upgrade(request): """One-click RigForge upgrade for a single rig (#597), via the HOST-side control runner. @@ -332,7 +375,8 @@ async def handle_worker_upgrade(request): from the RigForge release API over Tor and refuses a mismatch, then resolves the rig's address + bearer from config.json — this container never holds the token and cannot choose what gets installed. Returns 202 + the request id immediately (a rig build can take minutes); the client - polls /api/control/result. A rig already reporting the requested version short-circuits to a + polls /api/control/result, and a background task records the terminal outcome to the per-worker + history once it lands (#1014). A rig already reporting the requested version short-circuits to a no-op without spooling — a dial would just burn the rig's own 6h upgrade throttle.""" _require_control_header(request) try: @@ -360,6 +404,11 @@ async def handle_worker_upgrade(request): except Exception: logger.exception("Error submitting worker-upgrade") return web.json_response({"error": "Failed to submit the worker upgrade."}, status=500) + state_mgr = request.app["state_manager"] + bg_tasks = request.app["_bg_tasks"] + task = asyncio.create_task(_finalize_worker_upgrade(state_mgr, worker, version, rid)) + bg_tasks.add(task) + task.add_done_callback(bg_tasks.discard) return web.json_response({"id": rid, "status": "pending"}, status=202) @@ -492,12 +541,27 @@ async def security_headers_middleware(request, handler): raise _apply_security_headers(exc) from exc +async def _cancel_bg_tasks(app): + """Cancel any still-pending worker-upgrade recorder tasks (#1014) on shutdown — otherwise a + task waiting out its up-to-5-minute budget outlives the app and asyncio logs a "Task was + destroyed but it is pending" warning at interpreter exit.""" + tasks = list(app["_bg_tasks"]) + for t in tasks: + t.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + def create_app(state_manager, latest_data_ref): """Factory to create the web app instance.""" app = web.Application(middlewares=[security_headers_middleware]) # Pass shared state objects to the app context app["state_manager"] = state_manager app["latest_data"] = latest_data_ref + # Fire-and-forget recorder tasks (worker-upgrade, #1014) — tracked so they can't be + # garbage-collected mid-flight and so shutdown can cancel any still in progress. + app["_bg_tasks"] = set() + app.on_cleanup.append(_cancel_bg_tasks) app.add_routes( [ diff --git a/build/dashboard/mining_dashboard/web/static/chart.mjs b/build/dashboard/mining_dashboard/web/static/chart.mjs index 0c6bf1e9..22f9a022 100644 --- a/build/dashboard/mining_dashboard/web/static/chart.mjs +++ b/build/dashboard/mining_dashboard/web/static/chart.mjs @@ -27,6 +27,16 @@ const RANGES = [ ["all", "All"], ]; +// Reduced range set for the per-worker hashrate chart (#1013), sized to what worker_history can +// honestly support: it samples ~5 min (vs the main history's 30s), so a "1 Hr" button would show +// ~12 points — dropped rather than shipped dishonest. Retention is 30 days — exactly what "1 Mo" +// would mean — so "All" stands alone instead of offering an identical neighbour. +const WORKER_RANGES = [ + ["24h", "24 Hr"], + ["1w", "1 Wk"], + ["all", "All"], +]; + // Hashrate-averaging windows for the chart toggle (#168): [param key, button label]. The keys match // the server's `avg` param; labels are spelled out so the "1m" window (1 MINUTE) isn't mistaken for // the "1 Mo" RANGE above. Persisted in dashboard.js ui.avg (localStorage), default 10m. 12h/24h read @@ -507,3 +517,165 @@ export class ChartCard extends Component { `; } } + +// Per-worker hashrate chart (#1013): the same card/range-control/palette idioms as ChartCard +// above, sized down to what a single rig's data actually supports — one hashrate line, no +// avg-window toggle (worker_history stores only h15), no zoom (not asked for; ChartCard's zoom +// exists for #47's wide fleet range, not a per-rig glance). The "Changes" scatter overlay (#1015) +// is a fourth instance of the hidden-0-1-axis marker pattern Events/Raffle/Payouts already use +// above — not a new mechanism, just fed config-apply/rig-upgrade points instead. +// +// `props.chart` is pre-shaped by the caller (workerlogic.mjs's buildChartMarkers), the same way +// ChartCard's own d.events/d.raffle/d.payouts arrive pre-shaped: `{hashrate: [{x,y}], markers: +// [{x, y, label, kind, quiet}]}`. `quiet` marks an outcome where nothing actually changed +// (rejected/rolled_back/failed/throttled/noop/accepted) — still shown, just muted, rather than +// dropped (#1015). + +// Per-point style for the "Changes" marker dataset (#1015): a triangle for a rig upgrade, a +// diamond (matching the Events marker above) for a config apply; muted (c.ticks) for an outcome +// that didn't actually change anything (quiet), the chart's accent colour otherwise. Kept pure and +// exported, mirroring eventColors above, so the branch is unit-tested without a canvas. +export function workerMarkerStyle(markers, c) { + return { + pointStyle: (markers || []).map((m) => (m.kind === "upgrade" ? "triangle" : "rectRot")), + color: (markers || []).map((m) => (m.quiet ? c.ticks : c.accent)), + }; +} + +export class WorkerChartCard extends Component { + constructor(props) { + super(props); + this.canvasRef = createRef(); + } + + componentDidMount() { + this.create(); + } + componentDidUpdate() { + this.sync(); + } + componentWillUnmount() { + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } + } + + create() { + const canvas = this.canvasRef.current; + const d = this.props.chart; + if (!canvas || typeof Chart === "undefined" || !d.hashrate.length) return; + const c = paletteColors(); + const mk = workerMarkerStyle(d.markers, c); + this.chart = new Chart(canvas, { + type: "line", + data: { + datasets: [ + { + label: "Hashrate", + data: d.hashrate, + borderColor: c.accent, + borderWidth: AREA_BORDER_WIDTH, + tension: 0.3, + fill: true, + backgroundColor: areaFill(c.accent), + pointRadius: 0, + pointHitRadius: 20, + }, + // Config-apply / rig-upgrade markers, on their own hidden 0-1 axis so they ride near + // the top and never affect the hashrate y-range — same technique as Events above. + { + label: "Changes", + data: d.markers, + yAxisID: "markers", + pointStyle: mk.pointStyle, + pointRadius: 7, + pointHoverRadius: 10, + pointHitRadius: 100, + showLine: false, + pointBackgroundColor: mk.color, + pointBorderColor: mk.color, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: "nearest", axis: "x", intersect: false }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items) { + return items.length ? fmtTimestamp(items[0].parsed.x) : ""; + }, + label(context) { + if (context.dataset.label === "Changes") return context.raw.label; + return context.parsed.y !== null ? fmtHashrate(context.parsed.y) : ""; + }, + }, + }, + }, + scales: { + x: { type: "linear", display: false }, + y: { grid: { color: c.grid }, ticks: { color: c.ticks }, afterDataLimits: padYAxis }, + markers: { type: "linear", display: false, min: 0, max: 1 }, + }, + }, + }); + } + + sync() { + const d = this.props.chart; + if (!d.hashrate.length) { + // Range switched to a slice with no samples (e.g. a rig that's only been up an hour, on + // "1 Wk") — drop the instance so render()'s empty state takes over instead of an empty axis. + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } + return; + } + if (!this.chart) { + this.create(); + return; + } + const c = paletteColors(); // re-read so a theme switch recolours in place + const mk = workerMarkerStyle(d.markers, c); + const ds = this.chart.data.datasets; + ds[0].data = d.hashrate; + ds[0].borderColor = c.accent; + ds[0].backgroundColor = areaFill(c.accent); + ds[1].data = d.markers; + ds[1].pointStyle = mk.pointStyle; + ds[1].pointBackgroundColor = mk.color; + ds[1].pointBorderColor = mk.color; + this.chart.options.scales.y.grid.color = c.grid; + this.chart.options.scales.y.ticks.color = c.ticks; + this.chart.update(); + this.chart.resize(); + } + + render(props) { + const empty = !props.chart.hashrate.length; + return html` +
+
+ Range: + ${WORKER_RANGES.map( + ([r, label]) => html``, + )} +
+ ${ + empty + ? html`

No hashrate history for this rig yet.

` + : html`
` + } +
`; + } +} diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index 4eadc7ce..7523d1ea 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -573,23 +573,38 @@ function XvbDecisionTable({ calc, coeffDay, hr, energy }) { // own auto rule), what holding it costs, and the current vs target tier for context. Labelled // raffle status, never a payout. The draw is random above the threshold — donating more within // a tier buys nothing — but the odds themselves ARE knowable (#872: qualifier counts from XvB's -// winners file), so the comparison dropdown shows them. Hidden entirely while XvB is disabled. +// winners file), so the comparison dropdown shows them. Rendered with XvB disabled too (#938) — +// the block is the enable/don't-enable decision aid — but the live-credit cards (Current/Target +// tier) stand down with the flag: there is no credited donation to report. // `coeffDay` (earnings.coeff_day) feeds the per-tier payout comparison dropdown below. function XvbTierBlock({ calc, hr, coeffDay, energy, est }) { - if (!calc || !calc.enabled) return null; + if (!calc) return null; const t = computeXvbTier(hr, calc); return html`

XvB Tier (raffle)

+ ${ + calc.enabled + ? null + : html`

+ XvB donation is off — this table prices what enabling it would earn and cost at + your hashrate. Nothing is fetched from xmrvsbeast.com while it is off, so the + odds and reward columns run from the last cached read.

` + }
<${StatCard} label="Sustainable Tier" value=${t ? t.tier : "None"} cls="c-purple" title="The highest XvB donor tier this hashrate sustains while leaving P2Pool its share — the same auto rule the donation controller uses." /> <${StatCard} label="Hashrate Cost" value=${t ? fmtHashrate(t.cost) : "—"} title="Holding the tier means continuously donating about its threshold — hashrate that earns no P2Pool shares while donated." /> + ${ + calc.enabled + ? html` <${StatCard} label="Current Tier" value=${calc.current_tier} title="The tier your credited XvB donation clears right now (the lower of XvB's 1h and 24h averages)." /> <${StatCard} label="Target Tier" value=${calc.target_tier} - title=${"The tier the donation controller is configured to aim for" + (calc.sustainable ? "." : " — currently NOT sustainable at your hashrate.")} /> + title=${"The tier the donation controller is configured to aim for" + (calc.sustainable ? "." : " — currently NOT sustainable at your hashrate.")} />` + : null + }
${ // Current-tier expected reward (#712) in the same standardized Day/Month/Year table @@ -631,6 +646,14 @@ function ExpectedVsActualCard({ summary }) { const partialMark = (row, text) => (row.partial ? text + " *" : text); const anyPartial = (xmr.enabled && xmr.partial) || (tari.enabled && tari.partial); const rows = []; + // The server withholds pct past 999%: a near-zero expectation (a box idle for most of the + // window) turns the ratio into a five-digit figure that reads as a bug. Once available and + // enabled both hold, a null pct means exactly that — the tooltip owns the explanation. + const pctNote = + xmr.available && xmr.enabled && xmr.pct === null + ? " No percentage shown: the expected figure is near zero for this window — the miner " + + "was idle or unrecorded for most of it, so a ratio against it would be noise." + : ""; rows.push({ label: xmr.includes_xvb ? "Monero + XvB (30d)" : "Monero (30d)", expected: xmr.available ? formatXmr(xmr.expected_30d) : "—", @@ -638,23 +661,24 @@ function ExpectedVsActualCard({ summary }) { ? "set monero.view_key" : partialMark(xmr, formatXmr(xmr.actual_30d) + (xmr.pct !== null ? ` (${xmr.pct}%)` : "")), dim: !xmr.enabled, - title: xmr.includes_xvb - ? "Confirmed on-chain payouts over the trailing 30 days vs the P2Pool linear expectation " + - "at your 30-day average hashrate PLUS XvB's estimate for your tier — combined " + - "on both sides, because an XvB win pays out through ordinary payouts that cannot be " + - "told apart from P2Pool payouts. " + - (xmr.xvb_realization_pct !== null - ? `The XvB share is tempered to this wallet's measured win payouts — ` + - `${xmr.xvb_realization_pct}% of XvB's published face value over the last ` + - `${xmr.xvb_wins_measured} wins. ` - : "The XvB share is XvB's published face-value estimate — an upper bound: it prices " + - "every bonus hash at full block reward and assumes every won round runs to " + - "completion. ") + - "Payouts swing with luck; a sustained gap is the signal worth checking, not one window." - : "Confirmed on-chain payouts over the trailing 30 days vs the linear expectation at " + - "your 30-day average P2Pool hashrate. Any XvB win payouts land in the actual too — " + - "they cannot be told apart from P2Pool payouts. Payouts swing with luck; a sustained " + - "gap is the signal worth checking, not one window.", + title: + (xmr.includes_xvb + ? "Confirmed on-chain payouts over the trailing 30 days vs the P2Pool linear expectation " + + "at your 30-day average hashrate PLUS XvB's estimate for your tier — combined " + + "on both sides, because an XvB win pays out through ordinary payouts that cannot be " + + "told apart from P2Pool payouts. " + + (xmr.xvb_realization_pct !== null + ? `The XvB share is tempered to this wallet's measured win payouts — ` + + `${xmr.xvb_realization_pct}% of XvB's published face value over the last ` + + `${xmr.xvb_wins_measured} wins. ` + : "The XvB share is XvB's published face-value estimate — an upper bound: it prices " + + "every bonus hash at full block reward and assumes every won round runs to " + + "completion. ") + + "Payouts swing with luck; a sustained gap is the signal worth checking, not one window." + : "Confirmed on-chain payouts over the trailing 30 days vs the linear expectation at " + + "your 30-day average P2Pool hashrate. Any XvB win payouts land in the actual too — " + + "they cannot be told apart from P2Pool payouts. Payouts swing with luck; a sustained " + + "gap is the signal worth checking, not one window.") + pctNote, }); rows.push({ label: "Tari (30d)", @@ -808,14 +832,17 @@ class EarningsCard extends Component { const est = computeEarnings(hr, e); const xvb = this.props.xvb; const energy = this.props.energy; - // Tabs split the (now multi-domain) card body. XvB only appears when it's enabled and Energy only - // when the fleet reports any power — there's nothing to show otherwise. The one what-if input - // above the strip drives every tab's estimate. + // Tabs split the (now multi-domain) card body. The XvB tab stays with XvB disabled (#938) — + // its decision table is exactly the "should I enable it?" aid — as long as the server sent a + // tier table (a pre-#938 disabled payload carries none, and enabled always does). Energy only + // appears when the fleet reports any power — there's nothing to show otherwise. The one + // what-if input above the strip drives every tab's estimate. + const showXvb = !!(xvb && (xvb.enabled || (xvb.tiers || []).length)); const tabs = [ { id: "monero", label: "Monero" }, { id: "tari", label: "Tari" }, ]; - if (xvb && xvb.enabled) tabs.push({ id: "xvb", label: "XvB" }); + if (showXvb) tabs.push({ id: "xvb", label: "XvB" }); if (energy && energy.available) tabs.push({ id: "energy", label: "Energy" }); const active = tabs.some((t) => t.id === tab) ? tab : "monero"; // Fiat estimates (#520): each tab grows ≈-fiat rows once its coin's price is known — static @@ -877,7 +904,7 @@ class EarningsCard extends Component {
${ - xvb && xvb.enabled + showXvb ? html`