mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Merge pull request #6066 from mempool/mononaut/stale-block-page
stale block list page
This commit is contained in:
commit
d5329e1d64
10 changed files with 529 additions and 8 deletions
|
|
@ -57,6 +57,7 @@ class BitcoinRoutes {
|
|||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'chain-tips', this.getChainTips.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'stale-tips', this.getStaleTips.bind(this))
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts)
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTxs)
|
||||
// Temporarily add txs/package endpoint for all backends until esplora supports it
|
||||
|
|
@ -548,6 +549,26 @@ class BitcoinRoutes {
|
|||
}
|
||||
}
|
||||
|
||||
private async getStaleTips(req: Request, res: Response) {
|
||||
try {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
const tips = await chainTips.getStaleTips();
|
||||
if (tips.length > 0) {
|
||||
res.json(tips);
|
||||
} else {
|
||||
handleError(req, res, 503, `Temporarily unavailable`);
|
||||
return;
|
||||
}
|
||||
} else { // Liquid
|
||||
handleError(req, res, 404, `This API is only available for Bitcoin networks`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(req, res, 500, 'Failed to get stale tips');
|
||||
}
|
||||
}
|
||||
|
||||
private async getLegacyBlocks(req: Request, res: Response) {
|
||||
try {
|
||||
const returnBlocks: IEsploraApi.Block[] = [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import logger from '../logger';
|
||||
import { BlockExtended } from '../mempool.interfaces';
|
||||
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||
import { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
|
|
@ -13,6 +14,11 @@ export interface ChainTip {
|
|||
status: 'invalid' | 'active' | 'valid-fork' | 'valid-headers' | 'headers-only';
|
||||
};
|
||||
|
||||
export interface StaleTip extends ChainTip {
|
||||
stale: BlockExtended;
|
||||
canonical: BlockExtended;
|
||||
}
|
||||
|
||||
export interface OrphanedBlock {
|
||||
height: number;
|
||||
hash: string;
|
||||
|
|
@ -22,11 +28,14 @@ export interface OrphanedBlock {
|
|||
|
||||
class ChainTips {
|
||||
private chainTips: ChainTip[] = [];
|
||||
private staleTips: Record<number, StaleTip> = {};
|
||||
private orphanedBlocks: { [hash: string]: OrphanedBlock } = {};
|
||||
private blockCache: { [hash: string]: OrphanedBlock } = {};
|
||||
private orphansByHeight: { [height: number]: OrphanedBlock[] } = {};
|
||||
private indexingOrphanedBlocks = false;
|
||||
private indexingQueue: IEsploraApi.Block[] = [];
|
||||
private indexingQueue: { block: IEsploraApi.Block, tip: OrphanedBlock }[] = [];
|
||||
|
||||
private staleTipsCacheSize = 50;
|
||||
|
||||
public async updateOrphanedBlocks(): Promise<void> {
|
||||
try {
|
||||
|
|
@ -54,7 +63,7 @@ class ChainTips {
|
|||
prevhash: block.previousblockhash,
|
||||
};
|
||||
this.blockCache[hash] = orphan;
|
||||
this.indexingQueue.push(block);
|
||||
this.indexingQueue.push({ block, tip: orphan });
|
||||
}
|
||||
}
|
||||
if (orphan) {
|
||||
|
|
@ -82,6 +91,14 @@ class ChainTips {
|
|||
this.orphansByHeight[orphan.height].push(orphan);
|
||||
}
|
||||
|
||||
const heightsToKeep = new Set(this.chainTips.filter(tip => tip.status !== 'active').map(tip => tip.height));
|
||||
const heightsToRemove: number[] = Object.keys(this.staleTips).map(Number).filter(height => !heightsToKeep.has(height));
|
||||
for (const height of heightsToRemove) {
|
||||
delete this.staleTips[height];
|
||||
}
|
||||
|
||||
this.trimStaleTipsCache();
|
||||
|
||||
// index new orphaned blocks in the background
|
||||
void this.$indexOrphanedBlocks();
|
||||
|
||||
|
|
@ -97,25 +114,53 @@ class ChainTips {
|
|||
}
|
||||
this.indexingOrphanedBlocks = true;
|
||||
while (this.indexingQueue.length > 0) {
|
||||
const block = this.indexingQueue.shift();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const { block, tip } = this.indexingQueue.shift()!;
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
let staleBlock: BlockExtended | undefined;
|
||||
const alreadyIndexed = await BlocksSummariesRepository.$isSummaryIndexed(block.id);
|
||||
const needToCache = Object.keys(this.staleTips).length < this.staleTipsCacheSize || block.height > Object.keys(this.staleTips).map(Number).sort((a, b) => b - a)[this.staleTipsCacheSize - 1];
|
||||
if (!alreadyIndexed) {
|
||||
await blocks.$indexBlock(block.id, block, true);
|
||||
staleBlock = await blocks.$indexBlock(block.id, block, true);
|
||||
await blocks.$indexBlockSummary(block.id, block.height, true);
|
||||
// don't DDOS core by indexing too fast
|
||||
await Common.sleep$(5000);
|
||||
} else if (needToCache) {
|
||||
staleBlock = await blocks.$getBlock(block.id) as BlockExtended;
|
||||
}
|
||||
|
||||
if (staleBlock && needToCache) {
|
||||
const canonicalBlock = await blocks.$indexBlockByHeight(staleBlock.height);
|
||||
this.staleTips[staleBlock.height] = {
|
||||
height: staleBlock.height,
|
||||
hash: staleBlock.id,
|
||||
branchlen: tip.height - staleBlock.height,
|
||||
status: tip.status,
|
||||
stale: staleBlock,
|
||||
canonical: canonicalBlock,
|
||||
};
|
||||
this.trimStaleTipsCache();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Failed to index orphaned block ${block.id} at height ${block.height}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
// don't DDOS core by indexing too fast
|
||||
await Common.sleep$(5000);
|
||||
}
|
||||
this.indexingOrphanedBlocks = false;
|
||||
}
|
||||
|
||||
private trimStaleTipsCache(): void {
|
||||
const staleTipHeights = Object.keys(this.staleTips).map(Number).sort((a, b) => b - a);
|
||||
if (staleTipHeights.length > this.staleTipsCacheSize) {
|
||||
const heightsToDiscard = staleTipHeights.slice(this.staleTipsCacheSize);
|
||||
for (const height of heightsToDiscard) {
|
||||
delete this.staleTips[height];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getOrphanedBlocksAtHeight(height: number | undefined): OrphanedBlock[] {
|
||||
if (height === undefined) {
|
||||
return [];
|
||||
|
|
@ -128,6 +173,10 @@ class ChainTips {
|
|||
return this.chainTips;
|
||||
}
|
||||
|
||||
public getStaleTips(): StaleTip[] {
|
||||
return Object.values(this.staleTips).sort((a, b) => b.height - a.height);
|
||||
}
|
||||
|
||||
clearOrphanCacheAboveHeight(height: number): void {
|
||||
for (const h in this.orphansByHeight) {
|
||||
if (Number(h) > height) {
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@
|
|||
width: var(--block-size);
|
||||
height: var(--block-size);
|
||||
z-index: calc(-1 * (var(--stale-index) + 1));
|
||||
opacity: 0.5;
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
|
|||
125
frontend/src/app/components/stale-list/stale-list.component.html
Normal file
125
frontend/src/app/components/stale-list/stale-list.component.html
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<div class="container-xl" style="min-height: 335px">
|
||||
<h1 class="float-left" i18n="page.recent-stale-chain-tips">Recent Stale Chain Tips</h1>
|
||||
<div *ngIf="isLoading" class="spinner-border ml-3" role="status"></div>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<div class="chain-tips" [ngStyle]="{ 'min-height': '295px', 'opacity': isLoading ? '0.75' : '1' }">
|
||||
<ng-container *ngIf="chainTips$ | async as chainTips">
|
||||
<div *ngFor="let chainTip of chainTips" class="chain-tip">
|
||||
<p class="info">
|
||||
<span class="height">{{ chainTip.height }}</span>
|
||||
<span class="badges">
|
||||
<span class="type" [ngSwitch]="chainTip.status">
|
||||
<span *ngSwitchCase="'headers-only'" class="badge badge-info" i18n="chain-tips.headers-only">Headers Only</span>
|
||||
<span *ngSwitchCase="'valid-headers'" class="badge badge-info" i18n="chain-tips.valid-headers">Valid Headers</span>
|
||||
<span *ngSwitchCase="'valid-fork'" class="badge badge-info" i18n="chain-tips.valid-fork">Valid Fork</span>
|
||||
<span *ngSwitchCase="'invalid'" class="badge badge-info" i18n="chain-tips.invalid">Invalid</span>
|
||||
<span *ngSwitchDefault>{{ chainTip.status }}</span>
|
||||
</span>
|
||||
<span class="badge badge-secondary depth-badge" i18n="chain-tips.depth">Depth {{ chainTip.branchlen + 1 }}</span>
|
||||
</span>
|
||||
</p>
|
||||
<div class="stale-tip-wrapper">
|
||||
<div class="block-comparison">
|
||||
<div class="block-column stale">
|
||||
<div class="block-label" i18n="chain-tips.stale-block">Stale Block</div>
|
||||
<div class="block-wrapper">
|
||||
<div class="bitcoin-block mined-block stale-block" [style.background]="getBlockGradient(chainTip.stale)" [routerLink]="['/block/' + chainTip.stale.id]"></div>
|
||||
<div class="block-body">
|
||||
<div *ngIf="chainTip.stale?.extras; else emptyStaleBlockFees" class="fees">
|
||||
~<app-fee-rate [fee]="chainTip.stale.extras.medianFee" unitClass="" rounding="1.0-0"></app-fee-rate>
|
||||
</div>
|
||||
<ng-template #emptyStaleBlockFees>
|
||||
<div class="fees">
|
||||
<app-fee-rate unitClass=""></app-fee-rate>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div *ngIf="chainTip.stale?.extras?.minFee != null && chainTip.stale?.extras?.maxFee != null; else emptyStaleBlockFeeSpan" class="fee-span">
|
||||
<app-fee-rate [fee]="chainTip.stale.extras.minFee" [showUnit]="false" unitClass=""></app-fee-rate>
|
||||
-
|
||||
<app-fee-rate [fee]="chainTip.stale.extras.maxFee" unitClass=""></app-fee-rate>
|
||||
</div>
|
||||
<ng-template #emptyStaleBlockFeeSpan>
|
||||
<div class="fee-span">
|
||||
<app-fee-rate unitClass=""></app-fee-rate>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="block-size" *ngIf="chainTip.stale?.size" [innerHTML]="'‎' + (chainTip.stale.size | bytes: 2)"></div>
|
||||
<div class="transaction-count" *ngIf="chainTip.stale?.tx_count">
|
||||
{{ chainTip.stale.tx_count | number }} transaction<ng-container *ngIf="chainTip.stale.tx_count !== 1">s</ng-container>
|
||||
</div>
|
||||
<div class="time-difference" *ngIf="chainTip.stale?.timestamp">
|
||||
<app-time kind="since" [time]="chainTip.stale.timestamp" [fastRender]="true" [precision]="1" minUnit="minute"></app-time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="animated" *ngIf="chainTip.stale?.extras?.pool != undefined">
|
||||
<a class="badge" [class.miner-name]="chainTip.stale.extras.pool.minerNames?.length > 1 && chainTip.stale.extras.pool.minerNames[1] != ''" [routerLink]="[('/mining/pool/' + chainTip.stale.extras.pool.slug) | relativeUrl]">
|
||||
<ng-container *ngIf="chainTip.stale.extras.pool.minerNames?.length > 1 && chainTip.stale.extras.pool.minerNames[1] != ''; else staleBlockCentralisedPool">
|
||||
<img [ngbTooltip]="chainTip.stale.extras.pool.name" class="pool-logo faded" [src]="'/resources/mining-pools/' + chainTip.stale.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + chainTip.stale.extras.pool.name + ' mining pool'">
|
||||
{{ chainTip.stale.extras.pool.minerNames[1] }}
|
||||
</ng-container>
|
||||
<ng-template #staleBlockCentralisedPool>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + chainTip.stale.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + chainTip.stale.extras.pool.name + ' mining pool'"> {{ chainTip.stale.extras.pool.name }}
|
||||
</ng-template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vs-label">vs</div>
|
||||
|
||||
<div class="block-column canonical">
|
||||
<div class="block-label" i18n="chain-tips.winning-block">Winning Block</div>
|
||||
<div class="bitcoin-block mined-block canonical-block" [style.background]="getBlockGradient(chainTip.canonical)" [routerLink]="['/block/' + chainTip.canonical.id]">
|
||||
<div class="block-body">
|
||||
<div *ngIf="chainTip.canonical?.extras; else emptyCanonicalBlockFees" class="fees">
|
||||
~<app-fee-rate [fee]="chainTip.canonical.extras.medianFee" unitClass="" rounding="1.0-0"></app-fee-rate>
|
||||
</div>
|
||||
<ng-template #emptyCanonicalBlockFees>
|
||||
<div class="fees">
|
||||
<app-fee-rate unitClass=""></app-fee-rate>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div *ngIf="chainTip.canonical?.extras?.minFee != null && chainTip.canonical?.extras?.maxFee != null; else emptyCanonicalBlockFeeSpan" class="fee-span">
|
||||
<app-fee-rate [fee]="chainTip.canonical.extras.minFee" [showUnit]="false" unitClass=""></app-fee-rate>
|
||||
-
|
||||
<app-fee-rate [fee]="chainTip.canonical.extras.maxFee" unitClass=""></app-fee-rate>
|
||||
</div>
|
||||
<ng-template #emptyCanonicalBlockFeeSpan>
|
||||
<div class="fee-span">
|
||||
<app-fee-rate unitClass=""></app-fee-rate>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="block-size" *ngIf="chainTip.canonical?.size" [innerHTML]="'‎' + (chainTip.canonical.size | bytes: 2)"></div>
|
||||
<div class="transaction-count" *ngIf="chainTip.canonical?.tx_count">
|
||||
{{ chainTip.canonical.tx_count | number }} transaction<ng-container *ngIf="chainTip.canonical.tx_count !== 1">s</ng-container>
|
||||
</div>
|
||||
<div class="time-difference" *ngIf="chainTip.canonical?.timestamp">
|
||||
<app-time kind="since" [time]="chainTip.canonical.timestamp" [fastRender]="true" [precision]="1" minUnit="minute"></app-time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="animated" *ngIf="chainTip.canonical?.extras?.pool != undefined">
|
||||
<a class="badge" [class.miner-name]="chainTip.canonical.extras.pool.minerNames?.length > 1 && chainTip.canonical.extras.pool.minerNames[1] != ''" [routerLink]="[('/mining/pool/' + chainTip.canonical.extras.pool.slug) | relativeUrl]">
|
||||
<ng-container *ngIf="chainTip.canonical.extras.pool.minerNames?.length > 1 && chainTip.canonical.extras.pool.minerNames[1] != ''; else canonicalBlockCentralisedPool">
|
||||
<img [ngbTooltip]="chainTip.canonical.extras.pool.name" class="pool-logo faded" [src]="'/resources/mining-pools/' + chainTip.canonical.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + chainTip.canonical.extras.pool.name + ' mining pool'">
|
||||
{{ chainTip.canonical.extras.pool.minerNames[1] }}
|
||||
</ng-container>
|
||||
<ng-template #canonicalBlockCentralisedPool>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + chainTip.canonical.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + chainTip.canonical.extras.pool.name + ' mining pool'"> {{ chainTip.canonical.extras.pool.name }}
|
||||
</ng-template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-chain-tips" *ngIf="!chainTips?.length">
|
||||
<p i18n="chain-tips.no-stale-blocks-yet">This node hasn't seen any stale blocks yet!</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
201
frontend/src/app/components/stale-list/stale-list.component.scss
Normal file
201
frontend/src/app/components/stale-list/stale-list.component.scss
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
.spinner-border {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.chain-tips {
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
.type {
|
||||
.badge {
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.depth-badge {
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.chain-tip {
|
||||
margin-bottom: 2em;
|
||||
background: var(--box-bg);
|
||||
border-radius: 5px;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.no-chain-tips {
|
||||
margin: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stale-tip-wrapper {
|
||||
background: var(--stat-box-bg);
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.block-comparison {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 4em;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (max-width: 540px) {
|
||||
gap: 1.8em;
|
||||
}
|
||||
@media screen and (max-width: 420px) {
|
||||
.vs-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.block-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin-bottom: 1.5em;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
|
||||
.bitcoin-block {
|
||||
--block-size: 125px;
|
||||
width: var(--block-size);
|
||||
height: var(--block-size);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.block-wrapper {
|
||||
position: relative;
|
||||
width: var(--block-size);
|
||||
height: var(--block-size);
|
||||
}
|
||||
|
||||
.block-column.stale {
|
||||
.bitcoin-block {
|
||||
opacity: 0.6;
|
||||
box-shadow: 0 0 10px rgba(255, 0, 0, 0.25), 0 0 10px rgba(255, 0, 0, 0.25);
|
||||
filter: drop-shadow(0 0 10px rgba(255, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.block-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.block-column.canonical {
|
||||
.bitcoin-block {
|
||||
box-shadow: 0 0 10px rgba(0, 255, 0, 0.25), 0 0 10px rgba(0, 255, 0, 0.25);
|
||||
filter: drop-shadow(0 0 10px rgba(0, 255, 0, 0.25));
|
||||
}
|
||||
}
|
||||
|
||||
.bitcoin-block::after {
|
||||
content: '';
|
||||
width: var(--block-size);
|
||||
height: calc(0.192 * var(--block-size));
|
||||
position: absolute;
|
||||
top: calc(-0.192 * var(--block-size));
|
||||
left: calc(-0.16 * var(--block-size));
|
||||
background-color: #232838;
|
||||
transform: skew(40deg);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.bitcoin-block::before {
|
||||
content: '';
|
||||
width: calc(0.16 * var(--block-size));
|
||||
height: var(--block-size);
|
||||
position: absolute;
|
||||
top: calc(-0.096 * var(--block-size));
|
||||
left: calc(-0.16 * var(--block-size));
|
||||
background-color: #191c27;
|
||||
transform: skewY(50deg);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.block-body {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fees {
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.fee-span {
|
||||
font-size: 11px;
|
||||
margin-bottom: 5px;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.block-size {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.transaction-count {
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.time-difference {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.animated {
|
||||
transition: all 0.15s ease-in-out;
|
||||
white-space: nowrap;
|
||||
|
||||
.badge {
|
||||
position: relative;
|
||||
color: #FFF;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 145px;
|
||||
|
||||
&.miner-name {
|
||||
max-width: 125px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-right: 2px;
|
||||
|
||||
&.faded {
|
||||
filter: grayscale(100%) brightness(1.5);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { map, tap } from 'rxjs/operators';
|
||||
import { StaleTip, BlockExtended } from '@interfaces/node-api.interface';
|
||||
import { ApiService } from '@app/services/api.service';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
import { SeoService } from '@app/services/seo.service';
|
||||
import { seoDescriptionNetwork } from '@app/shared/common.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stale-list',
|
||||
templateUrl: './stale-list.component.html',
|
||||
styleUrls: ['./stale-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StaleList implements OnInit {
|
||||
chainTips$: Observable<StaleTip[]>;
|
||||
nextChainTipSubject = new BehaviorSubject(null);
|
||||
urlFragmentSubscription: Subscription;
|
||||
isLoading = true;
|
||||
|
||||
gradientColors = {
|
||||
'': ['var(--mainnet-alt)', 'var(--primary)'],
|
||||
liquid: ['var(--liquid)', 'var(--testnet-alt)'],
|
||||
'liquidtestnet': ['var(--liquidtestnet)', 'var(--liquidtestnet-alt)'],
|
||||
testnet: ['var(--testnet)', 'var(--testnet-alt)'],
|
||||
testnet4: ['var(--testnet)', 'var(--testnet-alt)'],
|
||||
signet: ['var(--signet)', 'var(--signet-alt)'],
|
||||
};
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
public stateService: StateService,
|
||||
private seoService: SeoService,
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.chainTips$ = this.apiService.getStaleTips$().pipe(
|
||||
map((chainTips) => {
|
||||
const filtered = chainTips.filter((chainTip) => chainTip.status !== 'active') as StaleTip[];
|
||||
|
||||
filtered.forEach((chainTip) => {
|
||||
if (chainTip.stale?.extras) {
|
||||
chainTip.stale.extras.minFee = this.getMinBlockFee(chainTip.stale);
|
||||
chainTip.stale.extras.maxFee = this.getMaxBlockFee(chainTip.stale);
|
||||
}
|
||||
if (chainTip.canonical?.extras) {
|
||||
chainTip.canonical.extras.minFee = this.getMinBlockFee(chainTip.canonical);
|
||||
chainTip.canonical.extras.maxFee = this.getMaxBlockFee(chainTip.canonical);
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}),
|
||||
tap(() => {
|
||||
this.isLoading = false;
|
||||
})
|
||||
);
|
||||
|
||||
this.seoService.setTitle($localize`:@@page.stale-chain-tips:Stale Chain Tips`);
|
||||
this.seoService.setDescription($localize`:@@meta.description.stale-chain-tips:See the most recent stale chain tips on the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network.`);
|
||||
}
|
||||
|
||||
getBlockGradient(block: BlockExtended): string {
|
||||
if (!block || !block.weight) {
|
||||
return 'var(--secondary)';
|
||||
}
|
||||
|
||||
const backgroundHeight = 100 - (block.weight / this.stateService.env.BLOCK_WEIGHT_UNITS) * 100;
|
||||
const network = this.stateService.network || '';
|
||||
|
||||
return `repeating-linear-gradient(
|
||||
var(--secondary),
|
||||
var(--secondary) ${backgroundHeight}%,
|
||||
${this.gradientColors[network][0]} ${Math.max(backgroundHeight, 0)}%,
|
||||
${this.gradientColors[network][1]} 100%
|
||||
)`;
|
||||
}
|
||||
|
||||
getMinBlockFee(block: BlockExtended): number {
|
||||
if (block?.extras?.feeRange) {
|
||||
if (block.extras.medianFee === block.extras.feeRange[3]) {
|
||||
return block.extras.feeRange[1];
|
||||
} else {
|
||||
return block.extras.feeRange[0];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
getMaxBlockFee(block: BlockExtended): number {
|
||||
if (block?.extras?.feeRange) {
|
||||
return block.extras.feeRange[block.extras.feeRange.length - 1];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -496,3 +496,15 @@ export interface Treasury {
|
|||
verifiedAddresses: string[];
|
||||
balances?: { balance: number, time: number }[];
|
||||
}
|
||||
|
||||
export interface ChainTip {
|
||||
height: number;
|
||||
hash: string;
|
||||
branchlen: number;
|
||||
status: 'invalid' | 'active' | 'valid-fork' | 'valid-headers' | 'headers-only';
|
||||
}
|
||||
|
||||
export interface StaleTip extends ChainTip {
|
||||
stale: BlockExtended;
|
||||
canonical: BlockExtended;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { TestTransactionsComponent } from '@components/test-transactions/test-tr
|
|||
import { CalculatorComponent } from '@components/calculator/calculator.component';
|
||||
import { BlocksList } from '@components/blocks-list/blocks-list.component';
|
||||
import { RbfList } from '@components/rbf-list/rbf-list.component';
|
||||
import { StaleList } from '@components/stale-list/stale-list.component';
|
||||
import { StratumList } from '@components/stratum/stratum-list/stratum-list.component';
|
||||
import { ServerHealthComponent } from '@components/server-health/server-health.component';
|
||||
import { ServerStatusComponent } from '@components/server-health/server-status.component';
|
||||
|
|
@ -59,6 +60,10 @@ const routes: Routes = [
|
|||
path: 'rbf',
|
||||
component: RbfList,
|
||||
},
|
||||
{
|
||||
path: 'stale',
|
||||
component: StaleList,
|
||||
},
|
||||
...(browserWindowEnv.STRATUM_ENABLED ? [{
|
||||
path: 'stratum',
|
||||
component: StartComponent,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
|
||||
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, Treasury, SubmitPackageResult } from '@interfaces/node-api.interface';
|
||||
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult, WalletAddress, Treasury, SubmitPackageResult, ChainTip, StaleTip } from '@interfaces/node-api.interface';
|
||||
import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
import { Transaction } from '@interfaces/electrs.interface';
|
||||
|
|
@ -168,6 +168,14 @@ export class ApiService {
|
|||
return this.httpClient.get<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
|
||||
}
|
||||
|
||||
getChainTips$(): Observable<ChainTip[]> {
|
||||
return this.httpClient.get<ChainTip[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/chain-tips');
|
||||
}
|
||||
|
||||
getStaleTips$(): Observable<StaleTip[]> {
|
||||
return this.httpClient.get<StaleTip[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/stale-tips');
|
||||
}
|
||||
|
||||
liquidPegs$(): Observable<CurrentPegs> {
|
||||
return this.httpClient.get<CurrentPegs>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe';
|
|||
import { DifficultyAdjustmentsTable } from '@components/difficulty-adjustments-table/difficulty-adjustments-table.components';
|
||||
import { BlocksList } from '@components/blocks-list/blocks-list.component';
|
||||
import { RbfList } from '@components/rbf-list/rbf-list.component';
|
||||
import { StaleList } from '@components/stale-list/stale-list.component';
|
||||
import { StratumList } from '@components/stratum/stratum-list/stratum-list.component';
|
||||
import { RewardStatsComponent } from '@components/reward-stats/reward-stats.component';
|
||||
import { DataCyDirective } from '@app/data-cy.directive';
|
||||
|
|
@ -209,6 +210,7 @@ import { GithubLogin } from '@components/github-login.component/github-login.com
|
|||
DifficultyAdjustmentsTable,
|
||||
BlocksList,
|
||||
RbfList,
|
||||
StaleList,
|
||||
StratumList,
|
||||
DataCyDirective,
|
||||
RewardStatsComponent,
|
||||
|
|
@ -358,6 +360,7 @@ import { GithubLogin } from '@components/github-login.component/github-login.com
|
|||
AmountShortenerPipe,
|
||||
DifficultyAdjustmentsTable,
|
||||
BlocksList,
|
||||
StaleList,
|
||||
StratumList,
|
||||
DataCyDirective,
|
||||
RewardStatsComponent,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue