diff --git a/backend/src/api/common.ts b/backend/src/api/common.ts index fe12e0f40..8635ee96f 100644 --- a/backend/src/api/common.ts +++ b/backend/src/api/common.ts @@ -207,6 +207,10 @@ export class Common { /** Decodes a channel id returned by lnd as uint64 to a short channel id */ static channelIntegerIdToShortId(id: string): string { + if (id.indexOf('/') !== -1) { + id = id.slice(0, -2); + } + if (id.indexOf('x') !== -1) { // Already a short id return id; } diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index f3512248f..5cbd78d40 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -4,7 +4,7 @@ import logger from '../logger'; import { Common } from './common'; class DatabaseMigration { - private static currentVersion = 37; + private static currentVersion = 38; private queryTimeout = 120000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -248,7 +248,6 @@ class DatabaseMigration { } if (databaseSchemaVersion < 25 && isBitcoin === true) { - await this.$executeQuery(`INSERT INTO state VALUES('last_node_stats', 0, '1970-01-01');`); await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats')); await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes')); await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels')); @@ -328,6 +327,16 @@ class DatabaseMigration { if (databaseSchemaVersion < 37 && isBitcoin == true) { await this.$executeQuery(this.getCreateLNNodesSocketsTableQuery(), await this.$checkIfTableExists('nodes_sockets')); } + + if (databaseSchemaVersion < 38 && isBitcoin == true) { + if (config.LIGHTNING.ENABLED) { + this.uniqueLog(logger.notice, `'lightning_stats' and 'node_stats' tables have been truncated.`); + } + await this.$executeQuery(`TRUNCATE lightning_stats`); + await this.$executeQuery(`TRUNCATE node_stats`); + await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL'); + await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL'); + } } /** diff --git a/backend/src/api/explorer/channels.api.ts b/backend/src/api/explorer/channels.api.ts index 67003be57..b88b21ca3 100644 --- a/backend/src/api/explorer/channels.api.ts +++ b/backend/src/api/explorer/channels.api.ts @@ -61,9 +61,14 @@ class ChannelsApi { } } - public async $getChannelsByStatus(status: number): Promise { + public async $getChannelsByStatus(status: number | number[]): Promise { try { - const query = `SELECT * FROM channels WHERE status = ?`; + let query: string; + if (Array.isArray(status)) { + query = `SELECT * FROM channels WHERE status IN (${status.join(',')})`; + } else { + query = `SELECT * FROM channels WHERE status = ?`; + } const [rows]: any = await DB.query(query, [status]); return rows; } catch (e) { @@ -218,23 +223,25 @@ class ChannelsApi { // Channels originating from node let query = ` - SELECT node2.alias, node2.public_key, channels.status, channels.node1_fee_rate, + SELECT COALESCE(node2.alias, SUBSTRING(node2_public_key, 0, 20)) AS alias, COALESCE(node2.public_key, node2_public_key) AS public_key, + channels.status, channels.node1_fee_rate, channels.capacity, channels.short_id, channels.id FROM channels - JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key + LEFT JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key WHERE node1_public_key = ? AND channels.status ${channelStatusFilter} `; - const [channelsFromNode]: any = await DB.query(query, [public_key, index, length]); + const [channelsFromNode]: any = await DB.query(query, [public_key]); // Channels incoming to node query = ` - SELECT node1.alias, node1.public_key, channels.status, channels.node2_fee_rate, + SELECT COALESCE(node1.alias, SUBSTRING(node1_public_key, 0, 20)) AS alias, COALESCE(node1.public_key, node1_public_key) AS public_key, + channels.status, channels.node2_fee_rate, channels.capacity, channels.short_id, channels.id FROM channels - JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key + LEFT JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key WHERE node2_public_key = ? AND channels.status ${channelStatusFilter} `; - const [channelsToNode]: any = await DB.query(query, [public_key, index, length]); + const [channelsToNode]: any = await DB.query(query, [public_key]); let allChannels = channelsFromNode.concat(channelsToNode); allChannels.sort((a, b) => { @@ -337,7 +344,7 @@ class ChannelsApi { /** * Save or update a channel present in the graph */ - public async $saveChannel(channel: ILightningApi.Channel): Promise { + public async $saveChannel(channel: ILightningApi.Channel, status = 1): Promise { const [ txid, vout ] = channel.chan_point.split(':'); const policy1: Partial = channel.node1_policy || {}; @@ -369,11 +376,11 @@ class ChannelsApi { node2_min_htlc_mtokens, node2_updated_at ) - VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ${status}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE capacity = ?, updated_at = ?, - status = 1, + status = ${status}, node1_public_key = ?, node1_base_fee_mtokens = ?, node1_cltv_delta = ?, diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index 88b434711..474a0cabf 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -169,7 +169,7 @@ class NodesApi { let query: string; if (full === false) { query = ` - SELECT nodes.public_key, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, + SELECT nodes.public_key as publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias, node_stats.channels FROM node_stats JOIN nodes ON nodes.public_key = node_stats.public_key @@ -296,19 +296,24 @@ class NodesApi { if (!ispList[isp1]) { ispList[isp1] = { - id: channel.isp1ID, + id: channel.isp1ID.toString(), capacity: 0, channels: 0, nodes: {}, }; + } else if (ispList[isp1].id.indexOf(channel.isp1ID) === -1) { + ispList[isp1].id += ',' + channel.isp1ID.toString(); } + if (!ispList[isp2]) { ispList[isp2] = { - id: channel.isp2ID, + id: channel.isp2ID.toString(), capacity: 0, channels: 0, nodes: {}, }; + } else if (ispList[isp2].id.indexOf(channel.isp2ID) === -1) { + ispList[isp2].id += ',' + channel.isp2ID.toString(); } ispList[isp1].capacity += channel.capacity; @@ -385,9 +390,10 @@ class NodesApi { public async $getNodesPerCountry(countryId: string) { try { const query = ` - SELECT nodes.public_key, CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity, CAST(COALESCE(node_stats.channels, 0) as INT) as channels, - nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, - geo_names_city.names as city + SELECT nodes.public_key, CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity, CAST(COALESCE(node_stats.channels, 0) as INT) as channels, + nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, + geo_names_city.names as city, geo_names_country.names as country, + geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision FROM node_stats JOIN ( SELECT public_key, MAX(added) as last_added @@ -395,15 +401,19 @@ class NodesApi { GROUP BY public_key ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added RIGHT JOIN nodes ON nodes.public_key = node_stats.public_key - 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_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' + LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' + LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division' WHERE geo_names_country.id = ? ORDER BY capacity DESC `; const [rows]: any = await DB.query(query, [countryId]); for (let i = 0; i < rows.length; ++i) { + rows[i].country = JSON.parse(rows[i].country); rows[i].city = JSON.parse(rows[i].city); + rows[i].subdivision = JSON.parse(rows[i].subdivision); } return rows; } catch (e) { @@ -417,7 +427,8 @@ class NodesApi { const query = ` SELECT nodes.public_key, CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity, CAST(COALESCE(node_stats.channels, 0) as INT) as channels, nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, - geo_names_city.names as city, geo_names_country.names as country + geo_names_city.names as city, geo_names_country.names as country, + geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision FROM node_stats JOIN ( SELECT public_key, MAX(added) as last_added @@ -425,8 +436,10 @@ class NodesApi { GROUP BY public_key ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added RIGHT JOIN nodes ON nodes.public_key = node_stats.public_key - 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_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' + LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' + LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division' WHERE nodes.as_number IN (?) ORDER BY capacity DESC `; @@ -435,6 +448,7 @@ class NodesApi { for (let i = 0; i < rows.length; ++i) { rows[i].country = JSON.parse(rows[i].country); rows[i].city = JSON.parse(rows[i].city); + rows[i].subdivision = JSON.parse(rows[i].subdivision); } return rows; } catch (e) { diff --git a/backend/src/index.ts b/backend/src/index.ts index 683f964f0..d1e3cee8d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -189,7 +189,7 @@ class Server { await networkSyncService.$startService(); await lightningStatsUpdater.$startService(); } catch(e) { - logger.err(`Lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`); + logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`); await Common.sleep$(1000 * 60); this.$runLightningBackend(); }; diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index 8ce300901..d704012f7 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -240,8 +240,8 @@ class NetworkSyncService { let progress = 0; try { - logger.info(`Starting closed channels scan...`); - const channels = await channelsApi.$getChannelsByStatus(0); + logger.info(`Starting closed channels scan`); + const channels = await channelsApi.$getChannelsByStatus([0, 1]); for (const channel of channels) { const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout); if (spendingTx.spent === true && spendingTx.status?.confirmed === true) { diff --git a/backend/src/tasks/lightning/sync-tasks/funding-tx-fetcher.ts b/backend/src/tasks/lightning/sync-tasks/funding-tx-fetcher.ts index 9dbc21c72..76865dc40 100644 --- a/backend/src/tasks/lightning/sync-tasks/funding-tx-fetcher.ts +++ b/backend/src/tasks/lightning/sync-tasks/funding-tx-fetcher.ts @@ -71,9 +71,7 @@ class FundingTxFetcher { } public async $fetchChannelOpenTx(channelId: string): Promise<{timestamp: number, txid: string, value: number}> { - if (channelId.indexOf('x') === -1) { - channelId = Common.channelIntegerIdToShortId(channelId); - } + channelId = Common.channelIntegerIdToShortId(channelId); if (this.fundingTxCache[channelId]) { return this.fundingTxCache[channelId]; diff --git a/backend/src/tasks/lightning/sync-tasks/stats-importer.ts b/backend/src/tasks/lightning/sync-tasks/stats-importer.ts index 5878f898a..e05ba4ab3 100644 --- a/backend/src/tasks/lightning/sync-tasks/stats-importer.ts +++ b/backend/src/tasks/lightning/sync-tasks/stats-importer.ts @@ -5,33 +5,11 @@ import fundingTxFetcher from './funding-tx-fetcher'; import config from '../../../config'; import { ILightningApi } from '../../../api/lightning/lightning-api.interface'; import { isIP } from 'net'; +import { Common } from '../../../api/common'; +import channelsApi from '../../../api/explorer/channels.api'; const fsPromises = promises; -interface Node { - id: string; - timestamp: number; - features: string; - rgb_color: string; - alias: string; - addresses: unknown[]; - out_degree: number; - in_degree: number; -} - -interface Channel { - channel_id: string; - node1_pub: string; - node2_pub: string; - timestamp: number; - features: string; - fee_base_msat: number; - fee_rate_milli_msat: number; - htlc_minimim_msat: number; - cltv_expiry_delta: number; - htlc_maximum_msat: number; -} - class LightningStatsImporter { topologiesFolder = config.LIGHTNING.TOPOLOGY_FOLDER; @@ -46,7 +24,8 @@ class LightningStatsImporter { /** * Generate LN network stats for one day */ - public async computeNetworkStats(timestamp: number, networkGraph: ILightningApi.NetworkGraph): Promise { + public async computeNetworkStats(timestamp: number, + networkGraph: ILightningApi.NetworkGraph, isHistorical: boolean = false): Promise { // Node counts and network shares let clearnetNodes = 0; let torNodes = 0; @@ -59,11 +38,11 @@ class LightningStatsImporter { let isUnnanounced = true; for (const socket of (node.addresses ?? [])) { - if (!socket.network?.length || !socket.addr?.length) { + if (!socket.network?.length && !socket.addr?.length) { continue; } - hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1; - hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0])); + hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1 || socket.addr.indexOf('torv2') !== -1 || socket.addr.indexOf('torv3') !== -1; + hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0])) || socket.addr.indexOf('ipv4') !== -1 || socket.addr.indexOf('ipv6') !== -1;; } if (hasOnion && hasClearnet) { clearnetTorNodes++; @@ -90,11 +69,14 @@ class LightningStatsImporter { const baseFees: number[] = []; const alreadyCountedChannels = {}; + const [channelsInDbRaw]: any[] = await DB.query(`SELECT short_id, created FROM channels`); + const channelsInDb = {}; + for (const channel of channelsInDbRaw) { + channelsInDb[channel.short_id] = channel; + } + for (const channel of networkGraph.edges) { - let short_id = channel.channel_id; - if (short_id.indexOf('/') !== -1) { - short_id = short_id.slice(0, -2); - } + const short_id = Common.channelIntegerIdToShortId(channel.channel_id); const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id); if (!tx) { @@ -102,6 +84,31 @@ class LightningStatsImporter { continue; } + // Channel is already in db, check if we need to update 'created' field + if (isHistorical === true) { + //@ts-ignore + if (channelsInDb[short_id] && channel.timestamp < channel.created) { + await DB.query(` + UPDATE channels SET created = FROM_UNIXTIME(?) WHERE channels.short_id = ?`, + //@ts-ignore + [channel.timestamp, short_id] + ); + } else if (!channelsInDb[short_id]) { + await channelsApi.$saveChannel({ + channel_id: short_id, + chan_point: `${tx.txid}:${short_id.split('x')[2]}`, + //@ts-ignore + last_update: channel.timestamp, + node1_pub: channel.node1_pub, + node2_pub: channel.node2_pub, + capacity: (tx.value * 100000000).toString(), + node1_policy: null, + node2_policy: null, + }, 0); + channelsInDb[channel.channel_id] = channel; + } + } + if (!nodeStats[channel.node1_pub]) { nodeStats[channel.node1_pub] = { capacity: 0, @@ -126,7 +133,7 @@ class LightningStatsImporter { nodeStats[channel.node2_pub].channels++; } - if (channel.node1_policy !== undefined) { // Coming from the node + if (isHistorical === false) { // Coming from the node for (const policy of [channel.node1_policy, channel.node2_policy]) { if (policy && parseInt(policy.fee_rate_milli_msat, 10) < 5000) { avgFeeRate += parseInt(policy.fee_rate_milli_msat, 10); @@ -137,30 +144,42 @@ class LightningStatsImporter { baseFees.push(parseInt(policy.fee_base_msat, 10)); } } - } else { // Coming from the historical import + } else { // @ts-ignore - if (channel.fee_rate_milli_msat < 5000) { + if (channel.node1_policy.fee_rate_milli_msat < 5000) { // @ts-ignore - avgFeeRate += parseInt(channel.fee_rate_milli_msat, 10); + avgFeeRate += parseInt(channel.node1_policy.fee_rate_milli_msat, 10); // @ts-ignore - feeRates.push(parseInt(channel.fee_rate_milli_msat), 10); + feeRates.push(parseInt(channel.node1_policy.fee_rate_milli_msat), 10); } // @ts-ignore - if (channel.fee_base_msat < 5000) { + if (channel.node1_policy.fee_base_msat < 5000) { // @ts-ignore - avgBaseFee += parseInt(channel.fee_base_msat, 10); + avgBaseFee += parseInt(channel.node1_policy.fee_base_msat, 10); // @ts-ignore - baseFees.push(parseInt(channel.fee_base_msat), 10); + baseFees.push(parseInt(channel.node1_policy.fee_base_msat), 10); } } } + let medCapacity = 0; + let medFeeRate = 0; + let medBaseFee = 0; + let avgCapacity = 0; + avgFeeRate /= Math.max(networkGraph.edges.length, 1); avgBaseFee /= Math.max(networkGraph.edges.length, 1); - const medCapacity = capacities.sort((a, b) => b - a)[Math.round(capacities.length / 2 - 1)]; - const medFeeRate = feeRates.sort((a, b) => b - a)[Math.round(feeRates.length / 2 - 1)]; - const medBaseFee = baseFees.sort((a, b) => b - a)[Math.round(baseFees.length / 2 - 1)]; - const avgCapacity = Math.round(capacity / Math.max(capacities.length, 1)); + + if (capacities.length > 0) { + medCapacity = capacities.sort((a, b) => b - a)[Math.round(capacities.length / 2 - 1)]; + avgCapacity = Math.round(capacity / Math.max(capacities.length, 1)); + } + if (feeRates.length > 0) { + medFeeRate = feeRates.sort((a, b) => b - a)[Math.round(feeRates.length / 2 - 1)]; + } + if (baseFees.length > 0) { + medBaseFee = baseFees.sort((a, b) => b - a)[Math.round(baseFees.length / 2 - 1)]; + } let query = `INSERT INTO lightning_stats( added, @@ -262,83 +281,154 @@ class LightningStatsImporter { * Import topology files LN historical data into the database */ async $importHistoricalLightningStats(): Promise { - const fileList = await fsPromises.readdir(this.topologiesFolder); - // Insert history from the most recent to the oldest - // This also put the .json cached files first - fileList.sort().reverse(); - - const [rows]: any[] = await DB.query(` - SELECT UNIX_TIMESTAMP(added) AS added, node_count - FROM lightning_stats - ORDER BY added DESC - `); - const existingStatsTimestamps = {}; - for (const row of rows) { - existingStatsTimestamps[row.added] = row; - } - - // For logging purpose - let processed = 10; - let totalProcessed = 0; - let logStarted = false; - - for (const filename of fileList) { - processed++; - - const timestamp = parseInt(filename.split('_')[1], 10); - - // Stats exist already, don't calculate/insert them - if (existingStatsTimestamps[timestamp] !== undefined) { - continue; - } - - if (filename.indexOf('.topology') === -1) { - continue; - } - - logger.debug(`Reading ${this.topologiesFolder}/${filename}`); - let fileContent = ''; + try { + let fileList: string[] = []; try { - fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8'); - } catch (e: any) { - if (e.errno == -1) { // EISDIR - Ignore directorie + fileList = await fsPromises.readdir(this.topologiesFolder); + } catch (e) { + logger.err(`Unable to open topology folder at ${this.topologiesFolder}`); + throw e; + } + // Insert history from the most recent to the oldest + // This also put the .json cached files first + fileList.sort().reverse(); + + const [rows]: any[] = await DB.query(` + SELECT UNIX_TIMESTAMP(added) AS added, node_count + FROM lightning_stats + ORDER BY added DESC + `); + const existingStatsTimestamps = {}; + for (const row of rows) { + existingStatsTimestamps[row.added] = row; + } + + // For logging purpose + let processed = 10; + let totalProcessed = 0; + let logStarted = false; + + for (const filename of fileList) { + processed++; + + const timestamp = parseInt(filename.split('_')[1], 10); + + // Stats exist already, don't calculate/insert them + if (existingStatsTimestamps[timestamp] !== undefined) { + totalProcessed++; continue; } + + if (filename.indexOf('topology_') === -1) { + totalProcessed++; + continue; + } + + logger.debug(`Reading ${this.topologiesFolder}/${filename}`); + let fileContent = ''; + try { + fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8'); + } catch (e: any) { + if (e.errno == -1) { // EISDIR - Ignore directorie + continue; + } + logger.err(`Unable to open ${this.topologiesFolder}/${filename}`); + continue; + } + + let graph; + try { + graph = JSON.parse(fileContent); + graph = await this.cleanupTopology(graph); + } catch (e) { + logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`); + continue; + } + + if (!logStarted) { + logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`); + logStarted = true; + } + + const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`; + logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`); + + totalProcessed++; + + if (processed > 10) { + logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`); + processed = 0; + } else { + logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`); + } + await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2))); + const stat = await this.computeNetworkStats(timestamp, graph, true); + + existingStatsTimestamps[timestamp] = stat; } - let graph; - try { - graph = JSON.parse(fileContent); - } catch (e) { - logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content`); + if (totalProcessed > 0) { + logger.info(`Lightning network stats historical import completed`); + } + } catch (e) { + logger.err(`Lightning network stats historical failed. Reason: ${e instanceof Error ? e.message : e}`); + } + } + + async cleanupTopology(graph) { + const newGraph = { + nodes: [], + edges: [], + }; + + for (const node of graph.nodes) { + const addressesParts = (node.addresses ?? '').split(','); + const addresses: any[] = []; + for (const address of addressesParts) { + addresses.push({ + network: '', + addr: address + }); + } + + newGraph.nodes.push({ + last_update: node.timestamp ?? 0, + pub_key: node.id ?? null, + alias: node.alias ?? null, + addresses: addresses, + color: node.rgb_color ?? null, + features: {}, + }); + } + + for (const adjacency of graph.adjacency) { + if (adjacency.length === 0) { continue; - } - - if (!logStarted) { - logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`); - logStarted = true; - } - - const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`; - logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`); - - totalProcessed++; - - if (processed > 10) { - logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`); - processed = 0; } else { - logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`); + for (const edge of adjacency) { + newGraph.edges.push({ + channel_id: edge.scid, + chan_point: '', + last_update: edge.timestamp, + node1_pub: edge.source ?? null, + node2_pub: edge.destination ?? null, + capacity: '0', // Will be fetch later + node1_policy: { + time_lock_delta: edge.cltv_expiry_delta, + min_htlc: edge.htlc_minimim_msat, + fee_base_msat: edge.fee_base_msat, + fee_rate_milli_msat: edge.fee_proportional_millionths, + max_htlc_msat: edge.htlc_maximum_msat, + last_update: edge.timestamp, + disabled: false, + }, + node2_policy: null, + }); + } } - await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2))); - const stat = await this.computeNetworkStats(timestamp, graph); - - existingStatsTimestamps[timestamp] = stat; } - if (totalProcessed > 0) { - logger.info(`Lightning network stats historical import completed`); - } + return newGraph; } } diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 3e78f60a2..17eb52878 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -20,7 +20,7 @@
- + 12 && tx['@vinLimit']"> + @@ -158,7 +158,7 @@
- +
- + 12 && tx['@voutLimit'] && !outputIndex"> + diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index e1e9880c0..69b65a8a4 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -26,6 +26,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { @Input() paginated = false; @Input() outputIndex: number; @Input() address: string = ''; + @Input() rowLimit = 12; + @Input() channels: { inputs: any[], outputs: any[] }; @Output() loadMore = new EventEmitter(); @@ -36,7 +38,6 @@ export class TransactionsListComponent implements OnInit, OnChanges { showDetails$ = new BehaviorSubject(false); outspends: Outspend[][] = []; assetsMinimal: any; - channels: { inputs: any[], outputs: any[] }; constructor( public stateService: StateService, @@ -127,7 +128,9 @@ export class TransactionsListComponent implements OnInit, OnChanges { }); const txIds = this.transactions.map((tx) => tx.txid); this.refreshOutspends$.next(txIds); - this.refreshChannels$.next(txIds); + if (!this.channels) { + this.refreshChannels$.next(txIds); + } } onScroll() { diff --git a/frontend/src/app/lightning/channel/channel-box/channel-box.component.scss b/frontend/src/app/lightning/channel/channel-box/channel-box.component.scss index 300b98b11..e9fde9ffb 100644 --- a/frontend/src/app/lightning/channel/channel-box/channel-box.component.scss +++ b/frontend/src/app/lightning/channel/channel-box/channel-box.component.scss @@ -16,3 +16,9 @@ color: #ffffff66; font-size: 12px; } + +@media (max-width: 768px) { + .box { + margin-bottom: 20px; + } +} \ No newline at end of file diff --git a/frontend/src/app/lightning/channel/channel.component.html b/frontend/src/app/lightning/channel/channel.component.html index ec49c78a0..646094619 100644 --- a/frontend/src/app/lightning/channel/channel.component.html +++ b/frontend/src/app/lightning/channel/channel.component.html @@ -14,7 +14,7 @@
- +
@@ -30,32 +30,6 @@
- - - - - - - - - - - - - -
- +
Last update
Opening transaction - - {{ channel.transaction_id | shortenString : 10 }} - - -
Closing transaction - - {{ channel.closing_transaction_id | shortenString : 10 }} - - -
Closing type - -
@@ -82,8 +56,23 @@
+
- + +
+ + + +

Opening transaction

+ +
+ +
+

Closing transaction

   +
+ +
+
diff --git a/frontend/src/app/lightning/channel/channel.component.scss b/frontend/src/app/lightning/channel/channel.component.scss index a5aff4428..f19215f87 100644 --- a/frontend/src/app/lightning/channel/channel.component.scss +++ b/frontend/src/app/lightning/channel/channel.component.scss @@ -39,3 +39,16 @@ app-fiat { margin-left: 10px; } } + +.closing-header { + display: flex; + flex-direction: row; + margin-bottom: 1rem; + align-items: center; +} + +@media (max-width: 768px) { + h3 { + font-size: 1.4rem; + } +} diff --git a/frontend/src/app/lightning/channel/channel.component.ts b/frontend/src/app/lightning/channel/channel.component.ts index 9c3cdd57e..bbf9be36d 100644 --- a/frontend/src/app/lightning/channel/channel.component.ts +++ b/frontend/src/app/lightning/channel/channel.component.ts @@ -1,7 +1,9 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, ParamMap } from '@angular/router'; -import { Observable, of } from 'rxjs'; -import { catchError, switchMap, tap } from 'rxjs/operators'; +import { forkJoin, Observable, of, share, zip } from 'rxjs'; +import { catchError, map, shareReplay, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from 'src/app/services/api.service'; +import { ElectrsApiService } from 'src/app/services/electrs-api.service'; import { SeoService } from 'src/app/services/seo.service'; import { LightningApiService } from '../lightning-api.service'; @@ -13,13 +15,15 @@ import { LightningApiService } from '../lightning-api.service'; }) export class ChannelComponent implements OnInit { channel$: Observable; + channelGeo$: Observable; + transactions$: Observable; error: any = null; - channelGeo: number[] = []; constructor( private lightningApiService: LightningApiService, private activatedRoute: ActivatedRoute, private seoService: SeoService, + private electrsApiService: ElectrsApiService, ) { } ngOnInit(): void { @@ -30,28 +34,41 @@ export class ChannelComponent implements OnInit { this.seoService.setTitle(`Channel: ${params.get('short_id')}`); return this.lightningApiService.getChannel$(params.get('short_id')) .pipe( - tap((data) => { - if (!data.node_left.longitude || !data.node_left.latitude || - !data.node_right.longitude || !data.node_right.latitude) { - this.channelGeo = []; - } else { - this.channelGeo = [ - data.node_left.public_key, - data.node_left.alias, - data.node_left.longitude, data.node_left.latitude, - data.node_right.public_key, - data.node_right.alias, - data.node_right.longitude, data.node_right.latitude, - ]; - } - }), catchError((err) => { this.error = err; return of(null); }) ); - }) + }), + shareReplay(), ); + + this.channelGeo$ = this.channel$.pipe( + map((data) => { + if (!data.node_left.longitude || !data.node_left.latitude || + !data.node_right.longitude || !data.node_right.latitude) { + return []; + } else { + return [ + data.node_left.public_key, + data.node_left.alias, + data.node_left.longitude, data.node_left.latitude, + data.node_right.public_key, + data.node_right.alias, + data.node_right.longitude, data.node_right.latitude, + ]; + } + }), + ); + + this.transactions$ = this.channel$.pipe( + switchMap((data) => { + return zip([ + data.transaction_id ? this.electrsApiService.getTransaction$(data.transaction_id) : of(null), + data.closing_transaction_id ? this.electrsApiService.getTransaction$(data.closing_transaction_id) : of(null), + ]); + }), + ); } } diff --git a/frontend/src/app/lightning/channels-list/channels-list.component.html b/frontend/src/app/lightning/channels-list/channels-list.component.html index b95cddf8d..780c0fdf6 100644 --- a/frontend/src/app/lightning/channels-list/channels-list.component.html +++ b/frontend/src/app/lightning/channels-list/channels-list.component.html @@ -19,7 +19,11 @@ - + +
No channels to display
diff --git a/frontend/src/app/lightning/channels-list/channels-list.component.ts b/frontend/src/app/lightning/channels-list/channels-list.component.ts index 6172a4a99..6a0732522 100644 --- a/frontend/src/app/lightning/channels-list/channels-list.component.ts +++ b/frontend/src/app/lightning/channels-list/channels-list.component.ts @@ -47,7 +47,7 @@ export class ChannelsListComponent implements OnInit, OnChanges { } ngOnChanges(): void { - this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false }); + this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: true }); this.channelsPage$.next(1); this.channels$ = merge( @@ -70,7 +70,7 @@ export class ChannelsListComponent implements OnInit, OnChanges { map((response) => { return { channels: response.body, - totalItems: parseInt(response.headers.get('x-total-count'), 10) + 1 + totalItems: parseInt(response.headers.get('x-total-count'), 10) }; }), ); diff --git a/frontend/src/app/lightning/node/node-preview.component.ts b/frontend/src/app/lightning/node/node-preview.component.ts index 6344a38b2..0d6908eb1 100644 --- a/frontend/src/app/lightning/node/node-preview.component.ts +++ b/frontend/src/app/lightning/node/node-preview.component.ts @@ -4,7 +4,7 @@ import { Observable } from 'rxjs'; import { catchError, map, switchMap } from 'rxjs/operators'; import { SeoService } from 'src/app/services/seo.service'; import { OpenGraphService } from 'src/app/services/opengraph.service'; -import { getFlagEmoji } from 'src/app/shared/graphs.utils'; +import { getFlagEmoji } from 'src/app/shared/common.utils'; import { LightningApiService } from '../lightning-api.service'; import { isMobile } from '../../shared/common.utils'; diff --git a/frontend/src/app/lightning/node/node.component.html b/frontend/src/app/lightning/node/node.component.html index de6c816f0..e2e500ac5 100644 --- a/frontend/src/app/lightning/node/node.component.html +++ b/frontend/src/app/lightning/node/node.component.html @@ -42,24 +42,10 @@ - + - - - - diff --git a/frontend/src/app/lightning/node/node.component.ts b/frontend/src/app/lightning/node/node.component.ts index a81849388..8ddaacf95 100644 --- a/frontend/src/app/lightning/node/node.component.ts +++ b/frontend/src/app/lightning/node/node.component.ts @@ -3,9 +3,9 @@ import { ActivatedRoute, ParamMap } from '@angular/router'; import { Observable } from 'rxjs'; import { catchError, map, switchMap } from 'rxjs/operators'; import { SeoService } from 'src/app/services/seo.service'; -import { getFlagEmoji } from 'src/app/shared/graphs.utils'; import { LightningApiService } from '../lightning-api.service'; import { isMobile } from '../../shared/common.utils'; +import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component'; @Component({ selector: 'app-node', @@ -58,7 +58,6 @@ export class NodeComponent implements OnInit { } else if (socket.indexOf('onion') > -1) { label = 'Tor'; } - node.flag = getFlagEmoji(node.iso_code); socketsObject.push({ label: label, socket: node.public_key + '@' + socket, @@ -66,6 +65,19 @@ export class NodeComponent implements OnInit { } node.socketsObject = socketsObject; node.avgCapacity = node.capacity / Math.max(1, node.active_channel_count); + + if (!node?.country && !node?.city && + !node?.subdivision && !node?.iso) { + node.geolocation = null; + } else { + node.geolocation = { + country: node.country?.en, + city: node.city?.en, + subdivision: node.subdivision?.en, + iso: node.iso_code, + }; + } + return node; }), catchError(err => { diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html index 87082257e..1d865d4b7 100644 --- a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html @@ -1,14 +1,16 @@ -
- -
-
- Lightning nodes channels world map +
+
+
+
+ Lightning nodes channels world map +
+ (Tor nodes excluded) +
+ +
- (Tor nodes excluded) -
- -
+
diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts index 033f944b1..296bb917c 100644 --- a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, HostListener, Input, Output, EventEmitter, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter, NgZone, OnInit } from '@angular/core'; import { SeoService } from 'src/app/services/seo.service'; import { ApiService } from 'src/app/services/api.service'; import { Observable, switchMap, tap, zip } from 'rxjs'; @@ -16,14 +16,14 @@ import { isMobile } from 'src/app/shared/common.utils'; styleUrls: ['./nodes-channels-map.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class NodesChannelsMap implements OnInit, OnDestroy { +export class NodesChannelsMap implements OnInit { @Input() style: 'graph' | 'nodepage' | 'widget' | 'channelpage' = 'graph'; @Input() publicKey: string | undefined; @Input() channel: any[] = []; @Input() fitContainer = false; @Output() readyEvent = new EventEmitter(); - observable$: Observable; + channelsObservable: Observable; center: number[] | undefined; zoom: number | undefined; @@ -31,6 +31,7 @@ export class NodesChannelsMap implements OnInit, OnDestroy { channelOpacity = 0.1; channelColor = '#466d9d'; channelCurve = 0; + nodeSize = 4; chartInstance = undefined; chartOptions: EChartsOption = {}; @@ -49,8 +50,6 @@ export class NodesChannelsMap implements OnInit, OnDestroy { ) { } - ngOnDestroy(): void {} - ngOnInit(): void { this.center = this.style === 'widget' ? [0, 40] : [0, 5]; this.zoom = 1.3; @@ -65,8 +64,12 @@ export class NodesChannelsMap implements OnInit, OnDestroy { if (this.style === 'graph') { this.seoService.setTitle($localize`Lightning nodes channels world map`); } + + if (['nodepage', 'channelpage'].includes(this.style)) { + this.nodeSize = 8; + } - this.observable$ = this.activatedRoute.paramMap + this.channelsObservable = this.activatedRoute.paramMap .pipe( switchMap((params: ParamMap) => { return zip( @@ -170,15 +173,8 @@ export class NodesChannelsMap implements OnInit, OnDestroy { prepareChartOptions(nodes, channels) { let title: object; if (channels.length === 0) { - title = { - textStyle: { - color: 'grey', - fontSize: 15 - }, - text: $localize`No geolocation data available`, - left: 'center', - top: 'center' - }; + this.chartOptions = null; + return; } this.chartOptions = { @@ -214,7 +210,7 @@ export class NodesChannelsMap implements OnInit, OnDestroy { data: nodes, coordinateSystem: 'geo', geoIndex: 0, - symbolSize: 4, + symbolSize: this.nodeSize, tooltip: { show: true, backgroundColor: 'rgba(17, 19, 31, 1)', diff --git a/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts b/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts index dbbb49483..ecbf92f39 100644 --- a/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts +++ b/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts @@ -10,6 +10,7 @@ import { download } from 'src/app/shared/graphs.utils'; import { SeoService } from 'src/app/services/seo.service'; import { LightningApiService } from '../lightning-api.service'; import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; +import { isMobile } from 'src/app/shared/common.utils'; @Component({ selector: 'app-nodes-networks-chart', @@ -108,19 +109,19 @@ export class NodesNetworksChartComponent implements OnInit { ); } - prepareChartOptions(data, maxYAxis) { + prepareChartOptions(data, maxYAxis): void { let title: object; - if (data.tor_nodes.length === 0) { + if (!this.widget && data.tor_nodes.length === 0) { title = { textStyle: { color: 'grey', fontSize: 15 }, - text: $localize`:@@23555386d8af1ff73f297e89dd4af3f4689fb9dd:Indexing blocks`, + text: $localize`Indexing in progess`, left: 'center', - top: 'top', + top: 'center', }; - } else if (this.widget) { + } else if (data.tor_nodes.length > 0) { title = { textStyle: { color: 'grey', @@ -140,11 +141,11 @@ export class NodesNetworksChartComponent implements OnInit { height: this.widget ? 100 : undefined, top: this.widget ? 10 : 40, bottom: this.widget ? 0 : 70, - right: (this.isMobile() && this.widget) ? 35 : this.right, - left: (this.isMobile() && this.widget) ? 40 :this.left, + right: (isMobile() && this.widget) ? 35 : this.right, + left: (isMobile() && this.widget) ? 40 :this.left, }, tooltip: { - show: !this.isMobile() || !this.widget, + show: !isMobile() || !this.widget, trigger: 'axis', axisPointer: { type: 'line' @@ -157,7 +158,7 @@ export class NodesNetworksChartComponent implements OnInit { align: 'left', }, borderColor: '#000', - formatter: (ticks) => { + formatter: (ticks): string => { let total = 0; const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); let tooltip = `${date}
`; @@ -180,7 +181,7 @@ export class NodesNetworksChartComponent implements OnInit { }, xAxis: data.tor_nodes.length === 0 ? undefined : { type: 'time', - splitNumber: (this.isMobile() || this.widget) ? 5 : 10, + splitNumber: (isMobile() || this.widget) ? 5 : 10, axisLabel: { hideOverlap: true, } @@ -372,7 +373,7 @@ export class NodesNetworksChartComponent implements OnInit { }; } - onChartInit(ec) { + onChartInit(ec): void { if (this.chartInstance !== undefined) { return; } @@ -384,11 +385,7 @@ export class NodesNetworksChartComponent implements OnInit { }); } - isMobile() { - return (window.innerWidth <= 767.98); - } - - onSaveChart() { + onSaveChart(): void { // @ts-ignore const prevBottom = this.chartOptions.grid.bottom; const now = new Date(); diff --git a/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts index c6a640015..09b00e032 100644 --- a/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts +++ b/frontend/src/app/lightning/nodes-per-country-chart/nodes-per-country-chart.component.ts @@ -9,7 +9,7 @@ import { StateService } from 'src/app/services/state.service'; import { download } from 'src/app/shared/graphs.utils'; import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; -import { getFlagEmoji } from 'src/app/shared/graphs.utils'; +import { getFlagEmoji } from 'src/app/shared/common.utils'; @Component({ selector: 'app-nodes-per-country-chart', diff --git a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html index 2896b4544..16f4265a2 100644 --- a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html +++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.html @@ -36,7 +36,7 @@ {{ node.channels }}
Location - {{ node.city.en }}, {{ node.subdivision.en }} -
- - {{ node.country.en }} -   - {{ node.flag }} - -
Location - - {{ node.country.en }} {{ node.flag }} - +
- {{ node?.city?.en ?? '-' }} +
diff --git a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts index a247baadf..644e6741a 100644 --- a/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts +++ b/frontend/src/app/lightning/nodes-per-country/nodes-per-country.component.ts @@ -3,7 +3,8 @@ import { ActivatedRoute } from '@angular/router'; import { map, Observable } from 'rxjs'; import { ApiService } from 'src/app/services/api.service'; import { SeoService } from 'src/app/services/seo.service'; -import { getFlagEmoji } from 'src/app/shared/graphs.utils'; +import { getFlagEmoji } from 'src/app/shared/common.utils'; +import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component'; @Component({ selector: 'app-nodes-per-country', @@ -29,6 +30,16 @@ export class NodesPerCountry implements OnInit { name: response.country.en, flag: getFlagEmoji(this.route.snapshot.params.country) }; + + for (const i in response.nodes) { + response.nodes[i].geolocation = { + country: response.nodes[i].country?.en, + city: response.nodes[i].city?.en, + subdivision: response.nodes[i].subdivision?.en, + iso: response.nodes[i].iso_code, + }; + } + this.seoService.setTitle($localize`Lightning nodes in ${this.country.name}`); return response.nodes; }) diff --git a/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.ts b/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.ts index 116c3215c..c60620f61 100644 --- a/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.ts +++ b/frontend/src/app/lightning/nodes-per-isp-chart/nodes-per-isp-chart.component.ts @@ -154,7 +154,7 @@ export class NodesPerISPChartComponent implements OnInit { }, borderColor: '#000', formatter: () => { - return `${isp[1]} (${isp[6]}%)
` + + return `${isp[1]} (${this.sortBy === 'capacity' ? isp[7] : isp[6]}%)
` + $localize`${isp[4].toString()} nodes
` + $localize`${this.amountShortenerPipe.transform(isp[2] / 100000000, 2)} BTC` ; diff --git a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.html b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.html index b69e749e6..a8931d843 100644 --- a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.html +++ b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.html @@ -33,7 +33,7 @@ {{ node.channels }} - {{ node?.city?.en ?? '-' }} + diff --git a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.ts b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.ts index 4c7667f5d..cc57056fc 100644 --- a/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.ts +++ b/frontend/src/app/lightning/nodes-per-isp/nodes-per-isp.component.ts @@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router'; import { map, Observable } from 'rxjs'; import { ApiService } from 'src/app/services/api.service'; import { SeoService } from 'src/app/services/seo.service'; +import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component'; @Component({ selector: 'app-nodes-per-isp', @@ -29,6 +30,16 @@ export class NodesPerISP implements OnInit { id: this.route.snapshot.params.isp }; this.seoService.setTitle($localize`Lightning nodes on ISP: ${response.isp} [AS${this.route.snapshot.params.isp}]`); + + for (const i in response.nodes) { + response.nodes[i].geolocation = { + country: response.nodes[i].country?.en, + city: response.nodes[i].city?.en, + subdivision: response.nodes[i].subdivision?.en, + iso: response.nodes[i].iso_code, + }; + } + return response.nodes; }) ); diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html index 22774a16f..a4401147d 100644 --- a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.html @@ -48,4 +48,10 @@
+
+
+ Indexing in progress +
+
+ \ No newline at end of file diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss index 760e782ca..c2e00e520 100644 --- a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.scss @@ -131,4 +131,13 @@ display: block; max-width: 80px; margin: 15px auto 3px; +} + +.indexing-message { + position: absolute; + font-size: 15px; + color: grey; + font-weight: bold; + margin-left: calc(50% - 85px); + margin-top: -10px; } \ No newline at end of file diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts index d889dd254..bd210b09a 100644 --- a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts @@ -1,7 +1,7 @@ import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; import { EChartsOption, graphic } from 'echarts'; 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 { formatNumber } from '@angular/common'; 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 { LightningApiService } from '../lightning-api.service'; import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; +import { isMobile } from 'src/app/shared/common.utils'; @Component({ selector: 'app-lightning-statistics-chart', @@ -96,12 +97,13 @@ export class LightningStatisticsChartComponent implements OnInit { }), ); }), - ) + share(), + ); } - prepareChartOptions(data) { + prepareChartOptions(data): void { let title: object; - if (data.channel_count.length === 0) { + if (!this.widget && data.channel_count.length === 0) { title = { textStyle: { color: 'grey', @@ -111,7 +113,7 @@ export class LightningStatisticsChartComponent implements OnInit { left: 'center', top: 'center' }; - } else if (this.widget) { + } else if (data.channel_count.length > 0) { title = { textStyle: { color: 'grey', @@ -138,11 +140,11 @@ export class LightningStatisticsChartComponent implements OnInit { height: this.widget ? 100 : undefined, top: this.widget ? 10 : 40, bottom: this.widget ? 0 : 70, - right: (this.isMobile() && this.widget) ? 35 : this.right, - left: (this.isMobile() && this.widget) ? 40 :this.left, + right: (isMobile() && this.widget) ? 35 : this.right, + left: (isMobile() && this.widget) ? 40 :this.left, }, tooltip: { - show: !this.isMobile(), + show: !isMobile(), trigger: 'axis', axisPointer: { type: 'line' @@ -155,7 +157,7 @@ export class LightningStatisticsChartComponent implements OnInit { align: 'left', }, borderColor: '#000', - formatter: (ticks) => { + formatter: (ticks): string => { let sizeString = ''; 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' }); - let tooltip = `${date}
+ const tooltip = ` + ${date}
${sizeString}
- ${weightString}`; + ${weightString} + `; return tooltip; } }, xAxis: data.channel_count.length === 0 ? undefined : { type: 'time', - splitNumber: (this.isMobile() || this.widget) ? 5 : 10, + splitNumber: (isMobile() || this.widget) ? 5 : 10, axisLabel: { hideOverlap: true, } @@ -315,7 +319,7 @@ export class LightningStatisticsChartComponent implements OnInit { }; } - onChartInit(ec) { + onChartInit(ec): void { if (this.chartInstance !== undefined) { return; } @@ -327,11 +331,7 @@ export class LightningStatisticsChartComponent implements OnInit { }); } - isMobile() { - return (window.innerWidth <= 767.98); - } - - onSaveChart() { + onSaveChart(): void { // @ts-ignore const prevBottom = this.chartOptions.grid.bottom; const now = new Date(); diff --git a/frontend/src/app/shared/common.utils.ts b/frontend/src/app/shared/common.utils.ts index 419c1665d..d38583217 100644 --- a/frontend/src/app/shared/common.utils.ts +++ b/frontend/src/app/shared/common.utils.ts @@ -1,3 +1,120 @@ -export function isMobile() { +export function isMobile(): boolean { return (window.innerWidth <= 767.98); } + +export function getFlagEmoji(countryCode): string { + if (!countryCode) { + return ''; + } + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt()); + return String.fromCodePoint(...codePoints); +} + +// https://gist.github.com/calebgrove/c285a9510948b633aa47 +export function convertRegion(input, to: 'name' | 'abbreviated'): string { + if (!input) { + return ''; + } + + const states = [ + ['Alabama', 'AL'], + ['Alaska', 'AK'], + ['American Samoa', 'AS'], + ['Arizona', 'AZ'], + ['Arkansas', 'AR'], + ['Armed Forces Americas', 'AA'], + ['Armed Forces Europe', 'AE'], + ['Armed Forces Pacific', 'AP'], + ['California', 'CA'], + ['Colorado', 'CO'], + ['Connecticut', 'CT'], + ['Delaware', 'DE'], + ['District Of Columbia', 'DC'], + ['Florida', 'FL'], + ['Georgia', 'GA'], + ['Guam', 'GU'], + ['Hawaii', 'HI'], + ['Idaho', 'ID'], + ['Illinois', 'IL'], + ['Indiana', 'IN'], + ['Iowa', 'IA'], + ['Kansas', 'KS'], + ['Kentucky', 'KY'], + ['Louisiana', 'LA'], + ['Maine', 'ME'], + ['Marshall Islands', 'MH'], + ['Maryland', 'MD'], + ['Massachusetts', 'MA'], + ['Michigan', 'MI'], + ['Minnesota', 'MN'], + ['Mississippi', 'MS'], + ['Missouri', 'MO'], + ['Montana', 'MT'], + ['Nebraska', 'NE'], + ['Nevada', 'NV'], + ['New Hampshire', 'NH'], + ['New Jersey', 'NJ'], + ['New Mexico', 'NM'], + ['New York', 'NY'], + ['North Carolina', 'NC'], + ['North Dakota', 'ND'], + ['Northern Mariana Islands', 'NP'], + ['Ohio', 'OH'], + ['Oklahoma', 'OK'], + ['Oregon', 'OR'], + ['Pennsylvania', 'PA'], + ['Puerto Rico', 'PR'], + ['Rhode Island', 'RI'], + ['South Carolina', 'SC'], + ['South Dakota', 'SD'], + ['Tennessee', 'TN'], + ['Texas', 'TX'], + ['US Virgin Islands', 'VI'], + ['Utah', 'UT'], + ['Vermont', 'VT'], + ['Virginia', 'VA'], + ['Washington', 'WA'], + ['West Virginia', 'WV'], + ['Wisconsin', 'WI'], + ['Wyoming', 'WY'], + ]; + + // So happy that Canada and the US have distinct abbreviations + const provinces = [ + ['Alberta', 'AB'], + ['British Columbia', 'BC'], + ['Manitoba', 'MB'], + ['New Brunswick', 'NB'], + ['Newfoundland', 'NF'], + ['Northwest Territory', 'NT'], + ['Nova Scotia', 'NS'], + ['Nunavut', 'NU'], + ['Ontario', 'ON'], + ['Prince Edward Island', 'PE'], + ['Quebec', 'QC'], + ['Saskatchewan', 'SK'], + ['Yukon', 'YT'], + ]; + + const regions = states.concat(provinces); + + let i; // Reusable loop variable + if (to == 'abbreviated') { + input = input.replace(/\w\S*/g, function (txt) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); }); + for (i = 0; i < regions.length; i++) { + if (regions[i][0] == input) { + return (regions[i][1]); + } + } + } else if (to == 'name') { + input = input.toUpperCase(); + for (i = 0; i < regions.length; i++) { + if (regions[i][1] == input) { + return (regions[i][0]); + } + } + } +} diff --git a/frontend/src/app/shared/components/geolocation/geolocation.component.html b/frontend/src/app/shared/components/geolocation/geolocation.component.html new file mode 100644 index 000000000..2788cd4c1 --- /dev/null +++ b/frontend/src/app/shared/components/geolocation/geolocation.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/shared/components/geolocation/geolocation.component.scss b/frontend/src/app/shared/components/geolocation/geolocation.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/shared/components/geolocation/geolocation.component.ts b/frontend/src/app/shared/components/geolocation/geolocation.component.ts new file mode 100644 index 000000000..d1c02e53a --- /dev/null +++ b/frontend/src/app/shared/components/geolocation/geolocation.component.ts @@ -0,0 +1,83 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { convertRegion, getFlagEmoji } from '../../common.utils'; + +export interface GeolocationData { + country: string; + city: string; + subdivision: string; + iso: string; +} + +@Component({ + selector: 'app-geolocation', + templateUrl: './geolocation.component.html', + styleUrls: ['./geolocation.component.scss'] +}) +export class GeolocationComponent implements OnChanges { + @Input() data: GeolocationData; + @Input() type: 'node' | 'list-isp' | 'list-country'; + + formattedLocation: string = ''; + + ngOnChanges(): void { + const city = this.data.city ? this.data.city : ''; + const subdivisionLikeCity = this.data.city === this.data.subdivision; + let subdivision = this.data.subdivision; + + if (['US', 'CA'].includes(this.data.iso) === false || (this.type === 'node' && subdivisionLikeCity)) { + this.data.subdivision = undefined; + } else if (['list-isp', 'list-country'].includes(this.type) === true) { + subdivision = convertRegion(this.data.subdivision, 'abbreviated'); + } + + if (this.type === 'list-country') { + if (this.data.city) { + this.formattedLocation += ' ' + city; + if (this.data.subdivision) { + this.formattedLocation += ', ' + subdivision; + } + } else { + this.formattedLocation += '-'; + } + } + + if (this.type === 'list-isp') { + this.formattedLocation = getFlagEmoji(this.data.iso); + if (this.data.city) { + this.formattedLocation += ' ' + city; + if (this.data.subdivision) { + this.formattedLocation += ', ' + subdivision; + } + } else { + this.formattedLocation += ' ' + this.data.country; + } + } + + if (this.type === 'node') { + const city = this.data.city ? this.data.city : ''; + + // City + this.formattedLocation = `${city}`; + + // ,Subdivision + if (this.formattedLocation.length > 0 && !subdivisionLikeCity) { + this.formattedLocation += ', '; + } + if (!subdivisionLikeCity) { + this.formattedLocation += `${subdivision}`; + } + + //
[flag] County + if (this.data?.country.length ?? 0 > 0) { + if ((this.formattedLocation?.length ?? 0 > 0) && !subdivisionLikeCity) { + this.formattedLocation += '
'; + } else if (this.data.city) { + this.formattedLocation += ', '; + } + this.formattedLocation += `${this.data.country} ${getFlagEmoji(this.data.iso)}`; + } + + return; + } + } +} diff --git a/frontend/src/app/shared/graphs.utils.ts b/frontend/src/app/shared/graphs.utils.ts index 019ca49e4..90977e6f4 100644 --- a/frontend/src/app/shared/graphs.utils.ts +++ b/frontend/src/app/shared/graphs.utils.ts @@ -91,13 +91,3 @@ export function detectWebGL() { return (gl && gl instanceof WebGLRenderingContext); } -export function getFlagEmoji(countryCode) { - if (!countryCode) { - return ''; - } - const codePoints = countryCode - .toUpperCase() - .split('') - .map(char => 127397 + char.charCodeAt()); - return String.fromCodePoint(...codePoints); -} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index bfd47e411..f9de57834 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -82,6 +82,7 @@ import { SatsComponent } from './components/sats/sats.component'; import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component'; import { TimestampComponent } from './components/timestamp/timestamp.component'; import { ToggleComponent } from './components/toggle/toggle.component'; +import { GeolocationComponent } from '../shared/components/geolocation/geolocation.component'; @NgModule({ declarations: [ @@ -158,6 +159,7 @@ import { ToggleComponent } from './components/toggle/toggle.component'; SearchResultsComponent, TimestampComponent, ToggleComponent, + GeolocationComponent, ], imports: [ CommonModule, @@ -261,6 +263,7 @@ import { ToggleComponent } from './components/toggle/toggle.component'; SearchResultsComponent, TimestampComponent, ToggleComponent, + GeolocationComponent, ] }) export class SharedModule {