From 2fd34cbd91656695b93ff431368bc93c1f441d6e Mon Sep 17 00:00:00 2001 From: nymkappa Date: Tue, 12 Jul 2022 22:32:13 +0200 Subject: [PATCH 01/18] Get nodes count per AS by calling `/lightning/nodes/asShare` API --- backend/src/api/explorer/nodes.api.ts | 29 ++++++++++++++++++++++++ backend/src/api/explorer/nodes.routes.ts | 13 +++++++++++ 2 files changed, 42 insertions(+) diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 590ed1f20..f76975d0a 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -93,6 +93,35 @@ class NodesApi { throw e; } } + + public async $getNodesAsShare() { + try { + let query = `SELECT names, COUNT(*) as nodesCount from nodes + JOIN geo_names ON geo_names.id = nodes.as_number + GROUP BY as_number + ORDER BY COUNT(*) DESC + LIMIT 20 + `; + const [nodesCountPerAS]: any = await DB.query(query); + + query = `SELECT COUNT(*) as total FROM nodes WHERE as_number IS NOT NULL`; + const [nodesWithAS]: any = await DB.query(query); + + const nodesPerAs: any[] = []; + for (const as of nodesCountPerAS) { + nodesPerAs.push({ + name: JSON.parse(as.names), + count: as.nodesCount, + share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100, + }) + } + + return nodesPerAs; + } catch (e) { + logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`); + throw e; + } + } } export default new NodesApi(); diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index 6c79c8201..d2960155b 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -8,6 +8,7 @@ class NodesRoutes { app .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/asShare', this.$getNodesAsShare) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode) ; @@ -56,6 +57,18 @@ class NodesRoutes { res.status(500).send(e instanceof Error ? e.message : e); } } + + private async $getNodesAsShare(req: Request, res: Response) { + try { + const nodesPerAs = await nodesApi.$getNodesAsShare(); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); + res.json(nodesPerAs); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } } export default new NodesRoutes(); From 28cf0f71eb2193f6e42dfe15e4946f25b6707584 Mon Sep 17 00:00:00 2001 From: nymkappa Date: Sat, 16 Jul 2022 10:44:05 +0200 Subject: [PATCH 02/18] Add nodes AS share chart and table component --- backend/src/api/explorer/nodes.api.ts | 1 - .../components/graphs/graphs.component.html | 2 + .../src/app/graphs/graphs.routing.module.ts | 5 + .../src/app/lightning/lightning.module.ts | 2 + .../nodes-per-as-chart.component.html | 41 ++++ .../nodes-per-as-chart.component.scss | 36 +++ .../nodes-per-as-chart.component.ts | 210 ++++++++++++++++++ frontend/src/app/services/api.service.ts | 3 + 8 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html create mode 100644 frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.scss create mode 100644 frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index f76975d0a..3bfa4d50e 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -100,7 +100,6 @@ class NodesApi { JOIN geo_names ON geo_names.id = nodes.as_number GROUP BY as_number ORDER BY COUNT(*) DESC - LIMIT 20 `; const [nodesCountPerAS]: any = await DB.query(query); diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index 6f93676f6..c75aac232 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -34,6 +34,8 @@ i18n="lightning.nodes-networks">Nodes per network Network capacity + Nodes per AS diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index 8bf531d80..193c6ab61 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -20,6 +20,7 @@ import { TelevisionComponent } from '../components/television/television.compone import { DashboardComponent } from '../dashboard/dashboard.component'; import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component'; import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component'; +import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component'; const browserWindow = window || {}; // @ts-ignore @@ -99,6 +100,10 @@ const routes: Routes = [ path: 'lightning/capacity', component: LightningStatisticsChartComponent, }, + { + path: 'lightning/nodes-per-as', + component: NodesPerAsChartComponent, + }, { path: '', redirectTo: 'mempool', diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index 9146975e4..1cf9992f6 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -18,6 +18,7 @@ import { NodeStatisticsChartComponent } from './node-statistics-chart/node-stati import { GraphsModule } from '../graphs/graphs.module'; import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component'; import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component'; +import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component'; @NgModule({ declarations: [ LightningDashboardComponent, @@ -33,6 +34,7 @@ import { ChannelsStatisticsComponent } from './channels-statistics/channels-stat LightningStatisticsChartComponent, NodesNetworksChartComponent, ChannelsStatisticsComponent, + NodesPerAsChartComponent, ], imports: [ CommonModule, diff --git a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html new file mode 100644 index 000000000..16ba4fea6 --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html @@ -0,0 +1,41 @@ +
+ +
+ Nodes per AS + +
+ +
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
RankNameHashrateNodes
{{ asEntry.rank }}{{ asEntry.name }}{{ asEntry.share }}%{{ asEntry.count }}
+
+ +
\ No newline at end of file diff --git a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.scss b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.scss new file mode 100644 index 000000000..832880122 --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.scss @@ -0,0 +1,36 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } +} + +.full-container { + padding: 0px 15px; + width: 100%; + height: calc(100% - 140px); + @media (max-width: 992px) { + height: calc(100% - 190px); + }; + @media (max-width: 575px) { + height: calc(100% - 230px); + }; +} + +.chart { + max-height: 400px; + @media (max-width: 767.98px) { + max-height: 230px; + margin-top: -35px; + } +} + +.bottom-padding { + @media (max-width: 992px) { + padding-bottom: 65px + }; + @media (max-width: 576px) { + padding-bottom: 65px + }; +} diff --git a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts new file mode 100644 index 000000000..cc8d5e759 --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts @@ -0,0 +1,210 @@ +import { ChangeDetectionStrategy, Component, OnInit, HostBinding } from '@angular/core'; +import { EChartsOption, PieSeriesOption } from 'echarts'; +import { map, Observable, share, tap } from 'rxjs'; +import { chartColors } from 'src/app/app.constants'; +import { ApiService } from 'src/app/services/api.service'; +import { SeoService } from 'src/app/services/seo.service'; +import { download } from 'src/app/shared/graphs.utils'; + +@Component({ + selector: 'app-nodes-per-as-chart', + templateUrl: './nodes-per-as-chart.component.html', + styleUrls: ['./nodes-per-as-chart.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodesPerAsChartComponent implements OnInit { + miningWindowPreference: string; + + isLoading = true; + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + timespan = ''; + chartInstance: any = undefined; + + @HostBinding('attr.dir') dir = 'ltr'; + + nodesPerAsObservable$: Observable; + + constructor( + private apiService: ApiService, + private seoService: SeoService, + ) { + } + + ngOnInit(): void { + this.seoService.setTitle($localize`Nodes per AS`); + + this.nodesPerAsObservable$ = this.apiService.getNodesPerAs() + .pipe( + tap(data => { + this.isLoading = false; + this.prepareChartOptions(data); + }), + map(data => { + for (let i = 0; i < data.length; ++i) { + data[i].rank = i + 1; + } + return data.slice(0, 100); + }), + share() + ); + } + + generateChartSerieData(as) { + const shareThreshold = this.isMobile() ? 2 : 1; + const data: object[] = []; + let totalShareOther = 0; + let totalNodeOther = 0; + + let edgeDistance: string | number = '10%'; + if (this.isMobile()) { + edgeDistance = 0; + } + + as.forEach((as) => { + if (as.share < shareThreshold) { + totalShareOther += as.share; + totalNodeOther += as.count; + return; + } + data.push({ + value: as.share, + name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`), + label: { + overflow: 'truncate', + color: '#b1b1b1', + alignTo: 'edge', + edgeDistance: edgeDistance, + }, + tooltip: { + show: !this.isMobile(), + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + }, + borderColor: '#000', + formatter: () => { + return `${as.name} (${as.share}%)
` + + $localize`${as.count.toString()} nodes`; + } + }, + data: as.slug, + } as PieSeriesOption); + }); + + // 'Other' + data.push({ + itemStyle: { + color: 'grey', + }, + value: totalShareOther, + name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`), + label: { + overflow: 'truncate', + color: '#b1b1b1', + alignTo: 'edge', + edgeDistance: edgeDistance + }, + tooltip: { + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + }, + borderColor: '#000', + formatter: () => { + return `${'Other'} (${totalShareOther.toFixed(2)}%)
` + + totalNodeOther.toString() + ` nodes`; + } + }, + } as PieSeriesOption); + + return data; + } + + prepareChartOptions(as) { + let pieSize = ['20%', '80%']; // Desktop + if (this.isMobile()) { + pieSize = ['15%', '60%']; + } + + this.chartOptions = { + color: chartColors, + tooltip: { + trigger: 'item', + textStyle: { + align: 'left', + } + }, + series: [ + { + zlevel: 0, + minShowLabelAngle: 3.6, + name: 'Mining pool', + type: 'pie', + radius: pieSize, + data: this.generateChartSerieData(as), + labelLine: { + lineStyle: { + width: 2, + }, + length: this.isMobile() ? 1 : 20, + length2: this.isMobile() ? 1 : undefined, + }, + label: { + fontSize: 14, + }, + itemStyle: { + borderRadius: 1, + borderWidth: 1, + borderColor: '#000', + }, + emphasis: { + itemStyle: { + shadowBlur: 40, + shadowColor: 'rgba(0, 0, 0, 0.75)', + }, + labelLine: { + lineStyle: { + width: 4, + } + } + } + } + ], + }; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } + + onChartInit(ec) { + if (this.chartInstance !== undefined) { + return; + } + this.chartInstance = ec; + } + + onSaveChart() { + const now = new Date(); + this.chartOptions.backgroundColor = '#11131f'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + excludeComponents: ['dataZoom'], + }), `ln-nodes-per-as-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } + + isEllipsisActive(e) { + return (e.offsetWidth < e.scrollWidth); + } +} + diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 8e3da7e09..48f23a94f 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -251,4 +251,7 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params }); } + getNodesPerAs(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/asShare'); + } } From 3edd6f23a556edcc34795f981114d4af6dffa2dd Mon Sep 17 00:00:00 2001 From: nymkappa Date: Sat, 16 Jul 2022 11:32:48 +0200 Subject: [PATCH 03/18] Add capacity per AS --- backend/src/api/explorer/nodes.api.ts | 7 +++++-- .../nodes-per-as-chart.component.html | 16 +++++++++------- .../nodes-per-as-chart.component.ts | 6 +++++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 3bfa4d50e..c3b3f8124 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -96,10 +96,12 @@ class NodesApi { public async $getNodesAsShare() { try { - let query = `SELECT names, COUNT(*) as nodesCount from nodes + let query = `SELECT names, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity + FROM nodes JOIN geo_names ON geo_names.id = nodes.as_number + JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key GROUP BY as_number - ORDER BY COUNT(*) DESC + ORDER BY COUNT(DISTINCT nodes.public_key) DESC `; const [nodesCountPerAS]: any = await DB.query(query); @@ -112,6 +114,7 @@ class NodesApi { name: JSON.parse(as.names), count: as.nodesCount, share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100, + capacity: as.capacity, }) } diff --git a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html index 16ba4fea6..3ea6f1e29 100644 --- a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html +++ b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.html @@ -21,18 +21,20 @@ - - - - + + + + + - + - - + + +
RankNameHashrateNodesRankNameShareNodesCapacity
{{ asEntry.rank }}{{ asEntry.rank }} {{ asEntry.name }}{{ asEntry.share }}%{{ asEntry.count }}{{ asEntry.share }}%{{ asEntry.count }}
diff --git a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts index cc8d5e759..ac94dfac4 100644 --- a/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts +++ b/frontend/src/app/lightning/nodes-per-as-chart/nodes-per-as-chart.component.ts @@ -5,6 +5,7 @@ import { chartColors } from 'src/app/app.constants'; import { ApiService } from 'src/app/services/api.service'; import { SeoService } from 'src/app/services/seo.service'; import { download } from 'src/app/shared/graphs.utils'; +import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; @Component({ selector: 'app-nodes-per-as-chart', @@ -30,6 +31,7 @@ export class NodesPerAsChartComponent implements OnInit { constructor( private apiService: ApiService, private seoService: SeoService, + private amountShortenerPipe: AmountShortenerPipe ) { } @@ -89,7 +91,9 @@ export class NodesPerAsChartComponent implements OnInit { borderColor: '#000', formatter: () => { return `${as.name} (${as.share}%)
` + - $localize`${as.count.toString()} nodes`; + $localize`${as.count.toString()} nodes
` + + $localize`${this.amountShortenerPipe.transform(as.capacity / 100000000, 2)} BTC capacity` + ; } }, data: as.slug, From 4093cc0cbf5c8ea2000500b029f705ab253e9784 Mon Sep 17 00:00:00 2001 From: nymkappa Date: Sat, 16 Jul 2022 21:37:45 +0200 Subject: [PATCH 04/18] Fix graph title & download button on mobile Fix wrong graph title on LN channels & capacity chart --- .../block-fee-rates-graph.component.html | 11 ++++++---- .../block-fees-graph.component.html | 11 ++++++---- .../block-prediction-graph.component.html | 11 ++++++---- .../block-rewards-graph.component.html | 11 ++++++---- .../block-sizes-weights-graph.component.html | 10 +++++---- .../hashrate-chart.component.html | 10 +++++---- .../hashrate-chart-pools.component.html | 13 ++++++----- .../pool-ranking/pool-ranking.component.html | 10 +++++---- .../statistics/statistics.component.html | 22 ++++++++++--------- .../nodes-networks-chart.component.html | 10 +++++---- .../lightning-statistics-chart.component.html | 10 +++++---- 11 files changed, 78 insertions(+), 51 deletions(-) diff --git a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html index 2dbe4d569..e694f5676 100644 --- a/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html +++ b/frontend/src/app/components/block-fee-rates-graph/block-fee-rates-graph.component.html @@ -2,10 +2,13 @@
- Block Fee Rates - +
+ Block Fee Rates + +
+