Merge branch 'master' into nymkappa/bugfix/missing-variable-ln

This commit is contained in:
wiz 2022-08-21 22:18:09 +09:00 committed by GitHub
commit 14bf256ab8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 523 additions and 196 deletions

View File

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

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

@ -169,7 +169,7 @@ class NodesApi {
let query: string; let query: string;
if (full === false) { if (full === false) {
query = ` 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 node_stats.channels
FROM node_stats FROM node_stats
JOIN nodes ON nodes.public_key = node_stats.public_key JOIN nodes ON nodes.public_key = node_stats.public_key
@ -296,19 +296,24 @@ class NodesApi {
if (!ispList[isp1]) { if (!ispList[isp1]) {
ispList[isp1] = { ispList[isp1] = {
id: channel.isp1ID, id: channel.isp1ID.toString(),
capacity: 0, capacity: 0,
channels: 0, channels: 0,
nodes: {}, nodes: {},
}; };
} else if (ispList[isp1].id.indexOf(channel.isp1ID) === -1) {
ispList[isp1].id += ',' + channel.isp1ID.toString();
} }
if (!ispList[isp2]) { if (!ispList[isp2]) {
ispList[isp2] = { ispList[isp2] = {
id: channel.isp2ID, id: channel.isp2ID.toString(),
capacity: 0, capacity: 0,
channels: 0, channels: 0,
nodes: {}, nodes: {},
}; };
} else if (ispList[isp2].id.indexOf(channel.isp2ID) === -1) {
ispList[isp2].id += ',' + channel.isp2ID.toString();
} }
ispList[isp1].capacity += channel.capacity; ispList[isp1].capacity += channel.capacity;
@ -387,7 +392,8 @@ class NodesApi {
const query = ` 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, 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, 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_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 FROM node_stats
JOIN ( JOIN (
SELECT public_key, MAX(added) as last_added SELECT public_key, MAX(added) as last_added
@ -395,15 +401,19 @@ class NodesApi {
GROUP BY public_key GROUP BY public_key
) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added ) 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 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_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 = ? WHERE geo_names_country.id = ?
ORDER BY capacity DESC ORDER BY capacity DESC
`; `;
const [rows]: any = await DB.query(query, [countryId]); const [rows]: any = await DB.query(query, [countryId]);
for (let i = 0; i < rows.length; ++i) { 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].city = JSON.parse(rows[i].city);
rows[i].subdivision = JSON.parse(rows[i].subdivision);
} }
return rows; return rows;
} catch (e) { } catch (e) {
@ -417,7 +427,8 @@ class NodesApi {
const query = ` 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, 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, 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 FROM node_stats
JOIN ( JOIN (
SELECT public_key, MAX(added) as last_added SELECT public_key, MAX(added) as last_added
@ -425,8 +436,10 @@ class NodesApi {
GROUP BY public_key GROUP BY public_key
) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added ) 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 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_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 (?) WHERE nodes.as_number IN (?)
ORDER BY capacity DESC ORDER BY capacity DESC
`; `;
@ -435,6 +448,7 @@ class NodesApi {
for (let i = 0; i < rows.length; ++i) { for (let i = 0; i < rows.length; ++i) {
rows[i].country = JSON.parse(rows[i].country); rows[i].country = JSON.parse(rows[i].country);
rows[i].city = JSON.parse(rows[i].city); rows[i].city = JSON.parse(rows[i].city);
rows[i].subdivision = JSON.parse(rows[i].subdivision);
} }
return rows; return rows;
} catch (e) { } catch (e) {

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

@ -95,11 +95,19 @@ class NetworkSyncService {
*/ */
private async $updateChannelsList(channels: ILightningApi.Channel[]): Promise<void> { private async $updateChannelsList(channels: ILightningApi.Channel[]): Promise<void> {
try { try {
const [closedChannelsRaw]: any[] = await DB.query(`SELECT id FROM channels WHERE status = 2`);
const closedChannels = {};
for (const closedChannel of closedChannelsRaw) {
closedChannels[Common.channelShortIdToIntegerId(closedChannel.id)] = true;
}
let progress = 0; let progress = 0;
const graphChannelsIds: string[] = []; const graphChannelsIds: string[] = [];
for (const channel of channels) { for (const channel of channels) {
if (!closedChannels[channel.channel_id]) {
await channelsApi.$saveChannel(channel); await channelsApi.$saveChannel(channel);
}
graphChannelsIds.push(channel.channel_id); graphChannelsIds.push(channel.channel_id);
++progress; ++progress;
@ -232,8 +240,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

@ -5,6 +5,8 @@ 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;
@ -22,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;
@ -66,18 +69,46 @@ class LightningStatsImporter {
const baseFees: number[] = []; const baseFees: number[] = [];
const alreadyCountedChannels = {}; const alreadyCountedChannels = {};
for (const channel of networkGraph.edges) { const [channelsInDbRaw]: any[] = await DB.query(`SELECT short_id, created FROM channels`);
let short_id = channel.channel_id; const channelsInDb = {};
if (short_id.indexOf('/') !== -1) { for (const channel of channelsInDbRaw) {
short_id = short_id.slice(0, -2); channelsInDb[channel.short_id] = channel;
} }
for (const channel of networkGraph.edges) {
const short_id = Common.channelIntegerIdToShortId(channel.channel_id);
const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id); const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id);
if (!tx) { if (!tx) {
logger.err(`Unable to fetch funding tx for channel ${short_id}. Capacity and creation date is unknown. Skipping channel.`); logger.err(`Unable to fetch funding tx for channel ${short_id}. Capacity and creation date is unknown. Skipping channel.`);
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,
@ -102,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);
@ -113,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,
@ -319,7 +362,7 @@ class LightningStatsImporter {
logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`); 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))); await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
const stat = await this.computeNetworkStats(timestamp, graph); const stat = await this.computeNetworkStats(timestamp, graph, true);
existingStatsTimestamps[timestamp] = stat; existingStatsTimestamps[timestamp] = stat;
} }

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,8 +128,10 @@ 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);
if (!this.channels) {
this.refreshChannels$.next(txIds); this.refreshChannels$.next(txIds);
} }
}
onScroll() { onScroll() {
const scrollHeight = document.body.scrollHeight; const scrollHeight = document.body.scrollHeight;

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>
@ -85,6 +59,21 @@
</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>
<br> <br>

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,12 +34,22 @@ 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) => { 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 || if (!data.node_left.longitude || !data.node_left.latitude ||
!data.node_right.longitude || !data.node_right.latitude) { !data.node_right.longitude || !data.node_right.latitude) {
this.channelGeo = []; return [];
} else { } else {
this.channelGeo = [ return [
data.node_left.public_key, data.node_left.public_key,
data.node_left.alias, data.node_left.alias,
data.node_left.longitude, data.node_left.latitude, data.node_left.longitude, data.node_left.latitude,
@ -45,12 +59,15 @@ export class ChannelComponent implements OnInit {
]; ];
} }
}), }),
catchError((err) => {
this.error = err;
return of(null);
})
); );
})
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

