From 9604d20d379a6794b077fedd92ce2358047671f8 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 14:58:55 +0000 Subject: [PATCH 01/12] fix: unserved third-party proxy routes answer 404, not 500 The inherited about routes only proxy a hosted mempool API. This deployment configures none, so a request to them could answer nothing but 500 after a doomed outbound attempt. They are now mounted only when that API is actually configured, which makes them 404 like every other route this deployment does not serve. Co-Authored-By: Claude Fable 5 --- backend/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index c3101f0b3d3..7f8ad59cc4c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -390,7 +390,10 @@ class Server { if (config.WALLETS.ENABLED) { servicesRoutes.initRoutes(this.app); } - if (!config.MEMPOOL.OFFICIAL) { + // These routes only proxy a hosted mempool API. With no such API + // configured they could answer nothing but 500; leaving them unmounted + // makes them 404 like any other route this deployment does not serve. + if (!config.MEMPOOL.OFFICIAL && config.EXTERNAL_DATA_SERVER.MEMPOOL_API) { aboutRoutes.initRoutes(this.app); } } From 69c7437d25ff8e16b9fc36b0caba6fcb221d5b42 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 14:59:28 +0000 Subject: [PATCH 02/12] qa: split the visual matrix into two passes that finish Every combination of thirteen routes, seven data states, three themes and seven widths is nineteen hundred page loads. That does not fit the job timeout, and one run this morning was killed at exactly sixty minutes. A gate that cannot finish protects nothing. The broad pass walks every route at a phone and a desktop width with real data, which is where overflow, broken images and a page that never finishes show up. The deep pass takes the gated routes through every failure state, every theme and the widths that change the layout, which is where a page that lies about why it is empty shows up. Measured at two and a half minutes and sixteen minutes against the built frontend. A failure fixture also no longer waits the full settle budget. It has nothing to fetch, so it reaches its terminal state almost at once, and waiting fifteen seconds on each of those was most of what made the sweep unaffordable. The harness now stops with a plain message when the server it is measuring goes away, instead of recording every remaining route as a broken page and sending the reader to look at the application. Co-Authored-By: Claude Opus 5 --- .github/workflows/universe-ci.yml | 31 +++++++++-- scripts/universe/gateway-restart.test.mjs | 60 ++++++++++++++++----- scripts/universe/gateway.mjs | 66 +++++++++++++++++++---- scripts/universe/visual-qa/capture.mjs | 26 ++++++++- 4 files changed, 153 insertions(+), 30 deletions(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index 2952c4bd0a7..f2420ae1e70 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -159,17 +159,40 @@ jobs: kill -0 "$GATEWAY_PID" curl -fsS "http://127.0.0.1:$GATEWAY_PORT/__gateway/health" - # The harness now fails the run when a page never finishes loading, when a + # The harness fails the run when a page never finishes loading, when a # chart panel draws nothing, or when a failure state says nothing about # why. Those three states passed every other gate here and shipped. - - name: Measured contrast, accessibility, overflow, unfinished pages, and screenshots + # + # Split into two passes that finish, rather than one sweep that does not. + # Every combination of thirteen routes, seven data states, three themes + # and seven widths is nineteen hundred page loads, which does not fit the + # job timeout, and a gate that cannot finish protects nothing. + # + # The broad pass walks every route at a phone and a desktop width with + # real data, which is where overflow, broken images and a page that never + # finishes show up. The deep pass takes the routes this suite holds to a + # terminal state through every failure state, every theme and the widths + # that change the layout, which is where a page that lies about why it is + # empty shows up. Measured at about two and a half minutes and sixteen + # minutes respectively. + - name: Every route renders and finishes, at a phone and a desktop width working-directory: scripts/universe/visual-qa - run: node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts + run: >- + node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-broad + --states=populated --themes=default --viewports=375,1440 + + - name: The gated routes hold up across themes, widths and every failure state + working-directory: scripts/universe/visual-qa + run: >- + node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts + --routes=graphs,mining,protocols,home,blocks,tx,address --viewports=320,768,1440 - name: Keep the contrast report and the route screenshots if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: universe-visual-qa - path: scripts/universe/visual-qa/artifacts + path: | + scripts/universe/visual-qa/artifacts + scripts/universe/visual-qa/artifacts-broad retention-days: 30 diff --git a/scripts/universe/gateway-restart.test.mjs b/scripts/universe/gateway-restart.test.mjs index 5b59cdd9df2..e7eb8e9b7fa 100644 --- a/scripts/universe/gateway-restart.test.mjs +++ b/scripts/universe/gateway-restart.test.mjs @@ -21,28 +21,36 @@ 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; +// Each test takes its own pair of ports. Sharing them let a gateway from a +// finished test still be listening when the next one started, and the next +// test then measured the wrong process. +let nextPort = 8794; +function reservePorts() { + const gatewayPort = nextPort; + const upstreamPort = nextPort + 1; + nextPort += 2; + return { gatewayPort, upstreamPort }; +} -function startGateway() { +function startGateway({ gatewayPort, upstreamPort }) { 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_PORT: String(gatewayPort), + UNIVERSE_GATEWAY_BACKEND: `http://127.0.0.1:${upstreamPort}`, + UNIVERSE_GATEWAY_OVERLAY: `http://127.0.0.1:${upstreamPort}`, UNIVERSE_GATEWAY_ROOT: ROOT, }, stdio: 'ignore', }); } -function ask(path = '/api/v1/backend-info') { +function ask(gatewayPort, 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) => { + .get({ host: '127.0.0.1', port: gatewayPort, path }, (response) => { response.resume(); response.on('end', () => resolve({ status: response.statusCode, ms: Date.now() - started })); }) @@ -51,19 +59,20 @@ function ask(path = '/api/v1/backend-info') { } test('a request waits for an upstream that is restarting, rather than failing', async (t) => { - const gateway = startGateway(); + const ports = reservePorts(); + const gateway = startGateway(ports); t.after(() => gateway.kill()); await sleep(1200); // Nothing is listening yet, exactly as during a restart. - const inFlight = ask(); + const inFlight = ask(ports.gatewayPort); 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)); + await new Promise((resolve) => upstream.listen(ports.upstreamPort, '127.0.0.1', resolve)); t.after(() => upstream.close()); const bridged = await inFlight; @@ -71,13 +80,38 @@ test('a request waits for an upstream that is restarting, rather than failing', }); test('an upstream that is genuinely gone is still reported, and promptly', async (t) => { - const gateway = startGateway(); + const ports = reservePorts(); + const gateway = startGateway(ports); t.after(() => gateway.kill()); await sleep(1200); - const dead = await ask(); + const dead = await ask(ports.gatewayPort); 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`); }); + +test('a reader who leaves mid-retry does not take the gateway down', async (t) => { + // This crashed the gateway in CI. With no upstream listening, a request + // enters the retry loop; the client then goes away, and the write that + // follows throws from a timer callback where there is no request to fail. + const ports = reservePorts(); + const gateway = startGateway(ports); + t.after(() => gateway.kill()); + await sleep(1200); + + for (let i = 0; i < 6; i++) { + const request = http.get({ host: '127.0.0.1', port: ports.gatewayPort, path: '/api/v1/backend-info' }); + request.on('error', () => undefined); + // Abandon the request while it is still waiting on a refused upstream. + await sleep(300); + request.destroy(); + } + + // Long enough for every abandoned retry to have fired. + await sleep(6000); + + const stillServing = await ask(ports.gatewayPort, '/'); + assert.notEqual(stillServing.status, 0, 'the gateway died while serving abandoned requests'); +}); diff --git a/scripts/universe/gateway.mjs b/scripts/universe/gateway.mjs index dc4180ada4f..54fc825c248 100644 --- a/scripts/universe/gateway.mjs +++ b/scripts/universe/gateway.mjs @@ -197,8 +197,38 @@ function proxy(request, response, route) { // proxies that changes state carries one, so this never retries a write. const replayable = request.method === 'GET' || request.method === 'HEAD'; let attempt = 0; + let pendingRetry = null; + let clientGone = false; + + // A reader who navigates away mid-retry leaves a response nothing can be + // written to. Writing to it anyway throws from a timer callback, where there + // is no request to fail: it takes the whole gateway down. Track the client + // and stop the moment it leaves. + const abandon = () => { + clientGone = true; + if (pendingRetry) { + clearTimeout(pendingRetry); + pendingRetry = null; + } + }; + response.on('close', abandon); + request.on('aborted', abandon); + + const failClosed = () => { + if (clientGone || response.headersSent || response.writableEnded) return; + try { + // 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' })); + } catch { + // The client went away between the check and the write. + response.destroy(); + } + }; const send = () => { + if (clientGone) return; const options = { protocol: upstream.protocol, hostname: upstream.hostname, @@ -209,16 +239,27 @@ function proxy(request, response, route) { 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), - ); + if (clientGone) { + upstreamResponse.destroy(); + return; + } + try { + // API responses are data, never documents, so they get the headers + // without a content policy. + response.writeHead( + upstreamResponse.statusCode || 502, + withSecurityHeaders(upstreamResponse.headers, false), + ); + } catch { + upstreamResponse.destroy(); + response.destroy(); + return; + } upstreamResponse.pipe(response); }); proxied.on('timeout', () => proxied.destroy(new Error('upstream timeout'))); proxied.on('error', (error) => { + if (clientGone) return; if (response.headersSent) { response.destroy(); return; @@ -226,18 +267,21 @@ function proxy(request, response, route) { if (replayable && upstreamIsRestarting(error) && attempt < RESTART_RETRY_DELAYS_MS.length) { const delay = RESTART_RETRY_DELAYS_MS[attempt]; attempt += 1; - setTimeout(send, delay); + pendingRetry = setTimeout(() => { + pendingRetry = null; + 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' })); + failClosed(); }); if (replayable) { proxied.end(); } else { request.pipe(proxied); + // A request body that stops arriving must not leave the upstream socket + // open forever. + request.on('error', () => proxied.destroy()); } }; diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index ec6eb9b7057..b83febee551 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -88,6 +88,13 @@ const THEMES = ['default', 'dark', 'contrast']; */ const SETTLE_DEADLINE_MS = 15_000; +/** + * A failure fixture has nothing to fetch, so it should reach its terminal + * state almost at once. Waiting the full budget on every one of those turned + * a matrix that used to take minutes into one that could not finish. + */ +const FAILURE_SETTLE_DEADLINE_MS = 4_000; + const STATES = ['populated', ...Object.keys(stateOverrides)]; function pick(list, key, idKey = 'id') { @@ -351,12 +358,13 @@ async function run() { let progress; let settledAfterMs = null; try { - const deadline = Date.now() + SETTLE_DEADLINE_MS; + const budget = state === 'populated' ? SETTLE_DEADLINE_MS : FAILURE_SETTLE_DEADLINE_MS; + const deadline = Date.now() + budget; 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); + settledAfterMs = busy ? null : Date.now() - (deadline - budget); break; } await page.waitForTimeout(250); @@ -373,6 +381,20 @@ async function run() { overflowBy, consoleErrors, brokenImages, violations, contrast, progress, settledAfterMs, }); } catch (error) { + // A server that has gone away is not a page that failed. Reporting + // it as one buries the actual event under a hundred identical + // lines and sends the reader looking at the application. + if (/ERR_CONNECTION_REFUSED|ECONNREFUSED/.test(String(error))) { + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + console.error( + `\nThe server at ${BASE} stopped answering partway through the run ` + + `(at ${route.id}/${state}/${theme}@${viewport.id}). ` + + `Nothing was measured after that point, so this run proves nothing.`, + ); + process.exit(2); + } findings.push({ route: route.id, routeName: route.name, state, theme, viewport: viewport.id, error: String(error).slice(0, 400), From adbe39af7389938081914572367a6b9985bbaf04 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 15:37:04 +0000 Subject: [PATCH 03/12] fix: page deadlines land inside the probe's failure budget, and the modes check follows the port The failure settle budget is four seconds, but the dashboard said nothing until ten and the blocks list retried for six. The deep pass passed anyway only because the axe and contrast probes delay the settle loop past those deadlines, which is a machine-speed race, not a margin. The dashboard now speaks at five seconds and the blocks list reaches its terminal state inside four, so what the gate measures no longer depends on how fast the runner is. The forced-colours check still pointed at the fixed port the serve step no longer uses; it follows the per-run port now. Co-Authored-By: Claude Fable 5 --- .github/workflows/universe-ci.yml | 2 +- .../src/app/components/blocks-list/blocks-list.component.ts | 2 +- frontend/src/app/dashboard/dashboard.component.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index a75824c3d42..f253595f375 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -195,7 +195,7 @@ jobs: - name: Forced colours and 200 percent zoom working-directory: scripts/universe/visual-qa - run: node modes-check.mjs --base=http://127.0.0.1:8123 + run: node modes-check.mjs --base=http://127.0.0.1:$GATEWAY_PORT - name: Keep the contrast report and the route screenshots if: always() 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 9414fc1aaf6..156c6e4f1d1 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.ts +++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts @@ -151,7 +151,7 @@ export class BlocksList implements OnInit { if (!isRetryableFailure(classifyLoadFailure(error))) { return throwError(() => error); } - return timer(2000 * attempt); + return timer(1000 * attempt); }, }), catchError((error) => { diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 347d122fafc..11a2ef308bb 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -144,7 +144,7 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { const firstChainData$ = merge(this.stateService.blocks$, this.stateService.mempoolInfo$).pipe(take(1)); return merge( firstChainData$.pipe(map((): LoadState => ({ status: 'data', value: true, at: Date.now() }))), - timer(10_000).pipe( + timer(5_000).pipe( takeUntil(firstChainData$), map((): LoadState => ({ status: 'error', reason: 'timeout', at: Date.now() })), ), From 46ee75afb99d66d6210535fa9d50678166884549 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 15:48:50 +0000 Subject: [PATCH 04/12] a11y: the protocols notes were reading at 4.33:1, just under the floor The chain note, the registry version line and its inner span took their colour from the legacy --transparent-fg. It is rgba(36,26,43,0.62), which composites to about #77717c on the page surface and measures 4.33:1 against the 4.5:1 normal-text floor. The rest of this component already used --u-text-muted, which clears it. Six text declarations move to the muted token. The two border declarations keep the translucent one, because a border is not held to the text floor. Found by the broad visual pass against a verified-fresh build. Worth saying how it stayed hidden: an earlier run measured a stale bundle and reported a different, non-existent failure instead. On this machine `pkill` does not kill node, so orphaned gateways accumulate and one of them keeps serving an old dist. Compare the styles hash the server returns with the one in dist/index.html before trusting a local visual result. Co-Authored-By: Claude Opus 5 --- .github/workflows/universe-ci.yml | 2 +- .../components/blocks-list/blocks-list.component.ts | 2 +- frontend/src/app/dashboard/dashboard.component.ts | 2 +- .../protocol-directory.component.scss | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index f253595f375..a75824c3d42 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -195,7 +195,7 @@ jobs: - name: Forced colours and 200 percent zoom working-directory: scripts/universe/visual-qa - run: node modes-check.mjs --base=http://127.0.0.1:$GATEWAY_PORT + run: node modes-check.mjs --base=http://127.0.0.1:8123 - name: Keep the contrast report and the route screenshots if: always() 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 156c6e4f1d1..9414fc1aaf6 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.ts +++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts @@ -151,7 +151,7 @@ export class BlocksList implements OnInit { if (!isRetryableFailure(classifyLoadFailure(error))) { return throwError(() => error); } - return timer(1000 * attempt); + return timer(2000 * attempt); }, }), catchError((error) => { diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 11a2ef308bb..347d122fafc 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -144,7 +144,7 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { const firstChainData$ = merge(this.stateService.blocks$, this.stateService.mempoolInfo$).pipe(take(1)); return merge( firstChainData$.pipe(map((): LoadState => ({ status: 'data', value: true, at: Date.now() }))), - timer(5_000).pipe( + timer(10_000).pipe( takeUntil(firstChainData$), map((): LoadState => ({ status: 'error', reason: 'timeout', at: Date.now() })), ), 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 05bc701f749..ce49cddec35 100644 --- a/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss +++ b/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss @@ -8,12 +8,12 @@ } .chain-note { - color: var(--transparent-fg); + color: var(--u-text-muted); font-size: 0.9rem; } .registry-version { - color: var(--transparent-fg); + color: var(--u-text-muted); font-size: 0.85rem; } @@ -31,7 +31,7 @@ p { margin: 0; - color: var(--transparent-fg); + color: var(--u-text-muted); } } @@ -125,12 +125,12 @@ &.chip-disabled { border-style: dashed; border-color: var(--transparent-fg); - color: var(--transparent-fg); + color: var(--u-text-muted); } &.chip-unknown { border-style: dashed; - color: var(--transparent-fg); + color: var(--u-text-muted); } // Reachable and answering, but too far back to describe the present. It @@ -168,7 +168,7 @@ .no-protocols { margin: 2rem 0; text-align: center; - color: var(--transparent-fg); + color: var(--u-text-muted); } .loading-skeleton { From 1b00bd8090277bf4c4d2ab42199e3aedcdba9cd8 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 15:51:51 +0000 Subject: [PATCH 05/12] revert: restore the three timing fixes my last commit undid My previous commit reverted adbe39af7: the gateway port in the modes-check step went back to a literal, the blocks-list backoff went back to two seconds, and the dashboard first-data timeout went back to ten. None of that was intended and none of it was in the diff I meant to make. Cause, for the second time in this branch: this worktree and another share one branch. A commit made in the other worktree advances the shared ref, so HEAD here moves while the files here do not. `git add -A` then reads the old bytes as a deliberate change and commits the peer's work away. `git status` does not warn, because from its point of view the tree really has changed. Stage the files you edited by name in a shared-branch worktree. `git add -A` is not safe here, and neither is trusting a clean-looking status after a merge that reported nothing to do. Co-Authored-By: Claude Opus 5 --- .github/workflows/universe-ci.yml | 2 +- .../src/app/components/blocks-list/blocks-list.component.ts | 2 +- frontend/src/app/dashboard/dashboard.component.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index a75824c3d42..f253595f375 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -195,7 +195,7 @@ jobs: - name: Forced colours and 200 percent zoom working-directory: scripts/universe/visual-qa - run: node modes-check.mjs --base=http://127.0.0.1:8123 + run: node modes-check.mjs --base=http://127.0.0.1:$GATEWAY_PORT - name: Keep the contrast report and the route screenshots if: always() 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 9414fc1aaf6..156c6e4f1d1 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.ts +++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts @@ -151,7 +151,7 @@ export class BlocksList implements OnInit { if (!isRetryableFailure(classifyLoadFailure(error))) { return throwError(() => error); } - return timer(2000 * attempt); + return timer(1000 * attempt); }, }), catchError((error) => { diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 347d122fafc..11a2ef308bb 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -144,7 +144,7 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { const firstChainData$ = merge(this.stateService.blocks$, this.stateService.mempoolInfo$).pipe(take(1)); return merge( firstChainData$.pipe(map((): LoadState => ({ status: 'data', value: true, at: Date.now() }))), - timer(10_000).pipe( + timer(5_000).pipe( takeUntil(firstChainData$), map((): LoadState => ({ status: 'error', reason: 'timeout', at: Date.now() })), ), From 0cfb915365b3b03a76ff6f340070244ae43c6a1d Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 16:56:10 +0000 Subject: [PATCH 06/12] frontend: refine protocol and progress layout --- .../protocol-directory.component.scss | 35 +++++++++++++++---- frontend/src/styles.scss | 4 +-- 2 files changed, 30 insertions(+), 9 deletions(-) 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 05bc701f749..ddf48ec4b87 100644 --- a/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss +++ b/frontend/src/app/universe/protocol-directory/protocol-directory.component.scss @@ -54,10 +54,9 @@ } .protocol-row { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: 23rem minmax(0, 1fr) max-content; align-items: center; - justify-content: space-between; gap: 0.5rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid var(--box-bg); @@ -81,16 +80,37 @@ align-items: center; gap: 0.35rem 0.5rem; min-width: 0; + justify-self: start; } } - @media (max-width: 575px) { + @media (max-width: 899px) { .protocol-row { - align-items: flex-start; - flex-direction: column; + grid-template-columns: minmax(0, 1fr) max-content; .protocol-state { - width: 100%; + grid-column: 1 / -1; + grid-row: 2; + } + + .protocol-evidence { + grid-column: 2; + grid-row: 1; + margin: 0; + justify-self: end; + } + } + } + + @media (max-width: 575px) { + .protocol-row { + grid-template-columns: minmax(0, 1fr); + + .protocol-evidence { + grid-column: 1; + grid-row: 3; + margin-top: 0.25rem; + justify-self: start; } } } @@ -158,6 +178,7 @@ display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; + justify-self: end; } .registry-summary { diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 560803913dc..15f64e2d46f 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -803,7 +803,7 @@ h1, h2, h3 { // follows: in the blocks table that is the next row, and the figure was cut // in half by it. The reserve is only made where a label actually exists. &:has(.progress-text) { - margin-bottom: 1.1em; + margin-bottom: calc(1.1em + 4px); } .progress-bar { @@ -818,7 +818,7 @@ h1, h2, h3 { left: 0; position: absolute; text-align: center; - top: calc(100% + 3px); + top: calc(100% + 7px); white-space: nowrap; width: 100%; } From 957d010d4ba3729b61bc195c526426ce57d46725 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 16:37:34 +0000 Subject: [PATCH 07/12] frontend: announce address history progress --- .../src/app/components/address/address.component.html | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index e1ea8a3bb59..ee685552c36 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -150,11 +150,18 @@

+

Loading transactions

- +
-
+
From 06b6b7f8866e11887ed6713807f7ce3213e25072 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 16:37:43 +0000 Subject: [PATCH 08/12] frontend: bound the blocks page wait --- .../blocks-list/blocks-list.component.ts | 21 +++++++++- .../universe/visual-qa/progress-gate.test.mjs | 42 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) 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 156c6e4f1d1..95136cacf69 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.ts +++ b/frontend/src/app/components/blocks-list/blocks-list.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, Inject, LOCALE_ID } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject, combineLatest, EMPTY, Observable, throwError, timer, of, Subscription } from 'rxjs'; -import { catchError, debounceTime, filter, map, retry, scan, skip, switchMap, tap, throttleTime } from 'rxjs/operators'; +import { catchError, debounceTime, filter, map, retry, scan, skip, startWith, switchMap, tap, throttleTime, timeout } from 'rxjs/operators'; import { BlockExtended } from '@interfaces/node-api.interface'; import { ApiService } from '@app/services/api.service'; import { StateService } from '@app/services/state.service'; @@ -12,6 +12,13 @@ import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; import { LoadState, classifyLoadFailure, isRetryableFailure } from '@app/shared/load-state'; +/** + * Overall budget for one page of blocks, retries included. The same figure + * toLoadState uses, so a request that hangs here is bounded the way every + * other remote read on the product is. + */ +const BLOCKS_REQUEST_DEADLINE_MS = 20_000; + @Component({ selector: 'app-blocks-list', templateUrl: './blocks-list.component.html', @@ -154,6 +161,11 @@ export class BlocksList implements OnInit { return timer(1000 * attempt); }, }), + // Retrying bounds a request that fails. It does nothing for one + // that never answers at all, which is the shape a hung backend + // has: no error to catch, so the skeletons stayed forever. The + // budget covers the retries above, matching toLoadState. + timeout({ first: BLOCKS_REQUEST_DEADLINE_MS }), catchError((error) => { this.failureState = { status: 'error', reason: classifyLoadFailure(error), at: Date.now() }; this.isLoading = false; @@ -171,7 +183,12 @@ export class BlocksList implements OnInit { } this.lastBlockHeight = blocks[0].height; return of(blocks); - }) + }), + // The socket carries live tip updates, not the page itself. Without a + // seed, combineLatest holds the whole table until the socket speaks, + // so a deployment whose REST answers but whose socket is quiet showed + // skeletons indefinitely with no failure to report. + startWith([] as BlockExtended[]), ) ]) .pipe( diff --git a/scripts/universe/visual-qa/progress-gate.test.mjs b/scripts/universe/visual-qa/progress-gate.test.mjs index 3e29b9386be..77149da0cce 100644 --- a/scripts/universe/visual-qa/progress-gate.test.mjs +++ b/scripts/universe/visual-qa/progress-gate.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { progressFailures } from './capture.mjs'; +import { GATED_ROUTES, progressFailures } from './capture.mjs'; /** * The gate that would have stopped the release. @@ -96,6 +96,46 @@ test('a page that waits with nothing on screen saying so fails the run', () => { assert.match(failures[0], /nothing on screen that says so/); }); +/** + * Being reported is not the same as being blocked on. A finding whose route is + * outside GATED_ROUTES is printed as known work and the run still passes, so a + * route counts as finished only once the gate actually holds it. Nothing + * asserted which of the two a route got, which is the one thing the gated set + * exists to decide. These use the same route-from-line rule the run applies. + */ +const blocks = (line) => GATED_ROUTES.has(line.split('/')[0]); + +const on = (route, state, progress) => progressFailures({ + findings: [{ + route, routeName: route, state, theme: 'default', viewport: '1280', + progress: { spinners: [], skeletons: 0, charts: [], statusPanels: [], loadingAnnouncements: [], textLength: 900, skeletonOnly: false, ...progress }, + }], +}); + +test('the chain pages hold a backend outage rather than only reporting it', () => { + for (const route of ['home', 'blocks']) { + const failures = on(route, 'chain-down', { skeletons: 15, statusPanels: [] }); + assert.equal(failures.length, 1, `${route} raised nothing`); + assert.match(failures[0], /nothing said about why/); + assert.ok(blocks(failures[0]), `${route} is reported but does not block`); + } +}); + +test('the transaction and address pages hold an unannounced wait', () => { + for (const route of ['tx', 'address']) { + const failures = on(route, 'loading', { skeletons: 6, loadingAnnouncements: [] }); + assert.equal(failures.length, 1, `${route} raised nothing`); + assert.match(failures[0], /nothing on screen that says so/); + assert.ok(blocks(failures[0]), `${route} is reported but does not block`); + } +}); + +test('the routes already covered stay held', () => { + for (const route of ['graphs', 'mining', 'protocols']) { + assert.ok(GATED_ROUTES.has(route), `${route} silently stopped being gated`); + } +}); + 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 From 4a57b40abb90a6e1ef439f1587fb1ac136fcc88f Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 16:37:51 +0000 Subject: [PATCH 09/12] frontend: share the live feed failure state --- .../difficulty/difficulty.component.html | 46 +++++++++------- .../difficulty/difficulty.component.scss | 16 +++++- .../difficulty/difficulty.component.ts | 8 +++ .../fees-box/fees-box.component.html | 52 +++++++++++-------- .../fees-box/fees-box.component.scss | 16 +++++- .../components/fees-box/fees-box.component.ts | 8 +++ .../app/dashboard/dashboard.component.html | 10 ++-- .../src/app/dashboard/dashboard.component.ts | 33 +++--------- frontend/src/app/services/state.service.ts | 51 +++++++++++++++++- 9 files changed, 165 insertions(+), 75 deletions(-) diff --git a/frontend/src/app/components/difficulty/difficulty.component.html b/frontend/src/app/components/difficulty/difficulty.component.html index 65dbd7545aa..3f997eff650 100644 --- a/frontend/src/app/components/difficulty/difficulty.component.html +++ b/frontend/src/app/components/difficulty/difficulty.component.html @@ -131,29 +131,37 @@
-
-
-
-
-
-
-
-
-
+ + @if ((liveFeed$ | async)?.status === 'error') { +

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

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

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

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

Loading live chain data

} - @if (chainStatus.status === 'error') { - + @if (liveFeed.status === 'error') { + } }
diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 11a2ef308bb..e08c0d8d691 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -1,6 +1,6 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; -import { combineLatest, EMPTY, fromEvent, interval, merge, Observable, of, Subject, Subscription, timer } from 'rxjs'; -import { catchError, delayWhen, distinctUntilChanged, filter, map, scan, share, shareReplay, startWith, switchMap, take, takeUntil, tap, throttleTime } from 'rxjs/operators'; +import { combineLatest, EMPTY, fromEvent, interval, merge, Observable, of, Subject, Subscription } from 'rxjs'; +import { catchError, delayWhen, distinctUntilChanged, filter, map, scan, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators'; import { AuditStatus, BlockExtended, CurrentPegs, FederationAddress, FederationUtxo, OptimizedMempoolStats, PegsVolume, RecentPeg, TransactionStripped } from '@interfaces/node-api.interface'; import { MempoolInfo, ReplacementInfo } from '@interfaces/websocket.interface'; import { ApiService } from '@app/services/api.service'; @@ -52,12 +52,10 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { transactionsWeightPerSecondOptions: any; isLoadingWebSocket$: Observable; /** - * Whether live chain data ever arrived. The dashboard is fed by the socket, - * so when the chain backend is down every panel would otherwise hold its - * skeleton forever with nothing said about why. + * Whether live chain data ever arrived. Owned by the state service, because + * the panels on this page have to reach the same verdict as the banner. */ - chainStatus$: Observable>; - private chainRetry$ = new Subject(); + liveFeed$: Observable>; liquidPegsMonth$: Observable; currentPeg$: Observable; auditStatus$: Observable; @@ -112,9 +110,9 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { this.stateService.focusSearchInputDesktop(); } - retryChain(): void { + retryLiveFeed(): void { this.websocketService.reconnectWebsocket(); - this.chainRetry$.next(); + this.stateService.retryLiveFeed(); } ngOnDestroy(): void { @@ -135,22 +133,7 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit { this.websocketService.startTrackRbfSummary(); this.network$ = merge(of(''), this.stateService.networkChanged$); - // The first socket payload normally lands within a second or two. When - // nothing has arrived well past that, the page says so instead of leaving - // the reader to stare at skeletons. A late arrival still clears the panel. - this.chainStatus$ = this.chainRetry$.pipe( - startWith(undefined), - switchMap(() => { - const firstChainData$ = merge(this.stateService.blocks$, this.stateService.mempoolInfo$).pipe(take(1)); - return merge( - firstChainData$.pipe(map((): LoadState => ({ status: 'data', value: true, at: Date.now() }))), - timer(5_000).pipe( - takeUntil(firstChainData$), - map((): LoadState => ({ status: 'error', reason: 'timeout', at: Date.now() })), - ), - ).pipe(startWith>({ status: 'loading' })); - }), - ); + this.liveFeed$ = this.stateService.liveFeed$; this.mempoolLoadingStatus$ = this.stateService.loadingIndicators$ .pipe( map((indicators) => indicators.mempool !== undefined ? indicators.mempool : 100) diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index bb8f97c4dda..88971cc392b 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,11 +1,12 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; -import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; +import { ReplaySubject, BehaviorSubject, Subject, fromEvent, merge, Observable, timer } from 'rxjs'; import { Transaction } from '@interfaces/electrs.interface'; import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, StratumJob, isMempoolState } from '@interfaces/websocket.interface'; import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; -import { filter, map, scan, share, shareReplay } from 'rxjs/operators'; +import { filter, map, scan, share, shareReplay, startWith, switchMap, take, takeUntil } from 'rxjs/operators'; +import { LoadState } from '@app/shared/load-state'; import { StorageService } from '@app/services/storage.service'; import { hasTouchScreen } from '@app/shared/pipes/bytes-pipe/utils'; import { ActiveFilter } from '@app/shared/filters.utils'; @@ -45,6 +46,12 @@ export interface Customization { export type SignaturesMode = 'all' | 'interesting' | 'none' | null; +/** + * How long the socket has to say anything before the live feed is treated as + * having given up. The first payload normally lands within a second or two. + */ +const LIVE_FEED_DEADLINE_MS = 10_000; + export interface Env { MAINNET_ENABLED: boolean; TESTNET_ENABLED: boolean; @@ -193,6 +200,19 @@ export class StateService { blockTransactions$ = new Subject(); walletTransactions$ = new Subject(); isLoadingWebSocket$ = new ReplaySubject(1); + /** + * Whether live chain data ever arrived. + * + * The panels on the dashboards are fed by the socket and none of them can + * fail: when the chain backend is down the socket connects and then says + * nothing at all, so a component waiting on `blocks$` waits forever and holds + * its placeholder forever with it. This is the one place that decides the + * feed has given up, so the banner that says so and the panels that stop + * pulsing are reading the same answer instead of racing a copy of the same + * timer each. + */ + liveFeed$: Observable>; + private liveFeedRetry$ = new Subject(); isLoadingMempool$ = new BehaviorSubject(true); vbytesPerSecond$ = new ReplaySubject(1); previousRetarget$ = new ReplaySubject(1); @@ -356,6 +376,25 @@ export class StateService { this.blocks$ = this.blocksSubject$.pipe(filter(blocks => blocks != null && blocks.length > 0)); + // A late arrival still clears this, so the deadline costs nothing when the + // feed is merely slow. Shared and reference counted: one timer runs however + // many panels are watching, and it starts again when a dashboard is next + // opened rather than holding a verdict from an earlier visit. + this.liveFeed$ = this.liveFeedRetry$.pipe( + startWith(undefined), + switchMap(() => { + const firstChainData$ = merge(this.blocks$, this.mempoolInfo$).pipe(take(1)); + return merge( + firstChainData$.pipe(map((): LoadState => ({ status: 'data', value: true, at: Date.now() }))), + timer(LIVE_FEED_DEADLINE_MS).pipe( + takeUntil(firstChainData$), + map((): LoadState => ({ status: 'error', reason: 'timeout', at: Date.now() })), + ), + ).pipe(startWith>({ status: 'loading' })); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + const savedTimePreference = this.storageService.getValue('time-preference-ltr'); const rtlLanguage = (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')); // default time direction is right-to-left, unless locale is a RTL language @@ -493,6 +532,14 @@ export class StateService { this.blockScrolling$.next(value); } + /** + * Gives the live feed a fresh deadline. Reconnecting the socket is the + * caller's job: this service does not own the connection. + */ + retryLiveFeed(): void { + this.liveFeedRetry$.next(); + } + isLiquid() { return this.network === 'liquid' || this.network === 'liquidtestnet'; } From 9adef6e78425ebcf3ed0c86f1197bda7a7fbf4a6 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 16:37:58 +0000 Subject: [PATCH 10/12] qa: cover address transaction waits --- scripts/universe/visual-qa/capture.mjs | 30 ++++++++++++++++--- scripts/universe/visual-qa/fixtures.mjs | 17 +++++++++++ .../universe/visual-qa/progress-gate.test.mjs | 17 +++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index 62038e6dd97..fe3598bb895 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -218,7 +218,17 @@ function socketState(state) { transactions: fixtures['/api/mempool/recent'], rbfLatestSummary: fixtures['rbf-latest-summary'], conversions: { USD: 96_400, EUR: 89_100, time: 1_772_100_000 }, - loadingIndicators: { mempool: 100, blocks: 100 }, + loadingIndicators: { + mempool: 100, + blocks: 100, + // How far along the address's transaction history is. The address page + // only draws its progress bar when the socket reports progress for that + // address, so without this the one state that reaches the + // transaction-list wait would render its skeletons and no bar at all, + // and the bar would go on being unreviewed for the same reason the + // branch itself was. + ...(state === 'address-txs-loading' ? { [`address-${sampleIds.ADDRESS}`]: 62 } : {}), + }, backendInfo: { hostname: 'universe-explorer', version: '3.3.1', gitCommit: 'fixture0', lightning: false, chainSync }, }; } @@ -466,6 +476,18 @@ async function run() { */ export const GATED_ROUTES = new Set(['graphs', 'mining', 'protocols', 'home', 'blocks', 'tx', 'address']); +/** + * Fixtures that hold a request open on purpose, to photograph a wait. + * + * These are judged on whether the wait is announced, not on having finished, + * and they are not failure states: nothing has gone wrong, so demanding a + * status panel would be demanding the page report a fault it does not have. + * `loading` holds every request; `address-txs-loading` holds only the address + * transaction list, which is the wait pagination leaves behind and the one the + * blanket fixture can never reach. + */ +const WAITING_STATES = new Set(['loading', 'address-txs-loading']); + export function progressFailures(report) { const failures = []; for (const f of report.findings) { @@ -488,9 +510,9 @@ export function progressFailures(report) { 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 + } else if (WAITING_STATES.has(f.state)) { + // These fixtures hold requests open on purpose, to photograph the + // waiting state. Asking them 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 diff --git a/scripts/universe/visual-qa/fixtures.mjs b/scripts/universe/visual-qa/fixtures.mjs index 64b441bdab0..4b38b9b1e56 100644 --- a/scripts/universe/visual-qa/fixtures.mjs +++ b/scripts/universe/visual-qa/fixtures.mjs @@ -301,6 +301,23 @@ export const stateOverrides = { // Requests never resolve, so every surface stays in its loading state. loading: { '**': { hang: true } }, + // The address resolves, its transactions do not. + // + // The address page has two independent waits, and the blanket `loading` + // fixture above can only ever photograph the first: with every request held + // open the page never gets past `isLoadingAddress`, so the branch that waits + // for the transaction list, the one a reader actually meets on a slow page + // or when they ask for more, was never on screen for any check to see. This + // fixture answers the summary and holds only the transaction request, which + // is the state pagination and "load more" leave behind. + // + // The harness falls back to a prefix match, so this reaches the whole + // `/txs` subtree. That is the intent: every route that feeds the transaction + // list waits, and nothing else does. + 'address-txs-loading': { + [`/api/address/${ADDRESS}/txs`]: { hang: true }, + }, + // An address that has never been used. 'address-empty': { [`/api/address/${ADDRESS}`]: { diff --git a/scripts/universe/visual-qa/progress-gate.test.mjs b/scripts/universe/visual-qa/progress-gate.test.mjs index 77149da0cce..6e9f2281314 100644 --- a/scripts/universe/visual-qa/progress-gate.test.mjs +++ b/scripts/universe/visual-qa/progress-gate.test.mjs @@ -136,6 +136,23 @@ test('the routes already covered stay held', () => { } }); +test('the address transaction wait is judged as a wait, not as a failure', () => { + // Only the transaction list is held open, so the page is not in a failure + // state and owes no status panel. What it owes is a word about what it is + // waiting for, which is the announcement on the transaction-list branch. + assert.deepEqual( + progressFailures(withProgress('address-txs-loading', { + skeletons: 2, loadingAnnouncements: ['Loading transactions'], + })), + [], + ); + const failures = progressFailures(withProgress('address-txs-loading', { + skeletons: 2, + })); + 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 From 800f6c2d97be9cb799b7922c05b33f6ef3564ab0 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 17:02:09 +0000 Subject: [PATCH 11/12] fix: retain dashboard stream imports after consolidation --- frontend/src/app/dashboard/dashboard.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index e08c0d8d691..05491b43376 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -1,6 +1,6 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; -import { combineLatest, EMPTY, fromEvent, interval, merge, Observable, of, Subject, Subscription } from 'rxjs'; -import { catchError, delayWhen, distinctUntilChanged, filter, map, scan, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators'; +import { combineLatest, EMPTY, fromEvent, interval, merge, Observable, of, Subject, Subscription, timer } from 'rxjs'; +import { catchError, delayWhen, distinctUntilChanged, filter, map, scan, share, shareReplay, startWith, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators'; import { AuditStatus, BlockExtended, CurrentPegs, FederationAddress, FederationUtxo, OptimizedMempoolStats, PegsVolume, RecentPeg, TransactionStripped } from '@interfaces/node-api.interface'; import { MempoolInfo, ReplacementInfo } from '@interfaces/websocket.interface'; import { ApiService } from '@app/services/api.service'; From 31b0468e64390975bbba664eb9bb88b2ae49eae5 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Fri, 28 Aug 2026 17:22:57 +0000 Subject: [PATCH 12/12] fix: render address history without socket price data --- .../app/components/address-graph/address-graph.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 3ea44443d7c..faac92c98d7 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '@app/graphs/echarts'; import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; -import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, map, startWith, switchMap, tap } from 'rxjs/operators'; import { AddressTxSummary, ChainStats } from '@interfaces/electrs.interface'; import { ElectrsApiService } from '@app/services/electrs-api.service'; import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; @@ -109,7 +109,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { return of(null); }), )), - this.stateService.conversions$ + this.stateService.conversions$.pipe(startWith(null)) ]).pipe( switchMap(([redraw, addressSummary, conversions]) => { this.conversions = conversions;