Save current progress on the block audit page

This commit is contained in:
nymkappa 2022-07-07 19:11:42 +02:00
parent c6f33310e5
commit 1be7c953ea
No known key found for this signature in database
GPG key ID: E155910B16E8BD04
18 changed files with 412 additions and 17 deletions

View file

@ -578,7 +578,7 @@ class Blocks {
// Index the response if needed
if (Common.blocksSummariesIndexingEnabled() === true) {
await BlocksSummariesRepository.$saveSummary(block.height, summary);
await BlocksSummariesRepository.$saveSummary(block.height, summary, null);
}
return summary.transactions;

View file

@ -4,7 +4,7 @@ import logger from '../logger';
import { Common } from './common';
class DatabaseMigration {
private static currentVersion = 31;
private static currentVersion = 32;
private queryTimeout = 120000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@ -297,7 +297,10 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE');
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`');
await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices'));
}
if (databaseSchemaVersion < 32 && isBitcoin == true) {
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"');
}
}

View file

@ -26,7 +26,8 @@ class MiningRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
;
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit)
;
}
private async $getPool(req: Request, res: Response): Promise<void> {
@ -233,6 +234,18 @@ class MiningRoutes {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
public async $getBlockAudit(req: Request, res: Response) {
try {
const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash);
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
res.json(audit);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
}
export default new MiningRoutes();

View file

@ -17,6 +17,7 @@ import rbfCache from './rbf-cache';
import difficultyAdjustment from './difficulty-adjustment';
import feeApi from './fee-api';
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
class WebsocketHandler {
private wss: WebSocket.Server | undefined;
@ -442,6 +443,19 @@ class WebsocketHandler {
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
if (Common.indexingEnabled()) {
const stripped = _mempoolBlocks[0].transactions.map((tx) => {
return {
txid: tx.txid,
vsize: tx.vsize,
fee: tx.fee ? Math.round(tx.fee) : 0,
value: tx.value,
};
});
BlocksSummariesRepository.$saveSummary(block.height, null, {
id: block.id,
transactions: stripped
});
BlocksAuditsRepository.$saveAudit({
time: block.timestamp,
height: block.height,

View file

@ -1,3 +1,4 @@
import transactionUtils from '../api/transaction-utils';
import DB from '../database';
import logger from '../logger';
import { BlockAudit } from '../mempool.interfaces';
@ -45,6 +46,30 @@ class BlocksAuditRepositories {
throw e;
}
}
public async $getBlockAudit(hash: string): Promise<any> {
try {
const [rows]: any[] = await DB.query(
`SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
blocks.weight, blocks.tx_count,
transactions, template, missing_txs as missingTxs, added_txs as addedTxs, match_rate as matchRate
FROM blocks_audits
JOIN blocks ON blocks.hash = blocks_audits.hash
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
WHERE blocks_audits.hash = "${hash}"
`);
rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
rows[0].transactions = JSON.parse(rows[0].transactions);
rows[0].template = JSON.parse(rows[0].template);
return rows[0];
} catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
}
export default new BlocksAuditRepositories();

View file

@ -17,14 +17,24 @@ class BlocksSummariesRepository {
return undefined;
}
public async $saveSummary(height: number, summary: BlockSummary) {
public async $saveSummary(height: number, mined: BlockSummary | null = null, template: BlockSummary | null = null) {
const blockId = mined?.id ?? template?.id;
try {
await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?)`, [height, summary.id, JSON.stringify(summary.transactions)]);
const [dbSummary]: any[] = await DB.query(`SELECT * FROM blocks_summaries WHERE id = "${blockId}"`);
if (dbSummary.length === 0) { // First insertion
await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?, ?)`, [
height, blockId, JSON.stringify(mined?.transactions ?? []), JSON.stringify(template?.transactions ?? [])
]);
} else if (mined !== null) { // Update mined block summary
await DB.query(`UPDATE blocks_summaries SET transactions = ? WHERE id = "${mined.id}"`, [JSON.stringify(mined?.transactions)]);
} else if (template !== null) { // Update template block summary
await DB.query(`UPDATE blocks_summaries SET template = ? WHERE id = "${template.id}"`, [JSON.stringify(template?.transactions)]);
}
} catch (e: any) {
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
logger.debug(`Cannot save block summary for ${summary.id} because it has already been indexed, ignoring`);
logger.debug(`Cannot save block summary for ${blockId} because it has already been indexed, ignoring`);
} else {
logger.debug(`Cannot save block summary for ${summary.id}. Reason: ${e instanceof Error ? e.message : e}`);
logger.debug(`Cannot save block summary for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
throw e;
}
}
@ -44,7 +54,7 @@ class BlocksSummariesRepository {
/**
* Delete blocks from the database from blockHeight
*/
public async $deleteBlocksFrom(blockHeight: number) {
public async $deleteBlocksFrom(blockHeight: number) {
logger.info(`Delete newer blocks summary from height ${blockHeight} from the database`);
try {

View file

@ -3,6 +3,7 @@ import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
import { StartComponent } from './components/start/start.component';
import { TransactionComponent } from './components/transaction/transaction.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 { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component';
@ -88,6 +89,15 @@ let routes: Routes = [
},
],
},
{
path: 'block-audit',
children: [
{
path: ':id',
component: BlockAuditComponent,
},
],
},
{
path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
@ -182,6 +192,15 @@ let routes: Routes = [
},
],
},
{
path: 'block-audit',
children: [
{
path: ':id',
component: BlockAuditComponent,
},
],
},
{
path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
@ -273,6 +292,15 @@ let routes: Routes = [
},
],
},
{
path: 'block-audit',
children: [
{
path: ':id',
component: BlockAuditComponent
},
],
},
{
path: 'docs',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)

