Refactor. API explanations. UX revamp.

This commit is contained in:
Simon Lindh 2020-02-17 20:39:20 +07:00 committed by wiz
parent acd658a0e7
commit 34645908e9
No known key found for this signature in database
GPG Key ID: A394E332255A6173
40 changed files with 474 additions and 210 deletions

View File

@ -1,5 +1,5 @@
const config = require('../../../mempool-config.json');
import { Transaction, Block } from '../../interfaces';
import { Transaction, Block, MempoolInfo } from '../../interfaces';
import * as request from 'request';
class ElectrsApi {
@ -7,6 +7,27 @@ class ElectrsApi {
constructor() {
}
getMempoolInfo(): Promise<MempoolInfo> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/mempool', { json: true, timeout: 10000 }, (err, res, response) => {
if (err) {
reject(err);
} else if (res.statusCode !== 200) {
reject(response);
} else {
if (!response.count) {
reject('Empty data');
return;
}
resolve({
size: response.count,
bytes: response.vsize,
});
}
});
});
}
getRawMempool(): Promise<Transaction['txid'][]> {
return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/mempool/txids', { json: true, timeout: 10000, forever: true }, (err, res, response) => {

View File

@ -32,6 +32,14 @@ class Mempool {
}
}
public async updateMemPoolInfo() {
try {
this.mempoolInfo = await bitcoinApi.getMempoolInfo();
} catch (err) {
console.log('Error getMempoolInfo', err);
}
}
public getMempoolInfo(): MempoolInfo | undefined {
return this.mempoolInfo;
}

View File

