diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index 2caf379fe6..cca670cfc5 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -184,6 +184,18 @@ impl Client { Ok(self.validator_api.get_mixnodes().await?) } + pub async fn get_cached_rewarded_mixnodes( + &self, + ) -> Result, ValidatorClientError> { + Ok(self.validator_api.get_rewarded_mixnodes().await?) + } + + pub async fn get_cached_active_mixnodes( + &self, + ) -> Result, ValidatorClientError> { + Ok(self.validator_api.get_active_mixnodes().await?) + } + pub async fn get_cached_gateways(&self) -> Result, ValidatorClientError> { Ok(self.validator_api.get_gateways().await?) } diff --git a/common/client-libs/validator-client/src/nymd/mod.rs b/common/client-libs/validator-client/src/nymd/mod.rs index dfaa1abbb8..95d699573d 100644 --- a/common/client-libs/validator-client/src/nymd/mod.rs +++ b/common/client-libs/validator-client/src/nymd/mod.rs @@ -27,9 +27,12 @@ pub use crate::nymd::cosmwasm_client::client::CosmWasmClient; pub use crate::nymd::cosmwasm_client::signing_client::SigningCosmWasmClient; pub use crate::nymd::fee::Fee; use crate::nymd::fee::DEFAULT_SIMULATED_GAS_MULTIPLIER; +pub use cosmrs::rpc::endpoint::validators::Response as ValidatorResponse; pub use cosmrs::rpc::HttpClient as QueryNymdClient; +pub use cosmrs::rpc::Paging; pub use cosmrs::tendermint::block::Height; pub use cosmrs::tendermint::hash; +pub use cosmrs::tendermint::validator::Info as TendermintValidatorInfo; pub use cosmrs::tendermint::Time as TendermintTime; pub use cosmrs::tx::{self, Gas}; pub use cosmrs::Coin as CosmosCoin; @@ -234,6 +237,17 @@ impl NymdClient { .map(|block| block.block_id.hash) } + pub async fn get_validators( + &self, + height: u64, + paging: Paging, + ) -> Result + where + C: CosmWasmClient + Sync, + { + Ok(self.client.validators(height as u32, paging).await?) + } + pub async fn get_balance( &self, address: &AccountId, diff --git a/explorer-api/src/mix_node/cache.rs b/explorer-api/src/cache/mod.rs similarity index 65% rename from explorer-api/src/mix_node/cache.rs rename to explorer-api/src/cache/mod.rs index 6b66ae2e2f..b00b25a1f2 100644 --- a/explorer-api/src/mix_node/cache.rs +++ b/explorer-api/src/cache/mod.rs @@ -13,19 +13,27 @@ impl Cache { } } - pub(crate) fn get(&self, identity_key: &str) -> Option - where - T: Clone, - { + pub(crate) fn len(&self) -> usize { + self.inner.len() + } + + pub(crate) fn get_all(&self) -> Vec { self.inner - .get(identity_key) + .values() + .map(|cache_item| cache_item.value.clone()) + .collect() + } + + pub(crate) fn get(&self, key: &str) -> Option { + self.inner + .get(key) .filter(|cache_item| cache_item.valid_until > SystemTime::now()) .map(|cache_item| cache_item.value.clone()) } - pub(crate) fn set(&mut self, identity_key: &str, value: T) { + pub(crate) fn set(&mut self, key: &str, value: T) { self.inner.insert( - identity_key.to_string(), + key.to_string(), CacheItem { valid_until: SystemTime::now() + Duration::from_secs(60 * 30), value, diff --git a/explorer-api/src/client.rs b/explorer-api/src/client.rs new file mode 100644 index 0000000000..110067572c --- /dev/null +++ b/explorer-api/src/client.rs @@ -0,0 +1,19 @@ +use network_defaults::{ + default_api_endpoints, default_nymd_endpoints, DEFAULT_MIXNET_CONTRACT_ADDRESS, +}; +use validator_client::nymd::QueryNymdClient; + +pub(crate) fn new_nymd_client() -> validator_client::Client { + let mixnet_contract = DEFAULT_MIXNET_CONTRACT_ADDRESS.to_string(); + let nymd_url = default_nymd_endpoints()[0].clone(); + let api_url = default_api_endpoints()[0].clone(); + + let client_config = validator_client::Config::new( + nymd_url, + api_url, + Some(mixnet_contract.parse().unwrap()), + None, + ); + + validator_client::Client::new_query(client_config).expect("Failed to connect to nymd!") +} diff --git a/explorer-api/src/country_statistics/country_nodes_distribution.rs b/explorer-api/src/country_statistics/country_nodes_distribution.rs index 8b5bf85226..f08e1cc330 100644 --- a/explorer-api/src/country_statistics/country_nodes_distribution.rs +++ b/explorer-api/src/country_statistics/country_nodes_distribution.rs @@ -5,13 +5,13 @@ use tokio::sync::RwLock; pub type CountryNodesDistribution = HashMap; #[derive(Clone)] -pub struct ConcurrentCountryNodesDistribution { +pub struct ThreadsafeCountryNodesDistribution { inner: Arc>, } -impl ConcurrentCountryNodesDistribution { +impl ThreadsafeCountryNodesDistribution { pub(crate) fn new() -> Self { - ConcurrentCountryNodesDistribution { + ThreadsafeCountryNodesDistribution { inner: Arc::new(RwLock::new(CountryNodesDistribution::new())), } } @@ -19,7 +19,7 @@ impl ConcurrentCountryNodesDistribution { pub(crate) fn new_from_distribution( country_node_distribution: CountryNodesDistribution, ) -> Self { - ConcurrentCountryNodesDistribution { + ThreadsafeCountryNodesDistribution { inner: Arc::new(RwLock::new(country_node_distribution)), } } diff --git a/explorer-api/src/country_statistics/distribution.rs b/explorer-api/src/country_statistics/distribution.rs index dba66ecab6..ebbc1eca9b 100644 --- a/explorer-api/src/country_statistics/distribution.rs +++ b/explorer-api/src/country_statistics/distribution.rs @@ -30,7 +30,7 @@ impl CountryStatisticsDistributionTask { /// Retrieves the current list of mixnodes from the validators and calculates how many nodes are in each country async fn calculate_nodes_per_country(&mut self) { - let cache = self.state.inner.mix_nodes.get_location_cache().await; + let cache = self.state.inner.mixnodes.get_locations().await; let three_letter_iso_country_codes: Vec = cache .values() diff --git a/explorer-api/src/country_statistics/geolocate.rs b/explorer-api/src/country_statistics/geolocate.rs index 55cd428798..170d4f5cc1 100644 --- a/explorer-api/src/country_statistics/geolocate.rs +++ b/explorer-api/src/country_statistics/geolocate.rs @@ -41,7 +41,7 @@ impl GeoLocateTask { let mixnode_bonds = self .state .inner - .mix_nodes + .mixnodes .get_mixnodes() .await .unwrap_or_default(); @@ -50,7 +50,7 @@ impl GeoLocateTask { if self .state .inner - .mix_nodes + .mixnodes .is_location_valid(&cache_item.mix_node.identity_key) .await { @@ -79,7 +79,7 @@ impl GeoLocateTask { self.state .inner - .mix_nodes + .mixnodes .set_location(&cache_item.mix_node.identity_key, Some(location)) .await; @@ -98,7 +98,7 @@ impl GeoLocateTask { ); self.state .inner - .mix_nodes + .mixnodes .set_location(&cache_item.mix_node.identity_key, None) .await; }, diff --git a/explorer-api/src/gateways/http.rs b/explorer-api/src/gateways/http.rs new file mode 100644 index 0000000000..084507eed5 --- /dev/null +++ b/explorer-api/src/gateways/http.rs @@ -0,0 +1,24 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use rocket::response::status::NotFound; +use rocket::serde::json::Json; +use rocket::{Route, State}; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::openapi_get_routes_spec; +use rocket_okapi::settings::OpenApiSettings; + +use crate::state::ExplorerApiStateContext; +use mixnet_contract_common::GatewayBond; + +pub fn gateways_make_default_routes(settings: &OpenApiSettings) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: list] +} + +#[openapi(tag = "gateways")] +#[get("/")] +pub(crate) async fn list( + state: &State, +) -> Result>, NotFound> { + Ok(Json(state.inner.gateways.get_gateways().await)) +} diff --git a/explorer-api/src/gateways/mod.rs b/explorer-api/src/gateways/mod.rs new file mode 100644 index 0000000000..5df938f83c --- /dev/null +++ b/explorer-api/src/gateways/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod http; +pub(crate) mod models; diff --git a/explorer-api/src/gateways/models.rs b/explorer-api/src/gateways/models.rs new file mode 100644 index 0000000000..690aa7cb7e --- /dev/null +++ b/explorer-api/src/gateways/models.rs @@ -0,0 +1,55 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use serde::Serialize; +use tokio::sync::RwLock; + +use mixnet_contract_common::GatewayBond; + +use crate::cache::Cache; + +pub(crate) struct GatewayCache { + pub(crate) gateways: Cache, +} + +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct GatewaySummary { + pub count: usize, +} + +#[derive(Clone)] +pub(crate) struct ThreadsafeGatewayCache { + inner: Arc>, +} + +impl ThreadsafeGatewayCache { + pub(crate) fn new() -> Self { + ThreadsafeGatewayCache { + inner: Arc::new(RwLock::new(GatewayCache { + gateways: Cache::new(), + })), + } + } + + pub(crate) async fn get_gateways(&self) -> Vec { + self.inner.read().await.gateways.get_all() + } + + pub(crate) async fn get_gateway_summary(&self) -> GatewaySummary { + GatewaySummary { + count: self.inner.read().await.gateways.len(), + } + } + + pub(crate) async fn update_cache(&self, gateways: Vec) { + let mut guard = self.inner.write().await; + + for gateway in gateways { + guard + .gateways + .set(gateway.clone().gateway.identity_key.as_str(), gateway) + } + } +} diff --git a/explorer-api/src/http/mod.rs b/explorer-api/src/http/mod.rs index 0cb78e6b4e..e75aec8762 100644 --- a/explorer-api/src/http/mod.rs +++ b/explorer-api/src/http/mod.rs @@ -1,15 +1,19 @@ use log::info; +use okapi::openapi3::OpenApi; use rocket::http::Method; use rocket::{Build, Request, Rocket}; use rocket_cors::{AllowedHeaders, AllowedOrigins}; use rocket_okapi::swagger_ui::make_swagger_ui; use crate::country_statistics::http::country_statistics_make_default_routes; +use crate::gateways::http::gateways_make_default_routes; use crate::http::swagger::get_docs; use crate::mix_node::http::mix_node_make_default_routes; use crate::mix_nodes::http::mix_nodes_make_default_routes; +use crate::overview::http::overview_make_default_routes; use crate::ping::http::ping_make_default_routes; use crate::state::ExplorerApiStateContext; +use crate::validators::http::validators_make_default_routes; mod swagger; @@ -38,14 +42,20 @@ fn configure_rocket(state: ExplorerApiStateContext) -> Rocket { let config = rocket::config::Config::release_default(); let mut building_rocket = rocket::build().configure(config); + let custom_route_spec = (vec![], custom_openapi_spec()); + mount_endpoints_and_merged_docs! { building_rocket, "/v1".to_owned(), openapi_settings, - "/ping" => ping_make_default_routes(&openapi_settings), + "/" => custom_route_spec, "/countries" => country_statistics_make_default_routes(&openapi_settings), + "/gateways" => gateways_make_default_routes(&openapi_settings), "/mix-node" => mix_node_make_default_routes(&openapi_settings), "/mix-nodes" => mix_nodes_make_default_routes(&openapi_settings), + "/overview" => overview_make_default_routes(&openapi_settings), + "/ping" => ping_make_default_routes(&openapi_settings), + "/validators" => validators_make_default_routes(&openapi_settings), }; building_rocket @@ -59,3 +69,32 @@ fn configure_rocket(state: ExplorerApiStateContext) -> Rocket { pub(crate) fn not_found(req: &Request) -> String { format!("I couldn't find '{}'. Try something else?", req.uri()) } + +fn custom_openapi_spec() -> OpenApi { + use rocket_okapi::okapi::openapi3::*; + OpenApi { + openapi: OpenApi::default_version(), + info: Info { + title: "Network Explorer API".to_owned(), + description: None, + terms_of_service: None, + contact: None, + license: None, + version: env!("CARGO_PKG_VERSION").to_owned(), + ..Default::default() + }, + servers: get_servers(), + ..Default::default() + } +} + +fn get_servers() -> Vec { + if std::env::var_os("CARGO").is_some() { + return vec![]; + } + return vec![rocket_okapi::okapi::openapi3::Server { + url: std::env::var("OPEN_API_BASE").unwrap_or_else(|_| "/api/v1/".to_owned()), + description: Some("API".to_owned()), + ..Default::default() + }]; +} diff --git a/explorer-api/src/main.rs b/explorer-api/src/main.rs index 999e9f3fc7..8ec90aaf4b 100644 --- a/explorer-api/src/main.rs +++ b/explorer-api/src/main.rs @@ -5,12 +5,18 @@ extern crate rocket_okapi; use log::info; +pub(crate) mod cache; +mod client; mod country_statistics; +mod gateways; mod http; mod mix_node; -mod mix_nodes; +pub(crate) mod mix_nodes; +mod overview; mod ping; mod state; +mod tasks; +mod validators; const GEO_IP_SERVICE: &str = "https://api.freegeoip.app/json"; const COUNTRY_DATA_REFRESH_INTERVAL: u64 = 60 * 15; // every 15 minutes @@ -40,7 +46,7 @@ impl ExplorerApi { info!("Using validator API - {}", validator_api_url); // spawn concurrent tasks - mix_nodes::tasks::MixNodesTasks::new(self.state.clone(), validator_api_url).start(); + crate::tasks::ExplorerApiTasks::new(self.state.clone()).start(); country_statistics::distribution::CountryStatisticsDistributionTask::new( self.state.clone(), ) diff --git a/explorer-api/src/mix_node/http.rs b/explorer-api/src/mix_node/http.rs index c3888f393e..d948327f2a 100644 --- a/explorer-api/src/mix_node/http.rs +++ b/explorer-api/src/mix_node/http.rs @@ -1,26 +1,47 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::mix_node::models::{NodeDescription, NodeStats}; -use crate::mix_nodes::delegations::{get_mixnode_delegations, get_single_mixnode_delegations}; -use crate::state::ExplorerApiStateContext; -use mixnet_contract_common::Delegation; use reqwest::Error as ReqwestError; +use rocket::response::status::NotFound; use rocket::serde::json::Json; use rocket::{Route, State}; use rocket_okapi::okapi::openapi3::OpenApi; use rocket_okapi::openapi_get_routes_spec; use rocket_okapi::settings::OpenApiSettings; +use mixnet_contract_common::Delegation; + +use crate::mix_node::models::{NodeDescription, NodeStats, PrettyDetailedMixNodeBond}; +use crate::mix_nodes::delegations::{get_mixnode_delegations, get_single_mixnode_delegations}; +use crate::state::ExplorerApiStateContext; + pub fn mix_node_make_default_routes(settings: &OpenApiSettings) -> (Vec, OpenApi) { openapi_get_routes_spec![ settings: get_delegations, + get_by_id, get_all_delegations, get_description, get_stats, ] } +#[openapi(tag = "mix_nodes")] +#[get("/")] +pub(crate) async fn get_by_id( + pubkey: &str, + state: &State, +) -> Result, NotFound> { + match state + .inner + .mixnodes + .get_detailed_mixnode_by_id(pubkey) + .await + { + Some(mixnode) => Ok(Json(mixnode)), + None => Err(NotFound("Mixnode not found".to_string())), + } +} + #[openapi(tag = "mix_node")] #[get("//delegations")] pub(crate) async fn get_delegations(pubkey: &str) -> Json> { @@ -39,13 +60,7 @@ pub(crate) async fn get_description( pubkey: &str, state: &State, ) -> Option> { - match state - .inner - .mix_node_cache - .clone() - .get_description(pubkey) - .await - { + match state.inner.mixnode.clone().get_description(pubkey).await { Some(cache_value) => { trace!("Returning cached value for {}", pubkey); Some(Json(cache_value)) @@ -64,7 +79,7 @@ pub(crate) async fn get_description( // cache the response and return as the HTTP response state .inner - .mix_node_cache + .mixnode .set_description(pubkey, response.clone()) .await; Some(Json(response)) @@ -90,7 +105,7 @@ pub(crate) async fn get_stats( pubkey: &str, state: &State, ) -> Option> { - match state.inner.mix_node_cache.get_node_stats(pubkey).await { + match state.inner.mixnode.get_node_stats(pubkey).await { Some(cache_value) => { trace!("Returning cached value for {}", pubkey); Some(Json(cache_value)) @@ -106,7 +121,7 @@ pub(crate) async fn get_stats( // cache the response and return as the HTTP response state .inner - .mix_node_cache + .mixnode .set_node_stats(pubkey, response.clone()) .await; Some(Json(response)) diff --git a/explorer-api/src/mix_node/mod.rs b/explorer-api/src/mix_node/mod.rs index b57132ec2c..5df938f83c 100644 --- a/explorer-api/src/mix_node/mod.rs +++ b/explorer-api/src/mix_node/mod.rs @@ -1,3 +1,2 @@ -mod cache; pub(crate) mod http; pub(crate) mod models; diff --git a/explorer-api/src/mix_node/models.rs b/explorer-api/src/mix_node/models.rs index 835bfe3991..c99f1ac77e 100644 --- a/explorer-api/src/mix_node/models.rs +++ b/explorer-api/src/mix_node/models.rs @@ -1,7 +1,7 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::mix_node::cache::Cache; +use crate::cache::Cache; use crate::mix_nodes::location::Location; use mixnet_contract_common::{Addr, Coin, Layer, MixNode}; use serde::Deserialize; @@ -10,7 +10,7 @@ use std::sync::Arc; use std::time::SystemTime; use tokio::sync::RwLock; -#[derive(Clone, Debug, Serialize, JsonSchema)] +#[derive(Clone, Debug, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub(crate) enum MixnodeStatus { Active, // in both the active set and the rewarded set diff --git a/explorer-api/src/mix_nodes/delegations.rs b/explorer-api/src/mix_nodes/delegations.rs index a6f0ee1c05..00fd75ccab 100644 --- a/explorer-api/src/mix_nodes/delegations.rs +++ b/explorer-api/src/mix_nodes/delegations.rs @@ -4,7 +4,7 @@ use mixnet_contract_common::Delegation; pub(crate) async fn get_single_mixnode_delegations(pubkey: &str) -> Vec { - let client = super::utils::new_nymd_client(); + let client = crate::client::new_nymd_client(); let delegates = match client .get_all_nymd_single_mixnode_delegations(pubkey.to_string()) .await @@ -19,7 +19,7 @@ pub(crate) async fn get_single_mixnode_delegations(pubkey: &str) -> Vec Vec { - let client = super::utils::new_nymd_client(); + let client = crate::client::new_nymd_client(); let delegates = match client.get_all_network_delegations().await { Ok(result) => result, Err(e) => { diff --git a/explorer-api/src/mix_nodes/http.rs b/explorer-api/src/mix_nodes/http.rs index a2b8b0eaba..6e57e568c7 100644 --- a/explorer-api/src/mix_nodes/http.rs +++ b/explorer-api/src/mix_nodes/http.rs @@ -1,4 +1,5 @@ -use crate::mix_node::models::PrettyDetailedMixNodeBond; +use crate::mix_node::models::{MixnodeStatus, PrettyDetailedMixNodeBond}; +use crate::mix_nodes::models::{MixNodeActiveSetSummary, MixNodeSummary}; use crate::state::ExplorerApiStateContext; use rocket::serde::json::Json; use rocket::{Route, State}; @@ -7,7 +8,13 @@ use rocket_okapi::openapi_get_routes_spec; use rocket_okapi::settings::OpenApiSettings; pub fn mix_nodes_make_default_routes(settings: &OpenApiSettings) -> (Vec, OpenApi) { - openapi_get_routes_spec![settings: list] + openapi_get_routes_spec![ + settings: list, + list_active_set, + list_inactive_set, + list_standby_set, + summary + ] } #[openapi(tag = "mix_nodes")] @@ -15,5 +22,69 @@ pub fn mix_nodes_make_default_routes(settings: &OpenApiSettings) -> (Vec, pub(crate) async fn list( state: &State, ) -> Json> { - Json(state.inner.mix_nodes.get_detailed_mixnodes().await) + Json(state.inner.mixnodes.get_detailed_mixnodes().await) +} + +#[openapi(tag = "mix_nodes")] +#[get("/active-set/active")] +pub(crate) async fn list_active_set( + state: &State, +) -> Json> { + Json(get_mixnodes_by_status( + state.inner.mixnodes.get_detailed_mixnodes().await, + MixnodeStatus::Active, + )) +} + +#[openapi(tag = "mix_nodes")] +#[get("/active-set/inactive")] +pub(crate) async fn list_inactive_set( + state: &State, +) -> Json> { + Json(get_mixnodes_by_status( + state.inner.mixnodes.get_detailed_mixnodes().await, + MixnodeStatus::Inactive, + )) +} + +#[openapi(tag = "mix_nodes")] +#[get("/active-set/standby")] +pub(crate) async fn list_standby_set( + state: &State, +) -> Json> { + Json(get_mixnodes_by_status( + state.inner.mixnodes.get_detailed_mixnodes().await, + MixnodeStatus::Standby, + )) +} + +#[openapi(tag = "mix_nodes")] +#[get("/summary")] +pub(crate) async fn summary(state: &State) -> Json { + Json(get_mixnode_summary(state).await) +} + +pub(crate) async fn get_mixnode_summary(state: &State) -> MixNodeSummary { + let mixnodes = state.inner.mixnodes.get_detailed_mixnodes().await; + let active = get_mixnodes_by_status(mixnodes.clone(), MixnodeStatus::Active).len(); + let standby = get_mixnodes_by_status(mixnodes.clone(), MixnodeStatus::Standby).len(); + let inactive = get_mixnodes_by_status(mixnodes.clone(), MixnodeStatus::Inactive).len(); + MixNodeSummary { + count: mixnodes.len(), + activeset: MixNodeActiveSetSummary { + active, + standby, + inactive, + }, + } +} + +fn get_mixnodes_by_status( + all_mixnodes: Vec, + status: MixnodeStatus, +) -> Vec { + all_mixnodes + .into_iter() + .filter(|mixnode| mixnode.status == status) + .collect() } diff --git a/explorer-api/src/mix_nodes/mod.rs b/explorer-api/src/mix_nodes/mod.rs index dacff38e32..cd25a4ca43 100644 --- a/explorer-api/src/mix_nodes/mod.rs +++ b/explorer-api/src/mix_nodes/mod.rs @@ -7,8 +7,7 @@ pub(crate) mod delegations; pub(crate) mod http; pub(crate) mod location; pub(crate) mod models; -pub(crate) mod tasks; pub(crate) mod utils; -pub(crate) const MIXNODES_CACHE_REFRESH_RATE: Duration = Duration::from_secs(30); -pub(crate) const MIXNODES_CACHE_ENTRY_TTL: Duration = Duration::from_secs(60); +pub(crate) const CACHE_REFRESH_RATE: Duration = Duration::from_secs(30); +pub(crate) const CACHE_ENTRY_TTL: Duration = Duration::from_secs(60); diff --git a/explorer-api/src/mix_nodes/models.rs b/explorer-api/src/mix_nodes/models.rs index 7a07131fc1..bd2ebe4fe8 100644 --- a/explorer-api/src/mix_nodes/models.rs +++ b/explorer-api/src/mix_nodes/models.rs @@ -1,15 +1,32 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::mix_node::models::{MixnodeStatus, PrettyDetailedMixNodeBond}; -use crate::mix_nodes::location::{Location, LocationCache, LocationCacheItem}; -use crate::mix_nodes::MIXNODES_CACHE_ENTRY_TTL; -use mixnet_contract_common::MixNodeBond; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, SystemTime}; + +use serde::Serialize; use tokio::sync::RwLock; +use mixnet_contract_common::MixNodeBond; + +use crate::mix_node::models::{MixnodeStatus, PrettyDetailedMixNodeBond}; +use crate::mix_nodes::location::{Location, LocationCache, LocationCacheItem}; +use crate::mix_nodes::CACHE_ENTRY_TTL; + +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct MixNodeActiveSetSummary { + pub active: usize, + pub standby: usize, + pub inactive: usize, +} + +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct MixNodeSummary { + pub count: usize, + pub activeset: MixNodeActiveSetSummary, +} + #[derive(Clone, Debug)] pub(crate) struct MixNodesResult { pub(crate) valid_until: SystemTime, @@ -60,28 +77,28 @@ impl MixNodesResult { } #[derive(Clone)] -pub(crate) struct ThreadsafeMixNodesResult { - mixnode_results: Arc>, - location_cache: Arc>, +pub(crate) struct ThreadsafeMixNodesCache { + mixnodes: Arc>, + locations: Arc>, } -impl ThreadsafeMixNodesResult { +impl ThreadsafeMixNodesCache { pub(crate) fn new() -> Self { - ThreadsafeMixNodesResult { - mixnode_results: Arc::new(RwLock::new(MixNodesResult::new())), - location_cache: Arc::new(RwLock::new(LocationCache::new())), + ThreadsafeMixNodesCache { + mixnodes: Arc::new(RwLock::new(MixNodesResult::new())), + locations: Arc::new(RwLock::new(LocationCache::new())), } } - pub(crate) fn new_with_location_cache(location_cache: LocationCache) -> Self { - ThreadsafeMixNodesResult { - mixnode_results: Arc::new(RwLock::new(MixNodesResult::new())), - location_cache: Arc::new(RwLock::new(location_cache)), + pub(crate) fn new_with_location_cache(locations: LocationCache) -> Self { + ThreadsafeMixNodesCache { + mixnodes: Arc::new(RwLock::new(MixNodesResult::new())), + locations: Arc::new(RwLock::new(locations)), } } pub(crate) async fn is_location_valid(&self, identity_key: &str) -> bool { - self.location_cache + self.locations .read() .await .get(identity_key) @@ -89,29 +106,53 @@ impl ThreadsafeMixNodesResult { .unwrap_or(false) } - pub(crate) async fn get_location_cache(&self) -> LocationCache { - self.location_cache.read().await.clone() + pub(crate) async fn get_locations(&self) -> LocationCache { + self.locations.read().await.clone() } pub(crate) async fn set_location(&self, identity_key: &str, location: Option) { // cache the location for this mix node so that it can be used when the mix node list is refreshed - self.location_cache.write().await.insert( + self.locations.write().await.insert( identity_key.to_string(), LocationCacheItem::new_from_location(location), ); } pub(crate) async fn get_mixnode(&self, pubkey: &str) -> Option { - self.mixnode_results.read().await.get_mixnode(pubkey) + self.mixnodes.read().await.get_mixnode(pubkey) } pub(crate) async fn get_mixnodes(&self) -> Option> { - self.mixnode_results.read().await.get_mixnodes() + self.mixnodes.read().await.get_mixnodes() + } + + pub(crate) async fn get_detailed_mixnode_by_id( + &self, + identity_key: &str, + ) -> Option { + let mixnodes_guard = self.mixnodes.read().await; + let location_guard = self.locations.read().await; + + let bond = mixnodes_guard.get_mixnode(identity_key); + let location = location_guard.get(identity_key); + + match bond { + Some(bond) => Some(PrettyDetailedMixNodeBond { + location: location.and_then(|l| l.location.clone()), + status: mixnodes_guard.determine_node_status(&bond.mix_node.identity_key), + pledge_amount: bond.pledge_amount, + total_delegation: bond.total_delegation, + owner: bond.owner, + layer: bond.layer, + mix_node: bond.mix_node, + }), + None => None, + } } pub(crate) async fn get_detailed_mixnodes(&self) -> Vec { - let mixnodes_guard = self.mixnode_results.read().await; - let location_guard = self.location_cache.read().await; + let mixnodes_guard = self.mixnodes.read().await; + let location_guard = self.locations.read().await; mixnodes_guard .all_mixnodes @@ -138,13 +179,13 @@ impl ThreadsafeMixNodesResult { rewarded_nodes: HashSet, active_nodes: HashSet, ) { - let mut guard = self.mixnode_results.write().await; + let mut guard = self.mixnodes.write().await; guard.all_mixnodes = all_bonds .into_iter() .map(|bond| (bond.mix_node.identity_key.to_string(), bond)) .collect(); guard.rewarded_mixnodes = rewarded_nodes; guard.active_mixnodes = active_nodes; - guard.valid_until = SystemTime::now() + MIXNODES_CACHE_ENTRY_TTL; + guard.valid_until = SystemTime::now() + CACHE_ENTRY_TTL; } } diff --git a/explorer-api/src/mix_nodes/tasks.rs b/explorer-api/src/mix_nodes/tasks.rs deleted file mode 100644 index b6257c1660..0000000000 --- a/explorer-api/src/mix_nodes/tasks.rs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2022 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use crate::mix_nodes::MIXNODES_CACHE_REFRESH_RATE; -use crate::state::ExplorerApiStateContext; -use mixnet_contract_common::MixNodeBond; -use reqwest::Url; -use std::future::Future; -use validator_client::ValidatorClientError; - -pub(crate) struct MixNodesTasks { - state: ExplorerApiStateContext, - validator_api_client: validator_client::ApiClient, -} - -impl MixNodesTasks { - pub(crate) fn new(state: ExplorerApiStateContext, validator_api_endpoint: Url) -> Self { - MixNodesTasks { - state, - validator_api_client: validator_client::ApiClient::new(validator_api_endpoint), - } - } - - // a helper to remove duplicate code when grabbing active/rewarded/all mixnodes - async fn retrieve_mixnodes<'a, F, Fut>(&'a self, f: F) -> Vec - where - F: FnOnce(&'a validator_client::ApiClient) -> Fut, - Fut: Future, ValidatorClientError>>, - { - let bonds = match f(&self.validator_api_client).await { - Ok(result) => result, - Err(e) => { - error!("Unable to retrieve mixnode bonds: {:?}", e); - vec![] - } - }; - - info!("Fetched {} mixnode bonds", bonds.len()); - bonds - } - - async fn retrieve_all_mixnodes(&self) -> Vec { - info!("About to retrieve all mixnode bonds..."); - self.retrieve_mixnodes(validator_client::ApiClient::get_cached_mixnodes) - .await - } - - async fn retrieve_rewarded_mixnodes(&self) -> Vec { - info!("About to retrieve rewarded mixnode bonds..."); - self.retrieve_mixnodes(validator_client::ApiClient::get_cached_rewarded_mixnodes) - .await - } - - async fn retrieve_active_mixnodes(&self) -> Vec { - info!("About to retrieve active mixnode bonds..."); - self.retrieve_mixnodes(validator_client::ApiClient::get_cached_active_mixnodes) - .await - } - - async fn update_mixnode_cache(&self) { - let all_bonds = self.retrieve_all_mixnodes().await; - let rewarded_nodes = self - .retrieve_rewarded_mixnodes() - .await - .into_iter() - .map(|bond| bond.mix_node.identity_key) - .collect(); - let active_nodes = self - .retrieve_active_mixnodes() - .await - .into_iter() - .map(|bond| bond.mix_node.identity_key) - .collect(); - self.state - .inner - .mix_nodes - .update_cache(all_bonds, rewarded_nodes, active_nodes) - .await; - } - - pub(crate) fn start(self) { - info!("Spawning mix nodes task runner..."); - tokio::spawn(async move { - let mut interval_timer = tokio::time::interval(MIXNODES_CACHE_REFRESH_RATE); - loop { - // wait for the next interval tick - interval_timer.tick().await; - - info!("Updating mix node cache..."); - self.update_mixnode_cache().await; - info!("Done"); - } - }); - } -} diff --git a/explorer-api/src/mix_nodes/utils.rs b/explorer-api/src/mix_nodes/utils.rs index 542bfe8660..a151ec318a 100644 --- a/explorer-api/src/mix_nodes/utils.rs +++ b/explorer-api/src/mix_nodes/utils.rs @@ -3,10 +3,6 @@ use crate::mix_nodes::location::GeoLocation; use isocountry::CountryCode; -use network_defaults::{ - default_api_endpoints, default_nymd_endpoints, DEFAULT_MIXNET_CONTRACT_ADDRESS, -}; -use validator_client::nymd::QueryNymdClient; pub(crate) fn map_2_letter_to_3_letter_country_code(geo: &GeoLocation) -> String { match CountryCode::for_alpha2(&geo.country_code) { @@ -20,18 +16,3 @@ pub(crate) fn map_2_letter_to_3_letter_country_code(geo: &GeoLocation) -> String } } } - -pub(crate) fn new_nymd_client() -> validator_client::Client { - let mixnet_contract = DEFAULT_MIXNET_CONTRACT_ADDRESS.to_string(); - let nymd_url = default_nymd_endpoints()[0].clone(); - let api_url = default_api_endpoints()[0].clone(); - - let client_config = validator_client::Config::new( - nymd_url, - api_url, - Some(mixnet_contract.parse().unwrap()), - None, - ); - - validator_client::Client::new_query(client_config).expect("Failed to connect to nymd!") -} diff --git a/explorer-api/src/node_numbers/http.rs b/explorer-api/src/node_numbers/http.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/explorer-api/src/node_numbers/network_size_recorder.rs b/explorer-api/src/node_numbers/network_size_recorder.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/explorer-api/src/overview/http.rs b/explorer-api/src/overview/http.rs new file mode 100644 index 0000000000..a5cfb127c5 --- /dev/null +++ b/explorer-api/src/overview/http.rs @@ -0,0 +1,23 @@ +use rocket::serde::json::Json; +use rocket::{Route, State}; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::openapi_get_routes_spec; +use rocket_okapi::settings::OpenApiSettings; + +use crate::mix_nodes::http::get_mixnode_summary; +use crate::overview::models::OverviewSummary; +use crate::state::ExplorerApiStateContext; + +pub fn overview_make_default_routes(settings: &OpenApiSettings) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: summary] +} + +#[openapi(tag = "overview")] +#[get("/summary")] +pub(crate) async fn summary(state: &State) -> Json { + Json(OverviewSummary { + mixnodes: get_mixnode_summary(state).await, + validators: state.inner.validators.get_validator_summary().await, + gateways: state.inner.gateways.get_gateway_summary().await, + }) +} diff --git a/explorer-api/src/overview/mod.rs b/explorer-api/src/overview/mod.rs new file mode 100644 index 0000000000..058ccb1d8b --- /dev/null +++ b/explorer-api/src/overview/mod.rs @@ -0,0 +1,5 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) mod http; +mod models; diff --git a/explorer-api/src/overview/models.rs b/explorer-api/src/overview/models.rs new file mode 100644 index 0000000000..b396e2e3fb --- /dev/null +++ b/explorer-api/src/overview/models.rs @@ -0,0 +1,15 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::Serialize; + +use crate::gateways::models::GatewaySummary; +use crate::mix_nodes::models::MixNodeSummary; +use crate::validators::models::ValidatorSummary; + +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct OverviewSummary { + pub mixnodes: MixNodeSummary, + pub gateways: GatewaySummary, + pub validators: ValidatorSummary, +} diff --git a/explorer-api/src/ping/http.rs b/explorer-api/src/ping/http.rs index 67a2730bb7..379084436e 100644 --- a/explorer-api/src/ping/http.rs +++ b/explorer-api/src/ping/http.rs @@ -25,7 +25,7 @@ pub(crate) async fn index( pubkey: &str, state: &State, ) -> Option> { - match state.inner.ping_cache.clone().get(pubkey).await { + match state.inner.ping.clone().get(pubkey).await { Some(cache_value) => { trace!("Returning cached value for {}", pubkey); Some(Json(PingResponse { @@ -39,7 +39,7 @@ pub(crate) async fn index( match state.inner.get_mix_node(pubkey).await { Some(bond) => { // set status to pending, so that any HTTP requests are pending - state.inner.ping_cache.set_pending(pubkey).await; + state.inner.ping.set_pending(pubkey).await; // do the check let ports = Some(port_check(&bond).await); @@ -51,7 +51,7 @@ pub(crate) async fn index( // cache for 1 min trace!("Caching value for {}", pubkey); - state.inner.ping_cache.set(pubkey, response.clone()).await; + state.inner.ping.set(pubkey, response.clone()).await; // return response Some(Json(response)) diff --git a/explorer-api/src/state.rs b/explorer-api/src/state.rs index 676c4f9423..b971f76269 100644 --- a/explorer-api/src/state.rs +++ b/explorer-api/src/state.rs @@ -8,27 +8,31 @@ use serde::{Deserialize, Serialize}; use mixnet_contract_common::MixNodeBond; use crate::country_statistics::country_nodes_distribution::{ - ConcurrentCountryNodesDistribution, CountryNodesDistribution, + CountryNodesDistribution, ThreadsafeCountryNodesDistribution, }; +use crate::gateways::models::ThreadsafeGatewayCache; use crate::mix_node::models::ThreadsafeMixNodeCache; use crate::mix_nodes::location::LocationCache; -use crate::mix_nodes::models::ThreadsafeMixNodesResult; +use crate::mix_nodes::models::ThreadsafeMixNodesCache; use crate::ping::models::ThreadsafePingCache; +use crate::validators::models::ThreadsafeValidatorCache; // TODO: change to an environment variable with a default value const STATE_FILE: &str = "explorer-api-state.json"; #[derive(Clone)] pub struct ExplorerApiState { - pub(crate) country_node_distribution: ConcurrentCountryNodesDistribution, - pub(crate) mix_nodes: ThreadsafeMixNodesResult, - pub(crate) mix_node_cache: ThreadsafeMixNodeCache, - pub(crate) ping_cache: ThreadsafePingCache, + pub(crate) country_node_distribution: ThreadsafeCountryNodesDistribution, + pub(crate) gateways: ThreadsafeGatewayCache, + pub(crate) mixnode: ThreadsafeMixNodeCache, + pub(crate) mixnodes: ThreadsafeMixNodesCache, + pub(crate) ping: ThreadsafePingCache, + pub(crate) validators: ThreadsafeValidatorCache, } impl ExplorerApiState { pub(crate) async fn get_mix_node(&self, pubkey: &str) -> Option { - self.mix_nodes.get_mixnode(pubkey).await + self.mixnodes.get_mixnode(pubkey).await } } @@ -61,14 +65,16 @@ impl ExplorerApiStateContext { info!("Loaded state from file {:?}: {:?}", json_file, state); ExplorerApiState { country_node_distribution: - ConcurrentCountryNodesDistribution::new_from_distribution( + ThreadsafeCountryNodesDistribution::new_from_distribution( state.country_node_distribution, ), - mix_nodes: ThreadsafeMixNodesResult::new_with_location_cache( + gateways: ThreadsafeGatewayCache::new(), + mixnode: ThreadsafeMixNodeCache::new(), + mixnodes: ThreadsafeMixNodesCache::new_with_location_cache( state.location_cache, ), - mix_node_cache: ThreadsafeMixNodeCache::new(), - ping_cache: ThreadsafePingCache::new(), + ping: ThreadsafePingCache::new(), + validators: ThreadsafeValidatorCache::new(), } } _ => { @@ -78,10 +84,12 @@ impl ExplorerApiStateContext { ); ExplorerApiState { - country_node_distribution: ConcurrentCountryNodesDistribution::new(), - mix_nodes: ThreadsafeMixNodesResult::new(), - mix_node_cache: ThreadsafeMixNodeCache::new(), - ping_cache: ThreadsafePingCache::new(), + country_node_distribution: ThreadsafeCountryNodesDistribution::new(), + gateways: ThreadsafeGatewayCache::new(), + mixnode: ThreadsafeMixNodeCache::new(), + mixnodes: ThreadsafeMixNodesCache::new(), + ping: ThreadsafePingCache::new(), + validators: ThreadsafeValidatorCache::new(), } } } @@ -93,7 +101,7 @@ impl ExplorerApiStateContext { let file = File::create(json_file_path).expect("unable to create state json file"); let state = ExplorerApiStateOnDisk { country_node_distribution: self.inner.country_node_distribution.get_all().await, - location_cache: self.inner.mix_nodes.get_location_cache().await, + location_cache: self.inner.mixnodes.get_locations().await, as_at: Utc::now(), }; serde_json::to_writer(file, &state).expect("error writing state to disk"); diff --git a/explorer-api/src/tasks.rs b/explorer-api/src/tasks.rs new file mode 100644 index 0000000000..1f09074285 --- /dev/null +++ b/explorer-api/src/tasks.rs @@ -0,0 +1,146 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::future::Future; + +use mixnet_contract_common::{GatewayBond, MixNodeBond}; +use validator_client::nymd::error::NymdError; +use validator_client::nymd::{Paging, QueryNymdClient, ValidatorResponse}; +use validator_client::ValidatorClientError; + +use crate::client::new_nymd_client; +use crate::mix_nodes::CACHE_REFRESH_RATE; +use crate::state::ExplorerApiStateContext; + +pub(crate) struct ExplorerApiTasks { + state: ExplorerApiStateContext, + validator_client: validator_client::Client, +} + +impl ExplorerApiTasks { + pub(crate) fn new(state: ExplorerApiStateContext) -> Self { + ExplorerApiTasks { + state, + validator_client: new_nymd_client(), + } + } + + // a helper to remove duplicate code when grabbing active/rewarded/all mixnodes + async fn retrieve_mixnodes<'a, F, Fut>(&'a self, f: F) -> Vec + where + F: FnOnce(&'a validator_client::Client) -> Fut, + Fut: Future, ValidatorClientError>>, + { + let bonds = match f(&self.validator_client).await { + Ok(result) => result, + Err(e) => { + error!("Unable to retrieve mixnode bonds: {:?}", e); + vec![] + } + }; + + info!("Fetched {} mixnode bonds", bonds.len()); + bonds + } + + async fn retrieve_all_mixnodes(&self) -> Vec { + info!("About to retrieve all mixnode bonds..."); + self.retrieve_mixnodes(validator_client::Client::get_cached_mixnodes) + .await + } + + async fn retrieve_all_gateways(&self) -> Result, ValidatorClientError> { + info!("About to retrieve all gateways..."); + self.validator_client.get_cached_gateways().await + } + + async fn retrieve_all_validators(&self) -> Result { + info!("About to retrieve all validators..."); + let height = self + .validator_client + .nymd + .get_current_block_height() + .await?; + let response: ValidatorResponse = self + .validator_client + .nymd + .get_validators(height.value(), Paging::All) + .await?; + info!("Fetched {} validators", response.validators.len()); + Ok(response) + } + + async fn retrieve_rewarded_mixnodes(&self) -> Vec { + info!("About to retrieve rewarded mixnode bonds..."); + self.retrieve_mixnodes(validator_client::Client::get_cached_rewarded_mixnodes) + .await + } + + async fn retrieve_active_mixnodes(&self) -> Vec { + info!("About to retrieve active mixnode bonds..."); + self.retrieve_mixnodes(validator_client::Client::get_cached_active_mixnodes) + .await + } + + async fn update_mixnode_cache(&self) { + let all_bonds = self.retrieve_all_mixnodes().await; + let rewarded_nodes = self + .retrieve_rewarded_mixnodes() + .await + .into_iter() + .map(|bond| bond.mix_node.identity_key) + .collect(); + let active_nodes = self + .retrieve_active_mixnodes() + .await + .into_iter() + .map(|bond| bond.mix_node.identity_key) + .collect(); + self.state + .inner + .mixnodes + .update_cache(all_bonds, rewarded_nodes, active_nodes) + .await; + } + + async fn update_validators_cache(&self) { + match self.retrieve_all_validators().await { + Ok(response) => self.state.inner.validators.update_cache(response).await, + Err(e) => { + error!("Failed to get validators: {:?}", e) + } + } + } + + async fn update_gateways_cache(&self) { + match self.retrieve_all_gateways().await { + Ok(response) => self.state.inner.gateways.update_cache(response).await, + Err(e) => { + error!("Failed to get gateways: {:?}", e) + } + } + } + + pub(crate) fn start(self) { + info!("Spawning mix nodes task runner..."); + tokio::spawn(async move { + let mut interval_timer = tokio::time::interval(CACHE_REFRESH_RATE); + loop { + // wait for the next interval tick + interval_timer.tick().await; + + info!("Updating validator cache..."); + self.update_validators_cache().await; + info!("Done"); + + info!("Updating gateway cache..."); + self.update_gateways_cache().await; + info!("Done"); + + info!("Updating mix node cache..."); + self.update_mixnode_cache().await; + info!("Done"); + } + }); + } +} diff --git a/explorer-api/src/validators/http.rs b/explorer-api/src/validators/http.rs new file mode 100644 index 0000000000..88a06eea01 --- /dev/null +++ b/explorer-api/src/validators/http.rs @@ -0,0 +1,24 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use rocket::response::status::NotFound; +use rocket::serde::json::Json; +use rocket::{Route, State}; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::openapi_get_routes_spec; +use rocket_okapi::settings::OpenApiSettings; + +use crate::state::ExplorerApiStateContext; +use crate::validators::models::PrettyValidatorInfo; + +pub fn validators_make_default_routes(settings: &OpenApiSettings) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: list] +} + +#[openapi(tag = "validators")] +#[get("/")] +pub(crate) async fn list( + state: &State, +) -> Result>, NotFound> { + Ok(Json(state.inner.validators.get_validators().await)) +} diff --git a/explorer-api/src/validators/mod.rs b/explorer-api/src/validators/mod.rs new file mode 100644 index 0000000000..5df938f83c --- /dev/null +++ b/explorer-api/src/validators/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod http; +pub(crate) mod models; diff --git a/explorer-api/src/validators/models.rs b/explorer-api/src/validators/models.rs new file mode 100644 index 0000000000..4f17cbe14a --- /dev/null +++ b/explorer-api/src/validators/models.rs @@ -0,0 +1,79 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use serde::Serialize; +use tokio::sync::RwLock; + +use validator_client::nymd::ValidatorResponse; + +use crate::cache::Cache; + +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct PrettyValidatorInfo { + pub address: String, + pub pub_key: String, + pub voting_power: u64, + pub name: Option, +} + +pub(crate) struct ValidatorCache { + pub(crate) validators: Cache, + pub(crate) summary: ValidatorSummary, +} + +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub(crate) struct ValidatorSummary { + pub(crate) count: i32, + pub(crate) block_height: u64, +} + +#[derive(Clone)] +pub(crate) struct ThreadsafeValidatorCache { + inner: Arc>, +} + +impl ThreadsafeValidatorCache { + pub(crate) fn new() -> Self { + ThreadsafeValidatorCache { + inner: Arc::new(RwLock::new(ValidatorCache { + validators: Cache::new(), + summary: ValidatorSummary { + block_height: 0, + count: 0, + }, + })), + } + } + + pub(crate) async fn get_validators(&self) -> Vec { + self.inner.read().await.validators.get_all() + } + + pub(crate) async fn get_validator_summary(&self) -> ValidatorSummary { + self.inner.read().await.summary.clone() + } + + pub(crate) async fn update_cache(&self, validator_response: ValidatorResponse) { + let mut guard = self.inner.write().await; + + for validator in validator_response.validators { + let address = validator.address.to_string(); + guard.validators.set( + address.clone().as_str(), + PrettyValidatorInfo { + address, + pub_key: validator.pub_key.to_hex(), + name: validator.name, + voting_power: validator.power.value(), + }, + ) + } + + guard.summary = ValidatorSummary { + count: validator_response.total, + block_height: validator_response.block_height.value(), + }; + } +} diff --git a/explorer/package-lock.json b/explorer/package-lock.json index b1c9eee465..8dce0ee469 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -24,6 +24,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-google-charts": "^3.0.15", + "react-identicons": "^1.2.5", "react-router-dom": "^5.3.0", "react-simple-maps": "^2.3.0", "react-tooltip": "^4.2.21" @@ -13680,6 +13681,15 @@ "react-dom": ">=16.3.0" } }, + "node_modules/react-identicons": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/react-identicons/-/react-identicons-1.2.5.tgz", + "integrity": "sha512-x7prkDoc2pD7wSl2C1pGxS+XAoSdq1ABWJWTBUimVTDVJArKOLd0B4wRUJpDm4r+9y7pgf8ylyPGsmlWSV5n2g==", + "peerDependencies": { + "react": "^17.0.1", + "react-dom": "^17.0.1" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -27550,6 +27560,12 @@ "react-load-script": "^0.0.6" } }, + "react-identicons": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/react-identicons/-/react-identicons-1.2.5.tgz", + "integrity": "sha512-x7prkDoc2pD7wSl2C1pGxS+XAoSdq1ABWJWTBUimVTDVJArKOLd0B4wRUJpDm4r+9y7pgf8ylyPGsmlWSV5n2g==", + "requires": {} + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/explorer/package.json b/explorer/package.json index 553adb4ecb..039f1ca1a1 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -19,6 +19,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-google-charts": "^3.0.15", + "react-identicons": "^1.2.5", "react-router-dom": "^5.3.0", "react-simple-maps": "^2.3.0", "react-tooltip": "^4.2.21" diff --git a/explorer/src/api/constants.ts b/explorer/src/api/constants.ts index 99f749f927..0dc59790da 100644 --- a/explorer/src/api/constants.ts +++ b/explorer/src/api/constants.ts @@ -4,6 +4,7 @@ export const VALIDATOR_API_BASE_URL = process.env.VALIDATOR_API_URL; export const BIG_DIPPER = process.env.BIG_DIPPER_URL; // specific API routes +export const OVERVIEW_API = `${API_BASE_URL}/overview`; export const MIXNODE_PING = `${API_BASE_URL}/ping`; export const MIXNODES_API = `${API_BASE_URL}/mix-nodes`; export const MIXNODE_API = `${API_BASE_URL}/mix-node`; @@ -17,8 +18,4 @@ export const UPTIME_STORY_API = `${VALIDATOR_API_BASE_URL}/api/v1/status/mixnode export const MIXNODE_API_ERROR = "We're having trouble finding that record, please try again or Contact Us."; -// socials -export const TELEGRAM_LINK = 'https://t.me/nymchan'; -export const TWITTER_LINK = 'https://twitter.com/nymproject'; -export const GITHUB_LINK = 'https://github.com/nymtech'; export const NYM_WEBSITE = 'https://nymtech.net'; diff --git a/explorer/src/api/index.ts b/explorer/src/api/index.ts index 808c8bfc5f..43c2b52fa8 100644 --- a/explorer/src/api/index.ts +++ b/explorer/src/api/index.ts @@ -1,24 +1,28 @@ import { - GATEWAYS_API, - MIXNODES_API, - VALIDATORS_API, BLOCK_API, COUNTRY_DATA_API, - MIXNODE_PING, - UPTIME_STORY_API, + GATEWAYS_API, MIXNODE_API, + MIXNODE_PING, + MIXNODES_API, + OVERVIEW_API, + UPTIME_STORY_API, + VALIDATORS_API, } from './constants'; import { - MixNodeResponse, - GatewayResponse, - ValidatorsResponse, CountryDataResponse, - MixNodeResponseItem, DelegationsResponse, + GatewayResponse, + MixNodeDescriptionResponse, + MixNodeResponse, + MixNodeResponseItem, + MixnodeStatus, StatsResponse, StatusResponse, + SummaryOverviewResponse, UptimeStoryResponse, + ValidatorsResponse, } from '../typeDefs/explorer-api'; function getFromCache(key: string) { @@ -33,8 +37,21 @@ function getFromCache(key: string) { function storeInCache(key: string, data: any) { localStorage.setItem(key, data); + localStorage.setItem('ts', Date.now().toString()); } + export class Api { + static fetchOverviewSummary = async (): Promise => { + const cache = getFromCache('overview-summary'); + if (cache) { + return cache; + } + const res = await fetch(`${OVERVIEW_API}/summary`); + const json = await res.json(); + storeInCache('overview-summary', JSON.stringify(json)); + return json; + }; + static fetchMixnodes = async (): Promise => { const cachedMixnodes = getFromCache('mixnodes'); if (cachedMixnodes) { @@ -43,18 +60,33 @@ export class Api { const res = await fetch(MIXNODES_API); const json = await res.json(); storeInCache('mixnodes', JSON.stringify(json)); - storeInCache('ts', Date.now()); + return json; + }; + + static fetchMixnodesActiveSetByStatus = async ( + status: MixnodeStatus, + ): Promise => { + const cachedMixnodes = getFromCache(`mixnodes-${status}`); + if (cachedMixnodes) { + return cachedMixnodes; + } + const res = await fetch(`${MIXNODES_API}/active-set/${status}`); + const json = await res.json(); + storeInCache(`mixnodes-${status}`, JSON.stringify(json)); return json; }; static fetchMixnodeByID = async ( id: string, ): Promise => { - const allMixnodes: MixNodeResponse = await Api.fetchMixnodes(); - const matchedByID = allMixnodes.filter( - (eachRecord) => eachRecord.mix_node.identity_key === id, - ); - return (matchedByID.length && matchedByID[0]) || undefined; + const response = await fetch(`${MIXNODE_API}/${id}`); + + // when the mixnode is not found, returned undefined + if (response.status === 404) { + return undefined; + } + + return response.json(); }; static fetchGateways = async (): Promise => { @@ -93,6 +125,11 @@ export class Api { static fetchStatsById = async (id: string): Promise => (await fetch(`${MIXNODE_API}/${id}/stats`)).json(); + static fetchMixnodeDescriptionById = async ( + id: string, + ): Promise => + (await fetch(`${MIXNODE_API}/${id}/description`)).json(); + static fetchStatusById = async (id: string): Promise => (await fetch(`${MIXNODE_PING}/${id}`)).json(); diff --git a/explorer/src/components/BondBreakdown.tsx b/explorer/src/components/BondBreakdown.tsx deleted file mode 100644 index d655e2b855..0000000000 --- a/explorer/src/components/BondBreakdown.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import * as React from 'react'; -import { Alert, Box, CircularProgress, useMediaQuery } from '@mui/material'; -import { useTheme } from '@mui/material/styles'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; -import Paper from '@mui/material/Paper'; -import { useMainContext } from 'src/context/main'; -import { ExpandMore } from '@mui/icons-material'; -import { currencyToString } from '../utils/currency'; - -export const BondBreakdownTable: React.FC = () => { - const { mixnodeDetailInfo, delegations } = useMainContext(); - const [allContentLoaded, setAllContentLoaded] = - React.useState(false); - const [showError, setShowError] = React.useState(false); - const [showDelegations, toggleShowDelegations] = - React.useState(false); - - const [bonds, setBonds] = React.useState({ - delegations: '0', - pledges: '0', - bondsTotal: '0', - hasLoaded: false, - }); - const theme = useTheme(); - const matches = useMediaQuery(theme.breakpoints.down('sm')); - - React.useEffect(() => { - if (mixnodeDetailInfo && mixnodeDetailInfo.data?.length) { - const thisMixnode = mixnodeDetailInfo?.data[0]; - - // delegations - const decimalisedDelegations = currencyToString( - thisMixnode.total_delegation.amount.toString(), - thisMixnode.total_delegation.denom, - ); - - // pledges - const decimalisedPledges = currencyToString( - thisMixnode.pledge_amount.amount.toString(), - thisMixnode.pledge_amount.denom, - ); - - // bonds total (del + pledges) - const pledgesSum = Number(thisMixnode.pledge_amount.amount); - const delegationsSum = Number(thisMixnode.total_delegation.amount); - const bondsTotal = currencyToString( - (delegationsSum + pledgesSum).toString(), - ); - - setBonds({ - delegations: decimalisedDelegations, - pledges: decimalisedPledges, - bondsTotal, - hasLoaded: true, - }); - } - }, [mixnodeDetailInfo]); - - React.useEffect(() => { - const hasError = Boolean(mixnodeDetailInfo?.error || delegations?.error); - const hasAllMixnodeInfo = Boolean( - mixnodeDetailInfo?.data !== undefined && - mixnodeDetailInfo?.data[0].mix_node, - ); - const hasAllDelegationsInfo = Boolean( - delegations?.data !== undefined && delegations?.data, - ); - const hasAllData = Boolean( - !hasError && hasAllMixnodeInfo && hasAllDelegationsInfo, - ); - setShowError(hasError); - setAllContentLoaded(hasAllData); - }, [mixnodeDetailInfo, delegations]); - - const expandDelegations = () => { - if (delegations?.data && delegations.data.length > 0) { - toggleShowDelegations(!showDelegations); - } - }; - const calcBondPercentage = (num: number) => { - if (mixnodeDetailInfo?.data !== undefined && mixnodeDetailInfo?.data[0]) { - const rawDelegationAmount = Number( - mixnodeDetailInfo.data[0].total_delegation.amount, - ); - const rawPledgeAmount = Number( - mixnodeDetailInfo.data[0].pledge_amount.amount, - ); - const rawTotalBondsAmount = rawDelegationAmount + rawPledgeAmount; - return ((num * 100) / rawTotalBondsAmount).toFixed(1); - } - return 0; - }; - - if (mixnodeDetailInfo?.isLoading) { - return ; - } - - if (showError) { - return ( - - We are unable to retrieve a Mixnode with that ID. Please try later or - Contact Us. - - ); - } - - if (!showError && allContentLoaded) { - return ( - <> - - - - - - Bond total - - - {bonds.bondsTotal} - - - - Pledge total - - {bonds.pledges} - - - - - - Delegation total {'\u00A0'} - {delegations?.data && delegations?.data?.length > 0 && ( - - )} - - - - {bonds.delegations} - - - -
- - {showDelegations && ( - - - - - - Delegators - - - Stake - - - Share from bond - - - - - - {delegations?.data?.map( - ({ owner, amount: { amount, denom } }) => ( - - - {owner} - - - {currencyToString(amount.toString(), denom)} - - - {calcBondPercentage(amount)}% - - - ), - )} - -
-
- )} -
- - ); - } - return null; -}; diff --git a/explorer/src/components/ContentCard.tsx b/explorer/src/components/ContentCard.tsx index 6c34396cf0..e1e7cf1407 100644 --- a/explorer/src/components/ContentCard.tsx +++ b/explorer/src/components/ContentCard.tsx @@ -2,7 +2,7 @@ import { Card, CardHeader, CardContent, Typography } from '@mui/material'; import React, { ReactEventHandler } from 'react'; type ContentCardProps = { - title?: string | React.ReactNode; + title?: React.ReactNode; subtitle?: string; Icon?: React.ReactNode; Action?: React.ReactNode; diff --git a/explorer/src/components/CustomColumnHeading.tsx b/explorer/src/components/CustomColumnHeading.tsx index c7b084b14e..00836a2d54 100644 --- a/explorer/src/components/CustomColumnHeading.tsx +++ b/explorer/src/components/CustomColumnHeading.tsx @@ -14,7 +14,7 @@ export const CustomColumnHeading: React.FC<{ headingTitle: string }> = ({ {columnsData?.map(({ field, title, flex }) => ( - + {title} ))} @@ -65,6 +65,7 @@ export const DetailTable: React.FC<{ ...cellStyles, padding: 2, width: 200, + fontSize: 14, }} data-testid={`${_.title.replace(/ /g, '-')}-value`} > diff --git a/explorer/src/components/Gateways.tsx b/explorer/src/components/Gateways.tsx new file mode 100644 index 0000000000..8a004285d1 --- /dev/null +++ b/explorer/src/components/Gateways.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { GatewayResponse } from '../typeDefs/explorer-api'; + +export type GatewayRowType = { + id: string; + owner: string; + identity_key: string; + bond: number; + host: string; + location: string; +}; + +export function gatewayToGridRow( + arrayOfGateways: GatewayResponse, +): GatewayRowType[] { + return !arrayOfGateways + ? [] + : arrayOfGateways.map((gw) => ({ + id: gw.owner, + owner: gw.owner, + identity_key: gw.gateway.identity_key || '', + location: gw?.gateway?.location || '', + bond: gw.pledge_amount.amount || 0, + host: gw.gateway.host || '', + })); +} diff --git a/explorer/src/components/Icons.tsx b/explorer/src/components/Icons.tsx new file mode 100644 index 0000000000..5ba3c23c1b --- /dev/null +++ b/explorer/src/components/Icons.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline'; +import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined'; +import { MixnodeStatus } from '../typeDefs/explorer-api'; + +export const Icons = { + mixnodes: { + status: { + active: CheckCircleOutlineIcon, + standby: PauseCircleOutlineIcon, + inactive: CircleOutlinedIcon, + }, + }, +}; + +export const getMixNodeIcon = (value: any) => { + if (value && typeof value === 'string') { + switch (value) { + case MixnodeStatus.active: + return Icons.mixnodes.status.active; + case MixnodeStatus.standby: + return Icons.mixnodes.status.standby; + default: + return Icons.mixnodes.status.inactive; + } + } + return Icons.mixnodes.status.inactive; +}; diff --git a/explorer/src/components/MixNodes/BondBreakdown.tsx b/explorer/src/components/MixNodes/BondBreakdown.tsx new file mode 100644 index 0000000000..186ad2ab59 --- /dev/null +++ b/explorer/src/components/MixNodes/BondBreakdown.tsx @@ -0,0 +1,194 @@ +import * as React from 'react'; +import { Alert, Box, CircularProgress, useMediaQuery } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Paper from '@mui/material/Paper'; +import { ExpandMore } from '@mui/icons-material'; +import { currencyToString } from '../../utils/currency'; +import { useMixnodeContext } from '../../context/mixnode'; + +export const BondBreakdownTable: React.FC = () => { + const { mixNode, delegations } = useMixnodeContext(); + const [showDelegations, toggleShowDelegations] = + React.useState(false); + + const [bonds, setBonds] = React.useState({ + delegations: '0', + pledges: '0', + bondsTotal: '0', + hasLoaded: false, + }); + const theme = useTheme(); + const matches = useMediaQuery(theme.breakpoints.down('sm')); + + React.useEffect(() => { + if (mixNode?.data) { + // delegations + const decimalisedDelegations = currencyToString( + mixNode.data.total_delegation.amount.toString(), + mixNode.data.total_delegation.denom, + ); + + // pledges + const decimalisedPledges = currencyToString( + mixNode.data.pledge_amount.amount.toString(), + mixNode.data.pledge_amount.denom, + ); + + // bonds total (del + pledges) + const pledgesSum = Number(mixNode.data.pledge_amount.amount); + const delegationsSum = Number(mixNode.data.total_delegation.amount); + const bondsTotal = currencyToString( + (delegationsSum + pledgesSum).toString(), + ); + + setBonds({ + delegations: decimalisedDelegations, + pledges: decimalisedPledges, + bondsTotal, + hasLoaded: true, + }); + } + }, [mixNode]); + + const expandDelegations = () => { + if (delegations?.data && delegations.data.length > 0) { + toggleShowDelegations(!showDelegations); + } + }; + const calcBondPercentage = (num: number) => { + if (mixNode?.data) { + const rawDelegationAmount = Number(mixNode.data.total_delegation.amount); + const rawPledgeAmount = Number(mixNode.data.pledge_amount.amount); + const rawTotalBondsAmount = rawDelegationAmount + rawPledgeAmount; + return ((num * 100) / rawTotalBondsAmount).toFixed(1); + } + return 0; + }; + + if (mixNode?.isLoading || delegations?.isLoading) { + return ; + } + + if (mixNode?.error) { + return Mixnode not found; + } + if (delegations?.error) { + return ( + Unable to get delegations for mixnode + ); + } + + return ( + <> + + + + + + Bond total + + + {bonds.bondsTotal} + + + + Pledge total + + {bonds.pledges} + + + + + + Delegation total {'\u00A0'} + {delegations?.data && delegations?.data?.length > 0 && ( + + )} + + + + {bonds.delegations} + + + +
+ + {showDelegations && ( + + + + + + Delegators + + + Stake + + + Share from bond + + + + + + {delegations?.data?.map( + ({ owner, amount: { amount, denom } }) => ( + + + {owner} + + + {currencyToString(amount.toString(), denom)} + + + {calcBondPercentage(amount)}% + + + ), + )} + +
+
+ )} +
+ + ); +}; diff --git a/explorer/src/components/MixNodes/DetailSection.tsx b/explorer/src/components/MixNodes/DetailSection.tsx new file mode 100644 index 0000000000..e3c235ceb3 --- /dev/null +++ b/explorer/src/components/MixNodes/DetailSection.tsx @@ -0,0 +1,106 @@ +import { Box, Button, Grid, Typography, useMediaQuery } from '@mui/material'; +import * as React from 'react'; +import Identicon from 'react-identicons'; +import { useTheme } from '@mui/material/styles'; +import { MixnodeRowType } from '.'; +import { getMixNodeStatusText, MixNodeStatus } from './Status'; +import { MixNodeDescriptionResponse } from '../../typeDefs/explorer-api'; + +interface MixNodeDetailProps { + mixNodeRow: MixnodeRowType; + mixnodeDescription: MixNodeDescriptionResponse; +} + +export const MixNodeDetailSection: React.FC = ({ + mixNodeRow, + mixnodeDescription, +}) => { + const theme = useTheme(); + const palette = [theme.palette.text.primary]; + const matches = useMediaQuery(theme.breakpoints.down('sm')); + const statusText = React.useMemo( + () => getMixNodeStatusText(mixNodeRow.status), + [mixNodeRow.status], + ); + return ( + + + + + + + + {mixnodeDescription.name} + + {(mixnodeDescription.description || '').slice(0, 1000)} + + + + + + + + + Node status: + + + + + + This node is {statusText} in this epoch + + + + + ); +}; diff --git a/explorer/src/components/MixNodes/Status.tsx b/explorer/src/components/MixNodes/Status.tsx new file mode 100644 index 0000000000..fe94fcb1b5 --- /dev/null +++ b/explorer/src/components/MixNodes/Status.tsx @@ -0,0 +1,53 @@ +import { Typography } from '@mui/material'; +import * as React from 'react'; +import { Theme, useTheme } from '@mui/material/styles'; +import { MixnodeRowType } from '.'; +import { getMixNodeIcon } from '../Icons'; +import { MixnodeStatus } from '../../typeDefs/explorer-api'; + +interface MixNodeStatusProps { + status: MixnodeStatus; +} + +export const MixNodeStatus: React.FC = ({ status }) => { + const theme = useTheme(); + const Icon = React.useMemo(() => getMixNodeIcon(status), [status]); + const color = React.useMemo(() => getMixNodeStatusColor(theme, status), [status, theme]); + + return ( + + + + {`${status[0].toUpperCase()}${status.slice(1)}`} + + + ); +}; + +export const getMixNodeStatusColor = (theme: Theme, status: MixnodeStatus) => { + let color; + switch (status) { + case MixnodeStatus.active: + color = theme.palette.nym.networkExplorer.mixnodes.status.active; + break; + case MixnodeStatus.standby: + color = theme.palette.nym.networkExplorer.mixnodes.status.standby; + break; + default: + color = theme.palette.nym.networkExplorer.mixnodes.status.inactive; + break; + } + return color; +}; + +// TODO: should be done with i18n +export const getMixNodeStatusText = (status: MixnodeStatus) => { + switch (status) { + case MixnodeStatus.active: + return 'active'; + case MixnodeStatus.standby: + return 'on standby'; + default: + return 'inactive'; + } +}; diff --git a/explorer/src/components/MixNodes/StatusDropdown.tsx b/explorer/src/components/MixNodes/StatusDropdown.tsx new file mode 100644 index 0000000000..420b726738 --- /dev/null +++ b/explorer/src/components/MixNodes/StatusDropdown.tsx @@ -0,0 +1,96 @@ +import { MenuItem, useMediaQuery } from '@mui/material'; +import * as React from 'react'; +import Select from '@mui/material/Select'; +import { SelectInputProps } from '@mui/material/Select/SelectInput'; +import { useTheme } from '@mui/material/styles'; +import { SxProps } from '@mui/system'; +import { MixNodeStatus } from './Status'; +import { + MixnodeStatus, + MixnodeStatusWithAll, +} from '../../typeDefs/explorer-api'; + +// TODO: replace with i18n +const ALL_NODES = 'All nodes'; + +interface MixNodeStatusDropdownProps { + status?: MixnodeStatusWithAll; + sx?: SxProps; + onSelectionChanged?: (status?: MixnodeStatusWithAll) => void; +} + +export const MixNodeStatusDropdown: React.FC = ({ + status, + onSelectionChanged, + sx, +}) => { + const theme = useTheme(); + const matches = useMediaQuery(theme.breakpoints.down('sm')); + const [statusValue, setStatusValue] = React.useState( + status || MixnodeStatusWithAll.all, + ); + const onChange: SelectInputProps['onChange'] = + React.useCallback( + ({ target: { value } }) => { + setStatusValue(value); + if (onSelectionChanged) { + onSelectionChanged(value); + } + }, + [onSelectionChanged], + ); + + return ( + + ); +}; + +MixNodeStatusDropdown.defaultProps = { + onSelectionChanged: undefined, + status: undefined, + sx: undefined, +}; diff --git a/explorer/src/components/MixNodes/index.ts b/explorer/src/components/MixNodes/index.ts new file mode 100644 index 0000000000..356cf6c38c --- /dev/null +++ b/explorer/src/components/MixNodes/index.ts @@ -0,0 +1,44 @@ +/* eslint-disable camelcase */ +import { + MixNodeResponse, + MixNodeResponseItem, + MixnodeStatus, +} from '../../typeDefs/explorer-api'; + +export type MixnodeRowType = { + id: string; + status: MixnodeStatus; + owner: string; + location: string; + identity_key: string; + bond: number; + self_percentage: string; + host: string; + layer: string; +}; + +export function mixnodeToGridRow( + arrayOfMixnodes?: MixNodeResponse, +): MixnodeRowType[] { + return (arrayOfMixnodes || []).map(mixNodeResponseItemToMixnodeRowType); +} + +export function mixNodeResponseItemToMixnodeRowType( + item: MixNodeResponseItem, +): MixnodeRowType { + const pledge = Number(item.pledge_amount.amount) || 0; + const delegations = Number(item.total_delegation.amount) || 0; + const totalBond = pledge + delegations; + const selfPercentage = ((pledge * 100) / totalBond).toFixed(2); + return { + id: item.owner, + status: item.status, + owner: item.owner, + identity_key: item.mix_node.identity_key || '', + bond: totalBond || 0, + location: item?.location?.country_name || '', + self_percentage: selfPercentage, + host: item?.mix_node?.host || '', + layer: item?.layer || '', + }; +} diff --git a/explorer/src/components/MobileNav.tsx b/explorer/src/components/MobileNav.tsx index 42189d148b..cfca612af8 100644 --- a/explorer/src/components/MobileNav.tsx +++ b/explorer/src/components/MobileNav.tsx @@ -81,7 +81,7 @@ export const MobileNav: React.FC<{ children: React.ReactNode }> = ({ sx={{ color: theme.palette.nym.networkExplorer.nav.text, fontSize: '18px', - fontWeight: 800, + fontWeight: 600, ml: 2, }} > diff --git a/explorer/src/components/Nav.tsx b/explorer/src/components/Nav.tsx index 0f94a3223e..2cbccea908 100644 --- a/explorer/src/components/Nav.tsx +++ b/explorer/src/components/Nav.tsx @@ -186,7 +186,7 @@ export const ExpandableButton: React.FC = ({ if (isChild) { setDynamicStyle({ background: palette.nym.networkExplorer.nav.selected.nested, - fontWeight: 800, + fontWeight: 600, }); } if (!nested && !isChild) { @@ -239,7 +239,7 @@ export const ExpandableButton: React.FC = ({ }} primaryTypographyProps={{ style: { - fontWeight: isActive ? 800 : 300, + fontWeight: isActive ? 600 : 400, }, }} /> @@ -314,6 +314,7 @@ export const Nav: React.FC = ({ children }) => { { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - width: 205, ml: 0.5, }} > @@ -342,7 +342,7 @@ export const Nav: React.FC = ({ children }) => { sx={{ color: theme.palette.nym.networkExplorer.nav.text, fontSize: '18px', - fontWeight: 800, + fontWeight: 600, }} > { PaperProps={{ style: { background: theme.palette.nym.networkExplorer.nav.background, + borderRadius: 0, }, }} > @@ -427,7 +428,7 @@ export const Nav: React.FC = ({ children }) => { ))} - + {children}