Merge pull request #2121 from mempool/nymkappa/feature/ln-nodes-per-as-list
Nodes list per ISP
This commit is contained in:
		
						commit
						09171c749a
					
				| @ -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(); | ||||
|  | ||||
| @ -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(); | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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: '' | ||||
|  | ||||
| @ -25,7 +25,7 @@ | ||||
|       <thead> | ||||
|         <tr> | ||||
|           <th *ngIf="!isMobile()" i18n="mining.rank">Rank</th> | ||||
|           <th i18n="lightning.as-name">Name</th> | ||||
|           <th i18n="lightning.isp">ISP</th> | ||||
|           <th *ngIf="!isMobile()" i18n="lightning.share">Share</th> | ||||
|           <th i18n="lightning.nodes-count">Nodes</th> | ||||
|           <th i18n="lightning.capacity">Capacity</th> | ||||
| @ -34,7 +34,9 @@ | ||||
|       <tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as asList"> | ||||
|         <tr *ngFor="let asEntry of asList"> | ||||
|           <td *ngIf="!isMobile()">{{ asEntry.rank }}</td> | ||||
|           <td class="text-truncate" style="max-width: 100px">{{ asEntry.name }}</td> | ||||
|           <td class="text-truncate" style="max-width: 100px"> | ||||
|             <a [routerLink]="[('/lightning/nodes/isp/' + asEntry.ispId) | relativeUrl]">{{ asEntry.name }}</a> | ||||
|           </td> | ||||
|           <td *ngIf="!isMobile()">{{ asEntry.share }}%</td> | ||||
|           <td>{{ asEntry.count }}</td> | ||||
|           <td><app-amount [satoshis]="asEntry.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td> | ||||
|  | ||||
| @ -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() { | ||||
|  | ||||
| @ -0,0 +1,42 @@ | ||||
| <div class="container-xl full-height" style="min-height: 335px"> | ||||
|   <h1 class="float-left" i18n="lightning.nodes-for-isp">Lightning nodes on ISP: {{ isp?.name }} [AS {{isp?.id}}]</h1> | ||||
| 
 | ||||
|   <div style="min-height: 295px"> | ||||
|     <table class="table table-borderless"> | ||||
|       <thead> | ||||
|         <th class="alias text-left" i18n="lightning.alias">Alias</th> | ||||
|         <th class="timestamp-first text-left" i18n="lightning.first_seen">First seen</th> | ||||
|         <th class="timestamp-update text-left" i18n="lightning.last_update">Last update</th> | ||||
|         <th class="capacity text-right" i18n="lightning.capacity">Capacity</th> | ||||
|         <th class="channels text-right" i18n="lightning.channels">Channels</th> | ||||
|         <th class="city text-right" i18n="lightning.city">City</th> | ||||
|       </thead> | ||||
|       <tbody *ngIf="nodes$ | async as nodes"> | ||||
|         <tr *ngFor="let node of nodes; let i= index; trackBy: trackByPublicKey"> | ||||
|           <td class="alias text-left text-truncate"> | ||||
|             <a [routerLink]="['/lightning/node/' | relativeUrl, node.public_key]">{{ node.alias }}</a> | ||||
|           </td> | ||||
|           <td class="timestamp-first text-left"> | ||||
|             <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.first_seen"></app-timestamp> | ||||
|           </td> | ||||
|           <td class="timestamp-update text-left"> | ||||
|             <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.updated_at"></app-timestamp> | ||||
|           </td> | ||||
|           <td class="capacity text-right"> | ||||
|             <app-amount *ngIf="node.capacity > 100000000; else smallchannel" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> | ||||
|             <ng-template #smallchannel> | ||||
|               {{ node.capacity | amountShortener: 1 }} | ||||
|               <span class="sats" i18n="shared.sats">sats</span> | ||||
|             </ng-template> | ||||
|           </td> | ||||
|           <td class="channels text-right"> | ||||
|             {{ node.channels }} | ||||
|           </td> | ||||
|           <td class="city text-right text-truncate"> | ||||
|             {{ node?.city?.en ?? '-' }} | ||||
|           </td> | ||||
|       </tbody> | ||||
|     </table> | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
| @ -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 | ||||
|   } | ||||
| } | ||||
| @ -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<any>; | ||||
|   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; | ||||
|   } | ||||
| } | ||||
| @ -258,4 +258,8 @@ export class ApiService { | ||||
|   getNodeForCountry$(country: string): Observable<any> { | ||||
|     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/country/' + country); | ||||
|   } | ||||
| 
 | ||||
|   getNodeForISP$(isp: string): Observable<any> { | ||||
|     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp); | ||||
|   } | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user