diff --git a/frontend/src/app/lightning/channels-list/channels-list.component.ts b/frontend/src/app/lightning/channels-list/channels-list.component.ts index debf2467a..0ac7da578 100644 --- a/frontend/src/app/lightning/channels-list/channels-list.component.ts +++ b/frontend/src/app/lightning/channels-list/channels-list.component.ts @@ -40,6 +40,8 @@ export class ChannelsListComponent implements OnInit, OnChanges { } ngOnChanges(): void { + this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false }) + this.channels$ = combineLatest([ this.channelsPage$, this.channelStatusForm.get('status').valueChanges.pipe(startWith(this.defaultStatus)) diff --git a/frontend/src/app/lightning/lightning-api.service.ts b/frontend/src/app/lightning/lightning-api.service.ts index 9197f4f02..5a6e63305 100644 --- a/frontend/src/app/lightning/lightning-api.service.ts +++ b/frontend/src/app/lightning/lightning-api.service.ts @@ -41,4 +41,8 @@ export class LightningApiService { listTopNodes$(): Observable { return this.httpClient.get(API_BASE_URL + '/nodes/top'); } + + listStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics'); + } } diff --git a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html index 31b0784d6..23c2c80ae 100644 --- a/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html +++ b/frontend/src/app/lightning/lightning-dashboard/lightning-dashboard.component.html @@ -24,6 +24,20 @@ +
+
+
+ +
+
+
+ +
+
+ +
+
+
diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index a8cad3dc9..48fc1c696 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -13,6 +13,8 @@ import { ChannelComponent } from './channel/channel.component'; import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component'; import { ChannelBoxComponent } from './channel/channel-box/channel-box.component'; import { ClosingTypeComponent } from './channel/closing-type/closing-type.component'; +import { LightningStatisticsChartComponent } from './statistics-chart/lightning-statistics-chart.component'; +import { NgxEchartsModule } from 'ngx-echarts'; @NgModule({ declarations: [ LightningDashboardComponent, @@ -24,12 +26,16 @@ import { ClosingTypeComponent } from './channel/closing-type/closing-type.compon LightningWrapperComponent, ChannelBoxComponent, ClosingTypeComponent, + LightningStatisticsChartComponent, ], imports: [ CommonModule, SharedModule, RouterModule, LightningRoutingModule, + NgxEchartsModule.forRoot({ + echarts: () => import('echarts') + }) ], providers: [ LightningApiService, diff --git a/frontend/src/app/lightning/node/node.component.html b/frontend/src/app/lightning/node/node.component.html index 93bb67e61..19f730d3e 100644 --- a/frontend/src/app/lightning/node/node.component.html +++ b/frontend/src/app/lightning/node/node.component.html @@ -91,20 +91,6 @@ - -

diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html new file mode 100644 index 000000000..252947352 --- /dev/null +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html @@ -0,0 +1,32 @@ +
+ +
+ Hashrate & Difficulty + +
+ +
+
+
+
+ +
+ + +
+
+
Hashrate
+

+ +

+
+
+
Difficulty
+

+ +

+
+
+
diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss new file mode 100644 index 000000000..fa044a4d6 --- /dev/null +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.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: 991px) { + position: relative; + top: -65px; + } + @media (min-width: 830px) and (max-width: 991px) { + 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; +} \ No newline at end of file diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts new file mode 100644 index 000000000..3d5a81afd --- /dev/null +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts @@ -0,0 +1,301 @@ +import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; +import { EChartsOption, graphic } from 'echarts'; +import { Observable } from 'rxjs'; +import { startWith, switchMap, tap } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { StorageService } from 'src/app/services/storage.service'; +import { MiningService } from 'src/app/services/mining.service'; +import { download } from 'src/app/shared/graphs.utils'; +import { LightningApiService } from '../lightning-api.service'; + +@Component({ + selector: 'app-lightning-statistics-chart', + templateUrl: './lightning-statistics-chart.component.html', + styleUrls: ['./lightning-statistics-chart.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], +}) +export class LightningStatisticsChartComponent implements OnInit { + @Input() right: number | string = 65; + @Input() left: number | string = 55; + @Input() widget = false; + + miningWindowPreference: string; + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + @HostBinding('attr.dir') dir = 'ltr'; + + blockSizesWeightsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private lightningApiService: LightningApiService, + private formBuilder: FormBuilder, + private storageService: StorageService, + private miningService: MiningService, + ) { + } + + ngOnInit(): void { + let firstRun = true; + + this.seoService.setTitle($localize`:@@mining.hashrate-difficulty:Hashrate and Weight`); + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith(this.miningWindowPreference), + switchMap((timespan) => { + this.timespan = timespan; + if (!firstRun) { + this.storageService.setValue('miningWindowPreference', timespan); + } + firstRun = false; + this.miningWindowPreference = timespan; + this.isLoading = true; + return this.lightningApiService.listStatistics$() + .pipe( + tap((data) => { + this.prepareChartOptions({ + nodes: data.map(val => [val.added * 1000, val.node_count]), + capacity: data.map(val => [val.added * 1000, val.total_capacity]), + }); + this.isLoading = false; + }), + ); + }), + ).subscribe(() => { + }); + } + + prepareChartOptions(data) { + let title: object; + if (data.nodes.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: `Indexing in progess`, + left: 'center', + top: 'center' + }; + } + + this.chartOptions = { + title: title, + animation: false, + color: [ + '#FDD835', + '#D81B60', + ], + grid: { + top: 30, + bottom: 70, + 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) => { + let sizeString = ''; + let weightString = ''; + + for (const tick of ticks) { + if (tick.seriesIndex === 0) { // Nodes + sizeString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.2-2')}`; + } else if (tick.seriesIndex === 1) { // Capacity + weightString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100000000, this.locale, '1.0-0')} BTC`; + } + } + + const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); + + let tooltip = `${date}
+ ${sizeString}
+ ${weightString}`; + + return tooltip; + } + }, + xAxis: data.nodes.length === 0 ? undefined : { + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + axisLabel: { + hideOverlap: true, + } + }, + legend: data.nodes.length === 0 ? undefined : { + padding: 10, + data: [ + { + name: 'Nodes', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'Capacity', + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + ], + selected: JSON.parse(this.storageService.getValue('sizes_ln_legend')) ?? { + 'Nodes': true, + 'Capacity': true, + } + }, + yAxis: data.nodes.length === 0 ? undefined : [ + { + min: (value) => { + return value.min * 0.9; + }, + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${Math.round(val)}`; + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: '#ffffff66', + opacity: 0.25, + } + }, + }, + { + min: (value) => { + return value.min * 0.9; + }, + type: 'value', + position: 'right', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${val / 100000000} BTC`; + } + }, + splitLine: { + show: false, + } + } + ], + series: data.nodes.length === 0 ? [] : [ + { + zlevel: 0, + name: 'Nodes', + showSymbol: false, + symbol: 'none', + data: data.nodes, + type: 'line', + lineStyle: { + width: 2, + }, + markLine: { + silent: true, + symbol: 'none', + lineStyle: { + type: 'solid', + color: '#ffffff66', + opacity: 1, + width: 1, + }, + data: [{ + yAxis: 1, + label: { + position: 'end', + show: true, + color: '#ffffff', + formatter: `1 MB` + } + }], + } + }, + { + zlevel: 1, + yAxisIndex: 1, + name: 'Capacity', + showSymbol: false, + symbol: 'none', + data: data.capacity, + type: 'line', + lineStyle: { + width: 2, + } + } + ], + }; + } + + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + + this.chartInstance.on('legendselectchanged', (e) => { + this.storageService.setValue('sizes_ln_legend', JSON.stringify(e.selected)); + }); + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + + onSaveChart() { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + const now = new Date(); + // @ts-ignore + this.chartOptions.grid.bottom = 40; + this.chartOptions.backgroundColor = '#11131f'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + }), `block-sizes-weights-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } +} diff --git a/lightning-backend/src/api/explorer/general.routes.ts b/lightning-backend/src/api/explorer/general.routes.ts index 326602d55..d6154bdc1 100644 --- a/lightning-backend/src/api/explorer/general.routes.ts +++ b/lightning-backend/src/api/explorer/general.routes.ts @@ -2,12 +2,14 @@ import config from '../../config'; import { Express, Request, Response } from 'express'; import nodesApi from './nodes.api'; import channelsApi from './channels.api'; +import statisticsApi from './statistics.api'; class GeneralRoutes { constructor() { } public initRoutes(app: Express) { app .get(config.MEMPOOL.API_URL_PREFIX + 'search', this.$searchNodesAndChannels) + .get(config.MEMPOOL.API_URL_PREFIX + 'statistics', this.$getStatistics) ; } @@ -27,6 +29,15 @@ class GeneralRoutes { res.status(500).send(e instanceof Error ? e.message : e); } } + + private async $getStatistics(req: Request, res: Response) { + try { + const statistics = await statisticsApi.$getStatistics(); + res.json(statistics); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } } export default new GeneralRoutes(); diff --git a/lightning-backend/src/api/explorer/statistics.api.ts b/lightning-backend/src/api/explorer/statistics.api.ts new file mode 100644 index 000000000..620e76fef --- /dev/null +++ b/lightning-backend/src/api/explorer/statistics.api.ts @@ -0,0 +1,17 @@ +import logger from '../../logger'; +import DB from '../../database'; + +class StatisticsApi { + public async $getStatistics(): Promise { + try { + const query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, node_count, total_capacity FROM statistics ORDER BY id DESC`; + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('$getStatistics error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } +} + +export default new StatisticsApi();