feat: initial Pezkuwi Apps rebrand from polkadot-apps

Rebranded terminology:
- Polkadot → Pezkuwi
- Kusama → Dicle
- Westend → Zagros
- Rococo → PezkuwiChain
- Substrate → Bizinikiwi
- parachain → teyrchain

Custom logos with Kurdistan brand colors (#e6007a → #86e62a):
- bizinikiwi-hexagon.svg
- sora-bizinikiwi.svg
- hezscanner.svg
- heztreasury.svg
- pezkuwiscan.svg
- pezkuwistats.svg
- pezkuwiassembly.svg
- pezkuwiholic.svg
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
View File
View File
+1
View File
@@ -0,0 +1 @@
# @pezkuwi/app-explorer
+26
View File
@@ -0,0 +1,26 @@
{
"bugs": "https://github.com/pezkuwichain/pezkuwi-apps/issues",
"engines": {
"node": ">=18"
},
"homepage": "https://github.com/pezkuwichain/pezkuwi-apps/tree/master/packages/page-explorer#readme",
"license": "Apache-2.0",
"name": "@pezkuwi/app-explorer",
"private": true,
"repository": {
"directory": "packages/page-explorer",
"type": "git",
"url": "https://github.com/pezkuwichain/pezkuwi-apps.git"
},
"sideEffects": false,
"type": "module",
"version": "0.168.2-4-x",
"dependencies": {
"@pezkuwi/react-components": "^0.168.2-4-x"
},
"peerDependencies": {
"react": "*",
"react-dom": "*",
"react-is": "*"
}
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChartOptions } from 'chart.js';
import type { ApiStats } from '@pezkuwi/react-hooks/ctx/types';
import React, { useMemo } from 'react';
import { CardSummary, NextTick, styled, SummaryBox } from '@pezkuwi/react-components';
import { useApiStats } from '@pezkuwi/react-hooks';
import { formatNumber } from '@pezkuwi/util';
import Chart from '../Latency/Chart.js';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
}
interface ChartContents {
labels: string[];
values: number[][];
}
interface ChartInfo {
bytesChart: ChartContents;
errorsChart: ChartContents;
requestsChart: ChartContents;
}
const OPTIONS: ChartOptions = {
aspectRatio: 6,
maintainAspectRatio: true,
scales: {
y: {
beginAtZero: true
}
}
};
// const COLORS_ERRORS = ['#8c0044', '#acacac'];
const COLORS_BYTES = ['#00448c', '#008c44', '#acacac'];
const COLORS_REQUESTS = ['#008c8c', '#00448c', '#8c4400', '#acacac'];
function getPoints (all: ApiStats[]): ChartInfo {
const bytesChart: ChartContents = {
labels: [],
values: [[], [], []]
};
const errorsChart: ChartContents = {
labels: [],
values: [[]]
};
const requestsChart: ChartContents = {
labels: [],
values: [[], [], [], []]
};
const reqBase = all.reduce((a, { stats: { active: { requests, subscriptions } } }) => a + requests + subscriptions, 0);
let { bytesRecv: prevRecv, bytesSent: prevSent, errors: prevErrors } = all[0].stats.total;
let recvTotal = 0;
for (let i = 1; i < all.length; i++) {
const { stats: { active: { requests: aReq, subscriptions: aSub }, total: { bytesRecv, bytesSent, errors } }, when } = all[i];
const time = new Date(when).toLocaleTimeString();
bytesChart.labels.push(time);
bytesChart.values[0].push(bytesSent - prevSent);
bytesChart.values[1].push(bytesRecv - prevRecv);
errorsChart.labels.push(time);
errorsChart.values[0].push(errors - prevErrors);
requestsChart.labels.push(time);
requestsChart.values[0].push(aReq + aSub);
requestsChart.values[1].push(aReq);
requestsChart.values[2].push(aSub);
requestsChart.values[3].push(reqBase / all.length);
recvTotal += bytesRecv - prevRecv;
prevErrors = errors;
prevRecv = bytesRecv;
prevSent = bytesSent;
}
const recvAvg = recvTotal / (all.length - 1);
for (let i = 1; i < all.length; i++) {
bytesChart.values[2].push(recvAvg);
}
return { bytesChart, errorsChart, requestsChart };
}
function Api ({ className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const stats = useApiStats();
const { bytesLegend, requestsLegend } = useMemo(
() => ({
bytesLegend: [t('sent'), t('recv'), t('average')],
errorsLegend: [t('errors')],
requestsLegend: [t('total'), t('requests'), t('subscriptions'), t('average')]
}), [t]
);
const { bytesChart, requestsChart } = useMemo(
() => getPoints(stats),
[stats]
);
const last = stats[stats.length - 1];
const isLoaded = last && (stats.length > 3);
const EMPTY_NUMBER = <span className='--tmp'>99</span>;
const EMPTY_BYTES = <span className='--tmp'>1,000kB</span>;
return (
<StyledDiv className={className}>
<SummaryBox>
<section>
<CardSummary label={t('sent')}>
{isLoaded
? <>{formatNumber(last.stats.total.bytesSent / 1024)}kB</>
: EMPTY_BYTES}
</CardSummary>
<CardSummary label={t('recv')}>
{isLoaded
? <>{formatNumber(last.stats.total.bytesRecv / 1024)}kB</>
: EMPTY_BYTES}
</CardSummary>
</section>
<section>
<CardSummary label={t('total req')}>
{isLoaded
? <>{formatNumber(last.stats.total.requests)}</>
: EMPTY_NUMBER}
</CardSummary>
<CardSummary label={t('total sub')}>
{isLoaded
? <>{formatNumber(last.stats.total.subscriptions)}</>
: EMPTY_NUMBER}
</CardSummary>
</section>
</SummaryBox>
<NextTick isActive={isLoaded}>
<Chart
colors={COLORS_REQUESTS}
legends={requestsLegend}
options={OPTIONS}
title={t('requests made')}
value={requestsChart}
/>
<Chart
colors={COLORS_BYTES}
legends={bytesLegend}
options={OPTIONS}
title={t('bytes transferred')}
value={bytesChart}
/>
</NextTick>
</StyledDiv>
);
}
const StyledDiv = styled.div`
.container {
background: var(--bg-table);
border: 1px solid var(--border-table);
border-radius: 0.25rem;
padding: 1rem 1.5rem;
}
.container+.container {
margin-top: 1rem;
}
`;
export default React.memo(Api);
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2017-2025 @pezkuwi/react-query authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Header } from '@pezkuwi/types/interfaces';
import React from 'react';
import { useApi, useCall } from '@pezkuwi/react-hooks';
interface Props {
className?: string;
label?: React.ReactNode;
}
function BestHash ({ className = '', label }: Props): React.ReactElement<Props> {
const { api } = useApi();
const newHead = useCall<Header>(api.rpc.chain.subscribeNewHeads);
return (
<div className={className}>
{label || ''}{newHead?.hash.toHex()}
</div>
);
}
export default React.memo(BestHash);
@@ -0,0 +1,99 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AugmentedBlockHeader } from '@pezkuwi/react-hooks/ctx/types';
import React from 'react';
import { Link } from 'react-router-dom';
import { AddressSmall, styled } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
interface Props {
headers: AugmentedBlockHeader[];
}
function formatValue (value: number, type = 's', withDecimal = false): React.ReactNode {
const [pre, post] = value.toFixed().split('.');
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 (
<>
{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);
@@ -0,0 +1,57 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AugmentedBlockHeader } from '@pezkuwi/react-hooks/ctx/types';
import React, { useMemo, useRef } from 'react';
import { Table } from '@pezkuwi/react-components';
import BlockHeader from './BlockHeader.js';
import { useTranslation } from './translate.js';
interface Props {
headers: AugmentedBlockHeader[];
}
function BlockHeaders ({ headers }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[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}
>
{Object
.entries(groupedByTimestamp)
.map(([timestamp, headers]): React.ReactNode => {
return (
<BlockHeader
headers={headers.filter((e) => !!e)}
key={timestamp}
/>
);
})}
</Table>
);
}
export default React.memo(BlockHeaders);
@@ -0,0 +1,280 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { HeaderExtended } from '@pezkuwi/api-derive/types';
import type { KeyedEvent } from '@pezkuwi/react-hooks/ctx/types';
import type { V2Weight } from '@pezkuwi/react-hooks/useWeight';
import type { EventRecord, Hash, RuntimeVersionPartial, SignedBlock } from '@pezkuwi/types/interfaces';
import type { FrameSupportDispatchPerDispatchClassWeight } from '@pezkuwi/types/lookup';
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { AddressSmall, Columar, CopyButton, LinkExternal, MarkError, styled, Table } from '@pezkuwi/react-components';
import { useApi, useIsMountedRef } from '@pezkuwi/react-hooks';
import { convertWeight } from '@pezkuwi/react-hooks/useWeight';
import { formatNumber, isBn } from '@pezkuwi/util';
import Events from '../Events.js';
import { useTranslation } from '../translate.js';
import Extrinsics from './Extrinsics.js';
import Justifications from './Justifications.js';
import Logs from './Logs.js';
import Summary from './Summary.js';
interface Props {
className?: string;
error?: Error | null;
value?: string | null;
}
interface State {
events?: KeyedEvent[] | null;
blockWeight?: FrameSupportDispatchPerDispatchClassWeight | null;
getBlock?: SignedBlock;
getHeader?: HeaderExtended;
nextBlockHash?: Hash | null;
runtimeVersion?: RuntimeVersionPartial;
}
const EMPTY_HEADER: [React.ReactNode?, string?, number?][] = [['...', 'start', 6]];
function transformResult ([[runtimeVersion, events, blockWeight], getBlock, getHeader]: [[RuntimeVersionPartial, EventRecord[] | null, FrameSupportDispatchPerDispatchClassWeight|null], SignedBlock, HeaderExtended?]): State {
return {
blockWeight,
events: events?.map((record, index) => ({
indexes: [index],
key: `${Date.now()}-${index}-${record.hash.toHex()}`,
record
})),
getBlock,
getHeader,
runtimeVersion
};
}
function BlockByHash ({ className = '', error, value }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const mountedRef = useIsMountedRef();
const [{ blockWeight, events, getBlock, getHeader, nextBlockHash, runtimeVersion }, setState] = useState<State>({});
const [blkError, setBlkError] = useState<Error | null | undefined>(error);
const [evtError, setEvtError] = useState<Error | null | undefined>();
const [isVersionCurrent, maxBlockWeight] = useMemo(
() => [
!!runtimeVersion && api.runtimeVersion.specName.eq(runtimeVersion.specName) && api.runtimeVersion.specVersion.eq(runtimeVersion.specVersion),
api.consts.system.blockWeights && api.consts.system.blockWeights.maxBlock && convertWeight(api.consts.system.blockWeights.maxBlock).v2Weight
],
[api, runtimeVersion]
);
useEffect((): void => {
error && setBlkError(error);
}, [error]);
const systemEvents = useMemo(
() => events?.filter(({ record: { phase } }) => !phase.isApplyExtrinsic),
[events]
);
useEffect((): void => {
value && Promise
.all([
api
.at(value)
.then((apiAt) =>
Promise.all([
Promise.resolve(apiAt.runtimeVersion),
apiAt.query.system
.events()
.catch((error: Error) => {
mountedRef.current && setEvtError(error);
return null;
}),
apiAt.query.system
.blockWeight()
.catch(() => {
return null;
})
])
),
api.rpc.chain.getBlock(value),
api.derive.chain.getHeader(value)
])
.then((result): void => {
mountedRef.current && setState(transformResult(result));
})
.catch((error: Error): void => {
mountedRef.current && setBlkError(error);
});
}, [api, mountedRef, value]);
useEffect((): (() => void) | undefined => {
if (!mountedRef.current || !getHeader?.number) {
return;
}
const nextBlockNumber = getHeader.number.unwrap().addn(1);
let unsub: (() => void) | undefined;
api.rpc.chain.getBlockHash(nextBlockNumber)
.then((hash) => {
if (!hash.isEmpty) {
setState((prev) => ({
...prev,
nextBlockHash: hash
}));
} else {
// Subscribe to new block headers until the next block is found, then unsubscribes.
api.derive.chain.subscribeNewHeads((header: HeaderExtended): void => {
if (mountedRef.current && header.number.unwrap().eq(nextBlockNumber)) {
setState((prev) => ({
...prev,
nextBlockHash: header.hash
}));
unsub && unsub();
}
}).then((_unsub) => {
unsub = _unsub;
}).catch((error: Error) => {
mountedRef.current && setBlkError(error);
});
}
})
.catch((error: Error) => {
mountedRef.current && setBlkError(error);
});
return (): void => {
unsub && unsub();
};
}, [api, getHeader?.number, mountedRef]);
const header = useMemo<[React.ReactNode?, string?, number?][]>(
() => getHeader
? [
[formatNumber(getHeader.number.unwrap()), 'start --digits', 1],
[t('hash'), 'start'],
[t('parent'), 'start'],
[t('next'), 'start'],
[t('extrinsics'), 'start media--1300'],
[t('state'), 'start media--1200'],
[runtimeVersion ? `${runtimeVersion.specName.toString()}/${runtimeVersion.specVersion.toString()}` : undefined, 'media--1000']
]
: EMPTY_HEADER,
[getHeader, runtimeVersion, t]
);
const blockNumber = getHeader?.number.unwrap();
const parentHash = getHeader?.parentHash.toHex();
const hasParent = !getHeader?.parentHash.isEmpty;
return (
<div className={className}>
<Summary
blockWeight={blockWeight}
events={events}
maxBlockWeight={(maxBlockWeight as V2Weight).refTime.toBn()}
maxProofSize={isBn(maxBlockWeight.proofSize) ? maxBlockWeight.proofSize : (maxBlockWeight as V2Weight).proofSize.toBn()}
signedBlock={getBlock}
/>
<StyledTable header={header}>
{blkError
? (
<tr>
<td colSpan={6}>
<MarkError content={t('Unable to retrieve the specified block details. {{error}}', { replace: { error: blkError.message } }) } />
</td>
</tr>
)
: getBlock && getHeader && !getBlock.isEmpty && !getHeader.isEmpty && (
<tr>
<td className='address'>
{getHeader.author && (
<AddressSmall value={getHeader.author} />
)}
</td>
<td className='hash overflow'>{getHeader.hash.toHex()}</td>
<td className='hash overflow'>{
hasParent
? (
<span className='inline-hash-copy'>
<Link to={`/explorer/query/${parentHash || ''}`}>{parentHash}</Link>
<CopyButton value={parentHash} />
</span>
)
: parentHash
}</td>
<td className='hash overflow'>{
nextBlockHash
? (
<span className='inline-hash-copy'>
<Link to={`/explorer/query/${nextBlockHash.toHex()}`}>{nextBlockHash.toHex()}</Link>
<CopyButton value={nextBlockHash.toHex()} />
</span>
)
: t('Waiting for next block...')
}</td>
<td className='hash overflow media--1300'>{getHeader.extrinsicsRoot.toHex()}</td>
<td className='hash overflow media--1200'>{getHeader.stateRoot.toHex()}</td>
<td className='media--1000'>
{value && (
<LinkExternal
data={value}
type='block'
/>
)}
</td>
</tr>
)
}
</StyledTable>
{getBlock && getHeader && (
<>
<Extrinsics
blockNumber={blockNumber}
events={events}
maxBlockWeight={(maxBlockWeight as V2Weight).refTime.toBn()}
value={getBlock.block.extrinsics}
withLink={isVersionCurrent}
/>
<Columar>
<Columar.Column>
<Events
error={evtError}
eventClassName='explorer--BlockByHash-block'
events={systemEvents}
label={t('system events')}
/>
</Columar.Column>
<Columar.Column>
<Logs value={getHeader.digest.logs} />
<Justifications value={getBlock.justifications} />
</Columar.Column>
</Columar>
</>
)}
</div>
);
}
const StyledTable = styled(Table)`
.inline-hash-copy {
align-items: center;
display: inline-flex;
gap: 0.25em;
width: 100%;
a {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
`;
export default React.memo(BlockByHash);
@@ -0,0 +1,47 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Hash } from '@pezkuwi/types/interfaces';
import React, { useEffect, useState } from 'react';
import { useApi, useIsMountedRef } from '@pezkuwi/react-hooks';
import BlockByHash from './ByHash.js';
interface Props {
value: string;
}
function BlockByNumber ({ value }: Props): React.ReactElement<Props> | null {
const { api } = useApi();
const [getBlockHash, setState] = useState<Hash | null>(null);
const mountedRef = useIsMountedRef();
const [error, setError] = useState<Error | null>(null);
useEffect((): void => {
api.rpc.chain
.getBlockHash(value)
.then((result): void => {
mountedRef.current && setState(result);
})
.catch((error: Error): void => {
console.error(1);
mountedRef.current && setError(error);
});
}, [api, mountedRef, value]);
return (
<BlockByHash
error={error}
value={
getBlockHash
? getBlockHash.toHex()
: null
}
/>
);
}
export default React.memo(BlockByNumber);
@@ -0,0 +1,210 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyedEvent } from '@pezkuwi/react-hooks/ctx/types';
import type { BlockNumber, DispatchInfo, Extrinsic } from '@pezkuwi/types/interfaces';
import type { ICompact, INumber } from '@pezkuwi/types/types';
import React, { useMemo } from 'react';
import { AddressMini, LinkExternal, styled } from '@pezkuwi/react-components';
import { convertWeight } from '@pezkuwi/react-hooks/useWeight';
import { CallExpander } from '@pezkuwi/react-params';
import { BN, formatNumber } from '@pezkuwi/util';
import Event from '../Event.js';
import { useTranslation } from '../translate.js';
interface Props {
blockNumber?: BlockNumber;
className?: string;
events?: KeyedEvent[] | null;
index: number;
maxBlockWeight?: BN;
value: Extrinsic;
withLink: boolean;
}
const BN_TEN_THOUSAND = new BN(10_000);
function getEra ({ era }: Extrinsic, blockNumber?: BlockNumber): [number, number] | null {
if (blockNumber && era.isMortalEra) {
const mortalEra = era.asMortalEra;
return [mortalEra.birth(blockNumber.toNumber()), mortalEra.death(blockNumber.toNumber())];
}
return null;
}
function filterEvents (index: number, events?: KeyedEvent[] | null, maxBlockWeight?: BN): [DispatchInfo | undefined, BN | undefined, number, KeyedEvent[]] {
const filtered = events
? events.filter(({ record: { phase } }) =>
phase.isApplyExtrinsic &&
phase.asApplyExtrinsic.eq(index)
)
: [];
const infoRecord = filtered.find(({ record: { event: { method, section } } }) =>
section === 'system' &&
['ExtrinsicFailed', 'ExtrinsicSuccess'].includes(method)
);
const dispatchInfo = infoRecord
? infoRecord.record.event.method === 'ExtrinsicSuccess'
? infoRecord.record.event.data[0] as DispatchInfo
: infoRecord.record.event.data[1] as DispatchInfo
: undefined;
const weight = dispatchInfo && convertWeight(dispatchInfo.weight);
return [
dispatchInfo,
weight?.v1Weight,
weight && maxBlockWeight
? weight.v1Weight.mul(BN_TEN_THOUSAND).div(maxBlockWeight).toNumber() / 100
: 0,
filtered
];
}
function ExtrinsicDisplay ({ blockNumber, className = '', events, index, maxBlockWeight, value, withLink }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const link = useMemo(
() => withLink
? `#/extrinsics/decode/${value.toHex()}`
: null,
[value, withLink]
);
const { method, section } = useMemo(
() => value.registry.findMetaCall(value.callIndex),
[value]
);
const timestamp = useMemo(
() => section === 'timestamp' && method === 'set'
? new Date((value.args[0] as ICompact<INumber>).unwrap().toNumber())
: undefined,
[method, section, value]
);
const mortality = useMemo(
(): string | undefined => {
if (value.isSigned) {
const era = getEra(value, blockNumber);
return era
? t('mortal, valid from #{{startAt}} to #{{endsAt}}', {
replace: {
endsAt: formatNumber(era[1]),
startAt: formatNumber(era[0])
}
})
: t('immortal');
}
return undefined;
},
[blockNumber, t, value]
);
const [, weight, weightPercentage, thisEvents] = useMemo(
() => filterEvents(index, events, maxBlockWeight),
[index, events, maxBlockWeight]
);
return (
<StyledTr
className={className}
key={`extrinsic:${index}`}
>
<td
className='top'
colSpan={2}
>
<CallExpander
className='details'
mortality={mortality}
tip={value.tip?.toBn()}
value={value}
withHash
withSignature
/>
{link && (
<a
className='isDecoded'
href={link}
rel='noreferrer'
>{link}</a>
)}
</td>
<td
className='top media--1000'
colSpan={2}
>
{thisEvents.map(({ key, record }) =>
<Event
className='explorer--BlockByHash-event'
key={key}
value={record}
/>
)}
</td>
<td className='top number media--1400'>
{weight && (
<>
<>{formatNumber(weight)}</>
<div>{weightPercentage.toFixed(2)}%</div>
</>
)}
</td>
<td className='top media--1200'>
{value.isSigned
? (
<>
<AddressMini value={value.signer} />
<div className='explorer--BlockByHash-nonce'>
{t('index')} {formatNumber(value.nonce)}
</div>
<LinkExternal
data={value.hash.toHex()}
type='extrinsic'
/>
</>
)
: timestamp
? timestamp.toLocaleString()
: null
}
</td>
</StyledTr>
);
}
const StyledTr = styled.tr`
.explorer--BlockByHash-event+.explorer--BlockByHash-event {
margin-top: 0.75rem;
}
.explorer--BlockByHash-nonce {
font-size: var(--font-size-small);
margin-left: 2.25rem;
margin-top: -0.5rem;
opacity: var(--opacity-light);
text-align: left;
}
.explorer--BlockByHash-unsigned {
opacity: var(--opacity-light);
font-weight: var(--font-weight-normal);
}
a.isDecoded {
display: block;
margin-top: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
export default React.memo(ExtrinsicDisplay);
@@ -0,0 +1,109 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyedEvent } from '@pezkuwi/react-hooks/ctx/types';
import type { GenericExtrinsic } from '@pezkuwi/types';
import type { BlockNumber, Extrinsic } from '@pezkuwi/types/interfaces';
import type { Vec } from '@pezkuwi/types-codec';
import type { AnyTuple } from '@pezkuwi/types-codec/types';
import type { BN } from '@pezkuwi/util';
import React, { useMemo } from 'react';
import { styled, Table, Toggle } from '@pezkuwi/react-components';
import { useAccounts, useToggle } from '@pezkuwi/react-hooks';
import { isEventFromMyAccounts } from '@pezkuwi/react-hooks/utils/isEventFromMyAccounts';
import { useTranslation } from '../translate.js';
import ExtrinsicDisplay from './Extrinsic.js';
interface Props {
blockNumber?: BlockNumber;
className?: string;
events?: KeyedEvent[] | null;
label?: React.ReactNode;
maxBlockWeight?: BN;
value?: Extrinsic[] | null;
withLink: boolean;
}
function Extrinsics ({ blockNumber, className = '', events, label, maxBlockWeight, value, withLink }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { allAccounts } = useAccounts();
const [showOnlyUserEvents, onToggleUserEvents] = useToggle();
const header = useMemo<[React.ReactNode?, string?, number?][]>(
() => [
[label || t('extrinsics'), 'start', 2],
[t('events'), 'start media--1000', 2],
[t('weight'), 'media--1400'],
[
<EventsToggle
key='eventsToggle'
onToggleUserEvents={onToggleUserEvents}
showOnlyUserEvents={showOnlyUserEvents}
/>,
'end media--1000'
]
],
[label, onToggleUserEvents, showOnlyUserEvents, t]
);
const filteredEvents = useMemo(() => {
if (!showOnlyUserEvents) {
return events;
}
return events?.filter((event) => isEventFromMyAccounts(event.record, value as Vec<GenericExtrinsic<AnyTuple>>, undefined, allAccounts));
}, [allAccounts, events, showOnlyUserEvents, value]);
return (
<Table
className={className}
empty={t('No extrinsics available')}
header={header}
isFixed
>
{value?.map((extrinsic, index): React.ReactNode =>
<ExtrinsicDisplay
blockNumber={blockNumber}
events={filteredEvents}
index={index}
key={`extrinsic:${index}`}
maxBlockWeight={maxBlockWeight}
value={extrinsic}
withLink={withLink}
/>
)}
</Table>
);
}
interface EventsToggleProps {
showOnlyUserEvents: boolean;
onToggleUserEvents: () => void;
}
const EventsToggle = ({ onToggleUserEvents, showOnlyUserEvents }: EventsToggleProps) => {
const { t } = useTranslation();
return (
<StyledDiv>
<Toggle
label={t('Show my events')}
onChange={onToggleUserEvents}
value={showOnlyUserEvents}
/>
</StyledDiv>
);
};
const StyledDiv = styled.div`
.ui--Toggle {
label{
font-size: var(--font-size-base);
}
}
`;
export default React.memo(Extrinsics);
@@ -0,0 +1,70 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option, Tuple } from '@pezkuwi/types';
import type { Justifications } from '@pezkuwi/types/interfaces';
import type { Codec, TypeDef } from '@pezkuwi/types/types';
import React, { useRef } from 'react';
import { Expander, Table } from '@pezkuwi/react-components';
import Params from '@pezkuwi/react-params';
import { getTypeDef } from '@pezkuwi/types/create';
import { useTranslation } from '../translate.js';
interface Props {
value: Option<Justifications>;
}
function formatTuple (tuple: Tuple): React.ReactNode {
const params = tuple.Types.map((type): { type: TypeDef } => ({
type: getTypeDef(type)
}));
const values = tuple.toArray().map((value): { isValid: boolean; value: Codec } => ({
isValid: true,
value
}));
return (
<Params
isDisabled
params={params}
values={values}
withExpander
/>
);
}
function JustificationList ({ value }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('justifications'), 'start']
]);
const justifications = value.unwrapOr(null);
if (!justifications) {
return null;
}
return (
<Table
empty={t('No justifications available')}
header={headerRef.current}
>
{justifications?.map((justification, index) => (
<tr key={`justification:${index}`}>
<td className='overflow'>
<Expander summary={justification[0].toString()}>
{formatTuple(justification as unknown as Tuple)}
</Expander>
</td>
</tr>
))}
</Table>
);
}
export default React.memo(JustificationList);
@@ -0,0 +1,133 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DigestItem } from '@pezkuwi/types/interfaces';
import type { Codec, TypeDef } from '@pezkuwi/types/types';
import React, { useRef } from 'react';
import { Expander, Table } from '@pezkuwi/react-components';
import Params from '@pezkuwi/react-params';
import { Raw, Struct, Tuple, Vec } from '@pezkuwi/types';
import { getTypeDef } from '@pezkuwi/types/create';
import { useTranslation } from '../translate.js';
interface Props {
value?: DigestItem[];
}
function formatU8a (value: Raw): React.ReactNode {
return (
<Params
isDisabled
params={[{ type: getTypeDef('Bytes') }]}
values={[{ isValid: true, value }]}
withExpander
/>
);
}
function formatStruct (struct: Struct): React.ReactNode {
const params = Object.entries(struct.Type).map(([name, value]): { name: string; type: TypeDef } => ({
name,
type: getTypeDef(value)
}));
const values = struct.toArray().map((value): { isValid: boolean; value: Codec } => ({
isValid: true,
value
}));
return (
<Params
isDisabled
params={params}
values={values}
withExpander
/>
);
}
function formatTuple (tuple: Tuple): React.ReactNode {
const params = tuple.Types.map((type): { type: TypeDef } => ({
type: getTypeDef(type)
}));
const values = tuple.toArray().map((value): { isValid: boolean; value: Codec } => ({
isValid: true,
value
}));
return (
<Params
isDisabled
params={params}
values={values}
withExpander
/>
);
}
function formatVector (vector: Vec<Codec>): React.ReactNode {
const type = getTypeDef(vector.Type);
const values = vector.toArray().map((value): { isValid: boolean; value: Codec } => ({
isValid: true,
value
}));
const params = values.map((_, index): { name: string; type: TypeDef } => ({
name: `${index}`,
type
}));
return (
<Params
isDisabled
params={params}
values={values}
withExpander
/>
);
}
function formatItem (item: DigestItem): React.ReactNode {
if (item.value instanceof Struct) {
return formatStruct(item.value as Struct);
} else if (item.value instanceof Tuple) {
return formatTuple(item.value);
} else if (item.value instanceof Vec) {
return formatVector(item.value as Vec<Codec>);
} else if (item.value instanceof Raw) {
return formatU8a(item.value);
}
return <div>{item.value.toString().split(',').join(', ')}</div>;
}
function Logs ({ value }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('logs'), 'start']
]);
return (
<Table
empty={t('No logs available')}
header={headerRef.current}
>
{value?.map((log, index) => (
<tr key={`log:${index}`}>
<td className='overflow'>
<Expander
isLeft
summary={log.type.toString()}
>
{formatItem(log)}
</Expander>
</td>
</tr>
))}
</Table>
);
}
export default React.memo(Logs);
@@ -0,0 +1,154 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyedEvent } from '@pezkuwi/react-hooks/ctx/types';
import type { V2Weight } from '@pezkuwi/react-hooks/useWeight';
import type { Balance, DispatchInfo, SignedBlock } from '@pezkuwi/types/interfaces';
import type { FrameSupportDispatchPerDispatchClassWeight } from '@pezkuwi/types/lookup';
import React, { useMemo } from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { convertWeight } from '@pezkuwi/react-hooks/useWeight';
import { FormatBalance } from '@pezkuwi/react-query';
import { BN, BN_ONE, BN_THREE, BN_TWO, formatNumber, isBn } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
events?: KeyedEvent[] | null;
blockWeight?: FrameSupportDispatchPerDispatchClassWeight | null;
maxBlockWeight?: BN;
maxProofSize?: BN;
signedBlock?: SignedBlock;
}
function accumulateWeights (
weight?: FrameSupportDispatchPerDispatchClassWeight | null
): { totalRefTime: BN; totalProofSize: BN } {
const totalRefTime = new BN(0);
const totalProofSize = new BN(0);
(['normal', 'operational', 'mandatory'] as const).forEach((cls) => {
totalRefTime.iadd(weight?.[cls].refTime.toBn() ?? new BN(0));
totalProofSize.iadd(weight?.[cls].proofSize.toBn() ?? new BN(0));
});
return { totalProofSize, totalRefTime };
}
function extractEventDetails (events?: KeyedEvent[] | null): [BN?, BN?, BN?, BN?] {
return events
? events.reduce(([deposits, transfers, weight, proofSize], { record: { event: { data, method, section } } }) => {
const size = (convertWeight(
((method === 'ExtrinsicSuccess' ? data[0] : data[1]) as DispatchInfo)?.weight
).v2Weight as V2Weight).proofSize;
return [
section === 'balances' && method === 'Deposit'
? deposits.iadd(data[1] as Balance)
: deposits,
section === 'balances' && method === 'Transfer'
? transfers.iadd(data[2] as Balance)
: transfers,
section === 'system' && ['ExtrinsicFailed', 'ExtrinsicSuccess'].includes(method)
? weight.iadd(convertWeight(
((method === 'ExtrinsicSuccess' ? data[0] : data[1]) as DispatchInfo)?.weight
).v1Weight)
: weight,
section === 'system' && ['ExtrinsicFailed', 'ExtrinsicSuccess'].includes(method)
? proofSize.iadd(isBn(size) ? size : size.toBn())
: proofSize
];
}, [new BN(0), new BN(0), new BN(0), new BN(0)])
: [];
}
function Summary ({ blockWeight, events, maxBlockWeight, maxProofSize, signedBlock }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [deposits, transfers, weight, size] = useMemo(
() => {
const eventDetails = extractEventDetails(events);
const { totalProofSize, totalRefTime } = accumulateWeights(blockWeight);
// Block weight is the source of truth; using events data as fallback only
if (blockWeight) {
eventDetails[2] = totalRefTime;
eventDetails[3] = totalProofSize;
}
return eventDetails;
},
[blockWeight, events]
);
return (
<SummaryBox>
<section>
{api.query.balances && (
<>
<CardSummary label={t('deposits')}>
<FormatBalance
className={deposits ? '' : '--tmp'}
value={deposits || BN_ONE}
/>
</CardSummary>
<CardSummary
className='media--1000'
label={t('transfers')}
>
<FormatBalance
className={transfers ? '' : '--tmp'}
value={transfers || BN_ONE}
/>
</CardSummary>
</>
)}
</section>
<section>
<CardSummary
label={t('ref time')}
progress={{
hideValue: true,
isBlurred: !(maxBlockWeight && weight),
total: (maxBlockWeight && weight) ? maxBlockWeight : BN_THREE,
value: (maxBlockWeight && weight) ? weight : BN_TWO
}}
>
{weight
? formatNumber(weight)
: <span className='--tmp'>999,999,999</span>}
</CardSummary>
{maxProofSize && size &&
<CardSummary
label={t('proof size')}
progress={{
hideValue: true,
isBlurred: false,
total: maxProofSize,
value: size
}}
>
{formatNumber(size)}
</CardSummary>}
</section>
<section className='media--900'>
<CardSummary label={t('event count')}>
{events
? formatNumber(events.length)
: <span className='--tmp'>99</span>}
</CardSummary>
<CardSummary label={t('extrinsic count')}>
{signedBlock
? formatNumber(signedBlock.block.extrinsics.length)
: <span className='--tmp'>99</span>}
</CardSummary>
</section>
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,48 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useBestNumber } from '@pezkuwi/react-hooks';
import { isHex } from '@pezkuwi/util';
import Query from '../Query.js';
import BlockByHash from './ByHash.js';
import BlockByNumber from './ByNumber.js';
function Entry (): React.ReactElement | null {
const bestNumber = useBestNumber();
const { value } = useParams<{ value: string }>();
const [stateValue, setStateValue] = useState<string | undefined>(value);
useEffect((): void => {
setStateValue((stateValue) =>
value && value !== stateValue
? value
: !stateValue && bestNumber
? bestNumber.toString()
: stateValue
);
}, [bestNumber, value]);
if (!stateValue) {
return null;
}
const Component = isHex(stateValue)
? BlockByHash
: BlockByNumber;
return (
<>
<Query />
<Component
key={stateValue}
value={stateValue}
/>
</>
);
}
export default React.memo(Entry);
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { EventRecord } from '@pezkuwi/types/interfaces';
import React from 'react';
import { Expander } from '@pezkuwi/react-components';
import { Event as EventDisplay } from '@pezkuwi/react-params';
interface Props {
className?: string;
value: EventRecord;
}
function Event ({ className = '', value: { event } }: Props): React.ReactElement<Props> {
const eventName = `${event.section}.${event.method}`;
return (
<Expander
className={className}
isLeft
summary={eventName}
summaryMeta={event.meta}
>
{event.data.length
? (
<EventDisplay
className='details'
eventName={eventName}
value={event}
withExpander
/>
)
: null
}
</Expander>
);
}
export default React.memo(Event);
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyedEvent } from '@pezkuwi/react-hooks/ctx/types';
import type { GenericExtrinsic, Vec } from '@pezkuwi/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { AnyTuple } from '@pezkuwi/types-codec/types';
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { MarkError, styled, Table, Toggle } from '@pezkuwi/react-components';
import { useAccounts, useApi, useToggle } from '@pezkuwi/react-hooks';
import { isEventFromMyAccounts } from '@pezkuwi/react-hooks/utils/isEventFromMyAccounts';
import { formatNumber } from '@pezkuwi/util';
import Event from './Event.js';
import { useTranslation } from './translate.js';
const MAX_CACHE = 200;
const blockCache = new Map<string, { author: AccountId | undefined; extrinsics: Vec<GenericExtrinsic<AnyTuple>>}>();
interface Props {
className?: string;
error?: Error | null;
emptyLabel?: React.ReactNode;
events?: KeyedEvent[] | null;
eventClassName?: string;
label?: React.ReactNode;
showToggle?: boolean
}
function renederEvent (className: string | undefined, { blockHash, blockNumber, indexes, key, record }: KeyedEvent): React.ReactNode {
return (
<tr
className={className}
key={key}
>
<td className='overflow relative'>
<Event value={record} />
{blockNumber && (
<div className='absolute --digits'>
{indexes.length !== 1 && <span>{formatNumber(indexes.length)}x&nbsp;</span>}
<Link to={`/explorer/query/${blockHash || ''}`}>{formatNumber(blockNumber)}-{indexes[0].toString().padStart(2, '0')}</Link>
</div>
)}
</td>
</tr>
);
}
function Events ({ className = '', emptyLabel, error, eventClassName, events, label, showToggle = false }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { allAccounts } = useAccounts();
const [showOnlyUserEvents, onToggleUserEvents] = useToggle();
const [filteredEvents, setFilteredEvents] = useState<Props['events']>([]);
const header = useMemo<[React.ReactNode?, string?, number?][]>(
() => [
[label || t('recent events'), 'start']
],
[label, t]
);
useEffect(() => {
const filter = async () => {
if (!events || !showOnlyUserEvents) {
return;
}
for (const event of events) {
const { blockHash, record } = event;
if (!blockHash) {
continue;
}
// use cached block info if available
let blockData = blockCache.get(blockHash);
if (!blockData) {
const [{ author }, block] = await Promise.all([
await api.derive.chain.getHeader(blockHash),
await api.rpc.chain.getBlock(blockHash)
]);
const extrinsics = block.block.extrinsics;
blockData = { author, extrinsics };
blockCache.set(blockHash, blockData);
// Evict oldest key
if (blockCache.size > MAX_CACHE) {
const oldest = blockCache.keys().next().value;
oldest && blockCache.delete(oldest);
}
}
const { author, extrinsics } = blockData;
if (isEventFromMyAccounts(record, extrinsics, author, allAccounts)) {
setFilteredEvents((prev) => {
const alreadyExists = (prev ?? []).some((e) => e.key === event.key);
if (alreadyExists) {
return prev;
}
return [event, ...(prev ?? [])];
});
}
}
};
filter().catch(console.error);
}, [allAccounts, api.derive.chain, api.rpc.chain, events, showOnlyUserEvents]);
return (
<StyledSection>
<Table
className={className}
empty={emptyLabel || t('No events available')}
header={header}
>
{error
? (
<tr
className={eventClassName}
key='error'
>
<td><MarkError content={t('Unable to decode the block events. {{error}}', { replace: { error: error.message } })} /></td>
</tr>
)
: (showOnlyUserEvents ? filteredEvents : events)?.map((e) => renederEvent(eventClassName, e))
}
</Table>
{showToggle &&
<Toggle
label={t('Show my events')}
onChange={onToggleUserEvents}
value={showOnlyUserEvents}
/>
}
</StyledSection>
);
}
const StyledSection = styled.section`
position: relative;
.ui--Toggle {
position: absolute;
top: 1.4rem;
right: 1rem;
z-index: 999;
}
`;
export default React.memo(Events);
+465
View File
@@ -0,0 +1,465 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Header } from '@pezkuwi/types/interfaces';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { CardSummary, IdentityIcon, styled, SummaryBox } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface LinkHeader {
author: string | null;
bn: string;
hash: string;
height: number;
isEmpty: boolean;
isFinalized: boolean;
parent: string;
width: number;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface,no-use-before-define
interface LinkArray extends Array<Link> {}
interface Link {
arr: LinkArray;
hdr: LinkHeader;
}
interface Props {
className?: string;
}
type UnsubFn = () => void;
interface Col {
author: string | null;
hash: string;
isEmpty: boolean;
isFinalized: boolean;
parent: string;
width: number;
}
interface Row {
bn: string;
cols: Col[];
}
// adjust the number of columns in a cell based on the children and tree depth
function calcWidth (children: LinkArray): number {
return Math.max(1, children.reduce((total, { hdr: { width } }): number => {
return total + width;
}, 0));
}
// counts the height of a specific node
function calcHeight (children: LinkArray): number {
return children.reduce((max, { arr, hdr }): number => {
hdr.height = hdr.isEmpty
? 0
: 1 + calcHeight(arr);
return Math.max(max, hdr.height);
}, 0);
}
// a single column in a row, it just has the details for the entry
function createCol ({ hdr: { author, hash, isEmpty, isFinalized, parent, width } }: Link): Col {
return { author, hash, isEmpty, isFinalized, parent, width };
}
// create a simplified structure that allows for easy rendering
function createRows (arr: LinkArray): Row[] {
if (!arr.length) {
return [];
}
return createRows(
arr.reduce((children: LinkArray, { arr }: Link): LinkArray =>
children.concat(...arr), [])
).concat({
bn: arr.reduce((result, { hdr: { bn } }): string =>
result || bn, ''),
cols: arr.map(createCol)
});
}
// fills in a header based on the supplied data
function createHdr (bn: string, hash: string, parent: string, author: string | null, isEmpty = false): LinkHeader {
return { author, bn, hash, height: 0, isEmpty, isFinalized: false, parent, width: 0 };
}
// empty link helper
function createLink (): Link {
return {
arr: [],
hdr: createHdr('', ' ', ' ', null, true)
};
}
// even out the columns, i.e. add empty spacers as applicable to get tree rendering right
function addColumnSpacers (arr: LinkArray): void {
// check is any of the children has a non-empty set
const hasChildren = arr.some(({ arr }): boolean => arr.length !== 0);
if (hasChildren) {
// ok, non-empty found - iterate through an add at least an empty cell to all
arr
.filter(({ arr }): boolean => arr.length === 0)
.forEach(({ arr }): number => arr.push(createLink()));
const newArr = arr.reduce((flat: LinkArray, { arr }): LinkArray => flat.concat(...arr), []);
// go one level deeper, ensure that the full tree has empty spacers
addColumnSpacers(newArr);
}
}
// checks to see if a row has a single non-empty entry, i.e. it is a candidate for collapsing
function isSingleRow (cols: Col[]): boolean {
if (!cols[0] || cols[0].isEmpty) {
return false;
}
return cols.reduce((result: boolean, col, index): boolean => {
return index === 0
? result
: (!col.isEmpty ? false : result);
}, true);
}
function renderCol ({ author, hash, isEmpty, isFinalized, parent, width }: Col, index: number): React.ReactNode {
return (
<td
className={`header ${isEmpty ? 'isEmpty' : ''} ${isFinalized ? 'isFinalized' : 'isNotFinal'}`}
colSpan={width}
key={`${hash}:${index}:${width}`}
>
{isEmpty
? <div className='empty' />
: (
<>
{author && (
<IdentityIcon
className='author'
size={28}
value={author}
/>
)}
<div className='contents'>
<div className='hash'>{hash}</div>
<div className='parent'>{parent}</div>
</div>
</>
)
}
</td>
);
}
// render the rows created by createRows to React nodes
function renderRows (rows: Row[]): React.ReactNode[] {
const lastIndex = rows.length - 1;
let isPrevShort = false;
return rows.map(({ bn, cols }, index): React.ReactNode => {
// if not first, not last and single only, see if we can collapse
if (index !== 0 && index !== lastIndex && isSingleRow(cols)) {
if (isPrevShort) {
// previous one was already a link, this one as well - skip it
return null;
} else if (isSingleRow(rows[index - 1].cols)) {
isPrevShort = true;
return (
<tr key={bn}>
<td key='blockNumber' />
<td
className='header isLink'
colSpan={cols[0].width}
>
<div className='link'>&#8942;</div>
</td>
</tr>
);
}
}
isPrevShort = false;
return (
<tr key={bn}>
<td key='blockNumber'>{`#${bn}`}</td>
{cols.map(renderCol)}
</tr>
);
});
}
function Forks ({ className }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [tree, setTree] = useState<Link | null>(null);
const childrenRef = useRef<Map<string, string[]>>(new Map([['root', []]]));
const countRef = useRef({ numBlocks: 0, numForks: 0 });
const headersRef = useRef<Map<string, LinkHeader>>(new Map());
const firstNumRef = useRef('');
const _finalize = useCallback(
(hash: string): void => {
const hdr = headersRef.current.get(hash);
if (hdr && !hdr.isFinalized) {
hdr.isFinalized = true;
_finalize(hdr.parent);
}
},
[]
);
// adds children for a specific header, retrieving based on matching parent
const _addChildren = useCallback(
(base: LinkHeader, children: LinkArray): LinkArray => {
// add the children
(childrenRef.current.get(base.hash) || [])
.map((hash): LinkHeader | undefined => headersRef.current.get(hash))
.filter((hdr): hdr is LinkHeader => !!hdr)
.forEach((hdr): void => {
children.push({ arr: _addChildren(hdr, []), hdr });
});
// calculate the max height/width for this entry
base.height = calcHeight(children);
base.width = calcWidth(children);
// place the active (larger, finalized) columns first for the pyramid display
children.sort((a, b): number =>
(a.hdr.width > b.hdr.width || a.hdr.height > b.hdr.height || a.hdr.isFinalized)
? -1
: (a.hdr.width < b.hdr.width || a.hdr.height < b.hdr.height || b.hdr.isFinalized)
? 1
: 0
);
return children;
},
[]
);
// create a tree list from the available headers
const _generateTree = useCallback(
(): Link => {
const root = createLink();
// add all the root entries first, we iterate from these
// We add the root entry explicitly, it exists as per init
(childrenRef.current.get('root') || []).forEach((hash): void => {
const hdr = headersRef.current.get(hash);
// if this fails, well, we have a bigger issue :(
if (hdr) {
root.arr.push({ arr: [], hdr: { ...hdr } });
}
});
// iterate through, adding the children for each of the root nodes
root.arr.forEach(({ arr, hdr }): void => {
_addChildren(hdr, arr);
});
// align the columns with empty spacers - this aids in display
addColumnSpacers(root.arr);
root.hdr.height = calcHeight(root.arr);
root.hdr.width = calcWidth(root.arr);
return root;
},
[_addChildren]
);
// callback when finalized
const _newFinalized = useCallback(
(header: Header): void => {
_finalize(header.hash.toHex());
},
[_finalize]
);
// callback for the subscribe headers sub
const _newHeader = useCallback(
(header: Header): void => {
// formatted block info
const bn = formatNumber(header.number);
const hash = header.hash.toHex();
const parent = header.parentHash.toHex();
let isFork = false;
// if this the first one?
if (!firstNumRef.current) {
firstNumRef.current = bn;
}
if (!headersRef.current.has(hash)) {
// if this is the first, add to the root entry
if (firstNumRef.current === bn) {
(childrenRef.current.get('root') as unknown[]).push(hash);
}
// add to the header map
// also for HeaderExtended header.author ? header.author.toString() : null
headersRef.current.set(hash, createHdr(bn, hash, parent, null));
// check to see if the children already has a entry
if (childrenRef.current.has(parent)) {
isFork = true;
(childrenRef.current.get(parent) as unknown[]).push(hash);
} else {
childrenRef.current.set(parent, [hash]);
}
// if we don't have the parent of this one, retrieve it
if (!headersRef.current.has(parent)) {
// just make sure we are not first in the list, we don't want to full chain
if (firstNumRef.current !== bn) {
console.warn(`Retrieving missing header ${header.parentHash.toHex()}`);
api.rpc.chain.getHeader(header.parentHash).then(_newHeader).catch(console.error);
// catch the refresh on the result
return;
}
}
// update our counters
countRef.current.numBlocks++;
if (isFork) {
countRef.current.numForks++;
}
// do the magic, extract the info into something useful and add to state
setTree(_generateTree());
}
},
[api, _generateTree]
);
useEffect((): () => void => {
let _subFinHead: UnsubFn | null = null;
let _subNewHead: UnsubFn | null = null;
(async (): Promise<void> => {
_subFinHead = await api.rpc.chain.subscribeFinalizedHeads(_newFinalized);
_subNewHead = await api.rpc.chain.subscribeNewHeads(_newHeader);
})().catch(console.error);
return (): void => {
_subFinHead && _subFinHead();
_subNewHead && _subNewHead();
};
}, [api, _newFinalized, _newHeader]);
if (!tree) {
return null;
}
return (
<StyledDiv className={className}>
<SummaryBox>
<section>
<CardSummary label={t('blocks')}>{formatNumber(countRef.current.numBlocks)}</CardSummary>
<CardSummary label={t('forks')}>{formatNumber(countRef.current.numForks)}</CardSummary>
</section>
</SummaryBox>
<table>
<tbody>
{renderRows(createRows(tree.arr))}
</tbody>
</table>
</StyledDiv>
);
}
const StyledDiv = styled.div`
margin-bottom: 1.5rem;
table {
border-collapse: separate;
border-spacing: 0.25rem;
font: var(--font-mono);
td {
padding: 0.25rem 0.5rem;
text-align: center;
.author,
.contents {
display: inline-block;
vertical-align: middle;
}
.author {
margin-right: 0.25rem;
}
.contents {
.hash, .parent {
margin: 0 auto;
max-width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parent {
font-size: var(--font-size-small);
line-height: 0.75rem;
max-width: 4.5rem;
}
}
&.blockNumber {
font-size: 1.25rem;
}
&.header {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 0.25rem;
&.isEmpty {
background: transparent;
border-color: transparent;
}
&.isFinalized {
background: rgba(0, 255, 0, 0.1);
}
&.isNotFinal {
color: rgba(0,0,0);
}
&.isLink {
background: transparent;
border-color: transparent;
line-height: 1rem;
padding: 0;
}
&.isMissing {
background: rgba(255, 0, 0, 0.05);
}
}
}
}
`;
export default React.memo(Forks);
@@ -0,0 +1,48 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChartOptions } from 'chart.js';
import type { ChartContents } from './types.js';
import React from 'react';
import { Chart, styled } from '@pezkuwi/react-components';
interface Props {
className?: string;
colors: string[];
legends: string[];
options?: ChartOptions;
title: string;
value: ChartContents;
}
const OPTIONS: ChartOptions = {
aspectRatio: 6,
maintainAspectRatio: true
};
function ChartDisplay ({ className, colors, legends, options, title, value: { labels, values } }: Props): React.ReactElement<Props> {
return (
<StyledDiv className={className}>
<Chart.Line
colors={colors}
labels={labels}
legends={legends}
options={options || OPTIONS}
title={title}
values={values}
/>
</StyledDiv>
);
}
const StyledDiv = styled.div`
background: var(--bg-table);
border: 1px solid var(--border-table);
border-radius: 0.25rem;
margin-bottom: 1rem;
padding: 1rem 1.5rem;
`;
export default React.memo(ChartDisplay);
@@ -0,0 +1,214 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChartContents, Detail } from './types.js';
import React, { useEffect, useMemo, useState } from 'react';
import { CardSummary, NextTick, styled, SummaryBox } from '@pezkuwi/react-components';
import { formatNumber, nextTick } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import Chart from './Chart.js';
import useLatency from './useLatency.js';
interface Props {
className?: string;
}
interface ChartInfo {
blockLast: number;
blocks: ChartContents;
events: ChartContents;
extrinsics: ChartContents;
times: ChartContents;
}
const ORDER = ['times', 'blocks', 'extrinsics', 'events'] as const;
const COLORS = {
blocks: ['#008c8c', '#acacac'],
events: ['#00448c', '#8c0044', '#acacac'],
extrinsics: ['#448c00', '#acacac'],
times: ['#8c8c00', '#acacac']
};
function getPoints (details: Detail[], timeAvg: number): ChartInfo {
const blocks: ChartContents = {
labels: [],
values: [[], []]
};
const events: ChartContents = {
labels: [],
values: [[], [], []]
};
const extrinsics: ChartContents = {
labels: [],
values: [[], []]
};
const times: ChartContents = {
labels: [],
values: [[], []]
};
const eventTotal = details.reduce((a, { events: { count } }) => a + count, 0);
const txTotal = details.reduce((a, { extrinsics: { count } }) => a + count, 0);
const blockTotal = details.reduce((a, { block: { bytes } }) => a + bytes, 0);
for (let i = 0, count = details.length; i < count; i++) {
const blockNumber = formatNumber(details[i].block.number);
blocks.labels.push(blockNumber);
blocks.values[0].push(details[i].block.bytes);
blocks.values[1].push(blockTotal / details.length);
events.labels.push(blockNumber);
events.values[0].push(details[i].events.count);
events.values[1].push(details[i].events.system);
events.values[2].push(eventTotal / details.length);
extrinsics.labels.push(blockNumber);
extrinsics.values[0].push(details[i].extrinsics.count);
extrinsics.values[1].push(txTotal / details.length);
}
const filtered = details.filter(({ delay }) => delay);
const avgBase = timeAvg * filtered.length;
for (let i = 0, count = filtered.length; i < count; i++) {
times.labels.push(formatNumber(filtered[i].block.number));
times.values[0].push(filtered[i].delay / 1000);
times.values[1].push(avgBase / filtered.length / 1000);
}
return {
blockLast: times.values[0][times.values[0].length - 1],
blocks,
events,
extrinsics,
times
};
}
function formatTime (time: number, divisor = 1000): React.ReactNode {
return <span className='--digits'>{`${(time / divisor).toFixed(3)}`}<span className='postfix'> s</span></span>;
}
function Latency ({ className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { details, isLoaded, maxItems, stdDev, timeAvg, timeMax, timeMin } = useLatency();
const [shouldRender, setShouldRender] = useState(() => new Array<boolean>(ORDER.length).fill(false));
useEffect((): void => {
// HACK try and render the charts in order - this _may_ work around the
// crosshair plugin init issues, but at best it is non-reproducible
if (isLoaded) {
const index = shouldRender.findIndex((v) => !v);
if (index !== -1) {
nextTick(() =>
setShouldRender(
shouldRender.map((v, i) => (i === index) || v)
)
);
}
}
}, [isLoaded, shouldRender]);
const points = useMemo(
() => getPoints(details, timeAvg),
[details, timeAvg]
);
const [legend, title] = useMemo(
() => [
{
blocks: [t('bytes'), t('average')],
events: [t('events'), t('system'), t('average')],
extrinsics: [t('extrinsics'), t('average')],
times: [t('blocktime'), t('average')]
},
{
blocks: t('blocksize (last {{n}} blocks)', { replace: { n: maxItems } }),
events: t('events (last {{n}} blocks)', { replace: { n: maxItems } }),
extrinsics: t('extrinsics (last {{n}} blocks)', { replace: { n: maxItems } }),
times: t('blocktimes (last {{n}} blocks)', { replace: { n: maxItems } })
}
],
[maxItems, t]
);
const EMPTY_TIME = <span className='--tmp --digits'>0.000 <span className='postfix'>s</span></span>;
return (
<StyledDiv className={className}>
<SummaryBox>
<section>
<CardSummary label={t('avg')}>
{isLoaded
? formatTime(timeAvg)
: EMPTY_TIME}
</CardSummary>
<CardSummary
className='media--1000'
label={t('std dev')}
>
{isLoaded
? formatTime(stdDev)
: EMPTY_TIME}
</CardSummary>
</section>
<section>
<CardSummary label={t('min')}>
{isLoaded
? formatTime(timeMin)
: EMPTY_TIME}
</CardSummary>
<CardSummary label={t('max')}>
{isLoaded
? formatTime(timeMax)
: EMPTY_TIME
}
</CardSummary>
</section>
<CardSummary label={t('last')}>
{isLoaded
? formatTime(points.blockLast, 1)
: EMPTY_TIME}
</CardSummary>
</SummaryBox>
<NextTick isActive={isLoaded}>
{ORDER.map((key) => (
<Chart
colors={COLORS[key]}
key={key}
legends={legend[key]}
title={title[key]}
value={points[key]}
/>
))}
</NextTick>
</StyledDiv>
);
}
const StyledDiv = styled.div`
.container {
background: var(--bg-table);
border: 1px solid var(--border-table);
border-radius: 0.25rem;
padding: 1rem 1.5rem;
}
.container+.container {
margin-top: 1rem;
}
span.--digits {
.postfix {
font-size: var(--font-percent-tiny);
}
}
`;
export default React.memo(Latency);
@@ -0,0 +1,37 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Hash } from '@pezkuwi/types/interfaces';
export interface Detail {
block: {
bytes: number;
number: number;
};
delay: number;
events: {
count: number;
system: number;
};
extrinsics: {
bytes: number;
count: number;
};
now: number;
parentHash: Hash;
}
export interface Result {
details: Detail[];
isLoaded: boolean;
maxItems: number;
stdDev: number;
timeAvg: number;
timeMax: number;
timeMin: number;
}
export interface ChartContents {
labels: string[];
values: number[][];
}
@@ -0,0 +1,226 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { SignedBlockExtended } from '@pezkuwi/api-derive/types';
import type { GenericExtrinsic, u32 } from '@pezkuwi/types';
import type { Block } from '@pezkuwi/types/interfaces';
import type { Detail, Result } from './types.js';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
const INITIAL_ITEMS = 50;
const MAX_ITEMS = INITIAL_ITEMS;
const EMPTY: Result = {
details: [],
isLoaded: false,
maxItems: MAX_ITEMS,
stdDev: 0,
timeAvg: 0,
timeMax: 0,
timeMin: 0
};
function getSetter ({ extrinsics }: Block): GenericExtrinsic | undefined {
return extrinsics.find(({ method: { method, section } }) =>
method === 'set' &&
section === 'timestamp'
);
}
/**
* 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)
.filter(({ block }, index) =>
index === 0 ||
block.number > details[index - 1].block.number
);
for (let i = 0; i < filtered.length - 1; i++) {
const a = filtered[i];
const b = filtered[i + 1];
// 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;
}
}
return filtered.slice(-MAX_ITEMS);
}
function addBlock (prev: Detail[], { block, events }: SignedBlockExtended): Detail[] {
const setter = getSetter(block);
if (!setter) {
return prev;
}
return [
...prev,
{
block: {
bytes: block.encodedLength,
number: block.header.number.toNumber()
},
delay: 0,
events: {
count: events.length,
system: events.filter(({ phase }) => !phase.isApplyExtrinsic).length
},
extrinsics: {
bytes: block.extrinsics.reduce((a, x) => a + x.encodedLength, 0),
count: block.extrinsics.length
},
now: (setter.args[0] as u32).toNumber(),
parentHash: block.header.parentHash
}
];
}
function addBlocks (prev: Detail[], blocks: SignedBlockExtended[]): Detail[] {
return blocks.reduce((p, b) => addBlock(p, b), prev);
}
async function getBlocks (api: ApiPromise, blockNumbers: number[]): Promise<SignedBlockExtended[]> {
if (!blockNumbers.length) {
return [];
}
const blocks = await Promise.all(
blockNumbers.map((n) => api.derive.chain.getBlockByNumber(n))
);
return blocks.filter((b): b is SignedBlockExtended => !!b);
}
async function getPrev (api: ApiPromise, { block: { header } }: SignedBlockExtended): Promise<SignedBlockExtended[]> {
const blockNumbers: number[] = [];
let blockNumber = header.number.toNumber();
for (let i = 1; blockNumber > 0 && i <= INITIAL_ITEMS; i++) {
blockNumbers.push(--blockNumber);
}
return getBlocks(api, blockNumbers);
}
async function getNext (api: ApiPromise, { block: { number: start } }: Detail, { block: { number: end } }: Detail): Promise<SignedBlockExtended[]> {
const blockNumbers: number[] = [];
for (let n = start + 1; n < end; n++) {
blockNumbers.push(n);
}
return getBlocks(api, blockNumbers);
}
function useLatencyImpl (): Result {
const { api } = useApi();
const [details, setDetails] = useState<Detail[]>([]);
const signedBlock = useCall<SignedBlockExtended>(api.derive.chain.subscribeNewBlocks);
const hasHistoric = useRef(false);
useEffect((): void => {
if (!signedBlock) {
return;
}
setDetails((prev) => calcDelay(addBlock(prev, signedBlock)));
if (hasHistoric.current) {
return;
}
hasHistoric.current = true;
getPrev(api, signedBlock)
.then((all) => setDetails((prev) => calcDelay(addBlocks(prev, all))))
.catch(console.error);
}, [api, signedBlock]);
useEffect((): void => {
if (details.length <= 2) {
return;
}
const lastIndex = details.findIndex(({ block }, index) =>
index !== (details.length - 1) &&
(details[index + 1].block.number - block.number) > 1
);
if (lastIndex === -1) {
return;
}
getNext(api, details[lastIndex], details[lastIndex + 1])
.then((all) => setDetails((prev) => calcDelay(addBlocks(prev, all))))
.catch(console.error);
}, [api, details]);
return useMemo((): Result => {
const delays = details
.map(({ delay }) => delay)
.filter((delay) => delay);
if (!delays.length) {
return EMPTY;
}
const timeAvg = delays.reduce((avg, d) => avg + d, 0) / delays.length;
const stdDev = Math.sqrt(delays.reduce((dev, d) => dev + Math.pow(timeAvg - d, 2), 0) / delays.length);
return {
details,
isLoaded: details.length === MAX_ITEMS,
maxItems: MAX_ITEMS,
stdDev,
timeAvg,
timeMax: Math.max(...delays),
timeMin: Math.min(...delays)
};
}, [details]);
}
export default createNamedHook('useLatency', useLatencyImpl);
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AugmentedBlockHeader, KeyedEvent } from '@pezkuwi/react-hooks/ctx/types';
import React from 'react';
import { Columar } from '@pezkuwi/react-components';
import BlockHeaders from './BlockHeaders.js';
import Events from './Events.js';
import Query from './Query.js';
import Summary from './Summary.js';
interface Props {
eventCount: number;
events: KeyedEvent[];
headers: AugmentedBlockHeader[];
}
function Main ({ eventCount, events, headers }: Props): React.ReactElement<Props> {
return (
<>
<Query />
<Summary
eventCount={eventCount}
headers={headers}
/>
<Columar>
<Columar.Column>
<BlockHeaders headers={headers} />
</Columar.Column>
<Columar.Column>
<Events
events={events}
showToggle
/>
</Columar.Column>
</Columar>
</>
);
}
export default React.memo(Main);
@@ -0,0 +1,77 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PeerInfo } from '@pezkuwi/types/interfaces';
import React, { useMemo, useRef } from 'react';
import { styled, Table } from '@pezkuwi/react-components';
import { formatNumber, stringPascalCase } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
peers?: PeerInfo[] | null;
}
function sortPeers (peers: PeerInfo[]) {
return peers
.map(({ bestHash, bestNumber, peerId, roles }) => ({
bestHash: bestHash.toHex(),
bestNumber,
peerId: peerId.toString(),
roles: stringPascalCase(roles)
}))
.sort((a, b) => a.peerId.localeCompare(b.peerId))
.sort((a, b) => a.roles.localeCompare(b.roles))
.sort((a, b) => b.bestNumber.cmp(a.bestNumber));
}
function Peers ({ className = '', peers }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('connected peers'), 'start', 2],
[t('best hash'), 'start'],
[t('best #'), 'number']
]);
const sorted = useMemo(
() => peers && sortPeers(peers),
[peers]
);
return (
<StyledTable
className={className}
empty={t('no peers connected')}
header={headerRef.current}
>
{sorted?.map(({ bestHash, bestNumber, peerId, roles }) => (
<tr key={peerId}>
<td className='roles'>{roles}</td>
<td className='hash overflow'>{peerId}</td>
<td className='hash overflow'>{bestHash}</td>
<td className='number bestNumber'>{formatNumber(bestNumber)}</td>
</tr>
))}
</StyledTable>
);
}
const StyledTable = styled(Table)`
overflow-x: auto;
td.roles {
max-width: 9ch;
width: 9ch;
}
td.bestNumber {
max-width: 11ch;
width: 11ch;
}
`;
export default React.memo(Peers);
@@ -0,0 +1,84 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Info } from './types.js';
import React, { useEffect, useState } from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { BestNumber, Elapsed } from '@pezkuwi/react-query';
import { BN_ZERO, formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
nextRefresh: number;
info: Info;
}
const EMPTY_INFO = { extrinsics: null, health: null, peers: null };
function Summary ({ info: { extrinsics, health, peers } = EMPTY_INFO, nextRefresh }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [peerBest, setPeerBest] = useState(BN_ZERO);
useEffect((): void => {
if (peers) {
const bestPeer = peers.sort((a, b): number => b.bestNumber.cmp(a.bestNumber))[0];
setPeerBest(
bestPeer
? bestPeer.bestNumber
: BN_ZERO
);
}
}, [peers]);
return (
<SummaryBox>
<section>
<CardSummary label={t('refresh in')}>
<Elapsed value={nextRefresh} />
</CardSummary>
{health && (
<>
<CardSummary
className='media--800'
label={t('total peers')}
>
{formatNumber(health.peers)}
</CardSummary>
<CardSummary
className='media--800'
label={t('syncing')}
>
{health.isSyncing.valueOf()
? t('yes')
: t('no')
}
</CardSummary>
</>
)}
</section>
{extrinsics && (extrinsics.length > 0) && (
<section className='media--1200'>
<CardSummary label={t('queued tx')}>
{extrinsics.length}
</CardSummary>
</section>
)}
<section>
{peerBest?.gtn(0) && (
<CardSummary label={t('peer best')}>
{formatNumber(peerBest)}
</CardSummary>
)}
<CardSummary label={t('our best')}>
<BestNumber />
</CardSummary>
</section>
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,72 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { Info } from './types.js';
import React, { useEffect, useState } from 'react';
import { useApi } from '@pezkuwi/react-hooks';
import Extrinsics from '../BlockInfo/Extrinsics.js';
import { useTranslation } from '../translate.js';
import Peers from './Peers.js';
import Summary from './Summary.js';
const POLL_TIMEOUT = 9900;
async function retrieveInfo (api: ApiPromise): Promise<Partial<Info>> {
try {
const [blockNumber, health, peers, extrinsics] = await Promise.all([
api.derive.chain.bestNumber(),
api.rpc.system.health().catch(() => null),
api.rpc.system.peers().catch(() => null),
api.rpc.author.pendingExtrinsics().catch(() => null)
]);
return { blockNumber, extrinsics, health, peers };
} catch {
return {};
}
}
function NodeInfo (): React.ReactElement {
const { t } = useTranslation();
const { api } = useApi();
const [info, setInfo] = useState<Partial<Info>>({});
const [nextRefresh, setNextRefresh] = useState(() => Date.now());
useEffect((): () => void => {
const getStatus = (): void => {
setNextRefresh(Date.now() + POLL_TIMEOUT);
retrieveInfo(api).then(setInfo).catch(console.error);
};
getStatus();
const timerId = window.setInterval(getStatus, POLL_TIMEOUT);
return (): void => {
window.clearInterval(timerId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<Summary
info={info}
nextRefresh={nextRefresh}
/>
<Peers peers={info.peers} />
<Extrinsics
blockNumber={info.blockNumber}
label={t('pending extrinsics')}
value={info.extrinsics}
withLink
/>
</>
);
}
export default React.memo(NodeInfo);
@@ -0,0 +1,12 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Vec } from '@pezkuwi/types';
import type { BlockNumber, Extrinsic, Health, PeerInfo } from '@pezkuwi/types/interfaces';
export interface Info {
blockNumber?: BlockNumber;
extrinsics?: Vec<Extrinsic> | null;
health?: Health | null;
peers?: PeerInfo[] | null;
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import { Button, FilterOverlay, Input, styled } from '@pezkuwi/react-components';
import { isHex } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
value?: string;
}
interface State {
value: string;
isValid: boolean;
}
function stateFromValue (value: string): State {
return {
isValid: isHex(value, 256) || /^\d+$/.test(value),
value
};
}
function Query ({ className = '', value: propsValue }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [{ isValid, value }, setState] = useState(() => stateFromValue(propsValue || ''));
const _setHash = useCallback(
(value: string): void => setState(stateFromValue(value)),
[]
);
const _onQuery = useCallback(
(): void => {
if (isValid && value.length !== 0) {
window.location.hash = `/explorer/query/${value}`;
}
},
[isValid, value]
);
return (
<StyledFilterOverlay className={`${className} ui--FilterOverlay hasOwnMaxWidth`}>
<Input
className='explorer--query'
defaultValue={propsValue}
isError={!isValid && value.length !== 0}
onChange={_setHash}
onEnter={_onQuery}
placeholder={t('block hash or number to query')}
withLabel={false}
>
<Button
icon='play'
onClick={_onQuery}
/>
</Input>
</StyledFilterOverlay>
);
}
const StyledFilterOverlay = styled(FilterOverlay)`
.explorer--query {
width: 20em;
}
`;
export default React.memo(Query);
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AugmentedBlockHeader } from '@pezkuwi/react-hooks/ctx/types';
import React from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import { BestFinalized, BestNumber, BlockToTime, TimeNow, TotalInactive, TotalIssuance } from '@pezkuwi/react-query';
import { BN_ONE, formatNumber } from '@pezkuwi/util';
import SummarySession from './SummarySession.js';
import { useTranslation } from './translate.js';
interface Props {
eventCount: number;
headers: AugmentedBlockHeader[];
}
function Summary ({ eventCount, headers }: Props): React.ReactElement {
const { t } = useTranslation();
const { api } = useApi();
return (
<SummaryBox>
<section>
{api.query.timestamp && (
<>
<CardSummary label={t('last block')}>
{/* Restart timer on key change */}
<TimeNow key={headers.at(0)?.hash.toHex()} />
</CardSummary>
<CardSummary
className='media--800'
label={t('slot')}
>
<BlockToTime value={BN_ONE} />
</CardSummary>
</>
)}
{api.query.balances && (
<>
<CardSummary
className='media--800'
label={t('total issuance')}
>
<TotalIssuance />
</CardSummary>
{!!api.query.balances.inactiveIssuance && (
<CardSummary
className='media--1300'
label={t('inactive issuance')}
>
<TotalInactive />
</CardSummary>
)}
</>
)}
</section>
<section className='media--1100'>
<SummarySession withEra={false} />
</section>
<section>
<CardSummary
className='media--1400'
label={t('last events')}
>
{formatNumber(eventCount)}
</CardSummary>
{api.query.grandpa && (
<CardSummary label={t('finalized')}>
<BestFinalized />
</CardSummary>
)}
<CardSummary label={t('best')}>
<BestNumber />
</CardSummary>
</section>
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,108 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveSessionProgress } from '@pezkuwi/api-derive/types';
import type { Forcing } from '@pezkuwi/types/interfaces';
import React from 'react';
import { CardSummary } from '@pezkuwi/react-components';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import { Elapsed } from '@pezkuwi/react-query';
import { BN_THREE, BN_TWO, formatNumber } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
withEra?: boolean;
withSession?: boolean;
}
function SummarySession ({ className, withEra = true, withSession = true }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const sessionInfo = useCall<DeriveSessionProgress>(api.derive.session?.progress);
const forcing = useCall<Forcing>(api.query.staking?.forceEra);
const eraLabel = t('era');
const sessionLabel = api.query.babe
? t('epoch')
: t('session');
const activeEraStart = sessionInfo?.activeEraStart.unwrapOr(null);
return (
<>
{api.derive.session && (
<>
{withSession && (
api.query.babe
? (
<CardSummary
className={className}
label={sessionLabel}
progress={{
isBlurred: !sessionInfo,
total: sessionInfo?.sessionLength || BN_THREE,
value: sessionInfo?.sessionProgress || BN_TWO,
withTime: true
}}
/>
)
: (
<CardSummary label={sessionLabel}>
#{sessionInfo
? formatNumber(sessionInfo.currentIndex)
: <span className='--tmp'>123</span>}
{withEra && activeEraStart && <div className='isSecondary'>&nbsp;</div>}
</CardSummary>
)
)}
{withEra && (
api.query.babe
? (
<CardSummary
className={className}
label={eraLabel}
progress={{
isBlurred: !(sessionInfo && forcing),
total: sessionInfo && forcing
? forcing.isForceAlways
? sessionInfo.sessionLength
: sessionInfo.eraLength
: BN_THREE,
value: sessionInfo && forcing
? forcing.isForceAlways
? sessionInfo.sessionProgress
: sessionInfo.eraProgress
: BN_TWO,
withTime: true
}}
/>
)
: (
<CardSummary
className={className}
label={eraLabel}
>
#{sessionInfo
? formatNumber(sessionInfo.activeEra)
: <span className='--tmp'>123</span>}
{activeEraStart && (
<Elapsed
className={`${sessionInfo ? '' : '--tmp'} isSecondary`}
value={activeEraStart}
>
&nbsp;{t('elapsed')}
</Elapsed>
)}
</CardSummary>
)
)}
</>
)}
</>
);
}
export default React.memo(SummarySession);
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { TabItem } from '@pezkuwi/react-components/types';
import type { KeyedEvent } from '@pezkuwi/react-hooks/ctx/types';
import React, { useMemo, useRef } from 'react';
import { Route, Routes } from 'react-router';
import { Tabs } from '@pezkuwi/react-components';
import { useApi, useBlockAuthors, useBlockEvents } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
import Api from './Api/index.js';
import BlockInfo from './BlockInfo/index.js';
import Latency from './Latency/index.js';
import NodeInfo from './NodeInfo/index.js';
import Forks from './Forks.js';
import Main from './Main.js';
import { useTranslation } from './translate.js';
interface Props {
basePath: string;
className?: string;
newEvents?: KeyedEvent[];
}
function createItemsRef (t: (key: string, options?: { replace: Record<string, unknown> }) => string): TabItem[] {
return [
{
isRoot: true,
name: 'chain',
text: t('Chain info')
},
{
hasParams: true,
name: 'query',
text: t('Block details')
},
{
name: 'latency',
text: t('Latency')
},
{
name: 'forks',
text: t('Forks')
},
{
name: 'node',
text: t('Node info')
},
{
// isHidden: true,
name: 'api',
text: t('API stats')
}
];
}
function ExplorerApp ({ basePath, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { lastHeaders } = useBlockAuthors();
const { eventCount, events } = useBlockEvents();
const itemsRef = useRef(createItemsRef(t));
const hidden = useMemo<string[]>(
() => isFunction(api.query.babe?.authorities) ? [] : ['forks'],
[api]
);
return (
<main className={className}>
<Tabs
basePath={basePath}
hidden={hidden}
items={itemsRef.current}
/>
<Routes>
<Route path={basePath}>
<Route
element={<Api />}
path='api'
/>
<Route
element={<Forks />}
path='forks'
/>
<Route
element={<Latency />}
path='latency'
/>
<Route
element={<NodeInfo />}
path='node'
/>
<Route
element={<BlockInfo />}
path='query/:value?'
/>
<Route
element={
<Main
eventCount={eventCount}
events={events}
headers={lastHeaders}
/>
}
index
/>
</Route>
</Routes>
</main>
);
}
export default React.memo(ExplorerApp);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-explorer authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-explorer');
}
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"outDir": "./build",
"rootDir": "./src"
},
"references": [
{ "path": "../react-components/tsconfig.build.json" },
{ "path": "../react-hooks/tsconfig.build.json" },
{ "path": "../react-params/tsconfig.build.json" }
]
}