Nodes per ISP list component
This commit is contained in:
parent
93e93d44f4
commit
dbf60dd4d9
@ -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();
|
||||||
|
@ -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();
|
||||||
|
@ -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,
|
||||||
|
@ -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: ''
|
||||||
|
@ -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>
|
||||||
|
@ -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() {
|
||||||
|
@ -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> {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user