Merge pull request #2098 from mempool/nymkappa/feature/ln-nodes-per-country
Add nodes per country table page
This commit is contained in:
		
						commit
						5417aed397
					
				@ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user