Merge branch 'master' into nymkappa/bugfix/isp-chart-perc
This commit is contained in:
		
						commit
						fe139651f5
					
				
							
								
								
									
										12
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							@ -1,12 +0,0 @@
 | 
			
		||||
# These are supported funding model platforms
 | 
			
		||||
 | 
			
		||||
github: ['mempool'] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
 | 
			
		||||
patreon: # Replace with a single Patreon username
 | 
			
		||||
open_collective: # Replace with a single Open Collective username
 | 
			
		||||
ko_fi: # Replace with a single Ko-fi username
 | 
			
		||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
 | 
			
		||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
 | 
			
		||||
liberapay: # Replace with a single Liberapay username
 | 
			
		||||
issuehunt: # Replace with a single IssueHunt username
 | 
			
		||||
otechie: # Replace with a single Otechie username
 | 
			
		||||
custom: ['https://mempool.space/sponsor'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
 | 
			
		||||
@ -207,6 +207,10 @@ export class Common {
 | 
			
		||||
 | 
			
		||||
  /** Decodes a channel id returned by lnd as uint64 to a short channel id */
 | 
			
		||||
  static channelIntegerIdToShortId(id: string): string {
 | 
			
		||||
    if (id.indexOf('/') !== -1) {
 | 
			
		||||
      id = id.slice(0, -2);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (id.indexOf('x') !== -1) { // Already a short id
 | 
			
		||||
      return id;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@ import logger from '../logger';
 | 
			
		||||
import { Common } from './common';
 | 
			
		||||
 | 
			
		||||
class DatabaseMigration {
 | 
			
		||||
  private static currentVersion = 37;
 | 
			
		||||
  private static currentVersion = 38;
 | 
			
		||||
  private queryTimeout = 120000;
 | 
			
		||||
  private statisticsAddedIndexed = false;
 | 
			
		||||
  private uniqueLogs: string[] = [];
 | 
			
		||||
@ -248,7 +248,6 @@ class DatabaseMigration {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (databaseSchemaVersion < 25 && isBitcoin === true) {
 | 
			
		||||
      await this.$executeQuery(`INSERT INTO state VALUES('last_node_stats', 0, '1970-01-01');`);
 | 
			
		||||
      await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats'));
 | 
			
		||||
      await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes'));
 | 
			
		||||
      await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels'));
 | 
			
		||||
@ -328,6 +327,16 @@ class DatabaseMigration {
 | 
			
		||||
    if (databaseSchemaVersion < 37 && isBitcoin == true) {
 | 
			
		||||
      await this.$executeQuery(this.getCreateLNNodesSocketsTableQuery(), await this.$checkIfTableExists('nodes_sockets'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (databaseSchemaVersion < 38 && isBitcoin == true) {
 | 
			
		||||
      if (config.LIGHTNING.ENABLED) {
 | 
			
		||||
        this.uniqueLog(logger.notice, `'lightning_stats' and 'node_stats' tables have been truncated.`);
 | 
			
		||||
      }
 | 
			
		||||
      await this.$executeQuery(`TRUNCATE lightning_stats`);
 | 
			
		||||
      await this.$executeQuery(`TRUNCATE node_stats`);
 | 
			
		||||
      await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL');
 | 
			
		||||
      await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 | 
			
		||||
@ -61,9 +61,14 @@ class ChannelsApi {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async $getChannelsByStatus(status: number): Promise<any[]> {
 | 
			
		||||
  public async $getChannelsByStatus(status: number | number[]): Promise<any[]> {
 | 
			
		||||
    try {
 | 
			
		||||
      const query = `SELECT * FROM channels WHERE status = ?`;
 | 
			
		||||
      let query: string;
 | 
			
		||||
      if (Array.isArray(status)) {
 | 
			
		||||
        query = `SELECT * FROM channels WHERE status IN (${status.join(',')})`;
 | 
			
		||||
      } else {
 | 
			
		||||
        query = `SELECT * FROM channels WHERE status = ?`;
 | 
			
		||||
      }
 | 
			
		||||
      const [rows]: any = await DB.query(query, [status]);
 | 
			
		||||
      return rows;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
@ -218,23 +223,25 @@ class ChannelsApi {
 | 
			
		||||
 | 
			
		||||
      // Channels originating from node
 | 
			
		||||
      let query = `
 | 
			
		||||
        SELECT node2.alias, node2.public_key, channels.status, channels.node1_fee_rate,
 | 
			
		||||
        SELECT COALESCE(node2.alias, SUBSTRING(node2_public_key, 0, 20)) AS alias, COALESCE(node2.public_key, node2_public_key) AS public_key,
 | 
			
		||||
          channels.status, channels.node1_fee_rate,
 | 
			
		||||
          channels.capacity, channels.short_id, channels.id
 | 
			
		||||
        FROM channels
 | 
			
		||||
        JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key
 | 
			
		||||
        LEFT JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key
 | 
			
		||||
        WHERE node1_public_key = ? AND channels.status ${channelStatusFilter}
 | 
			
		||||
      `;
 | 
			
		||||
      const [channelsFromNode]: any = await DB.query(query, [public_key, index, length]);
 | 
			
		||||
      const [channelsFromNode]: any = await DB.query(query, [public_key]);
 | 
			
		||||
 | 
			
		||||
      // Channels incoming to node
 | 
			
		||||
      query = `
 | 
			
		||||
        SELECT node1.alias, node1.public_key, channels.status, channels.node2_fee_rate,
 | 
			
		||||
        SELECT COALESCE(node1.alias, SUBSTRING(node1_public_key, 0, 20)) AS alias, COALESCE(node1.public_key, node1_public_key) AS public_key,
 | 
			
		||||
          channels.status, channels.node2_fee_rate,
 | 
			
		||||
          channels.capacity, channels.short_id, channels.id
 | 
			
		||||
        FROM channels
 | 
			
		||||
        JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key
 | 
			
		||||
        LEFT JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key
 | 
			
		||||
        WHERE node2_public_key = ? AND channels.status ${channelStatusFilter}
 | 
			
		||||
      `;
 | 
			
		||||
      const [channelsToNode]: any = await DB.query(query, [public_key, index, length]);
 | 
			
		||||
      const [channelsToNode]: any = await DB.query(query, [public_key]);
 | 
			
		||||
 | 
			
		||||
      let allChannels = channelsFromNode.concat(channelsToNode);
 | 
			
		||||
      allChannels.sort((a, b) => {
 | 
			
		||||
@ -337,7 +344,7 @@ class ChannelsApi {
 | 
			
		||||
  /**
 | 
			
		||||
   * Save or update a channel present in the graph
 | 
			
		||||
   */
 | 
			
		||||
  public async $saveChannel(channel: ILightningApi.Channel): Promise<void> {
 | 
			
		||||
  public async $saveChannel(channel: ILightningApi.Channel, status = 1): Promise<void> {
 | 
			
		||||
    const [ txid, vout ] = channel.chan_point.split(':');
 | 
			
		||||
 | 
			
		||||
    const policy1: Partial<ILightningApi.RoutingPolicy> = channel.node1_policy || {};
 | 
			
		||||
@ -369,11 +376,11 @@ class ChannelsApi {
 | 
			
		||||
        node2_min_htlc_mtokens,
 | 
			
		||||
        node2_updated_at
 | 
			
		||||
      )
 | 
			
		||||
      VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
      VALUES (?, ?, ?, ?, ?, ?, ${status}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
      ON DUPLICATE KEY UPDATE
 | 
			
		||||
        capacity = ?,
 | 
			
		||||
        updated_at = ?,
 | 
			
		||||
        status = 1,
 | 
			
		||||
        status = ${status},
 | 
			
		||||
        node1_public_key = ?,
 | 
			
		||||
        node1_base_fee_mtokens = ?,
 | 
			
		||||
        node1_cltv_delta = ?,
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import logger from '../../logger';
 | 
			
		||||
import DB from '../../database';
 | 
			
		||||
import { ResultSetHeader } from 'mysql2';
 | 
			
		||||
import { ILightningApi } from '../lightning/lightning-api.interface';
 | 
			
		||||
import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces';
 | 
			
		||||
 | 
			
		||||
class NodesApi {
 | 
			
		||||
  public async $getNode(public_key: string): Promise<any> {
 | 
			
		||||
@ -9,10 +10,10 @@ class NodesApi {
 | 
			
		||||
      // General info
 | 
			
		||||
      let query = `
 | 
			
		||||
        SELECT public_key, alias, UNIX_TIMESTAMP(first_seen) AS first_seen,
 | 
			
		||||
        UNIX_TIMESTAMP(updated_at) AS updated_at, color, sockets as sockets,
 | 
			
		||||
        as_number, city_id, country_id, subdivision_id, longitude, latitude,
 | 
			
		||||
        geo_names_iso.names as iso_code, geo_names_as.names as as_organization, geo_names_city.names as city,
 | 
			
		||||
        geo_names_country.names as country, geo_names_subdivision.names as subdivision
 | 
			
		||||
          UNIX_TIMESTAMP(updated_at) AS updated_at, color, sockets as sockets,
 | 
			
		||||
          as_number, city_id, country_id, subdivision_id, longitude, latitude,
 | 
			
		||||
          geo_names_iso.names as iso_code, geo_names_as.names as as_organization, geo_names_city.names as city,
 | 
			
		||||
          geo_names_country.names as country, geo_names_subdivision.names as subdivision
 | 
			
		||||
        FROM nodes
 | 
			
		||||
        LEFT JOIN geo_names geo_names_as on geo_names_as.id = as_number
 | 
			
		||||
        LEFT JOIN geo_names geo_names_city on geo_names_city.id = city_id
 | 
			
		||||
@ -112,20 +113,46 @@ class NodesApi {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async $getTopCapacityNodes(): Promise<any> {
 | 
			
		||||
  public async $getTopCapacityNodes(full: boolean): Promise<ITopNodesPerCapacity[]> {
 | 
			
		||||
    try {
 | 
			
		||||
      let [rows]: any[] = await DB.query('SELECT UNIX_TIMESTAMP(MAX(added)) as maxAdded FROM node_stats');
 | 
			
		||||
      const latestDate = rows[0].maxAdded;
 | 
			
		||||
 | 
			
		||||
      const query = `
 | 
			
		||||
        SELECT nodes.public_key, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, node_stats.capacity, node_stats.channels
 | 
			
		||||
        FROM node_stats
 | 
			
		||||
        JOIN nodes ON nodes.public_key = node_stats.public_key
 | 
			
		||||
        WHERE added = FROM_UNIXTIME(${latestDate})
 | 
			
		||||
        ORDER BY capacity DESC
 | 
			
		||||
        LIMIT 10;
 | 
			
		||||
      `;
 | 
			
		||||
      [rows] = await DB.query(query);
 | 
			
		||||
      let query: string;
 | 
			
		||||
      if (full === false) {
 | 
			
		||||
        query = `
 | 
			
		||||
          SELECT nodes.public_key AS publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
 | 
			
		||||
            node_stats.capacity
 | 
			
		||||
          FROM node_stats
 | 
			
		||||
          JOIN nodes ON nodes.public_key = node_stats.public_key
 | 
			
		||||
          WHERE added = FROM_UNIXTIME(${latestDate})
 | 
			
		||||
          ORDER BY capacity DESC
 | 
			
		||||
          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.capacity, 0) as INT) as capacity,
 | 
			
		||||
            CAST(COALESCE(node_stats.channels, 0) as INT) as channels,
 | 
			
		||||
            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 capacity DESC
 | 
			
		||||
          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) {
 | 
			
		||||
@ -134,20 +161,94 @@ class NodesApi {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async $getTopChannelsNodes(): Promise<any> {
 | 
			
		||||
  public async $getTopChannelsNodes(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;
 | 
			
		||||
 | 
			
		||||
      const query = `
 | 
			
		||||
        SELECT nodes.public_key, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, node_stats.capacity, node_stats.channels
 | 
			
		||||
        FROM node_stats
 | 
			
		||||
        JOIN nodes ON nodes.public_key = node_stats.public_key
 | 
			
		||||
        WHERE added = FROM_UNIXTIME(${latestDate})
 | 
			
		||||
        ORDER BY channels DESC
 | 
			
		||||
        LIMIT 10;
 | 
			
		||||
      `;
 | 
			
		||||
      [rows] = await DB.query(query);
 | 
			
		||||
      let query: string;
 | 
			
		||||
      if (full === false) {
 | 
			
		||||
        query = `
 | 
			
		||||
          SELECT nodes.public_key as publicKey, 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 channels DESC
 | 
			
		||||
          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 channels DESC
 | 
			
		||||
          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 $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) {
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import config from '../../config';
 | 
			
		||||
import { Application, Request, Response } from 'express';
 | 
			
		||||
import nodesApi from './nodes.api';
 | 
			
		||||
import DB from '../../database';
 | 
			
		||||
import { INodesRanking } from '../../mempool.interfaces';
 | 
			
		||||
 | 
			
		||||
class NodesRoutes {
 | 
			
		||||
  constructor() { }
 | 
			
		||||
@ -10,10 +11,13 @@ class NodesRoutes {
 | 
			
		||||
    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/isp-ranking', this.$getISPRanking)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP)
 | 
			
		||||
      .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries)
 | 
			
		||||
      .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)
 | 
			
		||||
    ;
 | 
			
		||||
@ -56,11 +60,14 @@ class NodesRoutes {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async $getTopNodes(req: Request, res: Response) {
 | 
			
		||||
  private async $getNodesRanking(req: Request, res: Response): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      const topCapacityNodes = await nodesApi.$getTopCapacityNodes();
 | 
			
		||||
      const topChannelsNodes = await nodesApi.$getTopChannelsNodes();
 | 
			
		||||
      res.json({
 | 
			
		||||
      const topCapacityNodes = await nodesApi.$getTopCapacityNodes(false);
 | 
			
		||||
      const topChannelsNodes = await nodesApi.$getTopChannelsNodes(false);
 | 
			
		||||
      res.header('Pragma', 'public');
 | 
			
		||||
      res.header('Cache-control', 'public');
 | 
			
		||||
      res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
 | 
			
		||||
      res.json(<INodesRanking>{
 | 
			
		||||
        topByCapacity: topCapacityNodes,
 | 
			
		||||
        topByChannels: topChannelsNodes,
 | 
			
		||||
      });
 | 
			
		||||
@ -69,6 +76,42 @@ class NodesRoutes {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async $getTopNodesByCapacity(req: Request, res: Response): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      const topCapacityNodes = await nodesApi.$getTopCapacityNodes(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 $getTopNodesByChannels(req: Request, res: Response): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      const topCapacityNodes = await nodesApi.$getTopChannelsNodes(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 $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();
 | 
			
		||||
 | 
			
		||||
@ -189,7 +189,7 @@ class Server {
 | 
			
		||||
      await networkSyncService.$startService();
 | 
			
		||||
      await lightningStatsUpdater.$startService();
 | 
			
		||||
    } catch(e) {
 | 
			
		||||
      logger.err(`Lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`);
 | 
			
		||||
      logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`);
 | 
			
		||||
      await Common.sleep$(1000 * 60);
 | 
			
		||||
      this.$runLightningBackend();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -251,3 +251,41 @@ export interface RewardStats {
 | 
			
		||||
  totalFee: number;
 | 
			
		||||
  totalTx: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ITopNodesPerChannels {
 | 
			
		||||
  publicKey: string,
 | 
			
		||||
  alias: string,
 | 
			
		||||
  channels?: number,
 | 
			
		||||
  capacity: number,
 | 
			
		||||
  firstSeen?: number,
 | 
			
		||||
  updatedAt?: number,
 | 
			
		||||
  city?: any,
 | 
			
		||||
  country?: any,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ITopNodesPerCapacity {
 | 
			
		||||
  publicKey: string,
 | 
			
		||||
  alias: string,
 | 
			
		||||
  capacity: number,
 | 
			
		||||
  channels?: number,
 | 
			
		||||
  firstSeen?: number,
 | 
			
		||||
  updatedAt?: number,
 | 
			
		||||
  city?: any,
 | 
			
		||||
  country?: any,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
}
 | 
			
		||||
@ -232,8 +232,8 @@ class NetworkSyncService {
 | 
			
		||||
    let progress = 0;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      logger.info(`Starting closed channels scan...`);
 | 
			
		||||
      const channels = await channelsApi.$getChannelsByStatus(0);
 | 
			
		||||
      logger.info(`Starting closed channels scan`);
 | 
			
		||||
      const channels = await channelsApi.$getChannelsByStatus([0, 1]);
 | 
			
		||||
      for (const channel of channels) {
 | 
			
		||||
        const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout);
 | 
			
		||||
        if (spendingTx.spent === true && spendingTx.status?.confirmed === true) {
 | 
			
		||||
 | 
			
		||||
@ -71,9 +71,7 @@ class FundingTxFetcher {
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  public async $fetchChannelOpenTx(channelId: string): Promise<{timestamp: number, txid: string, value: number}> {
 | 
			
		||||
    if (channelId.indexOf('x') === -1) {
 | 
			
		||||
      channelId = Common.channelIntegerIdToShortId(channelId);
 | 
			
		||||
    }
 | 
			
		||||
    channelId = Common.channelIntegerIdToShortId(channelId);
 | 
			
		||||
 | 
			
		||||
    if (this.fundingTxCache[channelId]) {
 | 
			
		||||
      return this.fundingTxCache[channelId];
 | 
			
		||||
 | 
			
		||||
@ -5,33 +5,11 @@ import fundingTxFetcher from './funding-tx-fetcher';
 | 
			
		||||
import config from '../../../config';
 | 
			
		||||
import { ILightningApi } from '../../../api/lightning/lightning-api.interface';
 | 
			
		||||
import { isIP } from 'net';
 | 
			
		||||
import { Common } from '../../../api/common';
 | 
			
		||||
import channelsApi from '../../../api/explorer/channels.api';
 | 
			
		||||
 | 
			
		||||
const fsPromises = promises;
 | 
			
		||||
 | 
			
		||||
interface Node {
 | 
			
		||||
  id: string;
 | 
			
		||||
  timestamp: number;
 | 
			
		||||
  features: string;
 | 
			
		||||
  rgb_color: string;
 | 
			
		||||
  alias: string;
 | 
			
		||||
  addresses: unknown[];
 | 
			
		||||
  out_degree: number;
 | 
			
		||||
  in_degree: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Channel {
 | 
			
		||||
  channel_id: string;
 | 
			
		||||
  node1_pub: string;
 | 
			
		||||
  node2_pub: string;
 | 
			
		||||
  timestamp: number;
 | 
			
		||||
  features: string;
 | 
			
		||||
  fee_base_msat: number;
 | 
			
		||||
  fee_rate_milli_msat: number;
 | 
			
		||||
  htlc_minimim_msat: number;
 | 
			
		||||
  cltv_expiry_delta: number;
 | 
			
		||||
  htlc_maximum_msat: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class LightningStatsImporter {
 | 
			
		||||
  topologiesFolder = config.LIGHTNING.TOPOLOGY_FOLDER;
 | 
			
		||||
 | 
			
		||||
@ -46,7 +24,8 @@ class LightningStatsImporter {
 | 
			
		||||
  /**
 | 
			
		||||
   * Generate LN network stats for one day
 | 
			
		||||
   */
 | 
			
		||||
  public async computeNetworkStats(timestamp: number, networkGraph: ILightningApi.NetworkGraph): Promise<unknown> {
 | 
			
		||||
  public async computeNetworkStats(timestamp: number,
 | 
			
		||||
    networkGraph: ILightningApi.NetworkGraph, isHistorical: boolean = false): Promise<unknown> {
 | 
			
		||||
    // Node counts and network shares
 | 
			
		||||
    let clearnetNodes = 0;
 | 
			
		||||
    let torNodes = 0;
 | 
			
		||||
@ -59,11 +38,11 @@ class LightningStatsImporter {
 | 
			
		||||
      let isUnnanounced = true;
 | 
			
		||||
 | 
			
		||||
      for (const socket of (node.addresses ?? [])) {
 | 
			
		||||
        if (!socket.network?.length || !socket.addr?.length) {
 | 
			
		||||
        if (!socket.network?.length && !socket.addr?.length) {
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
        hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1;
 | 
			
		||||
        hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0]));
 | 
			
		||||
        hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1 || socket.addr.indexOf('torv2') !== -1 || socket.addr.indexOf('torv3') !== -1;
 | 
			
		||||
        hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0])) || socket.addr.indexOf('ipv4') !== -1 || socket.addr.indexOf('ipv6') !== -1;;
 | 
			
		||||
      }
 | 
			
		||||
      if (hasOnion && hasClearnet) {
 | 
			
		||||
        clearnetTorNodes++;
 | 
			
		||||
@ -90,11 +69,14 @@ class LightningStatsImporter {
 | 
			
		||||
    const baseFees: number[] = [];
 | 
			
		||||
    const alreadyCountedChannels = {};
 | 
			
		||||
    
 | 
			
		||||
    const [channelsInDbRaw]: any[] = await DB.query(`SELECT short_id, created FROM channels`);
 | 
			
		||||
    const channelsInDb = {};
 | 
			
		||||
    for (const channel of channelsInDbRaw) {
 | 
			
		||||
      channelsInDb[channel.short_id] = channel;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (const channel of networkGraph.edges) {
 | 
			
		||||
      let short_id = channel.channel_id;
 | 
			
		||||
      if (short_id.indexOf('/') !== -1) {
 | 
			
		||||
        short_id = short_id.slice(0, -2);
 | 
			
		||||
      }
 | 
			
		||||
      const short_id = Common.channelIntegerIdToShortId(channel.channel_id);
 | 
			
		||||
 | 
			
		||||
      const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id);
 | 
			
		||||
      if (!tx) {
 | 
			
		||||
@ -102,6 +84,31 @@ class LightningStatsImporter {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Channel is already in db, check if we need to update 'created' field
 | 
			
		||||
      if (isHistorical === true) {
 | 
			
		||||
        //@ts-ignore
 | 
			
		||||
        if (channelsInDb[short_id] && channel.timestamp < channel.created) {
 | 
			
		||||
          await DB.query(`
 | 
			
		||||
            UPDATE channels SET created = FROM_UNIXTIME(?) WHERE channels.short_id = ?`,
 | 
			
		||||
            //@ts-ignore
 | 
			
		||||
            [channel.timestamp, short_id]
 | 
			
		||||
          );
 | 
			
		||||
        } else if (!channelsInDb[short_id]) {
 | 
			
		||||
          await channelsApi.$saveChannel({
 | 
			
		||||
            channel_id: short_id,
 | 
			
		||||
            chan_point: `${tx.txid}:${short_id.split('x')[2]}`,
 | 
			
		||||
            //@ts-ignore
 | 
			
		||||
            last_update: channel.timestamp,
 | 
			
		||||
            node1_pub: channel.node1_pub,
 | 
			
		||||
            node2_pub: channel.node2_pub,
 | 
			
		||||
            capacity: (tx.value * 100000000).toString(),
 | 
			
		||||
            node1_policy: null,
 | 
			
		||||
            node2_policy: null,
 | 
			
		||||
          }, 0);
 | 
			
		||||
          channelsInDb[channel.channel_id] = channel;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!nodeStats[channel.node1_pub]) {
 | 
			
		||||
        nodeStats[channel.node1_pub] = {
 | 
			
		||||
          capacity: 0,
 | 
			
		||||
@ -126,7 +133,7 @@ class LightningStatsImporter {
 | 
			
		||||
        nodeStats[channel.node2_pub].channels++;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (channel.node1_policy !== undefined) { // Coming from the node
 | 
			
		||||
      if (isHistorical === false) { // Coming from the node
 | 
			
		||||
        for (const policy of [channel.node1_policy, channel.node2_policy]) {
 | 
			
		||||
          if (policy && parseInt(policy.fee_rate_milli_msat, 10) < 5000) {
 | 
			
		||||
            avgFeeRate += parseInt(policy.fee_rate_milli_msat, 10);
 | 
			
		||||
@ -137,30 +144,42 @@ class LightningStatsImporter {
 | 
			
		||||
            baseFees.push(parseInt(policy.fee_base_msat, 10));
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      } else { // Coming from the historical import
 | 
			
		||||
      } else {
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        if (channel.fee_rate_milli_msat < 5000) {
 | 
			
		||||
        if (channel.node1_policy.fee_rate_milli_msat < 5000) {
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          avgFeeRate += parseInt(channel.fee_rate_milli_msat, 10);
 | 
			
		||||
          avgFeeRate += parseInt(channel.node1_policy.fee_rate_milli_msat, 10);
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          feeRates.push(parseInt(channel.fee_rate_milli_msat), 10);
 | 
			
		||||
          feeRates.push(parseInt(channel.node1_policy.fee_rate_milli_msat), 10);
 | 
			
		||||
        }
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        if (channel.fee_base_msat < 5000) {
 | 
			
		||||
        if (channel.node1_policy.fee_base_msat < 5000) {
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          avgBaseFee += parseInt(channel.fee_base_msat, 10);
 | 
			
		||||
          avgBaseFee += parseInt(channel.node1_policy.fee_base_msat, 10);
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          baseFees.push(parseInt(channel.fee_base_msat), 10);
 | 
			
		||||
          baseFees.push(parseInt(channel.node1_policy.fee_base_msat), 10);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let medCapacity = 0;
 | 
			
		||||
    let medFeeRate = 0;
 | 
			
		||||
    let medBaseFee = 0;
 | 
			
		||||
    let avgCapacity = 0;
 | 
			
		||||
 | 
			
		||||
    avgFeeRate /= Math.max(networkGraph.edges.length, 1);
 | 
			
		||||
    avgBaseFee /= Math.max(networkGraph.edges.length, 1);
 | 
			
		||||
    const medCapacity = capacities.sort((a, b) => b - a)[Math.round(capacities.length / 2 - 1)];
 | 
			
		||||
    const medFeeRate = feeRates.sort((a, b) => b - a)[Math.round(feeRates.length / 2 - 1)];
 | 
			
		||||
    const medBaseFee = baseFees.sort((a, b) => b - a)[Math.round(baseFees.length / 2 - 1)];
 | 
			
		||||
    const avgCapacity = Math.round(capacity / Math.max(capacities.length, 1));
 | 
			
		||||
 | 
			
		||||
    if (capacities.length > 0) {
 | 
			
		||||
      medCapacity = capacities.sort((a, b) => b - a)[Math.round(capacities.length / 2 - 1)];
 | 
			
		||||
      avgCapacity = Math.round(capacity / Math.max(capacities.length, 1));
 | 
			
		||||
    }
 | 
			
		||||
    if (feeRates.length > 0) {
 | 
			
		||||
      medFeeRate = feeRates.sort((a, b) => b - a)[Math.round(feeRates.length / 2 - 1)];
 | 
			
		||||
    }
 | 
			
		||||
    if (baseFees.length > 0) {
 | 
			
		||||
      medBaseFee = baseFees.sort((a, b) => b - a)[Math.round(baseFees.length / 2 - 1)];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let query = `INSERT INTO lightning_stats(
 | 
			
		||||
        added,
 | 
			
		||||
@ -262,83 +281,154 @@ class LightningStatsImporter {
 | 
			
		||||
   * Import topology files LN historical data into the database
 | 
			
		||||
   */
 | 
			
		||||
  async $importHistoricalLightningStats(): Promise<void> {
 | 
			
		||||
    const fileList = await fsPromises.readdir(this.topologiesFolder);
 | 
			
		||||
    // Insert history from the most recent to the oldest
 | 
			
		||||
    // This also put the .json cached files first
 | 
			
		||||
    fileList.sort().reverse();
 | 
			
		||||
 | 
			
		||||
    const [rows]: any[] = await DB.query(`
 | 
			
		||||
      SELECT UNIX_TIMESTAMP(added) AS added, node_count
 | 
			
		||||
      FROM lightning_stats
 | 
			
		||||
      ORDER BY added DESC
 | 
			
		||||
    `);
 | 
			
		||||
    const existingStatsTimestamps = {};
 | 
			
		||||
    for (const row of rows) {
 | 
			
		||||
      existingStatsTimestamps[row.added] = row;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // For logging purpose
 | 
			
		||||
    let processed = 10;
 | 
			
		||||
    let totalProcessed = 0;
 | 
			
		||||
    let logStarted = false;
 | 
			
		||||
 | 
			
		||||
    for (const filename of fileList) {
 | 
			
		||||
      processed++;
 | 
			
		||||
 | 
			
		||||
      const timestamp = parseInt(filename.split('_')[1], 10);
 | 
			
		||||
 | 
			
		||||
      // Stats exist already, don't calculate/insert them
 | 
			
		||||
      if (existingStatsTimestamps[timestamp] !== undefined) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (filename.indexOf('.topology') === -1) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      logger.debug(`Reading ${this.topologiesFolder}/${filename}`);
 | 
			
		||||
      let fileContent = '';
 | 
			
		||||
    try {
 | 
			
		||||
      let fileList: string[] = [];
 | 
			
		||||
      try {
 | 
			
		||||
        fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8');
 | 
			
		||||
      } catch (e: any) {
 | 
			
		||||
        if (e.errno == -1) { // EISDIR - Ignore directorie
 | 
			
		||||
        fileList = await fsPromises.readdir(this.topologiesFolder);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        logger.err(`Unable to open topology folder at ${this.topologiesFolder}`);
 | 
			
		||||
        throw e;
 | 
			
		||||
      }
 | 
			
		||||
      // Insert history from the most recent to the oldest
 | 
			
		||||
      // This also put the .json cached files first
 | 
			
		||||
      fileList.sort().reverse();
 | 
			
		||||
 | 
			
		||||
      const [rows]: any[] = await DB.query(`
 | 
			
		||||
        SELECT UNIX_TIMESTAMP(added) AS added, node_count
 | 
			
		||||
        FROM lightning_stats
 | 
			
		||||
        ORDER BY added DESC
 | 
			
		||||
      `);
 | 
			
		||||
      const existingStatsTimestamps = {};
 | 
			
		||||
      for (const row of rows) {
 | 
			
		||||
        existingStatsTimestamps[row.added] = row;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // For logging purpose
 | 
			
		||||
      let processed = 10;
 | 
			
		||||
      let totalProcessed = 0;
 | 
			
		||||
      let logStarted = false;
 | 
			
		||||
 | 
			
		||||
      for (const filename of fileList) {
 | 
			
		||||
        processed++;
 | 
			
		||||
 | 
			
		||||
        const timestamp = parseInt(filename.split('_')[1], 10);
 | 
			
		||||
 | 
			
		||||
        // Stats exist already, don't calculate/insert them
 | 
			
		||||
        if (existingStatsTimestamps[timestamp] !== undefined) {
 | 
			
		||||
          totalProcessed++;
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (filename.indexOf('topology_') === -1) {
 | 
			
		||||
          totalProcessed++;
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        logger.debug(`Reading ${this.topologiesFolder}/${filename}`);
 | 
			
		||||
        let fileContent = '';
 | 
			
		||||
        try {
 | 
			
		||||
          fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8');
 | 
			
		||||
        } catch (e: any) {
 | 
			
		||||
          if (e.errno == -1) { // EISDIR - Ignore directorie
 | 
			
		||||
            continue;
 | 
			
		||||
          }
 | 
			
		||||
          logger.err(`Unable to open ${this.topologiesFolder}/${filename}`);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let graph;
 | 
			
		||||
        try {
 | 
			
		||||
          graph = JSON.parse(fileContent);
 | 
			
		||||
          graph = await this.cleanupTopology(graph);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
        if (!logStarted) {
 | 
			
		||||
          logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`);
 | 
			
		||||
          logStarted = true;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
 | 
			
		||||
        logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
 | 
			
		||||
 | 
			
		||||
        totalProcessed++;
 | 
			
		||||
 | 
			
		||||
        if (processed > 10) {
 | 
			
		||||
          logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
 | 
			
		||||
          processed = 0;
 | 
			
		||||
        } else {
 | 
			
		||||
          logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
 | 
			
		||||
        }
 | 
			
		||||
        await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
 | 
			
		||||
        const stat = await this.computeNetworkStats(timestamp, graph, true);
 | 
			
		||||
 | 
			
		||||
        existingStatsTimestamps[timestamp] = stat;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let graph;
 | 
			
		||||
      try {
 | 
			
		||||
        graph = JSON.parse(fileContent);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`);
 | 
			
		||||
      if (totalProcessed > 0) {
 | 
			
		||||
        logger.info(`Lightning network stats historical import completed`);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      logger.err(`Lightning network stats historical failed. Reason: ${e instanceof Error ? e.message : e}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async cleanupTopology(graph) {
 | 
			
		||||
    const newGraph = {
 | 
			
		||||
      nodes: <ILightningApi.Node[]>[],
 | 
			
		||||
      edges: <ILightningApi.Channel[]>[],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    for (const node of graph.nodes) {
 | 
			
		||||
      const addressesParts = (node.addresses ?? '').split(',');
 | 
			
		||||
      const addresses: any[] = [];
 | 
			
		||||
      for (const address of addressesParts) {
 | 
			
		||||
        addresses.push({
 | 
			
		||||
          network: '',
 | 
			
		||||
          addr: address
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      newGraph.nodes.push({
 | 
			
		||||
        last_update: node.timestamp ?? 0,
 | 
			
		||||
        pub_key: node.id ?? null,
 | 
			
		||||
        alias: node.alias ?? null,
 | 
			
		||||
        addresses: addresses,
 | 
			
		||||
        color: node.rgb_color ?? null,
 | 
			
		||||
        features: {},
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (const adjacency of graph.adjacency) {
 | 
			
		||||
      if (adjacency.length === 0) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
  
 | 
			
		||||
      if (!logStarted) {
 | 
			
		||||
        logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`);
 | 
			
		||||
        logStarted = true;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
 | 
			
		||||
      logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
 | 
			
		||||
 | 
			
		||||
      totalProcessed++;
 | 
			
		||||
 | 
			
		||||
      if (processed > 10) {
 | 
			
		||||
        logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
 | 
			
		||||
        processed = 0;
 | 
			
		||||
      } else {
 | 
			
		||||
        logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
 | 
			
		||||
        for (const edge of adjacency) {
 | 
			
		||||
          newGraph.edges.push({
 | 
			
		||||
            channel_id: edge.scid,
 | 
			
		||||
            chan_point: '',
 | 
			
		||||
            last_update: edge.timestamp,
 | 
			
		||||
            node1_pub: edge.source ?? null,
 | 
			
		||||
            node2_pub: edge.destination ?? null,
 | 
			
		||||
            capacity: '0', // Will be fetch later
 | 
			
		||||
            node1_policy: {
 | 
			
		||||
              time_lock_delta: edge.cltv_expiry_delta,
 | 
			
		||||
              min_htlc: edge.htlc_minimim_msat,
 | 
			
		||||
              fee_base_msat: edge.fee_base_msat,
 | 
			
		||||
              fee_rate_milli_msat: edge.fee_proportional_millionths,
 | 
			
		||||
              max_htlc_msat: edge.htlc_maximum_msat,
 | 
			
		||||
              last_update: edge.timestamp,
 | 
			
		||||
              disabled: false,          
 | 
			
		||||
            },
 | 
			
		||||
            node2_policy: null,
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
 | 
			
		||||
      const stat = await this.computeNetworkStats(timestamp, graph);
 | 
			
		||||
 | 
			
		||||
      existingStatsTimestamps[timestamp] = stat;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (totalProcessed > 0) {
 | 
			
		||||
      logger.info(`Lightning network stats historical import completed`);
 | 
			
		||||
    }
 | 
			
		||||
    return newGraph;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								contributors/junderw.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/junderw.txt
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of August 19, 2022.
 | 
			
		||||
 | 
			
		||||
Signed: junderw
 | 
			
		||||
@ -4,7 +4,7 @@ import { ActivatedRoute, ParamMap, Router } from '@angular/router';
 | 
			
		||||
import { ElectrsApiService } from '../../services/electrs-api.service';
 | 
			
		||||
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators';
 | 
			
		||||
import { Transaction, Vout } from '../../interfaces/electrs.interface';
 | 
			
		||||
import { Observable, of, Subscription, asyncScheduler } from 'rxjs';
 | 
			
		||||
import { Observable, of, Subscription, asyncScheduler, EMPTY } from 'rxjs';
 | 
			
		||||
import { StateService } from '../../services/state.service';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { WebsocketService } from 'src/app/services/websocket.service';
 | 
			
		||||
@ -142,8 +142,21 @@ export class BlockComponent implements OnInit, OnDestroy {
 | 
			
		||||
                  this.location.replaceState(
 | 
			
		||||
                    this.router.createUrlTree([(this.network ? '/' + this.network : '') + '/block/', hash]).toString()
 | 
			
		||||
                  );
 | 
			
		||||
                  return this.apiService.getBlock$(hash);
 | 
			
		||||
                })
 | 
			
		||||
                  return this.apiService.getBlock$(hash).pipe(
 | 
			
		||||
                    catchError((err) => {
 | 
			
		||||
                      this.error = err;
 | 
			
		||||
                      this.isLoadingBlock = false;
 | 
			
		||||
                      this.isLoadingOverview = false;
 | 
			
		||||
                      return EMPTY;
 | 
			
		||||
                    })
 | 
			
		||||
                  );
 | 
			
		||||
                }),
 | 
			
		||||
                catchError((err) => {
 | 
			
		||||
                  this.error = err;
 | 
			
		||||
                  this.isLoadingBlock = false;
 | 
			
		||||
                  this.isLoadingOverview = false;
 | 
			
		||||
                  return EMPTY;
 | 
			
		||||
                }),
 | 
			
		||||
              );
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@ -152,7 +165,14 @@ export class BlockComponent implements OnInit, OnDestroy {
 | 
			
		||||
            return of(blockInCache);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return this.apiService.getBlock$(blockHash);
 | 
			
		||||
          return this.apiService.getBlock$(blockHash).pipe(
 | 
			
		||||
            catchError((err) => {
 | 
			
		||||
              this.error = err;
 | 
			
		||||
              this.isLoadingBlock = false;
 | 
			
		||||
              this.isLoadingOverview = false;
 | 
			
		||||
              return EMPTY;
 | 
			
		||||
            })
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
      tap((block: BlockExtended) => {
 | 
			
		||||
@ -168,7 +188,6 @@ export class BlockComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
        this.block = block;
 | 
			
		||||
        this.blockHeight = block.height;
 | 
			
		||||
        const direction = (this.lastBlockHeight < this.blockHeight) ? 'right' : 'left';
 | 
			
		||||
        this.lastBlockHeight = this.blockHeight;
 | 
			
		||||
        this.nextBlockHeight = block.height + 1;
 | 
			
		||||
        this.setNextAndPreviousBlockLink();
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,7 @@
 | 
			
		||||
      <div class="col">
 | 
			
		||||
        <table class="table table-borderless smaller-text table-sm table-tx-vin">
 | 
			
		||||
          <tbody>
 | 
			
		||||
            <ng-template ngFor let-vin [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length>12)?tx.vin.slice(0, 10): tx.vin.slice(0, 12)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
 | 
			
		||||
            <ng-template ngFor let-vin [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length > rowLimit) ? tx.vin.slice(0, rowLimit - 2) : tx.vin.slice(0, rowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
 | 
			
		||||
              <tr [ngClass]="{
 | 
			
		||||
                'assetBox': assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded,
 | 
			
		||||
                'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== ''
 | 
			
		||||
@ -146,9 +146,9 @@
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </ng-template>
 | 
			
		||||
            <tr *ngIf="tx.vin.length > 12 && tx['@vinLimit']">
 | 
			
		||||
            <tr *ngIf="tx.vin.length > rowLimit && tx['@vinLimit']">
 | 
			
		||||
              <td colspan="3" class="text-center">
 | 
			
		||||
                <button class="btn btn-sm btn-primary mt-2" (click)="loadMoreInputs(tx);"><span i18n="show-all">Show all</span> ({{ tx.vin.length - 10 }})</button>
 | 
			
		||||
                <button class="btn btn-sm btn-primary mt-2" (click)="loadMoreInputs(tx);"><span i18n="show-all">Show all</span> ({{ tx.vin.length }})</button>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </tbody>
 | 
			
		||||
@ -158,7 +158,7 @@
 | 
			
		||||
      <div class="col mobile-bottomcol">
 | 
			
		||||
        <table class="table table-borderless smaller-text table-sm table-tx-vout">
 | 
			
		||||
          <tbody>
 | 
			
		||||
            <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] && !outputIndex ? ((tx.vout.length > 12) ? tx.vout.slice(0, 10) : tx.vout.slice(0, 12)) : tx.vout" [ngForTrackBy]="trackByIndexFn">
 | 
			
		||||
            <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] && !outputIndex ? ((tx.vout.length > rowLimit) ? tx.vout.slice(0, rowLimit - 2) : tx.vout.slice(0, rowLimit)) : tx.vout" [ngForTrackBy]="trackByIndexFn">
 | 
			
		||||
              <tr [ngClass]="{
 | 
			
		||||
                'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex,
 | 
			
		||||
                'highlight': vout.scriptpubkey_address === this.address && this.address !== ''
 | 
			
		||||
@ -257,9 +257,9 @@
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </ng-template>
 | 
			
		||||
            <tr *ngIf="tx.vout.length > 12 && tx['@voutLimit'] && !outputIndex">
 | 
			
		||||
            <tr *ngIf="tx.vout.length > rowLimit && tx['@voutLimit'] && !outputIndex">
 | 
			
		||||
              <td colspan="3" class="text-center">
 | 
			
		||||
                <button class="btn btn-sm btn-primary mt-2" (click)="tx['@voutLimit'] = false;"><span i18n="show-all">Show all</span> ({{ tx.vout.length - 10 }})</button>
 | 
			
		||||
                <button class="btn btn-sm btn-primary mt-2" (click)="tx['@voutLimit'] = false;"><span i18n="show-all">Show all</span> ({{ tx.vout.length }})</button>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </tbody>
 | 
			
		||||
 | 
			
		||||
@ -26,6 +26,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
 | 
			
		||||
  @Input() paginated = false;
 | 
			
		||||
  @Input() outputIndex: number;
 | 
			
		||||
  @Input() address: string = '';
 | 
			
		||||
  @Input() rowLimit = 12;
 | 
			
		||||
  @Input() channels: { inputs: any[], outputs: any[] };
 | 
			
		||||
 | 
			
		||||
  @Output() loadMore = new EventEmitter();
 | 
			
		||||
 | 
			
		||||
@ -36,7 +38,6 @@ export class TransactionsListComponent implements OnInit, OnChanges {
 | 
			
		||||
  showDetails$ = new BehaviorSubject<boolean>(false);
 | 
			
		||||
  outspends: Outspend[][] = [];
 | 
			
		||||
  assetsMinimal: any;
 | 
			
		||||
  channels: { inputs: any[], outputs: any[] };
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    public stateService: StateService,
 | 
			
		||||
@ -127,7 +128,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
 | 
			
		||||
    });
 | 
			
		||||
    const txIds = this.transactions.map((tx) => tx.txid);
 | 
			
		||||
    this.refreshOutspends$.next(txIds);
 | 
			
		||||
    this.refreshChannels$.next(txIds);
 | 
			
		||||
    if (!this.channels) {
 | 
			
		||||
      this.refreshChannels$.next(txIds);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onScroll() {
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
<ng-template [ngIf]="!tx.status.confirmed || tx.status.block_height >= 477120">
 | 
			
		||||
<span *ngIf="segwitGains.realizedSegwitGains && !segwitGains.potentialSegwitGains; else segwitTwo" class="badge badge-success mr-1" i18n-ngbTooltip="ngbTooltip about segwit gains" ngbTooltip="This transaction saved {{ segwitGains.realizedSegwitGains * 100 | number:  '1.0-0' }}% on fees by using native SegWit" placement="bottom" i18n="tx-features.tag.segwit|SegWit">SegWit</span>
 | 
			
		||||
<ng-template #segwitTwo>
 | 
			
		||||
  <span *ngIf="segwitGains.realizedSegwitGains && segwitGains.potentialSegwitGains; else potentialP2shSegwitGains" class="badge badge-warning mr-1" i18n-ngbTooltip="ngbTooltip about double segwit gains" ngbTooltip="This transaction saved {{ segwitGains.realizedSegwitGains * 100 | number:  '1.0-0' }}% on fees by using SegWit and could save {{ segwitGains.potentialSegwitGains * 100 | number : '1.0-0' }}% more by fully upgrading to native SegWit" placement="bottom" i18n="tx-features.tag.segwit|SegWit">SegWit</span>
 | 
			
		||||
@ -5,17 +6,22 @@
 | 
			
		||||
    <span *ngIf="segwitGains.potentialP2shSegwitGains" class="badge badge-danger mr-1" i18n-ngbTooltip="ngbTooltip about missed out gains" ngbTooltip="This transaction could save {{ segwitGains.potentialSegwitGains * 100 | number : '1.0-0' }}% on fees by upgrading to native SegWit or {{ segwitGains.potentialP2shSegwitGains * 100 | number:  '1.0-0' }}% by upgrading to SegWit-P2SH" placement="bottom"><del i18n="tx-features.tag.segwit|SegWit">SegWit</del></span>
 | 
			
		||||
  </ng-template>
 | 
			
		||||
</ng-template>
 | 
			
		||||
</ng-template>
 | 
			
		||||
 | 
			
		||||
<span *ngIf="segwitGains.realizedTaprootGains && !segwitGains.potentialTaprootGains; else notFullyTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about privacy and fees saved with taproot" ngbTooltip="This transaction uses Taproot and thereby increased the user's privacy and saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
 | 
			
		||||
<ng-template [ngIf]="!tx.status.confirmed || tx.status.block_height >= 709632">
 | 
			
		||||
<span *ngIf="segwitGains.realizedTaprootGains && !segwitGains.potentialTaprootGains; else notFullyTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about fees saved with taproot" ngbTooltip="This transaction uses Taproot and thereby saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
 | 
			
		||||
<ng-template #notFullyTaproot>
 | 
			
		||||
  <span *ngIf="segwitGains.realizedTaprootGains && segwitGains.potentialTaprootGains; else noTaproot" class="badge badge-warning mr-1" i18n-ngbTooltip="Tooltip about privacy and more fees that could be saved with more taproot" ngbTooltip="This transaction uses Taproot and thereby increased the user's privacy and already saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees, but could save an additional {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% by fully using Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
 | 
			
		||||
  <span *ngIf="segwitGains.realizedTaprootGains && segwitGains.potentialTaprootGains; else noTaproot" class="badge badge-warning mr-1" i18n-ngbTooltip="Tooltip about fees that saved and could be saved with taproot" ngbTooltip="This transaction uses Taproot and already saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees, but could save an additional {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% by fully using Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
 | 
			
		||||
  <ng-template #noTaproot>
 | 
			
		||||
    <span *ngIf="segwitGains.potentialTaprootGains; else taprootButNoGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about privacy and fees that could be saved with taproot" ngbTooltip="This transaction could increase the user's privacy and save {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% on fees by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span>
 | 
			
		||||
    <span *ngIf="segwitGains.potentialTaprootGains; else taprootButNoGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about fees that could be saved with taproot" ngbTooltip="This transaction could save {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% on fees by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span>
 | 
			
		||||
    <ng-template #taprootButNoGains>
 | 
			
		||||
      <span *ngIf="isTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about privacy with taproot" ngbTooltip="This transaction uses Taproot and thereby increased the user's privacy" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
 | 
			
		||||
      <span *ngIf="isTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about taproot" ngbTooltip="This transaction uses Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
 | 
			
		||||
    </ng-template>
 | 
			
		||||
  </ng-template>
 | 
			
		||||
</ng-template>
 | 
			
		||||
</ng-template>
 | 
			
		||||
 | 
			
		||||
<ng-template [ngIf]="!tx.status.confirmed || tx.status.block_height > 399700">
 | 
			
		||||
<span *ngIf="isRbfTransaction; else rbfDisabled" class="badge badge-success" i18n-ngbTooltip="RBF tooltip" ngbTooltip="This transaction supports Replace-By-Fee (RBF) allowing fee bumping" placement="bottom" i18n="tx-features.tag.rbf|RBF">RBF</span>
 | 
			
		||||
<ng-template #rbfDisabled><span class="badge badge-danger mr-1" i18n-ngbTooltip="RBF disabled tooltip" ngbTooltip="This transaction does NOT support Replace-By-Fee (RBF) and cannot be fee bumped using this method" placement="bottom"><del i18n="tx-features.tag.rbf|RBF">RBF</del></span></ng-template>
 | 
			
		||||
</ng-template>
 | 
			
		||||
 | 
			
		||||
@ -151,3 +151,41 @@ export interface RewardStats {
 | 
			
		||||
  totalFee: number;
 | 
			
		||||
  totalTx: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ITopNodesPerChannels {
 | 
			
		||||
  publicKey: string,
 | 
			
		||||
  alias: string,
 | 
			
		||||
  channels?: number,
 | 
			
		||||
  capacity: number,
 | 
			
		||||
  firstSeen?: number,
 | 
			
		||||
  updatedAt?: number,
 | 
			
		||||
  city?: any,
 | 
			
		||||
  country?: any,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ITopNodesPerCapacity {
 | 
			
		||||
  publicKey: string,
 | 
			
		||||
  alias: string,
 | 
			
		||||
  capacity: number,
 | 
			
		||||
  channels?: number,
 | 
			
		||||
  firstSeen?: number,
 | 
			
		||||
  updatedAt?: number,
 | 
			
		||||
  city?: any,
 | 
			
		||||
  country?: any,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
}
 | 
			
		||||
@ -16,3 +16,9 @@
 | 
			
		||||
  color: #ffffff66;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  .box {
 | 
			
		||||
    margin-bottom: 20px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -14,7 +14,7 @@
 | 
			
		||||
 | 
			
		||||
  <div class="clearfix"></div>
 | 
			
		||||
 | 
			
		||||
  <app-nodes-channels-map *ngIf="!error" [style]="'channelpage'" [channel]="channelGeo"></app-nodes-channels-map>
 | 
			
		||||
  <app-nodes-channels-map *ngIf="!error && (channelGeo$ | async) as channelGeo" [style]="'channelpage'" [channel]="channelGeo"></app-nodes-channels-map>
 | 
			
		||||
 | 
			
		||||
  <div class="box">
 | 
			
		||||
 | 
			
		||||
@ -30,32 +30,6 @@
 | 
			
		||||
                <td i18n="address.total-sent">Last update</td>
 | 
			
		||||
                <td><app-timestamp [dateString]="channel.updated_at"></app-timestamp></td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td i18n="address.total-sent">Opening transaction</td>
 | 
			
		||||
                <td>
 | 
			
		||||
                  <a [routerLink]="['/tx' | relativeUrl, channel.transaction_id + ':' + channel.transaction_vout]" >
 | 
			
		||||
                    <span>{{ channel.transaction_id | shortenString : 10 }}</span>
 | 
			
		||||
                  </a>
 | 
			
		||||
                  <app-clipboard [text]="channel.transaction_id"></app-clipboard>
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <ng-template [ngIf]="channel.closing_transaction_id">
 | 
			
		||||
                <tr *ngIf="channel.closing_transaction_id">
 | 
			
		||||
                  <td i18n="address.total-sent">Closing transaction</td>
 | 
			
		||||
                  <td>
 | 
			
		||||
                    <a [routerLink]="['/tx' | relativeUrl, channel.closing_transaction_id]" >
 | 
			
		||||
                      <span>{{ channel.closing_transaction_id | shortenString : 10 }}</span>
 | 
			
		||||
                    </a>
 | 
			
		||||
                    <app-clipboard [text]="channel.closing_transaction_id"></app-clipboard>
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                <tr>
 | 
			
		||||
                  <td i18n="address.total-sent">Closing type</td>
 | 
			
		||||
                  <td>
 | 
			
		||||
                    <app-closing-type [type]="channel.closing_reason"></app-closing-type>
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
              </ng-template>
 | 
			
		||||
            </tbody>
 | 
			
		||||
          </table>
 | 
			
		||||
        </div>
 | 
			
		||||
@ -82,8 +56,23 @@
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col">
 | 
			
		||||
        <app-channel-box [channel]="channel.node_right"></app-channel-box>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
    
 | 
			
		||||
    <br>
 | 
			
		||||
 | 
			
		||||
    <ng-container *ngIf="transactions$ | async as transactions">
 | 
			
		||||
      <ng-template [ngIf]="transactions[0]">
 | 
			
		||||
        <h3>Opening transaction</h3>
 | 
			
		||||
        <app-transactions-list [transactions]="[transactions[0]]" [showConfirmations]="true" [rowLimit]="5" [channels]="{ inputs: [], outputs: [channel] }"></app-transactions-list>
 | 
			
		||||
      </ng-template>
 | 
			
		||||
      <ng-template [ngIf]="transactions[1]">
 | 
			
		||||
        <div class="closing-header">
 | 
			
		||||
          <h3 style="margin: 0;">Closing transaction</h3>  <app-closing-type [type]="channel.closing_reason"></app-closing-type>
 | 
			
		||||
        </div>
 | 
			
		||||
        <app-transactions-list [transactions]="[transactions[1]]" [showConfirmations]="true" [rowLimit]="5" [channels]="{ inputs: [channel], outputs: [] }"></app-transactions-list>
 | 
			
		||||
      </ng-template>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -39,3 +39,16 @@ app-fiat {
 | 
			
		||||
    margin-left: 10px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.closing-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  h3 {
 | 
			
		||||
    font-size: 1.4rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,9 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 | 
			
		||||
import { ActivatedRoute, ParamMap } from '@angular/router';
 | 
			
		||||
import { Observable, of } from 'rxjs';
 | 
			
		||||
import { catchError, switchMap, tap } from 'rxjs/operators';
 | 
			
		||||
import { forkJoin, Observable, of, share, zip } from 'rxjs';
 | 
			
		||||
import { catchError, map, shareReplay, switchMap, tap } from 'rxjs/operators';
 | 
			
		||||
import { ApiService } from 'src/app/services/api.service';
 | 
			
		||||
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
 | 
			
		||||
@ -13,13 +15,15 @@ import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
})
 | 
			
		||||
export class ChannelComponent implements OnInit {
 | 
			
		||||
  channel$: Observable<any>;
 | 
			
		||||
  channelGeo$: Observable<number[]>;
 | 
			
		||||
  transactions$: Observable<any>;
 | 
			
		||||
  error: any = null;
 | 
			
		||||
  channelGeo: number[] = [];
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private lightningApiService: LightningApiService,
 | 
			
		||||
    private activatedRoute: ActivatedRoute,
 | 
			
		||||
    private seoService: SeoService,
 | 
			
		||||
    private electrsApiService: ElectrsApiService,
 | 
			
		||||
  ) { }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
@ -30,28 +34,41 @@ export class ChannelComponent implements OnInit {
 | 
			
		||||
          this.seoService.setTitle(`Channel: ${params.get('short_id')}`);
 | 
			
		||||
          return this.lightningApiService.getChannel$(params.get('short_id'))
 | 
			
		||||
            .pipe(
 | 
			
		||||
              tap((data) => {
 | 
			
		||||
                if (!data.node_left.longitude || !data.node_left.latitude ||
 | 
			
		||||
                  !data.node_right.longitude || !data.node_right.latitude) {
 | 
			
		||||
                  this.channelGeo = [];
 | 
			
		||||
                } else {
 | 
			
		||||
                  this.channelGeo = [
 | 
			
		||||
                    data.node_left.public_key,
 | 
			
		||||
                    data.node_left.alias,
 | 
			
		||||
                    data.node_left.longitude, data.node_left.latitude,
 | 
			
		||||
                    data.node_right.public_key,
 | 
			
		||||
                    data.node_right.alias,
 | 
			
		||||
                    data.node_right.longitude, data.node_right.latitude,
 | 
			
		||||
                  ];
 | 
			
		||||
                }
 | 
			
		||||
              }),
 | 
			
		||||
              catchError((err) => {
 | 
			
		||||
                this.error = err;
 | 
			
		||||
                return of(null);
 | 
			
		||||
              })
 | 
			
		||||
            );
 | 
			
		||||
        })
 | 
			
		||||
        }),
 | 
			
		||||
        shareReplay(),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    this.channelGeo$ = this.channel$.pipe(
 | 
			
		||||
      map((data) => {
 | 
			
		||||
        if (!data.node_left.longitude || !data.node_left.latitude ||
 | 
			
		||||
          !data.node_right.longitude || !data.node_right.latitude) {
 | 
			
		||||
          return [];
 | 
			
		||||
        } else {
 | 
			
		||||
          return [
 | 
			
		||||
            data.node_left.public_key,
 | 
			
		||||
            data.node_left.alias,
 | 
			
		||||
            data.node_left.longitude, data.node_left.latitude,
 | 
			
		||||
            data.node_right.public_key,
 | 
			
		||||
            data.node_right.alias,
 | 
			
		||||
            data.node_right.longitude, data.node_right.latitude,
 | 
			
		||||
          ];
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    this.transactions$ = this.channel$.pipe(
 | 
			
		||||
      switchMap((data) => {
 | 
			
		||||
        return zip([
 | 
			
		||||
          data.transaction_id ? this.electrsApiService.getTransaction$(data.transaction_id) : of(null),
 | 
			
		||||
          data.closing_transaction_id ? this.electrsApiService.getTransaction$(data.closing_transaction_id) : of(null),
 | 
			
		||||
        ]);
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,11 @@
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
  
 | 
			
		||||
  <ngb-pagination *ngIf="response.channels.length > 0" class="pagination-container float-right" [size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
 | 
			
		||||
  <ngb-pagination *ngIf="response.channels.length > 0" class="pagination-container float-right"
 | 
			
		||||
    [size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true"
 | 
			
		||||
    [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)"
 | 
			
		||||
    [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false">
 | 
			
		||||
  </ngb-pagination>
 | 
			
		||||
 | 
			
		||||
  <table class="table table-borderless" *ngIf="response.channels.length === 0">
 | 
			
		||||
    <div class="d-flex justify-content-center" i18n="lightning.empty-channels-list">No channels to display</div>
 | 
			
		||||
 | 
			
		||||
@ -47,7 +47,7 @@ export class ChannelsListComponent implements OnInit, OnChanges {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnChanges(): void {
 | 
			
		||||
    this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false });
 | 
			
		||||
    this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: true });
 | 
			
		||||
    this.channelsPage$.next(1);
 | 
			
		||||
 | 
			
		||||
    this.channels$ = merge(
 | 
			
		||||
@ -70,7 +70,7 @@ export class ChannelsListComponent implements OnInit, OnChanges {
 | 
			
		||||
      map((response) => {
 | 
			
		||||
        return {
 | 
			
		||||
          channels: response.body,
 | 
			
		||||
          totalItems: parseInt(response.headers.get('x-total-count'), 10) + 1 
 | 
			
		||||
          totalItems: parseInt(response.headers.get('x-total-count'), 10)
 | 
			
		||||
        };
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -2,6 +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, IOldestNodes, ITopNodesPerCapacity, ITopNodesPerChannels } from '../interfaces/node-api.interface';
 | 
			
		||||
 | 
			
		||||
@Injectable({
 | 
			
		||||
  providedIn: 'root'
 | 
			
		||||
@ -48,8 +49,8 @@ export class LightningApiService {
 | 
			
		||||
    return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/statistics');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  listTopNodes$(): Observable<any> {
 | 
			
		||||
    return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/top');
 | 
			
		||||
  getNodesRanking$(): Observable<INodesRanking> {
 | 
			
		||||
    return this.httpClient.get<INodesRanking>(this.apiBasePath + '/api/v1/lightning/nodes/rankings');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  listChannelStats$(publicKey: string): Observable<any> {
 | 
			
		||||
@ -62,4 +63,22 @@ export class LightningApiService {
 | 
			
		||||
      (interval !== undefined ? `/${interval}` : ''), { observe: 'response' }
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getTopNodesByCapacity$(): Observable<ITopNodesPerCapacity[]> {
 | 
			
		||||
    return this.httpClient.get<ITopNodesPerCapacity[]>(
 | 
			
		||||
      this.apiBasePath + '/api/v1/lightning/nodes/rankings/capacity'
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getTopNodesByChannels$(): Observable<ITopNodesPerChannels[]> {
 | 
			
		||||
    return this.httpClient.get<ITopNodesPerChannels[]>(
 | 
			
		||||
      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,22 +54,30 @@
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Top nodes per capacity -->
 | 
			
		||||
    <div class="col">
 | 
			
		||||
      <div class="card">
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
          <h5 class="card-title">Top Capacity Nodes</h5>
 | 
			
		||||
          <app-nodes-list [nodes$]="nodesByCapacity$" [show]="'mobile-capacity'"></app-nodes-list>
 | 
			
		||||
          <!-- <div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div> -->
 | 
			
		||||
          <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>
 | 
			
		||||
 | 
			
		||||
    <!-- Top nodes per channels -->
 | 
			
		||||
    <div class="col">
 | 
			
		||||
      <div class="card">
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
          <h5 class="card-title">Most Connected Nodes</h5>
 | 
			
		||||
          <app-nodes-list [nodes$]="nodesByChannels$" [show]="'mobile-channels'"></app-nodes-list>
 | 
			
		||||
          <!-- <div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div> -->
 | 
			
		||||
          <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>
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 | 
			
		||||
import { Observable } from 'rxjs';
 | 
			
		||||
import { map, share } from 'rxjs/operators';
 | 
			
		||||
import { share } from 'rxjs/operators';
 | 
			
		||||
import { INodesRanking } from 'src/app/interfaces/node-api.interface';
 | 
			
		||||
import { SeoService } from 'src/app/services/seo.service';
 | 
			
		||||
import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
 | 
			
		||||
@ -11,9 +12,8 @@ import { LightningApiService } from '../lightning-api.service';
 | 
			
		||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
			
		||||
})
 | 
			
		||||
export class LightningDashboardComponent implements OnInit {
 | 
			
		||||
  nodesByCapacity$: Observable<any>;
 | 
			
		||||
  nodesByChannels$: Observable<any>;
 | 
			
		||||
  statistics$: Observable<any>;
 | 
			
		||||
  nodesRanking$: Observable<INodesRanking>;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private lightningApiService: LightningApiService,
 | 
			
		||||
@ -23,18 +23,7 @@ export class LightningDashboardComponent implements OnInit {
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.seoService.setTitle($localize`Lightning Dashboard`);
 | 
			
		||||
 | 
			
		||||
    const sharedObservable = this.lightningApiService.listTopNodes$().pipe(share());
 | 
			
		||||
 | 
			
		||||
    this.nodesByCapacity$ = sharedObservable
 | 
			
		||||
      .pipe(
 | 
			
		||||
        map((object) => object.topByCapacity),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    this.nodesByChannels$ = sharedObservable
 | 
			
		||||
      .pipe(
 | 
			
		||||
        map((object) => object.topByChannels),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    this.nodesRanking$ = this.lightningApiService.getNodesRanking$().pipe(share());
 | 
			
		||||
    this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -24,6 +24,12 @@ import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
 | 
			
		||||
import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
 | 
			
		||||
import { NodesMap } from '../lightning/nodes-map/nodes-map.component';
 | 
			
		||||
import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels-map.component';
 | 
			
		||||
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,
 | 
			
		||||
@ -45,6 +51,11 @@ import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels
 | 
			
		||||
    NodesPerCountryChartComponent,
 | 
			
		||||
    NodesMap,
 | 
			
		||||
    NodesChannelsMap,
 | 
			
		||||
    NodesRanking,
 | 
			
		||||
    TopNodesPerChannels,
 | 
			
		||||
    TopNodesPerCapacity,
 | 
			
		||||
    OldestNodes,
 | 
			
		||||
    NodesRankingsDashboard,
 | 
			
		||||
  ],
 | 
			
		||||
  imports: [
 | 
			
		||||
    CommonModule,
 | 
			
		||||
@ -73,6 +84,11 @@ import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels
 | 
			
		||||
    NodesPerCountryChartComponent,
 | 
			
		||||
    NodesMap,
 | 
			
		||||
    NodesChannelsMap,
 | 
			
		||||
    NodesRanking,
 | 
			
		||||
    TopNodesPerChannels,
 | 
			
		||||
    TopNodesPerCapacity,
 | 
			
		||||
    OldestNodes,
 | 
			
		||||
    NodesRankingsDashboard,
 | 
			
		||||
  ],
 | 
			
		||||
  providers: [
 | 
			
		||||
    LightningApiService,
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,8 @@ import { NodeComponent } from './node/node.component';
 | 
			
		||||
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 = [
 | 
			
		||||
    {
 | 
			
		||||
@ -32,6 +34,31 @@ const routes: Routes = [
 | 
			
		||||
          path: 'nodes/isp/:isp',
 | 
			
		||||
          component: NodesPerISP,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: 'nodes/rankings',
 | 
			
		||||
          component: NodesRankingsDashboard,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: 'nodes/top-capacity',
 | 
			
		||||
          component: NodesRanking,
 | 
			
		||||
          data: {
 | 
			
		||||
            type: 'capacity'
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: 'nodes/top-channels',
 | 
			
		||||
          component: NodesRanking,
 | 
			
		||||
          data: {
 | 
			
		||||
            type: 'channels'
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: 'nodes/oldest',
 | 
			
		||||
          component: NodesRanking,
 | 
			
		||||
          data: {
 | 
			
		||||
            type: 'oldest'
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: '**',
 | 
			
		||||
          redirectTo: ''
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
<app-top-nodes-per-capacity [nodes$]="null" [widget]="false" *ngIf="type === 'capacity'">
 | 
			
		||||
</app-top-nodes-per-capacity>
 | 
			
		||||
 | 
			
		||||
<app-top-nodes-per-channels [nodes$]="null" [widget]="false" *ngIf="type === 'channels'">
 | 
			
		||||
</app-top-nodes-per-channels>
 | 
			
		||||
 | 
			
		||||
<app-oldest-nodes [widget]="false" *ngIf="type === 'oldest'"></app-oldest-nodes>
 | 
			
		||||
@ -0,0 +1,20 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 | 
			
		||||
import { ActivatedRoute } from '@angular/router';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-nodes-ranking',
 | 
			
		||||
  templateUrl: './nodes-ranking.component.html',
 | 
			
		||||
  styleUrls: ['./nodes-ranking.component.scss'],
 | 
			
		||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
			
		||||
})
 | 
			
		||||
export class NodesRanking implements OnInit {
 | 
			
		||||
  type: string;
 | 
			
		||||
 | 
			
		||||
  constructor(private route: ActivatedRoute) {}
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.route.data.subscribe(data => {
 | 
			
		||||
      this.type = data.type;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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,71 @@
 | 
			
		||||
<div [class]="!widget ? 'container-xl full-height' : ''">
 | 
			
		||||
  <h1 *ngIf="!widget" class="float-left" i18n="lightning.top-100-capacity">
 | 
			
		||||
    <span>Top 100 nodes by capacity</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="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-first text-left" i18n="lightning.first_seen">First seen</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="topNodesPerCapacity$ | 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="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-first text-left">
 | 
			
		||||
            <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.firstSeen"></app-timestamp>
 | 
			
		||||
          </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: 55%;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
  max-width: 350px;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    max-width: 175px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full .capacity {
 | 
			
		||||
  width: 10%;
 | 
			
		||||
}
 | 
			
		||||
.widget .capacity {
 | 
			
		||||
  width: 32%;
 | 
			
		||||
  @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: 15%;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full .timestamp-update {
 | 
			
		||||
  width: 15%;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full .location {
 | 
			
		||||
  width: 10%;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,37 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { map, Observable } from 'rxjs';
 | 
			
		||||
import { INodesRanking, ITopNodesPerCapacity } from 'src/app/interfaces/node-api.interface';
 | 
			
		||||
import { LightningApiService } from '../../lightning-api.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-top-nodes-per-capacity',
 | 
			
		||||
  templateUrl: './top-nodes-per-capacity.component.html',
 | 
			
		||||
  styleUrls: ['./top-nodes-per-capacity.component.scss'],
 | 
			
		||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
			
		||||
})
 | 
			
		||||
export class TopNodesPerCapacity implements OnInit {
 | 
			
		||||
  @Input() nodes$: Observable<INodesRanking>;
 | 
			
		||||
  @Input() widget: boolean = false;
 | 
			
		||||
  
 | 
			
		||||
  topNodesPerCapacity$: Observable<ITopNodesPerCapacity[]>;
 | 
			
		||||
  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.topNodesPerCapacity$ = this.apiService.getTopNodesByCapacity$();
 | 
			
		||||
    } else {
 | 
			
		||||
      this.topNodesPerCapacity$ = this.nodes$.pipe(
 | 
			
		||||
        map((ranking) => {
 | 
			
		||||
          return ranking.topByCapacity.slice(0, 10);
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,71 @@
 | 
			
		||||
<div [class]="!widget ? 'container-xl full-height' : ''">
 | 
			
		||||
  <h1 *ngIf="!widget" class="float-left" i18n="lightning.top-100-channel">
 | 
			
		||||
    <span>Top 100 nodes by channel count</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="channels text-right" i18n="node.channels">Channels</th>
 | 
			
		||||
        <th *ngIf="!widget" class="capacity text-right" i18n="lightning.capacity">Capacity</th>
 | 
			
		||||
        <th *ngIf="!widget" class="timestamp-first text-left" i18n="lightning.first_seen">First seen</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="topNodesPerChannels$ | 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="channels text-right">
 | 
			
		||||
            {{ node.channels | number }}
 | 
			
		||||
          </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="timestamp-first text-left">
 | 
			
		||||
            <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.firstSeen"></app-timestamp>
 | 
			
		||||
          </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="channels text-right">
 | 
			
		||||
              <span class="skeleton-loader"></span>
 | 
			
		||||
            </td>
 | 
			
		||||
            <td *ngIf="!widget" class="capacity 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: 55%;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
  max-width: 350px;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    max-width: 175px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full .channels {
 | 
			
		||||
  width: 10%;
 | 
			
		||||
}
 | 
			
		||||
.widget .channels {
 | 
			
		||||
  width: 32%;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    padding-left: 0px;
 | 
			
		||||
    padding-right: 0px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full .capacity {
 | 
			
		||||
  width: 15%;
 | 
			
		||||
  padding-right: 50px;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full .timestamp-first {
 | 
			
		||||
  width: 15%;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full .timestamp-update {
 | 
			
		||||
  width: 15%;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.full .location {
 | 
			
		||||
  width: 10%;
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,37 @@
 | 
			
		||||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { map, Observable } from 'rxjs';
 | 
			
		||||
import { INodesRanking, ITopNodesPerChannels } from 'src/app/interfaces/node-api.interface';
 | 
			
		||||
import { LightningApiService } from '../../lightning-api.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-top-nodes-per-channels',
 | 
			
		||||
  templateUrl: './top-nodes-per-channels.component.html',
 | 
			
		||||
  styleUrls: ['./top-nodes-per-channels.component.scss'],
 | 
			
		||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
			
		||||
})
 | 
			
		||||
export class TopNodesPerChannels implements OnInit {
 | 
			
		||||
  @Input() nodes$: Observable<INodesRanking>;
 | 
			
		||||
  @Input() widget: boolean = false;
 | 
			
		||||
  
 | 
			
		||||
  topNodesPerChannels$: Observable<ITopNodesPerChannels[]>;
 | 
			
		||||
  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.topNodesPerChannels$ = this.apiService.getTopNodesByChannels$();
 | 
			
		||||
    } else {
 | 
			
		||||
      this.topNodesPerChannels$ = this.nodes$.pipe(
 | 
			
		||||
        map((ranking) => {
 | 
			
		||||
          return ranking.topByChannels.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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user