diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..630c92d7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig — whitespace/EOL baseline across every file surface (Wave 7 tooling, #280). +# https://editorconfig.org · Mirrors the formatters: ruff (Python), and the per-surface +# tools landing in #281 (shfmt -i 4 for shell, Biome 2-space for JS/CSS/JSON). +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# Web/config surfaces conventionally use 2-space indent. +[*.{js,mjs,cjs,css,json,jsonc,yml,yaml,toml,proto}] +indent_size = 2 + +# Markdown uses two trailing spaces for a hard line break — don't strip them. +[*.md] +trim_trailing_whitespace = false + +# Makefiles require real tabs. +[Makefile] +indent_style = tab diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..c5484ae4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,49 @@ +# Dependabot (Wave 7 tooling, #282): automated dependency + CVE update PRs across every +# supply-chain surface. Grouped + weekly to keep PR volume sane. +version: 2 +updates: + # GitHub Actions — keep the SHA-pinned actions (#282) fresh. Dependabot rewrites the SHA and the + # `# vX.Y.Z` comment together. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: ["*"] + + # Dashboard Python deps — bumps the hashed uv.lock + pyproject floors (#283). + - package-ecosystem: "uv" + directory: "/build/dashboard" + schedule: + interval: "weekly" + groups: + python: + patterns: ["*"] + ignore: + # protobuf/grpcio are pinned to what the vendored Tari gRPC stubs assert at import time + # (see build/dashboard/pyproject.toml); a major bump needs the stubs regenerated first. + - dependency-name: "protobuf" + update-types: ["version-update:semver-major"] + - dependency-name: "grpcio" + update-types: ["version-update:semver-major"] + + # Base-image digests across every build/* Dockerfile (incl. the uv build image). This is the + # mechanism that clears base-distro CVEs — e.g. the openssl point-release accepted in .trivyignore. + - package-ecosystem: "docker" + directories: + - "/build/dashboard" + - "/build/monero" + - "/build/p2pool" + - "/build/tor" + - "/build/xmrig-proxy" + schedule: + interval: "weekly" + groups: + docker: + patterns: ["*"] + ignore: + # Major/minor base bumps (e.g. python 3.11->3.14, ubuntu 24.04->26.04) are deliberate + # migrations, not security updates — keep Dependabot to digest + patch within the pinned tag. + - dependency-name: "*" + update-types: ["version-update:semver-major", "version-update:semver-minor"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a46996f3..9e4e0023 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,34 +2,53 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: +# Least privilege (#282): every job here only reads the repo — none push commits, comment, or +# publish packages. Narrowing the default GITHUB_TOKEN limits the blast radius of a compromised step. +permissions: + contents: read + jobs: dashboard: name: Dashboard tests (pytest + coverage) runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never # use the setup-python 3.11; don't fetch another interpreter steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 # full history so diff-cover can diff the PR against origin/develop (#286) + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - - name: Install dashboard with test extras - run: pip install -e "build/dashboard[test]" - - name: Run pytest with coverage gate - working-directory: build/dashboard - run: python -m pytest --cov=mining_dashboard --cov-report=term-missing --cov-fail-under=80 + - name: "Install uv (pinned — reproducible, hash-locked installs, #283)" + # Curl the pinned installer (same posture as hadolint below); avoids a mutable-tag action. + run: | + export UV_INSTALL_DIR="$HOME/.local/bin" # deterministic install dir (runners may set CARGO_HOME) + curl -LsSf https://astral.sh/uv/0.10.10/install.sh | sh + echo "$UV_INSTALL_DIR" >> "$GITHUB_PATH" + - name: Dashboard tests + coverage gate (emits coverage.xml) + run: make test-dashboard + - name: "Patch-coverage gate — changed lines >=90% (diff-cover vs origin/develop, #286)" + run: | + git fetch --no-tags origin develop + make test-patch-coverage - name: Fake-daemon contract test (real clients vs controllable fakes) # Points the real Monero/Tari clients at the integration fakes and asserts they parse # every state (synced/syncing/down). Docker-free, so it runs on every PR (issue #54). - run: PYTHONPATH=build/dashboard python -m pytest tests/integration/fakes -q + run: make test-fakes frontend: name: Frontend logic tests (node --test) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "20" # Pure client logic (worker sort, tooltip formatting) lives in static/logic.mjs and is @@ -42,7 +61,9 @@ jobs: name: Dashboard image (Docker test stage) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Build the dashboard test stage (installs package + runs the suite in-container) run: docker build --target test ./build/dashboard @@ -59,38 +80,125 @@ jobs: matrix: service: [monero, p2pool, tor, xmrig-proxy, dashboard] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: docker build ./build/${{ matrix.service }} run: docker build -t "pithead-${{ matrix.service }}:ci" "./build/${{ matrix.service }}" + - name: Scan image for CVEs (Trivy) + # Gate on actionable (fixable) HIGH/CRITICAL only; accepted findings live in .trivyignore. + # Must stay green before v1.1 images publish (#282). + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: pithead-${{ matrix.service }}:ci + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: "1" + trivyignores: .trivyignore hadolint: name: Dockerfile lint (hadolint) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Install hadolint + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: "Install hadolint (pinned + sha256-verified, #286)" run: | - sudo curl -fsSL -o /usr/local/bin/hadolint \ + curl -fsSL -o /tmp/hadolint \ https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64 - sudo chmod +x /usr/local/bin/hadolint + echo "56de6d5e5ec427e17b74fa48d51271c7fc0d61244bf5c90e828aab8362d55010 /tmp/hadolint" | sha256sum -c - + sudo install -m 0755 /tmp/hadolint /usr/local/bin/hadolint - name: Lint all build/* Dockerfiles (config in .hadolint.yaml) run: hadolint build/*/Dockerfile + python-lint: + name: Python lint + format (ruff) + runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" + - name: Install uv (pinned) + run: | + export UV_INSTALL_DIR="$HOME/.local/bin" # deterministic install dir (runners may set CARGO_HOME) + curl -LsSf https://astral.sh/uv/0.10.10/install.sh | sh + echo "$UV_INSTALL_DIR" >> "$GITHUB_PATH" + - name: ruff check + format --check (config in build/dashboard/pyproject.toml + root ruff.toml) + # Single source of truth: the Makefile `lint-py` target, which runs ruff via uv from the + # locked `dev` extra — one pinned ruff for CI, pre-commit, and local devs. + run: make lint-py + + gitleaks: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 # full history so the scan covers every commit, not just the tip + - name: Scan git history for secrets (gitleaks, pinned by digest) + # Run the binary via its pinned image (the gitleaks-action requires a license for org repos). + # Accepted false positives live in .gitleaks.toml. + run: | + docker run --rm -v "$PWD":/repo \ + ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \ + git /repo --no-banner --redact --config /repo/.gitleaks.toml + + zizmor: + name: Workflow audit (zizmor) + runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" + - name: Install uv (pinned) + run: | + export UV_INSTALL_DIR="$HOME/.local/bin" + curl -LsSf https://astral.sh/uv/0.10.10/install.sh | sh + echo "$UV_INSTALL_DIR" >> "$GITHUB_PATH" + - name: Audit workflows (injection, token scope, self-hosted-runner risks) + run: uvx zizmor@1.25.2 --offline .github/workflows/ + shell: name: Shell tests (shellcheck + pithead suite) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - # shellcheck is preinstalled on ubuntu-* runners, so we invoke it directly. - # Avoid `apt-get update`, which refreshes every configured source (incl. - # unrelated third-party mirrors like dl.google.com) and intermittently fails - # the job when one is briefly out of sync — see issue #64. - - name: Lint pithead, build/* container scripts, and test scripts - # Single source of truth: the Makefile `lint` target (so the file list can't drift between - # here and `make lint`). Now also covers build/*/*.sh — the entrypoints + healthchecks that + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: "Install shellcheck + shfmt (pinned + sha256-verified, #286)" + # Direct upstream-release downloads with a sha256 check (RigForge convention) instead of the + # runner's preinstalled shellcheck / apt — reproducible, and immune to the apt-mirror + # flakiness of #64. Keep these pins in sync with the local-dev tooling. + run: | + curl -fsSL -o /tmp/shfmt \ + https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_linux_amd64 + echo "fb096c5d1ac6beabbdbaa2874d025badb03ee07929f0c9ff67563ce8c75398b1 /tmp/shfmt" | sha256sum -c - + sudo install -m 0755 /tmp/shfmt /usr/local/bin/shfmt + curl -fsSL -o /tmp/sc.tar.xz \ + https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz + echo "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 /tmp/sc.tar.xz" | sha256sum -c - + tar -xJf /tmp/sc.tar.xz -C /tmp + sudo install -m 0755 /tmp/shellcheck-v0.11.0/shellcheck /usr/local/bin/shellcheck + - name: Lint pithead, build/* container scripts, and test scripts (shellcheck + shfmt) + # Single source of truth: the Makefile `lint-sh` target (so the file list can't drift + # between here and the Makefile). Covers build/*/*.sh — the entrypoints + healthchecks that # run in every container (#124). Gate on warnings+errors; info-level style nits vary by - # shellcheck version. - run: make lint + # shellcheck version. Python lint runs in its own `python-lint` job (ruff isn't on this + # runner; `make lint` runs both for local devs). + run: make lint-sh - name: Run pithead test suite run: bash tests/stack/run.sh - name: Run integration harness self-test @@ -107,6 +215,32 @@ jobs: name: Compose config + security hardening runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Validate docker-compose.yml interpolation + hardening invariants (#90) run: bash tests/stack/test_compose.sh + + lint-surfaces: + name: Lint per-surface (biome, yaml, markdown, proto, toml) + runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "20" + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" + - name: Install uv (pinned) + run: | + export UV_INSTALL_DIR="$HOME/.local/bin" + curl -LsSf https://astral.sh/uv/0.10.10/install.sh | sh + echo "$UV_INSTALL_DIR" >> "$GITHUB_PATH" + - name: Lint JS/CSS (Biome), YAML, Markdown, proto (buf), TOML (taplo) + # Single source of truth: the Makefile targets. Tools run via npx/uvx/docker (preinstalled). + run: make lint-js lint-yaml lint-md lint-proto lint-toml diff --git a/.github/workflows/integration-mini-stack.yml b/.github/workflows/integration-mini-stack.yml index 958a41c3..ed5f8aef 100644 --- a/.github/workflows/integration-mini-stack.yml +++ b/.github/workflows/integration-mini-stack.yml @@ -13,12 +13,17 @@ on: - "build/dashboard/**" - ".github/workflows/integration-mini-stack.yml" +permissions: + contents: read + jobs: mini-stack: name: Fake-daemon mini-stack (docker) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false # ubuntu-latest ships Docker with the Compose v2 plugin — no setup needed. - name: Run the fake-daemon mini-stack run: bash tests/integration/mini-stack/run-mini-stack.sh diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml new file mode 100644 index 00000000..e8adeada --- /dev/null +++ b/.github/workflows/lychee.yml @@ -0,0 +1,27 @@ +name: Link check (lychee) + +# Scheduled (not per-PR) so flaky external links never redden a PR (#281). The run goes red in the +# Actions tab if a link breaks; trigger on demand with workflow_dispatch. Ignored URLs: .lycheeignore. +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + lychee: + name: Check Markdown links + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Check links in Markdown + uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0 + with: + args: "--no-progress './**/*.md'" + fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # higher github.com rate limit for repo links diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 421a2e76..40282ebd 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -32,14 +32,26 @@ concurrency: group: release-gate cancel-in-progress: false +# Least privilege (#282): this runs on a self-hosted box holding real keys — keep the token read-only. +permissions: + contents: read + jobs: release-gate: name: Tier-4 live matrix (real nodes) + # Only run automatically once a self-hosted runner is actually wired up — set the repo variable + # ENABLE_RELEASE_GATE=true when one is registered. Without this guard, every push to main queues + # a job no runner can claim, and GitHub auto-cancels it after a 24h timeout — a permanent red ✗ + # on main. A skipped job is green/neutral instead. A manual workflow_dispatch ALWAYS runs, so a + # maintainer can still validate a reviewed ref on a registered runner on demand. + if: ${{ github.event_name == 'workflow_dispatch' || vars.ENABLE_RELEASE_GATE == 'true' }} # Register the server with these labels: `pithead-release` scopes the gate to the dedicated # box; prefer an ephemeral / just-in-time runner in its own runner group. runs-on: [self-hosted, pithead-release] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Validate against the real synced nodes # Inputs go through env (not interpolated into the script) to avoid shell injection. @@ -64,7 +76,7 @@ jobs: - name: Upload artifacts (redacted) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-gate-results path: tests/integration/results/ diff --git a/.gitignore b/.gitignore index 9c1ee478..8fa12d95 100644 --- a/.gitignore +++ b/.gitignore @@ -18,12 +18,15 @@ __pycache__/ *.pyc .pytest_cache/ .coverage +coverage.xml htmlcov/ *.egg-info/ .eggs/ +.venv/ +.ruff_cache/ # Integration test artifacts (manifest, per-scenario logs, captured state) /tests/integration/results/ # OS -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..f4bab3ea --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,13 @@ +# gitleaks secret-scan config (Wave 7 tooling, #282). Extends the upstream default ruleset. +[extend] +useDefault = true + +[allowlist] +description = "Accepted false positives" +regexTarget = "line" +# curl auth assembled from shell ENV VARS (e.g. `-u "${USER:-}:${PASS:-}"`) is not a hardcoded +# secret — the upstream `curl-auth-user` rule can't distinguish `${VAR}` from a literal credential. +# This pattern only matches env-var expansions, so a real `-u "admin:hunter2"` is still caught. +regexes = [ + '''-u "\$\{[A-Z_]+:-\}:\$\{[A-Z_]+:-\}"''', +] diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 00000000..4a4910b9 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,7 @@ +# Links lychee should skip (Wave 7 tooling, #281). One URL regex per line. +# Local/example/non-resolvable addresses that appear in docs as illustrations. +https?://127\.0\.0\.1.* +https?://localhost.* +https?://0\.0\.0\.0.* +https?://example\.(com|org).* +.*\.onion.* diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 00000000..2f90b554 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,21 @@ +// markdownlint-cli2 config (Wave 7 tooling, #281). Lints the repo's Markdown; the remaining +// rules (blanks around headings/lists/fences/tables, emphasis style, bare URLs, etc.) are mostly +// auto-fixable with `--fix`. +{ + "config": { + "MD013": false, // line-length: prose/tables/URLs run long — not worth gating + "MD033": false, // inline HTML: intentional in docs (
, ,
, badges) + "MD041": false, // first-line h1: some docs open with badges/intro before the heading + "MD040": false, // fenced-code-language: some blocks are plain console output + "MD028": false, // we place consecutive `>` admonition callouts intentionally + "MD001": false, // the README opens `# title` then an `###` tagline by design + "MD024": { "siblings_only": true } // Keep-a-Changelog repeats "Fixed"/"Added" under each version + }, + "globs": ["**/*.md"], + "ignores": [ + "**/node_modules/**", + "**/.venv/**", + "build/dashboard/mining_dashboard/client/tari/generated/**", + "docs/test-inventory.md" // generated by `make test-inventory`; not hand-edited + ] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..34e95778 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,84 @@ +# Pre-commit hooks (Wave 7 tooling, #280). Local == CI: these mirror `make lint` + the CI lint +# jobs, so issues are caught before they reach a PR. Set up once with: +# uv sync --project build/dashboard --extra dev && uv run --project build/dashboard pre-commit install +# Later Wave 7 children add more hooks here (#281 shfmt/Biome/yamllint/markdownlint). Keep the +# ruff `rev` in lockstep with the `dev` extra's ruff pin in build/dashboard/pyproject.toml. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.17 + hooks: + # Lints all repo Python; ruff discovers the right config per file (the dashboard package's + # pyproject.toml, or the root ruff.toml for the integration-test fakes). + - id: ruff-check + args: [--fix] + - id: ruff-format + + # Secret scanning (#282) — stop a key reaching history in the first place. Uses .gitleaks.toml. + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + # Cheap, high-value guards across every surface. + - id: detect-private-key # never commit an onion/wallet/SSH private key + - id: check-added-large-files + # Skip generated gRPC stubs and vendored/minified third-party assets — not ours to reformat. + - id: end-of-file-fixer + exclude: '^build/dashboard/mining_dashboard/(client/tari/generated/|web/static/vendor/)' + - id: trailing-whitespace + # Also skip Markdown — two trailing spaces are a hard line break (see .editorconfig). + exclude: '(^build/dashboard/mining_dashboard/(client/tari/generated/|web/static/vendor/)|\.md$)' + + # Per-surface lint/format (#281). These delegate to the Makefile targets so tool versions and file + # lists stay single-sourced (no drift between local, pre-commit, and CI). They need the relevant + # tools available (shellcheck, shfmt, node/npx, uv, docker) — see CONTRIBUTING.md. + - repo: local + hooks: + - id: lint-sh + name: shellcheck + shfmt + entry: make lint-sh + language: system + types_or: [shell] + pass_filenames: false + - id: lint-js + name: biome (static frontend) + entry: make lint-js + language: system + files: ^build/dashboard/mining_dashboard/web/static/.*\.(js|mjs|css)$ + pass_filenames: false + - id: lint-yaml + name: yamllint + entry: make lint-yaml + language: system + types: [yaml] + pass_filenames: false + - id: lint-md + name: markdownlint + entry: make lint-md + language: system + types: [markdown] + pass_filenames: false + - id: lint-proto + name: buf (proto) + entry: make lint-proto + language: system + types: [proto] + pass_filenames: false + - id: lint-toml + name: taplo (toml) + entry: make lint-toml + language: system + types: [toml] + pass_filenames: false + # Keep docs/test-inventory.md in lockstep with the suites (#54). The CI shell job already + # FAILS on drift (test-inventory-check); this hook regenerates it locally so a test add/remove + # never reaches CI stale — same auto-fix UX as ruff-format. Static grep, no test run, so cheap. + - id: test-inventory + name: test inventory (regenerate docs/test-inventory.md) + entry: make test-inventory + language: system + files: '^(build/dashboard/tests/|tests/(stack|integration)/|tests/inventory\.sh)' + pass_filenames: false diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 00000000..3bb24e96 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,2 @@ +# taplo (TOML formatter) config (Wave 7 tooling, #281). +exclude = ["**/uv.lock", "**/.venv/**", "**/node_modules/**"] diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 00000000..cd3c7dc8 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,14 @@ +# Trivy ignore file (Wave 7 tooling, #282). One finding ID per line. +# +# The image scan gates on ACTIONABLE (fixable) HIGH/CRITICAL only (`--ignore-unfixed`) — unfixed +# distro CVEs are reported but don't block. The entries below are the currently-accepted *fixable* +# findings, each with a rationale and how it clears. Revisit whenever Dependabot bumps a base image. + +# openssl: the fix is a base-distro point release (Debian deb13u2 / Ubuntu 3.0.13-0ubuntu3.11) not +# yet in our digest-pinned bases. Cleared when Dependabot (docker) bumps the base image digests. +CVE-2026-45447 + +# Base-image build tooling living in the python:3.11-slim *system* site-packages — NOT in the app's +# /app/.venv and never invoked at runtime (the entrypoint runs the venv's python). Cleared by a base bump. +CVE-2026-23949 +CVE-2026-24049 diff --git a/.yamllint b/.yamllint new file mode 100644 index 00000000..bdcce788 --- /dev/null +++ b/.yamllint @@ -0,0 +1,11 @@ +# yamllint config (Wave 7 tooling, #281). Extends the default ruleset; relaxes the rules that +# fight our house style while keeping the hygiene ones (trailing space, tabs, final newline, etc.). +extends: default +rules: + line-length: disable # config/comments/URLs run long — not worth gating or wrapping + document-start: disable # we don't use a leading `---` + truthy: + check-keys: false # allow GitHub Actions' `on:` key (otherwise flagged truthy) + comments: + min-spaces-from-content: 1 # we use `value # comment` with a single space in places + comments-indentation: disable diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8b1e6f..a7907fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,92 @@ Pithead ships as **one product, one version** — the version lives in the top-l [`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut per the process in [`docs/releasing.md`](docs/releasing.md). +## [1.1.0] - 2026-07-01 + +**Privacy release — the stack is now Tor-first by default and fail-closed.** Outbound P2Pool sidechain +peers (#165) and XvB donation mining (#166) route over Tor out of the box, a host firewall enforces +**Tor-only egress fail-closed** (#270) so a misconfiguration can't silently leak your IP, and every +container now runs **non-root** (#255/#91). Plus XvB raffle auto-registration, a Stack Topology panel, +and a large supply-chain / CI hardening wave. This is the first minor release on top of the 1.0.x line; +upgrade in place with `./pithead upgrade` (or re-download the bundle). + +**Upgrade note:** the Tor-by-default routing and the fail-closed egress firewall are picked up +automatically on `upgrade`. The two config example files were renamed for clarity — +**`config.minimal.json`** (the quick-start: just the two wallet addresses) and +**`config.reference.json`** (all options); your own `config.json` and preserved secrets (Tor onions, +RPC credentials, proxy token) are untouched. + +### Added + +- **Tor-by-default outbound routing.** P2Pool's outbound sidechain P2P (#165) and XvB donation mining + (#166) now ride Tor by default, closing the two clearnet yield paths that were left open in v1.0. + The local monerod RPC/ZMQ path stays **direct** via a loopback bridge so mining is unaffected (see + Fixed, #278). +- **Tor-only egress enforced fail-closed by a host firewall (#270).** A `DOCKER-USER` firewall installed + before the containers start (#276) blocks any non-Tor egress, so a bad flag or a stale image can't + leak your IP — it fails closed instead of leaking. The integration harness carries a **standing + no-clearnet-leak egress gate** (#274) so a regression is caught in CI. +- **XvB raffle auto-registration (#263).** Miners are registered with the XMRvsBeast raffle + automatically — no manual signup — so donations count toward the raffle without an extra step. +- **Stack Topology panel on the dashboard (#170).** A full wiring map of the stack: every ingress, + egress, and internal hop, labelled Tor vs clearnet, so you can see at a glance what talks to what + and over which transport. +- **One config-driven worker-API probe, default no-auth (#171, #172).** The dashboard's per-worker + stats probe is now a single, config-driven request that defaults to no authentication (matching + RigForge's open-by-default worker API), eliminating the spurious `401` log noise; auth is opt-in via + `workers.api_auth` / an access token. +- **Autonomous Tor-vs-clearnet benchmark harness (#256).** A self-driving benchmark that measures the + yield cost of running over Tor; the finalized methodology and results ship in `docs/privacy.md` + (Tor costs ~10% P2Pool yield on a mini node, with zero extra rejects — so Tor stays the default). +- **Third-party attribution + GPLv3 source pointers (#259).** The bundled upstream components + (P2Pool, Monero, XMRig-proxy, Tari, Caddy, …) are now attributed with license and source pointers. + +### Changed + +- **All containers run non-root (uid 1000) (#255, #91).** Every first-party image declares a non-root + `USER`; data moved from `/root` to the user's home, and `pithead` chowns the data dirs to match on + `apply`/`upgrade`/`restore`. The dashboard now owns its volume, so it can finally `cap_drop: [ALL]`. + The migration handles an install upgraded from the root-container era (root-owned files under a + user-owned dir are chowned to the container uid). +- **Config example files renamed** to `config.minimal.json` and `config.reference.json` (#326) — clearer + than the old names; the quick start and `setup` guidance point at the minimal one. +- **Reproducible Python builds** with `uv` + a committed `uv.lock` (#283). + +### Fixed + +- **Tari merge-mining works under Tor (#313).** The Tor default silently killed Tari merge-mining — + p2pool dialled the merge-mine gRPC at a private Docker IP through Tor, which rejects RFC1918, so the + channel stuck at `TRANSIENT_FAILURE`. Fixed with a loopback bridge (mirroring #278), and the + dashboard "✔" is now gated on the channel actually being `READY`, not just configured. +- **P2Pool ↔ monerod stays direct under Tor (#278).** #165's `--socks5` also proxied p2pool's *local* + monerod RPC/ZMQ, which p2pool only exempts for loopback — so with Tor on, p2pool couldn't fetch block + templates and mining stopped. The entrypoint now bridges `127.0.0.1` → the real node with `socat`, so + the node stays direct while the sidechain still rides Tor. +- **Fail loud when a stale p2pool image drops the Tor flags (#273).** A compose↔image mismatch that + would have silently disabled Tor routing now fails the start instead of running clearnet unnoticed. +- **Tor-egress firewall installed before compose on `upgrade` (#291)** and before containers start on + first bring-up (#276) — closing the startup window where a container could reach clearnet before the + firewall was in place. +- **Tari clearnet peer dials now route through Tor SOCKS (#271).** +- **XvB controller guarded against a stale/frozen stats fetch (#311).** A frozen XvB stats response no + longer steers the donation controller off stale data; the stale state is surfaced instead. +- **Snapshot persistence-health bug (#330).** + +### Security + +- **Supply-chain & secrets hardening (#282).** gitleaks secret-scanning, Trivy image/filesystem + scanning, Dependabot scoped to safe updates, all GitHub Actions SHA-pinned, and zizmor for workflow + auditing — with `develop` branch protection wiring the checks in as required gates. +- Non-root containers (#255) and Tor-only fail-closed egress (#270) are themselves the release's two + biggest security wins (see Changed / Added). + +### Internal + +- Repo-wide **ruff** lint + format (#280), per-surface linters (shfmt, Biome, yamllint, markdownlint, + buf, taplo) (#281), Hypothesis property tests over the money/numeric logic (#284), diff-cover patch + coverage adopted from RigForge (#286), and expanded live-validation coverage for the security + features (#206, #295, #170). Dev-facing only; no runtime change. + ## [1.0.3] - 2026-06-14 Hotfix — **validate the Monero payout address is a primary address**, so nobody silently mines to an @@ -465,7 +551,7 @@ cd pithead && cp config.json.template config.json # set your Monero + Tari pay node reports `target_height: 0` (no target), so the panel's `done` check — which compared `percent >= 100` against a target, and derived the state string from `has_target` first — never fired, leaving the *normal steady state* stuck at "loading" indefinitely (surfaced in the #180 - gouda validation; mining and worker-gating were unaffected — those use monerod's RPC flag + live validation; mining and worker-gating were unaffected — those use monerod's RPC flag directly). The sync state now trusts monerod's authoritative caught-up signal (`reachable && not is_syncing`), and the live integration harness asserts the panel reads "done" — closing the test gap that let this escape both the unit suite and the e2e matrix. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b801517a..c8badb72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,20 +1,36 @@ # Contributing to Pithead -Thanks for taking the time to contribute! Whether it's a bug fix, a docs tweak, or a -whole new feature, contributions are very welcome. This guide covers the workflow. +This guide covers the workflow for contributing bug fixes, docs changes, and features. ## Before you start - **Found a bug or have an idea?** Open an issue first. For anything beyond a small fix, - please discuss it in an issue before writing code — it saves everyone time and avoids + discuss it in an issue before writing code. It saves time and avoids surprises at review. - Check the [open issues](https://github.com/p2pool-starter-stack/pithead/issues) to see if someone's already on it. +## Dev environment + +The dashboard uses [uv](https://docs.astral.sh/uv/) for dependency management; a hashed +`uv.lock` pins every transitive dependency so installs are reproducible build-to-build. Its +Python tooling ([`ruff`](https://docs.astral.sh/ruff/) lint + format, +[`pre-commit`](https://pre-commit.com/)) lives in the `dev` extra. Install uv, then from the +repo root: + +```bash +uv sync --project build/dashboard --extra dev # deps + tooling into build/dashboard/.venv, from the lock +uv run --project build/dashboard pre-commit install +``` + +`make test` and `make lint-py` run through uv automatically (no venv to activate); `pre-commit` +then runs `ruff` (plus a few hygiene hooks) on your changed files. If you change dependencies in +`build/dashboard/pyproject.toml`, run `uv lock` and commit the updated `uv.lock`. + ## Development workflow 1. Fork the repo and create a branch off `main`. -2. Make your change. Keep it focused — one logical change per PR. +2. Make your change. Keep it focused: one logical change per PR. 3. Run the full test suite locally: ```bash @@ -23,9 +39,15 @@ whole new feature, contributions are very welcome. This guide covers the workflo This runs everything CI does without a server or Docker: - - **lint** — `shellcheck` over `pithead` and the test scripts (keep them - `--severity=warning` clean). - - **test-dashboard** — the dashboard `pytest` suite (must stay ≥ the **80% coverage gate**). + - **lint** — every file surface gets a linter/formatter check (`make lint` runs them all; run one + with `make lint-`): `lint-sh` (shellcheck + shfmt), `lint-py` (ruff), `lint-js` (Biome), + `lint-yaml` (yamllint), `lint-md` (markdownlint), `lint-proto` (buf), `lint-toml` (taplo). The + non-Python tools run via `npx`/`uvx`/`docker`, so a contributor needs **Node, uv, and Docker** + on PATH (plus `shfmt`); `pre-commit` runs the same checks on changed files. Link-checking + (`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. - **test-stack** — the `pithead` shell test suite. - **test-compose** — `docker-compose.yml` interpolation validation. - **test-integration-selftest** — the integration harness's own pure logic. @@ -34,9 +56,9 @@ whole new feature, contributions are very welcome. This guide covers the workflo regenerating [`docs/test-inventory.md`](docs/test-inventory.md) (`make test-inventory`). Bigger, infra-dependent suites run separately: `make test-mini-stack` (tier-3 docker) and - `make test-integration` (tier-4 live, against a real box — start with `--check`). + `make test-integration` (tier-4 live, against a real box; start with `--check`). -4. **Add or update tests** for your change — cover the *intent* (a behavior/contract), not just +4. **Add or update tests** for your change. Cover the *intent* (a behavior/contract), not just the line. The [Testing Guide](docs/testing-guide.md) has per-change recipes; the [Testing Strategy](docs/testing-strategy.md) explains the tiers. 5. Update the docs in [`docs/`](docs/) (and the README, if relevant) for any @@ -46,14 +68,16 @@ whole new feature, contributions are very welcome. This guide covers the workflo - Target the `main` branch and fill out the PR template. - Link the issue your PR addresses (e.g. `Closes #123`). -- Make sure `make test` passes — CI will run the same checks. +- Make sure `make test` passes; CI runs the same checks. - PRs require review before merging; the right reviewers are requested automatically via [CODEOWNERS](.github/CODEOWNERS). ## Style -- Match the surrounding code. Shell scripts should pass `shellcheck --severity=warning`. +- Match the surrounding code. Shell scripts should pass `shellcheck --severity=warning`; + Python is linted and formatted by `ruff` (config in `build/dashboard/pyproject.toml`). + Run `make lint-py`, or `cd build/dashboard && ruff format` to apply it. - Keep commits tidy and messages descriptive. By contributing, you agree that your contributions are licensed under the project's -[MIT License](LICENSE). Thanks again! 🙌 +[MIT License](LICENSE). diff --git a/LICENSE b/LICENSE index 90bb9303..cb5cd778 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,9 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +------------------------------------------------------------------------------- + +The MIT license above applies to this project's own code. Bundled third-party +components — including the GPLv3 `p2pool` and `xmrig-proxy` binaries in the +published images — retain their own licenses. See THIRD_PARTY_LICENSES.md. diff --git a/Makefile b/Makefile index 705cc487..1259a7b2 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,15 @@ # Local test entry points (mirror the GitHub Actions CI jobs). -.PHONY: test test-dashboard test-stack test-compose test-integration test-integration-selftest test-fakes test-mini-stack lint release +.PHONY: test test-dashboard test-patch-coverage test-stack test-compose test-integration test-integration-selftest test-fakes test-mini-stack lint lint-sh lint-py lint-js lint-yaml lint-md lint-proto lint-toml release test: lint test-dashboard test-stack test-compose test-integration-selftest test-fakes ## Run everything that doesn't need a server/docker -test-dashboard: ## Dashboard unit/component tests with coverage gate - cd build/dashboard && PYTHONPATH=. python3 -m pytest \ - --cov=mining_dashboard --cov-report=term-missing --cov-fail-under=80 +test-dashboard: ## Dashboard unit/component tests with coverage gate (deps from uv.lock); emits coverage.xml + cd build/dashboard && uv run --locked --extra test python -m pytest \ + --cov=mining_dashboard --cov-report=term-missing --cov-report=xml --cov-fail-under=80 + +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/develop --fail-under=90 test-stack: ## pithead shell test suite bash tests/stack/run.sh @@ -17,7 +21,7 @@ test-integration-selftest: ## Integration harness pure-logic self-test (no serve bash tests/integration/selftest.sh test-fakes: ## Fake-daemon contract test — real dashboard clients vs controllable fakes (no docker) - PYTHONPATH=build/dashboard python3 -m pytest tests/integration/fakes -q + uv run --locked --project build/dashboard --extra test python -m pytest tests/integration/fakes -q test-mini-stack: ## Fake-daemon docker mini-stack end-to-end (needs docker; CI) bash tests/integration/mini-stack/run-mini-stack.sh @@ -37,11 +41,35 @@ test-inventory-check: ## Fail if docs/test-inventory.md is stale (CI drift guard test-integration: ## Run the live config-matrix integration suite (requires a test box; pass ARGS=...) bash tests/integration/run.sh $(ARGS) -lint: ## shellcheck the CLI, the build/* container scripts, the release script, and the test scripts +lint: lint-sh lint-py lint-js lint-yaml lint-md lint-proto lint-toml ## Lint/format-check every surface + +lint-sh: ## shellcheck + shfmt over the CLI, build/* container scripts, release + test scripts shellcheck --severity=warning pithead scripts/*.sh build/*/*.sh tests/stack/run.sh tests/stack/test_compose.sh \ tests/inventory.sh tests/integration/*.sh tests/integration/mini-stack/*.sh + shfmt -i 4 -d pithead $(shell git ls-files '*.sh') + +lint-py: ## ruff lint + format check on all repo Python (ruff runs via uv from the locked dev extra) + uv run --locked --project build/dashboard --extra dev ruff check . + uv run --locked --project build/dashboard --extra dev ruff format --check . + +lint-js: ## Biome lint + format check on the static frontend (config: biome.json) + npx --yes @biomejs/biome@2.5.0 check . + +lint-yaml: ## yamllint over all tracked YAML (config: .yamllint) + uvx yamllint $(shell git ls-files '*.yml' '*.yaml') + +lint-md: ## markdownlint over all Markdown (config: .markdownlint-cli2.jsonc) + npx --yes markdownlint-cli2@0.18.1 + +lint-proto: ## buf lint + build on the vendored Tari protos (config: .../tari/proto/buf.yaml) + cd build/dashboard/mining_dashboard/client/tari/proto && \ + docker run --rm -v "$$PWD":/workspace --workdir /workspace bufbuild/buf:1.71.0 lint && \ + docker run --rm -v "$$PWD":/workspace --workdir /workspace bufbuild/buf:1.71.0 build + +lint-toml: ## taplo TOML format check (config: .taplo.toml) + npx --yes @taplo/cli@0.7.0 fmt --check -# Cut a release from the private build/test server (gouda) — GHCR publish, gated on the test suite + +# Cut a release from the private build/test server — GHCR publish, gated on the test suite + # the #54 integration matrix (issue #44). Pass options through ARGS, e.g. a safe plan-only preview: # make release ARGS="--dry-run" # See docs/releasing.md. diff --git a/README.md b/README.md index 5177b27b..0f662d9f 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,16 @@ # Pithead -### Private Monero + Tari merge mining, the whole stack, in one command. +### Private Monero + Tari merge mining stack [![CI](https://github.com/p2pool-starter-stack/pithead/actions/workflows/ci.yml/badge.svg)](https://github.com/p2pool-starter-stack/pithead/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) ![Platform: Ubuntu 24.04](https://img.shields.io/badge/Platform-Ubuntu%2024.04-E95420?logo=ubuntu&logoColor=white) ![Tor](https://img.shields.io/badge/Networking-Tor--first-7D4698?logo=torproject&logoColor=white) -A professional-grade, containerized stack for running a private [Monero](https://www.getmonero.org/) -full node, [P2Pool](https://github.com/SChernykh/p2pool), and [Tari](https://www.tari.com/) merge -mining — engineered for **privacy**, **performance**, and **a setup you can finish before your -coffee gets cold**. +Pithead is a containerized stack for running a private [Monero](https://www.getmonero.org/) full +node, [P2Pool](https://github.com/SChernykh/p2pool), and [Tari](https://www.tari.com/) merge mining. +An interactive setup script takes you from clone to mining in a few minutes. @@ -25,28 +24,27 @@ coffee gets cold**. --- -## ✨ Why this stack? - -- ⛏️ **Zero-fee, decentralized payouts.** Mine Monero on [P2Pool](https://p2pool.io/) — no pool - operator, no fees, rewards paid straight to your own wallet — and every hash **merge-mines Tari - for free**: a second payout for zero extra power or config. -- 🧠 **Set-and-forget yield optimizer.** An algorithmic engine watches the XMRvsBeast raffle and - automatically shifts hashrate to grab bonus rounds — donating only the **minimum** needed to hold - your tier, then handing every spare cycle back to your own P2Pool payouts. No manual tuning, no - over-donating. -- 🧅 **Tor-first, no port forwarding.** A built-in Tor daemon gives Monero, Tari, and P2Pool - hidden-service (onion) addresses, so **your router stays closed and your home IP is never - advertised to an inbound peer**. (Two outbound yield paths still touch clearnet in v1.0 — the - [privacy guide](docs/privacy.md) maps every connection and how to harden it today.) -- 🔌 **One endpoint for every rig.** Point all your workers at a single address — no wallet in the - miner, no per-rig pool config, ever. The stack routes the hashrate for you. -- 📊 **A dashboard worth leaving open.** Watch live hashrate, your P2Pool/XvB split shading in real - time, the PPLNS window, and every worker update — served over HTTPS on your LAN. -- 🚀 **One-command setup.** An interactive script handles dependencies, config, Tor, and — on Linux — - RandomX kernel tuning (it asks before touching GRUB), then offers to start everything for you. -- 🔒 **Hardened out of the box.** Least-privilege containers, SHA256-verified binaries, pinned - versions, localhost-only RPC, and least-privilege Docker socket proxies (a read-only one for - stats, plus a separate start/stop-only one for node-down worker failover). +## What it does + +- ⛏️ **Zero-fee, decentralized payouts.** Mines Monero on [P2Pool](https://p2pool.io/): no pool + operator, no fees, rewards paid straight to your own wallet. Every hash also merge-mines Tari at + no extra power or config cost. +- 🧠 **XvB yield optimizer.** An algorithmic engine watches the XMRvsBeast raffle and shifts + hashrate to catch bonus rounds. It donates only the minimum needed to hold your tier and returns + every spare cycle to your own P2Pool payouts. +- 🧅 **Tor-first networking.** A built-in Tor daemon gives Monero, Tari, and P2Pool onion addresses, + so your router stays closed and your home IP is never advertised to an inbound peer. Two outbound + yield paths still touch clearnet in v1.0; the [privacy guide](docs/privacy.md) maps every + connection and how to harden it. +- 🔌 **One endpoint for every rig.** Point all your workers at a single address. Wallets and per-rig + pool config stay out of the miner; the stack routes the hashrate. +- 📊 **Live dashboard.** Shows hashrate, your P2Pool/XvB split, the PPLNS window, and every worker + update, served over HTTPS on your LAN. +- 🚀 **Interactive setup.** A script handles dependencies, config, Tor, and (on Linux) RandomX + kernel tuning. It asks before touching GRUB, then offers to start the stack. +- 🔒 **Hardened defaults.** Least-privilege containers, SHA256-verified binaries, pinned versions, + localhost-only RPC, and least-privilege Docker socket proxies (a read-only one for stats, plus a + separate start/stop-only one for node-down worker failover). --- @@ -56,25 +54,25 @@ coffee gets cold**. # Grab the latest release — pulls the published, tested images (no local build) curl -fsSL https://github.com/p2pool-starter-stack/pithead/releases/latest/download/pithead.tar.gz | tar xz cd pithead -cp config.json.template config.json # then set your Monero + Tari payout addresses +cp config.minimal.json config.json # then set your Monero + Tari payout addresses ./pithead setup ``` -> Want every tunable? Copy `config.advanced.example.json` instead. Prefer to build from source (a -> `dev` build) — e.g. to contribute? See [Install from source](docs/getting-started.md#alternative-build-from-source). +> For every tunable, copy `config.reference.json` instead. To build from source (a `dev` +> build), e.g. to contribute, see [Install from source](docs/getting-started.md#alternative-build-from-source). -> **Prereqs:** Ubuntu Server **24.04 LTS**, **16 GB+ RAM**, an **SSD** (~300 GB pruned / ~500 GB -> full minimum — the chains grow ~100+ GB/year, so 2–4 TB is the set-and-forget choice), and your -> **Monero + Tari payout addresses** handy — full sizing in [Hardware Requirements](docs/hardware.md). +> NOTE: Prereqs are Ubuntu Server 24.04 LTS, 16 GB+ RAM, an SSD (~300 GB pruned / ~500 GB full +> minimum; the chains grow ~100+ GB/year, so 2–4 TB avoids a later resize), and your Monero + Tari +> payout addresses. Full sizing in [Hardware Requirements](docs/hardware.md). `setup` checks dependencies (and offers to install them on Ubuntu), asks for your wallet addresses, provisions Tor, tunes the kernel for RandomX, and offers to start the stack. Then: -1. **Open the dashboard** at `https://` (the script prints the exact URL). -2. **Let it sync.** On first boot the dashboard shows **Sync Mode** while your Monero and Tari - nodes catch up to the network — it switches to the live view automatically once synced. p2pool - and the proxy stay parked until then, so the sync logs stay clean. -3. **Connect your miners** by pointing any [XMRig](https://github.com/xmrig/xmrig) rig at +1. Open the dashboard at `https://` (the script prints the exact URL). +2. Let it sync. On first boot the dashboard shows Sync Mode while your Monero and Tari nodes catch + up to the network, then switches to the live view automatically once synced. p2pool and the + proxy stay parked until then, so the sync logs stay clean. +3. Connect your miners by pointing any [XMRig](https://github.com/xmrig/xmrig) rig at `YOUR_STACK_IP:3333` (no wallet address needed). New to mining? [RigForge](https://github.com/p2pool-starter-stack/rigforge) provisions a tuned worker in one command. @@ -83,10 +81,10 @@ addresses, provisions Tor, tunes the kernel for RandomX, and offers to start the Pithead — live mining dashboard tour -📖 **Full walkthrough:** [docs/getting-started.md](docs/getting-started.md) +Full walkthrough: [docs/getting-started.md](docs/getting-started.md) -> **Already have a synced Monero node?** Skip the wait by pointing the stack at your existing -> blockchain — see [Reusing an existing node](docs/configuration.md#reusing-an-existing-node). +> NOTE: Already have a synced Monero node? Point the stack at your existing blockchain to skip the +> wait. See [Reusing an existing node](docs/configuration.md#reusing-an-existing-node). --- @@ -95,12 +93,12 @@ addresses, provisions Tor, tunes the kernel for RandomX, and offers to start the | Guide | What's inside | |---|---| | **[Getting Started](docs/getting-started.md)** | Prerequisites, install, first-run setup, and what to expect while the node syncs. | -| **[Hardware Requirements](docs/hardware.md)** | Minimum vs. recommended specs for the **stack host** — CPU, RAM, disk, network — and how to run leaner. (Miner specs live in [RigForge](https://github.com/p2pool-starter-stack/rigforge).) | +| **[Hardware Requirements](docs/hardware.md)** | Minimum vs. recommended specs for the stack host (CPU, RAM, disk, network), and how to run leaner. (Miner specs live in [RigForge](https://github.com/p2pool-starter-stack/rigforge).) | | **[Configuration](docs/configuration.md)** | Every `config.json` key, applying changes safely, reusing an existing node, and remote Monero nodes. | | **[The Dashboard](docs/dashboard.md)** | Sync Mode and a tour of the live operational view. | | **[Connecting Miners](docs/workers.md)** | Point any existing rig at the stack, or spin up a tuned miner with [RigForge](https://github.com/p2pool-starter-stack/rigforge). | | **[Architecture](docs/architecture.md)** | The nine services, the privacy model, and the algorithmic XvB switching engine. | -| **[Privacy & Network Egress](docs/privacy.md)** | Every off-box connection — what's Tor-routed, what's clearnet today, and how to harden it. | +| **[Privacy & Network Egress](docs/privacy.md)** | Every off-box connection: what's Tor-routed, what's clearnet today, and how to harden it. | | **[Operations & Maintenance](docs/operations.md)** | Full command reference, upgrades, backups, and troubleshooting. | Browse the full index at **[docs/](docs/README.md)**. @@ -109,10 +107,10 @@ Browse the full index at **[docs/](docs/README.md)**. ## 🏗️ How it works -The stack orchestrates nine services via Docker Compose: a Monero **full node**, **P2Pool**, a -**Tari** base node, an **XMRig proxy** (your single worker endpoint), **Tor** for anonymity, the -**dashboard** + switching engine, a read-only **Docker socket proxy** (plus a tiny start/stop-only -control proxy), and **Caddy** for HTTPS. +The stack orchestrates nine services via Docker Compose: a Monero full node, P2Pool, a Tari base +node, an XMRig proxy (your single worker endpoint), Tor for anonymity, the dashboard plus switching +engine, a read-only Docker socket proxy (plus a tiny start/stop-only control proxy), and Caddy for +HTTPS. ```mermaid flowchart TB @@ -172,8 +170,8 @@ flowchart TB style core stroke:#10b981,stroke-width:1px,stroke-dasharray:5 4; ``` -Read the full breakdown — including the privacy model and the algorithmic switching engine — in -**[Architecture](docs/architecture.md)**. +Read the full breakdown, including the privacy model and the algorithmic switching engine, in +[Architecture](docs/architecture.md). --- @@ -199,8 +197,7 @@ Full reference: **[Operations & Maintenance](docs/operations.md)**. ## 🤝 Donate -If this stack saved you time and you'd like to support it, donations to this XMR wallet are -appreciated: +If this stack saved you time, donations to this XMR wallet are appreciated: ``` 486aGn4qhH1MkaASjnEWMDN7stD1SVtPF5fvihmjffeBE5ACL1u1jU95KxiqmoiaPZMexi4R4W11MLXut66XWVVF8wjAE5R @@ -208,4 +205,7 @@ appreciated: ## 📄 License -Provided "as-is" under the [MIT License](./LICENSE). +Pithead's own code is provided "as-is" under the [MIT License](./LICENSE). Bundled +third-party components keep their own licenses (two, `p2pool` and `xmrig-proxy`, are GPLv3, +shipped unmodified as separate containers). See +[`THIRD_PARTY_LICENSES.md`](./THIRD_PARTY_LICENSES.md). diff --git a/SECURITY.md b/SECURITY.md index 31e08ff4..dbaf8db5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,13 +1,16 @@ # Security Policy +This is the security policy for Pithead: supported versions, how to report a vulnerability, +and the stack's default security posture. + Pithead runs a Monero full node, P2Pool, Tari merge mining, and a dashboard on your -hardware, and it handles wallet payout addresses. We take security seriously and -appreciate reports that help keep operators safe. +hardware, and it handles wallet payout addresses. We appreciate reports that help keep +operators safe. ## Supported versions -Security fixes land on the latest `main`. There are no long-lived release branches — -please make sure you're running an up-to-date checkout before reporting an issue. +Security fixes land on the latest `main`. There are no long-lived release branches. +Make sure you're running an up-to-date checkout before reporting an issue. | Version | Supported | |---------------|--------------------| @@ -33,10 +36,10 @@ We aim to acknowledge reports promptly and will keep you posted as we work on a ## Security posture -The stack is hardened by default: least-privilege containers (leaf services run with -`no-new-privileges` and — except the dashboard, which writes its history DB as root into a -user-owned volume — drop all Linux capabilities; the internet-facing and Docker-socket-facing -ones also use a read-only root filesystem), SHA256-verified and version-pinned binaries, +The stack is hardened by default: least-privilege containers (every service runs as a **non-root +user**, not uid 0; leaf services run with `no-new-privileges` and drop all Linux capabilities; the +internet-facing and Docker-socket-facing ones also use a read-only root filesystem), +SHA256-verified and version-pinned binaries, localhost-only RPC, a LAN-scoped (and narrowable) stratum port, scoped Docker socket proxies, -and Tor for all node networking. If you find a gap in any of these, that's exactly the kind of +and Tor for all node networking. If you find a gap in any of these, that's the kind of report we want. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 00000000..eb98be92 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,32 @@ +# Third-Party Licenses + +Pithead's own code is [MIT](./LICENSE). The images it builds and the files it +vendors also **redistribute** third-party components, which keep their own licenses: + +## Bundled binaries (in the published `pithead-*` images) + +Version-pinned, sha256-verified, **unmodified** upstream binaries (pin + hash in each +`build//Dockerfile`): + +| Binary | Version | License | Source | +|--------|---------|---------|--------| +| monerod | v0.18.5.0 | BSD-3-Clause | | +| p2pool | v4.16 | **GPL-3.0-or-later** | | +| xmrig-proxy | 6.26.0 | **GPL-3.0-or-later** | | +| tor | distro | BSD-3-Clause | | + +`p2pool` and `xmrig-proxy` are GPL-3.0, shipped **unmodified** as **separate +containers** (mere aggregation — no linking into Pithead's code). The GPLv3 text is at +; the **corresponding source** is the exact +upstream release linked above (matching the pinned version + sha256 in the Dockerfile). + +## Vendored / generated (in `pithead-dashboard`) + +- Frontend JS under `build/dashboard/mining_dashboard/web/static/`: **preact** 10.24.3 (MIT), + **htm** 3.1.1 (Apache-2.0), **chart.js** 4.4.6 (MIT), **chartjs-plugin-zoom** 2.2.0 (MIT), + **hammerjs** 2.0.8 (MIT) — see that dir's `vendor/README.md`. +- Tari gRPC `.proto` files + generated stubs under + `build/dashboard/mining_dashboard/client/tari/`: **BSD-3-Clause**, © The Tari Project. + +Base images (`ubuntu`/`alpine`/`python-slim`) and the dashboard's Python dependencies +(`build/dashboard/pyproject.toml`) carry their own, permissive licenses. diff --git a/VERSION b/VERSION index 21e8796a..9084fa2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.3 +1.1.0 diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..352bee01 --- /dev/null +++ b/biome.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "build/dashboard/mining_dashboard/web/static/**/*.{js,mjs,css}", + "!build/dashboard/mining_dashboard/web/static/vendor", + "!build/dashboard/mining_dashboard/web/static/chart.umd.min.js" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { "useTemplate": "off" }, + "complexity": { "useOptionalChain": "off", "noImportantStyles": "off" } + } + }, + "javascript": { + "formatter": { "quoteStyle": "double", "semicolons": "always" } + } +} diff --git a/build/dashboard/.python-version b/build/dashboard/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/build/dashboard/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/build/dashboard/Dockerfile b/build/dashboard/Dockerfile index b58a5971..2e3739f0 100644 --- a/build/dashboard/Dockerfile +++ b/build/dashboard/Dockerfile @@ -1,8 +1,19 @@ # ========================================================================== -# Shared base: system deps + package metadata + source. +# Shared base: system deps + package metadata + source. Deliberately has NO uv — +# the production image must not ship the build tool (a runtime image with uv pulls +# uv's own CVEs into image scans, #282). uv lives only in the build/test stages. # ========================================================================== # Pinned by digest (#135) so the python:3.11-slim tag can't be silently re-pointed. -FROM python:3.11-slim@sha256:a3ab0b966bc4e91546a033e22093cb840908979487a9fc0e6e38295747e49ac0 AS base +FROM python:3.11-slim@sha256:b27df5841f3355e9473f9a516d38a6783b6c8dfeacaf2d14a240f443b368ddb6 AS base + +# Run from a project venv on PATH (so entrypoint.sh's `python3` resolves to it); use the +# digest-pinned base interpreter (never let uv download a different Python); compile bytecode and +# copy (not hardlink) from the BuildKit cache mount used on the sync steps below. +ENV UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PATH="/app/.venv/bin:$PATH" # System dependencies. RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -12,25 +23,33 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -# Package metadata first (better layer caching), then the source. -COPY pyproject.toml ./ +# Lock + metadata first (better layer caching), then the source. +COPY pyproject.toml uv.lock ./ COPY mining_dashboard/ ./mining_dashboard/ # ========================================================================== -# Test stage: install with test extras and run the suite with coverage. +# Build stage: uv resolves the locked runtime deps into /app/.venv. uv is COPYed in +# here (and in test) but never into production. +# ========================================================================== +FROM base AS build +COPY --from=ghcr.io/astral-sh/uv:0.10.10@sha256:cbe0a44ba994e327b8fe7ed72beef1aaa7d2c4c795fd406d1dbf328bacb2f1c5 /uv /bin/ +RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked + +# ========================================================================== +# Test stage: add the test extras to the venv and run the suite with coverage. # Build explicitly with `docker build --target test .` (CI does this); # a normal `docker compose build` targets the production stage and skips it. # ========================================================================== -FROM base AS test -RUN pip install --no-cache-dir -e ".[test]" +FROM build AS test +RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --extra test COPY tests/ ./tests/ RUN python -m pytest --cov=mining_dashboard --cov-report=term-missing --cov-fail-under=80 # ========================================================================== -# Production stage: install the package (runtime deps only) and run it. +# Production stage: copy ONLY the resolved venv from `build` (no uv binary) and run it. # ========================================================================== FROM base AS production -RUN pip install --no-cache-dir -e . +COPY --from=build /app/.venv /app/.venv COPY entrypoint.sh . RUN chmod +x entrypoint.sh @@ -51,4 +70,16 @@ LABEL org.opencontainers.image.title="Pithead Dashboard" \ org.opencontainers.image.version=$PITHEAD_VERSION \ org.opencontainers.image.revision=$PITHEAD_GIT_COMMIT +# Run non-root (#255). debian-slim has no stock uid-1000 user, so create 'pithead' (uid:gid +# 1000:1000) to match the uid pithead chowns ${DASHBOARD_DATA_DIR} (/data) to. /app + the venv stay +# root-owned and are only read/executed at runtime; the SQLite history is written to /data and the +# clearnet-state marker to /clearnet-state. Create both in-image owned by pithead: with a host +# bind-mount (the real stack) the mount masks them and pithead's chown governs ownership; with a +# NAMED volume (the integration mini-stack) Docker seeds the empty volume from the image dir's +# ownership, so this is what makes /data writable there. This lets compose finally cap_drop:[ALL] +# the dashboard — it no longer needs root's CAP_DAC_OVERRIDE to write the host-user-owned data dir. +RUN groupadd -g 1000 pithead && useradd -u 1000 -g 1000 -m -s /usr/sbin/nologin pithead \ + && install -d -o pithead -g pithead /data /clearnet-state +USER pithead + ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"] diff --git a/build/dashboard/README.md b/build/dashboard/README.md index f74d5d32..665ac13a 100644 --- a/build/dashboard/README.md +++ b/build/dashboard/README.md @@ -1,35 +1,36 @@ # Mining Dashboard -The monitoring web UI and XvB switching engine for Pithead. It aggregates -stats from the local collectors, the XMRig proxy, and the Tari node, and serves a single-page -dashboard (behind Caddy) on `127.0.0.1:8000`. +The monitoring web UI and XvB switching engine for Pithead. It aggregates stats from the local +collectors, the XMRig proxy, and the Tari node, and serves a single-page dashboard (behind Caddy) +on `127.0.0.1:8000`. ## Architecture -The server is a **data API**; the browser renders the UI. There is no server-side templating: - -- **`GET /`** serves a tiny static HTML shell. -- **`GET /api/state?range=…`** returns the whole dashboard as JSON — the contract built by - `views.build_state`. Computed domain values (effective hashrate, P2Pool/XvB averages, XvB - tier qualification, shares-in-window, sync/down state) live in one typed place, - `service/metrics.py` → `build_metrics() -> Metrics`; the view layer **formats** those into - display strings + semantic tokens (`variant: "ok"`, `level: "high"`) and emits no HTML. The - same `Metrics` is meant to back future consumers (Telegram #45, calculator #12) without - re-deriving from the raw dict. -- The client (`static/dashboard.js` + `components.mjs`) is a small **Preact** app (rendered with - `htm` tagged templates — no JSX, no build step). It polls `/api/state` every 30s and renders +The server is a data API; the browser renders the UI. There is no server-side templating. + +- `GET /` serves a tiny static HTML shell. +- `GET /api/state?range=…` returns the whole dashboard as JSON, the contract built by + `views.build_state`. Computed domain values (effective hashrate, P2Pool/XvB averages, XvB tier + qualification, shares-in-window, sync/down state) live in one typed place, + `service/metrics.py` → `build_metrics() -> Metrics`. The view layer formats those into display + strings and semantic tokens (`variant: "ok"`, `level: "high"`) and emits no HTML. The same + `Metrics` is meant to back future consumers (Telegram #45, calculator #12) without re-deriving + from the raw dict. +- The client (`static/dashboard.js` + `components.mjs`) is a small Preact app, rendered with `htm` + tagged templates (no JSX, no build step). It polls `/api/state` every 30s and renders declaratively, so the chart instance, table sort, selected range, and the simple/advanced view all survive each refresh. UI state lives on the client; data lives on the server. -Everything is served from `/static` — the vendored Preact, htm and Chart.js (`static/vendor/`), -plus the app modules and `dashboard.css`. Nothing is inlined and the libraries are eval-free, so -the page runs under a strict Content-Security-Policy with no `'unsafe-inline'`/`'unsafe-eval'`. +Everything is served from `/static`: the vendored Preact, htm and Chart.js (`static/vendor/`), plus +the app modules and `dashboard.css`. Nothing is inlined and the libraries are eval-free, so the +page runs under a strict Content-Security-Policy with no `'unsafe-inline'`/`'unsafe-eval'`. -Testing: the Python API — where all the logic and formatting live — is fully unit-tested. The -pure client logic (worker sort, tooltip formatting, hero-KPI selection in `static/logic.mjs`) is unit-tested with -**Node's built-in runner** (`node --test build/dashboard/tests/frontend/*.test.mjs`) — no -`package.json`/`node_modules`/build step, so the repo stays Node-free. Component *rendering* has -no unit tests by design; it's covered by a manual browser smoke test. +Testing: the Python API, where all the logic and formatting live, is fully unit-tested. The pure +client logic (worker sort, tooltip formatting, hero-KPI selection in `static/logic.mjs`) is +unit-tested with Node's built-in runner +(`node --test build/dashboard/tests/frontend/*.test.mjs`) — no +`package.json`/`node_modules`/build step, so the repo stays Node-free. Component rendering has no +unit tests by design; it's covered by a manual browser smoke test. ## Layout @@ -47,15 +48,14 @@ mining_dashboard/ └── helper/ # formatting utilities ``` -It is a proper installable package (`pyproject.toml`): all internal imports are absolute -(`mining_dashboard.*`), and it runs as a module — no `PYTHONPATH` gymnastics. +It is an installable package (`pyproject.toml`): all internal imports are absolute +(`mining_dashboard.*`), and it runs as a module, with no `PYTHONPATH` gymnastics. ## Development ```bash -# from build/dashboard/ -python3 -m venv .venv && source .venv/bin/activate # Python 3.11+ -pip install -e ".[test]" +# from build/dashboard/ — uv creates .venv and installs from the hashed uv.lock (Python 3.11+) +uv sync --extra test ``` ## Tests @@ -74,13 +74,24 @@ Or from the repo root: `make test-dashboard`. The same Python suite runs in the docker build --target test ./build/dashboard ``` -Tests are hermetic — no network, no containers, no real database (an in-memory SQLite is used -via the `state_manager` fixture and the auto-applied DB-isolation fixture in `tests/conftest.py`). +Tests are hermetic: no network, no containers, no real database (an in-memory SQLite is used via +the `state_manager` fixture and the auto-applied DB-isolation fixture in `tests/conftest.py`). + +Two coverage ratchets back the suite: a total floor (`--cov-fail-under=80`) and a patch gate +(`make test-patch-coverage` → `diff-cover` ≥ 90% on changed lines, #286). The money/numeric logic +(earnings, the XvB controller, the donation simulator) also has property-based tests (`hypothesis`, #284) +asserting invariants — non-negativity, conservation, monotonicity, clamp bounds — across a +wide input range, the class of bug example tests miss (cf. the #70 overshoot). + +Typing roadmap (deferred): the app is lightly annotated today, so a static type-checker gate is +premature (and `ty` is still pre-1.0). The on-ramp — turning on ruff's `ANN` ruleset as a +non-blocking annotation ratchet, then adopting `ty`/`pyright` once coverage is meaningful and `ty` +reaches 1.0 — is a post-1.0 follow-up (#284), not a v1.1 blocker. ## Image The `Dockerfile` is multi-stage: -- `base` — system deps + package metadata + source. -- `test` — `pip install -e .[test]` then `pytest --cov-fail-under=80` (build with `--target test`). -- `production` — runtime install + entrypoint (the default `docker compose build` target). +- `base`: uv (digest-pinned) + system deps + lock/metadata + source. +- `test`: `uv sync --locked --extra test` then `pytest --cov-fail-under=80` (build with `--target test`). +- `production`: `uv sync --locked` (runtime deps only) + entrypoint (the default `docker compose build` target). diff --git a/build/dashboard/entrypoint.sh b/build/dashboard/entrypoint.sh index 9c37403b..8fb1003d 100644 --- a/build/dashboard/entrypoint.sh +++ b/build/dashboard/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -# We no longer dynamically fetch the LAN IP here because the dashboard +# We no longer dynamically fetch the LAN IP here because the dashboard # MUST be bound to localhost (127.0.0.1) to remain secure behind Caddy. # The HOST_IP variable is now injected directly via docker-compose.yml. @@ -12,4 +12,4 @@ export HOST_IP="${HOST_IP:-127.0.0.1}" # 'exec' replaces the shell process so SIGTERM is handled correctly; # '-u' forces unbuffered stdout/stderr for real-time Docker logging. cd /app -exec python3 -u -m mining_dashboard.main \ No newline at end of file +exec python3 -u -m mining_dashboard.main diff --git a/build/dashboard/mining_dashboard/client/docker/docker_control.py b/build/dashboard/mining_dashboard/client/docker/docker_control.py index 693ecce5..28e95139 100644 --- a/build/dashboard/mining_dashboard/client/docker/docker_control.py +++ b/build/dashboard/mining_dashboard/client/docker/docker_control.py @@ -1,6 +1,7 @@ -import aiohttp import logging +import aiohttp + from mining_dashboard.config.config import DOCKER_CONTROL_URL, DOCKER_TIMEOUT logger = logging.getLogger("DockerControl") @@ -39,22 +40,33 @@ async def stop(self, container, stop_timeout=10, quiet=False, request_timeout=No daemon needs an HTTP timeout that OUTLASTS `stop_timeout` or the call gives up early and wrongly reports failure (#234: Tari took >5s to stop, so the default timeout aborted it). """ - return await self._post(f"/containers/{container}/stop", params={"t": stop_timeout}, - action="stop", container=container, quiet=quiet, - request_timeout=request_timeout) + return await self._post( + f"/containers/{container}/stop", + params={"t": stop_timeout}, + action="stop", + container=container, + quiet=quiet, + request_timeout=request_timeout, + ) async def start(self, container, quiet=False, request_timeout=None): """Start a container. Returns True on success (incl. already-running).""" - return await self._post(f"/containers/{container}/start", params=None, - action="start", container=container, quiet=quiet, - request_timeout=request_timeout) + return await self._post( + f"/containers/{container}/start", + params=None, + action="start", + container=container, + quiet=quiet, + request_timeout=request_timeout, + ) async def _post(self, path, params, action, container, quiet=False, request_timeout=None): url = f"{self.base_url}{path}" try: async with aiohttp.ClientSession() as session: - async with session.post(url, params=params, - timeout=request_timeout or self.timeout) as resp: + async with session.post( + url, params=params, timeout=request_timeout or self.timeout + ) as resp: # 204 No Content = done; 304 Not Modified = already in that state # (Docker's idempotent response) — both are success for us. if resp.status in (204, 304): @@ -62,7 +74,9 @@ async def _post(self, path, params, action, container, quiet=False, request_time log(f"Container {container} {action}: ok (HTTP {resp.status})") return True body = await resp.text() - logger.error(f"Container {container} {action} failed: HTTP {resp.status} {body[:200]}") + logger.error( + f"Container {container} {action} failed: HTTP {resp.status} {body[:200]}" + ) return False except Exception as e: logger.error(f"Container {container} {action} error via {self.base_url}: {e}") diff --git a/build/dashboard/mining_dashboard/client/monero/monero_client.py b/build/dashboard/mining_dashboard/client/monero/monero_client.py index 3c05f79b..c4ead287 100644 --- a/build/dashboard/mining_dashboard/client/monero/monero_client.py +++ b/build/dashboard/mining_dashboard/client/monero/monero_client.py @@ -1,11 +1,12 @@ import logging + import requests from requests.auth import HTTPDigestAuth from mining_dashboard.config.config import ( - MONERO_RPC_URL, - MONERO_NODE_USERNAME, MONERO_NODE_PASSWORD, + MONERO_NODE_USERNAME, + MONERO_RPC_URL, ) logger = logging.getLogger("MoneroClient") @@ -25,8 +26,13 @@ class MoneroClient: via `asyncio.to_thread`, mirroring how XvbClient / proxy_client are used. """ - def __init__(self, url=MONERO_RPC_URL, username=MONERO_NODE_USERNAME, - password=MONERO_NODE_PASSWORD, timeout=5): + def __init__( + self, + url=MONERO_RPC_URL, + username=MONERO_NODE_USERNAME, + password=MONERO_NODE_PASSWORD, + timeout=5, + ): self.url = url.rstrip("/") + "/get_info" # No creds (e.g. a remote node deployment) → send unauthenticated; the request # will simply fail and the caller falls back to log scraping. @@ -88,5 +94,10 @@ def get_sync_status(self): return {"is_syncing": False, "db_size": db_size} percent = int((height / target) * 100) - return {"is_syncing": True, "current": height, "target": target, - "percent": percent, "db_size": db_size} + return { + "is_syncing": True, + "current": height, + "target": target, + "percent": percent, + "db_size": db_size, + } diff --git a/build/dashboard/mining_dashboard/client/tari/Readme.md b/build/dashboard/mining_dashboard/client/tari/Readme.md index c50a06ac..218beb8c 100644 --- a/build/dashboard/mining_dashboard/client/tari/Readme.md +++ b/build/dashboard/mining_dashboard/client/tari/Readme.md @@ -1,9 +1,12 @@ # Tari gRPC Collector +The Tari gRPC client and its generated protobuf stubs. + ## Generate Protobuf Files + Ensure `base_node.proto` and `types.proto` are in the `proto/` subdirectory, then run: ```bash -docker run --rm -v "$PWD":/work -w /work python:3.11-slim \ - /bin/bash -c "pip install grpcio-tools && python -m grpc_tools.protoc -Iproto --python_out=generated --grpc_python_out=generated proto/*.proto && sed -i 's/^import.*_pb2/from . \0/' generated/*_pb2*.py" -``` \ No newline at end of file +docker run --rm -v "$PWD":/work -w /work ghcr.io/astral-sh/uv:0.10.10-python3.11-trixie-slim \ + /bin/bash -c "uvx --from grpcio-tools python -m grpc_tools.protoc -Iproto --python_out=generated --grpc_python_out=generated proto/*.proto && sed -i 's/^import.*_pb2/from . \0/' generated/*_pb2*.py" +``` diff --git a/build/dashboard/mining_dashboard/client/tari/proto/buf.yaml b/build/dashboard/mining_dashboard/client/tari/proto/buf.yaml new file mode 100644 index 00000000..71d29986 --- /dev/null +++ b/build/dashboard/mining_dashboard/client/tari/proto/buf.yaml @@ -0,0 +1,13 @@ +# buf config (Wave 7 tooling, #281). These are vendored upstream Tari protos, so we lint with the +# MINIMAL ruleset (structural only — not the opinionated style rules we don't own) and except +# PACKAGE_DIRECTORY_MATCH (the protos declare `package tari.rpc` but are vendored flat into proto/). +# `buf build` still verifies they compile; `breaking: FILE` enables `buf breaking --against `. +version: v2 +lint: + use: + - MINIMAL + except: + - PACKAGE_DIRECTORY_MATCH +breaking: + use: + - FILE diff --git a/build/dashboard/mining_dashboard/client/tari/tari_client.py b/build/dashboard/mining_dashboard/client/tari/tari_client.py index 04316dc5..f34e5006 100644 --- a/build/dashboard/mining_dashboard/client/tari/tari_client.py +++ b/build/dashboard/mining_dashboard/client/tari/tari_client.py @@ -1,19 +1,19 @@ -import aiohttp import logging -import grpc -import os import time +import grpc + from mining_dashboard.config.config import TARI_GRPC_ADDRESS logger = logging.getLogger("TariClient") # Attempt to import generated protobuf modules # See README.md for generation instructions (requires grpcio-tools) -from .generated import base_node_pb2 -from .generated import base_node_pb2_grpc from google.protobuf import empty_pb2 +from .generated import base_node_pb2_grpc + + class TariClient: # When the base node is briefly overloaded mid-sync (it logs "BaseNodeService failed # to send reply ... ChainMetadata" and its own `status` command times out), gRPC calls @@ -22,8 +22,7 @@ class TariClient: # the proper "node is down" indicator is tracked separately in the TODO. _MAX_STALE_SECONDS = 300 - def __init__(self, session: aiohttp.ClientSession): - self.session = session + def __init__(self): self.grpc_address = TARI_GRPC_ADDRESS self._channel = None self._stub = None @@ -63,7 +62,10 @@ async def get_sync_status(self): # gRPC unreachable this cycle. Serve the last good state briefly (node is likely # just busy), but stop once it's clearly stale so a down node isn't masked forever. - if self._last_sync_status and (time.monotonic() - self._last_sync_ts) <= self._MAX_STALE_SECONDS: + if ( + self._last_sync_status + and (time.monotonic() - self._last_sync_ts) <= self._MAX_STALE_SECONDS + ): return {**self._last_sync_status, "reachable": False} return {"is_syncing": False, "reachable": False} @@ -88,8 +90,12 @@ async def _fetch_sync_status(self): # The node reports initial sync complete — trust it over any height heuristic. if tip.initial_sync_achieved: - return {"is_syncing": False, "current": local_height, - "target": local_height, "percent": 100} + return { + "is_syncing": False, + "current": local_height, + "target": local_height, + "percent": 100, + } # Still syncing: ask the node what height it is syncing toward. target = 0 @@ -111,6 +117,10 @@ async def _fetch_sync_status(self): return {"is_syncing": True, "current": local_height, "target": target, "percent": percent} async def close(self): + # ponytail: intentionally NOT wired into DataService.run()'s shutdown. Doing so means a + # try/finally around the whole poll loop, which drags the (partly untested) loop body into + # the diff-cover patch gate — a lot of churn to close a channel the OS reclaims on process + # exit anyway. Kept as a tested lifecycle method for whenever a real graceful path needs it. if self._channel: await self._channel.close() - self._channel = None \ No newline at end of file + self._channel = None diff --git a/build/dashboard/mining_dashboard/client/xmrig_client.py b/build/dashboard/mining_dashboard/client/xmrig_client.py index 0f8f9645..0613dadb 100644 --- a/build/dashboard/mining_dashboard/client/xmrig_client.py +++ b/build/dashboard/mining_dashboard/client/xmrig_client.py @@ -1,18 +1,23 @@ import ipaddress import logging +import time from mining_dashboard.config.config import ( - XMRIG_API_PORT, - PROXY_API_PORT, - PROXY_AUTH_TOKEN, API_TIMEOUT, MINING_NET_CIDR, + XMRIG_API_AUTH, + XMRIG_API_PORT, + XMRIG_API_TOKEN, ) # Longest worker-name we'll ever echo back as a Bearer token (#122). xmrig names/tokens are short; # this just bounds a pathological miner-supplied value before it goes into a header. _MAX_NAME_TOKEN = 128 +# Re-warn about a worker whose API keeps failing at most this often, so a misconfigured fleet logs +# one line per worker per interval — not one per poll (the data loop runs every ~30s). +_WARN_INTERVAL_S = 300 + try: _INTERNAL_NET = ipaddress.ip_network(MINING_NET_CIDR, strict=False) except ValueError: @@ -42,8 +47,13 @@ def _safe_probe_host(ip): addr = ipaddress.ip_address(host) except ValueError: return None # not a bare IP — never treat a worker name/hostname as a request host - if (addr.is_loopback or addr.is_link_local or addr.is_multicast - or addr.is_unspecified or addr.is_reserved): + if ( + addr.is_loopback + or addr.is_link_local + or addr.is_multicast + or addr.is_unspecified + or addr.is_reserved + ): return None if addr.version == _INTERNAL_NET.version and addr in _INTERNAL_NET: return None @@ -58,54 +68,92 @@ def __init__(self, session): """ self.session = session self.logger = logging.getLogger("WorkerClient") + # host -> monotonic timestamp of the last failure we logged, so a persistently-broken + # worker doesn't spam a WARNING every poll. The client outlives the poll loop, so this + # state survives across iterations. + self._warned = {} + + def _auth_header(self, name_token): + """Build the single Authorization header for the configured auth mode (or no header).""" + mode = XMRIG_API_AUTH + if mode == "name": + return {"Authorization": f"Bearer {name_token}"} if name_token else {} + if mode == "token": + return {"Authorization": f"Bearer {XMRIG_API_TOKEN}"} if XMRIG_API_TOKEN else {} + # "none" (default) and any unrecognized value -> open, unauthenticated API + return {} + + def _fix_hint(self): + """A short, actionable remedy tailored to the configured auth mode.""" + mode = XMRIG_API_AUTH + if mode == "name": + return ( + "expected the miner's xmrig access-token to equal its stratum name; verify that, " + f"or that the API is on XMRIG_API_PORT ({XMRIG_API_PORT})" + ) + if mode == "token": + return ( + "expected XMRIG_API_TOKEN to match the miner's xmrig access-token; verify that, " + f"or that the API is on XMRIG_API_PORT ({XMRIG_API_PORT})" + ) + return ( + "expected an open (http.restricted, no access-token) miner API; if this miner sets an " + "access-token, set XMRIG_API_AUTH=name (or =token with XMRIG_API_TOKEN), or check " + f"XMRIG_API_PORT ({XMRIG_API_PORT})" + ) + + def _warn(self, host, name, url, detail): + now = time.monotonic() + if now - self._warned.get(host, float("-inf")) < _WARN_INTERVAL_S: + return + self._warned[host] = now + self.logger.warning( + "Worker %r (%s): xmrig API probe failed at %s — %s. %s.", + name, + host, + url, + detail, + self._fix_hint(), + ) async def get_stats(self, ip, name): """ - Fetch /1/summary from a worker. Works for both XMRig miners and upstream - XMRig Proxy instances, trying the most likely credential first: - - 1. No auth on XMRIG_API_PORT — open xmrig-proxy (restricted=true, no token) - 2. PROXY_AUTH_TOKEN on PROXY_API_PORT — secured proxy on a non-standard port - 3. Name-derived token on XMRIG_API_PORT — direct XMRig miner (name = access token) + Fetch /1/summary from a worker's xmrig API — exactly ONE way, derived from config. - Callers use the returned 'kind' field ('proxy' vs 'miner') to handle any - unit differences (xmrig-proxy reports hashrate in kH/s; miner reports H/s). + The auth method is chosen by ``XMRIG_API_AUTH`` (``none`` default / ``name`` / ``token``); + the port by ``XMRIG_API_PORT``. There is no auto-detection fallback: if the configured probe + fails, we return ``{"api_ok": False}`` and log a single (rate-limited) WARNING with a fix + hint, rather than silently trying alternatives or swallowing the error. On success the parsed + summary is returned with ``api_ok`` set to ``True``. Only the worker's validated IP is ever used as the request host (SSRF guard, #122): a - miner-controlled worker *name* is never a host, it's only offered back to that same IP as - the "name = access token" Bearer for direct XMRig miners. + miner-controlled worker *name* is never a host — in ``name`` auth it is only offered back to + that same IP as the Bearer token. """ host = _safe_probe_host(ip) if host is None: - # No safe target: ip is missing/internal/not a bare address. Never fall back to the - # miner-controlled name as a host — that is the SSRF this guard exists to prevent (#122). + # No safe target: ip is missing/internal/not a bare address. This isn't a misconfigured + # miner — it's a worker we deliberately won't probe — so stay quiet and leave api_ok + # unset (unknown) rather than flagging a failure. Never fall back to the + # miner-controlled name as a host: that is the SSRF this guard exists to prevent (#122). return {} - name_token = name.split('+')[0].strip()[:_MAX_NAME_TOKEN] if name else "" - - attempts = [ - # 1. Open proxy — no auth header at all - (f"http://{host}:{XMRIG_API_PORT}/1/summary", {}), - ] - # 2. Secured proxy on a custom port (only if distinct from XMRIG_API_PORT) - if PROXY_AUTH_TOKEN and PROXY_API_PORT != XMRIG_API_PORT: - attempts.append(( - f"http://{host}:{PROXY_API_PORT}/1/summary", - {"Authorization": f"Bearer {PROXY_AUTH_TOKEN}"}, - )) - # 3. Direct XMRig miner: the name doubles as the access token, sent only to its own IP. - if name_token: - attempts.append(( - f"http://{host}:{XMRIG_API_PORT}/1/summary", - {"Authorization": f"Bearer {name_token}"}, - )) - - for url, headers in attempts: - try: - async with self.session.get(url, headers=headers, timeout=API_TIMEOUT) as response: - if response.status == 200: - return await response.json() - except Exception as e: - self.logger.debug(f"Worker API Error ({url}): {e}") - - return {} + name_token = name.split("+")[0].strip()[:_MAX_NAME_TOKEN] if name else "" + url = f"http://{host}:{XMRIG_API_PORT}/1/summary" + headers = self._auth_header(name_token) + + try: + async with self.session.get(url, headers=headers, timeout=API_TIMEOUT) as response: + if response.status == 200: + payload = await response.json() + if isinstance(payload, dict): + self._warned.pop(host, None) # recovered — allow the next failure to log + payload["api_ok"] = True + return payload + self._warn(host, name, url, f"HTTP 200 but body was {type(payload).__name__}") + return {"api_ok": False} + self._warn(host, name, url, f"HTTP {response.status}") + return {"api_ok": False} + except Exception as e: + self._warn(host, name, url, f"{type(e).__name__}: {e}") + return {"api_ok": False} diff --git a/build/dashboard/mining_dashboard/client/xmrig_proxy_client.py b/build/dashboard/mining_dashboard/client/xmrig_proxy_client.py index 7c4e4df0..2da5923e 100644 --- a/build/dashboard/mining_dashboard/client/xmrig_proxy_client.py +++ b/build/dashboard/mining_dashboard/client/xmrig_proxy_client.py @@ -1,32 +1,34 @@ -import requests import json import logging + +import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry + class XMRigProxyClient: def __init__(self, host="127.0.0.1", port=8080, access_token=None): """ Initialize the XMRig Proxy Client. - + :param host: The hostname or IP address of the xmrig-proxy. :param port: The HTTP API port (configured via --http-port). :param access_token: The access token (configured via --http-access-token). """ self.logger = logging.getLogger("ProxyClient") self.base_url = f"http://{host}:{port}" - + # Configure Session with Retries self.session = requests.Session() retry_strategy = Retry( total=3, - backoff_factor=1, # Wait 1s, 2s, 4s between retries + backoff_factor=1, # Wait 1s, 2s, 4s between retries status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["HEAD", "GET", "PUT", "OPTIONS"] + allowed_methods=["HEAD", "GET", "PUT", "OPTIONS"], ) adapter = HTTPAdapter(max_retries=retry_strategy) self.session.mount("http://", adapter) - + if access_token: self.session.headers.update({"Authorization": f"Bearer {access_token}"}) @@ -165,14 +167,15 @@ def update_config(self, config_data): return {} return response.json() + if __name__ == "__main__": # Configuration # Ensure xmrig-proxy is running with API enabled: # ./xmrig-proxy --http-port=8080 --http-access-token=SECRET - + HOST = "127.0.0.1" - PORT = 8080 - TOKEN = "SECRET" + PORT = 8080 + TOKEN = "SECRET" # noqa: S105 — placeholder for this __main__ usage example, not a real secret client = XMRigProxyClient(HOST, PORT, TOKEN) @@ -200,4 +203,4 @@ def update_config(self, config_data): except requests.exceptions.RequestException as e: print(f"HTTP Request failed: {e}") except Exception as e: - print(f"An error occurred: {e}") \ No newline at end of file + print(f"An error occurred: {e}") diff --git a/build/dashboard/mining_dashboard/client/xvb_client.py b/build/dashboard/mining_dashboard/client/xvb_client.py index 5fafb354..4302d023 100644 --- a/build/dashboard/mining_dashboard/client/xvb_client.py +++ b/build/dashboard/mining_dashboard/client/xvb_client.py @@ -1,23 +1,39 @@ -import requests import logging import re + +import requests + +from mining_dashboard.config.config import XVB_SUBMIT_URL, XVB_TOR_PROXY from mining_dashboard.helper.utils import parse_hashrate -from mining_dashboard.config.config import XVB_TOR_PROXY + +# register() outcomes (#263). The endpoint returns plaintext "ERROR: ..." with a 422 for the error +# cases (not a 200/JSON contract), so success/failure is classified from status + body, not status +# alone. The caller maps these to dashboard state + retry behaviour. +REG_OK = "registered" # 2xx (fresh) OR "already registered" (idempotent) — wallet is in the raffle +REG_INVALID = "invalid_wallet" # endpoint rejected the address — permanent, stop retrying +REG_NOT_ELIGIBLE = "not_eligible" # no PPLNS share server-side yet — retry quietly, don't alarm +REG_ERROR = "error" # 5xx / network / unknown shape — transient, retry +REG_DISABLED = "disabled" # no endpoint configured (XVB_SUBMIT_URL disabled) — caller skips + class XvbClient: - def __init__(self, wallet_address, tor_proxy=None): + def __init__(self, wallet_address, tor_proxy=None, submit_url=None): """ Initialize the XvB Client. :param wallet_address: The Monero wallet address to query stats for. :param tor_proxy: SOCKS proxy URL to route the stats fetch through (defaults to the bridge Tor SOCKS, so the request can't correlate the operator's IP with the wallet, #163). + :param submit_url: Raffle-registration endpoint (#263). Deliberately UNPUBLISHED by the XvB + operator, so it is never hard-coded here — it's injected via XVB_SUBMIT_URL at deploy + time. Empty => register() is a no-op (we still mine to XvB and read stats). """ self.logger = logging.getLogger("XvbClient") self.wallet_address = wallet_address self.url = "https://xmrvsbeast.com/cgi-bin/p2pool_bonus_history.cgi" self.tor_proxy = tor_proxy if tor_proxy is not None else XVB_TOR_PROXY - + self.submit_url = submit_url if submit_url is not None else XVB_SUBMIT_URL + # Pre-compile regex patterns self.REGEX_FAIL_COUNT = re.compile(r"Fail Count:\s*(\d+)", re.IGNORECASE) self.REGEX_HR_1H = re.compile(r"1hr avg:\s*([\d\.]+)\s*([kKmMgG]?H/s)?", re.IGNORECASE) @@ -26,9 +42,9 @@ def __init__(self, wallet_address, tor_proxy=None): def get_stats(self): """ Retrieves bonus history statistics from the XMRvsBeast service. - + Returns: - dict or None: A dictionary containing 'fail_count', 'avg_1h', and 'avg_24h' + dict or None: A dictionary containing 'fail_count', 'avg_1h', and 'avg_24h' if successful, otherwise None. """ if not self.wallet_address or self.wallet_address == "placeholder": @@ -45,7 +61,9 @@ def get_stats(self): if response.status_code == 200: return self._parse_html(response.text) else: - self.logger.error(f"XvB API request failed with status code: {response.status_code}") + self.logger.error( + f"XvB API request failed with status code: {response.status_code}" + ) return None except requests.RequestException as e: self.logger.error(f"Network error while fetching XvB stats: {e}") @@ -54,16 +72,87 @@ def get_stats(self): self.logger.error(f"Unexpected error in XvB client: {e}") return None + def register(self): + """ + Enter the wallet into the XMRvsBeast raffle (#263). + + Mining to the XvB pool isn't enough to be entered — the wallet must be registered against + the operator's submit endpoint. That endpoint registers the wallet ONLY if it already has a + share in the P2Pool PPLNS window, so callers should gate this on PPLNS-share eligibility; + before a share lands server-side the call is a harmless no-op and we just retry next poll. + + ALWAYS routes over the SAME Tor SOCKS5 proxy as get_stats (XVB_TOR_PROXY) — the call carries + the FULL wallet address, so a clearnet request would correlate the operator's IP with the + wallet (#163). Like the stats fetch, this is NOT subject to the xvb.tor donation opt-out. + + The endpoint does NOT use a 200/JSON contract — it returns a plaintext ``ERROR: ...`` body + with HTTP 422 for the error cases — so the outcome is classified from status AND body + (verified live, see config). Returns one of the module ``REG_*`` strings: + REG_OK — 2xx, or "already registered" (idempotent; the wallet is in the raffle) + REG_INVALID — endpoint rejected the address (permanent; caller stops retrying) + REG_NOT_ELIGIBLE — no PPLNS share server-side yet (retry quietly) + REG_ERROR — 5xx / network / unrecognised shape (transient; retry) + REG_DISABLED — no endpoint configured (XVB_SUBMIT_URL disabled) + """ + if not self.submit_url: + # Endpoint disabled (XVB_SUBMIT_URL set to a disable sentinel): we never reach out. + self.logger.debug("XvB registration skipped: endpoint disabled.") + return REG_DISABLED + + if not self.wallet_address or self.wallet_address == "placeholder": + self.logger.warning( + "XvB registration skipped: MONERO_WALLET_ADDRESS is missing or invalid." + ) + return REG_INVALID + + params = {"address": self.wallet_address} + + # Same Tor routing as the stats fetch: socks5h so xmrvsbeast sees a Tor exit (not the + # operator's home IP) and the host is resolved over Tor too. The request carries the full + # wallet, so a clearnet call would correlate IP <-> wallet (#163). + proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None + try: + response = requests.get(self.submit_url, params=params, timeout=20, proxies=proxies) + body = (response.text or "").strip() + low = body.lower() + + # Fresh registration: the endpoint answers 2xx. + if 200 <= response.status_code < 300: + return REG_OK + # Idempotent: re-registering an entered wallet returns 422 "Already Registered". That's + # the steady state once we're in — treat it as success so the daily re-register is a + # no-op rather than a "failure". + if "already registered" in low: + return REG_OK + # Permanent: a wallet the endpoint won't accept (e.g. wrong address type). Don't hammer. + if "invalid wallet" in low: + self.logger.warning("XvB registration: endpoint rejected the wallet as invalid.") + return REG_INVALID + # Best-effort: the share hasn't propagated to XvB yet. We can't pin the exact wording + # (we only have the already-registered/invalid samples), so match the obvious tokens and + # otherwise fall through to a retryable error. + if "pplns" in low or "share" in low or "window" in low: + return REG_NOT_ELIGIBLE + # Anything else (5xx, an unrecognised body) — log it so a contract change surfaces. + self.logger.warning( + "XvB registration: unexpected response (HTTP %s): %s", + response.status_code, + body[:200], + ) + return REG_ERROR + except requests.RequestException as e: + self.logger.error(f"Network error while registering with XvB: {e}") + return REG_ERROR + except Exception as e: + self.logger.error(f"Unexpected error during XvB registration: {e}") + return REG_ERROR + def _parse_html(self, html_text): """ Parses raw HTML content to extract mining statistics. """ try: - stats = { - "fail_count": 0, - "avg_1h": 0.0, - "avg_24h": 0.0 - } + stats = {"fail_count": 0, "avg_1h": 0.0, "avg_24h": 0.0} # Extract Fail Count fail_match = self.REGEX_FAIL_COUNT.search(html_text) @@ -76,16 +165,18 @@ def _parse_html(self, html_text): if hr1_match: stats["avg_1h"] = parse_hashrate(hr1_match.group(1), hr1_match.group(2)) - + if hr24_match: stats["avg_24h"] = parse_hashrate(hr24_match.group(1), hr24_match.group(2)) if not fail_match and not hr1_match: - self.logger.warning("Parsing Warning: Critical stats not found in XvB response. HTML structure may have changed.") + self.logger.warning( + "Parsing Warning: Critical stats not found in XvB response. HTML structure may have changed." + ) return None return stats except Exception as e: self.logger.error(f"Parsing Error: Failed to process XvB HTML: {e}") - return None \ No newline at end of file + return None diff --git a/build/dashboard/mining_dashboard/collector/logs.py b/build/dashboard/mining_dashboard/collector/logs.py index 1720538f..141dbb0f 100644 --- a/build/dashboard/mining_dashboard/collector/logs.py +++ b/build/dashboard/mining_dashboard/collector/logs.py @@ -1,19 +1,28 @@ -import aiohttp import asyncio +import json import logging import re import struct -import json + import aiofiles +import aiohttp -from mining_dashboard.config.config import DOCKER_PROXY_URL, LOG_TAIL_LINES, DOCKER_TIMEOUT, NETWORK_STATS_PATH, MONERO_NODE_HOST, LOCAL_MONERO_HOST from mining_dashboard.client.monero.monero_client import MoneroClient +from mining_dashboard.config.config import ( + DOCKER_PROXY_URL, + DOCKER_TIMEOUT, + LOCAL_MONERO_HOST, + LOG_TAIL_LINES, + MONERO_NODE_HOST, + NETWORK_STATS_PATH, +) logger = logging.getLogger("LogCollector") # Stateless client reused across cycles; reads monerod's get_info RPC (Issue #29). _monero_client = MoneroClient() + async def fetch_docker_logs(container_name, tail=None): """ Fetches logs from a container via the Docker Socket Proxy. @@ -26,14 +35,10 @@ async def fetch_docker_logs(container_name, tail=None): base_url = DOCKER_PROXY_URL if base_url.startswith("tcp://"): base_url = base_url.replace("tcp://", "http://") - + # Docker Engine API: /containers/{id}/logs url = f"{base_url}/containers/{container_name}/logs" - params = { - "stdout": 1, - "stderr": 1, - "tail": tail - } + params = {"stdout": 1, "stderr": 1, "tail": tail} try: async with aiohttp.ClientSession() as session: @@ -42,11 +47,14 @@ async def fetch_docker_logs(container_name, tail=None): raw_data = await response.read() return _parse_docker_stream(raw_data) else: - logger.error(f"Failed to fetch logs for {container_name}. Status: {response.status}") + logger.error( + f"Failed to fetch logs for {container_name}. Status: {response.status}" + ) return [f"Error: Could not retrieve logs (Status {response.status})"] except Exception as e: logger.error(f"Error connecting to Docker Proxy at {base_url}: {e}") - return [f"Error: Connection to Docker Proxy failed."] + return ["Error: Connection to Docker Proxy failed."] + def _parse_docker_stream(data): """ @@ -56,54 +64,56 @@ def _parse_docker_stream(data): logs = [] i = 0 n = len(data) - + while i < n: if i + 8 > n: break - - payload_size = struct.unpack('>I', data[i+4:i+8])[0] - + + payload_size = struct.unpack(">I", data[i + 4 : i + 8])[0] + i += 8 if i + payload_size > n: break - - line = data[i:i+payload_size].decode('utf-8', errors='replace').strip() + + line = data[i : i + payload_size].decode("utf-8", errors="replace").strip() if line: logs.append(line) - + i += payload_size - + return logs + async def get_monero_logs(tail=None): return await fetch_docker_logs("monerod", tail=tail) + async def _get_remote_monero_sync_status(): """ Reads monerod sync status from the local stats file generated by p2pool. """ try: - async with aiofiles.open(NETWORK_STATS_PATH, mode='r') as f: + async with aiofiles.open(NETWORK_STATS_PATH) as f: contents = await f.read() if not contents: - return {"is_syncing": False} - + return {"is_syncing": False} + stats = json.loads(contents) - + current_height = stats.get("height", 0) target_height = stats.get("target_height", 0) - + if target_height > 0 and current_height < target_height: percent = int((current_height / target_height) * 100) return { "is_syncing": True, "current": current_height, "target": target_height, - "percent": percent + "percent": percent, } else: - return {"is_syncing": False} - + return {"is_syncing": False} + except FileNotFoundError: logger.warning(f"Network stats file not found at {NETWORK_STATS_PATH}") return {"is_syncing": False} @@ -114,6 +124,7 @@ async def _get_remote_monero_sync_status(): logger.error(f"Error reading monero sync status: {e}") return {"is_syncing": False} + async def _get_local_monero_sync_status(): """ Sync status for a local monerod. @@ -174,15 +185,11 @@ async def _get_monero_sync_status_from_logs(): elif target > 0: percent = int((current / target) * 100) - return { - "is_syncing": True, - "current": current, - "target": target, - "percent": percent - } + return {"is_syncing": True, "current": current, "target": target, "percent": percent} return {"is_syncing": False} + async def get_monero_sync_status(): """ Determines whether to check local docker logs or P2Pool's network stats @@ -194,4 +201,4 @@ async def get_monero_sync_status(): # feature (Issue #31) deliberately no-ops for remote nodes (p2pool manages those). status = await _get_remote_monero_sync_status() status.setdefault("reachable", True) - return status \ No newline at end of file + return status diff --git a/build/dashboard/mining_dashboard/collector/pools.py b/build/dashboard/mining_dashboard/collector/pools.py index 23c71da3..708bfb62 100644 --- a/build/dashboard/mining_dashboard/collector/pools.py +++ b/build/dashboard/mining_dashboard/collector/pools.py @@ -1,13 +1,16 @@ import json import os -import time + from mining_dashboard.config.config import ( - P2P_STATS_PATH, POOL_STATS_PATH, NETWORK_STATS_PATH, - STRATUM_STATS_PATH, TARI_STATS_PATH, SECOND_PER_BLOCK_MAIN, - BLOCK_PPLNS_WINDOW_MAIN, BLOCK_PPLNS_WINDOW_MINI, BLOCK_PPLNS_WINDOW_NANO, - SECOND_PER_BLOCK_P2POOL_MAIN, SECOND_PER_BLOCK_P2POOL_MINI, SECOND_PER_BLOCK_P2POOL_NANO + NETWORK_STATS_PATH, + P2P_STATS_PATH, + POOL_STATS_PATH, + SECOND_PER_BLOCK_MAIN, + STRATUM_STATS_PATH, + TARI_STATS_PATH, ) + def _read_json(path): """ Safely loads a JSON file, returning an empty dictionary on failure. @@ -15,7 +18,7 @@ def _read_json(path): """ if os.path.exists(path): try: - with open(path, 'r') as f: + with open(path) as f: return json.load(f) except (json.JSONDecodeError, OSError): # Fail silently to allow the dashboard to continue running @@ -23,17 +26,18 @@ def _read_json(path): pass return {} + def detect_pool_type(peers): """ Heuristically detects the P2Pool network type (Main, Mini, Nano) based on peer ports. - + Args: peers (list): List of peer connection strings (e.g., "1.2.3.4:37889"). """ counts = {"Main": 0, "Mini": 0, "Nano": 0} - if not peers: + if not peers: return "Unknown" - + # Match the port exactly (the last colon-segment), not as a substring of the whole peer # string — an IP that merely contains the port digits (e.g. "37.88.9.1:18080") would # otherwise be miscounted, and pool type drives block_time / the PPLNS-window duration the @@ -47,13 +51,14 @@ def detect_pool_type(peers): winner = max(counts, key=counts.get) return winner if counts[winner] > 0 else "Unknown" + def get_p2pool_stats(): """Aggregates P2Pool local statistics and P2P network health data.""" raw_p2p = _read_json(P2P_STATS_PATH) raw_pool = _read_json(POOL_STATS_PATH) raw_stratum = _read_json(STRATUM_STATS_PATH) pool_stats = raw_pool.get("pool_statistics", {}) - + pool_type = detect_pool_type(raw_p2p.get("peers", [])) last_share_time = raw_stratum.get("last_share_found_time", 0) @@ -66,7 +71,7 @@ def get_p2pool_stats(): "in_peers": raw_p2p.get("incoming_connections", 0), "peers_count": raw_p2p.get("peer_list_size", 0), "uptime": raw_p2p.get("uptime", 0), - "zmq_active": raw_p2p.get("zmq_last_active", 0) + "zmq_active": raw_p2p.get("zmq_last_active", 0), }, "pool": { "hashrate": pool_stats.get("hashRate", 0), @@ -81,50 +86,35 @@ def get_p2pool_stats(): "total_hashes": pool_stats.get("totalHashes", 0), "shares_found": shares_total, "last_share_time": last_share_time, - } + }, } return stats + def get_network_stats(): """Retrieves Monero network statistics (Difficulty, Height, Reward).""" raw = _read_json(NETWORK_STATS_PATH) - - diff = raw.get('difficulty', 0) - hashrate = raw.get('hash', 'N/A') - + + diff = raw.get("difficulty", 0) + hashrate = raw.get("hash", "N/A") + # Calculate hashrate if missing (Difficulty / Target Time) - if (hashrate == 'N/A' or hashrate == 0) and diff > 0: + if (hashrate == "N/A" or hashrate == 0) and diff > 0: hashrate = diff / SECOND_PER_BLOCK_MAIN - + return { "difficulty": diff, - "height": raw.get('height', 0), - "reward": raw.get('reward', 0), + "height": raw.get("height", 0), + "reward": raw.get("reward", 0), "hash": hashrate, - "timestamp": raw.get('timestamp', 0) + "timestamp": raw.get("timestamp", 0), } + def get_stratum_stats(): - """ - Parses local stratum statistics to extract worker configurations. - - Returns: - tuple: (Raw JSON dict, List of worker config dicts) - """ - raw = _read_json(STRATUM_STATS_PATH) - - worker_configs = [] - # Iterate through worker entries (Format: "IP, ..., ..., ..., Name, ...") - for w_entry in raw.get("workers", []): - if isinstance(w_entry, str): - parts = w_entry.split(',') - if len(parts) >= 1: - ip = parts[0].split(':')[0].strip() - # Default to "miner" if name field (index 4) is missing - name = parts[4].strip() if len(parts) >= 5 else "miner" - worker_configs.append({"ip": ip, "name": name, "parts": parts}) - - return raw, worker_configs + """Returns the raw local stratum statistics JSON dict.""" + return _read_json(STRATUM_STATS_PATH) + def get_tari_stats(): """Retrieves Tari merge mining status and rewards.""" @@ -132,12 +122,18 @@ def get_tari_stats(): chains = raw.get("chains", []) if chains: t = chains[0] + # `channel_state` is p2pool's gRPC connectivity state to the Tari node (IDLE/CONNECTING/READY/ + # TRANSIENT_FAILURE/SHUTDOWN). `active` only means a chain is configured; `connected` means the + # channel is actually up — the dashboard must gate the "✔" on the latter, never on `active`, + # so a broken channel can't render as "TRANSIENT_FAILURE ✔". + state = t.get("channel_state", "UNKNOWN") return { "active": True, - "status": t.get('channel_state', 'UNKNOWN'), - "address": t.get('wallet', 'Unknown'), - "height": t.get('height', 0), - "reward": t.get('reward', 0) / 1_000_000, # Convert uTari to Tari - "difficulty": t.get('difficulty', 0) + "status": state, + "connected": state == "READY", + "address": t.get("wallet", "Unknown"), + "height": t.get("height", 0), + "reward": t.get("reward", 0) / 1_000_000, # Convert uTari to Tari + "difficulty": t.get("difficulty", 0), } - return {"active": False} \ No newline at end of file + return {"active": False} diff --git a/build/dashboard/mining_dashboard/collector/system.py b/build/dashboard/mining_dashboard/collector/system.py index e5276458..ef5947da 100644 --- a/build/dashboard/mining_dashboard/collector/system.py +++ b/build/dashboard/mining_dashboard/collector/system.py @@ -1,11 +1,13 @@ -import shutil import os +import shutil + from mining_dashboard.config.config import DISK_PATH -BYTES_IN_GB = 1024 ** 3 +BYTES_IN_GB = 1024**3 _last_cpu_times = None + def get_disk_usage(): """ Calculates storage utilization for the configured data directory. @@ -21,14 +23,12 @@ def get_disk_usage(): "total_gb": usage.total / BYTES_IN_GB, "used_gb": usage.used / BYTES_IN_GB, "percent": percent, - "percent_str": f"{percent:.1f}%" + "percent_str": f"{percent:.1f}%", } except Exception: # Return zeroed metrics if the path is inaccessible - return { - "total_gb": 0, "used_gb": 0, - "percent": 0, "percent_str": "0%" - } + return {"total_gb": 0, "used_gb": 0, "percent": 0, "percent_str": "0%"} + def get_memory_usage(): """ @@ -38,13 +38,13 @@ def get_memory_usage(): try: mem_total = 0 mem_available = 0 - with open('/proc/meminfo', 'r') as f: + with open("/proc/meminfo") as f: for line in f: - if line.startswith('MemTotal:'): - mem_total = int(line.split()[1]) * 1024 # kB to bytes - elif line.startswith('MemAvailable:'): - mem_available = int(line.split()[1]) * 1024 # kB to bytes - + if line.startswith("MemTotal:"): + mem_total = int(line.split()[1]) * 1024 # kB to bytes + elif line.startswith("MemAvailable:"): + mem_available = int(line.split()[1]) * 1024 # kB to bytes + if mem_total > 0: used = mem_total - mem_available percent = (used / mem_total) * 100 @@ -52,12 +52,13 @@ def get_memory_usage(): "total_gb": mem_total / BYTES_IN_GB, "used_gb": used / BYTES_IN_GB, "percent": percent, - "percent_str": f"{percent:.1f}%" + "percent_str": f"{percent:.1f}%", } - except Exception: + except Exception: # noqa: S110 — best-effort memory stat; any failure falls through to the zeroed default below pass return {"total_gb": 0, "used_gb": 0, "percent": 0, "percent_str": "0%"} + def get_load_average(): """ Returns system load average (1m, 5m, 15m) as a string. @@ -68,6 +69,7 @@ def get_load_average(): except Exception: return "0.00 0.00 0.00" + def get_cpu_usage(): """ Calculates CPU usage percentage using /proc/stat. @@ -75,19 +77,20 @@ def get_cpu_usage(): """ global _last_cpu_times try: - with open('/proc/stat', 'r') as f: + with open("/proc/stat") as f: line = f.readline() - + parts = line.split() # cpu user nice system idle iowait irq softirq steal - if len(parts) < 5: return "0.0%" - + if len(parts) < 5: + return "0.0%" + # Sum all fields for total time values = [int(x) for x in parts[1:]] total = sum(values) # Idle is idle + iowait idle = values[3] + (values[4] if len(values) > 4 else 0) - + usage = 0.0 if _last_cpu_times: prev_total, prev_idle = _last_cpu_times @@ -95,16 +98,17 @@ def get_cpu_usage(): delta_idle = idle - prev_idle if delta_total > 0: usage = ((delta_total - delta_idle) / delta_total) * 100 - + _last_cpu_times = (total, idle) return f"{usage:.1f}%" except Exception: return "0.0%" + def get_hugepages_status(): """ Analyzes system memory configuration to determine HugePage availability. - + Parses /proc/meminfo to check if HugePages are allocated and actively used by the mining process (RandomX optimization). @@ -113,7 +117,7 @@ def get_hugepages_status(): """ try: mem_stats = {} - with open("/proc/meminfo", "r") as f: + with open("/proc/meminfo") as f: for line in f: if line.startswith("HugePages_Total"): mem_stats["total"] = int(line.split()[1]) @@ -125,27 +129,27 @@ def get_hugepages_status(): hp_total = mem_stats["total"] hp_free = mem_stats["free"] hp_used = hp_total - hp_free - + val_str = f"{hp_used} / {hp_total}" - + # Status Logic: # 1. Total == 0: Feature not enabled in kernel/GRUB. if hp_total == 0: return "Disabled", "status-bad", val_str - + # 2. Used > 0: Feature enabled and actively utilized by miner. elif hp_used > 0: return "Enabled", "status-ok", val_str - + # 3. Total > 0 but Used == 0: reserved but the miner isn't consuming them yet — the # normal startup / sync-hold state, NOT an error, so render it green (#175). Keep the # "Allocated" label (distinct from actively-"Enabled") but with the ok class. (The # genuinely-bad case is hp_total == 0 / "Disabled"; "Unknown" below stays warn.) else: return "Allocated", "status-ok", val_str - + except (FileNotFoundError, ValueError, IndexError): # Gracefully handle non-Linux systems or parsing errors pass - - return "Unknown", "status-warn", "0/0" \ No newline at end of file + + return "Unknown", "status-warn", "0/0" diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py index ef7063c3..3797c8ce 100644 --- a/build/dashboard/mining_dashboard/config/config.py +++ b/build/dashboard/mining_dashboard/config/config.py @@ -1,12 +1,12 @@ -import os import json +import os # --- System Configuration & Paths --- # Base directory for shared statistics generated by the P2Pool sidecar BASE_STATS_DIR = "/app/stats" # Persistent storage path for application state database -DISK_PATH = '/data' +DISK_PATH = "/data" DB_FILE_PATH = os.path.join(DISK_PATH, "mining_data.db") # Low-disk warning thresholds (% used of the data filesystem), surfaced as a top-bar badge (#138): @@ -29,8 +29,26 @@ HOST_IP = os.environ.get("HOST_IP", "Unknown Host") # XMRig Worker API Configuration -XMRIG_API_PORT = 8080 -API_TIMEOUT = 1 # Connection timeout (seconds) for worker API calls +# +# The dashboard enriches each proxy-reported worker by probing that miner's own xmrig HTTP API +# (/1/summary) for uptime + per-miner hashrate. There is exactly ONE way it does this, derived from +# config — no auto-detection fallback. A failed probe is surfaced (api_ok=false + a logged warning), +# never silently swallowed. +# +# XMRIG_API_AUTH how to authenticate to the worker API: +# none (default) — open, read-only API (xmrig http.restricted, no access-token). +# This is the stock RigForge worker. +# name — Bearer = the worker's stratum name (xmrig access-token = name). +# token — Bearer = a single shared XMRIG_API_TOKEN for every worker. +# XMRIG_API_TOKEN the shared bearer used when XMRIG_API_AUTH=token. +# XMRIG_API_PORT the port the worker API listens on. +# +# Upgrade note: stacks whose miners still set an xmrig access-token (the pre-open default) should set +# XMRIG_API_AUTH=name — otherwise the no-auth default probe 401s and those workers read api_ok=false. +XMRIG_API_PORT = int(os.environ.get("XMRIG_API_PORT", 8080)) +XMRIG_API_AUTH = os.environ.get("XMRIG_API_AUTH", "none").strip().lower() +XMRIG_API_TOKEN = os.environ.get("XMRIG_API_TOKEN", "") +API_TIMEOUT = 1 # Connection timeout (seconds) for worker API calls # The stack's internal docker bridge subnet. The dashboard runs network_mode: host and a connecting # miner fully controls its worker name/ip via stratum, yet per-worker stats are fetched at a host @@ -51,10 +69,10 @@ XVB_TIME_ALGO_MS = 600000 # Minimum dwell time on a pool to ensure valid share submission (15 seconds) -XVB_MIN_TIME_SEND_MS = 15000 +XVB_MIN_TIME_SEND_MS = 15000 # Wallet address used for fetching XvB bonus history and pool identification -MONERO_WALLET_ADDRESS = os.environ.get("MONERO_WALLET_ADDRESS", "") +MONERO_WALLET_ADDRESS = os.environ.get("MONERO_WALLET_ADDRESS", "") # Unique Donor ID for the XMRvsBeast pool XVB_DONOR_ID = os.environ.get("XVB_DONOR_ID", "") @@ -67,6 +85,52 @@ # host-networked dashboard reaches the bridge container's Tor SOCKS at 172.28.0.25:9050. XVB_TOR_PROXY = os.environ.get("XVB_TOR_PROXY", "socks5h://172.28.0.25:9050") +# Route XvB DONATION MINING through Tor by default too (#166), not just the stats fetch (#163): while +# donating, the proxy connects to na.xmrvsbeast.com, which would otherwise expose the host IP. The +# algo controller sets this host:port as the XvB pool's per-pool `socks5` field (xmrig-proxy resolves +# that pool's DNS proxy-side). `xvb.tor: false` opts out (direct, for max yield). The host:port is +# derived from XVB_TOR_PROXY (so a custom subnet, #180, carries through) with the scheme stripped — +# xmrig wants a bare host:port. +XVB_TOR_ENABLED = os.environ.get("XVB_TOR_ENABLED", "true").lower() == "true" +XVB_TOR_SOCKS5 = XVB_TOR_PROXY.split("://", 1)[-1] + +# XvB raffle-registration endpoint (#263). Mining to the XvB pool isn't enough to enter the raffle — +# a wallet is only entered once it's registered against this endpoint (and only if it already has a +# share in the P2Pool PPLNS window). Same host as the XvB stats fetch above. The operator asked us +# not to *advertise* this API ("to mitigate abuse"), so don't surface the bare URL in user-facing +# docs/README — but it ships here as the working default. Override with XVB_SUBMIT_URL (e.g. a test +# server); set it to a disable sentinel (off/none/false/disabled/0) to turn auto-registration off +# while keeping XvB on. The call ALWAYS rides Tor (XVB_TOR_PROXY) like the stats fetch (#163) since it +# carries the full wallet — it is NOT subject to the xvb.tor donation opt-out. +# +# Contract — GET ?address=, verified live 2026-06-28: +# 2xx => freshly registered +# 422 "ERROR: Wallet Address Already Registered" => already in (idempotent success) +# 422 "ERROR: Invalid Wallet Address" => bad wallet (permanent — stop retrying) +# no-share / 5xx / other => transient, retry on the next poll +_XVB_SUBMIT_DEFAULT = "https://xmrvsbeast.com/cgi-bin/p2pool_bonus_submit_api.cgi" +_XVB_SUBMIT_DISABLE_SENTINELS = {"off", "none", "false", "disabled", "0"} +_xvb_submit_env = os.environ.get("XVB_SUBMIT_URL", "").strip() +if _xvb_submit_env.lower() in _XVB_SUBMIT_DISABLE_SENTINELS: + XVB_SUBMIT_URL = "" # auto-registration explicitly disabled (XvB itself still runs) +else: + XVB_SUBMIT_URL = _xvb_submit_env or _XVB_SUBMIT_DEFAULT + +# How often to re-register with XvB once eligible (#263), seconds. Registration is idempotent, so we +# re-run on a daily cadence: it picks up the operator's newer security-token behaviour and re-enters +# a long-offline miner cleanly. +XVB_REGISTER_INTERVAL_S = 24 * 3600 + +# --- Egress posture knobs (#170 Component Health panel) --- +# Surfaced so the dashboard can show each component's outbound egress route + a privacy roll-up. +# Defaults are the privacy-safe resting state (firewall fail-closed; p2pool peers over Tor); pithead +# renders the real values into the dashboard env (docker-compose.yml) so the panel reflects actual +# config rather than a hardcoded guess. TOR_EGRESS_FIREWALL is the #270 network-layer backstop: when +# on, any non-Tor egress from the container subnet is DROPPED (fail-closed), so the whole stack is +# Tor-only regardless of per-app config. +TOR_EGRESS_FIREWALL = os.environ.get("TOR_EGRESS_FIREWALL", "true").strip().lower() == "true" +P2POOL_CLEARNET = os.environ.get("P2POOL_CLEARNET", "false").strip().lower() == "true" + # New-release check (#224, config.json: dashboard.check_for_updates). Default ON — the dashboard asks # GitHub for the latest release and shows a header badge linking to it if it's newer than the running # version. Notify-only (no upgrade — that's #59). The check is routed over the same bridge Tor SOCKS @@ -74,7 +138,9 @@ # GitHub — which is why it's safe to default on; it fails silently offline. Opt out with `false`. CHECK_FOR_UPDATES = os.environ.get("DASHBOARD_CHECK_UPDATES", "true").strip().lower() == "true" GITHUB_RELEASES_API = os.environ.get( - "GITHUB_RELEASES_API", "https://api.github.com/repos/p2pool-starter-stack/pithead/releases/latest") + "GITHUB_RELEASES_API", + "https://api.github.com/repos/p2pool-starter-stack/pithead/releases/latest", +) UPDATE_CHECK_INTERVAL = int(float(os.environ.get("UPDATE_CHECK_INTERVAL", "3600"))) # Donation tier to target (config.json: xvb.donation_level). The XvB raffle picks @@ -131,7 +197,9 @@ # #31's job — it stops only xmrig-proxy so p2pool keeps its sidechain position), and the latch # is persisted across dashboard restarts so a restart mid-sync doesn't prematurely release. SYNC_GATE_CONTAINERS = [ - c.strip() for c in os.environ.get("SYNC_GATE_CONTAINERS", "p2pool,xmrig-proxy").split(",") if c.strip() + c.strip() + for c in os.environ.get("SYNC_GATE_CONTAINERS", "p2pool,xmrig-proxy").split(",") + if c.strip() ] # Debounce: a node must be unreachable this long before it's declared DOWN, and reachable @@ -170,8 +238,18 @@ # signal the data loop already computes and, the first time a clearnet node reports synced, drops a # persistent marker in CLEARNET_STATE_DIR and restarts the container — whose entrypoint, seeing the # marker, comes back up Tor-only. Default off. Truthy parsing matches MONERO_PRUNE. -MONERO_CLEARNET_SYNC = os.environ.get("MONERO_CLEARNET_SYNC", "false").strip().lower() in ("true", "1", "yes", "on") -TARI_CLEARNET_SYNC = os.environ.get("TARI_CLEARNET_SYNC", "false").strip().lower() in ("true", "1", "yes", "on") +MONERO_CLEARNET_SYNC = os.environ.get("MONERO_CLEARNET_SYNC", "false").strip().lower() in ( + "true", + "1", + "yes", + "on", +) +TARI_CLEARNET_SYNC = os.environ.get("TARI_CLEARNET_SYNC", "false").strip().lower() in ( + "true", + "1", + "yes", + "on", +) # Shared, dashboard-writable dir holding the per-chain ".synced" transition markers. Mounted # read-only into monerod/tari at the same path. Container default matches the compose mount. CLEARNET_STATE_DIR = os.environ.get("CLEARNET_STATE_DIR", "/clearnet-state") @@ -207,6 +285,14 @@ # especially if XvB over-credits). 0.03 converges in a few hours and stays stable. XVB_CONTROL_GAIN = float(os.environ.get("XVB_CONTROL_GAIN", 0.03)) +# Age past which a frozen XvB stats read is treated as stale (#311). A successful +# fetch lands every ~10 poll cycles (data_service throttle) and only a real fetch +# bumps `last_update` (#136), so its age IS the fetch age. When the fetch goes quiet +# (e.g. a stuck Tor circuit) `avg_1h` freezes; steering off that frozen number +# over-donates against a target we can't refresh. Past this age the controller holds +# the last split instead of chasing it. Default ~3 fetch intervals. +XVB_STATS_STALE_AFTER_S = float(os.environ.get("XVB_STATS_STALE_AFTER_S", 3 * 10 * UPDATE_INTERVAL)) + # VIP / PPLNS reserve. To stay "VIP" we must keep finding p2pool shares, so we # reserve enough hashrate for p2pool to hold a share in the PPLNS window. The bare # minimum (one expected share per window) is sidechain_difficulty / window_seconds; @@ -220,11 +306,14 @@ # --- Data Retention Policies --- HISTORY_RETENTION_SEC = 30 * 24 * 3600 # 30 Days -WORKER_RETENTION_SEC = 7 * 24 * 3600 # 7 Days +# Retention for the known_workers persistence layer removed in #144. No live consumer in the current +# tree; kept for the deferred Telegram worker-presence monitor (#121), which reuses it as its +# retention default — consult that work before removing. +WORKER_RETENTION_SEC = 7 * 24 * 3600 # 7 Days # How long an offline worker lingers in the live "Workers Alive" table before it falls off (#182). -# Operates on the live proxy-sourced list (NOT the dead known_workers path, #144). A reconnect -# re-adds the worker. 1h keeps a just-disconnected rig visible (shown as DOWN) but clears ghosts. -WORKER_FALLOFF_SEC = 3600 # 1 Hour +# Operates on the live proxy-sourced list. A reconnect re-adds the worker. 1h keeps a +# just-disconnected rig visible (shown as DOWN) but clears ghosts. +WORKER_FALLOFF_SEC = 3600 # 1 Hour # --- Hashrate averaging windows (#168) --- # xmrig-proxy's /workers rows expose five native per-worker averaging windows. The chart lets the @@ -240,9 +329,9 @@ # window -> (p2pool column, xvb column) in the history table. 10m maps to the original pair so the # default view and all existing rows stay intact; the rest are additive columns (see _migrate_db). HASHRATE_WINDOW_COLUMNS = { - "1m": ("v_p2pool_1m", "v_xvb_1m"), - "10m": ("v_p2pool", "v_xvb"), - "1h": ("v_p2pool_1h", "v_xvb_1h"), + "1m": ("v_p2pool_1m", "v_xvb_1m"), + "10m": ("v_p2pool", "v_xvb"), + "1h": ("v_p2pool_1h", "v_xvb_1h"), "12h": ("v_p2pool_12h", "v_xvb_12h"), "24h": ("v_p2pool_24h", "v_xvb_24h"), } @@ -251,10 +340,10 @@ # Hashrate thresholds (H/s) for XMRvsBeast donation tiers. # Reference: Official XvB rules (Mega=1M, Whale=100k, VIP=10k, Donor=1k) TIER_DEFAULTS = { - "donor_mega": 1_000_000, - "donor_whale": 100_000, - "donor_vip": 10_000, - "donor": 1_000 + "donor_mega": 1_000_000, + "donor_whale": 100_000, + "donor_vip": 10_000, + "donor": 1_000, } # Allow runtime configuration override via environment variable (injected via .env) @@ -268,12 +357,4 @@ pass # --- P2Pool Protocol Constants --- -# Constants for PPLNS (Pay Per Last N Shares) window calculation -BLOCK_PPLNS_WINDOW_MAIN = 2160 # Window size in blocks -BLOCK_PPLNS_WINDOW_MINI = 2160 -BLOCK_PPLNS_WINDOW_NANO = 2160 - -SECOND_PER_BLOCK_MAIN = 120 # Monero Target block time in seconds -SECOND_PER_BLOCK_P2POOL_MAIN = 10 # P2Pool Main Block Time -SECOND_PER_BLOCK_P2POOL_MINI = 10 # P2Pool Mini Block Time -SECOND_PER_BLOCK_P2POOL_NANO = 30 # P2Pool Nano Block Time \ No newline at end of file +SECOND_PER_BLOCK_MAIN = 120 # Monero Target block time in seconds diff --git a/build/dashboard/mining_dashboard/helper/utils.py b/build/dashboard/mining_dashboard/helper/utils.py index 64b8f041..45f251e7 100644 --- a/build/dashboard/mining_dashboard/helper/utils.py +++ b/build/dashboard/mining_dashboard/helper/utils.py @@ -1,16 +1,18 @@ -import time -import socket import ipaddress -from mining_dashboard.config.config import TIER_DEFAULTS +import socket +import time + +from mining_dashboard.config.config import TIER_DEFAULTS, XVB_STATS_STALE_AFTER_S + def parse_hashrate(val_str, unit_str=None): """ Converts a numeric string and an optional unit suffix into raw hashes per second (H/s). - + Args: val_str (str|float): The numeric value (e.g., "1.5"). unit_str (str, optional): The unit suffix (e.g., "MH/s", "kH/s"). - + Returns: float: The standardized hashrate in H/s. Returns 0.0 on parsing failure. """ @@ -18,31 +20,35 @@ def parse_hashrate(val_str, unit_str=None): val = float(val_str) if not unit_str: return val - + # Normalize unit string for case-insensitive comparison unit = unit_str.lower() - - if "gh" in unit: return val * 1_000_000_000 - if "mh" in unit: return val * 1_000_000 - if "kh" in unit: return val * 1_000 - + + if "gh" in unit: + return val * 1_000_000_000 + if "mh" in unit: + return val * 1_000_000 + if "kh" in unit: + return val * 1_000 + return val except (ValueError, TypeError): return 0.0 + def format_hashrate(hashrate): """ Formats a raw hashrate value into a human-readable string with appropriate units. - + Args: hashrate (float): The raw hashrate in H/s. - + Returns: str: Formatted string (e.g., "1.25 MH/s"). """ try: val = float(hashrate) - + if val >= 1_000_000_000: return f"{val / 1_000_000_000:.2f} GH/s" elif val >= 1_000_000: @@ -51,22 +57,23 @@ def format_hashrate(hashrate): return f"{val / 1_000:.2f} kH/s" else: return f"{val:.2f} H/s" - + except (ValueError, TypeError): return "0 H/s" + def format_duration(seconds): """ Formats a duration in seconds into a concise human-readable string. - + Format logic: - > 1 day: "Xd Xh Xm" - > 1 hour: "Xh Xm" - < 1 hour: "Xm Xs" - + Args: seconds (int|float): Duration in seconds. - + Returns: str: Formatted duration string. """ @@ -76,35 +83,76 @@ def format_duration(seconds): hours = (seconds // 3600) % 24 minutes = (seconds // 60) % 60 secs = seconds % 60 - + if days > 0: return f"{days}d {hours}h {minutes}m" if hours > 0: return f"{hours}h {minutes}m" - + return f"{minutes}m {secs}s" - + except (ValueError, TypeError): return "0s" + def format_time_abs(timestamp): """ Converts a Unix timestamp into a localized time string (HH:MM:SS). - + Args: timestamp (float): Unix timestamp. - + Returns: str: Formatted time string or error placeholder. """ if not timestamp: return "Never" - + try: - return time.strftime('%H:%M:%S', time.localtime(timestamp)) + return time.strftime("%H:%M:%S", time.localtime(timestamp)) except (ValueError, OSError, TypeError): return "Invalid Time" + +def xvb_stats_are_stale(xvb_stats): + """True when the XvB stats fetch has gone quiet long enough that ``avg_1h`` / + ``avg_24h`` are no longer trustworthy live readings (#311). + + ``last_update`` bumps ONLY on a genuine xmrvsbeast.com fetch (#136), so its age + is the fetch age. A zero ``last_update`` means we've never fetched (cold start) — + NOT stale. Shared by the donation controller (which holds its split rather than + steering off a frozen number) and the dashboard (which greys the credited figures) + so the two never disagree about what "stale" means.""" + last_update = (xvb_stats or {}).get("last_update", 0) or 0 + return last_update > 0 and (time.time() - last_update) > XVB_STATS_STALE_AFTER_S + + +# PPLNS window math, shared by metrics (display), the donation controller (routing +# decisions), and XvB auto-register (eligibility) so the three never drift (#263). Nano +# sidechain blocks are 30s; Main/Mini are 10s. P2Pool's default window is 2160 blocks. +PPLNS_BLOCK_TIME_NANO = 30 +PPLNS_BLOCK_TIME_DEFAULT = 10 +DEFAULT_PPLNS_WINDOW = 2160 + + +def pplns_block_time(pool_type): + """Seconds per sidechain block for the given pool type ("Nano" else Main/Mini).""" + return PPLNS_BLOCK_TIME_NANO if pool_type == "Nano" else PPLNS_BLOCK_TIME_DEFAULT + + +def shares_in_pplns_window(shares, pplns_window, block_time, now=None): + """Count shares whose timestamp falls within the PPLNS window. + + The window spans ``pplns_window`` blocks of ``block_time`` seconds each; a share with + ``ts >= now - pplns_window * block_time`` counts. ``now`` defaults to ``time.time()`` + and is injectable for tests. + """ + if now is None: + now = time.time() + cutoff = now - pplns_window * block_time + return sum(1 for s in shares if s.get("ts", 0) >= cutoff) + + def get_tier_info(hashrate, tiers=None): """ Determines the donation tier based on hashrate. @@ -124,6 +172,7 @@ def get_tier_info(hashrate, tiers=None): return "None", 0.0 + def _configured_tier_threshold(tiers, donation_level): """ Maps a configured donation level to a tier threshold (H/s). @@ -154,6 +203,7 @@ def _configured_tier_threshold(tiers, donation_level): except (ValueError, TypeError): return float(positive[0]) + def resolve_target_threshold(tiers, stable_hr, donation_level, max_fraction): """ Resolves the donation tier to aim for. Returns ``(threshold_hs, sustainable)``. diff --git a/build/dashboard/mining_dashboard/main.py b/build/dashboard/mining_dashboard/main.py index 7d4b4033..e1956a4c 100644 --- a/build/dashboard/mining_dashboard/main.py +++ b/build/dashboard/mining_dashboard/main.py @@ -3,20 +3,20 @@ from aiohttp import web +from mining_dashboard.client.xmrig_proxy_client import XMRigProxyClient +from mining_dashboard.client.xvb_client import XvbClient from mining_dashboard.config.config import ( + MONERO_WALLET_ADDRESS, + PROXY_API_PORT, PROXY_AUTH_TOKEN, PROXY_HOST, - PROXY_API_PORT, - MONERO_WALLET_ADDRESS, ) +from mining_dashboard.service.algo_service import AlgoService +from mining_dashboard.service.data_service import DataService from mining_dashboard.service.storage_service import StateManager from mining_dashboard.web.server import create_app -from mining_dashboard.client.xmrig_proxy_client import XMRigProxyClient -from mining_dashboard.client.xvb_client import XvbClient -from mining_dashboard.service.data_service import DataService -from mining_dashboard.service.algo_service import AlgoService -logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("Main") @@ -27,26 +27,28 @@ def build_app() -> web.Application: side effects — nothing opens the database or a network client until the app is built. """ state_manager = StateManager() - proxy_client = XMRigProxyClient(host=PROXY_HOST, port=PROXY_API_PORT, access_token=PROXY_AUTH_TOKEN) + proxy_client = XMRigProxyClient( + host=PROXY_HOST, port=PROXY_API_PORT, access_token=PROXY_AUTH_TOKEN + ) xvb_client = XvbClient(wallet_address=MONERO_WALLET_ADDRESS) data_service = DataService(state_manager, proxy_client, xvb_client) algo_service = AlgoService(state_manager, proxy_client, data_service) async def start_background_tasks(app): """Initializes background services upon web application startup.""" - app['data_task'] = asyncio.create_task(data_service.run()) - app['algo_task'] = asyncio.create_task(algo_service.run()) + app["data_task"] = asyncio.create_task(data_service.run()) + app["algo_task"] = asyncio.create_task(algo_service.run()) async def cleanup_background_tasks(app): """Stops background tasks and closes resources on shutdown.""" - app['data_task'].cancel() - app['algo_task'].cancel() - await asyncio.gather(app['data_task'], app['algo_task'], return_exceptions=True) - if 'state_manager' in app: - app['state_manager'].close() + app["data_task"].cancel() + app["algo_task"].cancel() + await asyncio.gather(app["data_task"], app["algo_task"], return_exceptions=True) + if "state_manager" in app: + app["state_manager"].close() app = create_app(state_manager, data_service.latest_data) - app['state_manager'] = state_manager + app["state_manager"] = state_manager app.on_startup.append(start_background_tasks) app.on_cleanup.append(cleanup_background_tasks) return app @@ -57,7 +59,7 @@ def main() -> None: logger.info("Initializing Dashboard Web Server securely on 127.0.0.1:8000") # Bound to localhost (127.0.0.1) so it is inaccessible from the local network directly; # traffic is securely routed through the Caddy proxy. - web.run_app(app, host='127.0.0.1', port=8000, print=None) + web.run_app(app, host="127.0.0.1", port=8000, print=None) if __name__ == "__main__": diff --git a/build/dashboard/mining_dashboard/service/algo_service.py b/build/dashboard/mining_dashboard/service/algo_service.py index 15640e69..930180b7 100644 --- a/build/dashboard/mining_dashboard/service/algo_service.py +++ b/build/dashboard/mining_dashboard/service/algo_service.py @@ -1,28 +1,38 @@ import asyncio import logging -import time import math + from mining_dashboard.config.config import ( - XVB_TIME_ALGO_MS, + ENABLE_XVB, MONERO_WALLET_ADDRESS, - XVB_DONOR_ID, P2POOL_URL, - XVB_POOL_URL, - XVB_MIN_TIME_SEND_MS, - ENABLE_XVB, + UPDATE_INTERVAL, + XVB_CONTROL_GAIN, XVB_DONATION_LEVEL, - XVB_MAX_DONATION_FRACTION, - XVB_MAINT_MARGIN_PCT, + XVB_DONOR_ID, XVB_MAINT_MARGIN_ABS_CAP, - XVB_CONTROL_GAIN, + XVB_MAINT_MARGIN_PCT, + XVB_MAX_DONATION_FRACTION, + XVB_MIN_TIME_SEND_MS, XVB_P2POOL_RESERVE_FACTOR, + XVB_POOL_URL, + XVB_STATS_STALE_AFTER_S, XVB_SWITCH_OVERHEAD_MS, - UPDATE_INTERVAL + XVB_TIME_ALGO_MS, + XVB_TOR_ENABLED, + XVB_TOR_SOCKS5, +) +from mining_dashboard.helper.utils import ( + DEFAULT_PPLNS_WINDOW, + pplns_block_time, + resolve_target_threshold, + shares_in_pplns_window, + xvb_stats_are_stale, ) -from mining_dashboard.helper.utils import resolve_target_threshold logger = logging.getLogger("AlgoService") + class AlgoService: def __init__(self, state_manager, proxy_client, data_service): self.state_manager = state_manager @@ -44,16 +54,24 @@ async def switch_miners(self, mode, state_label=None): """ Configures the upstream pool priority for the XMRig Proxy. """ + # The local p2pool pool dials direct (it's on the bridge). The XvB pool routes through Tor by + # default (#166) — its per-pool `socks5` makes the proxy reach na.xmrvsbeast.com via Tor + # (DNS resolved proxy-side), so donation mining doesn't expose the home IP. `xvb.tor: false` + # opts out. Only the XvB pool gets `socks5`; the local pool never does. + p2pool_pool = { + "url": P2POOL_URL, + "user": MONERO_WALLET_ADDRESS, + "pass": "x", + "coin": "monero", + } + xvb_pool = {"url": XVB_POOL_URL, "user": XVB_DONOR_ID, "pass": "x", "coin": "monero"} + if XVB_TOR_ENABLED: + xvb_pool["socks5"] = XVB_TOR_SOCKS5 + if mode == "P2POOL": - pools = [ - {"url": P2POOL_URL, "user": MONERO_WALLET_ADDRESS, "pass": "x", "enabled": True, "coin": "monero"}, - {"url": XVB_POOL_URL, "user": XVB_DONOR_ID, "pass": "x", "enabled": False, "coin": "monero"} - ] + pools = [{**p2pool_pool, "enabled": True}, {**xvb_pool, "enabled": False}] else: - pools = [ - {"url": XVB_POOL_URL, "user": XVB_DONOR_ID, "pass": "x", "enabled": True, "coin": "monero"}, - {"url": P2POOL_URL, "user": MONERO_WALLET_ADDRESS, "pass": "x", "enabled": False, "coin": "monero"} - ] + pools = [{**xvb_pool, "enabled": True}, {**p2pool_pool, "enabled": False}] try: # Fetch current full configuration to preserve other settings @@ -66,7 +84,7 @@ async def switch_miners(self, mode, state_label=None): # Execute update via Proxy Client with the full configuration await asyncio.to_thread(self.proxy_client.update_config, current_config) - + # Update state manager with the new active mode final_label = state_label if state_label else mode await asyncio.to_thread(self.state_manager.update_xvb_stats, mode=final_label) @@ -74,8 +92,9 @@ async def switch_miners(self, mode, state_label=None): except Exception as e: logger.error(f"Failed to switch proxy mode: {e}") - def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, - shares, advance=True): + def get_decision( + self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, shares, advance=True + ): """ Evaluates the current mining state to determine the next operation mode. @@ -103,25 +122,24 @@ def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats # Constraint: Enforce P2Pool mode if no shares have been found recently. # This uses the same logic as the dashboard UI to count shares within the PPLNS window. - pool_type = p2p_stats.get('type', 'Main') - pplns_window = p2pool_stats.get('pplns_window', 2160) - - block_time = 10 # Default for Main/Mini - if pool_type == "Nano": - block_time = 30 - + pool_type = p2p_stats.get("type", "Main") + pplns_window = p2pool_stats.get("pplns_window", DEFAULT_PPLNS_WINDOW) + block_time = pplns_block_time(pool_type) window_duration = pplns_window * block_time - cutoff = time.time() - window_duration - shares_in_window_count = sum(1 for s in shares if s.get('ts', 0) >= cutoff) + shares_in_window_count = shares_in_pplns_window(shares, pplns_window, block_time) if shares_in_window_count == 0: - logger.info(f"Decision Strategy: Force P2POOL (Zero shares in PPLNS window of {window_duration}s)") + logger.info( + f"Decision Strategy: Force P2POOL (Zero shares in PPLNS window of {window_duration}s)" + ) return "P2POOL", 0 # Constraint: Fallback to P2Pool if XvB endpoint failures exceed threshold. - fail_count = xvb_stats.get('fail_count', 0) + fail_count = xvb_stats.get("fail_count", 0) if fail_count >= 3: - logger.warning(f"Decision Strategy: Force P2POOL (Excessive XvB failures: {fail_count})") + logger.warning( + f"Decision Strategy: Force P2POOL (Excessive XvB failures: {fail_count})" + ) return "P2POOL", 0 # Highest tier we can sustain, capped by the configured donation level. @@ -134,12 +152,24 @@ def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats # Cap the donated fraction so p2pool keeps finding shares (VIP status). max_fraction = self._max_donation_fraction(current_hr, window_duration, p2pool_stats) - avg_1h = xvb_stats.get('avg_1h', 0) - avg_24h = xvb_stats.get('avg_24h', 0) + avg_1h = xvb_stats.get("avg_1h", 0) + avg_24h = xvb_stats.get("avg_24h", 0) # Advance the calibration loop once per real cycle (not during _smart_sleep). + # But never steer off a stale read (#311): if the xmrvsbeast.com fetch has gone + # quiet, avg_1h is frozen and stepping the loop would wind the fraction up + # against a target we can't refresh. Hold the last split until a fresh read lands. if advance: - self._advance_controller(current_hr, target_hr, avg_1h, max_fraction) + if self._stats_are_stale(xvb_stats): + logger.warning( + "XvB stats stale (no fetch in >%.0fs): holding donation fraction at %.3f " + "(frozen 1h %.0f)", + XVB_STATS_STALE_AFTER_S, + self.donation_fraction or 0.0, + avg_1h, + ) + else: + self._advance_controller(current_hr, target_hr, avg_1h, max_fraction) fraction = min(self.donation_fraction or 0.0, max_fraction) needed_time_ms = self._fraction_to_ms(fraction) @@ -156,11 +186,15 @@ def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats needed_time_ms = XVB_TIME_ALGO_MS if needed_time_ms >= XVB_TIME_ALGO_MS: - logger.info(f"Decision: Full XVB cycle (target {target_hr:.0f}; 1h {avg_1h:.0f} / 24h {avg_24h:.0f})") + logger.info( + f"Decision: Full XVB cycle (target {target_hr:.0f}; 1h {avg_1h:.0f} / 24h {avg_24h:.0f})" + ) return "XVB", XVB_TIME_ALGO_MS - logger.info(f"Decision: Split ({needed_time_ms}ms to XvB; frac {fraction:.3f}; " - f"target {target_hr:.0f}; 1h {avg_1h:.0f} / 24h {avg_24h:.0f})") + logger.info( + f"Decision: Split ({needed_time_ms}ms to XvB; frac {fraction:.3f}; " + f"target {target_hr:.0f}; 1h {avg_1h:.0f} / 24h {avg_24h:.0f})" + ) return "SPLIT", int(needed_time_ms) def _get_target_donation_hr(self, stable_hr): @@ -175,6 +209,13 @@ def _get_target_donation_hr(self, stable_hr): ) return target + def _stats_are_stale(self, xvb_stats): + """A stale XvB read freezes ``avg_1h`` (#311); holding the split beats steering + off a frozen number. Shared with the dashboard via ``xvb_stats_are_stale`` so + the controller and the UI agree on staleness. Cold start (no fetch yet) is NOT + stale — the feedforward ramp must be left to seed and climb to tier.""" + return xvb_stats_are_stale(xvb_stats) + def _reference_hr(self, target_hr): """Hashrate the controller holds XvB's 1h average at: the tier threshold plus a small, noise-covering cushion (capped in absolute H/s). The raffle @@ -194,7 +235,7 @@ def _max_donation_fraction(self, current_hr, window_duration, p2pool_stats): headroom against variance. When difficulty is unknown (stats not ready), fall back to the flat hard cap. """ - difficulty = p2pool_stats.get('difficulty', 0) or 0 + difficulty = p2pool_stats.get("difficulty", 0) or 0 if current_hr <= 0 or difficulty <= 0 or window_duration <= 0: return self.max_donation_fraction @@ -275,13 +316,29 @@ async def _smart_sleep(self, duration_sec, check_interval_sec=None): # XvB's 1h average has slipped below the tier and we need to catch # up — so the next cycle reacts in seconds, not after the full dwell. decision, _ = self.get_decision( - current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, shares, + current_hr, + stable_hr, + p2pool_stats, + p2p_stats, + xvb_stats, + shares, advance=False, ) + # The "under tier -> catch up early" override acts directly on avg_1h, + # so suppress it when the read is stale (#311): a frozen below-tier + # number would otherwise cut every p2pool dwell short and drift the + # effective split far past the computed fraction. The held decision + # (XVB/SPLIT) still ends the dwell — only the avg-driven override pauses. target_hr = self._get_target_donation_hr(stable_hr) - under_tier = target_hr > 0 and xvb_stats.get('avg_1h', 0) < target_hr + under_tier = ( + not self._stats_are_stale(xvb_stats) + and target_hr > 0 + and xvb_stats.get("avg_1h", 0) < target_hr + ) if decision in ("XVB", "SPLIT") or under_tier: - logger.info("Smart-sleep: donation target needs attention — ending P2Pool dwell early.") + logger.info( + "Smart-sleep: donation target needs attention — ending P2Pool dwell early." + ) return except Exception as e: logger.debug(f"Smart-sleep check error: {e}") @@ -292,8 +349,8 @@ async def run(self): Determines the optimal mining mode and manages worker switching cycles. """ logger.info("Service Started: Algorithm Control Loop") - await asyncio.sleep(5) - + await asyncio.sleep(5) + while True: try: # While workers are rejected (a node is down, Issue #31) the proxy is @@ -320,9 +377,11 @@ async def run(self): p2p_stats = p2pool_data.get("p2p", {}) xvb_stats = self.state_manager.get_xvb_stats() shares = latest_data.get("shares", []) - + # Execute decision logic - decision, xvb_duration = self.get_decision(current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, shares) + decision, xvb_duration = self.get_decision( + current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, shares + ) # Record the fraction of this cycle actually routed to XvB so the # dashboard can show routed-vs-credited (the live credit factor). @@ -353,4 +412,4 @@ async def run(self): except Exception as e: logger.error(f"Algorithm Error: {e}") - await asyncio.sleep(10) \ No newline at end of file + await asyncio.sleep(10) diff --git a/build/dashboard/mining_dashboard/service/clearnet_sync.py b/build/dashboard/mining_dashboard/service/clearnet_sync.py index c887dc1e..fc666b15 100644 --- a/build/dashboard/mining_dashboard/service/clearnet_sync.py +++ b/build/dashboard/mining_dashboard/service/clearnet_sync.py @@ -7,8 +7,10 @@ # and was SIGKILL'd), and Docker holds the stop request open until the container is down — so the # HTTP timeout MUST exceed the stop deadline, or the stop call aborts early, reports failure, and # (with the old `stop and start`) skipped the start entirely, leaving the daemon down. -_RESTART_STOP_TIMEOUT = 30 # seconds Docker waits (SIGTERM → SIGKILL) for the daemon to stop -_RESTART_HTTP_TIMEOUT = 60 # HTTP timeout for the stop/start calls; must exceed _RESTART_STOP_TIMEOUT +_RESTART_STOP_TIMEOUT = 30 # seconds Docker waits (SIGTERM → SIGKILL) for the daemon to stop +_RESTART_HTTP_TIMEOUT = ( + 60 # HTTP timeout for the stop/start calls; must exceed _RESTART_STOP_TIMEOUT +) class ClearnetSyncSupervisor: @@ -60,8 +62,9 @@ def _write_marker(self, name): fh.write("clearnet initial sync complete; node returned to Tor (#234)\n") return True except OSError as exc: - logger.error("%s: could not write Tor-resync marker at %s: %s", - name, self.marker_path(name), exc) + logger.error( + "%s: could not write Tor-resync marker at %s: %s", name, self.marker_path(name), exc + ) return False async def maybe_transition(self, name, container, flag_on, synced): @@ -84,15 +87,17 @@ async def maybe_transition(self, name, container, flag_on, synced): # and a reboot would re-expose the node. Stay exposed and retry next cycle instead. if not self._write_marker(name): return True - logger.warning("%s: CLEARNET initial sync complete — switching %s back to Tor (#234).", - name, container) + logger.warning( + "%s: CLEARNET initial sync complete — switching %s back to Tor (#234).", name, container + ) # Stop with a generous window + an HTTP timeout that OUTLASTS it, then ALWAYS start. The old # `stop and start` left a slow-stopping daemon down: when stop's HTTP call timed out before # the container finished stopping, `and` short-circuited and start was never called (#234: # Tari took >5s to stop and never came back). `ok` is now the START result — the container # must end up running; a stop hiccup can no longer skip the start. - await self.docker_control.stop(container, stop_timeout=_RESTART_STOP_TIMEOUT, - request_timeout=_RESTART_HTTP_TIMEOUT) + await self.docker_control.stop( + container, stop_timeout=_RESTART_STOP_TIMEOUT, request_timeout=_RESTART_HTTP_TIMEOUT + ) ok = await self.docker_control.start(container, request_timeout=_RESTART_HTTP_TIMEOUT) if ok: self._flipped.add(name) @@ -100,8 +105,12 @@ async def maybe_transition(self, name, container, flag_on, synced): else: # Restart failed: do NOT mark flipped, so we retry next cycle. The marker is already on # disk, so any start (this retry, a manual restart, a reboot) brings the node up on Tor. - logger.error("%s: restart of %s onto Tor failed — will retry next cycle (the marker is " - "set, so any restart comes up Tor-only).", name, container) + logger.error( + "%s: restart of %s onto Tor failed — will retry next cycle (the marker is " + "set, so any restart comes up Tor-only).", + name, + container, + ) if self.on_transition is not None: try: self.on_transition(name, ok) diff --git a/build/dashboard/mining_dashboard/service/data_service.py b/build/dashboard/mining_dashboard/service/data_service.py index 159a4d4e..3c6366db 100644 --- a/build/dashboard/mining_dashboard/service/data_service.py +++ b/build/dashboard/mining_dashboard/service/data_service.py @@ -2,32 +2,55 @@ import logging import os import time + from aiohttp import ClientSession +from mining_dashboard.client.docker.docker_control import DockerControl +from mining_dashboard.client.tari.tari_client import TariClient +from mining_dashboard.client.xmrig_client import XMRigWorkerClient +from mining_dashboard.client.xvb_client import ( + REG_INVALID, + REG_NOT_ELIGIBLE, + REG_OK, +) +from mining_dashboard.collector.logs import get_monero_sync_status +from mining_dashboard.collector.pools import ( + get_network_stats, + get_p2pool_stats, + get_stratum_stats, + get_tari_stats, +) +from mining_dashboard.collector.system import ( + get_cpu_usage, + get_disk_usage, + get_hugepages_status, + get_load_average, + get_memory_usage, +) from mining_dashboard.config.config import ( - UPDATE_INTERVAL, - TARI_REQUIRED, - REJECT_WORKERS_CONTAINER, - SYNC_GATE_CONTAINERS, - ENABLE_XVB, - WORKER_FALLOFF_SEC, CHECK_FOR_UPDATES, + CLEARNET_STATE_DIR, + ENABLE_XVB, GITHUB_RELEASES_API, - UPDATE_CHECK_INTERVAL, - XVB_TOR_PROXY, MONERO_CLEARNET_SYNC, + REJECT_WORKERS_CONTAINER, + SYNC_GATE_CONTAINERS, TARI_CLEARNET_SYNC, - CLEARNET_STATE_DIR, + TARI_REQUIRED, + UPDATE_CHECK_INTERVAL, + UPDATE_INTERVAL, + WORKER_FALLOFF_SEC, + XVB_REGISTER_INTERVAL_S, + XVB_TOR_PROXY, +) +from mining_dashboard.helper.utils import ( + DEFAULT_PPLNS_WINDOW, + pplns_block_time, + shares_in_pplns_window, ) -from mining_dashboard.service.update_checker import GitHubReleaseClient, UpdateChecker -from mining_dashboard.client.xmrig_client import XMRigWorkerClient -from mining_dashboard.client.tari.tari_client import TariClient -from mining_dashboard.client.docker.docker_control import DockerControl from mining_dashboard.service.clearnet_sync import ClearnetSyncSupervisor -from mining_dashboard.collector.pools import get_p2pool_stats, get_network_stats, get_stratum_stats, get_tari_stats -from mining_dashboard.collector.logs import get_monero_sync_status -from mining_dashboard.collector.system import get_disk_usage, get_hugepages_status, get_memory_usage, get_load_average, get_cpu_usage from mining_dashboard.service.node_health import NodeHealthMonitor +from mining_dashboard.service.update_checker import GitHubReleaseClient, UpdateChecker logger = logging.getLogger("DataService") @@ -35,21 +58,27 @@ # normalization below isn't a wall of magic indices. A row has >= _PX_MIN_FIELDS entries. _PX_NAME = 0 _PX_IP = 1 -_PX_CONNECTIONS = 2 # active connections; 0 means a stale/disconnected worker -_PX_ACCEPTED = 3 # accepted shares (cumulative) -_PX_REJECTED = 4 # rejected shares (cumulative) -_PX_INVALID = 5 # invalid shares (cumulative) -_PX_LAST_SHARE_MS = 7 # epoch ms of the last accepted share -_PX_HR_1M = 8 # 1-minute hashrate, kH/s -_PX_HR_10M = 9 # 10-minute hashrate, kH/s -_PX_HR_1H = 10 # 1-hour hashrate, kH/s (#168) -_PX_HR_12H = 11 # 12-hour hashrate, kH/s (#168) -_PX_HR_24H = 12 # 24-hour hashrate, kH/s (#168) +_PX_CONNECTIONS = 2 # active connections; 0 means a stale/disconnected worker +_PX_ACCEPTED = 3 # accepted shares (cumulative) +_PX_REJECTED = 4 # rejected shares (cumulative) +_PX_INVALID = 5 # invalid shares (cumulative) +_PX_LAST_SHARE_MS = 7 # epoch ms of the last accepted share +_PX_HR_1M = 8 # 1-minute hashrate, kH/s +_PX_HR_10M = 9 # 10-minute hashrate, kH/s +_PX_HR_1H = 10 # 1-hour hashrate, kH/s (#168) +_PX_HR_12H = 11 # 12-hour hashrate, kH/s (#168) +_PX_HR_24H = 12 # 24-hour hashrate, kH/s (#168) _PX_MIN_FIELDS = 13 # xmrig-proxy reports hashrate in kH/s; the dashboard works in H/s. _KHS_TO_HS = 1000 +# Consecutive XvB-registration failures (while never yet registered) before we raise the dashboard +# "registration failing" warning (#263). A couple of transient blips during the normal first-share +# window shouldn't alarm; a configured-but-refusing endpoint should. At one attempt per 10th poll +# (~5 min) this is ~15 min of sustained failure. +_XVB_REGISTER_FAIL_ALERT = 3 + def _parse_proxy_list_worker(w): """Parse one xmrig-proxy 6.x positional row into a worker dict. @@ -171,23 +200,31 @@ def _merge_direct_stats(workers, results, active_pool_port): unreachable (falsy ``extra_stats``) the worker keeps its proxy-derived hashrate/uptime and stays online — the proxy already confirmed it's connected and submitting shares — rather than dropping out of the hashrate total and reading zero (Fixes #28). Each - worker is tagged with ``active_pool`` for the UI badge. + worker is tagged with ``active_pool`` for the UI badge, and with ``api_ok`` (True/False) when + the worker API was probed so the UI can flag a worker whose direct API is misconfigured / + unreachable — distinct from a worker that's simply offline. """ final_workers = [] - for w, extra_stats in zip(workers, results): - if extra_stats: - w['uptime'] = extra_stats.get('uptime', w['uptime']) + for w, extra_stats in zip(workers, results, strict=False): + # api_ok: True (probe succeeded), False (probe failed — surfaced, not swallowed), or unset + # (worker we deliberately didn't probe, e.g. an internal/invalid IP per the SSRF guard). + api_ok = extra_stats.get("api_ok") if extra_stats else None + if api_ok is not None: + w["api_ok"] = api_ok - is_proxy = extra_stats.get('kind') == 'proxy' + if api_ok: # only a successful probe carries uptime + per-miner hashrate + w["uptime"] = extra_stats.get("uptime", w["uptime"]) + + is_proxy = extra_stats.get("kind") == "proxy" hr_scale = _KHS_TO_HS if is_proxy else 1 - hr_total = extra_stats.get('hashrate', {}).get('total', []) + hr_total = extra_stats.get("hashrate", {}).get("total", []) if isinstance(hr_total, list) and len(hr_total) >= 3: - w['h10'] = (hr_total[0] or 0) * hr_scale - w['h60'] = (hr_total[1] or 0) * hr_scale - w['h15'] = (hr_total[2] or 0) * hr_scale + w["h10"] = (hr_total[0] or 0) * hr_scale + w["h60"] = (hr_total[1] or 0) * hr_scale + w["h15"] = (hr_total[2] or 0) * hr_scale - w['active_pool'] = active_pool_port + w["active_pool"] = active_pool_port final_workers.append(w) return final_workers @@ -202,14 +239,14 @@ def _aggregate_hashrate(workers): total_hr = 0 total_h10 = 0 for w in workers: - if w.get('status') == 'online': - w_hr = w.get('h15', 0) + if w.get("status") == "online": + w_hr = w.get("h15", 0) if w_hr == 0: - w_hr = w.get('h60', 0) + w_hr = w.get("h60", 0) if w_hr == 0: - w_hr = w.get('h10', 0) + w_hr = w.get("h10", 0) total_hr += w_hr - total_h10 += w.get('h10', 0) + total_h10 += w.get("h10", 0) return total_hr, total_h10 @@ -227,7 +264,7 @@ def _aggregate_window_hashrates(workers): """ totals = {win: 0 for win in _WINDOW_WORKER_KEYS} for w in workers: - if w.get('status') == 'online': + if w.get("status") == "online": for win, src in _WINDOW_WORKER_KEYS.items(): totals[win] += w.get(src, 0) or 0 return totals @@ -260,7 +297,7 @@ class WorkerLifecycle: (any positive value is left untouched). - ``last_active`` — the last time it was seen online. An offline worker falls off the table once it's been inactive longer than ``falloff_sec`` (#182); a reconnect re-adds it. Operates purely - on the live proxy-sourced list, never the dead ``known_workers`` path (#144). + on the live proxy-sourced worker list. Pure given (workers, now) plus its accumulated state, so it unit-tests without the data loop. Mutates each surviving online worker's ``uptime`` in place and returns the filtered list. @@ -268,7 +305,7 @@ class WorkerLifecycle: def __init__(self, falloff_sec): self.falloff_sec = falloff_sec - self._state = {} # name -> {"connected_since": float | None, "last_active": float} + self._state = {} # name -> {"connected_since": float | None, "last_active": float} def update(self, workers, now): live = [] @@ -278,18 +315,18 @@ def update(self, workers, now): seen.add(name) st = self._state.setdefault(name, {"connected_since": None, "last_active": 0.0}) if w.get("status") == "online": - if st["connected_since"] is None: # new connection or a reconnect + if st["connected_since"] is None: # new connection or a reconnect st["connected_since"] = now st["last_active"] = now - if not w.get("uptime"): # no real (direct-API) uptime → track it + if not w.get("uptime"): # no real (direct-API) uptime → track it w["uptime"] = int(now - st["connected_since"]) live.append(w) else: - st["connected_since"] = None # disconnected — uptime restarts on reconnect + st["connected_since"] = None # disconnected — uptime restarts on reconnect if st["last_active"] == 0.0: - st["last_active"] = now # first seen already offline + st["last_active"] = now # first seen already offline if now - st["last_active"] <= self.falloff_sec: - live.append(w) # recently-offline rows stay (shown as DOWN) + live.append(w) # recently-offline rows stay (shown as DOWN) # else: fall off — drop the ghost row # Forget ONLY workers the proxy no longer reports at all. A worker that has aged out of the # live table but is STILL reported (offline) must be KEPT in state so its `last_active` @@ -307,6 +344,7 @@ class DataService: Core service responsible for aggregating mining statistics from various sources (Local collectors, XMRig Proxy, Tari Node, etc.) and maintaining the application state. """ + def __init__(self, state_manager, proxy_client, xvb_client): self.state_manager = state_manager self.proxy_client = proxy_client @@ -321,6 +359,14 @@ def __init__(self, state_manager, proxy_client, xvb_client): enabled=CHECK_FOR_UPDATES, interval=UPDATE_CHECK_INTERVAL, ) + # XvB raffle auto-registration (#263): wall-clock of the last successful register() call, + # None until the wallet is first entered. Drives the daily re-register cadence below. + self._xvb_last_registered = None + # Consecutive transient register() failures while never-yet-registered (drives the "failing" + # badge), and a latch that stops retrying once the endpoint calls the wallet invalid — a + # permanent error that won't fix itself on retry (#263). + self._xvb_register_failures = 0 + self._xvb_invalid_wallet = False self.latest_data = { "workers": [], @@ -339,7 +385,7 @@ def __init__(self, state_manager, proxy_client, xvb_client): "workers_rejected": False, "miner_released": False, "miner_held": False, - "timestamp": 0 + "timestamp": 0, } # Node-down detection + optional worker rejection (Issue #31). @@ -350,7 +396,8 @@ def __init__(self, state_manager, proxy_client, xvb_client): # the same docker control proxy as the #31 failover (start/stop only). on_transition surfaces # the event into the snapshot so the UI/status can reflect "switched back to Tor". self.clearnet_supervisor = ClearnetSyncSupervisor( - CLEARNET_STATE_DIR, self.docker_control, + CLEARNET_STATE_DIR, + self.docker_control, on_transition=self._on_clearnet_transition, ) # Per-chain "currently exposed on clearnet" flags, surfaced in the snapshot for the UI/banner. @@ -401,8 +448,7 @@ async def _apply_worker_rejection(self, monero_down, tari_down): # Readmit only once every node we reject on is confirmed healthy (not merely 'not # down'), so a dashboard restart mid-outage doesn't bring workers back to a still-down # stack. Tari's health is ignored when it's non-blocking. - recovered = self.monero_health.healthy and \ - ((not TARI_REQUIRED) or self.tari_health.healthy) + recovered = self.monero_health.healthy and ((not TARI_REQUIRED) or self.tari_health.healthy) if self.workers_rejected and recovered: logger.info( f"Required nodes recovered — starting {REJECT_WORKERS_CONTAINER} to readmit workers." @@ -457,12 +503,110 @@ async def _apply_sync_gate(self, gate_satisfied): f"until synced." ) + async def _sync_xvb_stats(self): + """ + Fetch XvB's reported averages (avg_1h/avg_24h/fail_count) over Tor and persist them. + + A failed fetch (Tor timeout, 5xx) returns None — we write NOTHING in that case, leaving the + last-good values AND ``last_update`` frozen. That frozen ``last_update`` is exactly what the + controller and dashboard read to detect a stale feed and stop steering off a dead number + (#311). So "no write on failure" is a correctness precondition, not just an optimisation — + if this ever started stamping on failure, the staleness guard would silently never trigger. + + The caller already gated on ENABLE_XVB + the 10th-iteration throttle. + """ + real_xvb_stats = await asyncio.to_thread(self.xvb_client.get_stats) + if not real_xvb_stats: + return # fetch failed — keep the last reading + last_update frozen so #311 can detect it + await asyncio.to_thread(self.state_manager.update_xvb_stats, **real_xvb_stats) + logger.info(f"External Sync: XvB Stats Updated (1h={real_xvb_stats['avg_1h']:.0f} H/s)") + + async def _maybe_register_xvb(self, shares, p2pool_stats): + """ + Auto-enter the wallet into the XvB raffle once it's eligible (#263). + + Mining to the XvB pool doesn't enter a wallet — it must be registered against the operator's + endpoint, which only takes effect once the wallet has a share in the P2Pool PPLNS window. So + we gate on a PPLNS share existing (same window math as the dashboard/algo) and skip silently + until then, retrying on the next poll. After the first success we re-register on a daily + cadence (XVB_REGISTER_INTERVAL_S): registration is idempotent, and re-running picks up the + operator's newer security-token behaviour and re-enters a long-offline miner cleanly. + + The caller already gated on ENABLE_XVB + the 10th-iteration throttle. Edge cases are handled + from the endpoint's real contract (see XvbClient.register): "already registered" is the + idempotent steady state (success); an invalid wallet is permanent (latch + warn, stop + retrying); transient errors escalate to a "failing" badge only after a few attempts. + register() routes over Tor. + """ + # Nothing to do if registration is disabled (XVB_SUBMIT_URL off) or the wallet was already + # rejected as permanently invalid — both are terminal for this process, skip quietly. + if not self.xvb_client.submit_url or self._xvb_invalid_wallet: + return + + # PPLNS-share check — mirrors metrics/algo: a share counts if it's within pplns_window + # blocks (30s/block on Nano, else 10s) of now. + pool_type = p2pool_stats.get("p2p", {}).get("type", "Main") + pplns_window = p2pool_stats.get("pool", {}).get("pplns_window", DEFAULT_PPLNS_WINDOW) + block_time = pplns_block_time(pool_type) + if shares_in_pplns_window(shares, pplns_window, block_time) == 0: + return # no eligible share yet — the endpoint would no-op, so don't call it + + now = time.time() + if self._xvb_last_registered is not None and ( + now - self._xvb_last_registered < XVB_REGISTER_INTERVAL_S + ): + return # already registered recently; next re-register isn't due yet + + status = await asyncio.to_thread(self.xvb_client.register) + + if status == REG_OK: + # Fresh registration OR the idempotent "already registered" steady state — either way the + # wallet is in the raffle. Stamp it and clear the transient-failure counter. + self._xvb_last_registered = now + self._xvb_register_failures = 0 + await asyncio.to_thread( + self.state_manager.update_xvb_stats, + registered_at=now, + registration_state="registered", + ) + logger.info("External Sync: Registered wallet with XvB raffle ✓") + elif status == REG_INVALID: + # Permanent: the endpoint won't accept this wallet, and it won't change on retry. Latch + # off, warn once, and surface it — don't hammer the endpoint every poll. + self._xvb_invalid_wallet = True + logger.warning( + "XvB registration rejected MONERO_WALLET_ADDRESS as invalid — auto-registration " + "disabled. The XvB raffle needs a standard primary Monero address (4…). (#263)" + ) + await asyncio.to_thread( + self.state_manager.update_xvb_stats, registration_state="invalid" + ) + elif status == REG_NOT_ELIGIBLE: + # The share we see locally hasn't propagated to XvB yet — not a failure, just retry next + # poll. Don't count it toward the "failing" escalation. + return + else: + # Transient (network / 5xx / unrecognised). register() already logged specifics. Only + # escalate to a dashboard warning once it's *persistently* failing AND we've never + # succeeded — a blip while the first share propagates shouldn't alarm. (A failed daily + # re-register after a prior success keeps the "registered ✓"; we're still entered.) + self._xvb_register_failures += 1 + if ( + self._xvb_last_registered is None + and self._xvb_register_failures >= _XVB_REGISTER_FAIL_ALERT + ): + await asyncio.to_thread( + self.state_manager.update_xvb_stats, registration_state="failing" + ) + def _on_clearnet_transition(self, name, ok): """Called by the supervisor after a clearnet→Tor flip attempt (#234).""" if ok: logger.info("%s returned to Tor after its clearnet initial sync (#234).", name) else: - logger.warning("%s clearnet→Tor switch did not complete this cycle — will retry (#234).", name) + logger.warning( + "%s clearnet→Tor switch did not complete this cycle — will retry (#234).", name + ) async def run(self): """ @@ -470,13 +614,13 @@ async def run(self): Updates the `latest_data` state and persists historical metrics to the database. """ logger.info("Service Started: Data Collection Loop") - - iteration_count = 0 - + + iteration_count = 0 + async with ClientSession() as session: worker_client = XMRigWorkerClient(session) - tari_client = TariClient(session) - + tari_client = TariClient() + # P2Pool shares are recorded from the cumulative shares_found counter (#129); None until # the first poll baselines it, so we never backfill the whole historical count on startup # or re-record what the DB already loaded. @@ -485,8 +629,8 @@ async def run(self): while True: try: # 1. Collect Local Statistics (High Frequency Polling) - stratum_raw, worker_configs = get_stratum_stats() - + stratum_raw = get_stratum_stats() + # 2. Fetch Worker Statistics from XMRig Proxy + normalize the payload. proxy_workers = [] try: @@ -507,13 +651,15 @@ async def run(self): logger.error(f"Proxy Summary Fetch Error: {e}") # 3. Augment with Direct Worker Stats (Uptime, Hashrate) via Local API - tasks = [worker_client.get_stats(w['ip'], w['name']) for w in proxy_workers] + tasks = [worker_client.get_stats(w["ip"], w["name"]) for w in proxy_workers] worker_results = await asyncio.gather(*tasks) current_mode = self.state_manager.get_xvb_stats().get("current_mode", "P2POOL") # Determine active pool port for UI badges based on current Algo mode active_pool_port = "3344" if "XVB" in current_mode else "3333" - final_workers = _merge_direct_stats(proxy_workers, worker_results, active_pool_port) + final_workers = _merge_direct_stats( + proxy_workers, worker_results, active_pool_port + ) # 3b. Track per-worker connection lifecycle: fill true uptime for online workers # (#169) and drop stale offline rows past the fall-off window (#182). final_workers = self._lifecycle.update(final_workers, time.time()) @@ -532,10 +678,13 @@ async def run(self): current_share_ts = p2pool_stats["pool"].get("last_share_time", 0) current_shares_total = p2pool_stats["pool"].get("shares_found", 0) new_shares, last_known_shares_total = _shares_to_record( - last_known_shares_total, current_shares_total) + last_known_shares_total, current_shares_total + ) if new_shares > 0 and current_share_ts > 0: difficulty = p2pool_stats["pool"].get("difficulty", 0) - await asyncio.to_thread(self.state_manager.add_shares, new_shares, current_share_ts, difficulty) + await asyncio.to_thread( + self.state_manager.add_shares, new_shares, current_share_ts, difficulty + ) monero_sync = await get_monero_sync_status() tari_sync = await tari_client.get_sync_status() @@ -547,101 +696,107 @@ async def run(self): # (that's what #31's node-down handling is for). Reading the raw signal # also avoids a deadlock: the height override is fed by p2pool's stats # file, which reads 0 while p2pool is held — falsely "syncing" forever. - monero_synced = monero_sync.get('reachable', True) and not monero_sync.get('is_syncing', False) - tari_synced = tari_sync.get('reachable', True) and not tari_sync.get('is_syncing', False) + monero_synced = monero_sync.get("reachable", True) and not monero_sync.get( + "is_syncing", False + ) + tari_synced = tari_sync.get("reachable", True) and not tari_sync.get( + "is_syncing", False + ) # Auto-transition a clearnet initial-sync node back to Tor once it's synced # (#234). Reuses the synced signals above; the supervisor writes a persistent # marker + restarts the daemon (which then comes up Tor-only). Returns whether # each chain is still EXPOSED on clearnet, for the UI banner. monero_clearnet_exposed = await self.clearnet_supervisor.maybe_transition( - "monero", "monerod", MONERO_CLEARNET_SYNC, monero_synced) + "monero", "monerod", MONERO_CLEARNET_SYNC, monero_synced + ) tari_clearnet_exposed = await self.clearnet_supervisor.maybe_transition( - "tari", "tari", TARI_CLEARNET_SYNC, tari_synced) + "tari", "tari", TARI_CLEARNET_SYNC, tari_synced + ) self.clearnet_sync_state = { "monero": monero_clearnet_exposed, "tari": tari_clearnet_exposed, "active": monero_clearnet_exposed or tari_clearnet_exposed, } - # Determine effective Tari status for UI display - tari_active = tari_stats.get('active', False) - tari_status_str = tari_stats.get('status', 'Waiting...') if tari_active else 'Waiting...' - # Apply Sync Logic Overrides # 1. Monero Sync Check - if network_stats.get('height', 0) == 0: - monero_sync['is_syncing'] = True - if 'percent' not in monero_sync: - monero_sync.update({'percent': 0, 'current': 0, 'target': 1}) - + if network_stats.get("height", 0) == 0: + monero_sync["is_syncing"] = True + if "percent" not in monero_sync: + monero_sync.update({"percent": 0, "current": 0, "target": 1}) + # 2. Global Sync Logic. monerod always drives the full-screen Sync Mode; # Tari does so only when it's required (Issue #51). A non-blocking Tari # (dashboard.tari_required:false) keeps the operational view and surfaces # its progress in the Tari panel instead of hijacking the whole dashboard. - is_monero_syncing = monero_sync.get('is_syncing', False) - is_tari_syncing = tari_sync.get('is_syncing', False) + is_monero_syncing = monero_sync.get("is_syncing", False) + is_tari_syncing = tari_sync.get("is_syncing", False) global_sync = is_monero_syncing or (is_tari_syncing and TARI_REQUIRED) # True when Tari is syncing but we're staying in the operational view — the # UI shows a "Tari syncing" indicator rather than the takeover screen. tari_syncing_passive = is_tari_syncing and not global_sync if global_sync: - if not is_monero_syncing and 'percent' not in monero_sync: - h = network_stats.get('height', 1) - monero_sync.update({'percent': 100, 'current': h, 'target': h}) - if not is_tari_syncing and 'percent' not in tari_sync: - h = tari_stats.get('height', 0) - tari_sync.update({'percent': 100, 'current': h, 'target': h}) + if not is_monero_syncing and "percent" not in monero_sync: + h = network_stats.get("height", 1) + monero_sync.update({"percent": 100, "current": h, "target": h}) + if not is_tari_syncing and "percent" not in tari_sync: + h = tari_stats.get("height", 0) + tari_sync.update({"percent": 100, "current": h, "target": h}) # 3. Node-down detection + worker rejection (Issue #31). Debounce each # node's live reachability into a stable DOWN flag; monerod-down always # rejects, Tari-down rejects only when required (handled in the helper). - monero_down = self.monero_health.update(monero_sync.get('reachable', True)) - tari_down = self.tari_health.update(tari_sync.get('reachable', True)) - monero_sync['down'] = monero_down - tari_sync['down'] = tari_down + monero_down = self.monero_health.update(monero_sync.get("reachable", True)) + tari_down = self.tari_health.update(tari_sync.get("reachable", True)) + monero_sync["down"] = monero_down + tari_sync["down"] = tari_down # 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 # once released — before that there are no workers to fail over, and it # keeps the two features from both driving xmrig-proxy. - await self._apply_sync_gate(monero_synced and (tari_synced or not TARI_REQUIRED)) + await self._apply_sync_gate( + monero_synced and (tari_synced or not TARI_REQUIRED) + ) if self.miner_released: await self._apply_worker_rejection(monero_down, tari_down) # Fetch fresh shares list to populate UI shares_list = await asyncio.to_thread(self.state_manager.get_shares) - self.latest_data.update({ - "workers": final_workers, - "proxy_summary": proxy_summary, - "shares": shares_list, - "total_live_h15": total_hr, - "total_live_h10": total_h10, - "pool": p2pool_stats, - "network": network_stats, - "tari": tari_stats, - "monero_sync": monero_sync, - "tari_sync": tari_sync, - "global_sync": global_sync, - "tari_syncing_passive": tari_syncing_passive, - "workers_rejected": self.workers_rejected, - "miner_released": self.miner_released, - "miner_held": self.miner_held, - "clearnet_sync": self.clearnet_sync_state, - "system": { - "disk": get_disk_usage(), - "hugepages": get_hugepages_status(), - "memory": get_memory_usage(), - "load": get_load_average(), - "cpu_percent": get_cpu_usage() - }, - "stratum": stratum_raw, - "timestamp": time.time() - }) - + self.latest_data.update( + { + "workers": final_workers, + "proxy_summary": proxy_summary, + "shares": shares_list, + "total_live_h15": total_hr, + "total_live_h10": total_h10, + "pool": p2pool_stats, + "network": network_stats, + "tari": tari_stats, + "monero_sync": monero_sync, + "tari_sync": tari_sync, + "global_sync": global_sync, + "tari_syncing_passive": tari_syncing_passive, + "workers_rejected": self.workers_rejected, + "miner_released": self.miner_released, + "miner_held": self.miner_held, + "clearnet_sync": self.clearnet_sync_state, + "system": { + "disk": get_disk_usage(), + "hugepages": get_hugepages_status(), + "memory": get_memory_usage(), + "load": get_load_average(), + "cpu_percent": get_cpu_usage(), + }, + "stratum": stratum_raw, + "timestamp": time.time(), + } + ) + # 6. Persist Historical Data is_xvb = "XVB" in current_mode p2pool_hr = 0 if is_xvb else total_hr @@ -657,9 +812,13 @@ async def run(self): } await asyncio.to_thread( - self.state_manager.update_history, total_hr, p2pool_hr, xvb_hr, window_splits + self.state_manager.update_history, + total_hr, + p2pool_hr, + xvb_hr, + window_splits, ) - + # Create a lightweight snapshot (exclude shares entirely as they are safely in DB) snapshot_data = self.latest_data.copy() snapshot_data.pop("shares", None) @@ -668,19 +827,22 @@ async def run(self): # 7. External XvB stats sync over Tor (#163), throttled to every 10th iteration, # and ONLY when XvB is enabled — disabling XvB must stop the egress entirely. if ENABLE_XVB and iteration_count % 10 == 0: - real_xvb_stats = await asyncio.to_thread(self.xvb_client.get_stats) - if real_xvb_stats: - await asyncio.to_thread(self.state_manager.update_xvb_stats, **real_xvb_stats) - logger.info(f"External Sync: XvB Stats Updated (1h={real_xvb_stats['avg_1h']:.0f} H/s)") + await self._sync_xvb_stats() + + # 7b. XvB raffle auto-registration (#263). Rides the same throttle/egress as + # the stats sync (Tor, every 10th poll, XvB-enabled only). Gated on a PPLNS + # share existing — before then the endpoint is a no-op, so we just retry. + await self._maybe_register_xvb(shares_list, p2pool_stats) # 8. New-release check over Tor (#224) — ONLY when explicitly enabled (default off, # so the appliance never phones GitHub unbidden). The checker self-throttles to # hourly and returns the cached result; surfaced as state.update for the header badge. if self.update_checker.enabled: self.latest_data["update"] = await asyncio.to_thread( - self.update_checker.maybe_check, time.time()) + self.update_checker.maybe_check, time.time() + ) iteration_count += 1 except Exception as e: logger.error(f"Data Collection Error: {e}") - await asyncio.sleep(UPDATE_INTERVAL) \ No newline at end of file + await asyncio.sleep(UPDATE_INTERVAL) diff --git a/build/dashboard/mining_dashboard/service/egress.py b/build/dashboard/mining_dashboard/service/egress.py new file mode 100644 index 00000000..99581164 --- /dev/null +++ b/build/dashboard/mining_dashboard/service/egress.py @@ -0,0 +1,272 @@ +"""Egress posture (#170) — for each stack component, its outbound connections and their network +route (Tor / clearnet / local / inactive), plus a privacy roll-up. + +Routes are *derived from the live config*, never hardcoded, so the panel can't drift from reality or +lie after a regression — the #160 audit's lesson (``--onion-address`` *looked* like Tor but wasn't). + +Two backstops matter for whether a clearnet route is actually an IP leak: + +* The **#270 egress firewall** (``DOCKER-USER``, fail-closed) DROPs non-Tor egress from the *container* + subnet — so a container's clearnet route can't actually leave while it's on. +* It does **not** cover the **host-networked dashboard** (``network_mode: host``), whose own egress + (XvB stats fetch, update check) bypasses ``DOCKER-USER`` entirely. Those rely solely on their + SOCKS config — a clearnet route there is a real leak regardless of the firewall. + +So a connection is a *leak* only when its route is clearnet AND it isn't neutralised by a backstop. +""" + +from mining_dashboard.config import config + +TOR = "tor" +CLEARNET = "clearnet" +LOCAL = "local" +INACTIVE = "inactive" + + +def _xvb_route(xvb_enabled, xvb_tor): + if not xvb_enabled: + return INACTIVE + return TOR if xvb_tor else CLEARNET + + +def compute_egress_posture( + *, + firewall, + p2pool_clearnet, + xvb_enabled, + xvb_tor, + monero_clearnet_sync, + tari_clearnet_sync, + remote_monero, +): + """Pure derivation of the egress posture from config knobs. Returns ``{components, summary}``.""" + xvb = _xvb_route(xvb_enabled, xvb_tor) + + # ``firewalled``: is this component's egress on the container subnet the #270 firewall guards? + # The dashboard is host-networked, so its own outbound traffic is NOT covered. + components = [ + { + "name": "monerod", + "firewalled": True, + "conns": [ + {"to": "Monero P2P / tx relay", "route": TOR}, + *( + [{"to": "initial block download (clearnet sync)", "route": CLEARNET}] + if monero_clearnet_sync + else [] + ), + ], + }, + { + "name": "p2pool", + "firewalled": True, + "conns": [ + {"to": "sidechain P2P peers", "route": CLEARNET if p2pool_clearnet else TOR}, + {"to": "monerod RPC/ZMQ", "route": CLEARNET if remote_monero else LOCAL}, + ], + }, + { + "name": "tari", + "firewalled": True, + "conns": [ + {"to": "Tari P2P transport", "route": TOR}, + # dns_seeds=[] (#162); onion peer seeds resolve via Tor — no clearnet DNS. + {"to": "DNS resolution", "route": LOCAL}, + *( + [{"to": "initial sync (clearnet)", "route": CLEARNET}] + if tari_clearnet_sync + else [] + ), + ], + }, + { + "name": "xmrig-proxy", + "firewalled": True, + "conns": [ + {"to": "upstream pool (local p2pool stratum)", "route": LOCAL}, + # XvB donation mining dials na.xmrvsbeast.com via the proxy's per-pool socks5 (#166). + {"to": "XvB donation pool", "route": xvb}, + {"to": "dev donation", "route": INACTIVE}, # --donate-level 0 (#166) + ], + }, + { + "name": "dashboard", + "firewalled": False, # host-networked — bypasses the #270 DOCKER-USER firewall + "conns": [ + {"to": "XvB stats (xmrvsbeast.com)", "route": xvb}, # socks5h when on (#163) + {"to": "update check (github)", "route": TOR}, # socks5h, #224 + ], + }, + { + "name": "caddy", + "firewalled": True, + "conns": [{"to": "TLS (internal CA, no ACME)", "route": LOCAL}], + }, + ] + + leaks = 0 # clearnet egress that actually exposes the host IP + blocked = 0 # clearnet route a container is configured for, but the firewall DROPs it + for comp in components: + for conn in comp["conns"]: + if conn["route"] != CLEARNET: + continue + if comp["firewalled"] and firewall: + conn["blocked_by_firewall"] = True + blocked += 1 + else: + leaks += 1 + + if leaks: + label = f"{leaks} clearnet egress path(s) exposing your IP" + elif blocked: + label = f"All egress via Tor ({blocked} clearnet path(s) blocked by the egress firewall)" + else: + label = "All egress via Tor" + + return { + "components": components, + "summary": { + "firewall": firewall, + "leaks": leaks, + "blocked_by_firewall": blocked, + "all_tor": leaks == 0, + "level": "ok" if leaks == 0 else "warn", + "label": label, + }, + } + + +def egress_posture_from_config(): + """Build the posture from the live dashboard config (values pithead rendered into the env).""" + return compute_egress_posture( + firewall=config.TOR_EGRESS_FIREWALL, + p2pool_clearnet=config.P2POOL_CLEARNET, + xvb_enabled=config.ENABLE_XVB, + xvb_tor=config.XVB_TOR_ENABLED, + monero_clearnet_sync=config.MONERO_CLEARNET_SYNC, + tari_clearnet_sync=config.TARI_CLEARNET_SYNC, + remote_monero=config.MONERO_NODE_HOST != config.LOCAL_MONERO_HOST, + ) + + +# --- Stack topology (#170, trust-boundary view) ---------------------------------------- +# The egress list above answers "is anything leaking?"; the topology answers "how is the whole +# stack wired?" — every component and the route of each link (ingress, egress, internal). Same +# config-derived routes, so the two views can never disagree (the summary is shared verbatim). + +# Zones, left-to-right by trust: your LAN, the host's container bridge, the Tor hub, the Internet. +ZONE_LAN = "lan" +ZONE_HOST = "host" +ZONE_TOR = "tor" +ZONE_NET = "internet" + +# Nodes bracket the host components with the external actors they actually talk to. ``internal`` +# nodes (the socket proxies) only appear when the operator expands the internal mesh. +TOPOLOGY_NODES = [ + {"id": "rigs", "label": "Mining rigs", "zone": ZONE_LAN}, + {"id": "browser", "label": "Browser", "zone": ZONE_LAN}, + {"id": "xmrig-proxy", "label": "xmrig-proxy", "zone": ZONE_HOST}, + {"id": "caddy", "label": "caddy", "zone": ZONE_HOST}, + {"id": "dashboard", "label": "dashboard", "zone": ZONE_HOST}, + {"id": "p2pool", "label": "p2pool", "zone": ZONE_HOST}, + {"id": "monerod", "label": "monerod", "zone": ZONE_HOST}, + {"id": "tari", "label": "tari", "zone": ZONE_HOST}, + {"id": "docker", "label": "docker-proxy", "zone": ZONE_HOST, "internal": True}, + {"id": "tor", "label": "tor", "zone": ZONE_TOR}, + {"id": "internet", "label": "Tor network", "zone": ZONE_NET}, +] + + +def _edge(src, dst, route, label, kind): + return {"from": src, "to": dst, "route": route, "label": label, "kind": kind} + + +def _ext(route): + # Where a component's external link lands in the diagram: a Tor-routed link terminates at the + # `tor` hub; a clearnet link goes STRAIGHT to the internet node, so a leak visibly bypasses Tor. + return "internet" if route == CLEARNET else "tor" + + +def compute_topology( + *, + firewall, + p2pool_clearnet, + xvb_enabled, + xvb_tor, + monero_clearnet_sync, + tari_clearnet_sync, + remote_monero, +): + """Pure derivation of the stack topology. Returns ``{nodes, edges, summary}``. + + ``kind`` is one of ``ingress`` (inbound from your LAN), ``egress`` (outbound), ``p2p`` + (bidirectional — egress *and* onion ingress for the P2P daemons), or ``internal`` (host-only + plumbing, hidden until expanded). The summary is shared verbatim with the egress list. + """ + posture = compute_egress_posture( + firewall=firewall, + p2pool_clearnet=p2pool_clearnet, + xvb_enabled=xvb_enabled, + xvb_tor=xvb_tor, + monero_clearnet_sync=monero_clearnet_sync, + tari_clearnet_sync=tari_clearnet_sync, + remote_monero=remote_monero, + ) + xvb = _xvb_route(xvb_enabled, xvb_tor) + sidechain = CLEARNET if p2pool_clearnet else TOR + rpc = CLEARNET if remote_monero else LOCAL + + edges = [ + # Ingress from your LAN — the only listeners actually exposed to the network. + _edge("rigs", "xmrig-proxy", LOCAL, "stratum :3333", "ingress"), + _edge("browser", "caddy", LOCAL, "https :443", "ingress"), + # Daemon P2P: bidirectional (outbound peers + inbound via Tor onion services). + _edge("p2pool", _ext(sidechain), sidechain, "sidechain P2P", "p2p"), + _edge("monerod", "tor", TOR, "Monero P2P + tx", "p2p"), + _edge("tari", "tor", TOR, "Tari P2P", "p2p"), + # App-level egress. + _edge("xmrig-proxy", _ext(xvb), xvb, "XvB donation", "egress"), + _edge("dashboard", "tor", TOR, "update check", "egress"), + _edge("dashboard", _ext(xvb), xvb, "XvB stats", "egress"), + # The Tor hub to the network: SOCKS egress for every daemon + onion-service ingress. + _edge("tor", "internet", TOR, "SOCKS + onion circuits", "p2p"), + # Internal mesh (hidden until expanded). + _edge("xmrig-proxy", "p2pool", LOCAL, "upstream pool", "internal"), + _edge("p2pool", "monerod", rpc, "RPC / ZMQ", "internal"), + _edge("p2pool", "tari", LOCAL, "gRPC merge-mine", "internal"), + _edge("caddy", "dashboard", LOCAL, "reverse-proxy :8000", "internal"), + _edge("dashboard", "monerod", LOCAL, "get_info RPC", "internal"), + _edge("dashboard", "xmrig-proxy", LOCAL, "proxy API", "internal"), + _edge("dashboard", "tari", LOCAL, "gRPC", "internal"), + _edge("dashboard", "docker", LOCAL, "container API", "internal"), + ] + # Optional clearnet initial-sync paths (#183) bypass the Tor hub straight to the internet. + if monero_clearnet_sync: + edges.append(_edge("monerod", "internet", CLEARNET, "clearnet IBD", "egress")) + if tari_clearnet_sync: + edges.append(_edge("tari", "internet", CLEARNET, "clearnet IBD", "egress")) + + # Tag clearnet links as a real leak vs firewall-blocked — same rule as the egress list. Only the + # host-networked dashboard escapes the #270 container firewall, so its clearnet links truly leak. + for edge in edges: + if edge["route"] != CLEARNET: + continue + if edge["from"] != "dashboard" and firewall: + edge["blocked_by_firewall"] = True + else: + edge["leak"] = True + + return {"nodes": TOPOLOGY_NODES, "edges": edges, "summary": posture["summary"]} + + +def topology_from_config(): + """Build the topology from the live dashboard config (values pithead rendered into the env).""" + return compute_topology( + firewall=config.TOR_EGRESS_FIREWALL, + p2pool_clearnet=config.P2POOL_CLEARNET, + xvb_enabled=config.ENABLE_XVB, + xvb_tor=config.XVB_TOR_ENABLED, + monero_clearnet_sync=config.MONERO_CLEARNET_SYNC, + tari_clearnet_sync=config.TARI_CLEARNET_SYNC, + remote_monero=config.MONERO_NODE_HOST != config.LOCAL_MONERO_HOST, + ) diff --git a/build/dashboard/mining_dashboard/service/metrics.py b/build/dashboard/mining_dashboard/service/metrics.py index c293bb9c..cdf1bc26 100644 --- a/build/dashboard/mining_dashboard/service/metrics.py +++ b/build/dashboard/mining_dashboard/service/metrics.py @@ -10,47 +10,53 @@ (#45) and XvB/P2Pool calculator (#12) — read the same ``Metrics`` instead of re-deriving from the raw dict or scraping rendered HTML. Nothing here formats or emits markup. """ + import time from dataclasses import dataclass from mining_dashboard.config.config import ( - ENABLE_XVB, XVB_DONATION_LEVEL, XVB_MAX_DONATION_FRACTION, - MONERO_PRUNE, MONERO_NODE_HOST, LOCAL_MONERO_HOST, + ENABLE_XVB, + LOCAL_MONERO_HOST, + MONERO_NODE_HOST, + MONERO_PRUNE, + XVB_DONATION_LEVEL, + XVB_MAX_DONATION_FRACTION, +) +from mining_dashboard.helper.utils import ( + DEFAULT_PPLNS_WINDOW, + get_tier_info, + pplns_block_time, + resolve_target_threshold, + shares_in_pplns_window, + xvb_stats_are_stale, ) -from mining_dashboard.helper.utils import get_tier_info, resolve_target_threshold - -# Nano sidechain blocks are 30s; Main/Mini are 10s. Drives the PPLNS-window duration. -_BLOCK_TIME_NANO = 30 -_BLOCK_TIME_DEFAULT = 10 - -# Only a local monerod's pruning state is knowable to us; a remote node isn't probed. The local -# bridge IP tracks the configurable subnet prefix (#180). -_LOCAL_MONERO_HOST = LOCAL_MONERO_HOST @dataclass(frozen=True) class SyncMetric: """One chain's sync/health state (Issues #31, #51).""" + percent: int current: int target: int remaining: int - has_target: bool # a real target height is known (vs. still discovering it) - done: bool # fully synced - down: bool # debounced unreachable (node-health monitor) + has_target: bool # a real target height is known (vs. still discovering it) + done: bool # fully synced + down: bool # debounced unreachable (node-health monitor) @dataclass(frozen=True) class Metrics: """Computed dashboard domain values. All hashrates are raw H/s; no display formatting.""" + # Effective hashrate (H/s). total_h15: float p2pool_1h: float p2pool_24h: float - xvb_1h: float # credited (XvB API avg_1h) — controller input + Advanced card only - xvb_24h: float # credited (XvB API avg_24h) - xvb_routed_1h: float # routed (proxy v_xvb, time-weighted 1h) — header / Simple / chart (#156) - xvb_routed_24h: float # routed (proxy v_xvb, time-weighted 24h) + xvb_1h: float # credited (XvB API avg_1h) — controller input + Advanced card only + xvb_24h: float # credited (XvB API avg_24h) + xvb_routed_1h: float # routed (proxy v_xvb, time-weighted 1h) — header / Simple / chart (#156) + xvb_routed_24h: float # routed (proxy v_xvb, time-weighted 24h) stratum_h15: float stratum_h1h: float stratum_h24h: float @@ -61,16 +67,16 @@ class Metrics: target_tier: str target_threshold: float target_sustainable: bool - low_hr_warning: bool # an explicit tier was chosen that the hashrate can't sustain + low_hr_warning: bool # an explicit tier was chosen that the hashrate can't sustain xvb_fail_count: int - xvb_last_update: float # epoch seconds of the last XvB stats fetch + xvb_last_update: float # epoch seconds of the last XvB stats fetch # Workers. workers_online: int workers_total: int # Shares / PPLNS. shares_in_window: int - pplns_window: int # blocks - block_time: int # seconds per sidechain block + pplns_window: int # blocks + block_time: int # seconds per sidechain block # Pool / network (raw figures; e.g. payout-calculator inputs, #12). pool_type: str pool_hashrate: float @@ -81,8 +87,14 @@ class Metrics: global_syncing: bool monero: SyncMetric tari: SyncMetric - monero_mode: str # "Pruned" / "Full" / "Unknown" - tari_mining: bool # Tari merge-mining active + monero_mode: str # "Pruned" / "Full" / "Unknown" + tari_mining: bool # Tari merge-mining active + # XvB raffle registration (#263). Defaulted so direct Metrics(...) constructors needn't set them. + xvb_registered_at: float = 0.0 # epoch secs of last successful registration; 0.0 = never + xvb_registration_state: str = "" # ""|registered|invalid|failing — drives the badge + # True when the xmrvsbeast.com fetch has gone quiet (#311) — credited 1h/24h are frozen, + # so the UI greys them and the controller holds its split. Same predicate as the controller. + xvb_stale: bool = False def build_metrics(latest_data, state_mgr, history=None): @@ -98,15 +110,15 @@ def build_metrics(latest_data, state_mgr, history=None): xvb_stats = state_mgr.get_xvb_stats() or {} tiers = state_mgr.get_tiers() - mode = xvb_stats.get('current_mode', 'P2POOL') + mode = xvb_stats.get("current_mode", "P2POOL") if not ENABLE_XVB: mode = "P2POOL (XvB Disabled)" - total_h15 = data.get('total_live_h15', 0) or 0 + total_h15 = data.get("total_live_h15", 0) or 0 # Credited — XvB's own verdict (avg_1h/24h). The controller steers off this (#9/#70) and the # Advanced card shows it next to routed so the credit factor is visible; nowhere else (#156). - xvb_1h = xvb_stats.get('avg_1h', 0) or 0 - xvb_24h = xvb_stats.get('avg_24h', 0) or 0 + xvb_1h = xvb_stats.get("avg_1h", 0) or 0 + xvb_24h = xvb_stats.get("avg_24h", 0) or 0 # Routed — what the proxy ACTUALLY sent to XvB, time-weighted from our own DB history (v_xvb), # mirroring P2Pool's v_p2pool averaging so the two sum to total. This is the at-a-glance display # figure (header / Simple / chart), NOT the controller's intended donation_fraction (#156). @@ -129,28 +141,29 @@ def build_metrics(latest_data, state_mgr, history=None): ) target_tier, _ = get_tier_info(target_threshold, tiers) low_hr_warning = bool( - ENABLE_XVB and XVB_DONATION_LEVEL not in ("auto", "highest") - and target_threshold > 0 and not sustainable + ENABLE_XVB + and XVB_DONATION_LEVEL not in ("auto", "highest") + and target_threshold > 0 + and not sustainable ) if not ENABLE_XVB: current_tier = "Disabled" target_tier = "Disabled" low_hr_warning = False - stratum = data.get('stratum', {}) - pool_stats = data.get('pool', {}) - p2p = pool_stats.get('p2p', {}) - local_pool = pool_stats.get('pool', {}) - network = data.get('network', {}) + stratum = data.get("stratum", {}) + pool_stats = data.get("pool", {}) + p2p = pool_stats.get("p2p", {}) + local_pool = pool_stats.get("pool", {}) + network = data.get("network", {}) - pool_type = p2p.get('type', 'Main') - block_time = _BLOCK_TIME_NANO if pool_type == 'Nano' else _BLOCK_TIME_DEFAULT - pplns_window = local_pool.get('pplns_window', 2160) - cutoff = time.time() - pplns_window * block_time - shares_in_window = sum(1 for s in data.get('shares', []) if s.get('ts', 0) >= cutoff) + pool_type = p2p.get("type", "Main") + block_time = pplns_block_time(pool_type) + pplns_window = local_pool.get("pplns_window", DEFAULT_PPLNS_WINDOW) + shares_in_window = shares_in_pplns_window(data.get("shares", []), pplns_window, block_time) - workers = data.get('workers', []) - workers_online = sum(1 for w in workers if w.get('status') == 'online') + workers = data.get("workers", []) + workers_online = sum(1 for w in workers if w.get("status") == "online") return Metrics( total_h15=total_h15, @@ -160,9 +173,9 @@ def build_metrics(latest_data, state_mgr, history=None): xvb_24h=xvb_24h, xvb_routed_1h=xvb_routed_1h, xvb_routed_24h=xvb_routed_24h, - stratum_h15=stratum.get('hashrate_15m', 0) or 0, - stratum_h1h=stratum.get('hashrate_1h', 0) or 0, - stratum_h24h=stratum.get('hashrate_24h', 0) or 0, + stratum_h15=stratum.get("hashrate_15m", 0) or 0, + stratum_h1h=stratum.get("hashrate_1h", 0) or 0, + stratum_h24h=stratum.get("hashrate_24h", 0) or 0, mode=mode, xvb_enabled=bool(ENABLE_XVB), current_tier=current_tier, @@ -170,23 +183,26 @@ def build_metrics(latest_data, state_mgr, history=None): target_threshold=target_threshold, target_sustainable=sustainable, low_hr_warning=low_hr_warning, - xvb_fail_count=xvb_stats.get('fail_count', 0) or 0, - xvb_last_update=xvb_stats.get('last_update', 0) or 0, + xvb_fail_count=xvb_stats.get("fail_count", 0) or 0, + xvb_last_update=xvb_stats.get("last_update", 0) or 0, workers_online=workers_online, workers_total=len(workers), shares_in_window=shares_in_window, pplns_window=pplns_window, block_time=block_time, pool_type=pool_type, - pool_hashrate=local_pool.get('hashrate', 0) or 0, - pool_difficulty=local_pool.get('difficulty', 0) or 0, - network_difficulty=network.get('difficulty', 0) or 0, - network_height=network.get('height', 0) or 0, - global_syncing=bool(data.get('global_sync', False)), - monero=_sync_metric(data.get('monero_sync', {})), - tari=_sync_metric(data.get('tari_sync', {})), + pool_hashrate=local_pool.get("hashrate", 0) or 0, + pool_difficulty=local_pool.get("difficulty", 0) or 0, + network_difficulty=network.get("difficulty", 0) or 0, + network_height=network.get("height", 0) or 0, + global_syncing=bool(data.get("global_sync", False)), + monero=_sync_metric(data.get("monero_sync", {})), + tari=_sync_metric(data.get("tari_sync", {})), monero_mode=_monero_mode(), - tari_mining=bool(data.get('tari', {}).get('active', False)), + tari_mining=bool(data.get("tari", {}).get("active", False)), + xvb_registered_at=xvb_stats.get("registered_at", 0) or 0, + xvb_registration_state=xvb_stats.get("registration_state", "") or "", + xvb_stale=bool(ENABLE_XVB and xvb_stats_are_stale(xvb_stats)), ) @@ -205,11 +221,11 @@ def _avg_p2pool_over_window(history, window_seconds): total = 0.0 count = 0 for x in history: - if x.get('timestamp', 0) < cutoff: + if x.get("timestamp", 0) < cutoff: continue - vp = x.get('v_p2pool', 0) or 0 - vx = x.get('v_xvb', 0) or 0 - v = x.get('v', 0) or 0 + vp = x.get("v_p2pool", 0) or 0 + vx = x.get("v_xvb", 0) or 0 + v = x.get("v", 0) or 0 if vp == 0 and vx == 0 and v > 0: vp = v total += vp @@ -233,9 +249,9 @@ def _avg_xvb_over_window(history, window_seconds): total = 0.0 count = 0 for x in history: - if x.get('timestamp', 0) < cutoff: + if x.get("timestamp", 0) < cutoff: continue - total += x.get('v_xvb', 0) or 0 + total += x.get("v_xvb", 0) or 0 count += 1 return total / count if count else 0.0 @@ -243,9 +259,9 @@ def _avg_xvb_over_window(history, window_seconds): def _sync_metric(sync): """Build a :class:`SyncMetric` from a chain's raw ``*_sync`` dict.""" - percent = sync.get('percent', 0) or 0 - current = sync.get('current', 0) or 0 - target = sync.get('target', 0) or 0 + percent = sync.get("percent", 0) or 0 + current = sync.get("current", 0) or 0 + target = sync.get("target", 0) or 0 has_target = target > 0 return SyncMetric( percent=percent, @@ -255,16 +271,20 @@ def _sync_metric(sync): has_target=has_target, # "done" = the chain reports itself caught up. A synced monerod returns target_height: 0, so # the percent>=100 path never fires for it — which left a fully-synced node stuck at "loading" - # (found in the #180 gouda validation). Trust the authoritative reachable + not-is_syncing + # (found in the #180 live validation). Trust the authoritative reachable + not-is_syncing # signal too; Tari already reports current==target/percent=100, so it's unaffected. - done=(sync.get('reachable', False) and not sync.get('is_syncing', True)) or (has_target and percent >= 100), - down=bool(sync.get('down', False)), + done=(sync.get("reachable", False) and not sync.get("is_syncing", True)) + or (has_target and percent >= 100), + down=bool(sync.get("down", False)), ) def _monero_mode(): """Monero node pruning mode (Issue #32). Only meaningful for a local node — a remote - node's pruning state isn't something we probe, so it reads 'Unknown'.""" - if MONERO_NODE_HOST == _LOCAL_MONERO_HOST: + node's pruning state isn't something we probe, so it reads 'Unknown'. + + Only a local monerod's pruning state is knowable; the local bridge IP tracks the + configurable subnet prefix (#180).""" + if MONERO_NODE_HOST == LOCAL_MONERO_HOST: return "Pruned" if MONERO_PRUNE else "Full" return "Unknown" diff --git a/build/dashboard/mining_dashboard/service/node_health.py b/build/dashboard/mining_dashboard/service/node_health.py index 8027e3ad..c658f10a 100644 --- a/build/dashboard/mining_dashboard/service/node_health.py +++ b/build/dashboard/mining_dashboard/service/node_health.py @@ -29,8 +29,12 @@ class NodeHealthMonitor: Clock is injectable for tests; defaults to `time.monotonic`. """ - def __init__(self, down_after=NODE_DOWN_AFTER_SEC, recovery_after=NODE_RECOVERY_AFTER_SEC, - clock=time.monotonic): + def __init__( + self, + down_after=NODE_DOWN_AFTER_SEC, + recovery_after=NODE_RECOVERY_AFTER_SEC, + clock=time.monotonic, + ): self.down_after = down_after self.recovery_after = recovery_after self._clock = clock @@ -60,7 +64,11 @@ def update(self, reachable): if self._unreachable_since is None: self._unreachable_since = now # Only a node that has actually been up can fall DOWN. - if self.ever_up and not self.down and (now - self._unreachable_since) >= self.down_after: + if ( + self.ever_up + and not self.down + and (now - self._unreachable_since) >= self.down_after + ): self.down = True return self.down diff --git a/build/dashboard/mining_dashboard/service/storage_service.py b/build/dashboard/mining_dashboard/service/storage_service.py index 1f0209a3..c5e65995 100644 --- a/build/dashboard/mining_dashboard/service/storage_service.py +++ b/build/dashboard/mining_dashboard/service/storage_service.py @@ -1,15 +1,17 @@ +import json +import logging +import random import sqlite3 import threading -import logging -import json -import os import time -import random from collections import deque -from typing import Dict, List, Optional, Any +from typing import Any + from mining_dashboard.config.config import ( - DB_FILE_PATH, TIER_DEFAULTS, HISTORY_RETENTION_SEC, WORKER_RETENTION_SEC, + DB_FILE_PATH, HASHRATE_WINDOW_COLUMNS, + HISTORY_RETENTION_SEC, + TIER_DEFAULTS, ) # The 10m window reuses the original v_p2pool/v_xvb pair; every other window in @@ -27,10 +29,11 @@ class StateManager: """ Manages persistent application state including hashrate history and mining mode statistics. - + Handles atomic file I/O to prevent data corruption and ensures state consistency across application restarts. """ + def __init__(self, db_path: str = None): self.logger = logging.getLogger("StateManager") # Default to the configured path; tests inject a temp file or ":memory:". @@ -40,7 +43,6 @@ def __init__(self, db_path: str = None): self.state = { "hashrate_history": deque(), "shares": [], - "known_workers": {}, # Persist worker IPs by name to prevent loss during XvB switching "xvb": { "total_donated_time": 0.0, "current_mode": "P2POOL", @@ -48,16 +50,22 @@ def __init__(self, db_path: str = None): "avg_1h": 0.0, "fail_count": 0, "last_update": 0.0, + # Unix ts of the last successful XvB raffle registration (#263); 0.0 until the + # wallet is first auto-registered. Lets the UI show "Registered with XvB ✓". + "registered_at": 0.0, + # Registration status for the dashboard badge (#263): "" (pending), "registered", + # "invalid" (endpoint rejected the wallet), or "failing" (endpoint erroring). + "registration_state": "", # Fraction of the current cycle routed to XvB, written by the # controller each cycle. Lets the dashboard show what we *send* # (routed) next to what XvB *credits* (avg_1h/24h) — the live # credit-factor signal (Issue #70). - "donation_fraction": 0.0 + "donation_fraction": 0.0, }, # Initialize state with default values from configuration - "tiers": TIER_DEFAULTS.copy() + "tiers": TIER_DEFAULTS.copy(), } - + # Initialize persistent DB connection # check_same_thread=False allows the connection to be used by multiple threads # (serialized via self._db_lock) @@ -78,7 +86,7 @@ def _init_db(self): # Enable WAL mode for better concurrency self._conn.execute("PRAGMA journal_mode=WAL") self._conn.execute("PRAGMA synchronous=NORMAL") - + with self._conn: self._create_tables() self._migrate_db() @@ -104,10 +112,13 @@ def _create_tables(self): # Per-window hashrate columns (#168) are appended so a fresh DB starts with them; existing # DBs get them via _migrate_db. Same source list (_WINDOW_EXTRA_COLUMNS) for both paths. extra = "".join(f", {c} REAL DEFAULT 0" for c in _WINDOW_EXTRA_COLUMNS) - self._conn.execute(f"CREATE TABLE IF NOT EXISTS history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL{extra})") - self._conn.execute("CREATE TABLE IF NOT EXISTS workers (name TEXT PRIMARY KEY, ip TEXT, last_seen REAL)") + self._conn.execute( + f"CREATE TABLE IF NOT EXISTS history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL{extra})" + ) self._conn.execute("CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT)") - self._conn.execute("CREATE TABLE IF NOT EXISTS shares (ts REAL PRIMARY KEY, difficulty REAL)") + self._conn.execute( + "CREATE TABLE IF NOT EXISTS shares (ts REAL PRIMARY KEY, difficulty REAL)" + ) def _create_indexes(self): """Creates indexes. Called after migrations so the indexed columns are guaranteed to @@ -118,23 +129,25 @@ def _create_indexes(self): def _migrate_db(self): """Handles schema migrations for existing databases.""" cursor = self._conn.cursor() - + # History Table Migrations cursor.execute("PRAGMA table_info(history)") columns = {info[1] for info in cursor.fetchall()} - - if 'v_p2pool' not in columns: + + if "v_p2pool" not in columns: self.logger.info("Migrating DB: Adding v_p2pool column to history") self._conn.execute("ALTER TABLE history ADD COLUMN v_p2pool REAL DEFAULT 0") - if 'v_xvb' not in columns: + if "v_xvb" not in columns: self.logger.info("Migrating DB: Adding v_xvb column to history") self._conn.execute("ALTER TABLE history ADD COLUMN v_xvb REAL DEFAULT 0") - if 'timestamp' not in columns: + if "timestamp" not in columns: self.logger.info("Migrating DB: Adding timestamp column to history") self._conn.execute("ALTER TABLE history ADD COLUMN timestamp REAL") - self._conn.execute("UPDATE history SET timestamp = CAST(strftime('%s', t) AS REAL) WHERE timestamp IS NULL") + self._conn.execute( + "UPDATE history SET timestamp = CAST(strftime('%s', t) AS REAL) WHERE timestamp IS NULL" + ) self._conn.execute("UPDATE history SET timestamp = 0 WHERE timestamp IS NULL") # Per-window hashrate columns (#168) — additive, forward-only. Pre-existing rows keep DEFAULT @@ -144,13 +157,10 @@ def _migrate_db(self): self.logger.info(f"Migrating DB: Adding {col} column to history") self._conn.execute(f"ALTER TABLE history ADD COLUMN {col} REAL DEFAULT 0") - # Workers Table Migrations - cursor.execute("PRAGMA table_info(workers)") - w_columns = {info[1] for info in cursor.fetchall()} - if 'last_seen' not in w_columns: - self.logger.info("Migrating DB: Adding last_seen column to workers") - self._conn.execute("ALTER TABLE workers ADD COLUMN last_seen REAL") - self._conn.execute("UPDATE workers SET last_seen = ?", (time.time(),)) + # Drop the orphaned `workers` table (#144). It backed the known_workers persistence layer, + # which was dead code — the worker list is sourced live from the xmrig-proxy. Tidies old + # DBs; harmless no-op on fresh ones. + self._conn.execute("DROP TABLE IF EXISTS workers") def load(self): """ @@ -158,15 +168,22 @@ def load(self): """ try: with self._db_lock: - if not self._conn: return + if not self._conn: + return cursor = self._conn.cursor() - + with self._lock: # 1. Load History # Limit to retention period to prevent memory bloat history_cutoff = time.time() - HISTORY_RETENTION_SEC - hist_cols = ", ".join(["t", "v", "v_p2pool", "v_xvb", "timestamp"] + _WINDOW_EXTRA_COLUMNS) - cursor.execute(f"SELECT {hist_cols} FROM history WHERE timestamp > ? ORDER BY timestamp ASC", (history_cutoff,)) + hist_cols = ", ".join( + ["t", "v", "v_p2pool", "v_xvb", "timestamp"] + _WINDOW_EXTRA_COLUMNS + ) + cursor.execute( + # Column list is literals + a module constant, never user input; value is ?-bound. + f"SELECT {hist_cols} FROM history WHERE timestamp > ? ORDER BY timestamp ASC", # noqa: S608 + (history_cutoff,), + ) history = [] for row in cursor.fetchall(): item = dict(row) @@ -179,29 +196,19 @@ def load(self): history.append(item) self.state["hashrate_history"] = deque(history) - # 2. Load Workers - # Only load workers seen recently - worker_cutoff = time.time() - WORKER_RETENTION_SEC - cursor.execute("SELECT name, ip, last_seen FROM workers WHERE last_seen > ? OR last_seen IS NULL", (worker_cutoff,)) - self.state["known_workers"] = {} - for row in cursor.fetchall(): - self.state["known_workers"][row["name"]] = { - "ip": row["ip"], - "last_seen": row["last_seen"] if row["last_seen"] is not None else time.time() - } - - # 3. Load XVB Stats (KV Store) + # 2. Load XVB Stats (KV Store) cursor.execute("SELECT key, value FROM kv_store WHERE key LIKE 'xvb_%'") for row in cursor.fetchall(): - key = row["key"] - if key.startswith("xvb_"): - key = key[4:] - + # The query filters to 'xvb_%', so every key carries the prefix; strip it. + key = row["key"][4:] + val = row["value"] - + # Migration: Handle legacy keys from previous versions - if key == "1h_avg": key = "avg_1h" - if key == "24h_avg": key = "avg_24h" + if key == "1h_avg": + key = "avg_1h" + if key == "24h_avg": + key = "avg_24h" # Enforce schema: Ignore keys not present in the default state if key not in self.state["xvb"]: @@ -220,15 +227,20 @@ def load(self): except (ValueError, TypeError): self.logger.warning(f"Skipping corrupted KV pair: {key}={val}") - # 4. Load Shares - cursor.execute("SELECT ts, difficulty FROM shares WHERE ts > ? ORDER BY ts ASC", (history_cutoff,)) + # 3. Load Shares + cursor.execute( + "SELECT ts, difficulty FROM shares WHERE ts > ? ORDER BY ts ASC", + (history_cutoff,), + ) self.state["shares"] = [dict(row) for row in cursor.fetchall()] - + self.logger.info(f"State successfully loaded from {self.db_path}") except sqlite3.Error as e: self.logger.error(f"DB Load Error: {e}") - def update_history(self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = 0, windows=None): + def update_history( + self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = 0, windows=None + ): """Appends a new hashrate data point to the history buffer. ``windows`` (Issue #168) is an optional ``{window: (p2pool_hr, xvb_hr)}`` mapping of the @@ -236,7 +248,7 @@ def update_history(self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = ``p2pool_hr``/``xvb_hr`` pair above). Each is stored in its own column so the chart's window toggle can plot a true average per window; an omitted/unknown window defaults to 0. """ - t_str = time.strftime('%Y-%m-%d %H:%M:%S') + t_str = time.strftime("%Y-%m-%d %H:%M:%S") ts = time.time() try: @@ -264,18 +276,23 @@ def update_history(self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = with self._lock: # 1. Update In-Memory State - self.state["hashrate_history"].append({ - "t": t_str, - "v": v_val, - "v_p2pool": v_p2p, - "v_xvb": v_xvb, - "timestamp": ts, - **extra, - }) + self.state["hashrate_history"].append( + { + "t": t_str, + "v": v_val, + "v_p2pool": v_p2p, + "v_xvb": v_xvb, + "timestamp": ts, + **extra, + } + ) # Prune in-memory history to enforce retention policy cutoff = ts - HISTORY_RETENTION_SEC - while self.state["hashrate_history"] and self.state["hashrate_history"][0]["timestamp"] < cutoff: + while ( + self.state["hashrate_history"] + and self.state["hashrate_history"][0]["timestamp"] < cutoff + ): self.state["hashrate_history"].popleft() # 2. Persist to DB @@ -286,14 +303,19 @@ def update_history(self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = with self._conn: cols = ["t", "v", "v_p2pool", "v_xvb", "timestamp"] + _WINDOW_EXTRA_COLUMNS placeholders = ", ".join("?" * len(cols)) - values = (t_str, v_val, v_p2p, v_xvb, ts) + tuple(extra[c] for c in _WINDOW_EXTRA_COLUMNS) + values = (t_str, v_val, v_p2p, v_xvb, ts) + tuple( + extra[c] for c in _WINDOW_EXTRA_COLUMNS + ) self._conn.execute( - f"INSERT INTO history ({', '.join(cols)}) VALUES ({placeholders})", - values + # Column/placeholder lists are literals + a module constant, not user input. + f"INSERT INTO history ({', '.join(cols)}) VALUES ({placeholders})", # noqa: S608 + values, ) # Prune old history from DB to prevent unbounded growth (Probabilistic pruning to save I/O) - if random.random() < 0.05: - self._conn.execute("DELETE FROM history WHERE timestamp < ?", (ts - HISTORY_RETENTION_SEC,)) + if random.random() < 0.05: # noqa: S311 — pruning sampler, not a security context + self._conn.execute( + "DELETE FROM history WHERE timestamp < ?", (ts - HISTORY_RETENTION_SEC,) + ) except sqlite3.Error as e: self._db_error("History Update Error", e) @@ -301,9 +323,9 @@ def add_share(self, ts: float, difficulty: float): """Appends a new share to history and persists it to the DB.""" with self._lock: # Check if share already exists to prevent duplicate in-memory appends - if not any(s['ts'] == ts for s in self.state.get("shares", [])): + if not any(s["ts"] == ts for s in self.state.get("shares", [])): self.state["shares"].append({"ts": ts, "difficulty": difficulty}) - + # Prune in-memory state based on the 30-day config cutoff = time.time() - HISTORY_RETENTION_SEC self.state["shares"] = [s for s in self.state["shares"] if s["ts"] >= cutoff] @@ -311,12 +333,19 @@ def add_share(self, ts: float, difficulty: float): # Persist to DB try: with self._db_lock: - if not self._conn: return + if not self._conn: + return with self._conn: - self._conn.execute("INSERT OR IGNORE INTO shares (ts, difficulty) VALUES (?, ?)", (ts, difficulty)) - - if random.random() < 0.05: - self._conn.execute("DELETE FROM shares WHERE ts < ?", (time.time() - HISTORY_RETENTION_SEC,)) + self._conn.execute( + "INSERT OR IGNORE INTO shares (ts, difficulty) VALUES (?, ?)", + (ts, difficulty), + ) + + if random.random() < 0.05: # noqa: S311 — pruning sampler, not a security context + self._conn.execute( + "DELETE FROM shares WHERE ts < ?", + (time.time() - HISTORY_RETENTION_SEC,), + ) except sqlite3.Error as e: self._db_error("Share Insert Error", e) @@ -332,22 +361,29 @@ def add_shares(self, count: int, latest_ts: float, difficulty: float): # Distinct timestamps ending at latest_ts (1 ms steps back) so the ts PRIMARY KEY keeps all. self.add_share(round(latest_ts - 0.001 * (count - 1 - i), 3), difficulty) - def get_shares(self) -> List[Dict[str, Any]]: + def get_shares(self) -> list[dict[str, Any]]: """Returns a copy of the shares history.""" with self._lock: return list(self.state.get("shares", [])) - def get_xvb_stats(self) -> Dict[str, Any]: + def get_xvb_stats(self) -> dict[str, Any]: """Returns the current XvB mining statistics dictionary.""" with self._lock: return self.state["xvb"].copy() - def update_xvb_stats(self, mode: Optional[str] = None, avg_24h: Optional[float] = None, avg_1h: Optional[float] = None, fail_count: Optional[int] = None, **kwargs): + def update_xvb_stats( + self, + mode: str | None = None, + avg_24h: float | None = None, + avg_1h: float | None = None, + fail_count: int | None = None, + **kwargs, + ): """ Updates specific fields within the XvB statistics state. - + Allows partial updates to decouple mode switching from statistical updates. - + Args: mode (str, optional): The current mining mode (e.g., "P2POOL", "XVB"). avg_24h (float, optional): 24-hour average hashrate on XvB. @@ -407,7 +443,7 @@ def update_xvb_stats(self, mode: Optional[str] = None, avg_24h: Optional[float] ts = time.time() self.state["xvb"]["last_update"] = ts updates["xvb_last_update"] = ts - + # Persist to DB if updates: try: @@ -415,53 +451,14 @@ def update_xvb_stats(self, mode: Optional[str] = None, avg_24h: Optional[float] if not self._conn: return with self._conn: - self._conn.executemany("INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", - [(k, str(v)) for k, v in updates.items()]) + self._conn.executemany( + "INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", + [(k, str(v)) for k, v in updates.items()], + ) except sqlite3.Error as e: self._db_error("XVB Update Error", e) - def update_known_workers(self, workers_list: List[Dict[str, str]]): - """ - Updates the list of known workers. - - Args: - workers_list (list): List of dicts [{'name': '...', 'ip': '...'}, ...] - """ - if workers_list is None: - workers_list = [] - ts = time.time() - to_upsert = [] - - with self._lock: - for w in workers_list: - name = w.get('name') - ip = w.get('ip') - if name and ip: - # Update memory - self.state["known_workers"][name] = {"ip": ip, "last_seen": ts} - - # Always update DB timestamp for active workers - to_upsert.append((name, ip, ts)) - - # Prune old workers from memory - cutoff = ts - WORKER_RETENTION_SEC - to_remove = [k for k, v in self.state["known_workers"].items() if v["last_seen"] < cutoff] - for k in to_remove: - del self.state["known_workers"][k] - - if to_upsert: - try: - with self._db_lock: - if not self._conn: - return - with self._conn: - self._conn.executemany("INSERT OR REPLACE INTO workers (name, ip, last_seen) VALUES (?, ?, ?)", to_upsert) - # Prune old workers from DB - self._conn.execute("DELETE FROM workers WHERE last_seen < ?", (ts - WORKER_RETENTION_SEC,)) - except sqlite3.Error as e: - self._db_error("Worker Update Error", e) - - def save_snapshot(self, data: Dict[str, Any]): + def save_snapshot(self, data: dict[str, Any]): """Persists the full application state snapshot to the KV store.""" if not data: return @@ -471,14 +468,19 @@ def save_snapshot(self, data: Dict[str, Any]): if not self._conn: return with self._conn: - self._conn.execute("INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", - ("snapshot_latest_data", json_str)) + self._conn.execute( + "INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", + ("snapshot_latest_data", json_str), + ) except sqlite3.Error as e: self._db_error("Snapshot Save Error", e) except TypeError as e: - self.logger.error(f"Snapshot serialization error: {e}") + # A non-serializable snapshot is a persistent write failure (data lost on restart), + # so flag persistence unhealthy like every other write path — otherwise the #131 + # badge stays green while snapshots silently never persist. + self._db_error("Snapshot Serialization Error", e) - def load_snapshot(self) -> Optional[Dict[str, Any]]: + def load_snapshot(self) -> dict[str, Any] | None: """Loads the last persisted application state snapshot.""" try: with self._db_lock: @@ -493,17 +495,12 @@ def load_snapshot(self) -> Optional[Dict[str, Any]]: self.logger.error(f"Snapshot Load Error: {e}") return None - def get_known_workers(self) -> List[Dict[str, str]]: - """Returns a list of worker dicts for the collector.""" - with self._lock: - return [{"name": k, "ip": v["ip"]} for k, v in self.state["known_workers"].items()] - - def get_history(self) -> List[Dict[str, Any]]: + def get_history(self) -> list[dict[str, Any]]: """Returns a copy of the hashrate history.""" with self._lock: return list(self.state["hashrate_history"]) - def get_tiers(self) -> Dict[str, Any]: + def get_tiers(self) -> dict[str, Any]: """Returns a copy of the donation tiers configuration.""" with self._lock: return self.state["tiers"].copy() @@ -518,4 +515,4 @@ def close(self): except sqlite3.Error as e: self.logger.error(f"Error closing database: {e}") finally: - self._conn = None \ No newline at end of file + self._conn = None diff --git a/build/dashboard/mining_dashboard/service/update_checker.py b/build/dashboard/mining_dashboard/service/update_checker.py index 61b95e24..111a342b 100644 --- a/build/dashboard/mining_dashboard/service/update_checker.py +++ b/build/dashboard/mining_dashboard/service/update_checker.py @@ -9,6 +9,7 @@ `XVB_TOR_PROXY`, like the XvB stats fetch #163), so enabling it doesn't reveal the host IP to GitHub. Every failure path is silent (returns ``None``) so an offline / Tor-only stack just shows no badge. """ + import logging import requests @@ -53,8 +54,13 @@ def latest_release(self): proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None try: resp = requests.get( - self.api_url, timeout=20, proxies=proxies, - headers={"Accept": "application/vnd.github+json", "User-Agent": "pithead-dashboard"}, + self.api_url, + timeout=20, + proxies=proxies, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "pithead-dashboard", + }, ) if resp.status_code != 200: return None diff --git a/build/dashboard/mining_dashboard/sim/donation_model.py b/build/dashboard/mining_dashboard/sim/donation_model.py index 6e65c8cd..fd02c1e3 100644 --- a/build/dashboard/mining_dashboard/sim/donation_model.py +++ b/build/dashboard/mining_dashboard/sim/donation_model.py @@ -28,14 +28,14 @@ Run ``python -m mining_dashboard.sim.donation_model`` for a before/after report. """ -from dataclasses import dataclass, field, replace -from typing import Callable, Optional import math +from collections.abc import Callable +from dataclasses import dataclass, field, replace from mining_dashboard.config.config import ( - XVB_TIME_ALGO_MS, - XVB_SWITCH_OVERHEAD_MS, TIER_DEFAULTS, + XVB_SWITCH_OVERHEAD_MS, + XVB_TIME_ALGO_MS, ) # A 10-minute switching cycle is the simulation's time step. @@ -44,7 +44,7 @@ # Constraint inputs that keep AlgoService.get_decision past its guard clauses so we # exercise the donation math (a recent share, a healthy endpoint, a Main pool). -_RECENT_SHARES = [{"ts": 10 ** 12}] # far-future ts -> always inside the PPLNS window +_RECENT_SHARES = [{"ts": 10**12}] # far-future ts -> always inside the PPLNS window _P2P_MAIN = {"type": "Main"} _PPLNS_WINDOW = 2160 @@ -113,7 +113,7 @@ def __init__(self, span_cycles: int, semantics: str = "fixed"): def push(self, credited_hr: float) -> None: self._samples.append(credited_hr) if len(self._samples) > self.span: - self._samples = self._samples[-self.span:] + self._samples = self._samples[-self.span :] def average(self) -> float: if not self._samples: @@ -129,12 +129,12 @@ class Scenario: """A simulation setup. Times are in 10-minute cycles.""" name: str - target_hr: float # tier threshold to hold (H/s) - current_hr: float # steady rig hashrate (H/s) + target_hr: float # tier threshold to hold (H/s) + current_hr: float # steady rig hashrate (H/s) cycles: int = 3 * CYCLES_PER_DAY measurement: str = "fixed" # "fixed" | "connected" (see _Window) - warm_avg: float = 0.0 # initial avg_1h/avg_24h (0 = cold start; >0 = warm) - hr_noise: float = 0.0 # deterministic +/- fractional jitter on current_hr + warm_avg: float = 0.0 # initial avg_1h/avg_24h (0 = cold start; >0 = warm) + hr_noise: float = 0.0 # deterministic +/- fractional jitter on current_hr # Per-switch reconnect ramp: XvB credits ~0 for this long at the start of each # donation slice while miners reconnect to the new pool. This is exactly what # the controller's XVB_SWITCH_OVERHEAD_MS compensates for, so modelling it lets @@ -159,19 +159,19 @@ class Scenario: # actually keeps a share each cycle (VIP status) in `vip_held`. p2pool_difficulty: float = 0.0 # Optional sustained hashrate drop (worker disconnect) to test tier recovery. - drop_at: Optional[int] = None - drop_until: Optional[int] = None - drop_factor: float = 1.0 # current_hr multiplier while dropped + drop_at: int | None = None + drop_until: int | None = None + drop_factor: float = 1.0 # current_hr multiplier while dropped @dataclass class SimResult: scenario: Scenario - credited: list[float] = field(default_factory=list) # XvB-credited rate/cycle - credited_1h: list[float] = field(default_factory=list) # XvB-reported 1h avg + credited: list[float] = field(default_factory=list) # XvB-credited rate/cycle + credited_1h: list[float] = field(default_factory=list) # XvB-reported 1h avg credited_24h: list[float] = field(default_factory=list) # XvB-reported 24h avg - fraction: list[float] = field(default_factory=list) # donated fraction/cycle - current_hr: list[float] = field(default_factory=list) # rig hashrate seen + fraction: list[float] = field(default_factory=list) # donated fraction/cycle + current_hr: list[float] = field(default_factory=list) # rig hashrate seen # --- metrics ------------------------------------------------------------ @property @@ -223,10 +223,12 @@ def tier_held(self, tol: float = 0.02) -> bool: noise around the threshold. """ t = self.scenario.target_hr * (1 - tol) - return (min(self.credited_1h[self._tail], default=0.0) >= t - and min(self.credited_24h[self._tail], default=0.0) >= t) + return ( + min(self.credited_1h[self._tail], default=0.0) >= t + and min(self.credited_24h[self._tail], default=0.0) >= t + ) - def cycles_to_tier_24h(self) -> Optional[int]: + def cycles_to_tier_24h(self) -> int | None: """First cycle at which the 24h average reaches the threshold (or None).""" for i, v in enumerate(self.credited_24h): if v >= self.scenario.target_hr: @@ -247,7 +249,9 @@ def min_expected_shares(self, window_seconds: float = _PPLNS_WINDOW * 10) -> flo tail_hr = self.current_hr[self._tail] if not tail_frac: return float("inf") - return min((1 - f) * hr * window_seconds / diff for f, hr in zip(tail_frac, tail_hr)) + return min( + (1 - f) * hr * window_seconds / diff for f, hr in zip(tail_frac, tail_hr, strict=False) + ) def vip_held(self, min_shares: float = 1.0) -> bool: """True if p2pool keeps at least ``min_shares`` expected shares in the window @@ -284,8 +288,10 @@ def run_simulation(controller: Controller, scenario: Scenario) -> SimResult: # Effective rig hashrate this cycle: deterministic jitter + optional drop. base = scenario.current_hr - if scenario.drop_at is not None and scenario.drop_at <= cycle and ( - scenario.drop_until is None or cycle < scenario.drop_until + if ( + scenario.drop_at is not None + and scenario.drop_at <= cycle + and (scenario.drop_until is None or cycle < scenario.drop_until) ): base *= scenario.drop_factor # Deterministic pseudo-jitter (no RNG: reproducible across runs/resumes). @@ -345,31 +351,55 @@ def run_algo(scenario: Scenario, donation_level="vip", **tuning) -> SimResult: # VIP reserve is exercised rather than the flat fallback. _DIFFICULTY = 120_000_000 DEFAULT_SCENARIOS = [ - Scenario(name="field (VIP 10k on 46k rig, cold start)", - target_hr=10_000, current_hr=46_300, p2pool_difficulty=_DIFFICULTY), - Scenario(name="high headroom (VIP 10k on 200k rig)", - target_hr=10_000, current_hr=200_000, p2pool_difficulty=_DIFFICULTY), - Scenario(name="stale/laggy API reads (~2h reporting lag)", - target_hr=10_000, current_hr=46_300, report_lag_cycles=12, - p2pool_difficulty=_DIFFICULTY), - Scenario(name="XvB over-credits 3x (calibration must back off)", - target_hr=10_000, current_hr=46_300, credit_factor=3.0, - p2pool_difficulty=_DIFFICULTY), - Scenario(name="worker drop below tier mid-run, then recovery", - target_hr=10_000, current_hr=46_300, warm_avg=10_300, - drop_at=CYCLES_PER_DAY, drop_until=CYCLES_PER_DAY + 18, drop_factor=0.2, - p2pool_difficulty=_DIFFICULTY), + Scenario( + name="field (VIP 10k on 46k rig, cold start)", + target_hr=10_000, + current_hr=46_300, + p2pool_difficulty=_DIFFICULTY, + ), + Scenario( + name="high headroom (VIP 10k on 200k rig)", + target_hr=10_000, + current_hr=200_000, + p2pool_difficulty=_DIFFICULTY, + ), + Scenario( + name="stale/laggy API reads (~2h reporting lag)", + target_hr=10_000, + current_hr=46_300, + report_lag_cycles=12, + p2pool_difficulty=_DIFFICULTY, + ), + Scenario( + name="XvB over-credits 3x (calibration must back off)", + target_hr=10_000, + current_hr=46_300, + credit_factor=3.0, + p2pool_difficulty=_DIFFICULTY, + ), + Scenario( + name="worker drop below tier mid-run, then recovery", + target_hr=10_000, + current_hr=46_300, + warm_avg=10_300, + drop_at=CYCLES_PER_DAY, + drop_until=CYCLES_PER_DAY + 18, + drop_factor=0.2, + p2pool_difficulty=_DIFFICULTY, + ), ] def _fmt(r: SimResult) -> str: shares = r.min_expected_shares() vip = "inf" if shares == float("inf") else f"{shares:.1f}" - return (f"day1 {r.first_day_overshoot:4.2f}x " - f"peak {r.peak_overshoot:4.2f}x " - f"steady {r.steady_overshoot_1h:4.2f}x " - f"p2pool {r.p2pool_efficiency*100:5.1f}% " - f"tier {str(r.tier_held()):5} vip_shares {vip}") + return ( + f"day1 {r.first_day_overshoot:4.2f}x " + f"peak {r.peak_overshoot:4.2f}x " + f"steady {r.steady_overshoot_1h:4.2f}x " + f"p2pool {r.p2pool_efficiency * 100:5.1f}% " + f"tier {str(r.tier_held()):5} vip_shares {vip}" + ) def main(): # pragma: no cover - human-facing report, not a test path diff --git a/build/dashboard/mining_dashboard/version.py b/build/dashboard/mining_dashboard/version.py index bf3ad5ae..af61afcf 100644 --- a/build/dashboard/mining_dashboard/version.py +++ b/build/dashboard/mining_dashboard/version.py @@ -9,6 +9,7 @@ — the single source of truth — and ``git``). Reading only the environment keeps the container self-describing and this resolver a pure, unit-testable function of its inputs. """ + import os @@ -50,7 +51,8 @@ def resolve_version(env=None): else: text = "dev build" - detail = ", ".join(p for p in (f"branch {branch}" if branch else "", - f"commit {commit}" if commit else "") if p) + detail = ", ".join( + p for p in (f"branch {branch}" if branch else "", f"commit {commit}" if commit else "") if p + ) title = f"Development build ({detail})" if detail else "Development build" return {"text": text, "title": title, "dev": True} diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py index 8e3e0747..793b9053 100644 --- a/build/dashboard/mining_dashboard/web/server.py +++ b/build/dashboard/mining_dashboard/web/server.py @@ -1,9 +1,10 @@ -import os import logging import mimetypes +import os + from aiohttp import web -from mining_dashboard.web.views import build_state, get_shell_html, parse_window, canonical_window +from mining_dashboard.web.views import build_state, canonical_window, get_shell_html, parse_window logger = logging.getLogger("WebServer") @@ -18,20 +19,20 @@ async def handle_index(request): """Serve the static HTML shell. It carries no data — the client fetches ``/api/state`` and renders the dashboard. Pure transport.""" - return web.Response(text=get_shell_html(), content_type='text/html') + return web.Response(text=get_shell_html(), content_type="text/html") async def handle_state(request): """The dashboard's data API. Pull shared state, delegate to the view layer, and return the assembled state object as JSON (or a sanitized 500 on failure).""" app = request.app - data = app['latest_data'] - state_mgr = app['state_manager'] - range_arg = request.query.get('range', 'all') + data = app["latest_data"] + state_mgr = app["state_manager"] + range_arg = request.query.get("range", "all") # Optional manual-zoom window (Issue #47); malformed from/to falls back to the preset range. - window = parse_window(request.query.get('from'), request.query.get('to')) + window = parse_window(request.query.get("from"), request.query.get("to")) # Hashrate-averaging window for the chart (#168); unknown/missing falls back to the default. - avg_window = canonical_window(request.query.get('avg')) + avg_window = canonical_window(request.query.get("avg")) try: return web.json_response(build_state(data, state_mgr, range_arg, window, avg_window)) @@ -47,10 +48,10 @@ def _apply_security_headers(response): so no 'unsafe-inline' or 'unsafe-eval' is needed (Issue #60). The frontend libraries are eval-free ES modules; dynamic styling is applied via the CSSOM, which style-src doesn't govern.""" - response.headers['X-Content-Type-Options'] = 'nosniff' - response.headers['X-Frame-Options'] = 'DENY' - response.headers['Referrer-Policy'] = 'no-referrer' - response.headers['Content-Security-Policy'] = ( + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Content-Security-Policy"] = ( "default-src 'self'; img-src 'self' data:; style-src 'self'; " "script-src 'self'; connect-src 'self'; frame-ancestors 'none'; " "base-uri 'self'; form-action 'self'" @@ -60,7 +61,7 @@ def _apply_security_headers(response): # without this, a browser (notably iOS Safari) can keep serving the pre-upgrade dashboard.css # for an unpredictable while (Issue #83). 'no-cache' still allows a conditional request, so an # unchanged asset costs only a 304 — no re-download of the vendored libs on each page load. - response.headers['Cache-Control'] = 'no-cache' + response.headers["Cache-Control"] = "no-cache" return response @@ -70,22 +71,24 @@ async def security_headers_middleware(request, handler): try: return _apply_security_headers(await handler(request)) except web.HTTPException as exc: - raise _apply_security_headers(exc) + raise _apply_security_headers(exc) from exc 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 - - app.add_routes([ - web.get('/', handle_index), - web.get('/api/state', handle_state), - ]) + app["state_manager"] = state_manager + app["latest_data"] = latest_data_ref + + app.add_routes( + [ + web.get("/", handle_index), + web.get("/api/state", handle_state), + ] + ) static_path = os.path.join(os.path.dirname(__file__), "static") - app.router.add_static('/static', static_path) + app.router.add_static("/static", static_path) return app diff --git a/build/dashboard/mining_dashboard/web/static/chart.mjs b/build/dashboard/mining_dashboard/web/static/chart.mjs index 820dee43..e0302430 100644 --- a/build/dashboard/mining_dashboard/web/static/chart.mjs +++ b/build/dashboard/mining_dashboard/web/static/chart.mjs @@ -15,27 +15,39 @@ // scatter is kept on its own stack group so its y stays absolute. Zoom/pan (chartjs-plugin-zoom) // gestures hand the visible window up via onZoom, which refetches that window from the server at // duration-adaptive resolution — so zooming in reveals finer data. -import { Component, createRef, html } from './preact.mjs'; -import { fmtTimestamp, clampZoomWindow, bandBorderWidth } from './logic.mjs'; -const RANGES = [['1h', '1 Hr'], ['24h', '24 Hr'], ['1w', '1 Wk'], ['1m', '1 Mo']]; +import { bandBorderWidth, clampZoomWindow, fmtTimestamp } from "./logic.mjs"; +import { Component, createRef, html } from "./preact.mjs"; + +const RANGES = [ + ["1h", "1 Hr"], + ["24h", "24 Hr"], + ["1w", "1 Wk"], + ["1m", "1 Mo"], +]; // 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 // low until a rig has been online that long — flagged via the button title so it doesn't look broken. -const WINDOWS = [['1m', '1 Min'], ['10m', '10 Min'], ['1h', '1 Hr'], ['12h', '12 Hr'], ['24h', '24 Hr']]; +const WINDOWS = [ + ["1m", "1 Min"], + ["10m", "10 Min"], + ["1h", "1 Hr"], + ["12h", "12 Hr"], + ["24h", "24 Hr"], +]; const WINDOW_HINT = { - '12h': 'Average over the last 12 hours — needs ~12h of rig uptime to fully fill', - '24h': 'Average over the last 24 hours — needs ~24h of rig uptime to fully fill', + "12h": "Average over the last 12 hours — needs ~12h of rig uptime to fully fill", + "24h": "Average over the last 24 hours — needs ~24h of rig uptime to fully fill", }; // Series the user can show/hide (Issue #47): dataset index, label and swatch colour class. // Visibility lives in dashboard.js ui.series (persisted); applied to the chart in applyVisibility. const SERIES = [ - { key: 'p2pool', label: 'P2Pool (routed)', idx: 0, dot: 'dot-p2pool' }, - { key: 'xvb', label: 'XvB (routed)', idx: 1, dot: 'dot-xvb' }, - { key: 'shares', label: 'Shares', idx: 2, dot: 'dot-shares' }, + { key: "p2pool", label: "P2Pool (routed)", idx: 0, dot: "dot-p2pool" }, + { key: "xvb", label: "XvB (routed)", idx: 1, dot: "dot-xvb" }, + { key: "shares", label: "Shares", idx: 2, dot: "dot-shares" }, ]; // Smallest zoom window (ms) — guards against requesting a sub-sample slice (30s native cadence). @@ -45,239 +57,319 @@ const ZOOM_DEBOUNCE_MS = 300; // Register the zoom/pan plugin once (UMD global from chartjs-plugin-zoom.min.js; see index.html). // Guarded so the module is harmless if the global is absent (e.g. outside the browser). -if (typeof Chart !== 'undefined' && typeof window !== 'undefined' && window.ChartZoom) { - Chart.register(window.ChartZoom); +if (typeof Chart !== "undefined" && typeof window !== "undefined" && window.ChartZoom) { + Chart.register(window.ChartZoom); } // Append an 8-bit alpha to a #rrggbb hex (Chart.js accepts #rrggbbaa). Non-hex values pass // through opaque, so a future palette change can't break the fills. -const withAlpha = (hex, aa) => (/^#[0-9a-fA-F]{6}$/.test(hex) ? hex + aa : hex); +export const withAlpha = (hex, aa) => (/^#[0-9a-fA-F]{6}$/.test(hex) ? hex + aa : hex); // The chart's colours, read from the active theme's CSS variables (Issue #43) so the chart // matches light/dark/auto. Re-read on every sync() so a theme switch recolours it in place. function paletteColors() { - const cs = getComputedStyle(document.documentElement); - const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback; - const accent = v('--accent', '#58a6ff'); - const purple = v('--purple', '#a371f7'); - return { - accent, purple, - shares: v('--bad', '#da3633'), - grid: v('--border', '#30363d'), - ticks: v('--text-muted', '#8b949e'), - band: withAlpha(accent, '26'), // drag-to-zoom selection band (≈ 0.15 alpha) - }; + const cs = getComputedStyle(document.documentElement); + const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback; + const accent = v("--accent", "#58a6ff"); + const purple = v("--purple", "#a371f7"); + return { + accent, + purple, + shares: v("--bad", "#da3633"), + grid: v("--border", "#30363d"), + ticks: v("--text-muted", "#8b949e"), + band: withAlpha(accent, "26"), // drag-to-zoom selection band (≈ 0.15 alpha) + }; } // Area-fill gradient stops (Issue #145): strong near the line, fading toward the axis, so a flat // series reads as a solid mass instead of a thin strip. Line a touch thicker than the default so // the top edge pops against the fill. -const FILL_TOP = '59'; // ≈ 0.35 alpha at the line -const FILL_BOTTOM = '0d'; // ≈ 0.05 alpha at the axis +const FILL_TOP = "59"; // ≈ 0.35 alpha at the line +const FILL_BOTTOM = "0d"; // ≈ 0.05 alpha at the axis const AREA_BORDER_WIDTH = 3.5; // A vertical gradient fill for a stacked area, keyed to the live chart area (pixels, not data) so // it spans the visible card regardless of the y-range. Scriptable: re-evaluated on resize/zoom. // Before the first layout `chartArea` is undefined, so fall back to the flat top tint. function areaFill(baseHex) { - return (ctx) => { - const area = ctx.chart.chartArea; - if (!area) return withAlpha(baseHex, FILL_TOP); - const g = ctx.chart.ctx.createLinearGradient(0, area.top, 0, area.bottom); - g.addColorStop(0, withAlpha(baseHex, FILL_TOP)); - g.addColorStop(1, withAlpha(baseHex, FILL_BOTTOM)); - return g; - }; + return (ctx) => { + const area = ctx.chart.chartArea; + if (!area) return withAlpha(baseHex, FILL_TOP); + const g = ctx.chart.ctx.createLinearGradient(0, area.top, 0, area.bottom); + g.addColorStop(0, withAlpha(baseHex, FILL_TOP)); + g.addColorStop(1, withAlpha(baseHex, FILL_BOTTOM)); + return g; + }; } // Pad the auto-fitted y-range so a near-flat line fills the card instead of hugging the bottom // (Issue #145). Pads by a fraction of the visible span, with a floor tied to the magnitude so a // dead-flat series isn't magnified into pure noise; never drops below zero. -function padYAxis(scale) { - const { min, max } = scale; - if (!isFinite(min) || !isFinite(max)) return; // all series hidden / no data - const pad = Math.max((max - min) * 0.2, max * 0.03); - scale.min = Math.max(0, min - pad); - scale.max = max + pad; +export function padYAxis(scale) { + const { min, max } = scale; + if (!Number.isFinite(min) || !Number.isFinite(max)) return; // all series hidden / no data + const pad = Math.max((max - min) * 0.2, max * 0.03); + scale.min = Math.max(0, min - pad); + scale.max = max + pad; } export class ChartCard extends Component { - constructor(props) { - super(props); - this.canvasRef = createRef(); - this.shareCounts = []; - this.applyingServerData = false; // suppress the gesture handler during programmatic resetZoom - this._zoomDebounce = null; - this._prevWindow = props.window; // track window prop to detect the zoomed -> preset transition - } + constructor(props) { + super(props); + this.canvasRef = createRef(); + this.shareCounts = []; + this.applyingServerData = false; // suppress the gesture handler during programmatic resetZoom + this._zoomDebounce = null; + this._prevWindow = props.window; // track window prop to detect the zoomed -> preset transition + } - componentDidMount() { this.create(); } - componentDidUpdate() { this.sync(); } - componentWillUnmount() { - clearTimeout(this._zoomDebounce); - if (this.chart) { this.chart.destroy(); this.chart = null; } + componentDidMount() { + this.create(); + } + componentDidUpdate() { + this.sync(); + } + componentWillUnmount() { + clearTimeout(this._zoomDebounce); + if (this.chart) { + this.chart.destroy(); + this.chart = null; } + } - // Debounced: a gesture (wheel/drag-zoom/pan) settled — hand the visible window up to refetch - // it from the server at the right resolution. Ignored while we're programmatically resetting. - onGesture() { - if (this.applyingServerData) return; - clearTimeout(this._zoomDebounce); - this._zoomDebounce = setTimeout(() => { - if (!this.chart) return; - const x = this.chart.scales.x; - const w = clampZoomWindow(x.min, x.max, MIN_ZOOM_MS); - if (w) this.props.onZoom(w.from / 1000, w.to / 1000); // epoch ms -> seconds - }, ZOOM_DEBOUNCE_MS); - } + // Debounced: a gesture (wheel/drag-zoom/pan) settled — hand the visible window up to refetch + // it from the server at the right resolution. Ignored while we're programmatically resetting. + onGesture() { + if (this.applyingServerData) return; + clearTimeout(this._zoomDebounce); + this._zoomDebounce = setTimeout(() => { + if (!this.chart) return; + const x = this.chart.scales.x; + const w = clampZoomWindow(x.min, x.max, MIN_ZOOM_MS); + if (w) this.props.onZoom(w.from / 1000, w.to / 1000); // epoch ms -> seconds + }, ZOOM_DEBOUNCE_MS); + } - create() { - const canvas = this.canvasRef.current; - if (!canvas || typeof Chart === 'undefined') return; - const d = this.props.chart; - this.shareCounts = d.shares.map((s) => s.c); - const c = paletteColors(); - const tension = d.tension ?? 0.3; - const vis = this.props.series || {}; // persisted show/hide state (Issue #47) - const self = this; - this.chart = new Chart(canvas, { - type: 'line', - data: { - datasets: [ - // segment.borderWidth hides each band's top border-line where the band is flat-zero, - // so an all-to-one-pool window reads as a single solid color instead of the empty - // series painting its edge line over the other's (#184). The upper (XvB) band fills - // down to the series below it (fill: '-1'), NOT to origin — otherwise its - // semi-transparent purple is painted all the way to zero over the blue P2Pool fill, - // tinting an all-P2Pool window (XvB ≈ 0) lavender instead of leaving it blue. - { label: 'P2Pool (routed)', data: d.p2pool, borderColor: c.accent, borderWidth: AREA_BORDER_WIDTH, - segment: { borderWidth: (ctx) => bandBorderWidth(d.p2pool, ctx, AREA_BORDER_WIDTH) }, - tension, fill: true, hidden: vis.p2pool === false, - stack: 'hr', backgroundColor: areaFill(c.accent), pointRadius: 0, pointHitRadius: 20 }, - { label: 'XvB (routed)', data: d.xvb, borderColor: c.purple, borderWidth: AREA_BORDER_WIDTH, - segment: { borderWidth: (ctx) => bandBorderWidth(d.xvb, ctx, AREA_BORDER_WIDTH) }, - tension, fill: '-1', hidden: vis.xvb === false, - stack: 'hr', backgroundColor: areaFill(c.purple), pointRadius: 0, pointHitRadius: 20 }, - // On its own hidden 0–1 axis (yAxisID) so the markers ride near the top edge and - // never inflate the hashrate y-range (Issue #145). - { label: 'Shares', data: d.shares, borderColor: c.shares, backgroundColor: c.shares, - hidden: vis.shares === false, yAxisID: 'shares', - pointStyle: 'triangle', rotation: 180, pointRadius: d.shares.map((s) => s.r), - pointHoverRadius: 15, pointHitRadius: 100, showLine: false }, - ], + create() { + const canvas = this.canvasRef.current; + if (!canvas || typeof Chart === "undefined") return; + const d = this.props.chart; + this.shareCounts = d.shares.map((s) => s.c); + const c = paletteColors(); + const tension = d.tension ?? 0.3; + const vis = this.props.series || {}; // persisted show/hide state (Issue #47) + const self = this; + this.chart = new Chart(canvas, { + type: "line", + data: { + datasets: [ + // segment.borderWidth hides each band's top border-line where the band is flat-zero, + // so an all-to-one-pool window reads as a single solid color instead of the empty + // series painting its edge line over the other's (#184). The upper (XvB) band fills + // down to the series below it (fill: '-1'), NOT to origin — otherwise its + // semi-transparent purple is painted all the way to zero over the blue P2Pool fill, + // tinting an all-P2Pool window (XvB ≈ 0) lavender instead of leaving it blue. + { + label: "P2Pool (routed)", + data: d.p2pool, + borderColor: c.accent, + borderWidth: AREA_BORDER_WIDTH, + segment: { borderWidth: (ctx) => bandBorderWidth(d.p2pool, ctx, AREA_BORDER_WIDTH) }, + tension, + fill: true, + hidden: vis.p2pool === false, + stack: "hr", + backgroundColor: areaFill(c.accent), + pointRadius: 0, + pointHitRadius: 20, + }, + { + label: "XvB (routed)", + data: d.xvb, + borderColor: c.purple, + borderWidth: AREA_BORDER_WIDTH, + segment: { borderWidth: (ctx) => bandBorderWidth(d.xvb, ctx, AREA_BORDER_WIDTH) }, + tension, + fill: "-1", + hidden: vis.xvb === false, + stack: "hr", + backgroundColor: areaFill(c.purple), + pointRadius: 0, + pointHitRadius: 20, + }, + // On its own hidden 0–1 axis (yAxisID) so the markers ride near the top edge and + // never inflate the hashrate y-range (Issue #145). + { + label: "Shares", + data: d.shares, + borderColor: c.shares, + backgroundColor: c.shares, + hidden: vis.shares === false, + yAxisID: "shares", + pointStyle: "triangle", + rotation: 180, + pointRadius: d.shares.map((s) => s.r), + pointHoverRadius: 15, + pointHitRadius: 100, + showLine: false, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + spanGaps: false, // {x, y: null} break markers split the line across outages + 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 === "Shares") + return self.shareCounts[context.dataIndex] + " Shares"; + let label = context.dataset.label || ""; + if (label) label += ": "; + if (context.parsed.y !== null) label += context.parsed.y + " H/s"; + return label; + }, }, - options: { - responsive: true, maintainAspectRatio: false, animation: false, - spanGaps: false, // {x, y: null} break markers split the line across outages - 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 === 'Shares') return self.shareCounts[context.dataIndex] + ' Shares'; - let label = context.dataset.label || ''; - if (label) label += ': '; - if (context.parsed.y !== null) label += context.parsed.y + ' H/s'; - return label; - }, - } }, - // Drag = box-zoom, wheel = zoom, Shift-drag = pan (Issue #47). Each settled - // gesture triggers a server refetch of the visible window (onGesture). - zoom: { - zoom: { - wheel: { enabled: true }, - drag: { enabled: true, backgroundColor: c.band, borderColor: c.accent, borderWidth: 1 }, - mode: 'x', - onZoomComplete: () => self.onGesture(), - }, - pan: { enabled: true, mode: 'x', modifierKey: 'shift', onPanComplete: () => self.onGesture() }, - limits: { x: { minRange: MIN_ZOOM_MS } }, - }, - }, - scales: { - // Linear x positions points by real elapsed time (gaps occupy proportional - // space); axis hidden as before. y is stacked (P2Pool+XvB = total) and - // grid/ticks follow the theme; padYAxis keeps a flat line off the floor. - x: { type: 'linear', display: false }, - y: { stacked: true, grid: { color: c.grid }, ticks: { color: c.ticks }, afterDataLimits: padYAxis }, - // Hidden 0–1 axis the Shares scatter rides on; markers pin near the top (0.93, - // set server-side) so they never affect the hashrate y-range (Issue #145). - shares: { type: 'linear', display: false, min: 0, max: 1 }, - }, + }, + // Drag = box-zoom, wheel = zoom, Shift-drag = pan (Issue #47). Each settled + // gesture triggers a server refetch of the visible window (onGesture). + zoom: { + zoom: { + wheel: { enabled: true }, + drag: { + enabled: true, + backgroundColor: c.band, + borderColor: c.accent, + borderWidth: 1, + }, + mode: "x", + onZoomComplete: () => self.onGesture(), }, - }); - } + pan: { + enabled: true, + mode: "x", + modifierKey: "shift", + onPanComplete: () => self.onGesture(), + }, + limits: { x: { minRange: MIN_ZOOM_MS } }, + }, + }, + scales: { + // Linear x positions points by real elapsed time (gaps occupy proportional + // space); axis hidden as before. y is stacked (P2Pool+XvB = total) and + // grid/ticks follow the theme; padYAxis keeps a flat line off the floor. + x: { type: "linear", display: false }, + y: { + stacked: true, + grid: { color: c.grid }, + ticks: { color: c.ticks }, + afterDataLimits: padYAxis, + }, + // Hidden 0–1 axis the Shares scatter rides on; markers pin near the top (0.93, + // set server-side) so they never affect the hashrate y-range (Issue #145). + shares: { type: "linear", display: false, min: 0, max: 1 }, + }, + }, + }); + } - // Apply the persisted show/hide state to the datasets (Issue #47); each defaults to visible. - // Hiding a stacked series re-stacks the rest (Chart.js excludes hidden datasets from the sum). - applyVisibility() { - const vis = this.props.series || {}; - for (const s of SERIES) this.chart.setDatasetVisibility(s.idx, vis[s.key] !== false); - } + // Apply the persisted show/hide state to the datasets (Issue #47); each defaults to visible. + // Hiding a stacked series re-stacks the rest (Chart.js excludes hidden datasets from the sum). + applyVisibility() { + const vis = this.props.series || {}; + for (const s of SERIES) this.chart.setDatasetVisibility(s.idx, vis[s.key] !== false); + } - sync() { - if (!this.chart) { this.create(); return; } - const d = this.props.chart; - const c = paletteColors(); // re-read so a theme switch recolours in place - const tension = d.tension ?? 0.3; - this.shareCounts = d.shares.map((s) => s.c); - const ds = this.chart.data.datasets; - ds[0].data = d.p2pool; ds[0].borderColor = c.accent; ds[0].backgroundColor = areaFill(c.accent); ds[0].tension = tension; - ds[1].data = d.xvb; ds[1].borderColor = c.purple; ds[1].backgroundColor = areaFill(c.purple); ds[1].tension = tension; - ds[2].data = d.shares; ds[2].borderColor = c.shares; ds[2].backgroundColor = c.shares; - ds[2].pointRadius = d.shares.map((s) => s.r); - this.chart.options.scales.y.grid.color = c.grid; - this.chart.options.scales.y.ticks.color = c.ticks; - this.applyVisibility(); + sync() { + if (!this.chart) { + this.create(); + return; + } + const d = this.props.chart; + const c = paletteColors(); // re-read so a theme switch recolours in place + const tension = d.tension ?? 0.3; + this.shareCounts = d.shares.map((s) => s.c); + const ds = this.chart.data.datasets; + ds[0].data = d.p2pool; + ds[0].borderColor = c.accent; + ds[0].backgroundColor = areaFill(c.accent); + ds[0].tension = tension; + ds[1].data = d.xvb; + ds[1].borderColor = c.purple; + ds[1].backgroundColor = areaFill(c.purple); + ds[1].tension = tension; + ds[2].data = d.shares; + ds[2].borderColor = c.shares; + ds[2].backgroundColor = c.shares; + ds[2].pointRadius = d.shares.map((s) => s.r); + this.chart.options.scales.y.grid.color = c.grid; + this.chart.options.scales.y.ticks.color = c.ticks; + this.applyVisibility(); - // On the zoomed -> preset transition (Reset or picking a preset clears the window), drop - // any stale plugin zoom transform so the axis re-fits the new preset data. Keyed to the - // transition (not merely "window is null") so a refresh mid-gesture can't clobber an - // in-progress zoom before its debounce fires. - const justCleared = this._prevWindow && !this.props.window; - this._prevWindow = this.props.window; - if (justCleared && this.chart.isZoomedOrPanned && this.chart.isZoomedOrPanned()) { - this.applyingServerData = true; - this.chart.resetZoom('none'); - this.applyingServerData = false; - } - this.chart.update(); - this.chart.resize(); + // On the zoomed -> preset transition (Reset or picking a preset clears the window), drop + // any stale plugin zoom transform so the axis re-fits the new preset data. Keyed to the + // transition (not merely "window is null") so a refresh mid-gesture can't clobber an + // in-progress zoom before its debounce fires. + const justCleared = this._prevWindow && !this.props.window; + this._prevWindow = this.props.window; + if (justCleared && this.chart.isZoomedOrPanned && this.chart.isZoomedOrPanned()) { + this.applyingServerData = true; + this.chart.resetZoom("none"); + this.applyingServerData = false; } + this.chart.update(); + this.chart.resize(); + } - render(props) { - const zoomed = !!props.window; - return html` + render(props) { + const zoomed = !!props.window; + return html`
Range: - ${RANGES.map(([r, label]) => html` { e.preventDefault(); props.onRange(r); }}>${label}`)} - ${zoomed + ${RANGES.map( + ([r, label]) => html` { + e.preventDefault(); + props.onRange(r); + }}>${label}`, + )} + ${ + zoomed ? html`` - : html`Drag to zoom · Shift-drag to pan · Scroll to zoom`} + : html`Drag to zoom · Shift-drag to pan · Scroll to zoom` + }
Avg: - ${WINDOWS.map(([w, label]) => html``)} + title=${WINDOW_HINT[w] || label + " average"} + onClick=${() => props.onAvgWindow && props.onAvgWindow(w)}>${label}`, + )}
${SERIES.map((s) => { - const on = (props.series || {})[s.key] !== false; - return html``; })}
`; - } + } } diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index 533f337c..1e846e18 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -2,40 +2,57 @@ // on the server). The server sends formatted display strings and semantic tokens // (variant: "ok"/"purple"/"accent"/"muted", level: "high"/"ok"); the client maps those to // classes — it does no number formatting or business logic of its own. -import { Component, Fragment, html } from './preact.mjs'; -import { ChartCard } from './chart.mjs'; + +import { ChartCard } from "./chart.mjs"; import { - WORKER_COLUMNS, sortWorkers, THEME_ORDER, THEME_LABELS, heroKpis, raffleCls, - computeEarnings, formatXmr, formatTimeToShare, parseHashrate, uptimeCell, -} from './logic.mjs'; + computeEarnings, + egressRoute, + formatTimeToShare, + formatXmr, + heroKpis, + parseHashrate, + raffleCls, + sortWorkers, + THEME_LABELS, + THEME_ORDER, + uptimeCell, + WORKER_COLUMNS, +} from "./logic.mjs"; +import { Component, Fragment, html } from "./preact.mjs"; +import { StackTopology } from "./topology.mjs"; // Palette token -> text-colour class (defined in dashboard.css). -const cVar = (v) => 'c-' + v; +const cVar = (v) => "c-" + v; // --- Small shared pieces ------------------------------------------------------------- -const StatCard = ({ label, value, cls, span }) => html` -
+const StatCard = ({ label, value, cls, span, title }) => html` +
${label}
-

${value}

+

${value}

`; -const SharesStat = ({ sw, label = 'Share In Window' }) => html` +const SharesStat = ({ sw, label = "Share In Window" }) => html`
${label}
-

