mirror of
https://github.com/mempool/mempool.git
synced 2025-01-18 21:32:55 +01:00
Merge pull request #5103 from mempool/mononaut/multi-pool-acc
inline acceleration hashrate pie chart
This commit is contained in:
commit
33c9f4a8dc
@ -160,7 +160,8 @@ class BitcoinRoutes {
|
||||
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
|
||||
sigops: tx.sigops,
|
||||
adjustedVsize: tx.adjustedVsize,
|
||||
acceleration: tx.acceleration
|
||||
acceleration: tx.acceleration,
|
||||
acceleratedBy: tx.acceleratedBy || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import config from '../config';
|
||||
import { Worker } from 'worker_threads';
|
||||
import path from 'path';
|
||||
import mempool from './mempool';
|
||||
import { Acceleration } from './services/acceleration';
|
||||
|
||||
const MAX_UINT32 = Math.pow(2, 32) - 1;
|
||||
|
||||
@ -333,7 +334,7 @@ class MempoolBlocks {
|
||||
}
|
||||
}
|
||||
|
||||
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], candidates: GbtCandidates | undefined, accelerations, accelerationPool, saveResults): MempoolBlockWithTransactions[] {
|
||||
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], candidates: GbtCandidates | undefined, accelerations: { [txid: string]: Acceleration }, accelerationPool, saveResults): MempoolBlockWithTransactions[] {
|
||||
for (const txid of Object.keys(candidates?.txs ?? mempool)) {
|
||||
if (txid in mempool) {
|
||||
mempool[txid].cpfpDirty = false;
|
||||
@ -396,7 +397,7 @@ class MempoolBlocks {
|
||||
}
|
||||
}
|
||||
|
||||
const isAccelerated : { [txid: string]: boolean } = {};
|
||||
const isAcceleratedBy : { [txid: string]: number[] | false } = {};
|
||||
|
||||
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
|
||||
// update this thread's mempool with the results
|
||||
@ -427,17 +428,19 @@ class MempoolBlocks {
|
||||
};
|
||||
|
||||
const acceleration = accelerations[txid];
|
||||
if (isAccelerated[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
|
||||
if (isAcceleratedBy[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
|
||||
if (!mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
}
|
||||
mempoolTx.acceleration = true;
|
||||
mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools;
|
||||
for (const ancestor of mempoolTx.ancestors || []) {
|
||||
if (!mempool[ancestor.txid].acceleration) {
|
||||
mempool[ancestor.txid].cpfpDirty = true;
|
||||
}
|
||||
mempool[ancestor.txid].acceleration = true;
|
||||
isAccelerated[ancestor.txid] = true;
|
||||
mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy;
|
||||
isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy;
|
||||
}
|
||||
} else {
|
||||
if (mempoolTx.acceleration) {
|
||||
|
@ -820,6 +820,7 @@ class WebsocketHandler {
|
||||
position: {
|
||||
...mempoolTx.position,
|
||||
accelerated: mempoolTx.acceleration || undefined,
|
||||
acceleratedBy: mempoolTx.acceleratedBy || undefined,
|
||||
}
|
||||
};
|
||||
if (!mempoolTx.cpfpChecked && !mempoolTx.acceleration) {
|
||||
@ -858,6 +859,7 @@ class WebsocketHandler {
|
||||
txInfo.position = {
|
||||
...mempoolTx.position,
|
||||
accelerated: mempoolTx.acceleration || undefined,
|
||||
acceleratedBy: mempoolTx.acceleratedBy || undefined,
|
||||
};
|
||||
if (!mempoolTx.cpfpChecked) {
|
||||
calculateCpfp(mempoolTx, newMempool);
|
||||
@ -1134,6 +1136,7 @@ class WebsocketHandler {
|
||||
position: {
|
||||
...mempoolTx.position,
|
||||
accelerated: mempoolTx.acceleration || undefined,
|
||||
acceleratedBy: mempoolTx.acceleratedBy || undefined,
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1153,6 +1156,7 @@ class WebsocketHandler {
|
||||
...mempoolTx.position,
|
||||
},
|
||||
accelerated: mempoolTx.acceleration || undefined,
|
||||
acceleratedBy: mempoolTx.acceleratedBy || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -111,6 +111,7 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
|
||||
vsize: number,
|
||||
};
|
||||
acceleration?: boolean;
|
||||
acceleratedBy?: number[];
|
||||
replacement?: boolean;
|
||||
uid?: number;
|
||||
flags?: number;
|
||||
@ -432,7 +433,7 @@ export interface OptimizedStatistic {
|
||||
|
||||
export interface TxTrackingInfo {
|
||||
replacedBy?: string,
|
||||
position?: { block: number, vsize: number, accelerated?: boolean },
|
||||
position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[] },
|
||||
cpfp?: {
|
||||
ancestors?: Ancestor[],
|
||||
bestDescendant?: Ancestor | null,
|
||||
@ -443,6 +444,7 @@ export interface TxTrackingInfo {
|
||||
},
|
||||
utxoSpent?: { [vout: number]: { vin: number, txid: string } },
|
||||
accelerated?: boolean,
|
||||
acceleratedBy?: number[],
|
||||
confirmed?: boolean
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,35 @@
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="td-width" i18n="transaction.accelerated-to-feerate|Accelerated to feerate">Accelerated to</td>
|
||||
<td>
|
||||
<div class="effective-fee-container">
|
||||
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize)) {
|
||||
<app-fee-rate [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
|
||||
} @else {
|
||||
<app-fee-rate [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td rowspan="2" *ngIf="tx && (tx.acceleratedBy || accelerationInfo) && miningStats" class="text-right" style="width: 100%;">
|
||||
<div class="chart-container" style="width: 100px; margin-left:auto;">
|
||||
<div
|
||||
echarts
|
||||
*browserOnly
|
||||
class="chart"
|
||||
[initOpts]="chartInitOptions"
|
||||
[options]="chartOptions"
|
||||
style="height: 72px"
|
||||
(chartInit)="onChartInit($event)"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td-width" i18n="transaction.accelerated-by-hashrate|Accelerated to hashrate">Accelerated by</td>
|
||||
<td *ngIf="acceleratedByPercentage">
|
||||
{{ acceleratedByPercentage }} <span class="symbol">of hashrate</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
@ -0,0 +1,9 @@
|
||||
.td-width {
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 175px;
|
||||
min-width: 175px;
|
||||
}
|
||||
}
|
@ -0,0 +1,128 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Transaction } from '../../../interfaces/electrs.interface';
|
||||
import { Acceleration, SinglePoolStats } from '../../../interfaces/node-api.interface';
|
||||
import { EChartsOption, PieSeriesOption } from '../../../graphs/echarts';
|
||||
import { MiningStats } from '../../../services/mining.service';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-active-acceleration-box',
|
||||
templateUrl: './active-acceleration-box.component.html',
|
||||
styleUrls: ['./active-acceleration-box.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ActiveAccelerationBox implements OnChanges {
|
||||
@Input() tx: Transaction;
|
||||
@Input() accelerationInfo: Acceleration;
|
||||
@Input() miningStats: MiningStats;
|
||||
|
||||
acceleratedByPercentage: string = '';
|
||||
|
||||
chartOptions: EChartsOption = {};
|
||||
chartInitOptions = {
|
||||
renderer: 'svg',
|
||||
};
|
||||
timespan = '';
|
||||
chartInstance: any = undefined;
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (this.tx && (this.tx.acceleratedBy || this.accelerationInfo) && this.miningStats) {
|
||||
this.prepareChartOptions();
|
||||
}
|
||||
}
|
||||
|
||||
getChartData() {
|
||||
const data: object[] = [];
|
||||
const pools: { [id: number]: SinglePoolStats } = {};
|
||||
for (const pool of this.miningStats.pools) {
|
||||
pools[pool.poolUniqueId] = pool;
|
||||
}
|
||||
|
||||
const getDataItem = (value, color, tooltip) => ({
|
||||
value,
|
||||
itemStyle: {
|
||||
color,
|
||||
borderColor: 'rgba(0,0,0,0)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
emphasis: {
|
||||
disabled: true,
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||
borderRadius: 4,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textStyle: {
|
||||
color: 'var(--tooltip-grey)',
|
||||
},
|
||||
borderColor: '#000',
|
||||
formatter: () => {
|
||||
return tooltip;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let totalAcceleratedHashrate = 0;
|
||||
for (const poolId of (this.accelerationInfo?.pools || this.tx.acceleratedBy || [])) {
|
||||
const pool = pools[poolId];
|
||||
if (!pool) {
|
||||
continue;
|
||||
}
|
||||
totalAcceleratedHashrate += parseFloat(pool.lastEstimatedHashrate);
|
||||
}
|
||||
this.acceleratedByPercentage = ((totalAcceleratedHashrate / parseFloat(this.miningStats.lastEstimatedHashrate)) * 100).toFixed(1) + '%';
|
||||
data.push(getDataItem(
|
||||
totalAcceleratedHashrate,
|
||||
'var(--tertiary)',
|
||||
`${this.acceleratedByPercentage} accelerating`,
|
||||
) as PieSeriesOption);
|
||||
const notAcceleratedByPercentage = ((1 - (totalAcceleratedHashrate / parseFloat(this.miningStats.lastEstimatedHashrate))) * 100).toFixed(1) + '%';
|
||||
data.push(getDataItem(
|
||||
(parseFloat(this.miningStats.lastEstimatedHashrate) - totalAcceleratedHashrate),
|
||||
'rgba(127, 127, 127, 0.3)',
|
||||
`${notAcceleratedByPercentage} not accelerating`,
|
||||
) as PieSeriesOption);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
prepareChartOptions() {
|
||||
this.chartOptions = {
|
||||
animation: false,
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
trigger: 'item',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: '100%',
|
||||
data: this.getChartData(),
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
onChartInit(ec) {
|
||||
if (this.chartInstance !== undefined) {
|
||||
return;
|
||||
}
|
||||
this.chartInstance = ec;
|
||||
}
|
||||
}
|
@ -347,6 +347,7 @@ export class TrackerComponent implements OnInit, OnDestroy {
|
||||
|
||||
if (txPosition.position?.accelerated) {
|
||||
this.tx.acceleration = true;
|
||||
this.tx.acceleratedBy = txPosition.position?.acceleratedBy;
|
||||
}
|
||||
|
||||
if (txPosition.position?.block === 0) {
|
||||
@ -602,6 +603,7 @@ export class TrackerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
if (cpfpInfo.acceleration) {
|
||||
this.tx.acceleration = cpfpInfo.acceleration;
|
||||
this.tx.acceleratedBy = cpfpInfo.acceleratedBy;
|
||||
}
|
||||
|
||||
this.cpfpInfo = cpfpInfo;
|
||||
|
@ -419,7 +419,11 @@
|
||||
<ng-template #detailsRight>
|
||||
<ng-container *ngTemplateOutlet="feeRow"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="feeRateRow"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="effectiveRateRow"></ng-container>
|
||||
@if (!isLoadingTx && !tx?.status?.confirmed && ((cpfpInfo && hasEffectiveFeeRate) || accelerationInfo)) {
|
||||
<ng-container *ngTemplateOutlet="acceleratingRow"></ng-container>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="effectiveRateRow"></ng-container>
|
||||
}
|
||||
@if (tx?.status?.confirmed) {
|
||||
<ng-container *ngTemplateOutlet="minerRow"></ng-container>
|
||||
}
|
||||
@ -638,6 +642,15 @@
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #acceleratingRow>
|
||||
<tr>
|
||||
<td rowspan="2" colspan="2" style="padding: 0;">
|
||||
<app-active-acceleration-box [tx]="tx" [accelerationInfo]="accelerationInfo" [miningStats]="miningStats"></app-active-acceleration-box>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #minerRow>
|
||||
@if (network === '') {
|
||||
@if (!isLoadingTx) {
|
||||
|
@ -32,6 +32,7 @@ import { isFeatureActive } from '../../bitcoin.utils';
|
||||
import { ServicesApiServices } from '../../services/services-api.service';
|
||||
import { EnterpriseService } from '../../services/enterprise.service';
|
||||
import { ZONE_SERVICE } from '../../injection-tokens';
|
||||
import { MiningService, MiningStats } from '../../services/mining.service';
|
||||
|
||||
interface Pool {
|
||||
id: number;
|
||||
@ -98,6 +99,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
isAcceleration: boolean = false;
|
||||
filters: Filter[] = [];
|
||||
showCpfpDetails = false;
|
||||
miningStats: MiningStats;
|
||||
fetchCpfp$ = new Subject<string>();
|
||||
fetchRbfHistory$ = new Subject<string>();
|
||||
fetchCachedTx$ = new Subject<string>();
|
||||
@ -151,6 +153,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private priceService: PriceService,
|
||||
private storageService: StorageService,
|
||||
private enterpriseService: EnterpriseService,
|
||||
private miningService: MiningService,
|
||||
private cd: ChangeDetectorRef,
|
||||
@Inject(ZONE_SERVICE) private zoneService: any,
|
||||
) {}
|
||||
@ -696,6 +699,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
if (cpfpInfo.acceleration) {
|
||||
this.tx.acceleration = cpfpInfo.acceleration;
|
||||
this.tx.acceleratedBy = cpfpInfo.acceleratedBy;
|
||||
this.setIsAccelerated(firstCpfp);
|
||||
}
|
||||
|
||||
@ -713,6 +717,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.isAcceleration && initialState) {
|
||||
this.showAccelerationSummary = false;
|
||||
}
|
||||
if (this.isAcceleration) {
|
||||
// this immediately returns cached stats if we fetched them recently
|
||||
this.miningService.getMiningStats('1w').subscribe(stats => {
|
||||
this.miningStats = stats;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setFeatures(): void {
|
||||
@ -790,6 +800,20 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1);
|
||||
}
|
||||
|
||||
getUnacceleratedFeeRate(tx: Transaction, accelerated: boolean): number {
|
||||
if (accelerated) {
|
||||
let ancestorVsize = tx.weight / 4;
|
||||
let ancestorFee = tx.fee;
|
||||
for (const ancestor of tx.ancestors || []) {
|
||||
ancestorVsize += (ancestor.weight / 4);
|
||||
ancestorFee += ancestor.fee;
|
||||
}
|
||||
return Math.min(tx.fee / (tx.weight / 4), (ancestorFee / ancestorVsize));
|
||||
} else {
|
||||
return tx.effectiveFeePerVsize;
|
||||
}
|
||||
}
|
||||
|
||||
setupGraph() {
|
||||
this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1));
|
||||
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);
|
||||
|
@ -4,6 +4,7 @@ import { Routes, RouterModule } from '@angular/router';
|
||||
import { TransactionComponent } from './transaction.component';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { TxBowtieModule } from '../tx-bowtie-graph/tx-bowtie.module';
|
||||
import { GraphsModule } from '../../graphs/graphs.module';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@ -30,6 +31,7 @@ export class TransactionRoutingModule { }
|
||||
CommonModule,
|
||||
TransactionRoutingModule,
|
||||
SharedModule,
|
||||
GraphsModule,
|
||||
TxBowtieModule,
|
||||
],
|
||||
declarations: [
|
||||
|
@ -36,6 +36,7 @@ import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools
|
||||
import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component';
|
||||
import { AddressComponent } from '../components/address/address.component';
|
||||
import { AddressGraphComponent } from '../components/address-graph/address-graph.component';
|
||||
import { ActiveAccelerationBox } from '../components/acceleration/active-acceleration-box/active-acceleration-box.component';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@NgModule({
|
||||
@ -75,6 +76,7 @@ import { CommonModule } from '@angular/common';
|
||||
HashrateChartPoolsComponent,
|
||||
BlockHealthGraphComponent,
|
||||
AddressGraphComponent,
|
||||
ActiveAccelerationBox,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -86,6 +88,7 @@ import { CommonModule } from '@angular/common';
|
||||
],
|
||||
exports: [
|
||||
NgxEchartsModule,
|
||||
ActiveAccelerationBox,
|
||||
]
|
||||
})
|
||||
export class GraphsModule { }
|
||||
|
@ -20,6 +20,7 @@ export interface Transaction {
|
||||
bestDescendant?: BestDescendant | null;
|
||||
cpfpChecked?: boolean;
|
||||
acceleration?: boolean;
|
||||
acceleratedBy?: number[];
|
||||
deleteAfter?: number;
|
||||
_unblinded?: any;
|
||||
_deduced?: boolean;
|
||||
|
@ -29,6 +29,7 @@ export interface CpfpInfo {
|
||||
sigops?: number;
|
||||
adjustedVsize?: number;
|
||||
acceleration?: boolean;
|
||||
acceleratedBy?: number[];
|
||||
}
|
||||
|
||||
export interface RbfInfo {
|
||||
@ -132,6 +133,7 @@ export interface ITranslators { [language: string]: string; }
|
||||
*/
|
||||
export interface SinglePoolStats {
|
||||
poolId: number;
|
||||
poolUniqueId: number; // unique global pool id
|
||||
name: string;
|
||||
link: string;
|
||||
blockCount: number;
|
||||
@ -245,7 +247,8 @@ export interface RbfTransaction extends TransactionStripped {
|
||||
export interface MempoolPosition {
|
||||
block: number,
|
||||
vsize: number,
|
||||
accelerated?: boolean
|
||||
accelerated?: boolean,
|
||||
acceleratedBy?: number[],
|
||||
}
|
||||
|
||||
export interface RewardStats {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, tap } from 'rxjs/operators';
|
||||
import { PoolsStats, SinglePoolStats } from '../interfaces/node-api.interface';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { StateService } from './state.service';
|
||||
@ -25,6 +25,12 @@ export interface MiningStats {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MiningService {
|
||||
cache: {
|
||||
[interval: string]: {
|
||||
lastUpdated: number;
|
||||
data: MiningStats;
|
||||
}
|
||||
} = {};
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
@ -36,9 +42,20 @@ export class MiningService {
|
||||
* Generate pool ranking stats
|
||||
*/
|
||||
public getMiningStats(interval: string): Observable<MiningStats> {
|
||||
return this.apiService.listPools$(interval).pipe(
|
||||
map(response => this.generateMiningStats(response))
|
||||
);
|
||||
// returned cached data fetched within the last 5 minutes
|
||||
if (this.cache[interval] && this.cache[interval].lastUpdated > (Date.now() - (5 * 60000))) {
|
||||
return of(this.cache[interval].data);
|
||||
} else {
|
||||
return this.apiService.listPools$(interval).pipe(
|
||||
map(response => this.generateMiningStats(response)),
|
||||
tap(stats => {
|
||||
this.cache[interval] = {
|
||||
lastUpdated: Date.now(),
|
||||
data: stats,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user