Improve block time handling for chains with elastic scaling (#11876)
* fix: last block timer * refactor: update BlockHeader and BlockHeaders to use AugmentedBlockHeader type and add blockTime calculation * refactor: remove isLast prop from BlockHeader and update BlockHeaders mapping fix: update label in Summary component from 'target' to 'slot' fix: improve block time calculation in BlockAuthors context * refactor: update BlockHeader to accept multiple headers and group by timestamp in BlockHeaders * refactor: update BlockHeader time formatting and adjust display value units * refactor: simplify BlockAuthorsCtxRoot by removing blockTime calculation and parent timestamp retrieval * feat: enhance calcDelay function to handle elastic scaling and distribute delays across blocks
This commit is contained in:
parent
bf15606cf9
commit
9c1c4ee96f
@ -1,40 +1,99 @@
|
||||
// Copyright 2017-2025 @polkadot/app-explorer authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HeaderExtended } from '@polkadot/api-derive/types';
|
||||
import type { AugmentedBlockHeader } from '@polkadot/react-hooks/ctx/types';
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { AddressSmall } from '@polkadot/react-components';
|
||||
import { AddressSmall, styled } from '@polkadot/react-components';
|
||||
import { formatNumber } from '@polkadot/util';
|
||||
|
||||
interface Props {
|
||||
value: HeaderExtended;
|
||||
headers: AugmentedBlockHeader[];
|
||||
}
|
||||
|
||||
function BlockHeader ({ value }: Props): React.ReactElement<Props> | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
function formatValue (value: number, type = 's', withDecimal = false): React.ReactNode {
|
||||
const [pre, post] = value.toFixed().split('.');
|
||||
|
||||
const hashHex = value.hash.toHex();
|
||||
return withDecimal && post?.trim()?.length > 0
|
||||
? <>{pre}.{post} <span className='timeUnit'>{type} ago</span></>
|
||||
: <>{pre} <span className='timeUnit'>{type} ago</span></>;
|
||||
}
|
||||
|
||||
function getDisplayValue (elapsed: number): React.ReactNode {
|
||||
return (elapsed < 60)
|
||||
? formatValue(elapsed, 'secs', elapsed < 15)
|
||||
: (elapsed < 3600)
|
||||
? formatValue(elapsed / 60, 'mins')
|
||||
: formatValue(elapsed / 3600, 'hrs');
|
||||
}
|
||||
|
||||
function BlockHeader ({ headers }: Props): React.ReactElement<Props> | null {
|
||||
return (
|
||||
<tr>
|
||||
<td className='number'>
|
||||
<h4 className='--digits'>
|
||||
<Link to={`/explorer/query/${hashHex}`}>{formatNumber(value.number)}</Link>
|
||||
</h4>
|
||||
</td>
|
||||
<td className='all hash overflow'>{hashHex}</td>
|
||||
<td className='address'>
|
||||
{value.author && (
|
||||
<AddressSmall value={value.author} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<>
|
||||
{headers.map((value, index) => {
|
||||
const hashHex = value.hash.toHex();
|
||||
|
||||
return (
|
||||
<StyledTr
|
||||
className='isExpanded'
|
||||
isFirstItem={index === 0}
|
||||
isLastItem={index === headers.length - 1}
|
||||
key={value.number.toString()}
|
||||
>
|
||||
<td className='number'>
|
||||
<h4 className='--digits'>
|
||||
<Link to={`/explorer/query/${hashHex}`}>{formatNumber(value.number)}</Link>
|
||||
</h4>
|
||||
</td>
|
||||
<td className='all hash overflow'>{hashHex}</td>
|
||||
<td className='address'>
|
||||
{value.author && (
|
||||
<AddressSmall value={value.author} />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className='all --digits blockTime'
|
||||
key={Date.now()}
|
||||
>
|
||||
{getDisplayValue((Date.now() - value.timestamp.toNumber()) / 1000)}
|
||||
</td>
|
||||
</StyledTr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const BASE_BORDER = 0.125;
|
||||
const BORDER_TOP = `${BASE_BORDER * 3}rem solid var(--bg-page)`;
|
||||
const BORDER_RADIUS = `${BASE_BORDER * 4}rem`;
|
||||
|
||||
const StyledTr = styled.tr<{isFirstItem: boolean; isLastItem: boolean}>`
|
||||
.blockTime {
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
td {
|
||||
border-top: ${(props) => props.isFirstItem && BORDER_TOP};
|
||||
border-radius: 0rem !important;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: ${(props) => props.isFirstItem ? BORDER_RADIUS : '0rem'}!important;
|
||||
border-bottom-left-radius: ${(props) => props.isLastItem ? BORDER_RADIUS : '0rem'}!important;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: ${(props) => props.isFirstItem ? BORDER_RADIUS : '0rem'}!important;
|
||||
border-bottom-right-radius: ${(props) => props.isLastItem ? BORDER_RADIUS : '0rem'}!important;
|
||||
}
|
||||
|
||||
.timeUnit {
|
||||
font-size: var(--font-percent-small);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(BlockHeader);
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
// Copyright 2017-2025 @polkadot/app-explorer authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HeaderExtended } from '@polkadot/api-derive/types';
|
||||
import type { AugmentedBlockHeader } from '@polkadot/react-hooks/ctx/types';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import { Table } from '@polkadot/react-components';
|
||||
|
||||
@ -11,29 +11,45 @@ import BlockHeader from './BlockHeader.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
headers: HeaderExtended[];
|
||||
headers: AugmentedBlockHeader[];
|
||||
}
|
||||
|
||||
function BlockHeaders ({ headers }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
|
||||
[t('recent blocks'), 'start', 3]
|
||||
[t('recent blocks'), 'start', 4]
|
||||
]);
|
||||
|
||||
const groupedByTimestamp = useMemo(() => {
|
||||
return headers.reduce((acc, product) => {
|
||||
const timestamp = product.timestamp.toString();
|
||||
|
||||
if (!acc[timestamp]) {
|
||||
acc[timestamp] = [];
|
||||
}
|
||||
|
||||
acc[timestamp].push(product);
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, AugmentedBlockHeader[]>);
|
||||
}, [headers]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
empty={t('No blocks available')}
|
||||
header={headerRef.current}
|
||||
>
|
||||
{headers
|
||||
.filter((header) => !!header)
|
||||
.map((header): React.ReactNode => (
|
||||
<BlockHeader
|
||||
key={header.number.toString()}
|
||||
value={header}
|
||||
/>
|
||||
))}
|
||||
{Object
|
||||
.entries(groupedByTimestamp)
|
||||
.map(([timestamp, headers]): React.ReactNode => {
|
||||
return (
|
||||
<BlockHeader
|
||||
headers={headers.filter((e) => !!e)}
|
||||
key={timestamp}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,6 +30,10 @@ function getSetter ({ extrinsics }: Block): GenericExtrinsic | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the delay for each block, correctly handling elastic scaling
|
||||
* by distributing the time difference across all blocks produced between two differing slots.
|
||||
*/
|
||||
function calcDelay (details: Detail[]): Detail[] {
|
||||
const filtered = details
|
||||
.sort((a, b) => a.block.number - b.block.number)
|
||||
@ -42,8 +46,44 @@ function calcDelay (details: Detail[]): Detail[] {
|
||||
const a = filtered[i];
|
||||
const b = filtered[i + 1];
|
||||
|
||||
if ((b.block.number - a.block.number) === 1 && b.delay === 0) {
|
||||
b.delay = b.now - a.now;
|
||||
// Check if the current block 'a' and the next block 'b' share the same timestamp.
|
||||
// If they do, 'a' cannot be the slot anchor for 'b', so we skip 'a' and let the loop continue
|
||||
// to find the true anchor later when 'b' or a subsequent block is processed.
|
||||
if (a.now === b.now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// At this point, 'a' is the last block of the previous slot
|
||||
// and 'b' is the first block of the new slot.
|
||||
|
||||
const delta = b.now - a.now;
|
||||
|
||||
if (delta < 0) {
|
||||
b.delay = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let k = i + 1;
|
||||
|
||||
// Find the end of the current slot
|
||||
while (k < filtered.length && filtered[k].now === b.now) {
|
||||
k++;
|
||||
}
|
||||
|
||||
const lastBlockInSlot = filtered[k - 1];
|
||||
const blocksInWindow = lastBlockInSlot.block.number - a.block.number;
|
||||
|
||||
if (blocksInWindow <= 0) {
|
||||
b.delay = delta;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate the distributed delay (Delta t / N)
|
||||
const distributedDelay = delta / blocksInWindow;
|
||||
|
||||
// Apply the distributed delay to ALL blocks in the current slot/window
|
||||
for (let m = i + 1; m < k; m++) {
|
||||
filtered[m].delay = distributedDelay;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
// Copyright 2017-2025 @polkadot/app-explorer authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HeaderExtended } from '@polkadot/api-derive/types';
|
||||
import type { KeyedEvent } from '@polkadot/react-hooks/ctx/types';
|
||||
import type { AugmentedBlockHeader, KeyedEvent } from '@polkadot/react-hooks/ctx/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
@ -16,14 +15,17 @@ import Summary from './Summary.js';
|
||||
interface Props {
|
||||
eventCount: number;
|
||||
events: KeyedEvent[];
|
||||
headers: HeaderExtended[];
|
||||
headers: AugmentedBlockHeader[];
|
||||
}
|
||||
|
||||
function Main ({ eventCount, events, headers }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<>
|
||||
<Query />
|
||||
<Summary eventCount={eventCount} />
|
||||
<Summary
|
||||
eventCount={eventCount}
|
||||
headers={headers}
|
||||
/>
|
||||
<Columar>
|
||||
<Columar.Column>
|
||||
<BlockHeaders headers={headers} />
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
// Copyright 2017-2025 @polkadot/app-explorer authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AugmentedBlockHeader } from '@polkadot/react-hooks/ctx/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { CardSummary, SummaryBox } from '@polkadot/react-components';
|
||||
@ -13,9 +15,10 @@ import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
eventCount: number;
|
||||
headers: AugmentedBlockHeader[];
|
||||
}
|
||||
|
||||
function Summary ({ eventCount }: Props): React.ReactElement {
|
||||
function Summary ({ eventCount, headers }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
@ -25,11 +28,12 @@ function Summary ({ eventCount }: Props): React.ReactElement {
|
||||
{api.query.timestamp && (
|
||||
<>
|
||||
<CardSummary label={t('last block')}>
|
||||
<TimeNow />
|
||||
{/* Restart timer on key change */}
|
||||
<TimeNow key={headers.at(0)?.hash.toHex()} />
|
||||
</CardSummary>
|
||||
<CardSummary
|
||||
className='media--800'
|
||||
label={t('target')}
|
||||
label={t('slot')}
|
||||
>
|
||||
<BlockToTime value={BN_ONE} />
|
||||
</CardSummary>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
import type { HeaderExtended } from '@polkadot/api-derive/types';
|
||||
import type { EraRewardPoints } from '@polkadot/types/interfaces';
|
||||
import type { BlockAuthors } from './types.js';
|
||||
import type { AugmentedBlockHeader, BlockAuthors } from './types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@ -31,13 +31,16 @@ export function BlockAuthorsCtxRoot ({ children }: Props): React.ReactElement<Pr
|
||||
// No unsub, global context - destroyed on app close
|
||||
useEffect((): void => {
|
||||
api.isReady.then((): void => {
|
||||
let lastHeaders: HeaderExtended[] = [];
|
||||
let lastHeaders: AugmentedBlockHeader[] = [];
|
||||
let lastBlockAuthors: string[] = [];
|
||||
let lastBlockNumber = '';
|
||||
|
||||
// subscribe to new headers
|
||||
api.derive.chain.subscribeNewHeads((lastHeader: HeaderExtended): void => {
|
||||
if (lastHeader?.number) {
|
||||
api.derive.chain.subscribeNewHeads(async (header: HeaderExtended): Promise<void> => {
|
||||
if (header?.number) {
|
||||
const timestamp = await ((await api.at(header.hash)).query.timestamp.now());
|
||||
|
||||
const lastHeader = Object.assign(header, { timestamp }) as AugmentedBlockHeader;
|
||||
const blockNumber = lastHeader.number.unwrap();
|
||||
let thisBlockAuthor = '';
|
||||
|
||||
@ -60,7 +63,7 @@ export function BlockAuthorsCtxRoot ({ children }: Props): React.ReactElement<Pr
|
||||
|
||||
lastHeaders = lastHeaders
|
||||
.filter((old, index) => index < MAX_HEADERS && old.number.unwrap().lt(blockNumber))
|
||||
.reduce((next, header): HeaderExtended[] => {
|
||||
.reduce((next, header): AugmentedBlockHeader[] => {
|
||||
next.push(header);
|
||||
|
||||
return next;
|
||||
|
||||
@ -8,7 +8,7 @@ import type { HeaderExtended } from '@polkadot/api-derive/types';
|
||||
import type { LinkOption } from '@polkadot/apps-config/endpoints/types';
|
||||
import type { InjectedExtension } from '@polkadot/extension-inject/types';
|
||||
import type { ProviderStats } from '@polkadot/rpc-provider/types';
|
||||
import type { BlockNumber, EventRecord } from '@polkadot/types/interfaces';
|
||||
import type { BlockNumber, EventRecord, Moment } from '@polkadot/types/interfaces';
|
||||
import type { BN } from '@polkadot/util';
|
||||
import type { AssetInfoComplete } from '../types.js';
|
||||
|
||||
@ -75,13 +75,19 @@ export interface PayWithAsset {
|
||||
selectedFeeAsset: AssetInfoComplete | null;
|
||||
}
|
||||
|
||||
interface BlockTime {
|
||||
readonly timestamp: Moment;
|
||||
}
|
||||
|
||||
export type AugmentedBlockHeader = HeaderExtended & BlockTime;
|
||||
|
||||
export interface BlockAuthors {
|
||||
byAuthor: Record<string, string>;
|
||||
eraPoints: Record<string, string>;
|
||||
lastBlockAuthors: string[];
|
||||
lastBlockNumber?: string;
|
||||
lastHeader?: HeaderExtended;
|
||||
lastHeaders: HeaderExtended[];
|
||||
lastHeader?: AugmentedBlockHeader;
|
||||
lastHeaders: AugmentedBlockHeader[];
|
||||
}
|
||||
|
||||
export interface BlockEvents {
|
||||
|
||||
@ -5,8 +5,6 @@ import type { Moment } from '@polkadot/types/interfaces';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useApi, useCall } from '@polkadot/react-hooks';
|
||||
|
||||
import Elapsed from './Elapsed.js';
|
||||
|
||||
interface Props {
|
||||
@ -17,8 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
function TimeNow ({ children, className = '', label, value }: Props): React.ReactElement<Props> {
|
||||
const { api } = useApi();
|
||||
const timestamp = useCall<Moment>(!value && api.query.timestamp?.now);
|
||||
const timestamp = Date.now();
|
||||
|
||||
const [now, hasValue] = useMemo(
|
||||
() => [value || timestamp, !!(value || timestamp)],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user