Merge pull request #2734 from mempool/mononaut/block-page-fixes

Fix bugs on the new block page
This commit is contained in:
wiz 2022-11-25 19:57:14 +09:00 committed by GitHub
commit f73dc59f49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 423 additions and 648 deletions

View file

@ -10,9 +10,9 @@ const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first
class Audit { class Audit {
auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended }) auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended })
: { censored: string[], added: string[], score: number } { : { censored: string[], added: string[], fresh: string[], score: number } {
if (!projectedBlocks?.[0]?.transactionIds || !mempool) { if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
return { censored: [], added: [], score: 0 }; return { censored: [], added: [], fresh: [], score: 0 };
} }
const matches: string[] = []; // present in both mined block and template const matches: string[] = []; // present in both mined block and template
@ -83,7 +83,17 @@ class Audit {
} else { } else {
if (!isDisplaced[tx.txid]) { if (!isDisplaced[tx.txid]) {
added.push(tx.txid); added.push(tx.txid);
} else {
} }
let blockIndex = -1;
let index = -1;
projectedBlocks.forEach((block, bi) => {
const i = block.transactionIds.indexOf(tx.txid);
if (i >= 0) {
blockIndex = bi;
index = i;
}
});
overflowWeight += tx.weight; overflowWeight += tx.weight;
} }
totalWeight += tx.weight; totalWeight += tx.weight;
@ -119,48 +129,10 @@ class Audit {
return { return {
censored: Object.keys(isCensored), censored: Object.keys(isCensored),
added, added,
fresh,
score score
}; };
} }
public async $getBlockAuditScores(fromHeight?: number, limit: number = 15): Promise<AuditScore[]> {
let currentHeight = fromHeight !== undefined ? fromHeight : await blocksRepository.$mostRecentBlockHeight();
const returnScores: AuditScore[] = [];
if (currentHeight < 0) {
return returnScores;
}
for (let i = 0; i < limit && currentHeight >= 0; i++) {
const block = blocks.getBlocks().find((b) => b.height === currentHeight);
if (block?.extras?.matchRate != null) {
returnScores.push({
hash: block.id,
matchRate: block.extras.matchRate
});
} else {
let currentHash;
if (!currentHash && Common.indexingEnabled()) {
const dbBlock = await blocksRepository.$getBlockByHeight(currentHeight);
if (dbBlock && dbBlock['id']) {
currentHash = dbBlock['id'];
}
}
if (!currentHash) {
currentHash = await bitcoinApi.$getBlockHash(currentHeight);
}
if (currentHash) {
const auditScore = await blocksAuditsRepository.$getBlockAuditScore(currentHash);
returnScores.push({
hash: currentHash,
matchRate: auditScore?.matchRate
});
}
}
currentHeight--;
}
return returnScores;
}
} }
export default new Audit(); export default new Audit();

View file

