Merge branch 'master' into fix/difficulty-api

This commit is contained in:
wiz 2022-08-24 20:01:52 +09:00 committed by GitHub
commit 00bd61e1d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1125 additions and 472 deletions

View File

@ -77,13 +77,19 @@
},
"LIGHTNING": {
"ENABLED": false,
"BACKEND": "lnd"
"BACKEND": "lnd",
"STATS_REFRESH_INTERVAL": 600,
"GRAPH_REFRESH_INTERVAL": 600,
"LOGGER_UPDATE_INTERVAL": 30
},
"LND": {
"TLS_CERT_PATH": "tls.cert",
"MACAROON_PATH": "readonly.macaroon",
"REST_API_URL": "https://localhost:8080"
},
"CLIGHTNING": {
"SOCKET": "lightning-rpc"
},
"SOCKS5PROXY": {
"ENABLED": false,
"USE_ONION": true,

View File

@ -88,5 +88,21 @@
"LIQUID_ONION": "__EXTERNAL_DATA_SERVER_LIQUID_ONION__",
"BISQ_URL": "__EXTERNAL_DATA_SERVER_BISQ_URL__",
"BISQ_ONION": "__EXTERNAL_DATA_SERVER_BISQ_ONION__"
},
"LIGHTNING": {
"ENABLED": "__LIGHTNING_ENABLED__",
"BACKEND": "__LIGHTNING_BACKEND__",
"TOPOLOGY_FOLDER": "__LIGHTNING_TOPOLOGY_FOLDER__",
"STATS_REFRESH_INTERVAL": 600,
"GRAPH_REFRESH_INTERVAL": 600,
"LOGGER_UPDATE_INTERVAL": 30
},
"LND": {
"TLS_CERT_PATH": "",
"MACAROON_PATH": "",
"REST_API_URL": "https://localhost:8080"
},
"CLIGHTNING": {
"SOCKET": "__CLIGHTNING_SOCKET__"
}
}

View File

