From fc5fd244d0580eb3dadd0c4f4751309fcaa3e8b1 Mon Sep 17 00:00:00 2001 From: nymkappa Date: Wed, 13 Jul 2022 00:25:40 +0200 Subject: [PATCH 1/5] Get nodes per country list with `/lightning/nodes/country/:country` API --- backend/src/api/explorer/nodes.api.ts | 15 +++++++++++++++ backend/src/api/explorer/nodes.routes.ts | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index c3b3f8124..4abad3e4e 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -124,6 +124,21 @@ class NodesApi { throw e; } } + + public async $getNodesPerCountry(country: string) { + try { + const query = `SELECT nodes.* FROM nodes + JOIN geo_names ON geo_names.id = nodes.country_id + WHERE LOWER(json_extract(names, '$.en')) = ? + `; + + const [rows]: any = await DB.query(query, [`"${country}"`]); + return rows; + } catch (e) { + logger.err(`Cannot get nodes for country ${country}. Reason: ${e instanceof Error ? e.message : e}`); + throw e; + } + } } export default new NodesApi(); diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index d2960155b..07986f0b1 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -6,6 +6,7 @@ class NodesRoutes { 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 +70,18 @@ class NodesRoutes { res.status(500).send(e instanceof Error ? e.message : e); } } + + private async $getNodesPerCountry(req: Request, res: Response) { + try { + const nodes = await nodesApi.$getNodesPerCountry(req.params.country.toLowerCase()); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + res.json(nodes); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } } export default new NodesRoutes(); From 376484a9377e8c0df431d5c5963f23fcdce1c6d9 Mon Sep 17 00:00:00 2001 From: nymkappa Date: Sat, 16 Jul 2022 15:56:36 +0200 Subject: [PATCH 2/5] Nodes per country list component --- backend/src/api/explorer/nodes.api.ts | 21 ++++++- .../src/app/lightning/lightning.module.ts | 2 + .../app/lightning/lightning.routing.module.ts | 5 ++ .../nodes-per-country.component.html | 42 +++++++++++++ .../nodes-per-country.component.scss | 62 +++++++++++++++++++ .../nodes-per-country.component.ts | 37 +++++++++++ frontend/src/app/services/api.service.ts | 4 ++ .../timestamp/timestamp.component.html | 2 +- .../timestamp/timestamp.component.ts | 1 + 9 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html create mode 100644 frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.scss create mode 100644 frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 4abad3e4e..3814da73d 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -127,12 +127,27 @@ class NodesApi { public async $getNodesPerCountry(country: string) { try { - const query = `SELECT nodes.* FROM nodes - JOIN geo_names ON geo_names.id = nodes.country_id - WHERE LOWER(json_extract(names, '$.en')) = ? + 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 + 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 + LEFT 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 + LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id + WHERE LOWER(JSON_EXTRACT(geo_names_country.names, '$.en')) = ? + ORDER BY capacity DESC `; const [rows]: any = await DB.query(query, [`"${country}"`]); + 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 ${country}. Reason: ${e instanceof Error ? e.message : e}`); diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index 1cf9992f6..c4fa1bfb0 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -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, diff --git a/frontend/src/app/lightning/lightning.routing.module.ts b/frontend/src/app/lightning/lightning.routing.module.ts index e56a527f9..be6de3afd 100644 --- a/frontend/src/app/lightning/lightning.routing.module.ts +++ b/frontend/src/app/lightning/lightning.routing.module.ts @@ -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: '' diff --git a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html new file mode 100644 index 000000000..90e6ed1bd --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html @@ -0,0 +1,42 @@ +
+

Nodes in {{ country }}

+ +
+ + + + + + + + + + + + + + + + + + +
AliasFirst seenLast updateCapacityChannelsCity
+ {{ node.alias }} + + + + + + + + {{ node.capacity | amountShortener: 1 }} + sats + + + {{ node.channels }} + + {{ node?.city?.en ?? '-' }} +
+
+ +
diff --git a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.scss b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.scss new file mode 100644 index 000000000..02b47e8be --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.scss @@ -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 + } +} \ No newline at end of file diff --git a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts new file mode 100644 index 000000000..db9bc5809 --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts @@ -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'; + +@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; + country: string; + + constructor( + private apiService: ApiService, + private route: ActivatedRoute, + ) { } + + ngOnInit(): void { + this.country = this.route.snapshot.params.country; + this.country = this.country.charAt(0).toUpperCase() + this.country.slice(1); + + this.nodes$ = this.apiService.getNodeForCountry$(this.route.snapshot.params.country) + .pipe( + map(nodes => { + console.log(nodes); + return nodes; + }) + ); + } + + trackByPublicKey(index: number, node: any) { + return node.public_key; + } +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 48f23a94f..6f83ce7e8 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -254,4 +254,8 @@ export class ApiService { getNodesPerAs(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/asShare'); } + + getNodeForCountry$(country: string): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/country/' + country); + } } diff --git a/frontend/src/app/shared/components/timestamp/timestamp.component.html b/frontend/src/app/shared/components/timestamp/timestamp.component.html index b37ff065a..769b292d4 100644 --- a/frontend/src/app/shared/components/timestamp/timestamp.component.html +++ b/frontend/src/app/shared/components/timestamp/timestamp.component.html @@ -1,4 +1,4 @@ -‎{{ seconds * 1000 | date:'yyyy-MM-dd HH:mm' }} +‎{{ seconds * 1000 | date: customFormat ?? 'yyyy-MM-dd HH:mm' }}
()
diff --git a/frontend/src/app/shared/components/timestamp/timestamp.component.ts b/frontend/src/app/shared/components/timestamp/timestamp.component.ts index a0c9861f0..dc577a185 100644 --- a/frontend/src/app/shared/components/timestamp/timestamp.component.ts +++ b/frontend/src/app/shared/components/timestamp/timestamp.component.ts @@ -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; From b11fb444616a70c60ac4b0a98afb6b909bb8e59a Mon Sep 17 00:00:00 2001 From: nymkappa Date: Sat, 16 Jul 2022 22:55:28 +0200 Subject: [PATCH 3/5] Rename "Nodes" to "Lightning nodes" --- .../nodes-per-country/nodes-per-country.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html index 90e6ed1bd..82280bdab 100644 --- a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html +++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html @@ -1,5 +1,5 @@
-

Nodes in {{ country }}

+

Lightning nodes in {{ country }}

From 09020152645abf0b28c29289f3ab5029c1414b87 Mon Sep 17 00:00:00 2001 From: nymkappa Date: Sat, 16 Jul 2022 23:25:44 +0200 Subject: [PATCH 4/5] Use correct country name in component title --- backend/src/api/explorer/nodes.routes.ts | 15 ++++++++++++++- .../nodes-per-country.component.ts | 12 ++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index 07986f0b1..4bdd73f79 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -1,6 +1,8 @@ import config from '../../config'; import { Application, Request, Response } from 'express'; import nodesApi from './nodes.api'; +import DB from '../../database'; + class NodesRoutes { constructor() { } @@ -73,11 +75,22 @@ class NodesRoutes { private async $getNodesPerCountry(req: Request, res: Response) { try { + const [countryName]: any[] = await DB.query(`SELECT names FROM geo_names WHERE LOWER(JSON_EXTRACT(geo_names.names, '$.en')) = ?`, + [`"${req.params.country}"`]); + + if (countryName.length === 0) { + res.status(404).send(`This country does not exists`); + return; + } + const nodes = await nodesApi.$getNodesPerCountry(req.params.country.toLowerCase()); res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); - res.json(nodes); + res.json({ + country: JSON.parse(countryName[0].names), + nodes: nodes, + }); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); } diff --git a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts index db9bc5809..e353d1361 100644 --- a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts +++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts @@ -2,6 +2,7 @@ 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', @@ -15,18 +16,17 @@ export class NodesPerCountry implements OnInit { constructor( private apiService: ApiService, + private seoService: SeoService, private route: ActivatedRoute, ) { } ngOnInit(): void { - this.country = this.route.snapshot.params.country; - this.country = this.country.charAt(0).toUpperCase() + this.country.slice(1); - this.nodes$ = this.apiService.getNodeForCountry$(this.route.snapshot.params.country) .pipe( - map(nodes => { - console.log(nodes); - return nodes; + map(response => { + this.country = response.country.en + this.seoService.setTitle($localize`Lightning nodes in ${this.country}`); + return response.nodes; }) ); } From 93e93d44f4b607178b4c80fffc738c073c7a510c Mon Sep 17 00:00:00 2001 From: nymkappa Date: Sun, 17 Jul 2022 09:53:02 +0200 Subject: [PATCH 5/5] Use country iso code in ln nodes per country page url --- backend/src/api/database-migration.ts | 6 +++++- backend/src/api/explorer/nodes.api.ts | 14 +++++++------- backend/src/api/explorer/nodes.routes.ts | 17 +++++++++++------ .../lightning/sync-tasks/node-locations.ts | 7 +++++++ 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 0b43095cb..d9be6e1e7 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -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'); + } } /** diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 3814da73d..ef111c6a9 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -125,10 +125,10 @@ class NodesApi { } } - public async $getNodesPerCountry(country: string) { + public async $getNodesPerCountry(countryId: string) { try { const query = ` - SELECT node_stats.public_key, node_stats.capacity, node_stats.channels, nodes.alias, + 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 @@ -137,20 +137,20 @@ class NodesApi { FROM node_stats GROUP BY public_key ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added - LEFT 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 + 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 LOWER(JSON_EXTRACT(geo_names_country.names, '$.en')) = ? + WHERE geo_names_country.id = ? ORDER BY capacity DESC `; - const [rows]: any = await DB.query(query, [`"${country}"`]); + 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 ${country}. Reason: ${e instanceof Error ? e.message : e}`); + logger.err(`Cannot get nodes for country id ${countryId}. Reason: ${e instanceof Error ? e.message : e}`); throw e; } } diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index 4bdd73f79..44a4f42b9 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -75,20 +75,25 @@ class NodesRoutes { private async $getNodesPerCountry(req: Request, res: Response) { try { - const [countryName]: any[] = await DB.query(`SELECT names FROM geo_names WHERE LOWER(JSON_EXTRACT(geo_names.names, '$.en')) = ?`, - [`"${req.params.country}"`]); + 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 (countryName.length === 0) { - res.status(404).send(`This country does not exists`); + 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(req.params.country.toLowerCase()); + 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(countryName[0].names), + country: JSON.parse(country[0].country_names), nodes: nodes, }); } catch (e) { diff --git a/backend/src/tasks/lightning/sync-tasks/node-locations.ts b/backend/src/tasks/lightning/sync-tasks/node-locations.ts index 444bd6557..e503190a0 100644 --- a/backend/src/tasks/lightning/sync-tasks/node-locations.ts +++ b/backend/src/tasks/lightning/sync-tasks/node-locations.ts @@ -39,6 +39,13 @@ export async function $lookupNodeLocation(): Promise { [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(