custom dashboard wallet widgets

This commit is contained in:
Mononaut 2024-07-25 22:34:52 +00:00
parent 862c9591a1
commit e095192968
No known key found for this signature in database
GPG key ID: A3F058E41374C04E
18 changed files with 149 additions and 24 deletions

View file

@ -83,7 +83,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
ngOnChanges(changes: SimpleChanges): void {
this.isLoading = true;
if (!this.address || !this.stats) {
if (!this.addressSummary$ && (!this.address || !this.stats)) {
return;
}
if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) {
@ -144,15 +144,16 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
}
prepareChartOptions(summary: AddressTxSummary[]) {
if (!summary || !this.stats) {
if (!summary) {
return;
}
let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum);
const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0);
let runningTotal = total;
const processData = summary.map(d => {
const balance = total;
const fiatBalance = total * d.price / 100_000_000;
total -= d.value;
const balance = runningTotal;
const fiatBalance = runningTotal * d.price / 100_000_000;
runningTotal -= d.value;
return {
time: d.time * 1000,
balance,
@ -172,7 +173,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
this.fiatData = this.fiatData.filter(d => d[0] >= startFiat);
}
this.data.push(
{value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }}
{value: [now, total], symbol: 'none', tooltip: { show: false }}
);
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0);

View file

@ -12,7 +12,7 @@
<app-truncate [text]="transaction.txid" [lastChars]="5"></app-truncate>
</a>
</td>
<td class="table-cell-satoshis"><app-amount [satoshis]="transaction.value" digitsInfo="1.2-4" [noFiat]="true"></app-amount></td>
<td class="table-cell-satoshis"><app-amount [satoshis]="transaction.value" [digitsInfo]="getAmountDigits(transaction.value)" [noFiat]="true"></app-amount></td>
<td class="table-cell-fiat" ><app-fiat [value]="transaction.value" [blockConversion]="transaction.price" digitsInfo="1.0-0"></app-fiat></td>
<td class="table-cell-date"><app-time kind="since" [time]="transaction.time" [fastRender]="true" [showTooltip]="true"></app-time></td>
</tr>

View file

@ -43,7 +43,7 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On
startAddressSubscription(): void {
this.isLoading = true;
if (!this.address || !this.addressInfo) {
if (!this.addressSummary$ && (!this.address || !this.addressInfo)) {
return;
}
this.transactions$ = (this.addressSummary$ || (this.isPubkey
@ -55,7 +55,7 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On
})
)).pipe(
map(summary => {
return summary?.slice(0, 6);
return summary?.filter(tx => Math.abs(tx.value) >= 1000000)?.slice(0, 6);
}),
switchMap(txs => {
return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, txs.length < 3, this.currency).pipe(
@ -68,6 +68,12 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On
))));
})
);
}
getAmountDigits(value: number): string {
const decimals = Math.max(0, 4 - Math.ceil(Math.log10(Math.abs(value / 100_000_000))));
return `1.${decimals}-${decimals}`;
}
ngOnDestroy(): void {

View file

@ -4,10 +4,10 @@
<div class="item">
<h5 class="card-title" i18n="dashboard.btc-holdings">BTC Holdings</h5>
<div class="card-text">
{{ ((addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum) / 100_000_000) | number: '1.2-2' }} <span class="symbol" i18n="shared.btc|BTC">BTC</span>
{{ ((total) / 100_000_000) | number: '1.2-2' }} <span class="symbol" i18n="shared.btc|BTC">BTC</span>
</div>
<div class="symbol">
<app-fiat [value]="(addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum)"></app-fiat>
<app-fiat [value]="(total)"></app-fiat>
</div>
</div>
<div class="item">

View file

@ -19,6 +19,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges {
isLoading: boolean = true;
error: any;
total: number = 0;
delta7d: number = 0;
delta30d: number = 0;
@ -34,7 +35,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges {
ngOnChanges(changes: SimpleChanges): void {
this.isLoading = true;
if (!this.address || !this.addressInfo) {
if (!this.addressSummary$ && (!this.address || !this.addressInfo)) {
return;
}
(this.addressSummary$ || (this.isPubkey
@ -57,6 +58,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges {
calculateStats(summary: AddressTxSummary[]): void {
let weekTotal = 0;
let monthTotal = 0;
this.total = this.addressInfo ? this.addressInfo.chain_stats.funded_txo_sum - this.addressInfo.chain_stats.spent_txo_sum : summary.reduce((acc, tx) => acc + tx.value, 0);
const weekAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (7 * 24 * 60 * 60 * 1000)).getTime()) / 1000;
const monthAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (30 * 24 * 60 * 60 * 1000)).getTime()) / 1000;

View file

@ -257,6 +257,36 @@
</div>
</div>
}
@case ('walletBalance') {
<div class="col card-wrapper" [style.order]="isMobile && widget.mobileOrder || 8">
<div class="main-title" i18n="dashboard.treasury">Treasury</div>
<app-balance-widget [addressSummary$]="walletSummary$"></app-balance-widget>
</div>
}
@case ('wallet') {
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
<div class="card graph-card">
<div class="card-body">
<span class="title-link">
<h5 class="card-title d-inline" i18n="dashboard.balance-history">Balance History</h5>
</span>
<app-address-graph [addressSummary$]="walletSummary$" [period]="widget.props.period || 'all'" [widget]="true" [height]="graphHeight"></app-address-graph>
</div>
</div>
</div>
}
@case ('walletTransactions') {
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
<div class="card">
<div class="card-body">
<span class="title-link">
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
</span>
<app-address-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget>
</div>
</div>
</div>
}
@case ('twitter') {
<div class="col" style="min-height:410px" [style.order]="isMobile && widget.mobileOrder || 8">
<div class="card graph-card">

View file

@ -62,8 +62,10 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni
widgets;
addressSubscription: Subscription;
walletSubscription: Subscription;
blockTxSubscription: Subscription;
addressSummary$: Observable<AddressTxSummary[]>;
walletSummary$: Observable<AddressTxSummary[]>;
address: Address;
goggleResolution = 82;
@ -107,6 +109,10 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni
this.websocketService.stopTrackingAddress();
this.address = null;
}
if (this.walletSubscription) {
this.walletSubscription.unsubscribe();
this.websocketService.stopTrackingWallet();
}
this.destroy$.next(1);
this.destroy$.complete();
}
@ -260,6 +266,7 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni
});
this.startAddressSubscription();
this.startWalletSubscription();
}
handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) {
@ -358,6 +365,51 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni
}
}
startWalletSubscription(): void {
if (this.stateService.env.customize && this.stateService.env.customize.dashboard.widgets.some(w => w.props?.wallet)) {
const walletName = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.wallet).props.wallet;
this.websocketService.startTrackingWallet(walletName);
this.walletSummary$ = this.apiService.getWallet$(walletName).pipe(
catchError(e => {
return of(null);
}),
map((walletTransactions) => {
const transactions = Object.values(walletTransactions).flatMap(wallet => wallet.transactions);
return this.deduplicateWalletTransactions(transactions);
}),
switchMap(initial => this.stateService.walletTransactions$.pipe(
startWith(null),
scan((summary, walletTransactions) => {
if (walletTransactions) {
const transactions: AddressTxSummary[] = [...summary, ...Object.values(walletTransactions).flat()];
return this.deduplicateWalletTransactions(transactions);
}
return summary;
}, initial)
)),
share(),
);
}
}
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
const transactions = new Map<string, AddressTxSummary>();
for (const tx of walletTransactions) {
if (transactions.has(tx.txid)) {
transactions.get(tx.txid).value += tx.value;
} else {
transactions.set(tx.txid, tx);
}
}
return Array.from(transactions.values()).sort((a, b) => {
if (a.height === b.height) {
return b.tx_position - a.tx_position;
}
return b.height - a.height;
});
}
@HostListener('window:resize', ['$event'])
onResize(): void {
if (window.innerWidth >= 992) {

View file

@ -6,7 +6,7 @@
<img [src]="enterpriseInfo.img" class="subdomain_logo" [class]="{'rounded': enterpriseInfo.rounded_corner}">
}
@if (enterpriseInfo?.header_img) {
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="60px" class="mr-3">
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="60px" class="mr-3">
} @else {
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" width="500" height="126" class="mempool-logo" style="width: 200px; height: 50px"></app-svg-images>
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 200px; height: 50px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>

View file

@ -19,7 +19,7 @@
<a class="navbar-brand d-none d-md-flex" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
@if (enterpriseInfo?.header_img) {
<img [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="48px" class="mr-3">
<img [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="48px" class="mr-3">
} @else {
<ng-template [ngIf]="subdomain && enterpriseInfo">
<div class="subdomain_container">
@ -39,7 +39,7 @@
<!-- Mobile -->
<a class="navbar-brand d-flex d-md-none justify-content-center" [ngClass]="{'dual-logos': subdomain, 'mr-0': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
@if (enterpriseInfo?.header_img) {
<img [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="42px">
<img [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="42px">
} @else {
<ng-template [ngIf]="subdomain && enterpriseInfo">
<div class="subdomain_container">
@ -49,7 +49,7 @@
</ng-template>
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
@if (enterpriseInfo?.header_img) {
<img [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="36px">
<img [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="36px">
} @else {
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126" class="mempool-logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }"></app-svg-images>
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>

View file

@ -4,7 +4,7 @@
<div class="nav-header">
@if (enterpriseInfo?.header_img) {
<a class="d-flex" [routerLink]="['/' | relativeUrl]">
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" alt="enterpriseInfo.title" height="42px">
<img *ngIf="enterpriseInfo.header_img" [src]="enterpriseInfo?.header_img" [alt]="enterpriseInfo.title" height="42px">
</a>
} @else if (enterpriseInfo?.img || enterpriseInfo?.imageMd5) {
<a [routerLink]="['/' | relativeUrl]">

View file

@ -164,6 +164,7 @@ export interface AddressTxSummary {
height: number;
time: number;
price?: number;
tx_position?: number;
}
export interface ChainStats {

View file

@ -1,4 +1,4 @@
import { Block, Transaction } from "./electrs.interface";
import { AddressTxSummary, Block, Transaction } from "./electrs.interface";
export interface OptimizedMempoolStats {
added: number;
@ -471,3 +471,8 @@ export interface TxResult {
};
error?: string;
}
export interface WalletAddress {
address: string;
active: boolean;
transactions?: AddressTxSummary[];
}

View file

@ -36,6 +36,7 @@ export interface WebsocketResponse {
'track-rbf'?: string;
'track-rbf-summary'?: boolean;
'track-accelerations'?: boolean;
'track-wallet'?: string;
'watch-mempool'?: boolean;
'refresh-blocks'?: boolean;
}

View file

@ -1,8 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult,
SubmitPackageResult} from '../interfaces/node-api.interface';
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, SubmitPackageResult } from '../interfaces/node-api.interface';
import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
import { StateService } from './state.service';
import { Transaction } from '../interfaces/electrs.interface';
@ -518,6 +517,12 @@ export class ApiService {
);
}
getWallet$(walletName: string): Observable<Record<string, WalletAddress>> {
return this.httpClient.get<Record<string, WalletAddress>>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/wallet/${walletName}`
);
}
getAccelerationsByPool$(slug: string): Observable<AccelerationInfo[]> {
return this.httpClient.get<AccelerationInfo[]>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/accelerations/pool/${slug}`

View file

@ -1,6 +1,6 @@
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
import { Transaction } from '../interfaces/electrs.interface';
import { AddressTxSummary, Transaction } from '../interfaces/electrs.interface';
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, isMempoolState } from '../interfaces/websocket.interface';
import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '../interfaces/node-api.interface';
import { Router, NavigationStart } from '@angular/router';
@ -159,6 +159,7 @@ export class StateService {
mempoolRemovedTransactions$ = new Subject<Transaction>();
multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>();
blockTransactions$ = new Subject<Transaction>();
walletTransactions$ = new Subject<Record<string, AddressTxSummary[]>>();
isLoadingWebSocket$ = new ReplaySubject<boolean>(1);
isLoadingMempool$ = new BehaviorSubject<boolean>(true);
vbytesPerSecond$ = new ReplaySubject<number>(1);

View file

@ -34,6 +34,8 @@ export class WebsocketService {
private isTrackingAddress: string | false = false;
private isTrackingAddresses: string[] | false = false;
private isTrackingAccelerations: boolean = false;
private isTrackingWallet: boolean = false;
private trackingWalletName: string;
private trackingMempoolBlock: number;
private stoppingTrackMempoolBlock: any | null = null;
private latestGitCommit = '';
@ -137,6 +139,9 @@ export class WebsocketService {
if (this.isTrackingAccelerations) {
this.startTrackAccelerations();
}
if (this.isTrackingWallet) {
this.startTrackingWallet(this.trackingWalletName);
}
this.stateService.connectionState$.next(2);
}
@ -196,6 +201,18 @@ export class WebsocketService {
this.isTrackingAddresses = false;
}
startTrackingWallet(walletName: string) {
this.websocketSubject.next({ 'track-wallet': walletName });
this.isTrackingWallet = true;
this.trackingWalletName = walletName;
}
stopTrackingWallet() {
this.websocketSubject.next({ 'track-wallet': 'stop' });
this.isTrackingWallet = false;
this.trackingWalletName = '';
}
startTrackAsset(asset: string) {
this.websocketSubject.next({ 'track-asset': asset });
}
@ -452,6 +469,10 @@ export class WebsocketService {
}
}
if (response['wallet-transactions']) {
this.stateService.walletTransactions$.next(response['wallet-transactions']);
}
if (response['accelerations']) {
if (response['accelerations'].accelerations) {
this.stateService.accelerations$.next({

View file

@ -5,7 +5,7 @@
<div class="col-md-12 branding mt-2">
<div class="main-logo" [class]="{'services': isServicesPage}">
@if (enterpriseInfo?.footer_img) {
<img [src]="enterpriseInfo?.footer_img" alt="enterpriseInfo.title" height="60px" class="mr-3">
<img [src]="enterpriseInfo?.footer_img" [alt]="enterpriseInfo.title" height="60px" class="mr-3">
} @else {
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
<app-svg-images *ngIf="!officialMempoolSpace" name="mempoolSpace" viewBox="0 0 500 126"></app-svg-images>

View file

@ -23,7 +23,7 @@ export class FiatCurrencyPipe implements PipeTransform {
const digits = args[0] || 1;
const currency = args[1] || this.currency || 'USD';
if (num >= 1000) {
if (Math.abs(num) >= 1000) {
return new Intl.NumberFormat(this.locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(num);
} else {
return new Intl.NumberFormat(this.locale, { style: 'currency', currency }).format(num);