Merge branch 'master' into natsoni/federation-utxos-expiry

This commit is contained in:
natsoni
2024-03-07 10:27:44 +01:00
committed by GitHub
63 changed files with 1202 additions and 137 deletions

View File

@@ -6,6 +6,7 @@ import { EightBlocksComponent } from './components/eight-blocks/eight-blocks.com
import { MempoolBlockViewComponent } from './components/mempool-block-view/mempool-block-view.component';
import { ClockComponent } from './components/clock/clock.component';
import { StatusViewComponent } from './components/status-view/status-view.component';
import { AddressGroupComponent } from './components/address-group/address-group.component';
const browserWindow = window || {};
// @ts-ignore
@@ -26,6 +27,14 @@ let routes: Routes = [
loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule),
data: { preload: true },
},
{
path: 'wallet',
children: [],
component: AddressGroupComponent,
data: {
networkSpecific: true,
}
},
{
path: 'status',
data: { networks: ['bitcoin', 'liquid'] },
@@ -61,6 +70,14 @@ let routes: Routes = [
loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule),
data: { preload: true },
},
{
path: 'wallet',
children: [],
component: AddressGroupComponent,
data: {
networkSpecific: true,
}
},
{
path: 'status',
data: { networks: ['bitcoin', 'liquid'] },
@@ -88,6 +105,14 @@ let routes: Routes = [
loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule),
data: { preload: true },
},
{
path: 'wallet',
children: [],
component: AddressGroupComponent,
data: {
networkSpecific: true,
}
},
{
path: 'preview',
children: [
@@ -168,6 +193,14 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
loadChildren: () => import ('./liquid/liquid-master-page.module').then(m => m.LiquidMasterPageModule),
data: { preload: true },
},
{
path: 'wallet',
children: [],
component: AddressGroupComponent,
data: {
networkSpecific: true,
}
},
{
path: 'status',
data: { networks: ['bitcoin', 'liquid'] },
@@ -195,6 +228,14 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
loadChildren: () => import ('./liquid/liquid-master-page.module').then(m => m.LiquidMasterPageModule),
data: { preload: true },
},
{
path: 'wallet',
children: [],
component: AddressGroupComponent,
data: {
networkSpecific: true,
}
},
{
path: 'preview',
children: [

View File

@@ -0,0 +1,24 @@
<div class="frame {{ screenSize }}" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'">
<div class="heading">
<app-svg-images name="officialMempoolSpace" style="width: 144px; height: 36px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
<h3 i18n="addresses.balance">Balances</h3>
<div class="spacer"></div>
</div>
<table class="table table-borderless table-striped table-fixed">
<tr>
<th class="address" i18n="addresses.total">Total</th>
<th class="btc"><app-amount [satoshis]="balance" [digitsInfo]="digitsInfo" [noFiat]="true"></app-amount></th>
<th class="fiat"><app-fiat [value]="balance"></app-fiat></th>
</tr>
<tr *ngFor="let address of page">
<td class="address">
<app-truncate [text]="address" [lastChars]="8" [link]="['/address/' | relativeUrl, address]" [external]="true"></app-truncate>
</td>
<td class="btc"><app-amount [satoshis]="addresses[address]" [digitsInfo]="digitsInfo" [noFiat]="true"></app-amount></td>
<td class="fiat"><app-fiat [value]="addresses[address]"></app-fiat></td>
</tr>
</table>
<div *ngIf="addressStrings.length > itemsPerPage" class="pagination">
<ngb-pagination class="pagination-container float-right" [collectionSize]="addressStrings.length" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="pageIndex" (pageChange)="pageChange(pageIndex)" [boundaryLinks]="false" [ellipses]="false"></ngb-pagination>
</div>
</div>

View File

@@ -0,0 +1,101 @@
.frame {
position: relative;
background: #24273e;
padding: 0.5rem;
height: calc(100% + 60px);
}
.heading {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: start;
& > * {
flex-basis: 0;
flex-grow: 1;
}
h3 {
text-align: center;
margin: 0 1em;
}
}
.pagination {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
}
.table {
margin-top: 0.5em;
td, th {
padding: 0.15rem 0.5rem;
&.address {
width: auto;
}
&.btc {
width: 140px;
text-align: right;
}
&.fiat {
width: 142px;
text-align: right;
}
}
tr {
border-collapse: collapse;
&:first-child {
border-bottom: solid 1px white;
td, th {
padding-bottom: 0.3rem;
}
}
&:nth-child(2) {
td, th {
padding-top: 0.3rem;
}
}
&:nth-child(even) {
background: #181b2d;
}
}
@media (min-width: 528px) {
td, th {
&.btc {
width: 160px;
}
&.fiat {
width: 140px;
}
}
}
@media (min-width: 576px) {
td, th {
&.btc {
width: 170px;
}
&.fiat {
width: 140px;
}
}
}
@media (min-width: 992px) {
td, th {
&.btc {
width: 210px;
}
&.fiat {
width: 140px;
}
}
}
}

View File

@@ -0,0 +1,212 @@
import { Component, OnInit, OnDestroy, ChangeDetectorRef, HostListener } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, catchError } from 'rxjs/operators';
import { Address, Transaction } from '../../interfaces/electrs.interface';
import { WebsocketService } from '../../services/websocket.service';
import { StateService } from '../../services/state.service';
import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service';
import { of, Subscription, forkJoin } from 'rxjs';
import { SeoService } from '../../services/seo.service';
import { AddressInformation } from '../../interfaces/node-api.interface';
@Component({
selector: 'app-address-group',
templateUrl: './address-group.component.html',
styleUrls: ['./address-group.component.scss']
})
export class AddressGroupComponent implements OnInit, OnDestroy {
network = '';
balance = 0;
confirmed = 0;
mempool = 0;
addresses: { [address: string]: number | null };
addressStrings: string[] = [];
addressInfo: { [address: string]: AddressInformation | null };
seenTxs: { [txid: string ]: boolean } = {};
isLoadingAddress = true;
error: any;
mainSubscription: Subscription;
wsSubscription: Subscription;
page: string[] = [];
pageIndex: number = 1;
itemsPerPage: number = 10;
screenSize: 'lg' | 'md' | 'sm' = 'lg';
digitsInfo: string = '1.8-8';
constructor(
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
private websocketService: WebsocketService,
private stateService: StateService,
private audioService: AudioService,
private apiService: ApiService,
private seoService: SeoService,
private cd: ChangeDetectorRef,
) { }
ngOnInit(): void {
this.onResize();
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.websocketService.want(['blocks']);
this.mainSubscription = this.route.queryParamMap
.pipe(
switchMap((params: ParamMap) => {
this.error = undefined;
this.isLoadingAddress = true;
this.addresses = {};
this.addressInfo = {};
this.balance = 0;
this.addressStrings = params.get('addresses').split(',').map(address => {
if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) {
return address.toLowerCase();
} else {
return address;
}
});
return forkJoin(this.addressStrings.map(address => {
const getLiquidInfo = ((this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') && /^([a-zA-HJ-NP-Z1-9]{26,35}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[a-km-zA-HJ-NP-Z1-9]{80})$/.test(address));
return forkJoin([
of(address),
this.electrsApiService.getAddress$(address),
(getLiquidInfo ? this.apiService.validateAddress$(address) : of(null)),
]);
}));
}),
catchError(e => {
this.error = e;
return of([]);
})
).subscribe((addresses) => {
for (const addressData of addresses) {
const address = addressData[0];
const addressBalance = addressData[1] as Address;
if (addressBalance) {
this.addresses[address] = addressBalance.chain_stats.funded_txo_sum
+ addressBalance.mempool_stats.funded_txo_sum
- addressBalance.chain_stats.spent_txo_sum
- addressBalance.mempool_stats.spent_txo_sum;
this.balance += this.addresses[address];
this.confirmed += (addressBalance.chain_stats.funded_txo_sum - addressBalance.chain_stats.spent_txo_sum);
}
this.addressInfo[address] = addressData[2] ? addressData[2] as AddressInformation : null;
}
this.websocketService.startTrackAddresses(this.addressStrings);
this.isLoadingAddress = false;
this.pageChange(this.pageIndex);
});
this.wsSubscription = this.stateService.multiAddressTransactions$.subscribe(update => {
for (const address of Object.keys(update)) {
for (const tx of update[address].mempool) {
this.addTransaction(tx, false, false);
}
for (const tx of update[address].confirmed) {
this.addTransaction(tx, true, false);
}
for (const tx of update[address].removed) {
this.removeTransaction(tx, tx.status.confirmed);
}
}
});
}
pageChange(index): void {
this.page = this.addressStrings.slice((index - 1) * this.itemsPerPage, index * this.itemsPerPage);
}
addTransaction(transaction: Transaction, confirmed = false, playSound: boolean = true): boolean {
if (this.seenTxs[transaction.txid]) {
this.removeTransaction(transaction, false);
}
this.seenTxs[transaction.txid] = true;
let balance = 0;
transaction.vin.forEach((vin) => {
if (this.addressStrings.includes(vin?.prevout?.scriptpubkey_address)) {
this.addresses[vin?.prevout?.scriptpubkey_address] -= vin.prevout.value;
balance -= vin.prevout.value;
this.balance -= vin.prevout.value;
if (confirmed) {
this.confirmed -= vin.prevout.value;
}
}
});
transaction.vout.forEach((vout) => {
if (this.addressStrings.includes(vout?.scriptpubkey_address)) {
this.addresses[vout?.scriptpubkey_address] += vout.value;
balance += vout.value;
this.balance += vout.value;
if (confirmed) {
this.confirmed += vout.value;
}
}
});
if (playSound) {
if (balance > 0) {
this.audioService.playSound('cha-ching');
} else {
this.audioService.playSound('chime');
}
}
return true;
}
removeTransaction(transaction: Transaction, confirmed = false): boolean {
transaction.vin.forEach((vin) => {
if (this.addressStrings.includes(vin?.prevout?.scriptpubkey_address)) {
this.addresses[vin?.prevout?.scriptpubkey_address] += vin.prevout.value;
this.balance += vin.prevout.value;
if (confirmed) {
this.confirmed += vin.prevout.value;
}
}
});
transaction.vout.forEach((vout) => {
if (this.addressStrings.includes(vout?.scriptpubkey_address)) {
this.addresses[vout?.scriptpubkey_address] -= vout.value;
this.balance -= vout.value;
if (confirmed) {
this.confirmed -= vout.value;
}
}
});
return true;
}
@HostListener('window:resize', ['$event'])
onResize(): void {
if (window.innerWidth >= 992) {
this.screenSize = 'lg';
this.digitsInfo = '1.8-8';
} else if (window.innerWidth >= 528) {
this.screenSize = 'md';
this.digitsInfo = '1.4-4';
} else {
this.screenSize = 'sm';
this.digitsInfo = '1.2-2';
}
const newItemsPerPage = Math.floor((window.innerHeight - 150) / 30);
if (newItemsPerPage !== this.itemsPerPage) {
this.itemsPerPage = newItemsPerPage;
this.pageIndex = 1;
this.pageChange(this.pageIndex);
}
}
ngOnDestroy(): void {
this.mainSubscription.unsubscribe();
this.wsSubscription.unsubscribe();
this.websocketService.stopTrackingAddresses();
}
}

View File

@@ -56,7 +56,7 @@
<app-time kind="since" [time]="block.timestamp" [fastRender]="true" [precision]="1" minUnit="minute"></app-time></div>
</ng-container>
</div>
<div class="animated" [class]="showMiningInfo ? 'show' : 'hide'" *ngIf="block.extras?.pool != undefined">
<div class="animated" [class]="markHeight === block.height ? 'hide' : 'show'" *ngIf="block.extras?.pool != undefined">
<a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge badge-primary"
[routerLink]="[('/mining/pool/' + block.extras.pool.slug) | relativeUrl]">
{{ block.extras.pool.name}}</a>

View File

@@ -166,7 +166,7 @@
opacity: 1;
}
.hide {
opacity: 0;
opacity: 0.4;
pointer-events : none;
}

View File

@@ -1,4 +1,4 @@
<footer class="footer">
<footer class="footer" [class.inline-footer]="inline">
<div class="container-xl">
<div class="row text-center" *ngIf="mempoolInfoData$ | async as mempoolInfoData">
<div class="col d-none d-sm-block">

View File

@@ -6,6 +6,12 @@
background-color: #1d1f31;
box-shadow: 15px 15px 15px 15px #000;
z-index: 10;
&.inline-footer {
position: relative;
bottom: unset;
top: -44px;
}
}
.sub-text {

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Observable, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
@@ -23,6 +23,8 @@ interface MempoolInfoData {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FooterComponent implements OnInit {
@Input() inline = false;
mempoolBlocksData$: Observable<MempoolBlocksData>;
mempoolInfoData$: Observable<MempoolInfoData>;
vBytesPerSecondLimit = 1667;

View File

@@ -1,7 +1,7 @@
<ng-container *ngIf="(loadingBlocks$ | async) === false; else loadingBlocks" [class.minimal]="minimal">
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr" [style.--block-size]="blockWidth+'px'" *ngIf="(difficultyAdjustments$ | async) as da;">
<div class="flashing">
<ng-template ngFor let-projectedBlock [ngForOf]="mempoolBlocks$ | async" let-i="index" [ngForTrackBy]="trackByFn">
<div class="flashing" *ngIf="(mempoolBlocks$ | async) as mempoolBlocks">
<ng-template ngFor let-projectedBlock [ngForOf]="mempoolBlocks" let-i="index" [ngForTrackBy]="trackByFn">
<div
*ngIf="minimal && spotlight > 0 && spotlight === i + 1"
class="spotlight-bottom"

View File

@@ -1,9 +1,9 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges, Output, EventEmitter } from '@angular/core';
import { Subscription, Observable, fromEvent, merge, of, combineLatest } from 'rxjs';
import { Subscription, Observable, of, combineLatest } from 'rxjs';
import { MempoolBlock } from '../../interfaces/websocket.interface';
import { StateService } from '../../services/state.service';
import { Router } from '@angular/router';
import { take, map, switchMap, tap } from 'rxjs/operators';
import { map, switchMap, tap } from 'rxjs/operators';
import { feeLevels, mempoolFeeColors } from '../../app.constants';
import { specialBlocks } from '../../app.constants';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@@ -86,7 +86,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
public stateService: StateService,
private cd: ChangeDetectorRef,
private relativeUrlPipe: RelativeUrlPipe,
private location: Location
private location: Location,
) { }
enabledMiningInfoIfNeeded(url) {
@@ -129,50 +129,44 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
})
);
this.mempoolBlocks$ = merge(
of(true),
fromEvent(window, 'resize')
)
.pipe(
switchMap(() => combineLatest([
this.stateService.blocks$.pipe(map((blocks) => blocks[0])),
this.stateService.mempoolBlocks$
.pipe(
map((mempoolBlocks) => {
if (!mempoolBlocks.length) {
return [{ index: 0, blockSize: 0, blockVSize: 0, feeRange: [0, 0], medianFee: 0, nTx: 0, totalFees: 0 }];
}
return mempoolBlocks;
}),
)
])),
map(([lastBlock, mempoolBlocks]) => {
mempoolBlocks.forEach((block, i) => {
block.index = this.blockIndex + i;
block.height = lastBlock.height + i + 1;
block.blink = specialBlocks[block.height]?.networks.includes(this.stateService.network || 'mainnet') ? true : false;
});
this.mempoolBlocks$ = combineLatest([
this.stateService.blocks$.pipe(map((blocks) => blocks[0])),
this.stateService.mempoolBlocks$
.pipe(
map((mempoolBlocks) => {
if (!mempoolBlocks.length) {
return [{ index: 0, blockSize: 0, blockVSize: 0, feeRange: [0, 0], medianFee: 0, nTx: 0, totalFees: 0 }];
}
return mempoolBlocks;
}),
)
]).pipe(
map(([lastBlock, mempoolBlocks]) => {
mempoolBlocks.forEach((block, i) => {
block.index = this.blockIndex + i;
block.height = lastBlock.height + i + 1;
block.blink = specialBlocks[block.height]?.networks.includes(this.stateService.network || 'mainnet') ? true : false;
});
const stringifiedBlocks = JSON.stringify(mempoolBlocks);
this.mempoolBlocksFull = JSON.parse(stringifiedBlocks);
this.mempoolBlocks = this.reduceMempoolBlocksToFitScreen(JSON.parse(stringifiedBlocks));
const stringifiedBlocks = JSON.stringify(mempoolBlocks);
this.mempoolBlocksFull = JSON.parse(stringifiedBlocks);
this.mempoolBlocks = this.reduceMempoolBlocksToFitScreen(JSON.parse(stringifiedBlocks));
this.now = Date.now();
this.now = Date.now();
this.updateMempoolBlockStyles();
this.calculateTransactionPosition();
return this.mempoolBlocks;
}),
tap(() => {
const width = this.containerOffset + this.mempoolBlocks.length * this.blockOffset;
if (this.mempoolWidth !== width) {
this.mempoolWidth = width;
this.widthChange.emit(this.mempoolWidth);
this.cd.markForCheck();
}
})
);
this.updateMempoolBlockStyles();
this.calculateTransactionPosition();
return this.mempoolBlocks;
}),
tap(() => {
const width = this.containerOffset + this.mempoolBlocks.length * this.blockOffset;
if (this.mempoolWidth !== width) {
this.mempoolWidth = width;
this.widthChange.emit(this.mempoolWidth);
}
})
);
this.difficultyAdjustments$ = this.stateService.difficultyAdjustment$
.pipe(

View File

@@ -0,0 +1,36 @@
<div class="tomahawk">
<div class="links">
<span>Monitoring</span>
<a [routerLink]='"/nodes"'>Nodes</a>
</div>
<app-start [showLoadingIndicator]="true"></app-start>
<app-footer [inline]="true"></app-footer>
<ng-container *ngIf="(hosts$ | async) as hosts">
<div class="status-panel">
<table class="status-table table table-fixed table-borderless table-striped" *ngIf="(tip$ | async) as tip">
<tbody>
<tr>
<th class="rank"></th>
<th class="flag"></th>
<th class="host">Host</th>
<th class="updated">Last checked</th>
<th class="rtt only-small">RTT</th>
<th class="rtt only-large">RTT</th>
<th class="height">Height</th>
</tr>
<tr *ngFor="let host of hosts; let i = index; trackBy: trackByFn">
<td class="rank">{{ i + 1 }}</td>
<td class="flag">{{ host.active ? '⭐️' : host.flag }}</td>
<td class="host">{{ host.link }}</td>
<td class="updated">{{ getLastUpdateSeconds(host) }}</td>
<td class="rtt only-small">{{ (host.rtt / 1000) | number : '1.1-1' }} {{ host.rtt == null ? '' : 's'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }}</td>
<td class="rtt only-large">{{ host.rtt | number : '1.0-0' }} {{ host.rtt == null ? '' : 'ms'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }}</td>
<td class="height">{{ host.latestHeight }} {{ !host.checked ? '⏳' : (host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < tip ? '🟧' : '')) }}</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
</div>

