Merge pull request #2097 from mempool/nymkappa/feature/ln-nodes-per-as

Add nodes per AS chart
This commit is contained in:
wiz 2022-07-16 16:19:39 -05:00 committed by GitHub
commit 561d75c694
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 352 additions and 0 deletions

View File

@ -93,6 +93,37 @@ class NodesApi {
throw e;
}
}
public async $getNodesAsShare() {
try {
let query = `SELECT 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
GROUP BY as_number
ORDER BY COUNT(DISTINCT nodes.public_key) DESC
`;
const [nodesCountPerAS]: any = await DB.query(query);
query = `SELECT COUNT(*) as total FROM nodes WHERE as_number IS NOT NULL`;
const [nodesWithAS]: any = await DB.query(query);
const nodesPerAs: any[] = [];
for (const as of nodesCountPerAS) {
nodesPerAs.push({
name: JSON.parse(as.names),
count: as.nodesCount,
share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100,
capacity: as.capacity,
})
}
return nodesPerAs;
} catch (e) {
logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`);
throw e;
}
}
}
export default new NodesApi();

View File

@ -8,6 +8,7 @@ class NodesRoutes {
app
.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)
.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)
;
@ -56,6 +57,18 @@ class NodesRoutes {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getNodesAsShare(req: Request, res: Response) {
try {
const nodesPerAs = await nodesApi.$getNodesAsShare();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
res.json(nodesPerAs);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
}
export default new NodesRoutes();

View File

@ -34,6 +34,8 @@
i18n="lightning.nodes-networks">Nodes per network</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
i18n="lightning.capacity">Network capacity</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]"
i18n="lightning.nodes-per-isp">Lightning nodes per ISP</a>
</div>
</div>
</div>

View File

@ -20,6 +20,7 @@ import { TelevisionComponent } from '../components/television/television.compone
import { DashboardComponent } from '../dashboard/dashboard.component';
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component';
const browserWindow = window || {};
// @ts-ignore
@ -99,6 +100,10 @@ const routes: Routes = [
path: 'lightning/capacity',
component: LightningStatisticsChartComponent,
},
{
path: 'lightning/nodes-per-isp',
component: NodesPerAsChartComponent,
},
{
path: '',
redirectTo: 'mempool',

View File

@ -18,6 +18,7 @@ import { NodeStatisticsChartComponent } from './node-statistics-chart/node-stati
import { GraphsModule } from '../graphs/graphs.module';
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-per-as-chart.component';
@NgModule({
declarations: [
LightningDashboardComponent,
@ -33,6 +34,7 @@ import { ChannelsStatisticsComponent } from './channels-statistics/channels-stat
LightningStatisticsChartComponent,
NodesNetworksChartComponent,
ChannelsStatisticsComponent,
NodesPerAsChartComponent,
],
imports: [
CommonModule,

View File

@ -0,0 +1,46 @@
<div class="full-container h-100">
<div class="card-header">
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
<span i18n="lightning.nodes-per-isp">Lightning nodes per ISP</span>
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
</button>
</div>
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
</div>
<div class="container pb-lg-0 bottom-padding">
<div class="pb-lg-5" *ngIf="nodesPerAsObservable$ | async">
<div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
</div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
<table class="table table-borderless text-center">
<thead>
<tr>
<th *ngIf="!isMobile()" i18n="mining.rank">Rank</th>
<th i18n="lightning.as-name">Name</th>
<th *ngIf="!isMobile()" i18n="lightning.share">Share</th>
<th i18n="lightning.nodes-count">Nodes</th>
<th i18n="lightning.capacity">Capacity</th>
</tr>
</thead>
<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 *ngIf="!isMobile()">{{ asEntry.share }}%</td>
<td>{{ asEntry.count }}</td>
<td><app-amount [satoshis]="asEntry.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,36 @@
.card-header {
border-bottom: 0;
font-size: 18px;
@media (min-width: 465px) {
font-size: 20px;
}
}
.full-container {
padding: 0px 15px;
width: 100%;
height: calc(100% - 140px);
@media (max-width: 992px) {
height: calc(100% - 190px);
};
@media (max-width: 575px) {
height: calc(100% - 230px);
};
}
.chart {
max-height: 400px;
@media (max-width: 767.98px) {
max-height: 230px;
margin-top: -35px;
}
}
.bottom-padding {
@media (max-width: 992px) {
padding-bottom: 65px
};
@media (max-width: 576px) {
padding-bottom: 65px
};
}

View File

@ -0,0 +1,214 @@
import { ChangeDetectionStrategy, Component, OnInit, HostBinding } from '@angular/core';
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 { download } from 'src/app/shared/graphs.utils';
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
@Component({
selector: 'app-nodes-per-as-chart',
templateUrl: './nodes-per-as-chart.component.html',
styleUrls: ['./nodes-per-as-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodesPerAsChartComponent implements OnInit {
miningWindowPreference: string;
isLoading = true;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
timespan = '';
chartInstance: any = undefined;
@HostBinding('attr.dir') dir = 'ltr';
nodesPerAsObservable$: Observable<any>;
constructor(
private apiService: ApiService,
private seoService: SeoService,
private amountShortenerPipe: AmountShortenerPipe
) {
}
ngOnInit(): void {
this.seoService.setTitle($localize`Lightning nodes per ISP`);
this.nodesPerAsObservable$ = this.apiService.getNodesPerAs()
.pipe(
tap(data => {
this.isLoading = false;
this.prepareChartOptions(data);
}),
map(data => {
for (let i = 0; i < data.length; ++i) {
data[i].rank = i + 1;
}
return data.slice(0, 100);
}),
share()
);
}
generateChartSerieData(as) {
const shareThreshold = this.isMobile() ? 2 : 1;
const data: object[] = [];
let totalShareOther = 0;
let totalNodeOther = 0;
let edgeDistance: string | number = '10%';
if (this.isMobile()) {
edgeDistance = 0;
}
as.forEach((as) => {
if (as.share < shareThreshold) {
totalShareOther += as.share;
totalNodeOther += as.count;
return;
}
data.push({
value: as.share,
name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`),
label: {
overflow: 'truncate',
color: '#b1b1b1',
alignTo: 'edge',
edgeDistance: edgeDistance,
},
tooltip: {
show: !this.isMobile(),
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#b1b1b1',
},
borderColor: '#000',
formatter: () => {
return `<b style="color: white">${as.name} (${as.share}%)</b><br>` +
$localize`${as.count.toString()} nodes<br>` +
$localize`${this.amountShortenerPipe.transform(as.capacity / 100000000, 2)} BTC capacity`
;
}
},
data: as.slug,
} as PieSeriesOption);
});
// 'Other'
data.push({
itemStyle: {
color: 'grey',
},
value: totalShareOther,
name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`),
label: {
overflow: 'truncate',
color: '#b1b1b1',
alignTo: 'edge',
edgeDistance: edgeDistance
},
tooltip: {
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#b1b1b1',
},
borderColor: '#000',
formatter: () => {
return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
totalNodeOther.toString() + ` nodes`;
}
},
} as PieSeriesOption);
return data;
}
prepareChartOptions(as) {
let pieSize = ['20%', '80%']; // Desktop
if (this.isMobile()) {
pieSize = ['15%', '60%'];
}
this.chartOptions = {
color: chartColors,
tooltip: {
trigger: 'item',
textStyle: {
align: 'left',
}
},
series: [
{
zlevel: 0,
minShowLabelAngle: 3.6,
name: 'Mining pool',
type: 'pie',
radius: pieSize,
data: this.generateChartSerieData(as),
labelLine: {
lineStyle: {
width: 2,
},
length: this.isMobile() ? 1 : 20,
length2: this.isMobile() ? 1 : undefined,
},
label: {
fontSize: 14,
},
itemStyle: {
borderRadius: 1,
borderWidth: 1,
borderColor: '#000',
},
emphasis: {
itemStyle: {
shadowBlur: 40,
shadowColor: 'rgba(0, 0, 0, 0.75)',
},
labelLine: {
lineStyle: {
width: 4,
}
}
}
}
],
};
}
isMobile() {
return (window.innerWidth <= 767.98);
}
onChartInit(ec) {
if (this.chartInstance !== undefined) {
return;
}
this.chartInstance = ec;
}
onSaveChart() {
const now = new Date();
this.chartOptions.backgroundColor = '#11131f';
this.chartInstance.setOption(this.chartOptions);
download(this.chartInstance.getDataURL({
pixelRatio: 2,
excludeComponents: ['dataZoom'],
}), `ln-nodes-per-as-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
this.chartOptions.backgroundColor = 'none';
this.chartInstance.setOption(this.chartOptions);
}
isEllipsisActive(e) {
return (e.offsetWidth < e.scrollWidth);
}
}

View File

@ -251,4 +251,7 @@ export class ApiService {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
}
getNodesPerAs(): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/asShare');
}
}