diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index 2829e5df1..6b7d2e01d 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -18,23 +18,20 @@ class Mining { * Get historical block reward and total fee */ public async $getHistoricalBlockFees(interval: string | null = null): Promise { - let timeRange: number; - switch (interval) { - case '3y': timeRange = 43200; break; // 12h - case '2y': timeRange = 28800; break; // 8h - case '1y': timeRange = 28800; break; // 8h - case '6m': timeRange = 10800; break; // 3h - case '3m': timeRange = 7200; break; // 2h - case '1m': timeRange = 1800; break; // 30min - case '1w': timeRange = 300; break; // 5min - case '3d': timeRange = 1; break; - case '24h': timeRange = 1; break; - default: timeRange = 86400; break; // 24h - } + return await BlocksRepository.$getHistoricalBlockFees( + this.getTimeRange(interval), + Common.getSqlInterval(interval) + ); + } - interval = Common.getSqlInterval(interval); - - return await BlocksRepository.$getHistoricalBlockFees(timeRange, interval); + /** + * Get historical block rewards + */ + public async $getHistoricalBlockRewards(interval: string | null = null): Promise { + return await BlocksRepository.$getHistoricalBlockRewards( + this.getTimeRange(interval), + Common.getSqlInterval(interval) + ); } /** @@ -345,6 +342,21 @@ class Mining { return date; } + + private getTimeRange(interval: string | null): number { + switch (interval) { + case '3y': return 43200; // 12h + case '2y': return 28800; // 8h + case '1y': return 28800; // 8h + case '6m': return 10800; // 3h + case '3m': return 7200; // 2h + case '1m': return 1800; // 30min + case '1w': return 300; // 5min + case '3d': return 1; + case '24h': return 1; + default: return 86400; // 24h + } + } } export default new Mining(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 591afcfb4..c8e98c0b7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -317,6 +317,7 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/:interval', routes.$getHistoricalHashrate) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', routes.$getRewardStats) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', routes.$getHistoricalBlockFees) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', routes.$getHistoricalBlockRewards) ; } diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index a58d689e9..2ec97ce88 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -468,6 +468,35 @@ class BlocksRepository { throw e; } } + + /** + * Get the historical averaged block rewards + */ + public async $getHistoricalBlockRewards(div: number, interval: string | null): Promise { + let connection; + try { + connection = await DB.getConnection(); + + let query = `SELECT CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, + CAST(AVG(reward) as INT) as avg_rewards + FROM blocks`; + + if (interval !== null) { + query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`; + + const [rows]: any = await connection.query(query); + connection.release(); + + return rows; + } catch (e) { + connection.release(); + logger.err('$getHistoricalBlockRewards() error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } } export default new BlocksRepository(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index c2ddac72c..9d4adb796 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -654,6 +654,22 @@ class Routes { } } + public async $getHistoricalBlockRewards(req: Request, res: Response) { + try { + const blockRewards = await mining.$getHistoricalBlockRewards(req.params.interval ?? null); + 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, + blockRewards: blockRewards, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async getBlock(req: Request, res: Response) { try { const result = await bitcoinApi.$getBlock(req.params.hash); diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 0ff1ee006..57f91b946 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -34,6 +34,7 @@ import { MiningStartComponent } from './components/mining-start/mining-start.com import { GraphsComponent } from './components/graphs/graphs.component'; import { BlocksList } from './components/blocks-list/blocks-list.component'; import { BlockFeesGraphComponent } from './components/block-fees-graph/block-fees-graph.component'; +import { BlockRewardsGraphComponent } from './components/block-rewards-graph/block-rewards-graph.component'; let routes: Routes = [ { @@ -121,6 +122,10 @@ let routes: Routes = [ { path: 'mining/block-fees', component: BlockFeesGraphComponent, + }, + { + path: 'mining/block-rewards', + component: BlockRewardsGraphComponent, } ], }, @@ -255,6 +260,10 @@ let routes: Routes = [ { path: 'mining/block-fees', component: BlockFeesGraphComponent, + }, + { + path: 'mining/block-rewards', + component: BlockRewardsGraphComponent, } ] }, @@ -383,6 +392,10 @@ let routes: Routes = [ { path: 'mining/block-fees', component: BlockFeesGraphComponent, + }, + { + path: 'mining/block-rewards', + component: BlockRewardsGraphComponent, } ] }, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 4536a2ff1..470284591 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -81,6 +81,7 @@ import { BlocksList } from './components/blocks-list/blocks-list.component'; import { RewardStatsComponent } from './components/reward-stats/reward-stats.component'; import { DataCyDirective } from './data-cy.directive'; import { BlockFeesGraphComponent } from './components/block-fees-graph/block-fees-graph.component'; +import { BlockRewardsGraphComponent } from './components/block-rewards-graph/block-rewards-graph.component'; @NgModule({ declarations: [ @@ -143,6 +144,7 @@ import { BlockFeesGraphComponent } from './components/block-fees-graph/block-fee DataCyDirective, RewardStatsComponent, BlockFeesGraphComponent, + BlockRewardsGraphComponent, ], imports: [ BrowserModule.withServerTransition({ appId: 'serverApp' }), diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html new file mode 100644 index 000000000..c2a3bcf00 --- /dev/null +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html @@ -0,0 +1,64 @@ +
+ +
+ Block rewards +
+
+ + + + + + + + + + +
+
+
+ +
+
+
+
+
+ +
+ + +
+
+
Hashrate
+

+ +

+
+
+
Difficulty
+

+ +

+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss new file mode 100644 index 000000000..54dbe5fad --- /dev/null +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss @@ -0,0 +1,135 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + padding: 0px 15px; + width: 100%; + min-height: 500px; + height: calc(100% - 150px); + @media (max-width: 992px) { + height: 100%; + padding-bottom: 100px; + }; +} + +.chart { + width: 100%; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 270px; +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 1130px) { + position: relative; + top: -65px; + } + @media (min-width: 830px) and (max-width: 1130px) { + position: relative; + top: 0px; + } + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +.pool-distribution { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: #4a68b9; + } + .card-text { + font-size: 18px; + span { + color: #ffffff66; + font-size: 12px; + } + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts new file mode 100644 index 000000000..a22617922 --- /dev/null +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts @@ -0,0 +1,200 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { EChartsOption, graphic } from 'echarts'; +import { Observable } from 'rxjs'; +import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from 'src/app/services/api.service'; +import { SeoService } from 'src/app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { formatterXAxisLabel } from 'src/app/shared/graphs.utils'; +import { MiningService } from 'src/app/services/mining.service'; +import { StorageService } from 'src/app/services/storage.service'; + +@Component({ + selector: 'app-block-rewards-graph', + templateUrl: './block-rewards-graph.component.html', + styleUrls: ['./block-rewards-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlockRewardsGraphComponent implements OnInit { + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + miningWindowPreference: string; + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: FormBuilder, + private miningService: MiningService, + private storageService: StorageService + ) { + } + + ngOnInit(): void { + this.seoService.setTitle($localize`:@@mining.block-reward:Block Reward`); + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + this.statsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith(this.miningWindowPreference), + switchMap((timespan) => { + this.storageService.setValue('miningWindowPreference', timespan); + this.timespan = timespan; + this.isLoading = true; + return this.apiService.getHistoricalBlockRewards$(timespan) + .pipe( + tap((data: any) => { + this.prepareChartOptions({ + blockRewards: data.blockRewards.map(val => [val.timestamp * 1000, val.avg_rewards / 100000000]), + }); + this.isLoading = false; + }), + map((data: any) => { + const availableTimespanDay = ( + (new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp) + ) / 3600 / 24; + + return { + availableTimespanDay: availableTimespanDay, + }; + }), + ); + }), + share() + ); + } + + 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' } + ]), + ], + grid: { + top: 20, + bottom: 80, + right: this.right, + left: this.left, + }, + 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: (ticks) => { + const tick = ticks[0]; + const rewardsString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.3-3')} BTC`; + return ` + ${tick.axisValueLabel}
+ ${rewardsString} + `; + } + }, + xAxis: { + name: formatterXAxisLabel(this.locale, this.timespan), + nameLocation: 'middle', + nameTextStyle: { + padding: [10, 0, 0, 0], + }, + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + }, + yAxis: [ + { + min: value => Math.round(10 * value.min * 0.99) / 10, + max: value => Math.round(10 * value.max * 1.01) / 10, + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${val} BTC`; + } + }, + splitLine: { + show: false, + } + }, + ], + series: [ + { + zlevel: 0, + name: 'Reward', + showSymbol: false, + symbol: 'none', + data: data.blockRewards, + type: 'line', + lineStyle: { + width: 2, + }, + }, + ], + dataZoom: [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 10, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: 20, + right: 15, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + areaStyle: { + opacity: 0, + } + }, + }], + }; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } +} diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index 97654beb5..e3bdb0629 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -20,8 +20,12 @@ [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" i18n="mining.block-fees"> Block Fees + + Block Rewards + - \ No newline at end of file + 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 49004c225..93f17dcdf 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html @@ -70,4 +70,4 @@

- \ No newline at end of file + diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 4011993b2..16a8d21d5 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -173,7 +173,14 @@ export class ApiService { this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/fees` + (interval !== undefined ? `/${interval}` : '') ); -} + } + + getHistoricalBlockRewards$(interval: string | undefined) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/rewards` + + (interval !== undefined ? `/${interval}` : '') + ); + } getRewardStats$(blockCount: number = 144): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); diff --git a/production/nginx-cache-warmer b/production/nginx-cache-warmer index 21824b80b..3c3204493 100755 --- a/production/nginx-cache-warmer +++ b/production/nginx-cache-warmer @@ -47,6 +47,16 @@ do for url in / \ '/api/v1/mining/blocks/fees/2y' \ '/api/v1/mining/blocks/fees/3y' \ '/api/v1/mining/blocks/fees/all' \ + '/api/v1/mining/blocks/rewards/24h' \ + '/api/v1/mining/blocks/rewards/3d' \ + '/api/v1/mining/blocks/rewards/1w' \ + '/api/v1/mining/blocks/rewards/1m' \ + '/api/v1/mining/blocks/rewards/3m' \ + '/api/v1/mining/blocks/rewards/6m' \ + '/api/v1/mining/blocks/rewards/1y' \ + '/api/v1/mining/blocks/rewards/2y' \ + '/api/v1/mining/blocks/rewards/3y' \ + '/api/v1/mining/blocks/rewards/all' \ do curl -s "https://${hostname}${url}" >/dev/null