View File

@@ -0,0 +1,75 @@
.tomahawk {
.links {
text-align: right;
margin-inline-end: 1em;
a, span {
margin-left: 1em;
}
}
.status-panel {
max-width: 720px;
margin: auto;
padding: 1em;
background: #24273e;
}
.status-table {
width: 100%;
td, th {
padding: 0.25em;
&.rank, &.flag {
width: 28px;
text-align: right;
}
&.updated {
display: none;
width: 130px;
text-align: right;
white-space: pre-wrap;
}
&.rtt, &.height {
width: 92px;
text-align: right;
}
&.only-small {
display: table-cell;
&.rtt {
width: 60px;
}
}
&.only-large {
display: none;
}
&.height {
padding-right: 0.5em;
}
&.host {
width: auto;
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 576px) {
&.rank, &.flag {
width: 32px;
}
&.updated {
display: table-cell;
}
&.rtt, &.height {
width: 96px;
}
&.only-small {
display: none;
}
&.only-large {
display: table-cell;
}
}
}
}
}

View File

@@ -0,0 +1,84 @@
import { Component, OnInit, ChangeDetectionStrategy, SecurityContext, ChangeDetectorRef } from '@angular/core';
import { WebsocketService } from '../../services/websocket.service';
import { Observable, Subject, map } from 'rxjs';
import { StateService } from '../../services/state.service';
import { HealthCheckHost } from '../../interfaces/websocket.interface';
import { DomSanitizer } from '@angular/platform-browser';
@Component({
selector: 'app-server-health',
templateUrl: './server-health.component.html',
styleUrls: ['./server-health.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ServerHealthComponent implements OnInit {
hosts$: Observable<HealthCheckHost[]>;
tip$: Subject<number>;
interval: number;
now: number = Date.now();
constructor(
private websocketService: WebsocketService,
private stateService: StateService,
private cd: ChangeDetectorRef,
public sanitizer: DomSanitizer,
) {}
ngOnInit(): void {
this.hosts$ = this.stateService.serverHealth$.pipe(
map((hosts) => {
const subpath = window.location.pathname.slice(0, -11);
for (const host of hosts) {
let statusUrl = '';
let linkHost = '';
if (host.socket) {
statusUrl = 'https://' + window.location.hostname + subpath + '/status';
linkHost = window.location.hostname + subpath;
} else {
const hostUrl = new URL(host.host);
statusUrl = 'https://' + hostUrl.hostname + subpath + '/status';
linkHost = hostUrl.hostname + subpath;
}
host.statusPage = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL, statusUrl));
host.link = linkHost;
host.flag = this.parseFlag(host.host);
}
return hosts;
})
);
this.tip$ = this.stateService.chainTip$;
this.websocketService.want(['mempool-blocks', 'stats', 'blocks', 'tomahawk']);
this.interval = window.setInterval(() => {
this.now = Date.now();
this.cd.markForCheck();
}, 1000);
}
trackByFn(index: number, host: HealthCheckHost): string {
return host.host;
}
getLastUpdateSeconds(host: HealthCheckHost): string {
if (host.lastChecked) {
const seconds = Math.ceil((this.now - host.lastChecked) / 1000);
return `${seconds} second${seconds > 1 ? 's' : ' '} ago`;
} else {
return '~';
}
}
private parseFlag(host: string): string {
if (host.includes('.fra.')) {
return '🇩🇪';
} else if (host.includes('.tk7.')) {
return '🇯🇵';
} else if (host.includes('.fmt.')) {
return '🇺🇸';
} else if (host.includes('.va1.')) {
return '🇺🇸';
} else {
return '';
}
}
}

