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:
Mark Sinclair
2022-01-21 11:28:59 +00:00
committed by GitHub
parent fea64d4d4f
commit e52fe65985
80 changed files with 2302 additions and 983 deletions
@@ -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,
+19
View File
@@ -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;
},
+24
View File
@@ -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))
}
+2
View File
@@ -0,0 +1,2 @@
pub(crate) mod http;
pub(crate) mod models;
+55
View File
@@ -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)
}
}
}
+40 -1
View File
@@ -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()
}];
}
+8 -2
View File
@@ -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(),
)
+29 -14
View File
@@ -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
View File
@@ -1,3 +1,2 @@
mod cache;
pub(crate) mod http;
pub(crate) mod models;
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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) => {
+74 -3
View File
@@ -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()
}
+2 -3
View File
@@ -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);
+66 -25
View File
@@ -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;
}
}
-95
View File
@@ -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");
}
});
}
}
-19
View File
@@ -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!")
}
+23
View File
@@ -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,
})
}
+5
View File
@@ -0,0 +1,5 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub(crate) mod http;
mod models;
+15
View File
@@ -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,
}
+3 -3
View File
@@ -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
View File
@@ -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");
+146
View File
@@ -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");
}
});
}
}
+24
View File
@@ -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))
}
+2
View File
@@ -0,0 +1,2 @@
pub(crate) mod http;
pub(crate) mod models;
+79
View File
@@ -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(),
};
}
}
+16
View File
@@ -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",
+1
View File
@@ -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"
+1 -4
View File
@@ -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
View File
@@ -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();
-222
View File
@@ -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;
};
+1 -1
View File
@@ -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',
+3 -2
View File
@@ -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`}
>
+26
View File
@@ -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 || '',
}));
}
+29
View File
@@ -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,
};
+44
View File
@@ -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 || '',
};
}
+1 -1
View File
@@ -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,
}}
>
+6 -5
View File
@@ -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>
+30 -14
View File
@@ -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>
);
+27 -42
View File
@@ -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,
};
+5 -1
View File
@@ -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>
);
+42 -29
View File
@@ -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>
</>
);
+1
View File
@@ -6,6 +6,7 @@ export const Title: React.FC<{ text: string }> = ({ text }) => (
variant="h5"
sx={{
mb: 3,
fontWeight: 600,
}}
data-testid={text}
>
+30
View File
@@ -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
View File
@@ -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}
+174
View File
@@ -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];
}
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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,
};
+37
View File
@@ -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,
};
+1 -4
View File
@@ -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>
+1 -1
View File
@@ -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,
}}
+1 -1
View File
@@ -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';
+202 -165
View File
@@ -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>
);
};
+67 -20
View File
@@ -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,
};
};
+76 -29
View File
@@ -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}>
+4 -1
View File
@@ -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">
+8 -1
View File
@@ -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;
}
}
}
+13
View File
@@ -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;
};
};
};
}
+37 -5
View File
@@ -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,
};
+40
View File
@@ -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
View File
@@ -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 -61
View File
@@ -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 || '',
}));
}
+6 -1
View File
@@ -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',