Merge branch 'master' into nymkappa/bugfix/diff-rounding
This commit is contained in:
		
						commit
						5437beedef
					
				| @ -67,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); | ||||||
|  | |||||||
| @ -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 = 25; | ||||||
|   private queryTimeout = 120000; |   private queryTimeout = 120000; | ||||||
|   private statisticsAddedIndexed = false; |   private statisticsAddedIndexed = false; | ||||||
|   private uniqueLogs: string[] = []; |   private uniqueLogs: string[] = []; | ||||||
| @ -248,6 +248,15 @@ 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')); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       throw e; |       throw e; | ||||||
|     } |     } | ||||||
| @ -572,6 +581,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, | ||||||
|  | |||||||
							
								
								
									
										164
									
								
								backend/src/api/explorer/channels.api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								backend/src/api/explorer/channels.api.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,164 @@ | |||||||
|  | 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 $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(); | ||||||
							
								
								
									
										53
									
								
								backend/src/api/explorer/general.routes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								backend/src/api/explorer/general.routes.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | |||||||
|  | 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', 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(); | ||||||
|  |       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(); | ||||||
							
								
								
									
										32
									
								
								backend/src/api/explorer/statistics.api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								backend/src/api/explorer/statistics.api.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | import logger from '../../logger'; | ||||||
|  | import DB from '../../database'; | ||||||
|  | 
 | ||||||
|  | class StatisticsApi { | ||||||
|  |   public async $getStatistics(): Promise<any> { | ||||||
|  |     try { | ||||||
|  |       const query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, node_count, total_capacity FROM lightning_stats ORDER BY id DESC`; | ||||||
|  |       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 72`); | ||||||
|  |       return { | ||||||
|  |         latest: rows[0], | ||||||
|  |         previous: rows2[0], | ||||||
|  |       }; | ||||||
|  |     } 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; | ||||||
| @ -28,6 +28,15 @@ interface IConfig { | |||||||
|   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; | ||||||
| @ -160,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, | ||||||
| @ -168,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', | ||||||
| @ -192,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']; | ||||||
| @ -207,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); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										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(); | ||||||
							
								
								
									
										201
									
								
								backend/src/tasks/lightning/stats-updater.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								backend/src/tasks/lightning/stats-updater.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,201 @@ | |||||||
|  | import logger from "../../logger"; | ||||||
|  | import DB from "../../database"; | ||||||
|  | import lightningApi from "../../api/lightning/lightning-api-factory"; | ||||||
|  | 
 | ||||||
|  | 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 | ||||||
|  |         ) | ||||||
|  |         VALUES (FROM_UNIXTIME(?), ?, ?, ?)`;
 | ||||||
|  | 
 | ||||||
|  |       await DB.query(query, [ | ||||||
|  |         date.getTime() / 1000, | ||||||
|  |         channelsCount, | ||||||
|  |         0, | ||||||
|  |         totalCapacity, | ||||||
|  |       ]); | ||||||
|  | 
 | ||||||
|  |         // Add one day and continue
 | ||||||
|  |         date.setDate(date.getDate() + 1); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       const [nodes]: any = await DB.query(`SELECT first_seen FROM nodes ORDER BY first_seen ASC`); | ||||||
|  |       date = new Date(startTime); | ||||||
|  | 
 | ||||||
|  |       while (date < currentDate) { | ||||||
|  |         let nodeCount = 0; | ||||||
|  |         for (const node of nodes) { | ||||||
|  |           if (new Date(node.first_seen) > date) { | ||||||
|  |             break; | ||||||
|  |           } | ||||||
|  |           nodeCount++; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const query = `UPDATE lightning_stats SET node_count = ? WHERE added = FROM_UNIXTIME(?)`; | ||||||
|  | 
 | ||||||
|  |         await DB.query(query, [ | ||||||
|  |           nodeCount, | ||||||
|  |           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; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       const query = `INSERT INTO lightning_stats(
 | ||||||
|  |           added, | ||||||
|  |           channel_count, | ||||||
|  |           node_count, | ||||||
|  |           total_capacity | ||||||
|  |         ) | ||||||
|  |         VALUES (NOW(), ?, ?, ?)`;
 | ||||||
|  | 
 | ||||||
|  |       await DB.query(query, [ | ||||||
|  |         networkGraph.channels.length, | ||||||
|  |         networkGraph.nodes.length, | ||||||
|  |         total_capacity, | ||||||
|  |       ]); | ||||||
|  |       logger.info(`Lightning daily stats done.`); | ||||||
|  |     } catch (e) { | ||||||
|  |       logger.err('$logLightningStatsDaily() error: ' + (e instanceof Error ? e.message : e)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default new LightningStatsUpdater(); | ||||||
| @ -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> | ||||||
							
								
								
									
										21
									
								
								frontend/src/app/components/change/change.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								frontend/src/app/components/change/change.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | 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 { | ||||||
|  |     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!`; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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 { } | ||||||
|  | |||||||
| @ -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); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										58
									
								
								frontend/src/app/lightning/lightning-api.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								frontend/src/app/lightning/lightning-api.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | |||||||
