diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 9899e20fc..55b0ba5cb 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -12,13 +12,13 @@ class NodesApi { WHERE channels.status = 2 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS channel_closed_count, (SELECT Count(*) FROM channels - WHERE channels.status < 2 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS channel_active_count, + WHERE channels.status = 1 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS channel_active_count, (SELECT Sum(capacity) FROM channels - WHERE channels.status < 2 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS capacity, + WHERE channels.status = 1 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS capacity, (SELECT Avg(capacity) FROM channels - WHERE status < 2 AND ( node1_public_key = ? OR node2_public_key = ? )) AS channels_capacity_avg + WHERE status = 1 AND ( node1_public_key = ? OR node2_public_key = ? )) AS channels_capacity_avg FROM nodes LEFT JOIN geo_names geo_names_as on geo_names_as.id = as_number LEFT JOIN geo_names geo_names_city on geo_names_city.id = city_id @@ -98,29 +98,59 @@ class NodesApi { } } - public async $getNodesISP() { + public async $getNodesISP(groupBy: string, showTor: boolean) { try { - let query = `SELECT GROUP_CONCAT(DISTINCT(nodes.as_number)) as ispId, geo_names.names as names, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity + const orderBy = groupBy === 'capacity' ? `CAST(SUM(capacity) as INT)` : `COUNT(DISTINCT nodes.public_key)`; + + // Clearnet + let query = `SELECT GROUP_CONCAT(DISTINCT(nodes.as_number)) as ispId, geo_names.names as names, + COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) 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 geo_names.names - ORDER BY COUNT(DISTINCT nodes.public_key) DESC - `; + ORDER BY ${orderBy} DESC + `; 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); - + let total = 0; const nodesPerAs: any[] = []; + + for (const asGroup of nodesCountPerAS) { + if (groupBy === 'capacity') { + total += asGroup.capacity; + } else { + total += asGroup.nodesCount; + } + } + + // Tor + if (showTor) { + query = `SELECT COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) as capacity + FROM nodes + JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key + ORDER BY ${orderBy} DESC + `; + const [nodesCountTor]: any = await DB.query(query); + + total += groupBy === 'capacity' ? nodesCountTor[0].capacity : nodesCountTor[0].nodesCount; + nodesPerAs.push({ + ispId: null, + name: 'Tor', + count: nodesCountTor[0].nodesCount, + share: Math.floor((groupBy === 'capacity' ? nodesCountTor[0].capacity : nodesCountTor[0].nodesCount) / total * 10000) / 100, + capacity: nodesCountTor[0].capacity, + }); + } + for (const as of nodesCountPerAS) { nodesPerAs.push({ ispId: as.ispId, name: JSON.parse(as.names), count: as.nodesCount, - share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100, + share: Math.floor((groupBy === 'capacity' ? as.capacity : as.nodesCount) / total * 10000) / 100, capacity: as.capacity, - }) + }); } return nodesPerAs; diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index bbc8efb5a..83e3c393e 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -9,10 +9,10 @@ class NodesRoutes { public initRoutes(app: Application) { app .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry) - .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP) .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/isp', this.$getNodesISP) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp-ranking', this.$getISPRanking) + .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries) .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) @@ -63,9 +63,18 @@ class NodesRoutes { } } - private async $getNodesISP(req: Request, res: Response) { + private async $getISPRanking(req: Request, res: Response): Promise { try { - const nodesPerAs = await nodesApi.$getNodesISP(); + const groupBy = req.query.groupBy as string; + const showTor = req.query.showTor as string === 'true' ? true : false; + + if (!['capacity', 'node-count'].includes(groupBy)) { + res.status(400).send(`groupBy must be one of 'capacity' or 'node-count'`); + return; + } + + const nodesPerAs = await nodesApi.$getNodesISP(groupBy, showTor); + res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); diff --git a/backend/src/tasks/lightning/stats-updater.service.ts b/backend/src/tasks/lightning/stats-updater.service.ts index c56e8a015..f30da9e96 100644 --- a/backend/src/tasks/lightning/stats-updater.service.ts +++ b/backend/src/tasks/lightning/stats-updater.service.ts @@ -141,7 +141,22 @@ class LightningStatsUpdater { try { logger.info(`Running daily node stats update...`); - const query = `SELECT nodes.public_key, c1.channels_count_left, c2.channels_count_right, c1.channels_capacity_left, c2.channels_capacity_right FROM nodes LEFT JOIN (SELECT node1_public_key, COUNT(id) AS channels_count_left, SUM(capacity) AS channels_capacity_left FROM channels WHERE channels.status < 2 GROUP BY node1_public_key) c1 ON c1.node1_public_key = nodes.public_key LEFT JOIN (SELECT node2_public_key, COUNT(id) AS channels_count_right, SUM(capacity) AS channels_capacity_right FROM channels WHERE channels.status < 2 GROUP BY node2_public_key) c2 ON c2.node2_public_key = nodes.public_key`; + const query = ` + SELECT nodes.public_key, c1.channels_count_left, c2.channels_count_right, c1.channels_capacity_left, + c2.channels_capacity_right + FROM nodes + LEFT JOIN ( + SELECT node1_public_key, COUNT(id) AS channels_count_left, SUM(capacity) AS channels_capacity_left + FROM channels + WHERE channels.status = 1 + GROUP BY node1_public_key + ) c1 ON c1.node1_public_key = nodes.public_key + LEFT JOIN ( + SELECT node2_public_key, COUNT(id) AS channels_count_right, SUM(capacity) AS channels_capacity_right + FROM channels WHERE channels.status = 1 GROUP BY node2_public_key + ) c2 ON c2.node2_public_key = nodes.public_key + `; + const [nodes]: any = await DB.query(query); for (const node of nodes) { diff --git a/frontend/src/app/lightning/node/node.component.html b/frontend/src/app/lightning/node/node.component.html index 774f0aaab..cb0e5ed43 100644 --- a/frontend/src/app/lightning/node/node.component.html +++ b/frontend/src/app/lightning/node/node.component.html @@ -140,13 +140,8 @@

Channels ({{ channelsListStatus === 'open' ? node.channel_active_count : node.channel_closed_count }})

-
- List  - -  Map +
+
diff --git a/frontend/src/app/lightning/node/node.component.scss b/frontend/src/app/lightning/node/node.component.scss index 0bdb263a8..2b171416f 100644 --- a/frontend/src/app/lightning/node/node.component.scss +++ b/frontend/src/app/lightning/node/node.component.scss @@ -56,67 +56,4 @@ app-fiat { display: inline-block; margin-left: 10px; } -} - - /* The switch - the box around the slider */ - .switch { - position: relative; - display: inline-block; - width: 30px; - height: 17px; -} - -/* Hide default HTML checkbox */ -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -/* The slider */ -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ccc; - -webkit-transition: .4s; - transition: .4s; -} - -.slider:before { - position: absolute; - content: ""; - height: 13px; - width: 13px; - left: 2px; - bottom: 2px; - background-color: white; - -webkit-transition: .4s; - transition: .4s; -} - -input:checked + .slider { - background-color: #2196F3; -} - -input:focus + .slider { - box-shadow: 0 0 1px #2196F3; -} - -input:checked + .slider:before { - -webkit-transform: translateX(13px); - -ms-transform: translateX(13px); - transform: translateX(13px); -} - -/* Rounded sliders */ -.slider.round { - border-radius: 17px; -} - -.slider.round:before { - border-radius: 50%; -} \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/app/lightning/node/node.component.ts b/frontend/src/app/lightning/node/node.component.ts index c9971a4cb..a8d487938 100644 --- a/frontend/src/app/lightning/node/node.component.ts +++ b/frontend/src/app/lightning/node/node.component.ts @@ -75,8 +75,8 @@ export class NodeComponent implements OnInit { this.selectedSocketIndex = index; } - channelsListModeChange(e) { - if (e.target.checked === true) { + channelsListModeChange(toggle) { + if (toggle === true) { this.channelsListMode = 'map'; } else { this.channelsListMode = 'list'; diff --git a/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.html b/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.html index 28d314b9c..23f54bbba 100644 --- a/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.html +++ b/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.html @@ -7,7 +7,9 @@
- (Tor nodes excluded) + + (Tor nodes excluded) +
@@ -21,6 +23,11 @@
+
+ + +
+ @@ -34,8 +41,9 @@ - diff --git a/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.scss b/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.scss index 8e9a9903b..10ad39372 100644 --- a/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.scss +++ b/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.scss @@ -45,7 +45,7 @@ .name { width: 25%; @media (max-width: 576px) { - width: 80%; + width: 70%; max-width: 150px; padding-left: 0; padding-right: 0; @@ -69,7 +69,17 @@ .capacity { width: 20%; @media (max-width: 576px) { - width: 10%; + width: 20%; max-width: 100px; } +} + +.toggle { + justify-content: space-between; + padding-top: 15px; + @media (min-width: 576px) { + padding-bottom: 15px; + padding-left: 105px; + padding-right: 105px; + } } \ No newline at end of file diff --git a/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.ts b/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.ts index 63665f69a..6b9d41e74 100644 --- a/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.ts +++ b/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core'; import { Router } from '@angular/router'; import { EChartsOption, PieSeriesOption } from 'echarts'; -import { map, Observable, share, tap } from 'rxjs'; +import { combineLatest, map, Observable, share, Subject, switchMap, 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'; @@ -17,19 +17,20 @@ import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url. changeDetection: ChangeDetectionStrategy.OnPush, }) export class NodesPerISPChartComponent implements OnInit { - miningWindowPreference: string; - isLoading = true; chartOptions: EChartsOption = {}; chartInitOptions = { renderer: 'svg', }; timespan = ''; - chartInstance: any = undefined; + chartInstance = undefined; @HostBinding('attr.dir') dir = 'ltr'; nodesPerAsObservable$: Observable; + showTorObservable$: Observable; + groupBySubject = new Subject(); + showTorSubject = new Subject(); constructor( private apiService: ApiService, @@ -44,23 +45,32 @@ export class NodesPerISPChartComponent implements OnInit { ngOnInit(): void { this.seoService.setTitle($localize`Lightning nodes per ISP`); - this.nodesPerAsObservable$ = this.apiService.getNodesPerAs() + this.showTorObservable$ = this.showTorSubject.asObservable(); + this.nodesPerAsObservable$ = combineLatest([this.groupBySubject, this.showTorSubject]) .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); + switchMap((selectedFilters) => { + return this.apiService.getNodesPerAs( + selectedFilters[0] ? 'capacity' : 'node-count', + selectedFilters[1] // Show Tor nodes + ) + .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) { + generateChartSerieData(as): PieSeriesOption[] { const shareThreshold = this.isMobile() ? 2 : 0.5; const data: object[] = []; let totalShareOther = 0; @@ -78,6 +88,9 @@ export class NodesPerISPChartComponent implements OnInit { return; } data.push({ + itemStyle: { + color: as.ispId === null ? '#7D4698' : undefined, + }, value: as.share, name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`), label: { @@ -138,14 +151,14 @@ export class NodesPerISPChartComponent implements OnInit { return data; } - prepareChartOptions(as) { + prepareChartOptions(as): void { let pieSize = ['20%', '80%']; // Desktop if (this.isMobile()) { pieSize = ['15%', '60%']; } this.chartOptions = { - color: chartColors, + color: chartColors.slice(3), tooltip: { trigger: 'item', textStyle: { @@ -191,18 +204,18 @@ export class NodesPerISPChartComponent implements OnInit { }; } - isMobile() { + isMobile(): boolean { return (window.innerWidth <= 767.98); } - onChartInit(ec) { + onChartInit(ec): void { if (this.chartInstance !== undefined) { return; } this.chartInstance = ec; this.chartInstance.on('click', (e) => { - if (e.data.data === 9999) { // "Other" + if (e.data.data === 9999 || e.data.data === null) { // "Other" or Tor return; } this.zone.run(() => { @@ -212,7 +225,7 @@ export class NodesPerISPChartComponent implements OnInit { }); } - onSaveChart() { + onSaveChart(): void { const now = new Date(); this.chartOptions.backgroundColor = '#11131f'; this.chartInstance.setOption(this.chartOptions); @@ -224,8 +237,12 @@ export class NodesPerISPChartComponent implements OnInit { this.chartInstance.setOption(this.chartOptions); } - isEllipsisActive(e) { - return (e.offsetWidth < e.scrollWidth); + onTorToggleStatusChanged(e): void { + this.showTorSubject.next(e); + } + + onGroupToggleStatusChanged(e): void { + this.groupBySubject.next(e); } } diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index fdb2714bd..844451574 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -255,8 +255,9 @@ 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/isp'); + getNodesPerAs(groupBy: 'capacity' | 'node-count', showTorNodes: boolean): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp-ranking' + + `?groupBy=${groupBy}&showTor=${showTorNodes}`); } getNodeForCountry$(country: string): Observable { diff --git a/frontend/src/app/shared/components/toggle/toggle.component.html b/frontend/src/app/shared/components/toggle/toggle.component.html new file mode 100644 index 000000000..dac33c9d8 --- /dev/null +++ b/frontend/src/app/shared/components/toggle/toggle.component.html @@ -0,0 +1,8 @@ +
+ {{ textLeft }}  + +  {{ textRight }} +
diff --git a/frontend/src/app/shared/components/toggle/toggle.component.scss b/frontend/src/app/shared/components/toggle/toggle.component.scss new file mode 100644 index 000000000..a9c221290 --- /dev/null +++ b/frontend/src/app/shared/components/toggle/toggle.component.scss @@ -0,0 +1,62 @@ +/* The switch - the box around the slider */ +.switch { + position: relative; + display: inline-block; + width: 30px; + height: 17px; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 13px; + width: 13px; + left: 2px; + bottom: 2px; + background-color: white; + -webkit-transition: .4s; + transition: .4s; +} + +input:checked+.slider { + background-color: #2196F3; +} + +input:focus+.slider { + box-shadow: 0 0 1px #2196F3; +} + +input:checked+.slider:before { + -webkit-transform: translateX(13px); + -ms-transform: translateX(13px); + transform: translateX(13px); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 17px; +} + +.slider.round:before { + border-radius: 50%; +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/toggle/toggle.component.ts b/frontend/src/app/shared/components/toggle/toggle.component.ts new file mode 100644 index 000000000..4bd31ffbd --- /dev/null +++ b/frontend/src/app/shared/components/toggle/toggle.component.ts @@ -0,0 +1,21 @@ +import { Component, Input, Output, ChangeDetectionStrategy, EventEmitter, AfterViewInit } from '@angular/core'; + +@Component({ + selector: 'app-toggle', + templateUrl: './toggle.component.html', + styleUrls: ['./toggle.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ToggleComponent implements AfterViewInit { + @Output() toggleStatusChanged = new EventEmitter(); + @Input() textLeft: string; + @Input() textRight: string; + + ngAfterViewInit(): void { + this.toggleStatusChanged.emit(false); + } + + onToggleStatusChanged(e): void { + this.toggleStatusChanged.emit(e.target.checked); + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index cd087a3c4..df071033e 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -80,6 +80,7 @@ import { ChangeComponent } from '../components/change/change.component'; import { SatsComponent } from './components/sats/sats.component'; import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component'; import { TimestampComponent } from './components/timestamp/timestamp.component'; +import { ToggleComponent } from './components/toggle/toggle.component'; @NgModule({ declarations: [ @@ -154,6 +155,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component'; SatsComponent, SearchResultsComponent, TimestampComponent, + ToggleComponent, ], imports: [ CommonModule, @@ -255,6 +257,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component'; SatsComponent, SearchResultsComponent, TimestampComponent, + ToggleComponent, ] }) export class SharedModule {
{{ asEntry.rank }} - {{ asEntry.name }} + + {{ asEntry.name }} + {{ asEntry.name }} {{ asEntry.count }}