@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators'; import { catchError, map, switchMap } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service'; import { SeoService } from 'src/app/services/seo.service';
import { OpenGraphService } from 'src/app/services/opengraph.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 { LightningApiService } from '../lightning-api.service';
import { isMobile } from '../../shared/common.utils'; import { isMobile } from '../../shared/common.utils';

View File

@ -42,24 +42,10 @@
<app-fiat [value]="node.avgCapacity" digitsInfo="1.0-0"></app-fiat> <app-fiat [value]="node.avgCapacity" digitsInfo="1.0-0"></app-fiat>
</td> </td>
</tr> </tr>
<tr *ngIf="node.country && node.city && node.subdivision"> <tr *ngIf="node.geolocation">
<td i18n="location">Location</td> <td i18n="location">Location</td>
<td> <td>
<span>{{ node.city.en }}, {{ node.subdivision.en }}</span> <app-geolocation [data]="node.geolocation" [type]="'node'"></app-geolocation>
<br>
<a class="d-flex align-items-center" [routerLink]="['/lightning/nodes/country' | relativeUrl, node.iso_code]">
<span class="link">{{ node.country.en }}</span>
&nbsp;
<span class="flag">{{ node.flag }}</span>
</a>
</td>
</tr>
<tr *ngIf="node.country && !node.city">
<td i18n="location">Location</td>
<td>
<a [routerLink]="['/lightning/nodes/country' | relativeUrl, node.iso_code]">
{{ node.country.en }} {{ node.flag }}
</a>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -3,9 +3,9 @@ import { ActivatedRoute, ParamMap } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators'; import { catchError, map, switchMap } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service'; import { SeoService } from 'src/app/services/seo.service';
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
import { LightningApiService } from '../lightning-api.service'; import { LightningApiService } from '../lightning-api.service';
import { isMobile } from '../../shared/common.utils'; import { isMobile } from '../../shared/common.utils';
import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component';
@Component({ @Component({
selector: 'app-node', selector: 'app-node',
@ -58,7 +58,6 @@ export class NodeComponent implements OnInit {
} else if (socket.indexOf('onion') > -1) { } else if (socket.indexOf('onion') > -1) {
label = 'Tor'; label = 'Tor';
} }
node.flag = getFlagEmoji(node.iso_code);
socketsObject.push({ socketsObject.push({
label: label, label: label,
socket: node.public_key + '@' + socket, socket: node.public_key + '@' + socket,
@ -66,6 +65,19 @@ export class NodeComponent implements OnInit {
} }
node.socketsObject = socketsObject; node.socketsObject = socketsObject;
node.avgCapacity = node.capacity / Math.max(1, node.active_channel_count); 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 = <GeolocationData>{
country: node.country?.en,
city: node.city?.en,
subdivision: node.subdivision?.en,
iso: node.iso_code,
};
}
return node; return node;
}), }),
catchError(err => { catchError(err => {

View File

@ -1,5 +1,5 @@
<div [class]="'full-container ' + style + (fitContainer ? ' fit-container' : '')"> <div *ngIf="channelsObservable | async">
<div *ngIf="chartOptions" [class]="'full-container ' + style + (fitContainer ? ' fit-container' : '')">
<div *ngIf="style === 'graph'" class="card-header"> <div *ngIf="style === 'graph'" class="card-header">
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px"> <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
<span i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</span> <span i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</span>
@ -7,8 +7,10 @@
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small> <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
</div> </div>
<div *ngIf="observable$ | async" class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions" <div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"
(chartInit)="onChartInit($event)" (chartFinished)="onChartFinished($event)"> (chartFinished)="onChartFinished($event)">
</div>
</div> </div>
<div *ngIf="!chartOptions && style === 'nodepage'" style="padding-top: 30px"></div>
</div> </div>

View File

@ -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 { SeoService } from 'src/app/services/seo.service';
import { ApiService } from 'src/app/services/api.service'; import { ApiService } from 'src/app/services/api.service';
import { Observable, switchMap, tap, zip } from 'rxjs'; 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'], styleUrls: ['./nodes-channels-map.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class NodesChannelsMap implements OnInit, OnDestroy { export class NodesChannelsMap implements OnInit {
@Input() style: 'graph' | 'nodepage' | 'widget' | 'channelpage' = 'graph'; @Input() style: 'graph' | 'nodepage' | 'widget' | 'channelpage' = 'graph';
@Input() publicKey: string | undefined; @Input() publicKey: string | undefined;
@Input() channel: any[] = []; @Input() channel: any[] = [];
@Input() fitContainer = false; @Input() fitContainer = false;
@Output() readyEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter();
observable$: Observable<any>; channelsObservable: Observable<any>;
center: number[] | undefined; center: number[] | undefined;
zoom: number | undefined; zoom: number | undefined;
@ -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 = {};
@ -49,8 +50,6 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
) { ) {
} }
ngOnDestroy(): void {}
ngOnInit(): void { ngOnInit(): void {
this.center = this.style === 'widget' ? [0, 40] : [0, 5]; this.center = this.style === 'widget' ? [0, 40] : [0, 5];
this.zoom = 1.3; this.zoom = 1.3;
@ -66,7 +65,11 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
this.seoService.setTitle($localize`Lightning nodes channels world map`); this.seoService.setTitle($localize`Lightning nodes channels world map`);
} }
this.observable$ = this.activatedRoute.paramMap if (['nodepage', 'channelpage'].includes(this.style)) {
this.nodeSize = 8;
}
this.channelsObservable = this.activatedRoute.paramMap
.pipe( .pipe(
switchMap((params: ParamMap) => { switchMap((params: ParamMap) => {
return zip( return zip(
@ -170,15 +173,8 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
prepareChartOptions(nodes, channels) { prepareChartOptions(nodes, channels) {
let title: object; let title: object;
if (channels.length === 0) { if (channels.length === 0) {
title = { this.chartOptions = null;
textStyle: { return;
color: 'grey',
fontSize: 15
},
text: $localize`No geolocation data available`,
left: 'center',
top: 'center'
};
} }
this.chartOptions = { this.chartOptions = {
@ -214,7 +210,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

@ -9,7 +9,7 @@ import { StateService } from 'src/app/services/state.service';
import { download } from 'src/app/shared/graphs.utils'; import { download } from 'src/app/shared/graphs.utils';
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.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({ @Component({
selector: 'app-nodes-per-country-chart', selector: 'app-nodes-per-country-chart',

View File

@ -36,7 +36,7 @@
{{ node.channels }} {{ node.channels }}
</td> </td>
<td class="city text-right text-truncate"> <td class="city text-right text-truncate">
{{ node?.city?.en ?? '-' }} <app-geolocation [data]="node.geolocation" [type]="'list-country'"></app-geolocation>
</td> </td>
</tbody> </tbody>
</table> </table>

View File

@ -3,7 +3,8 @@ import { ActivatedRoute } from '@angular/router';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { ApiService } from 'src/app/services/api.service'; import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.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({ @Component({
selector: 'app-nodes-per-country', selector: 'app-nodes-per-country',
@ -29,6 +30,16 @@ export class NodesPerCountry implements OnInit {
name: response.country.en, name: response.country.en,
flag: getFlagEmoji(this.route.snapshot.params.country) flag: getFlagEmoji(this.route.snapshot.params.country)
}; };
for (const i in response.nodes) {
response.nodes[i].geolocation = <GeolocationData>{
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}`); this.seoService.setTitle($localize`Lightning nodes in ${this.country.name}`);
return response.nodes; return response.nodes;
}) })

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

@ -33,7 +33,7 @@
{{ node.channels }} {{ node.channels }}
</td> </td>
<td class="city text-right text-truncate"> <td class="city text-right text-truncate">
{{ node?.city?.en ?? '-' }} <app-geolocation [data]="node.geolocation" [type]="'list-isp'"></app-geolocation>
</td> </td>
</tbody> </tbody>
</table> </table>

View File

@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { ApiService } from 'src/app/services/api.service'; import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.service'; import { SeoService } from 'src/app/services/seo.service';
import { GeolocationData } from 'src/app/shared/components/geolocation/geolocation.component';
@Component({ @Component({
selector: 'app-nodes-per-isp', selector: 'app-nodes-per-isp',
@ -29,6 +30,16 @@ export class NodesPerISP implements OnInit {
id: this.route.snapshot.params.isp id: this.route.snapshot.params.isp
}; };
this.seoService.setTitle($localize`Lightning nodes on ISP: ${response.isp} [AS${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 = <GeolocationData>{
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; return response.nodes;
}) })
); );

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

@ -132,3 +132,12 @@
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();

View File

@ -1,3 +1,120 @@
export function isMobile() { export function isMobile(): boolean {
return (window.innerWidth <= 767.98); 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]);
}
}
}
}

View File

@ -0,0 +1 @@
<span [innerHTML]="formattedLocation"></span>

View File

@ -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}`;
}
// <br>[flag] County
if (this.data?.country.length ?? 0 > 0) {
if ((this.formattedLocation?.length ?? 0 > 0) && !subdivisionLikeCity) {
this.formattedLocation += '<br>';
} else if (this.data.city) {
this.formattedLocation += ', ';
}
this.formattedLocation += `${this.data.country} ${getFlagEmoji(this.data.iso)}`;
}
return;
}
}
}

View File

@ -91,13 +91,3 @@ export function detectWebGL() {
return (gl && gl instanceof WebGLRenderingContext); 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);
}

View File

@ -82,6 +82,7 @@ import { SatsComponent } from './components/sats/sats.component';
import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component'; import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component';
import { TimestampComponent } from './components/timestamp/timestamp.component'; import { TimestampComponent } from './components/timestamp/timestamp.component';
import { ToggleComponent } from './components/toggle/toggle.component'; import { ToggleComponent } from './components/toggle/toggle.component';
import { GeolocationComponent } from '../shared/components/geolocation/geolocation.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -158,6 +159,7 @@ import { ToggleComponent } from './components/toggle/toggle.component';
SearchResultsComponent, SearchResultsComponent,
TimestampComponent, TimestampComponent,
ToggleComponent, ToggleComponent,
GeolocationComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -261,6 +263,7 @@ import { ToggleComponent } from './components/toggle/toggle.component';
SearchResultsComponent, SearchResultsComponent,
TimestampComponent, TimestampComponent,
ToggleComponent, ToggleComponent,
GeolocationComponent,
] ]
}) })
export class SharedModule { export class SharedModule {