Merge branch 'master' into mononaut/testmempoolaccept

This commit is contained in:
softsimon
2024-05-04 14:11:48 +07:00
committed by GitHub
80 changed files with 4445 additions and 783 deletions

View File

@@ -23,7 +23,7 @@
<span class="font-weight-bold">Accelerate</span>
<span style="color: rgb(186, 186, 186); font-size: 14px;">Confirmation expected within ~30 minutes<br>
@if (!calculating) {
<app-fiat [value]="cost"></app-fiat>fee (<span><small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats|sats">sats</span></span>)
<app-fiat [value]="cost"></app-fiat>fee (<span><small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></span>)
} @else {
<span class="estimating">Calculating cost...</span>
}

View File

@@ -1,14 +1,14 @@
<app-indexing-progress></app-indexing-progress>
<app-indexing-progress *ngIf="!widget"></app-indexing-progress>
<div class="full-container">
<div class="card-header mb-0 mb-md-2">
<div [class.full-container]="!widget">
<div *ngIf="!widget" class="card-header mb-0 mb-md-2">
<div class="d-flex d-md-block align-items-baseline">
<span i18n="address.balance-history">Balance History</span>
</div>
</div>
</div>
<ng-container *ngIf="!error">
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
<div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
@@ -20,4 +20,8 @@
<p class="error">{{ error }}</p>
</div>
</ng-container>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>
</div>
</div>

View File

@@ -66,7 +66,6 @@
.chart-widget {
width: 100%;
height: 100%;
max-height: 270px;
}
.disabled {

View File

@@ -1,12 +1,22 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, SimpleChanges } from '@angular/core';
import { echarts, EChartsOption } from '../../graphs/echarts';
import { of } from 'rxjs';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ChainStats } from '../../interfaces/electrs.interface';
import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe';
import { Router } from '@angular/router';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { StateService } from '../../services/state.service';
const periodSeconds = {
'1d': (60 * 60 * 24),
'3d': (60 * 60 * 24 * 3),
'1w': (60 * 60 * 24 * 7),
'1m': (60 * 60 * 24 * 30),
'6m': (60 * 60 * 24 * 180),
'1y': (60 * 60 * 24 * 365),
};
@Component({
selector: 'app-address-graph',
@@ -26,8 +36,12 @@ export class AddressGraphComponent implements OnChanges {
@Input() address: string;
@Input() isPubkey: boolean = false;
@Input() stats: ChainStats;
@Input() addressSummary$: Observable<AddressTxSummary[]> | null;
@Input() period: '1d' | '3d' | '1w' | '1m' | '6m' | '1y' | 'all' = 'all';
@Input() height: number = 200;
@Input() right: number | string = 10;
@Input() left: number | string = 70;
@Input() widget: boolean = false;
data: any[] = [];
hoverData: any[] = [];
@@ -43,6 +57,7 @@ export class AddressGraphComponent implements OnChanges {
constructor(
@Inject(LOCALE_ID) public locale: string,
public stateService: StateService,
private electrsApiService: ElectrsApiService,
private router: Router,
private amountShortenerPipe: AmountShortenerPipe,
@@ -52,14 +67,17 @@ export class AddressGraphComponent implements OnChanges {
ngOnChanges(changes: SimpleChanges): void {
this.isLoading = true;
(this.isPubkey
if (!this.address || !this.stats) {
return;
}
(this.addressSummary$ || (this.isPubkey
? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac')
: this.electrsApiService.getAddressSummary$(this.address)).pipe(
catchError(e => {
this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`;
return of(null);
}),
).subscribe(addressSummary => {
)).subscribe(addressSummary => {
if (addressSummary) {
this.error = null;
this.prepareChartOptions(addressSummary);
@@ -70,14 +88,24 @@ export class AddressGraphComponent implements OnChanges {
}
prepareChartOptions(summary): void {
let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); // + (summary[0]?.value || 0);
let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum);
this.data = summary.map(d => {
const balance = total;
total -= d.value;
return [d.time * 1000, balance, d];
}).reverse();
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1])), 0);
if (this.period !== 'all') {
const now = Date.now();
const start = now - (periodSeconds[this.period] * 1000);
this.data = this.data.filter(d => d[0] >= start);
this.data.push(
{value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }}
);
}
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] || d.value[1])), 0);
const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] || d.value[1])), maxValue);
this.chartOptions = {
color: [
@@ -108,6 +136,9 @@ export class AddressGraphComponent implements OnChanges {
},
borderColor: '#000',
formatter: function (data): string {
if (!data?.length || !data[0]?.data?.[2]?.txid) {
return '';
}
const header = data.length === 1
? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}`
: `${data.length} transactions`;
@@ -141,13 +172,17 @@ export class AddressGraphComponent implements OnChanges {
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: (val): string => {
if (maxValue > 1_000_000_000) {
let valSpan = maxValue - (this.period === 'all' ? 0 : minValue);
if (valSpan > 100_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`;
} else if (maxValue > 100_000_000) {
}
else if (valSpan > 1_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`;
} else if (valSpan > 100_000_000) {
return `${(val / 100_000_000).toFixed(1)} BTC`;
} else if (maxValue > 10_000_000) {
} else if (valSpan > 10_000_000) {
return `${(val / 100_000_000).toFixed(2)} BTC`;
} else if (maxValue > 1_000_000) {
} else if (valSpan > 1_000_000) {
return `${(val / 100_000_000).toFixed(3)} BTC`;
} else {
return `${this.amountShortenerPipe.transform(val, 0)} sats`;
@@ -157,6 +192,7 @@ export class AddressGraphComponent implements OnChanges {
splitLine: {
show: false,
},
min: this.period === 'all' ? 0 : 'dataMin'
},
],
series: [

View File

@@ -0,0 +1,32 @@
<table class="table latest-transactions">
<thead>
<th class="table-cell-txid" i18n="dashboard.latest-transactions.txid">TXID</th>
<th class="table-cell-satoshis" i18n="dashboard.latest-transactions.amount">Amount</th>
<th class="table-cell-fiat">{{ currency }}</th>
<th class="table-cell-date" i18n="shared.date">Date</th>
</thead>
<tbody *ngIf="transactions$ | async as transactions else recentTransactionsSkeleton">
<tr *ngFor="let transaction of transactions; let i = index;">
<td class="table-cell-txid">
<a [routerLink]="['/tx' | relativeUrl, transaction.txid]">
<app-truncate [text]="transaction.txid" [lastChars]="5"></app-truncate>
</a>
</td>
<td class="table-cell-satoshis"><app-amount [satoshis]="transaction.value" digitsInfo="1.2-4" [noFiat]="true"></app-amount></td>
<td class="table-cell-fiat" ><app-fiat [value]="transaction.value" [blockConversion]="transaction.price" digitsInfo="1.0-0"></app-fiat></td>
<td class="table-cell-date"><app-time kind="since" [time]="transaction.time" [fastRender]="true"></app-time></td>
</tr>
</tbody>
<div class="">&nbsp;</div>
</table>
<ng-template #recentTransactionsSkeleton>
<tbody>
<tr *ngFor="let i of [1,2,3,4,5,6]">
<td class="table-cell-txid"><div class="skeleton-loader skeleton-loader-transactions"></div> </td>
<td class="table-cell-satoshis"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-fiat"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-fees"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
</tr>
</tbody>
</ng-template>

View File

@@ -0,0 +1,50 @@
.latest-transactions {
width: 100%;
text-align: left;
table-layout:fixed;
tr, td, th {
border: 0px;
padding-top: 0.71rem !important;
padding-bottom: 0.75rem !important;
}
td {
overflow:hidden;
width: 25%;
}
.table-cell-satoshis {
display: none;
text-align: right;
@media (min-width: 576px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 1100px) {
display: table-cell;
}
}
.table-cell-fiat {
display: none;
text-align: right;
@media (min-width: 485px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: table-cell;
}
}
.table-cell-date {
text-align: right;
}
}
.skeleton-loader-transactions {
max-width: 250px;
position: relative;
top: 2px;
margin-bottom: -3px;
height: 18px;
}

View File

@@ -0,0 +1,76 @@
import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Address, AddressTxSummary } from '../../interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { Observable, Subscription, catchError, map, of, switchMap, zip } from 'rxjs';
import { PriceService } from '../../services/price.service';
@Component({
selector: 'app-address-transactions-widget',
templateUrl: './address-transactions-widget.component.html',
styleUrls: ['./address-transactions-widget.component.scss'],
})
export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, OnDestroy {
@Input() address: string;
@Input() addressInfo: Address;
@Input() addressSummary$: Observable<AddressTxSummary[]> | null;
@Input() isPubkey: boolean = false;
currencySubscription: Subscription;
currency: string;
transactions$: Observable<any[]>;
isLoading: boolean = true;
error: any;
constructor(
public stateService: StateService,
private electrsApiService: ElectrsApiService,
private priceService: PriceService,
) { }
ngOnInit(): void {
this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => {
this.currency = fiat;
});
this.startAddressSubscription();
}
ngOnChanges(changes: SimpleChanges): void {
this.startAddressSubscription();
}
startAddressSubscription(): void {
this.isLoading = true;
if (!this.address || !this.addressInfo) {
return;
}
this.transactions$ = (this.addressSummary$ || (this.isPubkey
? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac')
: this.electrsApiService.getAddressSummary$(this.address)).pipe(
catchError(e => {
this.error = `Failed to fetch address history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`;
return of(null);
})
)).pipe(
map(summary => {
return summary?.slice(0, 6);
}),
switchMap(txs => {
return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, true, this.currency).pipe(
map(price => {
return {
...tx,
price,
};
})
))));
})
);
}
ngOnDestroy(): void {
this.currencySubscription.unsubscribe();
}
}

View File

@@ -1,4 +1,4 @@
<ng-container *ngIf="!noFiat && (viewFiat$ | async) && (conversions$ | async) as conversions; else viewFiatVin">
<ng-container *ngIf="!noFiat && (viewAmountMode$ | async) === 'fiat' && (conversions$ | async) as conversions; else viewFiatVin">
<span class="fiat" *ngIf="blockConversion; else noblockconversion">
{{ addPlus && satoshis >= 0 ? '+' : '' }}{{
(
@@ -20,10 +20,28 @@
<ng-template [ngIf]="(network === 'liquid' || network === 'liquidtestnet') && (satoshis === undefined || satoshis === null)" [ngIfElse]="default">
<span i18n="shared.confidential">Confidential</span>
</ng-template>
<ng-template #default>&lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }}
<span class="symbol"><ng-template [ngIf]="network === 'liquid' && !forceBtc">L-</ng-template>
<ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template>
<ng-template [ngIf]="network === 'testnet'">t</ng-template>
<ng-template [ngIf]="network === 'signet'">s</ng-template>BTC</span>
<ng-template #default>
@if ((viewAmountMode$ | async) === 'btc' || (viewAmountMode$ | async) === 'fiat') {
&lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }}
<span class="symbol">
<ng-container *ngTemplateOutlet="prefix"></ng-container>BTC
</span>
} @else {
@if (digitsInfo === '1.8-8') {
&lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number }}
} @else {
&lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | amountShortener : satoshis < 1000 && satoshis > -1000 ? 0 : 1 }}
}
<span class="symbol">
<ng-container *ngTemplateOutlet="prefix"></ng-container>sats
</span>
}
</ng-template>
</ng-template>
<ng-template #prefix>
<ng-template [ngIf]="network === 'liquid' && !forceBtc">L-</ng-template>
<ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template>
<ng-template [ngIf]="network === 'testnet'">t</ng-template>
<ng-template [ngIf]="network === 'signet'">s</ng-template>
</ng-template>

View File

@@ -12,7 +12,7 @@ import { Price } from '../../services/price.service';
export class AmountComponent implements OnInit, OnDestroy {
conversions$: Observable<any>;
currency: string;
viewFiat$: Observable<boolean>;
viewAmountMode$: Observable<'btc' | 'sats' | 'fiat'>;
network = '';
stateSubscription: Subscription;
@@ -37,7 +37,7 @@ export class AmountComponent implements OnInit, OnDestroy {
}
ngOnInit() {
this.viewFiat$ = this.stateService.viewFiat$.asObservable();
this.viewAmountMode$ = this.stateService.viewAmountMode$.asObservable();
this.conversions$ = this.stateService.conversions$.asObservable();
this.stateSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network);
}

View File

@@ -0,0 +1,59 @@
<div class="card">
<div class="card-body more-padding">
<div class="balance-container" *ngIf="!isLoading; else loading">
<div class="item">
<h5 class="card-title" i18n="dashboard.btc-holdings">BTC Holdings</h5>
<div class="card-text">
{{ ((addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum) / 100_000_000) | number: '1.2-2' }} <span class="symbol" i18n="shared.btc|BTC">BTC</span>
</div>
<div class="symbol">
<app-fiat [value]="(addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum)"></app-fiat>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="dashboard.7d-change">Change (7d)</h5>
<div class="card-text">
{{ delta7d > 0 ? '+' : ''}}{{ ((delta7d) / 100_000_000) | number: '1.2-2' }} <span class="symbol" i18n="shared.btc|BTC">BTC</span>
</div>
<div class="symbol">
<app-fiat [value]="delta7d"></app-fiat>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="dashboard.30d-change">Change (30d)</h5>
<div class="card-text">
{{ delta30d > 0 ? '+' : ''}}{{ ((delta30d) / 100_000_000) | number: '1.2-2' }} <span class="symbol" i18n="shared.btc|BTC">BTC</span>
</div>
<div class="symbol">
<app-fiat [value]="delta30d"></app-fiat>
</div>
</div>
</div>
</div>
</div>
<ng-template #loading>
<div class="balance-skeleton">
<div class="item">
<h5 class="card-title" i18n="dashboard.btc-holdings">BTC Holdings</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="dashboard.7d-change">Change (7d)</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="dashboard.30d-change">Change (30d)</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>

View File

@@ -0,0 +1,160 @@
.balance-container {
display: flex;
flex-direction: row;
justify-content: space-around;
height: 76px;
.shared-block {
color: var(--transparent-fg);
font-size: 12px;
}
.item {
padding: 0 5px;
width: 100%;
max-width: 150px;
&:last-child {
display: none;
@media (min-width: 485px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: table-cell;
}
}
}
.card-text {
font-size: 22px;
margin-top: -9px;
position: relative;
}
}
.balance-skeleton {
display: flex;
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
min-width: 120px;
max-width: 150px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
&:last-child{
display: none;
@media (min-width: 485px) {
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
&:last-child {
margin-bottom: 0;
}
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
margin: 14px auto 0;
max-width: 80px;
}
&:last-child {
margin: 10px auto 0;
max-width: 120px;
}
}
}
}
.card {
background-color: var(--bg);
height: 126px;
}
.card-title {
color: var(--title-fg);
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress {
display: inline-flex;
width: 100%;
background-color: var(--secondary);
height: 1.1rem;
max-width: 180px;
}
.skeleton-loader {
max-width: 100%;
}
.more-padding {
padding: 24px 20px;
}
.small-bar {
height: 8px;
top: -4px;
max-width: 120px;
}
.loading-container {
min-height: 76px;
}
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.card-wrapper {
.card {
height: auto !important;
}
.card-body {
display: flex;
flex: inherit;
text-align: center;
flex-direction: column;
justify-content: space-around;
padding: 24px 20px;
}
}
.retarget-sign {
margin-right: -3px;
font-size: 14px;
top: -2px;
position: relative;
}
.previous-retarget-sign {
margin-right: -2px;
font-size: 10px;
}
.symbol {
font-size: 13px;
white-space: nowrap;
}

View File

@@ -0,0 +1,71 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Address, AddressTxSummary } from '../../interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { Observable, catchError, of } from 'rxjs';
@Component({
selector: 'app-balance-widget',
templateUrl: './balance-widget.component.html',
styleUrls: ['./balance-widget.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BalanceWidgetComponent implements OnInit, OnChanges {
@Input() address: string;
@Input() addressInfo: Address;
@Input() addressSummary$: Observable<AddressTxSummary[]> | null;
@Input() isPubkey: boolean = false;
isLoading: boolean = true;
error: any;
delta7d: number = 0;
delta30d: number = 0;
constructor(
public stateService: StateService,
private electrsApiService: ElectrsApiService,
private cd: ChangeDetectorRef,
) { }
ngOnInit(): void {
}
ngOnChanges(changes: SimpleChanges): void {
this.isLoading = true;
if (!this.address || !this.addressInfo) {
return;
}
(this.addressSummary$ || (this.isPubkey
? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac')
: this.electrsApiService.getAddressSummary$(this.address)).pipe(
catchError(e => {
this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`;
return of(null);
}),
)).subscribe(addressSummary => {
if (addressSummary) {
this.error = null;
this.calculateStats(addressSummary);
}
this.isLoading = false;
this.cd.markForCheck();
});
}
calculateStats(summary: AddressTxSummary[]): void {
let weekTotal = 0;
let monthTotal = 0;
const weekAgo = (Date.now() / 1000) - (60 * 60 * 24 * 7);
const monthAgo = (Date.now() / 1000) - (60 * 60 * 24 * 30);
for (let i = 0; i < summary.length && summary[i].time >= monthAgo; i++) {
monthTotal += summary[i].value;
if (summary[i].time >= weekAgo) {
weekTotal += summary[i].value;
}
}
this.delta7d = weekTotal;
this.delta30d = monthTotal;
}
}

View File

@@ -81,6 +81,20 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
tooltipPosition: Position;
readyNextFrame = false;
lastUpdate: number = 0;
pendingUpdate: {
count: number,
add: { [txid: string]: TransactionStripped },
remove: { [txid: string]: string },
change: { [txid: string]: { txid: string, rate: number | undefined, acc: boolean | undefined } },
direction?: string,
} = {
count: 0,
add: {},
remove: {},
change: {},
direction: 'left',
};
searchText: string;
searchSubscription: Subscription;
@@ -176,6 +190,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
destroy(): void {
if (this.scene) {
this.scene.destroy();
this.clearUpdateQueue();
this.start();
}
}
@@ -188,6 +203,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
}
this.filtersAvailable = filtersAvailable;
if (this.scene) {
this.clearUpdateQueue();
this.scene.setup(transactions);
this.readyNextFrame = true;
this.start();
@@ -197,6 +213,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
enter(transactions: TransactionStripped[], direction: string): void {
if (this.scene) {
this.clearUpdateQueue();
this.scene.enter(transactions, direction);
this.start();
this.updateSearchHighlight();
@@ -205,6 +222,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
exit(direction: string): void {
if (this.scene) {
this.clearUpdateQueue();
this.scene.exit(direction);
this.start();
this.updateSearchHighlight();
@@ -213,13 +231,61 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
replace(transactions: TransactionStripped[], direction: string, sort: boolean = true, startTime?: number): void {
if (this.scene) {
this.clearUpdateQueue();
this.scene.replace(transactions || [], direction, sort, startTime);
this.start();
this.updateSearchHighlight();
}
}
// collates non-urgent updates into a set of consistent pending changes
queueUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void {
for (const tx of add) {
this.pendingUpdate.add[tx.txid] = tx;
delete this.pendingUpdate.remove[tx.txid];
delete this.pendingUpdate.change[tx.txid];
}
for (const txid of remove) {
delete this.pendingUpdate.add[txid];
this.pendingUpdate.remove[txid] = txid;
delete this.pendingUpdate.change[txid];
}
for (const tx of change) {
if (this.pendingUpdate.add[tx.txid]) {
this.pendingUpdate.add[tx.txid].rate = tx.rate;
this.pendingUpdate.add[tx.txid].acc = tx.acc;
} else {
this.pendingUpdate.change[tx.txid] = tx;
}
}
this.pendingUpdate.direction = direction;
this.pendingUpdate.count++;
}
applyQueuedUpdates(): void {
if (this.pendingUpdate.count && performance.now() > (this.lastUpdate + this.animationDuration)) {
this.update([], [], [], this.pendingUpdate?.direction);
}
}
clearUpdateQueue(): void {
this.pendingUpdate = {
count: 0,
add: {},
remove: {},
change: {},
};
this.lastUpdate = performance.now();
}
update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
// merge any pending changes into this update
this.queueUpdate(add, remove, change);
this.applyUpdate(Object.values(this.pendingUpdate.add), Object.values(this.pendingUpdate.remove), Object.values(this.pendingUpdate.change), direction, resetLayout);
this.clearUpdateQueue();
}
applyUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
if (this.scene) {
add = add.filter(tx => !this.scene.txs[tx.txid]);
remove = remove.filter(txid => this.scene.txs[txid]);
@@ -230,6 +296,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
}
this.scene.update(add, remove, change, direction, resetLayout);
this.start();
this.lastUpdate = performance.now();
this.updateSearchHighlight();
}
}
@@ -370,6 +437,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
if (!now) {
now = performance.now();
}
this.applyQueuedUpdates();
// skip re-render if there's no change to the scene
if (this.scene && this.gl) {
/* SET UP SHADER UNIFORMS */

View File

@@ -13,7 +13,7 @@ export default class BlockScene {
theme: ThemeService;
orientation: string;
flip: boolean;
animationDuration: number = 900;
animationDuration: number = 1000;
configAnimationOffset: number | null;
animationOffset: number;
highlightingEnabled: boolean;
@@ -179,7 +179,7 @@ export default class BlockScene {
removed.forEach(tx => {
tx.destroy();
});
}, 1000);
}, (startTime - performance.now()) + this.animationDuration + 1000);
if (resetLayout) {
add.forEach(tx => {
@@ -239,7 +239,7 @@ export default class BlockScene {
{ width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null }
): void {
this.animationDuration = animationDuration || 1000;
this.animationDuration = animationDuration || this.animationDuration || 1000;
this.configAnimationOffset = animationOffset;
this.animationOffset = this.configAnimationOffset == null ? (this.width * 1.4) : this.configAnimationOffset;
this.orientation = orientation;

View File

@@ -12,6 +12,7 @@
class="text-center bitcoin-block mined-block blockchain-blocks-offset-{{ offset }}-index-{{ i }}"
[class.offscreen]="!static && count && i >= count"
id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]"
[style]="blockTransformation"
[class.blink-bg]="isSpecial(block.height)">
<a draggable="false" [routerLink]="[getHref(i, block) | relativeUrl]" [state]="{ data: { block: block } }"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
@@ -40,7 +41,7 @@
<app-fee-rate unitClass=""></app-fee-rate>
</div>
</ng-template>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo$ | async; else noMiningInfo"
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="blockDisplayMode === 'fees'; else noMiningInfo"
class="block-size">
<app-amount [satoshis]="block.extras?.totalFees ?? 0" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, Input, OnChanges, SimpleChanges } from '@angular/core';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { Observable, Subscription, delay, filter, tap } from 'rxjs';
import { StateService } from '../../services/state.service';
import { specialBlocks } from '../../app.constants';
import { BlockExtended } from '../../interfaces/node-api.interface';
@@ -45,7 +45,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
markBlockSubscription: Subscription;
txConfirmedSubscription: Subscription;
loadingBlocks$: Observable<boolean>;
showMiningInfo$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
showMiningInfoSubscription: Subscription;
blockDisplayModeSubscription: Subscription;
blockDisplayMode: 'size' | 'fees';
blockTransformation = {};
blockStyles = [];
emptyBlockStyles = [];
interval: any;
@@ -78,22 +81,38 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
) {
}
enabledMiningInfoIfNeeded(url) {
const urlParts = url.split('/');
const onDashboard = ['','testnet','signet','mining','acceleration'].includes(urlParts[urlParts.length - 1]);
if (onDashboard) { // Only update showMiningInfo if we are on the main, mining or acceleration dashboards
this.stateService.showMiningInfo$.next(url.includes('/mining') || url.includes('/acceleration'));
}
}
ngOnInit() {
this.dynamicBlocksAmount = Math.min(8, this.stateService.env.KEEP_BLOCKS_AMOUNT);
if (['', 'testnet', 'signet'].includes(this.stateService.network)) {
this.enabledMiningInfoIfNeeded(this.location.path());
this.location.onUrlChange((url) => this.enabledMiningInfoIfNeeded(url));
this.showMiningInfo$ = this.stateService.showMiningInfo$;
}
this.blockDisplayMode = this.stateService.blockDisplayMode$.value as 'size' | 'fees';
this.blockDisplayModeSubscription = this.stateService.blockDisplayMode$
.pipe(
filter((mode: 'size' | 'fees') => mode !== this.blockDisplayMode),
tap(() => {
this.blockTransformation = this.timeLtr ? {
transform: 'scaleX(-1) rotateX(90deg)',
transition: 'transform 0.375s'
} : {
transform: 'rotateX(90deg)',
transition: 'transform 0.375s'
};
}),
delay(375),
tap((mode) => {
this.blockDisplayMode = mode;
this.blockTransformation = this.timeLtr ? {
transform: 'scaleX(-1)',
transition: 'transform 0.375s'
} : {
transition: 'transform 0.375s'
};
this.cd.markForCheck();
}),
delay(375),
)
.subscribe(() => {
this.blockTransformation = {};
});
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr;
@@ -204,6 +223,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.networkSubscription.unsubscribe();
this.tabHiddenSubscription.unsubscribe();
this.markBlockSubscription.unsubscribe();
this.blockDisplayModeSubscription.unsubscribe();
this.timeLtrSubscription.unsubscribe();
clearInterval(this.interval);
}

View File

@@ -10,6 +10,7 @@
</ng-container>
</div>
<div id="divider" [hidden]="pageIndex > 0">
<button class="block-display-toggle" (click)="toggleBlockDisplayMode()"><fa-icon [icon]="['fas', 'exchange-alt']" [fixedWidth]="true"></fa-icon></button>
<button class="time-toggle" (click)="toggleTimeDirection()"><fa-icon [icon]="['fas', 'exchange-alt']" [fixedWidth]="true"></fa-icon></button>
</div>
</span>

View File

@@ -67,9 +67,24 @@
padding: 0;
}
.block-display-toggle {
color: white;
font-size: 0.8rem;
position: absolute;
bottom: 15.8em;
left: 1px;
transform: translateX(-50%) rotate(90deg);
background: none;
border: none;
outline: none;
margin: 0;
padding: 0;
}
.blockchain-wrapper.ltr-transition .blocks-wrapper,
.blockchain-wrapper.ltr-transition .position-container,
.blockchain-wrapper.ltr-transition .time-toggle {
.blockchain-wrapper.ltr-transition .time-toggle,
.blockchain-wrapper.ltr-transition .block-display-toggle {
transition: transform 1s;
}
@@ -81,6 +96,10 @@
.time-toggle {
transform: translateX(-50%) scaleX(-1);
}
.block-display-toggle {
transform: translateX(-50%) scaleX(-1) rotate(90deg);
}
}
:host-context(.ltr-layout) {

View File

@@ -1,6 +1,7 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core';
import { firstValueFrom, Subscription } from 'rxjs';
import { StateService } from '../../services/state.service';
import { StorageService } from '../../services/storage.service';
@Component({
selector: 'app-blockchain',
@@ -26,15 +27,18 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges {
connectionStateSubscription: Subscription;
loadingTip: boolean = true;
connected: boolean = true;
blockDisplayMode: 'size' | 'fees';
dividerOffset: number | null = null;
mempoolOffset: number | null = null;
positionStyle = {
transform: "translateX(1280px)",
};
blockDisplayToggleStyle = {};
constructor(
public stateService: StateService,
public StorageService: StorageService,
private cd: ChangeDetectorRef,
) {}
@@ -51,6 +55,7 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges {
firstValueFrom(this.stateService.chainTip$).then(() => {
this.loadingTip = false;
});
this.blockDisplayMode = this.StorageService.getValue('block-display-mode-preference') as 'size' | 'fees' || 'fees';
}
ngOnDestroy(): void {
@@ -84,6 +89,13 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges {
}, 0);
}
toggleBlockDisplayMode(): void {
if (this.blockDisplayMode === 'size') this.blockDisplayMode = 'fees';
else this.blockDisplayMode = 'size';
this.StorageService.setValue('block-display-mode-preference', this.blockDisplayMode);
this.stateService.blockDisplayMode$.next(this.blockDisplayMode);
}
onMempoolWidthChange(width): void {
if (this.flipping) {
return;

View File

@@ -0,0 +1,270 @@
<div class="container-xl dashboard-container">
<div class="row row-cols-1 row-cols-md-2" *ngIf="{ value: (mempoolInfoData$ | async) } as mempoolInfoData">
@for (widget of widgets; track widget.component) {
@switch (widget.component) {
@case ('fees') {
<div class="col card-wrapper">
<div class="main-title" i18n="fees-box.transaction-fees">Transaction Fees</div>
<div class="card">
<div class="card-body less-padding">
<app-fees-box class="d-block"></app-fees-box>
</div>
</div>
</div>
}
@case ('difficulty') {
<div class="col">
<app-difficulty></app-difficulty>
</div>
}
@case ('goggles') {
<div class="col">
<div class="card graph-card">
<div class="card-body pl-lg-3 pr-lg-3 pl-2 pr-2">
<a class="title-link mb-0" style="margin-top: -2px" href="" [routerLink]="['/mempool-block/0' | relativeUrl]">
<h5 class="card-title d-inline"><span>Mempool Goggles&trade;</span> : {{ goggleCycle[goggleIndex].name }}</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<div class="quick-filter">
<div class="btn-group btn-group-toggle">
<label class="btn btn-primary btn-xs" [class.active]="filter.index === goggleIndex" *ngFor="let filter of goggleCycle">
<input type="radio" [value]="'3m'" fragment="3m" (click)="setFilter(filter.index)" [attr.data-cy]="'3m'"> {{ filter.name }}
</label>
</div>
</div>
<div class="mempool-block-wrapper" *ngIf="webGlEnabled">
<app-mempool-block-overview
[index]="0"
[resolution]="goggleResolution"
[filterFlags]="goggleFlags"
[filterMode]="goggleMode"
[gradientMode]="gradientMode"
></app-mempool-block-overview>
</div>
</div>
</div>
</div>
}
@case ('incoming') {
<div class="col">
<div class="card graph-card">
<div class="card-body">
<ng-container *ngTemplateOutlet="mempoolTable; context: { $implicit: mempoolInfoData }"></ng-container>
<h5 class="card-title mt-3" i18n="dashboard.incoming-transactions">Incoming Transactions</h5>
<div class="mempool-graph" *ngIf="(mempoolStats$ | async) as mempoolStats">
<app-incoming-transactions-graph
[height]="incomingGraphHeight"
[left]="50"
[right]="20"
[data]="mempoolStats?.weightPerSecond"
[windowPreferenceOverride]="'2h'"
></app-incoming-transactions-graph>
</div>
</div>
</div>
</div>
<ng-template #mempoolTable let-mempoolInfoData>
<div class="mempool-info-data">
<div class="item">
<h5 *ngIf="!mempoolInfoData.value || mempoolInfoData.value.memPoolInfo.mempoolminfee === mempoolInfoData.value.memPoolInfo.minrelaytxfee || (stateService.env.BASE_MODULE === 'liquid' && mempoolInfoData.value.memPoolInfo.mempoolminfee === 0.000001) else purgingText" class="card-title" i18n="dashboard.minimum-fee|Minimum mempool fee">Minimum fee</h5>
<ng-template #purgingText><h5 class="card-title" i18n="dashboard.purging|Purgin below fee">Purging</h5></ng-template>
<p class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loading">
<ng-template [ngIf]="mempoolInfoData.value.memPoolInfo.mempoolminfee !== mempoolInfoData.value.memPoolInfo.minrelaytxfee">&lt; </ng-template><app-fee-rate [fee]="mempoolInfoData.value.memPoolInfo.mempoolminfee * 100000"></app-fee-rate>
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="dashboard.unconfirmed|Unconfirmed count">Unconfirmed</h5>
<p class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loading">
{{ mempoolInfoData.value.memPoolInfo.size | number }} <span i18n="dashboard.txs">TXs</span>
</p>
</div>
<div class="item bar">
<h5 class="card-title" i18n="dashboard.memory-usage|Memory usage">Memory Usage</h5>
<div class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loadingbig">
<div class="progress">
<div class="progress-bar {{ mempoolInfoData.value.mempoolSizeProgress }}" role="progressbar" [ngStyle]="{'width': (mempoolInfoData.value.memPoolInfo.usage / mempoolInfoData.value.memPoolInfo.maxmempool * 100) + '%' }">&nbsp;</div>
<div class="progress-text">&lrm;<span [innerHTML]="mempoolInfoData.value.memPoolInfo.usage | bytes : 2 : 'B' : null : false : 3"></span> / <span [innerHTML]="mempoolInfoData.value.memPoolInfo.maxmempool | bytes"></span></div>
</div>
</div>
</div>
</div>
</ng-template>
}
@case ('replacements') {
<div class="col" style="max-height: 410px">
<div class="card">
<div class="card-body">
<a class="title-link" href="" [routerLink]="['/rbf' | relativeUrl]">
<h5 class="card-title d-inline" i18n="dashboard.recent-rbf-replacements">Recent Replacements</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<table class="table lastest-replacements-table">
<thead>
<th class="table-cell-txid" i18n="dashboard.latest-transactions.txid">TXID</th>
<th class="table-cell-old-fee" i18n="dashboard.previous-transaction-fee">Previous fee</th>
<th class="table-cell-new-fee" i18n="dashboard.new-transaction-fee">New fee</th>
<th class="table-cell-badges" i18n="transaction.status|Transaction Status">Status</th>
</thead>
<tbody *ngIf="replacements$ | async as replacements; else replacementsSkeleton">
<tr *ngFor="let replacement of replacements">
<td class="table-cell-txid">
<a [routerLink]="['/tx' | relativeUrl, replacement.txid]">
<app-truncate [text]="replacement.txid" [lastChars]="5"></app-truncate>
</a>
</td>
<td class="table-cell-old-fee"><app-fee-rate [fee]="replacement.oldFee" [weight]="replacement.oldVsize * 4"></app-fee-rate></td>
<td class="table-cell-new-fee"><app-fee-rate [fee]="replacement.newFee" [weight]="replacement.newVsize * 4"></app-fee-rate></td>
<td class="table-cell-badges">
<span *ngIf="replacement.mined" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
<span *ngIf="replacement.fullRbf" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span>
<span *ngIf="!replacement.fullRbf" class="badge badge-success" i18n="tx-features.tag.rbf|RBF">RBF</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<ng-template #replacementsSkeleton>
<tbody>
<tr *ngFor="let i of [1,2,3,4,5,6]">
<td class="table-cell-txid"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-old-fee"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-new-fee"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-badges"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
</tr>
</tbody>
</ng-template>
}
@case ('blocks') {
<div class="col" style="max-height: 410px">
<div class="card">
<div class="card-body">
<a class="title-link" href="" [routerLink]="['/blocks' | relativeUrl]">
<h5 class="card-title d-inline" i18n="dashboard.recent-blocks">Recent Blocks</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<table class="table lastest-blocks-table">
<thead>
<th class="table-cell-height" i18n="dashboard.latest-blocks.height">Height</th>
<th class="table-cell-mined" i18n="dashboard.latest-blocks.mined">Mined</th>
<th class="table-cell-transaction-count" i18n="dashboard.latest-blocks.transaction-count">TXs</th>
<th class="table-cell-size" i18n="dashboard.latest-blocks.size">Size</th>
</thead>
<tbody *ngIf="blocks$ | async as blocks; else blocksSkeleton">
<tr *ngFor="let block of blocks; let i = index; trackBy: trackByBlock">
<td class="table-cell-height" ><a [routerLink]="['/block' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height }}</a></td>
<td class="table-cell-mined" ><app-time kind="since" [time]="block.timestamp" [fastRender]="true"></app-time></td>
<td class="table-cell-transaction-count">{{ block.tx_count | number }}</td>
<td class="table-cell-size">
<div class="progress">
<div class="progress-bar progress-mempool {{ network$ | async }}" role="progressbar" [ngStyle]="{'width': (block.weight / stateService.env.BLOCK_WEIGHT_UNITS)*100 + '%' }">&nbsp;</div>
<div class="progress-text" [innerHTML]="block.size | bytes: 2"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<ng-template #blocksSkeleton>
<tbody>
<tr *ngFor="let i of [1,2,3,4,5,6]">
<td class="table-cell-height"><div class="skeleton-loader skeleton-loader-transactions"></div> </td>
<td class="table-cell-mined"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-transaction-count"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-size"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
</tr>
</tbody>
</ng-template>
}
@case ('transactions') {
<div class="col" style="max-height: 410px">
<div class="card">
<div class="card-body">
<h5 class="card-title" i18n="dashboard.recent-transactions">Recent Transactions</h5>
<table class="table latest-transactions">
<thead>
<th class="table-cell-txid" i18n="dashboard.latest-transactions.txid">TXID</th>
<th class="table-cell-satoshis" i18n="dashboard.latest-transactions.amount">Amount</th>
<th class="table-cell-fiat" *ngIf="(network$ | async) === ''">{{ currency }}</th>
<th class="table-cell-fees" i18n="transaction.fee|Transaction fee">Fee</th>
</thead>
<tbody *ngIf="transactions$ | async as transactions else recentTransactionsSkeleton">
<tr *ngFor="let transaction of transactions; let i = index;">
<td class="table-cell-txid">
<a [routerLink]="['/tx' | relativeUrl, transaction.txid]">
<app-truncate [text]="transaction.txid" [lastChars]="5"></app-truncate>
</a>
</td>
<td class="table-cell-satoshis"><app-amount *ngIf="(network$ | async) !== 'liquidtestnet'; else liquidAmount" [satoshis]="transaction.value" digitsInfo="1.2-4" [noFiat]="true"></app-amount><ng-template #liquidAmount i18n="shared.confidential">Confidential</ng-template></td>
<td class="table-cell-fiat" *ngIf="(network$ | async) === ''" ><app-fiat [value]="transaction.value" digitsInfo="1.0-0"></app-fiat></td>
<td class="table-cell-fees"><app-fee-rate [fee]="transaction.fee" [weight]="transaction.vsize * 4"></app-fee-rate></td>
</tr>
</tbody>
</table>
<div class="">&nbsp;</div>
</div>
</div>
</div>
<ng-template #recentTransactionsSkeleton>
<tbody>
<tr *ngFor="let i of [1,2,3,4,5,6]">
<td class="table-cell-txid"><div class="skeleton-loader skeleton-loader-transactions"></div> </td>
<td class="table-cell-satoshis"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-fiat" *ngIf="(network$ | async) === ''"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
<td class="table-cell-fees"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
</tr>
</tbody>
</ng-template>
}
@case ('balance') {
<div class="col card-wrapper">
<div class="main-title" i18n="dashboard.treasury">Treasury</div>
<app-balance-widget [address]="widget.props.address" [addressSummary$]="addressSummary$" [addressInfo]="address"></app-balance-widget>
</div>
}
@case ('address') {
<div class="col" style="max-height: 410px">
<div class="card graph-card">
<div class="card-body">
<a class="title-link" href="" [routerLink]="[('/address/' + widget.props.address) | relativeUrl]">
<h5 class="card-title d-inline" i18n="dashboard.balance-history">Balance History</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address?.chain_stats" [widget]="true" [height]="graphHeight"></app-address-graph>
</div>
</div>
</div>
}
@case ('addressTransactions') {
<div class="col" style="max-height: 410px">
<div class="card">
<div class="card-body">
<a class="title-link" href="" [routerLink]="[('/address/' + widget.props.address) | relativeUrl]">
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-transactions-widget [address]="widget.props.address" [addressSummary$]="addressSummary$" [addressInfo]="address"></app-address-transactions-widget>
</div>
</div>
</div>
}
}
}
</div>
</div>
<ng-template #loading>
<div class="skeleton-loader"></div>
</ng-template>
<ng-template #loadingbig>
<span class="skeleton-loader skeleton-loader-big" ></span>
</ng-template>

View File

@@ -0,0 +1,490 @@
.dashboard-container {
text-align: center;
margin-top: 0.5rem;
.col {
margin-bottom: 1.5rem;
}
}
.card {
background-color: var(--bg);
height: 100%;
}
.card-title {
color: var(--title-fg);
font-size: 1rem;
}
.info-block {
float: left;
width: 350px;
line-height: 25px;
}
.progress {
display: inline-flex;
width: 100%;
background-color: var(--secondary);
height: 1.1rem;
max-width: 180px;
}
.bg-warning {
background-color: #b58800 !important;
}
.skeleton-loader {
max-width: 100%;
}
.more-padding {
padding: 18px;
}
.graph-card {
height: 100%;
@media (min-width: 768px) {
height: 415px;
}
@media (min-width: 992px) {
height: 510px;
}
}
.mempool-info-data {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
&.lbtc-pegs-stats {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
margin: 0px auto 20px;
display: inline-block;
@media (min-width: 485px) {
margin: 0px auto 10px;
}
@media (min-width: 768px) {
margin: 0px auto 0px;
}
&:last-child {
margin: 0px auto 0px;
}
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-text {
font-size: 18px;
span {
color: var(--transparent-fg);
font-size: 12px;
}
.bitcoin-color {
color: var(--orange);
}
}
.progress {
width: 90%;
@media (min-width: 768px) {
width: 100%;
}
}
}
.bar {
width: 93%;
margin: 0px 5px 20px;
@media (min-width: 485px) {
max-width: 200px;
margin: 0px auto 0px;
}
}
.skeleton-loader {
width: 100%;
max-width: 100px;
display: block;
margin: 18px auto 0;
}
.skeleton-loader-big {
max-width: 180px;
}
}
.latest-transactions {
width: 100%;
text-align: left;
table-layout:fixed;
tr, td, th {
border: 0px;
padding-top: 0.71rem !important;
padding-bottom: 0.75rem !important;
}
td {
overflow:hidden;
width: 25%;
}
.table-cell-satoshis {
display: none;
text-align: right;
@media (min-width: 576px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 1100px) {
display: table-cell;
}
}
.table-cell-fiat {
display: none;
text-align: right;
@media (min-width: 485px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: table-cell;
}
}
.table-cell-fees {
text-align: right;
}
}
.skeleton-loader-transactions {
max-width: 250px;
position: relative;
top: 2px;
margin-bottom: -3px;
height: 18px;
}
.lastest-blocks-table {
width: 100%;
text-align: left;
tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.7rem !important;
}
.table-cell-height {
width: 15%;
}
.table-cell-mined {
width: 35%;
text-align: left;
}
.table-cell-transaction-count {
display: none;
text-align: right;
width: 20%;
display: table-cell;
}
.table-cell-size {
display: none;
text-align: center;
width: 30%;
@media (min-width: 485px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: table-cell;
}
}
}
.lastest-replacements-table {
width: 100%;
text-align: left;
table-layout:fixed;
tr, td, th {
border: 0px;
padding-top: 0.71rem !important;
padding-bottom: 0.75rem !important;
}
td {
overflow:hidden;
width: 25%;
}
.table-cell-txid {
width: 25%;
text-align: start;
}
.table-cell-old-fee {
width: 25%;
text-align: end;
@media(max-width: 1080px) {
display: none;
}
}
.table-cell-new-fee {
width: 20%;
text-align: end;
}
.table-cell-badges {
width: 23%;
padding-right: 0;
padding-left: 5px;
text-align: end;
.badge {
margin-left: 5px;
}
}
}
.mempool-graph {
height: 255px;
@media (min-width: 768px) {
height: 285px;
}
@media (min-width: 992px) {
height: 370px;
}
}
.loadingGraphs{
height: 250px;
display: grid;
place-items: center;
}
.inc-tx-progress-bar {
max-width: 250px;
.progress-bar {
padding: 4px;
}
}
.terms-of-service {
margin-top: 1rem;
}
.small-bar {
height: 8px;
top: -4px;
max-width: 120px;
}
.loading-container {
min-height: 76px;
}
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.card-wrapper {
.card {
height: auto !important;
}
.card-body {
display: flex;
flex: inherit;
text-align: center;
flex-direction: column;
justify-content: space-around;
padding: 22px 20px;
&.liquid {
height: 124.5px;
}
}
.less-padding {
padding: 20px 20px;
}
}
.retarget-sign {
margin-right: -3px;
font-size: 14px;
top: -2px;
position: relative;
}
.previous-retarget-sign {
margin-right: -2px;
font-size: 10px;
}
.assetIcon {
width: 40px;
height: 40px;
}
.asset-title {
text-align: left;
vertical-align: middle;
}
.asset-icon {
width: 65px;
height: 65px;
vertical-align: middle;
}
.circulating-amount {
text-align: right;
width: 100%;
vertical-align: middle;
}
.clear-link {
color: white;
}
.pool-name {
display: inline-block;
vertical-align: text-top;
padding-left: 10px;
}
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
display: block;
margin-bottom: 10px;
text-decoration: none;
color: inherit;
}
.mempool-block-wrapper {
max-height: 410px;
max-width: 410px;
margin: auto;
@media (min-width: 768px) {
max-height: 344px;
max-width: 344px;
}
@media (min-width: 992px) {
max-height: 410px;
max-width: 410px;
}
}
.goggle-badge {
margin: 6px 5px 8px;
background: none;
border: solid 2px var(--primary);
cursor: pointer;
&.active {
background: var(--primary);
}
}
.btn-xs {
padding: 0.35rem 0.5rem;
font-size: 12px;
}
.quick-filter {
margin-top: 5px;
margin-bottom: 6px;
}
.card-liquid {
background-color: var(--bg);
height: 418px;
@media (min-width: 992px) {
height: 512px;
}
&.smaller {
height: 408px;
}
}
.card-title-liquid {
padding-top: 20px;
margin-left: 10px;
}
.in-progress-message {
position: relative;
color: #ffffff91;
margin-top: 20px;
text-align: center;
padding-bottom: 3px;
font-weight: 500;
}
.stats-card {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
display: inline-block;
margin: 0px auto 20px;
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-title {
font-size: 1rem;
color: var(--title-fg);
}
.card-text {
font-size: 18px;
span {
color: var(--transparent-fg);
font-size: 12px;
}
}
}
}

View File

@@ -0,0 +1,372 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { combineLatest, merge, Observable, of, Subject, Subscription } from 'rxjs';
import { catchError, filter, map, scan, share, shareReplay, startWith, switchMap, tap } from 'rxjs/operators';
import { BlockExtended, OptimizedMempoolStats, TransactionStripped } from '../../interfaces/node-api.interface';
import { MempoolInfo, ReplacementInfo } from '../../interfaces/websocket.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
import { WebsocketService } from '../../services/websocket.service';
import { SeoService } from '../../services/seo.service';
import { ActiveFilter, FilterMode, GradientMode, toFlags } from '../../shared/filters.utils';
import { detectWebGL } from '../../shared/graphs.utils';
import { Address, AddressTxSummary } from '../../interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service';
interface MempoolBlocksData {
blocks: number;
size: number;
}
interface MempoolInfoData {
memPoolInfo: MempoolInfo;
vBytesPerSecond: number;
progressWidth: string;
progressColor: string;
}
interface MempoolStatsData {
mempool: OptimizedMempoolStats[];
weightPerSecond: any;
}
@Component({
selector: 'app-custom-dashboard',
templateUrl: './custom-dashboard.component.html',
styleUrls: ['./custom-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewInit {
network$: Observable<string>;
mempoolBlocksData$: Observable<MempoolBlocksData>;
mempoolInfoData$: Observable<MempoolInfoData>;
mempoolLoadingStatus$: Observable<number>;
vBytesPerSecondLimit = 1667;
transactions$: Observable<TransactionStripped[]>;
blocks$: Observable<BlockExtended[]>;
replacements$: Observable<ReplacementInfo[]>;
latestBlockHeight: number;
mempoolTransactionsWeightPerSecondData: any;
mempoolStats$: Observable<MempoolStatsData>;
transactionsWeightPerSecondOptions: any;
isLoadingWebSocket$: Observable<boolean>;
isLoad: boolean = true;
filterSubscription: Subscription;
mempoolInfoSubscription: Subscription;
currencySubscription: Subscription;
currency: string;
incomingGraphHeight: number = 300;
graphHeight: number = 300;
webGlEnabled = true;
widgets;
addressSubscription: Subscription;
blockTxSubscription: Subscription;
addressSummary$: Observable<AddressTxSummary[]>;
address: Address;
goggleResolution = 82;
goggleCycle: { index: number, name: string, mode: FilterMode, filters: string[], gradient: GradientMode }[] = [
{ index: 0, name: $localize`:@@dfc3c34e182ea73c5d784ff7c8135f087992dac1:All`, mode: 'and', filters: [], gradient: 'age' },
{ index: 1, name: $localize`Consolidation`, mode: 'and', filters: ['consolidation'], gradient: 'fee' },
{ index: 2, name: $localize`Coinjoin`, mode: 'and', filters: ['coinjoin'], gradient: 'fee' },
{ index: 3, name: $localize`Data`, mode: 'or', filters: ['inscription', 'fake_pubkey', 'op_return'], gradient: 'fee' },
];
goggleFlags = 0n;
goggleMode: FilterMode = 'and';
gradientMode: GradientMode = 'age';
goggleIndex = 0;
private destroy$ = new Subject();
constructor(
public stateService: StateService,
private apiService: ApiService,
private electrsApiService: ElectrsApiService,
private websocketService: WebsocketService,
private seoService: SeoService,
@Inject(PLATFORM_ID) private platformId: Object,
) {
this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
this.widgets = this.stateService.env.customize?.dashboard.widgets || [];
}
ngAfterViewInit(): void {
this.stateService.focusSearchInputDesktop();
}
ngOnDestroy(): void {
this.filterSubscription.unsubscribe();
this.mempoolInfoSubscription.unsubscribe();
this.currencySubscription.unsubscribe();
this.websocketService.stopTrackRbfSummary();
if (this.addressSubscription) {
this.addressSubscription.unsubscribe();
this.websocketService.stopTrackingAddress();
this.address = null;
}
this.destroy$.next(1);
this.destroy$.complete();
}
ngOnInit(): void {
this.onResize();
this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$;
this.seoService.resetTitle();
this.seoService.resetDescription();
this.websocketService.want(['blocks', 'stats', 'mempool-blocks', 'live-2h-chart']);
this.websocketService.startTrackRbfSummary();
this.network$ = merge(of(''), this.stateService.networkChanged$);
this.mempoolLoadingStatus$ = this.stateService.loadingIndicators$
.pipe(
map((indicators) => indicators.mempool !== undefined ? indicators.mempool : 100)
);
this.filterSubscription = this.stateService.activeGoggles$.subscribe((active: ActiveFilter) => {
const activeFilters = active.filters.sort().join(',');
for (const goggle of this.goggleCycle) {
if (goggle.mode === active.mode) {
const goggleFilters = goggle.filters.sort().join(',');
if (goggleFilters === activeFilters) {
this.goggleIndex = goggle.index;
this.goggleFlags = toFlags(goggle.filters);
this.goggleMode = goggle.mode;
this.gradientMode = active.gradient;
return;
}
}
}
this.goggleCycle.push({
index: this.goggleCycle.length,
name: 'Custom',
mode: active.mode,
filters: active.filters,
gradient: active.gradient,
});
this.goggleIndex = this.goggleCycle.length - 1;
this.goggleFlags = toFlags(active.filters);
this.goggleMode = active.mode;
});
this.mempoolInfoData$ = combineLatest([
this.stateService.mempoolInfo$,
this.stateService.vbytesPerSecond$
]).pipe(
map(([mempoolInfo, vbytesPerSecond]) => {
const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100);
let progressColor = 'bg-success';
if (vbytesPerSecond > 1667) {
progressColor = 'bg-warning';
}
if (vbytesPerSecond > 3000) {
progressColor = 'bg-danger';
}
const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100);
let mempoolSizeProgress = 'bg-danger';
if (mempoolSizePercentage <= 50) {
mempoolSizeProgress = 'bg-success';
} else if (mempoolSizePercentage <= 75) {
mempoolSizeProgress = 'bg-warning';
}
return {
memPoolInfo: mempoolInfo,
vBytesPerSecond: vbytesPerSecond,
progressWidth: percent + '%',
progressColor: progressColor,
mempoolSizeProgress: mempoolSizeProgress,
};
})
);
this.mempoolInfoSubscription = this.mempoolInfoData$.subscribe();
this.mempoolBlocksData$ = this.stateService.mempoolBlocks$
.pipe(
map((mempoolBlocks) => {
const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b, 0);
const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b, 0);
return {
size: size,
blocks: Math.ceil(vsize / this.stateService.blockVSize)
};
})
);
this.transactions$ = this.stateService.transactions$;
this.blocks$ = this.stateService.blocks$
.pipe(
tap((blocks) => {
this.latestBlockHeight = blocks[0].height;
}),
switchMap((blocks) => {
if (this.stateService.env.MINING_DASHBOARD === true) {
for (const block of blocks) {
// @ts-ignore: Need to add an extra field for the template
block.extras.pool.logo = `/resources/mining-pools/` +
block.extras.pool.slug + '.svg';
}
}
return of(blocks.slice(0, 6));
})
);
this.replacements$ = this.stateService.rbfLatestSummary$;
this.mempoolStats$ = this.stateService.connectionState$
.pipe(
filter((state) => state === 2),
switchMap(() => this.apiService.list2HStatistics$().pipe(
catchError((e) => {
return of(null);
})
)),
switchMap((mempoolStats) => {
return merge(
this.stateService.live2Chart$
.pipe(
scan((acc, stats) => {
acc.unshift(stats);
acc = acc.slice(0, 120);
return acc;
}, (mempoolStats || []))
),
of(mempoolStats)
);
}),
map((mempoolStats) => {
if (mempoolStats) {
return {
mempool: mempoolStats,
weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])),
};
} else {
return null;
}
}),
shareReplay(1),
);
this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => {
this.currency = fiat;
});
this.startAddressSubscription();
}
handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) {
mempoolStats.reverse();
const labels = mempoolStats.map(stats => stats.added);
return {
labels: labels,
series: [mempoolStats.map((stats) => [stats.added * 1000, stats.vbytes_per_second])],
};
}
trackByBlock(index: number, block: BlockExtended) {
return block.height;
}
getArrayFromNumber(num: number): number[] {
return Array.from({ length: num }, (_, i) => i + 1);
}
setFilter(index): void {
const selected = this.goggleCycle[index];
this.stateService.activeGoggles$.next(selected);
}
startAddressSubscription(): void {
if (this.stateService.env.customize && this.stateService.env.customize.dashboard.widgets.some(w => w.props?.address)) {
const address = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.address).props.address;
const addressString = (/^[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)) ? address.toLowerCase() : address;
this.addressSubscription = (
addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/)
? this.electrsApiService.getPubKeyAddress$(addressString)
: this.electrsApiService.getAddress$(addressString)
).pipe(
catchError((err) => {
console.log(err);
return of(null);
}),
filter((address) => !!address),
).subscribe((address: Address) => {
this.websocketService.startTrackAddress(address.address);
this.address = address;
});
this.addressSummary$ = (
addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/)
? this.electrsApiService.getScriptHashSummary$((addressString.length === 66 ? '21' : '41') + addressString + 'ac')
: this.electrsApiService.getAddressSummary$(addressString)).pipe(
catchError(e => {
return of(null);
}),
switchMap(initial => this.stateService.blockTransactions$.pipe(
startWith(null),
scan((summary, tx) => {
if (tx && !summary.some(t => t.txid === tx.txid)) {
let value = 0;
let funded = 0;
let fundedCount = 0;
let spent = 0;
let spentCount = 0;
for (const vout of tx.vout) {
if (vout.scriptpubkey_address === addressString) {
value += vout.value;
funded += vout.value;
fundedCount++;
}
}
for (const vin of tx.vin) {
if (vin.prevout?.scriptpubkey_address === addressString) {
value -= vin.prevout?.value;
spent += vin.prevout?.value;
spentCount++;
}
}
if (this.address && this.address.address === addressString) {
this.address.chain_stats.tx_count++;
this.address.chain_stats.funded_txo_sum += funded;
this.address.chain_stats.funded_txo_count += fundedCount;
this.address.chain_stats.spent_txo_sum += spent;
this.address.chain_stats.spent_txo_count += spentCount;
}
summary.unshift({
txid: tx.txid,
time: tx.status?.block_time,
height: tx.status?.block_height,
value
});
}
return summary;
}, initial)
)),
share(),
);
}
}
@HostListener('window:resize', ['$event'])
onResize(): void {
if (window.innerWidth >= 992) {
this.incomingGraphHeight = 300;
this.goggleResolution = 82;
this.graphHeight = 400;
} else if (window.innerWidth >= 768) {
this.incomingGraphHeight = 215;
this.goggleResolution = 80;
this.graphHeight = 310;
} else {
this.incomingGraphHeight = 180;
this.goggleResolution = 86;
this.graphHeight = 310;
}
}
}

View File

@@ -1,5 +1,5 @@
<div [formGroup]="languageForm" class="text-small text-center">
<select formControlName="language" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 95px;" (change)="changeLanguage()">
<select formControlName="language" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 120px;" (change)="changeLanguage()">
<option *ngFor="let lang of languages" [value]="lang.code">{{ lang.name }}</option>
</select>
</div>

View File

@@ -122,8 +122,8 @@ export class FederationUtxosListComponent implements OnInit {
getGradientColor(value: number): string {
const distanceToGreen = Math.abs(4032 - value);
const green = 'var(--green)';
const red = 'var(--red)';
const green = '#3bcc49';
const red = '#dc3545';
if (value < 0) {
return red;

View File

@@ -3,7 +3,7 @@
<h5 class="card-title" i18n="liquid.unpeg">Unpeg</h5>
<div *ngIf="(unbackedMonths$ | async) as unbackedMonths; else loadingData" class="card-text">
<ng-container *ngIf="unbackedMonths.historyComplete; else loadingData">
<div class="fee-text" [ngClass]="{'danger' : unbackedMonths.total > 0, 'correct': unbackedMonths.total === 0}">
<div class="fee-text" [ngClass]="{'danger' : unbackedMonths.total > 0, 'correct': unbackedMonths.total === 0}" i18n-ngbTooltip="liquid.unpeg-info" ngbTooltip="Number of times that the Federation's BTC holdings fall below 95% of the total L-BTC supply">
{{ unbackedMonths.total }} <span i18n="liquid.unpeg-event">Unpeg Event</span>
</div>
</ng-container>

View File

@@ -34,7 +34,7 @@ export class ReservesRatioStatsComponent implements OnInit {
let avg = 0;
for (let i = 0; i < ratioSeries.length; i++) {
avg += ratioSeries[i];
if (ratioSeries[i] < 1) {
if (ratioSeries[i] < 0.95) {
total++;
}
}

View File

@@ -85,6 +85,7 @@ export class ReservesRatioComponent implements OnInit, OnChanges {
{
type: 'gauge',
startAngle: 180,
silent: true,
endAngle: 0,
center: ['50%', '75%'],
radius: '100%',

View File

@@ -2,8 +2,15 @@
<div class="preview-wrapper">
<header>
<span class="header-brand" style="position: relative;">
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" width="500" height="126" class="mempool-logo" style="width: 200px; height: 50px"></app-svg-images>
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 200px; height: 50px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
@if (enterpriseInfo?.img) {
<img [src]="enterpriseInfo.img" class="subdomain_logo" [class]="{'rounded': enterpriseInfo.rounded_corner}">
}
@if (enterpriseInfo?.header_img) {
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="36px">
} @else {
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" width="500" height="126" class="mempool-logo" style="width: 200px; height: 50px"></app-svg-images>
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 200px; height: 50px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
}
</span>
<div [ngSwitch]="network.val">

View File

@@ -18,7 +18,7 @@
flex-direction: row;
justify-content: space-between;
align-items: center;
background: var(--active-bg);
background: var(--stat-box-bg);
text-align: start;
font-size: 1.8em;
}
@@ -77,3 +77,15 @@
flex-shrink: 1;
}
}
.subdomain_logo {
height: 35px;
overflow: clip;
max-width: 140px;
margin: auto;
align-self: center;
margin-right: 1em;
.rounded {
border-radius: 5px;
}
}

View File

@@ -1,7 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Observable, merge, of } from 'rxjs';
import { Observable, Subscription, merge, of } from 'rxjs';
import { LanguageService } from '../../services/language.service';
import { EnterpriseService } from '../../services/enterprise.service';
@Component({
selector: 'app-master-page-preview',
@@ -13,15 +14,23 @@ export class MasterPagePreviewComponent implements OnInit {
lightning$: Observable<boolean>;
officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
urlLanguage: string;
subdomain = '';
enterpriseInfo: any;
enterpriseInfo$: Subscription;
constructor(
public stateService: StateService,
private languageService: LanguageService,
private enterpriseService: EnterpriseService,
) { }
ngOnInit() {
this.network$ = merge(of(''), this.stateService.networkChanged$);
this.lightning$ = this.stateService.lightningChanged$;
this.urlLanguage = this.languageService.getLanguageForUrl();
this.subdomain = this.enterpriseService.getSubdomain();
this.enterpriseInfo$ = this.enterpriseService.info$.subscribe(info => {
this.enterpriseInfo = info;
});
}
}

View File

@@ -19,13 +19,17 @@
<a class="navbar-brand d-none d-md-flex" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
<ng-template [ngIf]="subdomain && enterpriseInfo">
<div class="subdomain_container">
<img [src]="'/api/v1/services/enterprise/images/' + subdomain + '/logo?imageMd5=' + enterpriseInfo.imageMd5" class="subdomain_logo" [class]="{'rounded': enterpriseInfo.rounded_corner}">
<img [src]="enterpriseInfo.img || '/api/v1/services/enterprise/images/' + subdomain + '/logo?imageMd5=' + enterpriseInfo.imageMd5" class="subdomain_logo" [class]="{'rounded': enterpriseInfo.rounded_corner}">
</div>
<div class="vertical-line"></div>
</ng-template>
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" class="mempool-logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }"></app-svg-images>
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
@if (enterpriseInfo?.header_img) {
<img [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="36px">
} @else {
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" class="mempool-logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }"></app-svg-images>
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
}
<div class="connection-badge">
<div class="badge badge-warning" *ngIf="connectionState.val === 0" i18n="master-page.offline">Offline</div>
<div class="badge badge-warning" *ngIf="connectionState.val === 1" i18n="master-page.reconnecting">Reconnecting...</div>
@@ -36,14 +40,18 @@
<a class="navbar-brand d-flex d-md-none justify-content-center" [ngClass]="{'dual-logos': subdomain, 'mr-0': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
<ng-template [ngIf]="subdomain && enterpriseInfo">
<div class="subdomain_container">
<img [src]="'/api/v1/services/enterprise/images/' + subdomain + '/logo?imageMd5=' + enterpriseInfo.imageMd5" class="subdomain_logo" [class]="{'rounded': enterpriseInfo.rounded_corner}">
<img [src]="enterpriseInfo.img || '/api/v1/services/enterprise/images/' + subdomain + '/logo?imageMd5=' + enterpriseInfo.imageMd5" class="subdomain_logo" [class]="{'rounded': enterpriseInfo.rounded_corner}">
</div>
<div class="vertical-line"></div>
</ng-template>
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" class="mempool-logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }"></app-svg-images>
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
<div class="connection-badge">
@if (enterpriseInfo?.header_img) {
<img [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="36px">
} @else {
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" class="mempool-logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }"></app-svg-images>
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
}
<div class="connection-badge">
<div class="badge badge-warning" *ngIf="connectionState.val === 0" i18n="master-page.offline">Offline</div>
<div class="badge badge-warning" *ngIf="connectionState.val === 1" i18n="master-page.reconnecting">Reconnecting...</div>
</div>

View File

@@ -1,11 +1,10 @@
import { Component, ComponentRef, ViewChild, HostListener, Input, Output, EventEmitter,
import { Component, ViewChild, Input, Output, EventEmitter,
OnInit, OnDestroy, OnChanges, ChangeDetectionStrategy, ChangeDetectorRef, AfterViewInit } from '@angular/core';
import { StateService } from '../../services/state.service';
import { MempoolBlockDelta } from '../../interfaces/websocket.interface';
import { MempoolBlockDelta, isMempoolDelta } from '../../interfaces/websocket.interface';
import { TransactionStripped } from '../../interfaces/node-api.interface';
import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component';
import { Subscription, BehaviorSubject, merge, of, timer } from 'rxjs';
import { switchMap, filter, concatMap, map } from 'rxjs/operators';
import { Subscription, BehaviorSubject } from 'rxjs';
import { WebsocketService } from '../../services/websocket.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { Router } from '@angular/router';
@@ -39,10 +38,6 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
poolDirection: string = 'left';
blockSub: Subscription;
rateLimit = 1000;
private lastEventTime = Date.now() - this.rateLimit;
private subId = 0;
firstLoad: boolean = true;
constructor(
@@ -62,39 +57,13 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
}
ngAfterViewInit(): void {
this.blockSub = merge(
this.stateService.mempoolBlockTransactions$,
this.stateService.mempoolBlockDelta$,
).pipe(
concatMap(update => {
const now = Date.now();
const timeSinceLastEvent = now - this.lastEventTime;
this.lastEventTime = Math.max(now, this.lastEventTime + this.rateLimit);
const subId = this.subId;
// If time since last event is less than X seconds, delay this event
if (timeSinceLastEvent < this.rateLimit) {
return timer(this.rateLimit - timeSinceLastEvent).pipe(
// Emit the event after the timer
map(() => ({ update, subId }))
);
} else {
// If enough time has passed, emit the event immediately
return of({ update, subId });
}
})
).subscribe(({ update, subId }) => {
// discard stale updates after a block transition
if (subId !== this.subId) {
return;
}
this.blockSub = this.stateService.mempoolBlockUpdate$.subscribe((update) => {
// process update
if (update['added']) {
if (isMempoolDelta(update)) {
// delta
this.updateBlock(update as MempoolBlockDelta);
this.updateBlock(update);
} else {
const transactionsStripped = update as TransactionStripped[];
const transactionsStripped = update.transactions;
// new transactions
if (this.firstLoad) {
this.replaceBlock(transactionsStripped);
@@ -137,7 +106,6 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
ngOnChanges(changes): void {
if (changes.index) {
this.subId++;
this.firstLoad = true;
if (this.blockGraph) {
this.blockGraph.clear(changes.index.currentValue > changes.index.previousValue ? this.chainDirection : this.poolDirection);
@@ -173,7 +141,11 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? this.poolDirection : this.chainDirection;
this.blockGraph.replace(delta.added, direction);
} else {
this.blockGraph.update(delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined);
if (blockMined) {
this.blockGraph.update(delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined);
} else {
this.blockGraph.queueUpdate(delta.added, delta.removed, delta.changed || [], this.poolDirection);
}
}
this.lastBlockHeight = this.stateService.latestBlockHeight;

View File

@@ -7,7 +7,7 @@
class="spotlight-bottom"
[style.right]="mempoolBlockStyles[i].right"
></div>
<div @blockEntryTrigger [@.disabled]="i > 0 || !animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" [class.hide-block]="count && i >= count" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink">
<div @blockEntryTrigger [@.disabled]="i > 0 || !animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" [class.hide-block]="count && i >= count" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink" [style]="blockTransformation">
<a draggable="false" [routerLink]="[getHref(i) | relativeUrl]"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div class="block-body">
@@ -20,7 +20,7 @@
-
<app-fee-rate [fee]="projectedBlock.feeRange[projectedBlock.feeRange.length - 1]" rounding="1.0-0" unitClass=""></app-fee-rate>
</div>
<div *ngIf="showMiningInfo$ | async; else noMiningInfo" class="block-size">
<div *ngIf="blockDisplayMode === 'fees'; else noMiningInfo" class="block-size">
<app-amount [attr.data-cy]="'mempool-block-' + i + '-total-fees'" [satoshis]="projectedBlock.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
</div>
<ng-template #noMiningInfo>

View File

@@ -1,9 +1,9 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges, Output, EventEmitter } from '@angular/core';
import { Subscription, Observable, of, combineLatest, BehaviorSubject } 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 { map, switchMap, tap } from 'rxjs/operators';
import { delay, filter, map, switchMap, tap } from 'rxjs/operators';
import { feeLevels } from '../../app.constants';
import { specialBlocks } from '../../app.constants';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@@ -43,7 +43,10 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
mempoolBlocks$: Observable<MempoolBlock[]>;
difficultyAdjustments$: Observable<DifficultyAdjustment>;
loadingBlocks$: Observable<boolean>;
showMiningInfo$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
showMiningInfoSubscription: Subscription;
blockDisplayModeSubscription: Subscription;
blockDisplayMode: 'size' | 'fees';
blockTransformation = {};
blocksSubscription: Subscription;
mempoolBlocksFull: MempoolBlock[] = [];
@@ -99,9 +102,29 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.mempoolWidth = width;
this.widthChange.emit(this.mempoolWidth);
if (['', 'testnet', 'signet'].includes(this.stateService.network)) {
this.showMiningInfo$ = this.stateService.showMiningInfo$;
}
this.blockDisplayMode = this.stateService.blockDisplayMode$.value as 'size' | 'fees';
this.blockDisplayModeSubscription = this.stateService.blockDisplayMode$
.pipe(
filter((mode: 'size' | 'fees') => mode !== this.blockDisplayMode),
tap(() => {
this.blockTransformation = {
transform: 'rotateX(90deg)',
transition: 'transform 0.375s'
};
}),
delay(375),
tap((mode) => {
this.blockDisplayMode = mode;
this.blockTransformation = {
transition: 'transform 0.375s'
};
this.cd.markForCheck();
}),
delay(375),
)
.subscribe(() => {
this.blockTransformation = {};
});
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !this.forceRtl && !!ltr;
@@ -262,6 +285,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.markBlocksSubscription.unsubscribe();
this.blockSubscription.unsubscribe();
this.networkSubscription.unsubscribe();
this.blockDisplayModeSubscription.unsubscribe();
this.timeLtrSubscription.unsubscribe();
this.chainTipSubscription.unsubscribe();
this.keySubscription.unsubscribe();

View File

@@ -51,7 +51,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges {
ngOnChanges(changes): void {
this.rows = this.buildTimelines(this.replacements);
if (changes.txid) {
if (changes.txid && !changes.txid.firstChange && changes.txid.previousValue !== changes.txid.currentValue) {
setTimeout(() => { this.scrollToSelected(); });
}
}

View File

@@ -81,6 +81,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
mempoolBlocksSubscription: Subscription;
blocksSubscription: Subscription;
miningSubscription: Subscription;
auditSubscription: Subscription;
currencyChangeSubscription: Subscription;
fragmentParams: URLSearchParams;
rbfTransaction: undefined | Transaction;
@@ -308,51 +309,57 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
filter((target) => target.txid === this.txId),
tap(() => {
this.pool = null;
this.auditStatus = null;
}),
switchMap(({ hash, height, txid }) => {
switchMap(({ hash, height }) => {
const foundBlock = this.cacheService.getCachedBlock(height) || null;
const auditAvailable = this.isAuditAvailable(height);
const isCoinbase = this.tx.vin.some(v => v.is_coinbase);
const fetchAudit = auditAvailable && !isCoinbase;
return combineLatest([
foundBlock ? of(foundBlock.extras.pool) : this.apiService.getBlock$(hash).pipe(
map(block => {
return block.extras.pool;
}),
retry({ count: 3, delay: 2000 }),
catchError(() => {
return of(null);
})
),
fetchAudit ? this.apiService.getBlockAudit$(hash).pipe(
map(audit => {
const isAdded = audit.addedTxs.includes(txid);
const isPrioritized = audit.prioritizedTxs.includes(txid);
const isAccelerated = audit.acceleratedTxs.includes(txid);
const isConflict = audit.fullrbfTxs.includes(txid);
const isExpected = audit.template.some(tx => tx.txid === txid);
return {
seen: isExpected || isPrioritized || isAccelerated,
expected: isExpected,
added: isAdded,
prioritized: isPrioritized,
conflict: isConflict,
accelerated: isAccelerated,
};
}),
retry({ count: 3, delay: 2000 }),
catchError(() => {
return of(null);
})
) : of(isCoinbase ? { coinbase: true } : null)
]);
return foundBlock ? of(foundBlock.extras.pool) : this.apiService.getBlock$(hash).pipe(
map(block => block.extras.pool),
retry({ count: 3, delay: 2000 }),
catchError(() => of(null))
);
}),
catchError((e) => {
return of(null);
})
).subscribe(([pool, auditStatus]) => {
).subscribe(pool => {
this.pool = pool;
});
this.auditSubscription = this.fetchMiningInfo$.pipe(
filter((target) => target.txid === this.txId),
tap(() => {
this.auditStatus = null;
}),
switchMap(({ hash, height, txid }) => {
const auditAvailable = this.isAuditAvailable(height);
const isCoinbase = this.tx.vin.some(v => v.is_coinbase);
const fetchAudit = auditAvailable && !isCoinbase;
return fetchAudit ? this.apiService.getBlockAudit$(hash).pipe(
map(audit => {
const isAdded = audit.addedTxs.includes(txid);
const isPrioritized = audit.prioritizedTxs.includes(txid);
const isAccelerated = audit.acceleratedTxs.includes(txid);
const isConflict = audit.fullrbfTxs.includes(txid);
const isExpected = audit.template.some(tx => tx.txid === txid);
return {
seen: isExpected || isPrioritized || isAccelerated,
expected: isExpected,
added: isAdded,
prioritized: isPrioritized,
conflict: isConflict,
accelerated: isAccelerated,
};
}),
retry({ count: 3, delay: 2000 }),
catchError(() => {
return of(null);
})
) : of(isCoinbase ? { coinbase: true } : null);
}),
catchError((e) => {
return of(null);
})
).subscribe(auditStatus => {
this.auditStatus = auditStatus;
this.setIsAccelerated();
@@ -858,6 +865,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.mempoolBlocksSubscription.unsubscribe();
this.blocksSubscription.unsubscribe();
this.miningSubscription?.unsubscribe();
this.auditSubscription?.unsubscribe();
this.currencyChangeSubscription?.unsubscribe();
this.leaveTransaction();
}

View File

@@ -10,6 +10,7 @@ import { filter, map, tap, switchMap, shareReplay, catchError } from 'rxjs/opera
import { BlockExtended } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { PriceService } from '../../services/price.service';
import { StorageService } from '../../services/storage.service';
@Component({
selector: 'app-transactions-list',
@@ -56,6 +57,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
private assetsService: AssetsService,
private ref: ChangeDetectorRef,
private priceService: PriceService,
private storageService: StorageService,
) { }
ngOnInit(): void {
@@ -271,8 +273,11 @@ export class TransactionsListComponent implements OnInit, OnChanges {
if (this.network === 'liquid' || this.network === 'liquidtestnet') {
return;
}
const oldvalue = !this.stateService.viewFiat$.value;
this.stateService.viewFiat$.next(oldvalue);
const modes = ['btc', 'sats', 'fiat'];
const oldIndex = modes.indexOf(this.stateService.viewAmountMode$.value);
const newIndex = (oldIndex + 1) % modes.length;
this.stateService.viewAmountMode$.next(modes[newIndex] as 'btc' | 'sats' | 'fiat');
this.storageService.setValue('view-amount-mode', modes[newIndex]);
}
trackByFn(index: number, tx: Transaction): string {

View File

@@ -63,7 +63,7 @@ export class TxBowtieGraphTooltipComponent implements OnChanges {
this.blockConversions = {};
this.inputStatus = {};
});
this.viewFiatSubscription = this.stateService.viewFiat$.subscribe(viewFiat => this.viewFiat = viewFiat);
this.viewFiatSubscription = this.stateService.viewAmountMode$.subscribe(viewFiat => this.viewFiat = viewFiat === 'fiat');
this.chainTipSubscription = this.stateService.chainTip$.subscribe(tip => this.chainTip = tip);
}