@ -89,6 +89,7 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary)
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
; ;
@ -324,6 +325,16 @@ class BitcoinRoutes {
} }
} }
private async getStrippedBlockTransactions(req: Request, res: Response) {
try {
const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash);
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transactions);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getBlock(req: Request, res: Response) { private async getBlock(req: Request, res: Response) {
try { try {
const block = await blocks.$getBlock(req.params.hash); const block = await blocks.$getBlock(req.params.hash);
@ -356,9 +367,9 @@ class BitcoinRoutes {
} }
} }
private async getStrippedBlockTransactions(req: Request, res: Response) { private async getBlockAuditSummary(req: Request, res: Response) {
try { try {
const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); const transactions = await blocks.$getBlockAuditSummary(req.params.hash);
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {

View file

@ -590,7 +590,7 @@ class Blocks {
if (skipMemoryCache === false) { if (skipMemoryCache === false) {
// Check the memory cache // Check the memory cache
const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash);
if (cachedSummary) { if (cachedSummary?.transactions?.length) {
return cachedSummary.transactions; return cachedSummary.transactions;
} }
} }
@ -598,7 +598,7 @@ class Blocks {
// Check if it's indexed in db // Check if it's indexed in db
if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) { if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) {
const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash); const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash);
if (indexedSummary !== undefined) { if (indexedSummary !== undefined && indexedSummary?.transactions?.length) {
return indexedSummary.transactions; return indexedSummary.transactions;
} }
} }
@ -651,6 +651,19 @@ class Blocks {
return returnBlocks; return returnBlocks;
} }
public async $getBlockAuditSummary(hash: string): Promise<any> {
let summary = await BlocksAuditsRepository.$getBlockAudit(hash);
// fallback to non-audited transaction summary
if (!summary?.transactions?.length) {
const strippedTransactions = await this.$getStrippedBlockTransactions(hash);
summary = {
transactions: strippedTransactions
};
}
return summary;
}
public getLastDifficultyAdjustmentTime(): number { public getLastDifficultyAdjustmentTime(): number {
return this.lastDifficultyAdjustmentTime; return this.lastDifficultyAdjustmentTime;
} }

View file

@ -4,7 +4,7 @@ import logger from '../logger';
import { Common } from './common'; import { Common } from './common';
class DatabaseMigration { class DatabaseMigration {
private static currentVersion = 44; private static currentVersion = 45;
private queryTimeout = 900_000; private queryTimeout = 900_000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
private uniqueLogs: string[] = []; private uniqueLogs: string[] = [];
@ -365,6 +365,10 @@ class DatabaseMigration {
await this.$executeQuery('TRUNCATE TABLE `blocks_audits`'); await this.$executeQuery('TRUNCATE TABLE `blocks_audits`');
await this.$executeQuery('UPDATE blocks_summaries SET template = NULL'); await this.$executeQuery('UPDATE blocks_summaries SET template = NULL');
} }
if (databaseSchemaVersion < 45 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"');
}
} }
/** /**

View file

@ -283,9 +283,12 @@ class MiningRoutes {
private async $getBlockAuditScores(req: Request, res: Response) { private async $getBlockAuditScores(req: Request, res: Response) {
try { try {
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); let height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
if (height == null) {
height = await BlocksRepository.$mostRecentBlockHeight();
}
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(await audits.$getBlockAuditScores(height, 15)); res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15));
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }

View file

@ -428,7 +428,7 @@ class WebsocketHandler {
if (Common.indexingEnabled() && memPool.isInSync()) { if (Common.indexingEnabled() && memPool.isInSync()) {
const projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); const projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool); const { censored, added, fresh, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool);
matchRate = Math.round(score * 100 * 100) / 100; matchRate = Math.round(score * 100 * 100) / 100;
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => { const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
@ -454,6 +454,7 @@ class WebsocketHandler {
hash: block.id, hash: block.id,
addedTxs: added, addedTxs: added,
missingTxs: censored, missingTxs: censored,
freshTxs: fresh,
matchRate: matchRate, matchRate: matchRate,
}); });

View file

@ -28,6 +28,7 @@ export interface BlockAudit {
height: number, height: number,
hash: string, hash: string,
missingTxs: string[], missingTxs: string[],
freshTxs: string[],
addedTxs: string[], addedTxs: string[],
matchRate: number, matchRate: number,
} }

View file

@ -1,3 +1,4 @@
import blocks from '../api/blocks';
import DB from '../database'; import DB from '../database';
import logger from '../logger'; import logger from '../logger';
import { BlockAudit, AuditScore } from '../mempool.interfaces'; import { BlockAudit, AuditScore } from '../mempool.interfaces';
@ -5,9 +6,9 @@ import { BlockAudit, AuditScore } from '../mempool.interfaces';
class BlocksAuditRepositories { class BlocksAuditRepositories {
public async $saveAudit(audit: BlockAudit): Promise<void> { public async $saveAudit(audit: BlockAudit): Promise<void> {
try { try {
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, match_rate) await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, match_rate)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs), VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
JSON.stringify(audit.addedTxs), audit.matchRate]); JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), audit.matchRate]);
} catch (e: any) { } catch (e: any) {
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`); logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`);
@ -51,7 +52,7 @@ class BlocksAuditRepositories {
const [rows]: any[] = await DB.query( const [rows]: any[] = await DB.query(
`SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size, `SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
blocks.weight, blocks.tx_count, blocks.weight, blocks.tx_count,
transactions, template, missing_txs as missingTxs, added_txs as addedTxs, match_rate as matchRate transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, match_rate as matchRate
FROM blocks_audits FROM blocks_audits
JOIN blocks ON blocks.hash = blocks_audits.hash JOIN blocks ON blocks.hash = blocks_audits.hash
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
@ -61,11 +62,15 @@ class BlocksAuditRepositories {
if (rows.length) { if (rows.length) {
rows[0].missingTxs = JSON.parse(rows[0].missingTxs); rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
rows[0].addedTxs = JSON.parse(rows[0].addedTxs); rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
rows[0].freshTxs = JSON.parse(rows[0].freshTxs);
rows[0].transactions = JSON.parse(rows[0].transactions); rows[0].transactions = JSON.parse(rows[0].transactions);
rows[0].template = JSON.parse(rows[0].template); rows[0].template = JSON.parse(rows[0].template);
}
return rows[0]; if (rows[0].transactions.length) {
return rows[0];
}
}
return null;
} catch (e: any) { } catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e; throw e;
@ -85,6 +90,20 @@ class BlocksAuditRepositories {
throw e; throw e;
} }
} }
public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise<AuditScore[]> {
try {
const [rows]: any[] = await DB.query(
`SELECT hash, match_rate as matchRate
FROM blocks_audits
WHERE blocks_audits.height BETWEEN ? AND ?
`, [minHeight, maxHeight]);
return rows;
} catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
} }
export default new BlocksAuditRepositories(); export default new BlocksAuditRepositories();

View file

@ -4,7 +4,6 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'
import { StartComponent } from './components/start/start.component'; import { StartComponent } from './components/start/start.component';
import { TransactionComponent } from './components/transaction/transaction.component'; import { TransactionComponent } from './components/transaction/transaction.component';
import { BlockComponent } from './components/block/block.component'; import { BlockComponent } from './components/block/block.component';
import { BlockAuditComponent } from './components/block-audit/block-audit.component';
import { AddressComponent } from './components/address/address.component'; import { AddressComponent } from './components/address/address.component';
import { MasterPageComponent } from './components/master-page/master-page.component'; import { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component'; import { AboutComponent } from './components/about/about.component';
@ -103,16 +102,6 @@ let routes: Routes = [
}, },
], ],
}, },
{
path: 'block-audit',
data: { networkSpecific: true },
children: [
{
path: ':id',
component: BlockAuditComponent,
},
],
},
{ {
path: 'docs', path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule), loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule),
@ -219,16 +208,6 @@ let routes: Routes = [
}, },
], ],
}, },
{
path: 'block-audit',
data: { networkSpecific: true },
children: [
{
path: ':id',
component: BlockAuditComponent,
},
],
},
{ {
path: 'docs', path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
@ -331,16 +310,6 @@ let routes: Routes = [
}, },
], ],
}, },
{
path: 'block-audit',
data: { networkSpecific: true },
children: [
{
path: ':id',
component: BlockAuditComponent
},
],
},
{ {
path: 'docs', path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)

View file

@ -1,172 +0,0 @@
<div class="container-xl" (window:resize)="onResize($event)">
<div class="title-block" id="block">
<h1>
<span class="next-previous-blocks">
<span i18n="shared.block-audit-title">Block Audit</span>
&nbsp;
<a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a>
&nbsp;
</span>
</h1>
<div class="grow"></div>
<button [routerLink]="['/block/' | relativeUrl, blockHash]" class="btn btn-sm">&#10005;</button>
</div>
<div *ngIf="!error && !isLoading">
<!-- OVERVIEW -->
<div class="box mb-3">
<div class="row">
<!-- LEFT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width" i18n="block.hash">Hash</td>
<td><a [routerLink]="['/block/' | relativeUrl, blockHash]" title="{{ blockHash }}">{{ blockHash | shortenString : 13 }}</a>
<app-clipboard class="d-none d-sm-inline-block" [text]="blockHash"></app-clipboard>
</td>
</tr>
<tr>
<td i18n="blockAudit.timestamp">Timestamp</td>
<td>
&lrm;{{ blockAudit.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="lg-inline">
<i class="symbol">(<app-time-since [time]="blockAudit.timestamp" [fastRender]="true">
</app-time-since>)</i>
</div>
</td>
</tr>
<tr>
<td i18n="blockAudit.size">Size</td>
<td [innerHTML]="'&lrm;' + (blockAudit.size | bytes: 2)"></td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (blockAudit.weight | wuBytes: 2)"></td>
</tr>
</tbody>
</table>
</div>
<!-- RIGHT COLUMN -->
<div class="col-sm" *ngIf="blockAudit">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width" i18n="shared.transaction-count">Transactions</td>
<td>{{ blockAudit.tx_count }}</td>
</tr>
<tr>
<td i18n="block.health">Block health</td>
<td>{{ blockAudit.matchRate }}%</td>
</tr>
<tr>
<td i18n="block.missing-txs">Removed txs</td>
<td>{{ blockAudit.missingTxs.length }}</td>
</tr>
<tr>
<td i18n="block.added-txs">Added txs</td>
<td>{{ blockAudit.addedTxs.length }}</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- row -->
</div> <!-- box -->
<!-- ADDED vs MISSING button -->
<div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile">
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
</div>
<ng-template [ngIf]="!error && isLoading">
<!-- OVERVIEW -->
<div class="box mb-3">
<div class="row">
<!-- LEFT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
</tbody>
</table>
</div>
<!-- RIGHT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
</tbody>
</table>
</div>
</div> <!-- row -->
</div> <!-- box -->
<!-- ADDED vs MISSING button -->
<div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile">
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div *ngIf="error && error.status === 404; else generalError" class="text-center">
<br>
<b i18n="error.audit-unavailable">audit unavailable</b>
<br><br>
<i>{{ error.error }}</i>
<br>
<br>
</div>
<ng-template #generalError>
<div class="text-center">
<br>
<span i18n="error.general-loading-data">Error loading data.</span>
<br><br>
<i>{{ error }}</i>
<br>
<br>
</div>
</ng-template>
</ng-template>
<!-- VISUALIZATIONS -->
<div class="box" *ngIf="!error">
<div class="row">
<!-- MISSING TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled">
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3>
<app-block-overview-graph #blockGraphProjected [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx"
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)"></app-block-overview-graph>
</div>
<!-- ADDED TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled && !isMobile">
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3>
<app-block-overview-graph #blockGraphActual [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx"
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)"></app-block-overview-graph>
</div>
</div> <!-- row -->
</div> <!-- box -->
</div>

View file

@ -1,44 +0,0 @@
.title-block {
border-top: none;
}
.table {
tr td {
&:last-child {
text-align: right;
@media (min-width: 768px) {
text-align: left;
}
}
}
}
.block-tx-title {
display: flex;
justify-content: space-between;
flex-direction: column;
position: relative;
@media (min-width: 550px) {
flex-direction: row;
}
h2 {
line-height: 1;
margin: 0;
position: relative;
padding-bottom: 10px;
@media (min-width: 550px) {
padding-bottom: 0px;
align-self: end;
}
}
}
.menu-button {
@media (min-width: 768px) {
max-width: 150px;
}
}
.block-subtitle {
text-align: center;
}

View file

@ -1,229 +0,0 @@
import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } from '@angular/core';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { Subscription, combineLatest, of } from 'rxjs';
import { map, switchMap, startWith, catchError, filter } from 'rxjs/operators';
import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { StateService } from '../../services/state.service';
import { detectWebGL } from '../../shared/graphs.utils';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component';
@Component({
selector: 'app-block-audit',
templateUrl: './block-audit.component.html',
styleUrls: ['./block-audit.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
})
export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy {
blockAudit: BlockAudit = undefined;
transactions: string[];
auditSubscription: Subscription;
urlFragmentSubscription: Subscription;
paginationMaxSize: number;
page = 1;
itemsPerPage: number;
mode: 'projected' | 'actual' = 'projected';
error: any;
isLoading = true;
webGlEnabled = true;
isMobile = window.innerWidth <= 767.98;
hoverTx: string;
childChangeSubscription: Subscription;
blockHash: string;
numMissing: number = 0;
numUnexpected: number = 0;
@ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>;
@ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>;
constructor(
private route: ActivatedRoute,
public stateService: StateService,
private router: Router,
private apiService: ApiService,
private electrsApiService: ElectrsApiService,
) {
this.webGlEnabled = detectWebGL();
}
ngOnDestroy() {
this.childChangeSubscription.unsubscribe();
this.urlFragmentSubscription.unsubscribe();
}
ngOnInit(): void {
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
if (fragment === 'actual') {
this.mode = 'actual';
} else {
this.mode = 'projected'
}
this.setupBlockGraphs();
});
this.auditSubscription = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const blockHash = params.get('id') || null;
if (!blockHash) {
return null;
}
let isBlockHeight = false;
if (/^[0-9]+$/.test(blockHash)) {
isBlockHeight = true;
} else {
this.blockHash = blockHash;
}
if (isBlockHeight) {
return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockHash, 10))
.pipe(
switchMap((hash: string) => {
if (hash) {
this.blockHash = hash;
return this.apiService.getBlockAudit$(this.blockHash)
} else {
return null;
}
}),
catchError((err) => {
this.error = err;
return of(null);
}),
);
}
return this.apiService.getBlockAudit$(this.blockHash)
}),
filter((response) => response != null),
map((response) => {
const blockAudit = response.body;
const inTemplate = {};
const inBlock = {};
const isAdded = {};
const isCensored = {};
const isMissing = {};
const isSelected = {};
this.numMissing = 0;
this.numUnexpected = 0;
for (const tx of blockAudit.template) {
inTemplate[tx.txid] = true;
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
for (const txid of blockAudit.addedTxs) {
isAdded[txid] = true;
}
for (const txid of blockAudit.missingTxs) {
isCensored[txid] = true;
}
// set transaction statuses
for (const tx of blockAudit.template) {
if (isCensored[tx.txid]) {
tx.status = 'censored';
} else if (inBlock[tx.txid]) {
tx.status = 'found';
} else {
tx.status = 'missing';
isMissing[tx.txid] = true;
this.numMissing++;
}
}
for (const [index, tx] of blockAudit.transactions.entries()) {
if (index === 0) {
tx.status = null;
} else if (isAdded[tx.txid]) {
tx.status = 'added';
} else if (inTemplate[tx.txid]) {
tx.status = 'found';
} else {
tx.status = 'selected';
isSelected[tx.txid] = true;
this.numUnexpected++;
}
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
return blockAudit;
}),
catchError((err) => {
console.log(err);
this.error = err;
this.isLoading = false;
return of(null);
}),
).subscribe((blockAudit) => {
this.blockAudit = blockAudit;
this.setupBlockGraphs();
this.isLoading = false;
});
}
ngAfterViewInit() {
this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => {
this.setupBlockGraphs();
})
}
setupBlockGraphs() {
if (this.blockAudit) {
this.blockGraphProjected.forEach(graph => {
graph.destroy();
if (this.isMobile && this.mode === 'actual') {
graph.setup(this.blockAudit.transactions);
} else {
graph.setup(this.blockAudit.template);
}
})
this.blockGraphActual.forEach(graph => {
graph.destroy();
graph.setup(this.blockAudit.transactions);
})
}
}
onResize(event: any) {
const isMobile = event.target.innerWidth <= 767.98;
const changed = isMobile !== this.isMobile;
this.isMobile = isMobile;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
if (changed) {
this.changeMode(this.mode);
}
}
changeMode(mode: 'projected' | 'actual') {
this.router.navigate([], { fragment: mode });
}
onTxClick(event: TransactionStripped): void {
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
this.router.navigate([url]);
}
onTxHover(txid: string): void {
if (txid && txid.length) {
this.hoverTx = txid;
} else {
this.hoverTx = null;
}
}
}

View file

@ -1,7 +1,8 @@
<div class="block-overview-graph"> <div class="block-overview-graph">
<canvas class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas> <canvas class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas>
<div class="loader-wrapper" [class.hidden]="!isLoading || disableSpinner"> <div class="loader-wrapper" [class.hidden]="(!isLoading || disableSpinner) && !unavailable">
<div class="spinner-border ml-3 loading" role="status"></div> <div *ngIf="isLoading" class="spinner-border ml-3 loading" role="status"></div>
<div *ngIf="!isLoading && unavailable" class="ml-3" i18n="block.not-available">not available</div>
</div> </div>
<app-block-overview-tooltip <app-block-overview-tooltip

View file

@ -19,6 +19,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
@Input() flip = true; @Input() flip = true;
@Input() disableSpinner = false; @Input() disableSpinner = false;
@Input() mirrorTxid: string | void; @Input() mirrorTxid: string | void;
@Input() unavailable: boolean = false;
@Output() txClickEvent = new EventEmitter<TransactionStripped>(); @Output() txClickEvent = new EventEmitter<TransactionStripped>();
@Output() txHoverEvent = new EventEmitter<string>(); @Output() txHoverEvent = new EventEmitter<string>();
@Output() readyEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter();

View file

@ -3,12 +3,13 @@ import { FastVertexArray } from './fast-vertex-array';
import { TransactionStripped } from '../../interfaces/websocket.interface'; import { TransactionStripped } from '../../interfaces/websocket.interface';
import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types'; import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types';
import { feeLevels, mempoolFeeColors } from '../../app.constants'; import { feeLevels, mempoolFeeColors } from '../../app.constants';
import BlockScene from './block-scene';
const hoverTransitionTime = 300; const hoverTransitionTime = 300;
const defaultHoverColor = hexToColor('1bd8f4'); const defaultHoverColor = hexToColor('1bd8f4');
const feeColors = mempoolFeeColors.map(hexToColor); const feeColors = mempoolFeeColors.map(hexToColor);
const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3)); const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9));
const auditColors = { const auditColors = {
censored: hexToColor('f344df'), censored: hexToColor('f344df'),
missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7),
@ -34,7 +35,8 @@ export default class TxView implements TransactionStripped {
vsize: number; vsize: number;
value: number; value: number;
feerate: number; feerate: number;
status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
context?: 'projected' | 'actual';
initialised: boolean; initialised: boolean;
vertexArray: FastVertexArray; vertexArray: FastVertexArray;
@ -48,6 +50,7 @@ export default class TxView implements TransactionStripped {
dirty: boolean; dirty: boolean;
constructor(tx: TransactionStripped, vertexArray: FastVertexArray) { constructor(tx: TransactionStripped, vertexArray: FastVertexArray) {
this.context = tx.context;
this.txid = tx.txid; this.txid = tx.txid;
this.fee = tx.fee; this.fee = tx.fee;
this.vsize = tx.vsize; this.vsize = tx.vsize;
@ -159,12 +162,18 @@ export default class TxView implements TransactionStripped {
return auditColors.censored; return auditColors.censored;
case 'missing': case 'missing':
return auditColors.missing; return auditColors.missing;
case 'fresh':
return auditColors.missing;
case 'added': case 'added':
return auditColors.added; return auditColors.added;
case 'selected': case 'selected':
return auditColors.selected; return auditColors.selected;
case 'found': case 'found':
return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; if (this.context === 'projected') {
return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1];
} else {
return feeLevelColor;
}
default: default:
return feeLevelColor; return feeLevelColor;
} }

View file

@ -37,9 +37,10 @@
<ng-container [ngSwitch]="tx?.status"> <ng-container [ngSwitch]="tx?.status">
<td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td> <td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td>
<td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td> <td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td>
<td *ngSwitchCase="'missing'" i18n="transaction.audit.omitted">omitted</td> <td *ngSwitchCase="'missing'" i18n="transaction.audit.marginal">marginal fee rate</td>
<td *ngSwitchCase="'fresh'" i18n="transaction.audit.recently-broadcast">recently broadcast</td>
<td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td> <td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td>
<td *ngSwitchCase="'selected'" i18n="transaction.audit.extra">extra</td> <td *ngSwitchCase="'selected'" i18n="transaction.audit.marginal">marginal fee rate</td>
</ng-container> </ng-container>
</tr> </tr>
</tbody> </tbody>

View file

@ -54,7 +54,19 @@
<td i18n="block.weight">Weight</td> <td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (block.weight | wuBytes: 2)"></td> <td [innerHTML]="'&lrm;' + (block.weight | wuBytes: 2)"></td>
</tr> </tr>
<ng-template [ngIf]="webGlEnabled"> <tr *ngIf="auditEnabled">
<td i18n="block.health">Block health</td>
<td>
<span *ngIf="blockAudit?.matchRate != null">{{ blockAudit.matchRate }}%</span>
<span *ngIf="blockAudit?.matchRate === null" i18n="unknown">Unknown</span>
</td>
</tr>
<ng-container *ngIf="!indexingAvailable && webGlEnabled">
<tr *ngIf="isMobile && auditEnabled"></tr>
<tr>
<td i18n="mempool-block.fee-span">Fee span</td>
<td><span>{{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td>
</tr>
<tr *ngIf="block?.extras?.medianFee != undefined"> <tr *ngIf="block?.extras?.medianFee != undefined">
<td class="td-width" i18n="block.median-fee">Median fee</td> <td class="td-width" i18n="block.median-fee">Median fee</td>
<td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td>
@ -98,26 +110,19 @@
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td i18n="block.miner">Miner</td> <td i18n="block.miner">Miner</td>
<td *ngIf="stateService.env.MINING_DASHBOARD"> <td *ngIf="stateService.env.MINING_DASHBOARD">
<a [attr.data-cy]="'block-details-miner-badge'" placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" <a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge"
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
{{ block.extras.pool.name }} {{ block.extras.pool.name }}
</a> </a>
</td> </td>
<td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'"> <td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
<span [attr.data-cy]="'block-details-miner-badge'" placement="bottom" class="badge" <span placement="bottom" class="badge"
[class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'"> [class]="block.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
{{ block.extras.pool.name }} {{ block.extras.pool.name }}
</span> </span>
</td> </td>
</tr> </tr>
<tr *ngIf="indexingAvailable"> </ng-container>
<td i18n="block.health">Block health</td>
<td>
<a *ngIf="block.extras?.matchRate != null" [routerLink]="['/block-audit/' | relativeUrl, blockHash]">{{ block.extras.matchRate }}%</a>
<span *ngIf="block.extras?.matchRate === null" i18n="unknown">Unknown</span>
</td>
</tr>
</ng-template>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -138,7 +143,11 @@
<tr> <tr>
<td colspan="2"><span class="skeleton-loader"></span></td> <td colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
<ng-template [ngIf]="webGlEnabled"> <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
<ng-container *ngIf="!indexingAvailable && webGlEnabled">
<tr *ngIf="isMobile && !auditEnabled"></tr>
<tr> <tr>
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
@ -148,17 +157,25 @@
<tr> <tr>
<td colspan="2"><span class="skeleton-loader"></span></td> <td colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
<tr> <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td colspan="2"><span class="skeleton-loader"></span></td> <td colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
</ng-template> <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</ng-container>
</tbody> </tbody>
</table> </table>
</div> </div>
</ng-template> </ng-template>
<div class="col-sm" *ngIf="!webGlEnabled"> <div class="col-sm">
<table class="table table-borderless table-striped" *ngIf="!isLoadingBlock"> <table class="table table-borderless table-striped" *ngIf="!isLoadingBlock && (indexingAvailable || !webGlEnabled)">
<tbody> <tbody>
<tr *ngIf="isMobile && auditEnabled"></tr>
<tr>
<td i18n="mempool-block.fee-span">Fee span</td>
<td><span>{{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td>
</tr>
<tr *ngIf="block?.extras?.medianFee != undefined"> <tr *ngIf="block?.extras?.medianFee != undefined">
<td class="td-width" i18n="block.median-fee">Median fee</td> <td class="td-width" i18n="block.median-fee">Median fee</td>
<td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td> <td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <span class="fiat"><app-fiat [value]="block?.extras?.medianFee * 140" digitsInfo="1.2-2" i18n-ngbTooltip="Transaction fee tooltip" ngbTooltip="Based on average native segwit transaction of 140 vBytes" placement="bottom"></app-fiat></span></td>
@ -216,8 +233,9 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<table class="table table-borderless table-striped" *ngIf="isLoadingBlock"> <table class="table table-borderless table-striped" *ngIf="isLoadingBlock && (indexingAvailable || !webGlEnabled)">
<tbody> <tbody>
<tr *ngIf="isMobile && !auditEnabled"></tr>
<tr> <tr>
<td class="td-width" colspan="2"><span class="skeleton-loader"></span></td> <td class="td-width" colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
@ -230,22 +248,54 @@
<tr> <tr>
<td colspan="2"><span class="skeleton-loader"></span></td> <td colspan="2"><span class="skeleton-loader"></span></td>
</tr> </tr>
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody> </tbody>
</table> </table>
</div> <div class="col-sm chart-container" *ngIf="webGlEnabled && !indexingAvailable">
<div class="col-sm chart-container" *ngIf="webGlEnabled"> <app-block-overview-graph
<app-block-overview-graph #blockGraphActual
#blockGraph [isLoading]="isLoadingOverview"
[isLoading]="isLoadingOverview" [resolution]="75"
[resolution]="75" [blockLimit]="stateService.blockVSize"
[blockLimit]="stateService.blockVSize" [orientation]="'top'"
[orientation]="'top'" [flip]="false"
[flip]="false" (txClickEvent)="onTxClick($event)"
(txClickEvent)="onTxClick($event)" ></app-block-overview-graph>
></app-block-overview-graph> </div>
</div> </div>
</div> </div>
</div> </div>
<span id="overview"></span>
<br>
<!-- VISUALIZATIONS -->
<div class="box" *ngIf="!error && webGlEnabled && indexingAvailable">
<div class="nav nav-tabs" *ngIf="isMobile && auditEnabled">
<a class="nav-link" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="nav-link" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
<div class="row">
<div class="col-sm">
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3>
<app-block-overview-graph #blockGraphProjected [isLoading]="isLoadingOverview" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx"
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="!isMobile && !auditEnabled"></app-block-overview-graph>
</div>
<div class="col-sm" *ngIf="!isMobile">
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3>
<app-block-overview-graph #blockGraphActual [isLoading]="isLoadingOverview" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx" mode="mined"
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="isMobile && !auditEnabled"></app-block-overview-graph>
</div>
</div>
</div>
<ng-template [ngIf]="!isLoadingBlock && !error"> <ng-template [ngIf]="!isLoadingBlock && !error">
<div [hidden]="!showDetails" id="details"> <div [hidden]="!showDetails" id="details">
<br> <br>
@ -273,6 +323,7 @@
<div class="col-sm" *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> <div class="col-sm" *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<table class="table table-borderless table-striped"> <table class="table table-borderless table-striped">
<tbody> <tbody>
<tr *ngIf="isMobile"></tr>
<tr> <tr>
<td class="td-width" i18n="block.difficulty">Difficulty</td> <td class="td-width" i18n="block.difficulty">Difficulty</td>
<td>{{ block.difficulty }}</td> <td>{{ block.difficulty }}</td>

View file

@ -171,3 +171,35 @@ h1 {
margin: auto; margin: auto;
} }
} }
.menu-button {
@media (min-width: 768px) {
max-width: 150px;
}
}
.block-subtitle {
text-align: center;
}
.nav-tabs {
border-color: white;
border-width: 1px;
}
.nav-tabs .nav-link {
background: inherit;
border-width: 1px;
border-bottom: none;
border-color: transparent;
margin-bottom: -1px;
cursor: pointer;
&.active {
background: #24273e;
}
&.active, &:hover {
border-color: white;
}
}

View file

@ -1,15 +1,15 @@
import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/core';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise, filter } from 'rxjs/operators';
import { Transaction, Vout } from '../../interfaces/electrs.interface'; import { Transaction, Vout } from '../../interfaces/electrs.interface';
import { Observable, of, Subscription, asyncScheduler, EMPTY, Subject } from 'rxjs'; import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest } from 'rxjs';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '../../services/websocket.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component';
import { detectWebGL } from '../../shared/graphs.utils'; import { detectWebGL } from '../../shared/graphs.utils';
@ -17,11 +17,20 @@ import { detectWebGL } from '../../shared/graphs.utils';
@Component({ @Component({
selector: 'app-block', selector: 'app-block',
templateUrl: './block.component.html', templateUrl: './block.component.html',
styleUrls: ['./block.component.scss'] styleUrls: ['./block.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
}) })
export class BlockComponent implements OnInit, OnDestroy { export class BlockComponent implements OnInit, OnDestroy {
network = ''; network = '';
block: BlockExtended; block: BlockExtended;
blockAudit: BlockAudit = undefined;
blockHeight: number; blockHeight: number;
lastBlockHeight: number; lastBlockHeight: number;
nextBlockHeight: number; nextBlockHeight: number;
@ -48,9 +57,16 @@ export class BlockComponent implements OnInit, OnDestroy {
overviewError: any = null; overviewError: any = null;
webGlEnabled = true; webGlEnabled = true;
indexingAvailable = false; indexingAvailable = false;
auditEnabled = true;
isMobile = window.innerWidth <= 767.98;
hoverTx: string;
numMissing: number = 0;
numUnexpected: number = 0;
mode: 'projected' | 'actual' = 'projected';
transactionSubscription: Subscription; transactionSubscription: Subscription;
overviewSubscription: Subscription; overviewSubscription: Subscription;
auditSubscription: Subscription;
keyNavigationSubscription: Subscription; keyNavigationSubscription: Subscription;
blocksSubscription: Subscription; blocksSubscription: Subscription;
networkChangedSubscription: Subscription; networkChangedSubscription: Subscription;
@ -60,10 +76,10 @@ export class BlockComponent implements OnInit, OnDestroy {
nextBlockTxListSubscription: Subscription = undefined; nextBlockTxListSubscription: Subscription = undefined;
timeLtrSubscription: Subscription; timeLtrSubscription: Subscription;
timeLtr: boolean; timeLtr: boolean;
fetchAuditScore$ = new Subject<string>(); childChangeSubscription: Subscription;
fetchAuditScoreSubscription: Subscription;
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>;
@ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@ -89,8 +105,8 @@ export class BlockComponent implements OnInit, OnDestroy {
this.timeLtr = !!ltr; this.timeLtr = !!ltr;
}); });
this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && this.stateService.env.MINING_DASHBOARD === true);
this.stateService.env.MINING_DASHBOARD === true); this.auditEnabled = this.indexingAvailable;
this.txsLoadingStatus$ = this.route.paramMap this.txsLoadingStatus$ = this.route.paramMap
.pipe( .pipe(
@ -107,30 +123,12 @@ export class BlockComponent implements OnInit, OnDestroy {
if (block.id === this.blockHash) { if (block.id === this.blockHash) {
this.block = block; this.block = block;
if (this.block.id && this.block?.extras?.matchRate == null) {
this.fetchAuditScore$.next(this.block.id);
}
if (block?.extras?.reward != undefined) { if (block?.extras?.reward != undefined) {
this.fees = block.extras.reward / 100000000 - this.blockSubsidy; this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
} }
} }
}); });
if (this.indexingAvailable) {
this.fetchAuditScoreSubscription = this.fetchAuditScore$
.pipe(
switchMap((hash) => this.apiService.getBlockAuditScore$(hash)),
catchError(() => EMPTY),
)
.subscribe((score) => {
if (score && score.hash === this.block.id) {
this.block.extras.matchRate = score.matchRate || null;
} else {
this.block.extras.matchRate = null;
}
});
}
const block$ = this.route.paramMap.pipe( const block$ = this.route.paramMap.pipe(
switchMap((params: ParamMap) => { switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || ''; const blockHash: string = params.get('id') || '';
@ -212,7 +210,7 @@ export class BlockComponent implements OnInit, OnDestroy {
setTimeout(() => { setTimeout(() => {
this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe(); this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe();
this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe(); this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe();
this.nextBlockSummarySubscription = this.apiService.getStrippedBlockTransactions$(block.previousblockhash).subscribe(); this.apiService.getBlockAudit$(block.previousblockhash);
}, 100); }, 100);
} }
@ -229,9 +227,6 @@ export class BlockComponent implements OnInit, OnDestroy {
this.fees = block.extras.reward / 100000000 - this.blockSubsidy; this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
} }
this.stateService.markBlock$.next({ blockHeight: this.blockHeight }); this.stateService.markBlock$.next({ blockHeight: this.blockHeight });
if (this.block.id && this.block?.extras?.matchRate == null) {
this.fetchAuditScore$.next(this.block.id);
}
this.isLoadingTransactions = true; this.isLoadingTransactions = true;
this.transactions = null; this.transactions = null;
this.transactionsError = null; this.transactionsError = null;
@ -263,40 +258,126 @@ export class BlockComponent implements OnInit, OnDestroy {
this.isLoadingOverview = false; this.isLoadingOverview = false;
}); });
this.overviewSubscription = block$.pipe( if (!this.indexingAvailable) {
startWith(null), this.overviewSubscription = block$.pipe(
pairwise(), startWith(null),
switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id) pairwise(),
.pipe( switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id)
catchError((err) => { .pipe(
this.overviewError = err; catchError((err) => {
return of([]); this.overviewError = err;
}), return of([]);
switchMap((transactions) => { }),
if (prevBlock) { switchMap((transactions) => {
return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); if (prevBlock) {
} else { return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' });
return of({ transactions, direction: 'down' }); } else {
return of({ transactions, direction: 'down' });
}
})
)
),
)
.subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => {
this.strippedTransactions = transactions;
this.isLoadingOverview = false;
this.setupBlockGraphs();
},
(error) => {
this.error = error;
this.isLoadingOverview = false;
});
}
if (this.indexingAvailable) {
this.auditSubscription = block$.pipe(
startWith(null),
pairwise(),
switchMap(([prevBlock, block]) => this.apiService.getBlockAudit$(block.id)
.pipe(
catchError((err) => {
this.overviewError = err;
return of([]);
})
)
),
filter((response) => response != null),
map((response) => {
const blockAudit = response.body;
const inTemplate = {};
const inBlock = {};
const isAdded = {};
const isCensored = {};
const isMissing = {};
const isSelected = {};
const isFresh = {};
this.numMissing = 0;
this.numUnexpected = 0;
if (blockAudit?.template) {
for (const tx of blockAudit.template) {
inTemplate[tx.txid] = true;
} }
}) for (const tx of blockAudit.transactions) {
) inBlock[tx.txid] = true;
), }
) for (const txid of blockAudit.addedTxs) {
.subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { isAdded[txid] = true;
this.strippedTransactions = transactions; }
this.isLoadingOverview = false; for (const txid of blockAudit.missingTxs) {
if (this.blockGraph) { isCensored[txid] = true;
this.blockGraph.destroy(); }
this.blockGraph.setup(this.strippedTransactions); for (const txid of blockAudit.freshTxs || []) {
} isFresh[txid] = true;
}, }
(error) => { // set transaction statuses
this.error = error; for (const tx of blockAudit.template) {
this.isLoadingOverview = false; tx.context = 'projected';
if (this.blockGraph) { if (isCensored[tx.txid]) {
this.blockGraph.destroy(); tx.status = 'censored';
} } else if (inBlock[tx.txid]) {
}); tx.status = 'found';
} else {
tx.status = isFresh[tx.txid] ? 'fresh' : 'missing';
isMissing[tx.txid] = true;
this.numMissing++;
}
}
for (const [index, tx] of blockAudit.transactions.entries()) {
tx.context = 'actual';
if (index === 0) {
tx.status = null;
} else if (isAdded[tx.txid]) {
tx.status = 'added';
} else if (inTemplate[tx.txid]) {
tx.status = 'found';
} else {
tx.status = 'selected';
isSelected[tx.txid] = true;
this.numUnexpected++;
}
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
this.auditEnabled = true;
} else {
this.auditEnabled = false;
}
return blockAudit;
}),
catchError((err) => {
console.log(err);
this.error = err;
this.isLoadingOverview = false;
return of(null);
}),
).subscribe((blockAudit) => {
this.blockAudit = blockAudit;
this.setupBlockGraphs();
this.isLoadingOverview = false;
});
}
this.networkChangedSubscription = this.stateService.networkChanged$ this.networkChangedSubscription = this.stateService.networkChanged$
.subscribe((network) => this.network = network); .subscribe((network) => this.network = network);
@ -307,6 +388,12 @@ export class BlockComponent implements OnInit, OnDestroy {
} else { } else {
this.showDetails = false; this.showDetails = false;
} }
if (params.view === 'projected') {
this.mode = 'projected';
} else {
this.mode = 'actual';
}
this.setupBlockGraphs();
}); });
this.keyNavigationSubscription = this.stateService.keyNavigation$.subscribe((event) => { this.keyNavigationSubscription = this.stateService.keyNavigation$.subscribe((event) => {
@ -325,17 +412,24 @@ export class BlockComponent implements OnInit, OnDestroy {
}); });
} }
ngAfterViewInit(): void {
this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => {
this.setupBlockGraphs();
});
}
ngOnDestroy() { ngOnDestroy() {
this.stateService.markBlock$.next({}); this.stateService.markBlock$.next({});
this.transactionSubscription.unsubscribe(); this.transactionSubscription.unsubscribe();
this.overviewSubscription.unsubscribe(); this.overviewSubscription?.unsubscribe();
this.auditSubscription?.unsubscribe();
this.keyNavigationSubscription.unsubscribe(); this.keyNavigationSubscription.unsubscribe();
this.blocksSubscription.unsubscribe(); this.blocksSubscription.unsubscribe();
this.networkChangedSubscription.unsubscribe(); this.networkChangedSubscription.unsubscribe();
this.queryParamsSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe();
this.timeLtrSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe();
this.fetchAuditScoreSubscription?.unsubscribe();
this.unsubscribeNextBlockSubscriptions(); this.unsubscribeNextBlockSubscriptions();
this.childChangeSubscription.unsubscribe();
} }
unsubscribeNextBlockSubscriptions() { unsubscribeNextBlockSubscriptions() {
@ -382,7 +476,7 @@ export class BlockComponent implements OnInit, OnDestroy {
this.showDetails = false; this.showDetails = false;
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { showDetails: false }, queryParams: { showDetails: false, view: this.mode },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
fragment: 'block' fragment: 'block'
}); });
@ -390,7 +484,7 @@ export class BlockComponent implements OnInit, OnDestroy {
this.showDetails = true; this.showDetails = true;
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { showDetails: true }, queryParams: { showDetails: true, view: this.mode },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
fragment: 'details' fragment: 'details'
}); });
@ -409,10 +503,6 @@ export class BlockComponent implements OnInit, OnDestroy {
return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000; return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000;
} }
onResize(event: any) {
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
}
navigateToPreviousBlock() { navigateToPreviousBlock() {
if (!this.block) { if (!this.block) {
return; return;
@ -443,8 +533,53 @@ export class BlockComponent implements OnInit, OnDestroy {
} }
} }
setupBlockGraphs(): void {
if (this.blockAudit || this.strippedTransactions) {
this.blockGraphProjected.forEach(graph => {
graph.destroy();
if (this.isMobile && this.mode === 'actual') {
graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []);
} else {
graph.setup(this.blockAudit?.template || []);
}
});
this.blockGraphActual.forEach(graph => {
graph.destroy();
graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []);
});
}
}
onResize(event: any): void {
const isMobile = event.target.innerWidth <= 767.98;
const changed = isMobile !== this.isMobile;
this.isMobile = isMobile;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
if (changed) {
this.changeMode(this.mode);
}
}
changeMode(mode: 'projected' | 'actual'): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { showDetails: this.showDetails, view: mode },
queryParamsHandling: 'merge',
fragment: 'overview'
});
}
onTxClick(event: TransactionStripped): void { onTxClick(event: TransactionStripped): void {
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
this.router.navigate([url]); this.router.navigate([url]);
} }
onTxHover(txid: string): void {
if (txid && txid.length) {
this.hoverTx = txid;
} else {
this.hoverTx = null;
}
}
} }

View file

@ -46,14 +46,13 @@
<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since> <app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>
</td> </td>
<td *ngIf="indexingAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"> <td *ngIf="indexingAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<a class="clear-link" [routerLink]="auditScores[block.id] != null ? ['/block-audit/' | relativeUrl, block.id] : null"> <a class="clear-link" [routerLink]="auditScores[block.id] != null ? ['/block/' | relativeUrl, block.id] : null">
<div class="progress progress-health"> <div class="progress progress-health">
<div class="progress-bar progress-bar-health" role="progressbar" <div class="progress-bar progress-bar-health" role="progressbar"
[ngStyle]="{'width': (100 - (auditScores[block.id] || 0)) + '%' }"></div> [ngStyle]="{'width': (100 - (auditScores[block.id] || 0)) + '%' }"></div>
<div class="progress-text"> <div class="progress-text">
<span *ngIf="auditScores[block.id] != null;">{{ auditScores[block.id] }}%</span> <span *ngIf="auditScores[block.id] != null;">{{ auditScores[block.id] }}%</span>
<span *ngIf="auditScores[block.id] === undefined" class="skeleton-loader"></span> <span *ngIf="auditScores[block.id] == null">~</span>
<span *ngIf="auditScores[block.id] === null">~</span>
</div> </div>
</div> </div>
</a> </a>

View file

@ -141,7 +141,7 @@ export interface TransactionStripped {
fee: number; fee: number;
vsize: number; vsize: number;
value: number; value: number;
status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
} }
export interface RewardStats { export interface RewardStats {

View file

@ -70,7 +70,8 @@ export interface TransactionStripped {
fee: number; fee: number;
vsize: number; vsize: number;
value: number; value: number;
status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
context?: 'projected' | 'actual';
} }
export interface IBackendInfo { export interface IBackendInfo {

View file

@ -230,7 +230,7 @@ export class ApiService {
getBlockAudit$(hash: string) : Observable<any> { getBlockAudit$(hash: string) : Observable<any> {
return this.httpClient.get<any>( return this.httpClient.get<any>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/` + hash, { observe: 'response' } this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/audit-summary`, { observe: 'response' }
); );
} }

View file

@ -45,7 +45,6 @@ import { StartComponent } from '../components/start/start.component';
import { TransactionComponent } from '../components/transaction/transaction.component'; import { TransactionComponent } from '../components/transaction/transaction.component';
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
import { BlockComponent } from '../components/block/block.component'; import { BlockComponent } from '../components/block/block.component';
import { BlockAuditComponent } from '../components/block-audit/block-audit.component';
import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component';
import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
import { AddressComponent } from '../components/address/address.component'; import { AddressComponent } from '../components/address/address.component';
@ -120,7 +119,6 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati
StartComponent, StartComponent,
TransactionComponent, TransactionComponent,
BlockComponent, BlockComponent,
BlockAuditComponent,
BlockOverviewGraphComponent, BlockOverviewGraphComponent,
BlockOverviewTooltipComponent, BlockOverviewTooltipComponent,
TransactionsListComponent, TransactionsListComponent,
@ -223,7 +221,6 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati
StartComponent, StartComponent,
TransactionComponent, TransactionComponent,
BlockComponent, BlockComponent,
BlockAuditComponent,
BlockOverviewGraphComponent, BlockOverviewGraphComponent,
BlockOverviewTooltipComponent, BlockOverviewTooltipComponent,
TransactionsListComponent, TransactionsListComponent,