Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions .github/workflows/universe-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,21 +165,44 @@ 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: 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()
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
5 changes: 4 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/app/components/address/address.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,18 @@ <h2 class="text-left">

<div class="text-center">
<ng-template [ngIf]="isLoadingTransactions">
<p class="sr-only" role="status" i18n="address.transactions-loading-announcement">Loading transactions</p>

<ng-container *ngIf="addressLoadingStatus$ as addressLoadingStatus">
<ng-container *ngIf="(addressLoadingStatus$ | async) as addressLoadingStatus">
<div class="header-bg box" style="padding: 12px; margin-bottom: 10px;">
<div class="progress progress-dark">
<div class="progress-bar progress-light" role="progressbar" [ngStyle]="{'width': addressLoadingStatus + '%' }"></div>
<div class="progress-bar progress-light" role="progressbar"
[attr.aria-valuenow]="addressLoadingStatus"
aria-valuemin="0"
aria-valuemax="100"
i18n-aria-label="address.transactions-loading-progress"
aria-label="Transaction history loading progress"
[ngStyle]="{'width': addressLoadingStatus + '%' }"></div>
</div>
</div>
</ng-container>
Expand Down
23 changes: 20 additions & 3 deletions frontend/src/app/components/blocks-list/blocks-list.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -151,9 +158,14 @@ export class BlocksList implements OnInit {
if (!isRetryableFailure(classifyLoadFailure(error))) {
return throwError(() => error);
}
return timer(2000 * attempt);
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;
Expand All @@ -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(
Expand Down
46 changes: 27 additions & 19 deletions frontend/src/app/components/difficulty/difficulty.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -131,29 +131,37 @@
</ng-template>

<ng-template #loadingDifficulty>
<div class="epoch-progress">
<div class="skeleton-loader"></div>
</div>
<div class="difficulty-skeleton loading-container">
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
<!-- Both sources here are socket fed, and the socket does not fail: when the
backend is down it connects and then says nothing. Once the feed has
given up, placeholders that keep pulsing are promising an answer that is
not coming. -->
@if ((liveFeed$ | async)?.status === 'error') {
<p class="feed-unavailable" i18n="difficulty-box.feed-unavailable">No live epoch data: the chain feed is not answering.</p>
} @else {
<div class="epoch-progress">
<div class="skeleton-loader"></div>
</div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
<div class="difficulty-skeleton loading-container">
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</div>
}
</ng-template>

<app-difficulty-tooltip
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/app/components/difficulty/difficulty.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,18 @@

.inactive {
color: var(--transparent-fg);
}
}

// What stands in for the epoch when the live feed has given up. It holds the
// height the progress bar and the stats occupy, so the card does not collapse
// 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: 84px;
text-align: center;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, El
import { combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { StateService } from '@app/services/state.service';
import { LoadState } from '@app/shared/load-state';

interface EpochProgress {
base: string;
Expand Down Expand Up @@ -50,6 +51,12 @@ export class DifficultyComponent implements OnInit {
@ViewChild('epochSvg') epochSvgElement: ElementRef<SVGElement>;

isLoadingWebSocket$: Observable<boolean>;
/**
* 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<LoadState<boolean>>;
difficultyEpoch$: Observable<EpochProgress>;

mode: 'difficulty' | 'halving' = 'difficulty';
Expand All @@ -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$,
Expand Down
52 changes: 30 additions & 22 deletions frontend/src/app/components/fees-box/fees-box.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -54,31 +54,39 @@
<span class="fee-label prority" i18n="fees-box.high-priority">High Priority</span>
</div>
</div>
<div class="fee-estimation-container loading-container">
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
<!-- These numbers only ever arrive over the socket, and the socket does not
fail: when the backend is down it connects and then says nothing. Once
the feed has given up, placeholders that keep pulsing are promising an
answer that is not coming. -->
@if ((liveFeed$ | async)?.status === 'error') {
<p class="feed-unavailable" i18n="fees-box.feed-unavailable">No live fee estimates: the chain feed is not answering.</p>
} @else {
<div class="fee-estimation-container loading-container">
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
<div class="band-separator"></div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
<div class="band-separator"></div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</div>
}
</ng-template>
16 changes: 15 additions & 1 deletion frontend/src/app/components/fees-box/fees-box.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,18 @@
&.prority {
width: 25%;
}
}
}

// 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;
}
8 changes: 8 additions & 0 deletions frontend/src/app/components/fees-box/fees-box.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -15,6 +16,12 @@ import { ThemeService } from '@app/services/theme.service';
})
export class FeesBoxComponent implements OnInit, OnDestroy {
isLoading$: Observable<boolean>;
/**
* 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<LoadState<boolean>>;
recommendedFees$: Observable<Recommendedfees>;
themeStateSubscription: Subscription;
gradient = 'linear-gradient(to right, var(--skeleton-bg), var(--skeleton-bg))';
Expand All @@ -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 })),
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/app/dashboard/dashboard.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
<div class="dashboard" *ngIf="(network$ | async) !== 'liquid'; else liquidDashboard">
<!-- Rendered only when live data never arrived: the panel explains the
skeletons below it instead of leaving them unexplained. -->
@if (chainStatus$ | async; as chainStatus) {
@if (chainStatus.status === 'loading') {
@if (liveFeed$ | async; as liveFeed) {
@if (liveFeed.status === 'loading') {
<p class="sr-only" role="status" i18n="dashboard.loading-announcement">Loading live chain data</p>
}
@if (chainStatus.status === 'error') {
<app-load-status [state]="chainStatus" label="live chain data" i18n-label="load.label.live-chain-data"
(retry)="retryChain()"></app-load-status>
@if (liveFeed.status === 'error') {
<app-load-status [state]="liveFeed" label="live chain data" i18n-label="load.label.live-chain-data"
(retry)="retryLiveFeed()"></app-load-status>
}
}
<div class="dashboard-grid" *ngIf="{ value: (mempoolInfoData$ | async) } as mempoolInfoData">
Expand Down
Loading
Loading