diff --git a/backend/src/api/explorer/channels.api.ts b/backend/src/api/explorer/channels.api.ts index a2db61f78..a0a617e43 100644 --- a/backend/src/api/explorer/channels.api.ts +++ b/backend/src/api/explorer/channels.api.ts @@ -245,8 +245,12 @@ class ChannelsApi { let channelStatusFilter; if (status === 'open') { channelStatusFilter = '< 2'; + } else if (status === 'active') { + channelStatusFilter = '= 1'; } else if (status === 'closed') { channelStatusFilter = '= 2'; + } else { + throw new Error('getChannelsForNode: Invalid status requested'); } // Channels originating from node @@ -275,7 +279,12 @@ class ChannelsApi { allChannels.sort((a, b) => { return b.capacity - a.capacity; }); - allChannels = allChannels.slice(index, index + length); + + if (index >= 0) { + allChannels = allChannels.slice(index, index + length); + } else if (index === -1) { // Node channels tree chart + allChannels = allChannels.slice(0, 1000); + } const channels: any[] = [] for (const row of allChannels) { diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index 4bf5e7890..8d6d64d66 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -285,44 +285,66 @@ class NetworkSyncService { for (const channel of channels) { let reason = 0; // Only Esplora backend can retrieve spent transaction outputs - const outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); - const lightningScriptReasons: number[] = []; - for (const outspend of outspends) { - if (outspend.spent && outspend.txid) { - const spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid); - const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]); - lightningScriptReasons.push(lightningScript); + try { + let outspends: IEsploraApi.Outspend[] | undefined; + try { + outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); + } catch (e) { + logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`); + continue; } - } - if (lightningScriptReasons.length === outspends.length - && lightningScriptReasons.filter((r) => r === 1).length === outspends.length) { - reason = 1; - } else { - const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); - if (filteredReasons.length) { - if (filteredReasons.some((r) => r === 2 || r === 4)) { - reason = 3; - } else { - reason = 2; + const lightningScriptReasons: number[] = []; + for (const outspend of outspends) { + if (outspend.spent && outspend.txid) { + let spendingTx: IEsploraApi.Transaction | undefined; + try { + spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid); + } catch (e) { + logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`); + continue; + } + const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]); + lightningScriptReasons.push(lightningScript); } + } + if (lightningScriptReasons.length === outspends.length + && lightningScriptReasons.filter((r) => r === 1).length === outspends.length) { + reason = 1; } else { - /* - We can detect a commitment transaction (force close) by reading Sequence and Locktime - https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction - */ - const closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); - const sequenceHex: string = closingTx.vin[0].sequence.toString(16); - const locktimeHex: string = closingTx.locktime.toString(16); - if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { - reason = 2; // Here we can't be sure if it's a penalty or not + const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); + if (filteredReasons.length) { + if (filteredReasons.some((r) => r === 2 || r === 4)) { + reason = 3; + } else { + reason = 2; + } } else { - reason = 1; + /* + We can detect a commitment transaction (force close) by reading Sequence and Locktime + https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction + */ + let closingTx: IEsploraApi.Transaction | undefined; + try { + closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); + } catch (e) { + logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`); + continue; + } + const sequenceHex: string = closingTx.vin[0].sequence.toString(16); + const locktimeHex: string = closingTx.locktime.toString(16); + if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { + reason = 2; // Here we can't be sure if it's a penalty or not + } else { + reason = 1; + } } } - } - if (reason) { - logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); - await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); + if (reason) { + logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); + await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); + } + } catch (e) { + logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`); } ++progress; diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 838208cc3..66ee8179e 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -188,4 +188,4 @@ export interface IOldestNodes { updatedAt?: number, city?: any, country?: any, -} \ No newline at end of file +} diff --git a/frontend/src/app/lightning/channels-list/channels-list.component.html b/frontend/src/app/lightning/channels-list/channels-list.component.html index 780c0fdf6..0dd2de183 100644 --- a/frontend/src/app/lightning/channels-list/channels-list.component.html +++ b/frontend/src/app/lightning/channels-list/channels-list.component.html @@ -87,7 +87,7 @@ -

Channels

+

Channels

diff --git a/frontend/src/app/lightning/lightning-api.service.ts b/frontend/src/app/lightning/lightning-api.service.ts index 1963235ef..cae853df5 100644 --- a/frontend/src/app/lightning/lightning-api.service.ts +++ b/frontend/src/app/lightning/lightning-api.service.ts @@ -32,7 +32,7 @@ export class LightningApiService { } getChannelsByNodeId$(publicKey: string, index: number = 0, status = 'open'): Observable { - let params = new HttpParams() + const params = new HttpParams() .set('public_key', publicKey) .set('index', index) .set('status', status) diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index 7ca02b2ba..beb0b5c46 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -29,6 +29,7 @@ import { TopNodesPerChannels } from '../lightning/nodes-ranking/top-nodes-per-ch import { TopNodesPerCapacity } from '../lightning/nodes-ranking/top-nodes-per-capacity/top-nodes-per-capacity.component'; import { OldestNodes } from '../lightning/nodes-ranking/oldest-nodes/oldest-nodes.component'; import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/nodes-rankings-dashboard.component'; +import { NodeChannels } from '../lightning/nodes-channels/node-channels.component'; @NgModule({ declarations: [ @@ -56,6 +57,7 @@ import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/no TopNodesPerCapacity, OldestNodes, NodesRankingsDashboard, + NodeChannels, ], imports: [ CommonModule, @@ -89,6 +91,7 @@ import { NodesRankingsDashboard } from '../lightning/nodes-rankings-dashboard/no TopNodesPerCapacity, OldestNodes, NodesRankingsDashboard, + NodeChannels, ], providers: [ LightningApiService, diff --git a/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.scss b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.scss index 85e7c5e68..d738daa81 100644 --- a/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.scss +++ b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.scss @@ -1,129 +1,5 @@ - -.main-title { - position: relative; - color: #ffffff91; - margin-top: -13px; - font-size: 10px; - text-transform: uppercase; - font-weight: 500; - text-align: center; - padding-bottom: 3px; -} - .full-container { - padding: 0px 15px; - width: 100%; - /* min-height: 500px; */ - height: calc(100% - 150px); - @media (max-width: 992px) { - height: 100%; - padding-bottom: 100px; - }; + margin-top: 25px; + margin-bottom: 25px; + min-height: 100%; } -/* -.chart { - width: 100%; - height: 100%; - padding-bottom: 20px; - padding-right: 10px; - @media (max-width: 992px) { - padding-bottom: 25px; - } - @media (max-width: 829px) { - padding-bottom: 50px; - } - @media (max-width: 767px) { - padding-bottom: 25px; - } - @media (max-width: 629px) { - padding-bottom: 55px; - } - @media (max-width: 567px) { - padding-bottom: 55px; - } -} -*/ -.chart-widget { - width: 100%; - height: 100%; - max-height: 270px; -} - -.formRadioGroup { - margin-top: 6px; - display: flex; - flex-direction: column; - @media (min-width: 991px) { - position: relative; - top: -65px; - } - @media (min-width: 830px) and (max-width: 991px) { - position: relative; - top: 0px; - } - @media (min-width: 830px) { - flex-direction: row; - float: right; - margin-top: 0px; - } - .btn-sm { - font-size: 9px; - @media (min-width: 830px) { - font-size: 14px; - } - } -} - -.pool-distribution { - min-height: 56px; - display: block; - @media (min-width: 485px) { - display: flex; - flex-direction: row; - } - h5 { - margin-bottom: 10px; - } - .item { - width: 50%; - display: inline-block; - margin: 0px auto 20px; - &:nth-child(2) { - order: 2; - @media (min-width: 485px) { - order: 3; - } - } - &:nth-child(3) { - order: 3; - @media (min-width: 485px) { - order: 2; - display: block; - } - @media (min-width: 768px) { - display: none; - } - @media (min-width: 992px) { - display: block; - } - } - .card-title { - font-size: 1rem; - color: #4a68b9; - } - .card-text { - font-size: 18px; - span { - color: #ffffff66; - font-size: 12px; - } - } - } -} - -.skeleton-loader { - width: 100%; - display: block; - max-width: 80px; - margin: 15px auto 3px; -} \ No newline at end of file diff --git a/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.ts b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.ts index 962059c9d..6f0721d38 100644 --- a/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.ts +++ b/frontend/src/app/lightning/node-statistics-chart/node-statistics-chart.component.ts @@ -25,7 +25,7 @@ import { ActivatedRoute, ParamMap } from '@angular/router'; export class NodeStatisticsChartComponent implements OnInit { @Input() publicKey: string; @Input() right: number | string = 65; - @Input() left: number | string = 55; + @Input() left: number | string = 45; @Input() widget = false; miningWindowPreference: string; @@ -96,7 +96,7 @@ export class NodeStatisticsChartComponent implements OnInit { ], grid: { top: 30, - bottom: 70, + bottom: 20, right: this.right, left: this.left, }, diff --git a/frontend/src/app/lightning/node/node.component.html b/frontend/src/app/lightning/node/node.component.html index e90b7d5ef..12ca8eaa1 100644 --- a/frontend/src/app/lightning/node/node.component.html +++ b/frontend/src/app/lightning/node/node.component.html @@ -118,15 +118,26 @@ - - +
+
+
+ +
+
+ +
+
-
-

Channels ({{ channelsListStatus === 'open' ? node.opened_channel_count : node.closed_channel_count }})

+

Active channels map

+ + +
+

Channels ({{ channelsListStatus === 'open' ? node.opened_channel_count : node.closed_channel_count }})

+
+ +
- -
diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html index 7eda48b2b..0b3e4198b 100644 --- a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html @@ -8,9 +8,8 @@ (Tor nodes excluded) -
-
+
diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss index fd93b09c5..254bf4d64 100644 --- a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss @@ -12,11 +12,6 @@ width: 100%; min-height: 600px; height: calc(100% - 150px); - - @media (max-width: 992px) { - height: 100%; - padding-bottom: 100px; - } } .full-container.nodepage { min-height: 400px; @@ -27,6 +22,7 @@ min-height: 400px; margin-top: 25px; margin-bottom: 25px; + min-height: 100%; } .full-container.widget { height: 250px; @@ -68,21 +64,21 @@ min-height: 600px; } .chart.nodepage { - min-height: 400px; + min-height: 100%; + width: 100%; + height: 100%; + padding-bottom: 0px; } .chart.channelpage { min-height: 400px; } .widget { - width: 90vw; + width: 100%; margin-left: auto; margin-right: auto; height: 250px; -webkit-mask: linear-gradient(0deg, #11131f00 5%, #11131fff 25%); - @media (max-width: 767.98px) { - width: 100vw; - } } .widget > .chart { min-height: 250px; @@ -107,4 +103,4 @@ @media (max-width: 767.98px) { top: 250px; } -} +} \ No newline at end of file diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts index 09e6a17bc..421190d48 100644 --- a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts @@ -165,7 +165,7 @@ export class NodesChannelsMap implements OnInit { if (this.style === 'nodepage' && thisNodeGPS) { this.center = [thisNodeGPS[0], thisNodeGPS[1]]; - this.zoom = 10; + this.zoom = 5; this.channelWidth = 1; this.channelOpacity = 1; } diff --git a/frontend/src/app/lightning/nodes-channels/node-channels.component.html b/frontend/src/app/lightning/nodes-channels/node-channels.component.html new file mode 100644 index 000000000..43a5fad60 --- /dev/null +++ b/frontend/src/app/lightning/nodes-channels/node-channels.component.html @@ -0,0 +1,2 @@ +
+
diff --git a/frontend/src/app/lightning/nodes-channels/node-channels.component.scss b/frontend/src/app/lightning/nodes-channels/node-channels.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/lightning/nodes-channels/node-channels.component.ts b/frontend/src/app/lightning/nodes-channels/node-channels.component.ts new file mode 100644 index 000000000..9d6d7df2b --- /dev/null +++ b/frontend/src/app/lightning/nodes-channels/node-channels.component.ts @@ -0,0 +1,138 @@ +import { formatNumber } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { ECharts, EChartsOption, TreemapSeriesOption } from 'echarts'; +import { Observable, tap } from 'rxjs'; +import { lerpColor } from 'src/app/shared/graphs.utils'; +import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; +import { LightningApiService } from '../lightning-api.service'; +import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; +import { StateService } from '../../services/state.service'; + +@Component({ + selector: 'app-node-channels', + templateUrl: './node-channels.component.html', + styleUrls: ['./node-channels.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodeChannels implements OnChanges { + @Input() publicKey: string; + + chartInstance: ECharts; + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + channelsObservable$: Observable; + isLoading: true; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private lightningApiService: LightningApiService, + private amountShortenerPipe: AmountShortenerPipe, + private zone: NgZone, + private router: Router, + private stateService: StateService, + ) {} + + ngOnChanges(): void { + this.prepareChartOptions(null); + + this.channelsObservable$ = this.lightningApiService.getChannelsByNodeId$(this.publicKey, -1, 'active') + .pipe( + tap((response) => { + const biggestCapacity = response.body[0].capacity; + this.prepareChartOptions(response.body.map(channel => { + return { + name: channel.node.alias, + value: channel.capacity, + shortId: channel.short_id, + id: channel.id, + itemStyle: { + color: lerpColor('#1E88E5', '#D81B60', Math.pow(channel.capacity / biggestCapacity, 0.4)), + } + }; + })); + }) + ); + } + + prepareChartOptions(data): void { + this.chartOptions = { + tooltip: { + trigger: 'item', + textStyle: { + align: 'left', + } + }, + series: [ + { + left: 0, + right: 0, + bottom: 0, + top: 0, + roam: false, + type: 'treemap', + data: data, + nodeClick: 'link', + progressive: 100, + tooltip: { + show: true, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + }, + borderColor: '#000', + formatter: (value): string => { + if (value.data.name === undefined) { + return ``; + } + let capacity = ''; + if (value.data.value > 100000000) { + capacity = formatNumber(Math.round(value.data.value / 100000000), this.locale, '1.2-2') + ' BTC'; + } else { + capacity = this.amountShortenerPipe.transform(value.data.value, 2) + ' sats'; + } + + return ` + ${value.data.shortId}
+ Node: ${value.name}
+ Capacity: ${capacity} + `; + } + }, + itemStyle: { + borderColor: 'black', + borderWidth: 1, + }, + breadcrumb: { + show: false, + } + } + ] + }; + } + + onChartInit(ec: ECharts): void { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + + this.chartInstance.on('click', (e) => { + //@ts-ignore + if (!e.data.id) { + return; + } + this.zone.run(() => { + //@ts-ignore + const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/channel/${e.data.id}`); + this.router.navigate([url]); + }); + }); + } +} \ No newline at end of file diff --git a/frontend/src/app/shared/graphs.utils.ts b/frontend/src/app/shared/graphs.utils.ts index 90977e6f4..37f2d3250 100644 --- a/frontend/src/app/shared/graphs.utils.ts +++ b/frontend/src/app/shared/graphs.utils.ts @@ -91,3 +91,25 @@ export function detectWebGL() { return (gl && gl instanceof WebGLRenderingContext); } +/** + * https://gist.githubusercontent.com/rosszurowski/67f04465c424a9bc0dae/raw/90ee06c5aa84ab352eb5b233d0a8263c3d8708e5/lerp-color.js + * A linear interpolator for hexadecimal colors + * @param {String} a + * @param {String} b + * @param {Number} amount + * @example + * // returns #7F7F7F + * lerpColor('#000000', '#ffffff', 0.5) + * @returns {String} + */ +export function lerpColor(a: string, b: string, amount: number): string { + const ah = parseInt(a.replace(/#/g, ''), 16), + ar = ah >> 16, ag = ah >> 8 & 0xff, ab = ah & 0xff, + bh = parseInt(b.replace(/#/g, ''), 16), + br = bh >> 16, bg = bh >> 8 & 0xff, bb = bh & 0xff, + rr = ar + amount * (br - ar), + rg = ag + amount * (bg - ag), + rb = ab + amount * (bb - ab); + + return '#' + ((1 << 24) + (rr << 16) + (rg << 8) + rb | 0).toString(16).slice(1); +} \ No newline at end of file