View file

@ -0,0 +1,111 @@
<div class="container-xl" (window:resize)="onResize($event)">
<div *ngIf="(auditObservable$ | async) as blockAudit; else skeleton">
<div class="title-block" id="block">
<h1>
<span class="next-previous-blocks">
<span i18n="shared.block-title">Block </span>
&nbsp;
<a [routerLink]="['/block/' | relativeUrl, blockAudit.id]">{{ blockAudit.height }}</a>
&nbsp;
<span i18n="shared.template-vs-mined">Template vs Mined</span>
</span>
</h1>
<div class="grow"></div>
<button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">&#10005;</button>
</div>
<!-- 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, blockAudit.id]" title="{{ blockAudit.id }}">{{ blockAudit.id | shortenString : 13 }}</a>
<app-clipboard class="d-none d-sm-inline-block" [text]="blockAudit.id"></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.match-rate">Match rate</td>
<td>{{ blockAudit.matchRate }}%</td>
</tr>
<tr>
<td i18n="block.missing-txs">Missing 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" *ngIf="isMobile">
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.missing-txs"
fragment="missing" (click)="changeMode('missing')">Missing</a>
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.added-txs"
fragment="added" (click)="changeMode('added')">Added</a>
</div>
</div>
<!-- VISUALIZATIONS -->
<div class="box">
<div class="row">
<!-- MISSING TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled">
<app-block-overview-graph #blockGraphTemplate [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
</div>
<!-- ADDED TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled && !isMobile">
<app-block-overview-graph #blockGraphMined [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
</div>
</div> <!-- row -->
</div> <!-- box -->
<ng-template #skeleton></ng-template>
</div>

View file

@ -0,0 +1,40 @@
.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;
}
}

View file

@ -0,0 +1,120 @@
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { map, share, switchMap, tap } from 'rxjs/operators';
import { BlockAudit, TransactionStripped } from 'src/app/interfaces/node-api.interface';
import { ApiService } from 'src/app/services/api.service';
import { StateService } from 'src/app/services/state.service';
import { detectWebGL } from 'src/app/shared/graphs.utils';
import { RelativeUrlPipe } from 'src/app/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, OnDestroy {
blockAudit: BlockAudit = undefined;
transactions: string[];
auditObservable$: Observable<BlockAudit>;
paginationMaxSize: number;
page = 1;
itemsPerPage: number;
mode: 'missing' | 'added' = 'missing';
isLoading = true;
webGlEnabled = true;
isMobile = window.innerWidth <= 767.98;
@ViewChild('blockGraphTemplate') blockGraphTemplate: BlockOverviewGraphComponent;
@ViewChild('blockGraphMined') blockGraphMined: BlockOverviewGraphComponent;
constructor(
private route: ActivatedRoute,
public stateService: StateService,
private router: Router,
private apiService: ApiService
) {
this.webGlEnabled = detectWebGL();
}
ngOnDestroy(): void {
}
ngOnInit(): void {
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
this.auditObservable$ = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || '';
return this.apiService.getBlockAudit$(blockHash)
.pipe(
map((response) => {
const blockAudit = response.body;
for (let i = 0; i < blockAudit.template.length; ++i) {
if (blockAudit.missingTxs.includes(blockAudit.template[i].txid)) {
blockAudit.template[i].status = 'missing';
} else if (blockAudit.addedTxs.includes(blockAudit.template[i].txid)) {
blockAudit.template[i].status = 'added';
} else {
blockAudit.template[i].status = 'found';
}
}
for (let i = 0; i < blockAudit.transactions.length; ++i) {
if (blockAudit.missingTxs.includes(blockAudit.transactions[i].txid)) {
blockAudit.transactions[i].status = 'missing';
} else if (blockAudit.addedTxs.includes(blockAudit.transactions[i].txid)) {
blockAudit.transactions[i].status = 'added';
} else {
blockAudit.transactions[i].status = 'found';
}
}
return blockAudit;
}),
tap((blockAudit) => {
this.changeMode(this.mode);
if (this.blockGraphTemplate) {
this.blockGraphTemplate.destroy();
this.blockGraphTemplate.setup(blockAudit.template);
}
if (this.blockGraphMined) {
this.blockGraphMined.destroy();
this.blockGraphMined.setup(blockAudit.transactions);
}
this.isLoading = false;
}),
);
}),
share()
);
}
onResize(event: any) {
this.isMobile = event.target.innerWidth <= 767.98;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
}
changeMode(mode: 'missing' | 'added') {
this.router.navigate([], { fragment: mode });
this.mode = mode;
}
onTxClick(event: TransactionStripped): void {
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
this.router.navigate([url]);
}
pageChange(page: number, target: HTMLElement) {
}
}

