Merge branch 'master' into nymkappa/bugfix/channel-list-pagination

This commit is contained in:
wiz 2022-08-20 20:19:55 +09:00 committed by GitHub
commit ea931da38b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1479 additions and 422 deletions

12
.github/FUNDING.yml vendored
View File

@ -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']

View File

@ -16,7 +16,6 @@
"bitcoinjs-lib": "6.0.2",
"crypto-js": "^4.0.0",
"express": "^4.18.0",
"fast-xml-parser": "^4.0.9",
"maxmind": "^4.3.6",
"mysql2": "2.3.3",
"node-worker-threads-pool": "^1.5.1",
@ -3136,21 +3135,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-xml-parser": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.0.9.tgz",
"integrity": "sha512-4G8EzDg2Nb1Qurs3f7BpFV4+jpMVsdgLVuG1Uv8O2OHJfVCg7gcA53obuKbmVqzd4Y7YXVBK05oJG7hzGIdyzg==",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
},
"funding": {
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
},
"node_modules/fastq": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
@ -5636,11 +5620,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strnum": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
"integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -8556,14 +8535,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"fast-xml-parser": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.0.9.tgz",
"integrity": "sha512-4G8EzDg2Nb1Qurs3f7BpFV4+jpMVsdgLVuG1Uv8O2OHJfVCg7gcA53obuKbmVqzd4Y7YXVBK05oJG7hzGIdyzg==",
"requires": {
"strnum": "^1.0.5"
}
},
"fastq": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
@ -10398,11 +10369,6 @@
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true
},
"strnum": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
"integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@ -38,7 +38,6 @@
"bitcoinjs-lib": "6.0.2",
"crypto-js": "^4.0.0",
"express": "^4.18.0",
"fast-xml-parser": "^4.0.9",
"maxmind": "^4.3.6",
"mysql2": "2.3.3",
"node-worker-threads-pool": "^1.5.1",

View File

@ -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;
}

View File