@ -58,6 +58,7 @@ class Server {
}
private async runMempoolIntervalFunctions() {
await memPool.updateMemPoolInfo();
await blocks.updateBlocks();
await memPool.updateMempool();
setTimeout(this.runMempoolIntervalFunctions.bind(this), config.ELECTRS_POLL_RATE_MS);
@ -83,28 +84,33 @@ class Server {
const parsedMessage = JSON.parse(message);
if (parsedMessage.action === 'want') {
client['want-stats'] = parsedMessage.data.indexOf('stats') > -1;
client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1;
client['want-mempool-blocks'] = parsedMessage.data.indexOf('mempool-blocks') > -1;
client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1;
client['want-stats'] = parsedMessage.data.indexOf('stats') > -1;
}
if (parsedMessage && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) {
client['txId'] = parsedMessage.txId;
}
} catch (e) {
console.log(e);
}
});
if (parsedMessage.action === 'init') {
const _blocks = blocks.getBlocks();
if (!_blocks) {
return;
}
client.send(JSON.stringify({
'mempoolInfo': memPool.getMempoolInfo(),
'vBytesPerSecond': memPool.getVBytesPerSecond(),
'blocks': _blocks,
'conversions': fiatConversion.getTickers()['BTCUSD'],
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
}));
}
} catch (e) {
console.log(e);
}
});
});
statistics.setNewStatisticsEntryCallback((stats: Statistic) => {
@ -113,11 +119,13 @@ class Server {
return;
}
if (client['want-live-2h-chart']) {
if (!client['want-live-2h-chart']) {
return;
}
client.send(JSON.stringify({
'live-2h-chart': stats
}));
}
});
});
@ -127,6 +135,10 @@ class Server {
return;
}
if (!client['want-blocks']) {
return;
}
if (client['txId'] && txIds.indexOf(client['txId']) > -1) {
client['txId'] = null;
client.send(JSON.stringify({
@ -143,16 +155,29 @@ class Server {
memPool.setMempoolChangedCallback((newMempool: { [txid: string]: SimpleTransaction }) => {
mempoolBlocks.updateMempoolBlocks(newMempool);
const pBlocks = mempoolBlocks.getMempoolBlocks();
const mBlocks = mempoolBlocks.getMempoolBlocks();
const mempoolInfo = memPool.getMempoolInfo();
const vBytesPerSecond = memPool.getVBytesPerSecond();
this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
client.send(JSON.stringify({
'mempool-blocks': pBlocks
}));
const response = {};
if (client['want-stats']) {
response['mempoolInfo'] = mempoolInfo;
response['vBytesPerSecond'] = vBytesPerSecond;
}
if (client['want-mempool-blocks']) {
response['mempool-blocks'] = mBlocks;
}
if (Object.keys(response).length) {
client.send(JSON.stringify(response));
}
});
});
}

View File

@ -8,6 +8,7 @@ import { MasterPageComponent } from './components/master-page/master-page.compon
import { AboutComponent } from './components/about/about.component';
import { TelevisionComponent } from './components/television/television.component';
import { StatisticsComponent } from './components/statistics/statistics.component';
import { ExplorerComponent } from './components/explorer/explorer.component';
const routes: Routes = [
{
@ -18,6 +19,10 @@ const routes: Routes = [
path: '',
component: StartComponent,
},
{
path: 'explorer',
component: ExplorerComponent,
},
{
path: 'graphs',
component: StatisticsComponent,

View File

@ -38,6 +38,8 @@ import { StatisticsComponent } from './components/statistics/statistics.componen
import { ChartistComponent } from './components/statistics/chartist.component';
import { BlockchainBlocksComponent } from './components/blockchain-blocks/blockchain-blocks.component';
import { BlockchainComponent } from './components/blockchain/blockchain.component';
import { FooterComponent } from './components/footer/footer.component';
import { ExplorerComponent } from './components/explorer/explorer.component';
@NgModule({
declarations: [
@ -68,7 +70,9 @@ import { BlockchainComponent } from './components/blockchain/blockchain.componen
LatestTransactionsComponent,
QrcodeComponent,
ClipboardComponent,
ExplorerComponent,
ChartistComponent,
FooterComponent,
],
imports: [
BrowserModule,

View File

@ -1,34 +1,54 @@
<div class="text-center">
<div class="container">
<div class="text-center">
<img src="./assets/mempool-tube.png" width="63" height="63" />
<br /><br />
<h2>About</h2>
<h1>About</h1>
<p>Mempool.Space is a realtime Bitcoin blockchain explorer and mempool visualizer.</p>
<p>Created by <a href="https://twitter.com/softbtc">@softbtc</a>
<br />Hosted by <a href="https://twitter.com/wiz">@wiz</a>
<br />Designed by <a href="https://instagram.com/markjborg">@markjborg</a>
<h2>Fee API</h2>
<div class="col-4 mx-auto">
<input class="form-control" type="text" value="https://mempool.space/api/v1/fees/recommended" readonly>
</div>
<br />
<h2>HTTP API</h2>
<h1>Donate</h1>
<img src="./assets/btc-qr-code-segwit.png" width="200" height="200" />
<br />
bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t
<table class="table">
<tr>
<td style="width: 50%;">Fee API</td>
<td>
<div class="mx-auto">
<input class="form-control" type="text" value="https://mempool.space/api/v1/fees/recommended" readonly>
</div>
</td>
</tr>
<tr>
<td>Mempool blocks</td>
<td>
<div class="mx-auto">
<input class="form-control" type="text" value="https://mempool.space/api/v1/fees/mempool-blocks" readonly>
</div>
</td>
</tr>
</table>
<h2>WebSocket API</h2>
<table class="table">
<tr>
<td style="width: 50%;">
<span class="text-small">
Upon connection, send object <span class="code">{{ '{' }} action: 'want', data: ['blocks', ...] {{ '}' }}</span>
to express what you want pushed. Available: 'blocks', 'mempool-blocks', 'live-2h-chart' and 'stats'.
</span>
</td>
<td>
<div class="mx-auto">
<input class="form-control" type="text" value="wss://mempool.space/ws" readonly>
</div>
</td>
</tr>
</table>
<br /><br />
<h3>PayNym</h3>
<img src="./assets/paynym-code.png" width="200" height="200" />
<br />
<p style="word-wrap: break-word; overflow-wrap: break-word;max-width: 300px; text-align: center; margin: auto;">
PM8TJZWDn1XbYmVVMR3RP9Kt1BW69VCSLTC12UB8iWUiKcEBJsxB4UUKBMJxc3LVaxtU5d524sLFrTy9kFuyPQ73QkEagGcMfCE6M38E5C67EF8KAqvS
</p>
</div>

View File

@ -0,0 +1,8 @@
.text-small {
font-size: 12px;
}
.code {
background-color: #1d1f31;
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New;
}

View File

@ -13,7 +13,7 @@ export class AboutComponent implements OnInit {
) { }
ngOnInit() {
this.websocketService.want([]);
this.websocketService.want(['blocks']);
}
}

View File

@ -1,6 +1,6 @@
<div class="container">
<app-blockchain></app-blockchain>
<app-blockchain position="top"></app-blockchain>
<h1>Block <ng-template [ngIf]="blockHeight"><a [routerLink]="['/block/', blockHash]">#{{ blockHeight }}</a></ng-template></h1>

View File

@ -5,6 +5,7 @@ import { switchMap } from 'rxjs/operators';
import { Block, Transaction } from '../../interfaces/electrs.interface';
import { of } from 'rxjs';
import { StateService } from '../../services/state.service';
import { WebsocketService } from 'src/app/services/websocket.service';
@Component({
selector: 'app-block',
@ -25,9 +26,12 @@ export class BlockComponent implements OnInit {
private route: ActivatedRoute,
private electrsApiService: ElectrsApiService,
private stateService: StateService,
private websocketService: WebsocketService,
) { }
ngOnInit() {
this.websocketService.want(['blocks', 'mempool-blocks']);
this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || '';
@ -40,6 +44,7 @@ export class BlockComponent implements OnInit {
this.blockHash = blockHash;
if (history.state.data && history.state.data.block) {
this.blockHeight = history.state.data.block.height;
return of(history.state.data.block);
} else {
this.isLoadingBlock = true;

View File

@ -1,6 +1,7 @@
<div class="blocks-container" *ngIf="blocks.length">
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn" >
<div [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }" class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" [ngStyle]="getStyleForBlock(block)">
<div class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" [ngStyle]="getStyleForBlock(block)">
<a [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }" class="blockLink">&nbsp;</a>
<div class="block-height">
<a [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }">#{{ block.height }}</a>
</div>

View File

@ -1,7 +1,14 @@
.bitcoin-block {
width: 125px;
height: 125px;
cursor: pointer;
}
.blockLink {
width: 100%;
height: 100%;
position: absolute;
left: 0;
z-index: 10;
}
.mined-block {

View File

@ -1,16 +1,10 @@
<div *ngIf="isLoading" class="text-center">
<h3>Loading blocks...</h3>
<div *ngIf="isLoading" class="loading-block">
<h3>Waiting for blocks...</h3>
<br>
<div class="spinner-border text-light"></div>
</div>
<div *ngIf="!isLoading && txTrackingLoading" class="text-center black-background">
<h3>Locating transaction...</h3>
</div>
<div *ngIf="txShowTxNotFound" class="text-center black-background">
<h3>Transaction not found!</h3>
</div>
<div class="text-center" class="blockchain-wrapper">
<div class="position-container">
<div class="position-container" [ngStyle]="{'top': position === 'top' ? '100px' : 'calc(50% - 60px)'}">
<app-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks>

View File

@ -23,8 +23,6 @@
.position-container {
position: absolute;
left: 50%;
/* top: calc(50% - 60px); */
top: 180px;
}
@media (max-width: 767.98px) {
@ -48,3 +46,11 @@
z-index: 100;
position: relative;
}
.loading-block {
position: absolute;
text-align: center;
margin: auto;
width: 100%;
top: 80px;
}

View File

@ -1,8 +1,6 @@
import { Component, OnInit, OnDestroy, Renderer2 } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
import { WebsocketService } from 'src/app/services/websocket.service';
import { StateService } from 'src/app/services/state.service';
@Component({
@ -11,6 +9,8 @@ import { StateService } from 'src/app/services/state.service';
styleUrls: ['./blockchain.component.scss']
})
export class BlockchainComponent implements OnInit, OnDestroy {
@Input() position: 'middle' | 'top' = 'middle';
txTrackingSubscription: Subscription;
blocksSubscription: Subscription;
@ -19,59 +19,10 @@ export class BlockchainComponent implements OnInit, OnDestroy {
isLoading = true;
constructor(
private route: ActivatedRoute,
private websocketService: WebsocketService,
private stateService: StateService,
) {}
ngOnInit() {
/*
this.apiService.webSocketWant(['stats', 'blocks', 'mempool-blocks']);
this.txTrackingSubscription = this.memPoolService.txTracking$
.subscribe((response: ITxTracking) => {
this.txTrackingLoading = false;
this.txShowTxNotFound = response.notFound;
if (this.txShowTxNotFound) {
setTimeout(() => { this.txShowTxNotFound = false; }, 2000);
}
});
*/
/*
this.route.paramMap
.subscribe((params: ParamMap) => {
if (this.memPoolService.txTracking$.value.enabled) {
return;
}
const txId: string | null = params.get('id');
if (!txId) {
return;
}
this.txTrackingLoading = true;
this.apiService.webSocketStartTrackTx(txId);
});
*/
/*
this.memPoolService.txIdSearch$
.subscribe((txId) => {
if (txId) {
if (this.memPoolService.txTracking$.value.enabled
&& this.memPoolService.txTracking$.value.tx
&& this.memPoolService.txTracking$.value.tx.txid === txId) {
return;
}
console.log('enabling tracking loading from idSearch!');
this.txTrackingLoading = true;
this.websocketService.startTrackTx(txId);
}
});
*/
this.blocksSubscription = this.stateService.blocks$
.pipe(
take(1)
@ -81,6 +32,5 @@ export class BlockchainComponent implements OnInit, OnDestroy {
ngOnDestroy() {
this.blocksSubscription.unsubscribe();
// this.txTrackingSubscription.unsubscribe();
}
}

View File

@ -1,4 +1,4 @@
<span #buttonWrapper [attr.data-tlite]="'Copied!'">
<span #buttonWrapper [attr.data-tlite]="'Copied!'" style="position: relative;">
<button #btn class="btn btn-sm btn-link pt-0" style="line-height: 1;" [attr.data-clipboard-text]="text">
<img src="./assets/clippy.svg" width="13">
</button>

View File

@ -0,0 +1,20 @@
<div class="container">
<br>
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'blocks'" routerLink="/explorer" (click)="view = 'blocks'">Blocks</a>
</li>
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'transactions'" routerLink="/explorer" fragment="transactions" (click)="view = 'transactions'">Transactions</a>
</li>
</ul>
<app-latest-blocks *ngIf="view === 'blocks'; else latestTransactions"></app-latest-blocks>
<ng-template #latestTransactions>
<app-latest-transactions></app-latest-transactions>
</ng-template>
</div>
<br>

View File

@ -0,0 +1,3 @@
.search-container {
padding-top: 50px;
}

View File

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

View File

@ -0,0 +1,25 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-explorer',
templateUrl: './explorer.component.html',
styleUrls: ['./explorer.component.scss']
})
export class ExplorerComponent implements OnInit {
view: 'blocks' | 'transactions' = 'blocks';
constructor(
private route: ActivatedRoute,
) {}
ngOnInit() {
this.route.fragment
.subscribe((fragment: string) => {
if (fragment === 'transactions' ) {
this.view = 'transactions';
}
});
}
}

View File

@ -0,0 +1,18 @@
<footer class="footer">
<div class="container">
<div class="my-2 my-md-0 mr-md-3">
<div *ngIf="memPoolInfo" class="info-block">
<span class="unconfirmedTx">Unconfirmed transactions:</span>&nbsp;<b>{{ memPoolInfo?.memPoolInfo?.size | number }}</b>
<br />
<span class="mempoolSize">Mempool size:</span>&nbsp;<b>{{ mempoolSize | bytes }} ({{ mempoolBlocks }} block<span [hidden]="mempoolBlocks <= 1">s</span>)</b>
<br />
<span class="txPerSecond">Tx weight per second:</span>&nbsp;
<div class="progress">
<div class="progress-bar {{ progressClass }}" role="progressbar" [ngStyle]="{'width': progressWidth}">{{ memPoolInfo?.vBytesPerSecond | ceil | number }} vBytes/s</div>
</div>
</div>
</div>
</div>
</footer>

View File

@ -0,0 +1,44 @@
.footer {
position: fixed;
bottom: 0;
width: 100%;
height: 120px;
background-color: #1d1f31;
}
.footer > .container {
margin-top: 25px;
}
.txPerSecond {
color: #4a9ff4;
}
.mempoolSize {
color: #4a68b9;
}
.unconfirmedTx {
color: #f14d80;
}
.info-block {
float: left;
width: 350px;
line-height: 25px;
}
.progress {
display: inline-flex;
width: 160px;
background-color: #2d3348;
height: 1.1rem;
}
.progress-bar {
padding: 4px;
}
.bg-warning {
background-color: #b58800 !important;
}

View File

@ -0,0 +1,61 @@
import { Component, OnInit } from '@angular/core';
import { StateService } from 'src/app/services/state.service';
import { MemPoolState } from 'src/app/interfaces/websocket.interface';
@Component({
selector: 'app-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
})
export class FooterComponent implements OnInit {
memPoolInfo: MemPoolState | undefined;
mempoolBlocks = 0;
progressWidth = '';
progressClass: string;
mempoolSize = 0;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.stateService.mempoolStats$
.subscribe((mempoolState) => {
this.memPoolInfo = mempoolState;
this.updateProgress();
});
this.stateService.mempoolBlocks$
.subscribe((mempoolBlocks) => {
if (!mempoolBlocks.length) { return; }
const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b);
const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b);
this.mempoolSize = size;
this.mempoolBlocks = Math.ceil(vsize / 1000000);
});
}
updateProgress() {
if (!this.memPoolInfo) {
return;
}
const vBytesPerSecondLimit = 1667;
let vBytesPerSecond = this.memPoolInfo.vBytesPerSecond;
if (vBytesPerSecond > 1667) {
vBytesPerSecond = 1667;
}
const percent = Math.round((vBytesPerSecond / vBytesPerSecondLimit) * 100);
this.progressWidth = percent + '%';
if (percent <= 75) {
this.progressClass = 'bg-success';
} else if (percent <= 99) {
this.progressClass = 'bg-warning';
} else {
this.progressClass = 'bg-danger';
}
}
}

View File

@ -9,7 +9,7 @@
</thead>
<tbody>
<tr *ngFor="let block of blocks; let i= index; trackBy: trackByBlock">
<td><a [routerLink]="['./block', block.id]" [state]="{ data: { block: block } }">#{{ block.height }}</a></td>
<td><a [routerLink]="['/block', 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>{{ block.timestamp | timeSince : trigger }} ago</td>
<td>{{ block.tx_count }}</td>
@ -34,10 +34,6 @@
</table>
<div class="text-center">
<ng-template [ngIf]="isLoading">
<div class="spinner-border"></div>
<br><br>
</ng-template>
<br>
<button *ngIf="blocks.length" [disabled]="isLoading" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button>
</div>

View File

@ -9,7 +9,7 @@
<ng-container *ngIf="(transactions$ | async) as transactions">
<ng-template [ngIf]="!isLoading">
<tr *ngFor="let transaction of transactions">
<td><a [routerLink]="['./tx/', transaction.txid]">{{ transaction.txid }}</a></td>
<td><a [routerLink]="['/tx/', transaction.txid]">{{ transaction.txid }}</a></td>
<td>{{ transaction.value / 100000000 }} BTC</td>
<td>{{ transaction.vsize | vbytes: 2 }}</td>
<td>{{ transaction.fee / transaction.vsize | number : '1.2-2'}} sats/vB</td>

View File

@ -9,7 +9,10 @@
<div class="navbar-collapse collapse" id="navbarCollapse" [ngClass]="{'show': navCollapsed}">
<ul class="navbar-nav mr-auto">
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
<a class="nav-link" routerLink="/" (click)="collapse()">Explorer</a>
<a class="nav-link" routerLink="/" (click)="collapse()">Blockchain</a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/explorer" (click)="collapse()">Explorer</a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/graphs" (click)="collapse()">Graphs</a>

View File

@ -1,21 +1,4 @@
<ng-template [ngIf]="location === 'start'" [ngIfElse]="top">
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="form-row">
<div class="col-12 col-md-10 mb-2 mb-md-0">
<input formControlName="searchText" type="text" class="form-control form-control-lg" [placeholder]="searchBoxPlaceholderText">
</div>
<div class="col-12 col-md-2">
<button type="submit" class="btn btn-block btn-lg btn-primary">{{ searchButtonText }}</button>
</div>
</div>
</form>
</ng-template>
<ng-template #top>
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="form-row">
<div style="width: 350px;" class="mr-2">
<input formControlName="searchText" type="text" class="form-control" [placeholder]="searchBoxPlaceholderText">
@ -24,6 +7,4 @@
<button type="submit" class="btn btn-block btn-primary">{{ searchButtonText }}</button>
</div>
</div>
</form>
</ng-template>
</form>

View File

@ -9,7 +9,6 @@ import { Router } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SearchFormComponent implements OnInit {
@Input() location: string;
searchForm: FormGroup;
searchButtonText = 'Search';

View File

@ -1,20 +1,3 @@
<app-blockchain></app-blockchain>
<div class="box">
<div class="container">
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'blocks'" href="#" (click)="view = 'blocks'">Blocks</a>
</li>
<li class="nav-item">
<a class="nav-link" [class.active]="view === 'transactions'" href="#" (click)="view = 'transactions'">Transactions</a>
</li>
</ul>
<app-latest-blocks *ngIf="view === 'blocks'; else latestTransactions"></app-latest-blocks>
<ng-template #latestTransactions>
<app-latest-transactions></app-latest-transactions>
</ng-template>
</div>
</div>
<app-footer></app-footer>

View File

@ -1,10 +1,19 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { WebsocketService } from 'src/app/services/websocket.service';
@Component({
selector: 'app-start',
templateUrl: './start.component.html',
styleUrls: ['./start.component.scss']
})
export class StartComponent {
export class StartComponent implements OnInit {
view: 'blocks' | 'transactions' = 'blocks';
constructor(
private websocketService: WebsocketService,
) { }
ngOnInit() {
this.websocketService.want(['blocks', 'stats', 'mempool-blocks']);
}
}

View File

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

View File

@ -1,7 +1,7 @@
#tv-wrapper {
height: 100%;
margin: 10px;
margin-top: 20px;
padding: 10px;
padding-top: 20px;
}
.blockchain-wrapper {

View File

@ -24,15 +24,12 @@ export class TelevisionComponent implements OnInit {
private websocketService: WebsocketService,
@Inject(LOCALE_ID) private locale: string,
private vbytesPipe: VbytesPipe,
private renderer: Renderer2,
private apiService: ApiService,
private stateService: StateService,
) { }
ngOnInit() {
this.websocketService.want(['live-2h-chart']);
this.renderer.addClass(document.body, 'disable-scroll');
this.websocketService.want(['blocks', 'live-2h-chart']);
const labelInterpolationFnc = (value: any, index: any) => {
return index % 6 === 0 ? formatDate(value, 'HH:mm', this.locale) : null;

View File

@ -1,6 +1,6 @@
<div class="container">
<app-blockchain></app-blockchain>
<app-blockchain position="top"></app-blockchain>
<div class="clearfix"></div>
@ -26,6 +26,7 @@
<td>Included in block</td>
<td><a [routerLink]="['/block/', tx.status.block_hash]" [state]="{ data: { blockHeight: tx.status.block_height } }">#{{ tx.status.block_height }}</a> at {{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }} <i>(<app-time-since [time]="tx.status.block_time"></app-time-since> ago)</i></td>
</tr>
<ng-template [ngIf]="tx.fee">
<tr>
<td>Fees</td>
<td>{{ tx.fee | number }} sats <span *ngIf="conversions">(<span class="green-color">{{ conversions.USD * tx.fee / 100000000 | currency:'USD':'symbol':'1.2-2' }}</span>)</span></td>
@ -34,6 +35,7 @@
<td>Fees per vByte</td>
<td>{{ tx.fee / (tx.weight / 4) | number : '1.2-2' }} sat/vB</td>
</tr>
</ng-template>
</tbody>
</table>
</div>
@ -66,15 +68,9 @@
</tbody>
</table>
</div>
</ng-template>
<br>
<!--
<div>
<app-mempool-blocks style="right: 20px; position: relative;" *ngIf="!tx.status.confirmed" [txFeePerVSize]="tx.fee / (tx.weight / 4)" (rightPosition)="rightPosition = $event" (blockDepth)="blockDepth = $event"></app-mempool-blocks>
</div>
-->
<div class="clearfix"></div>
</ng-template>
<h2>Inputs & Outputs</h2>

View File

@ -31,6 +31,8 @@ export class TransactionComponent implements OnInit {
) { }
ngOnInit() {
this.websocketService.want(['blocks', 'mempool-blocks']);
this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
this.txId = params.get('id') || '';

View File

@ -1,6 +1,7 @@
<ng-container *ngFor="let tx of transactions; let i = index; trackBy: trackByFn">
<div *ngIf="!transactionPage" class="header-bg box" style="padding: 10px; margin-bottom: 10px;">
<a [routerLink]="['/tx/', tx.txid]" [state]="{ data: tx }">{{ tx.txid }}</a>
<div class="float-right">{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }}</div>
</div>
<div class="header-bg box">
<div class="row">
@ -9,14 +10,19 @@
<tbody>
<tr *ngFor="let vin of tx.vin">
<td class="arrow-td">
<ng-template [ngIf]="vin.prevout === null" [ngIfElse]="hasPrevout">
<i class="arrow grey"></i>
</ng-template>
<ng-template #hasPrevout>
<a [routerLink]="['/tx/', vin.txid]">
<i class="arrow green"></i>
</a>
</ng-template>
</td>
<td>
<div>
<ng-template [ngIf]="vin.is_coinbase" [ngIfElse]="regularVin">
Coinbase
Coinbase (Newly Generated Coins)
</ng-template>
<ng-template #regularVin>
<a [routerLink]="['/address/', vin.prevout.scriptpubkey_address]" title="{{ vin.prevout.scriptpubkey_address }}">{{ vin.prevout.scriptpubkey_address | shortenString : 42 }}</a>
@ -55,9 +61,9 @@
<td class="pl-1 arrow-td">
<i *ngIf="!outspends[i]; else outspend" class="arrow grey"></i>
<ng-template #outspend>
<i *ngIf="!outspends[i][vindex] || !outspends[i][vindex].spent; else spent" class="arrow green"></i>
<i *ngIf="!outspends[i][vindex] || !outspends[i][vindex].spent; else spent" class="arrow red"></i>
<ng-template #spent>
<a [routerLink]="['/tx/', outspends[i][vindex].txid]"><i class="arrow red"></i></a>
<a [routerLink]="['/tx/', outspends[i][vindex].txid]"><i class="arrow green"></i></a>
</ng-template>
</ng-template>
</td>

View File

@ -7,6 +7,10 @@ export interface WebsocketResponse {
txId?: string;
txConfirmed?: boolean;
historicalDate?: string;
mempoolInfo?: MempoolInfo;
vBytesPerSecond?: number;
action?: string;
data?: string[];
}
export interface MempoolBlock {
@ -16,3 +20,17 @@ export interface MempoolBlock {
medianFee: number;
feeRange: number[];
}
export interface MemPoolState {
memPoolInfo: MempoolInfo;
vBytesPerSecond: number;
}
export interface MempoolInfo {
size: number;
bytes: number;
usage?: number;
maxmempool?: number;
mempoolminfee?: number;
minrelaytxfee?: number;
}

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject } from 'rxjs';
import { Block } from '../interfaces/electrs.interface';
import { MempoolBlock } from '../interfaces/websocket.interface';
import { MempoolBlock, MemPoolState } from '../interfaces/websocket.interface';
import { OptimizedMempoolStats } from '../interfaces/node-api.interface';
@Injectable({
@ -11,6 +11,7 @@ export class StateService {
latestBlockHeight = 0;
blocks$ = new ReplaySubject<Block>(8);
conversions$ = new ReplaySubject<any>(1);
mempoolStats$ = new ReplaySubject<MemPoolState>();
mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1);
txConfirmed = new Subject<Block>();
live2Chart$ = new Subject<OptimizedMempoolStats>();

View File

@ -24,12 +24,14 @@ export class WebsocketService {
}
startSubscription() {
this.websocketSubject.next({'action': 'init'});
this.websocketSubject
.pipe(
retryWhen((errors: any) => errors
.pipe(
tap(() => {
this.goneOffline = true;
this.websocketSubject.next({'action': 'init'});
this.stateService.isOffline$.next(true);
}),
delay(5000),
@ -39,11 +41,17 @@ export class WebsocketService {
.subscribe((response: WebsocketResponse) => {
if (response.blocks && response.blocks.length) {
const blocks = response.blocks;
blocks.forEach((block: Block) => this.stateService.blocks$.next(block));
blocks.forEach((block: Block) => {
if (block.height > this.stateService.latestBlockHeight) {
this.stateService.latestBlockHeight = block.height;
this.stateService.blocks$.next(block);
}
});
}
if (response.block) {
if (this.stateService.latestBlockHeight < response.block.height) {
if (response.block.height > this.stateService.latestBlockHeight) {
this.stateService.latestBlockHeight = response.block.height;
this.stateService.blocks$.next(response.block);
}
@ -61,6 +69,17 @@ export class WebsocketService {
this.stateService.mempoolBlocks$.next(response['mempool-blocks']);
}
if (response['live-2h-chart']) {
this.stateService.live2Chart$.next(response['live-2h-chart']);
}
if (response.mempoolInfo) {
this.stateService.mempoolStats$.next({
memPoolInfo: response.mempoolInfo,
vBytesPerSecond: response.vBytesPerSecond,
});
}
if (this.goneOffline === true) {
this.goneOffline = false;
if (this.lastWant) {
@ -90,8 +109,7 @@ export class WebsocketService {
}
want(data: string[]) {
// @ts-ignore
this.websocketSubject.next({action: 'want', data});
this.websocketSubject.next({action: 'want', data: data});
this.lastWant = data;
}
}

View File

@ -4,11 +4,11 @@ $body-bg: #1d1f31;
$body-color: #fff;
$gray-800: #1d1f31;
$gray-700: #fff;
$gray-200: #10131f;
$nav-tabs-link-active-bg: #24273e;
$nav-tabs-link-active-bg: #11131f;
$primary: #2b89c7;
$secondary: #2d3348;
$link-color: #1bd8f4;
$link-decoration: none !default;
@ -20,12 +20,17 @@ $link-hover-decoration: underline !default;
html, body {
height: 100%;
overflow-x: hidden;
}
body {
background-color: #11131f;
}
.container {
position: relative;
}
:focus {
outline: none !important;
}