${sw.count}

+

${sw.count}

`; -// Tari status with the ✔ the server signals via `active`. +// Tari merge-mine status. The ✔ means the gRPC channel is actually up, so it's gated on `connected` +// (channel_state READY) — NOT on `active` (a chain is merely configured). When configured but the +// channel is down (e.g. TRANSIENT_FAILURE) we show the raw state in a warn style and no ✔, so a dead +// channel can never read as "TRANSIENT_FAILURE ✔". const TariStatus = ({ tari }) => html` -

- ${tari.status}${tari.active ? html` ` : null} +

+ ${tari.status}${tari.connected ? html` ` : null}

`; const Badges = ({ badges }) => html`
- ${badges.map((b) => html` - ${b.text}`)} + ${badges.map( + (b) => html` + ${b.text}`, + )}
`; // Build-version badge (Issue #58). Muted badge-outline so it reads as informative, not loud; @@ -43,24 +60,24 @@ const Badges = ({ badges }) => html` // release to `vX.Y.Z` and any other build to `dev · branch @ hash`, so a dev build is // unmistakable. `dev` adds a marker class purely as a class hook (text already distinguishes it). const VersionBadge = ({ version }) => - version && version.text - ? html`${version.text}` - : null; + version && version.text + ? html`${version.text}` + : null; // New-release callout (#224). Shown only when the server reports a newer GitHub release is available // (opt-in `dashboard.check_for_updates`, off by default). Notify-only — it's a link to the release, // not an upgrade button (#59). Accent so it's noticeable; opens the release page in a new tab. const UpdateBadge = ({ update }) => - update && update.available && update.url - ? html`New release ${update.latest} available ↗` - : null; + : null; const HighUsage = ({ level }) => - level === 'high' ? html`High Usage` : null; + level === "high" ? html`High Usage` : null; // Theme icons (Issue #43) — minimal Lucide-style line glyphs drawn with currentColor, so they // pick up the segment's text colour (muted → full on hover/active). Inline SVG keeps them crisp @@ -69,9 +86,13 @@ const svgIcon = (body) => html` `; const THEME_ICON = { - light: () => svgIcon(html``), - auto: () => svgIcon(html``), - dark: () => svgIcon(html``), + light: () => + svgIcon( + html``, + ), + auto: () => + svgIcon(html``), + dark: () => svgIcon(html``), }; // Fixed bottom-right segmented control to pick light / auto / dark (Issue #43). Icon-only and @@ -79,25 +100,28 @@ const THEME_ICON = { // in every app state (loading / sync / dashboard) so it's always reachable; the choice is // persisted by the onTheme handler in dashboard.js. const ThemeSwitcher = ({ theme, onTheme }) => { - const current = theme || 'auto'; - return html` + const current = theme || "auto"; + return html`
- ${THEME_ORDER.map((id) => html` - `)} + `, + )}
`; }; // --- Top bar ------------------------------------------------------------------------- function Header({ state }) { - const s = state.system, hr = state.hashrate; - const labelCls = (level) => (level === 'high' ? 'status-bad' : 'text-muted'); - const valCls = (level) => (level === 'high' ? 'status-bad' : ''); - return html` + const s = state.system, + hr = state.hashrate; + const labelCls = (level) => (level === "high" ? "status-bad" : "text-muted"); + const valCls = (level) => (level === "high" ? "status-bad" : ""); + return html`
@@ -121,13 +145,13 @@ function Header({ state }) {
RAM: ${s.mem.used} / ${s.mem.total} GB (${s.mem.percent}) <${HighUsage} level=${s.mem.level} /> - Huge Pages: ${s.hugepages.status} (${s.hugepages.value}) + Huge Pages: ${s.hugepages.status} (${s.hugepages.value})
- Disk: ${s.disk.used} / ${s.disk.total} GB (${s.disk.percent}) <${HighUsage} level=${s.disk.level} /> + Disk: ${s.disk.used} / ${s.disk.total} GB (${s.disk.percent}) <${HighUsage} level=${s.disk.level} />
-
+
@@ -135,8 +159,8 @@ function Header({ state }) {
Last Update: ${state.last_update}
-
P2Pool (routed): ${hr.p2p_1h} (1h) / ${hr.p2p_24h} (24h)
-
XvB (routed): ${hr.xvb_routed_1h} (1h) / ${hr.xvb_routed_24h} (24h)
+
P2Pool (routed): ${hr.p2p_1h} (1h) / ${hr.p2p_24h} (24h)
+
XvB (routed): ${hr.xvb_routed_1h} (1h) / ${hr.xvb_routed_24h} (24h)
`; } @@ -149,28 +173,33 @@ function Header({ state }) { // when operational — during sync the numbers aren't meaningful yet. const HeroBand = ({ state }) => html`
- ${heroKpis(state).map((k) => html` + ${heroKpis(state).map( + (k) => html`
-
${k.value}
+
${k.value}
${k.label}
-
`)} +
`, + )}
`; // --- Sync Mode ----------------------------------------------------------------------- function Gauge({ percent, state }) { - const inner = state === 'done' - ? html`` - : state === 'loading' ? '…' : percent + '%'; - return html` + const inner = + state === "done" + ? html`` + : state === "loading" + ? "…" + : percent + "%"; + return html`
-
+
${inner}
`; } function SyncView({ sync }) { - return html` + return html`

System is currently synchronizing with the network.

@@ -198,10 +227,12 @@ function SyncView({ sync }) { // --- Operational cards --------------------------------------------------------------- function Overview({ state }) { - const hr = state.hashrate, st = state.stratum, t = state.tari; - // Stat order (#159): fleet headline (total / mode / workers) → raffle status (tier / VIP / - // shares / target) → routed split → reference (last share / Tari / wallets). - return html` + const hr = state.hashrate, + st = state.stratum, + t = state.tari; + // Stat order (#159): fleet headline (total / mode / workers) → raffle status (tier / VIP / + // shares / target) → routed split → reference (last share / Tari / wallets). + return html`

Overview

@@ -225,8 +256,9 @@ function Overview({ state }) { } function NodeStats({ state }) { - const hr = state.hashrate, st = state.stratum; - return html` + const hr = state.hashrate, + st = state.stratum; + return html`

My P2Pool Node Stats

@@ -251,8 +283,8 @@ function NodeStats({ state }) { } function GlobalStats({ state }) { - const p = state.pool; - return html` + const p = state.pool; + return html`

Global P2Pool Stats

@@ -273,26 +305,38 @@ function GlobalStats({ state }) { } function XvBStats({ state }) { - const hr = state.hashrate; - return html` + const hr = state.hashrate; + // When the xmrvsbeast.com fetch is stale (#311) the CREDITED figures are frozen — + // grey them and tag the label so they don't read as live. Routed (our own proxy + // history) is unaffected. Tooltip explains why. + const staleTitle = + "Stale: no successful fetch from xmrvsbeast.com since the time below. The credited " + + "figures are frozen at the last reading; the controller holds its split until a fresh read lands."; + const credLabel = (base) => (hr.xvb_stale ? base + " ⚠" : base); + const credCls = hr.xvb_stale ? "status-warn" : cVar(hr.xvb_variant); + const credTitle = hr.xvb_stale ? staleTitle : ""; + return html`

XvB Donation Stats

<${StatCard} label="Current Tier" value=${hr.tier} /> <${StatCard} label="Target Tier" value=${hr.target_tier} /> <${StatCard} label="1h Avg (Routed)" value=${hr.xvb_routed_1h} cls=${cVar(hr.xvb_variant)} /> - <${StatCard} label="1h Avg (Credited)" value=${hr.xvb_1h} cls=${cVar(hr.xvb_variant)} /> + <${StatCard} label=${credLabel("1h Avg (Credited)")} value=${hr.xvb_1h} cls=${credCls} title=${credTitle} /> <${StatCard} label="24h Avg (Routed)" value=${hr.xvb_routed_24h} cls=${cVar(hr.xvb_variant)} /> - <${StatCard} label="24h Avg (Credited)" value=${hr.xvb_24h} cls=${cVar(hr.xvb_variant)} /> + <${StatCard} label=${credLabel("24h Avg (Credited)")} value=${hr.xvb_24h} cls=${credCls} title=${credTitle} /> <${StatCard} label="Fail Count" value=${hr.xvb_fail_count} />
-
Stats fetched from xmrvsbeast.com (Updated: ${hr.xvb_updated})
+
+ ${hr.xvb_stale ? "⚠ Stale — last successful fetch from xmrvsbeast.com: " : "Stats fetched from xmrvsbeast.com (Updated: "}${hr.xvb_updated}${hr.xvb_stale ? "" : ")"} +
`; } function NetworkCard({ state }) { - const n = state.network, m = state.monero; - return html` + const n = state.network, + m = state.monero; + return html`

XMR Network

@@ -314,28 +358,28 @@ function NetworkCard({ state }) { // live P2Pool 1h-average hashrate (the same `p2pool_hr` figure the header / Overview show, which // already excludes the XvB-donated slice) until they take control, then holds their raw text. class EarningsCard extends Component { - constructor(props) { - super(props); - this.state = { input: null }; - this.onInput = (e) => this.setState({ input: e.target.value }); - } - - render() { - const e = this.props.earnings; - if (!e || !e.available) { - return html` + constructor(props) { + super(props); + this.state = { input: null }; + this.onInput = (e) => this.setState({ input: e.target.value }); + } + + render() { + const e = this.props.earnings; + if (!e || !e.available) { + return html`

P2Pool Earnings (estimated)

Network stats unavailable — the estimate can't be computed right now.

`; - } - const { input } = this.state; - const useDefault = input === null; - // Default to your P2Pool 1h-average hashrate (the figure shown in the header / Overview, - // already excluding the XvB-donated slice); once edited, use the parsed what-if value. - const hr = useDefault ? e.p2pool_hr : parseHashrate(input); - const est = computeEarnings(hr, e); - return html` + } + const { input } = this.state; + const useDefault = input === null; + // Default to your P2Pool 1h-average hashrate (the figure shown in the header / Overview, + // already excluding the XvB-donated slice); once edited, use the parsed what-if value. + const hr = useDefault ? e.p2pool_hr : parseHashrate(input); + const est = computeEarnings(hr, e); + return html`

P2Pool Earnings (estimated)

Estimated XMR from P2Pool mining only — excludes XvB donations and Tari merge-mining.

@@ -354,11 +398,11 @@ class EarningsCard extends Component {

${e.disclaimer}

`; - } + } } function TariCard({ tari }) { - return html` + return html`

Tari Merge Mining

@@ -374,29 +418,29 @@ function TariCard({ tari }) { // --- Workers table (WORKER_COLUMNS + sortWorkers live in logic.mjs, unit-tested) ----- function PoolBadge({ pool }) { - if (pool === 'p2pool') return html`P2Pool`; - if (pool === 'xvb') return html`XvB`; - return html`Unknown`; + if (pool === "p2pool") return html`P2Pool`; + if (pool === "xvb") return html`XvB`; + return html`Unknown`; } // Pool-wide proxy share totals (Issue #82) — a footer under the table. Hidden until the proxy // has reported any shares so it isn't an all-zero line on a fresh start. const ProxyTotals = ({ summary }) => { - if (!summary || !summary.has_data) return null; - // htm trims whitespace that wraps across a newline at an element boundary, so the spaces - // around the rejected are added explicitly via ${' '} rather than left to indentation. - const rejCls = summary.reject_level === 'high' ? 'status-bad' : ''; - return html` + if (!summary || !summary.has_data) return null; + // htm trims whitespace that wraps across a newline at an element boundary, so the spaces + // around the rejected are added explicitly via ${' '} rather than left to indentation. + const rejCls = summary.reject_level === "high" ? "status-bad" : ""; + return html`
- Proxy totals: ${summary.accepted} accepted ·${' '} - ${summary.rejected} rejected (${summary.reject_pct}) ·${' '} + Proxy totals: ${summary.accepted} accepted ·${" "} + ${summary.rejected} rejected (${summary.reject_pct}) ·${" "} ${summary.invalid} invalid · Best diff ${summary.best}
`; }; function WorkersTable({ workers, summary, ui, onSort }) { - const rows = sortWorkers(workers, ui.sortIndex, ui.sortAsc); - return html` + const rows = sortWorkers(workers, ui.sortIndex, ui.sortAsc); + return html`

Workers Alive

@@ -405,19 +449,27 @@ function WorkersTable({ workers, summary, ui, onSort }) { ${WORKER_COLUMNS.map((c, i) => html` onSort(i)}>${c.label}`)} - ${rows.map((w) => html` - - ${w.name} <${PoolBadge} pool=${w.pool} /> + ${rows.map( + (w) => html` + + ${w.name} <${PoolBadge} pool=${w.pool} />${ + w.api_ok === false + ? html` api ⚠` + : null + } ${w.ip} ${uptimeCell(w)} ${w.h10_str} ${w.h60_str} ${w.h15_str} ${w.accepted_str} - ${w.rejected_str}${w.reject_flag + ${w.rejected_str}${ + w.reject_flag ? html` ${w.reject_flag.text}` - : null} - `)} + : null + } + `, + )}
@@ -427,17 +479,75 @@ function WorkersTable({ workers, summary, ui, onSort }) { // --- Operational view ---------------------------------------------------------------- -function DashboardView({ state, ui, onRange, onSort, onView, onZoom, onResetZoom, onToggleSeries, onAvgWindow }) { - const advanced = ui.view === 'advanced'; - // Layout by operator relevance (#159): the at-a-glance chart and the rigs themselves lead (this - // stack may drive many machines), then this stack's own detail cards, then pool-wide and network - // context as reference at the bottom — "mine" first, "the world" last. - return html` -
+// Component Health & egress posture (#170). The topology map (StackTopology) is the panel: every +// component and the route of each link, derived from live config (service/egress.py), so it can't +// drift from reality. The glanceable summary rides in the header badges + the line below; the older +// per-component egress list lives on as an expandable drawer for the full text detail / a11y. +function ComponentHealth({ topology, egress }) { + if (!topology) return null; + const ok = topology.summary.level === "ok"; + return html` +
+

Stack Topology & Egress

+
+ ${ok ? "🛡️" : "⚠️"} ${topology.summary.label} +
+ <${StackTopology} topology=${topology} /> + ${ + egress + ? html`
+ All connections (per component) +
+ ${egress.components.map( + (comp) => html` +
+
${comp.name}
+
    + ${comp.conns.map((conn) => { + const r = egressRoute(conn.route); + return html` +
  • + ${r.icon} ${r.label} + ${conn.to}${ + conn.blocked_by_firewall + ? html` (firewall-blocked)` + : "" + } +
  • `; + })} +
+
`, + )} +
+
` + : "" + } +
`; +} + +function DashboardView({ + state, + ui, + onRange, + onSort, + onView, + onZoom, + onResetZoom, + onToggleSeries, + onAvgWindow, +}) { + const advanced = ui.view === "advanced"; + // Layout by operator relevance (#159): the at-a-glance chart and the rigs themselves lead (this + // stack may drive many machines), then this stack's own detail cards, then pool-wide and network + // context as reference at the bottom — "mine" first, "the world" last. + return html` +
- - + +
@@ -457,32 +567,47 @@ function DashboardView({ state, ui, onRange, onSort, onView, onZoom, onResetZoom <${TariCard} tari=${state.tari} /> <${GlobalStats} state=${state} /> <${NetworkCard} state=${state} /> + <${ComponentHealth} topology=${state.topology} egress=${state.egress} />
`; } // --- Root ---------------------------------------------------------------------------- -export function App({ state, connected, ui, onRange, onSort, onView, onTheme, onZoom, onResetZoom, onToggleSeries, onAvgWindow }) { - // The theme toggle is fixed-position and always available, even before the first data load. - const switcher = html`<${ThemeSwitcher} theme=${ui.theme} onTheme=${onTheme} />`; - if (!state) { - return html`<${Fragment}> -
${connected ? 'Connecting to the dashboard…' : 'Cannot reach the dashboard.'}
+export function App({ + state, + connected, + ui, + onRange, + onSort, + onView, + onTheme, + onZoom, + onResetZoom, + onToggleSeries, + onAvgWindow, +}) { + // The theme toggle is fixed-position and always available, even before the first data load. + const switcher = html`<${ThemeSwitcher} theme=${ui.theme} onTheme=${onTheme} />`; + if (!state) { + return html`<${Fragment}> +
${connected ? "Connecting to the dashboard…" : "Cannot reach the dashboard."}
${switcher} `; - } - return html`<${Fragment}> + } + return html`<${Fragment}> <${Header} state=${state} /> ${!connected ? html`
Disconnected — showing last known data. Retrying…
` : null} - ${state.syncing + ${ + state.syncing ? html`<${SyncView} sync=${state.sync} />` : html`<${Fragment}> <${HeroBand} state=${state} /> <${DashboardView} state=${state} ui=${ui} onRange=${onRange} onSort=${onSort} onView=${onView} onZoom=${onZoom} onResetZoom=${onResetZoom} onToggleSeries=${onToggleSeries} onAvgWindow=${onAvgWindow} /> - `} + ` + } ${switcher} `; } diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index a76bc85a..fedcb816 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -18,239 +18,723 @@ * Chart.js chart (which reads them via getComputedStyle) all follow the active theme. */ :root, :root[data-theme="dark"] { - --bg: #0d1117; --card: #161b22; --border: #30363d; - --text: #c9d1d9; --text-muted: #8b949e; - --accent: #58a6ff; --ok: #238636; --bad: #da3633; --warn: #d29922; --purple: #a371f7; - --elevated: #2d333b; /* raised surface (e.g. the active theme segment): lighter than --card */ + --bg: #0d1117; + --card: #161b22; + --border: #30363d; + --text: #c9d1d9; + --text-muted: #8b949e; + --accent: #58a6ff; + --ok: #238636; + --bad: #da3633; + --warn: #d29922; + --purple: #a371f7; + --elevated: #2d333b; /* raised surface (e.g. the active theme segment): lighter than --card */ } /* Light palette — GitHub Primer light, mirroring the dark set's GitHub-dark origin. Kept in * sync with the auto block below (plain CSS has no way to share one declaration list across a * selector and a media query). */ :root[data-theme="light"] { - --bg: #ffffff; --card: #f6f8fa; --border: #d0d7de; - --text: #1f2328; --text-muted: #656d76; - --accent: #0969da; --ok: #1a7f37; --bad: #cf222e; --warn: #9a6700; --purple: #8250df; - --elevated: #ffffff; /* white raised thumb on the off-white --card track */ + --bg: #ffffff; + --card: #f6f8fa; + --border: #d0d7de; + --text: #1f2328; + --text-muted: #656d76; + --accent: #0969da; + --ok: #1a7f37; + --bad: #cf222e; + --warn: #9a6700; + --purple: #8250df; + --elevated: #ffffff; /* white raised thumb on the off-white --card track */ } /* Auto: follow the system, but only when the user hasn't pinned dark or light. */ @media (prefers-color-scheme: light) { - :root:not([data-theme="dark"]):not([data-theme="light"]) { - --bg: #ffffff; --card: #f6f8fa; --border: #d0d7de; - --text: #1f2328; --text-muted: #656d76; - --accent: #0969da; --ok: #1a7f37; --bad: #cf222e; --warn: #9a6700; --purple: #8250df; - --elevated: #ffffff; - } + :root:not([data-theme="dark"]):not([data-theme="light"]) { + --bg: #ffffff; + --card: #f6f8fa; + --border: #d0d7de; + --text: #1f2328; + --text-muted: #656d76; + --accent: #0969da; + --ok: #1a7f37; + --bad: #cf222e; + --warn: #9a6700; + --purple: #8250df; + --elevated: #ffffff; + } } -body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); padding: 20px; margin: 0; transition: background-color 0.2s ease, color 0.2s ease; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + padding: 20px; + margin: 0; + transition: + background-color 0.2s ease, + color 0.2s ease; +} -.container { max-width: 1400px; margin: 0 auto; } +.container { + max-width: 1400px; + margin: 0 auto; +} /* Layout & Grid */ -.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border); padding-bottom: 20px; margin-bottom: 20px; } -.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; margin-bottom: 20px; } -.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 20px; display: flex; flex-direction: column; min-width: 0; } +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + border-bottom: 1px solid var(--border); + padding-bottom: 20px; + margin-bottom: 20px; +} +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 20px; + margin-bottom: 20px; +} +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 20px; + display: flex; + flex-direction: column; + min-width: 0; +} /* Brand block (Issue #81): the Pithead mark + wordmark in the header, with the host IP demoted * to a subtitle beneath the name. The host IP (HOST_IP) is arbitrary user input; a long unbroken * value (no hyphens/dots to break at) would otherwise push the header — and the page — wider than * a phone (Issue #83), so overflow-wrap:anywhere lets it break mid-token and wrap instead. */ -.brand { display: flex; align-items: center; gap: 12px; min-width: 0; } -.brand-logo { width: 40px; height: 40px; flex: none; } -.brand-name { margin: 0; font-size: 1.5rem; font-weight: 700; letter-spacing: 0.5px; } -.brand-host { font-size: 0.8rem; margin-top: 3px; line-height: 1.35; overflow-wrap: anywhere; } +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.brand-logo { + width: 40px; + height: 40px; + flex: none; +} +.brand-name { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.5px; +} +.brand-host { + font-size: 0.8rem; + margin-top: 3px; + line-height: 1.35; + overflow-wrap: anywhere; +} /* Host subtitle is "hostname @ ip" (Issue #119): the @ is a faint connector, not a value, so dim it and give it even breathing room. Hostname and ip stay at the one muted subtitle weight. */ -.brand-host-at { margin: 0 0.4em; opacity: 0.5; } +.brand-host-at { + margin: 0 0.4em; + opacity: 0.5; +} /* Hero KPI band (Issue #81): a prominent strip of the headline numbers under the header. A * responsive auto-fit grid so the cards sit in one row on a wide screen and reflow when narrow; * long tier/mode text wraps inside its card rather than overflowing. */ -.hero-band { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 20px; } -.hero-kpi { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px 18px; text-align: center; } -.hero-value { font-size: 1.6rem; font-weight: 700; line-height: 1.2; overflow-wrap: anywhere; } -.hero-label { margin-top: 6px; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); } +.hero-band { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} +.hero-kpi { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 18px; + text-align: center; +} +.hero-value { + font-size: 1.6rem; + font-weight: 700; + line-height: 1.2; + overflow-wrap: anywhere; +} +.hero-label { + margin-top: 6px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} /* Typography */ -h2 { margin: 0; font-size: 1.5rem; font-weight: 600; } -h3 { margin: 0 0 15px 0; font-size: 0.85rem; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; border-bottom: 1px solid var(--border); padding-bottom: 10px; } -.text-muted { color: var(--text-muted); } -.text-accent { color: var(--accent); } -.text-small { font-size: 0.85rem; } -.text-xs { font-size: 0.75rem; } -.font-mono { font-family: monospace; } +h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} +h3 { + margin: 0 0 15px 0; + font-size: 0.85rem; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + padding-bottom: 10px; +} +.text-muted { + color: var(--text-muted); +} +.text-accent { + color: var(--accent); +} +.text-small { + font-size: 0.85rem; +} +.text-xs { + font-size: 0.75rem; +} +.font-mono { + font-family: monospace; +} /* Stats */ -.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} /* min-width:0 lets the 1fr tracks shrink below their content's intrinsic width — without it a * long unbroken value (a shortened wallet like "48edf…aZ1", a hash, "Donor (1.00 kH/s+)") keeps * the grid wider than the card and overflows it on narrow screens (Issue #83). The values then * wrap via overflow-wrap on the

below rather than spilling out. */ -.stat-card { background: var(--bg); padding: 10px 12px; border-radius: 4px; border: 1px solid var(--border); min-width: 0; } -.stat-card h5 { margin: 0 0 4px 0; font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; } -.stat-card p { margin: 0; font-weight: 600; font-size: 0.95rem; overflow-wrap: anywhere; } -.col-span-2 { grid-column: span 2; } +.stat-card { + background: var(--bg); + padding: 10px 12px; + border-radius: 4px; + border: 1px solid var(--border); + min-width: 0; +} +.stat-card h5 { + margin: 0 0 4px 0; + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; +} +.stat-card p { + margin: 0; + font-weight: 600; + font-size: 0.95rem; + overflow-wrap: anywhere; +} +.col-span-2 { + grid-column: span 2; +} /* Earnings calculator what-if input (Issue #12) */ -.earnings-subtitle { margin: -4px 0 12px 0; line-height: 1.4; } -.earnings-input { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } -.earnings-input label { font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; } -.earnings-input input { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 4px; padding: 6px 10px; font-family: inherit; font-size: 0.9rem; } -.earnings-input input:focus { outline: none; border-color: var(--accent); } -.earnings-disclaimer { line-height: 1.4; } +.earnings-subtitle { + margin: -4px 0 12px 0; + line-height: 1.4; +} +.earnings-input { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.earnings-input label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; +} +.earnings-input input { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 6px 10px; + font-family: inherit; + font-size: 0.9rem; +} +.earnings-input input:focus { + outline: none; + border-color: var(--accent); +} +.earnings-disclaimer { + line-height: 1.4; +} /* Table */ -table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } -th { text-align: left; font-size: 0.75rem; color: var(--text-muted); padding: 10px; border-bottom: 1px solid var(--border); text-transform: uppercase; cursor: pointer; } -td { padding: 10px; border-bottom: 1px solid var(--border); } -tr:last-child td { border-bottom: none; } +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} +th { + text-align: left; + font-size: 0.75rem; + color: var(--text-muted); + padding: 10px; + border-bottom: 1px solid var(--border); + text-transform: uppercase; + cursor: pointer; +} +td { + padding: 10px; + border-bottom: 1px solid var(--border); +} +tr:last-child td { + border-bottom: none; +} /* Pool-wide proxy share totals under the Workers table (Issue #82). */ -.proxy-totals { margin-top: 12px; } +.proxy-totals { + margin-top: 12px; +} /* Horizontal-scroll wrapper for the workers table (Issue #83): the table can be wider than a * phone viewport (eight columns, incl. accepted/rejected per #82), so it lives in this wrapper * that scrolls sideways within its card instead of stretching the whole page. Cells stay on one * line so columns don't collapse; harmless on desktop, where the table fits and no scrollbar * appears. */ -.table-scroll { overflow-x: auto; } -.table-scroll th, .table-scroll td { white-space: nowrap; } +.table-scroll { + overflow-x: auto; +} +.table-scroll th, +.table-scroll td { + white-space: nowrap; +} /* Components */ -.status-ok { color: var(--ok); } -.status-bad { color: var(--bad); } -.status-warn { color: var(--warn); } - -.progress-bg { background: var(--border); border-radius: 4px; height: 6px; width: 100%; margin-top: 6px; overflow: hidden; } -.progress-fill { background: var(--accent); height: 100%; border-radius: 4px; } -.progress-fill.warning { background: var(--warn); } -.progress-fill.critical { background: var(--bad); } - -.badge { padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; vertical-align: middle; display: inline-block; } -.badge-outline { border: 1px solid var(--border); color: var(--text-muted); } -.badge-ok { background: var(--ok); color: white; } -.badge-purple { background: var(--purple); color: white; } -.badge-accent { background: var(--accent); color: white; } -.badge-bad { background: var(--bad); color: white; } -.badge-warn { background: var(--warn); color: white; } +.status-ok { + color: var(--ok); +} +.status-bad { + color: var(--bad); +} +.status-warn { + color: var(--warn); +} + +.progress-bg { + background: var(--border); + border-radius: 4px; + height: 6px; + width: 100%; + margin-top: 6px; + overflow: hidden; +} +.progress-fill { + background: var(--accent); + height: 100%; + border-radius: 4px; +} +.progress-fill.warning { + background: var(--warn); +} +.progress-fill.critical { + background: var(--bad); +} + +.badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + vertical-align: middle; + display: inline-block; +} +.badge-outline { + border: 1px solid var(--border); + color: var(--text-muted); +} +.badge-ok { + background: var(--ok); + color: white; +} +.badge-purple { + background: var(--purple); + color: white; +} +.badge-accent { + background: var(--accent); + color: white; +} +.badge-bad { + background: var(--bad); + color: white; +} +.badge-warn { + background: var(--warn); + color: white; +} /* Header status badges sit in a gapped row, so individual badges need no margins. */ -.badge-row { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: 10px; } +.badge-row { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-left: 10px; +} /* Build-version badge (Issue #58): monospace for the version/hash; a dev build keeps the muted palette but swaps to a dashed border so it's quietly, unmistakably not a release. */ -.version-badge { font-family: monospace; font-weight: 500; } -.version-badge.version-dev { border-style: dashed; } +.version-badge { + font-family: monospace; + font-weight: 500; +} +.version-badge.version-dev { + border-style: dashed; +} -.wallet-text { font-size: 0.7rem; color: var(--text-muted); margin-top: auto; padding-top: 15px; word-break: break-all; font-family: monospace; } +.wallet-text { + font-size: 0.7rem; + color: var(--text-muted); + margin-top: auto; + padding-top: 15px; + word-break: break-all; + font-family: monospace; +} /* Chart Controls */ -.chart-controls { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 15px; } -.btn-range { display: inline-block; padding: 4px 12px; border-radius: 4px; text-decoration: none; font-size: 0.8rem; border: 1px solid var(--border); background-color: var(--card); color: var(--text-muted); transition: all 0.2s; } -.btn-range:hover { border-color: var(--text-muted); color: var(--text); } -.btn-range.active { background-color: var(--ok); color: #fff; border-color: var(--ok); } +.chart-controls { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 15px; +} +.btn-range { + display: inline-block; + padding: 4px 12px; + border-radius: 4px; + text-decoration: none; + font-size: 0.8rem; + border: 1px solid var(--border); + background-color: var(--card); + color: var(--text-muted); + transition: all 0.2s; +} +.btn-range:hover { + border-color: var(--text-muted); + color: var(--text); +} +.btn-range.active { + background-color: var(--ok); + color: #fff; + border-color: var(--ok); +} /* Reset-zoom button reuses the range-button look but needs the +

+ + + ${ROUTES.map( + (r) => html` + + + + + + `, + )} + + LAN + host — mining_net bridge + Tor → internet + ${edges.map((e) => this._edge(e, byId)).filter(Boolean)} + ${nodes.map((n) => { + const p = POS[n.id]; + if (!p) return null; + return html` + + + ${n.label} + `; + })} + +
`; + } + + _edge(e, byId) { + const a = POS[e.from]; + const b = POS[e.to]; + if (!a || !b) return null; + 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; + 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` + + ${tip} + `; + } +} diff --git a/build/dashboard/mining_dashboard/web/static/vendor/README.md b/build/dashboard/mining_dashboard/web/static/vendor/README.md index 6372fe41..e132db00 100644 --- a/build/dashboard/mining_dashboard/web/static/vendor/README.md +++ b/build/dashboard/mining_dashboard/web/static/vendor/README.md @@ -5,13 +5,16 @@ step and serves entirely from `/static` under a strict Content-Security-Policy (`script-src 'self'`, no `'unsafe-inline'`/`'unsafe-eval'`). Both are standalone ES modules with no bare imports and no `eval`/`new Function`, so they load and run under that CSP. -| File | Package | Version | Source | -|---------------------|----------|---------|------------------------------------------------------| -| `preact.module.js` | preact | 10.24.3 | https://unpkg.com/preact@10.24.3/dist/preact.module.js | -| `htm.module.js` | htm | 3.1.1 | https://unpkg.com/htm@3.1.1/dist/htm.module.js | -| `chart.umd.min.js` | chart.js | (vendored previously) | https://www.chartjs.org/ | -| `chartjs-plugin-zoom.min.js` | chartjs-plugin-zoom | 2.2.0 | https://unpkg.com/chartjs-plugin-zoom@2.2.0/dist/chartjs-plugin-zoom.min.js | -| `hammer.min.js` | hammerjs | 2.0.8 | https://unpkg.com/hammerjs@2.0.8/hammer.min.js | +| File | Package | Version | License | Source | +|---------------------|----------|---------|---------|--------------------------------------------| +| `preact.module.js` | preact | 10.24.3 | MIT | | +| `htm.module.js` | htm | 3.1.1 | Apache-2.0 | | +| `chart.umd.min.js` | chart.js | 4.4.6 | MIT | | +| `chartjs-plugin-zoom.min.js` | chartjs-plugin-zoom | 2.2.0 | MIT | | +| `hammer.min.js` | hammerjs | 2.0.8 | MIT | | + +These licenses are also recorded in the repo-root `THIRD_PARTY_LICENSES.md`. `chart.umd.min.js` +lives one directory up (in `static/`, not `vendor/`); listed here so the attribution is complete. `chartjs-plugin-zoom` is a UMD bundle (like `chart.umd.min.js`), loaded as a classic `