[LN ISP chart] Adds toogle to order by nodes/capacity and show/hide Tor

This commit is contained in:
nymkappa 2022-07-27 13:20:54 +02:00
parent 16db740986
commit 12eea0e4cc
No known key found for this signature in database
GPG Key ID: E155910B16E8BD04
9 changed files with 123 additions and 46 deletions

View File

@ -98,29 +98,59 @@ class NodesApi {
} }
} }
public async $getNodesISP() { public async $getNodesISP(groupBy: string, showTor: boolean) {
try { 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 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
GROUP BY geo_names.names GROUP BY geo_names.names
ORDER BY COUNT(DISTINCT nodes.public_key) DESC ORDER BY ${orderBy} DESC
`; `;
const [nodesCountPerAS]: any = await DB.query(query); const [nodesCountPerAS]: any = await DB.query(query);
query = `SELECT COUNT(*) as total FROM nodes WHERE as_number IS NOT NULL`; let total = 0;
const [nodesWithAS]: any = await DB.query(query);
const nodesPerAs: any[] = []; 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) { for (const as of nodesCountPerAS) {
nodesPerAs.push({ nodesPerAs.push({
ispId: as.ispId, 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((groupBy === 'capacity' ? as.capacity : as.nodesCount) / total * 10000) / 100,
capacity: as.capacity, capacity: as.capacity,
}) });
} }
return nodesPerAs; return nodesPerAs;

View File

@ -9,10 +9,10 @@ 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/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/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/statistics', this.$getHistoricalNodeStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode) .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<void> {
try { 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('Pragma', 'public');
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());

View File

@ -140,8 +140,10 @@
<div class="d-flex justify-content-between" *ngIf="!error"> <div class="d-flex justify-content-between" *ngIf="!error">
<h2>Channels ({{ channelsListStatus === 'open' ? node.channel_active_count : node.channel_closed_count }})</h2> <h2>Channels ({{ channelsListStatus === 'open' ? node.channel_active_count : node.channel_closed_count }})</h2>
<div class="d-flex justify-content-end">
<app-toggle [textLeft]="'List'" [textRight]="'Map'" (toggleStatusChanged)="channelsListModeChange($event)"></app-toggle> <app-toggle [textLeft]="'List'" [textRight]="'Map'" (toggleStatusChanged)="channelsListModeChange($event)"></app-toggle>
</div> </div>
</div>
<app-nodes-channels-map *ngIf="channelsListMode === 'map' && !error" [style]="'nodepage'" [publicKey]="node.public_key"> <app-nodes-channels-map *ngIf="channelsListMode === 'map' && !error" [style]="'nodepage'" [publicKey]="node.public_key">
</app-nodes-channels-map> </app-nodes-channels-map>

View File

@ -21,6 +21,11 @@
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>
</div> </div>
<div class="d-flex toggle">
<app-toggle [textLeft]="'Show Tor'" [textRight]="" (toggleStatusChanged)="onTorToggleStatusChanged($event)"></app-toggle>
<app-toggle [textLeft]="'Nodes'" [textRight]="'Capacity'" (toggleStatusChanged)="onGroupToggleStatusChanged($event)"></app-toggle>
</div>
<table class="table table-borderless text-center m-auto" style="max-width: 900px"> <table class="table table-borderless text-center m-auto" style="max-width: 900px">
<thead> <thead>
<tr> <tr>
@ -34,8 +39,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 class="rank text-left pl-0">{{ asEntry.rank }}</td> <td class="rank text-left pl-0">{{ asEntry.rank }}</td>
<td class="name text-left text-truncate" style="max-width: 250px"> <td class="name text-left text-truncate">
<a [routerLink]="[('/lightning/nodes/isp/' + asEntry.ispId) | relativeUrl]">{{ asEntry.name }}</a> <a *ngIf="asEntry.ispId" [routerLink]="[('/lightning/nodes/isp/' + asEntry.ispId) | relativeUrl]">{{ asEntry.name }}</a>
<span *ngIf="!asEntry.ispId">{{ asEntry.name }}</span>
</td> </td>
<td class="share text-right">{{ asEntry.share }}%</td> <td class="share text-right">{{ asEntry.share }}%</td>
<td class="nodes text-right">{{ asEntry.count }}</td> <td class="nodes text-right">{{ asEntry.count }}</td>

View File

@ -45,7 +45,7 @@
.name { .name {
width: 25%; width: 25%;
@media (max-width: 576px) { @media (max-width: 576px) {
width: 80%; width: 70%;
max-width: 150px; max-width: 150px;
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
@ -69,7 +69,17 @@
.capacity { .capacity {
width: 20%; width: 20%;
@media (max-width: 576px) { @media (max-width: 576px) {
width: 10%; width: 20%;
max-width: 100px; max-width: 100px;
} }
} }
.toggle {
justify-content: space-between;
padding-top: 15px;
@media (min-width: 576px) {
padding-bottom: 15px;
padding-left: 105px;
padding-right: 105px;
}
}

View File

@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { EChartsOption, PieSeriesOption } from 'echarts'; 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 { 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';
@ -17,19 +17,19 @@ import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class NodesPerISPChartComponent implements OnInit { export class NodesPerISPChartComponent implements OnInit {
miningWindowPreference: string;
isLoading = true; isLoading = true;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
renderer: 'svg', renderer: 'svg',
}; };
timespan = ''; timespan = '';
chartInstance: any = undefined; chartInstance = undefined;
@HostBinding('attr.dir') dir = 'ltr'; @HostBinding('attr.dir') dir = 'ltr';
nodesPerAsObservable$: Observable<any>; nodesPerAsObservable$: Observable<any>;
groupBySubject = new Subject<boolean>();
showTorSubject = new Subject<boolean>();
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
@ -44,7 +44,13 @@ export class NodesPerISPChartComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.seoService.setTitle($localize`Lightning nodes per ISP`); this.seoService.setTitle($localize`Lightning nodes per ISP`);
this.nodesPerAsObservable$ = this.apiService.getNodesPerAs() this.nodesPerAsObservable$ = combineLatest([this.groupBySubject, this.showTorSubject])
.pipe(
switchMap((selectedFilters) => {
return this.apiService.getNodesPerAs(
selectedFilters[0] ? 'capacity' : 'node-count',
selectedFilters[1] // Show Tor nodes
)
.pipe( .pipe(
tap(data => { tap(data => {
this.isLoading = false; this.isLoading = false;
@ -55,12 +61,14 @@ export class NodesPerISPChartComponent implements OnInit {
data[i].rank = i + 1; data[i].rank = i + 1;
} }
return data.slice(0, 100); return data.slice(0, 100);
})
);
}), }),
share() share()
); );
} }
generateChartSerieData(as) { generateChartSerieData(as): PieSeriesOption[] {
const shareThreshold = this.isMobile() ? 2 : 0.5; const shareThreshold = this.isMobile() ? 2 : 0.5;
const data: object[] = []; const data: object[] = [];
let totalShareOther = 0; let totalShareOther = 0;
@ -78,6 +86,9 @@ export class NodesPerISPChartComponent implements OnInit {
return; return;
} }
data.push({ data.push({
itemStyle: {
color: as.ispId === null ? '#7D4698' : undefined,
},
value: as.share, value: as.share,
name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`), name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`),
label: { label: {
@ -138,14 +149,14 @@ export class NodesPerISPChartComponent implements OnInit {
return data; return data;
} }
prepareChartOptions(as) { prepareChartOptions(as): void {
let pieSize = ['20%', '80%']; // Desktop let pieSize = ['20%', '80%']; // Desktop
if (this.isMobile()) { if (this.isMobile()) {
pieSize = ['15%', '60%']; pieSize = ['15%', '60%'];
} }
this.chartOptions = { this.chartOptions = {
color: chartColors, color: chartColors.slice(3),
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
textStyle: { textStyle: {
@ -191,18 +202,18 @@ export class NodesPerISPChartComponent implements OnInit {
}; };
} }
isMobile() { isMobile(): boolean {
return (window.innerWidth <= 767.98); return (window.innerWidth <= 767.98);
} }
onChartInit(ec) { onChartInit(ec): void {
if (this.chartInstance !== undefined) { if (this.chartInstance !== undefined) {
return; return;
} }
this.chartInstance = ec; this.chartInstance = ec;
this.chartInstance.on('click', (e) => { this.chartInstance.on('click', (e) => {
if (e.data.data === 9999) { // "Other" if (e.data.data === 9999 || e.data.data === null) { // "Other" or Tor
return; return;
} }
this.zone.run(() => { this.zone.run(() => {
@ -212,7 +223,7 @@ export class NodesPerISPChartComponent implements OnInit {
}); });
} }
onSaveChart() { onSaveChart(): void {
const now = new Date(); const now = new Date();
this.chartOptions.backgroundColor = '#11131f'; this.chartOptions.backgroundColor = '#11131f';
this.chartInstance.setOption(this.chartOptions); this.chartInstance.setOption(this.chartOptions);
@ -224,8 +235,12 @@ export class NodesPerISPChartComponent implements OnInit {
this.chartInstance.setOption(this.chartOptions); this.chartInstance.setOption(this.chartOptions);
} }
isEllipsisActive(e) { onTorToggleStatusChanged(e): void {
return (e.offsetWidth < e.scrollWidth); this.showTorSubject.next(e);
}
onGroupToggleStatusChanged(e): void {
this.groupBySubject.next(e);
} }
} }

View File

@ -255,8 +255,9 @@ export class ApiService {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params }); return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
} }
getNodesPerAs(): Observable<any> { getNodesPerAs(groupBy: 'capacity' | 'node-count', showTorNodes: boolean): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp'); return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp-ranking'
+ `?groupBy=${groupBy}&showTor=${showTorNodes}`);
} }
getNodeForCountry$(country: string): Observable<any> { getNodeForCountry$(country: string): Observable<any> {

View File

@ -1,4 +1,4 @@
<div class="d-flex align-items-center justify-content-end"> <div class="d-flex align-items-center">
<span style="margin-bottom: 0.5rem">{{ textLeft }}</span>&nbsp; <span style="margin-bottom: 0.5rem">{{ textLeft }}</span>&nbsp;
<label class="switch"> <label class="switch">
<input type="checkbox" (change)="onToggleStatusChanged($event)"> <input type="checkbox" (change)="onToggleStatusChanged($event)">

View File

@ -1,4 +1,4 @@
import { Component, Input, Output, ChangeDetectionStrategy, EventEmitter } from '@angular/core'; import { Component, Input, Output, ChangeDetectionStrategy, EventEmitter, AfterViewInit } from '@angular/core';
@Component({ @Component({
selector: 'app-toggle', selector: 'app-toggle',
@ -6,11 +6,15 @@ import { Component, Input, Output, ChangeDetectionStrategy, EventEmitter } from
styleUrls: ['./toggle.component.scss'], styleUrls: ['./toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ToggleComponent { export class ToggleComponent implements AfterViewInit {
@Output() toggleStatusChanged = new EventEmitter<boolean>(); @Output() toggleStatusChanged = new EventEmitter<boolean>();
@Input() textLeft: string; @Input() textLeft: string;
@Input() textRight: string; @Input() textRight: string;
ngAfterViewInit(): void {
this.toggleStatusChanged.emit(false);
}
onToggleStatusChanged(e): void { onToggleStatusChanged(e): void {
this.toggleStatusChanged.emit(e.target.checked); this.toggleStatusChanged.emit(e.target.checked);
} }