diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 5e99e870c..c470f6fe7 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -154,4 +154,19 @@ export class Common { }); return parents; } + + static getSqlInterval(interval: string | null): string | null { + switch (interval) { + case '24h': return '1 DAY'; + case '3d': return '3 DAY'; + case '1w': return '1 WEEK'; + case '1m': return '1 MONTH'; + case '3m': return '3 MONTH'; + case '6m': return '6 MONTH'; + case '1y': return '1 YEAR'; + case '2y': return '2 YEAR'; + case '3y': return '3 YEAR'; + default: return null; + } + } } diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index 7431dc0b3..bf8c6b340 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -2,36 +2,20 @@ import { PoolInfo, PoolStats } from '../mempool.interfaces'; import BlocksRepository, { EmptyBlocks } from '../repositories/BlocksRepository'; import PoolsRepository from '../repositories/PoolsRepository'; import bitcoinClient from './bitcoin/bitcoin-client'; +import { Common } from './common'; class Mining { constructor() { } - private getSqlInterval(interval: string | null): string | null { - switch (interval) { - case '24h': return '1 DAY'; - case '3d': return '3 DAY'; - case '1w': return '1 WEEK'; - case '1m': return '1 MONTH'; - case '3m': return '3 MONTH'; - case '6m': return '6 MONTH'; - case '1y': return '1 YEAR'; - case '2y': return '2 YEAR'; - case '3y': return '3 YEAR'; - default: return null; - } - } - /** * Generate high level overview of the pool ranks and general stats */ public async $getPoolsStats(interval: string | null) : Promise { - const sqlInterval = this.getSqlInterval(interval); - const poolsStatistics = {}; - const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(sqlInterval); - const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$countEmptyBlocks(sqlInterval); + const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval); + const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(null, interval); const poolsStats: PoolStats[] = []; let rank = 1; @@ -58,7 +42,7 @@ class Mining { const oldestBlock = new Date(await BlocksRepository.$oldestBlockTimestamp()); poolsStatistics['oldestIndexedBlockTimestamp'] = oldestBlock.getTime(); - const blockCount: number = await BlocksRepository.$blockCount(sqlInterval); + const blockCount: number = await BlocksRepository.$blockCount(null, interval); poolsStatistics['blockCount'] = blockCount; const blockHeightTip = await bitcoinClient.getBlockCount(); @@ -74,15 +58,16 @@ class Mining { public async $getPoolStat(interval: string | null, poolId: number): Promise { const pool = await PoolsRepository.$getPool(poolId); if (!pool) { - throw new Error("This mining pool does not exist"); + throw new Error(`This mining pool does not exist`); } - const sqlInterval = this.getSqlInterval(interval); - const blocks = await BlocksRepository.$getBlocksByPool(sqlInterval, poolId); + const blockCount: number = await BlocksRepository.$blockCount(poolId, interval); + const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(poolId, interval); return { pool: pool, - blocks: blocks, + blockCount: blockCount, + emptyBlocks: emptyBlocks, }; } } diff --git a/backend/src/index.ts b/backend/src/index.ts index b8ca55339..4b0466bf6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -275,7 +275,9 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3y', routes.$getPools.bind(routes, '3y')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/all', routes.$getPools.bind(routes, 'all')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId', routes.$getPool) - .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/:interval', routes.$getPool); + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/:interval', routes.$getPool) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool-blocks/:poolId', routes.$getPoolBlocks) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool-blocks/:poolId/:height', routes.$getPoolBlocks); } if (config.BISQ.ENABLED) { diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 2d5092145..4869561c2 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -1,23 +1,23 @@ import { IEsploraApi } from './api/bitcoin/esplora-api.interface'; export interface PoolTag { - id: number, // mysql row id - name: string, - link: string, - regexes: string, // JSON array - addresses: string, // JSON array + id: number; // mysql row id + name: string; + link: string; + regexes: string; // JSON array + addresses: string; // JSON array } export interface PoolInfo { - poolId: number, // mysql row id - name: string, - link: string, - blockCount: number, + poolId: number; // mysql row id + name: string; + link: string; + blockCount: number; } export interface PoolStats extends PoolInfo { - rank: number, - emptyBlocks: number, + rank: number; + emptyBlocks: number; } export interface MempoolBlock { diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 03545f730..03b9fe5bf 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -1,6 +1,7 @@ import { BlockExtended, PoolTag } from '../mempool.interfaces'; import { DB } from '../database'; import logger from '../logger'; +import { Common } from '../api/common'; export interface EmptyBlocks { emptyBlocks: number; @@ -43,6 +44,7 @@ class BlocksRepository { block.extras?.reward ?? 0, ]; + logger.debug(query); await connection.query(query, params); } catch (e: any) { if (e.errno === 1062) { // ER_DUP_ENTRY @@ -64,35 +66,45 @@ class BlocksRepository { } const connection = await DB.pool.getConnection(); - const [rows] : any[] = await connection.query(` + const [rows]: any[] = await connection.query(` SELECT height FROM blocks - WHERE height <= ${startHeight} AND height >= ${endHeight} + WHERE height <= ? AND height >= ? ORDER BY height DESC; - `); + `, [startHeight, endHeight]); connection.release(); const indexedBlockHeights: number[] = []; rows.forEach((row: any) => { indexedBlockHeights.push(row.height); }); const seekedBlocks: number[] = Array.from(Array(startHeight - endHeight + 1).keys(), n => n + endHeight).reverse(); - const missingBlocksHeights = seekedBlocks.filter(x => indexedBlockHeights.indexOf(x) === -1); + const missingBlocksHeights = seekedBlocks.filter(x => indexedBlockHeights.indexOf(x) === -1); return missingBlocksHeights; } /** - * Count empty blocks for all pools + * Get empty blocks for one or all pools */ - public async $countEmptyBlocks(interval: string | null): Promise { - const query = ` - SELECT pool_id as poolId - FROM blocks - WHERE tx_count = 1` + - (interval != null ? ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) - ; + public async $getEmptyBlocks(poolId: number | null, interval: string | null = null): Promise { + interval = Common.getSqlInterval(interval); + const params: any[] = []; + let query = `SELECT height, hash, tx_count, size, pool_id, weight, UNIX_TIMESTAMP(blockTimestamp) as timestamp + FROM blocks + WHERE tx_count = 1`; + + if (poolId) { + query += ` AND pool_id = ?`; + params.push(poolId); + } + + if (interval) { + query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query); + const [rows] = await connection.query(query, params); connection.release(); return rows; @@ -101,15 +113,30 @@ class BlocksRepository { /** * Get blocks count for a period */ - public async $blockCount(interval: string | null): Promise { - const query = ` - SELECT count(height) as blockCount - FROM blocks` + - (interval != null ? ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) - ; + public async $blockCount(poolId: number | null, interval: string | null): Promise { + interval = Common.getSqlInterval(interval); + const params: any[] = []; + let query = `SELECT count(height) as blockCount + FROM blocks`; + + if (poolId) { + query += ` WHERE pool_id = ?`; + params.push(poolId); + } + + if (interval) { + if (poolId) { + query += ` AND`; + } else { + query += ` WHERE`; + } + query += ` blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query); + const [rows] = await connection.query(query, params); connection.release(); return rows[0].blockCount; @@ -119,13 +146,15 @@ class BlocksRepository { * Get the oldest indexed block */ public async $oldestBlockTimestamp(): Promise { - const connection = await DB.pool.getConnection(); - const [rows]: any[] = await connection.query(` - SELECT blockTimestamp + const query = `SELECT blockTimestamp FROM blocks ORDER BY height - LIMIT 1; - `); + LIMIT 1;`; + + + logger.debug(query); + const connection = await DB.pool.getConnection(); + const [rows]: any[] = await connection.query(query); connection.release(); if (rows.length <= 0) { @@ -138,18 +167,34 @@ class BlocksRepository { /** * Get blocks mined by a specific mining pool */ - public async $getBlocksByPool(interval: string | null, poolId: number): Promise { - const query = ` - SELECT * + public async $getBlocksByPool( + poolId: number, + startHeight: number | null = null + ): Promise { + const params: any[] = []; + let query = `SELECT height, hash, tx_count, size, weight, pool_id, UNIX_TIMESTAMP(blockTimestamp) as timestamp FROM blocks - WHERE pool_id = ${poolId}` - + (interval != null ? ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``); + WHERE pool_id = ?`; + params.push(poolId); + if (startHeight) { + query += ` AND height < ?`; + params.push(startHeight); + } + + query += ` ORDER BY height DESC + LIMIT 10`; + + logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query); + const [rows] = await connection.query(query, params); connection.release(); - return rows; + for (const block of rows) { + delete block['blockTimestamp']; + } + + return rows; } /** diff --git a/backend/src/repositories/PoolsRepository.ts b/backend/src/repositories/PoolsRepository.ts index 50f5268a7..b94f3d36d 100644 --- a/backend/src/repositories/PoolsRepository.ts +++ b/backend/src/repositories/PoolsRepository.ts @@ -1,4 +1,6 @@ +import { Common } from '../api/common'; import { DB } from '../database'; +import logger from '../logger'; import { PoolInfo, PoolTag } from '../mempool.interfaces'; class PoolsRepository { @@ -25,16 +27,21 @@ class PoolsRepository { /** * Get basic pool info and block count */ - public async $getPoolsInfo(interval: string | null): Promise { - const query = ` - SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link - FROM blocks - JOIN pools on pools.id = pool_id` + - (interval != null ? ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()` : ``) + - ` GROUP BY pool_id - ORDER BY COUNT(height) DESC - `; + public async $getPoolsInfo(interval: string | null = null): Promise { + interval = Common.getSqlInterval(interval); + let query = `SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link + FROM blocks + JOIN pools on pools.id = pool_id`; + + if (interval) { + query += ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` GROUP BY pool_id + ORDER BY COUNT(height) DESC`; + + logger.debug(query); const connection = await DB.pool.getConnection(); const [rows] = await connection.query(query); connection.release(); @@ -45,15 +52,15 @@ class PoolsRepository { /** * Get mining pool statistics for one pool */ - public async $getPool(poolId: number) : Promise { + public async $getPool(poolId: any): Promise { const query = ` SELECT * FROM pools - WHERE pools.id = ${poolId} - `; + WHERE pools.id = ?`; + logger.debug(query); const connection = await DB.pool.getConnection(); - const [rows] = await connection.query(query); + const [rows] = await connection.query(query, [poolId]); connection.release(); return rows[0]; diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 6811f9190..4e59bef3a 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -24,6 +24,7 @@ import miningStats from './api/mining'; import axios from 'axios'; import PoolsRepository from './repositories/PoolsRepository'; import mining from './api/mining'; +import BlocksRepository from './repositories/BlocksRepository'; class Routes { constructor() {} @@ -537,8 +538,7 @@ class Routes { public async $getPool(req: Request, res: Response) { try { - const poolId = parseInt(req.params.poolId); - const stats = await mining.$getPoolStat(req.params.interval ?? null, poolId); + const stats = await mining.$getPoolStat(req.params.interval ?? null, parseInt(req.params.poolId, 10)); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); @@ -548,9 +548,24 @@ class Routes { } } + public async $getPoolBlocks(req: Request, res: Response) { + try { + const poolBlocks = await BlocksRepository.$getBlocksByPool( + parseInt(req.params.poolId, 10), + parseInt(req.params.height, 10) ?? null, + ); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + res.json(poolBlocks); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async $getPools(interval: string, req: Request, res: Response) { try { - let stats = await miningStats.$getPoolsStats(interval); + const stats = await miningStats.$getPoolsStats(interval); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 4018ed64e..a13bc1e54 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -71,6 +71,10 @@ let routes: Routes = [ path: 'mining/pool/:poolId', component: PoolComponent, }, + { + path: 'mining/pool/:poolId/:interval', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, @@ -163,6 +167,10 @@ let routes: Routes = [ path: 'mining/pool/:poolId', component: PoolComponent, }, + { + path: 'mining/pool/:poolId/:interval', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, @@ -249,6 +257,10 @@ let routes: Routes = [ path: 'mining/pool/:poolId', component: PoolComponent, }, + { + path: 'mining/pool/:poolId/:interval', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, diff --git a/frontend/src/app/components/pool/pool.component.html b/frontend/src/app/components/pool/pool.component.html index 62c259ccf..9040e4553 100644 --- a/frontend/src/app/components/pool/pool.component.html +++ b/frontend/src/app/components/pool/pool.component.html @@ -1,5 +1,66 @@
- Pool +
+

