diff --git a/.github/workflows/backend-integration.yml b/.github/workflows/backend-integration.yml index f1a7290c43c..89624cb77b9 100644 --- a/.github/workflows/backend-integration.yml +++ b/.github/workflows/backend-integration.yml @@ -2,6 +2,7 @@ name: Backend Integration Tests with MariaDB on: pull_request: + branches: [master] types: [opened, review_requested, synchronize] push: branches: @@ -15,6 +16,10 @@ jobs: node: ["24.13.0"] fail-fast: false runs-on: mempool-ci + env: + COMPOSE_PROJECT_NAME: mempool-integration-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + MEMPOOL_TEST_BASE_CONFIG_FILE: ${{ github.workspace }}/${{ matrix.node }}/integration/backend/mempool-config.test.json + MEMPOOL_TEST_RUNTIME_CONFIG_FILE: ${{ github.workspace }}/${{ matrix.node }}/integration/backend/.mempool-config.integration.json name: Backend Integration Tests - node ${{ matrix.node }} @@ -87,7 +92,8 @@ jobs: npm run test:integration working-directory: ${{ matrix.node }}/integration/backend env: - MEMPOOL_CONFIG_FILE: ${{ github.workspace }}/${{ matrix.node }}/integration/backend/mempool-config.test.json + MEMPOOL_CONFIG_FILE: ${{ env.MEMPOOL_TEST_RUNTIME_CONFIG_FILE }} + SKIP_DB_TEARDOWN: '1' - name: Start MariaDB for server test run: docker compose -f docker-compose.test.yml up -d @@ -129,7 +135,7 @@ jobs: fi working-directory: ${{ matrix.node }}/integration/backend env: - MEMPOOL_CONFIG_FILE: ${{ github.workspace }}/${{ matrix.node }}/integration/backend/mempool-config.test.json + MEMPOOL_CONFIG_FILE: ${{ env.MEMPOOL_TEST_RUNTIME_CONFIG_FILE }} - name: Cleanup containers if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef76a6fb574..6656d416794 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI Pipeline for the Backend and Frontend on: pull_request: + branches: [master] types: [opened, review_requested, synchronize] push: branches: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 83a115fe681..f4abaf31920 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,6 +11,7 @@ on: - v[0-9]+.[0-9]+.[0-9]+ - v[0-9]+.[0-9]+.[0-9]+-* pull_request: + branches: [master] types: [opened, synchronize, reopened, labeled] permissions: diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index eb2fe97eab1..bed9fb53873 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -2,7 +2,7 @@ name: Universe CI on: push: - branches: [develop, main] + branches: [main] pull_request: branches: [develop, main] workflow_dispatch: @@ -12,7 +12,7 @@ permissions: concurrency: group: universe-ci-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: false jobs: docs: @@ -40,6 +40,9 @@ jobs: - name: Every dynamic palette carries a readable foreground run: node scripts/universe/check-palettes.mjs + - name: Every filled surface declares the ink on it + run: node scripts/universe/check-fills.mjs + - name: Contrast arithmetic is correct run: node --test scripts/universe/contrast.test.mjs @@ -52,10 +55,17 @@ jobs: - name: Gateway routing run: node --test scripts/universe/gateway.test.mjs + - name: The gateway bridges an upstream restart rather than failing + run: node --test scripts/universe/gateway-restart.test.mjs + backend: name: Backend build and test runs-on: [self-hosted, linux-ultra] timeout-minutes: 45 + env: + COMPOSE_PROJECT_NAME: mempool-integration-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + MEMPOOL_TEST_BASE_CONFIG_FILE: ${{ github.workspace }}/backend/mempool-config.test.json + MEMPOOL_TEST_RUNTIME_CONFIG_FILE: ${{ github.workspace }}/backend/.mempool-config.integration.json steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -83,10 +93,24 @@ jobs: working-directory: backend run: npm run test:ci + # The failure that shipped was a configuration one: the migrations never + # ran against the engine production uses, so nothing caught the syntax it + # rejects. These run the real backend against a real database. + - name: Migrations and repositories against a real database + working-directory: backend + run: npm run test:integration + env: + MEMPOOL_CONFIG_FILE: ${{ env.MEMPOOL_TEST_RUNTIME_CONFIG_FILE }} + frontend: name: Frontend build runs-on: [self-hosted, linux-ultra] - timeout-minutes: 60 + # The deep visual pass measured 48 minutes once the gated route list grew + # from three routes to seven, which put the job over the hour it had and + # killed it three minutes from the end. A gate that cannot finish protects + # nothing, which is the same reason the matrix was split in two to begin + # with. Budget is for the pass that actually runs, with room to grow. + timeout-minutes: 100 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -123,26 +147,73 @@ jobs: - name: Every asset the build references is in the output run: node scripts/universe/check-build-assets.mjs + - name: No em dash in what the build actually ships + run: node scripts/universe/check-text.mjs frontend/dist + - name: Install the visual QA harness working-directory: scripts/universe/visual-qa run: npm ci + - name: The unfinished-page gate fires on the states that shipped + working-directory: scripts/universe/visual-qa + run: node --test progress-gate.test.mjs + + # A fixed port here once measured another job's build: two runners share + # a host, the second gateway crashed on the taken port behind "&", the + # probe happily reached the first job's server, and the run died halfway + # when that other job ended. Each run now serves on its own free port and + # refuses to continue unless the process answering is the one it started. - name: Serve the built frontend run: | - UNIVERSE_GATEWAY_PORT=8123 UNIVERSE_GATEWAY_ROOT=frontend/dist/mempool/browser node scripts/universe/gateway.mjs & + GATEWAY_PORT=$(node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})") + echo "GATEWAY_PORT=$GATEWAY_PORT" >> "$GITHUB_ENV" + UNIVERSE_GATEWAY_PORT=$GATEWAY_PORT UNIVERSE_GATEWAY_ROOT=frontend/dist/mempool/browser node scripts/universe/gateway.mjs & + GATEWAY_PID=$! for i in $(seq 1 30); do - curl -fsS -o /dev/null http://127.0.0.1:8123/ && break + curl -fsS -o /dev/null "http://127.0.0.1:$GATEWAY_PORT/" && break sleep 1 done + kill -0 "$GATEWAY_PID" + curl -fsS "http://127.0.0.1:$GATEWAY_PORT/__gateway/health" + + # The harness fails the run when a page never finishes loading, when a + # chart panel draws nothing, or when a failure state says nothing about + # why. Those three states passed every other gate here and shipped. + # + # Split into two passes that finish, rather than one sweep that does not. + # Every combination of thirteen routes, seven data states, three themes + # and seven widths is nineteen hundred page loads, which does not fit the + # job timeout, and a gate that cannot finish protects nothing. + # + # The broad pass walks every route at a phone and a desktop width with + # real data, which is where overflow, broken images and a page that never + # finishes show up. The deep pass takes the routes this suite holds to a + # terminal state through every failure state, every theme and the widths + # that change the layout, which is where a page that lies about why it is + # empty shows up. Measured at about two and a half minutes and sixteen + # minutes respectively. + - name: Every route renders and finishes, at a phone and a desktop width + working-directory: scripts/universe/visual-qa + run: >- + node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-broad + --states=populated --themes=default --viewports=375,1440 + + - name: The gated routes hold up across themes, widths and every failure state + working-directory: scripts/universe/visual-qa + run: >- + node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts + --routes=graphs,mining,protocols,home,blocks,tx,address --viewports=320,768,1440 - - name: Measured contrast, accessibility, overflow, and screenshots + - name: Forced colours and 200 percent zoom working-directory: scripts/universe/visual-qa - run: node capture.mjs --base=http://127.0.0.1:8123 --out=./artifacts + run: node modes-check.mjs --base=http://127.0.0.1:$GATEWAY_PORT - name: Keep the contrast report and the route screenshots if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: universe-visual-qa - path: scripts/universe/visual-qa/artifacts + path: | + scripts/universe/visual-qa/artifacts + scripts/universe/visual-qa/artifacts-broad retention-days: 30 diff --git a/.github/workflows/universe-production-smoke.yml b/.github/workflows/universe-production-smoke.yml new file mode 100644 index 00000000000..a889acc7225 --- /dev/null +++ b/.github/workflows/universe-production-smoke.yml @@ -0,0 +1,44 @@ +name: Universe production smoke + +# Checks the live origin with nothing mocked. +# +# The rest of the suite answers every request from fixtures, which is what let +# a release ship with a Charts page and a Mining dashboard in front of routes +# that answered 404. Fixtures prove how a page renders given data; only this +# proves the deployment can produce any. + +on: + # After a release, and on a schedule so a dependency that fails later is + # noticed without anyone deploying. + workflow_dispatch: + inputs: + origin: + description: Origin to check + required: false + default: https://explorer.bitcoinuniverse.io + schedule: + - cron: '17 * * * *' + +permissions: + contents: read + +concurrency: + group: universe-production-smoke + cancel-in-progress: false + +jobs: + smoke: + name: Live origin answers with real content + runs-on: [self-hosted, linux-ultra] + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.19.0' + + - name: Every advertised feature has routes, data, and a matching release + run: node scripts/universe/synthetic-check.mjs "${{ github.event.inputs.origin || 'https://explorer.bitcoinuniverse.io' }}" diff --git a/.github/workflows/universe-release-artifact.yml b/.github/workflows/universe-release-artifact.yml new file mode 100644 index 00000000000..573e4f89157 --- /dev/null +++ b/.github/workflows/universe-release-artifact.yml @@ -0,0 +1,113 @@ +name: Universe release artifact + +# Builds the one artifact a release installs, on the runner fleet. +# +# It exists because the artifact used to be built on a workstation, and that +# machine has two ways of handing you a stale build without saying so: a +# leftover server process keeps serving an old output directory, and a failed +# build step can leave the previous output in place. Both have already produced +# a measurement that described a build nobody was running. A runner starts from +# a clean checkout every time, so the artifact matches the commit it names. + +# It builds the ref it is dispatched on, and takes no ref input. An input +# would be more convenient and is not safe: dispatched from the default +# branch, the job holds that branch's Actions cache scope, so building an +# arbitrary ref would run that ref's install and build scripts with write +# access to the cache every other workflow here restores from. Dispatch it +# against the branch or tag you want built instead. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + artifact: + name: Build and pack + runs-on: [self-hosted, linux-ultra] + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Record what is being built + id: sha + run: | + sha=$(git rev-parse HEAD) + printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT" + printf 'short=%s\n' "${sha:0:9}" >> "$GITHUB_OUTPUT" + printf 'Building %s (%s)\n' "$sha" "$(git log -1 --format=%s)" + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24.19.0' + + - name: Install backend + working-directory: backend + run: npm ci + + - name: Build backend + working-directory: backend + run: npm run build + + - name: Install frontend + working-directory: frontend + run: npm ci + + - name: Build frontend + working-directory: frontend + run: npm run build:universe + + # The same gates the CI run applies to a build, applied to the build that + # is about to be shipped. A green CI run on the same commit does not prove + # this output is sound, because this is a different build. + - name: Built bundle carries no upstream marks + run: node scripts/universe/check-branding.mjs frontend/dist + + - name: Built bundle calls no third-party origin + run: node scripts/universe/check-origins.mjs frontend/dist + + - name: Every asset the build references is in the output + run: node scripts/universe/check-build-assets.mjs + + - name: No em dash in what the build actually ships + run: node scripts/universe/check-text.mjs frontend/dist + + # release.sh reads the frontend from frontend/build, the backend from + # backend/dist, and the gateway from scripts/universe. node_modules and + # the compiled gbt module are not packed: install hard-links them from the + # running release, which is correct only while the dependency tree is + # unchanged. The lock file is packed so that release.sh install can + # compare it against the running release and refuse the hard link when it + # differs. That check belongs there, where both trees are visible; here + # only one of them is. + - name: Pack + id: pack + run: | + set -euo pipefail + sha='${{ steps.sha.outputs.short }}' + stage=$(mktemp -d) + mkdir -p "$stage/backend" "$stage/frontend" "$stage/scripts" + cp -a backend/dist "$stage/backend/dist" + cp -a backend/package.json "$stage/backend/package.json" + cp -a backend/package-lock.json "$stage/backend/package-lock.json" + cp -a frontend/dist/mempool/browser "$stage/frontend/build" + cp -a scripts/universe "$stage/scripts/universe" + test -f "$stage/backend/dist/index.js" + test -f "$stage/frontend/build/index.html" + test -f "$stage/scripts/universe/gateway.mjs" + out="$PWD/mempool-$sha.tar.gz" + tar -czf "$out" -C "$stage" backend frontend scripts + sha256sum "$out" | tee "$out.sha256" + printf 'name=mempool-%s\n' "$sha" >> "$GITHUB_OUTPUT" + + - name: Upload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.pack.outputs.name }} + path: | + mempool-*.tar.gz + mempool-*.tar.gz.sha256 + retention-days: 14 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index f154cf0ef3a..e1bde29dbbf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ node_modules/ # Visual QA working output. Curated review sets are committed under docs/design. scripts/universe/visual-qa/artifacts/ +scripts/universe/visual-qa/verify*/ +# Any output directory the harness is pointed at, not just the default one. +scripts/universe/visual-qa/artifacts-*/ scripts/universe/visual-qa/node_modules/ diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml index 9cb824d71e3..4294dca45c8 100644 --- a/backend/docker-compose.test.yml +++ b/backend/docker-compose.test.yml @@ -2,7 +2,10 @@ version: "3.7" services: db-test: - image: mariadb:10.5.21 + # Must stay the same engine and major version the Universe deployment runs. + # The migrations use MariaDB syntax that MySQL rejects outright, so a test + # database on a different engine proves nothing about the release. + image: mariadb:11.4 environment: MYSQL_DATABASE: "mempool_test" MYSQL_USER: "mempool_test" @@ -10,7 +13,9 @@ services: MYSQL_ROOT_PASSWORD: "admin" MARIADB_AUTO_UPGRADE: "1" ports: - - "33306:3306" + # Jest requests port zero so Docker reserves an unused port atomically. + # The shell-managed local test path keeps its established 33306 default. + - "127.0.0.1:${MEMPOOL_TEST_DB_PORT:-33306}:3306" healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mempool_test", "-pmempool_test"] interval: 5s diff --git a/backend/jest.integration.setup.ts b/backend/jest.integration.setup.ts index c5093b01ae5..95f2c23681b 100644 --- a/backend/jest.integration.setup.ts +++ b/backend/jest.integration.setup.ts @@ -1,12 +1,11 @@ // Setup that runs BEFORE setupFiles // This ensures MEMPOOL_CONFIG_FILE is set before any modules are loaded import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; import { execSync } from 'child_process'; -// Set the config file path if not already set -if (!process.env.MEMPOOL_CONFIG_FILE) { - process.env.MEMPOOL_CONFIG_FILE = path.join(__dirname, 'mempool-config.test.json'); -} +const defaultConfigFile = path.join(__dirname, 'mempool-config.test.json'); // Helper to get docker compose command (v1 or v2) function getDockerComposeCmd(): string { @@ -23,6 +22,45 @@ function getDockerComposeCmd(): string { } } +function setUniqueComposeProject(): string { + if (!process.env.COMPOSE_PROJECT_NAME) { + process.env.COMPOSE_PROJECT_NAME = `mempool-integration-${process.pid}-${Date.now()}`; + } + if (!process.env.MEMPOOL_TEST_DB_PORT) { + process.env.MEMPOOL_TEST_DB_PORT = '0'; + } + return process.env.COMPOSE_PROJECT_NAME; +} + +function getPublishedDatabasePort(dockerComposeCmd: string, composeFile: string): number { + const publishedAddress = execSync( + `${dockerComposeCmd} -f "${composeFile}" port db-test 3306`, + { cwd: __dirname, encoding: 'utf8' }, + ).trim(); + const match = publishedAddress.match(/:(\d+)$/); + const port = match ? Number(match[1]) : NaN; + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Could not determine the test database port from "${publishedAddress}"`); + } + + return port; +} + +function writeRuntimeConfig(port: number, composeProject: string): void { + const sourceConfigFile = process.env.MEMPOOL_TEST_BASE_CONFIG_FILE + || (!process.env.MEMPOOL_TEST_RUNTIME_CONFIG_FILE && process.env.MEMPOOL_CONFIG_FILE) + || defaultConfigFile; + const runtimeConfigFile = process.env.MEMPOOL_TEST_RUNTIME_CONFIG_FILE + || path.join(os.tmpdir(), `${composeProject}-config.json`); + const runtimeConfig = JSON.parse(fs.readFileSync(sourceConfigFile, 'utf8')); + + runtimeConfig.DATABASE.PORT = port; + fs.writeFileSync(runtimeConfigFile, `${JSON.stringify(runtimeConfig, null, 2)}\n`, { mode: 0o600 }); + process.env.MEMPOOL_TEST_DB_PORT = String(port); + process.env.MEMPOOL_CONFIG_FILE = runtimeConfigFile; +} + // Start the Docker test database container module.exports = async () => { // Skip if SKIP_DB_SETUP is set (e.g., when test-with-db.sh manages the database) @@ -31,28 +69,47 @@ module.exports = async () => { return; } + const composeFile = path.join(__dirname, 'docker-compose.test.yml'); + const dockerComposeCmd = getDockerComposeCmd(); + const composeProject = setUniqueComposeProject(); + console.log('Starting test database container...'); try { - const composeFile = path.join(__dirname, 'docker-compose.test.yml'); - const dockerComposeCmd = getDockerComposeCmd(); + // A container left running by an earlier run is not the container this run + // asked for: compose reports it as already up and never recreates it, so a + // change of image or of engine is silently ignored and the tests measure + // whatever was there before. Tear it down, volumes included, first. + try { + execSync(`${dockerComposeCmd} -f "${composeFile}" down -v --remove-orphans`, { + stdio: 'inherit', + cwd: __dirname + }); + } catch { + // Nothing to remove on a clean machine, which is the normal case. + } - // Start the container - execSync(`${dockerComposeCmd} -f "${composeFile}" up -d`, { + execSync(`${dockerComposeCmd} -f "${composeFile}" up -d --force-recreate`, { stdio: 'inherit', cwd: __dirname }); + writeRuntimeConfig(getPublishedDatabasePort(dockerComposeCmd, composeFile), composeProject); // Wait for database to be ready console.log('Waiting for database to be ready...'); let attempts = 0; - const maxAttempts = 30; + const maxAttempts = 60; while (attempts < maxAttempts) { try { - execSync(`${dockerComposeCmd} -f "${composeFile}" exec -T db-test mysqladmin ping -h localhost -u mempool_test -pmempool_test --silent`, { - cwd: __dirname, - stdio: 'pipe' - }); + // MariaDB renamed its client binaries; either name may be the one this + // image ships, so a probe that only knows one of them reports a + // healthy server as a database that never started. + execSync( + `${dockerComposeCmd} -f "${composeFile}" exec -T db-test sh -c ` + + `"mariadb-admin ping -h localhost -u mempool_test -pmempool_test --silent ` + + `|| mysqladmin ping -h localhost -u mempool_test -pmempool_test --silent"`, + { cwd: __dirname, stdio: 'pipe' }, + ); console.log('Database is ready!'); break; } catch (e) { @@ -65,6 +122,14 @@ module.exports = async () => { } } } catch (error) { + try { + execSync(`${dockerComposeCmd} -f "${composeFile}" down -v --remove-orphans`, { + stdio: 'inherit', + cwd: __dirname + }); + } catch { + // Preserve the startup error when cleanup also fails. + } console.error('Failed to start test database:', error instanceof Error ? error.message : error); throw error; } diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 39349201309..c8d811f86df 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -28,6 +28,7 @@ "AUTOMATIC_POOLS_UPDATE": false, "POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json", "POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master", + "POOLS_JSON_FILE": "tasks/pools/pools-v2.json", "POOLS_UPDATE_DELAY": 604800, "AUDIT": false, "RUST_GBT": true, diff --git a/backend/package.json b/backend/package.json index 35b9f7e6241..3411f4cd646 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,7 @@ "tsc": "./node_modules/typescript/bin/tsc -p tsconfig.build.json", "build": "npm run tsc && npm run create-resources", "clean": "rm -rf ./dist/ ./node_modules/ ./package/ ./rust-gbt/", - "create-resources": "cp ./src/tasks/price-feeds/mtgox-weekly.json ./dist/tasks && node dist/api/fetch-version.js", + "create-resources": "mkdir -p ./dist/tasks/pools && cp ./src/tasks/price-feeds/mtgox-weekly.json ./dist/tasks && cp ./src/tasks/pools/pools-v2.json ./dist/tasks/pools && node dist/api/fetch-version.js", "package": "./npm_package.sh", "package-rm-build-deps": "./npm_package_rm_build_deps.sh", "preinstall": "cd ../rust/gbt && npm run build-release && npm run to-backend", diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index c169caf6c32..7f56f841fdf 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -29,6 +29,7 @@ "INDEXING_BLOCKS_AMOUNT": 14, "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__", + "POOLS_JSON_FILE": "__MEMPOOL_POOLS_JSON_FILE__", "POOLS_UPDATE_DELAY": 604800, "AUDIT": true, "RUST_GBT": false, diff --git a/backend/src/__integration_tests__/database-connection.test.ts b/backend/src/__integration_tests__/database-connection.test.ts index 9363b404fe3..181c8e18410 100644 --- a/backend/src/__integration_tests__/database-connection.test.ts +++ b/backend/src/__integration_tests__/database-connection.test.ts @@ -12,7 +12,7 @@ describe('Database Connection Integration Tests', () => { const dbConfig = getTestDatabaseConfig(); expect(dbConfig.enabled).toBe(true); expect(dbConfig.database).toBe('mempool_test'); - expect(dbConfig.port).toBe(33306); + expect(dbConfig.port).toBe(Number(process.env.MEMPOOL_TEST_DB_PORT)); }); test('should execute a simple query', async () => { diff --git a/backend/src/__tests__/capabilities.test.ts b/backend/src/__tests__/capabilities.test.ts new file mode 100644 index 00000000000..90c3207bf1b --- /dev/null +++ b/backend/src/__tests__/capabilities.test.ts @@ -0,0 +1,61 @@ +import { preflightFailures, type PreflightInput } from '../api/capabilities.preflight'; + +/** + * The production incident these rules exist to prevent: the frontend shipped a + * Charts page and a Mining dashboard while the backend had the database off, so + * every request behind those pages answered 404 and the pages loaded forever. + */ + +const coherentWithData: PreflightInput = { + statisticsEnabled: true, + databaseEnabled: true, + mempoolEnabled: true, + indexingBlocksAmount: -1, +}; + +const coherentWithoutData: PreflightInput = { + statisticsEnabled: false, + databaseEnabled: false, + mempoolEnabled: true, + indexingBlocksAmount: 0, +}; + +describe('deployment configuration preflight', () => { + it('accepts a deployment that serves statistics and mining from a database', () => { + expect(preflightFailures(coherentWithData)).toEqual([]); + }); + + it('accepts a deployment that serves neither, with the database off', () => { + expect(preflightFailures(coherentWithoutData)).toEqual([]); + }); + + it('rejects statistics without a database, because the routes would never mount', () => { + const failures = preflightFailures({ ...coherentWithData, databaseEnabled: false, indexingBlocksAmount: 0 }); + expect(failures.map((failure) => failure.feature)).toContain('statistics'); + }); + + it('rejects statistics without the mempool backend collecting them', () => { + const failures = preflightFailures({ ...coherentWithData, mempoolEnabled: false }); + expect(failures.map((failure) => failure.reason.includes('MEMPOOL.ENABLED'))).toContain(true); + }); + + it('rejects block indexing without a database, because mining routes would never mount', () => { + const failures = preflightFailures({ ...coherentWithoutData, indexingBlocksAmount: 52560 }); + expect(failures.map((failure) => failure.feature)).toContain('mining'); + }); + + it('rejects a database that nothing uses', () => { + const failures = preflightFailures({ ...coherentWithoutData, databaseEnabled: true }); + expect(failures.map((failure) => failure.feature)).toContain('database'); + }); + + it('reports every problem at once rather than stopping at the first', () => { + const failures = preflightFailures({ + statisticsEnabled: true, + databaseEnabled: false, + mempoolEnabled: false, + indexingBlocksAmount: 144, + }); + expect(failures.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/backend/src/__tests__/config.test.ts b/backend/src/__tests__/config.test.ts index 7b7b6b4da10..d9ed56321a2 100644 --- a/backend/src/__tests__/config.test.ts +++ b/backend/src/__tests__/config.test.ts @@ -42,6 +42,7 @@ describe('Mempool Backend Config', () => { STDOUT_LOG_MIN_PRIORITY: 'debug', POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json', + POOLS_JSON_FILE: 'tasks/pools/pools-v2.json', POOLS_UPDATE_DELAY: 604800, AUDIT: false, RUST_GBT: true, diff --git a/backend/src/api/capabilities.preflight.ts b/backend/src/api/capabilities.preflight.ts new file mode 100644 index 00000000000..bfd1c8a3896 --- /dev/null +++ b/backend/src/api/capabilities.preflight.ts @@ -0,0 +1,55 @@ +/** + * Configuration combinations that must never reach production traffic. + * + * This module deliberately imports nothing. The release gate, the unit tests, + * and the running backend all judge the same rules, and none of them has to + * open a database or start a timer to do it. + */ + +/** A configuration combination that must never reach production traffic. */ +export interface PreflightFailure { + readonly feature: string; + readonly reason: string; +} + +/** The configuration facts the preflight rules judge. */ +export interface PreflightInput { + readonly statisticsEnabled: boolean; + readonly databaseEnabled: boolean; + readonly mempoolEnabled: boolean; + readonly indexingBlocksAmount: number; +} + +/** + * Returns every reason this configuration would put a broken public feature in + * front of users. An empty list means the deployment is coherent. + */ +export function preflightFailures(input: PreflightInput): PreflightFailure[] { + const failures: PreflightFailure[] = []; + + if (input.statisticsEnabled && !input.databaseEnabled) { + failures.push({ + feature: 'statistics', + reason: 'STATISTICS.ENABLED is true but DATABASE.ENABLED is false, so no statistics route can be served.', + }); + } + if (input.statisticsEnabled && !input.mempoolEnabled) { + failures.push({ + feature: 'statistics', + reason: 'STATISTICS.ENABLED is true but MEMPOOL.ENABLED is false, so nothing collects statistics.', + }); + } + if (input.indexingBlocksAmount !== 0 && !input.databaseEnabled) { + failures.push({ + feature: 'mining', + reason: 'INDEXING_BLOCKS_AMOUNT is set but DATABASE.ENABLED is false, so no mining route can be served.', + }); + } + if (input.databaseEnabled && input.indexingBlocksAmount === 0 && !input.statisticsEnabled) { + failures.push({ + feature: 'database', + reason: 'DATABASE.ENABLED is true but neither block indexing nor statistics uses it.', + }); + } + return failures; +} diff --git a/backend/src/api/capabilities.routes.ts b/backend/src/api/capabilities.routes.ts new file mode 100644 index 00000000000..54106f8b490 --- /dev/null +++ b/backend/src/api/capabilities.routes.ts @@ -0,0 +1,27 @@ +import { Application, Request, Response } from 'express'; +import config from '../config'; +import capabilities from './capabilities'; +import { handleError } from '../utils/api'; + +/** + * Publishes what this deployment can actually serve. The frontend reads it to + * render truthful states instead of guessing from its own build-time flags, + * and the release procedure reads it to refuse an incoherent cutover. + */ +class CapabilitiesRoutes { + public initRoutes(app: Application): void { + app.get(config.MEMPOOL.API_URL_PREFIX + 'capabilities', async (req: Request, res: Response) => { + try { + const report = await capabilities.$report(); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 10).toUTCString()); + res.json(report); + } catch (e) { + handleError(req, res, 500, 'Failed to build the capability report'); + } + }); + } +} + +export default new CapabilitiesRoutes(); diff --git a/backend/src/api/capabilities.ts b/backend/src/api/capabilities.ts new file mode 100644 index 00000000000..daa5700f984 --- /dev/null +++ b/backend/src/api/capabilities.ts @@ -0,0 +1,292 @@ +import config from '../config'; +import DB from '../database'; +import logger from '../logger'; +import backendInfo from './backend-info'; +import { Common } from './common'; +import { preflightFailures, type PreflightFailure, type PreflightInput } from './capabilities.preflight'; + +export { preflightFailures }; +export type { PreflightFailure, PreflightInput }; + +/** + * One place that knows which public features this deployment can actually + * serve, so the frontend never advertises a page whose backend routes were + * never mounted and the deployment can refuse to cut over when the two + * disagree. + * + * Nothing here reports a secret, a private origin, or a credential. + */ + +export type CapabilityState = 'ready' | 'degraded' | 'unavailable' | 'disabled'; + +export interface CapabilityDependency { + readonly name: string; + readonly configured: boolean; + readonly reachable: boolean; + readonly detail: string | null; +} + +export interface CapabilityReport { + /** Feature is switched on in configuration. */ + readonly enabled: boolean; + /** The HTTP routes backing the feature were actually mounted. */ + readonly routesRegistered: boolean; + readonly dependencies: readonly CapabilityDependency[]; + readonly state: CapabilityState; + /** Oldest and newest real data this feature can answer for. */ + readonly coverage: { readonly from: string | null; readonly to: string | null } | null; + readonly rowCount: number | null; + readonly lastSuccessfulUpdate: string | null; + /** Age of the newest row, in seconds, or null when nothing is stored yet. */ + readonly lagSeconds: number | null; + readonly degradedReason: string | null; +} + +export interface CapabilitiesResponse { + readonly schemaVersion: 'universe-explorer-capabilities-v1'; + readonly releaseSha: string; + readonly network: string; + readonly generatedAt: string; + readonly features: Readonly>; +} + +const CACHE_TTL_MS = 10_000; +/** A statistics row is written every minute; five minutes is a stall. */ +const STATISTICS_STALE_AFTER_SECONDS = 300; + +class Capabilities { + private registeredRoutes = new Set(); + private cached: { at: number; value: CapabilitiesResponse } | null = null; + + /** + * Called by the route setup for every feature it mounts, so the report can + * never claim a route exists that was not registered. + */ + public markRoutesRegistered(feature: string): void { + this.registeredRoutes.add(feature); + } + + public routesRegistered(feature: string): boolean { + return this.registeredRoutes.has(feature); + } + + /** True when configuration says the statistics feature should be served. */ + public statisticsEnabled(): boolean { + return config.STATISTICS.ENABLED === true + && config.DATABASE.ENABLED === true + && config.MEMPOOL.ENABLED === true; + } + + /** True when configuration says the mining feature should be served. */ + public miningEnabled(): boolean { + return Common.indexingEnabled() && config.MEMPOOL.ENABLED === true; + } + + /** + * Configuration combinations that would put a broken public feature in front + * of users. An empty list means the deployment is coherent. + */ + public preflight(): PreflightFailure[] { + return preflightFailures({ + statisticsEnabled: config.STATISTICS.ENABLED === true, + databaseEnabled: config.DATABASE.ENABLED === true, + mempoolEnabled: config.MEMPOOL.ENABLED === true, + indexingBlocksAmount: config.MEMPOOL.INDEXING_BLOCKS_AMOUNT, + }); + } + + /** + * Full report, cached briefly so health polling cannot load the database. + * @asyncSafe + */ + public async $report(): Promise { + const now = Date.now(); + if (this.cached && now - this.cached.at < CACHE_TTL_MS) { + return this.cached.value; + } + const value: CapabilitiesResponse = { + schemaVersion: 'universe-explorer-capabilities-v1', + releaseSha: backendInfo.getBackendInfo().gitCommit, + network: config.MEMPOOL.NETWORK, + generatedAt: new Date().toISOString(), + features: { + statistics: await this.$statisticsReport(), + mining: await this.$miningReport(), + }, + }; + this.cached = { at: now, value }; + return value; + } + + private databaseDependency(reachable: boolean, detail: string | null): CapabilityDependency { + return { + name: 'database', + configured: config.DATABASE.ENABLED === true, + reachable, + detail, + }; + } + + /** + * Confirms the database answers, without reporting host, user, or password. + * @asyncSafe + */ + private async $databaseReachable(): Promise<{ reachable: boolean; detail: string | null }> { + if (config.DATABASE.ENABLED !== true) { + return { reachable: false, detail: 'The database is disabled in configuration.' }; + } + try { + await DB.query('SELECT 1'); + return { reachable: true, detail: null }; + } catch (e) { + logger.debug('Capability probe could not reach the database: ' + (e instanceof Error ? e.message : e)); + return { reachable: false, detail: 'The database did not answer.' }; + } + } + + /** @asyncSafe */ + private async $statisticsReport(): Promise { + const enabled = this.statisticsEnabled(); + const routesRegistered = this.routesRegistered('statistics'); + if (!enabled) { + return { + enabled: false, + routesRegistered, + dependencies: [this.databaseDependency(false, null)], + state: 'disabled', + coverage: null, + rowCount: null, + lastSuccessfulUpdate: null, + lagSeconds: null, + degradedReason: 'Statistics collection is switched off in this deployment.', + }; + } + + const database = await this.$databaseReachable(); + const dependencies = [this.databaseDependency(database.reachable, database.detail)]; + if (!database.reachable) { + return { + enabled, routesRegistered, dependencies, + state: 'unavailable', + coverage: null, rowCount: null, lastSuccessfulUpdate: null, lagSeconds: null, + degradedReason: 'The statistics database is unavailable.', + }; + } + + try { + const [rows]: any[] = await DB.query( + `SELECT COUNT(*) AS total, + MIN(added) AS oldest, + MAX(added) AS newest + FROM statistics` + ); + const total = Number(rows[0]?.total ?? 0); + const oldest: Date | null = rows[0]?.oldest ?? null; + const newest: Date | null = rows[0]?.newest ?? null; + const lagSeconds = newest ? Math.max(0, Math.round((Date.now() - newest.getTime()) / 1000)) : null; + const stale = lagSeconds !== null && lagSeconds > STATISTICS_STALE_AFTER_SECONDS; + return { + enabled, routesRegistered, dependencies, + state: total === 0 ? 'degraded' : stale ? 'degraded' : 'ready', + coverage: { + from: oldest ? oldest.toISOString() : null, + to: newest ? newest.toISOString() : null, + }, + rowCount: total, + lastSuccessfulUpdate: newest ? newest.toISOString() : null, + lagSeconds, + degradedReason: total === 0 + ? 'Statistics collection has started but no sample has been stored yet.' + : stale + ? 'The newest statistics sample is older than the collection interval allows.' + : null, + }; + } catch (e) { + logger.debug('Capability probe could not read statistics: ' + (e instanceof Error ? e.message : e)); + return { + enabled, routesRegistered, dependencies, + state: 'unavailable', + coverage: null, rowCount: null, lastSuccessfulUpdate: null, lagSeconds: null, + degradedReason: 'The statistics table could not be read.', + }; + } + } + + /** @asyncSafe */ + private async $miningReport(): Promise { + const enabled = this.miningEnabled(); + const routesRegistered = this.routesRegistered('mining'); + if (!enabled) { + return { + enabled: false, + routesRegistered, + dependencies: [this.databaseDependency(false, null)], + state: 'disabled', + coverage: null, rowCount: null, lastSuccessfulUpdate: null, lagSeconds: null, + degradedReason: 'Block indexing is switched off in this deployment.', + }; + } + + const database = await this.$databaseReachable(); + const dependencies: CapabilityDependency[] = [this.databaseDependency(database.reachable, database.detail)]; + if (!database.reachable) { + return { + enabled, routesRegistered, dependencies, + state: 'unavailable', + coverage: null, rowCount: null, lastSuccessfulUpdate: null, lagSeconds: null, + degradedReason: 'The mining index database is unavailable.', + }; + } + + try { + const [rows]: any[] = await DB.query( + `SELECT COUNT(*) AS total, + MIN(height) AS lowest, + MAX(height) AS highest, + MAX(blockTimestamp) AS newest + FROM blocks` + ); + const total = Number(rows[0]?.total ?? 0); + const lowest = rows[0]?.lowest === null || rows[0]?.lowest === undefined ? null : Number(rows[0].lowest); + const highest = rows[0]?.highest === null || rows[0]?.highest === undefined ? null : Number(rows[0].highest); + const newest: Date | null = rows[0]?.newest ?? null; + + const [poolRows]: any[] = await DB.query('SELECT COUNT(*) AS total FROM pools'); + const poolCount = Number(poolRows[0]?.total ?? 0); + dependencies.push({ + name: 'pool-metadata', + configured: config.MEMPOOL.POOLS_JSON_FILE !== '', + reachable: poolCount > 0, + detail: poolCount > 0 ? null : 'No mining pool metadata has been imported.', + }); + + const indexed = total > 0 && poolCount > 0; + return { + enabled, routesRegistered, dependencies, + state: indexed ? 'ready' : 'degraded', + coverage: { + from: lowest === null ? null : String(lowest), + to: highest === null ? null : String(highest), + }, + rowCount: total, + lastSuccessfulUpdate: newest ? newest.toISOString() : null, + lagSeconds: newest ? Math.max(0, Math.round((Date.now() - newest.getTime()) / 1000)) : null, + degradedReason: total === 0 + ? 'Block indexing is running but no block has been indexed yet.' + : poolCount === 0 + ? 'Mining pool metadata has not been imported yet.' + : null, + }; + } catch (e) { + logger.debug('Capability probe could not read the mining index: ' + (e instanceof Error ? e.message : e)); + return { + enabled, routesRegistered, dependencies, + state: 'unavailable', + coverage: null, rowCount: null, lastSuccessfulUpdate: null, lagSeconds: null, + degradedReason: 'The mining index tables could not be read.', + }; + } + } +} + +export default new Capabilities(); diff --git a/backend/src/api/statistics/statistics-api.ts b/backend/src/api/statistics/statistics-api.ts index 5a3222e827f..4bf86ff749a 100644 --- a/backend/src/api/statistics/statistics-api.ts +++ b/backend/src/api/statistics/statistics-api.ts @@ -295,7 +295,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list2H() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -306,7 +306,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list24h() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -317,7 +317,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list1W() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -328,7 +328,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list1M() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -339,7 +339,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list3M() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -350,7 +350,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list6M() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -361,7 +361,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list1Y() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -372,7 +372,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list2Y() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -383,7 +383,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list3Y() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -394,7 +394,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$list4Y() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } @@ -405,7 +405,7 @@ class StatisticsApi { return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); } catch (e) { logger.err('$listAll() error' + (e instanceof Error ? e.message : e)); - return []; + throw e; } } diff --git a/backend/src/config.ts b/backend/src/config.ts index e0396a1d681..2f31e694903 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -33,6 +33,7 @@ interface IConfig { AUTOMATIC_POOLS_UPDATE: boolean; POOLS_JSON_URL: string, POOLS_JSON_TREE_URL: string, + POOLS_JSON_FILE: string, POOLS_UPDATE_DELAY: number, AUDIT: boolean; RUST_GBT: boolean; @@ -208,6 +209,7 @@ const defaults: IConfig = { 'AUTOMATIC_POOLS_UPDATE': false, 'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json', 'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master', + 'POOLS_JSON_FILE': 'tasks/pools/pools-v2.json', 'POOLS_UPDATE_DELAY': 604800, // in seconds, default is one week 'AUDIT': false, 'RUST_GBT': true, diff --git a/backend/src/index.ts b/backend/src/index.ts index 1428e405b0b..7f8ad59cc4c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -46,6 +46,8 @@ import bitcoinCoreRoutes from './api/bitcoin/bitcoin-core.routes'; import bitcoinSecondClient from './api/bitcoin/bitcoin-second-client'; import accelerationRoutes from './api/acceleration/acceleration.routes'; import aboutRoutes from './api/about.routes'; +import capabilities from './api/capabilities'; +import capabilitiesRoutes from './api/capabilities.routes'; import mempoolBlocks from './api/mempool-blocks'; import walletApi from './api/services/wallets'; import stratumApi from './api/services/stratum'; @@ -121,6 +123,17 @@ class Server { bitcoinApi.startHealthChecks(); } + // Refuse to start on a configuration that would advertise a public feature + // this deployment cannot serve. Failing here is loud and recoverable; a + // half-configured backend is neither. + const preflightFailures = capabilities.preflight(); + if (preflightFailures.length > 0) { + for (const failure of preflightFailures) { + logger.err(`Configuration preflight failed for ${failure.feature}: ${failure.reason}`); + } + throw new Error('The backend configuration is incoherent; see the preflight errors above.'); + } + if (config.DATABASE.ENABLED) { DB.getPidLock(); @@ -350,15 +363,18 @@ class Server { setUpHttpApiRoutes(): void { bitcoinRoutes.initRoutes(this.app); + capabilitiesRoutes.initRoutes(this.app); if (config.MEMPOOL.OFFICIAL) { bitcoinCoreRoutes.initRoutes(this.app); } pricesRoutes.initRoutes(this.app); - if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) { + if (capabilities.statisticsEnabled()) { statisticsRoutes.initRoutes(this.app); + capabilities.markRoutesRegistered('statistics'); } - if (Common.indexingEnabled() && config.MEMPOOL.ENABLED) { + if (capabilities.miningEnabled()) { miningRoutes.initRoutes(this.app); + capabilities.markRoutesRegistered('mining'); } if (Common.isLiquid()) { liquidRoutes.initRoutes(this.app); @@ -374,7 +390,10 @@ class Server { if (config.WALLETS.ENABLED) { servicesRoutes.initRoutes(this.app); } - if (!config.MEMPOOL.OFFICIAL) { + // These routes only proxy a hosted mempool API. With no such API + // configured they could answer nothing but 500; leaving them unmounted + // makes them 404 like any other route this deployment does not serve. + if (!config.MEMPOOL.OFFICIAL && config.EXTERNAL_DATA_SERVER.MEMPOOL_API) { aboutRoutes.initRoutes(this.app); } } diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts index a3933b7dbb7..252085dbfce 100644 --- a/backend/src/tasks/pools-updater.ts +++ b/backend/src/tasks/pools-updater.ts @@ -6,6 +6,9 @@ import backendInfo from '../api/backend-info'; import logger from '../logger'; import { SocksProxyAgent } from 'socks-proxy-agent'; import * as https from 'https'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; import { Common } from '../api/common'; /** @@ -18,6 +21,7 @@ class PoolsUpdater { currentSha: string | null = null; poolsUrl: string = config.MEMPOOL.POOLS_JSON_URL; treeUrl: string = config.MEMPOOL.POOLS_JSON_TREE_URL; + poolsFile: string = config.MEMPOOL.POOLS_JSON_FILE; /** @asyncSafe */ public async $startService(): Promise { @@ -31,6 +35,87 @@ class PoolsUpdater { } } + /** + * Resolves the bundled pools file against the compiled backend directory, so + * the same relative path works from `src` under ts-node and from `dist` in + * production. An absolute path is used as given. + */ + private resolvePoolsFile(): string { + return path.isAbsolute(this.poolsFile) + ? this.poolsFile + : path.resolve(__dirname, '..', this.poolsFile); + } + + /** + * Imports mining pool metadata from the bundled first-party file instead of + * fetching it from a third party at runtime. The identifier is the git blob + * hash of the file contents, so it is deterministic, offline, and directly + * comparable with the sha the database already stores. + * @asyncSafe + */ + private async $updatePoolsFromFile(): Promise { + const file = this.resolvePoolsFile(); + let raw: Buffer; + try { + raw = fs.readFileSync(file); + } catch (e) { + logger.err(`Cannot read the bundled mining pools file ${file}. Reason: ` + (e instanceof Error ? e.message : e), this.tag); + return; + } + + let poolsJson: any; + try { + poolsJson = JSON.parse(raw.toString('utf8')); + } catch (e) { + logger.err(`The bundled mining pools file ${file} is not valid JSON.`, this.tag); + return; + } + if (!Array.isArray(poolsJson) || poolsJson.length === 0) { + logger.err(`The bundled mining pools file ${file} must hold a non-empty array of pools.`, this.tag); + return; + } + + const fileSha = crypto.createHash('sha1') + .update(Buffer.from(`blob ${raw.length}`, 'utf8')) + .update(Buffer.alloc(1)) + .update(raw) + .digest('hex'); + + if (config.DATABASE.ENABLED === true) { + this.currentSha = await this.getShaFromDb(); + } + if (this.currentSha === fileSha) { + return; + } + + poolsParser.setMiningPools(poolsJson); + + if (config.DATABASE.ENABLED === false) { + this.currentSha = fileSha; + logger.info(`Mining pools imported from ${file} (${fileSha}), no database`, this.tag); + return; + } + + try { + await DB.query('START TRANSACTION;'); + await this.updateDBSha(fileSha); + await poolsParser.migratePoolsJson(); + await DB.query('COMMIT;'); + } catch (e) { + logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, this.tag); + try { + await DB.query('ROLLBACK;'); + } catch (rollbackError) { + logger.err('Could not roll back the mining pool import. Reason: ' + (rollbackError instanceof Error ? rollbackError.message : rollbackError), this.tag); + } + // The sha was optimistically set before the write; the rollback undid it, + // so clear it again rather than let startup believe pools are loaded. + this.currentSha = null; + return; + } + logger.info(`Mining pools imported from ${file} (${fileSha})`, this.tag); + } + /** @asyncSafe */ public async updatePoolsJson(): Promise { if (['mainnet', 'testnet', 'signet', 'testnet4', 'regtest'].includes(config.MEMPOOL.NETWORK) === false || @@ -46,6 +131,15 @@ class PoolsUpdater { this.lastRun = now; + if (this.poolsFile) { + try { + await this.$updatePoolsFromFile(); + } catch (e) { + logger.err(`PoolsUpdater failed to import the bundled pools file. Exception: ${JSON.stringify(e)}`, this.tag); + } + return; + } + try { if (config.DATABASE.ENABLED === true) { this.currentSha = await this.getShaFromDb(); @@ -123,6 +217,7 @@ class PoolsUpdater { /** * Fetch our latest pools-v2.json sha from the db + * @asyncSafe */ public async getShaFromDb(): Promise { try { diff --git a/backend/src/tasks/pools/pools-v2.json b/backend/src/tasks/pools/pools-v2.json new file mode 100644 index 00000000000..049f24a32d8 --- /dev/null +++ b/backend/src/tasks/pools/pools-v2.json @@ -0,0 +1,1839 @@ +[ + { + "id": 1, + "name": "BlockFills", + "addresses": [ + "1PzVut5X6Nx7Mv4JHHKPtVM9Jr9LJ4Rbry" + ], + "tags": [ + "/BlockfillsPool/" + ], + "link": "https://www.blockfills.com/mining" + }, + { + "id": 2, + "name": "ULTIMUSPOOL", + "addresses": [ + "1EMVSMe1VJUuqv7D7SFzctnVXk4KdjXATi", + "3C9sAKXrBVpJVe3b738yik4LPHpPmceBgd" + ], + "tags": [ + "/ultimus/" + ], + "link": "https://www.ultimuspool.com" + }, + { + "id": 3, + "name": "Terra Pool", + "addresses": [ + "32P5KVSbZYAkVmSHxDd2oBXaSk372rbV7L", + "3Qqp7LwxmSjPwRaKkDToysJsM3xA4ThqFk", + "bc1q39dled8an7enuxtmjql3pk7ny8kzvsxhd924sl" + ], + "tags": [ + "terrapool.io", + "Validated with Clean Energy" + ], + "link": "https://terrapool.io" + }, + { + "id": 4, + "name": "Luxor", + "addresses": [ + "1MkCDCzHpBsYQivp8MxjY5AkTGG1f2baoe", + "39bitUyBcUu3y3hRTtYprKbTp712t4ZWqK", + "32BfKjhByDSxx3BM5vUkQ3NQq9csZR6nt6" + ], + "tags": [ + "/LUXOR/", + "Luxor Tech" + ], + "link": "https://mining.luxor.tech" + }, + { + "id": 5, + "name": "1THash", + "addresses": [ + "147SwRQdpCfj5p8PnfsXV2SsVVpVcz3aPq", + "15vgygQ7ZsWdvZpctmTZK4673QBHsos6Sh" + ], + "tags": [ + "/1THash&58COIN/", + "/1THash/" + ], + "link": "https://www.1thash.top" + }, + { + "id": 6, + "name": "BTC.com", + "addresses": [ + "1Bf9sZvBHPFGVPX71WX2njhd1NXKv5y7v5", + "34qkc2iac6RsyxZVfyE2S5U5WcRsbg2dpK", + "3EhLZarJUNSfV6TWMZY1Nh5mi3FMsdHa5U", + "3NA8hsjfdgVkmmVS9moHmkZsVCoLxUkvvv", + "bc1qjl8uwezzlech723lpnyuza0h2cdkvxvh54v3dn" + ], + "tags": [ + "/BTC.COM/", + "/BTC.com/", + "btccom" + ], + "link": "https://pool.btc.com" + }, + { + "id": 7, + "name": "Bitfarms", + "addresses": [ + "3GvEGtnvgeBJ3p3EpdZhvUkxY4pDARkbjd" + ], + "tags": [ + "BITFARMS" + ], + "link": "https://www.bitfarms.io" + }, + { + "id": 8, + "name": "Huobi.pool", + "addresses": [ + "18Zcyxqna6h7Z7bRjhKvGpr8HSfieQWXqj", + "1EepjXgvWUoRyNvuLSAxjiqZ1QqKGDANLW", + "1MvYASoHjqynMaMnP7SBmenyEWiLsTqoU6", + "3HuobiNg2wHjdPU2mQczL9on8WF7hZmaGd" + ], + "tags": [ + "/HuoBi/", + "/Huobi/" + ], + "link": "https://www.hpt.com" + }, + { + "id": 9, + "name": "WAYI.CN", + "addresses": [], + "tags": [ + "/E2M & BTC.TOP/" + ], + "link": "https://www.easy2mine.com" + }, + { + "id": 10, + "name": "CanoePool", + "addresses": [ + "1GP8eWArgpwRum76saJS4cZKCHWJHs9PQo" + ], + "tags": [ + "/CANOE/", + "/canoepool/" + ], + "link": "https://btc.canoepool.com" + }, + { + "id": 11, + "name": "BTC.TOP", + "addresses": [ + "1Hz96kJKF2HLPGY15JWLB5m9qGNxvt8tHJ" + ], + "tags": [ + "/BTC.TOP/" + ], + "link": "https://btc.top" + }, + { + "id": 12, + "name": "Bitcoin.com", + "addresses": [], + "tags": [ + "pool.bitcoin.com" + ], + "link": "https://www.bitcoin.com" + }, + { + "id": 13, + "name": "175btc", + "addresses": [], + "tags": [ + "Mined By 175btc.com" + ], + "link": "https://www.175btc.com" + }, + { + "id": 14, + "name": "GBMiners", + "addresses": [], + "tags": [ + "/mined by gbminers/" + ], + "link": "https://gbminers.com" + }, + { + "id": 15, + "name": "A-XBT", + "addresses": [ + "1MFsp2txCPwMMBJjNNeKaduGGs8Wi1Ce7X" + ], + "tags": [ + "/A-XBT/" + ], + "link": "https://www.a-xbt.com" + }, + { + "id": 16, + "name": "ASICMiner", + "addresses": [], + "tags": [ + "ASICMiner" + ], + "link": "https://www.asicminer.co" + }, + { + "id": 17, + "name": "BitMinter", + "addresses": [ + "19PkHafEN18mquJ9ChwZt5YEFoCdPP5vYB" + ], + "tags": [ + "BitMinter" + ], + "link": "https://bitminter.com" + }, + { + "id": 18, + "name": "BitcoinRussia", + "addresses": [ + "14R2r9FkyDmyxGB9xUVwVLdgsX9YfdVamk", + "165GCEAx81wce33FWEnPCRhdjcXCrBJdKn" + ], + "tags": [ + "/Bitcoin-Russia.ru/" + ], + "link": "https://bitcoin-russia.ru" + }, + { + "id": 19, + "name": "BTCServ", + "addresses": [], + "tags": [ + "btcserv" + ], + "link": "https://btcserv.net" + }, + { + "id": 20, + "name": "simplecoin.us", + "addresses": [], + "tags": [ + "simplecoin" + ], + "link": "https://simplecoin.us" + }, + { + "id": 21, + "name": "BTC Guild", + "addresses": [], + "tags": [ + "BTC Guild" + ], + "link": "https://www.btcguild.com" + }, + { + "id": 22, + "name": "Eligius", + "addresses": [], + "tags": [ + "Eligius" + ], + "link": "https://eligius.st" + }, + { + "id": 23, + "name": "OzCoin", + "addresses": [], + "tags": [ + "ozco.in", + "ozcoin" + ], + "link": "https://ozcoin.net" + }, + { + "id": 24, + "name": "EclipseMC", + "addresses": [ + "15xiShqUqerfjFdyfgBH1K7Gwp6cbYmsTW", + "18M9o2mXNjNR96yKe7eyY6pfP6Nx4Nso3d" + ], + "tags": [ + "EMC ", + "EMC:" + ], + "link": "https://eclipsemc.com" + }, + { + "id": 25, + "name": "MaxBTC", + "addresses": [], + "tags": [ + "MaxBTC" + ], + "link": "https://maxbtc.com" + }, + { + "id": 26, + "name": "TripleMining", + "addresses": [], + "tags": [ + "Triplemining.com", + "triplemining" + ], + "link": "https://www.triplemining.com" + }, + { + "id": 27, + "name": "CoinLab", + "addresses": [], + "tags": [ + "CoinLab" + ], + "link": "https://coinlab.com" + }, + { + "id": 28, + "name": "50BTC", + "addresses": [], + "tags": [ + "50BTC" + ], + "link": "https://www.50btc.com" + }, + { + "id": 29, + "name": "GHash.IO", + "addresses": [ + "1CjPR7Z5ZSyWk6WtXvSFgkptmpoi4UM9BC" + ], + "tags": [ + "ghash.io" + ], + "link": "https://ghash.io" + }, + { + "id": 30, + "name": "ST Mining Corp", + "addresses": [], + "tags": [ + "st mining corp" + ], + "link": "https://bitcointalk.org/index.php?topic=77000.msg3207708#msg3207708" + }, + { + "id": 31, + "name": "Bitparking", + "addresses": [], + "tags": [ + "bitparking" + ], + "link": "https://mmpool.bitparking.com" + }, + { + "id": 32, + "name": "mmpool", + "addresses": [], + "tags": [ + "mmpool" + ], + "link": "https://mmpool.org/pool" + }, + { + "id": 33, + "name": "Polmine", + "addresses": [ + "13vWXwzNF5Ef9SUXNTdr7de7MqiV4G1gnL", + "16cv7wyeG6RRqhvJpY21CnsjxuKj2gAoK2", + "17kkmDx8eSwj2JTTULb3HkJhCmexfysExz", + "1AajKXkaq2DsnDmP8ZPTrE5gH1HFo1x3AU", + "1JrYhdhP2jCY6JwuVzdk9jUwc4pctcSes7", + "1Nsvmnv8VcTMD643xMYAo35Aco3XA5YPpe" + ], + "tags": [ + "by polmine.pl", + "bypmneU" + ], + "link": "https://polmine.pl" + }, + { + "id": 34, + "name": "KnCMiner", + "addresses": [], + "tags": [ + "KnCMiner" + ], + "link": "https://portal.kncminer.com/pool" + }, + { + "id": 35, + "name": "Bitalo", + "addresses": [ + "1HTejfsPZQGi3afCMEZTn2xdmoNzp13n3F" + ], + "tags": [ + "Bitalo" + ], + "link": "https://bitalo.com/mining" + }, + { + "id": 36, + "name": "F2Pool", + "addresses": [ + "1KFHE7w8BhaENAswwryaoccDb6qcT6DbYY", + "bc1qf274x7penhcd8hsv3jcmwa5xxzjl2a6pa9pxwm" + ], + "tags": [ + "七彩神仙鱼", + "F2Pool", + "🐟" + ], + "link": "https://www.f2pool.com" + }, + { + "id": 37, + "name": "HHTT", + "addresses": [], + "tags": [ + "HHTT" + ], + "link": "https://hhtt.1209k.com" + }, + { + "id": 38, + "name": "MegaBigPower", + "addresses": [ + "1K7znxRfkS8R1hcmyMvHDum1hAQreS4VQ4" + ], + "tags": [ + "megabigpower.com" + ], + "link": "https://megabigpower.com" + }, + { + "id": 39, + "name": "Mt Red", + "addresses": [], + "tags": [ + "/mtred/" + ], + "link": "https://mtred.com" + }, + { + "id": 40, + "name": "NMCbit", + "addresses": [], + "tags": [ + "nmcbit.com" + ], + "link": "https://nmcbit.com" + }, + { + "id": 41, + "name": "Yourbtc.net", + "addresses": [], + "tags": [ + "yourbtc.net" + ], + "link": "https://yourbtc.net" + }, + { + "id": 42, + "name": "Give Me Coins", + "addresses": [], + "tags": [ + "Give-Me-Coins" + ], + "link": "https://give-me-coins.com" + }, + { + "id": 43, + "name": "Braiins Pool", + "addresses": [ + "1AqTMY7kmHZxBuLUR5wJjPFUvqGs23sesr", + "1CK6KHY6MHgYvmRQ4PAafKYDrg1ejbH1cE" + ], + "tags": [ + "/slush/" + ], + "link": "https://braiins.com/pool" + }, + { + "id": 44, + "name": "AntPool", + "addresses": [ + "12dRugNcdxK39288NjcDV4GX7rMsKCGn6B", + "15kiNKfDWsq7UsPg87UwxA8rVvWAjzRkYS", + "16MdTdqmXusauybtXTmFEW4GNFPPgGxQYE", + "16kUc5B48qnASbxeZTisCqTNx6G3DPXuKn", + "17gVZssumiJqYMCHozHKXGyaAvyu6NCX6V", + "1AJQ3jXhUF8WiisEcuVd8Xmfq4QJ7n1SdL", + "1B7ZBX2C39b26M9chHLURGSFTJA6DDQkZv", + "1BWW3pg5jb6rxebrNeo9TATarwJ1rthnoe", + "1CBqo1w3hmm9SCmbu2Yg6Ls4uLfkUqZJsx", + "1CZHhV67Qos4xXb8uYqvAGjK8Wq52woPi5", + "1CyB8GJNEsNVXtPutB36nrDY3fMXBTzXSX", + "1D4UZG4qo8bF1MuZHSEyBHRZaxT8inatXS", + "1D9jw3QHNankXxtcGVihsDK7Z7THN6j7Pg", + "1DDXyKUT6q3H9e5QXm2Gv6BNNWgztFG55g", + "1DQaDTefKPjHz3beLuo8KHRZF9t2Sc6foP", + "1Dek9ArRHb9tyWb9gaaX8SWmkfi5V7U5Y6", + "1DyR7HPQWjM6Zrnk7SzHVY2GEpXRGNNH9o", + "1FdJkPdpXtK3t5utZHJAop3saLZWfPfgak", + "1FrHkVsW7csAYYaRbUUcrKSmv91hcQnsqQ", + "1GEG1JR81jvUXs7TMAuo3SPBHZrpJijcjt", + "1GRcX882sdBYCAWyG99iF2oz7j3nYzXhLM", + "1GT2N4dCufvbnTKMbS61QrQPN4SexCAFiH", + "1Gp7iCzDGMZiV55Kt8uKsux6VyoHe1aJaN", + "1H3u6R813MHGYhmGW6v86EYYriawRtACYD", + "1H6ckqNWikmVT3wpN3X1BQ6b156Xc9nT2L", + "1JBVrhSSDrZrRmm4RnoWouqgGGqJMvWHi8", + "1JwUDWVSbAY5NeCBJhxQk1E8AfETfZuPj4", + "1K8PNogxBZ6ts532DZnzxdbjgzJLjLdXqz", + "1KmgBTL7cFmFFYTD7HcdkMcZXRcTkh2WwS", + "1LTGvTjDxiy5S9YcKEE9Lb7xSpZcPSqinw", + "1MiQrT5sEKTUGNMbd9WS3yPPkSjWdpYA2r", + "1NS4gbx1G2D5rc9PnvVsPys12nKxGiQg72", + "1Nh7uHdvY6fNwtQtM1G5EZAFPLC33B59rB", + "1Pzf7qT7bBGouvnjRvtRD8VhTyqjX1NrJT", + "1Sjj2cPC3rTWcSTEYDeu2f3BavLosog4T", + "1jLVpwtNMfXWaHY4eiLDmGuBxokYLgv1X", + "3FaYVQF6wCMUB9NCeRe4tUp1zZx8qqM7H1" + ], + "tags": [ + "/AntPool/", + "Mined By AntPool", + "Mined by AntPool" + ], + "link": "https://www.antpool.com" + }, + { + "id": 45, + "name": "MultiCoin.co", + "addresses": [], + "tags": [ + "Mined by MultiCoin.co" + ], + "link": "https://multicoin.co" + }, + { + "id": 46, + "name": "bcpool.io", + "addresses": [], + "tags": [ + "bcpool.io" + ], + "link": "https://bcpool.io" + }, + { + "id": 47, + "name": "Cointerra", + "addresses": [ + "1BX5YoLwvqzvVwSrdD4dC32vbouHQn2tuF" + ], + "tags": [ + "cointerra" + ], + "link": "https://cointerra.com" + }, + { + "id": 48, + "name": "KanoPool", + "addresses": [], + "tags": [ + "Kano" + ], + "link": "https://kano.is" + }, + { + "id": 49, + "name": "Solo CK", + "addresses": [], + "tags": [ + "/solo.ckpool.org/" + ], + "link": "https://solo.ckpool.org" + }, + { + "id": 50, + "name": "CKPool", + "addresses": [], + "tags": [ + "/ckpool.org/" + ], + "link": "https://ckpool.org" + }, + { + "id": 51, + "name": "NiceHash", + "addresses": [], + "tags": [ + "/NiceHashSolo", + "/NiceHash/", + "/NiceHashMining/" + ], + "link": "https://www.nicehash.com" + }, + { + "id": 52, + "name": "BitClub", + "addresses": [ + "155fzsEBHy9Ri2bMQ8uuuR3tv1YzcDywd4" + ], + "tags": [ + "/BitClub Network/" + ], + "link": "https://bitclubpool.com" + }, + { + "id": 53, + "name": "Bitcoin Affiliate Network", + "addresses": [], + "tags": [ + "bitcoinaffiliatenetwork.com" + ], + "link": "https://mining.bitcoinaffiliatenetwork.com" + }, + { + "id": 54, + "name": "BTCC", + "addresses": [ + "152f1muMCNa7goXYhYAQC61hxEgGacmncB" + ], + "tags": [ + "/BTCC/", + "BTCChina Pool", + "BTCChina.com", + "btcchina.com" + ], + "link": "https://pool.btcc.com" + }, + { + "id": 55, + "name": "BWPool", + "addresses": [ + "1JLRXD8rjRgQtTS9MvfQALfHgGWau9L9ky" + ], + "tags": [ + "BW Pool", + "BWPool" + ], + "link": "https://bwpool.net" + }, + { + "id": 56, + "name": "EXX&BW", + "addresses": [], + "tags": [ + "xbtc.exx.com&bw.com" + ], + "link": "https://xbtc.exx.com" + }, + { + "id": 57, + "name": "Bitsolo", + "addresses": [ + "18zRehBcA2YkYvsC7dfQiFJNyjmWvXsvon" + ], + "tags": [ + "Bitsolo Pool" + ], + "link": "https://bitsolo.net" + }, + { + "id": 58, + "name": "BitFury", + "addresses": [ + "14yfxkcpHnju97pecpM7fjuTkVdtbkcfE6", + "1AcAj9p6zJn4xLXdvmdiuPCtY7YkBPTAJo" + ], + "tags": [ + "/BitFury/", + "/Bitfury/" + ], + "link": "https://bitfury.com" + }, + { + "id": 59, + "name": "21 Inc.", + "addresses": [ + "15rQXUSBQRubShPpiJfDLxmwS8ze2RUm4z", + "1CdJi2xRTXJF6CEJqNHYyQDNEcM3X7fUhD", + "1GC6HxDvnchDdb5cGkFXsJMZBFRsKAXfwi" + ], + "tags": [ + "/pool34/" + ], + "link": "https://21.co" + }, + { + "id": 60, + "name": "digitalBTC", + "addresses": [ + "1MimPd6LrPKGftPRHWdfk8S3KYBfN4ELnD" + ], + "tags": [ + "/agentD/" + ], + "link": "https://digitalbtc.com" + }, + { + "id": 61, + "name": "8baochi", + "addresses": [ + "1Hk9gD8xMo2XBUhE73y5zXEM8xqgffTB5f" + ], + "tags": [ + "/八宝池 8baochi.com/" + ], + "link": "https://8baochi.com" + }, + { + "id": 62, + "name": "myBTCcoin Pool", + "addresses": [ + "151T7r1MhizzJV6dskzzUkUdr7V8JxV2Dx" + ], + "tags": [ + "myBTCcoin Pool" + ], + "link": "https://mybtccoin.com" + }, + { + "id": 63, + "name": "TBDice", + "addresses": [ + "1BUiW44WuJ2jiJgXiyxJVFMN8bc1GLdXRk" + ], + "tags": [ + "TBDice" + ], + "link": "https://tbdice.org" + }, + { + "id": 64, + "name": "HASHPOOL", + "addresses": [], + "tags": [ + "HASHPOOL" + ], + "link": "https://hashpool.com" + }, + { + "id": 65, + "name": "Nexious", + "addresses": [ + "1GBo1f2tzVx5jScV9kJXPUP9RjvYXuNzV7" + ], + "tags": [ + "/Nexious/" + ], + "link": "https://nexious.com" + }, + { + "id": 66, + "name": "Bravo Mining", + "addresses": [], + "tags": [ + "/bravo-mining/" + ], + "link": "https://www.bravo-mining.com" + }, + { + "id": 67, + "name": "HotPool", + "addresses": [ + "17judvK4AC2M6KhaBbAEGw8CTKc9Pg8wup" + ], + "tags": [ + "/HotPool/" + ], + "link": "https://hotpool.co" + }, + { + "id": 68, + "name": "OKExPool", + "addresses": [], + "tags": [ + "/www.okex.com/" + ], + "link": "https://www.okex.com" + }, + { + "id": 69, + "name": "BCMonster", + "addresses": [ + "1E18BNyobcoiejcDYAz5SjbrzifNDEpM88" + ], + "tags": [ + "/BCMonster/" + ], + "link": "https://www.bcmonster.com" + }, + { + "id": 70, + "name": "1Hash", + "addresses": [ + "1F1xcRt8H8Wa623KqmkEontwAAVqDSAWCV" + ], + "tags": [ + "Mined by 1hash.com" + ], + "link": "https://www.1hash.com" + }, + { + "id": 71, + "name": "Bixin", + "addresses": [ + "13hQVEstgo4iPQZv9C7VELnLWF7UWtF4Q3", + "1KsFhYKLs8qb1GHqrPxHoywNQpet2CtP9t" + ], + "tags": [ + "/Bixin/", + "/HaoBTC/", + "HAOBTC" + ], + "link": "https://haopool.com" + }, + { + "id": 72, + "name": "TATMAS Pool", + "addresses": [], + "tags": [ + "/ViaBTC/TATMAS Pool/" + ], + "link": "https://tmsminer.com" + }, + { + "id": 73, + "name": "ViaBTC", + "addresses": [], + "tags": [ + "/ViaBTC/", + "viabtc.com deploy" + ], + "link": "https://viabtc.com" + }, + { + "id": 74, + "name": "ConnectBTC", + "addresses": [ + "1KPQkehgYAqwiC6UCcbojM3mbGjURrQJF2" + ], + "tags": [ + "/ConnectBTC - Home for Miners/" + ], + "link": "https://www.connectbtc.com" + }, + { + "id": 75, + "name": "BATPOOL", + "addresses": [ + "167ApWWxUSFQmz2jdz9xop3oAKdLejvMML" + ], + "tags": [ + "/BATPOOL/" + ], + "link": "https://www.batpool.com" + }, + { + "id": 76, + "name": "Waterhole", + "addresses": [ + "1FLH1SoLv4U68yUERhDiWzrJn5TggMqkaZ" + ], + "tags": [ + "/WATERHOLE.IO/" + ], + "link": "https://btc.waterhole.io" + }, + { + "id": 77, + "name": "DCExploration", + "addresses": [], + "tags": [ + "/DCExploration/" + ], + "link": "https://dcexploration.cn" + }, + { + "id": 78, + "name": "DCEX", + "addresses": [], + "tags": [ + "/DCEX/" + ], + "link": "https://dcexploration.cn" + }, + { + "id": 79, + "name": "BTPOOL", + "addresses": [], + "tags": [ + "/BTPOOL/" + ], + "link": "" + }, + { + "id": 80, + "name": "58COIN", + "addresses": [ + "199EDJoCpqV672qESEkfFgEqNT1iR2gj3t" + ], + "tags": [ + "/58coin.com/" + ], + "link": "https://www.58coin.com" + }, + { + "id": 81, + "name": "Bitcoin India", + "addresses": [], + "tags": [ + "/Bitcoin-India/" + ], + "link": "https://bitcoin-india.org" + }, + { + "id": 82, + "name": "shawnp0wers", + "addresses": [ + "12znnESiJ3bgCLftwwrg9wzQKN8fJtoBDa", + "18HEMWFXM9UGPVZHUMdBPD3CMFWYn2NPRX" + ], + "tags": [ + "--Nug--" + ], + "link": "https://www.brainofshawn.com" + }, + { + "id": 83, + "name": "PHash.IO", + "addresses": [], + "tags": [ + "/phash.cn/", + "/phash.io/" + ], + "link": "https://phash.io" + }, + { + "id": 84, + "name": "RigPool", + "addresses": [ + "1JpKmtspBJQVXK67DJP64eBJcAPhDvJ9Er" + ], + "tags": [ + "/RigPool.com/" + ], + "link": "https://www.rigpool.com" + }, + { + "id": 85, + "name": "HAOZHUZHU", + "addresses": [ + "19qa95rTbDziNCS9EexUbh2hVY4viUU9tt" + ], + "tags": [ + "/haozhuzhu/" + ], + "link": "https://haozhuzhu.com" + }, + { + "id": 86, + "name": "7pool", + "addresses": [ + "1JLc3JxvpdL1g5zoX8sKLP4BkJQiwnJftU" + ], + "tags": [ + "/$Mined by 7pool.com/" + ], + "link": "https://7pool.com" + }, + { + "id": 87, + "name": "MiningKings", + "addresses": [ + "1ApE99VM5RJzMRRtwd2JMgmkGabtJqoMEz", + "1EowSPumj9D9AMTpE64Jr7vT3PJDNopVcz", + "1KGbsDDAgJN2HDNBjmMHp9828qATo5B9c9" + ], + "tags": [ + "/mined by poopbut/" + ], + "link": "https://miningkings.com" + }, + { + "id": 88, + "name": "HashBX", + "addresses": [], + "tags": [ + "/Mined by HashBX.io/" + ], + "link": "https://hashbx.io" + }, + { + "id": 89, + "name": "DPOOL", + "addresses": [ + "1ACAgPuFFidYzPMXbiKptSrwT74Dg8hq2v" + ], + "tags": [ + "/DPOOL.TOP/" + ], + "link": "https://www.dpool.top" + }, + { + "id": 90, + "name": "Rawpool", + "addresses": [ + "1FbBbv5oYqFKwiPm4CAqvAy8345n8AQ74b", + "35y82tEPDa2wm6tzkEacMG8GPPW7zbMj83", + "3CLigLYNkrtoNgNcUwTaKoUSHCwr9W851W", + "3QYvfQoG9Gs9Vfvbpw6947muSqhoGagvF6", + "bc1q8ej2g5uxdsg0jwl0mpl606qfjxgkyv3p29yf37", + "bc1qnnl503n04cqacpwvhr89qr70metxr79ht3n380", + "bc1qru8mtv3e3u7ms6ecjmwgeakdakclemvhnw00q9", + "bc1qwlrsvgtn99rqp3fgaxq6f6jkgms80rnej0a8tc" + ], + "tags": [ + "/Rawpool.com/" + ], + "link": "https://www.rawpool.com" + }, + { + "id": 91, + "name": "haominer", + "addresses": [], + "tags": [ + "/haominer/" + ], + "link": "https://haominer.com" + }, + { + "id": 92, + "name": "Helix", + "addresses": [], + "tags": [ + "/Helix/" + ], + "link": "" + }, + { + "id": 93, + "name": "Bitcoin-Ukraine", + "addresses": [], + "tags": [ + "/Bitcoin-Ukraine.com.ua/" + ], + "link": "https://bitcoin-ukraine.com.ua" + }, + { + "id": 94, + "name": "Poolin", + "addresses": [ + "14sA8jqYQgMRQV9zUtGFvpeMEw7YDn77SK", + "17tUZLvy3X2557JGhceXRiij2TNYuhRr4r", + "1E8CZo2S3CqWg1VZSJNFCTbtT8hZPuQ2kB", + "1GNgwA8JfG7Kc8akJ8opdNWJUihqUztfPe", + "36n452uGq1x4mK7bfyZR8wgE47AnBb2pzi", + "3JQSigWTCHyBLRD979JWgEtWP5YiiFwcQB", + "3KJrsjfg1dD6CrsTeHdHVH3KqMpvL2XWQn" + ], + "tags": [ + "/poolin.com", + "/poolin/" + ], + "link": "https://www.poolin.com" + }, + { + "id": 95, + "name": "SecretSuperstar", + "addresses": [], + "tags": [ + "/SecretSuperstar/" + ], + "link": "" + }, + { + "id": 96, + "name": "tigerpool.net", + "addresses": [], + "tags": [ + "/tigerpool.net" + ], + "link": "" + }, + { + "id": 97, + "name": "Sigmapool.com", + "addresses": [ + "12cKiMNhCtBhZRUBCnYXo8A4WQzMUtYjmR" + ], + "tags": [ + "/Sigmapool.com/" + ], + "link": "https://sigmapool.com" + }, + { + "id": 98, + "name": "okpool.top", + "addresses": [], + "tags": [ + "/www.okpool.top/" + ], + "link": "https://www.okpool.top" + }, + { + "id": 99, + "name": "Hummerpool", + "addresses": [], + "tags": [ + "HummerPool", + "Hummerpool" + ], + "link": "https://www.hummerpool.com" + }, + { + "id": 100, + "name": "Tangpool", + "addresses": [ + "12Taz8FFXQ3E2AGn3ZW1SZM5bLnYGX4xR6" + ], + "tags": [ + "/Tangpool/" + ], + "link": "https://www.tangpool.com" + }, + { + "id": 101, + "name": "BytePool", + "addresses": [ + "39m5Wvn9ZqyhYmCYpsyHuGMt5YYw4Vmh1Z" + ], + "tags": [ + "/bytepool.com/" + ], + "link": "https://www.bytepool.com" + }, + { + "id": 102, + "name": "SpiderPool", + "addresses": [ + "125m2H43pwKpSZjLhMQHneuTwTJN5qRyYu", + "38u1srayb1oybVB43UWKBJsrwJbdHGtPx2", + "1BM1sAcrfV6d4zPKytzziu4McLQDsFC2Qc" + ], + "tags": [ + "SpiderPool" + ], + "link": "https://www.spiderpool.com" + }, + { + "id": 103, + "name": "NovaBlock", + "addresses": [ + "3Bmb9Jig8A5kHdDSxvDZ6eryj3AXd3swuJ" + ], + "tags": [ + "/NovaBlock/" + ], + "link": "https://novablock.com" + }, + { + "id": 104, + "name": "MiningCity", + "addresses": [ + "11wC5KcbgrWRBb43cwADdVrxgyF8mndVC" + ], + "tags": [ + "MiningCity" + ], + "link": "https://www.miningcity.com" + }, + { + "id": 105, + "name": "Binance Pool", + "addresses": [ + "122pN8zvqTxJaA8fRY1PDBu4QYodqE5m2X", + "16moWjUJVRnDQKqhoCdcszfJg9wzBdoTHw", + "1DSh7vX6ed2cgTeKPwufV5i4hSi4pp373h", + "1JvXhnHCi6XqcanvrZJ5s2Qiv4tsmm2UMy", + "3L8Ck6bm3sve1vJGKo6Ht2k167YKSKi8TZ", + "bc1qx9t2l3pyny2spqpqlye8svce70nppwtaxwdrp4", + "3G7jcEELKh38L6kaSV8K35pTqsh5bgZW2D" + ], + "tags": [ + "/Binance/", + "binance" + ], + "link": "https://pool.binance.com" + }, + { + "id": 106, + "name": "Minerium", + "addresses": [], + "tags": [ + "/Mined in the USA by: /Minerium.com/", + "/Minerium.com/" + ], + "link": "https://www.minerium.com" + }, + { + "id": 107, + "name": "Lubian.com", + "addresses": [ + "34Jpa4Eu3ApoPVUKNTN2WeuXVVq1jzxgPi" + ], + "tags": [ + "/Buffett/", + "/lubian.com/" + ], + "link": "https://www.lubian.com" + }, + { + "id": 108, + "name": "OKKONG", + "addresses": [ + "16JHXJ7M2MubWNX9grnqbjUqJ5PHwcCWw2" + ], + "tags": [ + "/hash.okkong.com/" + ], + "link": "https://hash.okkong.com" + }, + { + "id": 109, + "name": "AAO Pool", + "addresses": [ + "12QVFmJH2b4455YUHkMpEnWLeRY3eJ4Jb5" + ], + "tags": [ + "/AAOPOOL/" + ], + "link": "https://btc.tmspool.top" + }, + { + "id": 110, + "name": "EMCDPool", + "addresses": [ + "1BDbsWi3Mrcjp1wdop3PWFNCNZtu4R7Hjy" + ], + "tags": [ + "/EMCD/", + "/one_more_mcd/", + "get___emcd", + "emcd" + ], + "link": "https://pool.emcd.io" + }, + { + "id": 111, + "name": "Foundry USA", + "addresses": [ + "12KKDt4Mj7N5UAkQMN7LtPZMayenXHa8KL", + "1FFxkVijzvUPUeHgkFjBk2Qw8j3wQY2cDw", + "bc1qxhmdufsvnuaaaer4ynz88fspdsxq2h9e9cetdj", + "bc1p8k4v4xuz55dv49svzjg43qjxq2whur7ync9tm0xgl5t4wjl9ca9snxgmlt" + ], + "tags": [ + "/2cDw/", + "Foundry USA Pool" + ], + "link": "https://foundrydigital.com" + }, + { + "id": 112, + "name": "SBI Crypto", + "addresses": [], + "tags": [ + "/SBICrypto.com Pool/", + "SBI Crypto", + "SBICrypto" + ], + "link": "https://sbicrypto.com" + }, + { + "id": 113, + "name": "ArkPool", + "addresses": [ + "1QEiAhdHdMhBgVbDM7zUXWGkNhgEEJ6uLd" + ], + "tags": [ + "/ArkPool/" + ], + "link": "https://www.arkpool.com" + }, + { + "id": 114, + "name": "PureBTC.COM", + "addresses": [], + "tags": [ + "/PureBTC.COM/" + ], + "link": "https://purebtc.com" + }, + { + "id": 115, + "name": "MARA Pool", + "addresses": [ + "15MdAHnkxt9TMC2Rj595hsg8Hnv693pPBB", + "1A32KFEX7JNPmU1PVjrtiXRrTQcesT3Nf1" + ], + "tags": [ + "MARA Pool", + "MARA Made in USA" + ], + "link": "https://marapool.com" + }, + { + "id": 116, + "name": "KuCoinPool", + "addresses": [ + "1ArTPjj6pV3aNRhLPjJVPYoxB98VLBzUmb" + ], + "tags": [ + "KuCoinPool" + ], + "link": "https://www.kucoin.com/mining-pool" + }, + { + "id": 117, + "name": "Entrust Charity Pool", + "addresses": [], + "tags": [ + "Entrustus" + ], + "link": "pool.entustus.org" + }, + { + "id": 118, + "name": "OKMINER", + "addresses": [ + "15xcAZ2HfaSwYbCV6GGbasBSAekBRRC5Q2" + ], + "tags": [ + "okminer.com/euz" + ], + "link": "https://okminer.com" + }, + { + "id": 119, + "name": "Titan", + "addresses": [ + "14hLEtxozmmih6Gg5xrGZLfx51bEMj21NW" + ], + "tags": [ + "Titan.io" + ], + "link": "https://titan.io" + }, + { + "id": 120, + "name": "PEGA Pool", + "addresses": [ + "1BGFwRzjCfRR7EvRHnzfHyFjGR8XiBDFKa" + ], + "tags": [ + "/pegapool/" + ], + "link": "https://www.pega-pool.com" + }, + { + "id": 121, + "name": "BTC Nuggets", + "addresses": [ + "1BwZeHJo7b7M2op7VDfYnsmcpXsUYEcVHm" + ], + "tags": [], + "link": "https://104.197.8.250" + }, + { + "id": 122, + "name": "CloudHashing", + "addresses": [ + "1ALA5v7h49QT7WYLcRsxcXqXUqEqaWmkvw" + ], + "tags": [], + "link": "https://cloudhashing.com" + }, + { + "id": 123, + "name": "digitalX Mintsy", + "addresses": [ + "1NY15MK947MLzmPUa2gL7UgyR8prLh2xfu" + ], + "tags": [], + "link": "https://www.mintsy.co" + }, + { + "id": 124, + "name": "Telco 214", + "addresses": [ + "13Sd8Y7nUao3z4bJFkZvCRXpFqHvLy49YY", + "14M1pQ5KKeqmDrmqKyZEnaxAGJfBPrfWvQ", + "18hvMLisvfc58PvA5rHH7NsLN9CV5ddB2x", + "18ikmzPqk721ZNvWhDos1UL4H29w352Kj5", + "1AsEJU4ht5wR7BzV6xsNQpwi5qRx4qH1ac", + "1BUhwvF9oo3qkaSjjPpWrUzQxXNjkHdMZF", + "1CNq2FAw6S5JfBiDkjkYJUVNQwjoeY4Zfi", + "1DXRoTT67mCbhdHHL1it4J1xsSZHHnFxYR", + "1GaKSh2t396nfSg5Ku2J3Yn1vfVsXrGuH5", + "1LXWA3EEEwPixQcyFWXKX2hWHpkDoLknZW", + "1MoYfV4U61wqTPTHCyedzFmvf2o3uys2Ua", + "1P4B6rx1js8TaEDXvZvtrkiEb9XrJgMQ19" + ], + "tags": [], + "link": "https://www.telco214.com" + }, + { + "id": 125, + "name": "BTC Pool Party", + "addresses": [ + "1PmRrdp1YSkp1LxPyCfcmBHDEipG5X4eJB" + ], + "tags": [], + "link": "https://btcpoolparty.com" + }, + { + "id": 126, + "name": "Multipool", + "addresses": [ + "1MeffGLauEj2CZ18hRQqUauTXb9JAuLbGw" + ], + "tags": [], + "link": "https://www.multipool.us" + }, + { + "id": 127, + "name": "transactioncoinmining", + "addresses": [ + "1qtKetXKgqa7j1KrB19HbvfRiNUncmakk" + ], + "tags": [], + "link": "https://sha256.transactioncoinmining.com" + }, + { + "id": 128, + "name": "BTCDig", + "addresses": [ + "15MxzsutVroEE9XiDckLxUHTCDAEZgPZJi" + ], + "tags": [], + "link": "https://btcdig.com" + }, + { + "id": 129, + "name": "Tricky's BTC Pool", + "addresses": [ + "1AePMyovoijxvHuKhTqWvpaAkRCF4QswC6" + ], + "tags": [], + "link": "https://pool.wemine.uk" + }, + { + "id": 130, + "name": "BTCMP", + "addresses": [ + "1jKSjMLnDNup6NPgCjveeP9tUn4YpT94Y" + ], + "tags": [], + "link": "https://www.btcmp.com" + }, + { + "id": 131, + "name": "Eobot", + "addresses": [ + "16GsNC3q6KgVXkUX7j7aPxSUdHrt1sN2yN", + "1MPxhNkSzeTNTHSZAibMaS8HS1esmUL1ne" + ], + "tags": [], + "link": "https://eobot.com" + }, + { + "id": 132, + "name": "UNOMP", + "addresses": [ + "1BRY8AD7vSNUEE75NjzfgiG18mWjGQSRuJ" + ], + "tags": [], + "link": "https://199.115.116.7:8925" + }, + { + "id": 133, + "name": "Patels", + "addresses": [ + "197miJmttpCt2ubVs6DDtGBYFDroxHmvVB", + "19RE4mz2UbDxDVougc6GGdoT4x5yXxwFq2" + ], + "tags": [], + "link": "https://patelsminingpool.com" + }, + { + "id": 134, + "name": "GoGreenLight", + "addresses": [ + "18EPLvrs2UE11kWBB3ABS7Crwj5tTBYPoa" + ], + "tags": [], + "link": "https://www.gogreenlight.se" + }, + { + "id": 135, + "name": "BitcoinIndia", + "addresses": [ + "1AZ6BkCo4zgTuuLpRStJH8iNsehXTMp456" + ], + "tags": [], + "link": "https://pool.bitcoin-india.org" + }, + { + "id": 136, + "name": "EkanemBTC", + "addresses": [ + "1Cs5RT9SRk1hxsdzivAfkjesNmVVJqfqkw" + ], + "tags": [], + "link": "https://ekanembtc.com" + }, + { + "id": 137, + "name": "CANOE", + "addresses": [ + "1Afcpc2FpPnREU6i52K3cicmHdvYRAH9Wo" + ], + "tags": [], + "link": "https://www.canoepool.com" + }, + { + "id": 138, + "name": "tiger", + "addresses": [ + "1LsFmhnne74EmU4q4aobfxfrWY4wfMVd8w" + ], + "tags": [], + "link": "" + }, + { + "id": 139, + "name": "1M1X", + "addresses": [ + "1M1Xw2rczxkF3p3wiNHaTmxvbpZZ7M6vaa" + ], + "tags": [], + "link": "" + }, + { + "id": 140, + "name": "Zulupool", + "addresses": [ + "1ZULUPooLEQfkrTgynLV4uHyMgQYx71ip" + ], + "tags": [ + "ZULUPooL", + "ZU_test" + ], + "link": "https://beta.zulupool.com/" + }, + { + "id": 141, + "name": "SECPOOL", + "addresses": [ + "3Awm3FNpmwrbvAFVThRUFqgpbVuqWisni9" + ], + "tags": [ + "SecPool" + ], + "link": "https://www.secpool.com" + }, + { + "id": 142, + "name": "OCEAN", + "addresses": [ + "37dvwZZoT3D7RXpTCpN2yKzMmNs2i2Fd1n" + ], + "tags": [ + "OCEAN.XYZ" + ], + "link": "https://ocean.xyz/" + }, + { + "id": 143, + "name": "WhitePool", + "addresses": [ + "14VkxDwSAUWrzYTxV49HnYhKLWTJ3pCoUS" + ], + "tags": [ + "WhitePool" + ], + "link": "https://whitebit.com/mining-pool" + }, + { + "id": 144, + "name": "wiz", + "addresses": [ + "1wizSAYSbuyXbt9d8JV8ytm5acqq2TorC", + "tb1q548z58kqvwyjqwy8vc2ntmg33d7s2wyfv7ukq4" + ], + "tags": [ + "/@wiz/" + ], + "link": "https://wiz.biz/" + }, + { + "id": 145, + "name": "mononaut", + "addresses": [ + "mjP97q5BWtdpdsJLkEJvQWgLe9zw4MMVU6" + ], + "tags": [ + "🐵🚀" + ], + "link": "https://twitter.com/mononautical" + }, + { + "id": 146, + "name": "rijndael", + "addresses": [ + "tb1qg8zlznrvns9u46muxamxjh7sa8wry3vutzaujm" + ], + "tags": [ + "rijndael's toaster" + ], + "link": "https://twitter.com/rot13maxi" + }, + { + "id": 147, + "name": "wk057", + "addresses": [ + "1WizkidqARMLvjGUpfDQFRcEbnHpL55kK" + ], + "tags": [ + "wizkid057's block" + ], + "link": "" + }, + { + "id": 148, + "name": "FutureBit Apollo Solo", + "addresses": [], + "tags": [ + "Apollo", + "FutureBit", + "/mined by a Solo FutureBit Apollo/" + ], + "link": "https://www.futurebit.io" + }, + { + "id": 149, + "name": "emzy", + "addresses": [ + "tb1qmf7xdqc5nvzhturuzc46qtq5kywdf3p76cpq53" + ], + "tags": [ + "Emzy was here." + ], + "link": "https://twitter.com/emzy" + }, + { + "id": 150, + "name": "knorrium", + "addresses": [ + "tb1qtfqp4g7n7wc3sr6c2cuzsq62px4pfsxgsv2krx" + ], + "tags": [ + "knorrium" + ], + "link": "https://twitter.com/knorrium" + }, + { + "id": 151, + "name": "Carbon Negative", + "addresses": [ + "33SAB6pzbhEGPbfY6NVgRDV7jVfspZ3A3Z", + "3KZDwmJHB6QJ13QPXHaW7SS3yTESFPZoxb" + ], + "tags": [], + "link": "https://github.com/bitcoin-data/mining-pools/issues/48" + }, + { + "id": 152, + "name": "Portland.HODL", + "addresses": [ + ], + "tags": ["Portland.HODL"], + "link": "" + }, + { + "id": 153, + "name": "Phoenix", + "addresses": [ + "37cGvBD4qufoZQHopGS7XstxRUzx5cNuy1", + "bc1q2zcenaujmmdv8sqgf723cug4vjnphkvvf8zpst", + "1Ld6okoaLNDbSnougAyQTrchxRn9ELnTJg" + ], + "tags": ["/Phoenix/"], + "link": "https://phoenixpool.com" + }, + { + "id": 154, + "name": "Neopool", + "addresses": [ + "1HCAb2h89bUinm6QZrAPpfbk4ySBrT2V4w" + ], + "tags": ["/Neopool/"], + "link": "https://neopool.com/" + }, + { + "id": 155, + "name": "MaxiPool", + "addresses": [ + "36r3YqAXWpyqNcczjCBdHrYZ3m8X56WDzx" + ], + "tags": ["/MaxiPool/"], + "link": "https://maxipool.org/" + }, + { + "id": 156, + "name": "DrDetroit", + "addresses": [ + "tb1qtcruplnz89xw5f86kw8sj7x9r23d5yffrysx2p" + ], + "tags": [ + "DrDetroit" + ], + "link": "https://x.com/bankhatin" + }, + { + "id": 157, + "name": "BitFuFuPool", + "addresses": [ + "3JP3zF7LoeoAotqkNGdvX5szUyNPwd937d" + ], + "tags": ["/BitFuFu/"], + "link": "https://www.bitfufu.com/pool" + }, + { + "id": 158, + "name": "GDPool", + "addresses": [ + "1DnPPFQPrfyNTiHPXhDFyqNnW9T62GEhB1" + ], + "tags": ["Lucky pool", "GDPool"], + "link": "" + }, + { + "id": 159, + "name": "Mining-Dutch", + "addresses": [ + "1AfPSq5ZbqBaxU5QAayLQJMcXV8HZt92eq" + ], + "tags": ["/Mining-Dutch/"], + "link": "https://www.mining-dutch.nl/" + }, + { + "id": 160, + "name": "Public Pool", + "addresses": [], + "tags": [ + "Public-Pool", + "Public Pool on Umbrel" + ], + "link": "https://web.public-pool.io/" + }, + { + "id": 161, + "name": "Mining Squared", + "addresses": [ + "3GdjWJdkJhtkxRZ3Ns1LstaoHNMBW8XsvU", + "3AvXzTUat4p6Qf6ZLnRNvB3mDLjp3fNmjJ" + ], + "tags": ["MiningSquared", "BSquared Network", "/bsquared/"], + "link": "https://pool.bsquared.network/" + }, + { + "id": 162, + "name": "Innopolis Tech", + "addresses": [ + "bc1q75t4wewkmf3l9qg097zvtlh05v5pdz6699kv8k" + ], + "tags": ["Innopolis", "Innopolis.tech"], + "link": "https://innopolis.tech/" + }, + { + "id": 163, + "name": "nymkappa", + "addresses": [ + "tb1qdyy39724wqnqqqduv6zxsf56s2ec9lgypxs59h" + ], + "tags": ["/@nymkappa/"], + "link": "https://github.com/nymkappa" + }, + { + "id": 164, + "name": "BTCLab", + "addresses": [], + "tags": [ + "BTCLab", + "BTCLab.dev" + ], + "link": "https://btclab.dev/" + }, + { + "id": 165, + "name": "Parasite", + "addresses": [], + "tags": ["parasite"], + "link": "https://parasite.space" + }, + { + "id": 166, + "name": "RedRock Pool", + "addresses": [ + "3554kSaWNnP3B49Xyybert7gmxq2YSnfnx" + ], + "tags": ["RedRock"], + "link": "https://redrock.pro/" + }, + { + "id": 167, + "name": "Est3lar", + "addresses": [ + "34qGNFx6uQv6SzjYPbYVWtjvuy5DSGugt8" + ], + "tags": ["/Est3lar/", "est3lar", "Est3lar", "EST3LAR"], + "link": "https://est3lar.io" + }, + { + "id": 168, + "name": "Braiins Solo", + "addresses": [], + "tags": ["/braiinssolo/"], + "link": "https://solo.braiins.com" + }, + { + "id": 169, + "name": "SoloPool.com", + "addresses": [ + "bc1qreaftg3lr53nv84dnxhcvchmswevzlp9tdj2jd" + ], + "tags": ["/Mined @ SoloPool.Com/"], + "link": "https://solopool.com" + }, + { + "id": 170, + "name": "Noderunners", + "addresses": [], + "tags": ["/Noderunners/"], + "link": "https://pool.noderunners.network" + }, + { + "id": 171, + "name": "DMND", + "addresses": [], + "tags": ["/DMND/"], + "link": "https://www.dmnd.work/" + } +] diff --git a/docker/backend/mempool-config.json b/docker/backend/mempool-config.json index 49862964604..6298029745b 100644 --- a/docker/backend/mempool-config.json +++ b/docker/backend/mempool-config.json @@ -37,6 +37,7 @@ "ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__, "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__", + "POOLS_JSON_FILE": "__MEMPOOL_POOLS_JSON_FILE__", "POOLS_UPDATE_DELAY": __MEMPOOL_POOLS_UPDATE_DELAY__, "PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__, "MAX_TRACKED_ADDRESSES": __MEMPOOL_MAX_TRACKED_ADDRESSES__ diff --git a/docker/backend/start.sh b/docker/backend/start.sh index cc554c2130c..cfd782977d9 100755 --- a/docker/backend/start.sh +++ b/docker/backend/start.sh @@ -30,6 +30,7 @@ __MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info} __MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=true} __MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json} __MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=https://api.github.com/repos/mempool/mining-pools/git/trees/master} +__MEMPOOL_POOLS_JSON_FILE__=${MEMPOOL_POOLS_JSON_FILE:=tasks/pools/pools-v2.json} __MEMPOOL_POOLS_UPDATE_DELAY__=${MEMPOOL_POOLS_UPDATE_DELAY:=604800} __MEMPOOL_AUDIT__=${MEMPOOL_AUDIT:=false} __MEMPOOL_RUST_GBT__=${MEMPOOL_RUST_GBT:=true} @@ -195,6 +196,7 @@ sed -i "s!__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__!${__MEMPOOL_STDOUT_LOG_MIN_PRIORIT sed -i "s!__MEMPOOL_AUTOMATIC_POOLS_UPDATE__!${__MEMPOOL_AUTOMATIC_POOLS_UPDATE__}!g" mempool-config.json sed -i "s!__MEMPOOL_POOLS_JSON_URL__!${__MEMPOOL_POOLS_JSON_URL__}!g" mempool-config.json sed -i "s!__MEMPOOL_POOLS_JSON_TREE_URL__!${__MEMPOOL_POOLS_JSON_TREE_URL__}!g" mempool-config.json +sed -i "s!__MEMPOOL_POOLS_JSON_FILE__!${__MEMPOOL_POOLS_JSON_FILE__}!g" mempool-config.json sed -i "s!__MEMPOOL_POOLS_UPDATE_DELAY__!${__MEMPOOL_POOLS_UPDATE_DELAY__}!g" mempool-config.json sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json sed -i "s!__MEMPOOL_RUST_GBT__!${__MEMPOOL_RUST_GBT__}!g" mempool-config.json diff --git a/docs/architecture/ADR-PROTOCOL-OVERLAY.md b/docs/architecture/ADR-PROTOCOL-OVERLAY.md index 9774cd5f3b0..a0d3d0a4a8c 100644 --- a/docs/architecture/ADR-PROTOCOL-OVERLAY.md +++ b/docs/architecture/ADR-PROTOCOL-OVERLAY.md @@ -74,3 +74,47 @@ The overlay: docs updated in each repo's four-repo documentation set. - The explorer frontend has exactly one aggregate enrichment call per view (no browser-to-indexer traffic, no N+1). + +## Authority status contract + +A source has two independent facts, and the explorer must never let one stand +in for the other. + +**Capability** comes from the registry: what a protocol is implemented to do, +`VERIFIED READ ONLY` or `BLOCKED`. It changes when code ships. + +**Availability** comes from the runtime snapshot: whether the authority behind +that protocol can answer right now. It changes on its own. + +The overlay reports five availability states per source: + +| Status | Meaning | +| --- | --- | +| `ready` | reachable, ready, and close enough to the chain tip to describe the present | +| `stale` | reachable and answering, but too far behind to describe the present | +| `degraded` | reachable, but not ready or unable to produce a usable checkpoint | +| `unreachable` | the request did not complete at the transport layer | +| `unconfigured` | the registry names this authority and nothing configured it | + +`stale` sits between `ready` and `degraded` deliberately. An indexer rebuilding +its index answers every request correctly while sitting hundreds of thousands +of blocks back; folding that into `ready` is what let a rebuilding ord index be +presented as live, and folding it into `degraded` would call a healthy indexer +broken. + +Freshness is measured against the highest tip observed for the same chain and +network across every configured source, which in this deployment is the mempool +backend reading Bitcoin Core. Each source carries `lagBlocks`, `lastSuccessAt`, +and `consecutiveFailures` so a reader can judge the label rather than take it. + +`/api/v1/universe/availability` resolves capability against availability once, +next to the snapshot it comes from, so no client has to decide the precedence +for itself. Availability governs: a protocol whose authority is unreachable, +unconfigured, or catching up is not readable, whatever the registry says it +implements. + +A source is only configured when the whole read contract is usable, which means +a readiness route that answers 200 and a checkpoint carrying both a height and +a 64-hex tip hash. An authority that publishes a height with no hash is left +unconfigured rather than admitted without evidence: the explorer must not +assert a checkpoint the authority never committed to. diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index 1f57ee307c8..b5c9fedd29d 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -13,6 +13,10 @@ explorer.bitcoinuniverse.io public origin, TLS terminated at the gatewa /api/v1/universe/* -> universe-explorer-overlay 127.0.0.1:3400 /api/* -> universe-explorer-backend 127.0.0.1:8996 everything else -> the built frontend, SPA fallback + + universe-explorer-backend -> Bitcoin Core RPC 127.0.0.1:8332 + -> explorer database 127.0.0.1:3307 + universe-explorer-overlay -> protocol authorities, all on loopback ``` No indexer port, RPC port, or database port is exposed publicly, and the @@ -25,6 +29,7 @@ restricted to port forwarding and to that single destination port. | --- | --- | --- | | `universe-explorer-backend.service` | the forked explorer backend, `backend/dist/index.js` | 127.0.0.1:8996 | | `universe-explorer-overlay.service` | the `backend-apis` read-only protocol overlay, `dist/universe-explorer-main.js` | 127.0.0.1:3400 | +| `universe-explorer-mariadb` | the explorer database, a Docker container | 127.0.0.1:3307 | | `universe-explorer-gateway.service` | `scripts/universe/gateway.mjs`, static files plus routing | 127.0.0.1:8099 | All three run as the `universe-explorer` system user. The backend additionally @@ -36,15 +41,55 @@ nothing else. The backend reads only Universe-owned infrastructure: - Bitcoin Core on 127.0.0.1:8332, authenticated by cookie -- Fulcrum on 127.0.0.1:50001 for address and UTXO queries - no third-party API, at runtime or at build time -`DATABASE.ENABLED` is `false`. The database only powers historical indexing, -mining statistics, and audit features; the live explorer, the protocol overlay, -and every route this product leads with work without it. Running without it -keeps the explorer off the memory budget of a host that is also indexing. -`AUTOMATIC_POOLS_UPDATE` is `false` so no pool metadata is fetched from a third -party; mining pool logos fall back to the bundled default. +`MEMPOOL.BACKEND` is `none`, meaning Core answers everything. It was +`electrum`, pointing at a Fulcrum on 127.0.0.1:50001 that is no longer on this +host: no process, no data directory, no unit file. A dead address backend does +not fail loudly, it retries, and it was producing roughly two connection errors +a second and burying every real error in the journal. Core serves blocks, +transactions and the mempool either way; with `none` an address lookup fails +immediately and clearly instead of hanging. + +Set this back to `electrum` when Fulcrum is running again. The release +preflight refuses `electrum` when nothing is listening on the configured port, +so the retry storm cannot come back silently. + +The explorer database is `universe-explorer-mariadb`, a dedicated container on +loopback port 3307 with its own data directory under +`/data/indexers-c/universe-explorer/mariadb`. Credentials live in +`/etc/universe-explorer/mysql.env`, root-owned and group-readable by +`universe-explorer` only, and the database user is scoped to the one schema. + +It is MariaDB rather than the MySQL 8.4 pinned elsewhere in the workspace, +because the upstream migrations are written in MariaDB syntax that MySQL +rejects outright. `ALTER TABLE ... DROP FOREIGN KEY IF EXISTS` and a literal +`DEFAULT` on a `JSON` column both abort the whole migration run on MySQL, and +there are twenty of the latter. Carrying a patch for each one would put a +permanent divergence in the middle of upstream migration code and break again +on every upstream sync. The integration test database is pinned to the same +engine and major version for the same reason: a test database on a different +engine proves nothing about the release. + +`DATABASE.ENABLED` and `STATISTICS.ENABLED` are `true`, and +`INDEXING_BLOCKS_AMOUNT` is `52560`, one year of blocks. Statistics and every +mining route are registered only when those are on, so with them off the public +Charts and Mining pages sat in front of routes that answered 404. The indexing +bound is deliberate: it covers every range the mining pages offer up to 1Y and +keeps the initial index from competing with the protocol indexers for Bitcoin +Core for longer than it has to. The frontend only offers a range it has the +block count to cover, so ranges beyond the indexed history stay hidden rather +than empty. + +`AUTOMATIC_POOLS_UPDATE` is `false` and `POOLS_JSON_FILE` names the pool list +bundled in the repository. Pool metadata is identified by the git blob hash of +that file's own bytes, so the import is deterministic, needs no network, and +does no work on a redeploy that did not change it. Nothing fetches pool data +from a third party at runtime. + +`FIAT_PRICE.ENABLED` is `false`. The price updater reads public exchange APIs, +which is not allowed here, and no Universe-owned price source exists yet. Fiat +amounts render blank rather than as a confident zero. The overlay reads only Universe protocol authorities, configured through `UNIVERSE_EXPLORER_SOURCES_JSON` in `/etc/universe-explorer/overlay.env`, with @@ -60,6 +105,7 @@ the JSON, in responses, or in logs. | `backend.json` | explorer backend configuration | | `overlay.env` | overlay port and the protocol source registry | | `gateway.env` | gateway ports and the static root | +| `mysql.env` | explorer database name, user, and passwords | | `fulcrum.conf` | the Electrum index this deployment reads | ## Build @@ -83,34 +129,161 @@ without anything noticing. ## Release procedure +`scripts/universe/release.sh` performs the release and is installed on the host +as `/usr/local/bin/universe-explorer-release`. It exists because the previous +release passed every check in CI and still shipped two broken pages: the thing +that was wrong was the configuration of the machine, and nothing in a fixture +suite can see that. + 1. Run the full check set on the runner fleet (`.github/workflows/universe-ci.yml`), including the branding, origin, and - text gates against both the source tree and `frontend/dist`. -2. Install the new release beside the running one under - `/opt/universe-explorer/releases/mempool-/`. Never overwrite a live - release directory in place. -3. Point `/opt/universe-explorer/current` at the new release with an atomic - symlink swap. -4. Restart the three explorer units. The gateway comes back within a second, - so the public origin sees at most a brief connection reset rather than a - sustained outage. -5. Verify: `/__gateway/health` on the gateway, tip height against Core, - a known block and transaction render, `/api/v1/universe/status` reports its - sources, and the `/source` page shows the deployed commit. -6. Keep the previous release directory until the stability window closes. + text gates against both the source tree and `frontend/dist`, the backend + integration tests against a real database, and the visual matrix. +2. Build on the runner fleet and pack `backend/dist`, `frontend/dist`, and + `scripts/universe` into one artifact. +3. `universe-explorer-release install ` unpacks it beside the + running release under `/opt/universe-explorer/releases/mempool-/` and + hard-links the dependency tree from the release in use. A release directory + is never overwritten in place. +4. `universe-explorer-release preflight ` runs the gates and changes + nothing. It refuses a release whose build is incomplete, whose + configuration would advertise a feature it cannot serve, whose database does + not answer, whose source registry does not parse or names a token variable + that is missing, or where a protocol the registry calls readable has no + authority configured. +5. `universe-explorer-release cutover ` runs the gates again, swaps the + `current` symlink atomically, restarts what has to restart, and verifies. + + The backend and the overlay run from a path baked into their unit at exec + time, so they always restart; they take a few seconds to listen again, and + the gateway waits for them rather than answering 502. The gateway resolves + its static root per request, so a new frontend reaches it through the + symlink with no restart, and it is restarted only when its own file + changed. A cutover was probed once a second through the restart window and + every request returned 200. + + A release that changes the gateway itself used to be the one case that + still showed a gap, because the port went away with the process and nothing + could bridge it. `universe-explorer-gateway.socket` closes that: systemd + owns the listening port, so a restart of the service leaves it bound and + arriving connections wait in the kernel backlog. The unit and the service + drop-in that requires it are in `production/linux/`. Enable it with + + ```bash + systemctl enable --now universe-explorer-gateway.socket + ``` + + Measured on this host before adopting it, on a spare port with a throwaway + unit pair that was removed afterwards: the service logged that it was + listening on the socket systemd passed, and a full `systemctl restart` + under a probe every 20 milliseconds returned 200 on all 400 requests while + the process id changed underneath. The handover is not an assumption. + + Adopting it on a gateway that is already running costs one brief + interruption, since the running process holds the port without + `SO_REUSEPORT` and the socket unit cannot bind underneath it. Every deploy + after that is seamless. The cutover log states which of the two cases + applies rather than leaving a reader to assume. + + Once the socket owns the port it outlives the service, so stopping the + service alone does not take the gateway down: the next connection starts it + again. To stop it for real, stop both units. + + ```bash + systemctl stop universe-explorer-gateway.socket universe-explorer-gateway.service + ``` + + An upstream that is genuinely gone is still reported as a gateway failure + within a few seconds, so a real outage is never hidden behind a long wait. After the swap it reads `/api/v1/capabilities` and + fails the release if any feature is enabled with no routes registered, which + is exactly the state that shipped. A failed verification rolls back. +6. Run `node scripts/universe/synthetic-check.mjs` against the public origin. + It asks the live endpoints with nothing mocked and fails on an empty range, + a protocol advertised as readable whose authority cannot answer, a + configured authority with no checkpoint, or a frontend and backend on + different builds. +7. Keep the previous release directory until the stability window closes. + +Rolling back to a release from before the socket handover needs the port back, +because such a gateway opens 8099 itself and dies on bind while systemd holds +it. `release.sh rollback` detects that case by looking for `inheritedListenerFd` +in the target release and disables the socket first. That is worth knowing by +hand too, since the rollback path is reached exactly when something has already +gone wrong. + +## Database growth and retention + +Measured on the running deployment, with one year of blocks indexed and the +statistics writer taking a sample a minute: + +| Table | Rows | Size | Growth | +| --- | --- | --- | --- | +| `blocks` | 52,580 | 130 MB | about 0.4 MB a day, from new blocks only | +| `statistics` | 1 a minute | small | about 0.4 MB a day | +| `hashrates` | 1,437 | 0.2 MB | a few rows a day | + +That is roughly 300 MB a year against 1.5 TB free on `/data/indexers-c`, so +nothing is pruned. Statistics are kept indefinitely on purpose: the `all` range +is the whole series, and deleting old samples would silently shorten it. + +The indexed block window is bounded by `INDEXING_BLOCKS_AMOUNT`, so `blocks` +grows only with the chain rather than with the backlog. Raising that value +re-indexes further back and costs Bitcoin Core RPC time while it runs; do it +when the host is not also rebuilding an index. + +## Monitoring + +`universe-explorer-synthetic-check.timer` runs the synthetic check against the +public origin every five minutes. The unit files are in `production/linux/`. +It exercises the same path a reader takes, including the gateway, the tunnel, +and TLS, and marks the service failed when a check fails, so an outage between +releases shows up where every other service failure already does: + +```bash +systemctl list-timers universe-explorer-synthetic-check.timer +journalctl -u universe-explorer-synthetic-check.service -n 30 +``` + +It fails on a feature advertised with no routes behind it, a statistics range +that answers with nothing, a protocol marked readable whose authority cannot +answer, a configured authority that published no checkpoint, and a frontend and +backend reporting different builds. The same script runs hourly in CI through +`universe-production-smoke.yml`. ## Rollback -Point the symlink back and restart: +```bash +universe-explorer-release rollback +``` + +It points the symlink back, restarts the three units, and verifies the result, +failing loudly if the rollback target does not come back either. + +Configuration lives outside the release directory, so a rollback carries the +configuration forward. Database migrations do not roll back: they are additive, +and an older backend reads a newer schema. A rollback across a migration should +therefore be paired with a database restore only if the newer schema is known +to be incompatible, which has not happened yet. + +`universe-explorer-backup.timer` dumps the database daily into +`/data/indexers-c/universe-explorer/backups` and keeps two weeks. The script +refuses to keep a dump that is suspiciously small and checks the archive reads +back, because a backup nobody verifies is not a backup. Take one by hand before +a release that migrates: ```bash -ln -sfn /opt/universe-explorer/releases/mempool- /opt/universe-explorer/current.new -mv -Tf /opt/universe-explorer/current.new /opt/universe-explorer/current -systemctl restart universe-explorer-backend universe-explorer-overlay universe-explorer-gateway +systemctl start universe-explorer-backup.service ``` -Nothing else changes: configuration lives outside the release directory, and no -migration runs, so a rollback is exactly the same operation in reverse. +Verify a restore into a scratch schema after any change to the schema or the +engine, rather than trusting that the dump exists: + +```bash +docker exec universe-explorer-mariadb mariadb -u root -p"$MYSQL_ROOT_PASSWORD" \ + -e 'DROP DATABASE IF EXISTS restore_probe; CREATE DATABASE restore_probe;' +gunzip -c | docker exec -i universe-explorer-mariadb \ + mariadb -u root -p"$MYSQL_ROOT_PASSWORD" restore_probe +``` ## Rules diff --git a/docs/product/DESIGN-SYSTEM.md b/docs/product/DESIGN-SYSTEM.md index 99f0ade8163..3dfd0e5b1db 100644 --- a/docs/product/DESIGN-SYSTEM.md +++ b/docs/product/DESIGN-SYSTEM.md @@ -36,20 +36,153 @@ A visitor should feel which side of that line something is on before reading a word. That is why confirmed and projected blocks differ in weight and depth, not only in a label. +## The brand + +Universe Explorer is part of Bitcoin Universe, and it looks like it. Core, +Wallet and Inscribe are all built on the same pink-forward system, so an +explorer in ultramarine read as a different company's product wearing our name. + +### Personality + +A precision instrument finished to a high standard. Editorial, confident, +technical, and adult. The reference is a control room designed by people who +also design magazines, not a fashion site with charts bolted on. + +What that rules out, explicitly: bubblegum, cyberpunk neon, glass everywhere, +galaxy wallpaper, floating shapes, decorative gradients, rainbow fills, and +anything that would make a person hesitate to open this on a work screen. + +### The pink rule + +**Pink is the brand, and only the brand.** It marks identity and intent: the +mark, the primary action, active navigation, selection, focus offset, live +surfaces, and display type. It never carries a status, a protocol, a fee band, +or a quantity. + +That is the whole reason the pink can be loud. A reader learns in one screen +that pink means "this is Universe, and this is where you act", so it never has +to compete with the colours that mean something about Bitcoin. + +**Filling a bar is not carrying a quantity.** A progress bar, a block fullness +bar, or a single-series area chart may be brand pink, because the value is +carried by the length or the height and the fill is just the mark. What is +forbidden is colour that *encodes* the value: a fee band, a status, a protocol, +a heat scale. If changing the number would change the colour, the colour is not +allowed to be the brand. + +One consequence worth stating, because it is the only place the system bends: +the fee scale runs to a deep magenta at its expensive end, and that band sits +about 10.7 dE from the light brand fill. Both values are load bearing. It is +recorded in `check-palettes.mjs` at the distance it actually has, so nudging +either one closer fails the build. + +### Colour roles + +| Token | Value, light | Value, dark | Role | +|---|---|---|---| +| `--u-brand` | `#c40059` | `#ff8ab8` | The working pink. Readable as text and as a fill. | +| `--u-brand-hover` | `#a80049` | `#ffb3d1` | Hover of the above. | +| `--u-brand-contrast` | `#ffffff` | `#2a0a18` | The foreground every brand fill declares. | +| `--u-brand-accent` | `#ff0066` | `#ff2a85` | The identity anchor. Borders, rings, rules, display type. | +| `--u-brand-subtle` | `#fde9f1` | `#2e1226` | Brand-tinted surface. | +| `--u-magenta` | `#a3006b` | `#ff6fd0` | Second stop of the house sweep. | +| `--u-fuchsia` | `#8b2fb5` | `#d98cf5` | Third stop of the house sweep. | +| `--u-lavender` | `#5b2fa6` | `#c4a7ff` | The secondary accent, and the focus ring. | +| `--u-chrome` | `#6b6280` | `#b9bed6` | Metal, as readable ink. | +| `--u-chrome-rim` | `#c9cddf` | `#4a4560` | Metal, as a hairline. Carries no text. | +| `--u-pearl` | `#fff6fa` | `#fff6fa` | The light ground, and ink on brand tiles. | +| `--u-blush` | `#ffd8e7` | `#ffd8e7` | The softest brand tint. | + +`#ff0066` is the value the rest of Bitcoin Universe is built on, and it is the +value this product uses for identity. It is not a button fill: white on it +measures 3.85:1 and misses AA. That is the entire reason `--u-brand` and +`--u-brand-accent` are two tokens rather than one. + +### The foreground contract + +Every strong fill declares what goes on it. A component never guesses. + +```scss +background-image: var(--u-gloss), var(--u-gradient-brand); +color: var(--u-brand-contrast); +``` + +`check-palettes.mjs` measures that foreground against **every stop** of the +gradient, **through** the gloss layer. That check is not decorative: a 0.24 +gloss put a white label at 4.06:1 on the violet end of the sweep, which is why +the gloss is 0.16. + +### Gloss, glow, and the sweep + +Three finishes, and a rule for each. + +| Token | What it is | Where it is allowed | +|---|---|---| +| `--u-gradient-brand` | hot pink to magenta to fuchsia | The mark, the primary action, live surfaces | +| `--u-gloss` | a top specular highlight | Filled brand controls only | +| `--u-glow-brand` | a soft brand shadow | The primary action, and singleton surfaces | +| `--u-gradient-chrome` | pearl to chrome to steel | Metallic frames, never behind body text | +| `--u-gradient-pride` | a refined spectrum | At most one rule per screen, never a fill | + +The pride rule is a rule, not a wash. It appears as a hairline under a single +divider and nowhere else. Applied broadly it stops meaning anything. + +### Surfaces + +Light runs pearl to white: the page floor carries a violet cast so it belongs to +the same material as the pink on it, and a raised card is pure white, so +elevation reads without a shadow and dense tables stay crisp. + +Dark runs plum-black, the same shell the rest of Bitcoin Universe uses. That is +what makes hot pink read as lacquer rather than as neon on grey. + +### The mark + +Two squares on a diagonal: one still forming, one settled. That is the product's +own axis, and it is the only idea the mark carries. It is deliberately not a +hexagon, an orbit, a globe, or a planet, because those are the defaults of this +category and none of them says anything about this product. + +It is drawn in `currentColor` plus one brand token, so it is correct on either +theme, survives a monochrome favicon, and never depends on a font. + +The wordmark is live text in the interface's own font stack, so it always +matches the product around it. Exported assets that cannot rely on a font are +rendered to raster by `scripts/universe/brand/render-brand-assets.mjs`. + +### Icons and the tab strip + +The favicon is inverted on purpose: a pearl mark on a hot-pink tile, rather than +a pink glyph on white. Every explorer in this market ships a dark or a white +favicon, so a solid pink tile is the one that can be found in a strip of twenty +tabs. Pearl on `#c40059` measures 5.7:1, so the mark is still legible at 16px +rather than relying on the tile colour alone. + +### Relationship to the rest of Bitcoin Universe + +Same anchor, same secondary, same shell material, same discipline about status +colour. Not the same product: this one is denser, quieter, and light by default, +because it is read for minutes at a time rather than glanced at. + ## Colour Defined in `frontend/src/styles/_universe-tokens.scss` and emitted as CSS custom properties. A theme swap is a variable swap. -### The two rules +### The three rules + +1. **Pink is the brand, and only the brand.** It is never a status, a protocol, + a fee band, or a quantity. `check-palettes.mjs` measures that every brand + role sits at least 25 dE from every state colour and every protocol colour, + under normal vision. -1. **Colour carries evidence state, never protocol identity on its own.** +2. **Colour carries evidence state, never protocol identity on its own.** Protocol hues live in their own scale and only ever appear beside a protocol name. Evidence states always carry a word as well as a colour. That separation is why a protocol's colour can never be mistaken for "confirmed" or "failed", even where the hues are close. -2. **Green and red are reserved.** Green means proven. Red means unavailable or +3. **Green and red are reserved.** Green means proven. Red means unavailable or broken. Neither is spent on anything else. A block epoch running behind schedule is amber, because being late is a fact about timing rather than a failure. Not using an optional upgrade is neutral, because it is not an @@ -163,6 +296,26 @@ changed rather than the chain moving. | `frontend/src/styles/_universe-bootstrap-bridge.scss` | the inherited Bootstrap layer, repainted | | `frontend/src/theme-dark.scss`, `theme-contrast.scss` | the alternate themes | | `frontend/src/app/universe/_universe-tokens.scss` | Universe surface mixins, delegating to the tokens above | -| `scripts/universe/check-text.mjs` | the em dash gate | +| `scripts/universe/check-text.mjs` | the em dash gate, over source and over the built output | +| `scripts/universe/check-colors.mjs` | no raw interface colour, including in style and attribute bindings | +| `scripts/universe/check-palettes.mjs` | every dynamic palette, role separation, theme parity, retired values | +| `scripts/universe/check-fills.mjs` | every filled surface that carries text declares its ink | | `scripts/universe/check-branding.mjs` | the upstream mark gate | -| `scripts/universe/visual-qa/` | the visual, accessibility, and cross-browser matrix | +| `scripts/universe/visual-qa/capture.mjs` | the route matrix: themes, widths, and data states | +| `scripts/universe/visual-qa/modes-check.mjs` | forced colours and 200 percent zoom | +| `scripts/universe/visual-qa/fixtures.mjs` | the data every reviewed route renders from | + +## A note on the fixtures + +They are part of the design system, not a test detail. Five defects on this +product were invisible for as long as they existed because the surface that +showed them had no fixture and rendered as a skeleton, an error, or a flat +shape in every screenshot ever taken: a green at 2.37:1 on the mining +dashboard, a red button at 3.21:1 on the address page, the address page itself +throwing on a response of the wrong shape, the release identity page rendering +blank, and a depth chart whose entire palette collapsed into one band because +the numbers were four orders of magnitude too small. + +A route that renders nothing passes every automated check. When a fixture is +missing or wrong, the review it feeds is not weaker; it is absent, and it +reports success. diff --git a/docs/product/EXPERIENCE-BRIEF.md b/docs/product/EXPERIENCE-BRIEF.md index 91646e290c5..5bd8de7a13d 100644 --- a/docs/product/EXPERIENCE-BRIEF.md +++ b/docs/product/EXPERIENCE-BRIEF.md @@ -48,6 +48,29 @@ proves, it does not ship. 4. **Outputs as first-class pages.** Everyone else treats an output as a table row inside a transaction. 5. **Self-hostable and readable.** Full source, AGPL, no tracker, no account. +6. **An appearance nothing else in the category has.** Measured on 28 August + 2026, every explorer whose shell colour can be read from its own markup + declares a dark, cool one. Universe Explorer is light, warm, and hot pink. + That is copyable in principle and expensive in practice, because it means + adopting another company's brand rather than adjusting a hue. The products, + the values, and the method are in + `docs/research/visual-language-benchmark.md`. + +## What makes it unmistakably Universe + +The same anchor, secondary, and shell material as Core, Wallet and Inscribe, +applied to a denser product: `#ff0066` for identity, lavender for the secondary +and for focus, pearl on light and plum-black on dark. + +One rule makes it work at this density. **Pink is identity and intent, never a +fact about Bitcoin.** It marks the mark, the primary action, active navigation, +selection, and live surfaces. It is never a status, a protocol, a fee band, or a +quantity, and CI fails the build if a brand role drifts within 25 dE of a state +or protocol colour. + +That is why the pink can be loud without costing anything. A reader learns in +one screen that pink means "this is Universe, and this is where you act", so it +never competes with the green that means proven or the amber that means a fee. ## Why people come back diff --git a/docs/research/market-lessons.md b/docs/research/market-lessons.md index 9ab64dcfe7b..2851c0b0671 100644 --- a/docs/research/market-lessons.md +++ b/docs/research/market-lessons.md @@ -105,6 +105,68 @@ implies a trade. | An MCP or agent endpoint | Bitquery, Blockscout | Genuinely emerging, but out of scope for this release. The versioned public API is the prerequisite and it exists. | | Multi-chain expansion | Blockchair, OKLink | The registry lists other chains for completeness and the UI separates them. Serving them is a different product. | +## Lessons from the visual language pass, 28 August 2026 + +The August 26 pass answered what the product should do. It did not ask what the +product should look like, and a fork inherits an appearance the way it inherits +code. `visual-language-benchmark.md` holds the method, the measurements, and the +closure evidence for this pass. + +### 6. The whole category ships a dark, cool shell + +Read from each product's own declared `theme-color`: mempool.space `#1d1f31`, +Blockchain.com black, Blockstream dark, and the palette convention in this +category described in secondary sources as low chroma and cool. + +**Built:** a light-first pearl shell with a hot-pink brand, which is a position +no measurable competitor occupies. The favicon inverts to a pearl mark on a pink +tile for the same reason: every other explorer's is dark or white, and the tab +strip is where a return visit is actually won or lost. + +### 7. A saturated brand colour is a real risk to data colour + +This is the reason the position is empty, and it had to be answered rather than +waved away. Pink applied to a status or a fee band would destroy the product's +ability to say "proven" or "expensive" in colour. + +**Built:** pink is confined to identity and intent, and the confinement is +mechanical. `check-palettes.mjs` measures every brand role against every state +and protocol colour and fails the build under 25 dE. Adding that gate found +three collisions that predated it, including ordinals orange sitting 11.1 dE +from the amber that means partial evidence. + +### 8. Users in this category want clarity, not features + +A usability survey reported in CoinLedger's 2026 explorer guide puts 72% of +block explorer users ahead of clear data presentation over additional features. + +**Built:** nothing. That is the point. This pass added no feature. The effort +went into the colour system, the foreground contracts, and the checks, and the +information density of every route is unchanged. + +### 9. Accessibility is an open differentiator here, not a compliance cost + +CHI 2023 research on crypto technologies found every audited exchange site had +critical issues limiting keyboard-only or screen reader use, with poor contrast +throughout navigation. + +**Built:** the AA floor is measured rather than asserted, on the built bundle, +across three themes, seven widths, and six data states. The gate that does it +is the reason the gloss on the primary control is 0.16 and not the 0.24 that +looked better: at 0.24 a white label fell to 4.06:1 on the violet end of the +brand sweep. + +### 10. The incumbent is funded and has ruled out our axis + +mempool.space incorporated in El Salvador in November 2025 and raised roughly +$17M in January 2026, and its stated policy is still not to decode +metaprotocols. + +**Built:** no change of direction, which is the useful conclusion. The +protocol-aware evidence work stays the differentiator, and this pass spent its +effort on the appearance of it rather than on chasing features the incumbent is +now better funded to ship. + ## Closure evidence Discovery ran in four passes with different strategies: category search, diff --git a/docs/research/visual-language-benchmark.md b/docs/research/visual-language-benchmark.md new file mode 100644 index 00000000000..76702edcedf --- /dev/null +++ b/docs/research/visual-language-benchmark.md @@ -0,0 +1,138 @@ +# Visual language benchmark + +**Pass completed 28 August 2026.** The August 26 research pass covered +positioning, features, and user jobs. It did not look at what these products +*look like*, which is the question this pass answers, because the brand decision +it feeds is the one thing a visitor judges before reading a word. + +Evidence classes, as in `source-ledger.md`: **primary** (the product's own +output), **observed** (measured directly from the live product), **secondary** +(reporting or research), **inference** (labelled where used). + +## Method + +Rather than describe screenshots, this pass reads each product's declared shell +colour from its own markup. `` is the value the product +tells the operating system to paint its browser chrome with, so it is the +product's own statement about its ground, not an interpretation of one. + +```bash +curl -sS -L https:// | grep -oiE 'theme-color[^>]*content="[^"]*"' +``` + +## What the market actually looks like + +| Product | Declared shell | Class | +| --- | --- | --- | +| mempool.space | `#1d1f31` dark navy | observed, 28 Aug 2026 | +| Blockchain.com Explorer | `#000000` page background | observed, 28 Aug 2026 | +| Blockstream Explorer | dark, no declared value | observed, 28 Aug 2026 | +| Bitfeed | `#ffffff` | observed, 28 Aug 2026 | +| 3xpl | no declared value | observed, 28 Aug 2026 | +| Ordiscan, UniScan, Bitaps | behind a bot check, not measurable this way | observed, 28 Aug 2026 | + +**Finding.** The reference product for this entire category, and the one this +product is forked from, declares a dark blue-grey shell. Every other explorer +measurable this way is dark or neutral. Not one is warm, and not one is +chromatic. + +Secondary sources agree about the intent behind it: crypto dashboard palettes +are described as running "mostly muted saturation (lower chroma)" with +temperatures "leaning cool" (produkto.io palette analysis, secondary). + +**Inference.** A light, warm, chromatic explorer is not a crowded position. It +is an empty one. That is unusual enough to be worth checking for a reason it is +empty, and the next section is that check. + +## Is the position empty for a good reason? + +Two reasons a serious data product might avoid it, and what the evidence says. + +**"Dense financial data needs a dark ground."** Not supported. The +counter-example is the whole of financial and operational software outside +crypto, and the constraint that actually matters is measured contrast, which is +a property of the pairing rather than of the direction. This product holds every +pairing to WCAG 2.2 AA and measures it in CI, on all three themes. + +**"A saturated brand colour competes with data colour."** Supported, and it is +the real risk. A pink applied to statuses, fee bands, or protocol chips would +destroy the product's ability to say "proven" or "expensive" in colour. The +answer is not a quieter pink. It is a strict rule about what pink is allowed to +mean, enforced by a gate. See `docs/product/DESIGN-SYSTEM.md`. + +## What users say they want + +A usability survey reported by CoinLedger's 2026 explorer guide found **72% of +block explorer users value clear data presentation over additional features** +(secondary). The same reporting describes the common complaint as explorers +presenting large amounts of data without indicating what matters. + +CHI 2023 accessibility research on crypto technologies found that **every +audited exchange site had critical issues limiting keyboard-only or screen +reader use**, with poor labelling and poor colour contrast throughout navigation +(secondary, Bournemouth University / ACM). + +**Inference.** Accessibility is not a compliance cost in this market. It is an +open differentiator, because the incumbents measurably do not have it. + +## What changed at the top of the market + +mempool.space incorporated in El Salvador in November 2025 and raised roughly +$17M led by Fulgur Ventures in January 2026 (secondary, Wikipedia summary of +reporting). Its newest published release remains v3.3.1, April 2025 (primary, +GitHub releases). + +**Inference.** The incumbent is now funded to move faster, and its policy of not +decoding metaprotocols is a stated position rather than a resourcing gap. Both +readings point the same way: compete on the axis it has ruled out, not on the +axis it is about to spend money on. + +## Decisions this pass produced + +| Finding | Decision | +| --- | --- | +| Every measurable competitor ships a dark, cool shell | Light-first stays the primary experience, and the light ground moves from cool grey to pearl so it is warm as well as light. A visitor can tell the products apart from a thumbnail. | +| Saturated brand colour genuinely can corrupt data colour | Pink is confined to identity and intent. `check-palettes.mjs` measures every brand role at 25 dE or more from every state and protocol colour, and the build fails otherwise. | +| 72% of users prefer clarity over features | No feature was added in this pass. The work went into the colour system, the foreground contracts, and the gates. | +| Incumbent accessibility is measurably poor | The AA floor is held on all three themes and measured on the built bundle across 13 routes, 7 widths, and 6 data states, rather than asserted. | +| Every explorer's favicon is dark or white | The icon inverts: a pearl mark on a hot-pink tile. It is findable in a strip of tabs, which is where an explorer actually competes for a return visit. | +| Palettes in this category are low chroma and cool | The gloss, glow, and sweep are the differentiator, so they stay. They are confined to the primary action and singleton surfaces, so the density that makes the product usable is untouched. | + +## Rejected + +| Pattern | Why not | +| --- | --- | +| Recolouring the fee scale into the brand family | A fee band means a fee rate, and green to amber to red is the reading every Bitcoin user already has. Brand colour would overwrite a meaning to gain nothing. | +| Pink success and error states | The one thing that must never be ambiguous. Green and red stay. | +| Glass and blur as the house surface | Two always-on blur layers over a scrolling list is the standard cause of scroll jank, and this product's main view scrolls live data. | +| A downloaded display font | It would be a third-party fetch, or a self-hosted payload on the critical path, to buy an effect that weight and tracking already deliver. | +| Rainbow as a fill | Applied broadly it stops meaning anything. It survives as one rule, once per screen. | +| Following the category into dark-first | It is the position every competitor already holds. | + +## Closure + +Two further passes, using different strategies from the four recorded in +`source-ledger.md`, ran on 28 August 2026: + +| Pass | Strategy | Result | +| --- | --- | --- | +| 5 | Brand and palette direction: crypto explorer brand identity colour palette differentiation dark mode default 2026 | No new competitor, capability class, or trend. Confirmed the low-chroma cool convention. | +| 6 | Accessibility and reading preference: block explorer accessibility WCAG contrast audit crypto dashboard light mode preference | No new competitor or capability class. Produced the CHI accessibility finding above, which confirmed an existing decision rather than changing one. | + +Two consecutive passes with different query sets and different source sets +produced no new material competitor, capability category, user expectation, or +design pattern. That is the closure condition. + +## Sources + +- mempool.space, live markup, 28 August 2026 (observed) +- Blockchain.com Explorer, live markup, 28 August 2026 (observed) +- Blockstream.info, live markup, 28 August 2026 (observed) +- Bitfeed, live markup, 28 August 2026 (observed) +- 3xpl, live markup, 28 August 2026 (observed) +- mempool/mempool GitHub releases, v3.3.1 and earlier (primary) +- Wikipedia, Mempool.space, incorporation and funding summary (secondary) +- CoinLedger, Blockchain Explorers guide 2026, usability survey figure (secondary) +- Bournemouth University / ACM CHI 2023, Exploring the Accessibility of Crypto Technologies (secondary) +- produkto.io crypto palette analysis, chroma and temperature convention (secondary) +- FallingBrick, dark mode design guide 2026, semantic token guidance (secondary) diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index 730da5a816c..d2683182065 100644 --- a/frontend/src/app/app.constants.ts +++ b/frontend/src/app/app.constants.ts @@ -129,6 +129,20 @@ export const poolsColor = { 'unknown': '#FDD835', }; +/** + * The colours a named mining pool's slice can take. + * + * Three values are held back from the inherited ramp. #FDD835 is reserved for + * the unknown pool, so a named pool can never be drawn in the colour that means + * "we could not tell". #D81B60 and #880E4F sit 6.7 and 15.2 dE from the Universe + * brand pink, close enough that the largest pool on the dashboard was drawn in + * the product's own colour. Brand marks identity and intent, never data, so + * those two are excluded here rather than at each call site. + */ +export const poolChartColors = originalChartColors.filter( + (color) => !['#FDD835', '#D81B60', '#880E4F'].includes(color), +); + export const feeLevels = [0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000]; diff --git a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts index 1d41d22688c..0eb5935b116 100644 --- a/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts +++ b/frontend/src/app/components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component.ts @@ -12,7 +12,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Acceleration } from '@interfaces/node-api.interface'; import { ServicesApiServices } from '@app/services/services-api.service'; import { StateService } from '@app/services/state.service'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; @Component({ selector: 'app-acceleration-fees-graph', @@ -312,6 +312,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 2eae5c426cd..faac92c98d7 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '@app/graphs/echarts'; import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; -import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, map, startWith, switchMap, tap } from 'rxjs/operators'; import { AddressTxSummary, ChainStats } from '@interfaces/electrs.interface'; import { ElectrsApiService } from '@app/services/electrs-api.service'; import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; @@ -10,7 +10,7 @@ import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pip import { StateService } from '@app/services/state.service'; import { PriceService } from '@app/services/price.service'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; const periodSeconds = { '1d': (60 * 60 * 24), @@ -109,7 +109,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { return of(null); }), )), - this.stateService.conversions$ + this.stateService.conversions$.pipe(startWith(null)) ]).pipe( switchMap(([redraw, addressSummary, conversions]) => { this.conversions = conversions; @@ -402,6 +402,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: this.adjustedLeft, diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index cfdd80dbfc7..ee685552c36 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -150,11 +150,18 @@

