Replacing footer and latest blocks with a stats dashboard.

This commit is contained in:
softsimon 2020-09-21 19:41:12 +07:00
parent 8146939f0f
commit 6c1d28a9ac
No known key found for this signature in database
GPG Key ID: 488D7DCFB5A430D7
18 changed files with 235 additions and 209 deletions

View File

@ -7,6 +7,7 @@ import { Common } from './common';
class Blocks {
private blocks: Block[] = [];
private currentBlockHeight = 0;
private lastDifficultyAdjustmentTime = 0;
private newBlockCallback: ((block: Block, txIds: string[], transactions: TransactionExtended[]) => void) | undefined;
constructor() { }
@ -38,6 +39,13 @@ class Blocks {
this.currentBlockHeight = blockHeightTip - config.INITIAL_BLOCK_AMOUNT;
}
if (!this.lastDifficultyAdjustmentTime) {
const heightDiff = blockHeightTip % 2016;
const blockHash = await bitcoinApi.getBlockHash(blockHeightTip - heightDiff);
const block = await bitcoinApi.getBlock(blockHash);
this.lastDifficultyAdjustmentTime = block.timestamp;
}
while (this.currentBlockHeight < blockHeightTip) {
if (this.currentBlockHeight === 0) {
this.currentBlockHeight = blockHeightTip;
@ -78,6 +86,10 @@ class Blocks {
block.medianFee = transactions.length > 1 ? Common.median(transactions.map((tx) => tx.feePerVsize)) : 0;
block.feeRange = transactions.length > 1 ? Common.getFeesInRange(transactions.slice(0, transactions.length - 1), 8) : [0, 0];
if (block.height % 2016 === 0) {
this.lastDifficultyAdjustmentTime = block.timestamp;
}
this.blocks.push(block);
if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) {
this.blocks.shift();
@ -93,6 +105,10 @@ class Blocks {
}
}
public getLastDifficultyAdjustmentTime(): number {
return this.lastDifficultyAdjustmentTime;
}
private stripCoinbaseTransaction(tx: TransactionExtended): TransactionMinerInfo {
return {
vin: [{

View File

@ -84,6 +84,7 @@ class WebsocketHandler {
client.send(JSON.stringify({
'mempoolInfo': memPool.getMempoolInfo(),
'vBytesPerSecond': memPool.getVBytesPerSecond(),
'lastDifficultyAdjustment': blocks.getLastDifficultyAdjustmentTime(),
'blocks': _blocks.slice(Math.max(_blocks.length - config.INITIAL_BLOCK_AMOUNT, 0)),
'conversions': fiatConversion.getTickers()['BTCUSD'],
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
@ -270,6 +271,7 @@ class WebsocketHandler {
const response = {
'block': block,
'mempoolInfo': memPool.getMempoolInfo(),
'lastDifficultyAdjustment': blocks.getLastDifficultyAdjustmentTime(),
};
if (mBlocks && client['want-mempool-blocks']) {

View File

@ -9,10 +9,10 @@ import { AboutComponent } from './components/about/about.component';
import { TelevisionComponent } from './components/television/television.component';
import { StatisticsComponent } from './components/statistics/statistics.component';
import { MempoolBlockComponent } from './components/mempool-block/mempool-block.component';
import { LatestBlocksComponent } from './components/latest-blocks/latest-blocks.component';
import { AssetComponent } from './components/asset/asset.component';
import { AssetsComponent } from './assets/assets.component';
import { StatusViewComponent } from './components/status-view/status-view.component';
import { DashboardComponent } from './dashboard/dashboard.component';
const routes: Routes = [
{
@ -25,7 +25,7 @@ const routes: Routes = [
children: [
{
path: '',
component: LatestBlocksComponent
component: DashboardComponent,
},
{
path: 'tx/:id',
@ -69,7 +69,7 @@ const routes: Routes = [
children: [
{
path: '',
component: LatestBlocksComponent
component: DashboardComponent
},
{
path: 'tx/:id',
@ -134,7 +134,7 @@ const routes: Routes = [
children: [
{
path: '',
component: LatestBlocksComponent
component: DashboardComponent
},
{
path: 'tx/:id',

View File

@ -16,7 +16,6 @@ import { StateService } from './services/state.service';
import { BlockComponent } from './components/block/block.component';
import { AddressComponent } from './components/address/address.component';
import { SearchFormComponent } from './components/search-form/search-form.component';
import { LatestBlocksComponent } from './components/latest-blocks/latest-blocks.component';
import { WebsocketService } from './services/websocket.service';
import { AddressLabelsComponent } from './components/address-labels/address-labels.component';
import { MempoolBlocksComponent } from './components/mempool-blocks/mempool-blocks.component';
@ -41,6 +40,7 @@ import { MinerComponent } from './components/miner/miner.component';
import { SharedModule } from './shared/shared.module';
import { NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { FeesBoxComponent } from './components/fees-box/fees-box.component';
import { DashboardComponent } from './dashboard/dashboard.component';
@NgModule({
declarations: [
@ -58,7 +58,6 @@ import { FeesBoxComponent } from './components/fees-box/fees-box.component';
AddressComponent,
AmountComponent,
SearchFormComponent,
LatestBlocksComponent,
TimespanComponent,
AddressLabelsComponent,
MempoolBlocksComponent,
@ -72,6 +71,7 @@ import { FeesBoxComponent } from './components/fees-box/fees-box.component';
MinerComponent,
StatusViewComponent,
FeesBoxComponent,
DashboardComponent,
],
imports: [
BrowserModule,

View File

@ -48,7 +48,7 @@ export class AddressComponent implements OnInit, OnDestroy {
ngOnInit() {
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.websocketService.want(['blocks', 'stats', 'mempool-blocks']);
this.websocketService.want(['blocks', 'mempool-blocks']);
this.mainSubscription = this.route.paramMap
.pipe(

View File

@ -53,7 +53,7 @@ export class AssetComponent implements OnInit, OnDestroy {
) { }
ngOnInit() {
this.websocketService.want(['blocks', 'stats', 'mempool-blocks']);
this.websocketService.want(['blocks', 'mempool-blocks']);
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.mainSubscription = this.route.paramMap

View File

@ -1,39 +0,0 @@
<app-fees-box *ngIf="(network$ | async) === ''" class="d-block mr-2 ml-2 mb-4"></app-fees-box>
<div class="container-xl">
<hr>
<table class="table table-borderless" [alwaysCallback]="true" [fromRoot]="true" [infiniteScrollContainer]="'body'" infiniteScroll [infiniteScrollDistance]="1.5" [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
<thead>
<th style="width: 15%;">Height</th>
<th class="d-none d-md-block" style="width: 20%;">Timestamp</th>
<th style="width: 20%;">Mined</th>
<th class="d-none d-lg-block" style="width: 15%;">Transactions</th>
<th style="width: 20%;">Filled</th>
</thead>
<tbody>
<tr *ngFor="let block of blocks; let i= index; trackBy: trackByBlock">
<td><a [routerLink]="['/block' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height }}</a></td>
<td class="d-none d-md-block">{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}</td>
<td><app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since> ago</td>
<td class="d-none d-lg-block">{{ block.tx_count | number }}</td>
<td>
<div class="progress position-relative">
<div class="progress-bar progress-mempool {{ network$ | async }}" role="progressbar" [ngStyle]="{'width': (block.weight / 4000000)*100 + '%' }"></div>
<div class="progress-text">{{ block.size | bytes: 2 }}</div>
</div>
</td>
</tr>
<ng-template [ngIf]="isLoading">
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
<td><span class="skeleton-loader"></span></td>
<td class="d-none d-md-block"><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
<td class="d-none d-lg-block"><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</ng-template>
</tbody>
</table>
</div>

View File

@ -1,14 +0,0 @@
.progress {
background-color: #2d3348;
}
@media (min-width: 768px) {
.d-md-block {
display: table-cell !important;
}
}
@media (min-width: 992px) {
.d-lg-block {
display: table-cell !important;
}
}

View File

@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LatestBlocksComponent } from './latest-blocks.component';
describe('LatestBlocksComponent', () => {
let component: LatestBlocksComponent;
let fixture: ComponentFixture<LatestBlocksComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ LatestBlocksComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LatestBlocksComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,111 +0,0 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { StateService } from '../../services/state.service';
import { Block } from '../../interfaces/electrs.interface';
import { Subscription, Observable, merge, of } from 'rxjs';
import { SeoService } from 'src/app/services/seo.service';
@Component({
selector: 'app-latest-blocks',
templateUrl: './latest-blocks.component.html',
styleUrls: ['./latest-blocks.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LatestBlocksComponent implements OnInit, OnDestroy {
network$: Observable<string>;
blocks: any[] = [];
blockSubscription: Subscription;
isLoading = true;
interval: any;
latestBlockHeight: number;
heightOfPageUntilBlocks = 430;
heightOfBlocksTableChunk = 470;
constructor(
private electrsApiService: ElectrsApiService,
private stateService: StateService,
private seoService: SeoService,
private cd: ChangeDetectorRef,
) { }
ngOnInit() {
this.seoService.resetTitle();
this.network$ = merge(of(''), this.stateService.networkChanged$);
this.blockSubscription = this.stateService.blocks$
.subscribe(([block]) => {
if (block === null || !this.blocks.length) {
return;
}
this.latestBlockHeight = block.height;
if (block.height === this.blocks[0].height) {
return;
}
// If we are out of sync, reload the blocks instead
if (block.height > this.blocks[0].height + 1) {
this.loadInitialBlocks();
return;
}
if (block.height <= this.blocks[0].height) {
return;
}
this.blocks.pop();
this.blocks.unshift(block);
this.cd.markForCheck();
});
this.loadInitialBlocks();
}
ngOnDestroy() {
clearInterval(this.interval);
this.blockSubscription.unsubscribe();
}
loadInitialBlocks() {
this.electrsApiService.listBlocks$()
.subscribe((blocks) => {
this.blocks = blocks;
this.isLoading = false;
this.latestBlockHeight = blocks[0].height;
const spaceForBlocks = window.innerHeight - this.heightOfPageUntilBlocks;
const chunks = Math.ceil(spaceForBlocks / this.heightOfBlocksTableChunk) - 1;
if (chunks > 0) {
this.loadMore(chunks);
}
this.cd.markForCheck();
});
}
loadMore(chunks = 0) {
if (this.isLoading) {
return;
}
this.isLoading = true;
this.electrsApiService.listBlocks$(this.blocks[this.blocks.length - 1].height - 1)
.subscribe((blocks) => {
this.blocks = this.blocks.concat(blocks);
this.isLoading = false;
const chunksLeft = chunks - 1;
if (chunksLeft > 0) {
this.loadMore(chunksLeft);
}
this.cd.markForCheck();
});
}
trackByBlock(index: number, block: Block) {
return block.height;
}
}

View File

@ -66,10 +66,4 @@
<br>
<ng-container *ngIf="network.val !== 'bisq'">
<br><br>
<app-footer></app-footer>
</ng-container>
</ng-container>

View File

@ -108,10 +108,10 @@ export class StatisticsComponent implements OnInit {
switchMap(() => {
this.spinnerLoading = true;
if (this.radioGroupForm.controls.dateSpan.value === '2h') {
this.websocketService.want(['blocks', 'stats', 'live-2h-chart']);
this.websocketService.want(['blocks', 'live-2h-chart']);
return this.apiService.list2HStatistics$();
}
this.websocketService.want(['blocks', 'stats']);
this.websocketService.want(['blocks']);
if (this.radioGroupForm.controls.dateSpan.value === '24h') {
return this.apiService.list24HStatistics$();
}

View File

@ -0,0 +1,51 @@
<app-fees-box *ngIf="(network$ | async) === ''" class="d-block mr-2 ml-2 mb-5"></app-fees-box>
<div class="container-xl">
<div class="row row-cols-1 row-cols-md-2" *ngIf="mempoolInfoData$ | async as mempoolInfoData">
<div class="col mb-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title mempoolSize">Mempool size</h5>
<p class="card-text" *ngIf="(mempoolBlocksData$ | async) as mempoolBlocksData">{{ mempoolBlocksData.size | bytes }} ({{ mempoolBlocksData.blocks }} block<span [hidden]="mempoolBlocksData.blocks <= 1">s</span>)</p>
</div>
</div>
</div>
<div class="col mb-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title unconfirmedTx">Unconfirmed transactions</h5>
<p class="card-text">{{ mempoolInfoData.memPoolInfo.size | number }}</p>
</div>
</div>
</div>
<div class="col mb-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title txWeightPerSecond">Tx weight per second</h5>
<span *ngIf="mempoolInfoData.vBytesPerSecond === 0; else inSync">
&nbsp;<span class="badge badge-pill badge-warning">Backend is synchronizing</span>
</span>
<ng-template #inSync>
<div class="progress sub-text">
<div class="progress-bar {{ mempoolInfoData.progressClass }}" style="padding: 4px;" role="progressbar" [ngStyle]="{'width': mempoolInfoData.progressWidth}">{{ mempoolInfoData.vBytesPerSecond | ceil | number }} vB/s</div>
</div>
</ng-template>
</div>
</div>
</div>
<div class="col mb-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title txPerSecond">Difficulty Epoch</h5>
<div class="progress" *ngIf="(difficultyEpoch$ | async) as epochData">
<div class="progress-bar" role="progressbar" style="width: 15%; background-color: #105fb0" [ngStyle]="{'width': epochData.base}"></div>
<div class="progress-bar bg-success" role="progressbar" style="width: 0%" [ngStyle]="{'width': epochData.green}"></div>
<div class="progress-bar bg-danger" role="progressbar" style="width: 1%; background-color: #f14d80;" [ngStyle]="{'width': epochData.red}"></div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,36 @@
.card {
background-color: #1d1f31;
}
.txWeightPerSecond {
color: #4a9ff4;
}
.mempoolSize {
color: #4a68b9;
}
.txPerSecond {
color: #f4bb4a;;
}
.unconfirmedTx {
color: #f14d80;
}
.info-block {
float: left;
width: 350px;
line-height: 25px;
}
.progress {
display: inline-flex;
width: 250px;
background-color: #2d3348;
height: 1.1rem;
}
.bg-warning {
background-color: #b58800 !important;
}

View File

@ -0,0 +1,114 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { combineLatest, merge, Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { MempoolInfo } from '../interfaces/websocket.interface';
import { StateService } from '../services/state.service';
interface MempoolBlocksData {
blocks: number;
size: number;
}
interface EpochProgress {
base: string;
green: string;
red: string;
}
interface MempoolInfoData {
memPoolInfo: MempoolInfo;
vBytesPerSecond: number;
progressWidth: string;
progressClass: string;
}
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardComponent implements OnInit {
network$: Observable<string>;
mempoolBlocksData$: Observable<MempoolBlocksData>;
latestBlockHeight$: Observable<number>;
mempoolInfoData$: Observable<MempoolInfoData>;
difficultyEpoch$: Observable<EpochProgress>;
vBytesPerSecondLimit = 1667;
constructor(
private stateService: StateService,
) { }
ngOnInit(): void {
this.network$ = merge(of(''), this.stateService.networkChanged$);
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 progressClass = 'bg-danger';
if (percent <= 75) {
progressClass = 'bg-success';
} else if (percent <= 99) {
progressClass = 'bg-warning';
}
return {
memPoolInfo: mempoolInfo,
vBytesPerSecond: vbytesPerSecond,
progressWidth: percent + '%',
progressClass: progressClass,
};
})
);
this.difficultyEpoch$ = combineLatest([
this.stateService.blocks$.pipe(map(([block]) => block)),
this.stateService.lastDifficultyAdjustment$
])
.pipe(
map(([block, DATime]) => {
const now = new Date().getTime() / 1000;
const diff = now - DATime;
const blocksInEpoch = block.height % 2016;
const estimatedBlocks = Math.round(diff / 60 / 10);
let base = 0;
let green = 0;
let red = 0;
if (blocksInEpoch >= estimatedBlocks) {
base = estimatedBlocks / 2016 * 100;
green = (blocksInEpoch - estimatedBlocks) / 2016 * 100;
} else {
base = blocksInEpoch / 2016 * 100;
red = (estimatedBlocks - blocksInEpoch) / 2016 * 100;
}
return {
base: base + '%',
green: green + '%',
red: red + '%',
};
})
);
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 / 1000000)
};
})
);
}
}

View File

@ -8,6 +8,7 @@ export interface WebsocketResponse {
historicalDate?: string;
mempoolInfo?: MempoolInfo;
vBytesPerSecond?: number;
lastDifficultyAdjustment?: number;
action?: string;
data?: string[];
tx?: Transaction;
@ -31,8 +32,4 @@ export interface MempoolBlock {
export interface MempoolInfo {
size: number;
bytes: number;
usage?: number;
maxmempool?: number;
mempoolminfee?: number;
minrelaytxfee?: number;
}

View File

@ -31,6 +31,7 @@ export class StateService {
blockTransactions$ = new Subject<Transaction>();
isLoadingWebSocket$ = new ReplaySubject<boolean>(1);
vbytesPerSecond$ = new ReplaySubject<number>(1);
lastDifficultyAdjustment$ = new ReplaySubject<number>(1);
gitCommit$ = new ReplaySubject<string>(1);
live2Chart$ = new Subject<OptimizedMempoolStats>();

View File

@ -141,6 +141,10 @@ export class WebsocketService {
this.stateService.vbytesPerSecond$.next(response.vBytesPerSecond);
}
if (response.lastDifficultyAdjustment !== undefined) {
this.stateService.lastDifficultyAdjustment$.next(response.lastDifficultyAdjustment);
}
if (response['git-commit']) {
this.stateService.gitCommit$.next(response['git-commit']);
}