Merge branch 'master' into nymkappa/feature/improve-location

This commit is contained in:
wiz 2022-08-21 18:51:06 +09:00 committed by GitHub
commit 8c9ac01123
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1457 additions and 297 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

@ -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) {
@ -218,23 +223,25 @@ class ChannelsApi {
// Channels originating from node // Channels originating from node
let query = ` let query = `
SELECT node2.alias, node2.public_key, channels.status, channels.node1_fee_rate, SELECT COALESCE(node2.alias, SUBSTRING(node2_public_key, 0, 20)) AS alias, COALESCE(node2.public_key, node2_public_key) AS public_key,
channels.status, channels.node1_fee_rate,
channels.capacity, channels.short_id, channels.id channels.capacity, channels.short_id, channels.id
FROM channels FROM channels
JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key LEFT JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key
WHERE node1_public_key = ? AND channels.status ${channelStatusFilter} WHERE node1_public_key = ? AND channels.status ${channelStatusFilter}
`; `;
const [channelsFromNode]: any = await DB.query(query, [public_key, index, length]); const [channelsFromNode]: any = await DB.query(query, [public_key]);
// Channels incoming to node // Channels incoming to node
query = ` query = `
SELECT node1.alias, node1.public_key, channels.status, channels.node2_fee_rate, SELECT COALESCE(node1.alias, SUBSTRING(node1_public_key, 0, 20)) AS alias, COALESCE(node1.public_key, node1_public_key) AS public_key,
channels.status, channels.node2_fee_rate,
channels.capacity, channels.short_id, channels.id channels.capacity, channels.short_id, channels.id
FROM channels FROM channels
JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key LEFT JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key
WHERE node2_public_key = ? AND channels.status ${channelStatusFilter} WHERE node2_public_key = ? AND channels.status ${channelStatusFilter}
`; `;
const [channelsToNode]: any = await DB.query(query, [public_key, index, length]); const [channelsToNode]: any = await DB.query(query, [public_key]);
let allChannels = channelsFromNode.concat(channelsToNode); let allChannels = channelsFromNode.concat(channelsToNode);
allChannels.sort((a, b) => { allChannels.sort((a, b) => {
@ -337,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 || {};
@ -369,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) {

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,6 +76,42 @@ class NodesRoutes {
} }
} }
private async $getTopNodesByCapacity(req: Request, res: Response): Promise<void> {
try {
const topCapacityNodes = await nodesApi.$getTopCapacityNodes(true);
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getTopNodesByChannels(req: Request, res: Response): Promise<void> {
try {
const topCapacityNodes = await nodesApi.$getTopChannelsNodes(true);
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getOldestNodes(req: Request, res: Response): Promise<void> {
try {
const topCapacityNodes = await nodesApi.$getOldestNodes(true);
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getISPRanking(req: Request, res: Response): Promise<void> { private async $getISPRanking(req: Request, res: Response): Promise<void> {
try { try {
const nodesPerAs = await nodesApi.$getNodesISPRanking(); const nodesPerAs = await nodesApi.$getNodesISPRanking();

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

@ -5,33 +5,11 @@ 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;
@ -46,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;
@ -59,11 +38,11 @@ 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) { if (!socket.network?.length && !socket.addr?.length) {
continue; 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 || 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])); 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++;
@ -90,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) {
@ -102,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,
@ -126,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);
@ -137,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,
@ -262,83 +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> {
const fileList = await fsPromises.readdir(this.topologiesFolder); try {
// Insert history from the most recent to the oldest let fileList: string[] = [];
// This also put the .json cached files first
fileList.sort().reverse();
const [rows]: any[] = await DB.query(`
SELECT UNIX_TIMESTAMP(added) AS added, node_count
FROM lightning_stats
ORDER BY added DESC
`);
const existingStatsTimestamps = {};
for (const row of rows) {
existingStatsTimestamps[row.added] = row;
}
// For logging purpose
let processed = 10;
let totalProcessed = 0;
let logStarted = false;
for (const filename of fileList) {
processed++;
const timestamp = parseInt(filename.split('_')[1], 10);
// Stats exist already, don't calculate/insert them
if (existingStatsTimestamps[timestamp] !== undefined) {
continue;
}
if (filename.indexOf('.topology') === -1) {
continue;
}
logger.debug(`Reading ${this.topologiesFolder}/${filename}`);
let fileContent = '';
try { try {
fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8'); fileList = await fsPromises.readdir(this.topologiesFolder);
} catch (e: any) { } catch (e) {
if (e.errno == -1) { // EISDIR - Ignore directorie logger.err(`Unable to open topology folder at ${this.topologiesFolder}`);
throw e;
}
// Insert history from the most recent to the oldest
// This also put the .json cached files first
fileList.sort().reverse();
const [rows]: any[] = await DB.query(`
SELECT UNIX_TIMESTAMP(added) AS added, node_count
FROM lightning_stats
ORDER BY added DESC
`);
const existingStatsTimestamps = {};
for (const row of rows) {
existingStatsTimestamps[row.added] = row;
}
// For logging purpose
let processed = 10;
let totalProcessed = 0;
let logStarted = false;
for (const filename of fileList) {
processed++;
const timestamp = parseInt(filename.split('_')[1], 10);
// Stats exist already, don't calculate/insert them
if (existingStatsTimestamps[timestamp] !== undefined) {
totalProcessed++;
continue; continue;
} }
if (filename.indexOf('topology_') === -1) {
totalProcessed++;
continue;
}
logger.debug(`Reading ${this.topologiesFolder}/${filename}`);
let fileContent = '';
try {
fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8');
} catch (e: any) {
if (e.errno == -1) { // EISDIR - Ignore directorie
continue;
}
logger.err(`Unable to open ${this.topologiesFolder}/${filename}`);
continue;
}
let graph;
try {
graph = JSON.parse(fileContent);
graph = await this.cleanupTopology(graph);
} catch (e) {
logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`);
continue;
}
if (!logStarted) {
logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`);
logStarted = true;
}
const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
totalProcessed++;
if (processed > 10) {
logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
processed = 0;
} else {
logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
}
await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
const stat = await this.computeNetworkStats(timestamp, graph, true);
existingStatsTimestamps[timestamp] = stat;
} }
let graph; if (totalProcessed > 0) {
try { logger.info(`Lightning network stats historical import completed`);
graph = JSON.parse(fileContent); }
} catch (e) { } catch (e) {
logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`); logger.err(`Lightning network stats historical failed. Reason: ${e instanceof Error ? e.message : e}`);
}
}
async cleanupTopology(graph) {
const newGraph = {
nodes: <ILightningApi.Node[]>[],
edges: <ILightningApi.Channel[]>[],
};
for (const node of graph.nodes) {
const addressesParts = (node.addresses ?? '').split(',');
const addresses: any[] = [];
for (const address of addressesParts) {
addresses.push({
network: '',
addr: address
});
}
newGraph.nodes.push({
last_update: node.timestamp ?? 0,
pub_key: node.id ?? null,
alias: node.alias ?? null,
addresses: addresses,
color: node.rgb_color ?? null,
features: {},
});
}
for (const adjacency of graph.adjacency) {
if (adjacency.length === 0) {
continue; continue;
}
if (!logStarted) {
logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`);
logStarted = true;
}
const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`);
totalProcessed++;
if (processed > 10) {
logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`);
processed = 0;
} else { } else {
logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`); for (const edge of adjacency) {
newGraph.edges.push({
channel_id: edge.scid,
chan_point: '',
last_update: edge.timestamp,
node1_pub: edge.source ?? null,
node2_pub: edge.destination ?? null,
capacity: '0', // Will be fetch later
node1_policy: {
time_lock_delta: edge.cltv_expiry_delta,
min_htlc: edge.htlc_minimim_msat,
fee_base_msat: edge.fee_base_msat,
fee_rate_milli_msat: edge.fee_proportional_millionths,
max_htlc_msat: edge.htlc_maximum_msat,
last_update: edge.timestamp,
disabled: false,
},
node2_policy: null,
});
}
} }
await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
const stat = await this.computeNetworkStats(timestamp, graph);
existingStatsTimestamps[timestamp] = stat;
} }
if (totalProcessed > 0) { return newGraph;
logger.info(`Lightning network stats historical import completed`);
}
} }
} }

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

@ -20,7 +20,7 @@
<div class="col"> <div class="col">
<table class="table table-borderless smaller-text table-sm table-tx-vin"> <table class="table table-borderless smaller-text table-sm table-tx-vin">
<tbody> <tbody>
<ng-template ngFor let-vin [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length>12)?tx.vin.slice(0, 10): tx.vin.slice(0, 12)) : tx.vin" [ngForTrackBy]="trackByIndexFn"> <ng-template ngFor let-vin [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length > rowLimit) ? tx.vin.slice(0, rowLimit - 2) : tx.vin.slice(0, rowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{ <tr [ngClass]="{
'assetBox': assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded, 'assetBox': assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded,
'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== '' 'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== ''
@ -146,9 +146,9 @@
</td> </td>
</tr> </tr>
</ng-template> </ng-template>
<tr *ngIf="tx.vin.length > 12 && tx['@vinLimit']"> <tr *ngIf="tx.vin.length > rowLimit && tx['@vinLimit']">
<td colspan="3" class="text-center"> <td colspan="3" class="text-center">
<button class="btn btn-sm btn-primary mt-2" (click)="loadMoreInputs(tx);"><span i18n="show-all">Show all</span> ({{ tx.vin.length - 10 }})</button> <button class="btn btn-sm btn-primary mt-2" (click)="loadMoreInputs(tx);"><span i18n="show-all">Show all</span> ({{ tx.vin.length }})</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -158,7 +158,7 @@
<div class="col mobile-bottomcol"> <div class="col mobile-bottomcol">
<table class="table table-borderless smaller-text table-sm table-tx-vout"> <table class="table table-borderless smaller-text table-sm table-tx-vout">
<tbody> <tbody>
<ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] && !outputIndex ? ((tx.vout.length > 12) ? tx.vout.slice(0, 10) : tx.vout.slice(0, 12)) : tx.vout" [ngForTrackBy]="trackByIndexFn"> <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] && !outputIndex ? ((tx.vout.length > rowLimit) ? tx.vout.slice(0, rowLimit - 2) : tx.vout.slice(0, rowLimit)) : tx.vout" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{ <tr [ngClass]="{
'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex,
'highlight': vout.scriptpubkey_address === this.address && this.address !== '' 'highlight': vout.scriptpubkey_address === this.address && this.address !== ''
@ -257,9 +257,9 @@
</td> </td>
</tr> </tr>
</ng-template> </ng-template>
<tr *ngIf="tx.vout.length > 12 && tx['@voutLimit'] && !outputIndex"> <tr *ngIf="tx.vout.length > rowLimit && tx['@voutLimit'] && !outputIndex">
<td colspan="3" class="text-center"> <td colspan="3" class="text-center">
<button class="btn btn-sm btn-primary mt-2" (click)="tx['@voutLimit'] = false;"><span i18n="show-all">Show all</span> ({{ tx.vout.length - 10 }})</button> <button class="btn btn-sm btn-primary mt-2" (click)="tx['@voutLimit'] = false;"><span i18n="show-all">Show all</span> ({{ tx.vout.length }})</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View file

@ -26,6 +26,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
@Input() paginated = false; @Input() paginated = false;
@Input() outputIndex: number; @Input() outputIndex: number;
@Input() address: string = ''; @Input() address: string = '';
@Input() rowLimit = 12;
@Input() channels: { inputs: any[], outputs: any[] };
@Output() loadMore = new EventEmitter(); @Output() loadMore = new EventEmitter();
@ -36,7 +38,6 @@ export class TransactionsListComponent implements OnInit, OnChanges {
showDetails$ = new BehaviorSubject<boolean>(false); showDetails$ = new BehaviorSubject<boolean>(false);
outspends: Outspend[][] = []; outspends: Outspend[][] = [];
assetsMinimal: any; assetsMinimal: any;
channels: { inputs: any[], outputs: any[] };
constructor( constructor(
public stateService: StateService, public stateService: StateService,
@ -127,7 +128,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}); });
const txIds = this.transactions.map((tx) => tx.txid); const txIds = this.transactions.map((tx) => tx.txid);
this.refreshOutspends$.next(txIds); this.refreshOutspends$.next(txIds);
this.refreshChannels$.next(txIds); if (!this.channels) {
this.refreshChannels$.next(txIds);
}
} }
onScroll() { onScroll() {

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

@ -16,3 +16,9 @@
color: #ffffff66; color: #ffffff66;
font-size: 12px; font-size: 12px;
} }
@media (max-width: 768px) {
.box {
margin-bottom: 20px;
}
}

View file

@ -14,7 +14,7 @@
<div class="clearfix"></div> <div class="clearfix"></div>
<app-nodes-channels-map *ngIf="!error" [style]="'channelpage'" [channel]="channelGeo"></app-nodes-channels-map> <app-nodes-channels-map *ngIf="!error && (channelGeo$ | async) as channelGeo" [style]="'channelpage'" [channel]="channelGeo"></app-nodes-channels-map>
<div class="box"> <div class="box">
@ -30,32 +30,6 @@
<td i18n="address.total-sent">Last update</td> <td i18n="address.total-sent">Last update</td>
<td><app-timestamp [dateString]="channel.updated_at"></app-timestamp></td> <td><app-timestamp [dateString]="channel.updated_at"></app-timestamp></td>
</tr> </tr>
<tr>
<td i18n="address.total-sent">Opening transaction</td>
<td>
<a [routerLink]="['/tx' | relativeUrl, channel.transaction_id + ':' + channel.transaction_vout]" >
<span>{{ channel.transaction_id | shortenString : 10 }}</span>
</a>
<app-clipboard [text]="channel.transaction_id"></app-clipboard>
</td>
</tr>
<ng-template [ngIf]="channel.closing_transaction_id">
<tr *ngIf="channel.closing_transaction_id">
<td i18n="address.total-sent">Closing transaction</td>
<td>
<a [routerLink]="['/tx' | relativeUrl, channel.closing_transaction_id]" >
<span>{{ channel.closing_transaction_id | shortenString : 10 }}</span>
</a>
<app-clipboard [text]="channel.closing_transaction_id"></app-clipboard>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Closing type</td>
<td>
<app-closing-type [type]="channel.closing_reason"></app-closing-type>
</td>
</tr>
</ng-template>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -82,8 +56,23 @@
</div> </div>
<div class="col"> <div class="col">
<app-channel-box [channel]="channel.node_right"></app-channel-box> <app-channel-box [channel]="channel.node_right"></app-channel-box>
</div>
</div> </div>
</div>
<br>
<ng-container *ngIf="transactions$ | async as transactions">
<ng-template [ngIf]="transactions[0]">
<h3>Opening transaction</h3>
<app-transactions-list [transactions]="[transactions[0]]" [showConfirmations]="true" [rowLimit]="5" [channels]="{ inputs: [], outputs: [channel] }"></app-transactions-list>
</ng-template>
<ng-template [ngIf]="transactions[1]">
<div class="closing-header">
<h3 style="margin: 0;">Closing transaction</h3>&nbsp;&nbsp;<app-closing-type [type]="channel.closing_reason"></app-closing-type>
</div>
<app-transactions-list [transactions]="[transactions[1]]" [showConfirmations]="true" [rowLimit]="5" [channels]="{ inputs: [channel], outputs: [] }"></app-transactions-list>
</ng-template>
</ng-container>
</div> </div>

View file

@ -39,3 +39,16 @@ app-fiat {
margin-left: 10px; margin-left: 10px;
} }
} }
.closing-header {
display: flex;
flex-direction: row;
margin-bottom: 1rem;
align-items: center;
}
@media (max-width: 768px) {
h3 {
font-size: 1.4rem;
}
}

View file

@ -1,7 +1,9 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router'; import { ActivatedRoute, ParamMap } from '@angular/router';
import { Observable, of } from 'rxjs'; import { forkJoin, Observable, of, share, zip } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators'; import { catchError, map, shareReplay, switchMap, tap } from 'rxjs/operators';
import { ApiService } from 'src/app/services/api.service';
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
import { SeoService } from 'src/app/services/seo.service'; import { SeoService } from 'src/app/services/seo.service';
import { LightningApiService } from '../lightning-api.service'; import { LightningApiService } from '../lightning-api.service';
@ -13,13 +15,15 @@ import { LightningApiService } from '../lightning-api.service';
}) })
export class ChannelComponent implements OnInit { export class ChannelComponent implements OnInit {
channel$: Observable<any>; channel$: Observable<any>;
channelGeo$: Observable<number[]>;
transactions$: Observable<any>;
error: any = null; error: any = null;
channelGeo: number[] = [];
constructor( constructor(
private lightningApiService: LightningApiService, private lightningApiService: LightningApiService,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private seoService: SeoService, private seoService: SeoService,
private electrsApiService: ElectrsApiService,
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
@ -30,28 +34,41 @@ export class ChannelComponent implements OnInit {
this.seoService.setTitle(`Channel: ${params.get('short_id')}`); this.seoService.setTitle(`Channel: ${params.get('short_id')}`);
return this.lightningApiService.getChannel$(params.get('short_id')) return this.lightningApiService.getChannel$(params.get('short_id'))
.pipe( .pipe(
tap((data) => {
if (!data.node_left.longitude || !data.node_left.latitude ||
!data.node_right.longitude || !data.node_right.latitude) {
this.channelGeo = [];
} else {
this.channelGeo = [
data.node_left.public_key,
data.node_left.alias,
data.node_left.longitude, data.node_left.latitude,
data.node_right.public_key,
data.node_right.alias,
data.node_right.longitude, data.node_right.latitude,
];
}
}),
catchError((err) => { catchError((err) => {
this.error = err; this.error = err;
return of(null); return of(null);
}) })
); );
}) }),
shareReplay(),
); );
this.channelGeo$ = this.channel$.pipe(
map((data) => {
if (!data.node_left.longitude || !data.node_left.latitude ||
!data.node_right.longitude || !data.node_right.latitude) {
return [];
} else {
return [
data.node_left.public_key,
data.node_left.alias,
data.node_left.longitude, data.node_left.latitude,
data.node_right.public_key,
data.node_right.alias,
data.node_right.longitude, data.node_right.latitude,
];
}
}),
);
this.transactions$ = this.channel$.pipe(
switchMap((data) => {
return zip([
data.transaction_id ? this.electrsApiService.getTransaction$(data.transaction_id) : of(null),
data.closing_transaction_id ? this.electrsApiService.getTransaction$(data.closing_transaction_id) : of(null),
]);
}),
);
} }
} }

View file

@ -19,7 +19,11 @@
</tbody> </tbody>
</table> </table>
<ngb-pagination *ngIf="response.channels.length > 0" class="pagination-container float-right" [size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination> <ngb-pagination *ngIf="response.channels.length > 0" class="pagination-container float-right"
[size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true"
[pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)"
[maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination>
<table class="table table-borderless" *ngIf="response.channels.length === 0"> <table class="table table-borderless" *ngIf="response.channels.length === 0">
<div class="d-flex justify-content-center" i18n="lightning.empty-channels-list">No channels to display</div> <div class="d-flex justify-content-center" i18n="lightning.empty-channels-list">No channels to display</div>

View file

@ -47,7 +47,7 @@ export class ChannelsListComponent implements OnInit, OnChanges {
} }
ngOnChanges(): void { ngOnChanges(): void {
this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false }); this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: true });
this.channelsPage$.next(1); this.channelsPage$.next(1);
this.channels$ = merge( this.channels$ = merge(
@ -70,7 +70,7 @@ export class ChannelsListComponent implements OnInit, OnChanges {
map((response) => { map((response) => {
return { return {
channels: response.body, channels: response.body,
totalItems: parseInt(response.headers.get('x-total-count'), 10) + 1 totalItems: parseInt(response.headers.get('x-total-count'), 10)
}; };
}), }),
); );

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

@ -31,6 +31,7 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
channelOpacity = 0.1; channelOpacity = 0.1;
channelColor = '#466d9d'; channelColor = '#466d9d';
channelCurve = 0; channelCurve = 0;
nodeSize = 4;
chartInstance = undefined; chartInstance = undefined;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
@ -65,6 +66,10 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
if (this.style === 'graph') { if (this.style === 'graph') {
this.seoService.setTitle($localize`Lightning nodes channels world map`); this.seoService.setTitle($localize`Lightning nodes channels world map`);
} }
if (['nodepage', 'channelpage'].includes(this.style)) {
this.nodeSize = 8;
}
this.observable$ = this.activatedRoute.paramMap this.observable$ = this.activatedRoute.paramMap
.pipe( .pipe(
@ -214,7 +219,7 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
data: nodes, data: nodes,
coordinateSystem: 'geo', coordinateSystem: 'geo',
geoIndex: 0, geoIndex: 0,
symbolSize: 4, symbolSize: this.nodeSize,
tooltip: { tooltip: {
show: true, show: true,
backgroundColor: 'rgba(17, 19, 31, 1)', backgroundColor: 'rgba(17, 19, 31, 1)',

View file

@ -10,6 +10,7 @@ import { download } from 'src/app/shared/graphs.utils';
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';
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
import { isMobile } from 'src/app/shared/common.utils';
@Component({ @Component({
selector: 'app-nodes-networks-chart', selector: 'app-nodes-networks-chart',
@ -108,19 +109,19 @@ export class NodesNetworksChartComponent implements OnInit {
); );
} }
prepareChartOptions(data, maxYAxis) { prepareChartOptions(data, maxYAxis): void {
let title: object; let title: object;
if (data.tor_nodes.length === 0) { if (!this.widget && data.tor_nodes.length === 0) {
title = { title = {
textStyle: { textStyle: {
color: 'grey', color: 'grey',
fontSize: 15 fontSize: 15
}, },
text: $localize`:@@23555386d8af1ff73f297e89dd4af3f4689fb9dd:Indexing blocks`, text: $localize`Indexing in progess`,
left: 'center', left: 'center',
top: 'top', top: 'center',
}; };
} else if (this.widget) { } else if (data.tor_nodes.length > 0) {
title = { title = {
textStyle: { textStyle: {
color: 'grey', color: 'grey',
@ -140,11 +141,11 @@ export class NodesNetworksChartComponent implements OnInit {
height: this.widget ? 100 : undefined, height: this.widget ? 100 : undefined,
top: this.widget ? 10 : 40, top: this.widget ? 10 : 40,
bottom: this.widget ? 0 : 70, bottom: this.widget ? 0 : 70,
right: (this.isMobile() && this.widget) ? 35 : this.right, right: (isMobile() && this.widget) ? 35 : this.right,
left: (this.isMobile() && this.widget) ? 40 :this.left, left: (isMobile() && this.widget) ? 40 :this.left,
}, },
tooltip: { tooltip: {
show: !this.isMobile() || !this.widget, show: !isMobile() || !this.widget,
trigger: 'axis', trigger: 'axis',
axisPointer: { axisPointer: {
type: 'line' type: 'line'
@ -157,7 +158,7 @@ export class NodesNetworksChartComponent implements OnInit {
align: 'left', align: 'left',
}, },
borderColor: '#000', borderColor: '#000',
formatter: (ticks) => { formatter: (ticks): string => {
let total = 0; let total = 0;
const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
let tooltip = `<b style="color: white; margin-left: 2px">${date}</b><br>`; let tooltip = `<b style="color: white; margin-left: 2px">${date}</b><br>`;
@ -180,7 +181,7 @@ export class NodesNetworksChartComponent implements OnInit {
}, },
xAxis: data.tor_nodes.length === 0 ? undefined : { xAxis: data.tor_nodes.length === 0 ? undefined : {
type: 'time', type: 'time',
splitNumber: (this.isMobile() || this.widget) ? 5 : 10, splitNumber: (isMobile() || this.widget) ? 5 : 10,
axisLabel: { axisLabel: {
hideOverlap: true, hideOverlap: true,
} }
@ -372,7 +373,7 @@ export class NodesNetworksChartComponent implements OnInit {
}; };
} }
onChartInit(ec) { onChartInit(ec): void {
if (this.chartInstance !== undefined) { if (this.chartInstance !== undefined) {
return; return;
} }
@ -384,11 +385,7 @@ export class NodesNetworksChartComponent implements OnInit {
}); });
} }
isMobile() { onSaveChart(): void {
return (window.innerWidth <= 767.98);
}
onSaveChart() {
// @ts-ignore // @ts-ignore
const prevBottom = this.chartOptions.grid.bottom; const prevBottom = this.chartOptions.grid.bottom;
const now = new Date(); const now = new Date();

View file

@ -154,7 +154,7 @@ export class NodesPerISPChartComponent implements OnInit {
}, },
borderColor: '#000', borderColor: '#000',
formatter: () => { formatter: () => {
return `<b style="color: white">${isp[1]} (${isp[6]}%)</b><br>` + return `<b style="color: white">${isp[1]} (${this.sortBy === 'capacity' ? isp[7] : isp[6]}%)</b><br>` +
$localize`${isp[4].toString()} nodes<br>` + $localize`${isp[4].toString()} nodes<br>` +
$localize`${this.amountShortenerPipe.transform(isp[2] / 100000000, 2)} BTC` $localize`${this.amountShortenerPipe.transform(isp[2] / 100000000, 2)} BTC`
; ;

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

@ -48,4 +48,10 @@
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>
</div> </div>
<div *ngIf="widget && (capacityObservable$ | async) as stats">
<div *ngIf="stats.days === 0" class="indexing-message d-flex" i18n="lightning.indexing-in-progress">
Indexing in progress
</div>
</div>
</div> </div>

View file

@ -131,4 +131,13 @@
display: block; display: block;
max-width: 80px; max-width: 80px;
margin: 15px auto 3px; margin: 15px auto 3px;
}
.indexing-message {
position: absolute;
font-size: 15px;
color: grey;
font-weight: bold;
margin-left: calc(50% - 85px);
margin-top: -10px;
} }

View file

@ -1,7 +1,7 @@
import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core';
import { EChartsOption, graphic } from 'echarts'; import { EChartsOption, graphic } from 'echarts';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, startWith, switchMap, tap } from 'rxjs/operators'; import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service'; import { SeoService } from 'src/app/services/seo.service';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
@ -10,6 +10,7 @@ import { MiningService } from 'src/app/services/mining.service';
import { download } from 'src/app/shared/graphs.utils'; import { download } from 'src/app/shared/graphs.utils';
import { LightningApiService } from '../lightning-api.service'; import { LightningApiService } from '../lightning-api.service';
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
import { isMobile } from 'src/app/shared/common.utils';
@Component({ @Component({
selector: 'app-lightning-statistics-chart', selector: 'app-lightning-statistics-chart',
@ -96,12 +97,13 @@ export class LightningStatisticsChartComponent implements OnInit {
}), }),
); );
}), }),
) share(),
);
} }
prepareChartOptions(data) { prepareChartOptions(data): void {
let title: object; let title: object;
if (data.channel_count.length === 0) { if (!this.widget && data.channel_count.length === 0) {
title = { title = {
textStyle: { textStyle: {
color: 'grey', color: 'grey',
@ -111,7 +113,7 @@ export class LightningStatisticsChartComponent implements OnInit {
left: 'center', left: 'center',
top: 'center' top: 'center'
}; };
} else if (this.widget) { } else if (data.channel_count.length > 0) {
title = { title = {
textStyle: { textStyle: {
color: 'grey', color: 'grey',
@ -138,11 +140,11 @@ export class LightningStatisticsChartComponent implements OnInit {
height: this.widget ? 100 : undefined, height: this.widget ? 100 : undefined,
top: this.widget ? 10 : 40, top: this.widget ? 10 : 40,
bottom: this.widget ? 0 : 70, bottom: this.widget ? 0 : 70,
right: (this.isMobile() && this.widget) ? 35 : this.right, right: (isMobile() && this.widget) ? 35 : this.right,
left: (this.isMobile() && this.widget) ? 40 :this.left, left: (isMobile() && this.widget) ? 40 :this.left,
}, },
tooltip: { tooltip: {
show: !this.isMobile(), show: !isMobile(),
trigger: 'axis', trigger: 'axis',
axisPointer: { axisPointer: {
type: 'line' type: 'line'
@ -155,7 +157,7 @@ export class LightningStatisticsChartComponent implements OnInit {
align: 'left', align: 'left',
}, },
borderColor: '#000', borderColor: '#000',
formatter: (ticks) => { formatter: (ticks): string => {
let sizeString = ''; let sizeString = '';
let weightString = ''; let weightString = '';
@ -169,16 +171,18 @@ export class LightningStatisticsChartComponent implements OnInit {
const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
let tooltip = `<b style="color: white; margin-left: 18px">${date}</b><br> const tooltip = `
<b style="color: white; margin-left: 18px">${date}</b><br>
<span>${sizeString}</span><br> <span>${sizeString}</span><br>
<span>${weightString}</span>`; <span>${weightString}</span>
`;
return tooltip; return tooltip;
} }
}, },
xAxis: data.channel_count.length === 0 ? undefined : { xAxis: data.channel_count.length === 0 ? undefined : {
type: 'time', type: 'time',
splitNumber: (this.isMobile() || this.widget) ? 5 : 10, splitNumber: (isMobile() || this.widget) ? 5 : 10,
axisLabel: { axisLabel: {
hideOverlap: true, hideOverlap: true,
} }
@ -315,7 +319,7 @@ export class LightningStatisticsChartComponent implements OnInit {
}; };
} }
onChartInit(ec) { onChartInit(ec): void {
if (this.chartInstance !== undefined) { if (this.chartInstance !== undefined) {
return; return;
} }
@ -327,11 +331,7 @@ export class LightningStatisticsChartComponent implements OnInit {
}); });
} }
isMobile() { onSaveChart(): void {
return (window.innerWidth <= 767.98);
}
onSaveChart() {
// @ts-ignore // @ts-ignore
const prevBottom = this.chartOptions.grid.bottom; const prevBottom = this.chartOptions.grid.bottom;
const now = new Date(); const now = new Date();