+

Loading transactions

- +
-
+
@@ -181,6 +188,7 @@

+

Loading address

diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts index 7f957dd5598..1a7c2dcd969 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.ts @@ -13,7 +13,7 @@ import { selectPowerOfTen } from '@app/bitcoin.utils'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; import { StateService } from '@app/services/state.service'; import { ActivatedRoute, Router } from '@angular/router'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; @Component({ selector: 'app-block-fee-rates-graph', @@ -324,6 +324,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts index 96f4565e506..8c8524fff22 100644 --- a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts @@ -13,7 +13,7 @@ import { ActivatedRoute } from '@angular/router'; import { FiatShortenerPipe } from '@app/shared/pipes/fiat-shortener.pipe'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe'; import { StateService } from '@app/services/state.service'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; @Component({ selector: 'app-block-fees-graph', @@ -270,6 +270,7 @@ export class BlockFeesGraphComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts index 23d313d102c..77fa79e66c2 100644 --- a/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts +++ b/frontend/src/app/components/block-fees-subsidy-graph/block-fees-subsidy-graph.component.ts @@ -14,7 +14,7 @@ import { StateService } from '@app/services/state.service'; import { MiningService } from '@app/services/mining.service'; import { StorageService } from '@app/services/storage.service'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; -import { chartChrome } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle } from '@app/shared/chart-theme'; @Component({ selector: 'app-block-fees-subsidy-graph', @@ -389,6 +389,7 @@ export class BlockFeesSubsidyGraphComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/block-health-graph/block-health-graph.component.ts b/frontend/src/app/components/block-health-graph/block-health-graph.component.ts index 0d876e8b694..b8d97544986 100644 --- a/frontend/src/app/components/block-health-graph/block-health-graph.component.ts +++ b/frontend/src/app/components/block-health-graph/block-health-graph.component.ts @@ -11,7 +11,7 @@ import { StorageService } from '@app/services/storage.service'; import { ActivatedRoute, Router } from '@angular/router'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; import { StateService } from '@app/services/state.service'; -import { chartChrome } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle } from '@app/shared/chart-theme'; @Component({ selector: 'app-block-health-graph', @@ -213,6 +213,7 @@ export class BlockHealthGraphComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index 541d83dc9ab..e1e17b116a0 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -7,8 +7,16 @@ import { TransactionStripped } from '@interfaces/node-api.interface'; import { TransactionFlags } from '@app/shared/filters.utils'; const hoverTransitionTime = 300; -const defaultHoverColor = hexToColor('1bd8f4'); -const defaultHighlightColor = hexToColor('800080'); +// Pointer and search feedback, not data. +// +// These two are the only colours in the scene that answer the reader rather +// than the chain, so they take the brand pink and the Universe lavender. Both +// sit outside the fee scale, which runs green through amber to orange on the +// default palette and blue through amber on the high-contrast one, so neither +// can be mistaken for a fee band. The cyan and the plain purple they replaced +// belonged to no palette in this product. +const defaultHoverColor = hexToColor('ff0066'); +const defaultHighlightColor = hexToColor('8b5cf6'); // convert from this class's update format to TxSprite's update format function toSpriteUpdate(params: ViewUpdateParams): SpriteUpdateParams { diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts index ca6f2f7f6b9..52e10e054f7 100644 --- a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts @@ -13,7 +13,7 @@ import { ActivatedRoute } from '@angular/router'; import { FiatShortenerPipe } from '@app/shared/pipes/fiat-shortener.pipe'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe'; import { StateService } from '@app/services/state.service'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; @Component({ selector: 'app-block-rewards-graph', @@ -280,6 +280,7 @@ export class BlockRewardsGraphComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts index b7cbde318e5..e345b70f477 100644 --- a/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts +++ b/frontend/src/app/components/block-sizes-weights-graph/block-sizes-weights-graph.component.ts @@ -11,7 +11,7 @@ import { MiningService } from '@app/services/mining.service'; import { ActivatedRoute } from '@angular/router'; import { download, formatterXAxis } from '@app/shared/graphs.utils'; import { StateService } from '@app/services/state.service'; -import { chartChrome } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle } from '@app/shared/chart-theme'; @Component({ selector: 'app-block-sizes-weights-graph', @@ -302,6 +302,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss index 62314d4d604..529cae0fab2 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss @@ -33,7 +33,7 @@ margin-left: 4px; transition: color 0.15s ease-in-out; &:hover { - color: var(--info); + color: var(--u-brand); } } diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.html b/frontend/src/app/components/blocks-list/blocks-list.component.html index 50bc94446be..0b365e6f3a2 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.html +++ b/frontend/src/app/components/blocks-list/blocks-list.component.html @@ -3,7 +3,6 @@

Blocks

-
@@ -89,7 +88,7 @@

Blocks

- + @@ -123,6 +122,9 @@

Blocks

+ + diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.ts b/frontend/src/app/components/blocks-list/blocks-list.component.ts index 46fb80ae64d..95136cacf69 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.ts +++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, Inject, LOCALE_ID } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { BehaviorSubject, combineLatest, Observable, timer, of, Subscription } from 'rxjs'; -import { debounceTime, delayWhen, filter, map, retryWhen, scan, skip, switchMap, tap, throttleTime } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, EMPTY, Observable, throwError, timer, of, Subscription } from 'rxjs'; +import { catchError, debounceTime, filter, map, retry, scan, skip, startWith, switchMap, tap, throttleTime, timeout } from 'rxjs/operators'; import { BlockExtended } from '@interfaces/node-api.interface'; import { ApiService } from '@app/services/api.service'; import { StateService } from '@app/services/state.service'; @@ -10,6 +10,14 @@ import { SeoService } from '@app/services/seo.service'; import { OpenGraphService } from '@app/services/opengraph.service'; import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; +import { LoadState, classifyLoadFailure, isRetryableFailure } from '@app/shared/load-state'; + +/** + * Overall budget for one page of blocks, retries included. The same figure + * toLoadState uses, so a request that hangs here is bounded the way every + * other remote read on the product is. + */ +const BLOCKS_REQUEST_DEADLINE_MS = 20_000; @Component({ selector: 'app-blocks-list', @@ -27,6 +35,8 @@ export class BlocksList implements OnInit { indexingAvailable = false; auditAvailable = false; isLoading = true; + /** Set when the list request has failed for good, so the page says so instead of holding a skeleton. */ + failureState: LoadState | null = null; fromBlockHeight = undefined; lastBlockHeightFetched = -1; paginationMaxSize: number; @@ -115,6 +125,7 @@ export class BlocksList implements OnInit { filter(fromBlockHeight => fromBlockHeight !== this.lastBlockHeightFetched), switchMap((fromBlockHeight) => { this.isLoading = true; + this.failureState = null; this.lastBlockHeightFetched = fromBlockHeight; return this.apiService.getBlocks$(this.page === 1 ? undefined : fromBlockHeight) .pipe( @@ -139,7 +150,28 @@ export class BlocksList implements OnInit { } return blocks; }), - retryWhen(errors => errors.pipe(delayWhen(() => timer(10000)))) + // Bounded, so a chain backend that is down produces an answer + // instead of a skeleton that retries forever with nothing said. + retry({ + count: 2, + delay: (error, attempt) => { + if (!isRetryableFailure(classifyLoadFailure(error))) { + return throwError(() => error); + } + return timer(1000 * attempt); + }, + }), + // Retrying bounds a request that fails. It does nothing for one + // that never answers at all, which is the shape a hung backend + // has: no error to catch, so the skeletons stayed forever. The + // budget covers the retries above, matching toLoadState. + timeout({ first: BLOCKS_REQUEST_DEADLINE_MS }), + catchError((error) => { + this.failureState = { status: 'error', reason: classifyLoadFailure(error), at: Date.now() }; + this.isLoading = false; + this.cd.markForCheck(); + return EMPTY; + }) ); }) ), @@ -151,7 +183,12 @@ export class BlocksList implements OnInit { } this.lastBlockHeight = blocks[0].height; return of(blocks); - }) + }), + // The socket carries live tip updates, not the page itself. Without a + // seed, combineLatest holds the whole table until the socket speaks, + // so a deployment whose REST answers but whose socket is quiet showed + // skeletons indefinitely with no failure to report. + startWith([] as BlockExtended[]), ) ]) .pipe( @@ -187,6 +224,14 @@ export class BlocksList implements OnInit { this.router.navigate([this.relativeUrlPipe.transform('/blocks/'), page]); } + retryLoad(): void { + this.failureState = null; + this.isLoading = true; + this.lastBlockHeightFetched = -1; + this.fromHeightSubject.next(this.fromBlockHeight); + this.cd.markForCheck(); + } + trackByBlock(index: number, block: BlockExtended): number { return block.height; } diff --git a/frontend/src/app/components/change/change.component.html b/frontend/src/app/components/change/change.component.html index ffc00bf5f0e..b99c1d7442b 100644 --- a/frontend/src/app/components/change/change.component.html +++ b/frontend/src/app/components/change/change.component.html @@ -1,3 +1,3 @@ - + ‎{{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}% diff --git a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html index bf480289bec..c3e0baa26f3 100644 --- a/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html +++ b/frontend/src/app/components/difficulty-adjustments-table/difficulty-adjustments-table.component.html @@ -17,7 +17,7 @@ {{ diffChange.difficultyShorten }} - + {{ diffChange.change >= 0 ? '+' : '' }}{{ diffChange.change | amountShortener: 2 }}% diff --git a/frontend/src/app/components/difficulty/difficulty.component.html b/frontend/src/app/components/difficulty/difficulty.component.html index 65dbd7545aa..3f997eff650 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.html +++ b/frontend/src/app/components/difficulty/difficulty.component.html @@ -131,29 +131,37 @@ -
-
-
-
-
-
-
-
-
+ + @if ((liveFeed$ | async)?.status === 'error') { +

No live epoch data: the chain feed is not answering.

+ } @else { +
+
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+ } ; isLoadingWebSocket$: Observable; + /** + * Whether the socket that carries the epoch has given up. Both sources here + * are socket fed and neither can error, so without this the placeholders + * would go on claiming an answer was coming for as long as the tab was open. + */ + liveFeed$: Observable>; difficultyEpoch$: Observable; mode: 'difficulty' | 'halving' = 'difficulty'; @@ -76,6 +83,7 @@ export class DifficultyComponent implements OnInit { ngOnInit(): void { this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; + this.liveFeed$ = this.stateService.liveFeed$; this.difficultyEpoch$ = combineLatest([ this.stateService.blocks$, this.stateService.difficultyAdjustment$, diff --git a/frontend/src/app/components/fees-box/fees-box.component.html b/frontend/src/app/components/fees-box/fees-box.component.html index f969c66449d..3604e6a85a2 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.html +++ b/frontend/src/app/components/fees-box/fees-box.component.html @@ -54,31 +54,39 @@ High Priority
-
-
-
-
-
+ + @if ((liveFeed$ | async)?.status === 'error') { +

No live fee estimates: the chain feed is not answering.

+ } @else { +
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+ } diff --git a/frontend/src/app/components/fees-box/fees-box.component.scss b/frontend/src/app/components/fees-box/fees-box.component.scss index 1c7d31f4464..d02a54a0729 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.scss +++ b/frontend/src/app/components/fees-box/fees-box.component.scss @@ -164,4 +164,18 @@ &.prority { width: 25%; } -} \ No newline at end of file +} + +// What stands in for the numbers when the live feed has given up. It holds the +// height the estimates occupy, so the panels below it do not jump when the +// placeholders are replaced by a sentence. +.feed-unavailable { + align-items: center; + color: var(--u-text-secondary); + display: flex; + font-size: var(--u-text-sm); + justify-content: center; + margin: 0; + min-height: 50px; + text-align: center; +} diff --git a/frontend/src/app/components/fees-box/fees-box.component.ts b/frontend/src/app/components/fees-box/fees-box.component.ts index 728b2be9e98..815aa18fa4a 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.ts +++ b/frontend/src/app/components/fees-box/fees-box.component.ts @@ -5,6 +5,7 @@ import { Recommendedfees } from '@interfaces/websocket.interface'; import { feeLevels } from '@app/app.constants'; import { map, startWith, tap } from 'rxjs/operators'; import { ThemeService } from '@app/services/theme.service'; +import { LoadState } from '@app/shared/load-state'; @Component({ selector: 'app-fees-box', @@ -15,6 +16,12 @@ import { ThemeService } from '@app/services/theme.service'; }) export class FeesBoxComponent implements OnInit, OnDestroy { isLoading$: Observable; + /** + * Whether the socket that carries these numbers has given up. It is the only + * source for them and it never errors, so without this the placeholders here + * would go on claiming an answer was coming for as long as the tab was open. + */ + liveFeed$: Observable>; recommendedFees$: Observable; themeStateSubscription: Subscription; gradient = 'linear-gradient(to right, var(--skeleton-bg), var(--skeleton-bg))'; @@ -28,6 +35,7 @@ export class FeesBoxComponent implements OnInit, OnDestroy { ) { } ngOnInit(): void { + this.liveFeed$ = this.stateService.liveFeed$; this.isLoading$ = combineLatest( this.stateService.isLoadingWebSocket$.pipe(startWith(false)), this.stateService.loadingIndicators$.pipe(startWith({ mempool: 0 })), diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html index b146170a1cd..94eb8c6a8c5 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html @@ -3,7 +3,8 @@
-
+ +
Hashrate (1w)

@@ -30,7 +31,7 @@

Difficulty
-
+
-
-
-
-
+
- -
-
-
Hashrate
-

- -

-
-
-
Difficulty
-

- -

-
-
+ + diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts index 9d17b2a3708..e0069c097c6 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts @@ -1,7 +1,7 @@ -import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnDestroy, OnInit, HostBinding } from '@angular/core'; import { echarts, EChartsOption } from '@app/graphs/echarts'; -import { combineLatest, fromEvent, merge, Observable, of } from 'rxjs'; -import { map, mergeMap, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { BehaviorSubject, Subscription, combineLatest, fromEvent } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { ApiService } from '@app/services/api.service'; import { SeoService } from '@app/services/seo.service'; import { formatNumber } from '@angular/common'; @@ -14,7 +14,16 @@ import { ActivatedRoute } from '@angular/router'; import { StateService } from '@app/services/state.service'; import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; +import { LoadState, trackedLoadState } from '@app/shared/load-state'; + +interface HashrateSummary { + blockCount: number; + currentDifficulty: number; + currentHashrate: number; + /** Number of hashrate samples, so an empty range is not read as a failure. */ + hashrateCount: number; +} @Component({ selector: 'app-hashrate-chart', @@ -31,7 +40,7 @@ import { chartChrome, rampStops } from '@app/shared/chart-theme'; standalone: false, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class HashrateChartComponent implements OnInit { +export class HashrateChartComponent implements OnInit, OnDestroy { @Input() tableOnly = false; @Input() widget = false; @Input() height: number = 300; @@ -48,15 +57,20 @@ export class HashrateChartComponent implements OnInit { @HostBinding('attr.dir') dir = 'ltr'; - hashrateObservable$: Observable; - isLoading = true; + state: LoadState = { status: 'loading' }; formatNumber = formatNumber; timespan = ''; chartInstance: any = undefined; network = ''; + private retry$ = new BehaviorSubject(0); + private subscriptions: Subscription[] = []; + /** The last body rendered, so a resize can redraw without a refetch. */ + private lastBody: any = null; + constructor( @Inject(LOCALE_ID) public locale: string, + private cd: ChangeDetectorRef, private seoService: SeoService, private apiService: ApiService, private formBuilder: UntypedFormBuilder, @@ -83,116 +97,157 @@ export class HashrateChartComponent implements OnInit { this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); - this.route - .fragment - .subscribe((fragment) => { + this.subscriptions.push( + this.route.fragment.subscribe((fragment) => { if (['1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) { this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); } - }); + }), + ); - this.hashrateObservable$ = combineLatest( - merge( - this.radioGroupForm.get('dateSpan').valueChanges - .pipe( - startWith(this.radioGroupForm.controls.dateSpan.value), - switchMap((timespan) => { - if (!this.widget && !firstRun) { - this.storageService.setValue('miningWindowPreference', timespan); - } - this.timespan = timespan; - firstRun = false; - this.miningWindowPreference = timespan; - this.isLoading = true; - return this.apiService.getHistoricalHashrate$(this.timespan); - }) - ), - this.stateService.chainTip$ - .pipe( - switchMap(() => { - return this.apiService.getHistoricalHashrate$(this.timespan); - }) - ) + const dateSpan$ = this.radioGroupForm.get('dateSpan').valueChanges.pipe( + startWith(this.radioGroupForm.controls.dateSpan.value), + distinctUntilChanged(), + map((timespan: string) => { + if (!this.widget && !firstRun) { + this.storageService.setValue('miningWindowPreference', timespan); + } + this.timespan = timespan; + firstRun = false; + this.miningWindowPreference = timespan; + return timespan; + }), + ); + + // The tip and the range each used to start their own request, so a page + // load fired two. One combined trigger fires one, and the debounce + // coalesces the burst of tips around a new block. + const chainTip$ = this.stateService.chainTip$.pipe( + distinctUntilChanged(), + debounceTime(1000), + startWith(0), + ); + + const trigger$ = combineLatest([dateSpan$, chainTip$, this.retry$]).pipe( + map(([timespan, tip, attempt]) => timespan + ':' + tip + ':' + attempt), + ); + + this.subscriptions.push( + trackedLoadState( + trigger$, + (key) => this.apiService.getHistoricalHashrate$(key.split(':')[0]).pipe( + map((response: any) => this.summarise(response)), ), - fromEvent(window, 'resize').pipe(startWith(null)), - ).pipe( - map(([response, _]) => response), - tap((response: any) => { - const data = response.body; - - // always include the latest difficulty - if (data.difficulty.length && data.difficulty[data.difficulty.length - 1].difficulty !== data.currentDifficulty) { - data.difficulty.push({ - timestamp: Date.now() / 1000, - difficulty: data.currentDifficulty - }); - } + { isEmpty: (summary) => summary.hashrateCount === 0 }, + ).subscribe((state) => { + this.state = state; + this.cd.markForCheck(); + }), + ); - // We generate duplicated data point so the tooltip works nicely - const diffFixed = []; - let diffIndex = 1; - let hashIndex = 0; - while (hashIndex < data.hashrates.length) { - if (diffIndex >= data.difficulty.length) { - while (hashIndex < data.hashrates.length) { - diffFixed.push({ - timestamp: data.hashrates[hashIndex].timestamp, - difficulty: data.difficulty.length > 0 ? data.difficulty[data.difficulty.length - 1].difficulty : null - }); - ++hashIndex; - } - diffIndex++; - break; - } + // A resize only changes how the same numbers are drawn, so it redraws from + // the last body instead of asking the backend again. + this.subscriptions.push( + fromEvent(window, 'resize').pipe(debounceTime(200)).subscribe(() => { + if (this.lastBody) { + this.renderChart(this.lastBody); + this.cd.markForCheck(); + } + }), + ); + } - while (hashIndex < data.hashrates.length && diffIndex < data.difficulty.length && - data.hashrates[hashIndex].timestamp <= data.difficulty[diffIndex].time - ) { - diffFixed.push({ - timestamp: data.hashrates[hashIndex].timestamp, - difficulty: data.difficulty[diffIndex - 1].difficulty - }); - ++hashIndex; - } - ++diffIndex; - } + ngOnDestroy(): void { + for (const subscription of this.subscriptions) { + subscription.unsubscribe(); + } + this.subscriptions = []; + } - while (diffIndex <= data.difficulty.length) { - diffFixed.push({ - timestamp: data.difficulty[diffIndex - 1].time, - difficulty: data.difficulty[diffIndex - 1].difficulty - }); - diffIndex++; - } + /** The summary currently on screen, fresh or stale, or null when there is none. */ + get hashrates(): HashrateSummary | null { + return this.state.status === 'data' || this.state.status === 'stale' ? this.state.value : null; + } - const maResolution = 15; - const hashrateMa = []; - for (let i = maResolution - 1; i < data.hashrates.length; ++i) { - let avg = 0; - for (let y = maResolution - 1; y >= 0; --y) { - avg += data.hashrates[i - y].avgHashrate; - } - avg /= maResolution; - hashrateMa.push([data.hashrates[i].timestamp * 1000, avg]); - } + onRetry(): void { + this.retry$.next(this.retry$.value + 1); + } + + /** Draws the response and reduces it to the numbers the header shows. */ + private summarise(response: any): HashrateSummary { + const data = response.body; + this.lastBody = data; + this.renderChart(data); + return { + blockCount: parseInt(response.headers.get('x-total-count'), 10), + currentDifficulty: data.currentDifficulty, + currentHashrate: data.currentHashrate, + hashrateCount: Array.isArray(data.hashrates) ? data.hashrates.length : 0, + }; + } - this.prepareChartOptions({ - hashrates: data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate]), - difficulty: diffFixed.map(val => [val.timestamp * 1000, val.difficulty]), - hashrateMa: hashrateMa, + private renderChart(data: any): void { + // always include the latest difficulty + if (data.difficulty.length && data.difficulty[data.difficulty.length - 1].difficulty !== data.currentDifficulty) { + data.difficulty.push({ + timestamp: Date.now() / 1000, + difficulty: data.currentDifficulty + }); + } + + // We generate duplicated data point so the tooltip works nicely + const diffFixed = []; + let diffIndex = 1; + let hashIndex = 0; + while (hashIndex < data.hashrates.length) { + if (diffIndex >= data.difficulty.length) { + while (hashIndex < data.hashrates.length) { + diffFixed.push({ + timestamp: data.hashrates[hashIndex].timestamp, + difficulty: data.difficulty.length > 0 ? data.difficulty[data.difficulty.length - 1].difficulty : null }); - this.isLoading = false; - }), - map((response) => { - const data = response.body; - return { - blockCount: parseInt(response.headers.get('x-total-count'), 10), - currentDifficulty: data.currentDifficulty, - currentHashrate: data.currentHashrate, - }; - }), - share() - ); + ++hashIndex; + } + diffIndex++; + break; + } + + while (hashIndex < data.hashrates.length && diffIndex < data.difficulty.length && + data.hashrates[hashIndex].timestamp <= data.difficulty[diffIndex].time + ) { + diffFixed.push({ + timestamp: data.hashrates[hashIndex].timestamp, + difficulty: data.difficulty[diffIndex - 1].difficulty + }); + ++hashIndex; + } + ++diffIndex; + } + + while (diffIndex <= data.difficulty.length) { + diffFixed.push({ + timestamp: data.difficulty[diffIndex - 1].time, + difficulty: data.difficulty[diffIndex - 1].difficulty + }); + diffIndex++; + } + + const maResolution = 15; + const hashrateMa = []; + for (let i = maResolution - 1; i < data.hashrates.length; ++i) { + let avg = 0; + for (let y = maResolution - 1; y >= 0; --y) { + avg += data.hashrates[i - y].avgHashrate; + } + avg /= maResolution; + hashrateMa.push([data.hashrates[i].timestamp * 1000, avg]); + } + + this.prepareChartOptions({ + hashrates: data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate]), + difficulty: diffFixed.map(val => [val.timestamp * 1000, val.difficulty]), + hashrateMa: hashrateMa, + }); } prepareChartOptions(data) { @@ -423,6 +478,7 @@ export class HashrateChartComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts index c340236fe2f..3ac6722eebb 100644 --- a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts +++ b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts @@ -5,13 +5,13 @@ import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/op import { ApiService } from '@app/services/api.service'; import { SeoService } from '@app/services/seo.service'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; -import { originalChartColors as chartColors, poolsColor } from '@app/app.constants'; +import { poolChartColors, poolsColor } from '@app/app.constants'; import { StorageService } from '@app/services/storage.service'; import { MiningService } from '@app/services/mining.service'; import { download } from '@app/shared/graphs.utils'; import { ActivatedRoute } from '@angular/router'; import { StateService } from '@app/services/state.service'; -import { chartChrome } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle } from '@app/shared/chart-theme'; interface Hashrate { timestamp: number; @@ -210,7 +210,7 @@ export class HashrateChartPoolsComponent implements OnInit { this.chartOptions = { title: title, animation: false, - color: chartColors.filter(color => color !== '#FDD835'), + color: poolChartColors, grid: { right: this.right, left: this.left, @@ -278,6 +278,7 @@ export class HashrateChartPoolsComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/incoming-transactions-graph/incoming-transactions-graph.component.ts b/frontend/src/app/components/incoming-transactions-graph/incoming-transactions-graph.component.ts index 0f8e8871b81..48b1710a6e9 100644 --- a/frontend/src/app/components/incoming-transactions-graph/incoming-transactions-graph.component.ts +++ b/frontend/src/app/components/incoming-transactions-graph/incoming-transactions-graph.component.ts @@ -6,7 +6,7 @@ import { download, formatterXAxis, formatterXAxisLabel } from '@app/shared/graph import { formatNumber } from '@angular/common'; import { StateService } from '@app/services/state.service'; import { Subscription } from 'rxjs'; -import { chartChrome } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle } from '@app/shared/chart-theme'; const OUTLIERS_MEDIAN_MULTIPLIER = 4; @@ -187,6 +187,7 @@ export class IncomingTransactionsGraphComponent implements OnInit, OnChanges, On showDetail: false, show: (this.template === 'advanced') ? true : false, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, bottom: 0, diff --git a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts index 72e68361d42..5f16fbd6005 100644 --- a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts +++ b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts @@ -5,7 +5,7 @@ import { StateService } from '@app/services/state.service'; import { map, Subscription, switchMap } from 'rxjs'; import { PriceService } from '@app/services/price.service'; import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; -import { chartChrome } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle } from '@app/shared/chart-theme'; @Component({ selector: 'app-lbtc-pegs-graph', @@ -115,6 +115,7 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { }, { show: (this.template === 'advanced') ? true : false, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, selectedDataBackground: { diff --git a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts index 9cc95cfc6b9..85c03fdf830 100644 --- a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts +++ b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts @@ -7,9 +7,9 @@ import { OptimizedMempoolStats } from '@interfaces/node-api.interface'; import { StateService } from '@app/services/state.service'; import { StorageService } from '@app/services/storage.service'; import { EChartsOption } from '@app/graphs/echarts'; -import { feeLevels, chartColors } from '@app/app.constants'; +import { feeLevels } from '@app/app.constants'; import { download, formatterXAxis, formatterXAxisLabel } from '@app/shared/graphs.utils'; -import { chartChrome } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, feeScale } from '@app/shared/chart-theme'; @Component({ selector: 'app-mempool-graph', @@ -50,7 +50,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { feeLimitIndex: number; maxFeeIndex: number; feeLevelsOrdered = []; - chartColorsOrdered = chartColors; + chartColorsOrdered = feeScale(); inverted: boolean; chartInstance: any = undefined; weightMode: boolean = false; @@ -389,6 +389,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { showDetail: false, show: (this.template === 'advanced' && this.showZoom) ? true : false, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, bottom: 0, @@ -493,7 +494,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { } } } - this.chartColorsOrdered = chartColors.slice(0, this.feeLevelsOrdered.length); + this.chartColorsOrdered = feeScale().slice(0, this.feeLevelsOrdered.length); } isMobile() { diff --git a/frontend/src/app/components/menu/menu.component.scss b/frontend/src/app/components/menu/menu.component.scss index 6b71e3ad5af..8aa4a685625 100644 --- a/frontend/src/app/components/menu/menu.component.scss +++ b/frontend/src/app/components/menu/menu.component.scss @@ -79,18 +79,22 @@ .badge-og { background-color: var(--title-fg); + color: var(--u-text-inverse); } .badge-pleb { background-color: var(--u-brand); + color: var(--u-brand-contrast); } .badge-chad { background-color: var(--u-state-partial); + color: var(--u-text-inverse); } .badge-whale { background-color: var(--tertiary); + color: var(--u-text-inverse); } .badge-silver { diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.html b/frontend/src/app/components/pool-ranking/pool-ranking.component.html index 33c1fe84d30..f4fabb67de3 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.html +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.html @@ -3,7 +3,8 @@
-
+ +
Pools Luck
@@ -39,7 +40,7 @@
+ *ngIf="!widget && miningStats as stats">
-
-
-
-
+ - +
@@ -98,7 +98,7 @@ - +
RankEmpty Blocks
{{ pool.rank }} @@ -152,25 +152,7 @@ - -
-
-
Pools Luck
-

- -

-
-
-
Pools Count
-

- -

-
-
-
Blocks (1w)
-

- -

-
-
+ + diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts index 207c9837f10..c51d04900ef 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts @@ -1,18 +1,19 @@ -import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit, HostBinding } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnDestroy, OnInit, HostBinding } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { EChartsOption, PieSeriesOption } from '@app/graphs/echarts'; -import { merge, Observable } from 'rxjs'; -import { map, shareReplay, startWith, switchMap, tap } from 'rxjs/operators'; +import { BehaviorSubject, Subscription, combineLatest } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { SeoService } from '@app/services/seo.service'; import { StorageService } from '@app//services/storage.service'; import { MiningService, MiningStats } from '@app/services/mining.service'; import { StateService } from '@app/services/state.service'; -import { originalChartColors as chartColors, poolsColor } from '@app/app.constants'; +import { poolChartColors, poolsColor } from '@app/app.constants'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; import { download } from '@app/shared/graphs.utils'; import { isMobile } from '@app/shared/common.utils'; import { chartChrome } from '@app/shared/chart-theme'; +import { LoadState, trackedLoadState } from '@app/shared/load-state'; @Component({ selector: 'app-pool-ranking', @@ -21,7 +22,7 @@ import { chartChrome } from '@app/shared/chart-theme'; standalone: false, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PoolRankingComponent implements OnInit { +export class PoolRankingComponent implements OnInit, OnDestroy { @Input() height: number = 300; @Input() widget = false; @@ -30,7 +31,6 @@ export class PoolRankingComponent implements OnInit { auditAvailable = false; indexingAvailable = false; - isLoading = true; chartOptions: EChartsOption = {}; chartInitOptions = { renderer: 'svg', @@ -40,10 +40,14 @@ export class PoolRankingComponent implements OnInit { @HostBinding('attr.dir') dir = 'ltr'; - miningStatsObservable$: Observable; + state: LoadState = { status: 'loading' }; + + private retry$ = new BehaviorSubject(0); + private subscriptions: Subscription[] = []; constructor( public stateService: StateService, + private cd: ChangeDetectorRef, private storageService: StorageService, private formBuilder: UntypedFormBuilder, private miningService: MiningService, @@ -69,48 +73,74 @@ export class PoolRankingComponent implements OnInit { this.stateService.env.MINING_DASHBOARD === true); this.auditAvailable = this.indexingAvailable && this.stateService.env.AUDIT; - this.route - .fragment - .subscribe((fragment) => { + this.subscriptions.push( + this.route.fragment.subscribe((fragment) => { if (['24h', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) { this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); } - }); + }), + ); - this.miningStatsObservable$ = merge( - this.radioGroupForm.get('dateSpan').valueChanges - .pipe( - startWith(this.radioGroupForm.controls.dateSpan.value), // (trigger when the page loads) - tap((value) => { - this.isLoading = true; - this.timespan = value; - if (!this.widget) { - this.storageService.setValue('miningWindowPreference', value); - } - this.miningWindowPreference = value; + const dateSpan$ = this.radioGroupForm.get('dateSpan').valueChanges.pipe( + startWith(this.radioGroupForm.controls.dateSpan.value), + distinctUntilChanged(), + map((value: string) => { + this.timespan = value; + if (!this.widget) { + this.storageService.setValue('miningWindowPreference', value); + } + this.miningWindowPreference = value; + return value; + }), + ); + + // Route setup and the chain tip both used to start their own request, so a + // page load fired two. One combined trigger fires one, and the debounce + // coalesces the burst of tips around a new block. + const chainTip$ = this.stateService.chainTip$.pipe( + distinctUntilChanged(), + debounceTime(1000), + startWith(0), + ); + + const trigger$ = combineLatest([dateSpan$, chainTip$, this.retry$]).pipe( + map(([dateSpan, tip, attempt]) => `${dateSpan}:${tip}:${attempt}`), + ); + + this.subscriptions.push( + trackedLoadState( + trigger$, + (key) => this.miningService.getMiningStats(key.split(':')[0]).pipe( + map((data) => { + data['minersLuck'] = (100 * (data.blockCount / 1008)).toFixed(2); // luck 1w + return data; }), - switchMap(() => { - return this.miningService.getMiningStats(this.miningWindowPreference); - }) ), - this.stateService.chainTip$ - .pipe( - switchMap(() => { - return this.miningService.getMiningStats(this.miningWindowPreference); - }) - ) - ) - .pipe( - map(data => { - data['minersLuck'] = (100 * (data.blockCount / 1008)).toFixed(2); // luck 1w - return data; - }), - tap(data => { - this.isLoading = false; - this.prepareChartOptions(data); - }), - shareReplay(1) - ); + { isEmpty: (data) => !data || !Array.isArray(data.pools) || data.pools.length === 0 }, + ).subscribe((state) => { + this.state = state; + if (state.status === 'data' || state.status === 'stale') { + this.prepareChartOptions(state.value); + } + this.cd.markForCheck(); + }), + ); + } + + ngOnDestroy(): void { + for (const subscription of this.subscriptions) { + subscription.unsubscribe(); + } + this.subscriptions = []; + } + + /** The stats currently on screen, fresh or stale, or null when there are none. */ + get miningStats(): MiningStats | null { + return this.state.status === 'data' || this.state.status === 'stale' ? this.state.value : null; + } + + onRetry(): void { + this.retry$.next(this.retry$.value + 1); } generatePoolsChartSerieData(miningStats) { @@ -226,7 +256,7 @@ export class PoolRankingComponent implements OnInit { this.chartOptions = { animation: false, - color: chartColors.filter(color => color !== '#FDD835'), + color: poolChartColors, tooltip: { trigger: 'item', textStyle: { diff --git a/frontend/src/app/components/pool/pool.component.ts b/frontend/src/app/components/pool/pool.component.ts index 50bbd737cf2..ea2ed8b12a2 100644 --- a/frontend/src/app/components/pool/pool.component.ts +++ b/frontend/src/app/components/pool/pool.component.ts @@ -13,7 +13,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { StratumJob } from '@interfaces/websocket.interface'; import { WebsocketService } from '@app/services/websocket.service'; import { MiningService } from '@app/services/mining.service'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; interface AccelerationTotal { cost: number, @@ -324,6 +324,7 @@ export class PoolComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, bottom: 0, diff --git a/frontend/src/app/components/price-chart/price-chart.component.ts b/frontend/src/app/components/price-chart/price-chart.component.ts index 6db9cb37bd2..3425219e8dc 100644 --- a/frontend/src/app/components/price-chart/price-chart.component.ts +++ b/frontend/src/app/components/price-chart/price-chart.component.ts @@ -13,7 +13,7 @@ import { ActivatedRoute } from '@angular/router'; import { FiatShortenerPipe } from '@app/shared/pipes/fiat-shortener.pipe'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe'; import { StateService } from '@app/services/state.service'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; @Component({ selector: 'app-price-chart', @@ -248,6 +248,7 @@ export class PriceChartComponent implements OnInit { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/components/reward-stats/reward-stats.component.html b/frontend/src/app/components/reward-stats/reward-stats.component.html index c437ac9e2a1..261a15ef991 100644 --- a/frontend/src/app/components/reward-stats/reward-stats.component.html +++ b/frontend/src/app/components/reward-stats/reward-stats.component.html @@ -1,4 +1,6 @@ -
+ + +
- -
-
-
Miners Reward
-
-
-
-
-
-
-
Avg Block Fees
-
-
-
-
-
-
-
Avg Tx Fee
-
-
-
-
-
-
+ + diff --git a/frontend/src/app/components/reward-stats/reward-stats.component.ts b/frontend/src/app/components/reward-stats/reward-stats.component.ts index b935672f243..510003fefdb 100644 --- a/frontend/src/app/components/reward-stats/reward-stats.component.ts +++ b/frontend/src/app/components/reward-stats/reward-stats.component.ts @@ -1,9 +1,24 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { concat, Observable } from 'rxjs'; -import { map, switchMap, tap } from 'rxjs/operators'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { BehaviorSubject, Subscription, combineLatest, merge, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { ApiService } from '@app/services/api.service'; import { StateService } from '@app/services/state.service'; +import { LoadState, trackedLoadState } from '@app/shared/load-state'; +interface RewardSummary { + totalReward: number; + feePerTx: number; + feePerBlock: number; +} + +/** + * Reward numbers for the last 144 blocks. + * + * This widget used to concat the first read with a chain-tip driven refresh and + * render it through one async pipe with an else skeleton. A failing read killed + * the stream, so the skeleton stayed on screen for the rest of the session with + * nothing left to clear it. + */ @Component({ selector: 'app-reward-stats', templateUrl: './reward-stats.component.html', @@ -11,47 +26,64 @@ import { StateService } from '@app/services/state.service'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, }) -export class RewardStatsComponent implements OnInit { - public $rewardStats: Observable; - private lastBlockHeight: number; +export class RewardStatsComponent implements OnInit, OnDestroy { + state: LoadState = { status: 'loading' }; + + private retry$ = new BehaviorSubject(0); + private subscription: Subscription | null = null; - constructor(private apiService: ApiService, private stateService: StateService) { } + constructor( + private apiService: ApiService, + private stateService: StateService, + private cd: ChangeDetectorRef, + ) { } ngOnInit(): void { - this.$rewardStats = concat( - // We fetch the latest reward stats when the page load and - // wait for the API response before listening to websocket blocks - this.apiService.getRewardStats$() - .pipe( - tap((stats) => { - this.lastBlockHeight = stats.endBlock; - }) - ), - // Or when we receive a newer block, newer than the latest reward stats api call - this.stateService.blocks$ - .pipe( - switchMap((blocks) => { - const maxHeight = blocks.reduce((max, block) => Math.max(max, block.height), 0); - if (maxHeight <= this.lastBlockHeight) { - return []; // Return an empty stream so the last pipe is not executed - } - this.lastBlockHeight = maxHeight; - return this.apiService.getRewardStats$(); - }) - ) - ) - .pipe( - map((stats) => { - return { - totalReward: stats.totalReward, - feePerTx: stats.totalFee / stats.totalTx, - feePerBlock: stats.totalFee / 144, - }; - }) - ); + // Chain tips arrive in bursts around a new block. Coalescing them means one + // refresh per burst rather than one request per websocket frame. + const tip$ = this.stateService.blocks$.pipe( + map((blocks) => blocks.reduce((max, block) => Math.max(max, block.height), 0)), + distinctUntilChanged(), + debounceTime(1000), + startWith(0), + ); + + const trigger$ = combineLatest([tip$, this.retry$]).pipe( + map(([height, attempt]) => `${height}:${attempt}`), + ); + + this.subscription = trackedLoadState( + trigger$, + () => this.apiService.getRewardStats$().pipe( + map((stats): RewardSummary => ({ + totalReward: stats.totalReward, + // A window with no transactions is a real answer, not a division to + // let through as NaN. + feePerTx: stats.totalTx > 0 ? stats.totalFee / stats.totalTx : 0, + feePerBlock: stats.totalFee / 144, + })), + ), + { isEmpty: () => false }, + ).subscribe((state) => { + this.state = state; + this.cd.markForCheck(); + }); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.subscription = null; + } + + get rewardStats(): RewardSummary | null { + return this.state.status === 'data' || this.state.status === 'stale' ? this.state.value : null; + } + + onRetry(): void { + this.retry$.next(this.retry$.value + 1); } - isEllipsisActive(e) { + isEllipsisActive(e): boolean { return (e.offsetWidth < e.scrollWidth); } } diff --git a/frontend/src/app/components/search-form/search-form.component.scss b/frontend/src/app/components/search-form/search-form.component.scss index 911fe85182e..ef9f4af5865 100644 --- a/frontend/src/app/components/search-form/search-form.component.scss +++ b/frontend/src/app/components/search-form/search-form.component.scss @@ -71,23 +71,46 @@ } } +// Search is the product's primary action, so it is the one control that gets +// the full house finish: the brand sweep, a top gloss, and a soft brand glow. +// Nothing else on the page is allowed to look like this, which is what makes it +// obvious where to start. +// +// The label rides over both the sweep and the gloss. Both layers are held to +// the body contrast floor by check-palettes.mjs, which is why the gloss stops +// at 0.16 rather than wherever it looked best. .search-submit { align-items: center; - background: var(--u-brand); + background-color: var(--u-brand); + background-image: var(--u-gloss), var(--u-gradient-brand); border: 1px solid var(--u-brand); border-radius: var(--u-radius-md); + box-shadow: var(--u-glow-brand); color: var(--u-brand-contrast); cursor: pointer; display: flex; flex: 0 0 auto; height: 40px; justify-content: center; - transition: background-color var(--u-duration-fast) var(--u-ease-out); + transition: + filter var(--u-duration-fast) var(--u-ease-out), + box-shadow var(--u-duration-fast) var(--u-ease-out); width: 46px; + // Brightness rather than a second gradient: the measured relationship + // between the fill and its label survives a uniform lift, and a swapped + // gradient would have to be measured again. &:hover:not(:disabled) { - background: var(--u-brand-hover); - border-color: var(--u-brand-hover); + box-shadow: var(--u-glow-brand), var(--u-shadow-sm); + filter: brightness(1.06); + } + + &:active:not(:disabled) { + filter: brightness(0.94); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; } &:focus-visible { diff --git a/frontend/src/app/components/search-form/search-results/search-results.component.scss b/frontend/src/app/components/search-form/search-results/search-results.component.scss index 3754e0d4516..8a5132dc53c 100644 --- a/frontend/src/app/components/search-form/search-results/search-results.component.scss +++ b/frontend/src/app/components/search-form/search-results/search-results.component.scss @@ -1,10 +1,18 @@ +// A group label above its results, not a peer of them. +// +// This rule declared font-size twice and the second won, so the label rendered +// at 1rem, the same size as the result underneath it, and the grouping did no +// work: "Transaction" and the transaction it introduced looked like two +// results. It takes the eyebrow register now, which is the one place in this +// design system where small caps and wide tracking are allowed. .card-title { - color: var(--title-fg); - font-size: 10px; - margin-bottom: 4px; - font-size: 1rem; - + color: var(--u-text-muted); + font-size: var(--u-eyebrow-size); + font-weight: var(--u-weight-semibold); + letter-spacing: var(--u-eyebrow-tracking); + margin-bottom: var(--u-space-1); margin-left: 10px; + text-transform: uppercase; } .danger { diff --git a/frontend/src/app/components/statistics/statistics.component.html b/frontend/src/app/components/statistics/statistics.component.html index 0e1069d0e43..f0e9c3d62c9 100644 --- a/frontend/src/app/components/statistics/statistics.component.html +++ b/frontend/src/app/components/statistics/statistics.component.html @@ -13,7 +13,8 @@ @@ -60,7 +61,8 @@
- @@ -85,7 +87,8 @@
- @@ -93,10 +96,13 @@
-
+

{{ boundedHistoryNote }}

+ +
+ [data]="mempoolStats" [isLoading]="false">
@@ -122,9 +128,11 @@
-
+ +
+ [data]="mempoolTransactionsWeightPerSecondData" [outlierCappingEnabled]="outlierCappingEnabled" [isLoading]="false">
diff --git a/frontend/src/app/components/statistics/statistics.component.scss b/frontend/src/app/components/statistics/statistics.component.scss index 0e1d7d1f379..e4dc1fc4e9a 100644 --- a/frontend/src/app/components/statistics/statistics.component.scss +++ b/frontend/src/app/components/statistics/statistics.component.scss @@ -232,3 +232,12 @@ display: block; } } + +// Says how far back the data actually goes when that is less than the range +// asked for. Informational, not a failure, so it sits above the chart rather +// than replacing it. +.bounded-history { + margin: 0 0 var(--u-space-3); + font-size: var(--u-text-sm); + color: var(--u-text-muted); +} diff --git a/frontend/src/app/components/statistics/statistics.component.ts b/frontend/src/app/components/statistics/statistics.component.ts index af486970e37..2584d9139a0 100644 --- a/frontend/src/app/components/statistics/statistics.component.ts +++ b/frontend/src/app/components/statistics/statistics.component.ts @@ -1,8 +1,8 @@ -import { Component, OnInit, LOCALE_ID, Inject, ViewChild, ElementRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, LOCALE_ID, Inject, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { UntypedFormGroup, UntypedFormBuilder } from '@angular/forms'; -import { of, merge} from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; +import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { OptimizedMempoolStats } from '@interfaces/node-api.interface'; import { WebsocketService } from '@app/services/websocket.service'; @@ -11,9 +11,29 @@ import { ApiService } from '@app/services/api.service'; import { StateService } from '@app/services/state.service'; import { SeoService } from '@app/services/seo.service'; import { StorageService } from '@app/services/storage.service'; -import { feeLevels, chartColors } from '@app/app.constants'; +import { feeLevels } from '@app/app.constants'; import { MempoolGraphComponent } from '@components/mempool-graph/mempool-graph.component'; import { IncomingTransactionsGraphComponent } from '@components/incoming-transactions-graph/incoming-transactions-graph.component'; +import { feeScale } from '@app/shared/chart-theme'; +import { LoadState, trackedLoadState } from '@app/shared/load-state'; + +type DateSpan = '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y' | 'all'; + +const DATE_SPANS: DateSpan[] = ['2h', '24h', '1w', '1m', '3m', '6m', '1y', '2y', '3y', '4y', 'all']; + +/** Seconds each range asks for. `all` asks for whatever exists, so it is absent. */ +const RANGE_SECONDS: Partial> = { + '2h': 2 * 3600, + '24h': 24 * 3600, + '1w': 7 * 24 * 3600, + '1m': 30 * 24 * 3600, + '3m': 90 * 24 * 3600, + '6m': 180 * 24 * 3600, + '1y': 365 * 24 * 3600, + '2y': 2 * 365 * 24 * 3600, + '3y': 3 * 365 * 24 * 3600, + '4y': 4 * 365 * 24 * 3600, +}; @Component({ selector: 'app-statistics', @@ -21,15 +41,14 @@ import { IncomingTransactionsGraphComponent } from '@components/incoming-transac styleUrls: ['./statistics.component.scss'], standalone: false, }) -export class StatisticsComponent implements OnInit { +export class StatisticsComponent implements OnInit, OnDestroy { @ViewChild('mempoolgraph') mempoolGraph: MempoolGraphComponent; @ViewChild('incominggraph') incomingGraph: IncomingTransactionsGraphComponent; network = ''; - isLoading = true; feeLevels = feeLevels; - chartColors = chartColors; + chartColors = feeScale(); filterSize = 100000; filterFeeIndex = 0; showCount = false; @@ -38,6 +57,14 @@ export class StatisticsComponent implements OnInit { outlierCappingEnabled = false; mempoolStats: OptimizedMempoolStats[] = []; + /** + * The single source of truth for what this page is showing. It always reaches + * a terminal state, so the chart can never be left behind a spinner that + * nothing will clear. + */ + state$: Observable>; + state: LoadState = { status: 'loading' }; + mempoolVsizeFeesData: any; mempoolUnconfirmedTransactionsData: any; mempoolTransactionsWeightPerSecondData: any; @@ -48,6 +75,11 @@ export class StatisticsComponent implements OnInit { feeLevelDropdownData = []; timespan = ''; titleCount = $localize`Count`; + /** Set when the collected history is shorter than the range asked for. */ + boundedHistoryNote: string | null = null; + + private retry$ = new BehaviorSubject(0); + private subscriptions: Subscription[] = []; constructor( @Inject(LOCALE_ID) private locale: string, @@ -60,12 +92,14 @@ export class StatisticsComponent implements OnInit { private storageService: StorageService, ) { } - ngOnInit() { + ngOnInit(): void { this.inverted = this.storageService.getValue('inverted-graph') === 'true'; this.setFeeLevelDropdownData(); this.seoService.setTitle($localize`:@@5d4f792f048fcaa6df5948575d7cb325c9393383:Graphs`); this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.mempool:See mempool size (in MvB) and transactions per second (in vB/s) visualized over time.`); - this.stateService.networkChanged$.subscribe((network) => this.network = network); + this.subscriptions.push( + this.stateService.networkChanged$.subscribe((network) => this.network = network), + ); this.graphWindowPreference = this.storageService.getValue('graphWindowPreference') ? this.storageService.getValue('graphWindowPreference').trim() : '2h'; this.outlierCappingEnabled = this.storageService.getValue('cap-outliers') === 'true'; @@ -73,76 +107,156 @@ export class StatisticsComponent implements OnInit { dateSpan: this.graphWindowPreference }); - this.route - .fragment - .subscribe((fragment) => { - if (['2h', '24h', '1w', '1m', '3m', '6m', '1y', '2y', '3y', '4y', 'all'].indexOf(fragment) > -1) { - this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); - } else { - this.radioGroupForm.controls.dateSpan.setValue('2h', { emitEvent: false }); - } - }); + this.subscriptions.push( + this.route.fragment.subscribe((fragment) => { + const span = DATE_SPANS.includes(fragment as DateSpan) ? fragment as DateSpan : '2h'; + this.radioGroupForm.controls.dateSpan.setValue(span, { emitEvent: false }); + }), + ); + + // A retry re-runs the current range without changing it, so the same + // switchMap cancels whatever is in flight and starts exactly one request. + const trigger$ = combineLatest([ + this.radioGroupForm.controls.dateSpan.valueChanges.pipe( + startWith(this.radioGroupForm.controls.dateSpan.value), + distinctUntilChanged(), + ), + this.retry$, + ]).pipe( + map(([dateSpan, attempt]) => `${dateSpan}:${attempt}`), + ); - merge( - of(''), - this.radioGroupForm.controls.dateSpan.valueChanges - ) - .pipe( - switchMap(() => { - this.timespan = this.radioGroupForm.controls.dateSpan.value; - this.isLoading = true; - if (this.radioGroupForm.controls.dateSpan.value === '2h') { + this.state$ = trackedLoadState( + trigger$, + (key) => { + const dateSpan = key.split(':')[0] as DateSpan; + this.timespan = dateSpan; + if (dateSpan === '2h') { this.websocketService.want(['blocks', 'live-2h-chart']); - return this.apiService.list2HStatistics$(); - } - this.websocketService.want(['blocks']); - if (this.radioGroupForm.controls.dateSpan.value === '24h') { - return this.apiService.list24HStatistics$(); - } - if (this.radioGroupForm.controls.dateSpan.value === '1w') { - return this.apiService.list1WStatistics$(); - } - if (this.radioGroupForm.controls.dateSpan.value === '1m') { - return this.apiService.list1MStatistics$(); - } - if (this.radioGroupForm.controls.dateSpan.value === '3m') { - return this.apiService.list3MStatistics$(); - } - if (this.radioGroupForm.controls.dateSpan.value === '6m') { - return this.apiService.list6MStatistics$(); - } - if (this.radioGroupForm.controls.dateSpan.value === '1y') { - return this.apiService.list1YStatistics$(); - } - if (this.radioGroupForm.controls.dateSpan.value === '2y') { - return this.apiService.list2YStatistics$(); - } - if (this.radioGroupForm.controls.dateSpan.value === '3y') { - return this.apiService.list3YStatistics$(); - } - if (this.radioGroupForm.controls.dateSpan.value === '4y') { - return this.apiService.list4YStatistics$(); + } else { + this.websocketService.want(['blocks']); } - if (this.radioGroupForm.controls.dateSpan.value === 'all') { - return this.apiService.listAllTimeStatistics$(); + return this.statisticsFor(dateSpan); + }, + ); + + this.subscriptions.push( + this.state$.subscribe((state) => { + this.state = state; + if (state.status === 'data' || state.status === 'stale') { + this.mempoolStats = state.value; + this.handleNewMempoolData(this.mempoolStats.concat([])); + } else { + this.mempoolStats = []; + this.mempoolTransactionsWeightPerSecondData = null; } - }) - ) - .subscribe((mempoolStats: any) => { - this.mempoolStats = mempoolStats; - this.handleNewMempoolData(this.mempoolStats.concat([])); - this.isLoading = false; - }); + this.updateBoundedHistoryNote(); + }), + ); - this.stateService.live2Chart$ - .subscribe((mempoolStats) => { + this.subscriptions.push( + this.stateService.live2Chart$.subscribe((mempoolStats) => { + // A live sample is only meaningful on top of a loaded 2h series. With + // no series it would render a one-point chart over a failed load. + if (this.timespan !== '2h' || !this.mempoolStats.length) { + return; + } this.mempoolStats.unshift(mempoolStats); this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - 1); this.handleNewMempoolData(this.mempoolStats.concat([])); - }); + this.updateBoundedHistoryNote(); + }), + ); + } + + ngOnDestroy(): void { + for (const subscription of this.subscriptions) { + subscription.unsubscribe(); + } + this.subscriptions = []; + } + + /** Asks for one range. Anything unrecognised falls back to the live range. */ + private statisticsFor(dateSpan: DateSpan): Observable { + switch (dateSpan) { + case '24h': return this.apiService.list24HStatistics$(); + case '1w': return this.apiService.list1WStatistics$(); + case '1m': return this.apiService.list1MStatistics$(); + case '3m': return this.apiService.list3MStatistics$(); + case '6m': return this.apiService.list6MStatistics$(); + case '1y': return this.apiService.list1YStatistics$(); + case '2y': return this.apiService.list2YStatistics$(); + case '3y': return this.apiService.list3YStatistics$(); + case '4y': return this.apiService.list4YStatistics$(); + case 'all': return this.apiService.listAllTimeStatistics$(); + case '2h': return this.apiService.list2HStatistics$(); + default: return of([]); + } + } + + /** Re-runs the current range after a failure. */ + onRetry(): void { + this.retry$.next(this.retry$.value + 1); + } + + /** True while the chart has real numbers to draw, fresh or stale. */ + get hasData(): boolean { + return (this.state.status === 'data' || this.state.status === 'stale') && this.mempoolStats.length > 0; + } + + /** + * Recomputes how far back this deployment can actually answer for, when that + * is noticeably less than the range asked for. + * + * Statistics are collected from the moment the writer starts; nothing + * backfills them, because there is no first-party source to backfill from and + * inventing rows would be worse than having none. Drawing two hours of + * samples under a heading that says 1W is not a lie the chart tells on + * purpose, but it is one a reader would take away, so the shortfall is + * stated. + * + * Computed when the series changes rather than read from a getter: as a + * getter it walked the whole series on every change detection pass, on a page + * that takes a live sample every minute. + */ + private updateBoundedHistoryNote(): void { + this.boundedHistoryNote = null; + if (!this.hasData) return; + const requested = RANGE_SECONDS[this.timespan]; + if (!requested) return; + let oldest = Number.POSITIVE_INFINITY; + for (const stats of this.mempoolStats) { + if (stats.added < oldest) oldest = stats.added; + } + if (!Number.isFinite(oldest)) return; + const covered = Math.max(0, Math.floor(Date.now() / 1000) - oldest); + // A tenth short of the range is ordinary sampling slack, not a shortfall. + if (covered >= requested * 0.9) return; + this.boundedHistoryNote = + $localize`:@@statistics.bounded-history:Collection began ${this.describeSpan(covered)}:covered: ago, so this is all the history there is for this range.`; } - handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) { + /** A short, human span for the note above. */ + private describeSpan(seconds: number): string { + const hours = Math.floor(seconds / 3600); + if (hours < 1) { + const minutes = Math.max(1, Math.floor(seconds / 60)); + return minutes === 1 + ? $localize`:@@statistics.span-minute:1 minute` + : $localize`:@@statistics.span-minutes:${minutes}:minutes: minutes`; + } + if (hours < 48) { + return hours === 1 + ? $localize`:@@statistics.span-hour:1 hour` + : $localize`:@@statistics.span-hours:${hours}:hours: hours`; + } + const days = Math.floor(hours / 24); + return days === 1 + ? $localize`:@@statistics.span-day:1 day` + : $localize`:@@statistics.span-days:${days}:days: days`; + } + + handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]): void { mempoolStats.reverse(); const labels = mempoolStats.map(stats => stats.added); @@ -162,21 +276,22 @@ export class StatisticsComponent implements OnInit { }; } - saveGraphPreference() { + saveGraphPreference(): void { this.storageService.setValue('graphWindowPreference', this.radioGroupForm.controls.dateSpan.value); } - invertGraph() { + invertGraph(): void { this.storageService.setValue('inverted-graph', !this.inverted); document.location.reload(); } - setFeeLevelDropdownData() { + setFeeLevelDropdownData(): void { let _feeLevels = feeLevels; - let _chartColors = chartColors; + const scale = feeScale(); + let _chartColors = scale; if (!this.inverted) { _feeLevels = [...feeLevels].reverse(); - _chartColors = [...chartColors].reverse(); + _chartColors = [...scale].reverse(); } _feeLevels.forEach((fee, i) => { let range; @@ -194,19 +309,11 @@ export class StatisticsComponent implements OnInit { range = `${_feeLevels[i]} - ${_feeLevels[nextIndex]}`; } } - if (this.inverted) { - this.feeLevelDropdownData.push({ - fee: fee, - range, - color: _chartColors[i], - }); - } else { - this.feeLevelDropdownData.push({ - fee: fee, - range, - color: _chartColors[i], - }); - } + this.feeLevelDropdownData.push({ + fee: fee, + range, + color: _chartColors[i], + }); }); } @@ -215,7 +322,7 @@ export class StatisticsComponent implements OnInit { this.storageService.setValue('cap-outliers', e.target.checked); } - onSaveChart(name) { + onSaveChart(name): void { if (name === 'mempool') { this.mempoolGraph.onSaveChart(this.timespan); } else if (name === 'incoming') { @@ -223,7 +330,7 @@ export class StatisticsComponent implements OnInit { } } - isMobile() { + isMobile(): boolean { return (window.innerWidth <= 767.98); } } diff --git a/frontend/src/app/components/svg-images/svg-images.component.html b/frontend/src/app/components/svg-images/svg-images.component.html index 15e0e59ffc6..240673695eb 100644 --- a/frontend/src/app/components/svg-images/svg-images.component.html +++ b/frontend/src/app/components/svg-images/svg-images.component.html @@ -164,26 +164,13 @@ + fill="none" stroke="var(--u-brand-accent, currentColor)" stroke-width="5" opacity="0.55"/> UNIVERSE EXPLORER - - - - - - - - - - - - - @@ -283,68 +270,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/app/components/test-transactions/test-transactions.component.html b/frontend/src/app/components/test-transactions/test-transactions.component.html index 181db8b01e3..e8eebf92d30 100644 --- a/frontend/src/app/components/test-transactions/test-transactions.component.html +++ b/frontend/src/app/components/test-transactions/test-transactions.component.html @@ -1,7 +1,6 @@

Test Transactions

-
diff --git a/frontend/src/app/components/tracker/tracker-bar.component.scss b/frontend/src/app/components/tracker/tracker-bar.component.scss index 09bdd154b53..ba263550368 100644 --- a/frontend/src/app/components/tracker/tracker-bar.component.scss +++ b/frontend/src/app/components/tracker/tracker-bar.component.scss @@ -40,6 +40,9 @@ } &:nth-child(4n + 3) { --stage-color: var(--secondary); + // The translucent default composites to about 4.3:1 on this inset + // surface at this small size; solid secondary text stays above 4.5:1. + color: var(--u-text-secondary); } &.done { --stage-color: var(--primary); diff --git a/frontend/src/app/components/tracker/tracker.component.scss b/frontend/src/app/components/tracker/tracker.component.scss index b550e70d485..683acef7d27 100644 --- a/frontend/src/app/components/tracker/tracker.component.scss +++ b/frontend/src/app/components/tracker/tracker.component.scss @@ -163,6 +163,9 @@ height: 100%; padding: 0.5rem 0.25rem; background-color: var(--u-brand); + // A filled brand surface carrying a label, so it declares the ink that reads + // on it rather than inheriting the page ink. + color: var(--u-brand-contrast); } .bottom-panel { diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 7b682b736aa..b048488db25 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -224,6 +224,7 @@

Details

+

Loading transaction


diff --git a/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts index 1ca8ca36c9f..6ca0eedd912 100644 --- a/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts +++ b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts @@ -9,7 +9,7 @@ import { SeriesOption } from 'echarts/types/dist/echarts'; import { WalletStats } from '@app/shared/wallet-stats'; import { originalChartColors as chartColors } from '@app/app.constants'; import { Treasury } from '@interfaces/node-api.interface'; -import { chartChrome } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle } from '@app/shared/chart-theme'; // export const treasuriesPalette = [ @@ -375,6 +375,7 @@ export class TreasuriesGraphComponent implements OnInit, OnChanges, OnDestroy { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: this.adjustedLeft, diff --git a/frontend/src/app/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html index 345f4f0238a..3c47d584f9a 100644 --- a/frontend/src/app/dashboard/dashboard.component.html +++ b/frontend/src/app/dashboard/dashboard.component.html @@ -1,5 +1,16 @@
+ + @if (liveFeed$ | async; as liveFeed) { + @if (liveFeed.status === 'loading') { +

Loading live chain data

+ } + @if (liveFeed.status === 'error') { + + } + }
+  diff --git a/frontend/src/app/fiat/fiat.component.ts b/frontend/src/app/fiat/fiat.component.ts index 32afdaf3854..4222731a43e 100644 --- a/frontend/src/app/fiat/fiat.component.ts +++ b/frontend/src/app/fiat/fiat.component.ts @@ -45,4 +45,37 @@ export class FiatComponent implements OnInit, OnDestroy { this.currencySubscription.unsubscribe(); } + /** + * The rate to convert with, or null when this deployment has no price for + * this currency. + * + * A missing rate used to fall through to zero, so every amount rendered as + * a confident $0.00. A price we do not have is not a price of nothing, and + * showing it as one is the same class of error as reporting a failed query + * as an empty result. + */ + rateFrom(conversions: Record | null | undefined): number | null { + if (!conversions) return null; + const preferred = conversions[this.currency]; + return typeof preferred === 'number' && preferred > -1 ? preferred : null; + } + + /** Same, for a price pinned to the block being viewed. */ + blockRate(): number | null { + const price = this.blockConversion?.price; + if (!price) return null; + const preferred = price[this.currency]; + if (typeof preferred === 'number' && preferred > -1) return preferred; + const usd = price['USD']; + const rate = this.blockConversion.exchangeRates?.['USD' + this.currency]; + if (typeof usd === 'number' && usd > -1 && typeof rate === 'number') { + return usd * rate; + } + return null; + } + + /** Converts a satoshi amount at the given rate. */ + converted(rate: number): number { + return rate * this.value / 100000000; + } } diff --git a/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts b/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts index ffc91891fed..d900bb8ae91 100644 --- a/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts +++ b/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts @@ -12,7 +12,7 @@ import { LightningApiService } from '@app/lightning/lightning-api.service'; import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; import { isMobile } from '@app/shared/common.utils'; import { StateService } from '@app/services/state.service'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; @Component({ selector: 'app-nodes-networks-chart', @@ -399,6 +399,7 @@ export class NodesNetworksChartComponent implements OnInit, OnChanges { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts index 962634f8829..3bbf7728739 100644 --- a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts @@ -12,7 +12,7 @@ import { LightningApiService } from '@app/lightning/lightning-api.service'; import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; import { isMobile } from '@app/shared/common.utils'; import { StateService } from '@app/services/state.service'; -import { chartChrome, rampStops } from '@app/shared/chart-theme'; +import { chartChrome, chartDataZoomStyle, rampStops } from '@app/shared/chart-theme'; @Component({ selector: 'app-lightning-statistics-chart', @@ -312,6 +312,7 @@ export class LightningStatisticsChartComponent implements OnInit, OnChanges { showDetail: false, show: true, type: 'slider', + ...chartDataZoomStyle(), brushSelect: false, realtime: true, left: 20, diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index bb8f97c4dda..85e87c82645 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,11 +1,12 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; -import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; +import { ReplaySubject, BehaviorSubject, Subject, fromEvent, merge, Observable, timer } from 'rxjs'; import { Transaction } from '@interfaces/electrs.interface'; import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, StratumJob, isMempoolState } from '@interfaces/websocket.interface'; import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; -import { filter, map, scan, share, shareReplay } from 'rxjs/operators'; +import { filter, map, scan, share, shareReplay, startWith, switchMap, take, takeUntil } from 'rxjs/operators'; +import { LoadState } from '@app/shared/load-state'; import { StorageService } from '@app/services/storage.service'; import { hasTouchScreen } from '@app/shared/pipes/bytes-pipe/utils'; import { ActiveFilter } from '@app/shared/filters.utils'; @@ -45,6 +46,16 @@ export interface Customization { export type SignaturesMode = 'all' | 'interesting' | 'none' | null; +/** + * How long the socket has to say anything before the live feed is treated as + * having given up. The first payload normally lands within a second or two, + * so five seconds is already generous, and a late arrival still clears the + * verdict. It is also deliberately inside the visual gate's failure budget: + * at ten seconds, whether the gate saw the panel depended on how slowly the + * probes ran, which made the check a machine-speed race. + */ +const LIVE_FEED_DEADLINE_MS = 5_000; + export interface Env { MAINNET_ENABLED: boolean; TESTNET_ENABLED: boolean; @@ -193,6 +204,19 @@ export class StateService { blockTransactions$ = new Subject(); walletTransactions$ = new Subject(); isLoadingWebSocket$ = new ReplaySubject(1); + /** + * Whether live chain data ever arrived. + * + * The panels on the dashboards are fed by the socket and none of them can + * fail: when the chain backend is down the socket connects and then says + * nothing at all, so a component waiting on `blocks$` waits forever and holds + * its placeholder forever with it. This is the one place that decides the + * feed has given up, so the banner that says so and the panels that stop + * pulsing are reading the same answer instead of racing a copy of the same + * timer each. + */ + liveFeed$: Observable>; + private liveFeedRetry$ = new Subject(); isLoadingMempool$ = new BehaviorSubject(true); vbytesPerSecond$ = new ReplaySubject(1); previousRetarget$ = new ReplaySubject(1); @@ -356,6 +380,25 @@ export class StateService { this.blocks$ = this.blocksSubject$.pipe(filter(blocks => blocks != null && blocks.length > 0)); + // A late arrival still clears this, so the deadline costs nothing when the + // feed is merely slow. Shared and reference counted: one timer runs however + // many panels are watching, and it starts again when a dashboard is next + // opened rather than holding a verdict from an earlier visit. + this.liveFeed$ = this.liveFeedRetry$.pipe( + startWith(undefined), + switchMap(() => { + const firstChainData$ = merge(this.blocks$, this.mempoolInfo$).pipe(take(1)); + return merge( + firstChainData$.pipe(map((): LoadState => ({ status: 'data', value: true, at: Date.now() }))), + timer(LIVE_FEED_DEADLINE_MS).pipe( + takeUntil(firstChainData$), + map((): LoadState => ({ status: 'error', reason: 'timeout', at: Date.now() })), + ), + ).pipe(startWith>({ status: 'loading' })); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + const savedTimePreference = this.storageService.getValue('time-preference-ltr'); const rtlLanguage = (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')); // default time direction is right-to-left, unless locale is a RTL language @@ -493,6 +536,14 @@ export class StateService { this.blockScrolling$.next(value); } + /** + * Gives the live feed a fresh deadline. Reconnecting the socket is the + * caller's job: this service does not own the connection. + */ + retryLiveFeed(): void { + this.liveFeedRetry$.next(); + } + isLiquid() { return this.network === 'liquid' || this.network === 'liquidtestnet'; } diff --git a/frontend/src/app/shared/chart-theme.ts b/frontend/src/app/shared/chart-theme.ts index 185a68f2e81..76b9c908200 100644 --- a/frontend/src/app/shared/chart-theme.ts +++ b/frontend/src/app/shared/chart-theme.ts @@ -12,6 +12,8 @@ * chart reads a computed style while drawing, and nothing reads one per frame. */ +import { contrastMempoolFeeColors, defaultMempoolFeeColors } from '@app/app.constants'; + /** Chart chrome: the parts of a chart that are structure rather than data. */ export interface ChartChrome { /** Axis lines and ticks. */ @@ -30,6 +32,10 @@ export interface ChartChrome { surface: string; /** Outline drawn around a mark so it stays separable from its neighbour. */ markBorder: string; + /** The selected window of a zoom slider. */ + zoomFill: string; + /** The unselected track of a zoom slider. */ + zoomTrack: string; /** Categorical series, in order. Verified separable under colour vision * deficiency by `scripts/universe/check-palettes.mjs`. */ series: string[]; @@ -71,20 +77,22 @@ export const CHART_LINE_TYPES: ('solid' | 'dashed' | 'dotted')[] = [ ]; const FALLBACK: ChartChrome = { - axis: '#586475', - label: '#586475', - strongLabel: '#10151f', - grid: '#dde3ec', + axis: '#645a6e', + label: '#645a6e', + strongLabel: '#241a2b', + grid: '#e8e0ee', tooltipBackground: '#ffffff', - tooltipBorder: '#d5dce6', - tooltipText: '#10151f', + tooltipBorder: '#ded5e5', + tooltipText: '#241a2b', surface: '#ffffff', markBorder: '#ffffff', - series: ['#434ba3', '#a3436b', '#241763', '#a38343', '#63172a', '#4393a3', '#634417'], + zoomFill: 'rgba(196, 0, 89, 0.14)', + zoomTrack: '#ece5f0', + series: ['#c40059', '#5b2fa6', '#8a5100', '#4393a3', '#3d0a2a', '#7f9b5a', '#a3006b'], ramps: { - scale: ['#4393a3', '#2f7f9e', '#2f5a9e', '#43349e', '#5b1f7a'], - a: ['#434ba3', '#241763'], - b: ['#a3436b', '#63172a'], + scale: ['#e0568f', '#c40059', '#a3006b', '#7a2f8f', '#5b2fa6'], + a: ['#c40059', '#5b2fa6'], + b: ['#8a5100', '#63172a'], c: ['#4393a3', '#1c4f5c'], }, }; @@ -119,6 +127,8 @@ export function chartChrome(): ChartChrome { tooltipText: read(styles, '--u-text-primary', FALLBACK.tooltipText), surface: read(styles, '--u-surface-raised', FALLBACK.surface), markBorder: read(styles, '--u-surface-raised', FALLBACK.markBorder), + zoomFill: read(styles, '--u-chart-zoom-fill', FALLBACK.zoomFill), + zoomTrack: read(styles, '--u-chart-zoom-track', FALLBACK.zoomTrack), series: FALLBACK.series.map((fallback, i) => read(styles, `--u-chart-${i + 1}`, fallback)), ramps: { scale: FALLBACK.ramps.scale.map((fallback, i) => @@ -174,3 +184,55 @@ export function rampStops( const last = stops.length - 1; return stops.map((color, i) => ({ offset: last === 0 ? 0 : i / last, color: color + alpha })); } + +/** + * The zoom slider every chart shares. + * + * The charting library draws this control from its own defaults: a pale blue + * track and handle that belong to no palette in this product and measure about + * 1.15:1 against the light page, well under the 3:1 a control boundary owes. + * Eighteen charts carried it. They now spread this instead, so the control is + * themed once and moves with the rest of the system. + */ +export function chartDataZoomStyle(): Record { + const chrome = chartChrome(); + return { + backgroundColor: chrome.zoomTrack, + borderColor: chrome.tooltipBorder, + fillerColor: chrome.zoomFill, + dataBackground: { + lineStyle: { color: chrome.axis, opacity: 0.35 }, + areaStyle: { color: chrome.grid, opacity: 0.8 }, + }, + handleStyle: { color: chrome.surface, borderColor: chrome.axis, borderWidth: 1 }, + moveHandleStyle: { color: chrome.axis, opacity: 0.4 }, + emphasis: { handleStyle: { borderColor: chrome.series[0], borderWidth: 2 } }, + textStyle: { color: chrome.label }, + }; +} + +/** + * The fee scale this theme draws with. + * + * A fee rate should be one colour everywhere in this product. Blocks and the + * Lens already read the scale below; the mempool depth chart did not. It used + * the inherited categorical ramp, which is a rainbow, and applying a + * categorical palette to an ordered quantity means the reader cannot tell from + * the colour whether a band is cheap or expensive. It also put a band at + * #D81B60, 6.7 dE from the brand pink, so a fee level wore the product's own + * colour. + * + * The scale is a TypeScript array rather than a custom property, so the theme + * is detected from a variable only the high contrast theme declares. The result + * is cached with the rest of the chrome and dropped on a theme change. + */ +export function feeScale(): string[] { + if (typeof window === 'undefined' || typeof getComputedStyle !== 'function') { + return defaultMempoolFeeColors.map((hex) => '#' + hex); + } + const contrast = getComputedStyle(document.documentElement) + .getPropertyValue('--u-fee-label-ink') + .trim(); + const scale = contrast ? contrastMempoolFeeColors : defaultMempoolFeeColors; + return scale.map((hex) => '#' + hex); +} diff --git a/frontend/src/app/shared/components/load-status/load-status.component.html b/frontend/src/app/shared/components/load-status/load-status.component.html new file mode 100644 index 00000000000..c6e3f9fdd7b --- /dev/null +++ b/frontend/src/app/shared/components/load-status/load-status.component.html @@ -0,0 +1,33 @@ +
+ + + +

Loading {{ label }}

+

Loading

+
+ + +

Nothing to show yet

+

This range has no recorded data. Collection continues.

+
+ + +

{{ label }}

+

{{ message }}

+
+ + System status +
+
+ +
+ +
+ Showing the last known values. + {{ message }} + + Observed + + + +
diff --git a/frontend/src/app/shared/components/load-status/load-status.component.scss b/frontend/src/app/shared/components/load-status/load-status.component.scss new file mode 100644 index 00000000000..75899feeea2 --- /dev/null +++ b/frontend/src/app/shared/components/load-status/load-status.component.scss @@ -0,0 +1,73 @@ +.load-status { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--u-space-3); + padding: var(--u-space-6) var(--u-space-4); + text-align: center; +} + +// The spinner this replaces was drawn in white, which on the light shell was +// invisible: a page that had failed looked identical to a page that was blank. +// Both the track and the head come from theme tokens, so it stays visible in +// every theme. +.load-status-spinner { + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 3px solid var(--u-border-strong); + border-top-color: var(--u-brand); + animation: load-status-spin 900ms linear infinite; +} + +@keyframes load-status-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .load-status-spinner { + animation-duration: 3s; + } +} + +.load-status-title { + margin: 0; + font-weight: var(--u-weight-semibold); + color: var(--u-text-primary); +} + +.load-status-text { + margin: 0; + font-size: var(--u-text-sm); + color: var(--u-text-secondary); +} + +.load-status-actions { + display: flex; + flex-wrap: wrap; + gap: var(--u-space-2); + justify-content: center; +} + +// A stale panel keeps its numbers on screen, so this is a band above them +// rather than a replacement for them. +.load-status-stale { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--u-space-2); + padding: var(--u-space-2) var(--u-space-3); + margin-bottom: var(--u-space-3); + border: 1px solid var(--u-state-partial-border); + border-radius: var(--u-radius-md); + background: var(--u-state-partial-surface); + color: var(--u-state-partial); + font-size: var(--u-text-sm); + + .load-status-text { + color: var(--u-state-partial); + } +} diff --git a/frontend/src/app/shared/components/load-status/load-status.component.ts b/frontend/src/app/shared/components/load-status/load-status.component.ts new file mode 100644 index 00000000000..16a727d9081 --- /dev/null +++ b/frontend/src/app/shared/components/load-status/load-status.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { LoadFailure, LoadState, loadFailureMessage } from '@app/shared/load-state'; + +/** + * The panel a module shows when it has no fresh data to show. + * + * Every non-data outcome lands here, so a failed module says which outcome it + * is and offers a way forward, rather than leaving a spinner or a skeleton that + * cannot be told apart from a frozen page. + */ +@Component({ + selector: 'app-load-status', + templateUrl: './load-status.component.html', + styleUrls: ['./load-status.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class LoadStatusComponent { + /** The state to describe. Nothing is rendered for a plain data state. */ + @Input() state: LoadState | null = null; + /** Names the module in the message, for example "Hashrate". */ + @Input() label = ''; + /** Height reserved while loading, so the panel does not shift the layout. */ + @Input() minHeight = ''; + @Output() retry = new EventEmitter(); + + get isLoading(): boolean { + return this.state?.status === 'loading'; + } + + get isEmpty(): boolean { + return this.state?.status === 'empty'; + } + + get isStale(): boolean { + return this.state?.status === 'stale'; + } + + get isError(): boolean { + return this.state?.status === 'error'; + } + + get reason(): LoadFailure | null { + const state = this.state; + return state && (state.status === 'error' || state.status === 'stale') ? state.reason : null; + } + + get message(): string { + const reason = this.reason; + return reason ? loadFailureMessage(reason) : ''; + } + + /** A route this deployment does not serve will not start serving it on retry. */ + get canRetry(): boolean { + const reason = this.reason; + return reason !== null && reason !== 'missing'; + } + + get observedAt(): number | null { + const state = this.state; + return state && state.status === 'stale' ? state.at : null; + } + + onRetry(): void { + this.retry.emit(); + } +} diff --git a/frontend/src/app/shared/load-state.spec.ts b/frontend/src/app/shared/load-state.spec.ts new file mode 100644 index 00000000000..534d3313bbc --- /dev/null +++ b/frontend/src/app/shared/load-state.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'vitest'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Subject, firstValueFrom, of, throwError, timer } from 'rxjs'; +import { map, toArray } from 'rxjs/operators'; +import { + classifyLoadFailure, + isRetryableFailure, + toLoadState, + trackedLoadState, + type LoadState, +} from './load-state'; + +/** + * The failure this suite exists to prevent: a request that never resolves, + * leaving a spinner or a skeleton on screen with nothing subscribed to clear + * it. Every case below asserts that a terminal state is reached. + */ + +function statuses(states: LoadState[]): string[] { + return states.map((state) => state.status); +} + +async function collect(source: ReturnType>): Promise[]> { + return firstValueFrom(source.pipe(toArray())); +} + +const noRetry = { retries: 0 }; + +describe('classifyLoadFailure', () => { + it('calls a 404 missing, because this deployment does not serve the route', () => { + expect(classifyLoadFailure(new HttpErrorResponse({ status: 404 }))).toBe('missing'); + }); + + it('calls a 500 unavailable, because the dependency behind it is down', () => { + expect(classifyLoadFailure(new HttpErrorResponse({ status: 500 }))).toBe('unavailable'); + }); + + it('calls status zero a network failure', () => { + expect(classifyLoadFailure(new HttpErrorResponse({ status: 0 }))).toBe('network'); + }); + + it('calls a 403 missing, so it is never retried', () => { + expect(classifyLoadFailure(new HttpErrorResponse({ status: 403 }))).toBe('missing'); + }); +}); + +describe('isRetryableFailure', () => { + it('retries what a retry could fix', () => { + expect(isRetryableFailure('timeout')).toBe(true); + expect(isRetryableFailure('unavailable')).toBe(true); + expect(isRetryableFailure('network')).toBe(true); + }); + + it('does not retry a route this deployment does not serve', () => { + expect(isRetryableFailure('missing')).toBe(false); + expect(isRetryableFailure('malformed')).toBe(false); + }); +}); + +describe('toLoadState', () => { + it('reaches data when the request succeeds', async () => { + const states = await collect(toLoadState(of([1, 2, 3]), noRetry)); + expect(statuses(states)).toEqual(['loading', 'data']); + expect(states[1]).toMatchObject({ status: 'data', value: [1, 2, 3] }); + }); + + it('separates a valid empty answer from a failure', async () => { + const states = await collect(toLoadState(of([]), noRetry)); + expect(statuses(states)).toEqual(['loading', 'empty']); + }); + + it('reaches error on a 404 rather than staying loading', async () => { + const states = await collect( + toLoadState(throwError(() => new HttpErrorResponse({ status: 404 })), noRetry), + ); + expect(statuses(states)).toEqual(['loading', 'error']); + expect(states[1]).toMatchObject({ status: 'error', reason: 'missing' }); + }); + + it('reaches error on a 500', async () => { + const states = await collect( + toLoadState(throwError(() => new HttpErrorResponse({ status: 500 })), noRetry), + ); + expect(states[1]).toMatchObject({ status: 'error', reason: 'unavailable' }); + }); + + it('reaches error on a network failure', async () => { + const states = await collect( + toLoadState(throwError(() => new HttpErrorResponse({ status: 0 })), noRetry), + ); + expect(states[1]).toMatchObject({ status: 'error', reason: 'network' }); + }); + + it('gives up on a request that hangs, instead of loading forever', async () => { + const never = new Subject(); + const states = await collect(toLoadState(never, { timeoutMs: 40, ...noRetry })); + expect(statuses(states)).toEqual(['loading', 'error']); + expect(states[1]).toMatchObject({ reason: 'timeout' }); + }); + + it('treats a null body as malformed rather than as data', async () => { + const states = await collect(toLoadState(of(null as unknown as number[]), noRetry)); + expect(states[1]).toMatchObject({ status: 'error', reason: 'malformed' }); + }); + + it('keeps a previous good answer on screen, labelled stale, when a later read fails', async () => { + const states = await collect( + toLoadState( + throwError(() => new HttpErrorResponse({ status: 500 })), + noRetry, + { value: [7], at: 1000 }, + ), + ); + expect(statuses(states)).toEqual(['loading', 'stale']); + expect(states[1]).toMatchObject({ status: 'stale', value: [7], at: 1000, reason: 'unavailable' }); + }); + + it('retries a retryable failure and reports the recovered answer', async () => { + let attempts = 0; + const flaky = timer(0).pipe( + map(() => { + attempts += 1; + if (attempts < 3) throw new HttpErrorResponse({ status: 503 }); + return [attempts]; + }), + ); + const states = await collect(toLoadState(flaky, { retries: 3, retryDelayMs: 1 })); + expect(statuses(states)).toEqual(['loading', 'data']); + expect(attempts).toBe(3); + }); + + it('does not retry a 404, however many retries are allowed', async () => { + let attempts = 0; + const missing = timer(0).pipe( + map(() => { + attempts += 1; + throw new HttpErrorResponse({ status: 404 }); + }), + ); + const states = await collect(toLoadState(missing, { retries: 5, retryDelayMs: 1 })); + expect(statuses(states)).toEqual(['loading', 'error']); + expect(attempts).toBe(1); + }); + + it('honours a custom emptiness test', async () => { + const states = await collect( + toLoadState(of({ rows: [] }), { ...noRetry, isEmpty: (value) => value.rows.length === 0 }), + ); + expect(statuses(states)).toEqual(['loading', 'empty']); + }); +}); + +describe('trackedLoadState', () => { + it('emits a loading state for each new key', async () => { + const trigger = new Subject(); + const seen: LoadState[] = []; + const subscription = trackedLoadState(trigger, (key) => of([key.length]), noRetry).subscribe((state) => + seen.push(state), + ); + trigger.next('2h'); + trigger.next('24h'); + subscription.unsubscribe(); + expect(statuses(seen)).toEqual(['loading', 'data', 'loading', 'data']); + }); + + it('does not carry a previous range answer into a different range', async () => { + const trigger = new Subject(); + const seen: LoadState[] = []; + const subscription = trackedLoadState( + trigger, + (key) => (key === '2h' ? of([1]) : throwError(() => new HttpErrorResponse({ status: 500 }))), + noRetry, + ).subscribe((state) => seen.push(state)); + trigger.next('2h'); + trigger.next('24h'); + subscription.unsubscribe(); + // The 24h failure must not show 2h numbers under a stale label. + expect(statuses(seen)).toEqual(['loading', 'data', 'loading', 'error']); + }); + + it('stops emitting once unsubscribed, so a destroyed component cannot be updated', async () => { + const trigger = new Subject(); + const seen: LoadState[] = []; + const subscription = trackedLoadState(trigger, () => of([1]), noRetry).subscribe((state) => seen.push(state)); + trigger.next('2h'); + subscription.unsubscribe(); + trigger.next('24h'); + expect(seen.length).toBe(2); + }); +}); diff --git a/frontend/src/app/shared/load-state.ts b/frontend/src/app/shared/load-state.ts new file mode 100644 index 00000000000..b0207f9928c --- /dev/null +++ b/frontend/src/app/shared/load-state.ts @@ -0,0 +1,174 @@ +import { Observable, of, timer, throwError } from 'rxjs'; +import { catchError, map, retry, startWith, switchMap, timeout } from 'rxjs/operators'; +import { HttpErrorResponse } from '@angular/common/http'; + +/** + * A bounded lifecycle for one remote read. + * + * The pages this replaces subscribed with a single next handler and no error + * handler, so a failing request left a spinner or a skeleton on screen with + * nothing left subscribed to ever clear it. Every path here ends in a terminal + * state, and the states keep empty, stale, unavailable and failed apart instead + * of collapsing them into one blank panel. + */ + +export type LoadFailure = + /** The request did not finish inside its budget. */ + | 'timeout' + /** The route is not served by this deployment. */ + | 'missing' + /** The dependency behind the route is down. */ + | 'unavailable' + /** The answer did not have the shape this page can render. */ + | 'malformed' + /** The request never reached the server. */ + | 'network'; + +export type LoadState = + | { readonly status: 'loading' } + | { readonly status: 'data'; readonly value: T; readonly at: number } + | { readonly status: 'empty'; readonly at: number } + /** A previous good answer, kept on screen and labelled, after a later failure. */ + | { readonly status: 'stale'; readonly value: T; readonly at: number; readonly reason: LoadFailure } + | { readonly status: 'error'; readonly reason: LoadFailure; readonly at: number }; + +export interface LoadOptions { + /** Overall budget for one attempt, including retries. Defaults to 20 seconds. */ + readonly timeoutMs?: number; + /** Answers that are valid but have nothing to show. Defaults to an empty array. */ + readonly isEmpty?: (value: T) => boolean; + /** How many times a retryable failure is retried. Defaults to two. */ + readonly retries?: number; + /** Base backoff before the first retry, in milliseconds. Defaults to 500. */ + readonly retryDelayMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 20_000; +const DEFAULT_RETRIES = 2; +const DEFAULT_RETRY_DELAY_MS = 500; +const MAXIMUM_RETRY_DELAY_MS = 8_000; + +function defaultIsEmpty(value: unknown): boolean { + return Array.isArray(value) && value.length === 0; +} + +/** Classifies a thrown value into the failure kinds the templates render. */ +export function classifyLoadFailure(error: unknown): LoadFailure { + if (error instanceof HttpErrorResponse) { + if (error.status === 404) return 'missing'; + if (error.status === 0) return 'network'; + if (error.status >= 500) return 'unavailable'; + if (error.status >= 400) return 'missing'; + } + const name = (error as { name?: string })?.name; + if (name === 'TimeoutError') return 'timeout'; + if (name === 'HttpErrorResponse') return 'unavailable'; + return 'malformed'; +} + +/** + * Retrying a 404 or a 4xx cannot help: the route is simply not served here. + * Retrying a timeout or a 5xx can, so those get a small bounded backoff. + */ +export function isRetryableFailure(reason: LoadFailure): boolean { + return reason === 'timeout' || reason === 'unavailable' || reason === 'network'; +} + +/** + * Wraps one request so it always resolves to a terminal state. + * + * `previous` carries the last good answer for this key. When a later attempt + * fails, that answer is shown again as `stale` with the reason, rather than + * being replaced by a blank panel. + */ +export function toLoadState( + request$: Observable, + options: LoadOptions = {}, + previous: { value: T; at: number } | null = null, +): Observable> { + const isEmpty = options.isEmpty ?? defaultIsEmpty; + const maxRetries = options.retries ?? DEFAULT_RETRIES; + const baseDelay = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + + return request$.pipe( + retry({ + count: maxRetries, + delay: (error, attempt) => { + if (!isRetryableFailure(classifyLoadFailure(error))) { + return throwError(() => error); + } + // Exponential backoff with jitter, so a shared outage does not turn + // every open tab into a synchronised retry storm. + const ceiling = Math.min(baseDelay * Math.pow(2, attempt - 1), MAXIMUM_RETRY_DELAY_MS); + return timer(Math.round(ceiling * (0.5 + Math.random() * 0.5))); + }, + }), + // The budget covers the retries too, so a dependency that hangs cannot hold + // the page past the deadline however many attempts are left. + timeout({ first: options.timeoutMs ?? DEFAULT_TIMEOUT_MS }), + map((value): LoadState => { + const at = Date.now(); + if (value === null || value === undefined) { + return { status: 'error', reason: 'malformed', at }; + } + return isEmpty(value) ? { status: 'empty', at } : { status: 'data', value, at }; + }), + catchError((error) => { + const reason = classifyLoadFailure(error); + const at = Date.now(); + return of>( + previous + ? { status: 'stale', value: previous.value, at: previous.at, reason } + : { status: 'error', reason, at }, + ); + }), + startWith>({ status: 'loading' }), + ); +} + +/** + * Runs `build` for every trigger, keeping the last good answer so a later + * failure degrades to `stale` instead of to a blank panel. Switching means an + * obsolete request is cancelled the moment a new one starts. + */ +export function trackedLoadState( + trigger$: Observable, + build: (key: K) => Observable, + options: LoadOptions = {}, +): Observable> { + let previous: { value: T; at: number } | null = null; + let previousKey: K | undefined; + return trigger$.pipe( + switchMap((key) => { + // A different range is different data; last known good does not carry over. + if (previousKey !== undefined && previousKey !== key) { + previous = null; + } + previousKey = key; + return toLoadState(build(key), options, previous).pipe( + map((state) => { + if (state.status === 'data') { + previous = { value: state.value, at: state.at }; + } + return state; + }), + ); + }), + ); +} + +/** Human-readable reason, for the message a failed panel shows. */ +export function loadFailureMessage(reason: LoadFailure): string { + switch (reason) { + case 'missing': + return $localize`:@@load.failure.missing:This deployment does not serve this data yet.`; + case 'unavailable': + return $localize`:@@load.failure.unavailable:The service behind this panel is unavailable.`; + case 'timeout': + return $localize`:@@load.failure.timeout:This took too long to answer.`; + case 'network': + return $localize`:@@load.failure.network:The request could not reach the server.`; + default: + return $localize`:@@load.failure.malformed:The answer could not be read.`; + } +} diff --git a/frontend/src/app/shared/pipes/http-error-pipe/http-error.pipe.spec.ts b/frontend/src/app/shared/pipes/http-error-pipe/http-error.pipe.spec.ts new file mode 100644 index 00000000000..03ecc2ea9fa --- /dev/null +++ b/frontend/src/app/shared/pipes/http-error-pipe/http-error.pipe.spec.ts @@ -0,0 +1,58 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { describe, expect, it } from 'vitest'; + +import { HttpErrorPipe } from './http-error.pipe'; + +/** + * The case that produced these tests: the address page rendered + * "(undefined undefined: )" whenever the failure was not an HTTP response, + * because the pipe read `.status` off whatever it was given. + */ +describe('HttpErrorPipe', () => { + const pipe = new HttpErrorPipe(); + + it('says nothing when there is no error', () => { + expect(pipe.transform(null)).toBe(''); + expect(pipe.transform(undefined)).toBe(''); + }); + + it('reports the status and the reason', () => { + const error = new HttpErrorResponse({ status: 404, statusText: 'Not Found' }); + expect(pipe.transform(error)).toBe('404 Not Found'); + }); + + it('appends a string body as the detail', () => { + const error = new HttpErrorResponse({ + status: 413, + statusText: 'Payload Too Large', + error: 'too many transactions', + }); + expect(pipe.transform(error)).toBe('413 Payload Too Large: too many transactions'); + }); + + it('appends a structured body as the detail', () => { + const error = new HttpErrorResponse({ + status: 500, + statusText: 'Server Error', + error: { error: 'the index is rebuilding' }, + }); + expect(pipe.transform(error)).toBe('500 Server Error: the index is rebuilding'); + }); + + it('names a status 0 rather than printing the zero', () => { + const error = new HttpErrorResponse({ status: 0, statusText: 'Unknown Error' }); + expect(pipe.transform(error)).toBe('The request did not reach the server'); + }); + + it('never prints an undefined status for a plain error', () => { + const result = pipe.transform(new Error('timed out')); + expect(result).toBe('timed out'); + expect(result).not.toContain('undefined'); + }); + + it('says nothing rather than inventing a status', () => { + const result = pipe.transform({} as Error); + expect(result).toBe(''); + expect(result).not.toContain('undefined'); + }); +}); diff --git a/frontend/src/app/shared/pipes/http-error-pipe/http-error.pipe.ts b/frontend/src/app/shared/pipes/http-error-pipe/http-error.pipe.ts index 19aa840e17b..c26850c57f8 100644 --- a/frontend/src/app/shared/pipes/http-error-pipe/http-error.pipe.ts +++ b/frontend/src/app/shared/pipes/http-error-pipe/http-error.pipe.ts @@ -1,13 +1,49 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Pipe, PipeTransform } from '@angular/core'; +/** + * The parenthetical detail under a failed panel's headline. + * + * It used to read `${e.status} ${e.statusText}: ${errorMsg}` against whatever + * it was handed. Two things went wrong with that. The null guard came after the + * property access, so it could never run. And anything that is not an + * `HttpErrorResponse` has no `status` and no `statusText`, so a timeout, an + * aborted request, or a thrown `Error` printed the string + * "(undefined undefined: )" to the reader. That is worse than printing nothing: + * it looks like the product is broken rather than the request. + * + * So this now answers with a detail a person can act on, or with nothing at + * all. It never invents a status it was not given. + */ @Pipe({ name: 'httpErrorMsg', standalone: false, }) export class HttpErrorPipe implements PipeTransform { - transform(e: HttpErrorResponse | null): string { - const errorMsg = typeof e.error === 'string' ? e.error : e.error?.error ?? ''; - return e ? `${e.status} ${e.statusText}: ${errorMsg}` : ''; + transform(e: HttpErrorResponse | Error | null | undefined): string { + if (!e) { + return ''; + } + + const status = (e as HttpErrorResponse).status; + const statusText = ((e as HttpErrorResponse).statusText || '').trim(); + const body = (e as HttpErrorResponse).error; + const detail = (typeof body === 'string' ? body : body?.error ?? '').trim(); + + // status 0 is the browser refusing to tell us why, which is a network + // failure, a blocked request, or a cancelled one. Reporting "0" helps + // nobody, so it is named instead. + if (status === 0) { + return $localize`:@@http-error.unreachable:The request did not reach the server`; + } + + if (typeof status === 'number' && status > 0) { + const head = statusText ? `${status} ${statusText}` : `${status}`; + return detail ? `${head}: ${detail}` : head; + } + + // Not an HTTP failure. Whatever message it carries is better than a + // fabricated status, and an empty string is better than both. + return detail || (e as Error).message || ''; } } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 7fcf7e5b3ed..95fb521f94c 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -94,6 +94,7 @@ import { LoadingIndicatorComponent } from '@components/loading-indicator/loading import { IndexingProgressComponent } from '@components/indexing-progress/indexing-progress.component'; import { SvgImagesComponent } from '@components/svg-images/svg-images.component'; import { ChangeComponent } from '@components/change/change.component'; +import { LoadStatusComponent } from '@app/shared/components/load-status/load-status.component'; import { SatsComponent } from '@app/shared/components/sats/sats.component'; import { BtcComponent } from '@app/shared/components/btc/btc.component'; import { FeeRateComponent } from '@app/shared/components/fee-rate/fee-rate.component'; @@ -217,6 +218,7 @@ import { GithubLogin } from '@components/github-login.component/github-login.com DataCyDirective, RewardStatsComponent, LoadingIndicatorComponent, + LoadStatusComponent, IndexingProgressComponent, SvgImagesComponent, ChangeComponent, @@ -370,6 +372,7 @@ import { GithubLogin } from '@components/github-login.component/github-login.com DataCyDirective, RewardStatsComponent, LoadingIndicatorComponent, + LoadStatusComponent, IndexingProgressComponent, SvgImagesComponent, ChangeComponent, diff --git a/frontend/src/app/universe/protocol-directory/protocol-directory.component.html b/frontend/src/app/universe/protocol-directory/protocol-directory.component.html index be40f817ad2..81deb9024b7 100644 --- a/frontend/src/app/universe/protocol-directory/protocol-directory.component.html +++ b/frontend/src/app/universe/protocol-directory/protocol-directory.component.html @@ -5,12 +5,13 @@

Protocols

- {{ vm.liveCount }} of {{ vm.totalCount }} Bitcoin protocols are readable in this deployment today. + {{ vm.liveCount }} of {{ vm.totalCount }} Bitcoin protocols can be read right now.

@@ -22,29 +23,36 @@

{{ group.label }}

{{ protocol.displayName }}
+ {{ releaseStatusLabel(protocol) }} + class="state-chip state-chip-primary" + [ngClass]="availabilityClass(availability(protocol, vm.sourcesByAuthority))" + >{{ availabilityLabel(availability(protocol, vm.sourcesByAuthority)) }} + {{ capabilityLabel(protocol) }} {{ coverageLabel(protocol) }} - - {{ sourceLabel(source) }} -
+

+ {{ detail }} + + Checked  + +

The registry contains no Bitcoin mainnet protocols.

-

- Live authority status is currently unavailable. The registry below still applies. +

+ Authority status could not be read, so nothing below can be called readable right now. The registry still describes what each protocol implements. +

diff --git a/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss b/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss index 236a2cb675f..92413ae5651 100644 --- a/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss +++ b/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss @@ -8,12 +8,12 @@ } .chain-note { - color: var(--transparent-fg); + color: var(--u-text-muted); font-size: 0.9rem; } .registry-version { - color: var(--transparent-fg); + color: var(--u-text-muted); font-size: 0.85rem; } @@ -31,7 +31,7 @@ p { margin: 0; - color: var(--transparent-fg); + color: var(--u-text-muted); } } @@ -54,10 +54,9 @@ } .protocol-row { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: 23rem minmax(0, 1fr) max-content; align-items: center; - justify-content: space-between; gap: 0.5rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid var(--box-bg); @@ -81,16 +80,37 @@ align-items: center; gap: 0.35rem 0.5rem; min-width: 0; + justify-self: start; } } - @media (max-width: 575px) { + @media (max-width: 899px) { .protocol-row { - align-items: flex-start; - flex-direction: column; + grid-template-columns: minmax(0, 1fr) max-content; .protocol-state { - width: 100%; + grid-column: 1 / -1; + grid-row: 2; + } + + .protocol-evidence { + grid-column: 2; + grid-row: 1; + margin: 0; + justify-self: end; + } + } + } + + @media (max-width: 575px) { + .protocol-row { + grid-template-columns: minmax(0, 1fr); + + .protocol-evidence { + grid-column: 1; + grid-row: 3; + margin-top: 0.25rem; + justify-self: start; } } } @@ -125,15 +145,42 @@ &.chip-disabled { border-style: dashed; border-color: var(--transparent-fg); - color: var(--transparent-fg); + color: var(--u-text-muted); } &.chip-unknown { border-style: dashed; - color: var(--transparent-fg); + color: var(--u-text-muted); + } + + // Reachable and answering, but too far back to describe the present. It + // must not read as either working or broken. + &.chip-partial { + border-color: var(--u-state-partial-border); + background-color: var(--u-state-partial-surface); + color: var(--u-state-partial); + } + + // Availability is the fact a reader needs first, so it carries more weight + // than the capability and coverage chips beside it. + &.state-chip-primary { + font-weight: 700; + border-width: 2px; } } + // The evidence behind the primary label: where the authority reached, how + // far behind that is, and when it was last asked. + .protocol-evidence { + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: var(--u-text-muted); + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; + justify-self: end; + } + .registry-summary { margin-bottom: 1rem; font-size: 0.95rem; @@ -142,7 +189,7 @@ .no-protocols { margin: 2rem 0; text-align: center; - color: var(--transparent-fg); + color: var(--u-text-muted); } .loading-skeleton { diff --git a/frontend/src/app/universe/protocol-directory/protocol-directory.component.spec.ts b/frontend/src/app/universe/protocol-directory/protocol-directory.component.spec.ts index af8586a1513..7cd21d7e130 100644 --- a/frontend/src/app/universe/protocol-directory/protocol-directory.component.spec.ts +++ b/frontend/src/app/universe/protocol-directory/protocol-directory.component.spec.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import { firstValueFrom, of, throwError } from 'rxjs'; -import { ProtocolDirectoryComponent } from '@app/universe/protocol-directory/protocol-directory.component'; +import { describe, expect, it, vi } from 'vitest'; +import { NEVER, firstValueFrom, of, throwError } from 'rxjs'; +import { ProtocolDirectoryComponent, type DirectoryViewModel } from '@app/universe/protocol-directory/protocol-directory.component'; import { UniverseApiService } from '@app/universe/universe-api.service'; import { SeoService } from '@app/services/seo.service'; import { @@ -63,41 +63,71 @@ function registry(protocols: ExplorerProtocolDefinition[]): ProtocolsResponse { return { registryVersion: '1.0.0', primaryStrip: [], protocols }; } -describe('ProtocolDirectoryComponent release status', () => { +describe('ProtocolDirectoryComponent availability', () => { const subject = component(registry([])); + const readable = protocol({ releaseStatus: 'VERIFIED READ ONLY' }); + const sourcesWith = (entry: SourceEntry) => new Map([['index-ordinals', entry]]); - it('names the statuses the registry actually emits', () => { - expect(subject.releaseStatusLabel(protocol({ releaseStatus: 'VERIFIED READ ONLY' }))) - .toBe('Live, read only'); - expect(subject.releaseStatusLabel(protocol({ releaseStatus: 'PRODUCTION VERIFIED' }))) - .toBe('Live'); - expect(subject.releaseStatusLabel(protocol({ releaseStatus: 'BLOCKED' }))) - .toBe('Not yet available'); - expect(subject.releaseStatusLabel(protocol({ releaseStatus: 'INTENTIONALLY DISABLED' }))) - .toBe('Disabled'); + /** + * The Protocols page shipped "Live, read only" beside "Authority + * unreachable" for the same protocol, with equal weight, and counted it + * among the protocols readable that day. The registry says what a protocol + * implements; only the authority says what it can answer for now. + */ + it('does not call a protocol readable when its authority cannot be reached', () => { + const unreachable = source({ ready: false, status: 'unreachable', checkpoint: null }); + expect(subject.availability(readable, sourcesWith(unreachable))).toBe('unreachable'); + expect(subject.availabilityLabel('unreachable')).toBe('Unavailable'); + expect(subject.isLive(readable, sourcesWith(unreachable))).toBe(false); + }); + + it('does not call a protocol readable when its authority is still catching up', () => { + const stale = source({ status: 'stale', lagBlocks: '653560' }); + expect(subject.availability(readable, sourcesWith(stale))).toBe('catching-up'); + expect(subject.availabilityLabel('catching-up')).toBe('Catching up'); + expect(subject.isLive(readable, sourcesWith(stale))).toBe(false); + }); + + it('calls a protocol readable only when its authority is ready with a checkpoint', () => { + expect(subject.availability(readable, sourcesWith(source()))).toBe('available'); + expect(subject.availabilityLabel('available')).toBe('Readable now'); + expect(subject.isLive(readable, sourcesWith(source()))).toBe(true); + }); + + it('treats a ready authority with no checkpoint as degraded, not as readable', () => { + const noCheckpoint = source({ checkpoint: null }); + expect(subject.availability(readable, sourcesWith(noCheckpoint))).toBe('degraded'); + expect(subject.isLive(readable, sourcesWith(noCheckpoint))).toBe(false); + }); + + it('separates an authority nobody configured from one that is broken', () => { + expect(subject.availability(readable, new Map())).toBe('unconfigured'); + expect(subject.availabilityLabel('unconfigured')).toBe('Not served here'); + expect(subject.availabilityClass('unconfigured')).toBe('chip-unknown'); + expect(subject.availabilityClass('unreachable')).toBe('chip-blocked'); }); - it('gives a verified protocol its own chip rather than the neutral one', () => { - expect(subject.releaseStatusClass(protocol({ releaseStatus: 'VERIFIED READ ONLY' }))) - .toBe('chip-live'); - expect(subject.releaseStatusClass(protocol({ releaseStatus: 'PRODUCTION VERIFIED' }))) - .toBe('chip-production'); - expect(subject.releaseStatusClass(protocol({ releaseStatus: 'BLOCKED' }))) - .toBe('chip-blocked'); + it('lets the registry decide for a protocol that is not implemented at all', () => { + expect(subject.availability(protocol({ releaseStatus: 'BLOCKED' }), sourcesWith(source()))) + .toBe('not-implemented'); + expect(subject.availability(protocol({ releaseStatus: 'INTENTIONALLY DISABLED' }), null)) + .toBe('disabled'); }); - it('falls back readably for a status it has not seen', () => { - const unseen = protocol({ releaseStatus: 'PENDING_REVIEW' as never }); - expect(subject.releaseStatusLabel(unseen)).toBe('Pending review'); - expect(subject.releaseStatusClass(unseen)).toBe('chip-unknown'); - expect(subject.releaseStatusLabel(protocol({ releaseStatus: '' as never }))) - .toBe('Status unknown'); + it('claims nothing when the authority snapshot could not be read', () => { + expect(subject.availability(readable, null)).toBe('unknown'); + expect(subject.isLive(readable, null)).toBe(false); }); - it('counts a protocol as live only when it is verified', () => { - expect(subject.isLive(protocol({ releaseStatus: 'VERIFIED READ ONLY' }))).toBe(true); - expect(subject.isLive(protocol({ releaseStatus: 'PRODUCTION VERIFIED' }))).toBe(true); - expect(subject.isLive(protocol({ releaseStatus: 'BLOCKED' }))).toBe(false); + it('keeps the registry capability as a qualifier rather than a headline', () => { + expect(subject.capabilityLabel(protocol({ releaseStatus: 'VERIFIED READ ONLY' }))) + .toBe('Read only'); + expect(subject.capabilityLabel(protocol({ releaseStatus: 'PRODUCTION VERIFIED' }))) + .toBe('Read and verify'); + expect(subject.capabilityLabel(protocol({ releaseStatus: 'BLOCKED' }))) + .toBe('Not implemented'); + expect(subject.capabilityLabel(protocol({ releaseStatus: 'PENDING_REVIEW' as never }))) + .toBe('Pending review'); }); }); @@ -150,16 +180,24 @@ describe('ProtocolDirectoryComponent view model', () => { expect(vm.otherChainCount).toBe(2); }); - it('counts how many protocols are readable today', async () => { + it('counts only the protocols whose authority can actually answer', async () => { const subject = component( registry([ - protocol({ id: 'ordinals', releaseStatus: 'VERIFIED READ ONLY' }), - protocol({ id: 'runes', family: 'RUNES', releaseStatus: 'VERIFIED READ ONLY' }), - protocol({ id: 'brc20', family: 'OTHER', releaseStatus: 'BLOCKED' }), + protocol({ id: 'ordinals', releaseStatus: 'VERIFIED READ ONLY', indexerAuthority: 'ord' }), + protocol({ id: 'runes', family: 'RUNES', releaseStatus: 'VERIFIED READ ONLY', indexerAuthority: 'index-runes' }), + protocol({ id: 'brc20', family: 'OTHER', releaseStatus: 'BLOCKED', indexerAuthority: 'index-brc20' }), ]), + { + generatedAt: 'now', + sources: [ + source({ authorityId: 'ord' }), + // Implemented and configured, but rebuilding its index. + source({ authorityId: 'index-runes', status: 'stale', lagBlocks: '653560' }), + ], + }, ); const vm = await firstValueFrom(subject.vm$); - expect(vm.liveCount).toBe(2); + expect(vm.liveCount).toBe(1); expect(vm.totalCount).toBe(3); }); @@ -171,6 +209,27 @@ describe('ProtocolDirectoryComponent view model', () => { expect(vm.sourcesByAuthority).toBeNull(); }); + it('reaches the error state when the registry never answers, rather than waiting', async () => { + // The visual gate caught this: the loading fixture hangs every request, and + // the page sat on its skeleton with nothing left to clear it. + vi.useFakeTimers(); + try { + const api = { + getProtocols$: () => NEVER, + getSources$: () => NEVER, + } as unknown as UniverseApiService; + const subject = new ProtocolDirectoryComponent(api, seo); + subject.ngOnInit(); + const seen: DirectoryViewModel[] = []; + const subscription = subject.vm$.subscribe((vm) => seen.push(vm)); + await vi.advanceTimersByTimeAsync(25_000); + subscription.unsubscribe(); + expect(seen.at(-1)?.error).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('reports an error when the registry itself fails', async () => { const api = { getProtocols$: () => throwError(() => new Error('registry down')), @@ -183,7 +242,7 @@ describe('ProtocolDirectoryComponent view model', () => { }); }); -describe('ProtocolDirectoryComponent authority status', () => { +describe('ProtocolDirectoryComponent authority evidence', () => { const subject = component(registry([])); it('matches a protocol to its authority snapshot', () => { @@ -194,22 +253,27 @@ describe('ProtocolDirectoryComponent authority status', () => { expect(subject.sourceFor(protocol(), null)).toBeNull(); }); - it('states the checkpoint a ready authority is at', () => { - expect(subject.sourceLabel(source())).toBe('Authority ready at block 964103'); - expect(subject.sourceLabel(source({ checkpoint: null }))).toBe('Authority ready'); - expect(subject.sourceClass(source())).toBe('chip-live'); + it('shows where the authority reached, so the label is not taken on trust', () => { + expect(subject.sourceDetail(source())).toContain('Indexed to block 964103'); + }); + + it('says how far behind an authority is when it is behind', () => { + const detail = subject.sourceDetail(source({ status: 'stale', lagBlocks: '653560' })); + expect(detail).toContain('653560'); }); - it('distinguishes a missing configuration from a broken authority', () => { - const unconfigured = source({ ready: false, status: 'unconfigured', checkpoint: null }); - expect(subject.sourceLabel(unconfigured)).toBe('Authority not configured here'); - expect(subject.sourceClass(unconfigured)).toBe('chip-unknown'); + it('does not report a lag of zero as though it were news', () => { + expect(subject.sourceDetail(source({ lagBlocks: '0' }))).not.toContain('behind'); + }); - const unreachable = source({ ready: false, status: 'unreachable', checkpoint: null }); - expect(subject.sourceLabel(unreachable)).toBe('Authority unreachable'); - expect(subject.sourceClass(unreachable)).toBe('chip-blocked'); + it('surfaces a flapping authority through its failure count', () => { + expect(subject.sourceDetail(source({ consecutiveFailures: 4 }))).toContain('4'); + }); - const stale = source({ ready: false, status: 'stale', checkpoint: null }); - expect(subject.sourceLabel(stale)).toBe('Authority behind the chain tip'); + it('reads the last check time as epoch seconds, or nothing at all', () => { + expect(subject.checkedAtSeconds(source({ checkedAt: '2026-08-28T10:00:00.000Z' }))) + .toBe(1787911200); + expect(subject.checkedAtSeconds(source({ checkedAt: 'not a date' }))).toBeNull(); + expect(subject.checkedAtSeconds(null)).toBeNull(); }); }); diff --git a/frontend/src/app/universe/protocol-directory/protocol-directory.component.ts b/frontend/src/app/universe/protocol-directory/protocol-directory.component.ts index 8a6b97aee8c..52f873a8deb 100644 --- a/frontend/src/app/universe/protocol-directory/protocol-directory.component.ts +++ b/frontend/src/app/universe/protocol-directory/protocol-directory.component.ts @@ -1,5 +1,5 @@ import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core'; -import { Observable, catchError, combineLatest, map, of } from 'rxjs'; +import { BehaviorSubject, Observable, catchError, combineLatest, map, of, switchMap, timeout } from 'rxjs'; import { UniverseApiService } from '@app/universe/universe-api.service'; import { ExplorerProtocolDefinition, @@ -10,13 +10,24 @@ import { } from '@app/universe/universe.types'; import { SeoService } from '@app/services/seo.service'; +/** What a protocol can answer for right now, as opposed to what it implements. */ +export type ProtocolAvailability = + | 'available' + | 'catching-up' + | 'degraded' + | 'unreachable' + | 'unconfigured' + | 'not-implemented' + | 'disabled' + | 'unknown'; + interface FamilyGroup { family: string; label: string; protocols: ExplorerProtocolDefinition[]; } -interface DirectoryViewModel { +export interface DirectoryViewModel { loading: boolean; error: boolean; registryVersion?: string; @@ -28,6 +39,9 @@ interface DirectoryViewModel { sourcesByAuthority?: Map | null; } +/** How long the page waits for the registry before saying it could not load. */ +const REQUEST_TIMEOUT_MS = 20_000; + // display order of protocol families; unknown families sort after the known // ones and before OTHER const FAMILY_ORDER = ['ORDINALS', 'RUNES', 'ALKANES', 'STAMPS', 'ATOMICALS', 'OP DATA', 'OTHER']; @@ -42,6 +56,7 @@ const FAMILY_ORDER = ['ORDINALS', 'RUNES', 'ALKANES', 'STAMPS', 'ATOMICALS', 'OP export class ProtocolDirectoryComponent implements OnInit { vm$: Observable; skeletonRows = new Array(6); + private retry$ = new BehaviorSubject(0); constructor( private universeApiService: UniverseApiService, @@ -52,22 +67,32 @@ export class ProtocolDirectoryComponent implements OnInit { this.seoService.setTitle('Universe Protocols'); // The registry is the page; the live source snapshot only annotates it, so // a failing snapshot must not blank out the directory. - const sources$ = this.universeApiService.getSources$().pipe( - catchError(() => of(null)), - ); - this.vm$ = combineLatest([this.universeApiService.getProtocols$(), sources$]).pipe( + this.vm$ = this.retry$.pipe( + switchMap(() => combineLatest([ + // A request that hangs is the failure this page had left: the registry + // never arrived, nothing errored, and the skeleton stayed on screen + // with nothing subscribed to clear it. The budget turns that into the + // error state below, which says so and offers a retry. + this.universeApiService.getProtocols$().pipe(timeout({ first: REQUEST_TIMEOUT_MS })), + this.universeApiService.getSources$().pipe( + timeout({ first: REQUEST_TIMEOUT_MS }), + catchError(() => of(null)), + ), + ])), + ).pipe( map(([response, sources]: [ProtocolsResponse, SourcesResponse | null]): DirectoryViewModel => { const bitcoinProtocols = (response.protocols || []).filter(p => p.chain === 'bitcoin'); const otherChainCount = (response.protocols || []).length - bitcoinProtocols.length; + const sourcesByAuthority = this.indexSources(sources); return { loading: false, error: false, registryVersion: response.registryVersion, groups: this.groupByFamily(bitcoinProtocols), otherChainCount, - liveCount: bitcoinProtocols.filter(p => this.isLive(p)).length, + liveCount: bitcoinProtocols.filter(p => this.isLive(p, sourcesByAuthority)).length, totalCount: bitcoinProtocols.length, - sourcesByAuthority: this.indexSources(sources), + sourcesByAuthority, }; }), catchError(() => of({ loading: false, error: true })), @@ -117,33 +142,88 @@ export class ProtocolDirectoryComponent implements OnInit { return (protocol.releaseStatus || '').toLowerCase().replace(/[_-]+/g, ' ').trim(); } - /** True when this protocol is readable in the explorer today. */ - isLive(protocol: ExplorerProtocolDefinition): boolean { + /** + * What this protocol can answer for right now. + * + * The registry says what a protocol is implemented to do; the source + * snapshot says whether the authority behind it can answer at all. Those + * were rendered as two chips of equal weight, so a protocol whose authority + * was unreachable still led with "Live, read only" and read as working. + * Availability decides the primary label, and the registry capability is + * carried underneath it. + */ + availability( + protocol: ExplorerProtocolDefinition, + sources: Map | null, + ): ProtocolAvailability { const status = this.normalizeStatus(protocol); - return status === 'verified read only' || status === 'production verified'; + if (status === 'blocked') return 'not-implemented'; + if (status === 'intentionally disabled') return 'disabled'; + if (status !== 'verified read only' && status !== 'production verified') { + return 'unknown'; + } + // The registry says this protocol is readable, so the authority decides. + if (!sources) return 'unknown'; + const source = this.sourceFor(protocol, sources); + if (!source) return 'unconfigured'; + switch (source.status) { + case 'ready': return source.checkpoint ? 'available' : 'degraded'; + case 'stale': return 'catching-up'; + case 'unreachable': return 'unreachable'; + case 'unconfigured': return 'unconfigured'; + default: return 'degraded'; + } } - releaseStatusLabel(protocol: ExplorerProtocolDefinition): string { - switch (this.normalizeStatus(protocol)) { - case 'production verified': return $localize`:@@universe.protocols.status-production:Live`; - case 'verified read only': return $localize`:@@universe.protocols.status-read-only:Live, read only`; - case 'blocked': return $localize`:@@universe.protocols.status-blocked:Not yet available`; - case 'intentionally disabled': return $localize`:@@universe.protocols.status-disabled:Disabled`; - case '': return $localize`:@@universe.protocols.status-unknown:Status unknown`; - default: return this.humanize(this.normalizeStatus(protocol)); + /** True only when the authority behind this protocol can answer right now. */ + isLive( + protocol: ExplorerProtocolDefinition, + sources: Map | null, + ): boolean { + return this.availability(protocol, sources) === 'available'; + } + + availabilityLabel(availability: ProtocolAvailability): string { + switch (availability) { + case 'available': return $localize`:@@universe.protocols.availability-available:Readable now`; + case 'catching-up': return $localize`:@@universe.protocols.availability-catching-up:Catching up`; + case 'degraded': return $localize`:@@universe.protocols.availability-degraded:Unavailable`; + case 'unreachable': return $localize`:@@universe.protocols.availability-unreachable:Unavailable`; + case 'unconfigured': return $localize`:@@universe.protocols.availability-unconfigured:Not served here`; + case 'not-implemented': return $localize`:@@universe.protocols.availability-not-implemented:Not yet available`; + case 'disabled': return $localize`:@@universe.protocols.availability-disabled:Disabled`; + default: return $localize`:@@universe.protocols.availability-unknown:Status unknown`; } } - releaseStatusClass(protocol: ExplorerProtocolDefinition): string { - switch (this.normalizeStatus(protocol)) { - case 'production verified': return 'chip-production'; - case 'verified read only': return 'chip-live'; - case 'blocked': return 'chip-blocked'; - case 'intentionally disabled': return 'chip-disabled'; + availabilityClass(availability: ProtocolAvailability): string { + switch (availability) { + case 'available': return 'chip-live'; + case 'catching-up': return 'chip-partial'; + case 'degraded': + case 'unreachable': return 'chip-blocked'; + case 'not-implemented': + case 'unconfigured': return 'chip-unknown'; + case 'disabled': return 'chip-disabled'; default: return 'chip-unknown'; } } + /** + * The registry capability, phrased as what the protocol would do once its + * authority can answer. It is never the primary label. + */ + capabilityLabel(protocol: ExplorerProtocolDefinition): string { + switch (this.normalizeStatus(protocol)) { + case 'production verified': return $localize`:@@universe.protocols.capability-production:Read and verify`; + case 'verified read only': return $localize`:@@universe.protocols.capability-read-only:Read only`; + case 'blocked': return $localize`:@@universe.protocols.capability-blocked:Not implemented`; + case 'intentionally disabled': return $localize`:@@universe.protocols.capability-disabled:Disabled`; + case '': return $localize`:@@universe.protocols.capability-unknown:Capability unknown`; + default: return this.humanize(this.normalizeStatus(protocol)); + } + } + coverageLabel(protocol: ExplorerProtocolDefinition): string { const coverage = protocol.coverage; if (coverage === null || coverage === undefined || coverage === '') { @@ -177,25 +257,37 @@ export class ProtocolDirectoryComponent implements OnInit { return sources.get(protocol.indexerAuthority) ?? null; } - /** Live authority state, phrased as what the snapshot actually proves. */ - sourceLabel(source: SourceEntry): string { - if (source.ready && source.checkpoint) { - return $localize`:@@universe.protocols.source-ready:Authority ready at block ${source.checkpoint.heightAtomic}:height:`; + /** + * The evidence behind the primary label: where the authority has reached, + * how far behind that is, and when it was last checked. Without this a + * reader has to take the label on trust. + */ + sourceDetail(source: SourceEntry | null): string | null { + if (!source) return null; + const parts: string[] = []; + if (source.checkpoint) { + parts.push($localize`:@@universe.protocols.detail-height:Indexed to block ${source.checkpoint.heightAtomic}:height:`); } - switch (source.status) { - case 'ready': return $localize`:@@universe.protocols.source-ready-nocheckpoint:Authority ready`; - case 'stale': return $localize`:@@universe.protocols.source-stale:Authority behind the chain tip`; - case 'unreachable': return $localize`:@@universe.protocols.source-unreachable:Authority unreachable`; - case 'unconfigured': return $localize`:@@universe.protocols.source-unconfigured:Authority not configured here`; - default: return $localize`:@@universe.protocols.source-degraded:Authority degraded`; + const lag = source.lagBlocks; + if (lag && lag !== '0') { + parts.push($localize`:@@universe.protocols.detail-lag:${lag}:lag: blocks behind the chain tip`); + } + if (source.consecutiveFailures) { + parts.push($localize`:@@universe.protocols.detail-failures:${source.consecutiveFailures}:failures: checks failed in a row`); } + return parts.length ? parts.join(' \u00b7 ') : null; } - sourceClass(source: SourceEntry): string { - if (source.ready) { - return 'chip-live'; - } - return source.status === 'unconfigured' ? 'chip-unknown' : 'chip-blocked'; + /** When this authority was last asked, as epoch seconds, or null. */ + checkedAtSeconds(source: SourceEntry | null): number | null { + if (!source?.checkedAt) return null; + const at = Date.parse(source.checkedAt); + return Number.isFinite(at) ? Math.floor(at / 1000) : null; + } + + /** Re-reads the registry and the authority snapshot. */ + onRetry(): void { + this.retry$.next(this.retry$.value + 1); } trackByProtocol(index: number, protocol: ExplorerProtocolDefinition): string { diff --git a/frontend/src/app/universe/universe.types.ts b/frontend/src/app/universe/universe.types.ts index 0d8a5022416..2e5ecc193f1 100644 --- a/frontend/src/app/universe/universe.types.ts +++ b/frontend/src/app/universe/universe.types.ts @@ -49,6 +49,12 @@ export interface SourceEntry { status: SourceStatus | string; checkpoint: SourceCheckpoint | null; checkedAt: string; + /** Blocks behind the chain reference, as a decimal string. */ + lagBlocks?: string | null; + /** When this authority last answered ready with a usable checkpoint. */ + lastSuccessAt?: string | null; + /** Consecutive failed checks, so a flapping authority is visible as one. */ + consecutiveFailures?: number; } export interface SourcesResponse { @@ -59,6 +65,8 @@ export interface SourcesResponse { export interface SourceCounts { configured: number; ready: number; + /** Reachable and answering, but too far behind to describe the present. */ + stale: number; degraded: number; unreachable: number; } diff --git a/frontend/src/index.mempool.html b/frontend/src/index.mempool.html index 31e591cd62e..2584e3c7218 100644 --- a/frontend/src/index.mempool.html +++ b/frontend/src/index.mempool.html @@ -45,10 +45,10 @@ - + - - + + diff --git a/frontend/src/resources/favicons/android-chrome-192x192.png b/frontend/src/resources/favicons/android-chrome-192x192.png index 53d7291fb53..68cd537d796 100644 Binary files a/frontend/src/resources/favicons/android-chrome-192x192.png and b/frontend/src/resources/favicons/android-chrome-192x192.png differ diff --git a/frontend/src/resources/favicons/android-chrome-512x512.png b/frontend/src/resources/favicons/android-chrome-512x512.png index 19101c7fa87..b42f5d98960 100644 Binary files a/frontend/src/resources/favicons/android-chrome-512x512.png and b/frontend/src/resources/favicons/android-chrome-512x512.png differ diff --git a/frontend/src/resources/favicons/apple-touch-icon.png b/frontend/src/resources/favicons/apple-touch-icon.png index 6c43df9dba4..aee9fb1d6d7 100644 Binary files a/frontend/src/resources/favicons/apple-touch-icon.png and b/frontend/src/resources/favicons/apple-touch-icon.png differ diff --git a/frontend/src/resources/favicons/browserconfig.xml b/frontend/src/resources/favicons/browserconfig.xml index 4eafffea735..2e223ee6654 100644 --- a/frontend/src/resources/favicons/browserconfig.xml +++ b/frontend/src/resources/favicons/browserconfig.xml @@ -3,7 +3,7 @@ - #2438b8 + #c40059 diff --git a/frontend/src/resources/favicons/favicon-16x16.png b/frontend/src/resources/favicons/favicon-16x16.png index 4aec308adfe..7f034049539 100644 Binary files a/frontend/src/resources/favicons/favicon-16x16.png and b/frontend/src/resources/favicons/favicon-16x16.png differ diff --git a/frontend/src/resources/favicons/favicon-32x32.png b/frontend/src/resources/favicons/favicon-32x32.png index 18269e873a1..394be955217 100644 Binary files a/frontend/src/resources/favicons/favicon-32x32.png and b/frontend/src/resources/favicons/favicon-32x32.png differ diff --git a/frontend/src/resources/favicons/favicon.ico b/frontend/src/resources/favicons/favicon.ico index 2545f8e21eb..3753a8b6211 100644 Binary files a/frontend/src/resources/favicons/favicon.ico and b/frontend/src/resources/favicons/favicon.ico differ diff --git a/frontend/src/resources/favicons/mstile-150x150.png b/frontend/src/resources/favicons/mstile-150x150.png index 3389963d4a2..8282e849451 100644 Binary files a/frontend/src/resources/favicons/mstile-150x150.png and b/frontend/src/resources/favicons/mstile-150x150.png differ diff --git a/frontend/src/resources/favicons/site.webmanifest b/frontend/src/resources/favicons/site.webmanifest index 432c0cc6d99..380ea7f848f 100644 --- a/frontend/src/resources/favicons/site.webmanifest +++ b/frontend/src/resources/favicons/site.webmanifest @@ -14,8 +14,8 @@ "type": "image/png" } ], - "theme_color": "#ffffff", - "background_color": "#f4f6f9", + "theme_color": "#f7f4f8", + "background_color": "#f7f4f8", "display": "standalone", "start_url": "/" } diff --git a/frontend/src/resources/universe/universe-explorer-logo.svg b/frontend/src/resources/universe/universe-explorer-logo.svg deleted file mode 100644 index 67310621b2c..00000000000 --- a/frontend/src/resources/universe/universe-explorer-logo.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/resources/universe/universe-explorer-social.png b/frontend/src/resources/universe/universe-explorer-social.png index 847fde7b0c7..0a3cf6f34d5 100644 Binary files a/frontend/src/resources/universe/universe-explorer-social.png and b/frontend/src/resources/universe/universe-explorer-social.png differ diff --git a/frontend/src/resources/universe/universe-explorer-wordmark.svg b/frontend/src/resources/universe/universe-explorer-wordmark.svg deleted file mode 100644 index 3e37cb48176..00000000000 --- a/frontend/src/resources/universe/universe-explorer-wordmark.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - UNIVERSE - EXPLORER - diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 9bd9a264d1d..15f64e2d46f 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -3,12 +3,12 @@ @use "styles/universe-bootstrap-bridge" as universe-bridge; /* Theme */ -$bg: #11131f; +$bg: #0e0a13; $active-bg: #000000; -$hover-bg: #12131e; +$hover-bg: #241a30; $fg: #fff; $nav-bg: $bg; -$title-fg: #2055e3; +$title-fg: #c40059; $taproot: #eba814; $taproot-light: #d5a90a; @@ -22,11 +22,11 @@ $gray-700: $fg; $nav-tabs-link-active-bg: $active-bg; -$primary: #007cfa; -$secondary: #272f4e; -$tertiary: #6225b2; +$primary: #c40059; +$secondary: #2e2140; +$tertiary: #5b2fa6; $success: #0aab2f; -$info: #00ddff; +$info: #15547f; $h5-font-size: 1.15rem !default; @@ -42,7 +42,7 @@ $pagination-disabled-bg: $bg; $custom-select-indicator-color: $fg; -$link-color: $info; +$link-color: $primary; $link-decoration: none !default; $link-hover-color: color.adjust($link-color, $lightness: -15%) !default; $link-hover-decoration: underline !default; @@ -774,12 +774,38 @@ h1, h2, h3 { font-size: 14px; } +// Positive and negative movement. +// +// Two components set these with an inline style binding carrying #42B747 and +// #B74242, which no theme could reach and which the colour gate's markup +// pattern did not match because an Angular property binding is not a style +// attribute. On the light page the green measured 2.37:1 against 4.5:1 +// required, and it went unseen because the panel that shows it had no fixture +// and rendered as a skeleton in every screenshot. +// +// Green still means up and red still means down. They are the evidence state +// tokens now, which are the versions that clear the floor on every surface. +.movement-up { + color: var(--u-state-proven); +} + +.movement-down { + color: var(--u-state-unavailable); +} + .progress { // Visible so the label below the track is not clipped. The fill keeps // its own rounding, so nothing about the bar itself changes. overflow: visible; position: relative; + // The label is positioned out of flow, so without this it lands on whatever + // follows: in the blocks table that is the next row, and the figure was cut + // in half by it. The reserve is only made where a label actually exists. + &:has(.progress-text) { + margin-bottom: calc(1.1em + 4px); + } + .progress-bar { border-radius: inherit; } @@ -792,7 +818,7 @@ h1, h2, h3 { left: 0; position: absolute; text-align: center; - top: calc(100% + 3px); + top: calc(100% + 7px); white-space: nowrap; width: 100%; } @@ -1305,7 +1331,7 @@ th { .collapsed { background-color: var(--secondary); - color: var(--info); + color: var(--u-brand); } } diff --git a/frontend/src/styles/_universe-bootstrap-bridge.scss b/frontend/src/styles/_universe-bootstrap-bridge.scss index d4d4bf3b0e4..47c46754373 100644 --- a/frontend/src/styles/_universe-bootstrap-bridge.scss +++ b/frontend/src/styles/_universe-bootstrap-bridge.scss @@ -194,6 +194,37 @@ } } + // In a set of alternatives, exactly one may look chosen. + // + // Bootstrap paints every `.btn-primary` in a group as a filled control and + // marks the selection with `.active` alone. On the range selector that is + // eleven identically filled buttons and no visible answer to "which period + // am I looking at". It was wrong in ultramarine and it is unmissable in + // brand pink, which is the useful thing about a loud colour: it shows you + // where the hierarchy was already broken. + // + // Inside a group, the options nobody picked go quiet. The fill goes back to + // meaning "this is the one". Standalone primary buttons are untouched, + // because those are actions rather than answers. + .btn-group, + .btn-group-toggle, + .btn-toggle-rows, + .menu { + > .btn-primary:not(.active), + > * > .btn-primary:not(.active) { + background-color: var(--u-surface-raised); + border-color: var(--u-border-strong); + color: var(--u-text-secondary); + + &:hover, + &:focus { + background-color: var(--u-surface-hover); + border-color: var(--u-brand-border); + color: var(--u-brand); + } + } + } + .btn-secondary { background-color: var(--u-surface-inset); border-color: var(--u-border-strong); @@ -232,6 +263,31 @@ color: var(--u-text-inverse); } + // The other filled state buttons. Only .btn-success was restated here, so + // .btn-danger kept Bootstrap's own #dc3545 and inherited the page ink, which + // on the light theme is near black on red at 3.21:1. It renders on three + // surfaces: a stale block, an unconfirmed count, and a negative balance on + // the address page. The last of those went unmeasured for as long as the + // address fixture was answering the wrong shape and the page showed an error + // instead of a balance. + .btn-danger { + background-color: var(--u-state-unavailable); + border-color: var(--u-state-unavailable); + color: var(--u-text-inverse); + } + + .btn-warning { + background-color: var(--u-state-partial); + border-color: var(--u-state-partial); + color: var(--u-text-inverse); + } + + .btn-info { + background-color: var(--u-state-pending); + border-color: var(--u-state-pending); + color: var(--u-text-inverse); + } + .btn-link { color: var(--u-brand); diff --git a/frontend/src/styles/_universe-tokens.scss b/frontend/src/styles/_universe-tokens.scss index 33d7754b889..b9688f5be02 100644 --- a/frontend/src/styles/_universe-tokens.scss +++ b/frontend/src/styles/_universe-tokens.scss @@ -4,20 +4,34 @@ // application. Everything here is emitted as CSS custom properties so a theme // swap is a variable swap, never a rebuild of component styles. // -// Two rules hold the system together: +// Four rules hold the system together: // -// 1. Colour carries evidence state, never protocol identity on its own. +// 1. Pink is the brand, and only the brand. Hot pink, magenta, fuchsia, +// lavender, blush, pearl and chrome carry Bitcoin Universe identity: +// the mark, primary actions, active navigation, focus, selection, and the +// live surfaces the product leads with. They never carry a status. +// +// 2. Colour carries evidence state, never protocol identity on its own. // Protocol hues live in their own scale and always appear next to a -// protocol name. Evidence states always carry a word. That separation is -// why a protocol's colour can never be mistaken for "confirmed" or -// "failed". +// protocol name. Evidence states always carry a word, and they keep the +// conventional green, amber, blue and red a reader already knows. That +// separation is why a protocol's colour can never be mistaken for +// "confirmed" or "failed", and why nothing pink ever means "it worked". +// +// 3. A fill and a label are two different roles. The identity anchor is +// #ff0066, but white text on that exact fill measures 3.85:1 and misses +// AA. So the anchor paints borders, rings, glows and large display type, +// where it carries no small text, and filled controls take a measured +// darker pink that a white label clears. Every strong fill in this file +// declares the foreground that goes on it. // -// 2. Light is the primary experience. The light palette is defined on :root +// 4. Light is the primary experience. The light palette is defined on :root // so it needs no stylesheet fetch and cannot flash. Dark restates the same // token names with its own values and is held to the same contrast floor. // // Contrast: every text token below clears WCAG 2.2 AA (4.5:1 for body, 3:1 for // large text and non-text indicators) against the surface it is designed for. +// `scripts/universe/check-palettes.mjs` measures it rather than trusting it. // --------------------------------------------------------------------------- // Scales that do not change between themes @@ -40,7 +54,9 @@ --u-radius-pill: 999px; // Type. A readable text face and a purposeful data face. Both resolve to - // system stacks so nothing is fetched from a third-party font host. + // system stacks so nothing is fetched from a third-party font host, and the + // first paint carries no webfont round trip. The editorial voice comes from + // weight, tracking and case in --u-display-*, not from a downloaded family. --u-font-text: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; --u-font-data: ui-monospace, "SF Mono", "Cascadia Mono", "Segoe UI Mono", @@ -64,6 +80,12 @@ --u-weight-semibold: 600; --u-weight-bold: 700; + // The editorial register. An eyebrow is the only place small caps and wide + // tracking are allowed, and it never drops below 11px. + --u-display-tracking: -0.02em; + --u-eyebrow-tracking: 0.14em; + --u-eyebrow-size: 0.6875rem; + // Motion. Durations are short because every animation here reports a fact // (a transaction arrived, a block confirmed, a fee moved). Nothing decorative // is allowed to delay reading the number underneath it. @@ -83,33 +105,35 @@ // // The axis the whole product turns on is visible here. A projected block is // lighter and unfinished; a confirmed block is deep and settled. Nothing has - // to be read to feel which is which. - --u-block-ink: #f7f9fc; - --u-block-ink-muted: #dfe4ee; + // to be read to feel which is which. The settled end of that axis is the + // plum-black the rest of the Universe products are built on, so a confirmed + // block reads as the house material rather than as a neutral grey brick. + --u-block-ink: #fff4fa; + --u-block-ink-muted: #e8d6e4; // A projected block's face IS the live fee gradient, and its figures are // printed on top of it. No ink clears the contrast floor across that whole // scale, so the figures get a plate: the gradient still reads as colour // around and through it, and the text sits on a surface whose worst case is // measured rather than assumed. `check-palettes.mjs` holds it to that. - --u-block-plate: rgba(9, 12, 24, 0.62); - --u-block-confirmed-from: #2a3566; - --u-block-confirmed-to: #1a2142; - --u-block-confirmed-top: #3a477f; - --u-block-confirmed-side: #141930; + --u-block-plate: rgba(16, 7, 22, 0.66); + --u-block-confirmed-from: #3f1f4a; + --u-block-confirmed-to: #26122c; + --u-block-confirmed-top: #582a5f; + --u-block-confirmed-side: #190b1e; // The part of a block its transactions did not fill. It belongs to the block, // not to the page: it used to take a page surface token, which meant the top // of every confirmed block was pale and the figures printed on it vanished. - --u-block-confirmed-empty: #3a4166; - --u-block-projected-empty: #4a5169; + --u-block-confirmed-empty: #4d2a56; + --u-block-projected-empty: #574a63; // A block whose contents have not arrived. Deliberately desaturated so it // reads as absent rather than as a block that happens to be empty; the // figures on it are skeletons, which is the signal that does not depend on // anyone noticing a hue. - --u-block-loading: #565a63; - --u-block-projected-top: #5c6480; - --u-block-projected-side: #343a4d; + --u-block-loading: #5a5260; + --u-block-projected-top: #6b5a75; + --u-block-projected-side: #3a3142; // Layout --u-width-prose: 68ch; @@ -132,106 +156,199 @@ color-scheme: light; // Surfaces run from the page floor up. Cards are white so elevation reads - // without a shadow, which keeps dense tables crisp. - --u-surface-page: #f4f6f9; + // without a shadow, which keeps dense tables crisp. The floor carries a + // pearl cast rather than a cool grey, so the page belongs to the same + // material as the pink on it instead of sitting under it. + --u-surface-page: #f7f4f8; --u-surface-raised: #ffffff; - --u-surface-sunken: #e9edf3; - --u-surface-inset: #dfe5ee; - --u-surface-hover: #eef2f7; - --u-surface-active: #e3e9f2; - - // Text. Primary is near-black with a cool cast so it sits with the blues + --u-surface-sunken: #f0ebf4; + --u-surface-inset: #e6dfec; + --u-surface-hover: #faf3f7; + --u-surface-active: #f4e8f0; + --u-surface-selected: #fdeaf2; + --u-surface-disabled: #f0ecf2; + --u-surface-overlay: #ffffff; + + // Text. Primary is near-black with a plum cast so it sits with the pinks // rather than fighting them. - --u-text-primary: #10151f; // 16.8:1 on raised - --u-text-secondary: #414d61; // 8.9:1 on raised - --u-text-muted: #586475; // 6.0:1 on raised, 4.7:1 on inset + --u-text-primary: #241a2b; // 16.6:1 on raised + --u-text-secondary: #4a3f55; // 9.8:1 on raised + --u-text-muted: #645a6e; // 6.5:1 on raised, 4.9:1 on inset + --u-text-faint: #6f6579; // decorative only, never meaningful text --u-text-inverse: #ffffff; + --u-text-on-media: #ffffff; - --u-border: #d5dce6; - --u-border-strong: #b6c1d0; - --u-divider: #e4e9f0; + --u-border: #ded5e5; + --u-border-strong: #b7a9c6; + --u-divider: #ebe4f0; - // Brand. Ultramarine rather than the crypto-default violet: it reads as - // instrumentation, and it is far enough from every status hue to be used for - // structure (links, active nav, focus) without ever implying a state. - --u-brand: #2438b8; - --u-brand-hover: #1b2b93; + // Brand. + // + // --u-brand is the working pink: it is read as text on a page surface and it + // is painted as a fill under --u-brand-contrast, so it has to clear the body + // floor in both directions. #c40059 measures 6.01:1 either way. + // + // --u-brand-accent is the identity anchor, #ff0066 exactly. It is 3.85:1 on + // white, which is a border, a ring, a rule and a display-size headline, and + // is never a small label. + --u-brand: #c40059; + --u-brand-hover: #a80049; + --u-brand-active: #8c003c; --u-brand-contrast: #ffffff; - --u-brand-subtle: #e7ebfb; - --u-brand-border: #c3ccf3; - - // Focus. Deliberately not the brand colour so a focused brand-coloured - // control still shows a ring. - --u-focus-ring: #0a7ea4; + --u-brand-accent: #ff0066; + --u-brand-subtle: #fde9f1; + --u-brand-border: #f0bcd4; + + // The rest of the Universe palette. Magenta and fuchsia are the sweep the + // house gradient runs through; lavender is the secondary accent that carries + // everything the brand pink should not. + --u-magenta: #a3006b; + --u-fuchsia: #8b2fb5; + --u-lavender: #5b2fa6; + --u-lavender-subtle: #ece5fb; + --u-lavender-border: #cdbaf0; + + // Metal. --u-chrome is readable ink; --u-chrome-rim is the hairline that + // gives a control its polished edge and carries no text. + --u-chrome: #6b6280; + --u-chrome-rim: #c9cddf; + --u-pearl: #fff6fa; + --u-blush: #ffd8e7; + + // Focus. Deliberately not the brand colour, so a focused brand-coloured + // control still shows a ring. Lavender is the one Universe accent far enough + // from hot pink to never be mistaken for it, at 8.0:1 on the page. + --u-focus-ring: #5b2fa6; --u-focus-ring-offset: #ffffff; - // Evidence states. These four must never be confusable: the product's whole - // claim rests on the difference between proven and merely observed. + // Evidence states. These five must never be confusable with each other or + // with the brand: the product's whole claim rests on the difference between + // proven and merely observed, and none of it is pink. --u-state-proven: #0f6b3a; - --u-state-proven-surface: #e3f4ea; - --u-state-proven-border: #a9d9bf; + --u-state-proven-surface: #e2f4e9; + --u-state-proven-border: #a5d8bb; --u-state-partial: #8a5100; - --u-state-partial-surface: #fdf0dd; - --u-state-partial-border: #e6c491; + --u-state-partial-surface: #fbf0dc; + --u-state-partial-border: #e3c390; --u-state-pending: #15547f; --u-state-pending-surface: #e2eef7; - --u-state-pending-border: #a5c8e0; + --u-state-pending-border: #a2c7e0; --u-state-unavailable: #a12420; - --u-state-unavailable-surface: #fbe8e7; - --u-state-unavailable-border: #eab3b0; + --u-state-unavailable-surface: #fae8e7; + --u-state-unavailable-border: #e8b2af; - --u-state-neutral: #55617a; - --u-state-neutral-surface: #eaeef4; - --u-state-neutral-border: #c6cfdd; + --u-state-neutral: #5e5769; + --u-state-neutral-surface: #edeaf1; + --u-state-neutral-border: #c8c1d2; - // Protocol identity. Held apart from the state hues above, and only ever - // rendered beside a protocol name. Values are tuned for a light surface. - --u-protocol-ordinals: #9a4a06; + // Protocol identity. Held apart from the state hues above, from each other, + // and from the brand pink, and only ever rendered beside a protocol name. + // Every distance here is measured by check-palettes.mjs rather than judged. + // + // Three of these moved when that measurement was added. Stamps sat at + // #a3246b, close enough to the brand pink that a swatch could read as a + // product accent. Ordinals sat at #9a4a06, 11.1 dE from the amber that means + // partial evidence, so a protocol chip and a verdict chip were nearly the + // same colour; it moved to a redder rust and stays recognisably the orange + // the protocol is known by. Op data sat in the plum that the dark theme + // renders as a pink, which put it 23.1 dE from the brand. + --u-protocol-ordinals: #a34209; --u-protocol-runes: #0a6a7d; --u-protocol-alkanes: #5b3bb5; - --u-protocol-stamps: #a3246b; - --u-protocol-atomicals: #0d6560; - --u-protocol-op-data: #7a3572; - --u-protocol-fallback: #4a5568; - - // Chart series. Ordered for categorical use and checked so neighbouring - // series stay distinguishable for the common colour vision deficiencies. - --u-chart-1: #434ba3; - --u-chart-2: #a3436b; - --u-chart-3: #241763; - --u-chart-4: #a38343; - --u-chart-5: #63172a; - --u-chart-6: #4393a3; - --u-chart-7: #634417; - --u-chart-grid: #dde3ec; - --u-chart-axis: #586475; + --u-protocol-stamps: #721d72; + --u-protocol-atomicals: #1d7256; + --u-protocol-op-data: #2962a3; + --u-protocol-fallback: #5e5769; + + // Chart series. Ordered for categorical use, brand first because most charts + // here draw one series. Neighbours are held further apart than distant pairs + // because they are the ones read side by side, and the separation is measured + // through the three common colour vision deficiencies as well as normal + // vision by `check-palettes.mjs`. + --u-chart-1: #c40059; + --u-chart-2: #5b2fa6; + --u-chart-3: #8a5100; + --u-chart-4: #4393a3; + --u-chart-5: #3d0a2a; + --u-chart-6: #7f9b5a; + --u-chart-7: #a3006b; + --u-chart-grid: #e8e0ee; + --u-chart-axis: #645a6e; + // The zoom slider under a chart. Its track and handles were the charting + // library's own defaults, a pale blue that measured about 1.15:1 on this page + // and belonged to no palette here. + --u-chart-zoom-fill: rgba(196, 0, 89, 0.14); + --u-chart-zoom-track: #ece5f0; // Elevation. Soft and low: this is a data surface, not a floating card deck. - --u-shadow-color: rgba(16, 21, 31, 0.28); - --u-overlay-scrim: rgba(16, 21, 31, 0.55); - --u-shadow-sm: 0 1px 2px rgba(16, 21, 31, 0.07); - --u-shadow-md: 0 2px 8px rgba(16, 21, 31, 0.09); - --u-shadow-lg: 0 8px 24px rgba(16, 21, 31, 0.13); + // The shadow carries the plum cast of the page so a lifted card does not + // drop a grey shadow onto a pink ground. + --u-shadow-color: rgba(36, 26, 43, 0.28); + --u-overlay-scrim: rgba(26, 16, 32, 0.55); + --u-media-scrim: rgba(16, 7, 22, 0.62); + --u-shadow-sm: 0 1px 2px rgba(36, 26, 43, 0.07); + --u-shadow-md: 0 2px 8px rgba(36, 26, 43, 0.09); + --u-shadow-lg: 0 8px 24px rgba(36, 26, 43, 0.13); + + // Gloss and glow. The house finish, kept to primary controls and singleton + // surfaces. + // + // The gloss does sit over text: it is what makes the primary control read as + // lacquer rather than as a flat rectangle, and the label is under it. So its + // opacity is a measured value, not a taste one. At 0.24 it lifts the violet + // end of the brand gradient far enough that a white label falls to 4.06:1; + // 0.16 holds the worst stop at 4.8:1. check-palettes.mjs measures the label + // through this layer rather than against the fill alone. + --u-gloss: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.16) 0%, + rgba(255, 255, 255, 0.05) 46%, + rgba(255, 255, 255, 0) 54% + ); + --u-glow-brand: 0 6px 20px rgba(255, 0, 102, 0.22); + --u-glow-lavender: 0 6px 18px rgba(91, 47, 166, 0.18); + + // The house sweep. Used for the mark, the primary control, and the live + // surfaces, never as a page wallpaper. + --u-gradient-brand: linear-gradient(135deg, #c40059 0%, #a3006b 48%, #8b2fb5 100%); + --u-gradient-chrome: linear-gradient( + 150deg, + #ffffff 0%, + #e7eaf4 28%, + #c9cddf 52%, + #f2f4fa 76%, + #b6bbd0 100% + ); + // Selective only. At most one per screen, always as a rule and never a fill. + --u-gradient-pride: linear-gradient( + 90deg, + #c40059 0%, + #b8531a 20%, + #8a6b00 40%, + #0f6b3a 60%, + #15547f 80%, + #5b2fa6 100% + ); // A wash used behind live regions so an update is noticeable without a flash. - --u-highlight-arrive: rgba(36, 56, 184, 0.09); + --u-highlight-arrive: rgba(255, 0, 102, 0.09); // Loading placeholders. Low contrast on purpose: a skeleton must read as // absent, never as content. - --u-skeleton: #dde3ec; - --u-skeleton-sheen: #eef2f7; + --u-skeleton: #e9e1ee; + --u-skeleton-sheen: #f5eff8; // Network identity. Mainnet takes the brand; the test networks are visibly // different from it and from each other so a testnet page can never be // mistaken for mainnet. - --u-network-mainnet: #2438b8; + --u-network-mainnet: #c40059; --u-network-testnet: #0a6a7d; --u-network-testnet4: #0d6560; - --u-network-signet: #a3246b; - --u-network-regtest: #55617a; + --u-network-signet: #7a3572; + --u-network-regtest: #5e5769; --u-network-liquid: #0f6b3a; --u-network-liquidtestnet: #5c6879; @@ -240,20 +357,18 @@ // Data ramps. // // Charts fill areas with a gradient that means a quantity, not a category. - // The stops the product inherited were the Material palette, chosen against a - // dark page: pale amber and light green, which land near 1.3:1 on a white - // card and read as nothing at all. Every stop below clears the graphical - // floor on both light surfaces, and the sequential scale still darkens - // monotonically so "more" still looks like more. - --u-ramp-scale-1: #4393a3; - --u-ramp-scale-2: #2f7f9e; - --u-ramp-scale-3: #2f5a9e; - --u-ramp-scale-4: #43349e; - --u-ramp-scale-5: #5b1f7a; - - --u-ramp-a-from: #434ba3; - --u-ramp-a-to: #241763; - --u-ramp-b-from: #a3436b; + // The sequential scale darkens monotonically so "more" still looks like more, + // and it runs through the house sweep rather than an arbitrary spectrum. + // Every stop clears the graphical floor on both light surfaces. + --u-ramp-scale-1: #e0568f; + --u-ramp-scale-2: #c40059; + --u-ramp-scale-3: #a3006b; + --u-ramp-scale-4: #7a2f8f; + --u-ramp-scale-5: #5b2fa6; + + --u-ramp-a-from: #c40059; + --u-ramp-a-to: #5b2fa6; + --u-ramp-b-from: #8a5100; --u-ramp-b-to: #63172a; --u-ramp-c-from: #4393a3; --u-ramp-c-to: #1c4f5c; @@ -272,7 +387,7 @@ --u-code-arithmetic: #0f6b3a; --u-code-crypto: #a12420; --u-code-locktime: #8a5100; - --u-code-reserved: #55617a; + --u-code-reserved: #5e5769; // The one moment the product celebrates: a block whose height reads as a // round number. The plate is bright, so its ink is near-black in every theme. @@ -280,7 +395,7 @@ --u-special-ink: #241a00; // A translucent foreground for de-emphasised text over unknown backdrops. - --u-text-translucent: rgba(16, 21, 31, 0.62); + --u-text-translucent: rgba(36, 26, 43, 0.62); --u-fade-start: rgba(255, 255, 255, 0); --u-fade-end: rgba(255, 255, 255, 1); } @@ -292,101 +407,156 @@ @mixin universe-dark-tokens { color-scheme: dark; - --u-surface-page: #12141f; - --u-surface-raised: #1a1e2d; - --u-surface-sunken: #0d0f18; - --u-surface-inset: #232838; - --u-surface-hover: #232839; - --u-surface-active: #2b3146; - - --u-text-primary: #f2f4f8; // 14.9:1 on raised - --u-text-secondary: #c0c8d8; // 8.7:1 on raised - --u-text-muted: #98a2b6; // 5.4:1 on raised - --u-text-inverse: #10151f; - - --u-border: #2e3549; - --u-border-strong: #414a63; - --u-divider: #262c3d; - - --u-brand: #8fa2ff; - --u-brand-hover: #adbaff; - --u-brand-contrast: #10151f; - --u-brand-subtle: #1e2340; - --u-brand-border: #384169; - - --u-focus-ring: #4fc3e8; - --u-focus-ring-offset: #1a1e2d; + // Plum-black rather than neutral black. It is the same shell the other + // Universe products are built on, and it is what makes hot pink read as + // lacquer instead of as neon on grey. + --u-surface-page: #0e0a13; + --u-surface-raised: #1c1526; + --u-surface-sunken: #0b0810; + --u-surface-inset: #241a30; + --u-surface-hover: #241a30; + --u-surface-active: #2e2140; + --u-surface-selected: #33193a; + --u-surface-disabled: #201a28; + --u-surface-overlay: #1c1526; + + --u-text-primary: #f6f2f8; // 16.0:1 on raised + --u-text-secondary: #cfc4d8; // 10.5:1 on raised + --u-text-muted: #a99cb5; // 6.8:1 on raised + --u-text-faint: #8d8199; + --u-text-inverse: #1c1526; + --u-text-on-media: #ffffff; + + --u-border: #372b47; + --u-border-strong: #4d3d61; + --u-divider: #2a2038; + + // On a plum shell the anchor pink is a fill, not a label: #ff0066 is 4.59:1 + // here, fine for a rule or a ring but not for the small text the product is + // made of. So the working pink lightens and the anchor keeps the borders. + --u-brand: #ff8ab8; + --u-brand-hover: #ffb3d1; + --u-brand-active: #ffd4e5; + --u-brand-contrast: #2a0a18; + --u-brand-accent: #ff2a85; + --u-brand-subtle: #2e1226; + --u-brand-border: #5c2340; + + --u-magenta: #ff6fd0; + --u-fuchsia: #d98cf5; + --u-lavender: #c4a7ff; + --u-lavender-subtle: #241a3a; + --u-lavender-border: #4a3a75; + + --u-chrome: #b9bed6; + --u-chrome-rim: #4a4560; + --u-pearl: #fff6fa; + --u-blush: #ffd8e7; + + --u-focus-ring: #c4a7ff; + --u-focus-ring-offset: #1c1526; --u-state-proven: #5fd39a; - --u-state-proven-surface: #123227; + --u-state-proven-surface: #0f2a1f; --u-state-proven-border: #2c6d51; --u-state-partial: #f0be62; - --u-state-partial-surface: #33260f; + --u-state-partial-surface: #2e2210; --u-state-partial-border: #6d5527; --u-state-pending: #7cc0f0; - --u-state-pending-surface: #12283a; + --u-state-pending-surface: #122435; --u-state-pending-border: #2b5878; --u-state-unavailable: #f2918a; - --u-state-unavailable-surface: #391b1a; + --u-state-unavailable-surface: #331a1c; --u-state-unavailable-border: #743935; - --u-state-neutral: #a3adc2; - --u-state-neutral-surface: #212735; - --u-state-neutral-border: #3c445a; + --u-state-neutral: #b3a8bf; + --u-state-neutral-surface: #251c30; + --u-state-neutral-border: #453a54; --u-protocol-ordinals: #f0a463; --u-protocol-runes: #6fcfe2; --u-protocol-alkanes: #b6a5f5; - --u-protocol-stamps: #f19ac4; - --u-protocol-atomicals: #6fd3c9; - --u-protocol-op-data: #d79ad0; - --u-protocol-fallback: #b3bccd; - - --u-chart-1: #7575f0; - --u-chart-2: #c0ba7c; - --u-chart-3: #7cc0c0; - --u-chart-4: #f0f075; - --u-chart-5: #c07c7c; - --u-chart-6: #987cc0; - --u-chart-7: #bddbbf; - --u-chart-grid: #2a3145; - --u-chart-axis: #98a2b6; + --u-protocol-stamps: #d97fd9; + --u-protocol-atomicals: #5fd9c4; + --u-protocol-op-data: #5795db; + --u-protocol-fallback: #b3a8bf; + + --u-chart-1: #ff8ab8; + --u-chart-2: #c4a7ff; + --u-chart-3: #f0be62; + --u-chart-4: #6f5fa8; + --u-chart-5: #c96a8a; + --u-chart-6: #6fcfe2; + --u-chart-7: #9e8a5f; + --u-chart-grid: #2e2440; + --u-chart-axis: #a99cb5; + --u-chart-zoom-fill: rgba(255, 138, 184, 0.18); + --u-chart-zoom-track: #241a30; --u-shadow-color: rgba(0, 0, 0, 0.55); --u-overlay-scrim: rgba(0, 0, 0, 0.66); + --u-media-scrim: rgba(8, 4, 12, 0.7); --u-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); --u-shadow-md: 0 2px 8px rgba(0, 0, 0, 0.45); --u-shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.55); - --u-highlight-arrive: rgba(143, 162, 255, 0.14); - - --u-skeleton: #262c3d; - --u-skeleton-sheen: #333b52; - - --u-network-mainnet: #8fa2ff; + --u-gloss: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.22) 0%, + rgba(255, 255, 255, 0.05) 46%, + rgba(255, 255, 255, 0) 54% + ); + --u-glow-brand: 0 8px 24px rgba(255, 0, 102, 0.34); + --u-glow-lavender: 0 8px 22px rgba(196, 167, 255, 0.26); + + --u-gradient-brand: linear-gradient(135deg, #ff0066 0%, #ff2a85 42%, #d946ef 100%); + --u-gradient-chrome: linear-gradient( + 150deg, + #f8f9ff 0%, + #dfe2ef 28%, + #9aa0bd 52%, + #eceef8 76%, + #b9bed6 100% + ); + --u-gradient-pride: linear-gradient( + 90deg, + #ff5c9e 0%, + #ffb46a 20%, + #ffe27a 40%, + #7ce8b5 60%, + #7dc8ff 80%, + #c4a5f9 100% + ); + + --u-highlight-arrive: rgba(255, 138, 184, 0.14); + + --u-skeleton: #2a2038; + --u-skeleton-sheen: #382b4a; + + --u-network-mainnet: #ff8ab8; --u-network-testnet: #6fcfe2; --u-network-testnet4: #6fd3c9; - --u-network-signet: #f19ac4; - --u-network-regtest: #a3adc2; + --u-network-signet: #d79ad0; + --u-network-regtest: #b3a8bf; --u-network-liquid: #5fd39a; --u-network-liquidtestnet: #98a2b6; --u-taproot: #f0be62; - --u-ramp-scale-1: #7cc0c0; - --u-ramp-scale-2: #7aa8d8; - --u-ramp-scale-3: #8f9ae8; - --u-ramp-scale-4: #b394ec; - --u-ramp-scale-5: #d68fd0; - - --u-ramp-a-from: #7575f0; - --u-ramp-a-to: #b394ec; - --u-ramp-b-from: #c07c7c; - --u-ramp-b-to: #f0a4a4; - --u-ramp-c-from: #7cc0c0; + --u-ramp-scale-1: #8a5a9e; + --u-ramp-scale-2: #b5479e; + --u-ramp-scale-3: #e0348f; + --u-ramp-scale-4: #ff6fa8; + --u-ramp-scale-5: #ffb3d1; + + --u-ramp-a-from: #ff8ab8; + --u-ramp-a-to: #c4a7ff; + --u-ramp-b-from: #f0be62; + --u-ramp-b-to: #f0a463; + --u-ramp-c-from: #6fcfe2; --u-ramp-c-to: #a8dede; --u-code-constant: #f0a463; @@ -397,14 +567,14 @@ --u-code-arithmetic: #5fd39a; --u-code-crypto: #f2918a; --u-code-locktime: #f0be62; - --u-code-reserved: #a3adc2; + --u-code-reserved: #b3a8bf; --u-special-plate: #e8b30d; --u-special-ink: #241a00; - --u-text-translucent: rgba(242, 244, 248, 0.66); - --u-fade-start: rgba(26, 30, 45, 0); - --u-fade-end: rgba(26, 30, 45, 1); + --u-text-translucent: rgba(246, 242, 248, 0.66); + --u-fade-start: rgba(28, 21, 38, 0); + --u-fade-end: rgba(28, 21, 38, 1); } // --------------------------------------------------------------------------- @@ -424,7 +594,11 @@ --fg: var(--u-text-primary); --nav-bg: var(--u-surface-raised); --navbar-bg: var(--u-surface-raised); - --title-fg: var(--u-brand); + // The label above a number on a stat card, on 45 of them. It was the brand + // colour, which made a mining dashboard nine pink labels competing with the + // figures they introduce. Brand marks identity and intent; a label is + // neither, so the number leads and the label sits behind it. + --title-fg: var(--u-text-secondary); --box-bg: var(--u-surface-raised); --stat-box-bg: var(--u-surface-sunken); @@ -432,7 +606,7 @@ --primary: var(--u-brand); --secondary: var(--u-surface-inset); - --tertiary: var(--u-brand); + --tertiary: var(--u-lavender); --success: var(--u-state-proven); --info: var(--u-state-pending); --link-color: var(--u-brand); diff --git a/frontend/src/theme-contrast.scss b/frontend/src/theme-contrast.scss index dd8ff020e7d..dd740fc8172 100644 --- a/frontend/src/theme-contrast.scss +++ b/frontend/src/theme-contrast.scss @@ -3,8 +3,13 @@ // Dark with the contrast pushed well past the AA floor, for reading dense fee // and transaction tables in bright rooms or on low-quality panels. It inherits // the dark tokens and then raises only what needs raising: text against -// surface, borders, and the four evidence states, which are the colours a -// misread would actually cost something. +// surface, borders, the brand pink, and the five evidence states, which are the +// colours a misread would actually cost something. +// +// The brand does not leave when the contrast goes up. It lightens: the same +// hot pink family, taken to a tint that clears 7:1 on a near-black plum shell, +// so a person who needs this theme still gets the product's identity rather +// than a monochrome fallback. @use "styles/universe-tokens" as universe; @@ -12,41 +17,54 @@ @include universe.universe-invariant-tokens; @include universe.universe-dark-tokens; - // Push the surfaces apart and the text up. Text on raised reaches ~19:1. - --u-surface-page: #05070d; - --u-surface-raised: #0e1118; + // Push the surfaces apart and the text up. Text on raised reaches ~18:1. + --u-surface-page: #06040a; + --u-surface-raised: #120c18; --u-surface-sunken: #000000; - --u-surface-inset: #1a1f2b; - --u-surface-hover: #1c2230; - --u-surface-active: #252c3d; + --u-surface-inset: #1e1628; + --u-surface-hover: #221a2d; + --u-surface-active: #2c2239; + --u-surface-selected: #3a1f42; + --u-surface-overlay: #120c18; --u-text-primary: #ffffff; - --u-text-secondary: #dce3ee; - --u-text-muted: #b3bccd; + --u-text-secondary: #ece4f0; + --u-text-muted: #c6bcd0; + --u-text-faint: #c6bcd0; + + --u-border: #7d7291; + --u-border-strong: #9b8fae; + --u-divider: #3f3550; - --u-border: #707c98; - --u-border-strong: #6b7691; - --u-divider: #3a4258; + --u-brand: #ffb3d1; + --u-brand-hover: #ffd4e5; + --u-brand-active: #ffe8f1; + --u-brand-contrast: #2a0a18; + --u-brand-accent: #ff8ab8; + --u-brand-subtle: #2e1226; + --u-brand-border: #a8496e; - --u-brand: #a8b8ff; - --u-brand-hover: #c8d2ff; - --u-brand-subtle: #171d33; - --u-brand-border: #4d5885; + --u-magenta: #ff9ade; + --u-fuchsia: #e6aef7; + --u-lavender: #d9c4ff; + --u-lavender-border: #6f5c9e; + --u-chrome: #d4d8ea; + --u-chrome-rim: #6f6a86; - --u-focus-ring: #6fe0ff; - --u-focus-ring-offset: #0e1118; + --u-focus-ring: #d9c4ff; + --u-focus-ring-offset: #120c18; - // Evidence states, raised. These four carry the product's core claim, so they + // Evidence states, raised. These five carry the product's core claim, so they // get the most headroom. --u-state-proven: #7dea9f; --u-state-partial: #ffd27a; --u-state-pending: #9ed4ff; --u-state-unavailable: #ffa8a0; - --u-state-neutral: #c2cbdb; + --u-state-neutral: #cfc4d8; // This theme uses the brighter fee scale, on which white text falls under // the contrast floor. Near-black reads on every band of it. - --u-fee-label-ink: #05070d; + --u-fee-label-ink: #06040a; @include universe.universe-legacy-bridge; diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index d8e7985e8f0..1b04b93ce38 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -2,13 +2,18 @@ import { defineConfig } from 'vitest/config'; import { fileURLToPath } from 'node:url'; /** - * Unit tests for the Universe module. + * Unit tests for the code this product owns. * - * The suite exercises component and service logic directly rather than through - * the Angular TestBed: the pieces that carry risk here are the evidence - * derivations, label vocabulary, and API contract handling, none of which need - * a DOM. Template correctness is covered by the AOT production build, which - * fails on any template error. + * The suite exercises component, service, and pipe logic directly rather than + * through the Angular TestBed: the pieces that carry risk here are the evidence + * derivations, label vocabulary, API contract handling, and the small pure + * helpers the templates lean on, none of which need a DOM. Template correctness + * is covered by the AOT production build, which fails on any template error. + * + * The two inherited Lightning specs are deliberately outside the include. They + * are TestBed specs that need a browser environment and an Angular testing + * module this configuration does not set up, and they cover a feature this + * deployment does not enable. */ export default defineConfig({ // The root tsconfig.json is solution-style and carries no compiler options, @@ -34,7 +39,7 @@ export default defineConfig({ environment: 'node', globals: true, setupFiles: ['./src/universe-test-setup.ts'], - include: ['src/app/universe/**/*.spec.ts'], + include: ['src/app/universe/**/*.spec.ts', 'src/app/shared/**/*.spec.ts'], reporters: ['default'], }, }); diff --git a/production/linux/universe-explorer-backup b/production/linux/universe-explorer-backup new file mode 100644 index 00000000000..60a2c1bfcd1 --- /dev/null +++ b/production/linux/universe-explorer-backup @@ -0,0 +1,44 @@ +#!/bin/bash +# +# Dumps the Universe Explorer database and prunes old dumps. +# +# A restore was verified by hand once; a backup nobody takes is not a backup, +# so this runs daily and keeps two weeks. Verify a restore into a scratch +# schema after any change to the schema or the engine: +# +# gunzip -c | docker exec -i universe-explorer-mariadb \ +# mariadb -u root -p"$MYSQL_ROOT_PASSWORD" restore_probe +# +set -euo pipefail + +CONTAINER=universe-explorer-mariadb +DEST=/data/indexers-c/universe-explorer/backups +KEEP_DAYS=14 + +set -a +. /etc/universe-explorer/mysql.env +set +a + +mkdir -p "$DEST" +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +OUT="$DEST/universe_explorer-$STAMP.sql.gz" + +docker exec "$CONTAINER" mariadb-dump \ + -u root -p"$MYSQL_ROOT_PASSWORD" \ + --single-transaction --routines --events \ + "$MYSQL_DATABASE" 2>/dev/null | gzip > "$OUT" + +# A dump that is suspiciously small did not work, and keeping it would hide +# that the good one is missing. +SIZE=$(stat -c %s "$OUT") +if [ "$SIZE" -lt 1000000 ]; then + echo "backup is only $SIZE bytes, refusing to keep it" >&2 + rm -f "$OUT" + exit 1 +fi + +# Prove the dump can be read back before trusting it. +gunzip -t "$OUT" + +find "$DEST" -name 'universe_explorer-*.sql.gz' -mtime "+$KEEP_DAYS" -delete +echo "wrote $OUT ($SIZE bytes), keeping $KEEP_DAYS days" diff --git a/production/linux/universe-explorer-backup.service b/production/linux/universe-explorer-backup.service new file mode 100644 index 00000000000..ef6674dee0d --- /dev/null +++ b/production/linux/universe-explorer-backup.service @@ -0,0 +1,11 @@ +[Unit] +Description=Universe Explorer database backup +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# Reads the credentials from the file that already holds them, so no secret +# reaches a command line or the journal. Keeps two weeks of daily dumps; the +# database is a few hundred megabytes and the disk has terabytes free. +ExecStart=/usr/local/bin/universe-explorer-backup diff --git a/production/linux/universe-explorer-backup.timer b/production/linux/universe-explorer-backup.timer new file mode 100644 index 00000000000..408defda72d --- /dev/null +++ b/production/linux/universe-explorer-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Back up the Universe Explorer database daily + +[Timer] +OnCalendar=daily +RandomizedDelaySec=30min +Persistent=true +Unit=universe-explorer-backup.service + +[Install] +WantedBy=timers.target diff --git a/production/linux/universe-explorer-gateway.service.d/10-socket.conf b/production/linux/universe-explorer-gateway.service.d/10-socket.conf new file mode 100644 index 00000000000..9cd28549633 --- /dev/null +++ b/production/linux/universe-explorer-gateway.service.d/10-socket.conf @@ -0,0 +1,9 @@ +# Binds the gateway service to the socket unit that owns its port. +# +# Requires plus After means starting the service brings the socket up first, +# and, more to the point, restarting the service leaves the socket up. That is +# the whole reason this exists: the port survives the process. + +[Unit] +Requires=universe-explorer-gateway.socket +After=universe-explorer-gateway.socket diff --git a/production/linux/universe-explorer-gateway.socket b/production/linux/universe-explorer-gateway.socket new file mode 100644 index 00000000000..fa86487afbd --- /dev/null +++ b/production/linux/universe-explorer-gateway.socket @@ -0,0 +1,39 @@ +# Holds the gateway's listening port so that restarting the gateway does not +# refuse connections. +# +# The backend and the overlay restart behind the gateway, which bridges the gap +# for them. Nothing could bridge a restart of the gateway itself: the port went +# away with the process, and the edge answered 502 until it came back. That was +# the last remaining gap in a deploy, and it opened whenever gateway.mjs +# changed. +# +# With the socket owned by systemd, the port stays bound across the restart and +# arriving connections wait in the kernel backlog instead. Install with: +# +# systemctl enable --now universe-explorer-gateway.socket +# +# Adopting it on a gateway that is already running costs one brief interruption, +# because the old process holds the port without SO_REUSEPORT and the socket +# unit cannot bind underneath it. Every deploy after that is seamless. + +# It deliberately does not declare PartOf=universe-explorer-gateway.service. +# PartOf propagates stop and restart from the named unit to this one, so a +# restart of the service would take the socket down with it, drop whatever was +# queued in the backlog, and rebind. That is a shorter gap than before but it +# is still a gap, and it would make the claim above false. +# +# Because the socket outlives the service, stopping the service alone does not +# take the port down: the next connection activates the service again. To stop +# the gateway for real, stop both units. + +[Unit] +Description=Universe Explorer gateway socket + +[Socket] +ListenStream=127.0.0.1:8099 +# Matches the backlog the gateway asked for when it opened the port itself, so +# a burst arriving mid-restart is queued rather than dropped. +Backlog=511 + +[Install] +WantedBy=sockets.target diff --git a/production/linux/universe-explorer-synthetic-check.service b/production/linux/universe-explorer-synthetic-check.service new file mode 100644 index 00000000000..2007c1bd1d6 --- /dev/null +++ b/production/linux/universe-explorer-synthetic-check.service @@ -0,0 +1,17 @@ +[Unit] +Description=Universe Explorer synthetic check against the public origin +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# The public origin, not loopback: this has to exercise the same path a reader +# takes, including the gateway, the tunnel, and TLS. +ExecStart=/usr/local/bin/node /usr/local/lib/universe-explorer-synthetic-check.mjs https://explorer.bitcoinuniverse.io +DynamicUser=yes +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +RestrictAddressFamilies=AF_INET AF_INET6 +LockPersonality=true diff --git a/production/linux/universe-explorer-synthetic-check.timer b/production/linux/universe-explorer-synthetic-check.timer new file mode 100644 index 00000000000..340bb603240 --- /dev/null +++ b/production/linux/universe-explorer-synthetic-check.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run the Universe Explorer synthetic check every five minutes + +[Timer] +OnBootSec=3min +OnUnitActiveSec=5min +AccuracySec=30s +Unit=universe-explorer-synthetic-check.service + +[Install] +WantedBy=timers.target diff --git a/scripts/universe/brand/render-brand-assets.mjs b/scripts/universe/brand/render-brand-assets.mjs index cc0794984cf..a1a6e4bd826 100644 --- a/scripts/universe/brand/render-brand-assets.mjs +++ b/scripts/universe/brand/render-brand-assets.mjs @@ -28,10 +28,19 @@ const UNIVERSE = join(RESOURCES, 'universe'); // Taken from the light palette in styles/_universe-tokens.scss. Icons are // rendered once and served to every theme, so they carry the brand colour on an // opaque ground rather than trying to adapt. -const BRAND = '#2438b8'; -const INK = '#10151f'; -const PAPER = '#ffffff'; -const MUTED = '#5c6879'; +// +// The icon is inverted on purpose: a pearl mark on a hot-pink tile, rather than +// a pink glyph on white. Every explorer in this market ships a dark or white +// favicon, so a solid pink tile is the one that can be found in a strip of +// twenty tabs. Pearl on #c40059 measures 5.7:1, so the mark stays legible at +// 16px rather than relying on the tile colour alone. +const BRAND = '#c40059'; +const BRAND_ACCENT = '#ff0066'; +const MAGENTA = '#a3006b'; +const FUCHSIA = '#8b2fb5'; +const INK = '#241a2b'; +const PEARL = '#fff6fa'; +const MUTED = '#645a6e'; const mark = readFileSync(join(UNIVERSE, 'universe-mark.svg'), 'utf8') .replace(//g, '') @@ -61,11 +70,12 @@ function socialCard() { return `