View File

@@ -0,0 +1,16 @@
<div class="tomahawk">
<div class="links">
<a [routerLink]='"/monitoring"'>Monitoring</a>
<span>Nodes</span>
</div>
<app-start [showLoadingIndicator]="true"></app-start>
<app-footer [inline]="true"></app-footer>
<ng-container *ngFor="let host of hosts; trackBy: trackByFn">
<h5 [id]="host.host" class="hostLink">
<a [href]="'https://' + host.link">{{ host.link }}</a>
</h5>
<iframe class="mempoolStatus" [src]="host.statusPage"></iframe>
</ng-container>
</div>

View File

@@ -0,0 +1,22 @@
.tomahawk {
.links {
text-align: right;
margin-inline-end: 1em;
a, span {
margin-left: 1em;
}
}
.mempoolStatus {
width: 100%;
height: 270px;
border: none;
}
.hostLink {
text-align: center;
margin: auto;
margin-top: 1em;
}
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit, ChangeDetectionStrategy, SecurityContext, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { WebsocketService } from '../../services/websocket.service';
import { Observable, Subject, Subscription, map, tap } from 'rxjs';
import { StateService } from '../../services/state.service';
import { HealthCheckHost } from '../../interfaces/websocket.interface';
import { DomSanitizer } from '@angular/platform-browser';
@Component({
selector: 'app-server-status',
templateUrl: './server-status.component.html',
styleUrls: ['./server-status.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ServerStatusComponent implements OnInit, OnDestroy {
tip$: Subject<number>;
hosts: HealthCheckHost[] = [];
hostSubscription: Subscription;
constructor(
private websocketService: WebsocketService,
private stateService: StateService,
private cd: ChangeDetectorRef,
public sanitizer: DomSanitizer,
) {}
ngOnInit(): void {
this.hostSubscription = this.stateService.serverHealth$.pipe(
map((hosts) => {
const subpath = window.location.pathname.slice(0, -6);
for (const host of hosts) {
let statusUrl = '';
let linkHost = '';
if (host.socket) {
statusUrl = 'https://' + window.location.hostname + subpath + '/status';
linkHost = window.location.hostname + subpath;
} else {
const hostUrl = new URL(host.host);
statusUrl = 'https://' + hostUrl.hostname + subpath + '/status';
linkHost = hostUrl.hostname + subpath;
}
host.statusPage = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL, statusUrl));
host.link = linkHost;
}
return hosts;
}),
tap((hosts) => {
if (this.hosts.length !== hosts.length) {
this.hosts = hosts.sort((a,b) => {
const aParts = (a.host?.split('.') || []).reverse();
const bParts = (b.host?.split('.') || []).reverse();
let i = 0;
while (i < Math.max(aParts.length, bParts.length)) {
if (aParts[i] && !bParts[i]) {
return 1;
} else if (bParts[i] && !aParts[i]) {
return -1;
} else if (aParts[i] !== bParts[i]) {
return aParts[i].localeCompare(bParts[i]);
}
i++;
}
return 0;
});
}
this.cd.markForCheck();
})
).subscribe();
this.tip$ = this.stateService.chainTip$;
this.websocketService.want(['mempool-blocks', 'stats', 'blocks', 'tomahawk']);
}
trackByFn(index: number, host: HealthCheckHost): string {
return host.host;
}
ngOnDestroy(): void {
this.hosts = [];
this.hostSubscription.unsubscribe();
}
}

View File

@@ -1,3 +1,7 @@
<div id="enterprise-cta-desktop">
<p>Get higher API limits with Mempool Enterprise®</p>
<a class="btn btn-small btn-purple" href="/enterprise">More Info <fa-icon [icon]="['fas', 'angle-right']" [styles]="{'font-size': '12px'}"></fa-icon></a>
</div>
<div *ngFor="let item of tabData">
<p *ngIf="( item.type === 'category' ) && ( item.showConditions.indexOf(network.val) > -1 ) && ( !item.hasOwnProperty('options') || ( item.hasOwnProperty('options') && item.options.hasOwnProperty('officialOnly') && item.options.officialOnly && officialMempoolInstance ))">{{ item.title }}</p>
<a *ngIf="( item.type !== 'category' ) && ( item.showConditions.indexOf(network.val) > -1 ) && ( !item.hasOwnProperty('options') || ( item.hasOwnProperty('options') && item.options.hasOwnProperty('officialOnly') && item.options.officialOnly && officialMempoolInstance ) || ( item.hasOwnProperty('options') && item.options.hasOwnProperty('auditOnly') && item.options.auditOnly && auditEnabled ) )" [routerLink]="['./']" fragment="{{ item.fragment }}" (click)="navLinkClick($event)">{{ item.title }}</a>

View File

@@ -11,3 +11,22 @@ a {
display: block;
margin: 5px 0;
}
#enterprise-cta-desktop {
text-align: center;
padding: 20px;
margin: 20px 20px 20px 0;
background-color: #1d1f31;
border-radius: 12px;
}
#enterprise-cta-desktop p {
margin: 0 auto 16px auto;
color: #fff;
font-weight: 400;
}
#enterprise-cta-desktop a {
display: inline-block;
}