@ -4,7 +4,7 @@ import logger from '../logger';
import { Common } from './common';
class DatabaseMigration {
private static currentVersion = 38;
private static currentVersion = 39;
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'));
@ -338,6 +337,11 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL');
await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL');
}
if (databaseSchemaVersion < 39 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `nodes` ADD alias_search TEXT NULL DEFAULT NULL AFTER `alias`');
await this.$executeQuery('ALTER TABLE nodes ADD FULLTEXT(alias_search)');
}
}
/**

View File

@ -17,32 +17,60 @@ class ChannelsApi {
}
}
public async $getAllChannelsGeo(publicKey?: string): Promise<any[]> {
public async $getAllChannelsGeo(publicKey?: string, style?: string): Promise<any[]> {
try {
let select: string;
if (style === 'widget') {
select = `
nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude
`;
} else {
select = `
nodes_1.public_key as node1_public_key, nodes_1.alias AS node1_alias,
nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
nodes_2.public_key as node2_public_key, nodes_2.alias AS node2_alias,
nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude
`;
}
const params: string[] = [];
let query = `SELECT nodes_1.public_key as node1_public_key, nodes_1.alias AS node1_alias,
nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
nodes_2.public_key as node2_public_key, nodes_2.alias AS node2_alias,
nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude,
channels.capacity
FROM channels
JOIN nodes AS nodes_1 on nodes_1.public_key = channels.node1_public_key
JOIN nodes AS nodes_2 on nodes_2.public_key = channels.node2_public_key
WHERE nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL
AND nodes_2.latitude IS NOT NULL AND nodes_2.longitude IS NOT NULL
let query = `SELECT ${select}
FROM channels
JOIN nodes AS nodes_1 on nodes_1.public_key = channels.node1_public_key
JOIN nodes AS nodes_2 on nodes_2.public_key = channels.node2_public_key
WHERE nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL
AND nodes_2.latitude IS NOT NULL AND nodes_2.longitude IS NOT NULL
`;
if (publicKey !== undefined) {
query += ' AND (nodes_1.public_key = ? OR nodes_2.public_key = ?)';
params.push(publicKey);
params.push(publicKey);
} else {
query += ` AND channels.capacity > 1000000
GROUP BY nodes_1.public_key, nodes_2.public_key
ORDER BY channels.capacity DESC
LIMIT 10000
`;
}
const [rows]: any = await DB.query(query, params);
return rows.map((row) => [
row.node1_public_key, row.node1_alias, row.node1_longitude, row.node1_latitude,
row.node2_public_key, row.node2_alias, row.node2_longitude, row.node2_latitude,
row.capacity]);
return rows.map((row) => {
if (style === 'widget') {
return [
row.node1_longitude, row.node1_latitude,
row.node2_longitude, row.node2_latitude,
];
} else {
return [
row.node1_public_key, row.node1_alias,
row.node1_longitude, row.node1_latitude,
row.node2_public_key, row.node2_alias,
row.node2_longitude, row.node2_latitude,
];
}
});
} catch (e) {
logger.err('$getAllChannelsGeo error: ' + (e instanceof Error ? e.message : e));
throw e;
@ -61,9 +89,14 @@ class ChannelsApi {
}
}
public async $getChannelsByStatus(status: number): Promise<any[]> {
public async $getChannelsByStatus(status: number | number[]): Promise<any[]> {
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) {
@ -212,41 +245,53 @@ class ChannelsApi {
let channelStatusFilter;
if (status === 'open') {
channelStatusFilter = '< 2';
} else if (status === 'active') {
channelStatusFilter = '= 1';
} else if (status === 'closed') {
channelStatusFilter = '= 2';
} else {
throw new Error('getChannelsForNode: Invalid status requested');
}
// Channels originating from node
let query = `
SELECT node2.alias, node2.public_key, channels.status, channels.node1_fee_rate,
channels.capacity, channels.short_id, channels.id
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.closing_reason
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,
channels.capacity, channels.short_id, channels.id
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.closing_reason
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) => {
return b.capacity - a.capacity;
});
allChannels = allChannels.slice(index, index + length);
if (index >= 0) {
allChannels = allChannels.slice(index, index + length);
} else if (index === -1) { // Node channels tree chart
allChannels = allChannels.slice(0, 1000);
}
const channels: any[] = []
for (const row of allChannels) {
const activeChannelsStats: any = await nodesApi.$getActiveChannelsStats(row.public_key);
channels.push({
status: row.status,
closing_reason: row.closing_reason,
capacity: row.capacity ?? 0,
short_id: row.short_id,
id: row.id,
@ -337,7 +382,7 @@ class ChannelsApi {
/**
* 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 policy1: Partial<ILightningApi.RoutingPolicy> = channel.node1_policy || {};
@ -369,11 +414,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 = ?,

View File

@ -102,7 +102,11 @@ class ChannelsRoutes {
private async $getAllChannelsGeo(req: Request, res: Response) {
try {
const channels = await channelsApi.$getAllChannelsGeo(req.params?.publicKey);
const style: string = typeof req.query.style === 'string' ? req.query.style : '';
const channels = await channelsApi.$getAllChannelsGeo(req.params?.publicKey, style);
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(channels);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);

View File

@ -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
@ -259,9 +259,10 @@ class NodesApi {
public async $searchNodeByPublicKeyOrAlias(search: string) {
try {
const searchStripped = search.replace('%', '') + '%';
const query = `SELECT nodes.public_key, nodes.alias, node_stats.capacity FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key WHERE nodes.public_key LIKE ? OR nodes.alias LIKE ? GROUP BY nodes.public_key ORDER BY node_stats.capacity DESC LIMIT 10`;
const [rows]: any = await DB.query(query, [searchStripped, searchStripped]);
const publicKeySearch = search.replace('%', '') + '%';
const aliasSearch = search.replace(/[-_.]/g, ' ').replace(/[^a-zA-Z ]/g, '').split(' ').map((search) => '+' + search + '*').join(' ');
const query = `SELECT nodes.public_key, nodes.alias, node_stats.capacity FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key WHERE nodes.public_key LIKE ? OR MATCH nodes.alias_search AGAINST (? IN BOOLEAN MODE) GROUP BY nodes.public_key ORDER BY node_stats.capacity DESC LIMIT 10`;
const [rows]: any = await DB.query(query, [publicKeySearch, aliasSearch]);
return rows;
} catch (e) {
logger.err('$searchNodeByPublicKeyOrAlias error: ' + (e instanceof Error ? e.message : e));
@ -296,19 +297,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 +391,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 +402,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 +428,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 +437,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 +449,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) {
@ -487,21 +502,24 @@ class NodesApi {
first_seen,
updated_at,
alias,
alias_search,
color,
sockets,
status
)
VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME(?), alias = ?, color = ?, sockets = ?, status = 1`;
VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME(?), alias = ?, alias_search = ?, color = ?, sockets = ?, status = 1`;
await DB.query(query, [
node.pub_key,
node.last_update,
node.alias,
this.aliasToSearchText(node.alias),
node.color,
sockets,
node.last_update,
node.alias,
this.aliasToSearchText(node.alias),
node.color,
sockets,
]);
@ -535,6 +553,10 @@ class NodesApi {
logger.err('$setNodesInactive() error: ' + (e instanceof Error ? e.message : e));
}
}
private aliasToSearchText(str: string): string {
return str.replace(/[-_.]/g, ' ').replace(/[^a-zA-Z ]/g, '');
}
}
export default new NodesApi();

View File

@ -15,8 +15,8 @@ class NodesRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings', this.$getNodesRanking)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/capacity', this.$getTopNodesByCapacity)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/channels', this.$getTopNodesByChannels)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/liquidity', this.$getTopNodesByCapacity)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/connectivity', this.$getTopNodesByChannels)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/age', this.$getOldestNodes)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)

View File

@ -36,6 +36,7 @@ interface IConfig {
TOPOLOGY_FOLDER: string;
STATS_REFRESH_INTERVAL: number;
GRAPH_REFRESH_INTERVAL: number;
LOGGER_UPDATE_INTERVAL: number;
};
LND: {
TLS_CERT_PATH: string;
@ -191,6 +192,7 @@ const defaults: IConfig = {
'TOPOLOGY_FOLDER': '',
'STATS_REFRESH_INTERVAL': 600,
'GRAPH_REFRESH_INTERVAL': 600,
'LOGGER_UPDATE_INTERVAL': 30,
},
'LND': {
'TLS_CERT_PATH': '',

View File

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

View File

@ -83,7 +83,7 @@ class NetworkSyncService {
logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted`);
// If a channel if not present in the graph, mark it as inactive
nodesApi.$setNodesInactive(graphNodesPubkeys);
await nodesApi.$setNodesInactive(graphNodesPubkeys);
if (config.MAXMIND.ENABLED) {
$lookupNodeLocation();
@ -95,11 +95,19 @@ class NetworkSyncService {
*/
private async $updateChannelsList(channels: ILightningApi.Channel[]): Promise<void> {
try {
const [closedChannelsRaw]: any[] = await DB.query(`SELECT id FROM channels WHERE status = 2`);
const closedChannels = {};
for (const closedChannel of closedChannelsRaw) {
closedChannels[closedChannel.id] = true;
}
let progress = 0;
const graphChannelsIds: string[] = [];
for (const channel of channels) {
await channelsApi.$saveChannel(channel);
if (!closedChannels[channel.channel_id]) {
await channelsApi.$saveChannel(channel);
}
graphChannelsIds.push(channel.channel_id);
++progress;
@ -113,7 +121,7 @@ class NetworkSyncService {
logger.info(`${progress} channels updated`);
// If a channel if not present in the graph, mark it as inactive
channelsApi.$setChannelsInactive(graphChannelsIds);
await channelsApi.$setChannelsInactive(graphChannelsIds);
} catch (e) {
logger.err(`Cannot update channel list. Reason: ${(e instanceof Error ? e.message : e)}`);
}
@ -232,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) {
@ -277,44 +285,66 @@ class NetworkSyncService {
for (const channel of channels) {
let reason = 0;
// Only Esplora backend can retrieve spent transaction outputs
const outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id);
const lightningScriptReasons: number[] = [];
for (const outspend of outspends) {
if (outspend.spent && outspend.txid) {
const spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
lightningScriptReasons.push(lightningScript);
try {
let outspends: IEsploraApi.Outspend[] | undefined;
try {
outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id);
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
}
if (lightningScriptReasons.length === outspends.length
&& lightningScriptReasons.filter((r) => r === 1).length === outspends.length) {
reason = 1;
} else {
const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
if (filteredReasons.length) {
if (filteredReasons.some((r) => r === 2 || r === 4)) {
reason = 3;
} else {
reason = 2;
const lightningScriptReasons: number[] = [];
for (const outspend of outspends) {
if (outspend.spent && outspend.txid) {
let spendingTx: IEsploraApi.Transaction | undefined;
try {
spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
lightningScriptReasons.push(lightningScript);
}
}
if (lightningScriptReasons.length === outspends.length
&& lightningScriptReasons.filter((r) => r === 1).length === outspends.length) {
reason = 1;
} else {
/*
We can detect a commitment transaction (force close) by reading Sequence and Locktime
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
*/
const closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
const locktimeHex: string = closingTx.locktime.toString(16);
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
reason = 2; // Here we can't be sure if it's a penalty or not
const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
if (filteredReasons.length) {
if (filteredReasons.some((r) => r === 2 || r === 4)) {
reason = 3;
} else {
reason = 2;
}
} else {
reason = 1;
/*
We can detect a commitment transaction (force close) by reading Sequence and Locktime
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
*/
let closingTx: IEsploraApi.Transaction | undefined;
try {
closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`);
continue;
}
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
const locktimeHex: string = closingTx.locktime.toString(16);
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
reason = 2; // Here we can't be sure if it's a penalty or not
} else {
reason = 1;
}
}
}
}
if (reason) {
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
if (reason) {
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
}
} catch (e) {
logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
}
++progress;

View File

@ -5,6 +5,8 @@ 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;
@ -22,7 +24,8 @@ class LightningStatsImporter {
/**
* 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
let clearnetNodes = 0;
let torNodes = 0;
@ -66,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) {
@ -78,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,
@ -102,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);
@ -113,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,
@ -319,7 +362,7 @@ class LightningStatsImporter {
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);
const stat = await this.computeNetworkStats(timestamp, graph, true);
existingStatsTimestamps[timestamp] = stat;
}

View File

@ -350,3 +350,68 @@ Corresponding `docker-compose.yml` overrides:
PRICE_DATA_SERVER_CLEARNET_URL: ""
...
```
<br/>
`mempool-config.json`:
```
"LIGHTNING": {
"ENABLED": false
"BACKEND": "lnd"
"TOPOLOGY_FOLDER": ""
"STATS_REFRESH_INTERVAL": 600
"GRAPH_REFRESH_INTERVAL": 600
"LOGGER_UPDATE_INTERVAL": 30
}
```
Corresponding `docker-compose.yml` overrides:
```
api:
environment:
LIGHTNING_ENABLED: false
LIGHTNING_BACKEND: "lnd"
LIGHTNING_TOPOLOGY_FOLDER: ""
LIGHTNING_STATS_REFRESH_INTERVAL: 600
LIGHTNING_GRAPH_REFRESH_INTERVAL: 600
LIGHTNING_LOGGER_UPDATE_INTERVAL: 30
...
```
<br/>
`mempool-config.json`:
```
"LND": {
"TLS_CERT_PATH": ""
"MACAROON_PATH": ""
"REST_API_URL": "https://localhost:8080"
}
```
Corresponding `docker-compose.yml` overrides:
```
api:
environment:
LND_TLS_CERT_PATH: ""
LND_MACAROON_PATH: ""
LND_REST_API_URL: "https://localhost:8080"
...
```
<br/>
`mempool-config.json`:
```
"CLN": {
"SOCKET": ""
}
```
Corresponding `docker-compose.yml` overrides:
```
api:
environment:
CLN_SOCKET: ""
...
```

View File

@ -91,6 +91,22 @@ __EXTERNAL_DATA_SERVER_LIQUID_ONION__=${EXTERNAL_DATA_SERVER_LIQUID_ONION:=http:
__EXTERNAL_DATA_SERVER_BISQ_URL__=${EXTERNAL_DATA_SERVER_BISQ_URL:=https://bisq.markets/api}
__EXTERNAL_DATA_SERVER_BISQ_ONION__=${EXTERNAL_DATA_SERVER_BISQ_ONION:=http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api}
# LIGHTNING
__LIGHTNING_ENABLED__=${LIGHTNING_ENABLED:=false}
__LIGHTNING_BACKEND__=${LIGHTNING_BACKEND:="lnd"}
__LIGHTNING_TOPOLOGY_FOLDER__=${LIGHTNING_TOPOLOGY_FOLDER:=""}
__LIGHTNING_STATS_REFRESH_INTERVAL__=${LIGHTNING_STATS_REFRESH_INTERVAL:=600}
__LIGHTNING_GRAPH_REFRESH_INTERVAL__=${LIGHTNING_GRAPH_REFRESH_INTERVAL:=600}
__LIGHTNING_LOGGER_UPDATE_INTERVAL__=${LIGHTNING_LOGGER_UPDATE_INTERVAL:=30}
# LND
__LND_TLS_CERT_PATH__=${LND_TLS_CERT_PATH:=""}
__LND_MACAROON_PATH__=${LND_MACAROON_PATH:=""}
__LND_REST_API_URL__=${LND_REST_API_URL:="https://localhost:8080"}
# CLN
__CLN_SOCKET__=${CLN_SOCKET:=""}
mkdir -p "${__MEMPOOL_CACHE_DIR__}"
sed -i "s/__MEMPOOL_NETWORK__/${__MEMPOOL_NETWORK__}/g" mempool-config.json
@ -173,4 +189,20 @@ sed -i "s!__EXTERNAL_DATA_SERVER_LIQUID_ONION__!${__EXTERNAL_DATA_SERVER_LIQUID_
sed -i "s!__EXTERNAL_DATA_SERVER_BISQ_URL__!${__EXTERNAL_DATA_SERVER_BISQ_URL__}!g" mempool-config.json
sed -i "s!__EXTERNAL_DATA_SERVER_BISQ_ONION__!${__EXTERNAL_DATA_SERVER_BISQ_ONION__}!g" mempool-config.json
# LIGHTNING
sed -i "s!__LIGHTNING_ENABLED__!${__LIGHTNING_ENABLED__}!g" mempool-config.json
sed -i "s!__LIGHTNING_BACKEND__!${__LIGHTNING_BACKEND__}!g" mempool-config.json
sed -i "s!__LIGHTNING_TOPOLOGY_FOLDER__!${__LIGHTNING_TOPOLOGY_FOLDER__}!g" mempool-config.json
sed -i "s!__LIGHTNING_STATS_REFRESH_INTERVAL__!${__LIGHTNING_STATS_REFRESH_INTERVAL__}!g" mempool-config.json
sed -i "s!__LIGHTNING_GRAPH_REFRESH_INTERVAL__!${__LIGHTNING_GRAPH_REFRESH_INTERVAL__}!g" mempool-config.json
sed -i "s!__LIGHTNING_LOGGER_UPDATE_INTERVAL__!${__LIGHTNING_LOGGER_UPDATE_INTERVAL__}!g" mempool-config.json
# LND
sed -i "s!__LND_TLS_CERT_PATH__!${__LND_TLS_CERT_PATH__}!g" mempool-config.json
sed -i "s!__LND_MACAROON_PATH__!${__LND_MACAROON_PATH__}!g" mempool-config.json
sed -i "s!__LND_REST_API_URL__!${__LND_REST_API_URL__}!g" mempool-config.json
# CLN
sed -i "s!__CLN_SOCKET__!${__CLN_SOCKET__}!g" mempool-config.json
node /backend/dist/index.js

View File

@ -41,7 +41,9 @@
<a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="mining.mining-dashboard" title="Mining Dashboard"></fa-icon></a>
</li>
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.LIGHTNING">
<a class="nav-link" [routerLink]="['/lightning' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'bolt']" [fixedWidth]="true" i18n-title="master-page.lightning" title="Lightning Explorer"></fa-icon></a>
<a class="nav-link" [routerLink]="['/lightning' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'bolt']" [fixedWidth]="true" i18n-title="master-page.lightning" title="Lightning Explorer"></fa-icon>
<span class="badge badge-pill badge-warning beta" i18n="beta">beta</span>
</a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="!stateService.env.MINING_DASHBOARD">
<a class="nav-link" [routerLink]="['/blocks' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'cubes']" [fixedWidth]="true" i18n-title="master-page.blocks" title="Blocks"></fa-icon></a>

View File

@ -182,3 +182,14 @@ nav {
width: 140px;
height: 35px;
}
.beta {
display: inline;
position: absolute;
margin: 24px 0px 0px -15px;
font-size: 8px;
@media (max-width: 767.98px) {
margin: 33px 0px 0px -19px;
font-size: 7px;
}
}

View File

@ -20,7 +20,7 @@
<div class="col">
<table class="table table-borderless smaller-text table-sm table-tx-vin">
<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]="{
'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 !== ''
@ -146,9 +146,9 @@
</td>
</tr>
</ng-template>
<tr *ngIf="tx.vin.length > 12 && tx['@vinLimit']">
<tr *ngIf="tx.vin.length > rowLimit && tx['@vinLimit']">
<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>
</tr>
</tbody>
@ -158,7 +158,7 @@
<div class="col mobile-bottomcol">
<table class="table table-borderless smaller-text table-sm table-tx-vout">
<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]="{
'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 !== ''
@ -257,9 +257,9 @@
</td>
</tr>
</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">
<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>
</tr>
</tbody>

View File

@ -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<boolean>(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() {

View File

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

View File

@ -1,4 +1,5 @@
<div class="container-xl" *ngIf="(channel$ | async) as channel">
<h5 class="mb-0" style="color: #ffffff66" i18n="lightning.channel">Lightning channel</h5>
<div class="title-container">
<h1 class="mb-0">{{ channel.short_id }}</h1>
<span class="tx-link">
@ -14,7 +15,7 @@
<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">
@ -30,32 +31,6 @@
<td i18n="address.total-sent">Last update</td>
<td><app-timestamp [dateString]="channel.updated_at"></app-timestamp></td>
</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>
</table>
</div>
@ -82,8 +57,23 @@
</div>
<div class="col">
<app-channel-box [channel]="channel.node_right"></app-channel-box>
</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>

View File

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

View File

@ -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<any>;
channelGeo$: Observable<number[]>;
transactions$: Observable<any>;
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),
]);
}),
);
}
}

View File

@ -19,7 +19,11 @@
</tbody>
</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">
<div class="d-flex justify-content-center" i18n="lightning.empty-channels-list">No channels to display</div>
@ -83,7 +87,7 @@
</ng-template>
<ng-template #skeleton>
<h2 class="float-left">Channels</h2>
<h2 class="float-left" i18n="lightning.channels">Channels</h2>
<table class="table table-borderless">
<ng-container *ngTemplateOutlet="tableHeader"></ng-container>

View File

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

View File

@ -32,7 +32,7 @@ export class LightningApiService {
}
getChannelsByNodeId$(publicKey: string, index: number = 0, status = 'open'): Observable<any> {
let params = new HttpParams()
const params = new HttpParams()
.set('public_key', publicKey)
.set('index', index)
.set('status', status)
@ -66,13 +66,13 @@ export class LightningApiService {
getTopNodesByCapacity$(): Observable<ITopNodesPerCapacity[]> {
return this.httpClient.get<ITopNodesPerCapacity[]>(
this.apiBasePath + '/api/v1/lightning/nodes/rankings/capacity'
this.apiBasePath + '/api/v1/lightning/nodes/rankings/liquidity'
);
}
getTopNodesByChannels$(): Observable<ITopNodesPerChannels[]> {
return this.httpClient.get<ITopNodesPerChannels[]>(
this.apiBasePath + '/api/v1/lightning/nodes/rankings/channels'
this.apiBasePath + '/api/v1/lightning/nodes/rankings/connectivity'
);
}

View File

@ -37,7 +37,7 @@
<div class="card graph-card">
<div class="card-body pl-2 pr-2">
<app-nodes-per-isp-chart [widget]="true"></app-nodes-per-isp-chart>
<div class="mt-2"><a [attr.data-cy]="'pool-distribution-view-more'" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
<div style="margin-top: 5px"><a [attr.data-cy]="'pool-distribution-view-more'" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
</div>
</div>
</div>
@ -49,17 +49,17 @@
<h5 class="card-title mt-3" i18n="lightning.network-history">Lightning network history</h5>
<app-lightning-statistics-chart [widget]=true></app-lightning-statistics-chart>
<app-nodes-networks-chart [widget]=true></app-nodes-networks-chart>
<div class="mt-1"><a [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
<div><a [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
</div>
</div>
</div>
<!-- Top nodes per capacity -->
<div class="col">
<div class="card">
<div class="card" style="height: 409px">
<div class="card-body">
<a class="title-link" href="" [routerLink]="['/lightning/nodes/top-capacity' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.top-capacity-nodes">Top capacity nodes</h5>
<a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/liquidity' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity ranking</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
</a>
@ -70,10 +70,10 @@
<!-- Top nodes per channels -->
<div class="col">
<div class="card">
<div class="card" style="height: 409px">
<div class="card-body">
<a class="title-link" href="" [routerLink]="['/lightning/nodes/top-channels' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.top-channels-nodes">Most connected nodes</h5>
<a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/connectivity' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity ranking</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
</a>

View File

@ -29,6 +29,7 @@ import { TopNodesPerChannels } from '../lightning/nodes-ranking/top-nodes-per-ch
import { TopNodesPerCapacity } from '../lightning/nodes-ranking/top-nodes-per-capacity/top-nodes-per-capacity.component';
import { OldestNodes } from '../lightning/nodes-ranking/oldest-nodes/oldest-nodes.component';
import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/nodes-rankings-dashboard.component';
import { NodeChannels } from '../lightning/nodes-channels/node-channels.component';
@NgModule({
declarations: [
@ -56,6 +57,7 @@ import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/no
TopNodesPerCapacity,
OldestNodes,
NodesRankingsDashboard,
NodeChannels,
],
imports: [
CommonModule,
@ -89,6 +91,7 @@ import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/no
TopNodesPerCapacity,
OldestNodes,
NodesRankingsDashboard,
NodeChannels,
],
providers: [
LightningApiService,

View File

@ -39,14 +39,14 @@ const routes: Routes = [
component: NodesRankingsDashboard,
},
{
path: 'nodes/top-capacity',
path: 'nodes/rankings/liquidity',
component: NodesRanking,
data: {
type: 'capacity'
},
},
{
path: 'nodes/top-channels',
path: 'nodes/rankings/connectivity',
component: NodesRanking,
data: {
type: 'channels'

View File

@ -1,129 +1,5 @@
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.full-container {
padding: 0px 15px;
width: 100%;
/* min-height: 500px; */
height: calc(100% - 150px);
@media (max-width: 992px) {
height: 100%;
padding-bottom: 100px;
};
}
/*
.chart {
width: 100%;
height: 100%;
padding-bottom: 20px;
padding-right: 10px;
@media (max-width: 992px) {
padding-bottom: 25px;
}
@media (max-width: 829px) {
padding-bottom: 50px;
}
@media (max-width: 767px) {
padding-bottom: 25px;
}
@media (max-width: 629px) {
padding-bottom: 55px;
}
@media (max-width: 567px) {
padding-bottom: 55px;
}
}
*/
.chart-widget {
width: 100%;
height: 100%;
max-height: 270px;
}
.formRadioGroup {
margin-top: 6px;
display: flex;
flex-direction: column;
@media (min-width: 991px) {
position: relative;
top: -65px;
}
@media (min-width: 830px) and (max-width: 991px) {
position: relative;
top: 0px;
}
@media (min-width: 830px) {
flex-direction: row;
float: right;
margin-top: 0px;
}
.btn-sm {
font-size: 9px;
@media (min-width: 830px) {
font-size: 14px;
}
}
}
.pool-distribution {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
display: inline-block;
margin: 0px auto 20px;
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-title {
font-size: 1rem;
color: #4a68b9;
}
.card-text {
font-size: 18px;
span {
color: #ffffff66;
font-size: 12px;
}
}
}
}
.skeleton-loader {
width: 100%;
display: block;
max-width: 80px;
margin: 15px auto 3px;
margin-top: 25px;
margin-bottom: 25px;
min-height: 100%;
}

View File

@ -25,7 +25,7 @@ import { ActivatedRoute, ParamMap } from '@angular/router';
export class NodeStatisticsChartComponent implements OnInit {
@Input() publicKey: string;
@Input() right: number | string = 65;
@Input() left: number | string = 55;
@Input() left: number | string = 45;
@Input() widget = false;
miningWindowPreference: string;
@ -96,7 +96,7 @@ export class NodeStatisticsChartComponent implements OnInit {
],
grid: {
top: 30,
bottom: 70,
bottom: 20,
right: this.right,
left: this.left,
},

View File

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

View File

@ -1,4 +1,5 @@
<div class="container-xl" *ngIf="(node$ | async) as node">
<h5 class="mb-0" style="color: #ffffff66" i18n="lightning.node">Lightning node</h5>
<div class="title-container mb-2" *ngIf="!error">
<h1 class="mb-0">{{ node.alias }}</h1>
<span class="tx-link">
@ -42,24 +43,10 @@
<app-fiat [value]="node.avgCapacity" digitsInfo="1.0-0"></app-fiat>
</td>
</tr>
<tr *ngIf="node.country && node.city && node.subdivision">
<tr *ngIf="node.geolocation">
<td i18n="location">Location</td>
<td>
<span>{{ node.city.en }}, {{ node.subdivision.en }}</span>
<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>
<app-geolocation [data]="node.geolocation" [type]="'node'"></app-geolocation>
</td>
</tr>
</tbody>
@ -131,16 +118,27 @@
</button>
</div>
<app-nodes-channels-map *ngIf="!error" [style]="'nodepage'" [publicKey]="node.public_key"></app-nodes-channels-map>
<app-node-statistics-chart [publicKey]="node.public_key" *ngIf="!error"></app-node-statistics-chart>
<div *ngIf="!error">
<div class="row">
<div class="col-sm">
<app-nodes-channels-map [style]="'nodepage'" [publicKey]="node.public_key"></app-nodes-channels-map>
</div>
<div class="col-sm">
<app-node-statistics-chart [publicKey]="node.public_key"></app-node-statistics-chart>
</div>
</div>
<div class="d-flex justify-content-between" *ngIf="!error">
<h2>Channels ({{ channelsListStatus === 'open' ? node.opened_channel_count : node.closed_channel_count }})</h2>
<h2 i18n="lightning.active-channels-map">Active channels map</h2>
<app-node-channels style="display:block;margin-bottom: 40px" [publicKey]="node.public_key"></app-node-channels>
<div class="d-flex justify-content-between">
<h2>Channels ({{ channelsListStatus === 'open' ? node.opened_channel_count : node.closed_channel_count }})</h2>
</div>
<app-channels-list [publicKey]="node.public_key"
(channelsStatusChangedEvent)="onChannelsListStatusChanged($event)"></app-channels-list>
</div>
<app-channels-list *ngIf="!error" [publicKey]="node.public_key"
(channelsStatusChangedEvent)="onChannelsListStatusChanged($event)"></app-channels-list>
</div>
<br>

View File

@ -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 = <GeolocationData>{
country: node.country?.en,
city: node.city?.en,
subdivision: node.subdivision?.en,
iso: node.iso_code,
};
}
return node;
}),
catchError(err => {

View File

@ -1,14 +1,21 @@
<div [class]="'full-container ' + style + (fitContainer ? ' fit-container' : '')">
<div [style]="style === 'widget' ? 'height: 250px' : ''">
<div *ngIf="channelsObservable | async">
<div *ngIf="chartOptions" [class]="'full-container ' + style + (fitContainer ? ' fit-container' : '')">
<div *ngIf="style === 'graph'" class="card-header">
<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>
</div>
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
</div>
<div *ngIf="style === 'graph'" class="card-header">
<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>
<div class="chart" [class]="style" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"
(chartFinished)="onChartFinished($event)">
</div>
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
<div *ngIf="!chartOptions && style === 'nodepage'" style="padding-top: 30px"></div>
</div>
<div *ngIf="observable$ | async" class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)" (chartFinished)="onChartFinished($event)">
<div class="text-center loading-spinner" [class]="style" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
</div>

View File

@ -10,25 +10,24 @@
.full-container {
padding: 0px 15px;
width: 100%;
min-height: 500px;
min-height: 600px;
height: calc(100% - 150px);
@media (max-width: 992px) {
height: 100%;
padding-bottom: 100px;
}
}
.full-container.nodepage {
min-height: 400px;
margin-top: 25px;
margin-bottom: 25px;
}
.full-container.channelpage {
min-height: 400px;
margin-top: 25px;
margin-bottom: 25px;
min-height: 100%;
}
.full-container.widget {
height: 250px;
min-height: 250px;
}
.full-container.fit-container {
margin: 0;
padding: 0;
@ -41,25 +40,6 @@
}
}
.widget {
width: 90vw;
margin-left: auto;
margin-right: auto;
height: 250px;
-webkit-mask: linear-gradient(0deg, #11131f00 5%, #11131fff 25%);
@media (max-width: 767.98px) {
width: 100vw;
}
}
.widget > .chart {
min-height: 250px;
-webkit-mask: linear-gradient(180deg, #11131f00 0%, #11131fff 20%);
@media (max-width: 767.98px) {
padding-bottom: 0px;
}
}
.chart {
min-height: 500px;
width: 100%;
@ -80,3 +60,47 @@
padding-bottom: 55px;
}
}
.chart.graph {
min-height: 600px;
}
.chart.nodepage {
min-height: 100%;
width: 100%;
height: 100%;
padding-bottom: 0px;
}
.chart.channelpage {
min-height: 400px;
}
.widget {
width: 100%;
margin-left: auto;
margin-right: auto;
height: 250px;
-webkit-mask: linear-gradient(0deg, #11131f00 5%, #11131fff 25%);
}
.widget > .chart {
min-height: 250px;
-webkit-mask: linear-gradient(180deg, #11131f00 0%, #11131fff 20%);
@media (max-width: 767.98px) {
padding-bottom: 0px;
}
}
.loading-spinner {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
.loading-spinner.widget {
position: absolute;
top: 200px;
z-index: 100;
width: 100%;
left: 0;
@media (max-width: 767.98px) {
top: 250px;
}
}

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 { 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<any>;
channelsObservable: Observable<any>;
center: number[] | undefined;
zoom: number | undefined;
@ -31,6 +31,8 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
channelOpacity = 0.1;
channelColor = '#466d9d';
channelCurve = 0;
nodeSize = 4;
isLoading = true;
chartInstance = undefined;
chartOptions: EChartsOption = {};
@ -49,8 +51,6 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
) {
}
ngOnDestroy(): void {}
ngOnInit(): void {
this.center = this.style === 'widget' ? [0, 40] : [0, 5];
this.zoom = 1.3;
@ -66,12 +66,16 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
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(
switchMap((params: ParamMap) => {
return zip(
this.assetsService.getWorldMapJson$,
this.apiService.getChannelsGeo$(params.get('public_key') ?? undefined),
this.style !== 'channelpage' ? this.apiService.getChannelsGeo$(params.get('public_key') ?? undefined, this.style) : [''],
[params.get('public_key') ?? undefined]
).pipe(tap((data) => {
registerMap('world', data[0]);
@ -90,10 +94,12 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
}
}
for (const channel of geoloc) {
if (!thisNodeGPS && data[2] === channel[0]) {
thisNodeGPS = [channel[2], channel[3]];
} else if (!thisNodeGPS && data[2] === channel[4]) {
thisNodeGPS = [channel[6], channel[7]];
if (this.style === 'nodepage' && !thisNodeGPS) {
if (data[2] === channel[0]) {
thisNodeGPS = [channel[2], channel[3]];
} else if (data[2] === channel[4]) {
thisNodeGPS = [channel[6], channel[7]];
}
}
// 0 - node1 pubkey
@ -102,48 +108,68 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
// 4 - node2 pubkey
// 5 - node2 alias
// 6,7 - node2 GPS
const node1PubKey = 0;
const node1Alias = 1;
let node1GpsLat = 2;
let node1GpsLgt = 3;
const node2PubKey = 4;
const node2Alias = 5;
let node2GpsLat = 6;
let node2GpsLgt = 7;
let node1UniqueId = channel[node1PubKey];
let node2UniqueId = channel[node2PubKey];
if (this.style === 'widget') {
node1GpsLat = 0;
node1GpsLgt = 1;
node2GpsLat = 2;
node2GpsLgt = 3;
node1UniqueId = channel[node1GpsLat].toString() + channel[node1GpsLgt].toString();
node2UniqueId = channel[node2GpsLat].toString() + channel[node2GpsLgt].toString();
}
// We add a bit of noise so nodes at the same location are not all
// on top of each other
let random = Math.random() * 2 * Math.PI;
let random2 = Math.random() * 0.01;
if (!nodesPubkeys[channel[0]]) {
if (!nodesPubkeys[node1UniqueId]) {
nodes.push([
channel[2] + random2 * Math.cos(random),
channel[3] + random2 * Math.sin(random),
channel[node1GpsLat] + random2 * Math.cos(random),
channel[node1GpsLgt] + random2 * Math.sin(random),
1,
channel[0],
channel[1]
channel[node1PubKey],
channel[node1Alias]
]);
nodesPubkeys[channel[0]] = nodes[nodes.length - 1];
nodesPubkeys[node1UniqueId] = nodes[nodes.length - 1];
}
random = Math.random() * 2 * Math.PI;
random2 = Math.random() * 0.01;
if (!nodesPubkeys[channel[4]]) {
if (!nodesPubkeys[node2UniqueId]) {
nodes.push([
channel[6] + random2 * Math.cos(random),
channel[7] + random2 * Math.sin(random),
channel[node2GpsLat] + random2 * Math.cos(random),
channel[node2GpsLgt] + random2 * Math.sin(random),
1,
channel[4],
channel[5]
channel[node2PubKey],
channel[node2Alias]
]);
nodesPubkeys[channel[4]] = nodes[nodes.length - 1];
nodesPubkeys[node2UniqueId] = nodes[nodes.length - 1];
}
const channelLoc = [];
channelLoc.push(nodesPubkeys[channel[0]].slice(0, 2));
channelLoc.push(nodesPubkeys[channel[4]].slice(0, 2));
channelLoc.push(nodesPubkeys[node1UniqueId].slice(0, 2));
channelLoc.push(nodesPubkeys[node2UniqueId].slice(0, 2));
channelsLoc.push(channelLoc);
}
if (this.style === 'nodepage' && thisNodeGPS) {
this.center = [thisNodeGPS[0], thisNodeGPS[1]];
this.zoom = 10;
this.zoom = 5;
this.channelWidth = 1;
this.channelOpacity = 1;
}
if (this.style === 'channelpage' && this.channel.length > 0) {
this.channelWidth = 2;
this.channelOpacity = 1;
@ -170,15 +196,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 +233,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)',
@ -242,7 +261,7 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
},
{
large: false,
progressive: 200,
progressive: this.style === 'widget' ? 500 : 200,
silent: true,
type: 'lines',
coordinateSystem: 'geo',
@ -270,6 +289,10 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
this.chartInstance = ec;
this.chartInstance.on('finished', () => {
this.isLoading = false;
});
if (this.style === 'widget') {
this.chartInstance.getZr().on('click', (e) => {
this.zone.run(() => {

View File

@ -0,0 +1,2 @@
<div *ngIf="channelsObservable$ | async" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)">
</div>

View File

@ -0,0 +1,138 @@
import { formatNumber } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { ECharts, EChartsOption, TreemapSeriesOption } from 'echarts';
import { Observable, tap } from 'rxjs';
import { lerpColor } from 'src/app/shared/graphs.utils';
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
import { LightningApiService } from '../lightning-api.service';
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-node-channels',
templateUrl: './node-channels.component.html',
styleUrls: ['./node-channels.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodeChannels implements OnChanges {
@Input() publicKey: string;
chartInstance: ECharts;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
channelsObservable$: Observable<any>;
isLoading: true;
constructor(
@Inject(LOCALE_ID) public locale: string,
private lightningApiService: LightningApiService,
private amountShortenerPipe: AmountShortenerPipe,
private zone: NgZone,
private router: Router,
private stateService: StateService,
) {}
ngOnChanges(): void {
this.prepareChartOptions(null);
this.channelsObservable$ = this.lightningApiService.getChannelsByNodeId$(this.publicKey, -1, 'active')
.pipe(
tap((response) => {
const biggestCapacity = response.body[0].capacity;
this.prepareChartOptions(response.body.map(channel => {
return {
name: channel.node.alias,
value: channel.capacity,
shortId: channel.short_id,
id: channel.id,
itemStyle: {
color: lerpColor('#1E88E5', '#D81B60', Math.pow(channel.capacity / biggestCapacity, 0.4)),
}
};
}));
})
);
}
prepareChartOptions(data): void {
this.chartOptions = {
tooltip: {
trigger: 'item',
textStyle: {
align: 'left',
}
},
series: <TreemapSeriesOption[]>[
{
left: 0,
right: 0,
bottom: 0,
top: 0,
roam: false,
type: 'treemap',
data: data,
nodeClick: 'link',
progressive: 100,
tooltip: {
show: true,
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#b1b1b1',
},
borderColor: '#000',
formatter: (value): string => {
if (value.data.name === undefined) {
return ``;
}
let capacity = '';
if (value.data.value > 100000000) {
capacity = formatNumber(Math.round(value.data.value / 100000000), this.locale, '1.2-2') + ' BTC';
} else {
capacity = <string>this.amountShortenerPipe.transform(value.data.value, 2) + ' sats';
}
return `
<b style="color: white; margin-left: 2px">${value.data.shortId}</b><br>
<span>Node: ${value.name}</span><br>
<span>Capacity: ${capacity}</span>
`;
}
},
itemStyle: {
borderColor: 'black',
borderWidth: 1,
},
breadcrumb: {
show: false,
}
}
]
};
}
onChartInit(ec: ECharts): void {
if (this.chartInstance !== undefined) {
return;
}
this.chartInstance = ec;
this.chartInstance.on('click', (e) => {
//@ts-ignore
if (!e.data.id) {
return;
}
this.zone.run(() => {
//@ts-ignore
const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/channel/${e.data.id}`);
this.router.navigate([url]);
});
});
}
}

View File

@ -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 = `<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 : {
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();

View File

@ -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',

View File

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

View File

@ -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 = <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}`);
return response.nodes;
})

View File

@ -154,7 +154,7 @@ export class NodesPerISPChartComponent implements OnInit {
},
borderColor: '#000',
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`${this.amountShortenerPipe.transform(isp[2] / 100000000, 2)} BTC`
;

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<div [class]="!widget ? 'container-xl full-height' : ''">
<h1 *ngIf="!widget" class="float-left" i18n="lightning.top-100-capacity">
<span>Top 100 nodes by capacity</span>
<h1 *ngIf="!widget" class="float-left">
<span i18n="lightning.top-100-liquidity">Top 100 nodes liquidity ranking</span>
</h1>
<div [class]="widget ? 'widget' : 'full'">

View File

@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { map, Observable } from 'rxjs';
import { INodesRanking, ITopNodesPerCapacity } from 'src/app/interfaces/node-api.interface';
import { isMobile } from 'src/app/shared/common.utils';
import { LightningApiService } from '../../lightning-api.service';
@Component({
@ -19,7 +20,7 @@ export class TopNodesPerCapacity implements OnInit {
constructor(private apiService: LightningApiService) {}
ngOnInit(): void {
for (let i = 1; i <= (this.widget ? 10 : 100); ++i) {
for (let i = 1; i <= (this.widget ? (isMobile() ? 8 : 7) : 100); ++i) {
this.skeletonRows.push(i);
}
@ -28,7 +29,7 @@ export class TopNodesPerCapacity implements OnInit {
} else {
this.topNodesPerCapacity$ = this.nodes$.pipe(
map((ranking) => {
return ranking.topByCapacity.slice(0, 10);
return ranking.topByCapacity.slice(0, isMobile() ? 8 : 7);
})
);
}

View File

@ -1,6 +1,6 @@
<div [class]="!widget ? 'container-xl full-height' : ''">
<h1 *ngIf="!widget" class="float-left" i18n="lightning.top-100-channel">
<span>Top 100 nodes by channel count</span>
<h1 *ngIf="!widget" class="float-left">
<span i18n="lightning.top-100-connectivity">Top 100 nodes connectivity ranking</span>
</h1>
<div [class]="widget ? 'widget' : 'full'">

View File

@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { map, Observable } from 'rxjs';
import { INodesRanking, ITopNodesPerChannels } from 'src/app/interfaces/node-api.interface';
import { isMobile } from 'src/app/shared/common.utils';
import { LightningApiService } from '../../lightning-api.service';
@Component({
@ -19,7 +20,7 @@ export class TopNodesPerChannels implements OnInit {
constructor(private apiService: LightningApiService) {}
ngOnInit(): void {
for (let i = 1; i <= (this.widget ? 10 : 100); ++i) {
for (let i = 1; i <= (this.widget ? (isMobile() ? 8 : 7) : 100); ++i) {
this.skeletonRows.push(i);
}
@ -28,7 +29,7 @@ export class TopNodesPerChannels implements OnInit {
} else {
this.topNodesPerChannels$ = this.nodes$.pipe(
map((ranking) => {
return ranking.topByChannels.slice(0, 10);
return ranking.topByChannels.slice(0, isMobile() ? 8 : 7);
})
);
}

View File

@ -4,8 +4,8 @@
<div class="col">
<div class="card">
<div class="card-body">
<a class="title-link" href="" [routerLink]="['/lightning/nodes/top-capacity' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.top-capacity-nodes">Top capacity nodes</h5>
<a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/liquidity' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity ranking</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true"
style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
@ -18,8 +18,8 @@
<div class="col">
<div class="card">
<div class="card-body">
<a class="title-link" href="" [routerLink]="['/lightning/nodes/top-channels' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.top-channels-nodes">Most connected nodes</h5>
<a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/connectivity' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity ranking</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true"
style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>

View File

@ -48,4 +48,10 @@
<div class="spinner-border text-light"></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>

View File

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

View File

@ -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 = `<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>${weightString}</span>`;
<span>${weightString}</span>
`;
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();

View File

@ -271,10 +271,11 @@ export class ApiService {
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries');
}
getChannelsGeo$(publicKey?: string): Observable<any> {
getChannelsGeo$(publicKey?: string, style?: 'graph' | 'nodepage' | 'widget' | 'channelpage'): Observable<any> {
return this.httpClient.get<any[]>(
this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels-geo' +
(publicKey !== undefined ? `/${publicKey}` : '')
(publicKey !== undefined ? `/${publicKey}` : '') +
(style !== undefined ? `?style=${style}` : '')
);
}
}

View File

@ -153,7 +153,7 @@ export class StateService {
if (this.env.BASE_MODULE !== 'mempool' && this.env.BASE_MODULE !== 'liquid') {
return;
}
const networkMatches = url.match(/\/(bisq|testnet|liquidtestnet|liquid|signet)/);
const networkMatches = url.match(/^\/(bisq|testnet|liquidtestnet|liquid|signet)/);
switch (networkMatches && networkMatches[1]) {
case 'liquid':
if (this.network !== 'liquid') {

View File

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

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,25 @@ 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);
/**
* https://gist.githubusercontent.com/rosszurowski/67f04465c424a9bc0dae/raw/90ee06c5aa84ab352eb5b233d0a8263c3d8708e5/lerp-color.js
* A linear interpolator for hexadecimal colors
* @param {String} a
* @param {String} b
* @param {Number} amount
* @example
* // returns #7F7F7F
* lerpColor('#000000', '#ffffff', 0.5)
* @returns {String}
*/
export function lerpColor(a: string, b: string, amount: number): string {
const ah = parseInt(a.replace(/#/g, ''), 16),
ar = ah >> 16, ag = ah >> 8 & 0xff, ab = ah & 0xff,
bh = parseInt(b.replace(/#/g, ''), 16),
br = bh >> 16, bg = bh >> 8 & 0xff, bb = bh & 0xff,
rr = ar + amount * (br - ar),
rg = ag + amount * (bg - ag),
rb = ab + amount * (bb - ab);
return '#' + ((1 << 24) + (rr << 16) + (rg << 8) + rb | 0).toString(16).slice(1);
}

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 { 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 {

View File

@ -77,6 +77,8 @@ do for url in / \
'/api/v1/mining/difficulty-adjustments/2y' \
'/api/v1/mining/difficulty-adjustments/3y' \
'/api/v1/mining/difficulty-adjustments/all' \
'/api/v1/lightning/channels-geo?style=widget' \
'/api/v1/lightning/channels-geo?style=graph' \
do
curl -s "https://${hostname}${url}" >/dev/null