@ -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');
}
}
/**

View File

@ -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) {
@ -339,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 || {};
@ -371,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 = ?,

View File

@ -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) {
@ -168,64 +269,115 @@ class NodesApi {
}
}
public async $getNodesISPRanking(groupBy: string, showTor: boolean) {
public async $getNodesISPRanking() {
try {
const orderBy = groupBy === 'capacity' ? `CAST(SUM(capacity) as INT)` : `COUNT(DISTINCT nodes.public_key)`;
// Clearnet
let query = `SELECT GROUP_CONCAT(DISTINCT(nodes.as_number)) as ispId, geo_names.names as names,
COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) as capacity
FROM nodes
JOIN geo_names ON geo_names.id = nodes.as_number
JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
GROUP BY geo_names.names
ORDER BY ${orderBy} DESC
`;
const [nodesCountPerAS]: any = await DB.query(query);
let query = '';
let total = 0;
const nodesPerAs: any[] = [];
// List all channels and the two linked ISP
query = `
SELECT short_id, capacity,
channels.node1_public_key AS node1PublicKey, isp1.names AS isp1, isp1.id as isp1ID,
channels.node2_public_key AS node2PublicKey, isp2.names AS isp2, isp2.id as isp2ID
FROM channels
JOIN nodes node1 ON node1.public_key = channels.node1_public_key
JOIN nodes node2 ON node2.public_key = channels.node2_public_key
JOIN geo_names isp1 ON isp1.id = node1.as_number
JOIN geo_names isp2 ON isp2.id = node2.as_number
WHERE channels.status = 1
ORDER BY short_id DESC
`;
const [channelsIsp]: any = await DB.query(query);
for (const asGroup of nodesCountPerAS) {
if (groupBy === 'capacity') {
total += asGroup.capacity;
} else {
total += asGroup.nodesCount;
// Sum channels capacity and node count per ISP
const ispList = {};
for (const channel of channelsIsp) {
const isp1 = JSON.parse(channel.isp1);
const isp2 = JSON.parse(channel.isp2);
if (!ispList[isp1]) {
ispList[isp1] = {
id: channel.isp1ID,
capacity: 0,
channels: 0,
nodes: {},
};
}
if (!ispList[isp2]) {
ispList[isp2] = {
id: channel.isp2ID,
capacity: 0,
channels: 0,
nodes: {},
};
}
ispList[isp1].capacity += channel.capacity;
ispList[isp1].channels += 1;
ispList[isp1].nodes[channel.node1PublicKey] = true;
ispList[isp2].capacity += channel.capacity;
ispList[isp2].channels += 1;
ispList[isp2].nodes[channel.node2PublicKey] = true;
}
// Tor
if (showTor) {
query = `SELECT COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) as capacity
FROM nodes
JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
ORDER BY ${orderBy} DESC
`;
const [nodesCountTor]: any = await DB.query(query);
total += groupBy === 'capacity' ? nodesCountTor[0].capacity : nodesCountTor[0].nodesCount;
nodesPerAs.push({
ispId: null,
name: 'Tor',
count: nodesCountTor[0].nodesCount,
share: Math.floor((groupBy === 'capacity' ? nodesCountTor[0].capacity : nodesCountTor[0].nodesCount) / total * 10000) / 100,
capacity: nodesCountTor[0].capacity,
});
const ispRanking: any[] = [];
for (const isp of Object.keys(ispList)) {
ispRanking.push([
ispList[isp].id,
isp,
ispList[isp].capacity,
ispList[isp].channels,
Object.keys(ispList[isp].nodes).length,
]);
}
for (const as of nodesCountPerAS) {
nodesPerAs.push({
ispId: as.ispId,
name: JSON.parse(as.names),
count: as.nodesCount,
share: Math.floor((groupBy === 'capacity' ? as.capacity : as.nodesCount) / total * 10000) / 100,
capacity: as.capacity,
});
}
// Total active channels capacity
query = `SELECT SUM(capacity) AS capacity FROM channels WHERE status = 1`;
const [totalCapacity]: any = await DB.query(query);
return nodesPerAs;
// Get the total capacity of all channels which have at least one node on clearnet
query = `
SELECT SUM(capacity) as capacity
FROM (
SELECT capacity, GROUP_CONCAT(socket1.type, socket2.type) as networks
FROM channels
JOIN nodes_sockets socket1 ON node1_public_key = socket1.public_key
JOIN nodes_sockets socket2 ON node2_public_key = socket2.public_key
AND channels.status = 1
GROUP BY short_id
) channels_tmp
WHERE channels_tmp.networks LIKE '%ipv%'
`;
const [clearnetCapacity]: any = await DB.query(query);
// Get the total capacity of all channels which have both nodes on Tor
query = `
SELECT SUM(capacity) as capacity
FROM (
SELECT capacity, GROUP_CONCAT(socket1.type, socket2.type) as networks
FROM channels
JOIN nodes_sockets socket1 ON node1_public_key = socket1.public_key
JOIN nodes_sockets socket2 ON node2_public_key = socket2.public_key
AND channels.status = 1
GROUP BY short_id
) channels_tmp
WHERE channels_tmp.networks NOT LIKE '%ipv%' AND
channels_tmp.networks NOT LIKE '%dns%' AND
channels_tmp.networks NOT LIKE '%websocket%'
`;
const [torCapacity]: any = await DB.query(query);
const clearnetCapacityValue = parseInt(clearnetCapacity[0].capacity, 10);
const torCapacityValue = parseInt(torCapacity[0].capacity, 10);
const unknownCapacityValue = parseInt(totalCapacity[0].capacity) - clearnetCapacityValue - torCapacityValue;
return {
clearnetCapacity: clearnetCapacityValue,
torCapacity: torCapacityValue,
unknownCapacity: unknownCapacityValue,
ispRanking: ispRanking,
};
} catch (e) {
logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`);
logger.err(`Cannot get LN ISP ranking. Reason: ${e instanceof Error ? e.message : e}`);
throw e;
}
}

View File

@ -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,17 +76,45 @@ 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 groupBy = req.query.groupBy as string;
const showTor = req.query.showTor as string === 'true' ? true : false;
if (!['capacity', 'node-count'].includes(groupBy)) {
res.status(400).send(`groupBy must be one of 'capacity' or 'node-count'`);
return;
}
const nodesPerAs = await nodesApi.$getNodesISPRanking(groupBy, showTor);
const nodesPerAs = await nodesApi.$getNodesISPRanking();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');

View File

@ -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();
};

View File

@ -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,
}

View File

@ -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) {

View File

@ -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];

View File

@ -1,45 +1,19 @@
import DB from '../../../database';
import { promises } from 'fs';
import { XMLParser } from 'fast-xml-parser';
import logger from '../../../logger';
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;
parser = new XMLParser();
async $run(): Promise<void> {
logger.info(`Importing historical lightning stats`);
const [channels]: any[] = await DB.query('SELECT short_id from channels;');
logger.info('Caching funding txs for currently existing channels');
await fundingTxFetcher.$fetchChannelsFundingTxs(channels.map(channel => channel.short_id));
@ -50,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;
@ -63,8 +38,11 @@ class LightningStatsImporter {
let isUnnanounced = true;
for (const socket of (node.addresses ?? [])) {
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]));
if (!socket.network?.length && !socket.addr?.length) {
continue;
}
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++;
@ -91,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) {
@ -103,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,
@ -127,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);
@ -138,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,
@ -263,156 +281,154 @@ class LightningStatsImporter {
* Import topology files LN historical data into the database
*/
async $importHistoricalLightningStats(): Promise<void> {
let latestNodeCount = 1;
try {
let fileList: string[] = [];
try {
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 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 = -1;
for (const filename of fileList) {
processed++;
totalProcessed++;
const timestamp = parseInt(filename.split('_')[1], 10);
// Stats exist already, don't calculate/insert them
if (existingStatsTimestamps[timestamp] !== undefined) {
latestNodeCount = existingStatsTimestamps[timestamp].node_count;
continue;
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;
}
logger.debug(`Reading ${this.topologiesFolder}/${filename}`);
const fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8');
// For logging purpose
let processed = 10;
let totalProcessed = 0;
let logStarted = false;
let graph;
if (filename.indexOf('.json') !== -1) {
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;
}
} else {
graph = this.parseFile(fileContent);
if (!graph) {
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;
}
await fsPromises.writeFile(`${this.topologiesFolder}/${filename}.json`, JSON.stringify(graph));
}
const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
if (timestamp > 1556316000) {
// "No, the reason most likely is just that I started collection in 2019,
// so what I had before that is just the survivors from before, which weren't that many"
const diffRatio = graph.nodes.length / latestNodeCount;
if (diffRatio < 0.9) {
// Ignore drop of more than 90% of the node count as it's probably a missing data point
logger.debug(`Nodes count diff ratio threshold reached, ignore the data for this day ${graph.nodes.length} nodes vs ${latestNodeCount}`);
continue;
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;
}
latestNodeCount = graph.nodes.length;
const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
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`);
if (totalProcessed > 0) {
logger.info(`Lightning network stats historical import completed`);
}
await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
const stat = await this.computeNetworkStats(timestamp, graph);
existingStatsTimestamps[timestamp] = stat;
} catch (e) {
logger.err(`Lightning network stats historical failed. Reason: ${e instanceof Error ? e.message : e}`);
}
logger.info(`Lightning network stats historical import completed`);
}
/**
* Parse the file content into XML, and return a list of nodes and channels
*/
private parseFile(fileContent): any {
const graph = this.parser.parse(fileContent);
if (Object.keys(graph).length === 0) {
return null;
}
async cleanupTopology(graph) {
const newGraph = {
nodes: <ILightningApi.Node[]>[],
edges: <ILightningApi.Channel[]>[],
};
const nodes: Node[] = [];
const channels: Channel[] = [];
// If there is only one entry, the parser does not return an array, so we override this
if (!Array.isArray(graph.graphml.graph.node)) {
graph.graphml.graph.node = [graph.graphml.graph.node];
}
if (!Array.isArray(graph.graphml.graph.edge)) {
graph.graphml.graph.edge = [graph.graphml.graph.edge];
}
for (const node of graph.graphml.graph.node) {
if (!node.data) {
continue;
}
const addresses: unknown[] = [];
const sockets = node.data[5].split(',');
for (const socket of sockets) {
const parts = socket.split('://');
for (const node of graph.nodes) {
const addressesParts = (node.addresses ?? '').split(',');
const addresses: any[] = [];
for (const address of addressesParts) {
addresses.push({
network: parts[0],
addr: parts[1],
network: '',
addr: address
});
}
nodes.push({
id: node.data[0],
timestamp: node.data[1],
features: node.data[2],
rgb_color: node.data[3],
alias: node.data[4],
newGraph.nodes.push({
last_update: node.timestamp ?? 0,
pub_key: node.id ?? null,
alias: node.alias ?? null,
addresses: addresses,
out_degree: node.data[6],
in_degree: node.data[7],
color: node.rgb_color ?? null,
features: {},
});
}
for (const channel of graph.graphml.graph.edge) {
if (!channel.data) {
for (const adjacency of graph.adjacency) {
if (adjacency.length === 0) {
continue;
} else {
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,
});
}
}
channels.push({
channel_id: channel.data[0],
node1_pub: channel.data[1],
node2_pub: channel.data[2],
timestamp: channel.data[3],
features: channel.data[4],
fee_base_msat: channel.data[5],
fee_rate_milli_msat: channel.data[6],
htlc_minimim_msat: channel.data[7],
cltv_expiry_delta: channel.data[8],
htlc_maximum_msat: channel.data[9],
});
}
return {
nodes: nodes,
edges: channels,
};
return newGraph;
}
}

3
contributors/junderw.txt Normal file
View 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

View File

@ -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();

View File

@ -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>

View File

@ -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,
}

View File

@ -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'
);
}
}

View File

@ -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 &raquo;</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>&nbsp;</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 &raquo;</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>&nbsp;</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>

View File

@ -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());
}

View File

@ -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,

View File

@ -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: ''

View File

@ -3,21 +3,24 @@
<div *ngIf="widget">
<div class="pool-distribution" *ngIf="(nodesPerAsObservable$ | async) as stats; else loadingReward">
<div class="item">
<h5 class="card-title d-inline-block" i18n="lightning.tagged-isp">Tagged ISPs</h5>
<p class="card-text">
{{ stats.taggedISP }}
<h5 class="card-title d-inline-block" i18n="lightning.clearnet-capacity">Clearnet capacity</h5>
<p class="card-text" i18n-ngbTooltip="lightning.clearnet-capacity-desc"
ngbTooltip="How much liquidity is running on nodes advertising at least one clearnet IP address" placement="bottom">
<app-amount [satoshis]="stats.clearnetCapacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
</p>
</div>
<div class="item">
<h5 class="card-title d-inline-block" i18n="lightning.tagged-nodes">Tagged nodes</h5>
<p class="card-text" i18n-ngbTooltip="mining.pools-count-desc">
{{ stats.taggedNodeCount }}
<h5 class="card-title d-inline-block">Unknown capacity</h5>
<p class="card-text" i18n-ngbTooltip="lightning.unknown-capacity-desc"
ngbTooltip="How much liquidity is running on nodes which ISP was not identifiable" placement="bottom">
<app-amount [satoshis]="stats.unknownCapacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
</p>
</div>
<div class="item">
<h5 class="card-title d-inline-block" i18n="lightning.tagged-capacity">Tagged capacity</h5>
<p class="card-text" i18n-ngbTooltip="mining.blocks-count-desc">
<app-amount [satoshis]="stats.taggedCapacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
<h5 class="card-title d-inline-block">Tor capacity</h5>
<p class="card-text" i18n-ngbTooltip="lightning.tor-capacity-desc"
ngbTooltip="How much liquidity is running on nodes advertising only Tor addresses" placement="bottom">
<app-amount [satoshis]="stats.torCapacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
</p>
</div>
</div>
@ -25,13 +28,13 @@
<div class="card-header" *ngIf="!widget">
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
<span i18n="lightning.nodes-per-isp">Lightning nodes per ISP</span>
<span i18n="lightning.top-100-isp-ln">Top 100 ISP hosting LN nodes</span>
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
</button>
</div>
<small class="d-block" style="color: #ffffff66; min-height: 25px" i18n="lightning.tor-nodes-excluded">
<span *ngIf="!(showTorObservable$ | async)">(Tor nodes excluded)</span>
<span>(Tor nodes excluded)</span>
</small>
</div>
@ -44,9 +47,8 @@
<div class="spinner-border text-light"></div>
</div>
<div class="d-flex toggle" *ngIf="!widget">
<app-toggle [textLeft]="'Show Tor'" [textRight]="" (toggleStatusChanged)="onTorToggleStatusChanged($event)"></app-toggle>
<app-toggle [textLeft]="'Nodes'" [textRight]="'Capacity'" (toggleStatusChanged)="onGroupToggleStatusChanged($event)"></app-toggle>
<div class="d-flex justify-content-md-end toggle" *ngIf="!widget">
<app-toggle [textLeft]="'Sort by nodes'" [textRight]="'capacity'" [checked]="true" (toggleStatusChanged)="onGroupToggleStatusChanged($event)"></app-toggle>
</div>
<table class="table table-borderless text-center m-auto" style="max-width: 900px" *ngIf="!widget">
@ -59,16 +61,15 @@
<th class="capacity text-right pr-0" i18n="lightning.capacity">Capacity</th>
</tr>
</thead>
<tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as asList">
<tr *ngFor="let asEntry of asList.data">
<td class="rank text-left pl-0">{{ asEntry.rank }}</td>
<tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as result">
<tr *ngFor="let isp of result.ispRanking">
<td class="rank text-left pl-0">{{ isp[5] }}</td>
<td class="name text-left text-truncate">
<a *ngIf="asEntry.ispId" [routerLink]="[('/lightning/nodes/isp/' + asEntry.ispId) | relativeUrl]">{{ asEntry.name }}</a>
<span *ngIf="!asEntry.ispId">{{ asEntry.name }}</span>
<a [routerLink]="[('/lightning/nodes/isp/' + isp[0]) | relativeUrl]">{{ isp[1] }}</a>
</td>
<td class="share text-right">{{ asEntry.share }}%</td>
<td class="nodes text-right">{{ asEntry.count }}</td>
<td class="capacity text-right pr-0"><app-amount [satoshis]="asEntry.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td>
<td class="share text-right">{{ sortBy === 'capacity' ? isp[7] : isp[6] }}%</td>
<td class="nodes text-right">{{ isp[4] }}</td>
<td class="capacity text-right pr-0"><app-amount [satoshis]="isp[2]" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td>
</tr>
</tbody>
</table>

View File

@ -149,7 +149,8 @@
}
.name {
width: 25%;
width: 35%;
max-width: 300px;
@media (max-width: 576px) {
width: 70%;
max-width: 150px;
@ -159,14 +160,14 @@
}
.share {
width: 20%;
width: 15%;
@media (max-width: 576px) {
display: none
}
}
.nodes {
width: 20%;
width: 15%;
@media (max-width: 576px) {
width: 10%;
}

View File

@ -26,14 +26,15 @@ export class NodesPerISPChartComponent implements OnInit {
renderer: 'svg',
};
timespan = '';
sortBy = 'capacity';
showUnknown = false;
chartInstance = undefined;
@HostBinding('attr.dir') dir = 'ltr';
nodesPerAsObservable$: Observable<any>;
showTorObservable$: Observable<boolean>;
groupBySubject = new Subject<boolean>();
showTorSubject = new Subject<boolean>();
sortBySubject = new Subject<boolean>();
showUnknownSubject = new Subject<boolean>();
constructor(
private apiService: ApiService,
@ -48,32 +49,49 @@ export class NodesPerISPChartComponent implements OnInit {
ngOnInit(): void {
this.seoService.setTitle($localize`Lightning nodes per ISP`);
this.showTorObservable$ = this.showTorSubject.asObservable();
this.nodesPerAsObservable$ = combineLatest([
this.groupBySubject.pipe(startWith(false)),
this.showTorSubject.pipe(startWith(false)),
this.sortBySubject.pipe(startWith(true)),
])
.pipe(
switchMap((selectedFilters) => {
return this.apiService.getNodesPerAs(
selectedFilters[0] ? 'capacity' : 'node-count',
selectedFilters[1] // Show Tor nodes
)
this.sortBy = selectedFilters[0] ? 'capacity' : 'node-count';
return this.apiService.getNodesPerIsp()
.pipe(
tap(data => {
tap(() => {
this.isLoading = false;
this.prepareChartOptions(data);
}),
map(data => {
for (let i = 0; i < data.length; ++i) {
data[i].rank = i + 1;
let nodeCount = 0;
let totalCapacity = 0;
for (let i = 0; i < data.ispRanking.length; ++i) {
nodeCount += data.ispRanking[i][4];
totalCapacity += data.ispRanking[i][2];
data.ispRanking[i][5] = i;
}
for (let i = 0; i < data.ispRanking.length; ++i) {
data.ispRanking[i][6] = Math.round(data.ispRanking[i][4] / nodeCount * 10000) / 100;
data.ispRanking[i][7] = Math.round(data.ispRanking[i][2] / totalCapacity * 10000) / 100;
}
if (selectedFilters[0] === true) {
data.ispRanking.sort((a, b) => b[7] - a[7]);
} else {
data.ispRanking.sort((a, b) => b[6] - a[6]);
}
for (let i = 0; i < data.ispRanking.length; ++i) {
data.ispRanking[i][5] = i + 1;
}
this.prepareChartOptions(data.ispRanking);
return {
taggedISP: data.length,
taggedCapacity: data.reduce((partialSum, isp) => partialSum + isp.capacity, 0),
taggedNodeCount: data.reduce((partialSum, isp) => partialSum + isp.count, 0),
data: data.slice(0, 100),
taggedISP: data.ispRanking.length,
clearnetCapacity: data.clearnetCapacity,
unknownCapacity: data.unknownCapacity,
torCapacity: data.torCapacity,
ispRanking: data.ispRanking.slice(0, 100),
};
})
);
@ -82,22 +100,22 @@ export class NodesPerISPChartComponent implements OnInit {
);
if (this.widget) {
this.showTorSubject.next(false);
this.groupBySubject.next(false);
this.sortBySubject.next(false);
}
}
generateChartSerieData(as): PieSeriesOption[] {
generateChartSerieData(ispRanking): PieSeriesOption[] {
let shareThreshold = 0.5;
if (this.widget && isMobile() || isMobile()) {
shareThreshold = 1;
} else if (this.widget) {
shareThreshold = 0.75;
}
const data: object[] = [];
let totalShareOther = 0;
let totalNodeOther = 0;
let nodeCountOther = 0;
let capacityOther = 0;
let edgeDistance: string | number = '10%';
if (isMobile() && this.widget) {
@ -106,18 +124,19 @@ export class NodesPerISPChartComponent implements OnInit {
edgeDistance = 10;
}
as.forEach((as) => {
if (as.share < shareThreshold) {
totalShareOther += as.share;
totalNodeOther += as.count;
ispRanking.forEach((isp) => {
if ((this.sortBy === 'capacity' ? isp[7] : isp[6]) < shareThreshold) {
totalShareOther += this.sortBy === 'capacity' ? isp[7] : isp[6];
nodeCountOther += isp[4];
capacityOther += isp[2];
return;
}
data.push({
itemStyle: {
color: as.ispId === null ? '#7D4698' : undefined,
color: isp[0] === null ? '#7D4698' : undefined,
},
value: as.share,
name: as.name + (isMobile() || this.widget ? `` : ` (${as.share}%)`),
value: this.sortBy === 'capacity' ? isp[7] : isp[6],
name: isp[1].replace('&', '') + (isMobile() || this.widget ? `` : ` (${this.sortBy === 'capacity' ? isp[7] : isp[6]}%)`),
label: {
overflow: 'truncate',
width: isMobile() ? 75 : this.widget ? 125 : 250,
@ -135,13 +154,13 @@ export class NodesPerISPChartComponent implements OnInit {
},
borderColor: '#000',
formatter: () => {
return `<b style="color: white">${as.name} (${as.share}%)</b><br>` +
$localize`${as.count.toString()} nodes<br>` +
$localize`${this.amountShortenerPipe.transform(as.capacity / 100000000, 2)} BTC capacity`
return `<b style="color: white">${isp[1]} (${isp[6]}%)</b><br>` +
$localize`${isp[4].toString()} nodes<br>` +
$localize`${this.amountShortenerPipe.transform(isp[2] / 100000000, 2)} BTC`
;
}
},
data: as.ispId,
data: isp[0],
} as PieSeriesOption);
});
@ -167,8 +186,9 @@ export class NodesPerISPChartComponent implements OnInit {
},
borderColor: '#000',
formatter: () => {
return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
totalNodeOther.toString() + ` nodes`;
return `<b style="color: white">Other (${totalShareOther.toFixed(2)}%)</b><br>` +
$localize`${nodeCountOther.toString()} nodes<br>` +
$localize`${this.amountShortenerPipe.transform(capacityOther / 100000000, 2)} BTC`;
}
},
data: 9999 as any,
@ -177,7 +197,7 @@ export class NodesPerISPChartComponent implements OnInit {
return data;
}
prepareChartOptions(as): void {
prepareChartOptions(ispRanking): void {
let pieSize = ['20%', '80%']; // Desktop
if (isMobile() && !this.widget) {
pieSize = ['15%', '60%'];
@ -194,11 +214,11 @@ export class NodesPerISPChartComponent implements OnInit {
series: [
{
zlevel: 0,
minShowLabelAngle: 1.8,
minShowLabelAngle: 0.9,
name: 'Lightning nodes',
type: 'pie',
radius: pieSize,
data: this.generateChartSerieData(as),
data: this.generateChartSerieData(ispRanking),
labelLine: {
lineStyle: {
width: 2,
@ -259,16 +279,8 @@ export class NodesPerISPChartComponent implements OnInit {
this.chartInstance.setOption(this.chartOptions);
}
onTorToggleStatusChanged(e): void {
this.showTorSubject.next(e);
}
onGroupToggleStatusChanged(e): void {
this.groupBySubject.next(e);
}
isEllipsisActive(e) {
return (e.offsetWidth < e.scrollWidth);
this.sortBySubject.next(e);
}
}

View File

@ -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>

View File

@ -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;
});
}
}

View File

@ -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">
&lrm;{{ 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>

View File

@ -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;
}
}

View File

@ -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);
})
);
}
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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);
})
);
}
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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);
})
);
}
}
}

View File

@ -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>&nbsp;</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>&nbsp;</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>&nbsp;</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>

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -255,9 +255,8 @@ export class ApiService {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
}
getNodesPerAs(groupBy: 'capacity' | 'node-count', showTorNodes: boolean): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp-ranking'
+ `?groupBy=${groupBy}&showTor=${showTorNodes}`);
getNodesPerIsp(): Observable<any> {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp-ranking');
}
getNodeForCountry$(country: string): Observable<any> {

View File

@ -1,7 +1,7 @@
<div class="d-flex align-items-center">
<span style="margin-bottom: 0.5rem">{{ textLeft }}</span>&nbsp;
<label class="switch">
<input type="checkbox" (change)="onToggleStatusChanged($event)">
<input type="checkbox" [checked]="checked" (change)="onToggleStatusChanged($event)">
<span class="slider round"></span>
</label>
&nbsp;<span style="margin-bottom: 0.5rem">{{ textRight }}</span>

View File

@ -10,6 +10,7 @@ export class ToggleComponent implements AfterViewInit {
@Output() toggleStatusChanged = new EventEmitter<boolean>();
@Input() textLeft: string;
@Input() textRight: string;
@Input() checked: boolean = false;
ngAfterViewInit(): void {
this.toggleStatusChanged.emit(false);