+ {{ poolStats.pool.name }} +

+ +
+
+
+ + + + + + + + + + + +
Address{{ poolStats.pool.addresses }}
Coinbase Tag{{ poolStats.pool.regexes }}
+
+
+ + + + + + + + + + + +
Mined Blocks{{ poolStats.blockCount }}
Empty Blocks{{ poolStats.emptyBlocks.length }}
+
+
+
+
+ + + + + + + + + + + + + + + + + + +
HeightTimestampMinedTransactionsSize
{{ block.height }}‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}{{ block.tx_count | number }} +
+
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/pool/pool.component.ts b/frontend/src/app/components/pool/pool.component.ts index 907dcf0fd..8296f7520 100644 --- a/frontend/src/app/components/pool/pool.component.ts +++ b/frontend/src/app/components/pool/pool.component.ts @@ -1,4 +1,10 @@ import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map, switchMap, tap } from 'rxjs/operators'; +import { BlockExtended, PoolStat } from 'src/app/interfaces/node-api.interface'; +import { ApiService } from 'src/app/services/api.service'; +import { StateService } from 'src/app/services/state.service'; @Component({ selector: 'app-pool', @@ -6,9 +12,55 @@ import { Component, OnInit } from '@angular/core'; styleUrls: ['./pool.component.scss'] }) export class PoolComponent implements OnInit { + poolStats$: Observable; + isLoading = false; + + poolId: number; + interval: string; + + blocks: any[] = []; + constructor( + private apiService: ApiService, + private route: ActivatedRoute, + public stateService: StateService, ) { } ngOnInit(): void { + this.poolStats$ = this.route.params + .pipe( + switchMap((params) => { + this.poolId = params.poolId; + this.interval = params.interval; + this.loadMore(2); + return this.apiService.getPoolStats$(params.poolId, params.interval ?? 'all'); + }), + ); + } + + loadMore(chunks = 0) { + let fromHeight: number | undefined; + if (this.blocks.length > 0) { + fromHeight = this.blocks[this.blocks.length - 1].height - 1; + } + + this.apiService.getPoolBlocks$(this.poolId, fromHeight) + .subscribe((blocks) => { + this.blocks = this.blocks.concat(blocks); + + const chunksLeft = chunks - 1; + if (chunksLeft > 0) { + this.loadMore(chunksLeft); + } + // this.cd.markForCheck(); + }, + (error) => { + console.log(error); + // this.cd.markForCheck(); + }); + } + + trackByBlock(index: number, block: BlockExtended) { + return block.height; } } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index b923d25b7..dfcb5836e 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -54,6 +54,9 @@ export interface LiquidPegs { export interface ITranslators { [language: string]: string; } +/** + * PoolRanking component + */ export interface SinglePoolStats { poolId: number; name: string; @@ -66,20 +69,35 @@ export interface SinglePoolStats { emptyBlockRatio: string; logo: string; } - export interface PoolsStats { blockCount: number; lastEstimatedHashrate: number; oldestIndexedBlockTimestamp: number; pools: SinglePoolStats[]; } - export interface MiningStats { - lastEstimatedHashrate: string, - blockCount: number, - totalEmptyBlock: number, - totalEmptyBlockRatio: string, - pools: SinglePoolStats[], + lastEstimatedHashrate: string; + blockCount: number; + totalEmptyBlock: number; + totalEmptyBlockRatio: string; + pools: SinglePoolStats[]; +} + +/** + * Pool component + */ +export interface PoolInfo { + id: number | null; // mysql row id + name: string; + link: string; + regexes: string; // JSON array + addresses: string; // JSON array + emptyBlocks: number; +} +export interface PoolStat { + pool: PoolInfo; + blockCount: number; + emptyBlocks: BlockExtended[]; } export interface BlockExtension { diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index c19bf5a41..ec14d8a5d 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators, PoolsStats } from '../interfaces/node-api.interface'; +import { CpfpInfo, OptimizedMempoolStats, DifficultyAdjustment, AddressInformation, LiquidPegs, ITranslators, PoolsStats, PoolStat, BlockExtended, BlockExtension } from '../interfaces/node-api.interface'; import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; @@ -132,4 +132,12 @@ export class ApiService { listPools$(interval: string | null) : Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pools/${interval}`); } + + getPoolStats$(poolId: number, interval: string | null): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}/${interval}`); + } + + getPoolBlocks$(poolId: number, fromHeight: number): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool-blocks/${poolId}/${fromHeight}`); + } }