diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index eb2fe97eab1..2952c4bd0a7 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -52,6 +52,9 @@ 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] @@ -83,6 +86,13 @@ 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 + frontend: name: Frontend build runs-on: [self-hosted, linux-ultra] @@ -127,17 +137,34 @@ jobs: 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" - - name: Measured contrast, accessibility, overflow, and screenshots + # The harness now 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. + - name: Measured contrast, accessibility, overflow, unfinished pages, and screenshots working-directory: scripts/universe/visual-qa - run: node capture.mjs --base=http://127.0.0.1:8123 --out=./artifacts + run: node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts - name: Keep the contrast report and the route screenshots if: always() 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/.gitignore b/.gitignore index f154cf0ef3a..1898f494d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ node_modules/ # Visual QA working output. Curated review sets are committed under docs/design. scripts/universe/visual-qa/artifacts/ +# 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..4119387b3ad 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" diff --git a/backend/jest.integration.setup.ts b/backend/jest.integration.setup.ts index c5093b01ae5..9ccf5c87855 100644 --- a/backend/jest.integration.setup.ts +++ b/backend/jest.integration.setup.ts @@ -36,8 +36,29 @@ module.exports = async () => { const composeFile = path.join(__dirname, 'docker-compose.test.yml'); const dockerComposeCmd = getDockerComposeCmd(); - // Start the container - execSync(`${dockerComposeCmd} -f "${composeFile}" up -d`, { + // 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. + } + + // `down` only removes what compose owns. A container left behind under the + // same name by anything else still holds the name, and `up` fails on the + // conflict rather than starting the database. + try { + execSync('docker rm -f backend-db-test-1', { stdio: 'pipe' }); + } catch { + // No such container, which is the normal case. + } + + execSync(`${dockerComposeCmd} -f "${composeFile}" up -d --force-recreate`, { stdio: 'inherit', cwd: __dirname }); @@ -45,14 +66,19 @@ module.exports = async () => { // 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) { 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/__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..c3101f0b3d3 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); 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..3571a692b07 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,124 @@ 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 is + the one case that still shows a brief gap, and the cutover log says so. + + 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. + +## 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/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index cfdd80dbfc7..e1ea8a3bb59 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -181,6 +181,7 @@

+

Loading address

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..dc6fd89f5d4 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.html +++ b/frontend/src/app/components/blocks-list/blocks-list.component.html @@ -89,7 +89,7 @@

Blocks

- + @@ -123,6 +123,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..9414fc1aaf6 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, switchMap, tap, throttleTime } 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,7 @@ 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'; @Component({ selector: 'app-blocks-list', @@ -27,6 +28,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 +118,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 +143,23 @@ 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(2000 * attempt); + }, + }), + catchError((error) => { + this.failureState = { status: 'error', reason: classifyLoadFailure(error), at: Date.now() }; + this.isLoading = false; + this.cd.markForCheck(); + return EMPTY; + }) ); }) ), @@ -187,6 +207,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/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..62acacb5164 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'; @@ -15,6 +15,15 @@ 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 { 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) { 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..e6be91aecfe 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts @@ -1,9 +1,9 @@ -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'; @@ -13,6 +13,7 @@ import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pip 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) { 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/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..5d6a0c72961 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'; @@ -14,6 +14,25 @@ import { StorageService } from '@app/services/storage.service'; import { feeLevels, chartColors } 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 { 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,13 +40,12 @@ 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; filterSize = 100000; @@ -38,6 +56,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 +74,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 +91,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 +106,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,16 +275,16 @@ 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; if (!this.inverted) { @@ -194,19 +307,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 +320,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 +328,7 @@ export class StatisticsComponent implements OnInit { } } - isMobile() { + isMobile(): boolean { return (window.innerWidth <= 767.98); } } 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/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/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html index 345f4f0238a..ba64a24430f 100644 --- a/frontend/src/app/dashboard/dashboard.component.html +++ b/frontend/src/app/dashboard/dashboard.component.html @@ -1,5 +1,16 @@
+ + @if (chainStatus$ | async; as chainStatus) { + @if (chainStatus.status === 'loading') { +

Loading live chain data

+ } + @if (chainStatus.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/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/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..05bc701f749 100644 --- a/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss +++ b/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss @@ -132,6 +132,32 @@ border-style: dashed; color: var(--transparent-fg); } + + // 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; } .registry-summary { 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/vitest.config.ts b/frontend/vitest.config.ts index d8e7985e8f0..1c8ea44fff0 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -34,7 +34,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-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/gateway-restart.test.mjs b/scripts/universe/gateway-restart.test.mjs new file mode 100644 index 00000000000..5b59cdd9df2 --- /dev/null +++ b/scripts/universe/gateway-restart.test.mjs @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * A release restarts the backend and the overlay, and they take a few seconds + * to listen again. The gateway used to answer 502 the instant the connection + * was refused, so every request in that window became a visible failure and the + * production monitor recorded the deploy as an outage. + * + * These start the real gateway against an upstream that is not listening, and + * check that a request waits for it to come back rather than failing, while an + * upstream that is genuinely gone is still reported promptly. + */ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const GATEWAY = join(HERE, 'gateway.mjs'); +const ROOT = join(HERE, '..', '..', 'frontend', 'dist', 'mempool', 'browser'); + +const GATEWAY_PORT = 8794; +const UPSTREAM_PORT = 8795; + +function startGateway() { + return spawn(process.execPath, [GATEWAY], { + env: { + ...process.env, + UNIVERSE_GATEWAY_HOST: '127.0.0.1', + UNIVERSE_GATEWAY_PORT: String(GATEWAY_PORT), + UNIVERSE_GATEWAY_BACKEND: `http://127.0.0.1:${UPSTREAM_PORT}`, + UNIVERSE_GATEWAY_OVERLAY: `http://127.0.0.1:${UPSTREAM_PORT}`, + UNIVERSE_GATEWAY_ROOT: ROOT, + }, + stdio: 'ignore', + }); +} + +function ask(path = '/api/v1/backend-info') { + return new Promise((resolve) => { + const started = Date.now(); + http + .get({ host: '127.0.0.1', port: GATEWAY_PORT, path }, (response) => { + response.resume(); + response.on('end', () => resolve({ status: response.statusCode, ms: Date.now() - started })); + }) + .on('error', (error) => resolve({ status: 0, error: error.code, ms: Date.now() - started })); + }); +} + +test('a request waits for an upstream that is restarting, rather than failing', async (t) => { + const gateway = startGateway(); + t.after(() => gateway.kill()); + await sleep(1200); + + // Nothing is listening yet, exactly as during a restart. + const inFlight = ask(); + await sleep(1000); + + const upstream = http.createServer((_, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"ok":true}'); + }); + await new Promise((resolve) => upstream.listen(UPSTREAM_PORT, '127.0.0.1', resolve)); + t.after(() => upstream.close()); + + const bridged = await inFlight; + assert.equal(bridged.status, 200, 'a restart should be bridged, not reported as a failure'); +}); + +test('an upstream that is genuinely gone is still reported, and promptly', async (t) => { + const gateway = startGateway(); + t.after(() => gateway.kill()); + await sleep(1200); + + const dead = await ask(); + assert.equal(dead.status, 502, 'a dead upstream must be a gateway failure, never an empty success'); + // Bounded well inside the page's own request budget, so the interface still + // reaches a terminal state quickly. + assert.ok(dead.ms < 12_000, `gave up after ${dead.ms}ms, which is too long to hold a reader`); +}); diff --git a/scripts/universe/gateway.mjs b/scripts/universe/gateway.mjs index 1a70480cc14..dc4180ada4f 100644 --- a/scripts/universe/gateway.mjs +++ b/scripts/universe/gateway.mjs @@ -174,38 +174,74 @@ export function routeFor(pathname, originalUrl) { return null; } +/** + * How long a request waits for an upstream that is refusing connections. + * + * A release restarts the backend and the overlay, and they take a few seconds + * to listen again. Answering 502 the instant the connection is refused turns + * every request in that window into a visible failure, which is the difference + * between a deploy nobody notices and one that shows up as an outage. These + * delays bridge a restart and give up well inside the page's own budget, so a + * genuinely dead upstream is still reported promptly rather than hidden. + */ +const RESTART_RETRY_DELAYS_MS = [250, 500, 1000, 1500, 2000]; + +/** True for a failure that a moment's wait could plausibly resolve. */ +function upstreamIsRestarting(error) { + return error?.code === 'ECONNREFUSED' || error?.code === 'ECONNRESET'; +} + function proxy(request, response, route) { const upstream = route.upstream; - const options = { - protocol: upstream.protocol, - hostname: upstream.hostname, - port: upstream.port, - method: request.method, - path: route.path, - headers: { ...request.headers, host: upstream.host }, - timeout: UPSTREAM_TIMEOUT_MS, - }; - const proxied = http.request(options, (upstreamResponse) => { - // API responses are data, never documents, so they get the headers without - // a content policy. - response.writeHead( - upstreamResponse.statusCode || 502, - withSecurityHeaders(upstreamResponse.headers, false), - ); - upstreamResponse.pipe(response); - }); - proxied.on('timeout', () => proxied.destroy(new Error('upstream timeout'))); - proxied.on('error', () => { - if (response.headersSent) { - response.destroy(); - return; + // Only a request with no body can be replayed. Everything this gateway + // proxies that changes state carries one, so this never retries a write. + const replayable = request.method === 'GET' || request.method === 'HEAD'; + let attempt = 0; + + const send = () => { + const options = { + protocol: upstream.protocol, + hostname: upstream.hostname, + port: upstream.port, + method: request.method, + path: route.path, + headers: { ...request.headers, host: upstream.host }, + timeout: UPSTREAM_TIMEOUT_MS, + }; + const proxied = http.request(options, (upstreamResponse) => { + // API responses are data, never documents, so they get the headers + // without a content policy. + response.writeHead( + upstreamResponse.statusCode || 502, + withSecurityHeaders(upstreamResponse.headers, false), + ); + upstreamResponse.pipe(response); + }); + proxied.on('timeout', () => proxied.destroy(new Error('upstream timeout'))); + proxied.on('error', (error) => { + if (response.headersSent) { + response.destroy(); + return; + } + if (replayable && upstreamIsRestarting(error) && attempt < RESTART_RETRY_DELAYS_MS.length) { + const delay = RESTART_RETRY_DELAYS_MS[attempt]; + attempt += 1; + setTimeout(send, delay); + return; + } + // A dead upstream is reported as a gateway failure, never as an empty + // success: a caller must be able to tell the two apart. + response.writeHead(502, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: 'upstream-unavailable' })); + }); + if (replayable) { + proxied.end(); + } else { + request.pipe(proxied); } - // A dead upstream is reported as a gateway failure, never as an empty - // success: a caller must be able to tell the two apart. - response.writeHead(502, { 'content-type': 'application/json; charset=utf-8' }); - response.end(JSON.stringify({ error: 'upstream-unavailable' })); - }); - request.pipe(proxied); + }; + + send(); } /** Resolves a request path to a file inside the root, or null. */ diff --git a/scripts/universe/release.sh b/scripts/universe/release.sh new file mode 100644 index 00000000000..ee9fc5d5a02 --- /dev/null +++ b/scripts/universe/release.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +# +# Installs a Universe Explorer release beside the running one and cuts over to +# it only after every gate passes. +# +# The gates exist because the previous release passed CI and still shipped a +# Charts page and a Mining dashboard whose backend routes were never mounted. +# CI answered from fixtures; nothing checked the deployed configuration. So the +# checks here run against the release that is about to serve traffic, before +# the symlink moves, and the cutover is refused if any of them fails. +# +# Usage: +# release.sh install unpack beside the current release +# release.sh preflight run the gates, change nothing +# release.sh cutover gate, switch, restart, verify +# release.sh rollback point back at a previous release +# +set -euo pipefail + +ROOT=/opt/universe-explorer +RELEASES="$ROOT/releases" +CURRENT="$ROOT/current" +CONF=/etc/universe-explorer +UNITS="universe-explorer-backend universe-explorer-overlay universe-explorer-gateway" +GATEWAY=http://127.0.0.1:8099 +BACKEND=http://127.0.0.1:8996 +OVERLAY=http://127.0.0.1:3400 + +log() { printf '%s %s\n' "$(date -u +%H:%M:%SZ)" "$*"; } +fail() { printf '%s FAILED: %s\n' "$(date -u +%H:%M:%SZ)" "$*" >&2; exit 1; } + +release_dir() { printf '%s/mempool-%s' "$RELEASES" "$1"; } + +# ---------------------------------------------------------------- install ---- + +cmd_install() { + local sha=$1 tarball=$2 + local dir; dir=$(release_dir "$sha") + [ -f "$tarball" ] || fail "no artifact at $tarball" + [ -e "$dir" ] && fail "$dir already exists; releases are never overwritten in place" + + local previous; previous=$(readlink -f "$CURRENT" 2>/dev/null || true) + mkdir -p "$dir" + tar -xzf "$tarball" -C "$dir" + + # Dependencies and the compiled gbt module do not change between most + # releases, so they are hard-linked from the running one. That keeps the + # install quick and the disk flat, and the files stay immutable either way. + if [ -n "$previous" ] && [ -d "$previous/backend/node_modules" ]; then + [ -d "$dir/backend/node_modules" ] || cp -al "$previous/backend/node_modules" "$dir/backend/node_modules" + [ -d "$dir/backend/rust-gbt" ] || cp -al "$previous/backend/rust-gbt" "$dir/backend/rust-gbt" + [ -f "$dir/backend/package.json" ] || cp -a "$previous/backend/package.json" "$dir/backend/package.json" + [ -d "$dir/rust" ] || cp -al "$previous/rust" "$dir/rust" + fi + + printf '%s\n' "$sha" > "$dir/RELEASE-SHA" + chown -R root:universe-explorer "$dir" + chmod -R go-w "$dir" + log "installed $dir" +} + +# --------------------------------------------------------------- preflight --- + +# Reads a JSON value out of the backend configuration without printing secrets. +conf_value() { + python3 -c " +import json,sys +conf=json.load(open('$CONF/backend.json')) +node=conf +for key in sys.argv[1].split('.'): + node=node[key] +print(json.dumps(node)) +" "$1" +} + +gate_release_present() { + local dir=$1 + [ -f "$dir/backend/dist/index.js" ] || fail "release has no backend build" + [ -f "$dir/frontend/build/index.html" ] || fail "release has no frontend build" + [ -f "$dir/scripts/universe/gateway.mjs" ] || fail "release has no gateway" + [ -f "$dir/backend/dist/tasks/pools/pools-v2.json" ] \ + || fail "release has no bundled mining pool metadata, so enabling the database would abort at startup" +} + +# The same combinations the backend refuses at startup, checked before cutover +# so an incoherent configuration never reaches a restart. +gate_configuration() { + local database statistics indexing mempool + database=$(conf_value DATABASE.ENABLED) + statistics=$(conf_value STATISTICS.ENABLED) + indexing=$(conf_value MEMPOOL.INDEXING_BLOCKS_AMOUNT) + mempool=$(conf_value MEMPOOL.ENABLED) + + [ "$statistics" = true ] && [ "$database" != true ] \ + && fail "statistics is on with the database off, so no statistics route would be served" + [ "$statistics" = true ] && [ "$mempool" != true ] \ + && fail "statistics is on with the mempool backend off, so nothing would collect it" + [ "$indexing" != 0 ] && [ "$database" != true ] \ + && fail "block indexing is set with the database off, so no mining route would be served" + log "configuration is coherent" +} + +gate_database() { + [ "$(conf_value DATABASE.ENABLED)" = true ] || { log "database disabled, skipping"; return; } + python3 - <<'PY' || fail "the configured database did not answer" +import json, socket +conf = json.load(open('/etc/universe-explorer/backend.json'))['DATABASE'] +with socket.create_connection((conf['HOST'], conf['PORT']), timeout=5): + pass +PY + log "database accepts connections" +} + +# An address backend configured at a port nothing listens on does not fail +# loudly: it retries. That produced roughly two connection errors a second, +# forever, which buried every real error in the journal. +gate_address_backend() { + local backend; backend=$(conf_value MEMPOOL.BACKEND | tr -d '"') + [ "$backend" = electrum ] || { log "address backend is $backend, nothing to reach"; return; } + python3 - <<'PYGATE' || fail "MEMPOOL.BACKEND is electrum but nothing is listening on the configured Electrum port" +import json, socket +conf = json.load(open('/etc/universe-explorer/backend.json'))['ELECTRUM'] +with socket.create_connection((conf['HOST'], conf['PORT']), timeout=5): + pass +PYGATE + log "the configured Electrum server accepts connections" +} + +gate_sources_parse() { + python3 - <<'PY' || fail "the overlay source registry is not valid" +import json, os, re +env = {} +for line in open('/etc/universe-explorer/overlay.env', encoding='utf-8'): + line = line.rstrip('\n') + if '=' in line and not line.startswith('#'): + key, value = line.split('=', 1) + env[key] = value +raw = env.get('UNIVERSE_EXPLORER_SOURCES_JSON', '') +sources = json.loads(raw) if raw else [] +assert isinstance(sources, list) and sources, 'no sources configured' +for source in sources: + name = source['bearerTokenEnv'] + token = env.get(name, '') + assert len(token.encode()) >= 32, f'token variable {name} is missing or too short' + assert re.match(r'^https?://', source['origin']), 'origin must be an http(s) origin' +print(f'{len(sources)} sources parse, every token variable is present') +PY + log "overlay source registry parses with every token present" +} + +# A protocol the registry calls readable must have an authority configured for +# it, or the protocols page will advertise something nothing can answer. +gate_readable_protocols_have_authorities() { + local dir=$1 + python3 - "$dir" <<'PY' || fail "a readable protocol has no configured authority" +import json, sys, subprocess + +env = {} +for line in open('/etc/universe-explorer/overlay.env', encoding='utf-8'): + if '=' in line and not line.startswith('#'): + key, value = line.rstrip('\n').split('=', 1) + env[key] = value +raw = env.get('UNIVERSE_EXPLORER_SOURCES_JSON', '') +configured = {s['authorityId'] for s in (json.loads(raw) if raw else [])} + +out = subprocess.run( + ['curl', '-sS', '-m', '10', 'http://127.0.0.1:3400/api/v1/universe/protocols'], + capture_output=True, text=True) +if out.returncode != 0 or not out.stdout.strip(): + print('overlay not answering yet, skipping this gate') + sys.exit(0) + +manifest = json.loads(out.stdout) +missing = [ + p['id'] for p in manifest['protocols'] + if p.get('releaseStatus', '').upper().startswith('VERIFIED') + and p.get('indexerAuthority') + and p['indexerAuthority'] not in configured +] +if missing: + print('readable protocols with no configured authority:', ', '.join(sorted(missing))) + sys.exit(1) +print('every readable protocol has a configured authority') +PY + log "readable protocols all have authorities" +} + +cmd_preflight() { + local sha=$1 + local dir; dir=$(release_dir "$sha") + [ -d "$dir" ] || fail "no release at $dir" + gate_release_present "$dir" + gate_configuration + gate_database + gate_address_backend + gate_sources_parse + gate_readable_protocols_have_authorities "$dir" + log "preflight passed for $sha" +} + +# ---------------------------------------------------------------- cutover ---- + +# Waits for a URL to answer 200 within a bounded time. +wait_for() { + local url=$1 name=$2 deadline=$((SECONDS + 90)) + while [ $SECONDS -lt $deadline ]; do + if [ "$(curl -sS -o /dev/null -m 5 -w '%{http_code}' "$url" || true)" = 200 ]; then + log "$name is answering" + return 0 + fi + sleep 2 + done + return 1 +} + +# Returns non-zero rather than exiting, so a failed check can be rolled back +# instead of leaving the new release in place with nothing serving. +verify_live() { + wait_for "$GATEWAY/__gateway/health" gateway || { log "gateway did not come back"; return 1; } + wait_for "$BACKEND/api/v1/backend-info" backend || { log "backend did not come back"; return 1; } + wait_for "$OVERLAY/api/v1/universe/status" overlay || { log "overlay did not come back"; return 1; } + + local served + served=$(curl -sS -m 10 "$BACKEND/api/v1/backend-info" | python3 -c 'import json,sys; print(json.load(sys.stdin)["gitCommit"])') + log "backend reports $served" + + # A public feature must not be advertised by a backend that did not mount its + # routes. This is the exact failure that shipped last time. + python3 - "$BACKEND" <<'PY' || return 1 +import json, subprocess, sys +answer = subprocess.run( + ['curl', '-sS', '-m', '10', sys.argv[1] + '/api/v1/capabilities'], + capture_output=True, text=True) +if answer.returncode != 0 or not answer.stdout.strip(): + print('the capability report could not be read') + sys.exit(1) +report = json.loads(answer.stdout) +bad = [ + name for name, feature in report['features'].items() + if feature['enabled'] and not feature['routesRegistered'] +] +if bad: + print('features enabled with no routes registered:', ', '.join(bad)) + sys.exit(1) +for name, feature in report['features'].items(): + print(f"{name}: enabled={feature['enabled']} routes={feature['routesRegistered']} state={feature['state']}") +PY +} + +cmd_cutover() { + local sha=$1 + local dir; dir=$(release_dir "$sha") + cmd_preflight "$sha" + + local previous; previous=$(readlink -f "$CURRENT" 2>/dev/null || true) + log "previous release: ${previous:-none}" + + # Whether the gateway itself has to come down decides whether this deploy is + # visible at all. The backend and the overlay run from a path baked into + # their unit at exec time, so they must restart; the gateway resolves its + # static root per request, so a frontend change reaches it through the + # symlink with no restart. Restarting it anyway is the only part of a deploy + # that nothing can bridge, so it happens only when its own code changed. + local gateway_changed=no + if [ -n "$previous" ] && [ -f "$previous/scripts/universe/gateway.mjs" ]; then + if ! cmp -s "$previous/scripts/universe/gateway.mjs" "$dir/scripts/universe/gateway.mjs"; then + gateway_changed=yes + fi + else + gateway_changed=yes + fi + + ln -sfn "$dir" "$CURRENT.new" + mv -Tf "$CURRENT.new" "$CURRENT" + log "current now points at $dir" + + systemctl restart universe-explorer-backend universe-explorer-overlay + if [ "$gateway_changed" = yes ]; then + log "the gateway changed, restarting it too; the origin sees a brief gap" + systemctl restart universe-explorer-gateway + else + log "the gateway is unchanged, leaving it up so the origin sees no gap" + fi + if ! verify_live; then + log "verification failed, rolling back" + [ -n "$previous" ] && cmd_rollback "$(basename "$previous" | sed 's/^mempool-//')" + fail "cutover verification failed" + fi + log "cutover to $sha complete" +} + +cmd_rollback() { + local sha=$1 + local dir; dir=$(release_dir "$sha") + [ -d "$dir" ] || fail "no release at $dir to roll back to" + ln -sfn "$dir" "$CURRENT.new" + mv -Tf "$CURRENT.new" "$CURRENT" + # shellcheck disable=SC2086 + systemctl restart $UNITS + verify_live || fail "rollback target did not come back either" + log "rolled back to $sha" +} + +case "${1:-}" in + install) cmd_install "$2" "$3" ;; + preflight) cmd_preflight "$2" ;; + cutover) cmd_cutover "$2" ;; + rollback) cmd_rollback "$2" ;; + *) fail "usage: release.sh {install |preflight |cutover |rollback }" ;; +esac diff --git a/scripts/universe/synthetic-check.mjs b/scripts/universe/synthetic-check.mjs new file mode 100644 index 00000000000..9661ff396ff --- /dev/null +++ b/scripts/universe/synthetic-check.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/** + * Synthetic production check for the three routes that shipped broken. + * + * The visual QA suite answers from fixtures, so it proves how a page renders + * given data and nothing about whether the deployment can produce any. This + * runs against a real origin with no interception: it asks the endpoints those + * pages depend on and fails on the states that reached production unnoticed, + * which all answered HTTP 200 or rendered a page that never finished loading. + * + * Usage: + * node scripts/universe/synthetic-check.mjs [origin] + * + * Exit code 0 when every check passes, 1 otherwise. + */ + +const ORIGIN = (process.argv[2] || process.env.UNIVERSE_ORIGIN || 'https://explorer.bitcoinuniverse.io').replace(/\/+$/, ''); +const REQUEST_TIMEOUT_MS = 20_000; + +const failures = []; +const notes = []; + +function fail(check, detail) { + failures.push(`${check}: ${detail}`); +} + +function pass(check, detail) { + notes.push(`${check}: ${detail}`); +} + +async function get(path) { + const response = await fetch(`${ORIGIN}${path}`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const text = await response.text(); + let body = null; + try { + body = JSON.parse(text); + } catch { + body = null; + } + return { status: response.status, body, text }; +} + +/** + * A feature the deployment advertises must have its routes mounted. This is + * the exact contradiction that put a Charts page in front of routes that + * answered 404. + */ +async function checkCapabilities() { + const { status, body } = await get('/api/v1/capabilities'); + if (status !== 200 || !body?.features) { + fail('capabilities', `expected a report, got HTTP ${status}`); + return null; + } + for (const [name, feature] of Object.entries(body.features)) { + if (feature.enabled && !feature.routesRegistered) { + fail('capabilities', `${name} is enabled but its routes were never registered`); + } else { + pass('capabilities', `${name} enabled=${feature.enabled} state=${feature.state}`); + } + } + return body; +} + +/** + * Every route a page depends on must exist. A 404 here is a deployment + * failure, never an empty state. + */ +async function checkRoutesExist(label, paths) { + for (const path of paths) { + const { status } = await get(path); + if (status === 404) { + fail(label, `${path} is not served by this deployment`); + } else if (status >= 500) { + fail(label, `${path} answered HTTP ${status}`); + } else if (status !== 200) { + fail(label, `${path} answered HTTP ${status}`); + } else { + pass(label, `${path} answers 200`); + } + } +} + +/** The live chart must have real samples, not merely a route that answers. */ +async function checkStatisticsHaveContent() { + const { status, body } = await get('/api/v1/statistics/2h'); + if (status !== 200 || !Array.isArray(body)) { + fail('charts', `the live range did not answer with a series (HTTP ${status})`); + return; + } + if (body.length === 0) { + fail('charts', 'the live range is empty, so the chart has nothing to draw'); + return; + } + const sample = body[0]; + if (typeof sample.added !== 'number' || !Array.isArray(sample.vsizes)) { + fail('charts', 'a sample did not have the shape the chart renders'); + return; + } + pass('charts', `the live range has ${body.length} samples`); +} + +/** Reward stats must be real numbers, not zeroes standing in for a failure. */ +async function checkMiningHasContent() { + const { status, body } = await get('/api/v1/mining/reward-stats/144'); + if (status !== 200 || !body) { + fail('mining', `reward stats did not answer (HTTP ${status})`); + return; + } + const reward = Number(body.totalReward); + if (!Number.isFinite(reward) || reward <= 0) { + fail('mining', `reward stats reported a total of ${body.totalReward}`); + } else { + pass('mining', `reward stats total ${body.totalReward} over blocks ${body.startBlock} to ${body.endBlock}`); + } + + const pools = await get('/api/v1/mining/pools/1w'); + if (pools.status !== 200 || !Array.isArray(pools.body?.pools)) { + fail('mining', `pool ranking did not answer with pools (HTTP ${pools.status})`); + } else if (pools.body.pools.length === 0) { + fail('mining', 'pool ranking is empty, so the page has no pools to rank'); + } else { + pass('mining', `pool ranking lists ${pools.body.pools.length} pools`); + } +} + +/** + * No protocol may be presented as readable while its authority cannot answer. + * This is the Protocols page failure, checked at its source. + */ +async function checkProtocolsAreTruthful() { + const protocols = await get('/api/v1/universe/protocols'); + const sources = await get('/api/v1/universe/sources'); + if (protocols.status !== 200 || !Array.isArray(protocols.body?.protocols)) { + fail('protocols', `the registry did not answer (HTTP ${protocols.status})`); + return; + } + if (sources.status !== 200 || !Array.isArray(sources.body?.sources)) { + fail('protocols', `the source snapshot did not answer (HTTP ${sources.status})`); + return; + } + + const byAuthority = new Map(sources.body.sources.map((row) => [row.authorityId, row])); + for (const protocol of protocols.body.protocols) { + if (!String(protocol.releaseStatus || '').toUpperCase().startsWith('VERIFIED')) continue; + const source = protocol.indexerAuthority ? byAuthority.get(protocol.indexerAuthority) : null; + if (!source) { + fail('protocols', `${protocol.id} is marked readable but its authority is not configured`); + continue; + } + if (source.status === 'unreachable' || source.status === 'degraded') { + fail('protocols', `${protocol.id} is marked readable but its authority is ${source.status}`); + continue; + } + pass('protocols', `${protocol.id} authority ${source.authorityId} is ${source.status}, ${source.lagBlocks ?? 'unknown'} blocks behind`); + } + + for (const source of sources.body.sources) { + if (source.status === 'unconfigured') continue; + if (!source.checkpoint) { + fail('protocols', `${source.authorityId} is configured but published no checkpoint`); + } + } +} + +/** Frontend, backend and the published release must be the same build. */ +async function checkReleaseAgreement() { + const info = await get('/api/v1/backend-info'); + if (info.status !== 200 || !info.body?.gitCommit) { + fail('release', `the backend did not report its commit (HTTP ${info.status})`); + return; + } + const config = await fetch(`${ORIGIN}/resources/config.js`, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }).then((response) => response.text()); + const match = config.match(/GIT_COMMIT_HASH\s*=\s*'([^']+)'/); + if (!match) { + fail('release', 'the frontend did not publish a commit'); + return; + } + if (match[1] !== info.body.gitCommit) { + fail('release', `the frontend serves ${match[1]} while the backend runs ${info.body.gitCommit}`); + } else { + pass('release', `frontend and backend both report ${match[1]}`); + } +} + +async function main() { + console.log(`Synthetic check against ${ORIGIN}`); + + const capabilities = await checkCapabilities(); + await checkReleaseAgreement(); + await checkProtocolsAreTruthful(); + + if (capabilities?.features?.statistics?.enabled) { + await checkRoutesExist('charts', [ + '/api/v1/statistics/2h', + '/api/v1/statistics/24h', + '/api/v1/statistics/1w', + ]); + await checkStatisticsHaveContent(); + } else { + notes.push('charts: statistics are switched off in this deployment, skipping'); + } + + if (capabilities?.features?.mining?.enabled) { + await checkRoutesExist('mining', [ + '/api/v1/mining/reward-stats/144', + '/api/v1/mining/pools/1w', + '/api/v1/mining/hashrate/3d', + '/api/v1/mining/difficulty-adjustments', + ]); + await checkMiningHasContent(); + } else { + notes.push('mining: block indexing is switched off in this deployment, skipping'); + } + + for (const note of notes) console.log(` ok ${note}`); + for (const failure of failures) console.error(` FAIL ${failure}`); + + if (failures.length > 0) { + console.error(`\n${failures.length} synthetic check(s) failed against ${ORIGIN}`); + process.exit(1); + } + console.log(`\nAll synthetic checks passed against ${ORIGIN}`); +} + +main().catch((error) => { + console.error(`Synthetic check could not run: ${error instanceof Error ? error.message : error}`); + process.exit(1); +}); diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index 0d3344e64c3..ec6eb9b7057 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -12,8 +12,15 @@ * - no console errors * - no images that failed to load * - no accessibility violations at WCAG 2.2 A/AA + * - no loader or skeleton still on screen once the page has settled + * - no chart panel that drew nothing while its fixture has data + * - an explicit status panel in every failure state, rather than a wait * and writes a screenshot for the visual comparison. * + * The last three exist because a Charts page that never resolved and a Mining + * dashboard full of skeletons passed every other check in this list and + * shipped. + * * Usage: * node capture.mjs full matrix, chromium * node capture.mjs --browser=firefox another engine @@ -29,6 +36,7 @@ import AxeBuilder from '@axe-core/playwright'; import * as playwright from 'playwright'; import { addressFixtures, detailFixtures, fixtures, sampleIds, stateOverrides } from './fixtures.mjs'; import { contrastProbe } from './contrast-probe.mjs'; +import { progressProbe } from './progress-probe.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -72,6 +80,14 @@ const VIEWPORTS = [ const THEMES = ['default', 'dark', 'contrast']; +/** + * How long a page may take to stop showing loaders before the run calls it + * unfinished. Generous, because a loaded CI machine is slower than a laptop + * and the thing being measured is whether the page ever finishes, not how + * fast the machine is. + */ +const SETTLE_DEADLINE_MS = 15_000; + const STATES = ['populated', ...Object.keys(stateOverrides)]; function pick(list, key, idKey = 'id') { @@ -327,12 +343,34 @@ async function run() { contrast = { text: [], painted: [], canvas: [], sampled: 0, error: String(e).slice(0, 200) }; } + // A fixed pause is a race, not a deadline: on a loaded machine a + // page that finishes perfectly well can still be mid-render when + // the probe fires. Wait for it to settle, up to a real deadline, + // and record how long it took. What fails the run is a page that + // never settles, which is the actual requirement. + let progress; + let settledAfterMs = null; + try { + const deadline = Date.now() + SETTLE_DEADLINE_MS; + for (;;) { + progress = await page.evaluate(progressProbe); + const busy = (progress.spinners?.length ?? 0) > 0 || (progress.skeletons ?? 0) > 0; + if (!busy || Date.now() >= deadline) { + settledAfterMs = busy ? null : Date.now() - (deadline - SETTLE_DEADLINE_MS); + break; + } + await page.waitForTimeout(250); + } + } catch (e) { + progress = { spinners: [], skeletons: 0, charts: [], statusPanels: [], loadingAnnouncements: [], textLength: 0, error: String(e).slice(0, 200) }; + } + await page.screenshot({ path: join(OUT, `${label}.png`), fullPage: Boolean(args.fullPage) }); shots++; findings.push({ route: route.id, routeName: route.name, state, theme, viewport: viewport.id, - overflowBy, consoleErrors, brokenImages, violations, contrast, + overflowBy, consoleErrors, brokenImages, violations, contrast, progress, settledAfterMs, }); } catch (error) { findings.push({ @@ -352,7 +390,84 @@ async function run() { const report = { browser: BROWSER, base: BASE, screenshots: shots, findings }; writeFileSync(join(OUT, `report-${BROWSER}.json`), JSON.stringify(report, null, 2)); - summarise(report); + const stuck = summarise(report); + if (stuck.length > 0) { + console.error(`${stuck.length} page(s) never finished loading. This is the failure that shipped last time, so it fails the run.`); + process.exitCode = 1; + } +} + +/** + * Turns the progress probe into failures. + * + * A populated fixture leaves no excuse: the data is there, so a loader still + * on screen or a chart that drew nothing is the interface failing to finish. + * A failure fixture has the opposite obligation: the page must say what + * happened rather than wait. + */ + +/** + * Routes whose request lifecycle has been reviewed and is expected to reach a + * terminal state. A finding on one of these fails the run. + * + * The gate reports findings on every route, but only blocks on these. Adding a + * route here is how a finding on an uncovered route gets finished: fix the + * page, add the route, and the gate holds it forever after. Home, blocks, + * transaction and address joined once their waiting and failure states said + * something instead of holding a bare placeholder. + */ +export const GATED_ROUTES = new Set(['graphs', 'mining', 'protocols', 'home', 'blocks', 'tx', 'address']); + +export function progressFailures(report) { + const failures = []; + for (const f of report.findings) { + const progress = f.progress; + if (!progress) continue; + const where = `${f.route}/${f.state}/${f.theme}@${f.viewport}`; + + if (f.state === 'populated') { + if (progress.spinners?.length) { + failures.push(`${where}: never stopped loading (${progress.spinners.join(', ')})`); + } + if (progress.skeletons > 0) { + failures.push(`${where}: ${progress.skeletons} skeleton(s) never resolved`); + } + if (progress.skeletonOnly) { + failures.push(`${where}: the page is placeholders and almost no text`); + } + for (const chart of progress.charts ?? []) { + if (chart.drewNothing) { + failures.push(`${where}: chart ${chart.selector} (${chart.width}x${chart.height}) drew nothing`); + } + } + } else if (f.state === 'loading') { + // This fixture holds every request open on purpose, to photograph the + // waiting state. Asking it to have finished would be asking the wrong + // question; what matters is that the wait is announced rather than being + // a blank rectangle. The deadline itself is covered by the unit tests + // around the request lifecycle, which run far longer than this harness + // waits. + // Only a page that is actually holding placeholders is waiting. A page + // with nothing to fetch renders normally under this fixture and owes the + // reader no loader at all. + const waiting = (progress.skeletons ?? 0) > 0; + const announced = progress.spinners?.length + || progress.statusPanels?.length + || progress.loadingAnnouncements?.length; + if (waiting && !announced) { + failures.push(`${where}: waiting with nothing on screen that says so`); + } + } else { + // Every failure fixture must reach a state that says something. A page + // that is still spinning has not answered the user at all. + if (progress.spinners?.length || progress.skeletons > 0) { + if (!progress.statusPanels?.length) { + failures.push(`${where}: still waiting with nothing said about why`); + } + } + } + } + return failures; } function summarise(report) { @@ -447,7 +562,31 @@ function summarise(report) { console.log('\n-- navigation --'); for (const f of failed.slice(0, 15)) console.log(` ${f.route} ${f.state} ${f.theme} @${f.viewport}: ${f.error}`); } + + const stuck = progressFailures(report); + const blocking = stuck.filter((line) => GATED_ROUTES.has(line.split('/')[0])); + const known = stuck.filter((line) => !GATED_ROUTES.has(line.split('/')[0])); + + console.log(`\nunfinished pages : ${stuck.length} (blocking ${blocking.length})`); + if (blocking.length) { + console.log('-- pages that never finished, on routes this gate holds --'); + for (const line of blocking.slice(0, 40)) console.log(` ${line}`); + if (blocking.length > 40) console.log(` ... and ${blocking.length - 40} more, see the report`); + } + if (known.length) { + // Printed every run, never suppressed. These are real, they were found by + // this gate, and they are waiting for the same treatment the gated routes + // have had. + console.log('-- the same fault on routes not yet covered, known work --'); + for (const line of known.slice(0, 40)) console.log(` ${line}`); + if (known.length > 40) console.log(` ... and ${known.length - 40} more, see the report`); + } console.log(''); + return blocking; } -run().catch((e) => { console.error(e); process.exit(1); }); +// Only drive browsers when this file is the program. Importing it, as the +// gate's own test does, must not launch the matrix. +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + run().catch((e) => { console.error(e); process.exit(1); }); +} diff --git a/scripts/universe/visual-qa/progress-gate.test.mjs b/scripts/universe/visual-qa/progress-gate.test.mjs new file mode 100644 index 00000000000..3e29b9386be --- /dev/null +++ b/scripts/universe/visual-qa/progress-gate.test.mjs @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { progressFailures } from './capture.mjs'; + +/** + * The gate that would have stopped the release. + * + * A Charts page whose spinner never stopped and a Mining dashboard of 35 + * skeletons passed every other check in the matrix. These cases assert the new + * one actually fires, and that it does not fire on a page that finished. + */ + +const at = (overrides) => ({ + findings: [{ + route: 'graphs', routeName: 'Graphs', state: 'populated', + theme: 'default', viewport: '1440', + progress: { spinners: [], skeletons: 0, charts: [], statusPanels: [], loadingAnnouncements: [], textLength: 900, skeletonOnly: false }, + ...overrides, + }], +}); + +const withProgress = (state, progress) => at({ + state, + progress: { spinners: [], skeletons: 0, charts: [], statusPanels: [], loadingAnnouncements: [], textLength: 900, skeletonOnly: false, ...progress }, +}); + +test('a populated page that finished raises nothing', () => { + assert.deepEqual(progressFailures(withProgress('populated', { charts: [{ selector: 'div.chart', width: 800, height: 500, svgMarks: 40, canvasCount: 0, canvasPixels: 0, drewNothing: false }] })), []); +}); + +test('a spinner still turning on a populated page fails the run', () => { + const failures = progressFailures(withProgress('populated', { spinners: ['div.spinner-border'] })); + assert.equal(failures.length, 1); + assert.match(failures[0], /never stopped loading/); +}); + +test('skeletons that never resolved fail the run', () => { + const failures = progressFailures(withProgress('populated', { skeletons: 35 })); + assert.equal(failures.length, 1); + assert.match(failures[0], /35 skeleton/); +}); + +test('a chart panel that drew nothing fails the run', () => { + const failures = progressFailures(withProgress('populated', { + charts: [{ selector: 'div.chart', width: 800, height: 500, svgMarks: 0, canvasCount: 0, canvasPixels: 0, drewNothing: true }], + })); + assert.equal(failures.length, 1); + assert.match(failures[0], /drew nothing/); +}); + +test('a page that is only placeholders fails the run', () => { + const failures = progressFailures(withProgress('populated', { skeletons: 4, textLength: 60, skeletonOnly: true })); + assert.ok(failures.some((line) => /placeholders/.test(line))); +}); + +test('a failure state that waits without saying why fails the run', () => { + const failures = progressFailures(withProgress('chain-down', { spinners: ['div.spinner-border'], statusPanels: [] })); + assert.equal(failures.length, 1); + assert.match(failures[0], /nothing said about why/); +}); + +test('a failure state that says what happened is accepted', () => { + assert.deepEqual( + progressFailures(withProgress('chain-down', { + spinners: ['div.load-status-spinner'], + statusPanels: ['The service behind this panel is unavailable. Retry'], + })), + [], + ); +}); + +test('an empty range on a failure state is not treated as unfinished', () => { + assert.deepEqual(progressFailures(withProgress('authority-empty', {})), []); +}); + +test('a finding with no probe at all is skipped rather than guessed at', () => { + assert.deepEqual(progressFailures({ findings: [{ route: 'graphs', state: 'populated' }] }), []); +}); + +test('the loading fixture is judged on whether the wait is announced, not on having finished', () => { + // This fixture holds every request open on purpose. Asking it to have + // finished would be asking the wrong question. + assert.deepEqual( + progressFailures(withProgress('loading', { skeletons: 6, loadingAnnouncements: ['Loading protocol registry'] })), + [], + ); + assert.deepEqual( + progressFailures(withProgress('loading', { spinners: ['div.load-status-spinner'] })), + [], + ); +}); + +test('a page that waits with nothing on screen saying so fails the run', () => { + const failures = progressFailures(withProgress('loading', { skeletons: 6, loadingAnnouncements: [] })); + assert.equal(failures.length, 1); + assert.match(failures[0], /nothing on screen that says so/); +}); + +test('a page with nothing to fetch owes the reader no loader', () => { + // The docs and source pages render from what is already in the bundle. Under + // the loading fixture they simply appear, and demanding a spinner from them + // was the rule failing a page for having nothing to wait for. + assert.deepEqual( + progressFailures(withProgress('loading', { skeletons: 0, spinners: [], loadingAnnouncements: [] })), + [], + ); +}); diff --git a/scripts/universe/visual-qa/progress-probe.mjs b/scripts/universe/visual-qa/progress-probe.mjs new file mode 100644 index 00000000000..cd78c878bcd --- /dev/null +++ b/scripts/universe/visual-qa/progress-probe.mjs @@ -0,0 +1,125 @@ +/** + * Measures whether a page finished, rather than whether it rendered. + * + * The matrix already checked overflow, console errors, contrast and + * accessibility. A Charts page that never resolved and a Mining dashboard full + * of skeletons passed all four: nothing overflowed, nothing logged, the + * skeletons had fine contrast, and axe is happy with a placeholder. This probe + * is what would have caught them. + * + * Runs in the page. Returns plain data for the harness to judge. + */ +export function progressProbe() { + /** + * Whether a reader can actually see this element. + * + * Checking only display and visibility on the element itself is not enough: + * the block overview keeps its loader in the tree and hides the wrapper with + * opacity, so a spinner nobody can see was being counted as a page that + * never finished. checkVisibility walks the ancestors and understands + * opacity; the manual checks stay as a fallback where it is missing. + */ + const visible = (element) => { + if (typeof element.checkVisibility === 'function') { + return element.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + contentVisibilityAuto: true, + }); + } + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + const style = getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; + }; + + /** + * Whether the element is somewhere the reader is actually looking. + * + * Angular defers work until it scrolls into view, so a block page keeps a + * transaction placeholder below the fold on purpose until the reader gets + * there. Counting that as a page that never finished would fail a release + * for lazy loading correctly. The harness judges a viewport, so this does + * too, with a margin for content just under the edge. + */ + const inViewport = (element) => { + const rect = element.getBoundingClientRect(); + const margin = 200; + return rect.bottom > -margin + && rect.top < window.innerHeight + margin + && rect.right > -margin + && rect.left < window.innerWidth + margin; + }; + + const onScreen = (element) => visible(element) && inViewport(element); + + const selectorFor = (element) => { + const id = element.id ? `#${element.id}` : ''; + const cls = typeof element.className === 'string' && element.className + ? '.' + element.className.trim().split(/\s+/).slice(0, 2).join('.') + : ''; + return `${element.tagName.toLowerCase()}${id}${cls}`; + }; + + // Placeholders only. app-loading-indicator is deliberately excluded: it is a + // labelled progress banner, "Indexing blocks" and the like, which is supposed + // to stay on screen for as long as the work runs. Treating it as a page that + // never finished would fail a deployment for telling the truth about a long + // job, which is the opposite of what this gate is for. + const spinners = [...document.querySelectorAll( + '.spinner-border, .load-status-spinner, .loadingGraphs', + )].filter(onScreen); + const skeletons = [...document.querySelectorAll('.skeleton-loader, .skeleton-row')].filter(onScreen); + + // An ECharts host that drew no series is a blank panel. It is not detectable + // from the outside: the container has a size and a background like any other. + const charts = [...document.querySelectorAll('div[_echarts_instance_]')] + .filter(onScreen) + .map((host) => { + const svgMarks = host.querySelectorAll('svg path, svg rect, svg circle, svg polyline').length; + const canvases = [...host.querySelectorAll('canvas')]; + const canvasPixels = canvases.reduce((total, canvas) => total + canvas.width * canvas.height, 0); + const rect = host.getBoundingClientRect(); + return { + selector: selectorFor(host), + width: Math.round(rect.width), + height: Math.round(rect.height), + svgMarks, + canvasCount: canvases.length, + canvasPixels, + // A title is how ECharts renders "no data", so a host with a title and + // no marks is deliberately empty rather than broken. + hasTitleOnly: svgMarks > 0 && svgMarks < 4, + drewNothing: svgMarks === 0 && canvasPixels === 0, + }; + }); + + // A page that is nothing but placeholders has not loaded, however healthy it + // looks to every other check. + const main = document.querySelector('main') || document.body; + const text = (main.innerText || '').replace(/\s+/g, ' ').trim(); + + // The explicit states the interface is required to reach instead of waiting. + const statusPanels = [...document.querySelectorAll('.load-status, .load-status-stale')] + .filter(visible) + .map((panel) => panel.innerText.replace(/\s+/g, ' ').trim().slice(0, 160)); + + // What a page says while it is still working. A skeleton on its own says + // nothing to a screen reader, so the announcement counts separately from the + // placeholders themselves. + const loadingAnnouncements = [...document.querySelectorAll('.sr-only, [role="status"], [aria-live]')] + .map((element) => (element.textContent || '').replace(/\s+/g, ' ').trim()) + .filter((text) => /load|wait|fetch/i.test(text)); + + return { + spinners: spinners.map(selectorFor), + loadingAnnouncements, + skeletons: skeletons.length, + charts, + statusPanels, + textLength: text.length, + // Placeholders outnumbering real text is the shape of a page that never + // finished, as opposed to one that finished with little to say. + skeletonOnly: skeletons.length > 0 && text.length < 400, + }; +}