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'); const config = require('../../../mempool-config.json');
import { Transaction, Block } from '../../interfaces'; import { Transaction, Block, MempoolInfo } from '../../interfaces';
import * as request from 'request'; import * as request from 'request';
class ElectrsApi { class ElectrsApi {
@ -7,6 +7,27 @@ class ElectrsApi {
constructor() { 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'][]> { getRawMempool(): Promise<Transaction['txid'][]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request(config.ELECTRS_API_URL + '/mempool/txids', { json: true, timeout: 10000, forever: true }, (err, res, response) => { 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 { public getMempoolInfo(): MempoolInfo | undefined {
return this.mempoolInfo; return this.mempoolInfo;
} }

View File

@ -58,6 +58,7 @@ class Server {
} }
private async runMempoolIntervalFunctions() { private async runMempoolIntervalFunctions() {
await memPool.updateMemPoolInfo();
await blocks.updateBlocks(); await blocks.updateBlocks();
await memPool.updateMempool(); await memPool.updateMempool();
setTimeout(this.runMempoolIntervalFunctions.bind(this), config.ELECTRS_POLL_RATE_MS); setTimeout(this.runMempoolIntervalFunctions.bind(this), config.ELECTRS_POLL_RATE_MS);
@ -83,28 +84,33 @@ class Server {
const parsedMessage = JSON.parse(message); const parsedMessage = JSON.parse(message);
if (parsedMessage.action === 'want') { 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-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)) { if (parsedMessage && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) {
client['txId'] = parsedMessage.txId; client['txId'] = parsedMessage.txId;
} }
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) { } catch (e) {
console.log(e); console.log(e);
} }
}); });
const _blocks = blocks.getBlocks();
if (!_blocks) {
return;
}
client.send(JSON.stringify({
'blocks': _blocks,
'conversions': fiatConversion.getTickers()['BTCUSD'],
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
}));
}); });
statistics.setNewStatisticsEntryCallback((stats: Statistic) => { statistics.setNewStatisticsEntryCallback((stats: Statistic) => {
@ -113,11 +119,13 @@ class Server {
return; return;
} }
if (client['want-live-2h-chart']) { if (!client['want-live-2h-chart']) {
client.send(JSON.stringify({ return;
'live-2h-chart': stats
}));
} }
client.send(JSON.stringify({
'live-2h-chart': stats
}));
}); });
}); });
@ -127,6 +135,10 @@ class Server {
return; return;
} }
if (!client['want-blocks']) {
return;
}
if (client['txId'] && txIds.indexOf(client['txId']) > -1) { if (client['txId'] && txIds.indexOf(client['txId']) > -1) {
client['txId'] = null; client['txId'] = null;
client.send(JSON.stringify({ client.send(JSON.stringify({
@ -143,16 +155,29 @@ class Server {
memPool.setMempoolChangedCallback((newMempool: { [txid: string]: SimpleTransaction }) => { memPool.setMempoolChangedCallback((newMempool: { [txid: string]: SimpleTransaction }) => {
mempoolBlocks.updateMempoolBlocks(newMempool); 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) => { this.wss.clients.forEach((client: WebSocket) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
} }
client.send(JSON.stringify({ const response = {};
'mempool-blocks': pBlocks
})); 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 { AboutComponent } from './components/about/about.component';
import { TelevisionComponent } from './components/television/television.component'; import { TelevisionComponent } from './components/television/television.component';
import { StatisticsComponent } from './components/statistics/statistics.component'; import { StatisticsComponent } from './components/statistics/statistics.component';
import { ExplorerComponent } from './components/explorer/explorer.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -18,6 +19,10 @@ const routes: Routes = [
path: '', path: '',
component: StartComponent, component: StartComponent,
}, },
{
path: 'explorer',
component: ExplorerComponent,
},
{ {
path: 'graphs', path: 'graphs',
component: StatisticsComponent, component: StatisticsComponent,

View File

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

View File

@ -1,34 +1,54 @@
<div class="text-center"> <div class="container">
<img src="./assets/mempool-tube.png" width="63" height="63" /> <div class="text-center">
<br /><br /> <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>Mempool.Space is a realtime Bitcoin blockchain explorer and mempool visualizer.</p>
<p>Created by <a href="https://twitter.com/softbtc">@softbtc</a> <p>Created by <a href="https://twitter.com/softbtc">@softbtc</a>
<br />Hosted by <a href="https://twitter.com/wiz">@wiz</a> <br />Hosted by <a href="https://twitter.com/wiz">@wiz</a>
<br />Designed by <a href="https://instagram.com/markjborg">@markjborg</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> </div>
<br /> <h2>HTTP API</h2>
<h1>Donate</h1> <table class="table">
<img src="./assets/btc-qr-code-segwit.png" width="200" height="200" /> <tr>
<br /> <td style="width: 50%;">Fee API</td>
bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t <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> </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() { ngOnInit() {
this.websocketService.want([]); this.websocketService.want(['blocks']);
} }
} }

View File

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

View File

@ -1,6 +1,7 @@
<div class="blocks-container" *ngIf="blocks.length"> <div class="blocks-container" *ngIf="blocks.length">
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn" > <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"> <div class="block-height">
<a [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }">#{{ block.height }}</a> <a [routerLink]="['/block/', block.id]" [state]="{ data: { block: block } }">#{{ block.height }}</a>
</div> </div>

View File

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

View File

@ -1,20 +1,14 @@
<div *ngIf="isLoading" class="text-center"> <div *ngIf="isLoading" class="loading-block">
<h3>Loading blocks...</h3> <h3>Waiting for blocks...</h3>
<br> <br>
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>
</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="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-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks> <app-blockchain-blocks></app-blockchain-blocks>
<div id="divider" *ngIf="!isLoading"></div> <div id="divider" *ngIf="!isLoading"></div>
</div> </div>
</div> </div>

View File

@ -23,8 +23,6 @@
.position-container { .position-container {
position: absolute; position: absolute;
left: 50%; left: 50%;
/* top: calc(50% - 60px); */
top: 180px;
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
@ -48,3 +46,11 @@
z-index: 100; z-index: 100;
position: relative; 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 { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { WebsocketService } from 'src/app/services/websocket.service';
import { StateService } from 'src/app/services/state.service'; import { StateService } from 'src/app/services/state.service';
@Component({ @Component({
@ -11,6 +9,8 @@ import { StateService } from 'src/app/services/state.service';
styleUrls: ['./blockchain.component.scss'] styleUrls: ['./blockchain.component.scss']
}) })
export class BlockchainComponent implements OnInit, OnDestroy { export class BlockchainComponent implements OnInit, OnDestroy {
@Input() position: 'middle' | 'top' = 'middle';
txTrackingSubscription: Subscription; txTrackingSubscription: Subscription;
blocksSubscription: Subscription; blocksSubscription: Subscription;
@ -19,59 +19,10 @@ export class BlockchainComponent implements OnInit, OnDestroy {
isLoading = true; isLoading = true;
constructor( constructor(
private route: ActivatedRoute,
private websocketService: WebsocketService,
private stateService: StateService, private stateService: StateService,
) {} ) {}
ngOnInit() { 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$ this.blocksSubscription = this.stateService.blocks$
.pipe( .pipe(
take(1) take(1)
@ -81,6 +32,5 @@ export class BlockchainComponent implements OnInit, OnDestroy {
ngOnDestroy() { ngOnDestroy() {
this.blocksSubscription.unsubscribe(); 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"> <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"> <img src="./assets/clippy.svg" width="13">
</button> </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> </thead>
<tbody> <tbody>
<tr *ngFor="let block of blocks; let i= index; trackBy: trackByBlock"> <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 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.timestamp | timeSince : trigger }} ago</td>
<td>{{ block.tx_count }}</td> <td>{{ block.tx_count }}</td>
@ -34,10 +34,6 @@
</table> </table>
<div class="text-center"> <div class="text-center">
<ng-template [ngIf]="isLoading">
<div class="spinner-border"></div>
<br><br>
</ng-template>
<br> <br>
<button *ngIf="blocks.length" [disabled]="isLoading" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button> <button *ngIf="blocks.length" [disabled]="isLoading" type="button" class="btn btn-primary" (click)="loadMore()">Load more</button>
</div> </div>

View File

@ -9,7 +9,7 @@
<ng-container *ngIf="(transactions$ | async) as transactions"> <ng-container *ngIf="(transactions$ | async) as transactions">
<ng-template [ngIf]="!isLoading"> <ng-template [ngIf]="!isLoading">
<tr *ngFor="let transaction of transactions"> <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.value / 100000000 }} BTC</td>
<td>{{ transaction.vsize | vbytes: 2 }}</td> <td>{{ transaction.vsize | vbytes: 2 }}</td>
<td>{{ transaction.fee / transaction.vsize | number : '1.2-2'}} sats/vB</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}"> <div class="navbar-collapse collapse" id="navbarCollapse" [ngClass]="{'show': navCollapsed}">
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav mr-auto">
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}"> <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>
<li class="nav-item" routerLinkActive="active"> <li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/graphs" (click)="collapse()">Graphs</a> <a class="nav-link" routerLink="/graphs" (click)="collapse()">Graphs</a>

View File

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

View File

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

View File

@ -1,20 +1,3 @@
<app-blockchain></app-blockchain> <app-blockchain></app-blockchain>
<div class="box"> <app-footer></app-footer>
<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>

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({ @Component({
selector: 'app-start', selector: 'app-start',
templateUrl: './start.component.html', templateUrl: './start.component.html',
styleUrls: ['./start.component.scss'] styleUrls: ['./start.component.scss']
}) })
export class StartComponent { export class StartComponent implements OnInit {
view: 'blocks' | 'transactions' = 'blocks'; 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(() => { switchMap(() => {
this.spinnerLoading = true; this.spinnerLoading = true;
if (this.radioGroupForm.controls.dateSpan.value === '2h') { if (this.radioGroupForm.controls.dateSpan.value === '2h') {
this.websocketService.want(['live-2h-chart']); this.websocketService.want(['blocks', 'live-2h-chart']);
return this.apiService.list2HStatistics$(); return this.apiService.list2HStatistics$();
} }
this.websocketService.want([]); this.websocketService.want(['blocks']);
if (this.radioGroupForm.controls.dateSpan.value === '24h') { if (this.radioGroupForm.controls.dateSpan.value === '24h') {
return this.apiService.list24HStatistics$(); return this.apiService.list24HStatistics$();
} }

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<div class="container"> <div class="container">
<app-blockchain></app-blockchain> <app-blockchain position="top"></app-blockchain>
<div class="clearfix"></div> <div class="clearfix"></div>
@ -26,14 +26,16 @@
<td>Included in block</td> <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> <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> </tr>
<tr> <ng-template [ngIf]="tx.fee">
<td>Fees</td> <tr>
<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> <td>Fees</td>
</tr> <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>
<tr> </tr>
<td>Fees per vByte</td> <tr>
<td>{{ tx.fee / (tx.weight / 4) | number : '1.2-2' }} sat/vB</td> <td>Fees per vByte</td>
</tr> <td>{{ tx.fee / (tx.weight / 4) | number : '1.2-2' }} sat/vB</td>
</tr>
</ng-template>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -66,16 +68,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<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> </ng-template>
<br>
<h2>Inputs & Outputs</h2> <h2>Inputs & Outputs</h2>
<app-transactions-list [transactions]="[tx]" [transactionPage]="true"></app-transactions-list> <app-transactions-list [transactions]="[tx]" [transactionPage]="true"></app-transactions-list>

View File

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

View File

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

View File

@ -7,6 +7,10 @@ export interface WebsocketResponse {
txId?: string; txId?: string;
txConfirmed?: boolean; txConfirmed?: boolean;
historicalDate?: string; historicalDate?: string;
mempoolInfo?: MempoolInfo;
vBytesPerSecond?: number;
action?: string;
data?: string[];
} }
export interface MempoolBlock { export interface MempoolBlock {
@ -16,3 +20,17 @@ export interface MempoolBlock {
medianFee: number; medianFee: number;
feeRange: 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 { Injectable } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject } from 'rxjs'; import { ReplaySubject, BehaviorSubject, Subject } from 'rxjs';
import { Block } from '../interfaces/electrs.interface'; 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'; import { OptimizedMempoolStats } from '../interfaces/node-api.interface';
@Injectable({ @Injectable({
@ -11,6 +11,7 @@ export class StateService {
latestBlockHeight = 0; latestBlockHeight = 0;
blocks$ = new ReplaySubject<Block>(8); blocks$ = new ReplaySubject<Block>(8);
conversions$ = new ReplaySubject<any>(1); conversions$ = new ReplaySubject<any>(1);
mempoolStats$ = new ReplaySubject<MemPoolState>();
mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1); mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1);
txConfirmed = new Subject<Block>(); txConfirmed = new Subject<Block>();
live2Chart$ = new Subject<OptimizedMempoolStats>(); live2Chart$ = new Subject<OptimizedMempoolStats>();

View File

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

View File

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