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
4 changes: 3 additions & 1 deletion .github/workflows/universe-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ name: Universe CI

on:
push:
branches: [main]
# develop is pushed to directly, so restricting this to main left those
# pushes with no CI at all between one pull request and the next.
branches: [develop, main]
pull_request:
branches: [develop, main]
workflow_dispatch:
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/universe-release-artifact.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,19 @@ jobs:
cp -a backend/package-lock.json "$stage/backend/package-lock.json"
cp -a frontend/dist/mempool/browser "$stage/frontend/build"
cp -a scripts/universe "$stage/scripts/universe"
# The unit files travel with the release rather than being fetched
# from a checkout that happens to be on the right commit. Adopting
# socket activation needed both of these on the host, and taking
# them from a workstation is the same class of mistake as building
# the artifact there.
mkdir -p "$stage/production"
cp -a production/linux "$stage/production/linux"
test -f "$stage/production/linux/universe-explorer-gateway.socket"
test -f "$stage/backend/dist/index.js"
test -f "$stage/frontend/build/index.html"
test -f "$stage/scripts/universe/gateway.mjs"
out="$PWD/mempool-$sha.tar.gz"
tar -czf "$out" -C "$stage" backend frontend scripts
tar -czf "$out" -C "$stage" backend frontend scripts production
sha256sum "$out" | tee "$out.sha256"
printf 'name=mempool-%s\n' "$sha" >> "$GITHUB_OUTPUT"

