Merge branch 'master' into nymkappa/bugfix/diff-rounding

This commit is contained in:
wiz
2022-07-10 15:08:50 +02:00
committed by GitHub
95 changed files with 4887 additions and 178 deletions

View File

@@ -96,6 +96,10 @@ let routes: Routes = [
path: 'api',
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',
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',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
},
{
path: 'lightning',
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
},
],
},
{

View File

@@ -1,4 +1,13 @@
<span
*ngIf="label"
class="badge badge-pill badge-warning"
>{{ label }}</span>
<a *ngIf="channel; else default" [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">
<span
*ngIf="label"
class="badge badge-pill badge-warning"
>{{ label }}</span>
</a>
<ng-template #default>
<span
*ngIf="label"
class="badge badge-pill badge-warning"
>{{ label }}</span>
</ng-template>

View File

@@ -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 { StateService } from 'src/app/services/state.service';
@@ -8,11 +8,12 @@ import { StateService } from 'src/app/services/state.service';
styleUrls: ['./address-labels.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressLabelsComponent implements OnInit {
export class AddressLabelsComponent implements OnChanges {
network = '';
@Input() vin: Vin;
@Input() vout: Vout;
@Input() channel: any;
label?: string;
@@ -22,14 +23,21 @@ export class AddressLabelsComponent implements OnInit {
this.network = stateService.network;
}
ngOnInit() {
if (this.vin) {
ngOnChanges() {
if (this.channel) {
this.handleChannel();
} else if (this.vin) {
this.handleVin();
} else if (this.vout) {
this.handleVout();
}
}
handleChannel() {
const type = this.vout ? 'open' : 'close';
this.label = `Channel ${type}: ${this.channel.node_left.alias} <> ${this.channel.node_right.alias}`;
}
handleVin() {
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) {

View File

@@ -55,10 +55,7 @@
<tr>
<td i18n="block.timestamp">Timestamp</td>
<td>
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="lg-inline">
<i class="symbol">(<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>)</i>
</div>
<app-timestamp [unixTime]="block.timestamp"></app-timestamp>
</td>
</tr>
<tr>

View File

@@ -0,0 +1,3 @@
<span [style]="change >= 0 ? 'color: #42B747' : 'color: #B74242'">
{{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}%
</span>

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

View File

@@ -1,5 +1,5 @@
<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">
<img src="./resources/clippy.svg" width="13">
<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]="size === 'small' ? 10 : 13">
</button>
</span>

View File

@@ -1,3 +1,8 @@
.btn-link {
padding: 0.25rem 0 0.1rem 0.5rem;
}
img {
position: relative;
left: -3px;
}

View File

@@ -11,6 +11,7 @@ import * as tlite from 'tlite';
export class ClipboardComponent implements AfterViewInit {
@ViewChild('btn') btn: ElementRef;
@ViewChild('buttonWrapper') buttonWrapper: ElementRef;
@Input() size: 'small' | 'normal' = 'normal';
@Input() text: string;
copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`;

View File

@@ -35,6 +35,9 @@
<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>
</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">
<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>

View File

@@ -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 { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-qrcode',
templateUrl: './qrcode.component.html',
styleUrls: ['./qrcode.component.scss']
styleUrls: ['./qrcode.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QrcodeComponent implements AfterViewInit {
@Input() data: string;
@@ -19,7 +20,18 @@ export class QrcodeComponent implements AfterViewInit {
private stateService: StateService,
) { }
ngOnChanges() {
if (!this.canvas || !this.canvas.nativeElement) {
return;
}
this.render();
}
ngAfterViewInit() {
this.render();
}
render() {
if (!this.stateService.isBrowser) {
return;
}

View File

@@ -1,7 +1,10 @@
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="d-flex">
<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>
<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>

View File

@@ -32,6 +32,7 @@ form {
}
.search-box-container {
position: relative;
width: 100%;
@media (min-width: 768px) {
min-width: 400px;
@@ -48,4 +49,4 @@ form {
.btn {
width: 100px;
}
}
}

View File

@@ -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 { Router } from '@angular/router';
import { AssetsService } from 'src/app/services/assets.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 { 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 { 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({
selector: 'app-search-form',
templateUrl: './search-form.component.html',
styleUrls: ['./search-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchFormComponent implements OnInit {
network = '';
assets: object = {};
isSearching = false;
typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>);
typeAhead$: Observable<any>;
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})$/;
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
regexTransaction = /^([a-fA-F0-9]{64}):?(\d+)?$/;
regexBlockheight = /^[0-9]+$/;
@ViewChild('instance', {static: true}) instance: NgbTypeahead;
focus$ = 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(
private formBuilder: FormBuilder,
@@ -43,12 +42,11 @@ export class SearchFormComponent implements OnInit {
private assetsService: AssetsService,
private stateService: StateService,
private electrsApiService: ElectrsApiService,
private apiService: ApiService,
private relativeUrlPipe: RelativeUrlPipe,
private shortenStringPipe: ShortenStringPipe,
) { }
ngOnInit() {
this.typeaheadSearchFn = this.typeaheadSearch;
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.searchForm = this.formBuilder.group({
@@ -61,45 +59,74 @@ export class SearchFormComponent implements OnInit {
this.assets = assets;
});
}
}
typeaheadSearch = (text$: Observable<string>) => {
const debouncedText$ = text$.pipe(
map((text) => {
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
return text.substr(1);
}
return text;
}),
debounceTime(200),
distinctUntilChanged()
);
const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen()));
const inputFocus$ = this.focus$;
return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$)
this.typeAhead$ = this.searchForm.get('searchText').valueChanges
.pipe(
map((text) => {
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
return text.substr(1);
}
return text.trim();
}),
debounceTime(250),
distinctUntilChanged(),
switchMap((text) => {
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') {
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() {
setTimeout(() => this.search());
}
search() {
const searchText = this.searchForm.value.searchText.trim();
selectedResult(result: any) {
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) {
this.isSearching = true;
if (this.regexAddress.test(searchText)) {

View File

@@ -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> &nbsp;<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> &nbsp;<span class="symbol">{{ channel.id }}</span>
</button>
</ng-template>
</ng-template>
</div>

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy {
intervals = {};
@Input() time: number;
@Input() dateString: number;
@Input() fastRender = false;
constructor(
@@ -52,7 +53,13 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy {
}
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) {
return $localize`:@@date-base.just-now:Just now`;
}

View File

@@ -77,7 +77,7 @@
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
</ng-template>
<div>
<app-address-labels [vin]="vin"></app-address-labels>
<app-address-labels [vin]="vin" [channel]="channels && channels.inputs[i] || null"></app-address-labels>
</div>
</ng-template>
</ng-container>
@@ -172,7 +172,7 @@
</span>
</a>
<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>
<ng-template #scriptpubkey_type>
<ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type">

View File

@@ -1,11 +1,11 @@
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
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 { ElectrsApiService } from '../../services/electrs-api.service';
import { environment } from 'src/environments/environment';
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 { ApiService } from 'src/app/services/api.service';
@@ -32,9 +32,11 @@ export class TransactionsListComponent implements OnInit, OnChanges {
latestBlock$: Observable<BlockExtended>;
outspendsSubscription: Subscription;
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
showDetails$ = new BehaviorSubject<boolean>(false);
outspends: Outspend[][] = [];
assetsMinimal: any;
channels: { inputs: any[], outputs: any[] };
constructor(
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());
}
@@ -114,8 +125,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
tx['addressValue'] = addressIn - addressOut;
}
});
this.refreshOutspends$.next(this.transactions.map((tx) => tx.txid));
const txIds = this.transactions.map((tx) => tx.txid);
this.refreshOutspends$.next(txIds);
this.refreshChannels$.next(txIds);
}
onScroll() {

View File

@@ -57,6 +57,9 @@ import { CommonModule } from '@angular/common';
NgxEchartsModule.forRoot({
echarts: () => import('echarts')
})
],
exports: [
NgxEchartsModule,
]
})
export class GraphsModule { }

View File

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

View File

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

View File

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

View File

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

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

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

View 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);
})
);
})
);
}
}

View File

@@ -0,0 +1 @@
<span class="badge badge-pill badge-{{ label.class }}" >{{ label.label }}</span>

View File

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

View File

@@ -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">&nbsp;</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>

View File

@@ -0,0 +1,3 @@
.second-line {
font-size: 12px;
}

View File

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

View 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');
}
}

View File

@@ -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>&nbsp;
</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>&nbsp;
</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 &raquo;</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 &raquo;</a></div>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -231,4 +231,18 @@ export class ApiService {
getRewardStats$(blockCount: number = 144): Observable<RewardStats> {
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 });
}
}

View File

@@ -37,6 +37,7 @@ export interface Env {
LIQUID_WEBSITE_URL: string;
BISQ_WEBSITE_URL: string;
MINING_DASHBOARD: boolean;
LIGHTNING: boolean;
}
const defaultEnv: Env = {
@@ -60,7 +61,8 @@ const defaultEnv: Env = {
'MEMPOOL_WEBSITE_URL': 'https://mempool.space',
'LIQUID_WEBSITE_URL': 'https://liquid.network',
'BISQ_WEBSITE_URL': 'https://bisq.markets',
'MINING_DASHBOARD': true
'MINING_DASHBOARD': true,
'LIGHTNING': false,
};
@Injectable({

View File

@@ -0,0 +1,5 @@
&lrm;{{ 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>

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

View File

@@ -0,0 +1,4 @@
&lrm;{{ 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>

View File

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

View File

@@ -4,7 +4,7 @@ import { NgbCollapse, NgbCollapseModule, NgbRadioGroup, NgbTypeaheadModule } fro
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
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,
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 { MasterPageComponent } from '../components/master-page/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 { RouterModule } from '@angular/router';
import { CapAddressPipe } from './pipes/cap-address-pipe/cap-address-pipe';
import { StartComponent } from '../components/start/start.component';
import { TransactionComponent } from '../components/transaction/transaction.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 { IndexingProgressComponent } from '../components/indexing-progress/indexing-progress.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({
declarations: [
@@ -104,7 +107,6 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
MempoolBlocksComponent,
BlockchainBlocksComponent,
AmountComponent,
AboutComponent,
MasterPageComponent,
BisqMasterPageComponent,
@@ -142,6 +144,10 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
LoadingIndicatorComponent,
IndexingProgressComponent,
SvgImagesComponent,
ChangeComponent,
SatsComponent,
SearchResultsComponent,
TimestampComponent,
],
imports: [
CommonModule,
@@ -163,6 +169,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
NoSanitizePipe,
ShortenStringPipe,
CapAddressPipe,
AmountShortenerPipe,
],
exports: [
RouterModule,
@@ -203,7 +210,6 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
MempoolBlocksComponent,
BlockchainBlocksComponent,
AmountComponent,
StartComponent,
TransactionComponent,
BlockComponent,
@@ -237,6 +243,10 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
LoadingIndicatorComponent,
IndexingProgressComponent,
SvgImagesComponent,
ChangeComponent,
SatsComponent,
SearchResultsComponent,
TimestampComponent,
]
})
export class SharedModule {
@@ -275,5 +285,6 @@ export class SharedModule {
library.addIcons(faBook);
library.addIcons(faListUl);
library.addIcons(faDownload);
library.addIcons(faQrcode);
}
}