diff --git a/packages/page-staking-async/src/CommandCenter/ah.tsx b/packages/page-staking-async/src/CommandCenter/ah.tsx index b01193cecd..e8cb3e1a70 100644 --- a/packages/page-staking-async/src/CommandCenter/ah.tsx +++ b/packages/page-staking-async/src/CommandCenter/ah.tsx @@ -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 ( -
-

- {t('Asset Hub chain')} - {children} -

- {!ahApi && } - - {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]]; + + +

+ {t('Asset Hub')} + {children} +

+ {!ahApi && } + {ahOutput && ( +
+
+ {!isRelayChain + ? #{formatNumber(ahOutput.finalizedBlock)} + : ( + + #{formatNumber(ahOutput.finalizedBlock)} + ) + } +
+
+

{t('Staking')}

+
+ + {ahOutput.staking.currentEra} + + + {ahOutput.staking.activeEra.index} + {ahOutput.staking.activeEra.duration && ` (${ahOutput.staking.activeEra.duration})`} + + + {ahOutput.staking.erasStartSessionIndex || 'None'} + {ahOutput.rcClient.eraDepth && `, Era-depth: ${ahOutput.rcClient.eraDepth} sessions`} + + + {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'} + + + {ahOutput.staking.unprunedEras} + + + {ahOutput.staking.forcing || 'None'} + +
+
+
+

{t('Staking RC Client')}

+
+ + End={ahOutput.rcClient.lastSessionReportEndIndex}, Start={ahOutput.rcClient.lastSessionIndex} + +
+
+
+

{t('Election')}

+
+ + {ahOutput.multiblock.phase} + + + {ahOutput.multiblock.round} + + + {ahOutput.multiblock.signedSubmissions} + + + {ahOutput.multiblock.queuedScore || 'None'} + + + {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'} + +
+
+
+

{t('Validators/Nominators')}

+
+ + {ahOutput.staking.validatorCount || 'None'} + + + {ahOutput.staking.validatorCandidates + ? `${ahOutput.staking.validatorCandidates}${ahOutput.staking.maxValidatorsCount ? ` (max: ${ahOutput.staking.maxValidatorsCount})` : ''}` + : 'None'} + + + {ahOutput.staking.nominatorCandidates + ? `${ahOutput.staking.nominatorCandidates}${ahOutput.staking.maxNominatorsCount ? ` (max: ${ahOutput.staking.maxNominatorsCount})` : ''}` + : 'None'} + + + {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'} + +
+
+
+

{t('Bags List')}

+
+ + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */} + {ahOutput.bagsList ? ahOutput.bagsList.allNodes : 'None'} + + + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */} + {ahOutput.bagsList ? ahOutput.bagsList.lock : 'None'} + +
+
+
+ )} +
+ +

{t('Asset Hub Events')}

+ {ahEvents.length === 0 && ( +
+ {t('No relevant events in recent blocks')} +
+ )} + {ahEvents.map((enhancedEvent, index) => { + const { blockNumber, event, weight } = enhancedEvent; + const eventName = `${event.section}.${event.method}`; return ( -
-
-
-

+ + {eventName} +
+ {weight && [{weight}]} {!isRelayChain - ? #{formatNumber(ah.finalizedBlock)} + ? #{formatNumber(blockNumber)} : ( - #{formatNumber(ah.finalizedBlock)} + #{formatNumber(blockNumber)} ) } - -

-
-
-
- - {ah.staking.currentEra} - - - {ah.staking.erasStartSessionIndex} - - - {ah.staking.activeEra.index} -
-
- - {ah.multiblock.phase} - - {ah.multiblock.queuedScore && - - {ah.multiblock.queuedScore} - } - - {ah.multiblock.signedSubmissions} - - {!!ah.multiblock.snapshotRange.length && - - {`${minPage} → ${maxPage}`} - } -
-
- - {ah.rcClient.lastSessionReportEndIndex} - -
-
-
-
-

{t('events')}

- {ah.events.map((event) => { - const eventName = `${event.section}.${event.method}`; - - return ( - - - - - ); - })} - {ah.events.length === 0 && -
- - - -
- } -
-
+ + } + summaryMeta={event.meta} + > + + ); })} -
-
); + + + ); } -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; } } } diff --git a/packages/page-staking-async/src/CommandCenter/index.tsx b/packages/page-staking-async/src/CommandCenter/index.tsx index 9233467996..eb94b99534 100644 --- a/packages/page-staking-async/src/CommandCenter/index.tsx +++ b/packages/page-staking-async/src/CommandCenter/index.tsx @@ -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> + bondedEras: Vec>, + 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>, - setAhOutout: React.Dispatch> + setRcOutput: React.Dispatch>, + setAhOutput: React.Dispatch>, + setRcEvents: React.Dispatch>, + setAhEvents: React.Dispatch> ): Promise => { 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', hasNextActiveId).unwrap().toNumber(), - hasQueuedInClient: (() => { - const parsed = rcApi.createType('Option<(u32,Vec)>', 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', hasNextActiveId).unwrap().toNumber(), + hasQueuedInClient: (() => { + const parsed = rcApi.createType('Option<(u32,Vec)>', 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', 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', 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', 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; + + 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([]); - const [ahOutput, setAhOutput] = useState([]); + const [rcOutput, setRcOutput] = useState(undefined); + const [ahOutput, setAhOutput] = useState(undefined); + const [rcEvents, setRcEvents] = useState([]); + const [ahEvents, setAhEvents] = useState([]); const [rcUrl, setRcUrl] = useState(undefined); const [ahUrl, setAhUrl] = useState(undefined); @@ -224,23 +405,236 @@ function CommandCenter ({ ahApi: initialAhApi, ahEndPoints, isRelayChain, rcApi: const [ahApi, setAhApi] = useState(initialAhApi); const [rcApi, setRcApi] = useState(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(null); + const [ahLowestBlock, setAhLowestBlock] = useState(null); + const [loadingProgress, setLoadingProgress] = useState<{ rc: number; ah: number } | null>(null); + + const [customRcUrl, setCustomRcUrl] = useState('ws://127.0.0.1:9944'); + const [customAhUrl, setCustomAhUrl] = useState('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 ( + + + + +