Expand Down
48 changes: 48 additions & 0 deletions docs/operations/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ explorer.bitcoinuniverse.io public origin, TLS terminated at the gatewa
-> universe-explorer-tunnel forward-only SSH tunnel, 127.0.0.1:8385
-> universe-indexer-01:8099 universe-explorer-gateway
/api/v1/universe/* -> universe-explorer-overlay 127.0.0.1:3400
/api/v1/chains -> universe-explorer-overlay 127.0.0.1:3400
/api/v1/bitcoin/* -> universe-explorer-overlay 127.0.0.1:3400
/api/v1/dogecoin/* -> universe-explorer-overlay 127.0.0.1:3400
/api/v1/zcash/* -> universe-explorer-overlay 127.0.0.1:3400
/api/* -> universe-explorer-backend 127.0.0.1:8996
everything else -> the built frontend, SPA fallback

Expand Down Expand Up @@ -141,6 +145,22 @@ suite can see that.
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.
2b. Refresh `/usr/local/bin/universe-explorer-release` from the artifact
before using it. It is a copy, not a symlink, so it does not travel with a
release and will otherwise run the previous release's gates against the new
one while printing a log that looks entirely normal. It was found a release
behind, still carrying gates from before the dependency and rollback guards
existed.

```bash
tar -xzOf mempool-<sha>.tar.gz scripts/universe/release.sh > /usr/local/bin/universe-explorer-release.new
chmod 0755 /usr/local/bin/universe-explorer-release.new
mv -f /usr/local/bin/universe-explorer-release.new /usr/local/bin/universe-explorer-release
```

Taking it from the tarball rather than a checkout keeps the tool and the
release it installs on the same commit.

3. `universe-explorer-release install <sha> <artifact>` unpacks it beside the
running release under `/opt/universe-explorer/releases/mempool-<sha>/` and
hard-links the dependency tree from the release in use. A release directory
Expand Down Expand Up @@ -297,3 +317,31 @@ Every deployment publishes what it is running: the backend reports `gitCommit`
on `/api/v1/backend-info`, and the public `/source` page renders the release
SHA, the pinned upstream base, the licence, and the source repository link. A
build whose SHA is not published must not ship.

## The edge

The public origin terminates on the cPanel host and reaches the explorer
gateway over an SSH tunnel, through an nginx upstream with a keep-alive pool.
Two things about that path have already caused incidents and are worth knowing
before diagnosing a third.

`/favicon.ico` and `/robots.txt` are proxied to apache rather than the tunnel,
and apache inherited a parent rewrite that redirected them elsewhere. A
hijacked `robots.txt` means crawlers never saw this site's policy. Both are now
served from the explorer docroot, with `RewriteEngine On` reset there so the
parent catch-all does not apply.

The tunnel's server-side sshd child carries every public byte and is
single-threaded. Under memory pressure it lands in a per-session scope with
default resource weights, and a few megabytes of page-in on a saturated device
becomes seconds of stall. Runtime protections were applied during the incident
of 2026-08-28, but a per-session scope dies with the session, so the protection
lapses silently whenever the tunnel reconnects. The durable fix is to pin the
tunnel's server end under a controlled slice.

Operator access degrades before user traffic does on that host, which is
backwards from what an operator needs: new SSH connections fail during banner
exchange while the established tunnel keeps serving. A new connection needs
fork, exec, PAM and password file reads, all uncached IO, and proportional IO
weight cannot beat a saturated queue. An established session does none of that
per byte.
34 changes: 25 additions & 9 deletions frontend/src/app/components/master-page/master-page.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@
</ng-container>
</a>

<div ngbDropdown class="chain-picker" *ngIf="network.val === '' && (chainCapabilities$ | async) as capabilities">
<button ngbDropdownToggle type="button" class="chain-toggle" aria-haspopup="true" aria-label="Select blockchain">
<span class="chain-symbol">{{ chainTicker(activeChain) }}</span>
<span class="chain-name">{{ chainName(activeChain) }}</span>
<span class="chain-current-state">{{ chainState(chainCapability(capabilities, activeChain)) }}</span>
</button>
<div ngbDropdownMenu class="chain-menu" [ngClass]="{'dropdown-menu-right': isMobile}">
<button ngbDropdownItem type="button" *ngFor="let chain of explorerChains" class="chain-option"
[class.active]="activeChain === chain" (click)="switchChain(chain)">
<span class="chain-option-title">{{ chainName(chain) }} / {{ chainTicker(chain) }}</span>
<span class="chain-option-state">{{ chainState(chainCapability(capabilities, chain)) }}</span>
<span class="chain-option-detail">{{ chainDetail(chainCapability(capabilities, chain)) }}</span>
</button>
</div>
</div>

<!-- Network. Kept next to the brand and never inside the protocol
navigation, because choosing a chain and choosing a protocol are
different decisions and must not look like the same control. -->
Expand All @@ -44,7 +60,7 @@

<!-- Search is the fastest route to any answer here, so it takes the space
the navigation does not need rather than being tucked into a corner. -->
<app-search-form class="site-search" location="top" (searchTriggered)="collapse()"></app-search-form>
<app-search-form class="site-search" location="top" [activeChain]="activeChain" (searchTriggered)="collapse()"></app-search-form>

<!-- Connection. Only speaks when there is something wrong: a healthy socket
needs no ornament, and a broken one must not be a silent grey pill. -->
Expand All @@ -67,42 +83,42 @@
<nav class="primary-nav" i18n-aria-label="master-page.primary-navigation" aria-label="Primary">
<ul class="nav-list {{ network.val }}">
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-home">
<a class="nav-link" [routerLink]="['/' | relativeUrl]" (click)="collapse()">
<a class="nav-link" [routerLink]="chainRoute('dashboard')" (click)="collapse()">
<span class="nav-icon"><app-svg-images name="nav-tachometer" width="21.59" height="100%"></app-svg-images></span>
<span class="nav-label" i18n="master-page.nav-dashboard">Dashboard</span>
</a>
</li>
<li class="nav-item mining" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
<li class="nav-item mining" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="activeChain === 'bitcoin' && stateService.env.MINING_DASHBOARD">
<a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()">
<span class="nav-icon"><app-svg-images name="nav-pickaxe" width="21.59" height="100%"></app-svg-images></span>
<span class="nav-label" i18n="master-page.nav-mining">Mining</span>
</a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="!stateService.env.MINING_DASHBOARD">
<li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="activeChain === 'bitcoin' && !stateService.env.MINING_DASHBOARD">
<a class="nav-link" [routerLink]="['/blocks' | relativeUrl]" (click)="collapse()">
<span class="nav-icon"><app-svg-images name="nav-cubes" width="21.59" height="100%"></app-svg-images></span>
<span class="nav-label" i18n="master-page.nav-blocks">Blocks</span>
</a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-universe-pulse" *ngIf="network.val === ''">
<a class="nav-link" [routerLink]="['/pulse' | relativeUrl]" (click)="collapse()">
<a class="nav-link" [routerLink]="chainRoute('mempool')" (click)="collapse()">
<span class="nav-icon"><app-svg-images name="nav-universe-pulse" width="21.59" height="100%"></app-svg-images></span>
<span class="nav-label" i18n="master-page.nav-pulse">Pulse</span>
<span class="nav-label" i18n="master-page.nav-pulse">Mempool</span>
</a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-universe-protocols" *ngIf="network.val === ''">
<a class="nav-link" [routerLink]="['/protocols' | relativeUrl]" (click)="collapse()">
<a class="nav-link" [routerLink]="chainRoute('protocols')" (click)="collapse()">
<span class="nav-icon"><app-svg-images name="nav-universe-protocols" width="21.59" height="100%"></app-svg-images></span>
<span class="nav-label" i18n="master-page.nav-protocols">Protocols</span>
</a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-lightning" *ngIf="stateService.networkSupportsLightning()">
<li class="nav-item" routerLinkActive="active" id="btn-lightning" *ngIf="activeChain === 'bitcoin' && stateService.networkSupportsLightning()">
<a class="nav-link" [routerLink]="['/lightning' | relativeUrl]" (click)="collapse()">
<span class="nav-icon"><app-svg-images name="nav-bolt" width="21.59" height="100%"></app-svg-images></span>
<span class="nav-label" i18n="master-page.nav-lightning">Lightning</span>
</a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-graphs">
<li class="nav-item" routerLinkActive="active" id="btn-graphs" *ngIf="activeChain === 'bitcoin'">
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()">
<span class="nav-icon"><app-svg-images name="nav-chart-area" width="21.59" height="100%"></app-svg-images></span>
<span class="nav-label" i18n="master-page.nav-graphs">Charts</span>
Expand Down
80 changes: 78 additions & 2 deletions frontend/src/app/components/master-page/master-page.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { Router } from '@angular/router';
import { NavigationEnd, Router } from '@angular/router';
import { Env, StateService } from '@app/services/state.service';
import { Observable, merge, of, Subscription } from 'rxjs';
import { Observable, catchError, filter, merge, of, shareReplay, Subscription, switchMap, timer } from 'rxjs';
import { LanguageService } from '@app/services/language.service';
import { EnterpriseService } from '@app/services/enterprise.service';
import { NavigationService } from '@app/services/navigation.service';
import { StorageService } from '@app/services/storage.service';
import { UniverseApiService } from '@app/universe/universe-api.service';
import { UniverseLocalService } from '@app/universe/universe-local.service';
import { ChainCapabilityEnvelope, ExplorerChain } from '@app/universe/universe.types';
import {
explorerChainFromUrl,
explorerSectionRoute,
explorerSwitchTarget,
} from '@app/universe/universe-chain-routing';

@Component({
selector: 'app-master-page',
Expand All @@ -31,9 +39,13 @@ export class MasterPageComponent implements OnInit, OnDestroy {
footerVisible = true;
user: any = undefined;
isDropdownVisible: boolean;
readonly explorerChains: readonly ExplorerChain[] = ['bitcoin', 'dogecoin', 'zcash'];
activeChain: ExplorerChain = 'bitcoin';
chainCapabilities$: Observable<ChainCapabilityEnvelope[]>;

enterpriseInfo: any;
enterpriseInfo$: Subscription;
routeInfo$: Subscription;

constructor(
public stateService: StateService,
Expand All @@ -42,6 +54,8 @@ export class MasterPageComponent implements OnInit, OnDestroy {
private navigationService: NavigationService,
private storageService: StorageService,
private router: Router,
private universeApi: UniverseApiService,
private universeLocal: UniverseLocalService,
) { }

ngOnInit(): void {
Expand All @@ -68,6 +82,18 @@ export class MasterPageComponent implements OnInit, OnDestroy {

this.refreshAuth();
this.setDropdownVisibility();
this.activeChain = explorerChainFromUrl(this.router.url);
this.routeInfo$ = this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
).subscribe((event) => {
this.activeChain = explorerChainFromUrl(event.urlAfterRedirects);
});
this.chainCapabilities$ = this.stateService.isBrowser
? timer(0, 15_000).pipe(
switchMap(() => this.universeApi.getChains$().pipe(catchError(() => of([])))),
shareReplay({ bufferSize: 1, refCount: true }),
)
: of([]);
}

setDropdownVisibility(): void {
Expand Down Expand Up @@ -124,10 +150,60 @@ export class MasterPageComponent implements OnInit, OnDestroy {
this.user = this.storageService.getAuth()?.user ?? null;
}

chainName(chain: ExplorerChain): string {
switch (chain) {
case 'bitcoin': return 'Bitcoin';
case 'dogecoin': return 'Dogecoin';
case 'zcash': return 'Zcash';
}
}

chainTicker(chain: ExplorerChain): string {
switch (chain) {
case 'bitcoin': return 'BTC';
case 'dogecoin': return 'DOGE';
case 'zcash': return 'ZEC';
}
}

chainCapability(capabilities: ChainCapabilityEnvelope[], chain: ExplorerChain): ChainCapabilityEnvelope | undefined {
return capabilities.find((capability) => capability.chain === chain);
}

chainState(capability: ChainCapabilityEnvelope | undefined): string {
if (!capability) {return 'status unavailable';}
return capability.ready ? 'ready' : 'degraded';
}

chainDetail(capability: ChainCapabilityEnvelope | undefined): string {
if (!capability) {return 'Tip and mempool unavailable';}
const tip = capability.tip?.heightAtomic ?? 'unknown tip';
return `Tip ${tip}; mempool ${capability.mempool.state}, ${capability.mempool.completeness}`;
}

chainRoute(kind: 'dashboard' | 'mempool' | 'protocols'): string {
return explorerSectionRoute(this.activeChain, kind);
}

switchChain(chain: ExplorerChain): void {
if (chain === this.activeChain) {return;}
const sourceChain = this.activeChain;
const target = explorerSwitchTarget(this.router.url, chain);
this.universeLocal.setSelectedChain(chain);
if (target.droppedObject) {
void this.router.navigate([target.path], { queryParams: { switchedFrom: sourceChain } });
return;
}
void this.router.navigateByUrl(target.path);
}

ngOnDestroy(): void {
if (this.enterpriseInfo$) {
this.enterpriseInfo$.unsubscribe();
}
if (this.routeInfo$) {
this.routeInfo$.unsubscribe();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@
formControlName="searchText" type="text" class="form-control search-input"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
i18n-placeholder="search-form.searchbar-placeholder"
placeholder="Search a transaction, block, address, or asset">
[placeholder]="activeChain === 'bitcoin' ? 'Search a transaction, block, address, or asset' : 'Search ' + activeChain">
<app-search-results #searchResults [hidden]="dropdownHidden" [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results>
</div>
<button [disabled]="isSearching" type="submit" class="search-submit"
i18n-aria-label="search-form.search-title" aria-label="Search">
<fa-icon *ngIf="!(isTypeaheading$ | async) else searchLoading" [icon]="['fas', 'search']" [fixedWidth]="true"></fa-icon>
</button>
<label class="all-chains">
<input type="checkbox" [checked]="searchAllChains"
(change)="setSearchAllChains($any($event.target).checked)">
<span>All chains</span>
</label>
</div>
</form>

Expand Down
18 changes: 18 additions & 0 deletions frontend/src/app/components/search-form/search-form.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@
min-width: 0;
}

.all-chains {
align-items: center;
color: var(--u-text-secondary);
display: flex;
flex: 0 0 auto;
font-size: var(--u-text-xs);
gap: var(--u-space-1);
margin: 0;
white-space: nowrap;
}

@media (max-width: 420px) {
.all-chains span {
max-width: 2.4rem;
white-space: normal;
}
}

.search-box-container {
flex: 1 1 auto;
min-width: 0;
Expand Down
Loading
Loading