diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index 3ab5c4b9f..f9ae196b3 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -155,6 +155,21 @@ 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; + } + } + static indexingEnabled(): boolean { return ( ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index c89ea9324..2a978868f 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -11,24 +11,10 @@ class Mining { * Generate high level overview of the pool ranks and general stats */ public async $getPoolsStats(interval: string | null) : Promise { - let sqlInterval: string | null = null; - switch (interval) { - case '24h': sqlInterval = '1 DAY'; break; - case '3d': sqlInterval = '3 DAY'; break; - case '1w': sqlInterval = '1 WEEK'; break; - case '1m': sqlInterval = '1 MONTH'; break; - case '3m': sqlInterval = '3 MONTH'; break; - case '6m': sqlInterval = '6 MONTH'; break; - case '1y': sqlInterval = '1 YEAR'; break; - case '2y': sqlInterval = '2 YEAR'; break; - case '3y': sqlInterval = '3 YEAR'; break; - default: sqlInterval = null; break; - } - 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; @@ -55,7 +41,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(); @@ -64,6 +50,25 @@ class Mining { return poolsStatistics; } + + /** + * Get all mining pool stats for a pool + */ + 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`); + } + + const blockCount: number = await BlocksRepository.$blockCount(poolId, interval); + const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(poolId, interval); + + return { + pool: pool, + blockCount: blockCount, + emptyBlocks: emptyBlocks, + }; + } } export default new Mining(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 07808c98a..1f8575294 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -256,6 +256,14 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', routes.$getStatisticsByTime.bind(routes, '1y')) .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', routes.$getStatisticsByTime.bind(routes, '2y')) .get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', routes.$getStatisticsByTime.bind(routes, '3y')) + ; + } + + const indexingAvailable = + ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) && + config.DATABASE.ENABLED === true; + if (indexingAvailable) { + this.app .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/24h', routes.$getPools.bind(routes, '24h')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3d', routes.$getPools.bind(routes, '3d')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/1w', routes.$getPools.bind(routes, '1w')) @@ -266,7 +274,10 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/2y', routes.$getPools.bind(routes, '2y')) .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/blocks', routes.$getPoolBlocks) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks/:height', routes.$getPoolBlocks) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId', routes.$getPool) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/:interval', routes.$getPool); } 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 18023760f..6e74e9435 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) { @@ -135,6 +164,39 @@ class BlocksRepository { return rows[0].blockTimestamp; } + /** + * Get blocks mined by a specific mining pool + */ + public async $getBlocksByPool( + poolId: number, + startHeight: number | null = null + ): Promise { + const params: any[] = []; + let query = `SELECT height, hash as id, tx_count, size, weight, pool_id, UNIX_TIMESTAMP(blockTimestamp) as timestamp, reward + FROM blocks + 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, params); + connection.release(); + + for (const block of rows) { + delete block['blockTimestamp']; + } + + return rows; + } + /** * Get one block by height */ @@ -156,4 +218,4 @@ class BlocksRepository { } } -export default new BlocksRepository(); \ No newline at end of file +export default new BlocksRepository(); diff --git a/backend/src/repositories/PoolsRepository.ts b/backend/src/repositories/PoolsRepository.ts index b89725452..a7b716da7 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,22 +27,47 @@ 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(); return rows; } + + /** + * Get mining pool statistics for one pool + */ + public async $getPool(poolId: any): Promise { + const query = ` + SELECT * + FROM pools + WHERE pools.id = ?`; + + // logger.debug(query); + const connection = await DB.pool.getConnection(); + const [rows] = await connection.query(query, [poolId]); + connection.release(); + + rows[0].regexes = JSON.parse(rows[0].regexes); + rows[0].addresses = JSON.parse(rows[0].addresses); + + return rows[0]; + } } export default new PoolsRepository(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 9e25f0d35..66ecebd31 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -22,6 +22,9 @@ import elementsParser from './api/liquid/elements-parser'; import icons from './api/liquid/icons'; 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() {} @@ -533,9 +536,36 @@ class Routes { } } + public async $getPool(req: Request, res: Response) { + try { + 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()); + res.json(stats); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + 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/server.ts b/frontend/server.ts index df4ab1294..b6c765588 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -66,6 +66,7 @@ export function app(locale: string): express.Express { server.get('/address/*', getLocalizedSSR(indexHtml)); server.get('/blocks', getLocalizedSSR(indexHtml)); server.get('/mining/pools', getLocalizedSSR(indexHtml)); + server.get('/mining/pool/*', getLocalizedSSR(indexHtml)); server.get('/graphs', getLocalizedSSR(indexHtml)); server.get('/liquid', getLocalizedSSR(indexHtml)); server.get('/liquid/tx/*', getLocalizedSSR(indexHtml)); diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index aaf545206..4018ed64e 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -26,6 +26,7 @@ import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.com import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; import { AssetsComponent } from './components/assets/assets.component'; +import { PoolComponent } from './components/pool/pool.component'; let routes: Routes = [ { @@ -66,6 +67,10 @@ let routes: Routes = [ path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/pool/:poolId', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, @@ -154,6 +159,10 @@ let routes: Routes = [ path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/pool/:poolId', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, @@ -236,6 +245,10 @@ let routes: Routes = [ path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/pool/:poolId', + component: PoolComponent, + }, { path: 'graphs', component: StatisticsComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 97fc16204..20eb2ea03 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -38,6 +38,7 @@ import { TimeSpanComponent } from './components/time-span/time-span.component'; import { SeoService } from './services/seo.service'; import { MempoolGraphComponent } from './components/mempool-graph/mempool-graph.component'; import { PoolRankingComponent } from './components/pool-ranking/pool-ranking.component'; +import { PoolComponent } from './components/pool/pool.component'; import { LbtcPegsGraphComponent } from './components/lbtc-pegs-graph/lbtc-pegs-graph.component'; import { AssetComponent } from './components/asset/asset.component'; import { AssetsComponent } from './components/assets/assets.component'; @@ -96,6 +97,7 @@ import { AssetGroupComponent } from './components/assets/asset-group/asset-group IncomingTransactionsGraphComponent, MempoolGraphComponent, PoolRankingComponent, + PoolComponent, LbtcPegsGraphComponent, AssetComponent, AssetsComponent, diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.html b/frontend/src/app/components/pool-ranking/pool-ranking.component.html index 8deb8597a..1f6fdbc0e 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.html +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.html @@ -1,7 +1,7 @@
-
+
@@ -58,7 +58,7 @@ {{ pool.rank }} - {{ pool.name }} + {{ pool.name }} {{ pool.lastEstimatedHashrate }} {{ miningStats.miningUnits.hashrateUnit }} {{ pool['blockText'] }} {{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%) diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts index 27515d503..d1b64f190 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts @@ -1,6 +1,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; -import { EChartsOption } from 'echarts'; +import { Router } from '@angular/router'; +import { EChartsOption, PieSeriesOption } from 'echarts'; import { combineLatest, Observable, of } from 'rxjs'; import { catchError, map, share, skip, startWith, switchMap, tap } from 'rxjs/operators'; import { SinglePoolStats } from 'src/app/interfaces/node-api.interface'; @@ -31,6 +32,7 @@ export class PoolRankingComponent implements OnInit, OnDestroy { chartInitOptions = { renderer: 'svg' }; + chartInstance: any = undefined; miningStatsObservable$: Observable; @@ -40,6 +42,7 @@ export class PoolRankingComponent implements OnInit, OnDestroy { private formBuilder: FormBuilder, private miningService: MiningService, private seoService: SeoService, + private router: Router, ) { this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`); this.poolsWindowPreference = this.storageService.getValue('poolsWindowPreference') ? this.storageService.getValue('poolsWindowPreference') : '1w'; @@ -107,7 +110,7 @@ export class PoolRankingComponent implements OnInit, OnDestroy { if (parseFloat(pool.share) < poolShareThreshold) { return; } - data.push({ + data.push({ value: pool.share, name: pool.name + (this.isMobile() ? `` : ` (${pool.share}%)`), label: { @@ -129,7 +132,8 @@ export class PoolRankingComponent implements OnInit, OnDestroy { pool.blockCount.toString() + ` blocks`; } } - } + }, + data: pool.poolId, }); }); return data; @@ -197,6 +201,17 @@ export class PoolRankingComponent implements OnInit, OnDestroy { }; } + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + this.chartInstance.on('click', (e) => { + this.router.navigate(['/mining/pool/', e.data.data]); + }) + } + /** * Default mining stats if something goes wrong */ diff --git a/frontend/src/app/components/pool/pool.component.html b/frontend/src/app/components/pool/pool.component.html new file mode 100644 index 000000000..43bc647e8 --- /dev/null +++ b/frontend/src/app/components/pool/pool.component.html @@ -0,0 +1,113 @@ +
+ +
+

+ + {{ poolStats.pool.name }} +

+ +
+
+
+
+ + + + + + + + + + +
+
+
+
+ +
+
+
+ + + + + + + + + + + + +
Addresses + + ~
Coinbase Tags{{ poolStats.pool.regexes }}
+
+
+ + + + + + + + + + + +
Mined Blocks{{ poolStats.blockCount }}
Empty Blocks{{ poolStats.emptyBlocks.length }}
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
HeightTimestampMinedRewardTransactionsSize
{{ 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.scss b/frontend/src/app/components/pool/pool.component.scss new file mode 100644 index 000000000..271696a39 --- /dev/null +++ b/frontend/src/app/components/pool/pool.component.scss @@ -0,0 +1,41 @@ +.progress { + background-color: #2d3348; +} + +@media (min-width: 768px) { + .d-md-block { + display: table-cell !important; + } +} +@media (min-width: 992px) { + .d-lg-block { + display: table-cell !important; + } +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 830px) { + margin-left: 2%; + flex-direction: row; + float: left; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +div.scrollable { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: auto; + max-height: 100px; +} \ 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 new file mode 100644 index 000000000..9d094dce0 --- /dev/null +++ b/frontend/src/app/components/pool/pool.component.ts @@ -0,0 +1,84 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { distinctUntilChanged, map, startWith, 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', + templateUrl: './pool.component.html', + styleUrls: ['./pool.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PoolComponent implements OnInit { + poolStats$: Observable; + blocks$: Observable; + + fromHeight: number = -1; + fromHeightSubject: BehaviorSubject = new BehaviorSubject(this.fromHeight); + + blocks: BlockExtended[] = []; + poolId: number = undefined; + radioGroupForm: FormGroup; + + constructor( + private apiService: ApiService, + private route: ActivatedRoute, + public stateService: StateService, + private formBuilder: FormBuilder, + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1w' }); + this.radioGroupForm.controls.dateSpan.setValue('1w'); + } + + ngOnInit(): void { + this.poolStats$ = combineLatest([ + this.route.params.pipe(map((params) => params.poolId)), + this.radioGroupForm.get('dateSpan').valueChanges.pipe(startWith('1w')), + ]) + .pipe( + switchMap((params: any) => { + this.poolId = params[0]; + if (this.blocks.length === 0) { + this.fromHeightSubject.next(undefined); + } + return this.apiService.getPoolStats$(this.poolId, params[1] ?? '1w'); + }), + map((poolStats) => { + let regexes = '"'; + for (const regex of poolStats.pool.regexes) { + regexes += regex + '", "'; + } + poolStats.pool.regexes = regexes.slice(0, -3); + poolStats.pool.addresses = poolStats.pool.addresses; + + return Object.assign({ + logo: `./resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg' + }, poolStats); + }) + ); + + this.blocks$ = this.fromHeightSubject + .pipe( + distinctUntilChanged(), + switchMap((fromHeight) => { + return this.apiService.getPoolBlocks$(this.poolId, fromHeight); + }), + tap((newBlocks) => { + this.blocks = this.blocks.concat(newBlocks); + }), + map(() => this.blocks) + ) + } + + loadMore() { + this.fromHeightSubject.next(this.blocks[this.blocks.length - 1]?.height); + } + + 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 373385422..472df0088 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -54,8 +54,11 @@ export interface LiquidPegs { export interface ITranslators { [language: string]: string; } +/** + * PoolRanking component + */ export interface SinglePoolStats { - pooldId: number; + poolId: number; name: string; link: string; blockCount: number; @@ -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 { @@ -88,6 +106,10 @@ export interface BlockExtension { reward?: number; coinbaseTx?: Transaction; matchRate?: number; + pool?: { + id: number; + name: string; + } stage?: number; // Frontend only } diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index c19bf5a41..bf468c467 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,15 @@ 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/${poolId}/blocks` + + (fromHeight !== undefined ? `/${fromHeight}` : '') + ); + } }