View File

@@ -39,6 +39,14 @@
<div class="doc-content">
<div id="enterprise-cta-mobile" *ngIf="showMobileEnterpriseUpsell">
<p>Get higher API limits with <span class="no-line-break">Mempool Enterprise®</span></p>
<div class="button-group">
<a class="btn btn-small btn-secondary" (click)="showMobileEnterpriseUpsell = false">No Thanks</a>
<a class="btn btn-small btn-purple" href="https://mempool.space/enterprise">More Info <fa-icon [icon]="['fas', 'angle-right']" [styles]="{'font-size': '12px'}"></fa-icon></a>
</div>
</div>
<p class="doc-welcome-note">Below is a reference for the {{ network.val === '' ? 'Bitcoin' : network.val.charAt(0).toUpperCase() + network.val.slice(1) }} <ng-container i18n="api-docs.title">REST API service</ng-container>.</p>
<p class="doc-welcome-note api-note" *ngIf="officialMempoolInstance">Note that we enforce rate limits. If you exceed these limits, you will get an HTTP 429 error. If you repeatedly exceed the limits, you may be banned from accessing the service altogether. Consider an <a href="https://mempool.space/enterprise">enterprise sponsorship</a> if you need higher API limits.</p>

View File

@@ -315,6 +315,41 @@ h3 {
margin-bottom: 0;
}
#enterprise-cta-mobile {
padding: 20px;
background-color: #1d1f31;
border-radius: 0.25rem;
text-align: center;
position: fixed;
z-index: 100;
left: 30px;
width: calc(100% - 60px);
bottom: 70px;
display: none;
border: 3px solid #533180;
}
#enterprise-cta-mobile p {
font-size: 16px;
display: inline-block;
margin: 0 auto;
}
#enterprise-cta-mobile a {
padding: 4px 8px;
font-size: 16px;
margin: 15px 5px 5px 5px;
}
#enterprise-cta-mobile .btn-secondary:hover {
background-color: #2d3348;
border-color: #2d3348;
}
#enterprise-cta-mobile .no-line-break {
white-space: nowrap;
}
@media (max-width: 992px) {
h3 {
@@ -373,6 +408,10 @@ h3 {
#disclaimer table {
display: none;
}
#enterprise-cta-mobile {
display: initial;
}
}
@media (min-width: 992px) {

View File

@@ -30,6 +30,7 @@ export class ApiDocsComponent implements OnInit, AfterViewInit {
officialMempoolInstance: boolean;
auditEnabled: boolean;
mobileViewport: boolean = false;
showMobileEnterpriseUpsell: boolean = true;
timeLtrSubscription: Subscription;
timeLtr: boolean = this.stateService.timeLtr.value;

View File

@@ -1,3 +1,4 @@
import { SafeResourceUrl } from '@angular/platform-browser';
import { ILoadingIndicators } from '../services/state.service';
import { Transaction } from './electrs.interface';
import { BlockExtended, DifficultyAdjustment, RbfTree } from './node-api.interface';
@@ -120,4 +121,19 @@ export interface Recommendedfees {
hourFee: number;
minimumFee: number;
economyFee: number;
}
export interface HealthCheckHost {
host: string;
active: boolean;
rtt: number;
latestHeight: number;
socket: boolean;
outOfSync: boolean;
unreachable: boolean;
checked: boolean;
lastChecked: number;
link?: string;
statusPage?: SafeResourceUrl;
flag?: string;
}

View File

@@ -19,6 +19,8 @@ import { RecentPegsListComponent } from '../components/liquid-reserves-audit/rec
import { FederationWalletComponent } from '../components/liquid-reserves-audit/federation-wallet/federation-wallet.component';
import { FederationUtxosListComponent } from '../components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component';
import { FederationAddressesListComponent } from '../components/liquid-reserves-audit/federation-addresses-list/federation-addresses-list.component';
import { ServerHealthComponent } from '../components/server-health/server-health.component';
import { ServerStatusComponent } from '../components/server-health/server-status.component';
const routes: Routes = [
{
@@ -140,6 +142,19 @@ const routes: Routes = [
},
];
if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) {
routes[0].children.push({
path: 'nodes',
data: { networks: ['bitcoin', 'liquid'] },
component: ServerHealthComponent
});
routes[0].children.push({
path: 'network',
data: { networks: ['bitcoin', 'liquid'] },
component: ServerStatusComponent
});
}
@NgModule({
imports: [
RouterModule.forChild(routes)

View File

@@ -6,10 +6,13 @@ import { SharedModule } from './shared/shared.module';
import { StartComponent } from './components/start/start.component';
import { AddressComponent } from './components/address/address.component';
import { AddressGroupComponent } from './components/address-group/address-group.component';
import { PushTransactionComponent } from './components/push-transaction/push-transaction.component';
import { CalculatorComponent } from './components/calculator/calculator.component';
import { BlocksList } from './components/blocks-list/blocks-list.component';
import { RbfList } from './components/rbf-list/rbf-list.component';
import { ServerHealthComponent } from './components/server-health/server-health.component';
import { ServerStatusComponent } from './components/server-health/server-status.component';
const browserWindow = window || {};
// @ts-ignore
@@ -96,6 +99,19 @@ const routes: Routes = [
}
];
if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) {
routes[0].children.push({
path: 'monitoring',
data: { networks: ['bitcoin', 'liquid'] },
component: ServerHealthComponent
});
routes[0].children.push({
path: 'nodes',
data: { networks: ['bitcoin', 'liquid'] },
component: ServerStatusComponent
});
}
@NgModule({
imports: [
RouterModule.forChild(routes)

View File

@@ -1,14 +1,13 @@
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable, merge } from 'rxjs';
import { Transaction } from '../interfaces/electrs.interface';
import { IBackendInfo, MempoolBlock, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, TransactionCompressed, TransactionStripped } from '../interfaces/websocket.interface';
import { HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, TransactionStripped } from '../interfaces/websocket.interface';
import { BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface';
import { Router, NavigationStart } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
import { filter, map, scan, shareReplay } from 'rxjs/operators';
import { StorageService } from './storage.service';
import { hasTouchScreen } from '../shared/pipes/bytes-pipe/utils';
import { ApiService } from './api.service';
import { ActiveFilter } from '../shared/filters.utils';
export interface MarkBlockState {
@@ -119,6 +118,7 @@ export class StateService {
mempoolTransactions$ = new Subject<Transaction>();
mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition, cpfp: CpfpInfo | null}>();
mempoolRemovedTransactions$ = new Subject<Transaction>();
multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>();
blockTransactions$ = new Subject<Transaction>();
isLoadingWebSocket$ = new ReplaySubject<boolean>(1);
isLoadingMempool$ = new BehaviorSubject<boolean>(true);
@@ -129,6 +129,7 @@ export class StateService {
loadingIndicators$ = new ReplaySubject<ILoadingIndicators>(1);
recommendedFees$ = new ReplaySubject<Recommendedfees>(1);
chainTip$ = new ReplaySubject<number>(-1);
serverHealth$ = new Subject<HealthCheckHost[]>();
live2Chart$ = new Subject<OptimizedMempoolStats>();

View File

@@ -32,6 +32,7 @@ export class WebsocketService {
private isTrackingRbf: 'all' | 'fullRbf' | false = false;
private isTrackingRbfSummary = false;
private isTrackingAddress: string | false = false;
private isTrackingAddresses: string[] | false = false;
private trackingMempoolBlock: number;
private latestGitCommit = '';
private onlineCheckTimeout: number;
@@ -126,6 +127,9 @@ export class WebsocketService {
if (this.isTrackingAddress) {
this.startTrackAddress(this.isTrackingAddress);
}
if (this.isTrackingAddresses) {
this.startTrackAddresses(this.isTrackingAddresses);
}
this.stateService.connectionState$.next(2);
}
@@ -175,6 +179,16 @@ export class WebsocketService {
this.isTrackingAddress = false;
}
startTrackAddresses(addresses: string[]) {
this.websocketSubject.next({ 'track-addresses': addresses });
this.isTrackingAddresses = addresses;
}
stopTrackingAddresses() {
this.websocketSubject.next({ 'track-addresses': [] });
this.isTrackingAddresses = false;
}
startTrackAsset(asset: string) {
this.websocketSubject.next({ 'track-asset': asset });
}
@@ -374,6 +388,10 @@ export class WebsocketService {
});
}
if (response['multi-address-transactions']) {
this.stateService.multiAddressTransactions$.next(response['multi-address-transactions']);
}
if (response['block-transactions']) {
response['block-transactions'].forEach((addressTransaction: Transaction) => {
this.stateService.blockTransactions$.next(addressTransaction);
@@ -415,6 +433,10 @@ export class WebsocketService {
this.stateService.previousRetarget$.next(response.previousRetarget);
}
if (response['tomahawk']) {
this.stateService.serverHealth$.next(response['tomahawk']);
}
if (response['git-commit']) {
this.stateService.backendInfo$.next(response['git-commit']);
}

View File

@@ -1,6 +1,6 @@
<span class="truncate" [style.max-width]="maxWidth ? maxWidth + 'px' : null" [style.justify-content]="textAlign" [class.inline]="inline">
<ng-container *ngIf="link">
<a [routerLink]="link" class="truncate-link">
<a [routerLink]="link" class="truncate-link" [target]="external ? '_blank' : ''">
<ng-container *ngIf="rtl; then rtlTruncated; else ltrTruncated;"></ng-container>
</a>
</ng-container>

View File

@@ -9,6 +9,7 @@ import { Component, Input, Inject, LOCALE_ID, ChangeDetectionStrategy } from '@a
export class TruncateComponent {
@Input() text: string;
@Input() link: any = null;
@Input() external: boolean = false;
@Input() lastChars: number = 4;
@Input() maxWidth: number = null;
@Input() inline: boolean = false;

View File

@@ -46,6 +46,7 @@ import { BlockOverviewGraphComponent } from '../components/block-overview-graph/
import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
import { BlockFiltersComponent } from '../components/block-filters/block-filters.component';
import { AddressComponent } from '../components/address/address.component';
import { AddressGroupComponent } from '../components/address-group/address-group.component';
import { SearchFormComponent } from '../components/search-form/search-form.component';
import { AddressLabelsComponent } from '../components/address-labels/address-labels.component';
import { FooterComponent } from '../components/footer/footer.component';
@@ -53,6 +54,8 @@ import { AssetComponent } from '../components/asset/asset.component';
import { AssetsComponent } from '../components/assets/assets.component';
import { AssetsNavComponent } from '../components/assets/assets-nav/assets-nav.component';
import { StatusViewComponent } from '../components/status-view/status-view.component';
import { ServerHealthComponent } from '../components/server-health/server-health.component';
import { ServerStatusComponent } from '../components/server-health/server-status.component';
import { FeesBoxComponent } from '../components/fees-box/fees-box.component';
import { DifficultyComponent } from '../components/difficulty/difficulty.component';
import { DifficultyTooltipComponent } from '../components/difficulty/difficulty-tooltip.component';
@@ -145,12 +148,15 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
BlockFiltersComponent,
TransactionsListComponent,
AddressComponent,
AddressGroupComponent,
SearchFormComponent,
AddressLabelsComponent,
FooterComponent,
AssetComponent,
AssetsComponent,
StatusViewComponent,
ServerHealthComponent,
ServerStatusComponent,
FeesBoxComponent,
DifficultyComponent,
DifficultyMiningComponent,
@@ -271,12 +277,15 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
BlockFiltersComponent,
TransactionsListComponent,
AddressComponent,
AddressGroupComponent,
SearchFormComponent,
AddressLabelsComponent,
FooterComponent,
AssetComponent,
AssetsComponent,
StatusViewComponent,
ServerHealthComponent,
ServerStatusComponent,
FeesBoxComponent,
DifficultyComponent,
DifficultyMiningComponent,