mirror of
https://github.com/mempool/mempool.git
synced 2025-02-22 06:21:46 +01:00
Merge pull request #2316 from mempool/nymkappa/feature/node-ranking-page
Create node rankings dashboard
This commit is contained in:
commit
c767f39619
15 changed files with 411 additions and 3 deletions
|
@ -209,6 +209,54 @@ class NodesApi {
|
|||
}
|
||||
}
|
||||
|
||||
public async $getOldestNodes(full: boolean): Promise<ITopNodesPerChannels[]> {
|
||||
try {
|
||||
let [rows]: any[] = await DB.query('SELECT UNIX_TIMESTAMP(MAX(added)) as maxAdded FROM node_stats');
|
||||
const latestDate = rows[0].maxAdded;
|
||||
|
||||
let query: string;
|
||||
if (full === false) {
|
||||
query = `
|
||||
SELECT nodes.public_key, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
|
||||
node_stats.channels
|
||||
FROM node_stats
|
||||
JOIN nodes ON nodes.public_key = node_stats.public_key
|
||||
WHERE added = FROM_UNIXTIME(${latestDate})
|
||||
ORDER BY first_seen
|
||||
LIMIT 100;
|
||||
`;
|
||||
|
||||
[rows] = await DB.query(query);
|
||||
} else {
|
||||
query = `
|
||||
SELECT node_stats.public_key AS publicKey, IF(nodes.alias = '', SUBSTRING(node_stats.public_key, 1, 20), alias) as alias,
|
||||
CAST(COALESCE(node_stats.channels, 0) as INT) as channels,
|
||||
CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity,
|
||||
UNIX_TIMESTAMP(nodes.first_seen) as firstSeen, UNIX_TIMESTAMP(nodes.updated_at) as updatedAt,
|
||||
geo_names_city.names as city, geo_names_country.names as country
|
||||
FROM node_stats
|
||||
RIGHT JOIN nodes ON nodes.public_key = node_stats.public_key
|
||||
LEFT 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 added = FROM_UNIXTIME(${latestDate})
|
||||
ORDER BY first_seen
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
[rows] = await DB.query(query);
|
||||
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('$getTopChannelsNodes error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $searchNodeByPublicKeyOrAlias(search: string) {
|
||||
try {
|
||||
const searchStripped = search.replace('%', '') + '%';
|
||||
|
|
|
@ -17,6 +17,7 @@ class NodesRoutes {
|
|||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings', this.$getNodesRanking)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/capacity', this.$getTopNodesByCapacity)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/channels', this.$getTopNodesByChannels)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/age', this.$getOldestNodes)
|
||||
.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)
|
||||
;
|
||||
|
@ -99,6 +100,18 @@ class NodesRoutes {
|
|||
}
|
||||
}
|
||||
|
||||
private async $getOldestNodes(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const topCapacityNodes = await nodesApi.$getOldestNodes(true);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(topCapacityNodes);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getISPRanking(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const nodesPerAs = await nodesApi.$getNodesISPRanking();
|
||||
|
|
|
@ -277,4 +277,15 @@ export interface ITopNodesPerCapacity {
|
|||
export interface INodesRanking {
|
||||
topByCapacity: ITopNodesPerCapacity[];
|
||||
topByChannels: ITopNodesPerChannels[];
|
||||
}
|
||||
|
||||
export interface IOldestNodes {
|
||||
publicKey: string,
|
||||
alias: string,
|
||||
firstSeen: number,
|
||||
channels?: number,
|
||||
capacity: number,
|
||||
updatedAt?: number,
|
||||
city?: any,
|
||||
country?: any,
|
||||
}
|
|
@ -177,4 +177,15 @@ export interface ITopNodesPerCapacity {
|
|||
export interface INodesRanking {
|
||||
topByCapacity: ITopNodesPerCapacity[];
|
||||
topByChannels: ITopNodesPerChannels[];
|
||||
}
|
||||
|
||||
export interface IOldestNodes {
|
||||
publicKey: string,
|
||||
alias: string,
|
||||
firstSeen: number,
|
||||
channels?: number,
|
||||
capacity: number,
|
||||
updatedAt?: number,
|
||||
city?: any,
|
||||
country?: any,
|
||||
}
|
|
@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
|||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { StateService } from '../services/state.service';
|
||||
import { INodesRanking, ITopNodesPerCapacity, ITopNodesPerChannels } from '../interfaces/node-api.interface';
|
||||
import { INodesRanking, IOldestNodes, ITopNodesPerCapacity, ITopNodesPerChannels } from '../interfaces/node-api.interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
@ -75,4 +75,10 @@ export class LightningApiService {
|
|||
this.apiBasePath + '/api/v1/lightning/nodes/rankings/channels'
|
||||
);
|
||||
}
|
||||
|
||||
getOldestNodes$(): Observable<IOldestNodes[]> {
|
||||
return this.httpClient.get<IOldestNodes[]>(
|
||||
this.apiBasePath + '/api/v1/lightning/nodes/rankings/age'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network history -->
|
||||
<div class="col">
|
||||
<div class="card graph-card">
|
||||
<div class="card-body pl-2 pr-2 pt-1">
|
||||
|
@ -53,6 +54,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top nodes per capacity -->
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
@ -61,12 +63,12 @@
|
|||
<span> </span>
|
||||
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
|
||||
</a>
|
||||
<h5 class="card-title"></h5>
|
||||
<app-top-nodes-per-capacity [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-capacity>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top nodes per channels -->
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
|
|
@ -27,6 +27,9 @@ import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels
|
|||
import { NodesRanking } from '../lightning/nodes-ranking/nodes-ranking.component';
|
||||
import { TopNodesPerChannels } from '../lightning/nodes-ranking/top-nodes-per-channels/top-nodes-per-channels.component';
|
||||
import { TopNodesPerCapacity } from '../lightning/nodes-ranking/top-nodes-per-capacity/top-nodes-per-capacity.component';
|
||||
import { OldestNodes } from '../lightning/nodes-ranking/oldest-nodes/oldest-nodes.component';
|
||||
import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/nodes-rankings-dashboard.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
LightningDashboardComponent,
|
||||
|
@ -51,6 +54,8 @@ import { TopNodesPerCapacity } from '../lightning/nodes-ranking/top-nodes-per-ca
|
|||
NodesRanking,
|
||||
TopNodesPerChannels,
|
||||
TopNodesPerCapacity,
|
||||
OldestNodes,
|
||||
NodesRankingsDashboard,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
@ -82,6 +87,8 @@ import { TopNodesPerCapacity } from '../lightning/nodes-ranking/top-nodes-per-ca
|
|||
NodesRanking,
|
||||
TopNodesPerChannels,
|
||||
TopNodesPerCapacity,
|
||||
OldestNodes,
|
||||
NodesRankingsDashboard,
|
||||
],
|
||||
providers: [
|
||||
LightningApiService,
|
||||
|
|
|
@ -7,6 +7,7 @@ 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';
|
||||
import { NodesRanking } from './nodes-ranking/nodes-ranking.component';
|
||||
import { NodesRankingsDashboard } from './nodes-rankings-dashboard/nodes-rankings-dashboard.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
|
@ -33,6 +34,10 @@ const routes: Routes = [
|
|||
path: 'nodes/isp/:isp',
|
||||
component: NodesPerISP,
|
||||
},
|
||||
{
|
||||
path: 'nodes/rankings',
|
||||
component: NodesRankingsDashboard,
|
||||
},
|
||||
{
|
||||
path: 'nodes/top-capacity',
|
||||
component: NodesRanking,
|
||||
|
@ -47,6 +52,13 @@ const routes: Routes = [
|
|||
type: 'channels'
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'nodes/oldest',
|
||||
component: NodesRanking,
|
||||
data: {
|
||||
type: 'oldest'
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
|
|
@ -2,4 +2,6 @@
|
|||
</app-top-nodes-per-capacity>
|
||||
|
||||
<app-top-nodes-per-channels [nodes$]="null" [widget]="false" *ngIf="type === 'channels'">
|
||||
</app-top-nodes-per-channels>
|
||||
</app-top-nodes-per-channels>
|
||||
|
||||
<app-oldest-nodes [widget]="false" *ngIf="type === 'oldest'"></app-oldest-nodes>
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
<div [class]="!widget ? 'container-xl full-height' : ''">
|
||||
<h1 *ngIf="!widget" class="float-left" i18n="lightning.top-100-oldest-nodes">
|
||||
<span>Top 100 oldest lightning nodes</span>
|
||||
</h1>
|
||||
|
||||
<div [class]="widget ? 'widget' : 'full'">
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<th class="rank"></th>
|
||||
<th class="alias text-left" i18n="nodes.alias">Alias</th>
|
||||
<th class="timestamp-first text-right" i18n="lightning.first_seen">First seen</th>
|
||||
<th *ngIf="!widget" class="capacity text-right" i18n="node.capacity">Capacity</th>
|
||||
<th *ngIf="!widget" class="channels text-right" i18n="lightning.channels">Channels</th>
|
||||
<th *ngIf="!widget" class="timestamp-update text-left" i18n="lightning.last_update">Last update</th>
|
||||
<th *ngIf="!widget" class="location text-right" i18n="lightning.location">Location</th>
|
||||
</thead>
|
||||
<tbody *ngIf="oldestNodes$ | async as nodes; else skeleton">
|
||||
<tr *ngFor="let node of nodes; let i = index;">
|
||||
<td class="rank text-left">
|
||||
{{ i + 1 }}
|
||||
</td>
|
||||
<td class="alias text-left">
|
||||
<a [routerLink]="['/lightning/node' | relativeUrl, node.publicKey]">{{ node.alias }}</a>
|
||||
</td>
|
||||
<td class="timestamp-first text-right">
|
||||
‎{{ node.firstSeen * 1000 | date: 'yyyy-MM-dd' }}
|
||||
</td>
|
||||
<td *ngIf="!widget" class="capacity text-right">
|
||||
<app-amount [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
|
||||
</td>
|
||||
<td *ngIf="!widget" class="channels text-right">
|
||||
{{ node.channels | number }}
|
||||
</td>
|
||||
<td *ngIf="!widget" class="timestamp-update text-left">
|
||||
<app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.updatedAt"></app-timestamp>
|
||||
</td>
|
||||
<td *ngIf="!widget" class="location text-right text-truncate">
|
||||
{{ node?.city?.en ?? '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<ng-template #skeleton>
|
||||
<tbody>
|
||||
<tr *ngFor="let item of skeletonRows">
|
||||
<td class="rank text-left">
|
||||
<span class="skeleton-loader"></span>
|
||||
</td>
|
||||
<td class="alias text-left">
|
||||
<span class="skeleton-loader"></span>
|
||||
</td>
|
||||
<td class="capacity text-right">
|
||||
<span class="skeleton-loader"></span>
|
||||
</td>
|
||||
<td *ngIf="!widget" class="channels text-right">
|
||||
<span class="skeleton-loader"></span>
|
||||
</td>
|
||||
<td *ngIf="!widget" class="timestamp-first text-left">
|
||||
<span class="skeleton-loader"></span>
|
||||
</td>
|
||||
<td *ngIf="!widget" class="timestamp-update text-left">
|
||||
<span class="skeleton-loader"></span>
|
||||
</td>
|
||||
<td *ngIf="!widget" class="location text-right text-truncate">
|
||||
<span class="skeleton-loader"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</ng-template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,84 @@
|
|||
.container-xl {
|
||||
max-width: 1400px;
|
||||
padding-bottom: 100px;
|
||||
@media (min-width: 767.98px) {
|
||||
padding-left: 50px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.table td, .table th {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.full .rank {
|
||||
width: 5%;
|
||||
}
|
||||
.widget .rank {
|
||||
@media (min-width: 767.98px) {
|
||||
width: 13%;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.full .alias {
|
||||
width: 10%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 350px;
|
||||
@media (max-width: 767.98px) {
|
||||
max-width: 175px;
|
||||
}
|
||||
}
|
||||
.widget .alias {
|
||||
width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
@media (max-width: 767.98px) {
|
||||
max-width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.full .capacity {
|
||||
width: 10%;
|
||||
@media (max-width: 767.98px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.widget .capacity {
|
||||
width: 10%;
|
||||
@media (max-width: 767.98px) {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.full .channels {
|
||||
width: 15%;
|
||||
padding-right: 50px;
|
||||
@media (max-width: 767.98px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.full .timestamp-first {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.full .timestamp-update {
|
||||
width: 20%;
|
||||
@media (max-width: 767.98px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.full .location {
|
||||
width: 10%;
|
||||
@media (max-width: 767.98px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { IOldestNodes } from '../../../interfaces/node-api.interface';
|
||||
import { LightningApiService } from '../../lightning-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-oldest-nodes',
|
||||
templateUrl: './oldest-nodes.component.html',
|
||||
styleUrls: ['./oldest-nodes.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OldestNodes implements OnInit {
|
||||
@Input() widget: boolean = false;
|
||||
|
||||
oldestNodes$: Observable<IOldestNodes[]>;
|
||||
skeletonRows: number[] = [];
|
||||
|
||||
constructor(private apiService: LightningApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
for (let i = 1; i <= (this.widget ? 10 : 100); ++i) {
|
||||
this.skeletonRows.push(i);
|
||||
}
|
||||
|
||||
if (this.widget === false) {
|
||||
this.oldestNodes$ = this.apiService.getOldestNodes$();
|
||||
} else {
|
||||
this.oldestNodes$ = this.apiService.getOldestNodes$().pipe(
|
||||
map((nodes: IOldestNodes[]) => {
|
||||
return nodes.slice(0, 10);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
<div class="container main">
|
||||
<div class="row row-cols-1 row-cols-md-3">
|
||||
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<a class="title-link" href="" [routerLink]="['/lightning/nodes/top-capacity' | relativeUrl]">
|
||||
<h5 class="card-title d-inline" i18n="lightning.top-capacity-nodes">Top capacity nodes</h5>
|
||||
<span> </span>
|
||||
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true"
|
||||
style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
|
||||
</a>
|
||||
<app-top-nodes-per-capacity [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-capacity>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<a class="title-link" href="" [routerLink]="['/lightning/nodes/top-channels' | relativeUrl]">
|
||||
<h5 class="card-title d-inline" i18n="lightning.top-channels-nodes">Most connected nodes</h5>
|
||||
<span> </span>
|
||||
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true"
|
||||
style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
|
||||
</a>
|
||||
<app-top-nodes-per-channels [nodes$]="nodesRanking$" [widget]="true"></app-top-nodes-per-channels>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<a class="title-link" href="" [routerLink]="['/lightning/nodes/oldest' | relativeUrl]">
|
||||
<h5 class="card-title d-inline" i18n="lightning.top-channels-age">Oldest nodes</h5>
|
||||
<span> </span>
|
||||
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true"
|
||||
style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
|
||||
</a>
|
||||
<app-oldest-nodes [widget]="true"></app-oldest-nodes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,33 @@
|
|||
.main {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.col {
|
||||
padding-bottom: 20px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #1d1f31;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1rem;
|
||||
color: #4a68b9;
|
||||
}
|
||||
.card-title > a {
|
||||
color: #4a68b9;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { Observable, share } from 'rxjs';
|
||||
import { INodesRanking } from 'src/app/interfaces/node-api.interface';
|
||||
import { SeoService } from 'src/app/services/seo.service';
|
||||
import { LightningApiService } from '../lightning-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nodes-rankings-dashboard',
|
||||
templateUrl: './nodes-rankings-dashboard.component.html',
|
||||
styleUrls: ['./nodes-rankings-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NodesRankingsDashboard implements OnInit {
|
||||
nodesRanking$: Observable<INodesRanking>;
|
||||
|
||||
constructor(
|
||||
private lightningApiService: LightningApiService,
|
||||
private seoService: SeoService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seoService.setTitle($localize`Top lightning nodes`);
|
||||
this.nodesRanking$ = this.lightningApiService.getNodesRanking$().pipe(share());
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue