mirror of
https://github.com/mempool/mempool.git
synced 2024-11-20 18:32:19 +01:00
Merge pull request #2098 from mempool/nymkappa/feature/ln-nodes-per-country
Add nodes per country table page
This commit is contained in:
commit
3768c28b01
@ -4,7 +4,7 @@ import logger from '../logger';
|
||||
import { Common } from './common';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 32;
|
||||
private static currentVersion = 33;
|
||||
private queryTimeout = 120000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
@ -302,6 +302,10 @@ class DatabaseMigration {
|
||||
if (databaseSchemaVersion < 32 && isBitcoin == true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"');
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 33 && isBitcoin == true) {
|
||||
await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization", "country_iso_code") NOT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -124,6 +124,36 @@ class NodesApi {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNodesPerCountry(countryId: string) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT DISTINCT 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
|
||||
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
|
||||
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id
|
||||
WHERE geo_names_country.id = ?
|
||||
ORDER BY capacity DESC
|
||||
`;
|
||||
|
||||
const [rows]: any = await DB.query(query, [countryId]);
|
||||
for (let i = 0; i < rows.length; ++i) {
|
||||
rows[i].city = JSON.parse(rows[i].city);
|
||||
}
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get nodes for country id ${countryId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NodesApi();
|
||||
|
@ -1,11 +1,14 @@
|
||||
import config from '../../config';
|
||||
import { Application, Request, Response } from 'express';
|
||||
import nodesApi from './nodes.api';
|
||||
import DB from '../../database';
|
||||
|
||||
class NodesRoutes {
|
||||
constructor() { }
|
||||
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry)
|
||||
.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)
|
||||
@ -69,6 +72,34 @@ class NodesRoutes {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getNodesPerCountry(req: Request, res: Response) {
|
||||
try {
|
||||
const [country]: any[] = await DB.query(
|
||||
`SELECT geo_names.id, geo_names_country.names as country_names
|
||||
FROM geo_names
|
||||
JOIN geo_names geo_names_country on geo_names.id = geo_names_country.id AND geo_names_country.type = 'country'
|
||||
WHERE geo_names.type = 'country_iso_code' AND geo_names.names = ?`,
|
||||
[req.params.country]
|
||||
);
|
||||
|
||||
if (country.length === 0) {
|
||||
res.status(404).send(`This country does not exist or does not host any lightning nodes on clearnet`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = await nodesApi.$getNodesPerCountry(country[0].id);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json({
|
||||
country: JSON.parse(country[0].country_names),
|
||||
nodes: nodes,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NodesRoutes();
|
||||
|
@ -39,6 +39,13 @@ export async function $lookupNodeLocation(): Promise<void> {
|
||||
[city.country?.geoname_id, JSON.stringify(city.country?.names)]);
|
||||
}
|
||||
|
||||
// Store Country ISO code
|
||||
if (city.country?.iso_code) {
|
||||
await DB.query(
|
||||
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'country_iso_code', ?)`,
|
||||
[city.country?.geoname_id, city.country?.iso_code]);
|
||||
}
|
||||
|
||||
// Store Division
|
||||
if (city.subdivisions && city.subdivisions[0]) {
|
||||
await DB.query(
|
||||
|
@ -19,6 +19,7 @@ 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';
|
||||
import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
|
||||
@NgModule({
|
||||
declarations: [
|
||||
LightningDashboardComponent,
|
||||
@ -35,6 +36,7 @@ import { NodesPerAsChartComponent } from '../lightning/nodes-per-as-chart/nodes-
|
||||
NodesNetworksChartComponent,
|
||||
ChannelsStatisticsComponent,
|
||||
NodesPerAsChartComponent,
|
||||
NodesPerCountry,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
@ -4,6 +4,7 @@ import { LightningDashboardComponent } from './lightning-dashboard/lightning-das
|
||||
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
|
||||
import { NodeComponent } from './node/node.component';
|
||||
import { ChannelComponent } from './channel/channel.component';
|
||||
import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@ -22,6 +23,10 @@ const routes: Routes = [
|
||||
path: 'channel/:short_id',
|
||||
component: ChannelComponent,
|
||||
},
|
||||
{
|
||||
path: 'nodes/country/:country',
|
||||
component: NodesPerCountry,
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
@ -0,0 +1,42 @@
|
||||
<div class="container-xl full-height" style="min-height: 335px">
|
||||
<h1 class="float-left" i18n="lightning.nodes-in-country">Lightning nodes in {{ country }}</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,37 @@
|
||||
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-country',
|
||||
templateUrl: './nodes-per-country.component.html',
|
||||
styleUrls: ['./nodes-per-country.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NodesPerCountry implements OnInit {
|
||||
nodes$: Observable<any>;
|
||||
country: string;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private seoService: SeoService,
|
||||
private route: ActivatedRoute,
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.nodes$ = this.apiService.getNodeForCountry$(this.route.snapshot.params.country)
|
||||
.pipe(
|
||||
map(response => {
|
||||
this.country = response.country.en
|
||||
this.seoService.setTitle($localize`Lightning nodes in ${this.country}`);
|
||||
return response.nodes;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
trackByPublicKey(index: number, node: any) {
|
||||
return node.public_key;
|
||||
}
|
||||
}
|
@ -254,4 +254,8 @@ export class ApiService {
|
||||
getNodesPerAs(): Observable<any> {
|
||||
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/asShare');
|
||||
}
|
||||
|
||||
getNodeForCountry$(country: string): Observable<any> {
|
||||
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/country/' + country);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
‎{{ seconds * 1000 | date:'yyyy-MM-dd HH:mm' }}
|
||||
‎{{ seconds * 1000 | date: customFormat ?? 'yyyy-MM-dd HH:mm' }}
|
||||
<div class="lg-inline">
|
||||
<i class="symbol">(<app-time-since [time]="seconds" [fastRender]="true"></app-time-since>)</i>
|
||||
</div>
|
||||
|
@ -9,6 +9,7 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/c
|
||||
export class TimestampComponent implements OnChanges {
|
||||
@Input() unixTime: number;
|
||||
@Input() dateString: string;
|
||||
@Input() customFormat: string;
|
||||
|
||||
seconds: number;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user