Nodes per ISP list component

This commit is contained in:
nymkappa 2022-07-17 22:57:29 +02:00
parent 93e93d44f4
commit dbf60dd4d9
No known key found for this signature in database
GPG Key ID: E155910B16E8BD04
10 changed files with 241 additions and 7 deletions

View File

@ -96,7 +96,7 @@ class NodesApi {
public async $getNodesAsShare() { public async $getNodesAsShare() {
try { 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 FROM nodes
JOIN geo_names ON geo_names.id = nodes.as_number 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 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[] = []; const nodesPerAs: any[] = [];
for (const as of nodesCountPerAS) { for (const as of nodesCountPerAS) {
nodesPerAs.push({ nodesPerAs.push({
ispId: as.ispId,
name: JSON.parse(as.names), name: JSON.parse(as.names),
count: as.nodesCount, count: as.nodesCount,
share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100, share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100,
@ -154,6 +155,37 @@ class NodesApi {
throw e; 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(); export default new NodesApi();

View File

@ -9,6 +9,7 @@ class NodesRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
app app
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry) .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/search/:search', this.$searchNode)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes) .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/asShare', this.$getNodesAsShare)
@ -100,6 +101,33 @@ class NodesRoutes {
res.status(500).send(e instanceof Error ? e.message : e); 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(); export default new NodesRoutes();

View File

@ -20,6 +20,7 @@ import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networ
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component'; import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.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 { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
LightningDashboardComponent, LightningDashboardComponent,
@ -37,6 +38,7 @@ import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component
ChannelsStatisticsComponent, ChannelsStatisticsComponent,
NodesPerAsChartComponent, NodesPerAsChartComponent,
NodesPerCountry, NodesPerCountry,
NodesPerISP,
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@ -5,6 +5,7 @@ import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper
import { NodeComponent } from './node/node.component'; import { NodeComponent } from './node/node.component';
import { ChannelComponent } from './channel/channel.component'; import { ChannelComponent } from './channel/channel.component';
import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component'; import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -27,6 +28,10 @@ const routes: Routes = [
path: 'nodes/country/:country', path: 'nodes/country/:country',
component: NodesPerCountry, component: NodesPerCountry,
}, },
{
path: 'nodes/isp/:isp',
component: NodesPerISP,
},
{ {
path: '**', path: '**',
redirectTo: '' redirectTo: ''

View File

@ -25,7 +25,7 @@
<thead> <thead>
<tr> <tr>
<th *ngIf="!isMobile()" i18n="mining.rank">Rank</th> <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 *ngIf="!isMobile()" i18n="lightning.share">Share</th>
<th i18n="lightning.nodes-count">Nodes</th> <th i18n="lightning.nodes-count">Nodes</th>
<th i18n="lightning.capacity">Capacity</th> <th i18n="lightning.capacity">Capacity</th>
@ -34,7 +34,9 @@
<tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as asList"> <tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as asList">
<tr *ngFor="let asEntry of asList"> <tr *ngFor="let asEntry of asList">
<td *ngIf="!isMobile()">{{ asEntry.rank }}</td> <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 *ngIf="!isMobile()">{{ asEntry.share }}%</td>
<td>{{ asEntry.count }}</td> <td>{{ asEntry.count }}</td>
<td><app-amount [satoshis]="asEntry.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td> <td><app-amount [satoshis]="asEntry.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td>

View File

@ -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 { EChartsOption, PieSeriesOption } from 'echarts';
import { map, Observable, share, tap } from 'rxjs'; import { map, Observable, share, tap } from 'rxjs';
import { chartColors } from 'src/app/app.constants'; import { chartColors } from 'src/app/app.constants';
import { ApiService } from 'src/app/services/api.service'; import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.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 { download } from 'src/app/shared/graphs.utils';
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
@Component({ @Component({
selector: 'app-nodes-per-as-chart', selector: 'app-nodes-per-as-chart',
@ -31,7 +34,10 @@ export class NodesPerAsChartComponent implements OnInit {
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private seoService: SeoService, 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); } as PieSeriesOption);
}); });
@ -126,6 +132,7 @@ export class NodesPerAsChartComponent implements OnInit {
totalNodeOther.toString() + ` nodes`; totalNodeOther.toString() + ` nodes`;
} }
}, },
data: 9999 as any,
} as PieSeriesOption); } as PieSeriesOption);
return data; return data;
@ -149,7 +156,7 @@ export class NodesPerAsChartComponent implements OnInit {
{ {
zlevel: 0, zlevel: 0,
minShowLabelAngle: 3.6, minShowLabelAngle: 3.6,
name: 'Mining pool', name: 'Lightning nodes',
type: 'pie', type: 'pie',
radius: pieSize, radius: pieSize,
data: this.generateChartSerieData(as), data: this.generateChartSerieData(as),
@ -193,6 +200,16 @@ export class NodesPerAsChartComponent implements OnInit {
return; return;
} }
this.chartInstance = ec; 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() { onSaveChart() {

View File

@ -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>

View File

@ -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
}
}

View File

@ -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;
}
}

View File

@ -258,4 +258,8 @@ export class ApiService {
getNodeForCountry$(country: string): Observable<any> { getNodeForCountry$(country: string): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/country/' + country); 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);
}
} }