diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index c5e38da57..97a428c23 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -218,7 +218,7 @@ class BitcoinApi implements AbstractBitcoinApi { if (map[outputType]) { return map[outputType]; } else { - return ''; + return 'unknown'; } } diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index 4423e5f16..2e094229b 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -1,5 +1,5 @@ import { PoolInfo, PoolStats } from '../mempool.interfaces'; -import BlocksRepository, { EmptyBlocks } from '../repositories/BlocksRepository'; +import BlocksRepository from '../repositories/BlocksRepository'; import PoolsRepository from '../repositories/PoolsRepository'; import HashratesRepository from '../repositories/HashratesRepository'; import bitcoinClient from './bitcoin/bitcoin-client'; @@ -20,25 +20,21 @@ class Mining { const poolsStatistics = {}; const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval); - const emptyBlocks: EmptyBlocks[] = await BlocksRepository.$getEmptyBlocks(null, interval); + const emptyBlocks: any[] = await BlocksRepository.$countEmptyBlocks(null, interval); const poolsStats: PoolStats[] = []; let rank = 1; poolsInfo.forEach((poolInfo: PoolInfo) => { + const emptyBlocksCount = emptyBlocks.filter((emptyCount) => emptyCount.poolId === poolInfo.poolId); const poolStat: PoolStats = { poolId: poolInfo.poolId, // mysql row id name: poolInfo.name, link: poolInfo.link, blockCount: poolInfo.blockCount, rank: rank++, - emptyBlocks: 0 + emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0 }; - for (let i = 0; i < emptyBlocks.length; ++i) { - if (emptyBlocks[i].poolId === poolInfo.poolId) { - poolStat.emptyBlocks++; - } - } poolsStats.push(poolStat); }); @@ -58,19 +54,19 @@ class Mining { /** * Get all mining pool stats for a pool */ - public async $getPoolStat(interval: string | null, poolId: number): Promise { + public async $getPoolStat(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); + const blockCount: number = await BlocksRepository.$blockCount(poolId); + const emptyBlocksCount = await BlocksRepository.$countEmptyBlocks(poolId); return { pool: pool, blockCount: blockCount, - emptyBlocks: emptyBlocks, + emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0, }; } diff --git a/backend/src/index.ts b/backend/src/index.ts index 4ede865a6..c2de54521 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -299,6 +299,7 @@ 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/hashrate', routes.$getPoolHistoricalHashrate) .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) diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 844f62bad..2364a8485 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -3,11 +3,6 @@ import { DB } from '../database'; import logger from '../logger'; import { Common } from '../api/common'; -export interface EmptyBlocks { - emptyBlocks: number; - poolId: number; -} - class BlocksRepository { /** * Save indexed block data in the database @@ -100,12 +95,13 @@ class BlocksRepository { /** * Get empty blocks for one or all pools */ - public async $getEmptyBlocks(poolId: number | null, interval: string | null = null): Promise { + public async $countEmptyBlocks(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 + let query = `SELECT count(height) as count, pools.id as poolId FROM blocks + JOIN pools on pools.id = blocks.pool_id WHERE tx_count = 1`; if (poolId) { @@ -117,13 +113,14 @@ class BlocksRepository { query += ` AND blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; } - // logger.debug(query); + query += ` GROUP by pools.id`; + const connection = await DB.pool.getConnection(); try { const [rows] = await connection.query(query, params); connection.release(); - return rows; + return rows; } catch (e) { connection.release(); logger.err('$getEmptyBlocks() error' + (e instanceof Error ? e.message : e)); @@ -134,7 +131,7 @@ class BlocksRepository { /** * Get blocks count for a period */ - public async $blockCount(poolId: number | null, interval: string | null): Promise { + public async $blockCount(poolId: number | null, interval: string | null = null): Promise { interval = Common.getSqlInterval(interval); const params: any[] = []; diff --git a/backend/src/repositories/HashratesRepository.ts b/backend/src/repositories/HashratesRepository.ts index 3523004d5..749d3cb57 100644 --- a/backend/src/repositories/HashratesRepository.ts +++ b/backend/src/repositories/HashratesRepository.ts @@ -116,6 +116,52 @@ class HashratesRepository { } } + /** + * Returns a pool hashrate history + */ + public async $getPoolWeeklyHashrate(poolId: number): Promise { + const connection = await DB.pool.getConnection(); + + // Find hashrate boundaries + let query = `SELECT MIN(hashrate_timestamp) as firstTimestamp, MAX(hashrate_timestamp) as lastTimestamp + FROM hashrates + JOIN pools on pools.id = pool_id + WHERE hashrates.type = 'weekly' AND pool_id = ? AND avg_hashrate != 0 + ORDER by hashrate_timestamp LIMIT 1`; + + let boundaries = { + firstTimestamp: '1970-01-01', + lastTimestamp: '9999-01-01' + }; + try { + const [rows]: any[] = await connection.query(query, [poolId]); + boundaries = rows[0]; + connection.release(); + } catch (e) { + connection.release(); + logger.err('$getPoolWeeklyHashrate() error' + (e instanceof Error ? e.message : e)); + } + + // Get hashrates entries between boundaries + query = `SELECT UNIX_TIMESTAMP(hashrate_timestamp) as timestamp, avg_hashrate as avgHashrate, share, pools.name as poolName + FROM hashrates + JOIN pools on pools.id = pool_id + WHERE hashrates.type = 'weekly' AND hashrate_timestamp BETWEEN ? AND ? + AND pool_id = ? + ORDER by hashrate_timestamp`; + + try { + const [rows]: any[] = await connection.query(query, [boundaries.firstTimestamp, boundaries.lastTimestamp, poolId]); + connection.release(); + + return rows; + } catch (e) { + connection.release(); + logger.err('$getPoolWeeklyHashrate() error' + (e instanceof Error ? e.message : e)); + throw e; + } + } + public async $setLatestRunTimestamp(key: string, val: any = null) { const connection = await DB.pool.getConnection(); const query = `UPDATE state SET number = ? WHERE name = ?`; @@ -136,6 +182,9 @@ class HashratesRepository { const [rows] = await connection.query(query, [key]); connection.release(); + if (rows.length === 0) { + return 0; + } return rows[0]['number']; } catch (e) { connection.release(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index d36037d48..710cd8378 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -538,7 +538,7 @@ class Routes { public async $getPool(req: Request, res: Response) { try { - const stats = await mining.$getPoolStat(req.params.interval ?? null, parseInt(req.params.poolId, 10)); + const stats = await mining.$getPoolStat(parseInt(req.params.poolId, 10)); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); @@ -603,6 +603,22 @@ class Routes { } } + public async $getPoolHistoricalHashrate(req: Request, res: Response) { + try { + const hashrates = await HashratesRepository.$getPoolWeeklyHashrate(parseInt(req.params.poolId, 10)); + const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp(); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + res.json({ + oldestIndexedBlockTimestamp: oldestIndexedBlockTimestamp, + hashrates: hashrates, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async $getHistoricalHashrate(req: Request, res: Response) { try { const hashrates = await HashratesRepository.$getNetworkDailyHashrate(req.params.interval ?? null); diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html index 79d7a9f1b..b208d9c41 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html @@ -27,7 +27,7 @@
-
+
@@ -45,9 +45,19 @@ {{ diffChange.height }} {{ diffChange.difficultyShorten }} - {{ formatNumber(diffChange.change, locale, '1.2-2') }}% + + {{ diffChange.change >= 0 ? '+' : '' }}{{ formatNumber(diffChange.change, locale, '1.2-2') }}% + + + + + + + + +
diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts index 948ffed41..582dee263 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts @@ -125,7 +125,21 @@ export class HashrateChartComponent implements OnInit { } prepareChartOptions(data) { + let title = undefined; + if (data.hashrates.length === 0) { + title = { + textStyle: { + color: "grey", + fontSize: 15 + }, + text: "Indexing in progress...", + left: "center", + top: "center" + }; + } + this.chartOptions = { + title: title, color: [ new graphic.LinearGradient(0, 0, 0, 0.65, [ { offset: 0, color: '#F4511E' }, diff --git a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts index 7e3f081a6..016f7a319 100644 --- a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts +++ b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts @@ -124,7 +124,21 @@ export class HashrateChartPoolsComponent implements OnInit { } prepareChartOptions(data) { + let title = undefined; + if (data.series.length === 0) { + title = { + textStyle: { + color: "grey", + fontSize: 15 + }, + text: "Indexing in progress...", + left: "center", + top: this.widget ? 115 : this.isMobile() ? 'center' : 225, + }; + } + this.chartOptions = { + title: title, grid: { right: this.right, left: this.left, diff --git a/frontend/src/app/components/pool/pool.component.html b/frontend/src/app/components/pool/pool.component.html index 43bc647e8..3c2b78a34 100644 --- a/frontend/src/app/components/pool/pool.component.html +++ b/frontend/src/app/components/pool/pool.component.html @@ -1,50 +1,12 @@
-
-

- +
+

+ {{ poolStats.pool.name }}

-
-
-
-
- - - - - - - - - - -
-
-
-
-
@@ -54,10 +16,13 @@ Addresses - ~ + + ~ + Coinbase Tags @@ -75,7 +40,7 @@ Empty Blocks - {{ poolStats.emptyBlocks.length }} + {{ poolStats.emptyBlocks }} @@ -84,25 +49,36 @@
- +
+
+
+
+ +
- - + + - + - - - + + + @@ -110,4 +86,53 @@
Height Timestamp MinedRewardTransactionsRewardTransactions Size
{{ block.height }} ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}{{ block.tx_count | number }} + + + + {{ block.tx_count | number }}
-
+
-
\ No newline at end of file +
+ + +
+

+ +
+

+ +
+
+
+ + + + + + + + + + + + + + +
Addresses +
+
+
+
~
Coinbase Tags
+
+
+ + + + + + + + + + + +
Mined Blocks
Empty Blocks
+
+
+
+
+
\ 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 index 271696a39..2dfd85881 100644 --- a/frontend/src/app/components/pool/pool.component.scss +++ b/frontend/src/app/components/pool/pool.component.scss @@ -18,9 +18,8 @@ display: flex; flex-direction: column; @media (min-width: 830px) { - margin-left: 2%; flex-direction: row; - float: left; + float: right; margin-top: 0px; } .btn-sm { @@ -38,4 +37,14 @@ div.scrollable { padding: 0; overflow: auto; max-height: 100px; +} + +.skeleton-loader { + width: 100%; + max-width: 90px; +} + +.table { + margin: 0px auto; + max-width: 900px; } \ 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 9d094dce0..89d398ca3 100644 --- a/frontend/src/app/components/pool/pool.component.ts +++ b/frontend/src/app/components/pool/pool.component.ts @@ -1,51 +1,77 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith, switchMap, tap } from 'rxjs/operators'; +import { EChartsOption, graphic } from 'echarts'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged, map, switchMap, tap, toArray } 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'; +import { selectPowerOfTen } from 'src/app/bitcoin.utils'; +import { formatNumber } from '@angular/common'; @Component({ selector: 'app-pool', templateUrl: './pool.component.html', styleUrls: ['./pool.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], changeDetection: ChangeDetectionStrategy.OnPush }) export class PoolComponent implements OnInit { + @Input() right: number | string = 45; + @Input() left: number | string = 75; + poolStats$: Observable; blocks$: Observable; + isLoading = true; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + width: 'auto', + height: 'auto', + }; fromHeight: number = -1; fromHeightSubject: BehaviorSubject = new BehaviorSubject(this.fromHeight); blocks: BlockExtended[] = []; poolId: number = undefined; - radioGroupForm: FormGroup; constructor( + @Inject(LOCALE_ID) public locale: string, 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')), - ]) + this.poolStats$ = this.route.params.pipe(map((params) => params.poolId)) .pipe( - switchMap((params: any) => { - this.poolId = params[0]; + switchMap((poolId: any) => { + this.isLoading = true; + this.poolId = poolId; + return this.apiService.getPoolHashrate$(this.poolId) + .pipe( + switchMap((data) => { + this.isLoading = false; + this.prepareChartOptions(data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate])); + return poolId; + }), + ) + }), + switchMap(() => { if (this.blocks.length === 0) { this.fromHeightSubject.next(undefined); } - return this.apiService.getPoolStats$(this.poolId, params[1] ?? '1w'); + return this.apiService.getPoolStats$(this.poolId); }), map((poolStats) => { let regexes = '"'; @@ -74,6 +100,96 @@ export class PoolComponent implements OnInit { ) } + prepareChartOptions(data) { + this.chartOptions = { + animation: false, + color: [ + new graphic.LinearGradient(0, 0, 0, 0.65, [ + { offset: 0, color: '#F4511E' }, + { offset: 0.25, color: '#FB8C00' }, + { offset: 0.5, color: '#FFB300' }, + { offset: 0.75, color: '#FDD835' }, + { offset: 1, color: '#7CB342' } + ]), + '#D81B60', + ], + grid: { + right: this.right, + left: this.left, + bottom: 60, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + align: 'left', + }, + borderColor: '#000', + formatter: function (data) { + let hashratePowerOfTen: any = selectPowerOfTen(1); + let hashrate = data[0].data[1]; + + if (this.isMobile()) { + hashratePowerOfTen = selectPowerOfTen(data[0].data[1]); + hashrate = Math.round(data[0].data[1] / hashratePowerOfTen.divider); + } + + return ` + ${data[0].axisValueLabel}
+ ${data[0].marker} ${data[0].seriesName}: ${formatNumber(hashrate, this.locale, '1.0-0')} ${hashratePowerOfTen.unit}H/s
+ `; + }.bind(this) + }, + xAxis: { + type: 'time', + splitNumber: (this.isMobile()) ? 5 : 10, + }, + yAxis: [ + { + min: function (value) { + return value.min * 0.9; + }, + type: 'value', + name: 'Hashrate', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + const selectedPowerOfTen: any = selectPowerOfTen(val); + const newVal = Math.round(val / selectedPowerOfTen.divider); + return `${newVal} ${selectedPowerOfTen.unit}H/s` + } + }, + splitLine: { + show: false, + } + }, + ], + series: [ + { + name: 'Hashrate', + showSymbol: false, + symbol: 'none', + data: data, + type: 'line', + lineStyle: { + width: 2, + }, + }, + ], + }; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + loadMore() { this.fromHeightSubject.next(this.blocks[this.blocks.length - 1]?.height); } diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 5548780b1..858da3273 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -136,11 +136,12 @@ export class ApiService { ); } - getPoolStats$(poolId: number, interval: string | undefined): Observable { - return this.httpClient.get( - this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}` + - (interval !== undefined ? `/${interval}` : '') - ); + getPoolStats$(poolId: number): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}`); + } + + getPoolHashrate$(poolId: number): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}/hashrate`); } getPoolBlocks$(poolId: number, fromHeight: number): Observable { diff --git a/production/nginx-cache-warmer b/production/nginx-cache-warmer index 2eab09dd1..38db73215 100755 --- a/production/nginx-cache-warmer +++ b/production/nginx-cache-warmer @@ -38,5 +38,13 @@ do for url in / \ curl -s "https://${hostname}${url}" >/dev/null done + counter=1 + while [ $counter -le 134 ] + do + curl -s "https://${hostname}/api/v1/mining/pool/${counter}/hashrate" >/dev/null + curl -s "https://${hostname}/api/v1/mining/pool/${counter}" >/dev/null + ((counter++)) + done + sleep 10 done