mirror of
https://github.com/mempool/mempool.git
synced 2025-10-03 20:53:35 +02:00
Merge pull request #5886 from mempool/mononaut/treasury-dashboard
treasuries dashboard
This commit is contained in:
@@ -7,6 +7,7 @@ class ServicesRoutes {
|
|||||||
public initRoutes(app: Application): void {
|
public initRoutes(app: Application): void {
|
||||||
app
|
app
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'wallet/:walletId', this.$getWallet)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'wallet/:walletId', this.$getWallet)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'treasuries', this.$getTreasuries)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +27,15 @@ class ServicesRoutes {
|
|||||||
handleError(req, res, 500, 'Failed to get wallet');
|
handleError(req, res, 500, 'Failed to get wallet');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async $getTreasuries(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const treasuries = await WalletApi.getTreasuries();
|
||||||
|
res.status(200).send(treasuries);
|
||||||
|
} catch (e) {
|
||||||
|
handleError(req, res, 500, 'Failed to get treasuries');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ServicesRoutes();
|
export default new ServicesRoutes();
|
||||||
|
@@ -26,9 +26,17 @@ interface Wallet {
|
|||||||
lastPoll: number;
|
lastPoll: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Treasury {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
wallet: string,
|
||||||
|
enterprise: string,
|
||||||
|
}
|
||||||
|
|
||||||
const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
|
const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
class WalletApi {
|
class WalletApi {
|
||||||
|
private treasuries: Treasury[] = [];
|
||||||
private wallets: Record<string, Wallet> = {};
|
private wallets: Record<string, Wallet> = {};
|
||||||
private syncing = false;
|
private syncing = false;
|
||||||
private lastSync = 0;
|
private lastSync = 0;
|
||||||
@@ -65,6 +73,8 @@ class WalletApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.wallets = data.wallets;
|
this.wallets = data.wallets;
|
||||||
|
this.treasuries = data.treasuries || [];
|
||||||
|
|
||||||
// Reset lastSync time to force transaction history refresh
|
// Reset lastSync time to force transaction history refresh
|
||||||
for (const wallet of Object.values(this.wallets)) {
|
for (const wallet of Object.values(this.wallets)) {
|
||||||
wallet.lastPoll = 0;
|
wallet.lastPoll = 0;
|
||||||
@@ -90,6 +100,7 @@ class WalletApi {
|
|||||||
const cacheData = {
|
const cacheData = {
|
||||||
cacheSchemaVersion: this.cacheSchemaVersion,
|
cacheSchemaVersion: this.cacheSchemaVersion,
|
||||||
wallets: this.wallets,
|
wallets: this.wallets,
|
||||||
|
treasuries: this.treasuries,
|
||||||
};
|
};
|
||||||
|
|
||||||
await fsPromises.writeFile(
|
await fsPromises.writeFile(
|
||||||
@@ -126,6 +137,14 @@ class WalletApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getWallets(): string[] {
|
||||||
|
return Object.keys(this.wallets);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTreasuries(): Treasury[] {
|
||||||
|
return this.treasuries?.filter(treasury => !!this.wallets[treasury.wallet]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
// resync wallet addresses from the services backend
|
// resync wallet addresses from the services backend
|
||||||
async $syncWallets(): Promise<void> {
|
async $syncWallets(): Promise<void> {
|
||||||
if (!config.WALLETS.ENABLED || this.syncing) {
|
if (!config.WALLETS.ENABLED || this.syncing) {
|
||||||
@@ -158,9 +177,26 @@ class WalletApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update list of treasuries
|
||||||
|
const treasuriesResponse = await axios.get(config.MEMPOOL_SERVICES.API + `/treasuries`);
|
||||||
|
console.log(treasuriesResponse.data);
|
||||||
|
this.treasuries = treasuriesResponse.data || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.err(`Error updating active wallets: ${(e instanceof Error ? e.message : e)}`);
|
logger.err(`Error updating active wallets: ${(e instanceof Error ? e.message : e)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// update list of active treasuries
|
||||||
|
this.lastSync = Date.now();
|
||||||
|
const response = await axios.get(config.MEMPOOL_SERVICES.API + `/treasuries`);
|
||||||
|
const treasuries: Treasury[] = response.data;
|
||||||
|
if (treasuries) {
|
||||||
|
this.treasuries = treasuries;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Error updating active treasuries: ${(e instanceof Error ? e.message : e)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const walletKey of Object.keys(this.wallets)) {
|
for (const walletKey of Object.keys(this.wallets)) {
|
||||||
|
@@ -0,0 +1,21 @@
|
|||||||
|
<app-indexing-progress *ngIf="!widget"></app-indexing-progress>
|
||||||
|
|
||||||
|
<div [class.full-container]="!widget">
|
||||||
|
<ng-container *ngIf="!error">
|
||||||
|
<div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null, paddingBottom: !widget && !allowZoom ? '10px' : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||||
|
(chartInit)="onChartInit($event)">
|
||||||
|
</div>
|
||||||
|
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||||
|
<div class="spinner-border text-light"></div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="error">
|
||||||
|
<div class="error-wrapper">
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||||
|
<div class="spinner-border text-light"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,59 @@
|
|||||||
|
.card-header {
|
||||||
|
border-bottom: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
@media (min-width: 465px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
position: relative;
|
||||||
|
color: var(--fg);
|
||||||
|
opacity: var(--opacity);
|
||||||
|
margin-top: -13px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0px;
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
font-size: 15px;
|
||||||
|
color: grey;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
.chart-widget {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
@@ -0,0 +1,411 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
|
||||||
|
import { EChartsOption } from '@app/graphs/echarts';
|
||||||
|
import { BehaviorSubject, Observable, Subscription, combineLatest } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { AddressTxSummary } from '@interfaces/electrs.interface';
|
||||||
|
import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe';
|
||||||
|
import { StateService } from '@app/services/state.service';
|
||||||
|
import { SeriesOption } from 'echarts';
|
||||||
|
import { WalletStats } from '@app/shared/wallet-stats';
|
||||||
|
import { chartColors } from '@app/app.constants';
|
||||||
|
import { Treasury } from '../../../interfaces/node-api.interface';
|
||||||
|
|
||||||
|
|
||||||
|
// export const treasuriesPalette = [
|
||||||
|
// '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40',
|
||||||
|
// ];
|
||||||
|
|
||||||
|
const periodSeconds = {
|
||||||
|
'1d': (60 * 60 * 24),
|
||||||
|
'3d': (60 * 60 * 24 * 3),
|
||||||
|
'1w': (60 * 60 * 24 * 7),
|
||||||
|
'1m': (60 * 60 * 24 * 30),
|
||||||
|
'6m': (60 * 60 * 24 * 180),
|
||||||
|
'1y': (60 * 60 * 24 * 365),
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-treasuries-graph',
|
||||||
|
templateUrl: './treasuries-graph.component.html',
|
||||||
|
styleUrls: ['./treasuries-graph.component.scss'],
|
||||||
|
styles: [`
|
||||||
|
.loadingGraphs {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(50% - 15px);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class TreasuriesGraphComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
@Input() walletStats: Record<string, WalletStats>;
|
||||||
|
@Input() walletSummaries$: Observable<Record<string, AddressTxSummary[]>>;
|
||||||
|
@Input() selectedWallets: Record<string, boolean> = {};
|
||||||
|
@Input() treasuries: Treasury[] = [];
|
||||||
|
@Input() height: number = 400;
|
||||||
|
@Input() right: number | string = 10;
|
||||||
|
@Input() left: number | string = 70;
|
||||||
|
@Input() showLegend: boolean = true;
|
||||||
|
@Input() showYAxis: boolean = true;
|
||||||
|
@Input() widget: boolean = false;
|
||||||
|
@Input() allowZoom: boolean = false;
|
||||||
|
@Input() period: '1d' | '3d' | '1w' | '1m' | '6m' | '1y' | 'all' = 'all';
|
||||||
|
|
||||||
|
adjustedLeft: number = 70;
|
||||||
|
adjustedRight: number = 10;
|
||||||
|
walletData: Record<string, any[]> = {};
|
||||||
|
seriesNameToLabel: Record<string, string> = {};
|
||||||
|
hoverData: any[] = [];
|
||||||
|
|
||||||
|
subscription: Subscription;
|
||||||
|
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
chartOptions: EChartsOption = {};
|
||||||
|
chartInitOptions = {
|
||||||
|
renderer: 'svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
error: any;
|
||||||
|
isLoading = true;
|
||||||
|
chartInstance: any = undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(LOCALE_ID) public locale: string,
|
||||||
|
public stateService: StateService,
|
||||||
|
private amountShortenerPipe: AmountShortenerPipe,
|
||||||
|
private cd: ChangeDetectorRef,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.setupSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
this.adjustedRight = +this.right;
|
||||||
|
this.adjustedLeft = +this.left;
|
||||||
|
|
||||||
|
if (changes.walletSummaries$ || changes.selectedWallets || changes.period) {
|
||||||
|
if (this.subscription) {
|
||||||
|
this.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
this.setupSubscription();
|
||||||
|
} else {
|
||||||
|
// re-trigger subscription
|
||||||
|
this.redraw$.next(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSubscription(): void {
|
||||||
|
this.subscription = combineLatest([
|
||||||
|
this.redraw$,
|
||||||
|
this.walletSummaries$
|
||||||
|
]).pipe(
|
||||||
|
tap(([_, walletSummaries]) => {
|
||||||
|
if (walletSummaries) {
|
||||||
|
this.error = null;
|
||||||
|
this.processWalletData(walletSummaries);
|
||||||
|
this.prepareChartOptions();
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
this.cd.markForCheck();
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
processWalletData(walletSummaries: Record<string, AddressTxSummary[]>): void {
|
||||||
|
this.walletData = {};
|
||||||
|
|
||||||
|
this.treasuries.forEach(treasury => {
|
||||||
|
if (!walletSummaries[treasury.wallet] || !walletSummaries[treasury.wallet].length) return;
|
||||||
|
|
||||||
|
const total = this.walletStats[treasury.wallet] ? this.walletStats[treasury.wallet].balance : walletSummaries[treasury.wallet].reduce((acc, tx) => acc + tx.value, 0);
|
||||||
|
|
||||||
|
let runningTotal = total;
|
||||||
|
const processedData = walletSummaries[treasury.wallet].map(tx => {
|
||||||
|
const balance = runningTotal;
|
||||||
|
runningTotal -= tx.value;
|
||||||
|
return {
|
||||||
|
time: tx.time * 1000,
|
||||||
|
balance,
|
||||||
|
tx
|
||||||
|
};
|
||||||
|
}).reverse();
|
||||||
|
|
||||||
|
this.walletData[treasury.wallet] = processedData
|
||||||
|
.filter(({ tx }) => tx.txid !== undefined)
|
||||||
|
.map(({ time, balance, tx }) => [time, balance, tx]);
|
||||||
|
|
||||||
|
if (this.period !== 'all') {
|
||||||
|
const now = Date.now();
|
||||||
|
const start = now - (periodSeconds[this.period] * 1000);
|
||||||
|
|
||||||
|
const fullData = [...this.walletData[treasury.wallet]];
|
||||||
|
|
||||||
|
this.walletData[treasury.wallet] = this.walletData[treasury.wallet].filter(d => d[0] >= start);
|
||||||
|
|
||||||
|
if (this.walletData[treasury.wallet].length === 0 || this.walletData[treasury.wallet][0][0] > start) {
|
||||||
|
// Find the most recent balance at or before the period start
|
||||||
|
let startBalance = 0;
|
||||||
|
for (let i = fullData.length - 1; i >= 0; i--) {
|
||||||
|
if (fullData[i][0] <= start) {
|
||||||
|
startBalance = fullData[i][1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a data point at the period start with the correct historical balance
|
||||||
|
this.walletData[treasury.wallet].unshift([start, startBalance, { placeholder: true }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current point
|
||||||
|
this.walletData[treasury.wallet].push([Date.now(), total, { current: true }]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareChartOptions(): void {
|
||||||
|
// Prepare legend data
|
||||||
|
|
||||||
|
this.seriesNameToLabel = {};
|
||||||
|
for (const treasury of this.treasuries) {
|
||||||
|
this.seriesNameToLabel[treasury.wallet] = treasury.enterprise || treasury.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legendData = this.treasuries.map(treasury => ({
|
||||||
|
name: treasury.wallet,
|
||||||
|
inactiveColor: 'var(--grey)',
|
||||||
|
textStyle: {
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
icon: 'roundRect',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate min and max values across all wallets
|
||||||
|
let maxValue = 0;
|
||||||
|
let minValue = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
this.treasuries.forEach(treasury => {
|
||||||
|
const data = this.walletData[treasury.wallet];
|
||||||
|
if (data) {
|
||||||
|
data.forEach(point => {
|
||||||
|
const value = point[1] || (point.value && point.value[1]) || 0;
|
||||||
|
maxValue = Math.max(maxValue, Math.abs(value));
|
||||||
|
minValue = Math.min(minValue, Math.abs(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (minValue === Number.MAX_SAFE_INTEGER) {
|
||||||
|
minValue = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare series data
|
||||||
|
const series: SeriesOption[] = this.treasuries.map((treasury, index) => ({
|
||||||
|
name: treasury.wallet,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
showSymbol: false,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 8,
|
||||||
|
data: this.walletData[treasury.wallet] || [],
|
||||||
|
areaStyle: undefined,
|
||||||
|
triggerLineEvent: true,
|
||||||
|
type: 'line',
|
||||||
|
smooth: false,
|
||||||
|
step: 'end'
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.chartOptions = {
|
||||||
|
color: chartColors,
|
||||||
|
animation: false,
|
||||||
|
grid: {
|
||||||
|
top: 20,
|
||||||
|
bottom: this.allowZoom ? 65 : 20,
|
||||||
|
right: this.adjustedRight,
|
||||||
|
left: this.adjustedLeft,
|
||||||
|
},
|
||||||
|
legend: this.showLegend ? {
|
||||||
|
data: legendData,
|
||||||
|
selected: this.selectedWallets,
|
||||||
|
formatter: (name) => {
|
||||||
|
return this.seriesNameToLabel[name];
|
||||||
|
}
|
||||||
|
} : undefined,
|
||||||
|
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: function (data) {
|
||||||
|
if (!data.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current x-axis timestamp from the hovered point
|
||||||
|
const tooltipTime = data[0].data[0];
|
||||||
|
|
||||||
|
let tooltip = '<div>';
|
||||||
|
const date = new Date(tooltipTime).toLocaleTimeString(this.locale, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
tooltip += `<div><b style="color: white; margin-left: 2px">${date}</b><br>`;
|
||||||
|
|
||||||
|
// Get all active wallet IDs from the selected wallets
|
||||||
|
const activeTreasuries: { treasury: Treasury, index: number }[] = this.treasuries
|
||||||
|
.map((treasury, index) => ({ treasury, index }))
|
||||||
|
.filter(({ treasury }) => this.selectedWallets[treasury.wallet] && this.walletData[treasury.wallet]);
|
||||||
|
|
||||||
|
// For each active wallet, find and display the most recent balance
|
||||||
|
activeTreasuries.forEach(({ treasury, index }) => {
|
||||||
|
const walletPoints = this.walletData[treasury.wallet];
|
||||||
|
if (!walletPoints || !walletPoints.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most recent data point at or before the tooltip time
|
||||||
|
let mostRecentPoint: any = null;
|
||||||
|
for (let i = 0; i < walletPoints.length; i++) {
|
||||||
|
const point: any = walletPoints[i];
|
||||||
|
const pointTime = Array.isArray(point) ? point[0] :
|
||||||
|
(point && typeof point === 'object' && 'value' in point ? point.value[0] : null);
|
||||||
|
|
||||||
|
if (pointTime && pointTime <= tooltipTime) {
|
||||||
|
mostRecentPoint = point;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop once we pass the tooltip time
|
||||||
|
if (pointTime && pointTime > tooltipTime) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mostRecentPoint) {
|
||||||
|
// Extract balance from the point
|
||||||
|
const balance = Array.isArray(mostRecentPoint) ? mostRecentPoint[1] :
|
||||||
|
(mostRecentPoint && typeof mostRecentPoint === 'object' && 'value' in mostRecentPoint ? mostRecentPoint.value[1] : null);
|
||||||
|
|
||||||
|
if (balance !== null && !isNaN(balance)) {
|
||||||
|
const markerColor = chartColors[index % chartColors.length];
|
||||||
|
|
||||||
|
const marker = `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${markerColor};"></span>`;
|
||||||
|
|
||||||
|
tooltip += `<div style="display: flex; justify-content: space-between;">
|
||||||
|
<span style="text-align: left; margin-right: 10px;">${marker} ${treasury.enterprise || treasury.name}:</span>
|
||||||
|
<span style="text-align: right;">${this.formatBTC(balance)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tooltip += `</div></div>`;
|
||||||
|
return tooltip;
|
||||||
|
}.bind(this)
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'time',
|
||||||
|
splitNumber: this.isMobile() ? 5 : 10,
|
||||||
|
axisLabel: {
|
||||||
|
hideOverlap: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
position: 'left',
|
||||||
|
axisLabel: {
|
||||||
|
show: this.showYAxis,
|
||||||
|
color: 'rgb(110, 112, 121)',
|
||||||
|
formatter: (val): string => {
|
||||||
|
let valSpan = maxValue - (this.period === 'all' ? 0 : minValue);
|
||||||
|
if (valSpan > 100_000_000_000) {
|
||||||
|
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0, undefined, true)} BTC`;
|
||||||
|
}
|
||||||
|
else if (valSpan > 1_000_000_000) {
|
||||||
|
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2, undefined, true)} BTC`;
|
||||||
|
} else if (valSpan > 100_000_000) {
|
||||||
|
return `${(val / 100_000_000).toFixed(1)} BTC`;
|
||||||
|
} else if (valSpan > 10_000_000) {
|
||||||
|
return `${(val / 100_000_000).toFixed(2)} BTC`;
|
||||||
|
} else if (valSpan > 1_000_000) {
|
||||||
|
return `${(val / 100_000_000).toFixed(3)} BTC`;
|
||||||
|
} else {
|
||||||
|
return `${this.amountShortenerPipe.transform(val, 0, undefined, true)} sats`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
min: this.period === 'all' ? 0 : 'dataMin'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: series,
|
||||||
|
dataZoom: this.allowZoom ? [{
|
||||||
|
type: 'inside',
|
||||||
|
realtime: true,
|
||||||
|
zoomLock: true,
|
||||||
|
maxSpan: 100,
|
||||||
|
minSpan: 5,
|
||||||
|
moveOnMouseMove: false,
|
||||||
|
}, {
|
||||||
|
showDetail: false,
|
||||||
|
show: true,
|
||||||
|
type: 'slider',
|
||||||
|
brushSelect: false,
|
||||||
|
realtime: true,
|
||||||
|
left: this.adjustedLeft,
|
||||||
|
right: this.adjustedRight,
|
||||||
|
selectedDataBackground: {
|
||||||
|
lineStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
opacity: 0.45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}] : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBTC(val: number): string {
|
||||||
|
return `${(val / 100_000_000).toFixed(4)} BTC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChartInit(ec) {
|
||||||
|
this.chartInstance = ec;
|
||||||
|
this.chartInstance.on('legendselectchanged', this.onLegendSelectChanged.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
onLegendSelectChanged(e) {
|
||||||
|
this.selectedWallets = e.selected;
|
||||||
|
|
||||||
|
this.chartOptions = {
|
||||||
|
legend: {
|
||||||
|
selected: this.selectedWallets,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.chartInstance) {
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.subscription) {
|
||||||
|
this.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMobile() {
|
||||||
|
return (window.innerWidth <= 767.98);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,7 @@
|
|||||||
|
<div>
|
||||||
|
<div class="container pb-lg-0">
|
||||||
|
<div class="chart-widget" *browserOnly [style]="{ height: (height + 'px'), opacity: isLoading ? 0.5 : 1 }" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||||
|
(chartInit)="onChartInit($event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,135 @@
|
|||||||
|
.card-header {
|
||||||
|
border-bottom: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
@media (min-width: 465px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-container {
|
||||||
|
padding: 0px 15px;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 140px);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
height: calc(100% - 190px);
|
||||||
|
};
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
height: calc(100% - 230px);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
max-height: 400px;
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
max-height: 230px;
|
||||||
|
}
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.chart-widget {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
@media (max-width: 485px) {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.pools-table th,
|
||||||
|
.pools-table td {
|
||||||
|
padding: .3em !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
.pool-name {
|
||||||
|
max-width: 110px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.health-column {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingGraphs {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(50% - 15px);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-distribution {
|
||||||
|
min-height: 56px;
|
||||||
|
display: block;
|
||||||
|
@media (min-width: 485px) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
max-width: 160px;
|
||||||
|
width: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0px auto 20px;
|
||||||
|
&:nth-child(2) {
|
||||||
|
order: 2;
|
||||||
|
@media (min-width: 485px) {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
width: 50%;
|
||||||
|
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: var(--title-fg);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.card-text {
|
||||||
|
font-size: 18px;
|
||||||
|
span {
|
||||||
|
color: var(--transparent-fg);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-loader {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
max-width: 80px;
|
||||||
|
margin: 15px auto 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
td {
|
||||||
|
.difference {
|
||||||
|
&.positive {
|
||||||
|
color: rgb(66, 183, 71);
|
||||||
|
}
|
||||||
|
&.negative {
|
||||||
|
color: rgb(183, 66, 66);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,289 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, Input, OnChanges, SimpleChanges, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
|
||||||
|
import { EChartsOption, PieSeriesOption } from '@app/graphs/echarts';
|
||||||
|
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
|
||||||
|
import { StateService } from '@app/services/state.service';
|
||||||
|
import { download } from '@app/shared/graphs.utils';
|
||||||
|
import { isMobile } from '@app/shared/common.utils';
|
||||||
|
import { WalletStats } from '@app/shared/wallet-stats';
|
||||||
|
import { AddressTxSummary } from '@interfaces/electrs.interface';
|
||||||
|
import { chartColors } from '@app/app.constants';
|
||||||
|
import { formatNumber } from '@angular/common';
|
||||||
|
import { Treasury } from '@interfaces/node-api.interface';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-treasuries-pie',
|
||||||
|
templateUrl: './treasuries-pie.component.html',
|
||||||
|
styleUrls: ['./treasuries-pie.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class TreasuriesPieComponent implements OnChanges {
|
||||||
|
@Input() height: number = 300;
|
||||||
|
@Input() mode: 'relative' | 'all' = 'relative';
|
||||||
|
@Input() walletStats: Record<string, WalletStats>;
|
||||||
|
@Input() walletSummaries$: Observable<Record<string, AddressTxSummary[]>>;
|
||||||
|
@Input() selectedWallets: Record<string, boolean> = {};
|
||||||
|
@Input() treasuries: Treasury[] = [];
|
||||||
|
@Output() navigateToTreasury: EventEmitter<Treasury> = new EventEmitter();
|
||||||
|
|
||||||
|
chartOptions: EChartsOption = {};
|
||||||
|
chartInitOptions = {
|
||||||
|
renderer: 'svg',
|
||||||
|
};
|
||||||
|
chartInstance: any = undefined;
|
||||||
|
error: any;
|
||||||
|
isLoading = true;
|
||||||
|
subscription: Subscription;
|
||||||
|
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
walletBalance: Record<string, number> = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(LOCALE_ID) public locale: string,
|
||||||
|
public stateService: StateService,
|
||||||
|
private cd: ChangeDetectorRef,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.setupSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.walletSummaries$ || changes.selectedWallets || changes.mode) {
|
||||||
|
if (this.subscription) {
|
||||||
|
this.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
this.setupSubscription();
|
||||||
|
} else {
|
||||||
|
// re-trigger subscription
|
||||||
|
this.redraw$.next(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSubscription(): void {
|
||||||
|
this.subscription = combineLatest([
|
||||||
|
this.redraw$,
|
||||||
|
this.walletSummaries$
|
||||||
|
]).subscribe(([_, walletSummaries]) => {
|
||||||
|
if (walletSummaries) {
|
||||||
|
this.error = null;
|
||||||
|
this.processWalletData(walletSummaries);
|
||||||
|
this.prepareChartOptions();
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
this.cd.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
processWalletData(walletSummaries: Record<string, AddressTxSummary[]>): void {
|
||||||
|
this.walletBalance = {};
|
||||||
|
for (const treasury of this.treasuries) {
|
||||||
|
const summary = walletSummaries[treasury.wallet];
|
||||||
|
if (summary?.length) {
|
||||||
|
const total = this.walletStats[treasury.wallet] ? this.walletStats[treasury.wallet].balance : summary.reduce((acc, tx) => acc + tx.value, 0);
|
||||||
|
this.walletBalance[treasury.wallet] = total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateChartSeriesData(): PieSeriesOption[] {
|
||||||
|
let sliceThreshold = 1;
|
||||||
|
if (isMobile()) {
|
||||||
|
sliceThreshold = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: object[] = [];
|
||||||
|
|
||||||
|
let edgeDistance: any = '20%';
|
||||||
|
if (isMobile()) {
|
||||||
|
edgeDistance = 0;
|
||||||
|
} else {
|
||||||
|
edgeDistance = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const treasuriesTotal = Object.values(this.walletBalance).reduce((acc, v) => acc + v, 0);
|
||||||
|
const total = this.mode === 'relative' ? treasuriesTotal : 2099999997690000;
|
||||||
|
|
||||||
|
const entries: {
|
||||||
|
treasury?: any,
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
balance: number,
|
||||||
|
share: number,
|
||||||
|
color: string,
|
||||||
|
}[] = this.treasuries.map((treasury, index) => ({
|
||||||
|
treasury,
|
||||||
|
id: treasury.wallet,
|
||||||
|
label: treasury.enterprise || treasury.name,
|
||||||
|
balance: this.walletBalance[treasury.wallet],
|
||||||
|
share: (this.walletBalance[treasury.wallet] / total) * 100,
|
||||||
|
color: chartColors[index % chartColors.length],
|
||||||
|
}));
|
||||||
|
if (this.mode === 'all') {
|
||||||
|
entries.unshift({
|
||||||
|
id: 'remaining',
|
||||||
|
label: 'Remaining',
|
||||||
|
balance: (total - treasuriesTotal),
|
||||||
|
share: ((total - treasuriesTotal) / total) * 100,
|
||||||
|
color: 'orange'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('ALL! ', entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherEntry = { id: 'other', label: 'Other', balance: 0, share: 0 };
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.share < sliceThreshold) {
|
||||||
|
otherEntry.balance += entry.balance;
|
||||||
|
otherEntry.share = (otherEntry.balance / total) * 100;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.push({
|
||||||
|
itemStyle: {
|
||||||
|
color: entry.color,
|
||||||
|
},
|
||||||
|
value: entry.share,
|
||||||
|
name: entry.id,
|
||||||
|
label: {
|
||||||
|
overflow: 'none',
|
||||||
|
color: 'var(--tooltip-grey)',
|
||||||
|
alignTo: 'edge',
|
||||||
|
edgeDistance: edgeDistance,
|
||||||
|
formatter: () => {
|
||||||
|
return entry.label;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
show: !isMobile(),
|
||||||
|
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||||
|
borderRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: 'var(--tooltip-grey)',
|
||||||
|
},
|
||||||
|
borderColor: '#000',
|
||||||
|
formatter: () => {
|
||||||
|
return `<b style="color: white">${entry.label} (${entry.share.toFixed(2)}%)</b><br>
|
||||||
|
${formatNumber(entry.balance / 100_000_000, this.locale, '1.3-3')} BTC<br>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: entry.treasury,
|
||||||
|
} as PieSeriesOption);
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentage = otherEntry.share.toFixed(2) + '%';
|
||||||
|
|
||||||
|
if (otherEntry.share > 0) {
|
||||||
|
data.push({
|
||||||
|
itemStyle: {
|
||||||
|
color: '#6b6b6b',
|
||||||
|
},
|
||||||
|
value: otherEntry.share,
|
||||||
|
name: $localize`Other (${percentage})`,
|
||||||
|
label: {
|
||||||
|
overflow: 'none',
|
||||||
|
color: 'var(--tooltip-grey)',
|
||||||
|
alignTo: 'edge',
|
||||||
|
edgeDistance: edgeDistance
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||||
|
borderRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: 'var(--tooltip-grey)',
|
||||||
|
},
|
||||||
|
borderColor: '#000',
|
||||||
|
formatter: () => {
|
||||||
|
return `<b style="color: white">${otherEntry.id} (${otherEntry.share}%)</b><br>
|
||||||
|
${formatNumber(otherEntry.balance, this.locale, '1.3-3')}<br>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as PieSeriesOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareChartOptions(): void {
|
||||||
|
const pieSize = ['20%', '80%']; // Desktop
|
||||||
|
|
||||||
|
this.chartOptions = {
|
||||||
|
animation: false,
|
||||||
|
color: chartColors,
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
textStyle: {
|
||||||
|
align: 'left',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
zlevel: 0,
|
||||||
|
minShowLabelAngle: 1.8,
|
||||||
|
name: 'Treasuries',
|
||||||
|
type: 'pie',
|
||||||
|
radius: pieSize,
|
||||||
|
data: this.generateChartSeriesData(),
|
||||||
|
labelLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#000',
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 40,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onChartInit(ec): void {
|
||||||
|
if (this.chartInstance !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chartInstance = ec;
|
||||||
|
this.chartInstance.on('click', (e) => {
|
||||||
|
if (!e.data.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.navigateToTreasury.emit(e.data.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaveChart(): void {
|
||||||
|
const now = new Date();
|
||||||
|
this.chartOptions.backgroundColor = 'var(--active-bg)';
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
download(this.chartInstance.getDataURL({
|
||||||
|
pixelRatio: 2,
|
||||||
|
excludeComponents: ['dataZoom'],
|
||||||
|
}), `treasuries-pie-${Math.round(now.getTime() / 1000)}.svg`);
|
||||||
|
this.chartOptions.backgroundColor = 'none';
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
isEllipsisActive(e: HTMLElement): boolean {
|
||||||
|
return (e.offsetWidth < e.scrollWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
100
frontend/src/app/components/treasuries/treasuries.component.html
Normal file
100
frontend/src/app/components/treasuries/treasuries.component.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<div class="container-xl dashboard-container">
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title" i18n="dashboard.treasury-leaderboard">Treasury Leaderboard</h5>
|
||||||
|
<table class="table table-striped treasury-leaderboard-table">
|
||||||
|
<thead>
|
||||||
|
<th class="table-cell-position"></th>
|
||||||
|
<th class="table-cell-name" i18n="dashboard.treasury-leaderboard.treasury">Treasury</th>
|
||||||
|
<th class="table-cell-balance" i18n="dashboard.treasury-leaderboard.balance">BTC Balance</th>
|
||||||
|
<th class="table-cell-value" i18n="dashboard.treasury-leaderboard.value">USD Value</th>
|
||||||
|
</thead>
|
||||||
|
<tbody *ngIf="walletStats$ | async as walletStats; else leaderboardSkeleton">
|
||||||
|
<ng-container *ngIf="(sortedTreasuries$ | async) as sortedTreasuries">
|
||||||
|
<tr *ngFor="let treasury of sortedTreasuries.slice(0, 7); let i = index"
|
||||||
|
(click)="navigateToTreasury(treasury)"
|
||||||
|
class="clickable-row">
|
||||||
|
<td class="table-cell-position">
|
||||||
|
<div class="position-container">
|
||||||
|
<div class="color-swatch" [style.background-color]="getTreasuryColor(treasury)"></div>
|
||||||
|
<span class="position-number">{{i + 1}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="table-cell-name">{{ treasury.enterprise || treasury.name }}</td>
|
||||||
|
<td class="table-cell-balance">
|
||||||
|
<app-amount [satoshis]="walletStats[treasury.wallet]?.balance || 0" digitsInfo="1.2-4" [noFiat]="true"></app-amount>
|
||||||
|
</td>
|
||||||
|
<td class="table-cell-value">
|
||||||
|
<app-fiat [value]="walletStats[treasury.wallet]?.balance || 0" digitsInfo="1.0-0"></app-fiat>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-container>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body pl-2 pr-2">
|
||||||
|
<h5 class="card-title" i18n="dashboard.treasury-distribution">Treasury Distribution</h5>
|
||||||
|
<div *ngIf="walletStats$ | async as walletStats">
|
||||||
|
<app-treasuries-pie
|
||||||
|
[walletStats]="walletStats"
|
||||||
|
[walletSummaries$]="walletSummaries$"
|
||||||
|
[selectedWallets]="selectedWallets"
|
||||||
|
[treasuries]="currentSortedTreasuries"
|
||||||
|
[height]="375"
|
||||||
|
mode="relative"
|
||||||
|
(navigateToTreasury)="onNavigateToTreasury($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title" i18n="dashboard.balance-history">Balance History</h5>
|
||||||
|
<div *ngIf="walletStats$ | async as walletStats">
|
||||||
|
<app-treasuries-graph
|
||||||
|
[walletStats]="walletStats"
|
||||||
|
[walletSummaries$]="walletSummaries$"
|
||||||
|
[selectedWallets]="selectedWallets"
|
||||||
|
[treasuries]="currentSortedTreasuries"
|
||||||
|
[height]="400"
|
||||||
|
[right]="10"
|
||||||
|
[left]="70"
|
||||||
|
[showLegend]="true"
|
||||||
|
[showYAxis]="true"
|
||||||
|
[widget]="false"
|
||||||
|
[allowZoom]="false"
|
||||||
|
period="all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #leaderboardSkeleton>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let i of [1,2,3,4,5,6]">
|
||||||
|
<td class="table-cell-position">
|
||||||
|
<div class="position-container">
|
||||||
|
<div class="color-swatch skeleton-loader"></div>
|
||||||
|
<span class="position-number skeleton-loader"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="table-cell-name"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
|
||||||
|
<td class="table-cell-balance"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
|
||||||
|
<td class="table-cell-value"><div class="skeleton-loader skeleton-loader-transactions"></div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</ng-template>
|
162
frontend/src/app/components/treasuries/treasuries.component.scss
Normal file
162
frontend/src/app/components/treasuries/treasuries.component.scss
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
.dashboard-container {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
.col {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--bg);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: var(--title-fg);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--secondary);
|
||||||
|
height: 1.1rem;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning {
|
||||||
|
background-color: #b58800 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-loader {
|
||||||
|
max-width: 100%;
|
||||||
|
background-color: var(--secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-loader-transactions {
|
||||||
|
max-width: 250px;
|
||||||
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-padding {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-card {
|
||||||
|
height: 100%;
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
height: 415px;
|
||||||
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
height: 510px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
position: relative;
|
||||||
|
color: #ffffff91;
|
||||||
|
margin-top: -13px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
&.liquid {
|
||||||
|
height: 124.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.less-padding {
|
||||||
|
padding: 20px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.treasury-leaderboard-table {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
table-layout: fixed;
|
||||||
|
tr, td, th {
|
||||||
|
border: 0px;
|
||||||
|
padding-top: 0.65rem !important;
|
||||||
|
padding-bottom: 0.7rem !important;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.table-cell-position {
|
||||||
|
width: 10%;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
.position-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 40px;
|
||||||
|
.color-swatch {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.position-number {
|
||||||
|
min-width: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.table-cell-name {
|
||||||
|
width: 25%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.table-cell-balance {
|
||||||
|
width: 25%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.table-cell-value {
|
||||||
|
width: 25%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.table-cell-value {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-container {
|
||||||
|
.skeleton-loader {
|
||||||
|
&.color-swatch {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
}
|
||||||
|
&.position-number {
|
||||||
|
width: 20px;
|
||||||
|
height: 18px;
|
||||||
|
min-width: 20px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
292
frontend/src/app/components/treasuries/treasuries.component.ts
Normal file
292
frontend/src/app/components/treasuries/treasuries.component.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { Observable, combineLatest, of, Subscription } from 'rxjs';
|
||||||
|
import { AddressTxSummary, Transaction, Address } from '@interfaces/electrs.interface';
|
||||||
|
import { ApiService } from '@app/services/api.service';
|
||||||
|
import { StateService } from '@app/services/state.service';
|
||||||
|
import { catchError, map, scan, shareReplay, startWith, switchMap, tap } from 'rxjs/operators';
|
||||||
|
import { WalletStats } from '@app/shared/wallet-stats';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { chartColors } from '@app/app.constants';
|
||||||
|
import { Treasury } from '@interfaces/node-api.interface';
|
||||||
|
@Component({
|
||||||
|
selector: 'app-treasuries',
|
||||||
|
templateUrl: './treasuries.component.html',
|
||||||
|
styleUrls: ['./treasuries.component.scss']
|
||||||
|
})
|
||||||
|
export class TreasuriesComponent implements OnInit, OnDestroy {
|
||||||
|
treasuries: Treasury[] = [];
|
||||||
|
walletSummaries$: Observable<Record<string, AddressTxSummary[]>>;
|
||||||
|
selectedWallets: Record<string, boolean> = {};
|
||||||
|
isLoading = true;
|
||||||
|
error: any;
|
||||||
|
walletSubscriptions: Subscription[] = [];
|
||||||
|
currentSortedTreasuries: Treasury[] = [];
|
||||||
|
|
||||||
|
// Individual wallet data
|
||||||
|
walletObservables: Record<string, Observable<Record<string, any>>> = {};
|
||||||
|
walletAddressesObservables: Record<string, Observable<Record<string, Address>>> = {};
|
||||||
|
individualWalletSummaries: Record<string, Observable<AddressTxSummary[]>> = {};
|
||||||
|
walletStatsObservables: Record<string, Observable<WalletStats>> = {};
|
||||||
|
walletStats$: Observable<Record<string, WalletStats>>;
|
||||||
|
sortedTreasuries$: Observable<Treasury[]>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private stateService: StateService,
|
||||||
|
private router: Router,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Fetch the list of wallets from the API
|
||||||
|
this.apiService.getTreasuries$().pipe(
|
||||||
|
catchError(err => {
|
||||||
|
console.error('Error loading treasuries list:', err);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
).subscribe(treasuries => {
|
||||||
|
this.treasuries = treasuries;
|
||||||
|
|
||||||
|
// Initialize all wallets as enabled by default
|
||||||
|
this.treasuries.forEach(treasury => {
|
||||||
|
this.selectedWallets[treasury.wallet] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up wallet data after we have the wallet list
|
||||||
|
this.setupWalletData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupWalletData() {
|
||||||
|
this.treasuries.forEach(treasury => {
|
||||||
|
this.walletObservables[treasury.wallet] = this.apiService.getWallet$(treasury.wallet).pipe(
|
||||||
|
catchError((err) => {
|
||||||
|
console.log(`Error loading wallet ${treasury.wallet}:`, err);
|
||||||
|
return of({});
|
||||||
|
}),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.walletAddressesObservables[treasury.wallet] = this.walletObservables[treasury.wallet].pipe(
|
||||||
|
map(wallet => {
|
||||||
|
const walletInfo: Record<string, Address> = {};
|
||||||
|
for (const address of Object.keys(wallet || {})) {
|
||||||
|
walletInfo[address] = {
|
||||||
|
address,
|
||||||
|
chain_stats: wallet[address]?.stats || {
|
||||||
|
funded_txo_count: 0,
|
||||||
|
funded_txo_sum: 0,
|
||||||
|
spent_txo_count: 0,
|
||||||
|
spent_txo_sum: 0,
|
||||||
|
tx_count: 0
|
||||||
|
},
|
||||||
|
mempool_stats: {
|
||||||
|
funded_txo_count: 0,
|
||||||
|
funded_txo_sum: 0,
|
||||||
|
spent_txo_count: 0,
|
||||||
|
spent_txo_sum: 0,
|
||||||
|
tx_count: 0
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return walletInfo;
|
||||||
|
}),
|
||||||
|
switchMap(initial => this.stateService.walletTransactions$.pipe(
|
||||||
|
startWith(null),
|
||||||
|
scan((wallet, walletTransactions) => {
|
||||||
|
for (const tx of (walletTransactions || [])) {
|
||||||
|
const funded: Record<string, number> = {};
|
||||||
|
const spent: Record<string, number> = {};
|
||||||
|
const fundedCount: Record<string, number> = {};
|
||||||
|
const spentCount: Record<string, number> = {};
|
||||||
|
for (const vin of tx.vin || []) {
|
||||||
|
const address = vin.prevout?.scriptpubkey_address;
|
||||||
|
if (address && wallet[address]) {
|
||||||
|
spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0);
|
||||||
|
spentCount[address] = (spentCount[address] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const vout of tx.vout || []) {
|
||||||
|
const address = vout.scriptpubkey_address;
|
||||||
|
if (address && wallet[address]) {
|
||||||
|
funded[address] = (funded[address] ?? 0) + (vout.value ?? 0);
|
||||||
|
fundedCount[address] = (fundedCount[address] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const address of Object.keys({ ...funded, ...spent })) {
|
||||||
|
// update address stats
|
||||||
|
wallet[address].chain_stats.tx_count++;
|
||||||
|
wallet[address].chain_stats.funded_txo_count += fundedCount[address] || 0;
|
||||||
|
wallet[address].chain_stats.spent_txo_count += spentCount[address] || 0;
|
||||||
|
wallet[address].chain_stats.funded_txo_sum += funded[address] || 0;
|
||||||
|
wallet[address].chain_stats.spent_txo_sum += spent[address] || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wallet;
|
||||||
|
}, initial)
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.individualWalletSummaries[treasury.wallet] = this.walletObservables[treasury.wallet].pipe(
|
||||||
|
switchMap(wallet => this.stateService.walletTransactions$.pipe(
|
||||||
|
startWith([]),
|
||||||
|
scan((summaries, newTransactions: Transaction[]) => {
|
||||||
|
const newSummaries: AddressTxSummary[] = [];
|
||||||
|
for (const tx of newTransactions || []) {
|
||||||
|
const funded: Record<string, number> = {};
|
||||||
|
const spent: Record<string, number> = {};
|
||||||
|
const fundedCount: Record<string, number> = {};
|
||||||
|
const spentCount: Record<string, number> = {};
|
||||||
|
for (const vin of tx.vin || []) {
|
||||||
|
const address = vin.prevout?.scriptpubkey_address;
|
||||||
|
if (address && wallet[address]) {
|
||||||
|
spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0);
|
||||||
|
spentCount[address] = (spentCount[address] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const vout of tx.vout || []) {
|
||||||
|
const address = vout.scriptpubkey_address;
|
||||||
|
if (address && wallet[address]) {
|
||||||
|
funded[address] = (funded[address] ?? 0) + (vout.value ?? 0);
|
||||||
|
fundedCount[address] = (fundedCount[address] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const address of Object.keys({ ...funded, ...spent })) {
|
||||||
|
// add tx to summary
|
||||||
|
const txSummary: AddressTxSummary = {
|
||||||
|
txid: tx.txid,
|
||||||
|
value: (funded[address] ?? 0) - (spent[address] ?? 0),
|
||||||
|
height: tx.status.block_height,
|
||||||
|
time: tx.status.block_time,
|
||||||
|
};
|
||||||
|
if (wallet[address]?.transactions) {
|
||||||
|
wallet[address].transactions.push(txSummary);
|
||||||
|
} else if (wallet[address]) {
|
||||||
|
wallet[address].transactions = [txSummary];
|
||||||
|
}
|
||||||
|
newSummaries.push(txSummary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.deduplicateWalletTransactions([...summaries, ...newSummaries]);
|
||||||
|
}, this.deduplicateWalletTransactions(
|
||||||
|
Object.values(wallet || {}).flatMap(address => address?.transactions || [])
|
||||||
|
))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.walletStatsObservables[treasury.wallet] = this.walletObservables[treasury.wallet].pipe(
|
||||||
|
switchMap(wallet => {
|
||||||
|
const walletStats = new WalletStats(
|
||||||
|
Object.values(wallet || {}).map(w => w?.stats || {}),
|
||||||
|
Object.keys(wallet || {})
|
||||||
|
);
|
||||||
|
return of(walletStats);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const walletSummaryKeys = Object.keys(this.individualWalletSummaries);
|
||||||
|
const walletSummaryObservables = walletSummaryKeys.map(key => this.individualWalletSummaries[key]);
|
||||||
|
|
||||||
|
this.walletSummaries$ = combineLatest(walletSummaryObservables).pipe(
|
||||||
|
map((summaries) => {
|
||||||
|
const result: Record<string, AddressTxSummary[]> = {};
|
||||||
|
summaries.forEach((summary, index) => {
|
||||||
|
if (summary && summary.length > 0) {
|
||||||
|
result[walletSummaryKeys[index]] = summary;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
tap((data) => {
|
||||||
|
this.selectedWallets = {};
|
||||||
|
Object.keys(data).forEach(wallet => {
|
||||||
|
this.selectedWallets[wallet] = true;
|
||||||
|
});
|
||||||
|
this.isLoading = false;
|
||||||
|
}),
|
||||||
|
shareReplay(1),
|
||||||
|
catchError(err => {
|
||||||
|
this.error = err;
|
||||||
|
console.log(err);
|
||||||
|
return of({});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const walletStatsKeys = Object.keys(this.walletStatsObservables);
|
||||||
|
const walletStatsObservables = walletStatsKeys.map(key => this.walletStatsObservables[key]);
|
||||||
|
|
||||||
|
this.walletStats$ = combineLatest(walletStatsObservables).pipe(
|
||||||
|
map((stats) => {
|
||||||
|
const result: Record<string, WalletStats> = {};
|
||||||
|
stats.forEach((stat, index) => {
|
||||||
|
result[walletStatsKeys[index]] = stat;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
shareReplay(1),
|
||||||
|
catchError(err => {
|
||||||
|
this.error = err;
|
||||||
|
console.log(err);
|
||||||
|
return of({});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sortedTreasuries$ = this.walletStats$.pipe(
|
||||||
|
map(walletStats => {
|
||||||
|
return [...this.treasuries].sort((a, b) => {
|
||||||
|
const balanceA = walletStats[a.wallet]?.balance || 0;
|
||||||
|
const balanceB = walletStats[b.wallet]?.balance || 0;
|
||||||
|
return balanceB - balanceA;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
tap(sortedWallets => {
|
||||||
|
// Update selectedWallets to maintain the same order
|
||||||
|
const newSelectedWallets: Record<string, boolean> = {};
|
||||||
|
sortedWallets.forEach(treasury => {
|
||||||
|
newSelectedWallets[treasury.wallet] = this.selectedWallets[treasury.wallet] ?? true;
|
||||||
|
});
|
||||||
|
this.selectedWallets = newSelectedWallets;
|
||||||
|
this.currentSortedTreasuries = sortedWallets;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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 ?? 0) - (a.tx_position ?? 0);
|
||||||
|
}
|
||||||
|
return b.height - a.height;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTreasuryColor(treasury: Treasury): string {
|
||||||
|
const index = this.currentSortedTreasuries.indexOf(treasury);
|
||||||
|
return chartColors[index % chartColors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
onNavigateToTreasury(treasury: Treasury): void {
|
||||||
|
this.navigateToTreasury(treasury);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToTreasury(treasury: Treasury): void {
|
||||||
|
this.router.navigate(['/wallet', treasury.wallet]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
// Clean up subscriptions
|
||||||
|
this.walletSubscriptions.forEach(sub => {
|
||||||
|
if (sub) {
|
||||||
|
sub.unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -12,95 +12,7 @@ import { WalletAddress } from '@interfaces/node-api.interface';
|
|||||||
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
||||||
import { AudioService } from '@app/services/audio.service';
|
import { AudioService } from '@app/services/audio.service';
|
||||||
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
|
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
|
||||||
|
import { WalletStats } from '@app/shared/wallet-stats';
|
||||||
|
|
||||||
class WalletStats implements ChainStats {
|
|
||||||
addresses: string[];
|
|
||||||
funded_txo_count: number;
|
|
||||||
funded_txo_sum: number;
|
|
||||||
spent_txo_count: number;
|
|
||||||
spent_txo_sum: number;
|
|
||||||
tx_count: number;
|
|
||||||
|
|
||||||
constructor (stats: ChainStats[], addresses: string[]) {
|
|
||||||
Object.assign(this, stats.reduce((acc, stat) => {
|
|
||||||
acc.funded_txo_count += stat.funded_txo_count;
|
|
||||||
acc.funded_txo_sum += stat.funded_txo_sum;
|
|
||||||
acc.spent_txo_count += stat.spent_txo_count;
|
|
||||||
acc.spent_txo_sum += stat.spent_txo_sum;
|
|
||||||
acc.tx_count += stat.tx_count;
|
|
||||||
return acc;
|
|
||||||
}, {
|
|
||||||
funded_txo_count: 0,
|
|
||||||
funded_txo_sum: 0,
|
|
||||||
spent_txo_count: 0,
|
|
||||||
spent_txo_sum: 0,
|
|
||||||
tx_count: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
this.addresses = addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
public addTx(tx: Transaction): void {
|
|
||||||
for (const vin of tx.vin) {
|
|
||||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
|
||||||
this.spendTxo(vin.prevout.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const vout of tx.vout) {
|
|
||||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
|
||||||
this.fundTxo(vout.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.tx_count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeTx(tx: Transaction): void {
|
|
||||||
for (const vin of tx.vin) {
|
|
||||||
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
|
||||||
this.unspendTxo(vin.prevout.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const vout of tx.vout) {
|
|
||||||
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
|
||||||
this.unfundTxo(vout.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.tx_count--;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fundTxo(value: number): void {
|
|
||||||
this.funded_txo_sum += value;
|
|
||||||
this.funded_txo_count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
private unfundTxo(value: number): void {
|
|
||||||
this.funded_txo_sum -= value;
|
|
||||||
this.funded_txo_count--;
|
|
||||||
}
|
|
||||||
|
|
||||||
private spendTxo(value: number): void {
|
|
||||||
this.spent_txo_sum += value;
|
|
||||||
this.spent_txo_count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
private unspendTxo(value: number): void {
|
|
||||||
this.spent_txo_sum -= value;
|
|
||||||
this.spent_txo_count--;
|
|
||||||
}
|
|
||||||
|
|
||||||
get balance(): number {
|
|
||||||
return this.funded_txo_sum - this.spent_txo_sum;
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalReceived(): number {
|
|
||||||
return this.funded_txo_sum;
|
|
||||||
}
|
|
||||||
|
|
||||||
get utxos(): number {
|
|
||||||
return this.funded_txo_count - this.spent_txo_count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-wallet',
|
selector: 'app-wallet',
|
||||||
|
@@ -30,6 +30,7 @@ import { DashboardComponent } from '@app/dashboard/dashboard.component';
|
|||||||
import { CustomDashboardComponent } from '@components/custom-dashboard/custom-dashboard.component';
|
import { CustomDashboardComponent } from '@components/custom-dashboard/custom-dashboard.component';
|
||||||
import { MiningDashboardComponent } from '@components/mining-dashboard/mining-dashboard.component';
|
import { MiningDashboardComponent } from '@components/mining-dashboard/mining-dashboard.component';
|
||||||
import { AcceleratorDashboardComponent } from '@components/acceleration/accelerator-dashboard/accelerator-dashboard.component';
|
import { AcceleratorDashboardComponent } from '@components/acceleration/accelerator-dashboard/accelerator-dashboard.component';
|
||||||
|
import { TreasuriesComponent } from '@components/treasuries/treasuries.component';
|
||||||
import { HashrateChartComponent } from '@components/hashrate-chart/hashrate-chart.component';
|
import { HashrateChartComponent } from '@components/hashrate-chart/hashrate-chart.component';
|
||||||
import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/hashrate-chart-pools.component';
|
import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/hashrate-chart-pools.component';
|
||||||
import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component';
|
import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component';
|
||||||
@@ -37,6 +38,8 @@ import { AddressComponent } from '@components/address/address.component';
|
|||||||
import { WalletComponent } from '@components/wallet/wallet.component';
|
import { WalletComponent } from '@components/wallet/wallet.component';
|
||||||
import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component';
|
import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component';
|
||||||
import { AddressGraphComponent } from '@components/address-graph/address-graph.component';
|
import { AddressGraphComponent } from '@components/address-graph/address-graph.component';
|
||||||
|
import { TreasuriesGraphComponent } from '@components/treasuries/treasuries-graph/treasuries-graph.component';
|
||||||
|
import { TreasuriesPieComponent } from '@components/treasuries/treasuries-pie/treasuries-pie.component';
|
||||||
import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component';
|
import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component';
|
||||||
import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component';
|
import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component';
|
||||||
import { AddressesTreemap } from '@components/addresses-treemap/addresses-treemap.component';
|
import { AddressesTreemap } from '@components/addresses-treemap/addresses-treemap.component';
|
||||||
@@ -57,7 +60,7 @@ import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
|
|||||||
AcceleratorDashboardComponent,
|
AcceleratorDashboardComponent,
|
||||||
PoolComponent,
|
PoolComponent,
|
||||||
PoolRankingComponent,
|
PoolRankingComponent,
|
||||||
|
TreasuriesComponent,
|
||||||
StatisticsComponent,
|
StatisticsComponent,
|
||||||
GraphsComponent,
|
GraphsComponent,
|
||||||
AccelerationFeesGraphComponent,
|
AccelerationFeesGraphComponent,
|
||||||
@@ -82,6 +85,8 @@ import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
|
|||||||
HashrateChartPoolsComponent,
|
HashrateChartPoolsComponent,
|
||||||
BlockHealthGraphComponent,
|
BlockHealthGraphComponent,
|
||||||
AddressGraphComponent,
|
AddressGraphComponent,
|
||||||
|
TreasuriesGraphComponent,
|
||||||
|
TreasuriesPieComponent,
|
||||||
UtxoGraphComponent,
|
UtxoGraphComponent,
|
||||||
ActiveAccelerationBox,
|
ActiveAccelerationBox,
|
||||||
AddressesTreemap,
|
AddressesTreemap,
|
||||||
|
@@ -18,6 +18,7 @@ import { StartComponent } from '@components/start/start.component';
|
|||||||
import { StatisticsComponent } from '@components/statistics/statistics.component';
|
import { StatisticsComponent } from '@components/statistics/statistics.component';
|
||||||
import { DashboardComponent } from '@app/dashboard/dashboard.component';
|
import { DashboardComponent } from '@app/dashboard/dashboard.component';
|
||||||
import { CustomDashboardComponent } from '@components/custom-dashboard/custom-dashboard.component';
|
import { CustomDashboardComponent } from '@components/custom-dashboard/custom-dashboard.component';
|
||||||
|
import { TreasuriesComponent } from '@components/treasuries/treasuries.component';
|
||||||
import { AccelerationFeesGraphComponent } from '@components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component';
|
import { AccelerationFeesGraphComponent } from '@components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component';
|
||||||
import { AccelerationsListComponent } from '@components/acceleration/accelerations-list/accelerations-list.component';
|
import { AccelerationsListComponent } from '@components/acceleration/accelerations-list/accelerations-list.component';
|
||||||
import { AddressComponent } from '@components/address/address.component';
|
import { AddressComponent } from '@components/address/address.component';
|
||||||
@@ -181,6 +182,21 @@ const routes: Routes = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) {
|
||||||
|
routes[0].children?.push({
|
||||||
|
path: 'treasuries',
|
||||||
|
component: StartComponent,
|
||||||
|
children: [{
|
||||||
|
path: '',
|
||||||
|
component: TreasuriesComponent,
|
||||||
|
data: {
|
||||||
|
networks: ['bitcoin'],
|
||||||
|
networkSpecific: true,
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [RouterModule.forChild(routes)],
|
imports: [RouterModule.forChild(routes)],
|
||||||
})
|
})
|
||||||
|
@@ -481,3 +481,10 @@ export interface WalletAddress {
|
|||||||
stats: ChainStats;
|
stats: ChainStats;
|
||||||
transactions: AddressTxSummary[];
|
transactions: AddressTxSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Treasury {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
wallet: string,
|
||||||
|
enterprise: string,
|
||||||
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
|
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
|
||||||
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, SubmitPackageResult } from '../interfaces/node-api.interface';
|
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, Treasury, SubmitPackageResult } from '../interfaces/node-api.interface';
|
||||||
import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
|
import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
|
||||||
import { StateService } from '@app/services/state.service';
|
import { StateService } from '@app/services/state.service';
|
||||||
import { Transaction } from '@interfaces/electrs.interface';
|
import { Transaction } from '@interfaces/electrs.interface';
|
||||||
@@ -523,6 +523,12 @@ export class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTreasuries$(): Observable<Treasury[]> {
|
||||||
|
return this.httpClient.get<Treasury[]>(
|
||||||
|
this.apiBaseUrl + this.apiBasePath + `/api/v1/treasuries`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getWallet$(walletName: string): Observable<Record<string, WalletAddress>> {
|
getWallet$(walletName: string): Observable<Record<string, WalletAddress>> {
|
||||||
return this.httpClient.get<Record<string, WalletAddress>>(
|
return this.httpClient.get<Record<string, WalletAddress>>(
|
||||||
this.apiBaseUrl + this.apiBasePath + `/api/v1/wallet/${walletName}`
|
this.apiBaseUrl + this.apiBasePath + `/api/v1/wallet/${walletName}`
|
||||||
|
89
frontend/src/app/shared/wallet-stats.ts
Normal file
89
frontend/src/app/shared/wallet-stats.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { ChainStats, Transaction } from '@interfaces/electrs.interface';
|
||||||
|
|
||||||
|
export class WalletStats implements ChainStats {
|
||||||
|
addresses: string[];
|
||||||
|
funded_txo_count: number;
|
||||||
|
funded_txo_sum: number;
|
||||||
|
spent_txo_count: number;
|
||||||
|
spent_txo_sum: number;
|
||||||
|
tx_count: number;
|
||||||
|
|
||||||
|
constructor (stats: ChainStats[], addresses: string[]) {
|
||||||
|
Object.assign(this, stats.reduce((acc, stat) => {
|
||||||
|
acc.funded_txo_count += stat.funded_txo_count;
|
||||||
|
acc.funded_txo_sum += stat.funded_txo_sum;
|
||||||
|
acc.spent_txo_count += stat.spent_txo_count;
|
||||||
|
acc.spent_txo_sum += stat.spent_txo_sum;
|
||||||
|
acc.tx_count += stat.tx_count;
|
||||||
|
return acc;
|
||||||
|
}, {
|
||||||
|
funded_txo_count: 0,
|
||||||
|
funded_txo_sum: 0,
|
||||||
|
spent_txo_count: 0,
|
||||||
|
spent_txo_sum: 0,
|
||||||
|
tx_count: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.addresses = addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addTx(tx: Transaction): void {
|
||||||
|
for (const vin of tx.vin) {
|
||||||
|
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||||
|
this.spendTxo(vin.prevout.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const vout of tx.vout) {
|
||||||
|
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||||
|
this.fundTxo(vout.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.tx_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeTx(tx: Transaction): void {
|
||||||
|
for (const vin of tx.vin) {
|
||||||
|
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
|
||||||
|
this.unspendTxo(vin.prevout.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const vout of tx.vout) {
|
||||||
|
if (this.addresses.includes(vout.scriptpubkey_address)) {
|
||||||
|
this.unfundTxo(vout.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.tx_count--;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fundTxo(value: number): void {
|
||||||
|
this.funded_txo_sum += value;
|
||||||
|
this.funded_txo_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unfundTxo(value: number): void {
|
||||||
|
this.funded_txo_sum -= value;
|
||||||
|
this.funded_txo_count--;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spendTxo(value: number): void {
|
||||||
|
this.spent_txo_sum += value;
|
||||||
|
this.spent_txo_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unspendTxo(value: number): void {
|
||||||
|
this.spent_txo_sum -= value;
|
||||||
|
this.spent_txo_count--;
|
||||||
|
}
|
||||||
|
|
||||||
|
get balance(): number {
|
||||||
|
return this.funded_txo_sum - this.spent_txo_sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalReceived(): number {
|
||||||
|
return this.funded_txo_sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
get utxos(): number {
|
||||||
|
return this.funded_txo_count - this.spent_txo_count;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user