Merge branch 'master' into nymkappa/feature/node-ranking-page

This commit is contained in:
wiz 2022-08-19 03:51:55 +09:00 committed by GitHub
commit daf3e269f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 223 additions and 275 deletions

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

@ -269,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 // List all channels and the two linked ISP
let query = `SELECT GROUP_CONCAT(DISTINCT(nodes.as_number)) as ispId, geo_names.names as names, query = `
COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) as capacity SELECT short_id, capacity,
FROM nodes channels.node1_public_key AS node1PublicKey, isp1.names AS isp1, isp1.id as isp1ID,
JOIN geo_names ON geo_names.id = nodes.as_number channels.node2_public_key AS node2PublicKey, isp2.names AS isp2, isp2.id as isp2ID
JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key FROM channels
GROUP BY geo_names.names JOIN nodes node1 ON node1.public_key = channels.node1_public_key
ORDER BY ${orderBy} DESC 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 [nodesCountPerAS]: any = await DB.query(query); const [channelsIsp]: any = await DB.query(query);
let total = 0; // Sum channels capacity and node count per ISP
const nodesPerAs: any[] = []; const ispList = {};
for (const channel of channelsIsp) {
const isp1 = JSON.parse(channel.isp1);
const isp2 = JSON.parse(channel.isp2);
for (const asGroup of nodesCountPerAS) { if (!ispList[isp1]) {
if (groupBy === 'capacity') { ispList[isp1] = {
total += asGroup.capacity; id: channel.isp1ID,
} else { capacity: 0,
total += asGroup.nodesCount; channels: 0,
nodes: {},
};
} }
if (!ispList[isp2]) {
ispList[isp2] = {
id: channel.isp2ID,
capacity: 0,
channels: 0,
nodes: {},
};
} }
// Tor ispList[isp1].capacity += channel.capacity;
if (showTor) { ispList[isp1].channels += 1;
query = `SELECT COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) as capacity ispList[isp1].nodes[channel.node1PublicKey] = true;
FROM nodes ispList[isp2].capacity += channel.capacity;
JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key ispList[isp2].channels += 1;
ORDER BY ${orderBy} DESC ispList[isp2].nodes[channel.node2PublicKey] = true;
}
const ispRanking: any[] = [];
for (const isp of Object.keys(ispList)) {
ispRanking.push([
ispList[isp].id,
isp,
ispList[isp].capacity,
ispList[isp].channels,
Object.keys(ispList[isp].nodes).length,
]);
}
// Total active channels capacity
query = `SELECT SUM(capacity) AS capacity FROM channels WHERE status = 1`;
const [totalCapacity]: any = await DB.query(query);
// 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 [nodesCountTor]: any = await DB.query(query); const [clearnetCapacity]: any = await DB.query(query);
total += groupBy === 'capacity' ? nodesCountTor[0].capacity : nodesCountTor[0].nodesCount; // Get the total capacity of all channels which have both nodes on Tor
nodesPerAs.push({ query = `
ispId: null, SELECT SUM(capacity) as capacity
name: 'Tor', FROM (
count: nodesCountTor[0].nodesCount, SELECT capacity, GROUP_CONCAT(socket1.type, socket2.type) as networks
share: Math.floor((groupBy === 'capacity' ? nodesCountTor[0].capacity : nodesCountTor[0].nodesCount) / total * 10000) / 100, FROM channels
capacity: nodesCountTor[0].capacity, 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);
for (const as of nodesCountPerAS) { const clearnetCapacityValue = parseInt(clearnetCapacity[0].capacity, 10);
nodesPerAs.push({ const torCapacityValue = parseInt(torCapacity[0].capacity, 10);
ispId: as.ispId, const unknownCapacityValue = parseInt(totalCapacity[0].capacity) - clearnetCapacityValue - torCapacityValue;
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; 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

@ -114,15 +114,7 @@ class NodesRoutes {
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

@ -1,6 +1,5 @@
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';
@ -35,11 +34,8 @@ interface Channel {
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));
@ -63,6 +59,9 @@ class LightningStatsImporter {
let isUnnanounced = true; let isUnnanounced = true;
for (const socket of (node.addresses ?? [])) { for (const socket of (node.addresses ?? [])) {
if (!socket.network?.length || !socket.addr?.length) {
continue;
}
hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1; hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1;
hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0])); hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0]));
} }
@ -263,8 +262,6 @@ 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;
const fileList = await fsPromises.readdir(this.topologiesFolder); const fileList = await fsPromises.readdir(this.topologiesFolder);
// Insert history from the most recent to the oldest // Insert history from the most recent to the oldest
// This also put the .json cached files first // This also put the .json cached files first
@ -282,55 +279,51 @@ class LightningStatsImporter {
// For logging purpose // For logging purpose
let processed = 10; let processed = 10;
let totalProcessed = -1; let totalProcessed = 0;
let logStarted = false;
for (const filename of fileList) { for (const filename of fileList) {
processed++; processed++;
totalProcessed++;
const timestamp = parseInt(filename.split('_')[1], 10); const timestamp = parseInt(filename.split('_')[1], 10);
// Stats exist already, don't calculate/insert them // Stats exist already, don't calculate/insert them
if (existingStatsTimestamps[timestamp] !== undefined) { if (existingStatsTimestamps[timestamp] !== undefined) {
latestNodeCount = existingStatsTimestamps[timestamp].node_count; continue;
}
if (filename.indexOf('.topology') === -1) {
continue; continue;
} }
logger.debug(`Reading ${this.topologiesFolder}/${filename}`); logger.debug(`Reading ${this.topologiesFolder}/${filename}`);
const fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8'); let fileContent = '';
try {
fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8');
} catch (e: any) {
if (e.errno == -1) { // EISDIR - Ignore directorie
continue;
}
}
let graph; let graph;
if (filename.indexOf('.json') !== -1) {
try { try {
graph = JSON.parse(fileContent); graph = JSON.parse(fileContent);
} 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 (!graph) {
logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`);
continue;
}
await fsPromises.writeFile(`${this.topologiesFolder}/${filename}.json`, JSON.stringify(graph));
}
if (timestamp > 1556316000) { if (!logStarted) {
// "No, the reason most likely is just that I started collection in 2019, logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`);
// so what I had before that is just the survivors from before, which weren't that many" logStarted = true;
const diffRatio = graph.nodes.length / latestNodeCount;
if (diffRatio < 0.9) {
// Ignore drop of more than 90% of the node count as it's probably a missing data point
logger.debug(`Nodes count diff ratio threshold reached, ignore the data for this day ${graph.nodes.length} nodes vs ${latestNodeCount}`);
continue;
} }
}
latestNodeCount = graph.nodes.length;
const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`; const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`); logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
totalProcessed++;
if (processed > 10) { if (processed > 10) {
logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`); logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
processed = 0; processed = 0;
@ -343,76 +336,9 @@ class LightningStatsImporter {
existingStatsTimestamps[timestamp] = stat; existingStatsTimestamps[timestamp] = stat;
} }
if (totalProcessed > 0) {
logger.info(`Lightning network stats historical import completed`); logger.info(`Lightning network stats historical import completed`);
} }
/**
* Parse the file content into XML, and return a list of nodes and channels
*/
private parseFile(fileContent): any {
const graph = this.parser.parse(fileContent);
if (Object.keys(graph).length === 0) {
return null;
}
const nodes: Node[] = [];
const channels: Channel[] = [];
// If there is only one entry, the parser does not return an array, so we override this
if (!Array.isArray(graph.graphml.graph.node)) {
graph.graphml.graph.node = [graph.graphml.graph.node];
}
if (!Array.isArray(graph.graphml.graph.edge)) {
graph.graphml.graph.edge = [graph.graphml.graph.edge];
}
for (const node of graph.graphml.graph.node) {
if (!node.data) {
continue;
}
const addresses: unknown[] = [];
const sockets = node.data[5].split(',');
for (const socket of sockets) {
const parts = socket.split('://');
addresses.push({
network: parts[0],
addr: parts[1],
});
}
nodes.push({
id: node.data[0],
timestamp: node.data[1],
features: node.data[2],
rgb_color: node.data[3],
alias: node.data[4],
addresses: addresses,
out_degree: node.data[6],
in_degree: node.data[7],
});
}
for (const channel of graph.graphml.graph.edge) {
if (!channel.data) {
continue;
}
channels.push({
channel_id: channel.data[0],
node1_pub: channel.data[1],
node2_pub: channel.data[2],
timestamp: channel.data[3],
features: channel.data[4],
fee_base_msat: channel.data[5],
fee_rate_milli_msat: channel.data[6],
htlc_minimim_msat: channel.data[7],
cltv_expiry_delta: channel.data[8],
htlc_maximum_msat: channel.data[9],
});
}
return {
nodes: nodes,
edges: channels,
};
} }
} }

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,12 +100,11 @@ 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;
@ -97,7 +114,8 @@ export class NodesPerISPChartComponent implements OnInit {
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

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