View file

@ -1,6 +1,5 @@
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core';
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
import { WebsocketService } from 'src/app/services/websocket.service';
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
import { FastVertexArray } from './fast-vertex-array';
import BlockScene from './block-scene';
import TxSprite from './tx-sprite';

View file

@ -25,6 +25,7 @@ export default class TxView implements TransactionStripped {
vsize: number;
value: number;
feerate: number;
status?: 'found' | 'missing' | 'added';
initialised: boolean;
vertexArray: FastVertexArray;
@ -43,6 +44,7 @@ export default class TxView implements TransactionStripped {
this.vsize = tx.vsize;
this.value = tx.value;
this.feerate = tx.fee / tx.vsize;
this.status = tx.status;
this.initialised = false;
this.vertexArray = vertexArray;
@ -140,6 +142,16 @@ export default class TxView implements TransactionStripped {
}
getColor(): Color {
// Block audit
if (this.status === 'found') {
// return hexToColor('1a4987');
} else if (this.status === 'missing') {
return hexToColor('039BE5');
} else if (this.status === 'added') {
return hexToColor('D81B60');
}
// Block component
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1;
return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]);
}

View file

@ -12,6 +12,7 @@ import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.
import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface';
import { ApiService } from 'src/app/services/api.service';
import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
import { detectWebGL } from 'src/app/shared/graphs.utils';
@Component({
selector: 'app-block',
@ -390,10 +391,4 @@ export class BlockComponent implements OnInit, OnDestroy {
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
this.router.navigate([url]);
}
}
function detectWebGL() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
return (gl && gl instanceof WebGLRenderingContext);
}
}

View file

@ -128,11 +128,20 @@ export interface BlockExtended extends Block {
extras?: BlockExtension;
}
export interface BlockAudit extends BlockExtended {
missingTxs: string[],
addedTxs: string[],
matchRate: number,
template: TransactionStripped[],
transactions: TransactionStripped[],
}
export interface TransactionStripped {
txid: string;
fee: number;
vsize: number;
value: number;
status?: 'found' | 'missing' | 'added';
}
export interface RewardStats {

View file

@ -70,6 +70,7 @@ export interface TransactionStripped {
fee: number;
vsize: number;
value: number;
status?: 'found' | 'missing' | 'added';
}
export interface IBackendInfo {

View file

@ -228,6 +228,12 @@ export class ApiService {
);
}
getBlockAudit$(hash: string) : Observable<any> {
return this.httpClient.get<any>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/` + hash, { observe: 'response' }
);
}
getRewardStats$(blockCount: number = 144): Observable<RewardStats> {
return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`);
}

View file

@ -84,3 +84,9 @@ export const download = (href, name) => {
a.click();
document.body.removeChild(a);
};
export function detectWebGL() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
return (gl && gl instanceof WebGLRenderingContext);
}

View file

@ -44,6 +44,7 @@ import { StartComponent } from '../components/start/start.component';
import { TransactionComponent } from '../components/transaction/transaction.component';
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.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 { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
import { AddressComponent } from '../components/address/address.component';
@ -114,6 +115,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component';
StartComponent,
TransactionComponent,
BlockComponent,
BlockAuditComponent,
BlockOverviewGraphComponent,
BlockOverviewTooltipComponent,
TransactionsListComponent,
@ -213,6 +215,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component';
StartComponent,
TransactionComponent,
BlockComponent,
BlockAuditComponent,
BlockOverviewGraphComponent,
BlockOverviewTooltipComponent,
TransactionsListComponent,