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 c3b3f8124..ef111c6a9 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -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(); diff --git a/backend/src/api/explorer/nodes.routes.ts b/backend/src/api/explorer/nodes.routes.ts index d2960155b..44a4f42b9 100644 --- a/backend/src/api/explorer/nodes.routes.ts +++ b/backend/src/api/explorer/nodes.routes.ts @@ -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(); 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( 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..82280bdab --- /dev/null +++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html @@ -0,0 +1,42 @@ +
+

Lightning 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..e353d1361 --- /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'; +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; + 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; + } +} 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;