mirror of
https://github.com/mempool/mempool.git
synced 2024-11-20 02:11:49 +01:00
Merge branch 'master' into fix/difficulty-api
This commit is contained in:
commit
00bd61e1d3
@ -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,
|
||||
|
@ -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__"
|
||||
}
|
||||
}
|
||||
|
@ -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)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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 = ?,
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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)
|
||||
|
@ -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': '',
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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: ""
|
||||
...
|
||||
```
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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() {
|
||||
|
@ -188,4 +188,4 @@ export interface IOldestNodes {
|
||||
updatedAt?: number,
|
||||
city?: any,
|
||||
country?: any,
|
||||
}
|
||||
}
|
||||
|
@ -16,3 +16,9 @@
|
||||
color: #ffffff66;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.box {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
@ -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> <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>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 »</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 »</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 »</a></div>
|
||||
<div><a [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" i18n="dashboard.view-more">View more »</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> </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> </span>
|
||||
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
|
||||
</a>
|
||||
|
@ -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,
|
||||
|
@ -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'
|
||||
|
@ -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;
|
||||
};
|
||||
margin-top: 25px;
|
||||
margin-bottom: 25px;
|
||||
min-height: 100%;
|
||||
}
|
||||
/*
|
||||
.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;
|
||||
}
|
@ -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,
|
||||
},
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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>
|
||||
|
||||
<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,15 +118,26 @@
|
||||
</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>
|
||||
|
||||
|
@ -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 => {
|
||||
|
@ -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>
|
||||
|
@ -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%;
|
||||
@ -79,4 +59,48 @@
|
||||
@media (max-width: 567px) {
|
||||
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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
@ -65,13 +65,17 @@ export class NodesChannelsMap implements OnInit, OnDestroy {
|
||||
if (this.style === 'graph') {
|
||||
this.seoService.setTitle($localize`Lightning nodes channels world map`);
|
||||
}
|
||||
|
||||
if (['nodepage', 'channelpage'].includes(this.style)) {
|
||||
this.nodeSize = 8;
|
||||
}
|
||||
|
||||
this.observable$ = this.activatedRoute.paramMap
|
||||
this.channelsObservable = this.activatedRoute.paramMap
|
||||
.pipe(
|
||||
switchMap((params: ParamMap) => {
|
||||
return zip(
|
||||
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(() => {
|
||||
|
@ -0,0 +1,2 @@
|
||||
<div *ngIf="channelsObservable$ | async" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)">
|
||||
</div>
|
@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
})
|
||||
|
@ -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`
|
||||
;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
})
|
||||
);
|
||||
|
@ -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'">
|
||||
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -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'">
|
||||
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -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> </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> </span>
|
||||
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true"
|
||||
style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
|
||||
|
@ -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>
|
@ -131,4 +131,13 @@
|
||||
display: block;
|
||||
max-width: 80px;
|
||||
margin: 15px auto 3px;
|
||||
}
|
||||
|
||||
.indexing-message {
|
||||
position: absolute;
|
||||
font-size: 15px;
|
||||
color: grey;
|
||||
font-weight: bold;
|
||||
margin-left: calc(50% - 85px);
|
||||
margin-top: -10px;
|
||||
}
|
@ -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();
|
||||
|
@ -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}` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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') {
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1 @@
|
||||
<span [innerHTML]="formattedLocation"></span>
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user