New command center (#11899)

* some work

* more info

* history

* mostly done

* better formatting

* fix: lint issues and types

* chore: minor styling improvement

---------

Co-authored-by: Arjun Porwal <porwalarjun95@gmail.com>
This commit is contained in:
Kian Paimani 2025-10-16 04:22:40 +01:00 committed by GitHub
parent cc91700648
commit cb90e6eb37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 1100 additions and 417 deletions

View File

@ -3,208 +3,301 @@
import type { ReactNode } from 'react';
import type { ApiPromise } from '@polkadot/api';
import type { IAhOutput } from './index.js';
import type { EnhancedEvent, IAhOutput } from './index.js';
import React from 'react';
import { Link } from 'react-router-dom';
import { CardSummary, Expander, Icon, MarkWarning, Spinner, styled, Tooltip } from '@polkadot/react-components';
import { CardSummary, Expander, Spinner, styled } from '@polkadot/react-components';
import { Event as EventDisplay } from '@polkadot/react-params';
import { formatNumber } from '@polkadot/util';
import { formatBalance, formatNumber } from '@polkadot/util';
import { useTranslation } from '../translate.js';
interface Props {
children: ReactNode;
ahApi?: ApiPromise;
ahOutput: IAhOutput[];
ahOutput?: IAhOutput;
ahEvents: EnhancedEvent[];
ahUrl: string;
isRelayChain: boolean
}
function AssetHubSection ({ ahApi, ahOutput, ahUrl, children, isRelayChain }: Props) {
function AssetHubSection ({ ahApi, ahEvents, ahOutput, ahUrl, children, isRelayChain }: Props) {
const { t } = useTranslation();
return (
<div>
<h1
style={{
alignItems: 'center',
display: 'flex',
textTransform: 'capitalize'
}}
>
{t('Asset Hub chain')}
{children}
</h1>
{!ahApi && <Spinner label='Connecting to Asset Hub' />}
<StyledSection>
{ahOutput.map((ah) => {
const uniquePages = Array.from(new Set(ah.multiblock.snapshotRange.map((p) => Number(p.toString())))).sort((a, b) => a - b);
const [minPage, maxPage] = [uniquePages[0], uniquePages[uniquePages.length - 1]];
<StyledColumn>
<StyledInfoBox>
<h2>
{t('Asset Hub')}
{children}
</h2>
{!ahApi && <Spinner label={t('Connecting to Asset Hub')} />}
{ahOutput && (
<div className='info-content'>
<div className='block-info'>
{!isRelayChain
? <Link to={`/explorer/query/${ahOutput.finalizedBlock}`}>#{formatNumber(ahOutput.finalizedBlock)}</Link>
: (
<Link
target='_blank'
to={`${window.location.origin}/?rpc=${ahUrl}#/explorer/query/${ahOutput.finalizedBlock}`}
>
#{formatNumber(ahOutput.finalizedBlock)}
</Link>)
}
</div>
<div className='section'>
<h4>{t('Staking')}</h4>
<div className='stats'>
<CardSummary label={t('current era')}>
{ahOutput.staking.currentEra}
</CardSummary>
<CardSummary label={t('active era')}>
{ahOutput.staking.activeEra.index}
{ahOutput.staking.activeEra.duration && ` (${ahOutput.staking.activeEra.duration})`}
</CardSummary>
<CardSummary label={t('era start session')}>
{ahOutput.staking.erasStartSessionIndex || 'None'}
{ahOutput.rcClient.eraDepth && `, Era-depth: ${ahOutput.rcClient.eraDepth} sessions`}
</CardSummary>
<CardSummary label={t('bonded eras')}>
{ahOutput.staking.bondedEras && ahOutput.staking.bondedEras.length > 0
? (() => {
const firstEra = ahOutput.staking.bondedEras[0];
const lastEra = ahOutput.staking.bondedEras[ahOutput.staking.bondedEras.length - 1];
return `[(${firstEra[0].toString()}@${firstEra[1].toString()}) -> (${lastEra[0].toString()}@${lastEra[1].toString()})]`;
})()
: 'None'}
</CardSummary>
<CardSummary label={t('unpruned eras')}>
{ahOutput.staking.unprunedEras}
</CardSummary>
<CardSummary label={t('forcing')}>
{ahOutput.staking.forcing || 'None'}
</CardSummary>
</div>
</div>
<div className='section'>
<h4>{t('Staking RC Client')}</h4>
<div className='stats'>
<CardSummary label={t('last session report')}>
End={ahOutput.rcClient.lastSessionReportEndIndex}, Start={ahOutput.rcClient.lastSessionIndex}
</CardSummary>
</div>
</div>
<div className='section'>
<h4>{t('Election')}</h4>
<div className='stats'>
<CardSummary label={t('phase')}>
{ahOutput.multiblock.phase}
</CardSummary>
<CardSummary label={t('round')}>
{ahOutput.multiblock.round}
</CardSummary>
<CardSummary label={t('signed submissions')}>
{ahOutput.multiblock.signedSubmissions}
</CardSummary>
<CardSummary label={t('queued score')}>
{ahOutput.multiblock.queuedScore || 'None'}
</CardSummary>
<CardSummary label={t('snapshot range')}>
{ahOutput.multiblock.snapshotRange.length > 0
? (() => {
const uniquePages = Array.from(new Set(ahOutput.multiblock.snapshotRange.map((p) => Number(p.toString())))).sort((a, b) => a - b);
const [minPage, maxPage] = [uniquePages[0], uniquePages[uniquePages.length - 1]];
return `${minPage} → ${maxPage}`;
})()
: 'None'}
</CardSummary>
</div>
</div>
<div className='section'>
<h4>{t('Validators/Nominators')}</h4>
<div className='stats'>
<CardSummary label={t('wanted validators')}>
{ahOutput.staking.validatorCount || 'None'}
</CardSummary>
<CardSummary label={t('validator candidates')}>
{ahOutput.staking.validatorCandidates
? `${ahOutput.staking.validatorCandidates}${ahOutput.staking.maxValidatorsCount ? ` (max: ${ahOutput.staking.maxValidatorsCount})` : ''}`
: 'None'}
</CardSummary>
<CardSummary label={t('nominator candidates')}>
{ahOutput.staking.nominatorCandidates
? `${ahOutput.staking.nominatorCandidates}${ahOutput.staking.maxNominatorsCount ? ` (max: ${ahOutput.staking.maxNominatorsCount})` : ''}`
: 'None'}
</CardSummary>
<CardSummary label={t('min bonds')}>
{ahOutput.staking.minNominatorBond || ahOutput.staking.minValidatorBond || ahOutput.staking.minNominatorActiveStake
? `${ahOutput.staking.minNominatorBond ? `N: ${formatBalance(ahOutput.staking.minNominatorBond, { forceUnit: '-', withSi: true })}` : ''}${ahOutput.staking.minValidatorBond ? ` / V: ${formatBalance(ahOutput.staking.minValidatorBond, { forceUnit: '-', withSi: true })}` : ''}${ahOutput.staking.minNominatorActiveStake ? ` / Active: ${formatBalance(ahOutput.staking.minNominatorActiveStake, { forceUnit: '-', withSi: true })}` : ''}`
: 'None'}
</CardSummary>
</div>
</div>
<div className='section'>
<h4>{t('Bags List')}</h4>
<div className='stats'>
<CardSummary label={t('all nodes')}>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */}
{ahOutput.bagsList ? ahOutput.bagsList.allNodes : 'None'}
</CardSummary>
<CardSummary label={t('lock')}>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */}
{ahOutput.bagsList ? ahOutput.bagsList.lock : 'None'}
</CardSummary>
</div>
</div>
</div>
)}
</StyledInfoBox>
<StyledEventsBox>
<h3>{t('Asset Hub Events')}</h3>
{ahEvents.length === 0 && (
<div className='no-events'>
{t('No relevant events in recent blocks')}
</div>
)}
{ahEvents.map((enhancedEvent, index) => {
const { blockNumber, event, weight } = enhancedEvent;
const eventName = `${event.section}.${event.method}`;
return (
<div
className='assethub__chain'
key={ah.finalizedBlock}
>
<div className='details'>
<div className='session__summary'>
<h4 className='--digits'>
<Expander
isLeft
key={`${event.index.toString()}-${index}`}
summary={
<>
{eventName}
<div className='absolute --digits'>
{weight && <span className='weight'>[{weight}]</span>}
{!isRelayChain
? <Link to={`/explorer/query/${ah.finalizedBlock}`}>#{formatNumber(ah.finalizedBlock)}</Link>
? <Link to={`/explorer/query/${blockNumber}`}>#{formatNumber(blockNumber)}</Link>
: (
<Link
target='_blank'
to={`${window.location.origin}/?rpc=${ahUrl}#/explorer/query/${ah.finalizedBlock}`}
to={`${window.location.origin}/?rpc=${ahUrl}#/explorer/query/${blockNumber}`}
>
#{formatNumber(ah.finalizedBlock)}
#{formatNumber(blockNumber)}
</Link>)
}
</h4>
</div>
<div>
<div className='staking__summary'>
<CardSummary label={t('current era')}>
{ah.staking.currentEra}
</CardSummary>
<CardSummary label={t('Relay Session Index')}>
{ah.staking.erasStartSessionIndex}
</CardSummary>
<CardSummary label={t('Relay Active Era Index')}>
{ah.staking.activeEra.index}
</CardSummary>
</div>
<div className='multiblock__summary'>
<CardSummary label={t('multiblock phase')}>
{ah.multiblock.phase}
</CardSummary>
{ah.multiblock.queuedScore &&
<CardSummary label={t('multiblock queued score')}>
{ah.multiblock.queuedScore}
</CardSummary>}
<CardSummary label={t('signed submissions')}>
{ah.multiblock.signedSubmissions}
</CardSummary>
{!!ah.multiblock.snapshotRange.length &&
<CardSummary label={t('snapshot range')}>
{`${minPage} → ${maxPage}`}
</CardSummary>}
</div>
<div className='rcClient__summary'>
<CardSummary label={t('Last Session Report End Index')}>
{ah.rcClient.lastSessionReportEndIndex}
</CardSummary>
</div>
</div>
</div>
<div className='events__summary'>
<h3>{t('events')}</h3>
{ah.events.map((event) => {
const eventName = `${event.section}.${event.method}`;
return (
<Expander
isLeft
key={event.index.toString()}
summary={eventName}
summaryMeta={event.meta}
>
<EventDisplay
className='details'
eventName={eventName}
value={event}
withExpander
/>
</Expander>
);
})}
{ah.events.length === 0 &&
<div className='warning__tooltip'>
<MarkWarning content={t('No events available')} />
<Icon
icon='info-circle'
tooltip={`${ah.finalizedBlock}-ah-no-events`}
/>
<Tooltip
text={'No relevant events found for multiBlockElection, multiBlockElectionVerifier, multiBlockElectionUnsigned, stakingRcClient or staking in the current block.'}
trigger={`${ah.finalizedBlock}-ah-no-events`}
/>
</div>
}
</div>
</div>
</>
}
summaryMeta={event.meta}
>
<EventDisplay
className='details'
eventName={eventName}
value={event}
withExpander
/>
</Expander>
);
})}
</StyledSection>
</div>);
</StyledEventsBox>
</StyledColumn>
);
}
const StyledSection = styled.section`
margin-block: 1rem;
overflow: auto;
white-space: nowrap;
const StyledColumn = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
`;
.warning {
max-width: fit-content;
margin-left: 0;
const StyledInfoBox = styled.div`
background: var(--bg-table);
padding: 1.5rem;
border-radius: 0.5rem;
h2 {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
font-size: var(--font-size-h2);
font-weight: 500;
}
.ui--Labelled-content {
font-size: var(--font-size-h3);
font-weight: var(--font-weight-normal);
}
.info-content {
display: flex;
flex-direction: column;
gap: 1rem;
.assethub__chain {
display: grid;
grid-template-columns: repeat(2, 1fr);
place-items: start;
background: var(--bg-table);
margin-bottom: 0.35rem;
padding: 0.8rem 1rem;
border-radius: 0.5rem;
.block-info {
font-size: var(--font-size-h3);
font-weight: 600;
}
.details {
display: flex;
align-items: center;
gap: 0.8rem;
.session__summary {
display: flex;
align-items: center;
h4 {
font-weight: 400;
font-size: medium;
}
}
.staking__summary {
display: flex;
justify-content: end;
}
.multiblock__summary, .rcClient__summary {
display: flex;
justify-content: end;
margin-top: 1.5rem;
.section {
h4 {
font-size: var(--font-size-base);
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
margin-bottom: 0.5rem;
letter-spacing: 0.05em;
}
}
.events__summary {
display: grid;
.stats {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-self: center;
h3 {
font-weight: 500;
font-size: var(--font-size-h2);
}
.ui--Labelled {
label {
font-size: medium !important;
text-align: left !important;
}
.warning__tooltip {
display: flex;
align-items: center;
gap: 0.5rem;
.ui--Labelled-content {
font-size: small !important;
text-align: left !important;
}
}
}
`;
const StyledEventsBox = styled.div`
background: var(--bg-table);
padding: 1.5rem;
border-radius: 0.5rem;
max-height: 500px;
overflow-y: auto;
h3 {
margin-bottom: 1rem;
font-size: var(--font-size-h3);
font-weight: 500;
}
.no-events {
color: var(--color-text-secondary);
font-style: italic;
padding: 1rem;
text-align: center;
}
.ui--Expander {
margin-bottom: 0.5rem;
position: relative;
.absolute {
position: absolute;
right: 0.5rem;
top: 0.72rem;
white-space: nowrap;
.weight {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
margin-right: 0.5rem;
}
}
}

View File

@ -2,20 +2,28 @@
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { AccountId32, Event } from '@polkadot/types/interfaces';
import type { AccountId32, Event, Hash } from '@polkadot/types/interfaces';
import type { FrameSupportDispatchPerDispatchClassWeight, PolkadotRuntimeParachainsConfigurationHostConfiguration } from '@polkadot/types/lookup';
import type { IEventData, ITuple } from '@polkadot/types/types';
import type { u32, Vec } from '@polkadot/types-codec';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Dropdown, styled } from '@polkadot/react-components';
import { Button, Dropdown, Input, MarkWarning, styled } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { getApi } from '@polkadot/react-hooks/ctx/StakingAsync';
import { formatBalance } from '@polkadot/util';
import AssetHubSection from './ah.js';
import RelaySection from './relay.js';
const MAX_EVENTS = 25;
// Enhanced event type with metadata
export interface EnhancedEvent {
event: Event;
blockNumber: number;
blockHash: string;
weight?: string;
}
export interface IRcOutput {
finalizedBlock: number,
@ -27,21 +35,42 @@ export interface IRcOutput {
stakingAhClient: {
mode: string
hasNextActiveId?: number,
hasQueuedInClient?: [number, AccountId32[]]
hasQueuedInClient?: [number, AccountId32[]],
validatorPoints: number
},
events: Event[]
staking: {
forceEra?: string,
validatorCount?: number,
electionPhase?: string
},
parachainConfig?: {
maxDownwardMessageSize: number,
maxUpwardMessageSize: number
}
}
export interface IAhOutput {
finalizedBlock: number,
staking: {
currentEra: number,
activeEra: {index: number, start: string},
activeEra: {index: number, start: string, duration?: string},
erasStartSessionIndex?: number,
bondedEras: Vec<ITuple<[u32, u32]>>
bondedEras: Vec<ITuple<[u32, u32]>>,
unprunedEras: string,
validatorCount?: number,
validatorCandidates?: number,
nominatorCandidates?: number,
maxValidatorsCount?: number,
maxNominatorsCount?: number,
minNominatorBond?: string,
minValidatorBond?: string,
minNominatorActiveStake?: string,
forcing?: string
},
rcClient: {
lastSessionReportEndIndex: string
lastSessionReportEndIndex: string,
lastSessionIndex: number,
eraDepth?: number
},
multiblock: {
phase: string,
@ -50,14 +79,45 @@ export interface IAhOutput {
queuedScore: string|null,
signedSubmissions: number
},
events: Event[]
bagsList?: {
allNodes: number,
lock: string
}
}
// Filter functions for relevant events
const filterRcEvents = (e: IEventData): boolean => {
const ahClientEvents = (e: IEventData) =>
e.section === 'stakingAhClient';
const sessionEvents = (e: IEventData) =>
e.section === 'session' || e.section === 'historical';
return ahClientEvents(e) || sessionEvents(e);
};
const filterAhEvents = (e: IEventData): boolean => {
const election = (e: IEventData) =>
e.section === 'multiBlockElection' ||
e.section === 'multiBlockElectionVerifier' ||
e.section === 'multiBlockElectionSigned' ||
e.section === 'multiBlockElectionUnsigned';
const rcClient = (e: IEventData) => e.section === 'stakingRcClient';
const staking = (e: IEventData) =>
e.section === 'staking' &&
(e.method === 'EraPaid' ||
e.method === 'SessionRotated' ||
e.method === 'PagedElectionProceeded');
return election(e) || rcClient(e) || staking(e);
};
const commandCenterHandler = async (
rcApi: ApiPromise,
ahApi: ApiPromise,
setRcOutout: React.Dispatch<React.SetStateAction<IRcOutput[]>>,
setAhOutout: React.Dispatch<React.SetStateAction<IAhOutput[]>>
setRcOutput: React.Dispatch<React.SetStateAction<IRcOutput | undefined>>,
setAhOutput: React.Dispatch<React.SetStateAction<IAhOutput | undefined>>,
setRcEvents: React.Dispatch<React.SetStateAction<EnhancedEvent[]>>,
setAhEvents: React.Dispatch<React.SetStateAction<EnhancedEvent[]>>
): Promise<void> => {
await rcApi.rpc.chain.subscribeFinalizedHeads(async (header) => {
// --- RC:
@ -77,64 +137,110 @@ const commandCenterHandler = async (
// Operating mode of the client.
const mode = await rcApi.query.stakingAhClient.mode();
// Staking info from RC if available
const forceEra = rcApi.query.staking?.forceEra ? await rcApi.query.staking.forceEra() : undefined;
const validatorCount = rcApi.query.staking?.validatorCount ? await rcApi.query.staking.validatorCount() : undefined;
const electionPhase = rcApi.query.electionProviderMultiPhase?.currentPhase ? await rcApi.query.electionProviderMultiPhase.currentPhase() : undefined;
// Validator points
const validatorPointsKeys = await rcApi.query.stakingAhClient.validatorPoints.keys();
// Parachain config
let parachainConfig;
try {
const configuration = await rcApi.query.configuration.activeConfig() as PolkadotRuntimeParachainsConfigurationHostConfiguration;
parachainConfig = {
maxDownwardMessageSize: configuration.maxDownwardMessageSize?.toNumber() || 0,
maxUpwardMessageSize: configuration.maxUpwardMessageSize?.toNumber() || 0
};
} catch {
parachainConfig = undefined;
}
// Events that we are interested in from RC:
const eventsOfInterest = (await (await rcApi.at(header.hash.toHex())).query.system.events())
.map((e) => e.event)
.filter((e) => {
const ahClientEvents = (e: IEventData) =>
e.section === 'stakingAhClient';
const sessionEvents = (e: IEventData) =>
e.section === 'session' || e.section === 'historical';
.filter((e) => filterRcEvents(e.data));
return ahClientEvents(e.data) || sessionEvents(e.data);
});
// Wrap events with metadata
const enhancedEvents: EnhancedEvent[] = eventsOfInterest.map((event) => ({
blockHash: header.hash.toString(),
blockNumber: header.number.toNumber(),
event
}));
setRcOutout((prev) => {
const newItem = {
events: eventsOfInterest,
finalizedBlock: header.number.toNumber(),
session: {
hasQueuedInSession: hasQueuedInSession.isTrue,
historicalRange: historicalRange.isSome
? [historicalRange.unwrap()[0].toNumber(), historicalRange.unwrap()[1].toNumber()] as [number, number]
: undefined,
index: index.toNumber()
},
stakingAhClient: {
hasNextActiveId: hasNextActiveId.isEmpty
? undefined
: rcApi.createType('Option<u32>', hasNextActiveId).unwrap().toNumber(),
hasQueuedInClient: (() => {
const parsed = rcApi.createType('Option<(u32,Vec<AccountId32>)>', hasQueuedInClient);
setRcOutput({
finalizedBlock: header.number.toNumber(),
parachainConfig,
session: {
hasQueuedInSession: hasQueuedInSession.isTrue,
historicalRange: historicalRange.isSome
? [historicalRange.unwrap()[0].toNumber(), historicalRange.unwrap()[1].toNumber()] as [number, number]
: undefined,
index: index.toNumber()
},
staking: {
electionPhase: electionPhase?.toString(),
forceEra: forceEra?.toString(),
validatorCount: validatorCount?.toNumber()
},
stakingAhClient: {
hasNextActiveId: hasNextActiveId.isEmpty
? undefined
: rcApi.createType('Option<u32>', hasNextActiveId).unwrap().toNumber(),
hasQueuedInClient: (() => {
const parsed = rcApi.createType('Option<(u32,Vec<AccountId32>)>', hasQueuedInClient);
return parsed.isNone ? undefined : [parsed.unwrap()[0].toNumber(), parsed.unwrap()[1]] as [number, AccountId32[]];
})(),
mode: mode.toString()
}
};
return [newItem, ...prev]
.reduce((acc, curr) => {
if (!acc.find((item) => item.finalizedBlock === curr.finalizedBlock)) {
acc.push(curr);
}
return acc;
}, [] as typeof prev)
.slice(0, MAX_EVENTS);
return parsed.isNone ? undefined : [parsed.unwrap()[0].toNumber(), parsed.unwrap()[1]] as [number, AccountId32[]];
})(),
mode: mode.toString(),
validatorPoints: validatorPointsKeys.length
}
});
if (enhancedEvents.length > 0) {
setRcEvents(enhancedEvents);
}
});
await ahApi.rpc.chain.subscribeFinalizedHeads(async (header) => {
// the current planned era
const currentEra = await rcApi.query.session.currentIndex();
const currentEra = (await ahApi.query.staking.currentEra()).unwrap().toNumber();
// the active era
const activeEra = (await ahApi.query.staking.activeEra()).unwrap();
const activeEraDuration = activeEra.start.isSome
? (() => {
const startTime = activeEra.start.unwrap().toNumber();
const durationMs = Date.now() - startTime;
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
})()
: undefined;
// the starting index of the active era
const bondedEras = await ahApi.query.staking.bondedEras();
const activeEraStartSessionIndex = bondedEras.find(([e]) => e.eq(activeEra.index))?.[1];
// the starting index of the active era
// const erasStartSessionIndex = await ahApi.query.staking.erasStartSessionIndex(activeEra.unwrap().index);
// Unpruned eras
const unprunedEras = ahApi.query.staking.eraPruningState
? (await ahApi.query.staking.eraPruningState.entries()).map(([k]) => k.args[0]).sort().join(', ')
: 'unimplemented!';
// Additional staking info
const validatorCount = ahApi.query.staking.validatorCount ? await ahApi.query.staking.validatorCount() : undefined;
const validatorCandidates = ahApi.query.staking.counterForValidators ? await ahApi.query.staking.counterForValidators() : undefined;
const nominatorCandidates = ahApi.query.staking.counterForNominators ? await ahApi.query.staking.counterForNominators() : undefined;
const maxValidatorsCount = ahApi.query.staking.maxValidatorsCount ? await ahApi.query.staking.maxValidatorsCount() : undefined;
const maxNominatorsCount = ahApi.query.staking.maxNominatorsCount ? await ahApi.query.staking.maxNominatorsCount() : undefined;
const minNominatorBond = ahApi.query.staking.minNominatorBond ? await ahApi.query.staking.minNominatorBond() : undefined;
const minValidatorBond = ahApi.query.staking.minValidatorBond ? await ahApi.query.staking.minValidatorBond() : undefined;
const minNominatorActiveStake = ahApi.query.staking.minimumActiveStake ? await ahApi.query.staking.minimumActiveStake() : undefined;
const forcing = ahApi.query.staking.forceEra ? await ahApi.query.staking.forceEra() : undefined;
// the basic state of the election provider
const phase = await ahApi.query.multiBlockElection.currentPhase();
@ -150,57 +256,130 @@ const commandCenterHandler = async (
await ahApi.query.multiBlockElectionSigned.sortedScores(round);
// The client
const lastSessionReportEndIndex = await ahApi.query.stakingRcClient.lastSessionReportEndingIndex();
const lastSessionReportEndIndexRaw = await ahApi.query.stakingRcClient.lastSessionReportEndingIndex();
const lastSessionReportEndIndex = ahApi.createType('Option<BlockNumber>', lastSessionReportEndIndexRaw);
const lastSessionIndex = lastSessionReportEndIndex.isSome ? lastSessionReportEndIndex.unwrap().toNumber() + 1 : 0;
const eraDepth = activeEraStartSessionIndex ? lastSessionIndex - activeEraStartSessionIndex.toNumber() : undefined;
// Events that we are interested in from RC:
// Bags list
let bagsList;
try {
const allNodes = await ahApi.query.voterList.counterForListNodes();
const lock = await ahApi.query.voterList.lock();
bagsList = {
allNodes: allNodes.toNumber(),
lock: lock.toU8a().toString()
};
} catch {
bagsList = undefined;
}
// Events that we are interested in from AH:
const eventsOfInterest = (await (await ahApi.at(header.hash.toHex())).query.system.events())
.map((e) => e.event)
.filter((e) => {
const election = (e: IEventData) => e.section === 'multiBlockElection' || e.section === 'multiBlockElectionVerifier' || e.section === 'multiBlockElectionSigned' || e.section === 'multiBlockElectionUnsigned';
const rcClient = (e: IEventData) => e.section === 'stakingRcClient';
const staking = (e: IEventData) => e.section === 'staking' && (e.method === 'EraPaid' || e.method === 'SessionRotated' || e.method === 'PagedElectionProceeded');
.filter((e) => filterAhEvents(e.data));
return election(e.data) || rcClient(e.data) || staking(e.data);
});
// Get block weight
const weight = await (await ahApi.at(header.hash)).query.system.blockWeight();
setAhOutout((prev) => {
const parsedQueuedScore = ahApi.createType('Option<SpNposElectionsElectionScore>', queuedScore);
const formatWeight = (w: FrameSupportDispatchPerDispatchClassWeight) => {
const normalRef = w.normal?.refTime?.toBigInt() || 0n;
const operationalRef = w.operational?.refTime?.toBigInt() || 0n;
const mandatoryRef = w.mandatory?.refTime?.toBigInt() || 0n;
const totalRef = normalRef + operationalRef + mandatoryRef;
const newItem = {
events: eventsOfInterest,
finalizedBlock: header.number.toNumber(),
multiblock: {
phase: phase.toString(),
queuedScore: parsedQueuedScore.isSome ? parsedQueuedScore.unwrap().toString() : null,
round: ahApi.createType('u32', round).toNumber(),
signedSubmissions: ahApi.createType(
'Vec<(AccountId32,SpNposElectionsElectionScore)>',
signedSubmissions
).length,
snapshotRange: snapshotRange.map((a) => a.toString())
},
rcClient: { lastSessionReportEndIndex: lastSessionReportEndIndex.toString() },
staking: {
activeEra: {
index: activeEra.index.toNumber(),
start: activeEra.toString()
},
bondedEras,
currentEra: currentEra.toNumber(),
erasStartSessionIndex: activeEraStartSessionIndex?.toNumber()
const normalProof = w.normal?.proofSize?.toBigInt() || 0n;
const operationalProof = w.operational?.proofSize?.toBigInt() || 0n;
const mandatoryProof = w.mandatory?.proofSize?.toBigInt() || 0n;
const totalProof = normalProof + operationalProof + mandatoryProof;
return `${(Number(totalRef) / 1_000_000_000_000).toFixed(2)}s / ${(Number(totalProof) / 1024).toFixed(0)}KB`;
};
// Wrap events with metadata including weight
const enhancedEvents: EnhancedEvent[] = eventsOfInterest.map((event) => ({
blockHash: header.hash.toString(),
blockNumber: header.number.toNumber(),
event,
weight: formatWeight(weight)
}));
const parsedQueuedScore = ahApi.createType('Option<SpNposElectionsElectionScore>', queuedScore);
const formattedQueuedScore = parsedQueuedScore.isSome
? (() => {
const score = parsedQueuedScore.unwrap();
const minimalStake = score.minimalStake?.toString() || '0';
const formattedMinStake = formatBalance(minimalStake, { forceUnit: '-', withSi: true });
return `minStake: ${formattedMinStake}, ...`;
})()
: null;
// Format phase to be more readable (e.g., "Unsigned(1)" instead of {"unsigned":1})
const formattedPhase = (() => {
const phaseStr = phase.toString();
try {
const phaseJson = JSON.parse(phaseStr) as Record<string, string>;
if (typeof phaseJson === 'object' && phaseJson !== null) {
const key = Object.keys(phaseJson)[0];
const value = phaseJson[key];
return `${key.charAt(0).toUpperCase() + key.slice(1)}(${value})`;
}
};
} catch {
// If not JSON, return as is
}
return [newItem, ...prev]
.reduce((acc, curr) => {
if (!acc.find((item) => item.finalizedBlock === curr.finalizedBlock)) {
acc.push(curr);
}
return phaseStr;
})();
return acc;
}, [] as typeof prev)
.slice(0, MAX_EVENTS);
setAhOutput({
bagsList,
finalizedBlock: header.number.toNumber(),
multiblock: {
phase: formattedPhase,
queuedScore: formattedQueuedScore,
round: ahApi.createType('u32', round).toNumber(),
signedSubmissions: ahApi.createType(
'Vec<(AccountId32,SpNposElectionsElectionScore)>',
signedSubmissions
).length,
snapshotRange: snapshotRange.map((a) => a.toString())
},
rcClient: {
eraDepth,
lastSessionIndex,
lastSessionReportEndIndex: lastSessionReportEndIndex.isSome ? lastSessionReportEndIndex.unwrap().toString() : '0'
},
staking: {
activeEra: {
duration: activeEraDuration,
index: activeEra.index.toNumber(),
start: activeEra.toString()
},
bondedEras,
currentEra,
erasStartSessionIndex: activeEraStartSessionIndex?.toNumber(),
forcing: forcing?.toString(),
maxNominatorsCount: maxNominatorsCount?.isSome ? maxNominatorsCount.unwrap().toNumber() : undefined,
maxValidatorsCount: maxValidatorsCount?.isSome ? maxValidatorsCount.unwrap().toNumber() : undefined,
minNominatorActiveStake: minNominatorActiveStake?.toString(),
minNominatorBond: minNominatorBond?.toString(),
minValidatorBond: minValidatorBond?.toString(),
nominatorCandidates: nominatorCandidates?.toNumber(),
unprunedEras,
validatorCandidates: validatorCandidates?.toNumber(),
validatorCount: validatorCount?.toNumber()
}
});
if (enhancedEvents.length > 0) {
setAhEvents(enhancedEvents);
}
});
};
@ -215,8 +394,10 @@ interface Props {
function CommandCenter ({ ahApi: initialAhApi, ahEndPoints, isRelayChain, rcApi: initialRcApi, rcEndPoints }: Props) {
const { api, apiUrl } = useApi();
const [rcOutput, setRcOutput] = useState<IRcOutput[]>([]);
const [ahOutput, setAhOutput] = useState<IAhOutput[]>([]);
const [rcOutput, setRcOutput] = useState<IRcOutput | undefined>(undefined);
const [ahOutput, setAhOutput] = useState<IAhOutput | undefined>(undefined);
const [rcEvents, setRcEvents] = useState<EnhancedEvent[]>([]);
const [ahEvents, setAhEvents] = useState<EnhancedEvent[]>([]);
const [rcUrl, setRcUrl] = useState<string|undefined>(undefined);
const [ahUrl, setAhUrl] = useState<string|undefined>(undefined);
@ -224,23 +405,236 @@ function CommandCenter ({ ahApi: initialAhApi, ahEndPoints, isRelayChain, rcApi:
const [ahApi, setAhApi] = useState<ApiPromise|undefined>(initialAhApi);
const [rcApi, setRcApi] = useState<ApiPromise|undefined>(initialRcApi);
const rcEndPointOptions = useRef(rcEndPoints.map((e) => ({ text: e, value: e })));
const ahEndPointOptions = useRef(ahEndPoints.map((e) => ({ text: e, value: e })));
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [rcLowestBlock, setRcLowestBlock] = useState<number | null>(null);
const [ahLowestBlock, setAhLowestBlock] = useState<number | null>(null);
const [loadingProgress, setLoadingProgress] = useState<{ rc: number; ah: number } | null>(null);
const [customRcUrl, setCustomRcUrl] = useState<string>('ws://127.0.0.1:9944');
const [customAhUrl, setCustomAhUrl] = useState<string>('ws://127.0.0.1:9944');
const [showCustomRcInput, setShowCustomRcInput] = useState(false);
const [showCustomAhInput, setShowCustomAhInput] = useState(false);
const CUSTOM_OPTION = 'custom';
const rcEndPointOptions = useRef([...rcEndPoints.map((e) => ({ text: e, value: e })), { text: 'Custom endpoint...', value: CUSTOM_OPTION }]);
const ahEndPointOptions = useRef([...ahEndPoints.map((e) => ({ text: e, value: e })), { text: 'Custom endpoint...', value: CUSTOM_OPTION }]);
// Load historical events from RC
const loadRcHistoricalEvents = useCallback(async (blocksToLoad: number) => {
if (!rcApi) {
return;
}
let currentBlockHash: Hash;
if (rcLowestBlock === null) {
const finalizedHead = await rcApi.rpc.chain.getFinalizedHead();
currentBlockHash = finalizedHead;
} else if (rcLowestBlock === 0) {
return;
} else {
currentBlockHash = await rcApi.rpc.chain.getBlockHash(rcLowestBlock);
}
let blocksProcessed = 0;
while (blocksProcessed < blocksToLoad) {
try {
const block = await rcApi.rpc.chain.getBlock(currentBlockHash);
const blockNumber = block.block.header.number.toNumber();
if (blockNumber === 0) {
setRcLowestBlock(0);
break;
}
const events = await (await rcApi.at(currentBlockHash)).query.system.events();
const relevantEvents = events
.map((e) => e.event)
.filter((e) => filterRcEvents(e.data));
if (relevantEvents.length > 0) {
const enhancedEvents: EnhancedEvent[] = relevantEvents.map((event) => ({
blockHash: currentBlockHash.toString(),
blockNumber,
event
}));
setRcEvents((prev) => {
// Insert new events and sort by block number (highest first)
const combined = [...prev, ...enhancedEvents];
return combined.sort((a, b) => b.blockNumber - a.blockNumber);
});
}
setRcLowestBlock(blockNumber - 1);
currentBlockHash = block.block.header.parentHash;
blocksProcessed++;
// Update progress
const remaining = blocksToLoad - blocksProcessed;
setLoadingProgress((prev) => ({ ah: prev?.ah ?? 0, rc: remaining }));
} catch (error) {
console.error(`RC: Error at block ${currentBlockHash.toHex()}:`, error);
break;
}
}
}, [rcApi, rcLowestBlock]);
// Load historical events from AH
const loadAhHistoricalEvents = useCallback(async (blocksToLoad: number) => {
if (!ahApi) {
return;
}
let currentBlockHash: Hash;
if (ahLowestBlock === null) {
const finalizedHead = await ahApi.rpc.chain.getFinalizedHead();
currentBlockHash = finalizedHead;
} else if (ahLowestBlock === 0) {
return;
} else {
currentBlockHash = await ahApi.rpc.chain.getBlockHash(ahLowestBlock);
}
let blocksProcessed = 0;
while (blocksProcessed < blocksToLoad) {
try {
const block = await ahApi.rpc.chain.getBlock(currentBlockHash);
const blockNumber = block.block.header.number.toNumber();
if (blockNumber === 0) {
setAhLowestBlock(0);
break;
}
const events = await (await ahApi.at(currentBlockHash)).query.system.events();
const relevantEvents = events
.map((e) => e.event)
.filter((e) => filterAhEvents(e.data));
if (relevantEvents.length > 0) {
// Get block weight
const weight = await (await ahApi.at(currentBlockHash)).query.system.blockWeight();
const formatWeight = (w: FrameSupportDispatchPerDispatchClassWeight) => {
const normalRef = w.normal?.refTime?.toBigInt() || 0n;
const operationalRef = w.operational?.refTime?.toBigInt() || 0n;
const mandatoryRef = w.mandatory?.refTime?.toBigInt() || 0n;
const totalRef = normalRef + operationalRef + mandatoryRef;
const normalProof = w.normal?.proofSize?.toBigInt() || 0n;
const operationalProof = w.operational?.proofSize?.toBigInt() || 0n;
const mandatoryProof = w.mandatory?.proofSize?.toBigInt() || 0n;
const totalProof = normalProof + operationalProof + mandatoryProof;
return `${(Number(totalRef) / 1_000_000_000_000).toFixed(2)}s / ${(Number(totalProof) / 1024).toFixed(0)}KB`;
};
const enhancedEvents: EnhancedEvent[] = relevantEvents.map((event) => ({
blockHash: currentBlockHash.toString(),
blockNumber,
event,
weight: formatWeight(weight)
}));
setAhEvents((prev) => {
// Insert new events and sort by block number (highest first)
const combined = [...prev, ...enhancedEvents];
return combined.sort((a, b) => b.blockNumber - a.blockNumber);
});
}
setAhLowestBlock(blockNumber - 1);
currentBlockHash = block.block.header.parentHash;
blocksProcessed++;
// Update progress
const remaining = blocksToLoad - blocksProcessed;
setLoadingProgress((prev) => ({ ah: remaining, rc: prev?.rc ?? 0 }));
} catch (error) {
console.error(`AH: Error at block ${currentBlockHash.toHex()}:`, error);
break;
}
}
}, [ahApi, ahLowestBlock]);
// Load historical events (600 blocks at a time)
const _loadHistoricalEvents = useCallback(async () => {
if (isLoadingHistory || (!rcApi && !ahApi)) {
return;
}
setIsLoadingHistory(true);
setLoadingProgress({ ah: 600, rc: 600 });
try {
await Promise.all([
loadRcHistoricalEvents(600),
loadAhHistoricalEvents(600)
]);
console.log('Historical events loaded');
} catch (error) {
console.error('Error loading historical events:', error);
} finally {
setIsLoadingHistory(false);
setLoadingProgress(null);
}
}, [isLoadingHistory, rcApi, ahApi, loadRcHistoricalEvents, loadAhHistoricalEvents]);
const _onSelectAhUrl = useCallback((newAhUrl: string) => {
if (newAhUrl !== ahUrl) {
if (newAhUrl === CUSTOM_OPTION) {
setShowCustomAhInput(true);
} else if (newAhUrl !== ahUrl) {
ahApi?.disconnect().catch(console.log);
setShowCustomAhInput(false);
setAhUrl(newAhUrl);
}
}, [ahApi, ahUrl]);
const _onSelectRcUrl = useCallback((newRcUrl: string) => {
if (newRcUrl !== rcUrl) {
if (newRcUrl === CUSTOM_OPTION) {
setShowCustomRcInput(true);
} else if (newRcUrl !== rcUrl) {
rcApi?.disconnect().catch(console.log);
setShowCustomRcInput(false);
setRcUrl(newRcUrl);
}
}, [rcApi, rcUrl]);
const _onCustomRcUrlChange = useCallback((value: string) => {
setCustomRcUrl(value);
}, []);
const _onCustomAhUrlChange = useCallback((value: string) => {
setCustomAhUrl(value);
}, []);
const _onApplyCustomRcUrl = useCallback(() => {
if (customRcUrl?.trim()) {
rcApi?.disconnect().catch(console.log);
setRcUrl(customRcUrl.trim());
setShowCustomRcInput(false);
}
}, [customRcUrl, rcApi]);
const _onApplyCustomAhUrl = useCallback(() => {
if (customAhUrl?.trim()) {
ahApi?.disconnect().catch(console.log);
setAhUrl(customAhUrl.trim());
setShowCustomAhInput(false);
}
}, [customAhUrl, ahApi]);
useEffect(() => {
if (isRelayChain) {
setRcUrl(apiUrl);
@ -258,8 +652,10 @@ function CommandCenter ({ ahApi: initialAhApi, ahEndPoints, isRelayChain, rcApi:
useEffect(() => {
setRcApi(undefined);
setAhApi(undefined);
setRcOutput([]);
setAhOutput([]);
setRcOutput(undefined);
setAhOutput(undefined);
setRcEvents([]);
setAhEvents([]);
if (isRelayChain) {
setRcApi(api);
@ -277,43 +673,166 @@ function CommandCenter ({ ahApi: initialAhApi, ahEndPoints, isRelayChain, rcApi:
}, [ahUrl, api, isRelayChain, rcUrl]);
useEffect(() => {
ahApi && rcApi && commandCenterHandler(rcApi, ahApi, setRcOutput, setAhOutput).catch(console.log);
ahApi && rcApi && commandCenterHandler(rcApi, ahApi, setRcOutput, setAhOutput, setRcEvents, setAhEvents).catch(console.log);
}, [ahApi, rcApi]);
// Auto-load 600 blocks on page load
useEffect(() => {
if (ahApi && rcApi && rcLowestBlock === null && ahLowestBlock === null) {
_loadHistoricalEvents().catch(console.error);
}
}, [ahApi, rcApi, rcLowestBlock, ahLowestBlock, _loadHistoricalEvents]);
return (
<StyledDiv>
<StyledWarningBanner>
<MarkWarning content='This page is for developer debugging only.' />
</StyledWarningBanner>
<StyledButtonContainer>
<Button
icon='history'
isDisabled={isLoadingHistory || (!rcApi && !ahApi)}
label='Load 600 Blocks History'
onClick={_loadHistoricalEvents}
/>
{loadingProgress && (
<div className='history-info progress'>
Loading historical events... RC: {loadingProgress.rc} blocks left | AH: {loadingProgress.ah} blocks left
</div>
)}
{!loadingProgress && (rcLowestBlock !== null || ahLowestBlock !== null) && (
<div className='history-info'>
{rcLowestBlock !== null && `RC: Indexed to Block #${rcLowestBlock}`}
{rcLowestBlock !== null && ahLowestBlock !== null && ' | '}
{ahLowestBlock !== null && `AH: Indexed to Block #${ahLowestBlock}`}
</div>
)}
</StyledButtonContainer>
<RelaySection
isRelayChain={!!isRelayChain}
rcApi={rcApi}
rcEvents={rcEvents}
rcOutput={rcOutput}
rcUrl={rcUrl || ''}
>
<Dropdown
defaultValue={rcUrl}
isButton
isDisabled={!!isRelayChain}
onChange={_onSelectRcUrl}
options={rcEndPointOptions.current}
/>
<StyledEndpointControls>
<Dropdown
defaultValue={rcUrl}
isButton
isDisabled={!!isRelayChain}
onChange={_onSelectRcUrl}
options={rcEndPointOptions.current}
/>
{showCustomRcInput && !isRelayChain && (
<StyledCustomInput>
<Input
autoFocus
label='Custom RC Endpoint'
onChange={_onCustomRcUrlChange}
placeholder='wss://your-relay-chain-endpoint.com'
value={customRcUrl}
/>
<Button
label='Connect'
onClick={_onApplyCustomRcUrl}
/>
</StyledCustomInput>
)}
</StyledEndpointControls>
</RelaySection>
<AssetHubSection
ahApi={ahApi}
ahEvents={ahEvents}
ahOutput={ahOutput}
ahUrl={ahUrl || ''}
isRelayChain={!!isRelayChain}
>
<Dropdown
defaultValue={ahUrl}
isButton
isDisabled={!isRelayChain}
onChange={_onSelectAhUrl}
options={ahEndPointOptions.current}
/>
<StyledEndpointControls>
<Dropdown
defaultValue={ahUrl}
isButton
isDisabled={!isRelayChain}
onChange={_onSelectAhUrl}
options={ahEndPointOptions.current}
/>
{showCustomAhInput && isRelayChain && (
<StyledCustomInput>
<Input
autoFocus
label='Custom AH Endpoint'
onChange={_onCustomAhUrlChange}
placeholder='wss://your-asset-hub-endpoint.com'
value={customAhUrl}
/>
<Button
label='Connect'
onClick={_onApplyCustomAhUrl}
/>
</StyledCustomInput>
)}
</StyledEndpointControls>
</AssetHubSection>
</StyledDiv>
);
}
const StyledEndpointControls = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
`;
const StyledCustomInput = styled.div`
display: flex;
gap: 0.5rem;
align-items: flex-end;
> div {
flex: 1;
}
button {
margin-bottom: 0.25rem;
}
`;
const StyledWarningBanner = styled.div`
grid-column: 1 / -1;
padding: 1rem 1.5rem;
background: var(--bg-table);
border-radius: 0.5rem;
border: 2px solid var(--color-warning);
.ui--MarkWarning {
margin: 0;
font-size: var(--font-size-h3);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
`;
const StyledButtonContainer = styled.div`
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-table);
border-radius: 0.5rem;
.history-info {
font-size: var(--font-size-small);
color: var(--color-text-secondary);
&.progress {
color: var(--color-text);
font-weight: 500;
}
}
`;
const StyledDiv = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -322,7 +841,7 @@ const StyledDiv = styled.div`
.dropdown {
min-width: max-content !important;
}
@media screen and (max-width: 1200px){
grid-template-columns: repeat(1, 1fr);
}