|  | 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'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   listStatistics$(): Observable<any> { | ||||||
|  |     return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/statistics'); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -0,0 +1,62 @@ | |||||||
|  | <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> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <div class="col"> | ||||||
|  |       <div class="card"> | ||||||
|  |         <div class="card-body"> | ||||||
|  |           <app-lightning-statistics-chart [widget]=true></app-lightning-statistics-chart> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <div class="col"> | ||||||
|  |       <div class="card-wrapper"> | ||||||
|  |          | ||||||
|  |       </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$(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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']); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										44
									
								
								frontend/src/app/lightning/lightning.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/src/app/lightning/lightning.module.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,44 @@ | |||||||
|  | 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'; | ||||||
|  | @NgModule({ | ||||||
|  |   declarations: [ | ||||||
|  |     LightningDashboardComponent, | ||||||
|  |     NodesListComponent, | ||||||
|  |     NodeStatisticsComponent, | ||||||
|  |     NodeStatisticsChartComponent, | ||||||
|  |     NodeComponent, | ||||||
|  |     ChannelsListComponent, | ||||||
|  |     ChannelComponent, | ||||||
|  |     LightningWrapperComponent, | ||||||
|  |     ChannelBoxComponent, | ||||||
|  |     ClosingTypeComponent, | ||||||
|  |     LightningStatisticsChartComponent, | ||||||
|  |   ], | ||||||
|  |   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,76 @@ | |||||||
|  | <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"> | ||||||
|  |         <app-amount [satoshis]="statistics.latest?.total_capacity" digitsInfo="1.2-2"></app-amount> | ||||||
|  |         <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> | ||||||
| @ -0,0 +1,18 @@ | |||||||
|  | import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; | ||||||
|  | import { Observable } from 'rxjs'; | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-nodes-list', | ||||||
|  |   templateUrl: './nodes-list.component.html', | ||||||
|  |   styleUrls: ['./nodes-list.component.scss'], | ||||||
|  |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
|  | }) | ||||||
|  | export class NodesListComponent implements OnInit { | ||||||
|  |   @Input() nodes$: Observable<any>; | ||||||
|  | 
 | ||||||
|  |   constructor() { } | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -0,0 +1,32 @@ | |||||||
|  | <div [class]="widget === false ? 'full-container' : ''"> | ||||||
|  | 
 | ||||||
|  |   <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''"> | ||||||
|  |     <span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span> | ||||||
|  |     <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||||
|  |       <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  | 
 | ||||||
|  |   <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> | ||||||
|  | 
 | ||||||
|  | <ng-template #loadingStats> | ||||||
|  |   <div class="pool-distribution"> | ||||||
|  |     <div class="item"> | ||||||
|  |       <h5 class="card-title" i18n="mining.miners-luck">Hashrate</h5> | ||||||
|  |       <p class="card-text"> | ||||||
|  |         <span class="skeleton-loader skeleton-loader-big"></span> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |     <div class="item"> | ||||||
|  |       <h5 class="card-title" i18n="master-page.blocks">Difficulty</h5> | ||||||
|  |       <p class="card-text"> | ||||||
|  |         <span class="skeleton-loader skeleton-loader-big"></span> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </ng-template> | ||||||
| @ -0,0 +1,135 @@ | |||||||
|  | .card-header { | ||||||
|  |   border-bottom: 0; | ||||||
|  |   font-size: 18px; | ||||||
|  |   @media (min-width: 465px) { | ||||||
|  |     font-size: 20px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .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,299 @@ | |||||||
|  | import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; | ||||||
|  | import { EChartsOption, graphic } from 'echarts'; | ||||||
|  | import { Observable } from 'rxjs'; | ||||||
|  | import { startWith, switchMap, tap } from 'rxjs/operators'; | ||||||
|  | import { SeoService } from 'src/app/services/seo.service'; | ||||||
|  | import { formatNumber } from '@angular/common'; | ||||||
|  | import { FormBuilder, FormGroup } from '@angular/forms'; | ||||||
|  | import { StorageService } from 'src/app/services/storage.service'; | ||||||
|  | import { MiningService } from 'src/app/services/mining.service'; | ||||||
|  | import { download } from 'src/app/shared/graphs.utils'; | ||||||
|  | import { LightningApiService } from '../lightning-api.service'; | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-lightning-statistics-chart', | ||||||
|  |   templateUrl: './lightning-statistics-chart.component.html', | ||||||
|  |   styleUrls: ['./lightning-statistics-chart.component.scss'], | ||||||
|  |   styles: [` | ||||||
|  |     .loadingGraphs { | ||||||
|  |       position: absolute; | ||||||
|  |       top: 50%; | ||||||
|  |       left: calc(50% - 15px); | ||||||
|  |       z-index: 100; | ||||||
|  |     } | ||||||
|  |   `],
 | ||||||
|  | }) | ||||||
|  | export class LightningStatisticsChartComponent implements OnInit { | ||||||
|  |   @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 seoService: SeoService, | ||||||
|  |     private lightningApiService: LightningApiService, | ||||||
|  |     private formBuilder: FormBuilder, | ||||||
|  |     private storageService: StorageService, | ||||||
|  |     private miningService: MiningService, | ||||||
|  |   ) { | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     let firstRun = true; | ||||||
|  | 
 | ||||||
|  |     this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); | ||||||
|  |     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||||
|  |     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||||
|  | 
 | ||||||
|  |     this.radioGroupForm.get('dateSpan').valueChanges | ||||||
|  |       .pipe( | ||||||
|  |         startWith(this.miningWindowPreference), | ||||||
|  |         switchMap((timespan) => { | ||||||
|  |           this.timespan = timespan; | ||||||
|  |           if (!firstRun) { | ||||||
|  |             this.storageService.setValue('miningWindowPreference', timespan); | ||||||
|  |           } | ||||||
|  |           firstRun = false; | ||||||
|  |           this.miningWindowPreference = timespan; | ||||||
|  |           this.isLoading = true; | ||||||
|  |           return this.lightningApiService.listStatistics$() | ||||||
|  |             .pipe( | ||||||
|  |               tap((data) => { | ||||||
|  |                 this.prepareChartOptions({ | ||||||
|  |                   nodes: data.map(val => [val.added * 1000, val.node_count]), | ||||||
|  |                   capacity: data.map(val => [val.added * 1000, val.total_capacity]), | ||||||
|  |                 }); | ||||||
|  |                 this.isLoading = false; | ||||||
|  |               }), | ||||||
|  |             ); | ||||||
|  |         }), | ||||||
|  |       ).subscribe(() => { | ||||||
|  |       }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   prepareChartOptions(data) { | ||||||
|  |     let title: object; | ||||||
|  |     if (data.nodes.length === 0) { | ||||||
|  |       title = { | ||||||
|  |         textStyle: { | ||||||
|  |           color: 'grey', | ||||||
|  |           fontSize: 15 | ||||||
|  |         }, | ||||||
|  |         text: `Indexing in progess`, | ||||||
|  |         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) { // Nodes
 | ||||||
|  |               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' }); | ||||||
|  | 
 | ||||||
|  |           let tooltip = `<b style="color: white; margin-left: 18px">${date}</b><br>
 | ||||||
|  |             <span>${sizeString}</span><br> | ||||||
|  |             <span>${weightString}</span>`;
 | ||||||
|  | 
 | ||||||
|  |           return tooltip; | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       xAxis: data.nodes.length === 0 ? undefined : { | ||||||
|  |         type: 'time', | ||||||
|  |         splitNumber: this.isMobile() ? 5 : 10, | ||||||
|  |         axisLabel: { | ||||||
|  |           hideOverlap: true, | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       legend: data.nodes.length === 0 ? undefined : { | ||||||
|  |         padding: 10, | ||||||
|  |         data: [ | ||||||
|  |           { | ||||||
|  |             name: 'Nodes', | ||||||
|  |             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'))  ?? { | ||||||
|  |           'Nodes': true, | ||||||
|  |           'Capacity': true, | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       yAxis: data.nodes.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.nodes.length === 0 ? [] : [ | ||||||
|  |         { | ||||||
|  |           zlevel: 1, | ||||||
|  |           name: 'Nodes', | ||||||
|  |           showSymbol: false, | ||||||
|  |           symbol: 'none', | ||||||
|  |           data: data.nodes, | ||||||
|  |           type: 'line', | ||||||
|  |           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', | ||||||
|  |         } | ||||||
|  |       ], | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   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); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -231,4 +231,18 @@ export class ApiService { | |||||||
|   getRewardStats$(blockCount: number = 144): Observable<RewardStats> { |   getRewardStats$(blockCount: number = 144): Observable<RewardStats> { | ||||||
|     return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); |     return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   getChannelByTxIds$(txIds: string[]): Observable<{ inputs: any[], outputs: any[] }> { | ||||||
|  |     let params = new HttpParams(); | ||||||
|  |     txIds.forEach((txId: string) => { | ||||||
|  |       params = params.append('txId[]', txId); | ||||||
|  |     }); | ||||||
|  |     return this.httpClient.get<{ inputs: any[], outputs: any[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels/txids/', { params }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   lightningSearch$(searchText: string): Observable<any[]> { | ||||||
|  |     let params = new HttpParams().set('searchText', searchText); | ||||||
|  |     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -37,6 +37,7 @@ export interface Env { | |||||||
|   LIQUID_WEBSITE_URL: string; |   LIQUID_WEBSITE_URL: string; | ||||||
|   BISQ_WEBSITE_URL: string; |   BISQ_WEBSITE_URL: string; | ||||||
|   MINING_DASHBOARD: boolean; |   MINING_DASHBOARD: boolean; | ||||||
|  |   LIGHTNING: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const defaultEnv: Env = { | const defaultEnv: Env = { | ||||||
| @ -60,7 +61,8 @@ const defaultEnv: Env = { | |||||||
|   '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, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| @Injectable({ | @Injectable({ | ||||||
|  | |||||||
| @ -0,0 +1,5 @@ | |||||||
|  | ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number : digitsInfo }} | ||||||
|  | <span class="symbol"><ng-template [ngIf]="network === 'liquid'">L-</ng-template> | ||||||
|  | <ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template> | ||||||
|  | <ng-template [ngIf]="network === 'testnet'">t-</ng-template> | ||||||
|  | <ng-template [ngIf]="network === 'signet'">s-</ng-template>sats</span> | ||||||
							
								
								
									
										32
									
								
								frontend/src/app/shared/components/sats/sats.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								frontend/src/app/shared/components/sats/sats.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | import { Component, Input, OnInit } from '@angular/core'; | ||||||
|  | import { Subscription } from 'rxjs'; | ||||||
|  | import { StateService } from '../../../services/state.service'; | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-sats', | ||||||
|  |   templateUrl: './sats.component.html', | ||||||
|  |   styleUrls: ['./sats.component.scss'] | ||||||
|  | }) | ||||||
|  | export class SatsComponent implements OnInit { | ||||||
|  |   @Input() satoshis: number; | ||||||
|  |   @Input() digitsInfo = '1.0-0'; | ||||||
|  |   @Input() addPlus = false; | ||||||
|  | 
 | ||||||
|  |   network = ''; | ||||||
|  |   stateSubscription: Subscription; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     private stateService: StateService, | ||||||
|  |   ) { } | ||||||
|  | 
 | ||||||
|  |   ngOnInit() { | ||||||
|  |     this.stateSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ngOnDestroy() { | ||||||
|  |     if (this.stateSubscription) { | ||||||
|  |       this.stateSubscription.unsubscribe(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -0,0 +1,4 @@ | |||||||
|  | ‎{{ seconds * 1000 | date:'yyyy-MM-dd HH:mm' }} | ||||||
|  | <div class="lg-inline"> | ||||||
|  |   <i class="symbol">(<app-time-since [time]="seconds" [fastRender]="true"></app-time-since>)</i> | ||||||
|  | </div> | ||||||
| @ -0,0 +1,25 @@ | |||||||
|  | import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-timestamp', | ||||||
|  |   templateUrl: './timestamp.component.html', | ||||||
|  |   styleUrls: ['./timestamp.component.scss'], | ||||||
|  |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
|  | }) | ||||||
|  | export class TimestampComponent implements OnChanges { | ||||||
|  |   @Input() unixTime: number; | ||||||
|  |   @Input() dateString: string; | ||||||
|  | 
 | ||||||
|  |   seconds: number; | ||||||
|  | 
 | ||||||
|  |   constructor() { } | ||||||
|  | 
 | ||||||
|  |   ngOnChanges(): void { | ||||||
|  |     if (this.unixTime) { | ||||||
|  |       this.seconds = this.unixTime; | ||||||
|  |     } else if (this.dateString) { | ||||||
|  |       this.seconds  = new Date(this.dateString).getTime() / 1000 | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -4,7 +4,7 @@ import { NgbCollapse, NgbCollapseModule, NgbRadioGroup, NgbTypeaheadModule } fro | |||||||
| import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; | import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; | ||||||
| import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, | import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, | ||||||
|   faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown, |   faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown, | ||||||
|   faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload } from '@fortawesome/free-solid-svg-icons'; |   faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode } from '@fortawesome/free-solid-svg-icons'; | ||||||
| import { InfiniteScrollModule } from 'ngx-infinite-scroll'; | import { InfiniteScrollModule } from 'ngx-infinite-scroll'; | ||||||
| import { MasterPageComponent } from '../components/master-page/master-page.component'; | import { MasterPageComponent } from '../components/master-page/master-page.component'; | ||||||
| import { BisqMasterPageComponent } from '../components/bisq-master-page/bisq-master-page.component'; | import { BisqMasterPageComponent } from '../components/bisq-master-page/bisq-master-page.component'; | ||||||
| @ -40,7 +40,6 @@ import { BlockchainBlocksComponent } from '../components/blockchain-blocks/block | |||||||
| import { AmountComponent } from '../components/amount/amount.component'; | import { AmountComponent } from '../components/amount/amount.component'; | ||||||
| import { RouterModule } from '@angular/router'; | import { RouterModule } from '@angular/router'; | ||||||
| import { CapAddressPipe } from './pipes/cap-address-pipe/cap-address-pipe'; | import { CapAddressPipe } from './pipes/cap-address-pipe/cap-address-pipe'; | ||||||
| 
 |  | ||||||
| import { StartComponent } from '../components/start/start.component'; | import { StartComponent } from '../components/start/start.component'; | ||||||
| import { TransactionComponent } from '../components/transaction/transaction.component'; | import { TransactionComponent } from '../components/transaction/transaction.component'; | ||||||
| import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; | import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; | ||||||
| @ -74,6 +73,10 @@ import { DataCyDirective } from '../data-cy.directive'; | |||||||
| import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component'; | import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component'; | ||||||
| import { IndexingProgressComponent } from '../components/indexing-progress/indexing-progress.component'; | import { IndexingProgressComponent } from '../components/indexing-progress/indexing-progress.component'; | ||||||
| import { SvgImagesComponent } from '../components/svg-images/svg-images.component'; | import { SvgImagesComponent } from '../components/svg-images/svg-images.component'; | ||||||
|  | import { ChangeComponent } from '../components/change/change.component'; | ||||||
|  | import { SatsComponent } from './components/sats/sats.component'; | ||||||
|  | import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component'; | ||||||
|  | import { TimestampComponent } from './components/timestamp/timestamp.component'; | ||||||
| 
 | 
 | ||||||
| @NgModule({ | @NgModule({ | ||||||
|   declarations: [ |   declarations: [ | ||||||
| @ -104,7 +107,6 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | |||||||
|     MempoolBlocksComponent, |     MempoolBlocksComponent, | ||||||
|     BlockchainBlocksComponent, |     BlockchainBlocksComponent, | ||||||
|     AmountComponent, |     AmountComponent, | ||||||
| 
 |  | ||||||
|     AboutComponent, |     AboutComponent, | ||||||
|     MasterPageComponent, |     MasterPageComponent, | ||||||
|     BisqMasterPageComponent, |     BisqMasterPageComponent, | ||||||
| @ -142,6 +144,10 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | |||||||
|     LoadingIndicatorComponent, |     LoadingIndicatorComponent, | ||||||
|     IndexingProgressComponent, |     IndexingProgressComponent, | ||||||
|     SvgImagesComponent, |     SvgImagesComponent, | ||||||
|  |     ChangeComponent, | ||||||
|  |     SatsComponent, | ||||||
|  |     SearchResultsComponent, | ||||||
|  |     TimestampComponent, | ||||||
|   ], |   ], | ||||||
|   imports: [ |   imports: [ | ||||||
|     CommonModule, |     CommonModule, | ||||||
| @ -163,6 +169,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | |||||||
|     NoSanitizePipe, |     NoSanitizePipe, | ||||||
|     ShortenStringPipe, |     ShortenStringPipe, | ||||||
|     CapAddressPipe, |     CapAddressPipe, | ||||||
|  |     AmountShortenerPipe, | ||||||
|   ], |   ], | ||||||
|   exports: [ |   exports: [ | ||||||
|     RouterModule, |     RouterModule, | ||||||
| @ -203,7 +210,6 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | |||||||
|     MempoolBlocksComponent, |     MempoolBlocksComponent, | ||||||
|     BlockchainBlocksComponent, |     BlockchainBlocksComponent, | ||||||
|     AmountComponent, |     AmountComponent, | ||||||
| 
 |  | ||||||
|     StartComponent, |     StartComponent, | ||||||
|     TransactionComponent, |     TransactionComponent, | ||||||
|     BlockComponent, |     BlockComponent, | ||||||
| @ -237,6 +243,10 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen | |||||||
|     LoadingIndicatorComponent, |     LoadingIndicatorComponent, | ||||||
|     IndexingProgressComponent, |     IndexingProgressComponent, | ||||||
|     SvgImagesComponent, |     SvgImagesComponent, | ||||||
|  |     ChangeComponent, | ||||||
|  |     SatsComponent, | ||||||
|  |     SearchResultsComponent, | ||||||
|  |     TimestampComponent, | ||||||
|   ] |   ] | ||||||
| }) | }) | ||||||
| export class SharedModule { | export class SharedModule { | ||||||
| @ -275,5 +285,6 @@ export class SharedModule { | |||||||
|     library.addIcons(faBook); |     library.addIcons(faBook); | ||||||
|     library.addIcons(faListUl); |     library.addIcons(faListUl); | ||||||
|     library.addIcons(faDownload); |     library.addIcons(faDownload); | ||||||
|  |     library.addIcons(faQrcode); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user