mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-13 09:21:07 +00:00
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:
@@ -0,0 +1 @@
|
||||
# @pezkuwi/app-explorer
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 </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);
|
||||
@@ -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'>⋮</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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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'> </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}
|
||||
>
|
||||
{t('elapsed')}
|
||||
</Elapsed>
|
||||
)}
|
||||
</CardSummary>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(SummarySession);
|
||||
@@ -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);
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user