diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index ef111c6a9..5f75e31b8 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -96,7 +96,7 @@ class NodesApi { public async $getNodesAsShare() { try { - let query = `SELECT names, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity + let query = `SELECT nodes.as_number as ispId, geo_names.names as 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 @@ -111,6 +111,7 @@ class NodesApi { const nodesPerAs: any[] = []; 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, @@ -154,6 +155,37 @@ class NodesApi { throw e; } } + + public async $getNodesPerISP(ISPId: string) { + try { + const query = ` + SELECT node_stats.public_key, node_stats.capacity, node_stats.channels, nodes.alias, + UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, + geo_names_city.names as city, geo_names_country.names as country + FROM node_stats + JOIN ( + SELECT public_key, MAX(added) as last_added + FROM node_stats + GROUP BY public_key + ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added + JOIN nodes ON nodes.public_key = node_stats.public_key + JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country' + LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city' + WHERE nodes.as_number = ? + ORDER BY capacity DESC + `; + + const [rows]: any = await DB.query(query, [ISPId]); + for (let i = 0; i < rows.length; ++i) { + rows[i].country = JSON.parse(rows[i].country); + rows[i].city = JSON.parse(rows[i].city); + } + return rows; + } catch (e) { + logger.err(`Cannot get nodes for ISP id ${ISPId}. 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 44a4f42b9..4d82ed725 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -9,6 +9,7 @@ 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/asShare', this.$getNodesAsShare) @@ -100,6 +101,33 @@ class NodesRoutes { res.status(500).send(e instanceof Error ? e.message : e); } } + + private async $getNodesPerISP(req: Request, res: Response) { + try { + const [isp]: any[] = await DB.query( + `SELECT geo_names.names as isp_name + FROM geo_names + WHERE geo_names.type = 'as_organization' AND geo_names.id = ?`, + [req.params.isp] + ); + + if (isp.length === 0) { + res.status(404).send(`This ISP does not exist or does not host any lightning nodes on clearnet`); + return; + } + + const nodes = await nodesApi.$getNodesPerISP(req.params.isp); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + res.json({ + isp: JSON.parse(isp[0].isp_name), + nodes: nodes, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } } export default new NodesRoutes(); diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index c4fa1bfb0..f417839d4 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -20,6 +20,7 @@ import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networ import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component'; import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component'; import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component'; +import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component'; @NgModule({ declarations: [ LightningDashboardComponent, @@ -37,6 +38,7 @@ import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component ChannelsStatisticsComponent, NodesPerAsChartComponent, NodesPerCountry, + NodesPerISP, ], imports: [ CommonModule, diff --git a/frontend/src/app/lightning/lightning.routing.module.ts b/frontend/src/app/lightning/lightning.routing.module.ts index be6de3afd..8bfb467af 100644 --- a/frontend/src/app/lightning/lightning.routing.module.ts +++ b/frontend/src/app/lightning/lightning.routing.module.ts @@ -5,6 +5,7 @@ import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper import { NodeComponent } from './node/node.component'; import { ChannelComponent } from './channel/channel.component'; import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component'; +import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component'; const routes: Routes = [ { @@ -27,6 +28,10 @@ const routes: Routes = [ path: 'nodes/country/:country', component: NodesPerCountry, }, + { + path: 'nodes/isp/:isp', + component: NodesPerISP, + }, { path: '**', redirectTo: '' 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 3658eea18..24d634a3f 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 @@ -25,7 +25,7 @@ Rank - Name + ISP Share Nodes Capacity @@ -34,7 +34,9 @@ {{ asEntry.rank }} - {{ asEntry.name }} + + {{ asEntry.name }} + {{ 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 32fad6459..cf6c0568a 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 @@ -1,11 +1,14 @@ -import { ChangeDetectionStrategy, Component, OnInit, HostBinding } from '@angular/core'; +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 { chartColors } from 'src/app/app.constants'; import { ApiService } from 'src/app/services/api.service'; import { SeoService } from 'src/app/services/seo.service'; +import { StateService } from 'src/app/services/state.service'; import { download } from 'src/app/shared/graphs.utils'; import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; +import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; @Component({ selector: 'app-nodes-per-as-chart', @@ -31,7 +34,10 @@ export class NodesPerAsChartComponent implements OnInit { constructor( private apiService: ApiService, private seoService: SeoService, - private amountShortenerPipe: AmountShortenerPipe + private amountShortenerPipe: AmountShortenerPipe, + private router: Router, + private zone: NgZone, + private stateService: StateService, ) { } @@ -96,7 +102,7 @@ export class NodesPerAsChartComponent implements OnInit { ; } }, - data: as.slug, + data: as.ispId, } as PieSeriesOption); }); @@ -126,6 +132,7 @@ export class NodesPerAsChartComponent implements OnInit { totalNodeOther.toString() + ` nodes`; } }, + data: 9999 as any, } as PieSeriesOption); return data; @@ -149,7 +156,7 @@ export class NodesPerAsChartComponent implements OnInit { { zlevel: 0, minShowLabelAngle: 3.6, - name: 'Mining pool', + name: 'Lightning nodes', type: 'pie', radius: pieSize, data: this.generateChartSerieData(as), @@ -193,6 +200,16 @@ export class NodesPerAsChartComponent implements OnInit { return; } this.chartInstance = ec; + + this.chartInstance.on('click', (e) => { + if (e.data.data === 9999) { // "Other" + return; + } + this.zone.run(() => { + const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/isp/${e.data.data}`); + this.router.navigate([url]); + }); + }); } onSaveChart() { diff --git a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.html b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.html new file mode 100644 index 000000000..b69e749e6 --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.html @@ -0,0 +1,42 @@ +
+

Lightning nodes on ISP: {{ isp?.name }} [AS {{isp?.id}}]

+ +
+ + + + + + + + + + + + + + + + + + +
AliasFirst seenLast updateCapacityChannelsCity
+ {{ node.alias }} + + + + + + + + {{ node.capacity | amountShortener: 1 }} + sats + + + {{ node.channels }} + + {{ node?.city?.en ?? '-' }} +
+
+ +
diff --git a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.scss b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.scss new file mode 100644 index 000000000..02b47e8be --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.scss @@ -0,0 +1,62 @@ +.container-xl { + max-width: 1400px; + padding-bottom: 100px; +} + +.sats { + color: #ffffff66; + font-size: 12px; + top: 0px; +} + +.alias { + width: 30%; + max-width: 400px; + padding-right: 70px; + + @media (max-width: 576px) { + width: 50%; + max-width: 150px; + padding-right: 0px; + } +} + +.timestamp-first { + width: 20%; + + @media (max-width: 576px) { + display: none + } +} + +.timestamp-update { + width: 16%; + + @media (max-width: 576px) { + display: none + } +} + +.capacity { + width: 10%; + + @media (max-width: 576px) { + width: 25%; + } +} + +.channels { + width: 10%; + + @media (max-width: 576px) { + width: 25%; + } +} + +.city { + max-width: 150px; + + @media (max-width: 576px) { + display: none + } +} \ No newline at end of file diff --git a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.ts b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.ts new file mode 100644 index 000000000..d29d0e67f --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { map, Observable } from 'rxjs'; +import { ApiService } from 'src/app/services/api.service'; +import { SeoService } from 'src/app/services/seo.service'; + +@Component({ + selector: 'app-nodes-per-isp', + templateUrl: './nodes-per-isp.component.html', + styleUrls: ['./nodes-per-isp.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodesPerISP implements OnInit { + nodes$: Observable; + isp: {name: string, id: number}; + + constructor( + private apiService: ApiService, + private seoService: SeoService, + private route: ActivatedRoute, + ) { } + + ngOnInit(): void { + this.nodes$ = this.apiService.getNodeForISP$(this.route.snapshot.params.isp) + .pipe( + map(response => { + this.isp = { + name: response.isp, + id: this.route.snapshot.params.isp + }; + this.seoService.setTitle($localize`Lightning nodes on ISP: ${response.isp} [AS${this.route.snapshot.params.isp}]`); + return response.nodes; + }) + ); + } + + trackByPublicKey(index: number, node: any) { + return node.public_key; + } +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 6f83ce7e8..7d0ef30c3 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -258,4 +258,8 @@ export class ApiService { getNodeForCountry$(country: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/country/' + country); } + + getNodeForISP$(isp: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp); + } }