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", "bitcoinjs-lib": "6.0.2",
"crypto-js": "^4.0.0", "crypto-js": "^4.0.0",
"express": "^4.18.0", "express": "^4.18.0",
"fast-xml-parser": "^4.0.9",
"maxmind": "^4.3.6", "maxmind": "^4.3.6",
"mysql2": "2.3.3", "mysql2": "2.3.3",
"node-worker-threads-pool": "^1.5.1", "node-worker-threads-pool": "^1.5.1",
@ -3136,21 +3135,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true "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": { "node_modules/fastq": {
"version": "1.13.0", "version": "1.13.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
@ -5636,11 +5620,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -8556,14 +8535,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true "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": { "fastq": {
"version": "1.13.0", "version": "1.13.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
@ -10398,11 +10369,6 @@
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true "dev": true
}, },
"strnum": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
"integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="
},
"supports-color": { "supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@ -38,7 +38,6 @@
"bitcoinjs-lib": "6.0.2", "bitcoinjs-lib": "6.0.2",
"crypto-js": "^4.0.0", "crypto-js": "^4.0.0",
"express": "^4.18.0", "express": "^4.18.0",
"fast-xml-parser": "^4.0.9",
"maxmind": "^4.3.6", "maxmind": "^4.3.6",
"mysql2": "2.3.3", "mysql2": "2.3.3",
"node-worker-threads-pool": "^1.5.1", "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 */ /** Decodes a channel id returned by lnd as uint64 to a short channel id */
static channelIntegerIdToShortId(id: string): string { static channelIntegerIdToShortId(id: string): string {
if (id.indexOf('/') !== -1) {
id = id.slice(0, -2);
}
if (id.indexOf('x') !== -1) { // Already a short id if (id.indexOf('x') !== -1) { // Already a short id
return id; return id;
} }

View File

@ -4,7 +4,7 @@ import logger from '../logger';
import { Common } from './common'; import { Common } from './common';
class DatabaseMigration { class DatabaseMigration {
private static currentVersion = 37; private static currentVersion = 38;
private queryTimeout = 120000; private queryTimeout = 120000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
private uniqueLogs: string[] = []; private uniqueLogs: string[] = [];
@ -248,7 +248,6 @@ class DatabaseMigration {
} }
if (databaseSchemaVersion < 25 && isBitcoin === true) { 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.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats'));
await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes')); await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes'));
await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels')); await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels'));
@ -328,6 +327,16 @@ class DatabaseMigration {
if (databaseSchemaVersion < 37 && isBitcoin == true) { if (databaseSchemaVersion < 37 && isBitcoin == true) {
await this.$executeQuery(this.getCreateLNNodesSocketsTableQuery(), await this.$checkIfTableExists('nodes_sockets')); 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 { 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]); const [rows]: any = await DB.query(query, [status]);
return rows; return rows;
} catch (e) { } catch (e) {
@ -339,7 +344,7 @@ class ChannelsApi {
/** /**
* Save or update a channel present in the graph * 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 [ txid, vout ] = channel.chan_point.split(':');
const policy1: Partial<ILightningApi.RoutingPolicy> = channel.node1_policy || {}; const policy1: Partial<ILightningApi.RoutingPolicy> = channel.node1_policy || {};
@ -371,11 +376,11 @@ class ChannelsApi {
node2_min_htlc_mtokens, node2_min_htlc_mtokens,
node2_updated_at node2_updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ${status}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
capacity = ?, capacity = ?,
updated_at = ?, updated_at = ?,
status = 1, status = ${status},
node1_public_key = ?, node1_public_key = ?,
node1_base_fee_mtokens = ?, node1_base_fee_mtokens = ?,
node1_cltv_delta = ?, node1_cltv_delta = ?,

View File

@ -2,6 +2,7 @@ import logger from '../../logger';
import DB from '../../database'; import DB from '../../database';
import { ResultSetHeader } from 'mysql2'; import { ResultSetHeader } from 'mysql2';
import { ILightningApi } from '../lightning/lightning-api.interface'; import { ILightningApi } from '../lightning/lightning-api.interface';
import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces';
class NodesApi { class NodesApi {
public async $getNode(public_key: string): Promise<any> { public async $getNode(public_key: string): Promise<any> {
@ -9,10 +10,10 @@ class NodesApi {
// General info // General info
let query = ` let query = `
SELECT public_key, alias, UNIX_TIMESTAMP(first_seen) AS first_seen, SELECT public_key, alias, UNIX_TIMESTAMP(first_seen) AS first_seen,
UNIX_TIMESTAMP(updated_at) AS updated_at, color, sockets as sockets, UNIX_TIMESTAMP(updated_at) AS updated_at, color, sockets as sockets,
as_number, city_id, country_id, subdivision_id, longitude, latitude, 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_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 geo_names_country.names as country, geo_names_subdivision.names as subdivision
FROM nodes FROM nodes
LEFT JOIN geo_names geo_names_as on geo_names_as.id = as_number 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 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 { try {
let [rows]: any[] = await DB.query('SELECT UNIX_TIMESTAMP(MAX(added)) as maxAdded FROM node_stats'); let [rows]: any[] = await DB.query('SELECT UNIX_TIMESTAMP(MAX(added)) as maxAdded FROM node_stats');
const latestDate = rows[0].maxAdded; const latestDate = rows[0].maxAdded;
const query = ` let query: string;
SELECT nodes.public_key, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, node_stats.capacity, node_stats.channels if (full === false) {
FROM node_stats query = `
JOIN nodes ON nodes.public_key = node_stats.public_key SELECT nodes.public_key AS publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
WHERE added = FROM_UNIXTIME(${latestDate}) node_stats.capacity
ORDER BY capacity DESC FROM node_stats
LIMIT 10; JOIN nodes ON nodes.public_key = node_stats.public_key
`; WHERE added = FROM_UNIXTIME(${latestDate})
[rows] = await DB.query(query); 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; return rows;
} catch (e) { } catch (e) {
@ -134,20 +161,94 @@ class NodesApi {
} }
} }
public async $getTopChannelsNodes(): Promise<any> { public async $getTopChannelsNodes(full: boolean): Promise<ITopNodesPerChannels[]> {
try { try {
let [rows]: any[] = await DB.query('SELECT UNIX_TIMESTAMP(MAX(added)) as maxAdded FROM node_stats'); let [rows]: any[] = await DB.query('SELECT UNIX_TIMESTAMP(MAX(added)) as maxAdded FROM node_stats');
const latestDate = rows[0].maxAdded; const latestDate = rows[0].maxAdded;
const query = ` let query: string;
SELECT nodes.public_key, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, node_stats.capacity, node_stats.channels if (full === false) {
FROM node_stats query = `
JOIN nodes ON nodes.public_key = node_stats.public_key SELECT nodes.public_key as publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
WHERE added = FROM_UNIXTIME(${latestDate}) node_stats.channels
ORDER BY channels DESC FROM node_stats
LIMIT 10; JOIN nodes ON nodes.public_key = node_stats.public_key
`; WHERE added = FROM_UNIXTIME(${latestDate})
[rows] = await DB.query(query); 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; return rows;
} catch (e) { } catch (e) {
@ -168,64 +269,115 @@ class NodesApi {
} }
} }
public async $getNodesISPRanking(groupBy: string, showTor: boolean) { public async $getNodesISPRanking() {
try { try {
const orderBy = groupBy === 'capacity' ? `CAST(SUM(capacity) as INT)` : `COUNT(DISTINCT nodes.public_key)`; let query = '';
// 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 total = 0; // List all channels and the two linked ISP
const nodesPerAs: any[] = []; 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) { // Sum channels capacity and node count per ISP
if (groupBy === 'capacity') { const ispList = {};
total += asGroup.capacity; for (const channel of channelsIsp) {
} else { const isp1 = JSON.parse(channel.isp1);
total += asGroup.nodesCount; 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 const ispRanking: any[] = [];
if (showTor) { for (const isp of Object.keys(ispList)) {
query = `SELECT COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) as capacity ispRanking.push([
FROM nodes ispList[isp].id,
JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key isp,
ORDER BY ${orderBy} DESC ispList[isp].capacity,
`; ispList[isp].channels,
const [nodesCountTor]: any = await DB.query(query); Object.keys(ispList[isp].nodes).length,
]);
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,
});
} }
for (const as of nodesCountPerAS) { // Total active channels capacity
nodesPerAs.push({ query = `SELECT SUM(capacity) AS capacity FROM channels WHERE status = 1`;
ispId: as.ispId, const [totalCapacity]: any = await DB.query(query);
name: JSON.parse(as.names),
count: as.nodesCount,
share: Math.floor((groupBy === 'capacity' ? as.capacity : as.nodesCount) / total * 10000) / 100,
capacity: as.capacity,
});
}
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) { } 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; throw e;
} }
} }

View File

@ -2,6 +2,7 @@ import config from '../../config';
import { Application, Request, Response } from 'express'; import { Application, Request, Response } from 'express';
import nodesApi from './nodes.api'; import nodesApi from './nodes.api';
import DB from '../../database'; import DB from '../../database';
import { INodesRanking } from '../../mempool.interfaces';
class NodesRoutes { class NodesRoutes {
constructor() { } constructor() { }
@ -10,10 +11,13 @@ class NodesRoutes {
app app
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry) .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/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-ranking', this.$getISPRanking)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP) .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/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/statistics', this.$getHistoricalNodeStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode) .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 { try {
const topCapacityNodes = await nodesApi.$getTopCapacityNodes(); const topCapacityNodes = await nodesApi.$getTopCapacityNodes(false);
const topChannelsNodes = await nodesApi.$getTopChannelsNodes(); const topChannelsNodes = await nodesApi.$getTopChannelsNodes(false);
res.json({ res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(<INodesRanking>{
topByCapacity: topCapacityNodes, topByCapacity: topCapacityNodes,
topByChannels: topChannelsNodes, 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> { private async $getISPRanking(req: Request, res: Response): Promise<void> {
try { try {
const groupBy = req.query.groupBy as string; const nodesPerAs = await nodesApi.$getNodesISPRanking();
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);
res.header('Pragma', 'public'); res.header('Pragma', 'public');
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');

View File

@ -189,7 +189,7 @@ class Server {
await networkSyncService.$startService(); await networkSyncService.$startService();
await lightningStatsUpdater.$startService(); await lightningStatsUpdater.$startService();
} catch(e) { } 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); await Common.sleep$(1000 * 60);
this.$runLightningBackend(); this.$runLightningBackend();
}; };

View File

@ -251,3 +251,41 @@ export interface RewardStats {
totalFee: number; totalFee: number;
totalTx: 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; let progress = 0;
try { try {
logger.info(`Starting closed channels scan...`); logger.info(`Starting closed channels scan`);
const channels = await channelsApi.$getChannelsByStatus(0); const channels = await channelsApi.$getChannelsByStatus([0, 1]);
for (const channel of channels) { for (const channel of channels) {
const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout); const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout);
if (spendingTx.spent === true && spendingTx.status?.confirmed === true) { 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}> { 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]) { if (this.fundingTxCache[channelId]) {
return this.fundingTxCache[channelId]; return this.fundingTxCache[channelId];

View File

@ -1,45 +1,19 @@
import DB from '../../../database'; import DB from '../../../database';
import { promises } from 'fs'; import { promises } from 'fs';
import { XMLParser } from 'fast-xml-parser';
import logger from '../../../logger'; import logger from '../../../logger';
import fundingTxFetcher from './funding-tx-fetcher'; import fundingTxFetcher from './funding-tx-fetcher';
import config from '../../../config'; import config from '../../../config';
import { ILightningApi } from '../../../api/lightning/lightning-api.interface'; import { ILightningApi } from '../../../api/lightning/lightning-api.interface';
import { isIP } from 'net'; import { isIP } from 'net';
import { Common } from '../../../api/common';
import channelsApi from '../../../api/explorer/channels.api';
const fsPromises = promises; 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 { class LightningStatsImporter {
topologiesFolder = config.LIGHTNING.TOPOLOGY_FOLDER; topologiesFolder = config.LIGHTNING.TOPOLOGY_FOLDER;
parser = new XMLParser();
async $run(): Promise<void> { async $run(): Promise<void> {
logger.info(`Importing historical lightning stats`);
const [channels]: any[] = await DB.query('SELECT short_id from channels;'); const [channels]: any[] = await DB.query('SELECT short_id from channels;');
logger.info('Caching funding txs for currently existing channels'); logger.info('Caching funding txs for currently existing channels');
await fundingTxFetcher.$fetchChannelsFundingTxs(channels.map(channel => channel.short_id)); await fundingTxFetcher.$fetchChannelsFundingTxs(channels.map(channel => channel.short_id));
@ -50,7 +24,8 @@ class LightningStatsImporter {
/** /**
* Generate LN network stats for one day * 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 // Node counts and network shares
let clearnetNodes = 0; let clearnetNodes = 0;
let torNodes = 0; let torNodes = 0;
@ -63,8 +38,11 @@ class LightningStatsImporter {
let isUnnanounced = true; let isUnnanounced = true;
for (const socket of (node.addresses ?? [])) { for (const socket of (node.addresses ?? [])) {
hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1; if (!socket.network?.length && !socket.addr?.length) {
hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0])); 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) { if (hasOnion && hasClearnet) {
clearnetTorNodes++; clearnetTorNodes++;
@ -91,11 +69,14 @@ class LightningStatsImporter {
const baseFees: number[] = []; const baseFees: number[] = [];
const alreadyCountedChannels = {}; 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) { for (const channel of networkGraph.edges) {
let short_id = channel.channel_id; const short_id = Common.channelIntegerIdToShortId(channel.channel_id);
if (short_id.indexOf('/') !== -1) {
short_id = short_id.slice(0, -2);
}
const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id); const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id);
if (!tx) { if (!tx) {
@ -103,6 +84,31 @@ class LightningStatsImporter {
continue; 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]) { if (!nodeStats[channel.node1_pub]) {
nodeStats[channel.node1_pub] = { nodeStats[channel.node1_pub] = {
capacity: 0, capacity: 0,
@ -127,7 +133,7 @@ class LightningStatsImporter {
nodeStats[channel.node2_pub].channels++; 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]) { for (const policy of [channel.node1_policy, channel.node2_policy]) {
if (policy && parseInt(policy.fee_rate_milli_msat, 10) < 5000) { if (policy && parseInt(policy.fee_rate_milli_msat, 10) < 5000) {
avgFeeRate += parseInt(policy.fee_rate_milli_msat, 10); avgFeeRate += parseInt(policy.fee_rate_milli_msat, 10);
@ -138,30 +144,42 @@ class LightningStatsImporter {
baseFees.push(parseInt(policy.fee_base_msat, 10)); baseFees.push(parseInt(policy.fee_base_msat, 10));
} }
} }
} else { // Coming from the historical import } else {
// @ts-ignore // @ts-ignore
if (channel.fee_rate_milli_msat < 5000) { if (channel.node1_policy.fee_rate_milli_msat < 5000) {
// @ts-ignore // @ts-ignore
avgFeeRate += parseInt(channel.fee_rate_milli_msat, 10); avgFeeRate += parseInt(channel.node1_policy.fee_rate_milli_msat, 10);
// @ts-ignore // @ts-ignore
feeRates.push(parseInt(channel.fee_rate_milli_msat), 10); feeRates.push(parseInt(channel.node1_policy.fee_rate_milli_msat), 10);
} }
// @ts-ignore // @ts-ignore
if (channel.fee_base_msat < 5000) { if (channel.node1_policy.fee_base_msat < 5000) {
// @ts-ignore // @ts-ignore
avgBaseFee += parseInt(channel.fee_base_msat, 10); avgBaseFee += parseInt(channel.node1_policy.fee_base_msat, 10);
// @ts-ignore // @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); avgFeeRate /= Math.max(networkGraph.edges.length, 1);
avgBaseFee /= 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)]; if (capacities.length > 0) {
const medBaseFee = baseFees.sort((a, b) => b - a)[Math.round(baseFees.length / 2 - 1)]; medCapacity = capacities.sort((a, b) => b - a)[Math.round(capacities.length / 2 - 1)];
const avgCapacity = Math.round(capacity / Math.max(capacities.length, 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( let query = `INSERT INTO lightning_stats(
added, added,
@ -263,156 +281,154 @@ class LightningStatsImporter {
* Import topology files LN historical data into the database * Import topology files LN historical data into the database
*/ */
async $importHistoricalLightningStats(): Promise<void> { 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); const [rows]: any[] = await DB.query(`
// Insert history from the most recent to the oldest SELECT UNIX_TIMESTAMP(added) AS added, node_count
// This also put the .json cached files first FROM lightning_stats
fileList.sort().reverse(); ORDER BY added DESC
`);
const [rows]: any[] = await DB.query(` const existingStatsTimestamps = {};
SELECT UNIX_TIMESTAMP(added) AS added, node_count for (const row of rows) {
FROM lightning_stats existingStatsTimestamps[row.added] = row;
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;
} }
logger.debug(`Reading ${this.topologiesFolder}/${filename}`); // For logging purpose
const fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8'); let processed = 10;
let totalProcessed = 0;
let logStarted = false;
let graph; for (const filename of fileList) {
if (filename.indexOf('.json') !== -1) { 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 { try {
graph = JSON.parse(fileContent); graph = JSON.parse(fileContent);
graph = await this.cleanupTopology(graph);
} catch (e) { } catch (e) {
logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`); logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`);
continue; continue;
} }
} else {
graph = this.parseFile(fileContent); if (!logStarted) {
if (!graph) { logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`);
logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`); logStarted = true;
continue;
} }
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) { totalProcessed++;
// "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" if (processed > 10) {
const diffRatio = graph.nodes.length / latestNodeCount; logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
if (diffRatio < 0.9) { processed = 0;
// Ignore drop of more than 90% of the node count as it's probably a missing data point } else {
logger.debug(`Nodes count diff ratio threshold reached, ignore the data for this day ${graph.nodes.length} nodes vs ${latestNodeCount}`); logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
continue;
} }
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})`; if (totalProcessed > 0) {
logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`); logger.info(`Lightning network stats historical import completed`);
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))); } catch (e) {
const stat = await this.computeNetworkStats(timestamp, graph); logger.err(`Lightning network stats historical failed. Reason: ${e instanceof Error ? e.message : e}`);
existingStatsTimestamps[timestamp] = stat;
} }
logger.info(`Lightning network stats historical import completed`);
} }
/** async cleanupTopology(graph) {
* Parse the file content into XML, and return a list of nodes and channels const newGraph = {
*/ nodes: <ILightningApi.Node[]>[],
private parseFile(fileContent): any { edges: <ILightningApi.Channel[]>[],
const graph = this.parser.parse(fileContent); };
if (Object.keys(graph).length === 0) {
return null;
}
const nodes: Node[] = []; for (const node of graph.nodes) {
const channels: Channel[] = []; const addressesParts = (node.addresses ?? '').split(',');
const addresses: any[] = [];
// If there is only one entry, the parser does not return an array, so we override this for (const address of addressesParts) {
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('://');
addresses.push({ addresses.push({
network: parts[0], network: '',
addr: parts[1], addr: address
}); });
} }
nodes.push({
id: node.data[0], newGraph.nodes.push({
timestamp: node.data[1], last_update: node.timestamp ?? 0,
features: node.data[2], pub_key: node.id ?? null,
rgb_color: node.data[3], alias: node.alias ?? null,
alias: node.data[4],
addresses: addresses, addresses: addresses,
out_degree: node.data[6], color: node.rgb_color ?? null,
in_degree: node.data[7], features: {},
}); });
} }
for (const channel of graph.graphml.graph.edge) { for (const adjacency of graph.adjacency) {
if (!channel.data) { if (adjacency.length === 0) {
continue; 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 { return newGraph;
nodes: nodes,
edges: channels,
};
} }
} }

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 { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators';
import { Transaction, Vout } from '../../interfaces/electrs.interface'; 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 { StateService } from '../../services/state.service';
import { SeoService } from 'src/app/services/seo.service'; import { SeoService } from 'src/app/services/seo.service';
import { WebsocketService } from 'src/app/services/websocket.service'; import { WebsocketService } from 'src/app/services/websocket.service';
@ -142,8 +142,21 @@ export class BlockComponent implements OnInit, OnDestroy {
this.location.replaceState( this.location.replaceState(
this.router.createUrlTree([(this.network ? '/' + this.network : '') + '/block/', hash]).toString() 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 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) => { tap((block: BlockExtended) => {
@ -168,7 +188,6 @@ export class BlockComponent implements OnInit, OnDestroy {
this.block = block; this.block = block;
this.blockHeight = block.height; this.blockHeight = block.height;
const direction = (this.lastBlockHeight < this.blockHeight) ? 'right' : 'left';
this.lastBlockHeight = this.blockHeight; this.lastBlockHeight = this.blockHeight;
this.nextBlockHeight = block.height + 1; this.nextBlockHeight = block.height + 1;
this.setNextAndPreviousBlockLink(); 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> <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> <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> <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> <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> </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> <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> <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> <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> </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> <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 #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; totalFee: number;
totalTx: 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 { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { StateService } from '../services/state.service'; import { StateService } from '../services/state.service';
import { INodesRanking, IOldestNodes, ITopNodesPerCapacity, ITopNodesPerChannels } from '../interfaces/node-api.interface';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -48,8 +49,8 @@ export class LightningApiService {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/statistics'); return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/statistics');
} }
listTopNodes$(): Observable<any> { getNodesRanking$(): Observable<INodesRanking> {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/top'); return this.httpClient.get<INodesRanking>(this.apiBasePath + '/api/v1/lightning/nodes/rankings');
} }
listChannelStats$(publicKey: string): Observable<any> { listChannelStats$(publicKey: string): Observable<any> {
@ -62,4 +63,22 @@ export class LightningApiService {
(interval !== undefined ? `/${interval}` : ''), { observe: 'response' } (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>
</div> </div>
<!-- Network history -->
<div class="col"> <div class="col">
<div class="card graph-card"> <div class="card graph-card">
<div class="card-body pl-2 pr-2 pt-1"> <div class="card-body pl-2 pr-2 pt-1">
@ -53,22 +54,30 @@
</div> </div>
</div> </div>
<!-- Top nodes per capacity -->
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Top Capacity Nodes</h5> <a class="title-link" href="" [routerLink]="['/lightning/nodes/top-capacity' | relativeUrl]">
<app-nodes-list [nodes$]="nodesByCapacity$" [show]="'mobile-capacity'"></app-nodes-list> <h5 class="card-title d-inline" i18n="lightning.top-capacity-nodes">Top capacity nodes</h5>
<!-- <div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div> --> <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>
</div> </div>
<!-- Top nodes per channels -->
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Most Connected Nodes</h5> <a class="title-link" href="" [routerLink]="['/lightning/nodes/top-channels' | relativeUrl]">
<app-nodes-list [nodes$]="nodesByChannels$" [show]="'mobile-channels'"></app-nodes-list> <h5 class="card-title d-inline" i18n="lightning.top-channels-nodes">Most connected nodes</h5>
<!-- <div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div> --> <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>
</div> </div>

View File

@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; 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 { SeoService } from 'src/app/services/seo.service';
import { LightningApiService } from '../lightning-api.service'; import { LightningApiService } from '../lightning-api.service';
@ -11,9 +12,8 @@ import { LightningApiService } from '../lightning-api.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class LightningDashboardComponent implements OnInit { export class LightningDashboardComponent implements OnInit {
nodesByCapacity$: Observable<any>;
nodesByChannels$: Observable<any>;
statistics$: Observable<any>; statistics$: Observable<any>;
nodesRanking$: Observable<INodesRanking>;
constructor( constructor(
private lightningApiService: LightningApiService, private lightningApiService: LightningApiService,
@ -23,18 +23,7 @@ export class LightningDashboardComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.seoService.setTitle($localize`Lightning Dashboard`); this.seoService.setTitle($localize`Lightning Dashboard`);
const sharedObservable = this.lightningApiService.listTopNodes$().pipe(share()); this.nodesRanking$ = this.lightningApiService.getNodesRanking$().pipe(share());
this.nodesByCapacity$ = sharedObservable
.pipe(
map((object) => object.topByCapacity),
);
this.nodesByChannels$ = sharedObservable
.pipe(
map((object) => object.topByChannels),
);
this.statistics$ = this.lightningApiService.getLatestStatistics$().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 { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
import { NodesMap } from '../lightning/nodes-map/nodes-map.component'; import { NodesMap } from '../lightning/nodes-map/nodes-map.component';
import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels-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({ @NgModule({
declarations: [ declarations: [
LightningDashboardComponent, LightningDashboardComponent,
@ -45,6 +51,11 @@ import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels
NodesPerCountryChartComponent, NodesPerCountryChartComponent,
NodesMap, NodesMap,
NodesChannelsMap, NodesChannelsMap,
NodesRanking,
TopNodesPerChannels,
TopNodesPerCapacity,
OldestNodes,
NodesRankingsDashboard,
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -73,6 +84,11 @@ import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels
NodesPerCountryChartComponent, NodesPerCountryChartComponent,
NodesMap, NodesMap,
NodesChannelsMap, NodesChannelsMap,
NodesRanking,
TopNodesPerChannels,
TopNodesPerCapacity,
OldestNodes,
NodesRankingsDashboard,
], ],
providers: [ providers: [
LightningApiService, LightningApiService,

View File

@ -6,6 +6,8 @@ import { NodeComponent } from './node/node.component';
import { ChannelComponent } from './channel/channel.component'; import { ChannelComponent } from './channel/channel.component';
import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component'; import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.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 = [ const routes: Routes = [
{ {
@ -32,6 +34,31 @@ const routes: Routes = [
path: 'nodes/isp/:isp', path: 'nodes/isp/:isp',
component: NodesPerISP, 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: '**', path: '**',
redirectTo: '' redirectTo: ''

View File

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

View File

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

View File

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

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

View File

@ -1,7 +1,7 @@
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span style="margin-bottom: 0.5rem">{{ textLeft }}</span>&nbsp; <span style="margin-bottom: 0.5rem">{{ textLeft }}</span>&nbsp;
<label class="switch"> <label class="switch">
<input type="checkbox" (change)="onToggleStatusChanged($event)"> <input type="checkbox" [checked]="checked" (change)="onToggleStatusChanged($event)">
<span class="slider round"></span> <span class="slider round"></span>
</label> </label>
&nbsp;<span style="margin-bottom: 0.5rem">{{ textRight }}</span> &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>(); @Output() toggleStatusChanged = new EventEmitter<boolean>();
@Input() textLeft: string; @Input() textLeft: string;
@Input() textRight: string; @Input() textRight: string;
@Input() checked: boolean = false;
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.toggleStatusChanged.emit(false); this.toggleStatusChanged.emit(false);