From 5433a3a68f88638059d25d1b81a67511b8dc27b9 Mon Sep 17 00:00:00 2001 From: Longarithm Date: Fri, 14 Feb 2025 15:45:21 +0400 Subject: [PATCH 1/2] epoch infos --- chain/client-primitives/src/debug.rs | 5 +- chain/client/src/debug.rs | 269 +++++++++------- chain/jsonrpc/src/lib.rs | 45 ++- tools/debug-ui/src/CurrentPeersView.tsx | 8 +- tools/debug-ui/src/EpochShardsView.tsx | 2 +- tools/debug-ui/src/EpochValidatorsView.scss | 83 ++++- tools/debug-ui/src/EpochValidatorsView.tsx | 337 +++++++++++++++----- tools/debug-ui/src/RecentEpochsView.tsx | 16 +- tools/debug-ui/src/api.tsx | 17 +- 9 files changed, 571 insertions(+), 211 deletions(-) diff --git a/chain/client-primitives/src/debug.rs b/chain/client-primitives/src/debug.rs index 5a9d2435ffb..bec423e5db2 100644 --- a/chain/client-primitives/src/debug.rs +++ b/chain/client-primitives/src/debug.rs @@ -30,7 +30,8 @@ pub struct EpochInfoView { pub height: BlockHeight, pub first_block: Option<(CryptoHash, Utc)>, pub block_producers: Vec, - pub chunk_only_producers: Vec, + pub chunk_producers: Vec, + pub chunk_validators: Vec, pub validator_info: Option, pub protocol_version: u32, pub shards_size_and_parts: Vec<(u64, u64, bool)>, @@ -182,7 +183,7 @@ pub enum DebugStatus { // Request currently tracked shards TrackedShards, // Detailed information about last couple epochs. - EpochInfo, + EpochInfo(Option), // Detailed information about last couple blocks. BlockStatus(Option), // Consensus related information. diff --git a/chain/client/src/debug.rs b/chain/client/src/debug.rs index 0ee065cad1b..11903cbc4bf 100644 --- a/chain/client/src/debug.rs +++ b/chain/client/src/debug.rs @@ -2,6 +2,7 @@ //! without backwards compatibility. use crate::chunk_inclusion_tracker::ChunkInclusionTracker; use crate::client_actor::ClientActorInner; +use itertools::Itertools; use near_async::messaging::Handler; use near_async::time::{Clock, Instant}; use near_chain::crypto_hash_timer::CryptoHashTimer; @@ -173,8 +174,8 @@ impl Handler for ClientActorInner { DebugStatus::TrackedShards => { Ok(DebugStatusResponse::TrackedShards(self.get_tracked_shards_view()?)) } - DebugStatus::EpochInfo => { - Ok(DebugStatusResponse::EpochInfo(self.get_recent_epoch_info()?)) + DebugStatus::EpochInfo(epoch_id) => { + Ok(DebugStatusResponse::EpochInfo(self.get_recent_epoch_info(epoch_id)?)) } DebugStatus::BlockStatus(height) => { Ok(DebugStatusResponse::BlockStatus(self.get_last_blocks_info(height)?)) @@ -195,86 +196,123 @@ impl Handler for ClientActorInner { } } +fn get_epoch_start_height( + epoch_manager: &dyn EpochManagerAdapter, + epoch_identifier: &ValidatorInfoIdentifier, +) -> Result { + match epoch_identifier { + ValidatorInfoIdentifier::EpochId(epoch_id) => { + epoch_manager.get_epoch_start_from_epoch_id(epoch_id) + } + ValidatorInfoIdentifier::BlockHash(block_hash) => { + epoch_manager.get_epoch_start_height(block_hash) + } + } +} + +fn get_prev_epoch_identifier( + chain: &Chain, + first_block: Option, +) -> Option { + let epoch_start_block_header = chain.get_block_header(first_block.as_ref()?).ok()?; + if epoch_start_block_header.is_genesis() { + return None; + } + let prev_epoch_last_block_hash = epoch_start_block_header.prev_hash(); + let prev_epoch_last_block_header = chain.get_block_header(prev_epoch_last_block_hash).ok()?; + if prev_epoch_last_block_header.is_genesis() { + return None; + } + Some(ValidatorInfoIdentifier::EpochId(*prev_epoch_last_block_header.epoch_id())) +} + impl ClientActorInner { - // Gets a list of block producers and chunk-only producers for a given epoch. - fn get_producers_for_epoch( + // Gets a list of block producers, chunk producers and chunk validators for a given epoch. + fn get_validators_for_epoch( &self, epoch_id: &EpochId, - ) -> Result<(Vec, Vec), Error> { - let mut block_producers_set = HashSet::new(); + ) -> Result<(Vec, Vec, Vec), Error> { + let all_validators = self + .client + .epoch_manager + .get_epoch_all_validators(epoch_id)? + .into_iter() + .map(|validator_stake| validator_stake.take_account_id().to_string()) + .collect_vec(); let block_producers: Vec = self .client .epoch_manager .get_epoch_block_producers_ordered(epoch_id)? .into_iter() - .map(|validator_stake| { - block_producers_set.insert(validator_stake.account_id().as_str().to_owned()); - ValidatorInfo { account_id: validator_stake.take_account_id(), is_slashed: false } + .map(|validator_stake| ValidatorInfo { + account_id: validator_stake.take_account_id(), + is_slashed: false, }) .collect(); - let chunk_only_producers = self + let chunk_producers = self .client .epoch_manager .get_epoch_chunk_producers(&epoch_id)? - .iter() - .filter_map(|producer| { - if block_producers_set.contains(&producer.account_id().to_string()) { - None - } else { - Some(producer.account_id().to_string()) - } - }) - .collect::>(); - Ok((block_producers, chunk_only_producers)) + .into_iter() + .map(|validator_stake| validator_stake.take_account_id().to_string()) + .collect_vec(); + // Note that currently all validators are chunk validators. + Ok((block_producers, chunk_producers, all_validators)) } /// Gets the information about the epoch that contains a given block. - /// Also returns the hash of the last block of the previous epoch. fn get_epoch_info_view( &mut self, - current_block: CryptoHash, - is_current_block_head: bool, - ) -> Result<(EpochInfoView, CryptoHash), Error> { + epoch_identifier: &ValidatorInfoIdentifier, + ) -> Result { let epoch_start_height = - self.client.epoch_manager.get_epoch_start_height(¤t_block)?; - - let block = self.client.chain.get_block_by_height(epoch_start_height)?; - let epoch_id = block.header().epoch_id(); + get_epoch_start_height(self.client.epoch_manager.as_ref(), epoch_identifier)?; + let epoch_start_block_header = + self.client.chain.get_block_header_by_height(epoch_start_height)?; + let epoch_id = epoch_start_block_header.epoch_id(); let shard_layout = self.client.epoch_manager.get_shard_layout(&epoch_id)?; - let (validators, chunk_only_producers) = self.get_producers_for_epoch(&epoch_id)?; - - let shards_size_and_parts: Vec<(u64, u64)> = block - .chunks() - .iter_deprecated() - .enumerate() - .map(|(shard_index, chunk)| { - let shard_id = shard_layout.get_shard_id(shard_index); - let Ok(shard_id) = shard_id else { - tracing::error!("Failed to get shard id for shard index {}", shard_index); - return (0, 0); - }; + let (block_producers, chunk_producers, chunk_validators) = + self.get_validators_for_epoch(&epoch_id)?; - let state_root_node = self.client.runtime_adapter.get_state_root_node( - shard_id, - block.hash(), - &chunk.prev_state_root(), - ); - if let Ok(state_root_node) = state_root_node { - ( - state_root_node.memory_usage, - get_num_state_parts(state_root_node.memory_usage), - ) - } else { - (0, 0) - } - }) - .collect(); + let shards_size_and_parts: Vec<(u64, u64)> = if let Ok(block) = + self.client.chain.get_block(epoch_start_block_header.hash()) + { + block + .chunks() + .iter_raw() + .enumerate() + .map(|(shard_index, chunk)| { + let shard_id = shard_layout.get_shard_id(shard_index); + let Ok(shard_id) = shard_id else { + tracing::error!("Failed to get shard id for shard index {}", shard_index); + return (0, 0); + }; + + let state_root_node = self.client.runtime_adapter.get_state_root_node( + shard_id, + epoch_start_block_header.hash(), + &chunk.prev_state_root(), + ); + if let Ok(state_root_node) = state_root_node { + ( + state_root_node.memory_usage, + get_num_state_parts(state_root_node.memory_usage), + ) + } else { + (0, 0) + } + }) + .collect() + } else { + epoch_start_block_header.chunk_mask().iter().map(|_| (0, 0)).collect() + }; let state_header_exists: Vec = shard_layout .shard_ids() .map(|shard_id| { - let key = borsh::to_vec(&StateHeaderKey(shard_id, *block.hash())); + let key = + borsh::to_vec(&StateHeaderKey(shard_id, *epoch_start_block_header.hash())); match key { Ok(key) => { matches!( @@ -297,64 +335,61 @@ impl ClientActorInner { .map(|((a, b), c)| (*a, *b, *c)) .collect(); - let validator_info = if is_current_block_head { - self.client - .epoch_manager - .get_validator_info(ValidatorInfoIdentifier::BlockHash(current_block))? - } else { - self.client + let validator_info = + self.client.epoch_manager.get_validator_info(epoch_identifier.clone())?; + let epoch_height = + self.client.epoch_manager.get_epoch_info(&epoch_id).map(|info| info.epoch_height())?; + Ok(EpochInfoView { + epoch_height, + epoch_id: epoch_id.0, + height: epoch_start_block_header.height(), + first_block: Some(( + *epoch_start_block_header.hash(), + epoch_start_block_header.timestamp(), + )), + block_producers, + chunk_producers, + chunk_validators, + validator_info: Some(validator_info), + protocol_version: self + .client .epoch_manager - .get_validator_info(ValidatorInfoIdentifier::EpochId(*epoch_id))? - }; - return Ok(( - EpochInfoView { - epoch_height: self - .client - .epoch_manager - .get_epoch_info(&epoch_id) - .map(|info| info.epoch_height())?, - epoch_id: epoch_id.0, - height: block.header().height(), - first_block: Some((*block.header().hash(), block.header().timestamp())), - block_producers: validators.to_vec(), - chunk_only_producers, - validator_info: Some(validator_info), - protocol_version: self - .client - .epoch_manager - .get_epoch_protocol_version(epoch_id) - .unwrap_or(0), - shards_size_and_parts, - }, - // Last block of the previous epoch. - *block.header().prev_hash(), - )); + .get_epoch_protocol_version(epoch_id) + .unwrap_or(0), + shards_size_and_parts, + }) } - fn get_next_epoch_view(&self) -> Result { - let head = self.client.chain.head()?; + /// Get information about the next epoch, which may not have started yet. + fn get_next_epoch_view( + &self, + epoch_identifier: &ValidatorInfoIdentifier, + ) -> Result { let epoch_start_height = - self.client.epoch_manager.get_epoch_start_height(&head.last_block_hash)?; - let (validators, chunk_only_producers) = - self.get_producers_for_epoch(&head.next_epoch_id)?; + get_epoch_start_height(self.client.epoch_manager.as_ref(), epoch_identifier)?; + let epoch_first_block = self.client.chain.get_block_header_by_height(epoch_start_height)?; + let next_epoch_id = epoch_first_block.next_epoch_id(); + let (block_producers, chunk_producers, chunk_validators) = + self.get_validators_for_epoch(next_epoch_id)?; Ok(EpochInfoView { epoch_height: self .client .epoch_manager - .get_epoch_info(&head.next_epoch_id) + .get_epoch_info(next_epoch_id) .map(|info| info.epoch_height())?, - epoch_id: head.next_epoch_id.0, + epoch_id: next_epoch_id.0, // Expected height of the next epoch. height: epoch_start_height + self.client.config.epoch_length, first_block: None, - block_producers: validators, - chunk_only_producers, + block_producers, + chunk_producers, + chunk_validators, validator_info: None, protocol_version: self .client .epoch_manager - .get_epoch_protocol_version(&head.next_epoch_id)?, + .get_epoch_protocol_version(next_epoch_id)?, shards_size_and_parts: vec![], }) } @@ -386,25 +421,41 @@ impl ClientActorInner { fn get_recent_epoch_info( &mut self, + epoch_id: Option, ) -> Result, near_chain_primitives::Error> { - // Next epoch id let mut epochs_info: Vec = Vec::new(); - if let Ok(next_epoch) = self.get_next_epoch_view() { + let head = self.client.chain.head()?; + let epoch_identifier = match epoch_id { + // Use epoch id if the epoch is already finalized. + Some(epoch_id) if head.epoch_id != epoch_id => { + ValidatorInfoIdentifier::EpochId(epoch_id) + } + // Otherwise use the last block hash. + _ => ValidatorInfoIdentifier::BlockHash(self.client.chain.head()?.last_block_hash), + }; + + // Fetch the next epoch info + if let Ok(next_epoch) = self.get_next_epoch_view(&epoch_identifier) { epochs_info.push(next_epoch); } - let head = self.client.chain.head()?; - let mut current_block = head.last_block_hash; - for i in 0..DEBUG_EPOCHS_TO_FETCH { - if let Ok((epoch_view, block_previous_epoch)) = - self.get_epoch_info_view(current_block, i == 0) - { - current_block = block_previous_epoch; - epochs_info.push(epoch_view); - } else { + + let mut current_epoch_identifier = epoch_identifier; + for _ in 0..DEBUG_EPOCHS_TO_FETCH { + let Ok(epoch_view) = self.get_epoch_info_view(¤t_epoch_identifier) else { break; - } + }; + let first_block = epoch_view.first_block.map(|(hash, _)| hash); + epochs_info.push(epoch_view); + + let Some(prev_epoch_identifier) = + get_prev_epoch_identifier(&self.client.chain, first_block) + else { + break; + }; + current_epoch_identifier = prev_epoch_identifier; } + Ok(epochs_info) } diff --git a/chain/jsonrpc/src/lib.rs b/chain/jsonrpc/src/lib.rs index d9656221c7c..650d1da40be 100644 --- a/chain/jsonrpc/src/lib.rs +++ b/chain/jsonrpc/src/lib.rs @@ -786,7 +786,7 @@ impl JsonRpcHandler { self.client_send(DebugStatus::CatchupStatus).await?.rpc_into() } "/debug/api/epoch_info" => { - self.client_send(DebugStatus::EpochInfo).await?.rpc_into() + self.client_send(DebugStatus::EpochInfo(None)).await?.rpc_into() } "/debug/api/block_status" => { self.client_send(DebugStatus::BlockStatus(None)).await?.rpc_into() @@ -858,6 +858,23 @@ impl JsonRpcHandler { } } + pub async fn debug_epoch_info( + &self, + epoch_id: Option, + ) -> Result< + Option, + near_jsonrpc_primitives::types::status::RpcStatusError, + > { + if self.enable_debug_rpc { + let debug_status = self.client_send(DebugStatus::EpochInfo(epoch_id)).await?.rpc_into(); + Ok(Some(near_jsonrpc_primitives::types::status::RpcDebugStatusResponse { + status_response: debug_status, + })) + } else { + Ok(None) + } + } + pub async fn protocol_config( &self, request_data: near_jsonrpc_primitives::types::config::RpcProtocolConfigRequest, @@ -1472,6 +1489,24 @@ async fn debug_block_status_handler( } } +#[derive(serde::Serialize, serde::Deserialize, Debug)] +pub struct DebugRpcEpochInfoRequest { + #[serde(flatten)] + pub epoch_id: near_primitives::types::EpochId, +} + +async fn debug_epoch_info_handler( + path: web::Path, + handler: web::Data, +) -> Result { + let epoch_id: near_primitives::types::EpochId = path.into_inner().parse().unwrap(); + match handler.debug_epoch_info(Some(epoch_id)).await { + Ok(Some(value)) => Ok(HttpResponse::Ok().json(&value)), + Ok(None) => Ok(HttpResponse::MethodNotAllowed().finish()), + Err(_) => Ok(HttpResponse::ServiceUnavailable().finish()), + } +} + async fn health_handler(handler: web::Data) -> Result { match handler.health().await { Ok(value) => Ok(HttpResponse::Ok().json(&value)), @@ -1642,11 +1677,15 @@ pub fn start_http( .service(web::resource("/network_info").route(web::get().to(network_info_handler))) .service(web::resource("/metrics").route(web::get().to(prometheus_handler))) .service(web::resource("/debug/api/entity").route(web::post().to(handle_entity_debug))) - .service(web::resource("/debug/api/{api}").route(web::get().to(debug_handler))) .service( - web::resource("/debug/api/block_status/{starting_height}") + web::resource("/debug/api/block_status") .route(web::get().to(debug_block_status_handler)), ) + .service(web::resource("/debug/api/{api}").route(web::get().to(debug_handler))) + .service( + web::resource("/debug/api/epoch_info/{epoch_id}") + .route(web::get().to(debug_epoch_info_handler)), + ) .service( web::resource("/debug/client_config").route(web::get().to(client_config_handler)), ) diff --git a/tools/debug-ui/src/CurrentPeersView.tsx b/tools/debug-ui/src/CurrentPeersView.tsx index bd7ce21a7ad..3d0e37c48bc 100644 --- a/tools/debug-ui/src/CurrentPeersView.tsx +++ b/tools/debug-ui/src/CurrentPeersView.tsx @@ -1,8 +1,8 @@ -import { MouseEvent, useCallback, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { MouseEvent, useCallback, useMemo, useState } from 'react'; import { PeerInfoView, fetchEpochInfo, fetchFullStatus } from './api'; -import { addDebugPortLink, formatDurationInMillis, formatTraffic } from './utils'; import './CurrentPeersView.scss'; +import { addDebugPortLink, formatDurationInMillis, formatTraffic } from './utils'; type NetworkInfoViewProps = { addr: string; @@ -55,7 +55,7 @@ export const CurrentPeersView = ({ addr }: NetworkInfoViewProps) => { data: epochInfo, error: epochInfoError, isLoading: epochInfoLoading, - } = useQuery(['epochInfo', addr], () => fetchEpochInfo(addr)); + } = useQuery(['epochInfo', addr], () => fetchEpochInfo(addr, null)); const { blockProducers, chunkProducers, knownSet, reachableSet, numPeersByStatus, peers } = useMemo(() => { @@ -75,7 +75,7 @@ export const CurrentPeersView = ({ addr }: NetworkInfoViewProps) => { blockProducers = new Set( oneEpoch.block_producers.map((bp) => bp.account_id) ); - chunkProducers = new Set(oneEpoch.chunk_only_producers); + chunkProducers = new Set(oneEpoch.chunk_producers || []); break; } } diff --git a/tools/debug-ui/src/EpochShardsView.tsx b/tools/debug-ui/src/EpochShardsView.tsx index ab88837e066..5178c04eb88 100644 --- a/tools/debug-ui/src/EpochShardsView.tsx +++ b/tools/debug-ui/src/EpochShardsView.tsx @@ -32,7 +32,7 @@ export const EpochShardsView = ({ addr }: EpochShardsViewProps) => { data: epochData, error: epochError, isLoading: epochIsLoading, - } = useQuery(['epochInfo', addr], () => fetchEpochInfo(addr)); + } = useQuery(['epochInfo', addr], () => fetchEpochInfo(addr, null)); if (epochIsLoading) { return
Loading...
; diff --git a/tools/debug-ui/src/EpochValidatorsView.scss b/tools/debug-ui/src/EpochValidatorsView.scss index e489977c7ab..41f180a3a21 100644 --- a/tools/debug-ui/src/EpochValidatorsView.scss +++ b/tools/debug-ui/src/EpochValidatorsView.scss @@ -46,10 +46,6 @@ } } - .kickout-reason { - color: red; - } - $current-border: 4px solid #3bda26; $next-border: 3px solid gray; @@ -83,7 +79,9 @@ &:nth-child(2), &:nth-child(3), &:nth-child(4) { - opacity: 0.5; + &:not(:has(.kickout)):not(:has(.kickout-reason)) { + opacity: 0.5; + } } &:nth-child(5) { @@ -114,6 +112,12 @@ border-bottom: $current-border; } } + + .kickout { + color: red; + font-size: 16px; + font-weight: bold; + } } .validator-role { @@ -139,3 +143,72 @@ @extend .validator-role; background-color: bisque; } + +.epoch-navigation { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + margin-bottom: 20px; + + .arrow-button { + padding: 8px 16px; + border: 1px solid #ccc; + border-radius: 4px; + background: #f5f5f5; + cursor: pointer; + font-size: 18px; + min-width: 45px; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + background: #e5e5e5; + } + } + + .epoch-info { + text-align: center; + min-width: 200px; + + .epoch-height { + font-size: 16px; + font-weight: bold; + margin-bottom: 8px; + } + } +} + +.loading-overlay { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.4); + pointer-events: none; + } +} + +.epoch-start-height { + font-size: 0.9em; + color: #666; + margin-left: 8px; +} + +.kickout-disclaimer { + margin-bottom: 20px; + padding: 8px; + background-color: #fff3cd; + border: 1px solid #ffeeba; + border-radius: 4px; + color: #856404; + font-size: 16px; +} diff --git a/tools/debug-ui/src/EpochValidatorsView.tsx b/tools/debug-ui/src/EpochValidatorsView.tsx index 2bd9ab6ee91..07fddc1bf4f 100644 --- a/tools/debug-ui/src/EpochValidatorsView.tsx +++ b/tools/debug-ui/src/EpochValidatorsView.tsx @@ -1,7 +1,7 @@ -import { useId } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useId, useState } from 'react'; import { Tooltip } from 'react-tooltip'; -import { ValidatorKickoutReason, fetchEpochInfo } from './api'; +import { EpochInfoView, ValidatorKickoutReason, fetchEpochInfo } from './api'; import './EpochValidatorsView.scss'; interface ProducedAndExpected { @@ -104,31 +104,25 @@ class Validators { } } -type EpochValidatorViewProps = { - addr: string; -}; +interface ProcessedEpochData { + validators: Validators; + maxStake: number; + totalStake: number; + maxExpectedBlocks: number; + maxExpectedChunks: number; + maxExpectedEndorsements: number; +} -export const EpochValidatorsView = ({ addr }: EpochValidatorViewProps) => { - const { - data: epochData, - error: epochError, - isLoading: epochIsLoading, - } = useQuery(['epochInfo', addr], () => fetchEpochInfo(addr)); - - if (epochIsLoading) { - return
Loading...
; - } - if (epochError) { - return
{(epochError as Error).stack}
; - } +function processEpochData(epochData: any): ProcessedEpochData { let maxStake = 0, totalStake = 0, maxExpectedBlocks = 0, maxExpectedChunks = 0, maxExpectedEndorsements = 0; - const epochs = epochData!.status_response.EpochInfo; + const epochs = epochData.status_response.EpochInfo; const validators = new Validators(epochs.length); - const currentValidatorInfo = epochData!.status_response.EpochInfo[1].validator_info; + const currentValidatorInfo = epochData.status_response.EpochInfo[1].validator_info; + for (const validatorInfo of currentValidatorInfo.current_validators) { const validator = validators.validator(validatorInfo.account_id); const stake = parseFloat(validatorInfo.stake); @@ -157,6 +151,7 @@ export const EpochValidatorsView = ({ addr }: EpochValidatorViewProps) => { validatorInfo.num_expected_endorsements ); } + for (const validatorInfo of currentValidatorInfo.next_validators) { const validator = validators.validator(validatorInfo.account_id); validator.next = { @@ -169,16 +164,22 @@ export const EpochValidatorsView = ({ addr }: EpochValidatorViewProps) => { shards: validatorInfo.shards, }); } + validators.addValidatorRole(validator.accountId, 0, { + kind: 'ChunkValidator', + }); } + for (const proposal of currentValidatorInfo.current_proposals) { const validator = validators.validator(proposal.account_id); validator.proposalStake = parseFloat(proposal.stake); } + for (const kickout of currentValidatorInfo.prev_epoch_kickout) { const validator = validators.validator(kickout.account_id); validator.kickoutReason = kickout.reason; } - epochs.forEach((epochInfo, index) => { + + epochs.forEach((epochInfo: any, index: number) => { for (const blockProducer of epochInfo.block_producers) { validators.addValidatorRole(blockProducer.account_id, index, { kind: 'BlockProducer' }); } @@ -199,87 +200,259 @@ export const EpochValidatorsView = ({ addr }: EpochValidatorViewProps) => { } }); - return ( - + return { + validators, + maxStake, + totalStake, + maxExpectedBlocks, + maxExpectedChunks, + maxExpectedEndorsements, + }; +} + +type EpochValidatorViewProps = { + addr: string; +}; + +export const EpochValidatorsView = ({ addr }: EpochValidatorViewProps) => { + const [enteredEpochId, setEnteredEpochId] = useState(''); + const [currentEpochId, setCurrentEpochId] = useState(null); + const [validators, setValidators] = useState(null); + const [maxStake, setMaxStake] = useState(0); + const [totalStake, setTotalStake] = useState(0); + const [maxExpectedBlocks, setMaxExpectedBlocks] = useState(0); + const [maxExpectedChunks, setMaxExpectedChunks] = useState(0); + const [maxExpectedEndorsements, setMaxExpectedEndorsements] = useState(0); + + const { data: epochData, error: epochError, isLoading: epochIsLoading, isFetching } = useQuery( + ['epochInfo', addr, currentEpochId], + () => fetchEpochInfo(addr, currentEpochId), + { + onSuccess: (data) => { + const { + validators, maxStake, totalStake, + maxExpectedBlocks, maxExpectedChunks, maxExpectedEndorsements + } = processEpochData(data); + setValidators(validators); + setMaxStake(maxStake); + setTotalStake(totalStake); + setMaxExpectedBlocks(maxExpectedBlocks); + setMaxExpectedChunks(maxExpectedChunks); + setMaxExpectedEndorsements(maxExpectedEndorsements); + }, + keepPreviousData: true + } + ); + + if (epochIsLoading) { + return
Loading...
; + } + if (epochError) { + return
{(epochError as Error).stack}
; + } + + const handleNavigateEpoch = async (direction: 'left' | 'right') => { + if (!epochData?.status_response.EpochInfo) return; + + const epochs = epochData.status_response.EpochInfo; + const currentIndex = 1; + const targetIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1; + + if (targetIndex < 0 || targetIndex >= epochs.length) { + alert('No more epochs available in that direction'); + return; + } + + const targetEpochId = epochs[targetIndex].epoch_id; + setCurrentEpochId(targetEpochId); + setEnteredEpochId(targetEpochId); + }; + + const handleButtonClick = async () => { + try { + setCurrentEpochId(enteredEpochId || null); + } catch (error) { + console.error('Error fetching epoch data:', error); + alert('Failed to fetch epoch data'); + } + }; + + const handleEpochIdInput = (event: React.ChangeEvent) => { + setEnteredEpochId(event.target.value); + }; + + function renderTableHeaders(epochData: any): JSX.Element { + return (
- - - + + + - - - - - {epochs.slice(2).map((epoch) => { - return ( - - ); - })} + {epochData?.status_response.EpochInfo.slice(2).map((epoch: any) => ( + + ))} + ); + } + + function renderTableBody( + validators: Validators | null, + maxStake: number, + totalStake: number, + maxExpectedBlocks: number, + maxExpectedChunks: number, + maxExpectedEndorsements: number, + ): JSX.Element { + return ( - {validators.sorted().map((validator) => { + {validators?.sorted().map((validator: ValidatorInfo) => { return ( - - - - - - - - - - + - {validator.roles.slice(2).map((roles, i) => { - return ; - })} + {drawStakeBar(validator.next?.stake ?? null, maxStake, totalStake)} + + + + + + + + {validator.roles.slice(2).map((roles: ValidatorRole[], i: number) => ( + + ))} ); })} -
Next EpochCurrent EpochPast EpochsNext Epoch {epochData?.status_response.EpochInfo[0].epoch_height}Current Epoch {epochData?.status_response.EpochInfo[1].epoch_height}Past Epochs
ValidatorRoles (shards) Stake ProposalRoles (shards) Stake Blocks Produced Chunks Endorsed ChunksKickout - {epoch.epoch_id.substring(0, 4)}... - + {epoch.epoch_id.substring(0, 4)}... +
{validator.accountId}{renderRoles(validator.roles[0])} - {drawStakeBar(validator.next?.stake ?? null, maxStake, totalStake)} - {drawStakeBar(validator.proposalStake, maxStake, totalStake)}{renderRoles(validator.roles[1])} - {drawStakeBar( - validator.current?.stake ?? null, - maxStake, - totalStake - )} - - {drawProducedAndExpectedBar( - validator.current?.blocks ?? null, - maxExpectedBlocks - )} - - {drawProducedAndExpectedBar( - validator.current?.chunks ?? null, - maxExpectedChunks - )} - - {drawProducedAndExpectedBar( - validator.current?.endorsements ?? null, - maxExpectedEndorsements - )} - {renderRoles(validator.roles[0], validator.kickoutReason, true)} - - {renderRoles(roles)}{drawStakeBar(validator.proposalStake, maxStake, totalStake)}{renderRoles(validator.roles[1], validator.kickoutReason)} + {drawStakeBar( + validator.current?.stake ?? null, + maxStake, + totalStake + )} + + {drawProducedAndExpectedBar( + validator.current?.blocks ?? null, + maxExpectedBlocks + )} + + {drawProducedAndExpectedBar( + validator.current?.chunks ?? null, + maxExpectedChunks + )} + + {drawProducedAndExpectedBar( + validator.current?.endorsements ?? null, + maxExpectedEndorsements + )} + {renderRoles(roles, validator.kickoutReason)}
+ ); + } + + function renderValidatorsTable( + epochData: any, + validators: Validators | null, + maxStake: number, + totalStake: number, + maxExpectedBlocks: number, + maxExpectedChunks: number, + maxExpectedEndorsements: number, + ): JSX.Element { + return ( + + {renderTableHeaders(epochData)} + {renderTableBody( + validators, + maxStake, + totalStake, + maxExpectedBlocks, + maxExpectedChunks, + maxExpectedEndorsements + )} +
+ ); + } + + return ( +
+
+ Note: Validator kickouts are determined at the end of the second previous epoch +
+ +
+ + +
+ {epochData && ( + <> +
+ Epoch {epochData.status_response.EpochInfo[1].epoch_height} + + (starts at {epochData.status_response.EpochInfo[1].height || 'N/A'}) + +
+
+ Recent Epochs +
+ {epochData.status_response.EpochInfo.map((epoch: EpochInfoView, index: number) => ( +
+ Epoch {epoch.epoch_height}: {epoch.epoch_id} +
+ ))} +
+
+ + )} +
+ + +
+ + + + {renderValidatorsTable( + epochData, + validators, + maxStake, + totalStake, + maxExpectedBlocks, + maxExpectedChunks, + maxExpectedEndorsements + )} +
); }; @@ -339,7 +512,11 @@ function drawStakeBar(stake: number | null, maxStake: number, totalStake: number ); } -function renderRoles(roles: ValidatorRole[]): JSX.Element { +function renderRoles(roles: ValidatorRole[], kickoutReason: ValidatorKickoutReason | null = null, isNextEpoch: boolean = false): JSX.Element { + if (isNextEpoch && kickoutReason) { + return ; + } + const renderedItems = []; for (const role of roles) { switch (role.kind) { @@ -356,6 +533,7 @@ function renderRoles(roles: ValidatorRole[]): JSX.Element { break; } } + return <>{renderedItems}; } @@ -381,6 +559,9 @@ const KickoutReason = ({ reason }: { reason: ValidatorKickoutReason | null }) => } else if ('NotEnoughChunks' in reason) { kickoutSummary = '#Chunks'; kickoutReason = `Validator did not produce enough chunks: expected ${reason.NotEnoughChunks.expected}, actually produced ${reason.NotEnoughChunks.produced}`; + } else if ('NotEnoughChunkEndorsements' in reason) { + kickoutSummary = '#Endors'; + kickoutReason = `Validator did not produce enough chunk endorsements: expected ${reason.NotEnoughChunkEndorsements.expected}, actually produced ${reason.NotEnoughChunkEndorsements.produced}`; } else if ('NotEnoughStake' in reason) { kickoutSummary = 'LowStake'; kickoutReason = `Validator did not have enough stake: minimum stake required was ${reason.NotEnoughStake.threshold}, but validator only had ${reason.NotEnoughStake.stake}`; diff --git a/tools/debug-ui/src/RecentEpochsView.tsx b/tools/debug-ui/src/RecentEpochsView.tsx index 9e6bba8d114..c6273537482 100644 --- a/tools/debug-ui/src/RecentEpochsView.tsx +++ b/tools/debug-ui/src/RecentEpochsView.tsx @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import { parse } from 'date-fns'; import { EpochInfoView, fetchEpochInfo, fetchFullStatus } from './api'; -import { formatDurationInMillis } from './utils'; import './RecentEpochsView.scss'; +import { formatDurationInMillis } from './utils'; type RecentEpochsViewProps = { addr: string; @@ -13,7 +13,7 @@ export const RecentEpochsView = ({ addr }: RecentEpochsViewProps) => { data: epochData, error: epochError, isLoading: epochIsLoading, - } = useQuery(['epochInfo', addr], () => fetchEpochInfo(addr)); + } = useQuery(['epochInfo', addr], () => fetchEpochInfo(addr, null)); const { data: statusData, error: statusError, @@ -43,7 +43,6 @@ export const RecentEpochsView = ({ addr }: RecentEpochsViewProps) => { Block Producers Chunk Producers Chunk Validators - Chunk-only Producers @@ -104,9 +103,12 @@ export const RecentEpochsView = ({ addr }: RecentEpochsViewProps) => { {firstBlockColumn} {epochStartColumn} {epochInfo.block_producers.length} - {getChunkProducersTotal(epochInfo)} - {getChunkValidatorsTotal(epochInfo)} - {epochInfo.chunk_only_producers.length} + + {epochInfo.chunk_producers?.length || getChunkProducersTotal(epochInfo)} + + + {epochInfo.chunk_validators?.length || getChunkValidatorsTotal(epochInfo)} + ); })} @@ -115,6 +117,7 @@ export const RecentEpochsView = ({ addr }: RecentEpochsViewProps) => { ); }; +// TODO(2.6): remove as superseded by chunk_producers field. function getChunkProducersTotal(epochInfo: EpochInfoView) { return ( epochInfo.validator_info?.current_validators.reduce((acc, it) => { @@ -126,6 +129,7 @@ function getChunkProducersTotal(epochInfo: EpochInfoView) { ); } +// TODO(2.6): remove as superseded by chunk_validators field. function getChunkValidatorsTotal(epochInfo: EpochInfoView) { return ( epochInfo.validator_info?.current_validators.reduce((acc, it) => { diff --git a/tools/debug-ui/src/api.tsx b/tools/debug-ui/src/api.tsx index 777213364dd..a2779434877 100644 --- a/tools/debug-ui/src/api.tsx +++ b/tools/debug-ui/src/api.tsx @@ -182,7 +182,8 @@ export interface EpochInfoView { height: number; first_block: null | [string, string]; block_producers: ValidatorInfo[]; - chunk_only_producers: string[]; + chunk_producers: string[]; + chunk_validators: string[]; validator_info: EpochValidatorInfo; protocol_version: number; shards_size_and_parts: [number, number, boolean][]; @@ -236,6 +237,7 @@ export type ValidatorKickoutReason = | 'Slashed' | { NotEnoughBlocks: { produced: number; expected: number } } | { NotEnoughChunks: { produced: number; expected: number } } + | { NotEnoughChunkEndorsements: { produced: number; expected: number } } | 'Unstaked' | { NotEnoughStake: { stake: string; threshold: string } } | 'DidNotGetASeat'; @@ -442,8 +444,17 @@ export async function fetchBlockStatus( return await response.json(); } -export async function fetchEpochInfo(addr: string): Promise { - const response = await fetch(`http://${addr}/debug/api/epoch_info`); +export async function fetchEpochInfo( + addr: string, + epochId: string | null +): Promise { + const trailing = epochId ? `/${epochId}` : ''; + const response = await fetch(`http://${addr}/debug/api/epoch_info${trailing}`); + + if (!response.ok) { + throw new Error(`Failed to fetch epoch info: ${response.statusText}`); + } + return await response.json(); } From ea3511c4d440ccc74a910e04f40f100a5ff4ad3d Mon Sep 17 00:00:00 2001 From: Longarithm Date: Fri, 14 Feb 2025 15:50:51 +0400 Subject: [PATCH 2/2] spell --- tools/debug-ui/src/EpochValidatorsView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/debug-ui/src/EpochValidatorsView.tsx b/tools/debug-ui/src/EpochValidatorsView.tsx index 07fddc1bf4f..1800087f591 100644 --- a/tools/debug-ui/src/EpochValidatorsView.tsx +++ b/tools/debug-ui/src/EpochValidatorsView.tsx @@ -560,6 +560,7 @@ const KickoutReason = ({ reason }: { reason: ValidatorKickoutReason | null }) => kickoutSummary = '#Chunks'; kickoutReason = `Validator did not produce enough chunks: expected ${reason.NotEnoughChunks.expected}, actually produced ${reason.NotEnoughChunks.produced}`; } else if ('NotEnoughChunkEndorsements' in reason) { + {/* cspell: words Endors */} kickoutSummary = '#Endors'; kickoutReason = `Validator did not produce enough chunk endorsements: expected ${reason.NotEnoughChunkEndorsements.expected}, actually produced ${reason.NotEnoughChunkEndorsements.produced}`; } else if ('NotEnoughStake' in reason) {