Merge branch 'master' into dependabot/npm_and_yarn/frontend/tinyify-3.1.0
This commit is contained in:
commit
291277f299
@ -20,7 +20,8 @@
|
|||||||
"EXTERNAL_MAX_RETRY": 1,
|
"EXTERNAL_MAX_RETRY": 1,
|
||||||
"EXTERNAL_RETRY_INTERVAL": 0,
|
"EXTERNAL_RETRY_INTERVAL": 0,
|
||||||
"USER_AGENT": "mempool",
|
"USER_AGENT": "mempool",
|
||||||
"STDOUT_LOG_MIN_PRIORITY": "debug"
|
"STDOUT_LOG_MIN_PRIORITY": "debug",
|
||||||
|
"AUTOMATIC_BLOCK_REINDEXING": false
|
||||||
},
|
},
|
||||||
"CORE_RPC": {
|
"CORE_RPC": {
|
||||||
"HOST": "127.0.0.1",
|
"HOST": "127.0.0.1",
|
||||||
@ -66,6 +67,15 @@
|
|||||||
"ENABLED": false,
|
"ENABLED": false,
|
||||||
"DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db"
|
"DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db"
|
||||||
},
|
},
|
||||||
|
"LIGHTNING": {
|
||||||
|
"ENABLED": false,
|
||||||
|
"BACKEND": "lnd"
|
||||||
|
},
|
||||||
|
"LND": {
|
||||||
|
"TLS_CERT_PATH": "tls.cert",
|
||||||
|
"MACAROON_PATH": "admin.macaroon",
|
||||||
|
"SOCKET": "localhost:10009"
|
||||||
|
},
|
||||||
"SOCKS5PROXY": {
|
"SOCKS5PROXY": {
|
||||||
"ENABLED": false,
|
"ENABLED": false,
|
||||||
"USE_ONION": true,
|
"USE_ONION": true,
|
||||||
|
994
backend/package-lock.json
generated
994
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mempool-backend",
|
"name": "mempool-backend",
|
||||||
"version": "2.4.1-dev",
|
"version": "2.5.0-dev",
|
||||||
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
|
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
|
||||||
"license": "GNU Affero General Public License v3.0",
|
"license": "GNU Affero General Public License v3.0",
|
||||||
"homepage": "https://mempool.space",
|
"homepage": "https://mempool.space",
|
||||||
@ -34,8 +34,10 @@
|
|||||||
"@types/node": "^16.11.41",
|
"@types/node": "^16.11.41",
|
||||||
"axios": "~0.27.2",
|
"axios": "~0.27.2",
|
||||||
"bitcoinjs-lib": "6.0.1",
|
"bitcoinjs-lib": "6.0.1",
|
||||||
|
"bolt07": "^1.8.1",
|
||||||
"crypto-js": "^4.0.0",
|
"crypto-js": "^4.0.0",
|
||||||
"express": "^4.18.0",
|
"express": "^4.18.0",
|
||||||
|
"lightning": "^5.16.3",
|
||||||
"mysql2": "2.3.3",
|
"mysql2": "2.3.3",
|
||||||
"node-worker-threads-pool": "^1.5.1",
|
"node-worker-threads-pool": "^1.5.1",
|
||||||
"socks-proxy-agent": "~7.0.0",
|
"socks-proxy-agent": "~7.0.0",
|
||||||
|
@ -13,6 +13,7 @@ export interface AbstractBitcoinApi {
|
|||||||
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||||
$getAddressPrefix(prefix: string): string[];
|
$getAddressPrefix(prefix: string): string[];
|
||||||
$sendRawTransaction(rawTransaction: string): Promise<string>;
|
$sendRawTransaction(rawTransaction: string): Promise<string>;
|
||||||
|
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
|
||||||
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
|
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
|
||||||
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
|
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
|
||||||
}
|
}
|
||||||
|
@ -130,6 +130,16 @@ class BitcoinApi implements AbstractBitcoinApi {
|
|||||||
return this.bitcoindClient.sendRawTransaction(rawTransaction);
|
return this.bitcoindClient.sendRawTransaction(rawTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
|
||||||
|
const txOut = await this.bitcoindClient.getTxOut(txId, vout, false);
|
||||||
|
return {
|
||||||
|
spent: txOut === null,
|
||||||
|
status: {
|
||||||
|
confirmed: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
|
async $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
|
||||||
const outSpends: IEsploraApi.Outspend[] = [];
|
const outSpends: IEsploraApi.Outspend[] = [];
|
||||||
const tx = await this.$getRawTransaction(txId, true, false);
|
const tx = await this.$getRawTransaction(txId, true, false);
|
||||||
@ -195,7 +205,9 @@ class BitcoinApi implements AbstractBitcoinApi {
|
|||||||
sequence: vin.sequence,
|
sequence: vin.sequence,
|
||||||
txid: vin.txid || '',
|
txid: vin.txid || '',
|
||||||
vout: vin.vout || 0,
|
vout: vin.vout || 0,
|
||||||
witness: vin.txinwitness,
|
witness: vin.txinwitness || [],
|
||||||
|
inner_redeemscript_asm: '',
|
||||||
|
inner_witnessscript_asm: '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -25,10 +25,10 @@ export namespace IEsploraApi {
|
|||||||
is_coinbase: boolean;
|
is_coinbase: boolean;
|
||||||
scriptsig: string;
|
scriptsig: string;
|
||||||
scriptsig_asm: string;
|
scriptsig_asm: string;
|
||||||
inner_redeemscript_asm?: string;
|
inner_redeemscript_asm: string;
|
||||||
inner_witnessscript_asm?: string;
|
inner_witnessscript_asm: string;
|
||||||
sequence: any;
|
sequence: any;
|
||||||
witness?: string[];
|
witness: string[];
|
||||||
prevout: Vout | null;
|
prevout: Vout | null;
|
||||||
// Elements
|
// Elements
|
||||||
is_pegin?: boolean;
|
is_pegin?: boolean;
|
||||||
|
@ -66,6 +66,11 @@ class ElectrsApi implements AbstractBitcoinApi {
|
|||||||
throw new Error('Method not implemented.');
|
throw new Error('Method not implemented.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
|
||||||
|
return axios.get<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig)
|
||||||
|
.then((response) => response.data);
|
||||||
|
}
|
||||||
|
|
||||||
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
|
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
|
||||||
return axios.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig)
|
return axios.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig)
|
||||||
.then((response) => response.data);
|
.then((response) => response.data);
|
||||||
|
@ -579,17 +579,13 @@ class Blocks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> {
|
public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> {
|
||||||
let currentHeight = fromHeight !== undefined ? fromHeight : this.getCurrentBlockHeight();
|
let currentHeight = fromHeight !== undefined ? fromHeight : await blocksRepository.$mostRecentBlockHeight();
|
||||||
const returnBlocks: BlockExtended[] = [];
|
const returnBlocks: BlockExtended[] = [];
|
||||||
|
|
||||||
if (currentHeight < 0) {
|
if (currentHeight < 0) {
|
||||||
return returnBlocks;
|
return returnBlocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentHeight === 0 && Common.indexingEnabled()) {
|
|
||||||
currentHeight = await blocksRepository.$mostRecentBlockHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if block height exist in local cache to skip the hash lookup
|
// Check if block height exist in local cache to skip the hash lookup
|
||||||
const blockByHeight = this.getBlocks().find((b) => b.height === currentHeight);
|
const blockByHeight = this.getBlocks().find((b) => b.height === currentHeight);
|
||||||
let startFromHash: string | null = null;
|
let startFromHash: string | null = null;
|
||||||
|
@ -4,7 +4,7 @@ import logger from '../logger';
|
|||||||
import { Common } from './common';
|
import { Common } from './common';
|
||||||
|
|
||||||
class DatabaseMigration {
|
class DatabaseMigration {
|
||||||
private static currentVersion = 24;
|
private static currentVersion = 27;
|
||||||
private queryTimeout = 120000;
|
private queryTimeout = 120000;
|
||||||
private statisticsAddedIndexed = false;
|
private statisticsAddedIndexed = false;
|
||||||
private uniqueLogs: string[] = [];
|
private uniqueLogs: string[] = [];
|
||||||
@ -248,6 +248,32 @@ class DatabaseMigration {
|
|||||||
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`');
|
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`');
|
||||||
await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits'));
|
await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
await this.$executeQuery(this.getCreateNodesStatsQuery(), await this.$checkIfTableExists('node_stats'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (databaseSchemaVersion < 26 && isBitcoin === true) {
|
||||||
|
this.uniqueLog(logger.notice, `'lightning_stats' table has been truncated. Will re-generate historical data from scratch.`);
|
||||||
|
await this.$executeQuery(`TRUNCATE lightning_stats`);
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD tor_nodes int(11) NOT NULL DEFAULT "0"');
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_nodes int(11) NOT NULL DEFAULT "0"');
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD unannounced_nodes int(11) NOT NULL DEFAULT "0"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (databaseSchemaVersion < 27 && isBitcoin === true) {
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_capacity bigint(20) unsigned NOT NULL DEFAULT "0"');
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_fee_rate int(11) unsigned NOT NULL DEFAULT "0"');
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"');
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_capacity bigint(20) unsigned NOT NULL DEFAULT "0"');
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_fee_rate int(11) unsigned NOT NULL DEFAULT "0"');
|
||||||
|
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"');
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@ -572,6 +598,82 @@ class DatabaseMigration {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCreateLightningStatisticsQuery(): string {
|
||||||
|
return `CREATE TABLE IF NOT EXISTS lightning_stats (
|
||||||
|
id int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
added datetime NOT NULL,
|
||||||
|
channel_count int(11) NOT NULL,
|
||||||
|
node_count int(11) NOT NULL,
|
||||||
|
total_capacity double unsigned NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCreateNodesQuery(): string {
|
||||||
|
return `CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
public_key varchar(66) NOT NULL,
|
||||||
|
first_seen datetime NOT NULL,
|
||||||
|
updated_at datetime NOT NULL,
|
||||||
|
alias varchar(200) CHARACTER SET utf8mb4 NOT NULL,
|
||||||
|
color varchar(200) NOT NULL,
|
||||||
|
sockets text DEFAULT NULL,
|
||||||
|
PRIMARY KEY (public_key),
|
||||||
|
KEY alias (alias(10))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCreateChannelsQuery(): string {
|
||||||
|
return `CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id bigint(11) unsigned NOT NULL,
|
||||||
|
short_id varchar(15) NOT NULL DEFAULT '',
|
||||||
|
capacity bigint(20) unsigned NOT NULL,
|
||||||
|
transaction_id varchar(64) NOT NULL,
|
||||||
|
transaction_vout int(11) NOT NULL,
|
||||||
|
updated_at datetime DEFAULT NULL,
|
||||||
|
created datetime DEFAULT NULL,
|
||||||
|
status int(11) NOT NULL DEFAULT 0,
|
||||||
|
closing_transaction_id varchar(64) DEFAULT NULL,
|
||||||
|
closing_date datetime DEFAULT NULL,
|
||||||
|
closing_reason int(11) DEFAULT NULL,
|
||||||
|
node1_public_key varchar(66) NOT NULL,
|
||||||
|
node1_base_fee_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||||
|
node1_cltv_delta int(11) DEFAULT NULL,
|
||||||
|
node1_fee_rate bigint(11) DEFAULT NULL,
|
||||||
|
node1_is_disabled tinyint(1) DEFAULT NULL,
|
||||||
|
node1_max_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||||
|
node1_min_htlc_mtokens bigint(20) DEFAULT NULL,
|
||||||
|
node1_updated_at datetime DEFAULT NULL,
|
||||||
|
node2_public_key varchar(66) NOT NULL,
|
||||||
|
node2_base_fee_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||||
|
node2_cltv_delta int(11) DEFAULT NULL,
|
||||||
|
node2_fee_rate bigint(11) DEFAULT NULL,
|
||||||
|
node2_is_disabled tinyint(1) DEFAULT NULL,
|
||||||
|
node2_max_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||||
|
node2_min_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||||
|
node2_updated_at datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY node1_public_key (node1_public_key),
|
||||||
|
KEY node2_public_key (node2_public_key),
|
||||||
|
KEY status (status),
|
||||||
|
KEY short_id (short_id),
|
||||||
|
KEY transaction_id (transaction_id),
|
||||||
|
KEY closing_transaction_id (closing_transaction_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCreateNodesStatsQuery(): string {
|
||||||
|
return `CREATE TABLE IF NOT EXISTS node_stats (
|
||||||
|
id int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
public_key varchar(66) NOT NULL DEFAULT '',
|
||||||
|
added date NOT NULL,
|
||||||
|
capacity bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||||
|
channels int(11) unsigned NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY added (added,public_key),
|
||||||
|
KEY public_key (public_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
|
}
|
||||||
|
|
||||||
private getCreateBlocksAuditsTableQuery(): string {
|
private getCreateBlocksAuditsTableQuery(): string {
|
||||||
return `CREATE TABLE IF NOT EXISTS blocks_audits (
|
return `CREATE TABLE IF NOT EXISTS blocks_audits (
|
||||||
time timestamp NOT NULL,
|
time timestamp NOT NULL,
|
||||||
|
227
backend/src/api/explorer/channels.api.ts
Normal file
227
backend/src/api/explorer/channels.api.ts
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import logger from '../../logger';
|
||||||
|
import DB from '../../database';
|
||||||
|
|
||||||
|
class ChannelsApi {
|
||||||
|
public async $getAllChannels(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT * FROM channels`;
|
||||||
|
const [rows]: any = await DB.query(query);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getAllChannels error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $searchChannelsById(search: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const searchStripped = search.replace('%', '') + '%';
|
||||||
|
const query = `SELECT id, short_id, capacity FROM channels WHERE id LIKE ? OR short_id LIKE ? LIMIT 10`;
|
||||||
|
const [rows]: any = await DB.query(query, [searchStripped, searchStripped]);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$searchChannelsById error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getChannelsByStatus(status: number): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT * FROM channels WHERE status = ?`;
|
||||||
|
const [rows]: any = await DB.query(query, [status]);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getChannelsByStatus error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getClosedChannelsWithoutReason(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason IS NULL AND closing_transaction_id != ''`;
|
||||||
|
const [rows]: any = await DB.query(query);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getClosedChannelsWithoutReason error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getChannelsWithoutCreatedDate(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT * FROM channels WHERE created IS NULL`;
|
||||||
|
const [rows]: any = await DB.query(query);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getChannelsWithoutCreatedDate error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getChannel(id: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.*, ns1.channels AS channels_left, ns1.capacity AS capacity_left, ns2.channels AS channels_right, ns2.capacity AS capacity_right FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key LEFT JOIN node_stats AS ns1 ON ns1.public_key = channels.node1_public_key LEFT JOIN node_stats AS ns2 ON ns2.public_key = channels.node2_public_key WHERE (ns1.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node1_public_key) AND ns2.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node2_public_key)) AND channels.id = ?`;
|
||||||
|
const [rows]: any = await DB.query(query, [id]);
|
||||||
|
if (rows[0]) {
|
||||||
|
return this.convertChannel(rows[0]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getChannel error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getChannelsStats(): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Feedback from zerofeerouting:
|
||||||
|
// "I would argue > 5000ppm can be ignored. Channels charging more than .5% fee are ignored by CLN for example."
|
||||||
|
const ignoredFeeRateThreshold = 5000;
|
||||||
|
const ignoredBaseFeeThreshold = 5000;
|
||||||
|
|
||||||
|
// Capacity
|
||||||
|
let query = `SELECT AVG(capacity) AS avgCapacity FROM channels WHERE status = 1 ORDER BY capacity`;
|
||||||
|
const [avgCapacity]: any = await DB.query(query);
|
||||||
|
|
||||||
|
query = `SELECT capacity FROM channels WHERE status = 1 ORDER BY capacity`;
|
||||||
|
let [capacity]: any = await DB.query(query);
|
||||||
|
capacity = capacity.map(capacity => capacity.capacity);
|
||||||
|
const medianCapacity = capacity[Math.floor(capacity.length / 2)];
|
||||||
|
|
||||||
|
// Fee rates
|
||||||
|
query = `SELECT node1_fee_rate FROM channels WHERE node1_fee_rate < ${ignoredFeeRateThreshold} AND status = 1`;
|
||||||
|
let [feeRates1]: any = await DB.query(query);
|
||||||
|
feeRates1 = feeRates1.map(rate => rate.node1_fee_rate);
|
||||||
|
query = `SELECT node2_fee_rate FROM channels WHERE node2_fee_rate < ${ignoredFeeRateThreshold} AND status = 1`;
|
||||||
|
let [feeRates2]: any = await DB.query(query);
|
||||||
|
feeRates2 = feeRates2.map(rate => rate.node2_fee_rate);
|
||||||
|
|
||||||
|
let feeRates = (feeRates1.concat(feeRates2)).sort((a, b) => a - b);
|
||||||
|
let avgFeeRate = 0;
|
||||||
|
for (const rate of feeRates) {
|
||||||
|
avgFeeRate += rate;
|
||||||
|
}
|
||||||
|
avgFeeRate /= feeRates.length;
|
||||||
|
const medianFeeRate = feeRates[Math.floor(feeRates.length / 2)];
|
||||||
|
|
||||||
|
// Base fees
|
||||||
|
query = `SELECT node1_base_fee_mtokens FROM channels WHERE node1_base_fee_mtokens < ${ignoredBaseFeeThreshold} AND status = 1`;
|
||||||
|
let [baseFees1]: any = await DB.query(query);
|
||||||
|
baseFees1 = baseFees1.map(rate => rate.node1_base_fee_mtokens);
|
||||||
|
query = `SELECT node2_base_fee_mtokens FROM channels WHERE node2_base_fee_mtokens < ${ignoredBaseFeeThreshold} AND status = 1`;
|
||||||
|
let [baseFees2]: any = await DB.query(query);
|
||||||
|
baseFees2 = baseFees2.map(rate => rate.node2_base_fee_mtokens);
|
||||||
|
|
||||||
|
let baseFees = (baseFees1.concat(baseFees2)).sort((a, b) => a - b);
|
||||||
|
let avgBaseFee = 0;
|
||||||
|
for (const fee of baseFees) {
|
||||||
|
avgBaseFee += fee;
|
||||||
|
}
|
||||||
|
avgBaseFee /= baseFees.length;
|
||||||
|
const medianBaseFee = feeRates[Math.floor(baseFees.length / 2)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
avgCapacity: parseInt(avgCapacity[0].avgCapacity, 10),
|
||||||
|
avgFeeRate: avgFeeRate,
|
||||||
|
avgBaseFee: avgBaseFee,
|
||||||
|
medianCapacity: medianCapacity,
|
||||||
|
medianFeeRate: medianFeeRate,
|
||||||
|
medianBaseFee: medianBaseFee,
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot calculate channels statistics. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getChannelsByTransactionId(transactionIds: string[]): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
transactionIds = transactionIds.map((id) => '\'' + id + '\'');
|
||||||
|
const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.* FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key WHERE channels.transaction_id IN (${transactionIds.join(', ')}) OR channels.closing_transaction_id IN (${transactionIds.join(', ')})`;
|
||||||
|
const [rows]: any = await DB.query(query);
|
||||||
|
const channels = rows.map((row) => this.convertChannel(row));
|
||||||
|
return channels;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getChannelByTransactionId error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getChannelsForNode(public_key: string, index: number, length: number, status: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
// Default active and inactive channels
|
||||||
|
let statusQuery = '< 2';
|
||||||
|
// Closed channels only
|
||||||
|
if (status === 'closed') {
|
||||||
|
statusQuery = '= 2';
|
||||||
|
}
|
||||||
|
const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.*, ns1.channels AS channels_left, ns1.capacity AS capacity_left, ns2.channels AS channels_right, ns2.capacity AS capacity_right FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key LEFT JOIN node_stats AS ns1 ON ns1.public_key = channels.node1_public_key LEFT JOIN node_stats AS ns2 ON ns2.public_key = channels.node2_public_key WHERE (ns1.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node1_public_key) AND ns2.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node2_public_key)) AND (node1_public_key = ? OR node2_public_key = ?) AND status ${statusQuery} ORDER BY channels.capacity DESC LIMIT ?, ?`;
|
||||||
|
const [rows]: any = await DB.query(query, [public_key, public_key, index, length]);
|
||||||
|
const channels = rows.map((row) => this.convertChannel(row));
|
||||||
|
return channels;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getChannelsForNode error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getChannelsCountForNode(public_key: string, status: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Default active and inactive channels
|
||||||
|
let statusQuery = '< 2';
|
||||||
|
// Closed channels only
|
||||||
|
if (status === 'closed') {
|
||||||
|
statusQuery = '= 2';
|
||||||
|
}
|
||||||
|
const query = `SELECT COUNT(*) AS count FROM channels WHERE (node1_public_key = ? OR node2_public_key = ?) AND status ${statusQuery}`;
|
||||||
|
const [rows]: any = await DB.query(query, [public_key, public_key]);
|
||||||
|
return rows[0]['count'];
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getChannelsForNode error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertChannel(channel: any): any {
|
||||||
|
return {
|
||||||
|
'id': channel.id,
|
||||||
|
'short_id': channel.short_id,
|
||||||
|
'capacity': channel.capacity,
|
||||||
|
'transaction_id': channel.transaction_id,
|
||||||
|
'transaction_vout': channel.transaction_vout,
|
||||||
|
'closing_transaction_id': channel.closing_transaction_id,
|
||||||
|
'closing_reason': channel.closing_reason,
|
||||||
|
'updated_at': channel.updated_at,
|
||||||
|
'created': channel.created,
|
||||||
|
'status': channel.status,
|
||||||
|
'node_left': {
|
||||||
|
'alias': channel.alias_left,
|
||||||
|
'public_key': channel.node1_public_key,
|
||||||
|
'channels': channel.channels_left,
|
||||||
|
'capacity': channel.capacity_left,
|
||||||
|
'base_fee_mtokens': channel.node1_base_fee_mtokens,
|
||||||
|
'cltv_delta': channel.node1_cltv_delta,
|
||||||
|
'fee_rate': channel.node1_fee_rate,
|
||||||
|
'is_disabled': channel.node1_is_disabled,
|
||||||
|
'max_htlc_mtokens': channel.node1_max_htlc_mtokens,
|
||||||
|
'min_htlc_mtokens': channel.node1_min_htlc_mtokens,
|
||||||
|
'updated_at': channel.node1_updated_at,
|
||||||
|
},
|
||||||
|
'node_right': {
|
||||||
|
'alias': channel.alias_right,
|
||||||
|
'public_key': channel.node2_public_key,
|
||||||
|
'channels': channel.channels_right,
|
||||||
|
'capacity': channel.capacity_right,
|
||||||
|
'base_fee_mtokens': channel.node2_base_fee_mtokens,
|
||||||
|
'cltv_delta': channel.node2_cltv_delta,
|
||||||
|
'fee_rate': channel.node2_fee_rate,
|
||||||
|
'is_disabled': channel.node2_is_disabled,
|
||||||
|
'max_htlc_mtokens': channel.node2_max_htlc_mtokens,
|
||||||
|
'min_htlc_mtokens': channel.node2_min_htlc_mtokens,
|
||||||
|
'updated_at': channel.node2_updated_at,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ChannelsApi();
|
98
backend/src/api/explorer/channels.routes.ts
Normal file
98
backend/src/api/explorer/channels.routes.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import { Application, Request, Response } from 'express';
|
||||||
|
import channelsApi from './channels.api';
|
||||||
|
|
||||||
|
class ChannelsRoutes {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
public initRoutes(app: Application) {
|
||||||
|
app
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/txids', this.$getChannelsByTransactionIds)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/search/:search', this.$searchChannelsById)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/:short_id', this.$getChannel)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels', this.$getChannelsForNode)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $searchChannelsById(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const channels = await channelsApi.$searchChannelsById(req.params.search);
|
||||||
|
res.json(channels);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getChannel(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const channel = await channelsApi.$getChannel(req.params.short_id);
|
||||||
|
if (!channel) {
|
||||||
|
res.status(404).send('Channel not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(channel);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getChannelsForNode(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
if (typeof req.query.public_key !== 'string') {
|
||||||
|
res.status(400).send('Missing parameter: public_key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = parseInt(typeof req.query.index === 'string' ? req.query.index : '0', 10) || 0;
|
||||||
|
const status: string = typeof req.query.status === 'string' ? req.query.status : '';
|
||||||
|
const length = 25;
|
||||||
|
const channels = await channelsApi.$getChannelsForNode(req.query.public_key, index, length, status);
|
||||||
|
const channelsCount = await channelsApi.$getChannelsCountForNode(req.query.public_key, status);
|
||||||
|
res.header('X-Total-Count', channelsCount.toString());
|
||||||
|
res.json(channels);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getChannelsByTransactionIds(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
if (!Array.isArray(req.query.txId)) {
|
||||||
|
res.status(400).send('Not an array');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const txIds: string[] = [];
|
||||||
|
for (const _txId in req.query.txId) {
|
||||||
|
if (typeof req.query.txId[_txId] === 'string') {
|
||||||
|
txIds.push(req.query.txId[_txId].toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const channels = await channelsApi.$getChannelsByTransactionId(txIds);
|
||||||
|
const inputs: any[] = [];
|
||||||
|
const outputs: any[] = [];
|
||||||
|
for (const txid of txIds) {
|
||||||
|
const foundChannelInputs = channels.find((channel) => channel.closing_transaction_id === txid);
|
||||||
|
if (foundChannelInputs) {
|
||||||
|
inputs.push(foundChannelInputs);
|
||||||
|
} else {
|
||||||
|
inputs.push(null);
|
||||||
|
}
|
||||||
|
const foundChannelOutputs = channels.find((channel) => channel.transaction_id === txid);
|
||||||
|
if (foundChannelOutputs) {
|
||||||
|
outputs.push(foundChannelOutputs);
|
||||||
|
} else {
|
||||||
|
outputs.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
inputs: inputs,
|
||||||
|
outputs: outputs,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ChannelsRoutes();
|
58
backend/src/api/explorer/general.routes.ts
Normal file
58
backend/src/api/explorer/general.routes.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import { Application, Request, Response } from 'express';
|
||||||
|
import nodesApi from './nodes.api';
|
||||||
|
import channelsApi from './channels.api';
|
||||||
|
import statisticsApi from './statistics.api';
|
||||||
|
class GeneralLightningRoutes {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
public initRoutes(app: Application) {
|
||||||
|
app
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/search', this.$searchNodesAndChannels)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/statistics/latest', this.$getGeneralStats)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/statistics/:interval', this.$getStatistics)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $searchNodesAndChannels(req: Request, res: Response) {
|
||||||
|
if (typeof req.query.searchText !== 'string') {
|
||||||
|
res.status(400).send('Missing parameter: searchText');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.query.searchText);
|
||||||
|
const channels = await channelsApi.$searchChannelsById(req.query.searchText);
|
||||||
|
res.json({
|
||||||
|
nodes: nodes,
|
||||||
|
channels: channels,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getStatistics(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const statistics = await statisticsApi.$getStatistics(req.params.interval);
|
||||||
|
const statisticsCount = await statisticsApi.$getStatisticsCount();
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.header('X-total-count', statisticsCount.toString());
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||||
|
res.json(statistics);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getGeneralStats(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const statistics = await statisticsApi.$getLatestStatistics();
|
||||||
|
res.json(statistics);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new GeneralLightningRoutes();
|
62
backend/src/api/explorer/nodes.api.ts
Normal file
62
backend/src/api/explorer/nodes.api.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import logger from '../../logger';
|
||||||
|
import DB from '../../database';
|
||||||
|
|
||||||
|
class NodesApi {
|
||||||
|
public async $getNode(public_key: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT nodes.*, (SELECT COUNT(*) FROM channels WHERE channels.status < 2 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)) AS channel_count, (SELECT SUM(capacity) FROM channels WHERE channels.status < 2 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)) AS capacity, (SELECT AVG(capacity) FROM channels WHERE status < 2 AND (node1_public_key = ? OR node2_public_key = ?)) AS channels_capacity_avg FROM nodes WHERE public_key = ?`;
|
||||||
|
const [rows]: any = await DB.query(query, [public_key, public_key, public_key, public_key, public_key, public_key, public_key]);
|
||||||
|
return rows[0];
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getNode error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getNodeStats(public_key: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT UNIX_TIMESTAMP(added) AS added, capacity, channels FROM node_stats WHERE public_key = ? ORDER BY added DESC`;
|
||||||
|
const [rows]: any = await DB.query(query, [public_key]);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getNodeStats error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getTopCapacityNodes(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT nodes.*, node_stats.capacity, node_stats.channels FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key ORDER BY node_stats.added DESC, node_stats.capacity DESC LIMIT 10`;
|
||||||
|
const [rows]: any = await DB.query(query);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getTopCapacityNodes error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getTopChannelsNodes(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT nodes.*, node_stats.capacity, node_stats.channels FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key ORDER BY node_stats.added DESC, node_stats.channels DESC LIMIT 10`;
|
||||||
|
const [rows]: any = await DB.query(query);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getTopChannelsNodes error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$searchNodeByPublicKeyOrAlias error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NodesApi();
|
61
backend/src/api/explorer/nodes.routes.ts
Normal file
61
backend/src/api/explorer/nodes.routes.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import { Application, Request, Response } from 'express';
|
||||||
|
import nodesApi from './nodes.api';
|
||||||
|
class NodesRoutes {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
public initRoutes(app: Application) {
|
||||||
|
app
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $searchNode(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search);
|
||||||
|
res.json(nodes);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getNode(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const node = await nodesApi.$getNode(req.params.public_key);
|
||||||
|
if (!node) {
|
||||||
|
res.status(404).send('Node not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(node);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getHistoricalNodeStats(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const statistics = await nodesApi.$getNodeStats(req.params.public_key);
|
||||||
|
res.json(statistics);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getTopNodes(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const topCapacityNodes = await nodesApi.$getTopCapacityNodes();
|
||||||
|
const topChannelsNodes = await nodesApi.$getTopChannelsNodes();
|
||||||
|
res.json({
|
||||||
|
topByCapacity: topCapacityNodes,
|
||||||
|
topByChannels: topChannelsNodes,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NodesRoutes();
|
52
backend/src/api/explorer/statistics.api.ts
Normal file
52
backend/src/api/explorer/statistics.api.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import logger from '../../logger';
|
||||||
|
import DB from '../../database';
|
||||||
|
import { Common } from '../common';
|
||||||
|
|
||||||
|
class StatisticsApi {
|
||||||
|
public async $getStatistics(interval: string | null = null): Promise<any> {
|
||||||
|
interval = Common.getSqlInterval(interval);
|
||||||
|
|
||||||
|
let query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, node_count, total_capacity, tor_nodes, clearnet_nodes, unannounced_nodes
|
||||||
|
FROM lightning_stats`;
|
||||||
|
|
||||||
|
if (interval) {
|
||||||
|
query += ` WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY id DESC`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows]: any = await DB.query(query);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getStatistics error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getLatestStatistics(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const [rows]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY id DESC LIMIT 1`);
|
||||||
|
const [rows2]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY id DESC LIMIT 1 OFFSET 7`);
|
||||||
|
return {
|
||||||
|
latest: rows[0],
|
||||||
|
previous: rows2[0],
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getLatestStatistics error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getStatisticsCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const [rows]: any = await DB.query(`SELECT count(*) as count FROM lightning_stats`);
|
||||||
|
return rows[0].count;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getLatestStatistics error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new StatisticsApi();
|
@ -0,0 +1,7 @@
|
|||||||
|
import { ILightningApi } from './lightning-api.interface';
|
||||||
|
|
||||||
|
export interface AbstractLightningApi {
|
||||||
|
$getNetworkInfo(): Promise<ILightningApi.NetworkInfo>;
|
||||||
|
$getNetworkGraph(): Promise<ILightningApi.NetworkGraph>;
|
||||||
|
$getInfo(): Promise<ILightningApi.Info>;
|
||||||
|
}
|
13
backend/src/api/lightning/lightning-api-factory.ts
Normal file
13
backend/src/api/lightning/lightning-api-factory.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import { AbstractLightningApi } from './lightning-api-abstract-factory';
|
||||||
|
import LndApi from './lnd/lnd-api';
|
||||||
|
|
||||||
|
function lightningApiFactory(): AbstractLightningApi {
|
||||||
|
switch (config.LIGHTNING.BACKEND) {
|
||||||
|
case 'lnd':
|
||||||
|
default:
|
||||||
|
return new LndApi();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default lightningApiFactory();
|
71
backend/src/api/lightning/lightning-api.interface.ts
Normal file
71
backend/src/api/lightning/lightning-api.interface.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
export namespace ILightningApi {
|
||||||
|
export interface NetworkInfo {
|
||||||
|
average_channel_size: number;
|
||||||
|
channel_count: number;
|
||||||
|
max_channel_size: number;
|
||||||
|
median_channel_size: number;
|
||||||
|
min_channel_size: number;
|
||||||
|
node_count: number;
|
||||||
|
not_recently_updated_policy_count: number;
|
||||||
|
total_capacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkGraph {
|
||||||
|
channels: Channel[];
|
||||||
|
nodes: Node[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
capacity: number;
|
||||||
|
policies: Policy[];
|
||||||
|
transaction_id: string;
|
||||||
|
transaction_vout: number;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Policy {
|
||||||
|
public_key: string;
|
||||||
|
base_fee_mtokens?: string;
|
||||||
|
cltv_delta?: number;
|
||||||
|
fee_rate?: number;
|
||||||
|
is_disabled?: boolean;
|
||||||
|
max_htlc_mtokens?: string;
|
||||||
|
min_htlc_mtokens?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
alias: string;
|
||||||
|
color: string;
|
||||||
|
features: Feature[];
|
||||||
|
public_key: string;
|
||||||
|
sockets: string[];
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Info {
|
||||||
|
chains: string[];
|
||||||
|
color: string;
|
||||||
|
active_channels_count: number;
|
||||||
|
alias: string;
|
||||||
|
current_block_hash: string;
|
||||||
|
current_block_height: number;
|
||||||
|
features: Feature[];
|
||||||
|
is_synced_to_chain: boolean;
|
||||||
|
is_synced_to_graph: boolean;
|
||||||
|
latest_block_at: string;
|
||||||
|
peers_count: number;
|
||||||
|
pending_channels_count: number;
|
||||||
|
public_key: string;
|
||||||
|
uris: any[];
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Feature {
|
||||||
|
bit: number;
|
||||||
|
is_known: boolean;
|
||||||
|
is_required: boolean;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
}
|
45
backend/src/api/lightning/lnd/lnd-api.ts
Normal file
45
backend/src/api/lightning/lnd/lnd-api.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { AbstractLightningApi } from '../lightning-api-abstract-factory';
|
||||||
|
import { ILightningApi } from '../lightning-api.interface';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { authenticatedLndGrpc, getWalletInfo, getNetworkGraph, getNetworkInfo } from 'lightning';
|
||||||
|
import config from '../../../config';
|
||||||
|
import logger from '../../../logger';
|
||||||
|
|
||||||
|
class LndApi implements AbstractLightningApi {
|
||||||
|
private lnd: any;
|
||||||
|
constructor() {
|
||||||
|
if (!config.LIGHTNING.ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tls = fs.readFileSync(config.LND.TLS_CERT_PATH).toString('base64');
|
||||||
|
const macaroon = fs.readFileSync(config.LND.MACAROON_PATH).toString('base64');
|
||||||
|
|
||||||
|
const { lnd } = authenticatedLndGrpc({
|
||||||
|
cert: tls,
|
||||||
|
macaroon: macaroon,
|
||||||
|
socket: config.LND.SOCKET,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.lnd = lnd;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('Could not initiate the LND service handler: ' + (e instanceof Error ? e.message : e));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async $getNetworkInfo(): Promise<ILightningApi.NetworkInfo> {
|
||||||
|
return await getNetworkInfo({ lnd: this.lnd });
|
||||||
|
}
|
||||||
|
|
||||||
|
async $getInfo(): Promise<ILightningApi.Info> {
|
||||||
|
// @ts-ignore
|
||||||
|
return await getWalletInfo({ lnd: this.lnd });
|
||||||
|
}
|
||||||
|
|
||||||
|
async $getNetworkGraph(): Promise<ILightningApi.NetworkGraph> {
|
||||||
|
return await getNetworkGraph({ lnd: this.lnd });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LndApi;
|
@ -173,8 +173,6 @@ class Mining {
|
|||||||
*/
|
*/
|
||||||
public async $generatePoolHashrateHistory(): Promise<void> {
|
public async $generatePoolHashrateHistory(): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
|
||||||
const lastestRunDate = await HashratesRepository.$getLatestRun('last_weekly_hashrates_indexing');
|
const lastestRunDate = await HashratesRepository.$getLatestRun('last_weekly_hashrates_indexing');
|
||||||
|
|
||||||
// Run only if:
|
// Run only if:
|
||||||
@ -184,14 +182,15 @@ class Mining {
|
|||||||
if (!runIndexing) {
|
if (!runIndexing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
|
||||||
|
|
||||||
|
const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0));
|
||||||
|
const genesisTimestamp = genesisBlock.time * 1000;
|
||||||
|
|
||||||
const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
|
const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
|
||||||
const hashrates: any[] = [];
|
const hashrates: any[] = [];
|
||||||
const genesisTimestamp = 1231006505000; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
|
|
||||||
|
|
||||||
const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7));
|
const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7));
|
||||||
const lastMondayMidnight = this.getDateMidnight(lastMonday);
|
const lastMondayMidnight = this.getDateMidnight(lastMonday);
|
||||||
@ -207,7 +206,7 @@ class Mining {
|
|||||||
logger.debug(`Indexing weekly mining pool hashrate`);
|
logger.debug(`Indexing weekly mining pool hashrate`);
|
||||||
loadingIndicators.setProgress('weekly-hashrate-indexing', 0);
|
loadingIndicators.setProgress('weekly-hashrate-indexing', 0);
|
||||||
|
|
||||||
while (toTimestamp > genesisTimestamp) {
|
while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) {
|
||||||
const fromTimestamp = toTimestamp - 604800000;
|
const fromTimestamp = toTimestamp - 604800000;
|
||||||
|
|
||||||
// Skip already indexed weeks
|
// Skip already indexed weeks
|
||||||
@ -217,14 +216,6 @@ class Mining {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have blocks for the previous week (which mean that the week
|
|
||||||
// we are currently indexing has complete data)
|
|
||||||
const blockStatsPreviousWeek: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
|
||||||
null, (fromTimestamp - 604800000) / 1000, (toTimestamp - 604800000) / 1000);
|
|
||||||
if (blockStatsPreviousWeek.blockCount === 0) { // We are done indexing
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
||||||
null, fromTimestamp / 1000, toTimestamp / 1000);
|
null, fromTimestamp / 1000, toTimestamp / 1000);
|
||||||
const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
|
const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
|
||||||
@ -232,6 +223,7 @@ class Mining {
|
|||||||
|
|
||||||
let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp / 1000, toTimestamp / 1000);
|
let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp / 1000, toTimestamp / 1000);
|
||||||
const totalBlocks = pools.reduce((acc, pool) => acc + pool.blockCount, 0);
|
const totalBlocks = pools.reduce((acc, pool) => acc + pool.blockCount, 0);
|
||||||
|
if (totalBlocks > 0) {
|
||||||
pools = pools.map((pool: any) => {
|
pools = pools.map((pool: any) => {
|
||||||
pool.hashrate = (pool.blockCount / totalBlocks) * lastBlockHashrate;
|
pool.hashrate = (pool.blockCount / totalBlocks) * lastBlockHashrate;
|
||||||
pool.share = (pool.blockCount / totalBlocks);
|
pool.share = (pool.blockCount / totalBlocks);
|
||||||
@ -241,7 +233,7 @@ class Mining {
|
|||||||
for (const pool of pools) {
|
for (const pool of pools) {
|
||||||
hashrates.push({
|
hashrates.push({
|
||||||
hashrateTimestamp: toTimestamp / 1000,
|
hashrateTimestamp: toTimestamp / 1000,
|
||||||
avgHashrate: pool['hashrate'],
|
avgHashrate: pool['hashrate'] ,
|
||||||
poolId: pool.poolId,
|
poolId: pool.poolId,
|
||||||
share: pool['share'],
|
share: pool['share'],
|
||||||
type: 'weekly',
|
type: 'weekly',
|
||||||
@ -251,6 +243,7 @@ class Mining {
|
|||||||
newlyIndexed += hashrates.length;
|
newlyIndexed += hashrates.length;
|
||||||
await HashratesRepository.$saveHashrates(hashrates);
|
await HashratesRepository.$saveHashrates(hashrates);
|
||||||
hashrates.length = 0;
|
hashrates.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||||
if (elapsedSeconds > 1) {
|
if (elapsedSeconds > 1) {
|
||||||
@ -285,20 +278,19 @@ class Mining {
|
|||||||
* [INDEXING] Generate daily hashrate data
|
* [INDEXING] Generate daily hashrate data
|
||||||
*/
|
*/
|
||||||
public async $generateNetworkHashrateHistory(): Promise<void> {
|
public async $generateNetworkHashrateHistory(): Promise<void> {
|
||||||
try {
|
|
||||||
// We only run this once a day around midnight
|
// We only run this once a day around midnight
|
||||||
const latestRunDate = await HashratesRepository.$getLatestRun('last_hashrates_indexing');
|
const latestRunDate = await HashratesRepository.$getLatestRun('last_hashrates_indexing');
|
||||||
const now = new Date().getUTCDate();
|
const now = new Date().getUTCDate();
|
||||||
if (now === latestRunDate) {
|
if (now === latestRunDate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
throw e;
|
const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const indexedTimestamp = (await HashratesRepository.$getNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
|
const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0));
|
||||||
const genesisTimestamp = (config.MEMPOOL.NETWORK === 'signet') ? 1598918400000 : 1231006505000; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
|
const genesisTimestamp = genesisBlock.time * 1000;
|
||||||
|
const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
|
||||||
const lastMidnight = this.getDateMidnight(new Date());
|
const lastMidnight = this.getDateMidnight(new Date());
|
||||||
let toTimestamp = Math.round(lastMidnight.getTime());
|
let toTimestamp = Math.round(lastMidnight.getTime());
|
||||||
const hashrates: any[] = [];
|
const hashrates: any[] = [];
|
||||||
@ -313,7 +305,7 @@ class Mining {
|
|||||||
logger.debug(`Indexing daily network hashrate`);
|
logger.debug(`Indexing daily network hashrate`);
|
||||||
loadingIndicators.setProgress('daily-hashrate-indexing', 0);
|
loadingIndicators.setProgress('daily-hashrate-indexing', 0);
|
||||||
|
|
||||||
while (toTimestamp > genesisTimestamp) {
|
while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) {
|
||||||
const fromTimestamp = toTimestamp - 86400000;
|
const fromTimestamp = toTimestamp - 86400000;
|
||||||
|
|
||||||
// Skip already indexed days
|
// Skip already indexed days
|
||||||
@ -323,17 +315,9 @@ class Mining {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have blocks for the previous day (which mean that the day
|
|
||||||
// we are currently indexing has complete data)
|
|
||||||
const blockStatsPreviousDay: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
|
||||||
null, (fromTimestamp - 86400000) / 1000, (toTimestamp - 86400000) / 1000);
|
|
||||||
if (blockStatsPreviousDay.blockCount === 0 && config.MEMPOOL.NETWORK === 'mainnet') { // We are done indexing
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
||||||
null, fromTimestamp / 1000, toTimestamp / 1000);
|
null, fromTimestamp / 1000, toTimestamp / 1000);
|
||||||
const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
|
const lastBlockHashrate = blockStats.blockCount === 0 ? 0 : await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
|
||||||
blockStats.lastBlockHeight);
|
blockStats.lastBlockHeight);
|
||||||
|
|
||||||
hashrates.push({
|
hashrates.push({
|
||||||
@ -368,8 +352,8 @@ class Mining {
|
|||||||
++totalIndexed;
|
++totalIndexed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add genesis block manually on mainnet and testnet
|
// Add genesis block manually
|
||||||
if ('signet' !== config.MEMPOOL.NETWORK && toTimestamp <= genesisTimestamp && !indexedTimestamp.includes(genesisTimestamp)) {
|
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && !indexedTimestamp.includes(genesisTimestamp / 1000)) {
|
||||||
hashrates.push({
|
hashrates.push({
|
||||||
hashrateTimestamp: genesisTimestamp / 1000,
|
hashrateTimestamp: genesisTimestamp / 1000,
|
||||||
avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1),
|
avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1),
|
||||||
@ -405,27 +389,37 @@ class Mining {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blocks: any = await BlocksRepository.$getBlocksDifficulty();
|
const blocks: any = await BlocksRepository.$getBlocksDifficulty();
|
||||||
|
const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0));
|
||||||
let currentDifficulty = 0;
|
let currentDifficulty = genesisBlock.difficulty;
|
||||||
let totalIndexed = 0;
|
let totalIndexed = 0;
|
||||||
|
|
||||||
if (indexedHeights[0] !== true) {
|
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) {
|
||||||
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
||||||
time: (config.MEMPOOL.NETWORK === 'signet') ? 1598918400 : 1231006505,
|
time: genesisBlock.time,
|
||||||
height: 0,
|
height: 0,
|
||||||
difficulty: (config.MEMPOOL.NETWORK === 'signet') ? 0.001126515290698186 : 1.0,
|
difficulty: currentDifficulty,
|
||||||
adjustment: 0.0,
|
adjustment: 0.0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldestConsecutiveBlock = await BlocksRepository.$getOldestConsecutiveBlock();
|
||||||
|
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== -1) {
|
||||||
|
currentDifficulty = oldestConsecutiveBlock.difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalBlockChecked = 0;
|
||||||
|
let timer = new Date().getTime() / 1000;
|
||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
if (block.difficulty !== currentDifficulty) {
|
if (block.difficulty !== currentDifficulty) {
|
||||||
if (block.height === 0 || indexedHeights[block.height] === true) { // Already indexed
|
if (indexedHeights[block.height] === true) { // Already indexed
|
||||||
|
if (block.height >= oldestConsecutiveBlock.height) {
|
||||||
currentDifficulty = block.difficulty;
|
currentDifficulty = block.difficulty;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let adjustment = block.difficulty / Math.max(1, currentDifficulty);
|
let adjustment = block.difficulty / currentDifficulty;
|
||||||
adjustment = Math.round(adjustment * 1000000) / 1000000; // Remove float point noise
|
adjustment = Math.round(adjustment * 1000000) / 1000000; // Remove float point noise
|
||||||
|
|
||||||
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
||||||
@ -436,10 +430,20 @@ class Mining {
|
|||||||
});
|
});
|
||||||
|
|
||||||
totalIndexed++;
|
totalIndexed++;
|
||||||
|
if (block.height >= oldestConsecutiveBlock.height) {
|
||||||
currentDifficulty = block.difficulty;
|
currentDifficulty = block.difficulty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalBlockChecked++;
|
||||||
|
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||||
|
if (elapsedSeconds > 5) {
|
||||||
|
const progress = Math.round(totalBlockChecked / blocks.length * 100);
|
||||||
|
logger.info(`Indexing difficulty adjustment at block #${block.height} | Progress: ${progress}%`);
|
||||||
|
timer = new Date().getTime() / 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (totalIndexed > 0) {
|
if (totalIndexed > 0) {
|
||||||
logger.notice(`Indexed ${totalIndexed} difficulty adjustments`);
|
logger.notice(`Indexed ${totalIndexed} difficulty adjustments`);
|
||||||
}
|
}
|
||||||
|
@ -222,6 +222,10 @@ class PoolsParser {
|
|||||||
* Delete blocks which needs to be reindexed
|
* Delete blocks which needs to be reindexed
|
||||||
*/
|
*/
|
||||||
private async $deleteBlocskToReindex(finalPoolDataUpdate: any[]) {
|
private async $deleteBlocskToReindex(finalPoolDataUpdate: any[]) {
|
||||||
|
if (config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||||
if (blockCount === 0) {
|
if (blockCount === 0) {
|
||||||
return;
|
return;
|
||||||
|
@ -23,10 +23,20 @@ interface IConfig {
|
|||||||
EXTERNAL_RETRY_INTERVAL: number;
|
EXTERNAL_RETRY_INTERVAL: number;
|
||||||
USER_AGENT: string;
|
USER_AGENT: string;
|
||||||
STDOUT_LOG_MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug';
|
STDOUT_LOG_MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug';
|
||||||
|
AUTOMATIC_BLOCK_REINDEXING: boolean;
|
||||||
};
|
};
|
||||||
ESPLORA: {
|
ESPLORA: {
|
||||||
REST_API_URL: string;
|
REST_API_URL: string;
|
||||||
};
|
};
|
||||||
|
LIGHTNING: {
|
||||||
|
ENABLED: boolean;
|
||||||
|
BACKEND: 'lnd' | 'cln' | 'ldk';
|
||||||
|
};
|
||||||
|
LND: {
|
||||||
|
TLS_CERT_PATH: string;
|
||||||
|
MACAROON_PATH: string;
|
||||||
|
SOCKET: string;
|
||||||
|
};
|
||||||
ELECTRUM: {
|
ELECTRUM: {
|
||||||
HOST: string;
|
HOST: string;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
@ -113,6 +123,7 @@ const defaults: IConfig = {
|
|||||||
'EXTERNAL_RETRY_INTERVAL': 0,
|
'EXTERNAL_RETRY_INTERVAL': 0,
|
||||||
'USER_AGENT': 'mempool',
|
'USER_AGENT': 'mempool',
|
||||||
'STDOUT_LOG_MIN_PRIORITY': 'debug',
|
'STDOUT_LOG_MIN_PRIORITY': 'debug',
|
||||||
|
'AUTOMATIC_BLOCK_REINDEXING': false,
|
||||||
},
|
},
|
||||||
'ESPLORA': {
|
'ESPLORA': {
|
||||||
'REST_API_URL': 'http://127.0.0.1:3000',
|
'REST_API_URL': 'http://127.0.0.1:3000',
|
||||||
@ -158,6 +169,15 @@ const defaults: IConfig = {
|
|||||||
'ENABLED': false,
|
'ENABLED': false,
|
||||||
'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db'
|
'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db'
|
||||||
},
|
},
|
||||||
|
'LIGHTNING': {
|
||||||
|
'ENABLED': false,
|
||||||
|
'BACKEND': 'lnd'
|
||||||
|
},
|
||||||
|
'LND': {
|
||||||
|
'TLS_CERT_PATH': '',
|
||||||
|
'MACAROON_PATH': '',
|
||||||
|
'SOCKET': 'localhost:10009',
|
||||||
|
},
|
||||||
'SOCKS5PROXY': {
|
'SOCKS5PROXY': {
|
||||||
'ENABLED': false,
|
'ENABLED': false,
|
||||||
'USE_ONION': true,
|
'USE_ONION': true,
|
||||||
@ -166,11 +186,11 @@ const defaults: IConfig = {
|
|||||||
'USERNAME': '',
|
'USERNAME': '',
|
||||||
'PASSWORD': ''
|
'PASSWORD': ''
|
||||||
},
|
},
|
||||||
"PRICE_DATA_SERVER": {
|
'PRICE_DATA_SERVER': {
|
||||||
'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices',
|
'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices',
|
||||||
'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices'
|
'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices'
|
||||||
},
|
},
|
||||||
"EXTERNAL_DATA_SERVER": {
|
'EXTERNAL_DATA_SERVER': {
|
||||||
'MEMPOOL_API': 'https://mempool.space/api/v1',
|
'MEMPOOL_API': 'https://mempool.space/api/v1',
|
||||||
'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
|
'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
|
||||||
'LIQUID_API': 'https://liquid.network/api/v1',
|
'LIQUID_API': 'https://liquid.network/api/v1',
|
||||||
@ -190,6 +210,8 @@ class Config implements IConfig {
|
|||||||
SYSLOG: IConfig['SYSLOG'];
|
SYSLOG: IConfig['SYSLOG'];
|
||||||
STATISTICS: IConfig['STATISTICS'];
|
STATISTICS: IConfig['STATISTICS'];
|
||||||
BISQ: IConfig['BISQ'];
|
BISQ: IConfig['BISQ'];
|
||||||
|
LIGHTNING: IConfig['LIGHTNING'];
|
||||||
|
LND: IConfig['LND'];
|
||||||
SOCKS5PROXY: IConfig['SOCKS5PROXY'];
|
SOCKS5PROXY: IConfig['SOCKS5PROXY'];
|
||||||
PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER'];
|
PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER'];
|
||||||
EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER'];
|
EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER'];
|
||||||
@ -205,6 +227,8 @@ class Config implements IConfig {
|
|||||||
this.SYSLOG = configs.SYSLOG;
|
this.SYSLOG = configs.SYSLOG;
|
||||||
this.STATISTICS = configs.STATISTICS;
|
this.STATISTICS = configs.STATISTICS;
|
||||||
this.BISQ = configs.BISQ;
|
this.BISQ = configs.BISQ;
|
||||||
|
this.LIGHTNING = configs.LIGHTNING;
|
||||||
|
this.LND = configs.LND;
|
||||||
this.SOCKS5PROXY = configs.SOCKS5PROXY;
|
this.SOCKS5PROXY = configs.SOCKS5PROXY;
|
||||||
this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER;
|
this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER;
|
||||||
this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER;
|
this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { Application, Request, Response, NextFunction, Express } from 'express';
|
import { Application, Request, Response, NextFunction } from 'express';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as WebSocket from 'ws';
|
import * as WebSocket from 'ws';
|
||||||
import cluster from 'cluster';
|
import cluster from 'cluster';
|
||||||
@ -28,6 +28,11 @@ import { Common } from './api/common';
|
|||||||
import poolsUpdater from './tasks/pools-updater';
|
import poolsUpdater from './tasks/pools-updater';
|
||||||
import indexer from './indexer';
|
import indexer from './indexer';
|
||||||
import priceUpdater from './tasks/price-updater';
|
import priceUpdater from './tasks/price-updater';
|
||||||
|
import nodesRoutes from './api/explorer/nodes.routes';
|
||||||
|
import channelsRoutes from './api/explorer/channels.routes';
|
||||||
|
import generalLightningRoutes from './api/explorer/general.routes';
|
||||||
|
import lightningStatsUpdater from './tasks/lightning/stats-updater.service';
|
||||||
|
import nodeSyncService from './tasks/lightning/node-sync.service';
|
||||||
import BlocksAuditsRepository from './repositories/BlocksAuditsRepository';
|
import BlocksAuditsRepository from './repositories/BlocksAuditsRepository';
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
@ -130,6 +135,11 @@ class Server {
|
|||||||
bisqMarkets.startBisqService();
|
bisqMarkets.startBisqService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.LIGHTNING.ENABLED) {
|
||||||
|
nodeSyncService.$startService()
|
||||||
|
.then(() => lightningStatsUpdater.$startService());
|
||||||
|
}
|
||||||
|
|
||||||
this.server.listen(config.MEMPOOL.HTTP_PORT, () => {
|
this.server.listen(config.MEMPOOL.HTTP_PORT, () => {
|
||||||
if (worker) {
|
if (worker) {
|
||||||
logger.info(`Mempool Server worker #${process.pid} started`);
|
logger.info(`Mempool Server worker #${process.pid} started`);
|
||||||
@ -362,6 +372,12 @@ class Server {
|
|||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', routes.$getElementsPegsByMonth)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', routes.$getElementsPegsByMonth)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.LIGHTNING.ENABLED) {
|
||||||
|
generalLightningRoutes.initRoutes(this.app);
|
||||||
|
nodesRoutes.initRoutes(this.app);
|
||||||
|
channelsRoutes.initRoutes(this.app);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,6 +35,8 @@ class Indexer {
|
|||||||
this.runIndexer = false;
|
this.runIndexer = false;
|
||||||
this.indexerRunning = true;
|
this.indexerRunning = true;
|
||||||
|
|
||||||
|
logger.debug(`Running mining indexer`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chainValid = await blocks.$generateBlockDatabase();
|
const chainValid = await blocks.$generateBlockDatabase();
|
||||||
if (chainValid === false) {
|
if (chainValid === false) {
|
||||||
@ -54,9 +56,15 @@ class Indexer {
|
|||||||
this.indexerRunning = false;
|
this.indexerRunning = false;
|
||||||
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
|
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
setTimeout(() => this.reindex(), 10000);
|
setTimeout(() => this.reindex(), 10000);
|
||||||
|
this.indexerRunning = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.indexerRunning = false;
|
this.indexerRunning = false;
|
||||||
|
|
||||||
|
const runEvery = 1000 * 3600; // 1 hour
|
||||||
|
logger.debug(`Indexing completed. Next run planned at ${new Date(new Date().getTime() + runEvery).toUTCString()}`);
|
||||||
|
setTimeout(() => this.reindex(), runEvery);
|
||||||
}
|
}
|
||||||
|
|
||||||
async $resetHashratesIndexingState() {
|
async $resetHashratesIndexingState() {
|
||||||
|
@ -610,6 +610,24 @@ class BlocksRepository {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the oldest block from a consecutive chain of block from the most recent one
|
||||||
|
*/
|
||||||
|
public async $getOldestConsecutiveBlock(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const [rows]: any = await DB.query(`SELECT height, UNIX_TIMESTAMP(blockTimestamp) as timestamp, difficulty FROM blocks ORDER BY height DESC`);
|
||||||
|
for (let i = 0; i < rows.length - 1; ++i) {
|
||||||
|
if (rows[i].height - rows[i + 1].height > 1) {
|
||||||
|
return rows[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows[rows.length - 1];
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new BlocksRepository();
|
export default new BlocksRepository();
|
||||||
|
@ -46,9 +46,38 @@ class DifficultyAdjustmentsRepository {
|
|||||||
query += ` GROUP BY UNIX_TIMESTAMP(time) DIV ${86400}`;
|
query += ` GROUP BY UNIX_TIMESTAMP(time) DIV ${86400}`;
|
||||||
|
|
||||||
if (descOrder === true) {
|
if (descOrder === true) {
|
||||||
query += ` ORDER BY time DESC`;
|
query += ` ORDER BY height DESC`;
|
||||||
} else {
|
} else {
|
||||||
query += ` ORDER BY time`;
|
query += ` ORDER BY height`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await DB.query(query);
|
||||||
|
return rows as IndexedDifficultyAdjustment[];
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot get difficulty adjustments from the database. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getRawAdjustments(interval: string | null, descOrder: boolean = false): Promise<IndexedDifficultyAdjustment[]> {
|
||||||
|
interval = Common.getSqlInterval(interval);
|
||||||
|
|
||||||
|
let query = `SELECT
|
||||||
|
UNIX_TIMESTAMP(time) as time,
|
||||||
|
height as height,
|
||||||
|
difficulty as difficulty,
|
||||||
|
adjustment as adjustment
|
||||||
|
FROM difficulty_adjustments`;
|
||||||
|
|
||||||
|
if (interval) {
|
||||||
|
query += ` WHERE time BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descOrder === true) {
|
||||||
|
query += ` ORDER BY height DESC`;
|
||||||
|
} else {
|
||||||
|
query += ` ORDER BY height`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { escape } from 'mysql2';
|
import { escape } from 'mysql2';
|
||||||
import { Common } from '../api/common';
|
import { Common } from '../api/common';
|
||||||
import config from '../config';
|
|
||||||
import DB from '../database';
|
import DB from '../database';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import PoolsRepository from './PoolsRepository';
|
import PoolsRepository from './PoolsRepository';
|
||||||
@ -30,6 +29,32 @@ class HashratesRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async $getRawNetworkDailyHashrate(interval: string | null): Promise<any[]> {
|
||||||
|
interval = Common.getSqlInterval(interval);
|
||||||
|
|
||||||
|
let query = `SELECT
|
||||||
|
UNIX_TIMESTAMP(hashrate_timestamp) as timestamp,
|
||||||
|
avg_hashrate as avgHashrate
|
||||||
|
FROM hashrates`;
|
||||||
|
|
||||||
|
if (interval) {
|
||||||
|
query += ` WHERE hashrate_timestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()
|
||||||
|
AND hashrates.type = 'daily'`;
|
||||||
|
} else {
|
||||||
|
query += ` WHERE hashrates.type = 'daily'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER by hashrate_timestamp`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows]: any[] = await DB.query(query);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async $getNetworkDailyHashrate(interval: string | null): Promise<any[]> {
|
public async $getNetworkDailyHashrate(interval: string | null): Promise<any[]> {
|
||||||
interval = Common.getSqlInterval(interval);
|
interval = Common.getSqlInterval(interval);
|
||||||
|
|
||||||
|
@ -4,6 +4,12 @@ import { Prices } from '../tasks/price-updater';
|
|||||||
|
|
||||||
class PricesRepository {
|
class PricesRepository {
|
||||||
public async $savePrices(time: number, prices: Prices): Promise<void> {
|
public async $savePrices(time: number, prices: Prices): Promise<void> {
|
||||||
|
if (prices.USD === -1) {
|
||||||
|
// Some historical price entries have not USD prices, so we just ignore them to avoid future UX issues
|
||||||
|
// As of today there are only 4 (on 2013-09-05, 2013-09-19, 2013-09-12 and 2013-09-26) so that's fine
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await DB.query(`
|
await DB.query(`
|
||||||
INSERT INTO prices(time, USD, EUR, GBP, CAD, CHF, AUD, JPY)
|
INSERT INTO prices(time, USD, EUR, GBP, CAD, CHF, AUD, JPY)
|
||||||
@ -17,17 +23,17 @@ class PricesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async $getOldestPriceTime(): Promise<number> {
|
public async $getOldestPriceTime(): Promise<number> {
|
||||||
const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices ORDER BY time LIMIT 1`);
|
const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != -1 ORDER BY time LIMIT 1`);
|
||||||
return oldestRow[0] ? oldestRow[0].time : 0;
|
return oldestRow[0] ? oldestRow[0].time : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $getLatestPriceTime(): Promise<number> {
|
public async $getLatestPriceTime(): Promise<number> {
|
||||||
const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices ORDER BY time DESC LIMIT 1`);
|
const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != -1 ORDER BY time DESC LIMIT 1`);
|
||||||
return oldestRow[0] ? oldestRow[0].time : 0;
|
return oldestRow[0] ? oldestRow[0].time : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $getPricesTimes(): Promise<number[]> {
|
public async $getPricesTimes(): Promise<number[]> {
|
||||||
const [times]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices`);
|
const [times]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != -1`);
|
||||||
return times.map(time => time.time);
|
return times.map(time => time.time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -734,7 +734,7 @@ class Routes {
|
|||||||
|
|
||||||
public async $getDifficultyAdjustments(req: Request, res: Response) {
|
public async $getDifficultyAdjustments(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const difficulty = await DifficultyAdjustmentsRepository.$getAdjustments(req.params.interval, true);
|
const difficulty = await DifficultyAdjustmentsRepository.$getRawAdjustments(req.params.interval, true);
|
||||||
res.header('Pragma', 'public');
|
res.header('Pragma', 'public');
|
||||||
res.header('Cache-control', 'public');
|
res.header('Cache-control', 'public');
|
||||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||||
@ -790,7 +790,7 @@ class Routes {
|
|||||||
|
|
||||||
public async getBlocks(req: Request, res: Response) {
|
public async getBlocks(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
if (['mainnet', 'testnet', 'signet', 'regtest'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
||||||
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
|
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
|
||||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||||
res.json(await blocks.$getBlocks(height, 15));
|
res.json(await blocks.$getBlocks(height, 15));
|
||||||
|
396
backend/src/tasks/lightning/node-sync.service.ts
Normal file
396
backend/src/tasks/lightning/node-sync.service.ts
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
import { chanNumber } from 'bolt07';
|
||||||
|
import DB from '../../database';
|
||||||
|
import logger from '../../logger';
|
||||||
|
import channelsApi from '../../api/explorer/channels.api';
|
||||||
|
import bitcoinClient from '../../api/bitcoin/bitcoin-client';
|
||||||
|
import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory';
|
||||||
|
import config from '../../config';
|
||||||
|
import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface';
|
||||||
|
import lightningApi from '../../api/lightning/lightning-api-factory';
|
||||||
|
import { ILightningApi } from '../../api/lightning/lightning-api.interface';
|
||||||
|
|
||||||
|
class NodeSyncService {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
public async $startService() {
|
||||||
|
logger.info('Starting node sync service');
|
||||||
|
|
||||||
|
await this.$runUpdater();
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
await this.$runUpdater();
|
||||||
|
}, 1000 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $runUpdater() {
|
||||||
|
try {
|
||||||
|
logger.info(`Updating nodes and channels...`);
|
||||||
|
|
||||||
|
const networkGraph = await lightningApi.$getNetworkGraph();
|
||||||
|
|
||||||
|
for (const node of networkGraph.nodes) {
|
||||||
|
await this.$saveNode(node);
|
||||||
|
}
|
||||||
|
logger.info(`Nodes updated.`);
|
||||||
|
|
||||||
|
await this.$setChannelsInactive();
|
||||||
|
|
||||||
|
for (const channel of networkGraph.channels) {
|
||||||
|
await this.$saveChannel(channel);
|
||||||
|
}
|
||||||
|
logger.info(`Channels updated.`);
|
||||||
|
|
||||||
|
await this.$findInactiveNodesAndChannels();
|
||||||
|
await this.$lookUpCreationDateFromChain();
|
||||||
|
await this.$updateNodeFirstSeen();
|
||||||
|
await this.$scanForClosedChannels();
|
||||||
|
await this.$runClosedChannelsForensics();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$updateNodes() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method look up the creation date of the earliest channel of the node
|
||||||
|
// and update the node to that date in order to get the earliest first seen date
|
||||||
|
private async $updateNodeFirstSeen() {
|
||||||
|
try {
|
||||||
|
const [nodes]: any[] = await DB.query(`SELECT nodes.public_key, UNIX_TIMESTAMP(nodes.first_seen) AS first_seen, (SELECT UNIX_TIMESTAMP(created) FROM channels WHERE channels.node1_public_key = nodes.public_key ORDER BY created ASC LIMIT 1) AS created1, (SELECT UNIX_TIMESTAMP(created) FROM channels WHERE channels.node2_public_key = nodes.public_key ORDER BY created ASC LIMIT 1) AS created2 FROM nodes`);
|
||||||
|
for (const node of nodes) {
|
||||||
|
let lowest = 0;
|
||||||
|
if (node.created1) {
|
||||||
|
if (node.created2 && node.created2 < node.created1) {
|
||||||
|
lowest = node.created2;
|
||||||
|
} else {
|
||||||
|
lowest = node.created1;
|
||||||
|
}
|
||||||
|
} else if (node.created2) {
|
||||||
|
lowest = node.created2;
|
||||||
|
}
|
||||||
|
if (lowest && lowest < node.first_seen) {
|
||||||
|
const query = `UPDATE nodes SET first_seen = FROM_UNIXTIME(?) WHERE public_key = ?`;
|
||||||
|
const params = [lowest, node.public_key];
|
||||||
|
await DB.query(query, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`Node first seen dates scan complete.`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$updateNodeFirstSeen() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $lookUpCreationDateFromChain() {
|
||||||
|
logger.info(`Running channel creation date lookup...`);
|
||||||
|
try {
|
||||||
|
const channels = await channelsApi.$getChannelsWithoutCreatedDate();
|
||||||
|
for (const channel of channels) {
|
||||||
|
const transaction = await bitcoinClient.getRawTransaction(channel.transaction_id, 1);
|
||||||
|
await DB.query(`UPDATE channels SET created = FROM_UNIXTIME(?) WHERE channels.id = ?`, [transaction.blocktime, channel.id]);
|
||||||
|
}
|
||||||
|
logger.info(`Channel creation dates scan complete.`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$setCreationDateFromChain() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looking for channels whos nodes are inactive
|
||||||
|
private async $findInactiveNodesAndChannels(): Promise<void> {
|
||||||
|
logger.info(`Running inactive channels scan...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const [channels]: [ILightningApi.Channel[]] = await DB.query(`SELECT channels.id FROM channels WHERE channels.status = 1 AND ((SELECT COUNT(*) FROM nodes WHERE nodes.public_key = channels.node1_public_key) = 0 OR (SELECT COUNT(*) FROM nodes WHERE nodes.public_key = channels.node2_public_key) = 0)`);
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
await this.$updateChannelStatus(channel.id, 0);
|
||||||
|
}
|
||||||
|
logger.info(`Inactive channels scan complete.`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$findInactiveNodesAndChannels() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $scanForClosedChannels(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info(`Starting closed channels scan...`);
|
||||||
|
const channels = await channelsApi.$getChannelsByStatus(0);
|
||||||
|
for (const channel of channels) {
|
||||||
|
const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout);
|
||||||
|
if (spendingTx.spent === true && spendingTx.status?.confirmed === true) {
|
||||||
|
logger.debug('Marking channel: ' + channel.id + ' as closed.');
|
||||||
|
await DB.query(`UPDATE channels SET status = 2, closing_date = FROM_UNIXTIME(?) WHERE id = ?`,
|
||||||
|
[spendingTx.status.block_time, channel.id]);
|
||||||
|
if (spendingTx.txid && !channel.closing_transaction_id) {
|
||||||
|
await DB.query(`UPDATE channels SET closing_transaction_id = ? WHERE id = ?`, [spendingTx.txid, channel.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`Closed channels scan complete.`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Mutually closed
|
||||||
|
2. Forced closed
|
||||||
|
3. Forced closed with penalty
|
||||||
|
*/
|
||||||
|
|
||||||
|
private async $runClosedChannelsForensics(): Promise<void> {
|
||||||
|
if (!config.ESPLORA.REST_API_URL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
logger.info(`Started running closed channel forensics...`);
|
||||||
|
const channels = await channelsApi.$getClosedChannelsWithoutReason();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
} 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`Closed channels forensics scan complete.`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findLightningScript(vin: IEsploraApi.Vin): number {
|
||||||
|
const topElement = vin.witness[vin.witness.length - 2];
|
||||||
|
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) {
|
||||||
|
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
|
||||||
|
if (topElement === '01') {
|
||||||
|
// top element is '01' to get in the revocation path
|
||||||
|
// 'Revoked Lightning Force Close';
|
||||||
|
// Penalty force closed
|
||||||
|
return 2;
|
||||||
|
} else {
|
||||||
|
// top element is '', this is a delayed to_local output
|
||||||
|
// 'Lightning Force Close';
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) ||
|
||||||
|
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm)
|
||||||
|
) {
|
||||||
|
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
|
||||||
|
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
|
||||||
|
if (topElement.length === 66) {
|
||||||
|
// top element is a public key
|
||||||
|
// 'Revoked Lightning HTLC'; Penalty force closed
|
||||||
|
return 4;
|
||||||
|
} else if (topElement) {
|
||||||
|
// top element is a preimage
|
||||||
|
// 'Lightning HTLC';
|
||||||
|
return 5;
|
||||||
|
} else {
|
||||||
|
// top element is '' to get in the expiry of the script
|
||||||
|
// 'Expired Lightning HTLC';
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
} else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) {
|
||||||
|
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
|
||||||
|
if (topElement) {
|
||||||
|
// top element is a signature
|
||||||
|
// 'Lightning Anchor';
|
||||||
|
return 7;
|
||||||
|
} else {
|
||||||
|
// top element is '', it has been swept after 16 blocks
|
||||||
|
// 'Swept Lightning Anchor';
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $saveChannel(channel: ILightningApi.Channel): Promise<void> {
|
||||||
|
const fromChannel = chanNumber({ channel: channel.id }).number;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `INSERT INTO channels
|
||||||
|
(
|
||||||
|
id,
|
||||||
|
short_id,
|
||||||
|
capacity,
|
||||||
|
transaction_id,
|
||||||
|
transaction_vout,
|
||||||
|
updated_at,
|
||||||
|
status,
|
||||||
|
node1_public_key,
|
||||||
|
node1_base_fee_mtokens,
|
||||||
|
node1_cltv_delta,
|
||||||
|
node1_fee_rate,
|
||||||
|
node1_is_disabled,
|
||||||
|
node1_max_htlc_mtokens,
|
||||||
|
node1_min_htlc_mtokens,
|
||||||
|
node1_updated_at,
|
||||||
|
node2_public_key,
|
||||||
|
node2_base_fee_mtokens,
|
||||||
|
node2_cltv_delta,
|
||||||
|
node2_fee_rate,
|
||||||
|
node2_is_disabled,
|
||||||
|
node2_max_htlc_mtokens,
|
||||||
|
node2_min_htlc_mtokens,
|
||||||
|
node2_updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
capacity = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
status = 1,
|
||||||
|
node1_public_key = ?,
|
||||||
|
node1_base_fee_mtokens = ?,
|
||||||
|
node1_cltv_delta = ?,
|
||||||
|
node1_fee_rate = ?,
|
||||||
|
node1_is_disabled = ?,
|
||||||
|
node1_max_htlc_mtokens = ?,
|
||||||
|
node1_min_htlc_mtokens = ?,
|
||||||
|
node1_updated_at = ?,
|
||||||
|
node2_public_key = ?,
|
||||||
|
node2_base_fee_mtokens = ?,
|
||||||
|
node2_cltv_delta = ?,
|
||||||
|
node2_fee_rate = ?,
|
||||||
|
node2_is_disabled = ?,
|
||||||
|
node2_max_htlc_mtokens = ?,
|
||||||
|
node2_min_htlc_mtokens = ?,
|
||||||
|
node2_updated_at = ?
|
||||||
|
;`;
|
||||||
|
|
||||||
|
await DB.query(query, [
|
||||||
|
fromChannel,
|
||||||
|
channel.id,
|
||||||
|
channel.capacity,
|
||||||
|
channel.transaction_id,
|
||||||
|
channel.transaction_vout,
|
||||||
|
channel.updated_at ? this.utcDateToMysql(channel.updated_at) : 0,
|
||||||
|
channel.policies[0].public_key,
|
||||||
|
channel.policies[0].base_fee_mtokens,
|
||||||
|
channel.policies[0].cltv_delta,
|
||||||
|
channel.policies[0].fee_rate,
|
||||||
|
channel.policies[0].is_disabled,
|
||||||
|
channel.policies[0].max_htlc_mtokens,
|
||||||
|
channel.policies[0].min_htlc_mtokens,
|
||||||
|
channel.policies[0].updated_at ? this.utcDateToMysql(channel.policies[0].updated_at) : 0,
|
||||||
|
channel.policies[1].public_key,
|
||||||
|
channel.policies[1].base_fee_mtokens,
|
||||||
|
channel.policies[1].cltv_delta,
|
||||||
|
channel.policies[1].fee_rate,
|
||||||
|
channel.policies[1].is_disabled,
|
||||||
|
channel.policies[1].max_htlc_mtokens,
|
||||||
|
channel.policies[1].min_htlc_mtokens,
|
||||||
|
channel.policies[1].updated_at ? this.utcDateToMysql(channel.policies[1].updated_at) : 0,
|
||||||
|
channel.capacity,
|
||||||
|
channel.updated_at ? this.utcDateToMysql(channel.updated_at) : 0,
|
||||||
|
channel.policies[0].public_key,
|
||||||
|
channel.policies[0].base_fee_mtokens,
|
||||||
|
channel.policies[0].cltv_delta,
|
||||||
|
channel.policies[0].fee_rate,
|
||||||
|
channel.policies[0].is_disabled,
|
||||||
|
channel.policies[0].max_htlc_mtokens,
|
||||||
|
channel.policies[0].min_htlc_mtokens,
|
||||||
|
channel.policies[0].updated_at ? this.utcDateToMysql(channel.policies[0].updated_at) : 0,
|
||||||
|
channel.policies[1].public_key,
|
||||||
|
channel.policies[1].base_fee_mtokens,
|
||||||
|
channel.policies[1].cltv_delta,
|
||||||
|
channel.policies[1].fee_rate,
|
||||||
|
channel.policies[1].is_disabled,
|
||||||
|
channel.policies[1].max_htlc_mtokens,
|
||||||
|
channel.policies[1].min_htlc_mtokens,
|
||||||
|
channel.policies[1].updated_at ? this.utcDateToMysql(channel.policies[1].updated_at) : 0,
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$saveChannel() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $updateChannelStatus(channelShortId: string, status: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await DB.query(`UPDATE channels SET status = ? WHERE id = ?`, [status, channelShortId]);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$updateChannelStatus() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $setChannelsInactive(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await DB.query(`UPDATE channels SET status = 0 WHERE status = 1`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$setChannelsInactive() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $saveNode(node: ILightningApi.Node): Promise<void> {
|
||||||
|
try {
|
||||||
|
const updatedAt = node.updated_at ? this.utcDateToMysql(node.updated_at) : '0000-00-00 00:00:00';
|
||||||
|
const sockets = node.sockets.join(',');
|
||||||
|
const query = `INSERT INTO nodes(
|
||||||
|
public_key,
|
||||||
|
first_seen,
|
||||||
|
updated_at,
|
||||||
|
alias,
|
||||||
|
color,
|
||||||
|
sockets
|
||||||
|
)
|
||||||
|
VALUES (?, NOW(), ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at = ?, alias = ?, color = ?, sockets = ?;`;
|
||||||
|
|
||||||
|
await DB.query(query, [
|
||||||
|
node.public_key,
|
||||||
|
updatedAt,
|
||||||
|
node.alias,
|
||||||
|
node.color,
|
||||||
|
sockets,
|
||||||
|
updatedAt,
|
||||||
|
node.alias,
|
||||||
|
node.color,
|
||||||
|
sockets,
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$saveNode() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private utcDateToMysql(dateString: string): string {
|
||||||
|
const d = new Date(Date.parse(dateString));
|
||||||
|
return d.toISOString().split('T')[0] + ' ' + d.toTimeString().split(' ')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NodeSyncService();
|
276
backend/src/tasks/lightning/stats-updater.service.ts
Normal file
276
backend/src/tasks/lightning/stats-updater.service.ts
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
|
||||||
|
import DB from '../../database';
|
||||||
|
import logger from '../../logger';
|
||||||
|
import lightningApi from '../../api/lightning/lightning-api-factory';
|
||||||
|
import channelsApi from '../../api/explorer/channels.api';
|
||||||
|
import * as net from 'net';
|
||||||
|
|
||||||
|
class LightningStatsUpdater {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
public async $startService() {
|
||||||
|
logger.info('Starting Lightning Stats service');
|
||||||
|
let isInSync = false;
|
||||||
|
let error: any;
|
||||||
|
try {
|
||||||
|
error = null;
|
||||||
|
isInSync = await this.$lightningIsSynced();
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
if (!isInSync) {
|
||||||
|
if (error) {
|
||||||
|
logger.warn('Was not able to fetch Lightning Node status: ' + (error instanceof Error ? error.message : error) + '. Retrying in 1 minute...');
|
||||||
|
} else {
|
||||||
|
logger.notice('The Lightning graph is not yet in sync. Retrying in 1 minute...');
|
||||||
|
}
|
||||||
|
setTimeout(() => this.$startService(), 60 * 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nextHourInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), Math.floor(now.getHours() / 1) + 1, 0, 0, 0);
|
||||||
|
const difference = nextHourInterval.getTime() - now.getTime();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setInterval(async () => {
|
||||||
|
await this.$runTasks();
|
||||||
|
}, 1000 * 60 * 60);
|
||||||
|
}, difference);
|
||||||
|
|
||||||
|
await this.$runTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $lightningIsSynced(): Promise<boolean> {
|
||||||
|
const nodeInfo = await lightningApi.$getInfo();
|
||||||
|
return nodeInfo.is_synced_to_chain && nodeInfo.is_synced_to_graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $runTasks() {
|
||||||
|
await this.$populateHistoricalData();
|
||||||
|
await this.$logLightningStatsDaily();
|
||||||
|
await this.$logNodeStatsDaily();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $logNodeStatsDaily() {
|
||||||
|
const currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
try {
|
||||||
|
const [state]: any = await DB.query(`SELECT string FROM state WHERE name = 'last_node_stats'`);
|
||||||
|
// Only store once per day
|
||||||
|
if (state[0].string === currentDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Running daily node stats update...`);
|
||||||
|
|
||||||
|
const query = `SELECT nodes.public_key, c1.channels_count_left, c2.channels_count_right, c1.channels_capacity_left, c2.channels_capacity_right FROM nodes LEFT JOIN (SELECT node1_public_key, COUNT(id) AS channels_count_left, SUM(capacity) AS channels_capacity_left FROM channels WHERE channels.status < 2 GROUP BY node1_public_key) c1 ON c1.node1_public_key = nodes.public_key LEFT JOIN (SELECT node2_public_key, COUNT(id) AS channels_count_right, SUM(capacity) AS channels_capacity_right FROM channels WHERE channels.status < 2 GROUP BY node2_public_key) c2 ON c2.node2_public_key = nodes.public_key`;
|
||||||
|
const [nodes]: any = await DB.query(query);
|
||||||
|
|
||||||
|
// First run we won't have any nodes yet
|
||||||
|
if (nodes.length < 10) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
await DB.query(
|
||||||
|
`INSERT INTO node_stats(public_key, added, capacity, channels) VALUES (?, NOW(), ?, ?)`,
|
||||||
|
[node.public_key, (parseInt(node.channels_capacity_left || 0, 10)) + (parseInt(node.channels_capacity_right || 0, 10)),
|
||||||
|
node.channels_count_left + node.channels_count_right]);
|
||||||
|
}
|
||||||
|
await DB.query(`UPDATE state SET string = ? WHERE name = 'last_node_stats'`, [currentDate]);
|
||||||
|
logger.info('Daily node stats has updated.');
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$logNodeStatsDaily() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only run this on first launch
|
||||||
|
private async $populateHistoricalData() {
|
||||||
|
const startTime = '2018-01-13';
|
||||||
|
try {
|
||||||
|
const [rows]: any = await DB.query(`SELECT COUNT(*) FROM lightning_stats`);
|
||||||
|
// Only store once per day
|
||||||
|
if (rows[0]['COUNT(*)'] > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info(`Running historical stats population...`);
|
||||||
|
|
||||||
|
const [channels]: any = await DB.query(`SELECT capacity, created, closing_date FROM channels ORDER BY created ASC`);
|
||||||
|
|
||||||
|
let date: Date = new Date(startTime);
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
while (date < currentDate) {
|
||||||
|
let totalCapacity = 0;
|
||||||
|
let channelsCount = 0;
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (new Date(channel.created) > date) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (channel.closing_date !== null && new Date(channel.closing_date) < date) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totalCapacity += channel.capacity;
|
||||||
|
channelsCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `INSERT INTO lightning_stats(
|
||||||
|
added,
|
||||||
|
channel_count,
|
||||||
|
node_count,
|
||||||
|
total_capacity,
|
||||||
|
tor_nodes,
|
||||||
|
clearnet_nodes,
|
||||||
|
unannounced_nodes
|
||||||
|
)
|
||||||
|
VALUES (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`;
|
||||||
|
|
||||||
|
await DB.query(query, [
|
||||||
|
date.getTime() / 1000,
|
||||||
|
channelsCount,
|
||||||
|
0,
|
||||||
|
totalCapacity,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add one day and continue
|
||||||
|
date.setDate(date.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [nodes]: any = await DB.query(`SELECT first_seen, sockets FROM nodes ORDER BY first_seen ASC`);
|
||||||
|
date = new Date(startTime);
|
||||||
|
|
||||||
|
while (date < currentDate) {
|
||||||
|
let nodeCount = 0;
|
||||||
|
let clearnetNodes = 0;
|
||||||
|
let torNodes = 0;
|
||||||
|
let unannouncedNodes = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (new Date(node.first_seen) > date) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nodeCount++;
|
||||||
|
|
||||||
|
const sockets = node.sockets.split(',');
|
||||||
|
let isUnnanounced = true;
|
||||||
|
for (const socket of sockets) {
|
||||||
|
const hasOnion = socket.indexOf('.onion') !== -1;
|
||||||
|
if (hasOnion) {
|
||||||
|
torNodes++;
|
||||||
|
isUnnanounced = false;
|
||||||
|
}
|
||||||
|
const hasClearnet = [4, 6].includes(net.isIP(socket.split(':')[0]));
|
||||||
|
if (hasClearnet) {
|
||||||
|
clearnetNodes++;
|
||||||
|
isUnnanounced = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isUnnanounced) {
|
||||||
|
unannouncedNodes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `UPDATE lightning_stats SET node_count = ?, tor_nodes = ?, clearnet_nodes = ?, unannounced_nodes = ? WHERE added = FROM_UNIXTIME(?)`;
|
||||||
|
|
||||||
|
await DB.query(query, [
|
||||||
|
nodeCount,
|
||||||
|
torNodes,
|
||||||
|
clearnetNodes,
|
||||||
|
unannouncedNodes,
|
||||||
|
date.getTime() / 1000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add one day and continue
|
||||||
|
date.setDate(date.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Historical stats populated.');
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$populateHistoricalData() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $logLightningStatsDaily() {
|
||||||
|
const currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
try {
|
||||||
|
const [state]: any = await DB.query(`SELECT string FROM state WHERE name = 'last_node_stats'`);
|
||||||
|
// Only store once per day
|
||||||
|
if (state[0].string === currentDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Running lightning daily stats log...`);
|
||||||
|
|
||||||
|
const networkGraph = await lightningApi.$getNetworkGraph();
|
||||||
|
let total_capacity = 0;
|
||||||
|
for (const channel of networkGraph.channels) {
|
||||||
|
if (channel.capacity) {
|
||||||
|
total_capacity += channel.capacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let clearnetNodes = 0;
|
||||||
|
let torNodes = 0;
|
||||||
|
let unannouncedNodes = 0;
|
||||||
|
for (const node of networkGraph.nodes) {
|
||||||
|
let isUnnanounced = true;
|
||||||
|
for (const socket of node.sockets) {
|
||||||
|
const hasOnion = socket.indexOf('.onion') !== -1;
|
||||||
|
if (hasOnion) {
|
||||||
|
torNodes++;
|
||||||
|
isUnnanounced = false;
|
||||||
|
}
|
||||||
|
const hasClearnet = [4, 6].includes(net.isIP(socket.split(':')[0]));
|
||||||
|
if (hasClearnet) {
|
||||||
|
clearnetNodes++;
|
||||||
|
isUnnanounced = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isUnnanounced) {
|
||||||
|
unannouncedNodes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelStats = await channelsApi.$getChannelsStats();
|
||||||
|
|
||||||
|
const query = `INSERT INTO lightning_stats(
|
||||||
|
added,
|
||||||
|
channel_count,
|
||||||
|
node_count,
|
||||||
|
total_capacity,
|
||||||
|
tor_nodes,
|
||||||
|
clearnet_nodes,
|
||||||
|
unannounced_nodes,
|
||||||
|
avg_capacity,
|
||||||
|
avg_fee_rate,
|
||||||
|
avg_base_fee_mtokens,
|
||||||
|
med_capacity,
|
||||||
|
med_fee_rate,
|
||||||
|
med_base_fee_mtokens
|
||||||
|
)
|
||||||
|
VALUES (NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||||
|
|
||||||
|
await DB.query(query, [
|
||||||
|
networkGraph.channels.length,
|
||||||
|
networkGraph.nodes.length,
|
||||||
|
total_capacity,
|
||||||
|
torNodes,
|
||||||
|
clearnetNodes,
|
||||||
|
unannouncedNodes,
|
||||||
|
channelStats.avgCapacity,
|
||||||
|
channelStats.avgFeeRate,
|
||||||
|
channelStats.avgBaseFee,
|
||||||
|
channelStats.medianCapacity,
|
||||||
|
channelStats.medianFeeRate,
|
||||||
|
channelStats.medianBaseFee,
|
||||||
|
]);
|
||||||
|
logger.info(`Lightning daily stats done.`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$logLightningStatsDaily() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LightningStatsUpdater();
|
@ -20,7 +20,8 @@
|
|||||||
"USER_AGENT": "__MEMPOOL_USER_AGENT__",
|
"USER_AGENT": "__MEMPOOL_USER_AGENT__",
|
||||||
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
||||||
"INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__,
|
"INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__,
|
||||||
"BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__
|
"BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__,
|
||||||
|
"AUTOMATIC_BLOCK_REINDEXING": __MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__
|
||||||
},
|
},
|
||||||
"CORE_RPC": {
|
"CORE_RPC": {
|
||||||
"HOST": "__CORE_RPC_HOST__",
|
"HOST": "__CORE_RPC_HOST__",
|
||||||
|
@ -22,6 +22,8 @@ __MEMPOOL_EXTERNAL_MAX_RETRY__=${MEMPOOL_EXTERNAL_MAX_RETRY:=1}
|
|||||||
__MEMPOOL_EXTERNAL_RETRY_INTERVAL__=${MEMPOOL_EXTERNAL_RETRY_INTERVAL:=0}
|
__MEMPOOL_EXTERNAL_RETRY_INTERVAL__=${MEMPOOL_EXTERNAL_RETRY_INTERVAL:=0}
|
||||||
__MEMPOOL_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool}
|
__MEMPOOL_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool}
|
||||||
__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info}
|
__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info}
|
||||||
|
__MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=false}
|
||||||
|
__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__=${MEMPOOL_AUTOMATIC_BLOCK_REINDEXING:=false}
|
||||||
|
|
||||||
# CORE_RPC
|
# CORE_RPC
|
||||||
__CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1}
|
__CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1}
|
||||||
@ -110,6 +112,8 @@ sed -i "s!__MEMPOOL_EXTERNAL_MAX_RETRY__!${__MEMPOOL_EXTERNAL_MAX_RETRY__}!g" me
|
|||||||
sed -i "s!__MEMPOOL_EXTERNAL_RETRY_INTERVAL__!${__MEMPOOL_EXTERNAL_RETRY_INTERVAL__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_EXTERNAL_RETRY_INTERVAL__!${__MEMPOOL_EXTERNAL_RETRY_INTERVAL__}!g" mempool-config.json
|
||||||
sed -i "s!__MEMPOOL_USER_AGENT__!${__MEMPOOL_USER_AGENT__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_USER_AGENT__!${__MEMPOOL_USER_AGENT__}!g" mempool-config.json
|
||||||
sed -i "s/__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__/${__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__}/g" mempool-config.json
|
sed -i "s/__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__/${__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MEMPOOL_INDEXING_BLOCKS_AMOUNT__/${__MEMPOOL_INDEXING_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__/${__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__}/g" mempool-config.json
|
||||||
|
|
||||||
sed -i "s/__CORE_RPC_HOST__/${__CORE_RPC_HOST__}/g" mempool-config.json
|
sed -i "s/__CORE_RPC_HOST__/${__CORE_RPC_HOST__}/g" mempool-config.json
|
||||||
sed -i "s/__CORE_RPC_PORT__/${__CORE_RPC_PORT__}/g" mempool-config.json
|
sed -i "s/__CORE_RPC_PORT__/${__CORE_RPC_PORT__}/g" mempool-config.json
|
||||||
|
@ -121,20 +121,20 @@ describe('Mainnet', () => {
|
|||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.get('.search-box-container > .form-control').type('1wiz').then(() => {
|
cy.get('.search-box-container > .form-control').type('1wiz').then(() => {
|
||||||
cy.wait('@search-1wiz');
|
cy.wait('@search-1wiz');
|
||||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 10);
|
cy.get('app-search-results button.dropdown-item').should('have.length', 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.get('.search-box-container > .form-control').type('S').then(() => {
|
cy.get('.search-box-container > .form-control').type('S').then(() => {
|
||||||
cy.wait('@search-1wizS');
|
cy.wait('@search-1wizS');
|
||||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 5);
|
cy.get('app-search-results button.dropdown-item').should('have.length', 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.get('.search-box-container > .form-control').type('A').then(() => {
|
cy.get('.search-box-container > .form-control').type('A').then(() => {
|
||||||
cy.wait('@search-1wizSA');
|
cy.wait('@search-1wizSA');
|
||||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1)
|
cy.get('app-search-results button.dropdown-item').should('have.length', 1)
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
|
cy.get('app-search-results button.dropdown-item.active').click().then(() => {
|
||||||
cy.url().should('include', '/address/1wizSAYSbuyXbt9d8JV8ytm5acqq2TorC');
|
cy.url().should('include', '/address/1wizSAYSbuyXbt9d8JV8ytm5acqq2TorC');
|
||||||
cy.waitForSkeletonGone();
|
cy.waitForSkeletonGone();
|
||||||
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
||||||
@ -145,8 +145,8 @@ describe('Mainnet', () => {
|
|||||||
it(`allows searching for partial case insensitive bech32m addresses: ${searchTerm}`, () => {
|
it(`allows searching for partial case insensitive bech32m addresses: ${searchTerm}`, () => {
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
|
cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
|
||||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1);
|
cy.get('app-search-results button.dropdown-item').should('have.length', 1);
|
||||||
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
|
cy.get('app-search-results button.dropdown-item.active').click().then(() => {
|
||||||
cy.url().should('include', '/address/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e');
|
cy.url().should('include', '/address/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e');
|
||||||
cy.waitForSkeletonGone();
|
cy.waitForSkeletonGone();
|
||||||
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
||||||
@ -159,8 +159,8 @@ describe('Mainnet', () => {
|
|||||||
it(`allows searching for partial case insensitive bech32 addresses: ${searchTerm}`, () => {
|
it(`allows searching for partial case insensitive bech32 addresses: ${searchTerm}`, () => {
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
|
cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
|
||||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1);
|
cy.get('app-search-results button.dropdown-item').should('have.length', 1);
|
||||||
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
|
cy.get('app-search-results button.dropdown-item.active').click().then(() => {
|
||||||
cy.url().should('include', '/address/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy');
|
cy.url().should('include', '/address/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy');
|
||||||
cy.waitForSkeletonGone();
|
cy.waitForSkeletonGone();
|
||||||
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
||||||
|
@ -16,5 +16,6 @@
|
|||||||
"MEMPOOL_WEBSITE_URL": "https://mempool.space",
|
"MEMPOOL_WEBSITE_URL": "https://mempool.space",
|
||||||
"LIQUID_WEBSITE_URL": "https://liquid.network",
|
"LIQUID_WEBSITE_URL": "https://liquid.network",
|
||||||
"BISQ_WEBSITE_URL": "https://bisq.markets",
|
"BISQ_WEBSITE_URL": "https://bisq.markets",
|
||||||
"MINING_DASHBOARD": true
|
"MINING_DASHBOARD": true,
|
||||||
|
"LIGHTNING": false
|
||||||
}
|
}
|
||||||
|
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mempool-frontend",
|
"name": "mempool-frontend",
|
||||||
"version": "2.4.1-dev",
|
"version": "2.5.0-dev",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mempool-frontend",
|
"name": "mempool-frontend",
|
||||||
"version": "2.4.1-dev",
|
"version": "2.5.0-dev",
|
||||||
"license": "GNU Affero General Public License v3.0",
|
"license": "GNU Affero General Public License v3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular-devkit/build-angular": "~13.3.7",
|
"@angular-devkit/build-angular": "~13.3.7",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mempool-frontend",
|
"name": "mempool-frontend",
|
||||||
"version": "2.4.1-dev",
|
"version": "2.5.0-dev",
|
||||||
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
|
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
|
||||||
"license": "GNU Affero General Public License v3.0",
|
"license": "GNU Affero General Public License v3.0",
|
||||||
"homepage": "https://mempool.space",
|
"homepage": "https://mempool.space",
|
||||||
|
@ -102,6 +102,16 @@ if (configContent && configContent.BASE_MODULE === 'bisq') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PROXY_CONFIG.push(...[
|
PROXY_CONFIG.push(...[
|
||||||
|
{
|
||||||
|
context: ['/testnet/api/v1/lightning/**'],
|
||||||
|
target: `http://localhost:8999`,
|
||||||
|
secure: false,
|
||||||
|
changeOrigin: true,
|
||||||
|
proxyTimeout: 30000,
|
||||||
|
pathRewrite: {
|
||||||
|
"^/testnet": ""
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
context: ['/api/v1/**'],
|
context: ['/api/v1/**'],
|
||||||
target: `http://localhost:8999`,
|
target: `http://localhost:8999`,
|
||||||
|
@ -96,6 +96,10 @@ let routes: Routes = [
|
|||||||
path: 'api',
|
path: 'api',
|
||||||
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lightning',
|
||||||
|
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -186,6 +190,10 @@ let routes: Routes = [
|
|||||||
path: 'api',
|
path: 'api',
|
||||||
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lightning',
|
||||||
|
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -273,6 +281,10 @@ let routes: Routes = [
|
|||||||
path: 'api',
|
path: 'api',
|
||||||
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lightning',
|
||||||
|
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,4 +1,13 @@
|
|||||||
<span
|
<a *ngIf="channel; else default" [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">
|
||||||
|
<span
|
||||||
*ngIf="label"
|
*ngIf="label"
|
||||||
class="badge badge-pill badge-warning"
|
class="badge badge-pill badge-warning"
|
||||||
>{{ label }}</span>
|
>{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ng-template #default>
|
||||||
|
<span
|
||||||
|
*ngIf="label"
|
||||||
|
class="badge badge-pill badge-warning"
|
||||||
|
>{{ label }}</span>
|
||||||
|
</ng-template>
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core';
|
||||||
import { Vin, Vout } from '../../interfaces/electrs.interface';
|
import { Vin, Vout } from '../../interfaces/electrs.interface';
|
||||||
import { StateService } from 'src/app/services/state.service';
|
import { StateService } from 'src/app/services/state.service';
|
||||||
|
|
||||||
@ -8,11 +8,12 @@ import { StateService } from 'src/app/services/state.service';
|
|||||||
styleUrls: ['./address-labels.component.scss'],
|
styleUrls: ['./address-labels.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class AddressLabelsComponent implements OnInit {
|
export class AddressLabelsComponent implements OnChanges {
|
||||||
network = '';
|
network = '';
|
||||||
|
|
||||||
@Input() vin: Vin;
|
@Input() vin: Vin;
|
||||||
@Input() vout: Vout;
|
@Input() vout: Vout;
|
||||||
|
@Input() channel: any;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
||||||
@ -22,14 +23,21 @@ export class AddressLabelsComponent implements OnInit {
|
|||||||
this.network = stateService.network;
|
this.network = stateService.network;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnChanges() {
|
||||||
if (this.vin) {
|
if (this.channel) {
|
||||||
|
this.handleChannel();
|
||||||
|
} else if (this.vin) {
|
||||||
this.handleVin();
|
this.handleVin();
|
||||||
} else if (this.vout) {
|
} else if (this.vout) {
|
||||||
this.handleVout();
|
this.handleVout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleChannel() {
|
||||||
|
const type = this.vout ? 'open' : 'close';
|
||||||
|
this.label = `Channel ${type}: ${this.channel.node_left.alias} <> ${this.channel.node_right.alias}`;
|
||||||
|
}
|
||||||
|
|
||||||
handleVin() {
|
handleVin() {
|
||||||
if (this.vin.inner_witnessscript_asm) {
|
if (this.vin.inner_witnessscript_asm) {
|
||||||
if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) {
|
if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) {
|
||||||
|
@ -55,10 +55,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td i18n="block.timestamp">Timestamp</td>
|
<td i18n="block.timestamp">Timestamp</td>
|
||||||
<td>
|
<td>
|
||||||
‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
|
<app-timestamp [unixTime]="block.timestamp"></app-timestamp>
|
||||||
<div class="lg-inline">
|
|
||||||
<i class="symbol">(<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>)</i>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
3
frontend/src/app/components/change/change.component.html
Normal file
3
frontend/src/app/components/change/change.component.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<span [style]="change >= 0 ? 'color: #42B747' : 'color: #B74242'">
|
||||||
|
{{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}%
|
||||||
|
</span>
|
25
frontend/src/app/components/change/change.component.ts
Normal file
25
frontend/src/app/components/change/change.component.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-change',
|
||||||
|
templateUrl: './change.component.html',
|
||||||
|
styleUrls: ['./change.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ChangeComponent implements OnChanges {
|
||||||
|
@Input() current: number;
|
||||||
|
@Input() previous: number;
|
||||||
|
|
||||||
|
change: number;
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
if (!this.previous) {
|
||||||
|
this.change = 0;
|
||||||
|
} else {
|
||||||
|
this.change = (this.current - this.previous) / this.previous * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;">
|
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;">
|
||||||
<button #btn class="btn btn-sm btn-link pt-0" style="line-height: 0.9;" [attr.data-clipboard-text]="text">
|
<button #btn class="btn btn-sm btn-link pt-0" [style]="{'line-height': size === 'small' ? '0.2' : '0.8'}" [attr.data-clipboard-text]="text">
|
||||||
<img src="./resources/clippy.svg" width="13">
|
<img src="./resources/clippy.svg" [width]="size === 'small' ? 10 : 13">
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
.btn-link {
|
.btn-link {
|
||||||
padding: 0.25rem 0 0.1rem 0.5rem;
|
padding: 0.25rem 0 0.1rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: relative;
|
||||||
|
left: -3px;
|
||||||
|
}
|
@ -11,6 +11,7 @@ import * as tlite from 'tlite';
|
|||||||
export class ClipboardComponent implements AfterViewInit {
|
export class ClipboardComponent implements AfterViewInit {
|
||||||
@ViewChild('btn') btn: ElementRef;
|
@ViewChild('btn') btn: ElementRef;
|
||||||
@ViewChild('buttonWrapper') buttonWrapper: ElementRef;
|
@ViewChild('buttonWrapper') buttonWrapper: ElementRef;
|
||||||
|
@Input() size: 'small' | 'normal' = 'normal';
|
||||||
@Input() text: string;
|
@Input() text: string;
|
||||||
copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`;
|
copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`;
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { map } from 'rxjs/operators';
|
|||||||
import { ApiService } from 'src/app/services/api.service';
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
import { formatNumber } from '@angular/common';
|
import { formatNumber } from '@angular/common';
|
||||||
import { selectPowerOfTen } from 'src/app/bitcoin.utils';
|
import { selectPowerOfTen } from 'src/app/bitcoin.utils';
|
||||||
|
import { StateService } from 'src/app/services/state.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-difficulty-adjustments-table',
|
selector: 'app-difficulty-adjustments-table',
|
||||||
@ -26,10 +27,16 @@ export class DifficultyAdjustmentsTable implements OnInit {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(LOCALE_ID) public locale: string,
|
@Inject(LOCALE_ID) public locale: string,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
|
public stateService: StateService
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
let decimals = 2;
|
||||||
|
if (this.stateService.network === 'signet') {
|
||||||
|
decimals = 5;
|
||||||
|
}
|
||||||
|
|
||||||
this.hashrateObservable$ = this.apiService.getDifficultyAdjustments$('3m')
|
this.hashrateObservable$ = this.apiService.getDifficultyAdjustments$('3m')
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => {
|
map((response) => {
|
||||||
@ -43,7 +50,7 @@ export class DifficultyAdjustmentsTable implements OnInit {
|
|||||||
change: (adjustment[3] - 1) * 100,
|
change: (adjustment[3] - 1) * 100,
|
||||||
difficultyShorten: formatNumber(
|
difficultyShorten: formatNumber(
|
||||||
adjustment[2] / selectedPowerOfTen.divider,
|
adjustment[2] / selectedPowerOfTen.divider,
|
||||||
this.locale, '1.2-2') + selectedPowerOfTen.unit
|
this.locale, `1.${decimals}-${decimals}`) + selectedPowerOfTen.unit
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
<div *ngIf="stateService.env.MINING_DASHBOARD" class="mb-3 d-inline-flex menu" style="padding: 0px 35px;">
|
<div *ngIf="stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING" class="mb-3 d-flex menu"
|
||||||
|
style="padding: 0px 35px;">
|
||||||
|
|
||||||
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1"
|
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1"
|
||||||
[routerLink]="['/graphs/mempool' | relativeUrl]">Mempool</a>
|
[routerLink]="['/graphs/mempool' | relativeUrl]">Mempool</a>
|
||||||
<div ngbDropdown class="w-50">
|
|
||||||
|
<div ngbDropdown class="w-50" *ngIf="stateService.env.MINING_DASHBOARD">
|
||||||
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="mining">Mining</button>
|
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="mining">Mining</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
||||||
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools' | relativeUrl]"
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools' | relativeUrl]"
|
||||||
@ -9,19 +12,30 @@
|
|||||||
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]"
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]"
|
||||||
i18n="mining.pools-dominance">Pools Dominance</a>
|
i18n="mining.pools-dominance">Pools Dominance</a>
|
||||||
<a class="dropdown-item" routerLinkActive="active"
|
<a class="dropdown-item" routerLinkActive="active"
|
||||||
[routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="mining.hashrate-difficulty">Hashrate & Difficulty</a>
|
[routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="mining.hashrate-difficulty">Hashrate &
|
||||||
<a class="dropdown-item" routerLinkActive="active"
|
Difficulty</a>
|
||||||
[routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" i18n="mining.block-fee-rates">Block Fee Rates</a>
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"
|
||||||
<a class="dropdown-item" routerLinkActive="active"
|
i18n="mining.block-fee-rates">Block Fee Rates</a>
|
||||||
[routerLink]="['/graphs/mining/block-fees' | relativeUrl]" i18n="mining.block-fees">Block Fees</a>
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"
|
||||||
<a class="dropdown-item" routerLinkActive="active"
|
i18n="mining.block-fees">Block Fees</a>
|
||||||
[routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" i18n="mining.block-rewards">Block Rewards</a>
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"
|
||||||
|
i18n="mining.block-rewards">Block Rewards</a>
|
||||||
<a class="dropdown-item" routerLinkActive="active"
|
<a class="dropdown-item" routerLinkActive="active"
|
||||||
[routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" i18n="mining.block-sizes-weights">Block Sizes and Weights</a>
|
[routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" i18n="mining.block-sizes-weights">Block Sizes and Weights</a>
|
||||||
<a class="dropdown-item" routerLinkActive="active"
|
<a class="dropdown-item" routerLinkActive="active"
|
||||||
[routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</a>
|
[routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ngbDropdown class="w-50" *ngIf="stateService.env.LIGHTNING">
|
||||||
|
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
||||||
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
|
||||||
|
i18n="lightning.nodes-networks">Nodes per network</a>
|
||||||
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
|
||||||
|
i18n="lightning.capacity">Network capacity</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
@ -12,8 +12,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<h5 class="card-title" i18n="block.difficulty">Difficulty</h5>
|
<h5 class="card-title" i18n="block.difficulty">Difficulty</h5>
|
||||||
<p class="card-text">
|
<p class="card-text" *ngIf="network === 'signet'">
|
||||||
{{ hashrates.currentDifficulty | amountShortener }}
|
{{ hashrates.currentDifficulty | amountShortener : 5 }}
|
||||||
|
</p>
|
||||||
|
<p class="card-text" *ngIf="network !== 'signet'">
|
||||||
|
{{ hashrates.currentDifficulty | amountShortener : 2 }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -335,6 +335,9 @@ export class HashrateChartComponent implements OnInit {
|
|||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: 'rgb(110, 112, 121)',
|
color: 'rgb(110, 112, 121)',
|
||||||
formatter: (val) => {
|
formatter: (val) => {
|
||||||
|
if (this.stateService.network === 'signet') {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
const selectedPowerOfTen: any = selectPowerOfTen(val);
|
const selectedPowerOfTen: any = selectPowerOfTen(val);
|
||||||
const newVal = Math.round(val / selectedPowerOfTen.divider);
|
const newVal = Math.round(val / selectedPowerOfTen.divider);
|
||||||
return `${newVal} ${selectedPowerOfTen.unit}`;
|
return `${newVal} ${selectedPowerOfTen.unit}`;
|
||||||
|
@ -35,6 +35,9 @@
|
|||||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
|
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
|
||||||
<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>
|
<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>
|
||||||
|
<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>
|
||||||
|
</li>
|
||||||
<li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="!stateService.env.MINING_DASHBOARD">
|
<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>
|
<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>
|
||||||
</li>
|
</li>
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { Component, Input, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
|
import { Component, Input, AfterViewInit, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { StateService } from 'src/app/services/state.service';
|
import { StateService } from 'src/app/services/state.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-qrcode',
|
selector: 'app-qrcode',
|
||||||
templateUrl: './qrcode.component.html',
|
templateUrl: './qrcode.component.html',
|
||||||
styleUrls: ['./qrcode.component.scss']
|
styleUrls: ['./qrcode.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class QrcodeComponent implements AfterViewInit {
|
export class QrcodeComponent implements AfterViewInit {
|
||||||
@Input() data: string;
|
@Input() data: string;
|
||||||
@ -19,7 +20,18 @@ export class QrcodeComponent implements AfterViewInit {
|
|||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
ngOnChanges() {
|
||||||
|
if (!this.canvas || !this.canvas.nativeElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
if (!this.stateService.isBrowser) {
|
if (!this.stateService.isBrowser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
|
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="search-box-container mr-2">
|
<div class="search-box-container mr-2">
|
||||||
<input #instance="ngbTypeahead" [ngbTypeahead]="typeaheadSearchFn" [resultFormatter]="formatterFn" (selectItem)="itemSelected()" (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="TXID, block height, hash or address">
|
<input (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="TXID, block height, hash or address">
|
||||||
|
|
||||||
|
<app-search-results #searchResults [results]="typeAhead$ | async" [searchTerm]="searchForm.get('searchText').value" (selectedResult)="selectedResult($event)"></app-search-results>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary"><fa-icon [icon]="['fas', 'search']" [fixedWidth]="true" i18n-title="search-form.search-title" title="Search"></fa-icon></button>
|
<button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary"><fa-icon [icon]="['fas', 'search']" [fixedWidth]="true" i18n-title="search-form.search-title" title="Search"></fa-icon></button>
|
||||||
|
@ -32,6 +32,7 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-box-container {
|
.search-box-container {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
min-width: 400px;
|
min-width: 400px;
|
||||||
|
@ -1,41 +1,40 @@
|
|||||||
import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild } from '@angular/core';
|
import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener } from '@angular/core';
|
||||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { AssetsService } from 'src/app/services/assets.service';
|
import { AssetsService } from 'src/app/services/assets.service';
|
||||||
import { StateService } from 'src/app/services/state.service';
|
import { StateService } from 'src/app/services/state.service';
|
||||||
import { Observable, of, Subject, merge } from 'rxjs';
|
import { Observable, of, Subject, merge, zip } from 'rxjs';
|
||||||
import { debounceTime, distinctUntilChanged, switchMap, filter, catchError, map } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged, switchMap, filter, catchError, map } from 'rxjs/operators';
|
||||||
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
|
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
|
||||||
import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
|
|
||||||
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
||||||
import { ShortenStringPipe } from 'src/app/shared/pipes/shorten-string-pipe/shorten-string.pipe';
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
|
import { SearchResultsComponent } from './search-results/search-results.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-search-form',
|
selector: 'app-search-form',
|
||||||
templateUrl: './search-form.component.html',
|
templateUrl: './search-form.component.html',
|
||||||
styleUrls: ['./search-form.component.scss'],
|
styleUrls: ['./search-form.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class SearchFormComponent implements OnInit {
|
export class SearchFormComponent implements OnInit {
|
||||||
network = '';
|
network = '';
|
||||||
assets: object = {};
|
assets: object = {};
|
||||||
isSearching = false;
|
isSearching = false;
|
||||||
typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>);
|
typeAhead$: Observable<any>;
|
||||||
|
|
||||||
searchForm: FormGroup;
|
searchForm: FormGroup;
|
||||||
isMobile = (window.innerWidth <= 767.98);
|
|
||||||
@Output() searchTriggered = new EventEmitter();
|
|
||||||
|
|
||||||
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/;
|
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/;
|
||||||
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
|
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
|
||||||
regexTransaction = /^([a-fA-F0-9]{64}):?(\d+)?$/;
|
regexTransaction = /^([a-fA-F0-9]{64}):?(\d+)?$/;
|
||||||
regexBlockheight = /^[0-9]+$/;
|
regexBlockheight = /^[0-9]+$/;
|
||||||
|
|
||||||
@ViewChild('instance', {static: true}) instance: NgbTypeahead;
|
|
||||||
focus$ = new Subject<string>();
|
focus$ = new Subject<string>();
|
||||||
click$ = new Subject<string>();
|
click$ = new Subject<string>();
|
||||||
|
|
||||||
formatterFn = (address: string) => this.shortenStringPipe.transform(address, this.isMobile ? 33 : 40);
|
@Output() searchTriggered = new EventEmitter();
|
||||||
|
@ViewChild('searchResults') searchResults: SearchResultsComponent;
|
||||||
|
@HostListener('keydown', ['$event']) keydown($event) {
|
||||||
|
this.handleKeyDown($event);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
@ -43,12 +42,11 @@ export class SearchFormComponent implements OnInit {
|
|||||||
private assetsService: AssetsService,
|
private assetsService: AssetsService,
|
||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
private electrsApiService: ElectrsApiService,
|
private electrsApiService: ElectrsApiService,
|
||||||
|
private apiService: ApiService,
|
||||||
private relativeUrlPipe: RelativeUrlPipe,
|
private relativeUrlPipe: RelativeUrlPipe,
|
||||||
private shortenStringPipe: ShortenStringPipe,
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.typeaheadSearchFn = this.typeaheadSearch;
|
|
||||||
this.stateService.networkChanged$.subscribe((network) => this.network = network);
|
this.stateService.networkChanged$.subscribe((network) => this.network = network);
|
||||||
|
|
||||||
this.searchForm = this.formBuilder.group({
|
this.searchForm = this.formBuilder.group({
|
||||||
@ -61,45 +59,74 @@ export class SearchFormComponent implements OnInit {
|
|||||||
this.assets = assets;
|
this.assets = assets;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
typeaheadSearch = (text$: Observable<string>) => {
|
this.typeAhead$ = this.searchForm.get('searchText').valueChanges
|
||||||
const debouncedText$ = text$.pipe(
|
.pipe(
|
||||||
map((text) => {
|
map((text) => {
|
||||||
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
|
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
|
||||||
return text.substr(1);
|
return text.substr(1);
|
||||||
}
|
}
|
||||||
return text;
|
return text.trim();
|
||||||
}),
|
}),
|
||||||
debounceTime(200),
|
debounceTime(250),
|
||||||
distinctUntilChanged()
|
distinctUntilChanged(),
|
||||||
);
|
|
||||||
const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen()));
|
|
||||||
const inputFocus$ = this.focus$;
|
|
||||||
|
|
||||||
return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$)
|
|
||||||
.pipe(
|
|
||||||
switchMap((text) => {
|
switchMap((text) => {
|
||||||
if (!text.length) {
|
if (!text.length) {
|
||||||
return of([]);
|
return of([
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
nodes: [],
|
||||||
|
channels: [],
|
||||||
}
|
}
|
||||||
return this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([])));
|
]);
|
||||||
|
}
|
||||||
|
if (!this.stateService.env.LIGHTNING) {
|
||||||
|
return zip(
|
||||||
|
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
|
||||||
|
[{ nodes: [], channels: [] }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return zip(
|
||||||
|
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
|
||||||
|
this.apiService.lightningSearch$(text).pipe(catchError(() => of({
|
||||||
|
nodes: [],
|
||||||
|
channels: [],
|
||||||
|
}))),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
map((result: string[]) => {
|
map((result: any[]) => {
|
||||||
if (this.network === 'bisq') {
|
if (this.network === 'bisq') {
|
||||||
return result.map((address: string) => 'B' + address);
|
return result[0].map((address: string) => 'B' + address);
|
||||||
}
|
}
|
||||||
return result;
|
return {
|
||||||
|
addresses: result[0],
|
||||||
|
nodes: result[1].nodes,
|
||||||
|
channels: result[1].channels,
|
||||||
|
totalResults: result[0].length + result[1].nodes.length + result[1].channels.length,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
handleKeyDown($event) {
|
||||||
|
this.searchResults.handleKeyDown($event);
|
||||||
|
}
|
||||||
|
|
||||||
itemSelected() {
|
itemSelected() {
|
||||||
setTimeout(() => this.search());
|
setTimeout(() => this.search());
|
||||||
}
|
}
|
||||||
|
|
||||||
search() {
|
selectedResult(result: any) {
|
||||||
const searchText = this.searchForm.value.searchText.trim();
|
if (typeof result === 'string') {
|
||||||
|
this.search(result);
|
||||||
|
} else if (result.alias) {
|
||||||
|
this.navigate('/lightning/node/', result.public_key);
|
||||||
|
} else if (result.short_id) {
|
||||||
|
this.navigate('/lightning/channel/', result.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
search(result?: string) {
|
||||||
|
const searchText = result || this.searchForm.value.searchText.trim();
|
||||||
if (searchText) {
|
if (searchText) {
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
if (this.regexAddress.test(searchText)) {
|
if (this.regexAddress.test(searchText)) {
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.addresses.length && !results.nodes.length && !results.channels.length">
|
||||||
|
<ng-template [ngIf]="results.addresses.length">
|
||||||
|
<div class="card-title" *ngIf="stateService.env.LIGHTNING">Bitcoin Addresses</div>
|
||||||
|
<ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index">
|
||||||
|
<button (click)="clickItem(i)" [class.active]="i === activeIdx" type="button" role="option" class="dropdown-item">
|
||||||
|
<ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="searchTerm"></ngb-highlight>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="results.nodes.length">
|
||||||
|
<div class="card-title">Lightning Nodes</div>
|
||||||
|
<ng-template ngFor [ngForOf]="results.nodes" let-node let-i="index">
|
||||||
|
<button (click)="clickItem(results.addresses.length + i)" [class.active]="results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item">
|
||||||
|
<ngb-highlight [result]="node.alias" [term]="searchTerm"></ngb-highlight> <span class="symbol">{{ node.public_key | shortenString : 10 }}</span>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="results.channels.length">
|
||||||
|
<div class="card-title">Lightning Channels</div>
|
||||||
|
<ng-template ngFor [ngForOf]="results.channels" let-channel let-i="index">
|
||||||
|
<button (click)="clickItem(results.addresses.length + results.nodes.length + i)" [class.active]="results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item">
|
||||||
|
<ngb-highlight [result]="channel.short_id" [term]="searchTerm"></ngb-highlight> <span class="symbol">{{ channel.id }}</span>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
@ -0,0 +1,16 @@
|
|||||||
|
.card-title {
|
||||||
|
color: #4a68b9;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 42px;
|
||||||
|
left: 0px;
|
||||||
|
box-shadow: 0.125rem 0.125rem 0.25rem rgba(0,0,0,0.075);
|
||||||
|
width: 100%;
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
|
||||||
|
import { StateService } from 'src/app/services/state.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-search-results',
|
||||||
|
templateUrl: './search-results.component.html',
|
||||||
|
styleUrls: ['./search-results.component.scss'],
|
||||||
|
})
|
||||||
|
export class SearchResultsComponent implements OnChanges {
|
||||||
|
@Input() results: any = {};
|
||||||
|
@Input() searchTerm = '';
|
||||||
|
@Output() selectedResult = new EventEmitter();
|
||||||
|
|
||||||
|
isMobile = (window.innerWidth <= 767.98);
|
||||||
|
resultsFlattened = [];
|
||||||
|
activeIdx = 0;
|
||||||
|
focusFirst = true;
|
||||||
|
|
||||||
|
constructor(public stateService: StateService) { }
|
||||||
|
|
||||||
|
ngOnChanges() {
|
||||||
|
this.activeIdx = 0;
|
||||||
|
if (this.results) {
|
||||||
|
this.resultsFlattened = [...this.results.addresses, ...this.results.nodes, ...this.results.channels];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
this.next();
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
this.prev();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.resultsFlattened[this.activeIdx]) {
|
||||||
|
this.selectedResult.emit(this.resultsFlattened[this.activeIdx]);
|
||||||
|
} else {
|
||||||
|
this.selectedResult.emit(this.searchTerm);
|
||||||
|
}
|
||||||
|
this.results = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clickItem(id: number) {
|
||||||
|
this.selectedResult.emit(this.resultsFlattened[id]);
|
||||||
|
this.results = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
if (this.activeIdx === this.resultsFlattened.length - 1) {
|
||||||
|
this.activeIdx = this.focusFirst ? (this.activeIdx + 1) % this.resultsFlattened.length : -1;
|
||||||
|
} else {
|
||||||
|
this.activeIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prev() {
|
||||||
|
if (this.activeIdx < 0) {
|
||||||
|
this.activeIdx = this.resultsFlattened.length - 1;
|
||||||
|
} else if (this.activeIdx === 0) {
|
||||||
|
this.activeIdx = this.focusFirst ? this.resultsFlattened.length - 1 : -1;
|
||||||
|
} else {
|
||||||
|
this.activeIdx--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -13,6 +13,7 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
intervals = {};
|
intervals = {};
|
||||||
|
|
||||||
@Input() time: number;
|
@Input() time: number;
|
||||||
|
@Input() dateString: number;
|
||||||
@Input() fastRender = false;
|
@Input() fastRender = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -52,7 +53,13 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
calculate() {
|
calculate() {
|
||||||
const seconds = Math.floor((+new Date() - +new Date(this.time * 1000)) / 1000);
|
let date: Date;
|
||||||
|
if (this.dateString) {
|
||||||
|
date = new Date(this.dateString)
|
||||||
|
} else {
|
||||||
|
date = new Date(this.time * 1000);
|
||||||
|
}
|
||||||
|
const seconds = Math.floor((+new Date() - +date) / 1000);
|
||||||
if (seconds < 60) {
|
if (seconds < 60) {
|
||||||
return $localize`:@@date-base.just-now:Just now`;
|
return $localize`:@@date-base.just-now:Just now`;
|
||||||
}
|
}
|
||||||
|
@ -77,7 +77,7 @@
|
|||||||
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
|
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<div>
|
<div>
|
||||||
<app-address-labels [vin]="vin"></app-address-labels>
|
<app-address-labels [vin]="vin" [channel]="channels && channels.inputs[i] || null"></app-address-labels>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@ -172,7 +172,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<div>
|
||||||
<app-address-labels [vout]="vout"></app-address-labels>
|
<app-address-labels [vout]="vout" [channel]="channels && channels.outputs[i] && channels.outputs[i].transaction_vout === vindex ? channels.outputs[i] : null"></app-address-labels>
|
||||||
</div>
|
</div>
|
||||||
<ng-template #scriptpubkey_type>
|
<ng-template #scriptpubkey_type>
|
||||||
<ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type">
|
<ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type">
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
|
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
import { Observable, forkJoin, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs';
|
import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs';
|
||||||
import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface';
|
import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface';
|
||||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { AssetsService } from 'src/app/services/assets.service';
|
import { AssetsService } from 'src/app/services/assets.service';
|
||||||
import { map, tap, switchMap } from 'rxjs/operators';
|
import { filter, map, tap, switchMap } from 'rxjs/operators';
|
||||||
import { BlockExtended } from 'src/app/interfaces/node-api.interface';
|
import { BlockExtended } from 'src/app/interfaces/node-api.interface';
|
||||||
import { ApiService } from 'src/app/services/api.service';
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
|
|
||||||
@ -32,9 +32,11 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
latestBlock$: Observable<BlockExtended>;
|
latestBlock$: Observable<BlockExtended>;
|
||||||
outspendsSubscription: Subscription;
|
outspendsSubscription: Subscription;
|
||||||
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
|
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
|
||||||
|
refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
|
||||||
showDetails$ = new BehaviorSubject<boolean>(false);
|
showDetails$ = new BehaviorSubject<boolean>(false);
|
||||||
outspends: Outspend[][] = [];
|
outspends: Outspend[][] = [];
|
||||||
assetsMinimal: any;
|
assetsMinimal: any;
|
||||||
|
channels: { inputs: any[], outputs: any[] };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public stateService: StateService,
|
public stateService: StateService,
|
||||||
@ -73,7 +75,16 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
this.refreshChannels$
|
||||||
|
.pipe(
|
||||||
|
filter(() => this.stateService.env.LIGHTNING),
|
||||||
|
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
|
||||||
|
map((channels) => {
|
||||||
|
this.channels = channels;
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
,
|
||||||
).subscribe(() => this.ref.markForCheck());
|
).subscribe(() => this.ref.markForCheck());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,8 +125,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
tx['addressValue'] = addressIn - addressOut;
|
tx['addressValue'] = addressIn - addressOut;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const txIds = this.transactions.map((tx) => tx.txid);
|
||||||
this.refreshOutspends$.next(this.transactions.map((tx) => tx.txid));
|
this.refreshOutspends$.next(txIds);
|
||||||
|
this.refreshChannels$.next(txIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
onScroll() {
|
onScroll() {
|
||||||
|
@ -57,6 +57,9 @@ import { CommonModule } from '@angular/common';
|
|||||||
NgxEchartsModule.forRoot({
|
NgxEchartsModule.forRoot({
|
||||||
echarts: () => import('echarts')
|
echarts: () => import('echarts')
|
||||||
})
|
})
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
NgxEchartsModule,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class GraphsModule { }
|
export class GraphsModule { }
|
||||||
|
@ -18,6 +18,8 @@ import { StartComponent } from '../components/start/start.component';
|
|||||||
import { StatisticsComponent } from '../components/statistics/statistics.component';
|
import { StatisticsComponent } from '../components/statistics/statistics.component';
|
||||||
import { TelevisionComponent } from '../components/television/television.component';
|
import { TelevisionComponent } from '../components/television/television.component';
|
||||||
import { DashboardComponent } from '../dashboard/dashboard.component';
|
import { DashboardComponent } from '../dashboard/dashboard.component';
|
||||||
|
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
|
||||||
|
import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
|
||||||
|
|
||||||
const browserWindow = window || {};
|
const browserWindow = window || {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -89,6 +91,14 @@ const routes: Routes = [
|
|||||||
path: 'mining/block-sizes-weights',
|
path: 'mining/block-sizes-weights',
|
||||||
component: BlockSizesWeightsGraphComponent,
|
component: BlockSizesWeightsGraphComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lightning/nodes-networks',
|
||||||
|
component: NodesNetworksChartComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'lightning/capacity',
|
||||||
|
component: LightningStatisticsChartComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'mempool',
|
redirectTo: 'mempool',
|
||||||
|
@ -0,0 +1,54 @@
|
|||||||
|
<div class="mb-2 box-top">
|
||||||
|
<div class="box-left">
|
||||||
|
<h3 class="mb-0">{{ channel.alias || '?' }}</h3>
|
||||||
|
<a [routerLink]="['/lightning/node' | relativeUrl, channel.public_key]" >
|
||||||
|
{{ channel.public_key | shortenString : 12 }}
|
||||||
|
</a>
|
||||||
|
<app-clipboard [text]="channel.node1_public_key"></app-clipboard>
|
||||||
|
</div>
|
||||||
|
<div class="box-right">
|
||||||
|
<div class="second-line">{{ channel.channels }} channels</div>
|
||||||
|
<div class="second-line"><app-amount [satoshis]="channel.capacity" digitsInfo="1.2-2"></app-amount></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
|
||||||
|
<div class="col-md">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-sent">Fee rate</td>
|
||||||
|
<td>
|
||||||
|
{{ channel.fee_rate }} <span class="symbol">ppm ({{ channel.fee_rate / 10000 | number }}%)</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-sent">Base fee</td>
|
||||||
|
<td>
|
||||||
|
<app-sats [satoshis]="channel.base_fee_mtokens / 1000" digitsInfo="1.0-2"></app-sats>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-sent">Min HTLC</td>
|
||||||
|
<td>
|
||||||
|
<app-sats [satoshis]="channel.min_htlc_mtokens / 1000"></app-sats>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-sent">Max HTLC</td>
|
||||||
|
<td>
|
||||||
|
<app-sats [satoshis]="channel.max_htlc_mtokens / 1000"></app-sats>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-sent">Timelock delta</td>
|
||||||
|
<td>
|
||||||
|
<ng-container *ngTemplateOutlet="blocksPlural; context: {$implicit: channel.cltv_delta }"></ng-container>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
@ -0,0 +1,18 @@
|
|||||||
|
.box-top {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-left {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-right {
|
||||||
|
text-align: right;
|
||||||
|
width: 50%;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-block {
|
||||||
|
color: #ffffff66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ChannelBoxComponent } from './channel-box.component';
|
||||||
|
|
||||||
|
describe('ChannelBoxComponent', () => {
|
||||||
|
let component: ChannelBoxComponent;
|
||||||
|
let fixture: ComponentFixture<ChannelBoxComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ ChannelBoxComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ChannelBoxComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,14 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-channel-box',
|
||||||
|
templateUrl: './channel-box.component.html',
|
||||||
|
styleUrls: ['./channel-box.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ChannelBoxComponent {
|
||||||
|
@Input() channel: any;
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
}
|
96
frontend/src/app/lightning/channel/channel.component.html
Normal file
96
frontend/src/app/lightning/channel/channel.component.html
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<div class="container-xl" *ngIf="(channel$ | async) as channel">
|
||||||
|
<div class="title-container">
|
||||||
|
<h1 class="mb-0">{{ channel.short_id }}</h1>
|
||||||
|
<span class="tx-link">
|
||||||
|
<a [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">{{ channel.id }}</a>
|
||||||
|
<app-clipboard [text]="channel.id"></app-clipboard>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="badges mb-2">
|
||||||
|
<span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0">Inactive</span>
|
||||||
|
<span class="badge rounded-pill badge-success" *ngIf="channel.status === 1">Active</span>
|
||||||
|
<span class="badge rounded-pill badge-danger" *ngIf="channel.status === 2">Closed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-sent">Created</td>
|
||||||
|
<td><app-timestamp [dateString]="channel.created"></app-timestamp></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
<div class="w-100 d-block d-md-none"></div>
|
||||||
|
<div class="col-md">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-received">Capacity</td>
|
||||||
|
<td><app-sats [satoshis]="channel.capacity"></app-sats><app-fiat [value]="channel.capacity" digitsInfo="1.0-0"></app-fiat></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2">
|
||||||
|
<div class="col">
|
||||||
|
<app-channel-box [channel]="channel.node_left"></app-channel-box>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<app-channel-box [channel]="channel.node_right"></app-channel-box>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<ng-template [ngIf]="error">
|
||||||
|
<div class="text-center">
|
||||||
|
<span i18n="error.general-loading-data">Error loading data.</span>
|
||||||
|
<br><br>
|
||||||
|
<i>{{ error.status }}: {{ error.error }}</i>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
41
frontend/src/app/lightning/channel/channel.component.scss
Normal file
41
frontend/src/app/lightning/channel/channel.component.scss
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
.title-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-link {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
@media (min-width: 650px) {
|
||||||
|
align-self: end;
|
||||||
|
margin-left: 15px;
|
||||||
|
margin-top: 0px;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
top: 1px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-fiat {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
42
frontend/src/app/lightning/channel/channel.component.ts
Normal file
42
frontend/src/app/lightning/channel/channel.component.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { catchError, switchMap } from 'rxjs/operators';
|
||||||
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
|
import { LightningApiService } from '../lightning-api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-channel',
|
||||||
|
templateUrl: './channel.component.html',
|
||||||
|
styleUrls: ['./channel.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ChannelComponent implements OnInit {
|
||||||
|
channel$: Observable<any>;
|
||||||
|
error: any = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private lightningApiService: LightningApiService,
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
private seoService: SeoService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.channel$ = this.activatedRoute.paramMap
|
||||||
|
.pipe(
|
||||||
|
switchMap((params: ParamMap) => {
|
||||||
|
this.error = null;
|
||||||
|
this.seoService.setTitle(`Channel: ${params.get('short_id')}`);
|
||||||
|
return this.lightningApiService.getChannel$(params.get('short_id'))
|
||||||
|
.pipe(
|
||||||
|
catchError((err) => {
|
||||||
|
this.error = err;
|
||||||
|
console.log(this.error);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
<span class="badge badge-pill badge-{{ label.class }}" >{{ label.label }}</span>
|
@ -0,0 +1,37 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-closing-type',
|
||||||
|
templateUrl: './closing-type.component.html',
|
||||||
|
styleUrls: ['./closing-type.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ClosingTypeComponent implements OnChanges {
|
||||||
|
@Input() type = 0;
|
||||||
|
label: { label: string; class: string };
|
||||||
|
|
||||||
|
ngOnChanges() {
|
||||||
|
this.label = this.getLabelFromType(this.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLabelFromType(type: number): { label: string; class: string } {
|
||||||
|
switch (type) {
|
||||||
|
case 1: return {
|
||||||
|
label: 'Mutually closed',
|
||||||
|
class: 'success',
|
||||||
|
};
|
||||||
|
case 2: return {
|
||||||
|
label: 'Force closed',
|
||||||
|
class: 'warning',
|
||||||
|
};
|
||||||
|
case 3: return {
|
||||||
|
label: 'Force closed with penalty',
|
||||||
|
class: 'danger',
|
||||||
|
};
|
||||||
|
default: return {
|
||||||
|
label: 'Unknown',
|
||||||
|
class: 'secondary',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,101 @@
|
|||||||
|
<div *ngIf="channels$ | async as response; else skeleton">
|
||||||
|
<h2 class="float-left">Channels ({{ response.totalItems }})</h2>
|
||||||
|
|
||||||
|
<form [formGroup]="channelStatusForm" class="formRadioGroup float-right">
|
||||||
|
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="status">
|
||||||
|
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||||
|
<input ngbButton type="radio" [value]="'open'" fragment="open"> Open
|
||||||
|
</label>
|
||||||
|
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||||
|
<input ngbButton type="radio" [value]="'closed'" fragment="closed"> Closed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<ng-container *ngTemplateOutlet="tableHeader"></ng-container>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let channel of response.channels; let i = index;">
|
||||||
|
<ng-container *ngTemplateOutlet="tableTemplate; context: { $implicit: channel, node: channel.node_left.public_key === publicKey ? channel.node_right : channel.node_left }"></ng-container>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<ngb-pagination 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #tableHeader>
|
||||||
|
<thead>
|
||||||
|
<th class="alias text-left" i18n="nodes.alias">Node Alias</th>
|
||||||
|
<th class="alias text-left d-none d-md-table-cell" i18n="channels.transaction"> </th>
|
||||||
|
<th class="alias text-left d-none d-md-table-cell" i18n="nodes.alias">Status</th>
|
||||||
|
<th class="channels text-left d-none d-md-table-cell" i18n="channels.rate">Fee Rate</th>
|
||||||
|
<th class="capacity text-right d-none d-md-table-cell" i18n="nodes.capacity">Capacity</th>
|
||||||
|
<th class="capacity text-right" i18n="channels.id">Channel ID</th>
|
||||||
|
</thead>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #tableTemplate let-channel let-node="node">
|
||||||
|
<td class="alias text-left">
|
||||||
|
<div>{{ node.alias || '?' }}</div>
|
||||||
|
<div class="second-line">
|
||||||
|
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">
|
||||||
|
<span>{{ node.public_key | shortenString : 10 }}</span>
|
||||||
|
</a>
|
||||||
|
<app-clipboard [text]="node.public_key" size="small"></app-clipboard>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="alias text-left d-none d-md-table-cell">
|
||||||
|
<div class="second-line">{{ node.channels }} channels</div>
|
||||||
|
<div class="second-line"><app-amount [satoshis]="node.capacity" digitsInfo="1.2-2"></app-amount></div>
|
||||||
|
</td>
|
||||||
|
<td class="d-none d-md-table-cell">
|
||||||
|
<span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0">Inactive</span>
|
||||||
|
<span class="badge rounded-pill badge-success" *ngIf="channel.status === 1">Active</span>
|
||||||
|
<ng-template [ngIf]="channel.status === 2">
|
||||||
|
<span class="badge rounded-pill badge-secondary" *ngIf="!channel.closing_reason; else closingReason">Closed</span>
|
||||||
|
<ng-template #closingReason>
|
||||||
|
<app-closing-type [type]="channel.closing_reason"></app-closing-type>
|
||||||
|
</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
</td>
|
||||||
|
<td class="capacity text-left d-none d-md-table-cell">
|
||||||
|
{{ node.fee_rate }} <span class="symbol">ppm ({{ node.fee_rate / 10000 | number }}%)</span>
|
||||||
|
</td>
|
||||||
|
<td class="capacity text-right d-none d-md-table-cell">
|
||||||
|
<app-amount [satoshis]="channel.capacity" digitsInfo="1.2-2"></app-amount>
|
||||||
|
</td>
|
||||||
|
<td class="capacity text-right">
|
||||||
|
<a [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">{{ channel.short_id }}</a>
|
||||||
|
</td>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #skeleton>
|
||||||
|
<h2 class="float-left">Channels</h2>
|
||||||
|
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<ng-container *ngTemplateOutlet="tableHeader"></ng-container>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
|
||||||
|
<td class="alias text-left" style="width: 370px;">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
<td class="alias text-left">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
<td class="capacity text-left d-none d-md-table-cell">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
<td class="channels text-left d-none d-md-table-cell">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
<td class="channels text-right d-none d-md-table-cell">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
<td class="channels text-left">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ng-template>
|
@ -0,0 +1,3 @@
|
|||||||
|
.second-line {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { BehaviorSubject, combineLatest, merge, Observable, of } from 'rxjs';
|
||||||
|
import { map, startWith, switchMap } from 'rxjs/operators';
|
||||||
|
import { LightningApiService } from '../lightning-api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-channels-list',
|
||||||
|
templateUrl: './channels-list.component.html',
|
||||||
|
styleUrls: ['./channels-list.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ChannelsListComponent implements OnInit, OnChanges {
|
||||||
|
@Input() publicKey: string;
|
||||||
|
channels$: Observable<any>;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
paginationSize: 'sm' | 'lg' = 'md';
|
||||||
|
paginationMaxSize = 10;
|
||||||
|
itemsPerPage = 25;
|
||||||
|
page = 1;
|
||||||
|
channelsPage$ = new BehaviorSubject<number>(1);
|
||||||
|
channelStatusForm: FormGroup;
|
||||||
|
defaultStatus = 'open';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private lightningApiService: LightningApiService,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
) {
|
||||||
|
this.channelStatusForm = this.formBuilder.group({
|
||||||
|
status: [this.defaultStatus],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (document.body.clientWidth < 670) {
|
||||||
|
this.paginationSize = 'sm';
|
||||||
|
this.paginationMaxSize = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false })
|
||||||
|
|
||||||
|
this.channels$ = combineLatest([
|
||||||
|
this.channelsPage$,
|
||||||
|
this.channelStatusForm.get('status').valueChanges.pipe(startWith(this.defaultStatus))
|
||||||
|
])
|
||||||
|
.pipe(
|
||||||
|
switchMap(([page, status]) =>this.lightningApiService.getChannelsByNodeId$(this.publicKey, (page -1) * this.itemsPerPage, status)),
|
||||||
|
map((response) => {
|
||||||
|
return {
|
||||||
|
channels: response.body,
|
||||||
|
totalItems: parseInt(response.headers.get('x-total-count'), 10)
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageChange(page: number) {
|
||||||
|
this.channelsPage$.next(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,126 @@
|
|||||||
|
<div class="widget-toggler">
|
||||||
|
<a href="javascript:;" (click)="switchMode('avg')" class="toggler-option"
|
||||||
|
[ngClass]="{'inactive': mode !== 'avg'}"><small>avg</small></a>
|
||||||
|
<span style="color: #ffffff66; font-size: 8px"> | </span>
|
||||||
|
<a href="javascript:;" (click)="switchMode('med')" class="toggler-option"
|
||||||
|
[ngClass]="{'inactive': mode !== 'med'}"><small>med</small></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fee-estimation-wrapper" *ngIf="statistics$ | async as statistics; else loadingReward">
|
||||||
|
|
||||||
|
<div class="fee-estimation-container" *ngIf="mode === 'avg'">
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="ln.average-capacity">Avg Capacity</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="fee-text">
|
||||||
|
{{ statistics.latest?.avg_capacity || 0 | number: '1.0-0' }}
|
||||||
|
<span i18n="shared.sat-vbyte|sat/vB">sats</span>
|
||||||
|
</div>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-change [current]="statistics.latest?.avg_capacity" [previous]="statistics.previous?.avg_capacity"></app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="ln.average-feerate">Avg Fee Rate</h5>
|
||||||
|
<div class="card-text" i18n-ngbTooltip="ln.average-feerate-desc"
|
||||||
|
ngbTooltip="The average fee rate charged by routing nodes, ignoring fee rates > 0.5% or 5000ppm"
|
||||||
|
placement="bottom">
|
||||||
|
<div class="fee-text">
|
||||||
|
{{ statistics.latest?.avg_fee_rate || 0 | number: '1.0-0' }}
|
||||||
|
<span i18n="shared.sat-vbyte|sat/vB">ppm</span>
|
||||||
|
</div>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-change [current]="statistics.latest?.avg_fee_rate" [previous]="statistics.previous?.avg_fee_rate"></app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="ln.average-basefee">Avg Base Fee</h5>
|
||||||
|
<div class="card-text" i18n-ngbTooltip="ln.average-basefee-desc"
|
||||||
|
ngbTooltip="The average base fee charged by routing nodes, ignoring base fees > 5000ppm" placement="bottom">
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="fee-text">
|
||||||
|
{{ statistics.latest?.avg_base_fee_mtokens || 0 | number: '1.0-0' }}
|
||||||
|
<span i18n="shared.sat-vbyte|sat/vB">msats</span>
|
||||||
|
</div>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-change [current]="statistics.latest?.avg_base_fee_mtokens" [previous]="statistics.previous?.avg_base_fee_mtokens"></app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fee-estimation-container" *ngIf="mode === 'med'">
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="ln.median-capacity">Med Capacity</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="fee-text">
|
||||||
|
{{ statistics.latest?.med_capacity || 0 | number: '1.0-0' }}
|
||||||
|
<span i18n="shared.sat-vbyte|sat/vB">sats</span>
|
||||||
|
</div>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-change [current]="statistics.latest?.med_capacity" [previous]="statistics.previous?.med_capacity"></app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="ln.average-feerate">Med Fee Rate</h5>
|
||||||
|
<div class="card-text" i18n-ngbTooltip="ln.median-feerate-desc"
|
||||||
|
ngbTooltip="The average fee rate charged by routing nodes, ignoring fee rates > 0.5% or 5000ppm"
|
||||||
|
placement="bottom">
|
||||||
|
<div class="fee-text">
|
||||||
|
{{ statistics.latest?.med_fee_rate || 0 | number: '1.0-0' }}
|
||||||
|
<span i18n="shared.sat-vbyte|sat/vB">ppm</span>
|
||||||
|
</div>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-change [current]="statistics.latest?.med_fee_rate" [previous]="statistics.previous?.med_fee_rate"></app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="ln.median-basefee">Med Base Fee</h5>
|
||||||
|
<div class="card-text" i18n-ngbTooltip="ln.median-basefee-desc"
|
||||||
|
ngbTooltip="The median base fee charged by routing nodes, ignoring base fees > 5000ppm" placement="bottom">
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="fee-text">
|
||||||
|
{{ statistics.latest?.med_base_fee_mtokens || 0 | number: '1.0-0' }}
|
||||||
|
<span i18n="shared.sat-vbyte|sat/vB">msats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-change [current]="statistics.latest?.med_base_fee_mtokens" [previous]="statistics.previous?.med_base_fee_mtokens"></app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #loadingReward>
|
||||||
|
<div class="fee-estimation-container loading-container">
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.rewards">Nodes</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.rewards-per-tx">Channels</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.average-fee">Average Channel</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
@ -0,0 +1,101 @@
|
|||||||
|
.card-title {
|
||||||
|
color: #4a68b9;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text {
|
||||||
|
font-size: 22px;
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.green-color {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fee-estimation-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
@media (min-width: 376px) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
max-width: 150px;
|
||||||
|
margin: 0;
|
||||||
|
width: -webkit-fill-available;
|
||||||
|
@media (min-width: 376px) {
|
||||||
|
margin: 0 auto 0px;
|
||||||
|
}
|
||||||
|
&:first-child{
|
||||||
|
display: none;
|
||||||
|
@media (min-width: 485px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.card-text span {
|
||||||
|
color: #ffffff66;
|
||||||
|
font-size: 12px;
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
.fee-text{
|
||||||
|
border-bottom: 1px solid #ffffff1c;
|
||||||
|
width: fit-content;
|
||||||
|
margin: auto;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 0px 2px;
|
||||||
|
}
|
||||||
|
.fiat {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text {
|
||||||
|
.skeleton-loader {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
&:first-child {
|
||||||
|
max-width: 90px;
|
||||||
|
margin: 15px auto 3px;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin: 10px auto 3px;
|
||||||
|
max-width: 55px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-toggler {
|
||||||
|
font-size: 12px;
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
right: 3px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggler-option {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive {
|
||||||
|
color: #ffffff66;
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-channels-statistics',
|
||||||
|
templateUrl: './channels-statistics.component.html',
|
||||||
|
styleUrls: ['./channels-statistics.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ChannelsStatisticsComponent implements OnInit {
|
||||||
|
@Input() statistics$: Observable<any>;
|
||||||
|
mode: string = 'avg';
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
switchMode(mode: 'avg' | 'med') {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
}
|
65
frontend/src/app/lightning/lightning-api.service.ts
Normal file
65
frontend/src/app/lightning/lightning-api.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { StateService } from '../services/state.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class LightningApiService {
|
||||||
|
private apiBasePath = ''; // network path is /testnet, etc. or '' for mainnet
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private httpClient: HttpClient,
|
||||||
|
private stateService: StateService,
|
||||||
|
) {
|
||||||
|
this.apiBasePath = ''; // assume mainnet by default
|
||||||
|
this.stateService.networkChanged$.subscribe((network) => {
|
||||||
|
if (network === 'bisq' && !this.stateService.env.BISQ_SEPARATE_BACKEND) {
|
||||||
|
network = '';
|
||||||
|
}
|
||||||
|
this.apiBasePath = network ? '/' + network : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getNode$(publicKey: string): Observable<any> {
|
||||||
|
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChannel$(shortId: string): Observable<any> {
|
||||||
|
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/channels/' + shortId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChannelsByNodeId$(publicKey: string, index: number = 0, status = 'open'): Observable<any> {
|
||||||
|
let params = new HttpParams()
|
||||||
|
.set('public_key', publicKey)
|
||||||
|
.set('index', index)
|
||||||
|
.set('status', status)
|
||||||
|
;
|
||||||
|
|
||||||
|
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/channels', { params, observe: 'response' });
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatestStatistics$(): Observable<any> {
|
||||||
|
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/statistics/latest');
|
||||||
|
}
|
||||||
|
|
||||||
|
listNodeStats$(publicKey: string): Observable<any> {
|
||||||
|
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/statistics');
|
||||||
|
}
|
||||||
|
|
||||||
|
listTopNodes$(): Observable<any> {
|
||||||
|
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/top');
|
||||||
|
}
|
||||||
|
|
||||||
|
listChannelStats$(publicKey: string): Observable<any> {
|
||||||
|
return this.httpClient.get<any>(this.apiBasePath + '/channels/' + publicKey + '/statistics');
|
||||||
|
}
|
||||||
|
|
||||||
|
listStatistics$(interval: string | undefined): Observable<any> {
|
||||||
|
return this.httpClient.get<any>(
|
||||||
|
this.apiBasePath + '/api/v1/lightning/statistics' +
|
||||||
|
(interval !== undefined ? `/${interval}` : ''), { observe: 'response' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
<div class="container-xl dashboard-container">
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2">
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="main-title">
|
||||||
|
<span i18n="lightning.statistics-title">Network Statistics</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-wrapper">
|
||||||
|
<div class="card" style="height: 123px">
|
||||||
|
<div class="card-body more-padding">
|
||||||
|
<app-node-statistics [statistics$]="statistics$"></app-node-statistics>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="main-title">
|
||||||
|
<span i18n="lightning.statistics-title">Channels Statistics</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-wrapper">
|
||||||
|
<div class="card" style="height: 123px">
|
||||||
|
<div class="card-body more-padding">
|
||||||
|
<app-channels-statistics [statistics$]="statistics$"></app-channels-statistics>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<app-lightning-statistics-chart [widget]=true></app-lightning-statistics-chart>
|
||||||
|
<div class="mt-1"><a [routerLink]="['/graphs/lightning/capacity' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Top Capacity Nodes</h5>
|
||||||
|
<app-nodes-list [nodes$]="nodesByCapacity$"></app-nodes-list>
|
||||||
|
<div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Most Connected Nodes</h5>
|
||||||
|
<app-nodes-list [nodes$]="nodesByChannels$"></app-nodes-list>
|
||||||
|
<div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,80 @@
|
|||||||
|
.dashboard-container {
|
||||||
|
padding-bottom: 60px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
padding-bottom: 0px;
|
||||||
|
}
|
||||||
|
.col {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #1d1f31;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4a68b9;
|
||||||
|
}
|
||||||
|
.card-title > a {
|
||||||
|
color: #4a68b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1.25rem 1rem 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.card-body.pool-ranking {
|
||||||
|
padding: 1.25rem 0.25rem 0.75rem 0.25rem;
|
||||||
|
}
|
||||||
|
.card-text {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
position: relative;
|
||||||
|
color: #ffffff91;
|
||||||
|
margin-top: -13px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-padding {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrapper {
|
||||||
|
.card {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
flex: inherit;
|
||||||
|
text-align: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 22px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-loader {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
&:first-child {
|
||||||
|
max-width: 90px;
|
||||||
|
margin: 15px auto 3px;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin: 10px auto 3px;
|
||||||
|
max-width: 55px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map, share } from 'rxjs/operators';
|
||||||
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
|
import { LightningApiService } from '../lightning-api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-lightning-dashboard',
|
||||||
|
templateUrl: './lightning-dashboard.component.html',
|
||||||
|
styleUrls: ['./lightning-dashboard.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class LightningDashboardComponent implements OnInit {
|
||||||
|
nodesByCapacity$: Observable<any>;
|
||||||
|
nodesByChannels$: Observable<any>;
|
||||||
|
statistics$: Observable<any>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private lightningApiService: LightningApiService,
|
||||||
|
private seoService: SeoService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.setTitle($localize`Lightning Dashboard`);
|
||||||
|
|
||||||
|
const sharedObservable = this.lightningApiService.listTopNodes$().pipe(share());
|
||||||
|
|
||||||
|
this.nodesByCapacity$ = sharedObservable
|
||||||
|
.pipe(
|
||||||
|
map((object) => object.topByCapacity),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.nodesByChannels$ = sharedObservable
|
||||||
|
.pipe(
|
||||||
|
map((object) => object.topByChannels),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
@ -0,0 +1,20 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||||
|
import { WebsocketService } from 'src/app/services/websocket.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-lightning-wrapper',
|
||||||
|
templateUrl: './lightning-wrapper.component.html',
|
||||||
|
styleUrls: ['./lightning-wrapper.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class LightningWrapperComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private websocketService: WebsocketService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.websocketService.want(['blocks']);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
48
frontend/src/app/lightning/lightning.module.ts
Normal file
48
frontend/src/app/lightning/lightning.module.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { LightningDashboardComponent } from './lightning-dashboard/lightning-dashboard.component';
|
||||||
|
import { LightningApiService } from './lightning-api.service';
|
||||||
|
import { NodesListComponent } from './nodes-list/nodes-list.component';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { NodeStatisticsComponent } from './node-statistics/node-statistics.component';
|
||||||
|
import { NodeComponent } from './node/node.component';
|
||||||
|
import { LightningRoutingModule } from './lightning.routing.module';
|
||||||
|
import { ChannelsListComponent } from './channels-list/channels-list.component';
|
||||||
|
import { ChannelComponent } from './channel/channel.component';
|
||||||
|
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
|
||||||
|
import { ChannelBoxComponent } from './channel/channel-box/channel-box.component';
|
||||||
|
import { ClosingTypeComponent } from './channel/closing-type/closing-type.component';
|
||||||
|
import { LightningStatisticsChartComponent } from './statistics-chart/lightning-statistics-chart.component';
|
||||||
|
import { NodeStatisticsChartComponent } from './node-statistics-chart/node-statistics-chart.component';
|
||||||
|
import { GraphsModule } from '../graphs/graphs.module';
|
||||||
|
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
|
||||||
|
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
LightningDashboardComponent,
|
||||||
|
NodesListComponent,
|
||||||
|
NodeStatisticsComponent,
|
||||||
|
NodeStatisticsChartComponent,
|
||||||
|
NodeComponent,
|
||||||
|
ChannelsListComponent,
|
||||||
|
ChannelComponent,
|
||||||
|
LightningWrapperComponent,
|
||||||
|
ChannelBoxComponent,
|
||||||
|
ClosingTypeComponent,
|
||||||
|
LightningStatisticsChartComponent,
|
||||||
|
NodesNetworksChartComponent,
|
||||||
|
ChannelsStatisticsComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
SharedModule,
|
||||||
|
RouterModule,
|
||||||
|
LightningRoutingModule,
|
||||||
|
GraphsModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
LightningApiService,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LightningModule { }
|
41
frontend/src/app/lightning/lightning.routing.module.ts
Normal file
41
frontend/src/app/lightning/lightning.routing.module.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { LightningDashboardComponent } from './lightning-dashboard/lightning-dashboard.component';
|
||||||
|
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
|
||||||
|
import { NodeComponent } from './node/node.component';
|
||||||
|
import { ChannelComponent } from './channel/channel.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: LightningWrapperComponent,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: LightningDashboardComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'node/:public_key',
|
||||||
|
component: NodeComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'channel/:short_id',
|
||||||
|
component: ChannelComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class LightningRoutingModule { }
|
@ -0,0 +1,8 @@
|
|||||||
|
<div class="full-container">
|
||||||
|
|
||||||
|
<div [class]="!widget ? 'chart' : 'chart-widget'" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"></div>
|
||||||
|
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||||
|
<div class="spinner-border text-light"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
@ -0,0 +1,129 @@
|
|||||||
|
|
||||||
|
.main-title {
|
||||||
|
position: relative;
|
||||||
|
color: #ffffff91;
|
||||||
|
margin-top: -13px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-container {
|
||||||
|
padding: 0px 15px;
|
||||||
|
width: 100%;
|
||||||
|
/* min-height: 500px; */
|
||||||
|
height: calc(100% - 150px);
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
height: 100%;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
padding-right: 10px;
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
padding-bottom: 25px;
|
||||||
|
}
|
||||||
|
@media (max-width: 829px) {
|
||||||
|
padding-bottom: 50px;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
padding-bottom: 25px;
|
||||||
|
}
|
||||||
|
@media (max-width: 629px) {
|
||||||
|
padding-bottom: 55px;
|
||||||
|
}
|
||||||
|
@media (max-width: 567px) {
|
||||||
|
padding-bottom: 55px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
.chart-widget {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 270px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRadioGroup {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
@media (min-width: 991px) {
|
||||||
|
position: relative;
|
||||||
|
top: -65px;
|
||||||
|
}
|
||||||
|
@media (min-width: 830px) and (max-width: 991px) {
|
||||||
|
position: relative;
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
@media (min-width: 830px) {
|
||||||
|
flex-direction: row;
|
||||||
|
float: right;
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
font-size: 9px;
|
||||||
|
@media (min-width: 830px) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-distribution {
|
||||||
|
min-height: 56px;
|
||||||
|
display: block;
|
||||||
|
@media (min-width: 485px) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
width: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0px auto 20px;
|
||||||
|
&:nth-child(2) {
|
||||||
|
order: 2;
|
||||||
|
@media (min-width: 485px) {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
order: 3;
|
||||||
|
@media (min-width: 485px) {
|
||||||
|
order: 2;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4a68b9;
|
||||||
|
}
|
||||||
|
.card-text {
|
||||||
|
font-size: 18px;
|
||||||
|
span {
|
||||||
|
color: #ffffff66;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-loader {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
max-width: 80px;
|
||||||
|
margin: 15px auto 3px;
|
||||||
|
}
|
@ -0,0 +1,287 @@
|
|||||||
|
import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core';
|
||||||
|
import { EChartsOption } from 'echarts';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { switchMap, tap } from 'rxjs/operators';
|
||||||
|
import { formatNumber } from '@angular/common';
|
||||||
|
import { FormGroup } from '@angular/forms';
|
||||||
|
import { StorageService } from 'src/app/services/storage.service';
|
||||||
|
import { download } from 'src/app/shared/graphs.utils';
|
||||||
|
import { LightningApiService } from '../lightning-api.service';
|
||||||
|
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-node-statistics-chart',
|
||||||
|
templateUrl: './node-statistics-chart.component.html',
|
||||||
|
styleUrls: ['./node-statistics-chart.component.scss'],
|
||||||
|
styles: [`
|
||||||
|
.loadingGraphs {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(50% - 15px);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class NodeStatisticsChartComponent implements OnInit {
|
||||||
|
@Input() publicKey: string;
|
||||||
|
@Input() right: number | string = 65;
|
||||||
|
@Input() left: number | string = 55;
|
||||||
|
@Input() widget = false;
|
||||||
|
|
||||||
|
miningWindowPreference: string;
|
||||||
|
radioGroupForm: FormGroup;
|
||||||
|
|
||||||
|
chartOptions: EChartsOption = {};
|
||||||
|
chartInitOptions = {
|
||||||
|
renderer: 'svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
@HostBinding('attr.dir') dir = 'ltr';
|
||||||
|
|
||||||
|
blockSizesWeightsObservable$: Observable<any>;
|
||||||
|
isLoading = true;
|
||||||
|
formatNumber = formatNumber;
|
||||||
|
timespan = '';
|
||||||
|
chartInstance: any = undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(LOCALE_ID) public locale: string,
|
||||||
|
private lightningApiService: LightningApiService,
|
||||||
|
private storageService: StorageService,
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
|
||||||
|
this.activatedRoute.paramMap
|
||||||
|
.pipe(
|
||||||
|
switchMap((params: ParamMap) => {
|
||||||
|
this.isLoading = true;
|
||||||
|
return this.lightningApiService.listNodeStats$(params.get('public_key'))
|
||||||
|
.pipe(
|
||||||
|
tap((data) => {
|
||||||
|
this.prepareChartOptions({
|
||||||
|
channels: data.map(val => [val.added * 1000, val.channels]),
|
||||||
|
capacity: data.map(val => [val.added * 1000, val.capacity]),
|
||||||
|
});
|
||||||
|
this.isLoading = false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
).subscribe(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareChartOptions(data) {
|
||||||
|
let title: object;
|
||||||
|
if (data.channels.length === 0) {
|
||||||
|
title = {
|
||||||
|
textStyle: {
|
||||||
|
color: 'grey',
|
||||||
|
fontSize: 15
|
||||||
|
},
|
||||||
|
text: `Loading`,
|
||||||
|
left: 'center',
|
||||||
|
top: 'center'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chartOptions = {
|
||||||
|
title: title,
|
||||||
|
animation: false,
|
||||||
|
color: [
|
||||||
|
'#FDD835',
|
||||||
|
'#D81B60',
|
||||||
|
],
|
||||||
|
grid: {
|
||||||
|
top: 30,
|
||||||
|
bottom: 70,
|
||||||
|
right: this.right,
|
||||||
|
left: this.left,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
show: !this.isMobile(),
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'line'
|
||||||
|
},
|
||||||
|
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||||
|
borderRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#b1b1b1',
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
borderColor: '#000',
|
||||||
|
formatter: (ticks) => {
|
||||||
|
let sizeString = '';
|
||||||
|
let weightString = '';
|
||||||
|
|
||||||
|
for (const tick of ticks) {
|
||||||
|
if (tick.seriesIndex === 0) { // Channels
|
||||||
|
sizeString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`;
|
||||||
|
} else if (tick.seriesIndex === 1) { // Capacity
|
||||||
|
weightString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100000000, this.locale, '1.0-0')} BTC`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
|
const tooltip = `<b style="color: white; margin-left: 18px">${date}</b><br>
|
||||||
|
<span>${sizeString}</span><br>
|
||||||
|
<span>${weightString}</span>`;
|
||||||
|
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xAxis: data.channels.length === 0 ? undefined : {
|
||||||
|
type: 'time',
|
||||||
|
splitNumber: this.isMobile() ? 5 : 10,
|
||||||
|
axisLabel: {
|
||||||
|
hideOverlap: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: data.channels.length === 0 ? undefined : {
|
||||||
|
padding: 10,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: 'Channels',
|
||||||
|
inactiveColor: 'rgb(110, 112, 121)',
|
||||||
|
textStyle: {
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
icon: 'roundRect',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Capacity',
|
||||||
|
inactiveColor: 'rgb(110, 112, 121)',
|
||||||
|
textStyle: {
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
icon: 'roundRect',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected: JSON.parse(this.storageService.getValue('sizes_ln_legend')) ?? {
|
||||||
|
'Channels': true,
|
||||||
|
'Capacity': true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: data.channels.length === 0 ? undefined : [
|
||||||
|
{
|
||||||
|
min: (value) => {
|
||||||
|
return value.min * 0.9;
|
||||||
|
},
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: {
|
||||||
|
color: 'rgb(110, 112, 121)',
|
||||||
|
formatter: (val) => {
|
||||||
|
return `${Math.round(val)}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
type: 'dotted',
|
||||||
|
color: '#ffffff66',
|
||||||
|
opacity: 0.25,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: (value) => {
|
||||||
|
return value.min * 0.9;
|
||||||
|
},
|
||||||
|
type: 'value',
|
||||||
|
position: 'right',
|
||||||
|
axisLabel: {
|
||||||
|
color: 'rgb(110, 112, 121)',
|
||||||
|
formatter: (val) => {
|
||||||
|
return `${val / 100000000} BTC`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: data.channels.length === 0 ? [] : [
|
||||||
|
{
|
||||||
|
zlevel: 1,
|
||||||
|
name: 'Channels',
|
||||||
|
showSymbol: false,
|
||||||
|
symbol: 'none',
|
||||||
|
data: data.channels,
|
||||||
|
type: 'line',
|
||||||
|
step: 'middle',
|
||||||
|
lineStyle: {
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
markLine: {
|
||||||
|
silent: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: {
|
||||||
|
type: 'solid',
|
||||||
|
color: '#ffffff66',
|
||||||
|
opacity: 1,
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
data: [{
|
||||||
|
yAxis: 1,
|
||||||
|
label: {
|
||||||
|
position: 'end',
|
||||||
|
show: true,
|
||||||
|
color: '#ffffff',
|
||||||
|
formatter: `1 MB`
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
zlevel: 0,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
name: 'Capacity',
|
||||||
|
showSymbol: false,
|
||||||
|
symbol: 'none',
|
||||||
|
stack: 'Total',
|
||||||
|
data: data.capacity,
|
||||||
|
areaStyle: {},
|
||||||
|
type: 'line',
|
||||||
|
step: 'middle',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onChartInit(ec) {
|
||||||
|
if (this.chartInstance !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chartInstance = ec;
|
||||||
|
|
||||||
|
this.chartInstance.on('legendselectchanged', (e) => {
|
||||||
|
this.storageService.setValue('sizes_ln_legend', JSON.stringify(e.selected));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isMobile() {
|
||||||
|
return (window.innerWidth <= 767.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaveChart() {
|
||||||
|
// @ts-ignore
|
||||||
|
const prevBottom = this.chartOptions.grid.bottom;
|
||||||
|
const now = new Date();
|
||||||
|
// @ts-ignore
|
||||||
|
this.chartOptions.grid.bottom = 40;
|
||||||
|
this.chartOptions.backgroundColor = '#11131f';
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
download(this.chartInstance.getDataURL({
|
||||||
|
pixelRatio: 2,
|
||||||
|
}), `block-sizes-weights-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
|
||||||
|
// @ts-ignore
|
||||||
|
this.chartOptions.grid.bottom = prevBottom;
|
||||||
|
this.chartOptions.backgroundColor = 'none';
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
<div class="fee-estimation-wrapper" *ngIf="statistics$ | async as statistics; else loadingReward">
|
||||||
|
<div class="fee-estimation-container">
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.average-fee">Capacity</h5>
|
||||||
|
<div class="card-text" i18n-ngbTooltip="mining.average-fee" ngbTooltip="Percentage change past week"
|
||||||
|
placement="bottom">
|
||||||
|
<div class="fee-text">
|
||||||
|
<app-amount [satoshis]="statistics.latest?.total_capacity" digitsInfo="1.2-2"></app-amount>
|
||||||
|
</div>
|
||||||
|
<span class="fiat" *ngIf="statistics.previous">
|
||||||
|
<app-change [current]="statistics.latest.total_capacity" [previous]="statistics.previous.total_capacity">
|
||||||
|
</app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.rewards">Nodes</h5>
|
||||||
|
<div class="card-text" i18n-ngbTooltip="mining.rewards-desc" ngbTooltip="Percentage change past week"
|
||||||
|
placement="bottom">
|
||||||
|
<div class="fee-text">
|
||||||
|
{{ statistics.latest?.node_count || 0 | number }}
|
||||||
|
</div>
|
||||||
|
<span class="fiat" *ngIf="statistics.previous">
|
||||||
|
<app-change [current]="statistics.latest.node_count" [previous]="statistics.previous.node_count"></app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.rewards-per-tx">Channels</h5>
|
||||||
|
<div class="card-text" i18n-ngbTooltip="mining.rewards-per-tx-desc" ngbTooltip="Percentage change past week"
|
||||||
|
placement="bottom">
|
||||||
|
<div class="fee-text">
|
||||||
|
{{ statistics.latest?.channel_count || 0 | number }}
|
||||||
|
</div>
|
||||||
|
<span class="fiat" *ngIf="statistics.previous">
|
||||||
|
<app-change [current]="statistics.latest.channel_count" [previous]="statistics.previous.channel_count">
|
||||||
|
</app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.average-fee">Average Channel</h5>
|
||||||
|
<div class="card-text" i18n-ngbTooltip="mining.average-fee"
|
||||||
|
ngbTooltip="Fee paid on average for each transaction in the past 144 blocks" placement="bottom">
|
||||||
|
<app-amount [satoshis]="statistics.latest.average_channel_size" digitsInfo="1.2-3"></app-amount>
|
||||||
|
<span class="fiat">
|
||||||
|
<app-change [current]="statistics.latest.average_channel_size" [previous]="statistics.previous.average_channel_size"></app-change>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #loadingReward>
|
||||||
|
<div class="fee-estimation-container loading-container">
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.rewards">Nodes</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.rewards-per-tx">Channels</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="mining.average-fee">Average Channel</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
@ -0,0 +1,85 @@
|
|||||||
|
.card-title {
|
||||||
|
color: #4a68b9;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text {
|
||||||
|
font-size: 22px;
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.green-color {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fee-estimation-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
@media (min-width: 376px) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
max-width: 150px;
|
||||||
|
margin: 0;
|
||||||
|
width: -webkit-fill-available;
|
||||||
|
@media (min-width: 376px) {
|
||||||
|
margin: 0 auto 0px;
|
||||||
|
}
|
||||||
|
&:first-child{
|
||||||
|
display: none;
|
||||||
|
@media (min-width: 485px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.card-text span {
|
||||||
|
color: #ffffff66;
|
||||||
|
font-size: 12px;
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
.fee-text{
|
||||||
|
border-bottom: 1px solid #ffffff1c;
|
||||||
|
width: fit-content;
|
||||||
|
margin: auto;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 0px 2px;
|
||||||
|
}
|
||||||
|
.fiat {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text {
|
||||||
|
.skeleton-loader {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
&:first-child {
|
||||||
|
max-width: 90px;
|
||||||
|
margin: 15px auto 3px;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin: 10px auto 3px;
|
||||||
|
max-width: 55px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-node-statistics',
|
||||||
|
templateUrl: './node-statistics.component.html',
|
||||||
|
styleUrls: ['./node-statistics.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NodeStatisticsComponent implements OnInit {
|
||||||
|
@Input() statistics$: Observable<any>;
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
100
frontend/src/app/lightning/node/node.component.html
Normal file
100
frontend/src/app/lightning/node/node.component.html
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<div class="container-xl" *ngIf="(node$ | async) as node">
|
||||||
|
<div class="title-container mb-2">
|
||||||
|
<h1 class="mb-0">{{ node.alias }}</h1>
|
||||||
|
<span class="tx-link">
|
||||||
|
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">{{ node.public_key | shortenString : 12 }}</a>
|
||||||
|
<app-clipboard [text]="node.public_key"></app-clipboard>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-received">Total capacity</td>
|
||||||
|
<td>
|
||||||
|
<app-sats [satoshis]="node.capacity"></app-sats><app-fiat [value]="node.capacity" digitsInfo="1.0-0"></app-fiat>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-sent">Total channels</td>
|
||||||
|
<td>
|
||||||
|
{{ node.channel_count }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-received">Average channel size</td>
|
||||||
|
<td>
|
||||||
|
<app-sats [satoshis]="node.channels_capacity_avg"></app-sats><app-fiat [value]="node.channels_capacity_avg" digitsInfo="1.0-0"></app-fiat>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="w-100 d-block d-md-none"></div>
|
||||||
|
<div class="col-md">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-received">First seen</td>
|
||||||
|
<td>
|
||||||
|
<app-timestamp [dateString]="node.first_seen"></app-timestamp>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.total-sent">Last update</td>
|
||||||
|
<td>
|
||||||
|
<app-timestamp [dateString]="node.updated_at"></app-timestamp>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="address.balance">Color</td>
|
||||||
|
<td><div [ngStyle]="{'color': node.color}">{{ node.color }}</div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="input-group mb-3" *ngIf="node.socketsObject.length">
|
||||||
|
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown" *ngIf="node.socketsObject.length > 1; else noDropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" type="button" aria-expanded="false" ngbDropdownAnchor (focus)="myDrop.open()"><div class="dropdownLabel">{{ node.socketsObject[selectedSocketIndex].label }}</div></button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownManual">
|
||||||
|
<button *ngFor="let socket of node.socketsObject; let i = index;" ngbDropdownItem (click)="changeSocket(i)">{{ socket.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template #noDropdown>
|
||||||
|
<span class="input-group-text" id="basic-addon3">{{ node.socketsObject[selectedSocketIndex].label }}</span>
|
||||||
|
</ng-template>
|
||||||
|
<input type="text" class="form-control" aria-label="Text input with dropdown button" [value]="node.socketsObject[selectedSocketIndex].socket">
|
||||||
|
<button class="btn btn-secondary ml-1" type="button" id="inputGroupFileAddon04" (mouseover)="qrCodeVisible = true" (mouseout)="qrCodeVisible = false">
|
||||||
|
<fa-icon [icon]="['fas', 'qrcode']" [fixedWidth]="true"></fa-icon>
|
||||||
|
<div class="qr-wrapper" [hidden]="!qrCodeVisible">
|
||||||
|
<app-qrcode [size]="200" [data]="node.socketsObject[selectedSocketIndex].socket"></app-qrcode>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary ml-1" type="button" id="inputGroupFileAddon04">
|
||||||
|
<app-clipboard [text]="node.socketsObject[selectedSocketIndex].socket"></app-clipboard>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<app-node-statistics-chart [publicKey]="node.public_key"></app-node-statistics-chart>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<app-channels-list [publicKey]="node.public_key"></app-channels-list>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
60
frontend/src/app/lightning/node/node.component.scss
Normal file
60
frontend/src/app/lightning/node/node.component.scss
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
.title-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-link {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
@media (min-width: 650px) {
|
||||||
|
align-self: end;
|
||||||
|
margin-left: 15px;
|
||||||
|
margin-top: 0px;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
top: 1px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrapper {
|
||||||
|
background-color: #FFF;
|
||||||
|
padding: 10px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
bottom: 50px;
|
||||||
|
left: -175px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownLabel {
|
||||||
|
min-width: 50px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inputGroupFileAddon04 {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-fiat {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
64
frontend/src/app/lightning/node/node.component.ts
Normal file
64
frontend/src/app/lightning/node/node.component.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map, switchMap } from 'rxjs/operators';
|
||||||
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
|
import { LightningApiService } from '../lightning-api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-node',
|
||||||
|
templateUrl: './node.component.html',
|
||||||
|
styleUrls: ['./node.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NodeComponent implements OnInit {
|
||||||
|
node$: Observable<any>;
|
||||||
|
statistics$: Observable<any>;
|
||||||
|
publicKey$: Observable<string>;
|
||||||
|
selectedSocketIndex = 0;
|
||||||
|
qrCodeVisible = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private lightningApiService: LightningApiService,
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
private seoService: SeoService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.node$ = this.activatedRoute.paramMap
|
||||||
|
.pipe(
|
||||||
|
switchMap((params: ParamMap) => {
|
||||||
|
return this.lightningApiService.getNode$(params.get('public_key'));
|
||||||
|
}),
|
||||||
|
map((node) => {
|
||||||
|
this.seoService.setTitle(`Node: ${node.alias}`);
|
||||||
|
|
||||||
|
const socketsObject = [];
|
||||||
|
for (const socket of node.sockets.split(',')) {
|
||||||
|
if (socket === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let label = '';
|
||||||
|
if (socket.match(/(?:[0-9]{1,3}\.){3}[0-9]{1,3}/)) {
|
||||||
|
label = 'IPv4';
|
||||||
|
} else if (socket.indexOf('[') > -1) {
|
||||||
|
label = 'IPv6';
|
||||||
|
} else if (socket.indexOf('onion') > -1) {
|
||||||
|
label = 'Tor';
|
||||||
|
}
|
||||||
|
socketsObject.push({
|
||||||
|
label: label,
|
||||||
|
socket: node.public_key + '@' + socket,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
node.socketsObject = socketsObject;
|
||||||
|
return node;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeSocket(index: number) {
|
||||||
|
this.selectedSocketIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
<div style="min-height: 295px">
|
||||||
|
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<thead>
|
||||||
|
<th class="alias text-left" i18n="nodes.alias">Alias</th>
|
||||||
|
<th class="capacity text-right" i18n="node.capacity">Capacity</th>
|
||||||
|
<th class="channels text-right" i18n="node.channels">Channels</th>
|
||||||
|
</thead>
|
||||||
|
<tbody *ngIf="nodes$ | async as nodes; else skeleton">
|
||||||
|
<tr *ngFor="let node of nodes; let i = index;">
|
||||||
|
<td class="alias text-left">
|
||||||
|
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">{{ node.alias }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="capacity text-right">
|
||||||
|
<app-amount [satoshis]="node.capacity" digitsInfo="1.2-2"></app-amount>
|
||||||
|
</td>
|
||||||
|
<td class="channels text-right">
|
||||||
|
{{ node.channels | number }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<ng-template #skeleton>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
|
||||||
|
<td class="alias text-left">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
<td class="capacity text-right">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
<td class="channels text-right">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</ng-template>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user