Network Explorer: updates to API and UI to show the active set (#1056)
* Add identicons package * Tidy up styling and move methods into component directories with better naming * Add mixnode status colours to theme * Mixnode status and icon components * Add status to mixnode types * Add API method to get mixnode details * Add mixnode details to state * Add status and name+description section to mixnode detail page * Wrap with div instead of p * Limit width of description and link to new tab * Limit length of link button and truncate with elipsis * Replace `filter` with `find` * Move mix node detail components to a location that is better named * Refactor mixnode detail state and separate into an independent context from main state. This prevents the mixnode detail page from showing stale data when switching between mix nodes. * Tidy up mixnode detail page adding new state provider and a guard component to handle loading, error and not found states * Layout changes to mixnode description header section * Add methods to Explorer API client to get a mixnode by id, active set by status and overview summary * Add color prop to StatsCard and make count optional * Add optional start and end children to TableToolbar * Tidy up naming * Add summary overview and getting mixnodes by active set status to main state * Add mix node status overview cards * Add mix node status to routes * Mixnode list has a dropdown component to select the active set status * Clean up caching code * Add resource to get a single mixnode by id * Add API resources to get `active`, `inactive` and `standby` mixnodes * Add mixnode summary to API * Add overview summary endpoint to API * Fix OpenAPI/swagger base url * Make clippy happy * Add method to get validators * Add methods to get active and rewarded mixnodes * Fix naming * Move client creation to crate root * Move cache to module * Delete unused files * Add validators API resource * Add gateways API resource * Move tasks to crate root * Add new HTTP resources for validators and gateways to routes * Tidy up naming and locations for mixnodes * Add validator and gateways to state, and tidy up naming * Add gateways and validator modules to main * Overview shows validator and gateway summaries from state * Bundle variable weight Open Sans fonts * Fix up font weights and sizes * Fix up typing * Fix up social icons * Fix navbar colour * Fix paper colour in dark mode and border radius * Fix up stats card * Tidy up Nym icons * Fix up overview * Fix up spacing and padding for overview * Add light mode shades that are darker for mixnode status values * Review feedback
This commit is contained in:
@@ -184,6 +184,18 @@ impl<C> Client<C> {
|
||||
Ok(self.validator_api.get_mixnodes().await?)
|
||||
}
|
||||
|
||||
pub async fn get_cached_rewarded_mixnodes(
|
||||
&self,
|
||||
) -> Result<Vec<MixNodeBond>, ValidatorClientError> {
|
||||
Ok(self.validator_api.get_rewarded_mixnodes().await?)
|
||||
}
|
||||
|
||||
pub async fn get_cached_active_mixnodes(
|
||||
&self,
|
||||
) -> Result<Vec<MixNodeBond>, ValidatorClientError> {
|
||||
Ok(self.validator_api.get_active_mixnodes().await?)
|
||||
}
|
||||
|
||||
pub async fn get_cached_gateways(&self) -> Result<Vec<GatewayBond>, ValidatorClientError> {
|
||||
Ok(self.validator_api.get_gateways().await?)
|
||||
}
|
||||
|
||||
@@ -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<C> NymdClient<C> {
|
||||
.map(|block| block.block_id.hash)
|
||||
}
|
||||
|
||||
pub async fn get_validators(
|
||||
&self,
|
||||
height: u64,
|
||||
paging: Paging,
|
||||
) -> Result<ValidatorResponse, NymdError>
|
||||
where
|
||||
C: CosmWasmClient + Sync,
|
||||
{
|
||||
Ok(self.client.validators(height as u32, paging).await?)
|
||||
}
|
||||
|
||||
pub async fn get_balance(
|
||||
&self,
|
||||
address: &AccountId,
|
||||
|
||||
@@ -13,19 +13,27 @@ impl<T: Clone> Cache<T> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, identity_key: &str) -> Option<T>
|
||||
where
|
||||
T: Clone,
|
||||
{
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.inner.len()
|
||||
}
|
||||
|
||||
pub(crate) fn get_all(&self) -> Vec<T> {
|
||||
self.inner
|
||||
.get(identity_key)
|
||||
.values()
|
||||
.map(|cache_item| cache_item.value.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, key: &str) -> Option<T> {
|
||||
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,
|
||||
@@ -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<QueryNymdClient> {
|
||||
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!")
|
||||
}
|
||||
@@ -5,13 +5,13 @@ use tokio::sync::RwLock;
|
||||
pub type CountryNodesDistribution = HashMap<String, u32>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConcurrentCountryNodesDistribution {
|
||||
pub struct ThreadsafeCountryNodesDistribution {
|
||||
inner: Arc<RwLock<CountryNodesDistribution>>,
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = cache
|
||||
.values()
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![settings: list]
|
||||
}
|
||||
|
||||
#[openapi(tag = "gateways")]
|
||||
#[get("/")]
|
||||
pub(crate) async fn list(
|
||||
state: &State<ExplorerApiStateContext>,
|
||||
) -> Result<Json<Vec<GatewayBond>>, NotFound<String>> {
|
||||
Ok(Json(state.inner.gateways.get_gateways().await))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod http;
|
||||
pub(crate) mod models;
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<GatewayBond>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct GatewaySummary {
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ThreadsafeGatewayCache {
|
||||
inner: Arc<RwLock<GatewayCache>>,
|
||||
}
|
||||
|
||||
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<GatewayBond> {
|
||||
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<GatewayBond>) {
|
||||
let mut guard = self.inner.write().await;
|
||||
|
||||
for gateway in gateways {
|
||||
guard
|
||||
.gateways
|
||||
.set(gateway.clone().gateway.identity_key.as_str(), gateway)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Build> {
|
||||
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<Build> {
|
||||
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<rocket_okapi::okapi::openapi3::Server> {
|
||||
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()
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
settings: get_delegations,
|
||||
get_by_id,
|
||||
get_all_delegations,
|
||||
get_description,
|
||||
get_stats,
|
||||
]
|
||||
}
|
||||
|
||||
#[openapi(tag = "mix_nodes")]
|
||||
#[get("/<pubkey>")]
|
||||
pub(crate) async fn get_by_id(
|
||||
pubkey: &str,
|
||||
state: &State<ExplorerApiStateContext>,
|
||||
) -> Result<Json<PrettyDetailedMixNodeBond>, NotFound<String>> {
|
||||
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("/<pubkey>/delegations")]
|
||||
pub(crate) async fn get_delegations(pubkey: &str) -> Json<Vec<Delegation>> {
|
||||
@@ -39,13 +60,7 @@ pub(crate) async fn get_description(
|
||||
pubkey: &str,
|
||||
state: &State<ExplorerApiStateContext>,
|
||||
) -> Option<Json<NodeDescription>> {
|
||||
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<ExplorerApiStateContext>,
|
||||
) -> Option<Json<NodeStats>> {
|
||||
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))
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
mod cache;
|
||||
pub(crate) mod http;
|
||||
pub(crate) mod models;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use mixnet_contract_common::Delegation;
|
||||
|
||||
pub(crate) async fn get_single_mixnode_delegations(pubkey: &str) -> Vec<Delegation> {
|
||||
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<Delegati
|
||||
}
|
||||
|
||||
pub(crate) async fn get_mixnode_delegations() -> Vec<Delegation> {
|
||||
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) => {
|
||||
|
||||
@@ -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<Route>, 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<Route>,
|
||||
pub(crate) async fn list(
|
||||
state: &State<ExplorerApiStateContext>,
|
||||
) -> Json<Vec<PrettyDetailedMixNodeBond>> {
|
||||
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<ExplorerApiStateContext>,
|
||||
) -> Json<Vec<PrettyDetailedMixNodeBond>> {
|
||||
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<ExplorerApiStateContext>,
|
||||
) -> Json<Vec<PrettyDetailedMixNodeBond>> {
|
||||
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<ExplorerApiStateContext>,
|
||||
) -> Json<Vec<PrettyDetailedMixNodeBond>> {
|
||||
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<ExplorerApiStateContext>) -> Json<MixNodeSummary> {
|
||||
Json(get_mixnode_summary(state).await)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_mixnode_summary(state: &State<ExplorerApiStateContext>) -> 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<PrettyDetailedMixNodeBond>,
|
||||
status: MixnodeStatus,
|
||||
) -> Vec<PrettyDetailedMixNodeBond> {
|
||||
all_mixnodes
|
||||
.into_iter()
|
||||
.filter(|mixnode| mixnode.status == status)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<RwLock<MixNodesResult>>,
|
||||
location_cache: Arc<RwLock<LocationCache>>,
|
||||
pub(crate) struct ThreadsafeMixNodesCache {
|
||||
mixnodes: Arc<RwLock<MixNodesResult>>,
|
||||
locations: Arc<RwLock<LocationCache>>,
|
||||
}
|
||||
|
||||
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<Location>) {
|
||||
// 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<MixNodeBond> {
|
||||
self.mixnode_results.read().await.get_mixnode(pubkey)
|
||||
self.mixnodes.read().await.get_mixnode(pubkey)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_mixnodes(&self) -> Option<HashMap<String, MixNodeBond>> {
|
||||
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<PrettyDetailedMixNodeBond> {
|
||||
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<PrettyDetailedMixNodeBond> {
|
||||
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<String>,
|
||||
active_nodes: HashSet<String>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<MixNodeBond>
|
||||
where
|
||||
F: FnOnce(&'a validator_client::ApiClient) -> Fut,
|
||||
Fut: Future<Output = Result<Vec<MixNodeBond>, 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<MixNodeBond> {
|
||||
info!("About to retrieve all mixnode bonds...");
|
||||
self.retrieve_mixnodes(validator_client::ApiClient::get_cached_mixnodes)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn retrieve_rewarded_mixnodes(&self) -> Vec<MixNodeBond> {
|
||||
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<MixNodeBond> {
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<QueryNymdClient> {
|
||||
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!")
|
||||
}
|
||||
|
||||
@@ -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<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![settings: summary]
|
||||
}
|
||||
|
||||
#[openapi(tag = "overview")]
|
||||
#[get("/summary")]
|
||||
pub(crate) async fn summary(state: &State<ExplorerApiStateContext>) -> Json<OverviewSummary> {
|
||||
Json(OverviewSummary {
|
||||
mixnodes: get_mixnode_summary(state).await,
|
||||
validators: state.inner.validators.get_validator_summary().await,
|
||||
gateways: state.inner.gateways.get_gateway_summary().await,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub(crate) mod http;
|
||||
mod models;
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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,
|
||||
}
|
||||
@@ -25,7 +25,7 @@ pub(crate) async fn index(
|
||||
pubkey: &str,
|
||||
state: &State<ExplorerApiStateContext>,
|
||||
) -> Option<Json<PingResponse>> {
|
||||
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))
|
||||
|
||||
+24
-16
@@ -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<MixNodeBond> {
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<QueryNymdClient>,
|
||||
}
|
||||
|
||||
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<MixNodeBond>
|
||||
where
|
||||
F: FnOnce(&'a validator_client::Client<QueryNymdClient>) -> Fut,
|
||||
Fut: Future<Output = Result<Vec<MixNodeBond>, 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<MixNodeBond> {
|
||||
info!("About to retrieve all mixnode bonds...");
|
||||
self.retrieve_mixnodes(validator_client::Client::get_cached_mixnodes)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn retrieve_all_gateways(&self) -> Result<Vec<GatewayBond>, ValidatorClientError> {
|
||||
info!("About to retrieve all gateways...");
|
||||
self.validator_client.get_cached_gateways().await
|
||||
}
|
||||
|
||||
async fn retrieve_all_validators(&self) -> Result<ValidatorResponse, NymdError> {
|
||||
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<MixNodeBond> {
|
||||
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<MixNodeBond> {
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![settings: list]
|
||||
}
|
||||
|
||||
#[openapi(tag = "validators")]
|
||||
#[get("/")]
|
||||
pub(crate) async fn list(
|
||||
state: &State<ExplorerApiStateContext>,
|
||||
) -> Result<Json<Vec<PrettyValidatorInfo>>, NotFound<String>> {
|
||||
Ok(Json(state.inner.validators.get_validators().await))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod http;
|
||||
pub(crate) mod models;
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// 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<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct ValidatorCache {
|
||||
pub(crate) validators: Cache<PrettyValidatorInfo>,
|
||||
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<RwLock<ValidatorCache>>,
|
||||
}
|
||||
|
||||
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<PrettyValidatorInfo> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
+52
-15
@@ -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<SummaryOverviewResponse> => {
|
||||
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<MixNodeResponse> => {
|
||||
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<MixNodeResponse> => {
|
||||
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<MixNodeResponseItem | undefined> => {
|
||||
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<GatewayResponse> => {
|
||||
@@ -93,6 +125,11 @@ export class Api {
|
||||
static fetchStatsById = async (id: string): Promise<StatsResponse> =>
|
||||
(await fetch(`${MIXNODE_API}/${id}/stats`)).json();
|
||||
|
||||
static fetchMixnodeDescriptionById = async (
|
||||
id: string,
|
||||
): Promise<MixNodeDescriptionResponse> =>
|
||||
(await fetch(`${MIXNODE_API}/${id}/description`)).json();
|
||||
|
||||
static fetchStatusById = async (id: string): Promise<StatusResponse> =>
|
||||
(await fetch(`${MIXNODE_PING}/${id}`)).json();
|
||||
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const [showError, setShowError] = React.useState<boolean>(false);
|
||||
const [showDelegations, toggleShowDelegations] =
|
||||
React.useState<boolean>(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 <CircularProgress />;
|
||||
}
|
||||
|
||||
if (showError) {
|
||||
return (
|
||||
<Alert severity="warning">
|
||||
We are unable to retrieve a Mixnode with that ID. Please try later or
|
||||
Contact Us.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!showError && allContentLoaded) {
|
||||
return (
|
||||
<>
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="bond breakdown totals">
|
||||
<TableBody>
|
||||
<TableRow sx={matches ? { minWidth: '70vw' } : null}>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
width: '150px',
|
||||
}}
|
||||
align="left"
|
||||
>
|
||||
Bond total
|
||||
</TableCell>
|
||||
<TableCell align="left" data-testid="bond-total-amount">
|
||||
{bonds.bondsTotal}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell align="left">Pledge total</TableCell>
|
||||
<TableCell align="left" data-testid="pledge-total-amount">
|
||||
{bonds.pledges}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell onClick={expandDelegations} align="left">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
Delegation total {'\u00A0'}
|
||||
{delegations?.data && delegations?.data?.length > 0 && (
|
||||
<ExpandMore />
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell align="left" data-testid="delegation-total-amount">
|
||||
{bonds.delegations}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{showDelegations && (
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 400,
|
||||
overflowY: 'scroll',
|
||||
}}
|
||||
>
|
||||
<Table stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 'bold', background: '#242C3D' }}
|
||||
align="left"
|
||||
>
|
||||
Delegators
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 'bold', background: '#242C3D' }}
|
||||
align="left"
|
||||
>
|
||||
Stake
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
background: '#242C3D',
|
||||
width: '200px',
|
||||
}}
|
||||
align="left"
|
||||
>
|
||||
Share from bond
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{delegations?.data?.map(
|
||||
({ owner, amount: { amount, denom } }) => (
|
||||
<TableRow key={owner}>
|
||||
<TableCell
|
||||
sx={matches ? { width: 190 } : null}
|
||||
align="left"
|
||||
>
|
||||
{owner}
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
{currencyToString(amount.toString(), denom)}
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
{calcBondPercentage(amount)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -14,7 +14,7 @@ export const CustomColumnHeading: React.FC<{ headingTitle: string }> = ({
|
||||
<Box alignItems="center" display="flex" onClick={handleClick}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
padding: 0,
|
||||
// border: '1px solid red',
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
TableRow,
|
||||
} from '@mui/material';
|
||||
import { cellStyles } from './Universal-DataGrid';
|
||||
import { MixnodeRowType } from '../utils/index';
|
||||
import { currencyToString } from '../utils/currency';
|
||||
import { MixnodeRowType } from './MixNodes';
|
||||
|
||||
export type ColumnsType = {
|
||||
field: string;
|
||||
@@ -43,7 +43,7 @@ export const DetailTable: React.FC<{
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columnsData?.map(({ field, title, flex }) => (
|
||||
<TableCell key={field} sx={{ fontWeight: 'bold', flex }}>
|
||||
<TableCell key={field} sx={{ fontSize: 14, fontWeight: 600, flex }}>
|
||||
{title}
|
||||
</TableCell>
|
||||
))}
|
||||
@@ -65,6 +65,7 @@ export const DetailTable: React.FC<{
|
||||
...cellStyles,
|
||||
padding: 2,
|
||||
width: 200,
|
||||
fontSize: 14,
|
||||
}}
|
||||
data-testid={`${_.title.replace(/ /g, '-')}-value`}
|
||||
>
|
||||
|
||||
@@ -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 || '',
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<boolean>(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 <CircularProgress />;
|
||||
}
|
||||
|
||||
if (mixNode?.error) {
|
||||
return <Alert severity="error">Mixnode not found</Alert>;
|
||||
}
|
||||
if (delegations?.error) {
|
||||
return (
|
||||
<Alert severity="error">Unable to get delegations for mixnode</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="bond breakdown totals">
|
||||
<TableBody>
|
||||
<TableRow sx={matches ? { minWidth: '70vw' } : null}>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 400,
|
||||
width: '150px',
|
||||
}}
|
||||
align="left"
|
||||
>
|
||||
Bond total
|
||||
</TableCell>
|
||||
<TableCell align="left" data-testid="bond-total-amount">
|
||||
{bonds.bondsTotal}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell align="left">Pledge total</TableCell>
|
||||
<TableCell align="left" data-testid="pledge-total-amount">
|
||||
{bonds.pledges}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell onClick={expandDelegations} align="left">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
Delegation total {'\u00A0'}
|
||||
{delegations?.data && delegations?.data?.length > 0 && (
|
||||
<ExpandMore />
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell align="left" data-testid="delegation-total-amount">
|
||||
{bonds.delegations}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{showDelegations && (
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 400,
|
||||
overflowY: 'scroll',
|
||||
}}
|
||||
>
|
||||
<Table stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, background: '#242C3D' }}
|
||||
align="left"
|
||||
>
|
||||
Delegators
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, background: '#242C3D' }}
|
||||
align="left"
|
||||
>
|
||||
Stake
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
background: '#242C3D',
|
||||
width: '200px',
|
||||
}}
|
||||
align="left"
|
||||
>
|
||||
Share from bond
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{delegations?.data?.map(
|
||||
({ owner, amount: { amount, denom } }) => (
|
||||
<TableRow key={owner}>
|
||||
<TableCell
|
||||
sx={matches ? { width: 190 } : null}
|
||||
align="left"
|
||||
>
|
||||
{owner}
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
{currencyToString(amount.toString(), denom)}
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
{calcBondPercentage(amount)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<MixNodeDetailProps> = ({
|
||||
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 (
|
||||
<Grid container>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box display="flex" flexDirection="row" width="100%">
|
||||
<Box
|
||||
width={72}
|
||||
height={72}
|
||||
sx={{
|
||||
minWidth: 72,
|
||||
minHeight: 72,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.palette.text.primary,
|
||||
borderStyle: 'solid',
|
||||
borderRadius: '50%',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Identicon
|
||||
size={43}
|
||||
string={mixNodeRow.identity_key}
|
||||
palette={palette}
|
||||
/>
|
||||
</Box>
|
||||
<Box ml={2}>
|
||||
<Typography fontSize={21}>{mixnodeDescription.name}</Typography>
|
||||
<Typography>
|
||||
{(mixnodeDescription.description || '').slice(0, 1000)}
|
||||
</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
variant="text"
|
||||
sx={{
|
||||
mt: 4,
|
||||
borderRadius: '30px',
|
||||
fontWeight: 600,
|
||||
padding: 0,
|
||||
}}
|
||||
href={mixnodeDescription.link}
|
||||
target="_blank"
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
maxWidth="250px"
|
||||
>
|
||||
{mixnodeDescription.link}
|
||||
</Typography>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
display="flex"
|
||||
justifyContent="end"
|
||||
mt={matches ? 5 : undefined}
|
||||
>
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Typography fontWeight="600" alignSelf="self-end">
|
||||
Node status:
|
||||
</Typography>
|
||||
<Box mt={2} alignSelf="self-end">
|
||||
<MixNodeStatus status={mixNodeRow.status} />
|
||||
</Box>
|
||||
<Typography
|
||||
mt={1}
|
||||
alignSelf="self-end"
|
||||
color={theme.palette.text.secondary}
|
||||
fontSize="smaller"
|
||||
>
|
||||
This node is {statusText} in this epoch
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -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<MixNodeStatusProps> = ({ status }) => {
|
||||
const theme = useTheme();
|
||||
const Icon = React.useMemo(() => getMixNodeIcon(status), [status]);
|
||||
const color = React.useMemo(() => getMixNodeStatusColor(theme, status), [status, theme]);
|
||||
|
||||
return (
|
||||
<Typography color={color} display="flex" alignItems="center">
|
||||
<Icon />
|
||||
<Typography ml={1} component="span" color="inherit">
|
||||
{`${status[0].toUpperCase()}${status.slice(1)}`}
|
||||
</Typography>
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
@@ -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<MixNodeStatusDropdownProps> = ({
|
||||
status,
|
||||
onSelectionChanged,
|
||||
sx,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const matches = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [statusValue, setStatusValue] = React.useState<MixnodeStatusWithAll>(
|
||||
status || MixnodeStatusWithAll.all,
|
||||
);
|
||||
const onChange: SelectInputProps<MixnodeStatusWithAll>['onChange'] =
|
||||
React.useCallback(
|
||||
({ target: { value } }) => {
|
||||
setStatusValue(value);
|
||||
if (onSelectionChanged) {
|
||||
onSelectionChanged(value);
|
||||
}
|
||||
},
|
||||
[onSelectionChanged],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
labelId="mixnodeStatusSelect_label"
|
||||
id="mixnodeStatusSelect"
|
||||
value={statusValue}
|
||||
onChange={onChange}
|
||||
renderValue={(value) => {
|
||||
switch (value) {
|
||||
case MixnodeStatusWithAll.active:
|
||||
case MixnodeStatusWithAll.standby:
|
||||
case MixnodeStatusWithAll.inactive:
|
||||
return <MixNodeStatus status={value as unknown as MixnodeStatus} />;
|
||||
default:
|
||||
return ALL_NODES;
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
width: matches ? 'auto' : 200,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
value={MixnodeStatus.active}
|
||||
data-testid="mixnodeStatusSelectOption_active"
|
||||
>
|
||||
<MixNodeStatus status={MixnodeStatus.active} />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
value={MixnodeStatus.standby}
|
||||
data-testid="mixnodeStatusSelectOption_standby"
|
||||
>
|
||||
<MixNodeStatus status={MixnodeStatus.standby} />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
value={MixnodeStatus.inactive}
|
||||
data-testid="mixnodeStatusSelectOption_inactive"
|
||||
>
|
||||
<MixNodeStatus status={MixnodeStatus.inactive} />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
value={MixnodeStatusWithAll.all}
|
||||
data-testid="mixnodeStatusSelectOption_allNodes"
|
||||
>
|
||||
{ALL_NODES}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
MixNodeStatusDropdown.defaultProps = {
|
||||
onSelectionChanged: undefined,
|
||||
status: undefined,
|
||||
sx: undefined,
|
||||
};
|
||||
@@ -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 || '',
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -186,7 +186,7 @@ export const ExpandableButton: React.FC<ExpandableButtonType> = ({
|
||||
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<ExpandableButtonType> = ({
|
||||
}}
|
||||
primaryTypographyProps={{
|
||||
style: {
|
||||
fontWeight: isActive ? 800 : 300,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -314,6 +314,7 @@ export const Nav: React.FC = ({ children }) => {
|
||||
<AppBar
|
||||
sx={{
|
||||
background: theme.palette.nym.networkExplorer.topNav.appBar,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
@@ -329,7 +330,6 @@ 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,
|
||||
}}
|
||||
>
|
||||
<MuiLink
|
||||
@@ -385,6 +385,7 @@ export const Nav: React.FC = ({ children }) => {
|
||||
PaperProps={{
|
||||
style: {
|
||||
background: theme.palette.nym.networkExplorer.nav.background,
|
||||
borderRadius: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -427,7 +428,7 @@ export const Nav: React.FC = ({ children }) => {
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box sx={{ width: '100%', p: 4, mt: 7 }}>
|
||||
<Box sx={{ width: '100%', py: 5, px: 6, mt: 7 }}>
|
||||
{children}
|
||||
<Footer />
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import TwitterIcon from '@mui/icons-material/Twitter';
|
||||
import { GITHUB_LINK, TELEGRAM_LINK, TWITTER_LINK } from 'src/api/constants';
|
||||
import { TelegramIcon } from '../icons/socials/TelegramIcon';
|
||||
import { GitHubIcon } from '../icons/socials/GitHubIcon';
|
||||
import { TwitterIcon } from '../icons/socials/TwitterIcon';
|
||||
import { DiscordIcon } from '../icons/socials/DiscordIcon';
|
||||
|
||||
// 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 DISCORD_LINK = 'https://discord.gg/jUqJYGB5';
|
||||
|
||||
export const Socials: React.FC<{ isFooter?: boolean }> = ({ isFooter }) => {
|
||||
const theme = useTheme();
|
||||
@@ -19,7 +25,25 @@ export const Socials: React.FC<{ isFooter?: boolean }> = ({ isFooter }) => {
|
||||
target="_blank"
|
||||
data-testid="telegram"
|
||||
>
|
||||
<TelegramIcon sx={{ color }} />
|
||||
<TelegramIcon color={color} size={24} />
|
||||
</IconButton>
|
||||
{false && (
|
||||
<IconButton
|
||||
component="a"
|
||||
href={DISCORD_LINK}
|
||||
target="_blank"
|
||||
data-testid="discord"
|
||||
>
|
||||
<DiscordIcon color={color} size={24} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
component="a"
|
||||
href={TWITTER_LINK}
|
||||
target="_blank"
|
||||
data-testid="twitter"
|
||||
>
|
||||
<TwitterIcon color={color} size={24} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
component="a"
|
||||
@@ -27,15 +51,7 @@ export const Socials: React.FC<{ isFooter?: boolean }> = ({ isFooter }) => {
|
||||
target="_blank"
|
||||
data-testid="github"
|
||||
>
|
||||
<GitHubIcon sx={{ color }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
component="a"
|
||||
href={TWITTER_LINK}
|
||||
target="_blank"
|
||||
data-testid="twitter"
|
||||
>
|
||||
<TwitterIcon sx={{ color }} />
|
||||
<GitHubIcon color={color} size={24} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import { Box, Card, CardContent, IconButton, Typography } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt';
|
||||
import EastIcon from '@mui/icons-material/East';
|
||||
|
||||
interface StatsCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
count: string | number;
|
||||
count?: string | number;
|
||||
errorMsg?: Error | string;
|
||||
onClick?: () => void;
|
||||
color?: string;
|
||||
}
|
||||
export const StatsCard: React.FC<StatsCardProps> = ({
|
||||
icon,
|
||||
@@ -23,59 +17,48 @@ export const StatsCard: React.FC<StatsCardProps> = ({
|
||||
count,
|
||||
onClick,
|
||||
errorMsg,
|
||||
color: colorProp,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const matches = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const color = colorProp || theme.palette.text.primary;
|
||||
return (
|
||||
<Card onClick={onClick} sx={{ height: '100%' }}>
|
||||
<CardContent
|
||||
sx={{
|
||||
padding: 2,
|
||||
padding: 1.5,
|
||||
paddingLeft: 3,
|
||||
'&:last-child': {
|
||||
paddingBottom: 2,
|
||||
paddingBottom: 1.5,
|
||||
},
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: 18,
|
||||
justifyContent: matches ? 'space-between' : 'flex-start',
|
||||
maxWidth: matches ? 230 : null,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
color: theme.palette.text.primary,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
fontSize: 18,
|
||||
justifyContent: matches ? 'space-between' : 'flex-start',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: matches ? 230 : null,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" color={color}>
|
||||
<Box display="flex">
|
||||
{icon}
|
||||
<Typography
|
||||
ml={3}
|
||||
mr={0.75}
|
||||
fontSize="inherit"
|
||||
fontWeight="inherit"
|
||||
data-testid={`${title}-amount`}
|
||||
>
|
||||
{count}
|
||||
{count === undefined || count === null ? '' : count}
|
||||
</Typography>
|
||||
<Typography mr={1} fontSize="inherit" data-testid={title}>
|
||||
<Typography
|
||||
mr={1}
|
||||
fontSize="inherit"
|
||||
fontWeight="inherit"
|
||||
data-testid={title}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<IconButton color="inherit">
|
||||
<ArrowRightAltIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<IconButton color="inherit" sx={{ fontSize: '16px' }}>
|
||||
<EastIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{errorMsg && (
|
||||
<Typography variant="body2" sx={{ color: 'danger', padding: 2 }}>
|
||||
@@ -90,4 +73,6 @@ export const StatsCard: React.FC<StatsCardProps> = ({
|
||||
StatsCard.defaultProps = {
|
||||
onClick: undefined,
|
||||
errorMsg: undefined,
|
||||
color: undefined,
|
||||
count: undefined,
|
||||
};
|
||||
|
||||
@@ -65,7 +65,11 @@ export const DarkLightSwitchDesktop: React.FC<{ defaultChecked: boolean }> = ({
|
||||
}) => {
|
||||
const { toggleMode } = useMainContext();
|
||||
return (
|
||||
<Button onClick={() => toggleMode()} data-testid="switch-button">
|
||||
<Button
|
||||
sx={{ paddingLeft: 0 }}
|
||||
onClick={() => toggleMode()}
|
||||
data-testid="switch-button"
|
||||
>
|
||||
<DarkLightSwitch defaultChecked={defaultChecked} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ type TableToolBarProps = {
|
||||
onChangePageSize: (event: SelectChangeEvent<string>) => void;
|
||||
pageSize: string;
|
||||
searchTerm: string;
|
||||
childrenBefore?: React.ReactNode;
|
||||
childrenAfter?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const TableToolbar: React.FC<TableToolBarProps> = ({
|
||||
@@ -15,6 +17,8 @@ export const TableToolbar: React.FC<TableToolBarProps> = ({
|
||||
onChangeSearch,
|
||||
onChangePageSize,
|
||||
pageSize,
|
||||
childrenBefore,
|
||||
childrenAfter,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const matches = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
@@ -29,35 +33,44 @@ export const TableToolbar: React.FC<TableToolBarProps> = ({
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
labelId="simple-select-label"
|
||||
id="simple-select"
|
||||
value={pageSize}
|
||||
onChange={onChangePageSize}
|
||||
sx={{
|
||||
width: matches ? 100 : 200,
|
||||
}}
|
||||
>
|
||||
<MenuItem value={10} data-testid="ten">
|
||||
10
|
||||
</MenuItem>
|
||||
<MenuItem value={30} data-testid="thirty">
|
||||
30
|
||||
</MenuItem>
|
||||
<MenuItem value={50} data-testid="fifty">
|
||||
50
|
||||
</MenuItem>
|
||||
<MenuItem value={100} data-testid="hundred">
|
||||
100
|
||||
</MenuItem>
|
||||
</Select>
|
||||
<TextField
|
||||
sx={{ width: matches ? '100%' : 350, marginBottom: matches ? 2 : 0 }}
|
||||
value={searchTerm}
|
||||
data-testid="search-box"
|
||||
placeholder="search"
|
||||
onChange={(event) => onChangeSearch(event.target.value)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'middle' }}>
|
||||
{childrenBefore}
|
||||
<Select
|
||||
labelId="simple-select-label"
|
||||
id="simple-select"
|
||||
value={pageSize}
|
||||
onChange={onChangePageSize}
|
||||
sx={{
|
||||
width: matches ? 100 : 200,
|
||||
}}
|
||||
>
|
||||
<MenuItem value={10} data-testid="ten">
|
||||
10
|
||||
</MenuItem>
|
||||
<MenuItem value={30} data-testid="thirty">
|
||||
30
|
||||
</MenuItem>
|
||||
<MenuItem value={50} data-testid="fifty">
|
||||
50
|
||||
</MenuItem>
|
||||
<MenuItem value={100} data-testid="hundred">
|
||||
100
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'middle' }}>
|
||||
<TextField
|
||||
sx={{
|
||||
width: matches ? '100%' : 350,
|
||||
marginBottom: matches ? 2 : 0,
|
||||
}}
|
||||
value={searchTerm}
|
||||
data-testid="search-box"
|
||||
placeholder="search"
|
||||
onChange={(event) => onChangeSearch(event.target.value)}
|
||||
/>
|
||||
{childrenAfter}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ export const Title: React.FC<{ text: string }> = ({ text }) => (
|
||||
variant="h5"
|
||||
sx={{
|
||||
mb: 3,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
data-testid={text}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { ApiState } from '../typeDefs/explorer-api';
|
||||
|
||||
type WrappedApiFn<T> = () => Promise<ApiState<T>>;
|
||||
|
||||
export const useApiState = <T>(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
fn: Function,
|
||||
errorMessage: string,
|
||||
): [ApiState<T>, WrappedApiFn<T>] => {
|
||||
const [value, setValue] = React.useState<ApiState<T>>();
|
||||
const wrappedFn = React.useCallback(async () => {
|
||||
try {
|
||||
setValue((prevState) => ({ ...prevState, isLoading: true }));
|
||||
const data = await fn();
|
||||
setValue({
|
||||
isLoading: false,
|
||||
data,
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
setValue({
|
||||
error: error instanceof Error ? error : new Error(errorMessage),
|
||||
isLoading: false,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}, [setValue, fn]);
|
||||
return [value || { isLoading: true }, wrappedFn];
|
||||
};
|
||||
+43
-149
@@ -13,51 +13,41 @@ import {
|
||||
StatsResponse,
|
||||
StatusResponse,
|
||||
UptimeStoryResponse,
|
||||
MixNodeDescriptionResponse,
|
||||
MixnodeStatus,
|
||||
SummaryOverviewResponse,
|
||||
} from 'src/typeDefs/explorer-api';
|
||||
import { Api } from '../api';
|
||||
import { navOptionType, originalNavOptions } from './nav';
|
||||
|
||||
interface State {
|
||||
mode: PaletteMode;
|
||||
toggleMode: () => void;
|
||||
navState: navOptionType[];
|
||||
updateNavState: (id: number) => void;
|
||||
mixnodes?: ApiState<MixNodeResponse>;
|
||||
fetchMixnodes: () => void;
|
||||
filterMixnodes: (arg: MixNodeResponse) => void;
|
||||
gateways?: ApiState<GatewayResponse>;
|
||||
validators?: ApiState<ValidatorsResponse>;
|
||||
interface StateData {
|
||||
summaryOverview?: ApiState<SummaryOverviewResponse>;
|
||||
block?: ApiState<BlockResponse>;
|
||||
countryData?: ApiState<CountryDataResponse>;
|
||||
gateways?: ApiState<GatewayResponse>;
|
||||
globalError?: string | undefined;
|
||||
mixnodeDetailInfo?: ApiState<MixNodeResponse>;
|
||||
fetchMixnodeById: (arg: string) => void;
|
||||
fetchDelegationsById: (arg: string) => void;
|
||||
delegations?: ApiState<DelegationsResponse>;
|
||||
stats?: ApiState<StatsResponse>;
|
||||
fetchStatsById: (arg: string) => void;
|
||||
status?: ApiState<StatusResponse>;
|
||||
fetchStatusById: (arg: string) => void;
|
||||
fetchUptimeStoryById: (arg: string) => void;
|
||||
uptimeStory?: ApiState<UptimeStoryResponse>;
|
||||
mixnodes?: ApiState<MixNodeResponse>;
|
||||
mode: PaletteMode;
|
||||
navState: navOptionType[];
|
||||
validators?: ApiState<ValidatorsResponse>;
|
||||
}
|
||||
|
||||
interface StateApi {
|
||||
fetchMixnodes: (status?: MixnodeStatus) => void;
|
||||
filterMixnodes: (mixnodes: MixNodeResponse) => void;
|
||||
toggleMode: () => void;
|
||||
updateNavState: (id: number) => void;
|
||||
}
|
||||
|
||||
type State = StateData & StateApi;
|
||||
|
||||
export const MainContext = React.createContext<State>({
|
||||
mode: 'dark',
|
||||
updateNavState: () => null,
|
||||
navState: originalNavOptions,
|
||||
fetchMixnodeById: () => null,
|
||||
toggleMode: () => undefined,
|
||||
fetchDelegationsById: () => null,
|
||||
fetchStatsById: () => null,
|
||||
fetchStatusById: () => null,
|
||||
fetchUptimeStoryById: () => null,
|
||||
filterMixnodes: () => null,
|
||||
fetchMixnodes: () => null,
|
||||
status: { data: undefined, isLoading: false, error: undefined },
|
||||
stats: { data: undefined, isLoading: false, error: undefined },
|
||||
mixnodeDetailInfo: { data: undefined, isLoading: false, error: undefined },
|
||||
delegations: { data: undefined, isLoading: false, error: undefined },
|
||||
});
|
||||
|
||||
export const useMainContext = (): React.ContextType<typeof MainContext> =>
|
||||
@@ -75,6 +65,8 @@ export const MainContextProvider: React.FC = ({ children }) => {
|
||||
const [globalError, setGlobalError] = React.useState<string>();
|
||||
|
||||
// various APIs for Overview page
|
||||
const [summaryOverview, setSummaryOverview] =
|
||||
React.useState<ApiState<SummaryOverviewResponse>>();
|
||||
const [mixnodes, setMixnodes] = React.useState<ApiState<MixNodeResponse>>();
|
||||
const [gateways, setGateways] = React.useState<ApiState<GatewayResponse>>();
|
||||
const [validators, setValidators] =
|
||||
@@ -82,119 +74,30 @@ export const MainContextProvider: React.FC = ({ children }) => {
|
||||
const [block, setBlock] = React.useState<ApiState<BlockResponse>>();
|
||||
const [countryData, setCountryData] =
|
||||
React.useState<ApiState<CountryDataResponse>>();
|
||||
const [mixnodeDetailInfo, setMixnodeDetailInfo] =
|
||||
React.useState<ApiState<MixNodeResponse>>();
|
||||
|
||||
// various APIs for Detail page
|
||||
const [delegations, setDelegations] =
|
||||
React.useState<ApiState<DelegationsResponse>>();
|
||||
const [status, setStatus] = React.useState<ApiState<StatusResponse>>();
|
||||
const [stats, setStats] = React.useState<ApiState<StatsResponse>>();
|
||||
const [uptimeStory, setUptimeStory] =
|
||||
React.useState<ApiState<UptimeStoryResponse>>();
|
||||
|
||||
const toggleMode = () => setMode((m) => (m !== 'light' ? 'light' : 'dark'));
|
||||
|
||||
const fetchUptimeStoryById = async (id: string) => {
|
||||
setUptimeStory({
|
||||
data: uptimeStory?.data,
|
||||
isLoading: true,
|
||||
error: uptimeStory?.error,
|
||||
});
|
||||
const fetchOverviewSummary = async () => {
|
||||
try {
|
||||
const data = await Api.fetchUptimeStoryById(id);
|
||||
setUptimeStory({ data, isLoading: false });
|
||||
const data = await Api.fetchOverviewSummary();
|
||||
setSummaryOverview({ data, isLoading: false });
|
||||
} catch (error) {
|
||||
setUptimeStory({
|
||||
setSummaryOverview({
|
||||
error:
|
||||
error instanceof Error ? error : new Error('Uptime Story api fail'),
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Overview summary api fail'),
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
const fetchDelegationsById = async (id: string) => {
|
||||
setDelegations({ data: delegations?.data, isLoading: true });
|
||||
try {
|
||||
const data = await Api.fetchDelegationsById(id);
|
||||
setDelegations({ data, isLoading: false });
|
||||
} catch (error) {
|
||||
setDelegations({
|
||||
error:
|
||||
error instanceof Error ? error : new Error('Delegations api fail'),
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
const fetchStatusById = async (id: string) => {
|
||||
setStatus({ data: status?.data, isLoading: true, error: status?.error });
|
||||
try {
|
||||
const data = await Api.fetchStatusById(id);
|
||||
setStatus({ data, isLoading: false });
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
error: error instanceof Error ? error : new Error('Status api fail'),
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
const fetchStatsById = async (id: string) => {
|
||||
setStats({ data: stats?.data, isLoading: true, error: stats?.error });
|
||||
try {
|
||||
const data = await Api.fetchStatsById(id);
|
||||
setStats({ data, isLoading: false });
|
||||
} catch (error) {
|
||||
setStats({
|
||||
error: error instanceof Error ? error : new Error('Stats api fail'),
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
const fetchMixnodeById = async (id: string) => {
|
||||
setMixnodeDetailInfo({ data: mixnodeDetailInfo?.data, isLoading: true });
|
||||
|
||||
// 1. if mixnode data already exists filter down to this ID
|
||||
if (mixnodes && mixnodes.data) {
|
||||
const [matchedToID] = mixnodes.data.filter(
|
||||
(eachMixnode: MixNodeResponseItem) =>
|
||||
eachMixnode.mix_node.identity_key === id,
|
||||
);
|
||||
|
||||
// b) SUCCESS | if there *IS* a matched ID in mixnodes
|
||||
if (matchedToID) {
|
||||
setMixnodeDetailInfo({ data: [matchedToID], isLoading: false });
|
||||
}
|
||||
// b) FAIL | if there is no matching ID in mixnodes
|
||||
if (!matchedToID) {
|
||||
setGlobalError(MIXNODE_API_ERROR);
|
||||
setMixnodeDetailInfo({
|
||||
isLoading: false,
|
||||
error: new Error(MIXNODE_API_ERROR),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 2. if mixnode data DOESN'T already exist, fetch this specific ID's information.
|
||||
try {
|
||||
const data = await Api.fetchMixnodeByID(id);
|
||||
// a) fetches from cache^, then API, then filters down then dumps in `mixnodes` context.
|
||||
if (data) {
|
||||
setMixnodeDetailInfo({ data: [data], isLoading: false });
|
||||
} else {
|
||||
throw Error('api failed to retrieve mixnode via id');
|
||||
}
|
||||
// NOTE: Only returning mixnodes api info at the moment. Other `ping` api required also.
|
||||
} catch (error) {
|
||||
setGlobalError(MIXNODE_API_ERROR);
|
||||
setMixnodeDetailInfo({
|
||||
isLoading: false,
|
||||
error: new Error(MIXNODE_API_ERROR),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const fetchMixnodes = async () => {
|
||||
const fetchMixnodes = async (status?: MixnodeStatus) => {
|
||||
setMixnodes((d) => ({ ...d, isLoading: true }));
|
||||
try {
|
||||
const data = await Api.fetchMixnodes();
|
||||
const data = status
|
||||
? await Api.fetchMixnodesActiveSetByStatus(status)
|
||||
: await Api.fetchMixnodes();
|
||||
setMixnodes({ data, isLoading: false });
|
||||
} catch (error) {
|
||||
setMixnodes({
|
||||
@@ -262,7 +165,7 @@ export const MainContextProvider: React.FC = ({ children }) => {
|
||||
};
|
||||
React.useEffect(() => {
|
||||
Promise.all([
|
||||
fetchMixnodes(),
|
||||
fetchOverviewSummary(),
|
||||
fetchGateways(),
|
||||
fetchValidators(),
|
||||
fetchBlock(),
|
||||
@@ -273,28 +176,19 @@ export const MainContextProvider: React.FC = ({ children }) => {
|
||||
return (
|
||||
<MainContext.Provider
|
||||
value={{
|
||||
mode,
|
||||
updateNavState,
|
||||
navState,
|
||||
toggleMode,
|
||||
mixnodes,
|
||||
filterMixnodes,
|
||||
fetchMixnodes,
|
||||
gateways,
|
||||
validators,
|
||||
block,
|
||||
countryData,
|
||||
fetchMixnodes,
|
||||
filterMixnodes,
|
||||
gateways,
|
||||
globalError,
|
||||
mixnodeDetailInfo,
|
||||
fetchMixnodeById,
|
||||
fetchDelegationsById,
|
||||
delegations,
|
||||
stats,
|
||||
fetchStatsById,
|
||||
status,
|
||||
fetchStatusById,
|
||||
uptimeStory,
|
||||
fetchUptimeStoryById,
|
||||
mixnodes,
|
||||
mode,
|
||||
navState,
|
||||
summaryOverview,
|
||||
toggleMode,
|
||||
updateNavState,
|
||||
validators,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ApiState,
|
||||
DelegationsResponse,
|
||||
MixNodeDescriptionResponse,
|
||||
MixNodeResponseItem,
|
||||
StatsResponse,
|
||||
StatusResponse,
|
||||
UptimeStoryResponse,
|
||||
} from 'src/typeDefs/explorer-api';
|
||||
import { Api } from '../api';
|
||||
import {
|
||||
mixNodeResponseItemToMixnodeRowType,
|
||||
MixnodeRowType,
|
||||
} from '../components/MixNodes';
|
||||
|
||||
/**
|
||||
* This context provides the state for a single mixnode by identity key.
|
||||
*/
|
||||
|
||||
interface MixnodeState {
|
||||
delegations?: ApiState<DelegationsResponse>;
|
||||
description?: ApiState<MixNodeDescriptionResponse>;
|
||||
mixNode?: ApiState<MixNodeResponseItem | undefined>;
|
||||
mixNodeRow?: MixnodeRowType;
|
||||
stats?: ApiState<StatsResponse>;
|
||||
status?: ApiState<StatusResponse>;
|
||||
uptimeStory?: ApiState<UptimeStoryResponse>;
|
||||
}
|
||||
|
||||
export const MixnodeContext = React.createContext<MixnodeState>({});
|
||||
|
||||
export const useMixnodeContext = (): React.ContextType<typeof MixnodeContext> =>
|
||||
React.useContext<MixnodeState>(MixnodeContext);
|
||||
|
||||
interface MixnodeContextProviderProps {
|
||||
mixNodeIdentityKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a state context for a mixnode by identity
|
||||
* @param mixNodeIdentityKey The identity key of the mixnode
|
||||
*/
|
||||
export const MixnodeContextProvider: React.FC<MixnodeContextProviderProps> = ({
|
||||
mixNodeIdentityKey,
|
||||
children,
|
||||
}) => {
|
||||
const [mixNode, fetchMixnodeById, clearMixnodeById] = useApiState<
|
||||
MixNodeResponseItem | undefined
|
||||
>(mixNodeIdentityKey, Api.fetchMixnodeByID, 'Failed to fetch mixnode by id');
|
||||
|
||||
const [mixNodeRow, setMixnodeRow] = React.useState<
|
||||
MixnodeRowType | undefined
|
||||
>();
|
||||
|
||||
const [delegations, fetchDelegations, clearDelegations] =
|
||||
useApiState<DelegationsResponse>(
|
||||
mixNodeIdentityKey,
|
||||
Api.fetchDelegationsById,
|
||||
'Failed to fetch delegations for mixnode',
|
||||
);
|
||||
|
||||
const [status, fetchStatus, clearStatus] = useApiState<StatusResponse>(
|
||||
mixNodeIdentityKey,
|
||||
Api.fetchStatusById,
|
||||
'Failed to fetch mixnode status',
|
||||
);
|
||||
|
||||
const [stats, fetchStats, clearStats] = useApiState<StatsResponse>(
|
||||
mixNodeIdentityKey,
|
||||
Api.fetchStatsById,
|
||||
'Failed to fetch mixnode stats',
|
||||
);
|
||||
|
||||
const [description, fetchDescription, clearDescription] =
|
||||
useApiState<MixNodeDescriptionResponse>(
|
||||
mixNodeIdentityKey,
|
||||
Api.fetchMixnodeDescriptionById,
|
||||
'Failed to fetch mixnode description',
|
||||
);
|
||||
|
||||
const [uptimeStory, fetchUptimeHistory, clearUptimeHistory] =
|
||||
useApiState<UptimeStoryResponse>(
|
||||
mixNodeIdentityKey,
|
||||
Api.fetchUptimeStoryById,
|
||||
'Failed to fetch mixnode uptime history',
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// when the identity key changes, remove all previous data
|
||||
clearMixnodeById();
|
||||
clearDelegations();
|
||||
clearStatus();
|
||||
clearStats();
|
||||
clearDescription();
|
||||
clearUptimeHistory();
|
||||
|
||||
// fetch the mixnode, then get all the other stuff
|
||||
fetchMixnodeById().then((value) => {
|
||||
if (!value.data || value.error) {
|
||||
setMixnodeRow(undefined);
|
||||
return;
|
||||
}
|
||||
setMixnodeRow(mixNodeResponseItemToMixnodeRowType(value.data));
|
||||
Promise.all([
|
||||
fetchDelegations(),
|
||||
fetchStatus(),
|
||||
fetchStats(),
|
||||
fetchDescription(),
|
||||
fetchUptimeHistory(),
|
||||
]);
|
||||
});
|
||||
}, [mixNodeIdentityKey]);
|
||||
|
||||
return (
|
||||
<MixnodeContext.Provider
|
||||
value={{
|
||||
delegations,
|
||||
mixNode,
|
||||
mixNodeRow,
|
||||
description,
|
||||
stats,
|
||||
status,
|
||||
uptimeStory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MixnodeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to get data from the API by passing an id to a delegate method that fetches the data asynchronously
|
||||
* @param id The id to fetch
|
||||
* @param fn Delegate the fetching to this method (must take `(id: string)` as a parameter)
|
||||
* @param errorMessage A static error message, to use when no dynamic error message is returned
|
||||
*/
|
||||
function useApiState<T>(
|
||||
id: string,
|
||||
fn: (argId: string) => Promise<T>,
|
||||
errorMessage: string,
|
||||
): [ApiState<T> | undefined, () => Promise<ApiState<T>>, () => void] {
|
||||
// stores the state
|
||||
const [value, setValue] = React.useState<ApiState<T> | undefined>();
|
||||
|
||||
// clear the value
|
||||
const clearValueFn = () => setValue(undefined);
|
||||
|
||||
// this provides a method to trigger the delegate to fetch data
|
||||
const wrappedFetchFn = React.useCallback(async () => {
|
||||
try {
|
||||
// keep previous state and set to loading
|
||||
setValue((prevState) => ({ ...prevState, isLoading: true }));
|
||||
|
||||
// delegate to user function to get data and set if successful
|
||||
const data = await fn(id);
|
||||
const newValue: ApiState<T> = {
|
||||
isLoading: false,
|
||||
data,
|
||||
};
|
||||
setValue(newValue);
|
||||
return newValue;
|
||||
} catch (error) {
|
||||
// return the caught error or create a new error with the static error message
|
||||
const newValue: ApiState<T> = {
|
||||
error: error instanceof Error ? error : new Error(errorMessage),
|
||||
isLoading: false,
|
||||
};
|
||||
setValue(newValue);
|
||||
return newValue;
|
||||
}
|
||||
}, [setValue, fn]);
|
||||
return [value || { isLoading: true }, wrappedFetchFn, clearValueFn];
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -6,8 +6,8 @@ export const GatewaysSVG: React.FC = () => {
|
||||
const color = theme.palette.text.primary;
|
||||
return (
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -7,8 +7,8 @@ export const MixnodesSVG: React.FC = () => {
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -6,8 +6,8 @@ export const ValidatorsSVG: React.FC = () => {
|
||||
const color = theme.palette.text.primary;
|
||||
return (
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
interface DiscordIconProps {
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const DiscordIcon: React.FC<DiscordIconProps> = ({
|
||||
size,
|
||||
color: colorProp,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const color = colorProp || theme.palette.text.primary;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<g clipPath="url(#clip0_1223_2296)">
|
||||
<path
|
||||
d="M12.4 0C5.80002 0 0.400024 5.4 0.400024 12C0.400024 18.6 5.80002 24 12.4 24C19 24 24.4 18.6 24.4 12C24.4 5.4 19 0 12.4 0ZM20.1 15.9C18.8 16.9 17.5 17.5 16.2 17.9C16.2 17.9 16.2 17.9 16.1 17.9C15.8 17.5 15.5 17.1 15.3 16.6V16.5C15.7 16.3 16.1 16.1 16.5 15.9V15.8C16.4 15.7 16.3 15.7 16.3 15.6C16.3 15.6 16.3 15.6 16.2 15.6C13.7 16.8 10.9 16.8 8.40002 15.6C8.40002 15.6 8.40002 15.6 8.30002 15.6C8.20002 15.7 8.10002 15.7 8.10002 15.8V15.9C8.50002 16.1 8.90002 16.3 9.30002 16.5C9.30002 16.5 9.30002 16.5 9.30002 16.6C9.10002 17.1 8.80002 17.5 8.50002 17.9C8.50002 17.9 8.50002 17.9 8.40002 17.9C7.10002 17.5 5.90002 16.9 4.50002 15.9C4.40002 13 5.00002 10.1 7.00002 7.1C8.00002 6.6 9.00002 6.3 10.2 6.1C10.2 6.1 10.2 6.1 10.3 6.1C10.4 6.3 10.6 6.7 10.7 6.9C11.9 6.7 13.1 6.7 14.2 6.9C14.3 6.7 14.5 6.3 14.6 6.1C14.6 6.1 14.6 6.1 14.7 6.1C15.8 6.3 16.9 6.6 17.9 7.1C19.5 9.7 20.4 12.6 20.1 15.9Z"
|
||||
fill={color}
|
||||
/>
|
||||
<path
|
||||
d="M15 11C14.2 11 13.6 11.7 13.6 12.6C13.6 13.5 14.2 14.2 15 14.2C15.8 14.2 16.4 13.5 16.4 12.6C16.4 11.7 15.8 11 15 11Z"
|
||||
fill={color}
|
||||
/>
|
||||
<path
|
||||
d="M9.80002 11C9.10002 11 8.40002 11.7 8.40002 12.6C8.40002 13.5 9.00002 14.2 9.80002 14.2C10.6 14.2 11.2 13.5 11.2 12.6C11.2 11.7 10.6 11 9.80002 11Z"
|
||||
fill={color}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1223_2296">
|
||||
<rect width="24" height="24" transform="translate(0.400024)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
DiscordIcon.defaultProps = {
|
||||
size: 24,
|
||||
color: undefined,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
interface GitHubIconProps {
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const GitHubIcon: React.FC<GitHubIconProps> = ({
|
||||
size,
|
||||
color: colorProp,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const color = colorProp || theme.palette.text.primary;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<g clipPath="url(#clip0_1223_2302)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.7 0C5.90002 0 0.400024 5.5 0.400024 12.3C0.400024 17.7 3.90002 22.3 8.80002 24C9.40002 24.1 9.60002 23.7 9.60002 23.4C9.60002 23.1 9.60002 22.1 9.60002 21.1C6.50002 21.7 5.70002 20.3 5.50002 19.7C5.40002 19.3 4.80002 18.3 4.20002 18C3.80002 17.8 3.20002 17.2 4.20002 17.2C5.20002 17.2 5.90002 18.1 6.10002 18.5C7.20002 20.4 9.00002 19.8 9.70002 19.5C9.80002 18.7 10.1 18.2 10.5 17.9C7.80002 17.6 4.90002 16.5 4.90002 11.8C4.90002 10.5 5.40002 9.4 6.20002 8.5C6.00002 8 5.60002 6.8 6.30002 5.1C6.30002 5.1 7.30002 4.8 9.70002 6.4C10.7 6.1 11.7 6 12.8 6C13.8 6 14.9 6.1 15.9 6.4C18.3 4.8 19.3 5.1 19.3 5.1C20 6.8 19.5 8.1 19.4 8.4C20.2 9.3 20.7 10.4 20.7 11.7C20.7 16.4 17.8 17.5 15.1 17.8C15.5 18.2 15.9 18.9 15.9 20.1C15.9 21.7 15.9 23.1 15.9 23.5C15.9 23.8 16.1 24.2 16.7 24.1C21.6 22.5 25.1 17.9 25.1 12.4C25 5.5 19.5 0 12.7 0Z"
|
||||
fill={color}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1223_2302">
|
||||
<rect width="24" height="24" transform="translate(0.400024)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
GitHubIcon.defaultProps = {
|
||||
size: 24,
|
||||
color: undefined,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
interface TelegramIconProps {
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const TelegramIcon: React.FC<TelegramIconProps> = ({
|
||||
size,
|
||||
color: colorProp,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const color = colorProp || theme.palette.text.primary;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12.4 24C19.029 24 24.4 18.629 24.4 12C24.4 5.371 19.029 0 12.4 0C5.77102 0 0.400024 5.371 0.400024 12C0.400024 18.629 5.77102 24 12.4 24ZM5.89102 11.74L17.461 7.279C17.998 7.085 18.467 7.41 18.293 8.222L18.294 8.221L16.324 17.502C16.178 18.16 15.787 18.32 15.24 18.01L12.24 15.799L10.793 17.193C10.633 17.353 10.498 17.488 10.188 17.488L10.401 14.435L15.961 9.412C16.203 9.199 15.907 9.079 15.588 9.291L8.71702 13.617L5.75502 12.693C5.11202 12.489 5.09802 12.05 5.89102 11.74Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
TelegramIcon.defaultProps = {
|
||||
size: 24,
|
||||
color: undefined,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
interface TwitterIconProps {
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const TwitterIcon: React.FC<TwitterIconProps> = ({
|
||||
size,
|
||||
color: colorProp,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const color = colorProp || theme.palette.text.primary;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<g clipPath="url(#clip0_1223_2294)">
|
||||
<path
|
||||
d="M12.4 0C5.77362 0 0.400024 5.3736 0.400024 12C0.400024 18.6264 5.77362 24 12.4 24C19.0264 24 24.4 18.6264 24.4 12C24.4 5.3736 19.0264 0 12.4 0ZM17.8791 9.35632C17.8844 9.47443 17.887 9.59308 17.887 9.71228C17.887 13.3519 15.1166 17.5488 10.0502 17.549H10.0504H10.0502C8.49475 17.549 7.0473 17.0931 5.82837 16.3118C6.04388 16.3372 6.26324 16.3499 6.48535 16.3499C7.77588 16.3499 8.9635 15.9097 9.90631 15.1708C8.70056 15.1485 7.68396 14.3522 7.33313 13.2578C7.50104 13.29 7.67371 13.3076 7.85077 13.3076C8.10217 13.3076 8.3457 13.2737 8.57715 13.2105C7.31683 12.9582 6.36743 11.8444 6.36743 10.5106C6.36743 10.4982 6.36743 10.487 6.3678 10.4755C6.73895 10.6818 7.16339 10.806 7.6153 10.8199C6.87573 10.3264 6.38959 9.48285 6.38959 8.52722C6.38959 8.02258 6.526 7.5498 6.76257 7.14276C8.12085 8.80939 10.1508 9.90546 12.4399 10.0206C12.3927 9.81885 12.3683 9.60864 12.3683 9.39258C12.3683 7.87207 13.6019 6.63849 15.123 6.63849C15.9153 6.63849 16.6309 6.97339 17.1335 7.50879C17.761 7.38501 18.3502 7.15576 18.8825 6.84027C18.6765 7.48315 18.24 8.02258 17.6713 8.36371C18.2285 8.29706 18.7595 8.14929 19.2529 7.92993C18.8843 8.48236 18.4169 8.96759 17.8791 9.35632V9.35632Z"
|
||||
fill={color}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1223_2294">
|
||||
<rect width="24" height="24" transform="translate(0.400024)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
TwitterIcon.defaultProps = {
|
||||
size: 24,
|
||||
color: undefined,
|
||||
};
|
||||
@@ -5,13 +5,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>Nym Network Explorer</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const Page404: React.FC = () => {
|
||||
</Typography>
|
||||
<Button
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 600,
|
||||
bgcolor: theme.palette.primary.main,
|
||||
color: theme.palette.secondary.main,
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Card, Grid, Typography } from '@mui/material';
|
||||
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
|
||||
import { SelectChangeEvent } from '@mui/material/Select';
|
||||
import { useMainContext } from 'src/context/main';
|
||||
import { gatewayToGridRow } from 'src/utils';
|
||||
import { gatewayToGridRow } from 'src/components/Gateways';
|
||||
import { GatewayResponse } from 'src/typeDefs/explorer-api';
|
||||
import { TableToolbar } from 'src/components/TableToolbar';
|
||||
import { CustomColumnHeading } from 'src/components/CustomColumnHeading';
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Grid, Typography } from '@mui/material';
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ColumnsType, DetailTable } from 'src/components/DetailTable';
|
||||
import { mixnodeToGridRow, scrollToRef } from 'src/utils';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { BondBreakdownTable } from 'src/components/BondBreakdown';
|
||||
import { BondBreakdownTable } from 'src/components/MixNodes/BondBreakdown';
|
||||
import { ComponentError } from 'src/components/ComponentError';
|
||||
import { ContentCard } from 'src/components/ContentCard';
|
||||
import { MixNodeResponseItem } from 'src/typeDefs/explorer-api';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { TwoColSmallTable } from 'src/components/TwoColSmallTable';
|
||||
import { UptimeChart } from 'src/components/UptimeChart';
|
||||
import { WorldMap } from 'src/components/WorldMap';
|
||||
import { useMainContext } from 'src/context/main';
|
||||
import { MixNodeDetailSection } from '../../components/MixNodes/DetailSection';
|
||||
import {
|
||||
MixnodeContextProvider,
|
||||
useMixnodeContext,
|
||||
} from '../../context/mixnode';
|
||||
import { Title } from '../../components/Title';
|
||||
|
||||
const columns: ColumnsType[] = [
|
||||
{
|
||||
@@ -28,8 +37,8 @@ const columns: ColumnsType[] = [
|
||||
},
|
||||
|
||||
{
|
||||
field: 'pledge',
|
||||
title: 'Pledge',
|
||||
field: 'bond',
|
||||
title: 'Bond',
|
||||
flex: 1,
|
||||
headerAlign: 'left',
|
||||
},
|
||||
@@ -59,166 +68,194 @@ const columns: ColumnsType[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Wrapper component that adds the mixnode content based on the `id` in the address URL
|
||||
*/
|
||||
export const PageMixnodeDetail: React.FC = () => {
|
||||
const ref = React.useRef();
|
||||
const [row, setRow] = React.useState<MixNodeResponseItem[]>([]);
|
||||
const {
|
||||
fetchMixnodeById,
|
||||
mixnodeDetailInfo,
|
||||
fetchStatsById,
|
||||
fetchDelegationsById,
|
||||
fetchUptimeStoryById,
|
||||
fetchStatusById,
|
||||
stats,
|
||||
status,
|
||||
uptimeStory,
|
||||
} = useMainContext();
|
||||
const { id }: any = useParams();
|
||||
const { id } = useParams<{ id: string | undefined }>();
|
||||
|
||||
React.useEffect(() => {
|
||||
const hasNoDetail = id && !mixnodeDetailInfo;
|
||||
const hasIncorrectDetail =
|
||||
id &&
|
||||
mixnodeDetailInfo?.data &&
|
||||
mixnodeDetailInfo?.data[0].mix_node.identity_key !== id;
|
||||
if (hasNoDetail || hasIncorrectDetail) {
|
||||
fetchMixnodeById(id);
|
||||
fetchDelegationsById(id);
|
||||
fetchStatsById(id);
|
||||
fetchStatusById(id);
|
||||
fetchUptimeStoryById(id);
|
||||
} else if (mixnodeDetailInfo?.data !== undefined) {
|
||||
setRow(mixnodeDetailInfo?.data);
|
||||
}
|
||||
}, [id, mixnodeDetailInfo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
scrollToRef(ref);
|
||||
}, [ref]);
|
||||
if (!id) {
|
||||
return (
|
||||
<Alert severity="error">Oh no! No mixnode identity key specified</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box component="main" ref={ref}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Title text="Mixnode Detail" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<DetailTable
|
||||
columnsData={columns}
|
||||
tableName="Mixnode detail table"
|
||||
rows={mixnodeToGridRow(row)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} mt={0}>
|
||||
<Grid item xs={12}>
|
||||
<ContentCard title="Bond Breakdown">
|
||||
<BondBreakdownTable />
|
||||
</ContentCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} mt={0}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<ContentCard title="Mixnode Stats">
|
||||
{stats && (
|
||||
<>
|
||||
{stats.error && (
|
||||
<ComponentError text="There was a problem retrieving this nodes stats." />
|
||||
)}
|
||||
<TwoColSmallTable
|
||||
loading={stats.isLoading}
|
||||
error={stats?.error?.message}
|
||||
title="Since startup"
|
||||
keys={['Received', 'Sent', 'Explicitly dropped']}
|
||||
values={[
|
||||
stats?.data?.packets_received_since_startup || 0,
|
||||
stats?.data?.packets_sent_since_startup || 0,
|
||||
stats?.data?.packets_explicitly_dropped_since_startup ||
|
||||
0,
|
||||
]}
|
||||
/>
|
||||
<TwoColSmallTable
|
||||
loading={stats.isLoading}
|
||||
error={stats?.error?.message}
|
||||
title="Since last update"
|
||||
keys={['Received', 'Sent', 'Explicitly dropped']}
|
||||
values={[
|
||||
stats?.data?.packets_received_since_last_update || 0,
|
||||
stats?.data?.packets_sent_since_last_update || 0,
|
||||
stats?.data
|
||||
?.packets_explicitly_dropped_since_last_update || 0,
|
||||
]}
|
||||
marginBottom
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!stats && <Typography>No stats information</Typography>}
|
||||
</ContentCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
{uptimeStory && (
|
||||
<ContentCard title="Uptime story">
|
||||
{uptimeStory.error && (
|
||||
<ComponentError text="There was a problem retrieving uptime history." />
|
||||
)}
|
||||
<UptimeChart
|
||||
loading={uptimeStory.isLoading}
|
||||
xLabel="date"
|
||||
yLabel="uptime"
|
||||
uptimeStory={uptimeStory}
|
||||
/>
|
||||
</ContentCard>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} mt={0}>
|
||||
<Grid item xs={12} md={4}>
|
||||
{status && (
|
||||
<ContentCard title="Mixnode Status">
|
||||
{status.error && (
|
||||
<ComponentError text="There was a problem retrieving port information" />
|
||||
)}
|
||||
<TwoColSmallTable
|
||||
loading={status.isLoading}
|
||||
error={status?.error?.message}
|
||||
keys={['Mix port', 'Verloc port', 'HTTP port']}
|
||||
values={[1789, 1790, 8000].map((each) => each)}
|
||||
icons={
|
||||
(status?.data?.ports &&
|
||||
Object.values(status.data.ports)) || [false, false, false]
|
||||
}
|
||||
/>
|
||||
</ContentCard>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
{mixnodeDetailInfo && (
|
||||
<ContentCard title="Location">
|
||||
{mixnodeDetailInfo?.error && (
|
||||
<ComponentError text="There was a problem retrieving this mixnode location" />
|
||||
)}
|
||||
{mixnodeDetailInfo.data &&
|
||||
mixnodeDetailInfo?.data[0]?.location && (
|
||||
<WorldMap
|
||||
loading={mixnodeDetailInfo.isLoading}
|
||||
userLocation={[
|
||||
mixnodeDetailInfo?.data[0]?.location?.lng,
|
||||
mixnodeDetailInfo?.data[0]?.location?.lat,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ContentCard>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
<MixnodeContextProvider mixNodeIdentityKey={id}>
|
||||
<PageMixnodeDetailGuard />
|
||||
</MixnodeContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Guard component to handle loading and not found states
|
||||
*/
|
||||
const PageMixnodeDetailGuard: React.FC = () => {
|
||||
const { mixNode } = useMixnodeContext();
|
||||
const { id } = useParams<{ id: string | undefined }>();
|
||||
|
||||
if (mixNode?.isLoading) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
if (mixNode?.error) {
|
||||
console.error(mixNode?.error);
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Oh no! Could not load mixnode <code>{id || ''}</code>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// loaded, but not found
|
||||
if (mixNode && !mixNode.isLoading && !mixNode.data) {
|
||||
return (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>Mixnode not found</AlertTitle>
|
||||
Sorry, we could not find a mixnode with id <code>{id || ''}</code>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return <PageMixnodeDetailWithState />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows mix node details
|
||||
*/
|
||||
const PageMixnodeDetailWithState: React.FC = () => {
|
||||
const { mixNode, mixNodeRow, description, stats, status, uptimeStory } =
|
||||
useMixnodeContext();
|
||||
|
||||
return (
|
||||
<Box component="main">
|
||||
<Title text="Mixnode Detail" />
|
||||
|
||||
<Grid container spacing={2} mt={1} mb={6}>
|
||||
<Grid item xs={12}>
|
||||
{mixNodeRow && description?.data && (
|
||||
<MixNodeDetailSection
|
||||
mixNodeRow={mixNodeRow}
|
||||
mixnodeDescription={description.data}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<DetailTable
|
||||
columnsData={columns}
|
||||
tableName="Mixnode detail table"
|
||||
rows={mixNodeRow ? [mixNodeRow] : []}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} mt={0}>
|
||||
<Grid item xs={12}>
|
||||
<ContentCard title="Bond Breakdown">
|
||||
<BondBreakdownTable />
|
||||
</ContentCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} mt={0}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<ContentCard title="Mixnode Stats">
|
||||
{stats && (
|
||||
<>
|
||||
{stats.error && (
|
||||
<ComponentError text="There was a problem retrieving this nodes stats." />
|
||||
)}
|
||||
<TwoColSmallTable
|
||||
loading={stats.isLoading}
|
||||
error={stats?.error?.message}
|
||||
title="Since startup"
|
||||
keys={['Received', 'Sent', 'Explicitly dropped']}
|
||||
values={[
|
||||
stats?.data?.packets_received_since_startup || 0,
|
||||
stats?.data?.packets_sent_since_startup || 0,
|
||||
stats?.data?.packets_explicitly_dropped_since_startup || 0,
|
||||
]}
|
||||
/>
|
||||
<TwoColSmallTable
|
||||
loading={stats.isLoading}
|
||||
error={stats?.error?.message}
|
||||
title="Since last update"
|
||||
keys={['Received', 'Sent', 'Explicitly dropped']}
|
||||
values={[
|
||||
stats?.data?.packets_received_since_last_update || 0,
|
||||
stats?.data?.packets_sent_since_last_update || 0,
|
||||
stats?.data?.packets_explicitly_dropped_since_last_update ||
|
||||
0,
|
||||
]}
|
||||
marginBottom
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!stats && <Typography>No stats information</Typography>}
|
||||
</ContentCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
{uptimeStory && (
|
||||
<ContentCard title="Uptime story">
|
||||
{uptimeStory.error && (
|
||||
<ComponentError text="There was a problem retrieving uptime history." />
|
||||
)}
|
||||
<UptimeChart
|
||||
loading={uptimeStory.isLoading}
|
||||
xLabel="date"
|
||||
yLabel="uptime"
|
||||
uptimeStory={uptimeStory}
|
||||
/>
|
||||
</ContentCard>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} mt={0}>
|
||||
<Grid item xs={12} md={4}>
|
||||
{status && (
|
||||
<ContentCard title="Mixnode Status">
|
||||
{status.error && (
|
||||
<ComponentError text="There was a problem retrieving port information" />
|
||||
)}
|
||||
<TwoColSmallTable
|
||||
loading={status.isLoading}
|
||||
error={status?.error?.message}
|
||||
keys={['Mix port', 'Verloc port', 'HTTP port']}
|
||||
values={[1789, 1790, 8000].map((each) => each)}
|
||||
icons={
|
||||
(status?.data?.ports && Object.values(status.data.ports)) || [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ContentCard>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
{mixNode && (
|
||||
<ContentCard title="Location">
|
||||
{mixNode?.error && (
|
||||
<ComponentError text="There was a problem retrieving this mixnode location" />
|
||||
)}
|
||||
{mixNode.data && mixNode?.data?.location && (
|
||||
<WorldMap
|
||||
loading={mixNode.isLoading}
|
||||
userLocation={[
|
||||
mixNode?.data?.location?.lng,
|
||||
mixNode?.data?.location?.lat,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ContentCard>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
|
||||
import { Button, Grid, Link as MuiLink, Card } from '@mui/material';
|
||||
import { Link as RRDLink } from 'react-router-dom';
|
||||
import { Button, Card, Grid, Link as MuiLink } from '@mui/material';
|
||||
import { Link as RRDLink, useParams } from 'react-router-dom';
|
||||
import { SelectChangeEvent } from '@mui/material/Select';
|
||||
import { useMainContext } from 'src/context/main';
|
||||
import { mixnodeToGridRow } from 'src/utils';
|
||||
import { MixnodeRowType, mixnodeToGridRow } from 'src/components/MixNodes';
|
||||
import { TableToolbar } from 'src/components/TableToolbar';
|
||||
import { MixNodeResponse } from 'src/typeDefs/explorer-api';
|
||||
import {
|
||||
MixNodeResponse,
|
||||
MixnodeStatusWithAll,
|
||||
toMixnodeStatus,
|
||||
} from 'src/typeDefs/explorer-api';
|
||||
import { BIG_DIPPER } from 'src/api/constants';
|
||||
import { CustomColumnHeading } from 'src/components/CustomColumnHeading';
|
||||
import { Title } from 'src/components/Title';
|
||||
import {
|
||||
UniversalDataGrid,
|
||||
cellStyles,
|
||||
UniversalDataGrid,
|
||||
} from 'src/components/Universal-DataGrid';
|
||||
import { SxProps } from '@mui/system';
|
||||
import { Theme, useTheme } from '@mui/material/styles';
|
||||
import { useHistory } from 'react-router';
|
||||
import { currencyToString } from '../../utils/currency';
|
||||
import { getMixNodeStatusColor } from '../../components/MixNodes/Status';
|
||||
import { MixNodeStatusDropdown } from '../../components/MixNodes/StatusDropdown';
|
||||
|
||||
export const PageMixnodes: React.FC = () => {
|
||||
const { mixnodes } = useMainContext();
|
||||
const { mixnodes, fetchMixnodes } = useMainContext();
|
||||
const [filteredMixnodes, setFilteredMixnodes] =
|
||||
React.useState<MixNodeResponse>([]);
|
||||
const [pageSize, setPageSize] = React.useState<string>('10');
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>('');
|
||||
const theme = useTheme();
|
||||
const { status } = useParams<{ status: MixnodeStatusWithAll | undefined }>();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSearch = (str: string) => {
|
||||
setSearchTerm(str.toLowerCase());
|
||||
@@ -45,7 +57,20 @@ export const PageMixnodes: React.FC = () => {
|
||||
setFilteredMixnodes(filtered);
|
||||
}
|
||||
}
|
||||
}, [searchTerm, mixnodes?.data]);
|
||||
}, [searchTerm, mixnodes?.data, mixnodes?.isLoading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// when the status changes, get the mixnodes
|
||||
fetchMixnodes(toMixnodeStatus(status));
|
||||
}, [status]);
|
||||
|
||||
const handleMixnodeStatusChanged = (newStatus?: MixnodeStatusWithAll) => {
|
||||
history.push(
|
||||
newStatus && newStatus !== MixnodeStatusWithAll.all
|
||||
? `/network-components/mixnodes/${newStatus}`
|
||||
: '/network-components/mixnodes',
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -59,7 +84,7 @@ export const PageMixnodes: React.FC = () => {
|
||||
<MuiLink
|
||||
href={`${BIG_DIPPER}/account/${params.value}`}
|
||||
target="_blank"
|
||||
sx={cellStyles}
|
||||
sx={getCellStyles(theme, params.row)}
|
||||
data-testid="big-dipper-link"
|
||||
>
|
||||
{params.value}
|
||||
@@ -75,9 +100,9 @@ export const PageMixnodes: React.FC = () => {
|
||||
headerAlign: 'left',
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<MuiLink
|
||||
sx={cellStyles}
|
||||
sx={getCellStyles(theme, params.row)}
|
||||
component={RRDLink}
|
||||
to={`/network-components/mixnodes/${params.value}`}
|
||||
to={`/network-components/mixnode/${params.value}`}
|
||||
data-testid="identity-link"
|
||||
>
|
||||
{params.value}
|
||||
@@ -94,9 +119,9 @@ export const PageMixnodes: React.FC = () => {
|
||||
headerAlign: 'left',
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<MuiLink
|
||||
sx={cellStyles}
|
||||
sx={getCellStyles(theme, params.row)}
|
||||
component={RRDLink}
|
||||
to={`/network-components/mixnodes/${params.row.identity_key}`}
|
||||
to={`/network-components/mixnode/${params.row.identity_key}`}
|
||||
>
|
||||
{currencyToString(params.value)}
|
||||
</MuiLink>
|
||||
@@ -112,7 +137,10 @@ export const PageMixnodes: React.FC = () => {
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<Button
|
||||
onClick={() => handleSearch(params.value as string)}
|
||||
sx={{ ...cellStyles, justifyContent: 'flex-start' }}
|
||||
sx={{
|
||||
...getCellStyles(theme, params.row),
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{params.value}
|
||||
</Button>
|
||||
@@ -128,9 +156,9 @@ export const PageMixnodes: React.FC = () => {
|
||||
headerAlign: 'left',
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<MuiLink
|
||||
sx={cellStyles}
|
||||
sx={getCellStyles(theme, params.row)}
|
||||
component={RRDLink}
|
||||
to={`/network-components/mixnodes/${params.row.identity_key}`}
|
||||
to={`/network-components/mixnode/${params.row.identity_key}`}
|
||||
>
|
||||
{params.value}%
|
||||
</MuiLink>
|
||||
@@ -141,13 +169,13 @@ export const PageMixnodes: React.FC = () => {
|
||||
headerName: 'Host',
|
||||
renderHeader: () => <CustomColumnHeading headingTitle="Host" />,
|
||||
headerClassName: 'MuiDataGrid-header-override',
|
||||
width: 110,
|
||||
width: 130,
|
||||
headerAlign: 'left',
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<MuiLink
|
||||
sx={cellStyles}
|
||||
sx={getCellStyles(theme, params.row)}
|
||||
component={RRDLink}
|
||||
to={`/network-components/mixnodes/${params.row.identity_key}`}
|
||||
to={`/network-components/mixnode/${params.row.identity_key}`}
|
||||
>
|
||||
{params.value}
|
||||
</MuiLink>
|
||||
@@ -162,9 +190,9 @@ export const PageMixnodes: React.FC = () => {
|
||||
headerAlign: 'left',
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<MuiLink
|
||||
sx={{ ...cellStyles, textAlign: 'left' }}
|
||||
sx={{ ...getCellStyles(theme, params.row), textAlign: 'left' }}
|
||||
component={RRDLink}
|
||||
to={`/network-components/mixnodes/${params.row.identity_key}`}
|
||||
to={`/network-components/mixnode/${params.row.identity_key}`}
|
||||
>
|
||||
{params.value}
|
||||
</MuiLink>
|
||||
@@ -188,6 +216,13 @@ export const PageMixnodes: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<TableToolbar
|
||||
childrenBefore={
|
||||
<MixNodeStatusDropdown
|
||||
sx={{ mr: 2 }}
|
||||
status={status}
|
||||
onSelectionChanged={handleMixnodeStatusChanged}
|
||||
/>
|
||||
}
|
||||
onChangeSearch={handleSearch}
|
||||
onChangePageSize={handlePageSize}
|
||||
pageSize={pageSize}
|
||||
@@ -195,6 +230,7 @@ export const PageMixnodes: React.FC = () => {
|
||||
/>
|
||||
<UniversalDataGrid
|
||||
pagination
|
||||
loading={Boolean(mixnodes?.isLoading)}
|
||||
rows={mixnodeToGridRow(filteredMixnodes)}
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
@@ -205,3 +241,14 @@ export const PageMixnodes: React.FC = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getCellStyles = (theme: Theme, row: MixnodeRowType): SxProps => {
|
||||
const color = getMixNodeStatusColor(theme, row.status);
|
||||
return {
|
||||
...cellStyles,
|
||||
// TODO: should these be here, or change in `cellStyles`??
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
color,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,43 +1,80 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Grid, Link } from '@mui/material';
|
||||
import { Box, Grid, Link, Typography } from '@mui/material';
|
||||
import { WorldMap } from 'src/components/WorldMap';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useMainContext } from 'src/context/main';
|
||||
import { formatNumber } from 'src/utils';
|
||||
import { BIG_DIPPER } from 'src/api/constants';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import { ValidatorsSVG } from 'src/icons/ValidatorsSVG';
|
||||
import { GatewaysSVG } from 'src/icons/GatewaysSVG';
|
||||
import { MixnodesSVG } from 'src/icons/MixnodesSVG';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { ContentCard } from '../../components/ContentCard';
|
||||
import { StatsCard } from '../../components/StatsCard';
|
||||
import { Icons } from '../../components/Icons';
|
||||
|
||||
export const PageOverview: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const { mixnodes, gateways, validators, block, countryData } =
|
||||
const { summaryOverview, gateways, validators, block, countryData } =
|
||||
useMainContext();
|
||||
return (
|
||||
<>
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<Grid>
|
||||
<Grid item>
|
||||
<Grid item paddingBottom={3}>
|
||||
<Title text="Overview" />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Grid container spacing={2}>
|
||||
{mixnodes && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<StatsCard
|
||||
onClick={() => history.push('/network-components/mixnodes')}
|
||||
title="Mixnodes"
|
||||
icon={<MixnodesSVG />}
|
||||
count={mixnodes?.data?.length || ''}
|
||||
errorMsg={mixnodes?.error}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container spacing={3}>
|
||||
{summaryOverview && (
|
||||
<>
|
||||
<Grid item xs={12} md={4}>
|
||||
<StatsCard
|
||||
onClick={() =>
|
||||
history.push('/network-components/mixnodes')
|
||||
}
|
||||
title="Mixnodes"
|
||||
icon={<MixnodesSVG />}
|
||||
count={summaryOverview.data?.mixnodes.count || ''}
|
||||
errorMsg={summaryOverview?.error}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<StatsCard
|
||||
onClick={() =>
|
||||
history.push('/network-components/mixnodes/active')
|
||||
}
|
||||
title="Active nodes"
|
||||
icon={<Icons.mixnodes.status.active />}
|
||||
color={
|
||||
theme.palette.nym.networkExplorer.mixnodes.status.active
|
||||
}
|
||||
count={summaryOverview.data?.mixnodes.activeset.active}
|
||||
errorMsg={summaryOverview?.error}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<StatsCard
|
||||
onClick={() =>
|
||||
history.push('/network-components/mixnodes/standby')
|
||||
}
|
||||
title="Standby nodes"
|
||||
color={
|
||||
theme.palette.nym.networkExplorer.mixnodes.status
|
||||
.standby
|
||||
}
|
||||
icon={<Icons.mixnodes.status.standby />}
|
||||
count={summaryOverview.data?.mixnodes.activeset.standby}
|
||||
errorMsg={summaryOverview?.error}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{gateways && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<StatsCard
|
||||
onClick={() => history.push('/network-components/gateways')}
|
||||
title="Gateways"
|
||||
@@ -49,7 +86,7 @@ export const PageOverview: React.FC = () => {
|
||||
)}
|
||||
|
||||
{validators && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<StatsCard
|
||||
onClick={() => window.open(`${BIG_DIPPER}/validators`)}
|
||||
title="Validators"
|
||||
@@ -61,19 +98,29 @@ export const PageOverview: React.FC = () => {
|
||||
)}
|
||||
{block?.data && (
|
||||
<Grid item xs={12}>
|
||||
<ContentCard
|
||||
title={
|
||||
<Link
|
||||
href={`${BIG_DIPPER}/blocks`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
underline="none"
|
||||
color="inherit"
|
||||
>
|
||||
Current block height is {formatNumber(block.data)}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
href={`${BIG_DIPPER}/blocks`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
underline="none"
|
||||
color="inherit"
|
||||
marginY={2}
|
||||
paddingX={3}
|
||||
paddingY={0.25}
|
||||
fontSize={14}
|
||||
fontWeight={600}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography fontWeight="inherit" fontSize="inherit">
|
||||
Current block height is {formatNumber(block.data)}
|
||||
</Typography>
|
||||
<OpenInNewIcon
|
||||
fontWeight="inherit"
|
||||
fontSize="inherit"
|
||||
sx={{ ml: 0.5 }}
|
||||
/>
|
||||
</Link>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12}>
|
||||
|
||||
@@ -8,10 +8,13 @@ import { PageMixnodes } from '../pages/Mixnodes';
|
||||
export const NetworkComponentsRoutes: React.FC = () => (
|
||||
<>
|
||||
<Switch>
|
||||
<Route exact path="/network-components/mixnodes/:status">
|
||||
<PageMixnodes />
|
||||
</Route>
|
||||
<Route exact path="/network-components/mixnodes">
|
||||
<PageMixnodes />
|
||||
</Route>
|
||||
<Route path="/network-components/mixnodes/:id">
|
||||
<Route path="/network-components/mixnode/:id">
|
||||
<PageMixnodeDetail />
|
||||
</Route>
|
||||
<Route path="/network-components/gateways">
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
/* last resort for styles that cannot be handled by Material UI and Emotion JS */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
src: url('./fonts/OpenSans-VariableFont_wdth,wght.ttf') format('truetype-variations'),
|
||||
url('./fonts/OpenSans-Italic-VariableFont_wdth,wght.ttf') format('truetype-variations');
|
||||
font-weight: 100 1000;
|
||||
}
|
||||
|
||||
/* TODO - this override should take place in either
|
||||
the theme declaration in index.tsx or the style prop
|
||||
in <DataGrid /> */
|
||||
@@ -31,4 +38,4 @@ div div.MuiDataGrid-root .MuiDataGrid-columnHeaderTitleContainer {
|
||||
min-width: 100vw;
|
||||
margin-top: 58px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+13
@@ -56,6 +56,12 @@ declare module '@mui/material/styles' {
|
||||
background: string;
|
||||
hover: string;
|
||||
};
|
||||
mixnodes: {
|
||||
status: {
|
||||
active: string;
|
||||
standby: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +93,13 @@ declare module '@mui/material/styles' {
|
||||
footer: {
|
||||
socialIcons: string;
|
||||
};
|
||||
mixnodes: {
|
||||
status: {
|
||||
active: string;
|
||||
standby: string;
|
||||
inactive: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ const darkMode: NymPaletteVariant = {
|
||||
background: '#242C3D',
|
||||
hover: '#111826',
|
||||
},
|
||||
mixnodes: {
|
||||
status: {
|
||||
active: '#20D073',
|
||||
standby: '#5FD7EF',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lightMode: NymPaletteVariant = {
|
||||
@@ -62,6 +68,12 @@ const lightMode: NymPaletteVariant = {
|
||||
background: '#242C3D',
|
||||
hover: '#111826',
|
||||
},
|
||||
mixnodes: {
|
||||
status: {
|
||||
active: '#1CBB67',
|
||||
standby: '#55C1D7',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -94,13 +106,20 @@ const networkExplorerPalette = (
|
||||
},
|
||||
topNav: {
|
||||
...variant.topNav,
|
||||
appBar: '#070B15',
|
||||
appBar: '#080715',
|
||||
socialIcons: '#F2F2F2',
|
||||
},
|
||||
footer: {
|
||||
socialIcons:
|
||||
variant.mode === 'light' ? nymPalette.text.footer : darkMode.text.main,
|
||||
},
|
||||
mixnodes: {
|
||||
status: {
|
||||
active: variant.mixnodes.status.active,
|
||||
standby: variant.mixnodes.status.standby,
|
||||
inactive: variant.text.main,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,7 +167,7 @@ const createDarkModePalette = (): PaletteOptions => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* IMPORANT: if you need to get the default MUI theme, use the following
|
||||
* IMPORTANT: if you need to get the default MUI theme, use the following
|
||||
*
|
||||
* import { createTheme as systemCreateTheme } from '@mui/system';
|
||||
*
|
||||
@@ -203,7 +222,7 @@ export const getDesignTokens = (mode: PaletteMode): ThemeOptions => {
|
||||
'Helvetica Neue',
|
||||
].join(','),
|
||||
fontSize: 14,
|
||||
fontWeightRegular: 600,
|
||||
fontWeightRegular: 400,
|
||||
},
|
||||
transitions: {
|
||||
duration: {
|
||||
@@ -223,8 +242,8 @@ export const getDesignTokens = (mode: PaletteMode): ThemeOptions => {
|
||||
MuiCardHeader: {
|
||||
styleOverrides: {
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 800,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -243,6 +262,19 @@ export const getDesignTokens = (mode: PaletteMode): ThemeOptions => {
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '10px',
|
||||
},
|
||||
elevation1: {
|
||||
backgroundImage: mode === 'dark' ? 'none' : undefined,
|
||||
},
|
||||
elevation2: {
|
||||
backgroundImage: mode === 'dark' ? 'none' : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
palette,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,23 @@ export interface ClientConfig {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface SummaryOverviewResponse {
|
||||
mixnodes: {
|
||||
count: number;
|
||||
activeset: {
|
||||
active: number;
|
||||
standby: number;
|
||||
inactive: number;
|
||||
};
|
||||
};
|
||||
gateways: {
|
||||
count: number;
|
||||
};
|
||||
validators: {
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MixNode {
|
||||
host: string;
|
||||
mix_port: number;
|
||||
@@ -32,11 +49,34 @@ export interface Amount {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export enum MixnodeStatus {
|
||||
active = 'active', // in both the active set and the rewarded set
|
||||
standby = 'standby', // only in the rewarded set
|
||||
inactive = 'inactive', // in neither the rewarded set nor the active set
|
||||
}
|
||||
|
||||
export enum MixnodeStatusWithAll {
|
||||
active = 'active', // in both the active set and the rewarded set
|
||||
standby = 'standby', // only in the rewarded set
|
||||
inactive = 'inactive', // in neither the rewarded set nor the active set
|
||||
all = 'all', // any status
|
||||
}
|
||||
|
||||
export const toMixnodeStatus = (
|
||||
status?: MixnodeStatusWithAll,
|
||||
): MixnodeStatus | undefined => {
|
||||
if (!status || status === MixnodeStatusWithAll.all) {
|
||||
return undefined;
|
||||
}
|
||||
return status as unknown as MixnodeStatus;
|
||||
};
|
||||
|
||||
export interface MixNodeResponseItem {
|
||||
pledge_amount: Amount;
|
||||
total_delegation: Amount;
|
||||
owner: string;
|
||||
layer: string;
|
||||
status: MixnodeStatus;
|
||||
location: {
|
||||
country_name: string;
|
||||
lat: number;
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
declare module 'react-identicons' {
|
||||
import * as React from 'react';
|
||||
|
||||
interface IdenticonProps {
|
||||
string: string;
|
||||
size?: number;
|
||||
padding?: number;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
palette?: string[];
|
||||
count?: number;
|
||||
// getColor: Function;
|
||||
}
|
||||
|
||||
declare function Identicon(
|
||||
props: IdenticonProps,
|
||||
): React.ReactElement<IdenticonProps>;
|
||||
|
||||
export default Identicon;
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
/* eslint-disable camelcase */
|
||||
import { MutableRefObject } from 'react';
|
||||
import {
|
||||
CountryData,
|
||||
GatewayResponse,
|
||||
MixNodeResponse,
|
||||
} from 'src/typeDefs/explorer-api';
|
||||
import { CountryData } from 'src/typeDefs/explorer-api';
|
||||
import { registerLocale, getName } from 'i18n-iso-countries';
|
||||
|
||||
registerLocale(require('i18n-iso-countries/langs/en.json'));
|
||||
@@ -19,26 +15,6 @@ export function scrollToRef(
|
||||
if (ref?.current) ref.current.scrollIntoView();
|
||||
}
|
||||
|
||||
export type MixnodeRowType = {
|
||||
id: string;
|
||||
owner: string;
|
||||
location: string;
|
||||
identity_key: string;
|
||||
bond: number;
|
||||
self_percentage: string;
|
||||
host: string;
|
||||
layer: string;
|
||||
};
|
||||
|
||||
export type GatewayRowType = {
|
||||
id: string;
|
||||
owner: string;
|
||||
identity_key: string;
|
||||
bond: number;
|
||||
host: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
export type CountryDataRowType = {
|
||||
id: number;
|
||||
ISO3: string;
|
||||
@@ -64,39 +40,3 @@ export function countryDataToGridRow(
|
||||
const sorted = formatted.sort((a, b) => (a.nodes < b.nodes ? 1 : -1));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function mixnodeToGridRow(arrayOfMixnodes: MixNodeResponse): any {
|
||||
return !arrayOfMixnodes
|
||||
? []
|
||||
: arrayOfMixnodes.map((mn) => {
|
||||
const pledge = Number(mn.pledge_amount.amount) || 0;
|
||||
const delegations = Number(mn.total_delegation.amount) || 0;
|
||||
const totalBond = pledge + delegations;
|
||||
const selfPercentage = ((pledge * 100) / totalBond).toFixed(2);
|
||||
return {
|
||||
id: mn.owner,
|
||||
owner: mn.owner,
|
||||
identity_key: mn.mix_node.identity_key || '',
|
||||
bond: totalBond || 0,
|
||||
location: mn?.location?.country_name || '',
|
||||
self_percentage: selfPercentage,
|
||||
host: mn?.mix_node?.host || '',
|
||||
layer: mn?.layer || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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 || '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -25,10 +25,15 @@ module.exports = {
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2|md)$/i,
|
||||
test: /\.(png|jpe?g|gif|svg|md)$/i,
|
||||
// More information here https://webpack.js.org/guides/asset-modules/
|
||||
type: 'asset',
|
||||
},
|
||||
{
|
||||
// See https://webpack.js.org/guides/asset-management/#loading-fonts
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
{
|
||||
test: /\.ya?ml$/,
|
||||
type: 'json',
|
||||
|
||||
Reference in New Issue
Block a user