View File

@ -3,12 +3,12 @@
import type { ReactNode } from 'react';
import type { ApiPromise } from '@polkadot/api';
import type { IRcOutput } from './index.js';
import type { EnhancedEvent, IRcOutput } from './index.js';
import React from 'react';
import { Link } from 'react-router-dom';
import { CardSummary, Expander, Icon, MarkWarning, Spinner, styled, Tooltip } from '@polkadot/react-components';
import { CardSummary, Expander, Spinner, styled } from '@polkadot/react-components';
import { Event as EventDisplay } from '@polkadot/react-params';
import { formatNumber } from '@polkadot/util';
@ -17,170 +17,241 @@ import { useTranslation } from '../translate.js';
interface Props {
children: ReactNode;
rcApi?: ApiPromise;
rcOutput: IRcOutput[];
rcOutput?: IRcOutput;
rcEvents: EnhancedEvent[];
rcUrl: string;
isRelayChain: boolean
}
function RelaySection ({ children, isRelayChain, rcApi, rcOutput, rcUrl }: Props) {
function RelaySection ({ children, isRelayChain, rcApi, rcEvents, rcOutput, rcUrl }: Props) {
const { t } = useTranslation();
return (
<div>
<h1
style={{
alignItems: 'center',
display: 'flex',
textTransform: 'capitalize'
}}
>
{t('Relay chain')}
{children}
</h1>
{!rcApi && <Spinner label='Connecting to Relay chain' />}
<StyledSection>
{rcOutput.map((rc) => {
return (
<div
className='relay__chain'
key={rc.finalizedBlock}
>
<div className='details'>
<div className='session__summary'>
<h4 className='--digits'>
{isRelayChain
? <Link to={`/explorer/query/${rc.finalizedBlock}`}>#{formatNumber(rc.finalizedBlock)}</Link>
: (
<Link
target='_blank'
to={`${window.location.origin}/?rpc=${rcUrl}#/explorer/query/${rc.finalizedBlock}`}
>
#{formatNumber(rc.finalizedBlock)}
</Link>)
}
</h4>
<CardSummary label={t('session')}>
#{formatNumber(rc.session.index)}
<StyledColumn>
<StyledInfoBox>
<h2>
{t('Relay Chain')}
{children}
</h2>
{!rcApi && <Spinner label={t('Connecting to Relay chain')} />}
{rcOutput && (
<div className='info-content'>
<div className='block-info'>
{isRelayChain
? <Link to={`/explorer/query/${rcOutput.finalizedBlock}`}>#{formatNumber(rcOutput.finalizedBlock)}</Link>
: (
<Link
target='_blank'
to={`${window.location.origin}/?rpc=${rcUrl}#/explorer/query/${rcOutput.finalizedBlock}`}
>
#{formatNumber(rcOutput.finalizedBlock)}
</Link>)
}
</div>
<div className='section'>
<h4>{t('Session Info')}</h4>
<div className='stats'>
<CardSummary label={t('session')}>
#{formatNumber(rcOutput.session.index)}
</CardSummary>
<CardSummary label={t('queued')}>
{rcOutput.session.hasQueuedInSession ? 'Yes' : 'No'}
</CardSummary>
{rcOutput.session.historicalRange &&
<CardSummary label={t('historical range')}>
{`#${formatNumber(rcOutput.session.historicalRange[0])} → #${formatNumber(rcOutput.session.historicalRange[1])}`}
</CardSummary>
{rc.session.historicalRange &&
<CardSummary label={t('historical range')}>
{`${rc.session.historicalRange?.[0]} → ${rc.session.historicalRange?.[1]}`}
</CardSummary>
}
</div>
<div className='stakingAhClient__summary'>
{rc.stakingAhClient.hasQueuedInClient &&
<div className='stakingAhClient__hasQueuedInClient'>
<MarkWarning content={t('There is a validator set queued in ah-client.')} />
<CardSummary label={t('id')}>
{rc.stakingAhClient.hasQueuedInClient[0]}
</CardSummary>
<CardSummary label={t('number of validators')}>
{rc.stakingAhClient.hasQueuedInClient[1].length}
</CardSummary>
</div>}
</div>
</div>
<div className='events__summary'>
<h3>{t('events')}</h3>
{rc.events.map((event) => {
const eventName = `${event.section}.${event.method}`;
return (
<Expander
isLeft
key={event.index.toString()}
summary={eventName}
summaryMeta={event.meta}
>
<EventDisplay
className='details'
eventName={eventName}
value={event}
withExpander
/>
</Expander>
);
})}
{rc.events.length === 0 &&
<div className='warning__tooltip'>
<MarkWarning content={t('No events available')} />
<Icon
icon='info-circle'
tooltip={`${rc.finalizedBlock}-rc-no-events`}
/>
<Tooltip
text={'No relevant events found for stakingAhClient, session, or historical in the current block.'}
trigger={`${rc.finalizedBlock}-rc-no-events`}
/>
</div>
}
</div>
</div>
<div className='section'>
<h4>{t('Staking AH Client')}</h4>
<div className='stats'>
<CardSummary label={t('mode')}>
{rcOutput.stakingAhClient.mode}
</CardSummary>
<CardSummary label={t('next active id')}>
{rcOutput.stakingAhClient.hasNextActiveId !== undefined ? rcOutput.stakingAhClient.hasNextActiveId : 'None'}
</CardSummary>
<CardSummary label={t('queued validator points')}>
{rcOutput.stakingAhClient.validatorPoints}
</CardSummary>
</div>
<div className='stats'>
<CardSummary label={t('queued validator set id')}>
{rcOutput.stakingAhClient.hasQueuedInClient ? rcOutput.stakingAhClient.hasQueuedInClient[0] : 'None'}
</CardSummary>
<CardSummary label={t('queued validators')}>
{rcOutput.stakingAhClient.hasQueuedInClient ? rcOutput.stakingAhClient.hasQueuedInClient[1].length : 'None'}
</CardSummary>
</div>
</div>
<div className='section'>
<h4>{t('Staking/Elections')}</h4>
<div className='stats'>
<CardSummary label={t('force era')}>
{rcOutput.staking.forceEra || 'None'}
</CardSummary>
<CardSummary label={t('election phase')}>
{rcOutput.staking.electionPhase || 'None'}
</CardSummary>
<CardSummary label={t('validator count')}>
{rcOutput.staking.validatorCount || 'None'}
</CardSummary>
</div>
</div>
<div className='section'>
<h4>{t('Parachain Config')}</h4>
<div className='stats'>
<CardSummary label={t('max downward msg size')}>
{rcOutput.parachainConfig ? formatNumber(rcOutput.parachainConfig.maxDownwardMessageSize) : 'None'}
</CardSummary>
<CardSummary label={t('max upward msg size')}>
{rcOutput.parachainConfig ? formatNumber(rcOutput.parachainConfig.maxUpwardMessageSize) : 'None'}
</CardSummary>
</div>
</div>
</div>
)}
</StyledInfoBox>
<StyledEventsBox>
<h3>{t('Relay Chain Events')}</h3>
{rcEvents.length === 0 && (
<div className='no-events'>
{t('No relevant events in recent blocks')}
</div>
)}
{rcEvents.map((enhancedEvent, index) => {
const { blockNumber, event } = enhancedEvent;
const eventName = `${event.section}.${event.method}`;
return (
<Expander
isLeft
key={`${event.index.toString()}-${index}`}
summary={
<>
{eventName}
<div className='absolute --digits'>
{isRelayChain
? <Link to={`/explorer/query/${blockNumber}`}>#{formatNumber(blockNumber)}</Link>
: (
<Link
target='_blank'
to={`${window.location.origin}/?rpc=${rcUrl}#/explorer/query/${blockNumber}`}
>
#{formatNumber(blockNumber)}
</Link>)
}
</div>
</>
}
summaryMeta={event.meta}
>
<EventDisplay
className='details'
eventName={eventName}
value={event}
withExpander
/>
</Expander>
);
})}
</StyledSection>
</div>);
</StyledEventsBox>
</StyledColumn>
);
}
const StyledSection = styled.section`
margin-block: 1rem;
overflow: auto;
const StyledColumn = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
`;
.warning {
max-width: fit-content;
margin-left: 0;
}
const StyledInfoBox = styled.div`
background: var(--bg-table);
padding: 1.5rem;
border-radius: 0.5rem;
.ui--Labelled-content {
font-size: var(--font-size-h3);
font-weight: var(--font-weight-normal);
}
.warning__tooltip {
h2 {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 1rem;
margin-bottom: 1rem;
font-size: var(--font-size-h2);
font-weight: 500;
}
.relay__chain {
display: grid;
grid-template-columns: repeat(2, 1fr);
background: var(--bg-table);
margin-bottom: 0.35rem;
padding: 0.8rem 1rem;
border-radius: 0.5rem;
.info-content {
display: flex;
flex-direction: column;
gap: 1rem;
.details {
display: flex;
flex-direction: column;
gap: 0.8rem;
.block-info {
font-size: var(--font-size-h3);
font-weight: 600;
}
.session__summary {
display: flex;
align-items: center;
h4 {
font-weight: 400;
font-size: medium;
}
}
.stakingAhClient__summary {
.stakingAhClient__hasQueuedInClient {
display: flex;
justify-content: space-evenly;
}
.section {
h4 {
font-size: var(--font-size-base);
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
margin-bottom: 0.5rem;
letter-spacing: 0.05em;
}
}
.events__summary {
justify-self: center;
h3 {
font-weight: 500;
font-size: var(--font-size-h2);
.stats {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
}
.ui--Labelled {
label {
font-size: medium !important;
text-align: left !important;
}
.ui--Labelled-content {
font-size: small !important;
text-align: left !important;
}
}
`;
const StyledEventsBox = styled.div`
background: var(--bg-table);
padding: 1.5rem;
border-radius: 0.5rem;
max-height: 500px;
overflow-y: auto;
h3 {
margin-bottom: 1rem;
font-size: var(--font-size-h3);
font-weight: 500;
}
.no-events {
color: var(--color-text-secondary);
font-style: italic;
padding: 1rem;
text-align: center;
}
.ui--Expander {
margin-bottom: 0.5rem;
position: relative;
.absolute {
position: absolute;
right: 0.5rem;
top: 0.72rem;
white-space: nowrap;
}
}
`;