Compare commits

..

7 Commits

Author SHA1 Message Date
dynco-nym 42c03e0923 Make max_concurrent_tasks configurable 2025-06-27 13:25:19 +02:00
dynco-nym bf180f29cc Remove shared queue which was always re-initialized 2025-06-27 13:11:59 +02:00
dynco-nym d5146831d9 Bump version 2025-06-27 13:07:38 +02:00
dynco-nym bafc836dc7 Clippy 2025-06-27 13:07:16 +02:00
dynco-nym 31df4c127c Batch SQL operations 2025-06-27 13:00:52 +02:00
dynco-nym 61361d84cf Move stuff around 2025-06-26 14:32:41 +02:00
dynco-nym 447352b8d6 Set busy_timeout in sqlx (#5872)
* Set busy_timeout

* Bump version
2025-06-26 10:44:06 +02:00
71 changed files with 3067 additions and 475 deletions
@@ -0,0 +1,75 @@
name: ci-nym-wallet-storybook
on:
pull_request:
paths:
- 'nym-wallet/**'
- '.github/workflows/ci-nym-wallet-storybook.yml'
jobs:
build:
runs-on: custom-linux
steps:
- uses: actions/checkout@v4
- name: Install rsync
run: sudo apt-get install rsync
continue-on-error: true
- uses: rlespinasse/github-slug-action@v3.x
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup yarn
run: npm install -g yarn
- name: Install Rust stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Build dependencies
run: yarn && yarn build
- name: Build storybook
run: yarn storybook:build
working-directory: ./nym-wallet
- name: Deploy branch to CI www (storybook)
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-rltgoDzvO --delete"
SOURCE: "nym-wallet/storybook-static/"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/wallet-${{ env.GITHUB_REF_SLUG }}
EXCLUDE: "/dist/, /node_modules/"
- name: Matrix - Node Install
run: npm install
working-directory: .github/workflows/support-files
- name: Matrix - Send Notification
env:
NYM_NOTIFICATION_KIND: nym-wallet
NYM_PROJECT_NAME: "nym-wallet"
NYM_CI_WWW_BASE: "${{ secrets.NYM_CI_WWW_BASE }}"
NYM_CI_WWW_LOCATION: "wallet-${{ env.GITHUB_REF_SLUG }}"
GIT_COMMIT_MESSAGE: "${{ github.event.head_commit.message }}"
GIT_BRANCH: "${GITHUB_REF##*/}"
IS_SUCCESS: "${{ job.status == 'success' }}"
MATRIX_SERVER: "${{ secrets.MATRIX_SERVER }}"
MATRIX_ROOM: "${{ secrets.MATRIX_ROOM }}"
MATRIX_USER_ID: "${{ secrets.MATRIX_USER_ID }}"
MATRIX_TOKEN: "${{ secrets.MATRIX_TOKEN }}"
MATRIX_DEVICE_ID: "${{ secrets.MATRIX_DEVICE_ID }}"
uses: docker://keybaseio/client:stable-node
with:
args: .github/workflows/support-files/notifications/entry_point.sh
@@ -5,6 +5,8 @@
>
> ➡️➡️➡️➡️➡️ **View output:**
>
> `storybook`: https://{{ env.NYM_CI_WWW_LOCATION }}.{{ env.NYM_CI_WWW_BASE }}
>
> `branch` {{ env.GITHUB_SERVER_URL }}/{{ env.GITHUB_REPOSITORY }}/tree/{{ env.GIT_BRANCH_NAME }}
>
> `commit` {{ env.GITHUB_SERVER_URL }}/{{ env.GITHUB_REPOSITORY }}/commit/{{ env.GITHUB_SHA }}
Generated
+1 -1
View File
@@ -6684,7 +6684,7 @@ dependencies = [
[[package]]
name = "nym-node-status-api"
version = "3.1.0"
version = "3.1.2"
dependencies = [
"ammonia",
"anyhow",
@@ -3,7 +3,7 @@
set -eu
export ENVIRONMENT=${ENVIRONMENT:-"mainnet"}
probe_git_ref="nym-vpn-core-v1.10.0"
probe_git_ref="nym-vpn-core-v1.4.0"
crate_root=$(dirname $(realpath "$0"))
monorepo_root=$(realpath "${crate_root}/../..")
@@ -3,7 +3,7 @@
[package]
name = "nym-node-status-api"
version = "3.1.0"
version = "3.1.2"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -45,6 +45,10 @@ pub(crate) struct Cli {
#[clap(long, env = "DATABASE_URL")]
pub(crate) database_url: String,
#[clap(long, default_value = "5", env = "SQLX_BUSY_TIMEOUT_S")]
#[arg(value_parser = parse_duration)]
pub(crate) sqlx_busy_timeout_s: Duration,
#[clap(
long,
default_value = "300",
@@ -69,6 +73,13 @@ pub(crate) struct Cli {
#[arg(value_delimiter = ',')]
pub(crate) agent_key_list: Vec<String>,
#[clap(
long,
default_value_t = 10,
env = "NYM_NODE_STATUS_API_PACKET_STATS_MAX_CONCURRENT_TASKS"
)]
pub(crate) packet_stats_max_concurrent_tasks: usize,
/// https://github.com/ipinfo/rust
#[clap(long, env = "IPINFO_API_TOKEN")]
pub(crate) ipinfo_api_token: String,
@@ -1,10 +1,11 @@
use anyhow::{anyhow, Result};
use sqlx::{
migrate::Migrator,
query,
sqlite::{SqliteAutoVacuum, SqliteConnectOptions, SqliteSynchronous},
ConnectOptions, SqlitePool,
};
use std::str::FromStr;
use std::{str::FromStr, time::Duration};
pub(crate) mod models;
pub(crate) mod queries;
@@ -18,9 +19,10 @@ pub(crate) struct Storage {
}
impl Storage {
pub async fn init(connection_url: String) -> Result<Self> {
pub async fn init(connection_url: String, busy_timeout: Duration) -> Result<Self> {
let connect_options = SqliteConnectOptions::from_str(&connection_url)?
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(busy_timeout)
.synchronous(SqliteSynchronous::Normal)
.auto_vacuum(SqliteAutoVacuum::Incremental)
.foreign_keys(true)
@@ -33,6 +35,9 @@ impl Storage {
MIGRATOR.run(&pool).await?;
// aftering setting pragma, check whether it was set successfully
Self::assert_busy_timeout(pool.clone(), busy_timeout.as_secs() as i64).await?;
Ok(Storage { pool })
}
@@ -40,4 +45,27 @@ impl Storage {
pub fn pool_owned(&self) -> DbPool {
self.pool.clone()
}
async fn assert_busy_timeout(pool: DbPool, expected_busy_timeout_s: i64) -> Result<()> {
let mut conn = pool.acquire().await?;
// Sqlite stores this value as miliseconds
// https://www.sqlite.org/pragma.html#pragma_busy_timeout
let busy_timeout_db = query!("PRAGMA busy_timeout;")
.fetch_one(conn.as_mut())
.await?;
let actual_busy_timeout_ms = busy_timeout_db.timeout.unwrap_or(0);
tracing::info!("PRAGMA busy_timeout={}ms", actual_busy_timeout_ms);
let expected_busy_timeout_ms = expected_busy_timeout_s * 1000;
if expected_busy_timeout_ms != actual_busy_timeout_ms {
anyhow::bail!(
"PRAGMA busy_timeout expected: {}ms, actual: {}ms",
expected_busy_timeout_ms,
actual_busy_timeout_ms
);
}
Ok(())
}
}
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::str::FromStr;
use strum_macros::{EnumString, FromRepr};
use time::{Date, OffsetDateTime};
use time::{Date, OffsetDateTime, UtcDateTime};
use utoipa::ToSchema;
macro_rules! serialize_opt_to_value {
@@ -362,7 +362,7 @@ impl TryFrom<GatewaySessionsRecord> for http::models::SessionStats {
}
}
#[derive(strum_macros::Display)]
#[derive(strum_macros::Display, Clone)]
pub(crate) enum ScrapeNodeKind {
LegacyMixnode { mix_id: i64 },
MixingNymNode { node_id: i64 },
@@ -520,3 +520,10 @@ pub struct NodeStats {
pub packets_sent: i64,
pub packets_dropped: i64,
}
pub struct InsertStatsRecord {
pub node_kind: ScrapeNodeKind,
pub timestamp_utc: UtcDateTime,
pub unix_timestamp: i64,
pub stats: NodeStats,
}
@@ -6,7 +6,7 @@ use crate::{
DbPool,
},
http::models::Gateway,
mixnet_scraper::helpers::NodeDescriptionResponse,
node_scraper::helpers::NodeDescriptionResponse,
};
use futures_util::TryStreamExt;
use sqlx::{pool::PoolConnection, Sqlite};
@@ -10,7 +10,7 @@ use crate::{
DbPool,
},
http::models::{DailyStats, Mixnode},
mixnet_scraper::helpers::NodeDescriptionResponse,
node_scraper::helpers::NodeDescriptionResponse,
};
pub(crate) async fn update_mixnodes(
@@ -19,7 +19,7 @@ pub(crate) use nym_nodes::{
get_described_node_bond_info, get_node_self_description, update_nym_nodes,
};
pub(crate) use packet_stats::{
get_raw_node_stats, insert_daily_node_stats, insert_node_packet_stats,
batch_store_packet_stats, get_raw_node_stats, insert_daily_node_stats_uncommitted,
};
pub(crate) use scraper::{get_nodes_for_scraping, insert_scraped_node_description};
pub(crate) use summary::{get_summary, get_summary_history};
@@ -13,7 +13,7 @@ use crate::{
models::{NymNodeDto, NymNodeInsertRecord},
DbPool,
},
mixnet_scraper::helpers::NodeDescriptionResponse,
node_scraper::helpers::NodeDescriptionResponse,
};
pub(crate) async fn get_all_nym_nodes(pool: &DbPool) -> anyhow::Result<Vec<NymNodeDto>> {
@@ -1,17 +1,70 @@
use crate::db::{
models::{NodeStats, ScrapeNodeKind, ScraperNodeInfo},
DbPool,
use crate::{
db::{
models::{InsertStatsRecord, NodeStats, ScrapeNodeKind},
DbPool,
},
node_scraper::helpers::update_daily_stats_uncommitted,
utils::now_utc,
};
use anyhow::Result;
use sqlx::Transaction;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{info, instrument};
pub(crate) async fn insert_node_packet_stats(
#[instrument(level = "info", skip_all)]
pub(crate) async fn batch_store_packet_stats(
pool: &DbPool,
results: Arc<Mutex<Vec<InsertStatsRecord>>>,
) -> anyhow::Result<()> {
let results_iter = results.lock().await;
info!(
"📊 ⏳ Storing {} packet stats into the DB",
results_iter.len()
);
let started_at = now_utc();
let mut tx = pool
.begin()
.await
.map_err(|err| anyhow::anyhow!("Failed to begin transaction: {err}"))?;
for stats_record in &(*results_iter) {
insert_node_packet_stats_uncommitted(
&mut tx,
&stats_record.node_kind,
&stats_record.stats,
stats_record.unix_timestamp,
)
.await?;
update_daily_stats_uncommitted(
&mut tx,
&stats_record.node_kind,
stats_record.timestamp_utc,
&stats_record.stats,
)
.await?;
}
tx.commit()
.await
.inspect(|_| {
let elapsed = now_utc() - started_at;
info!(
"📊 ☑️ Packet stats successfully committed to DB (took {}s)",
elapsed.as_seconds_f32()
);
})
.map_err(|err| anyhow::anyhow!("Failed to commit: {err}"))
}
async fn insert_node_packet_stats_uncommitted(
tx: &mut Transaction<'static, sqlx::Sqlite>,
node_kind: &ScrapeNodeKind,
stats: &NodeStats,
timestamp_utc: i64,
) -> Result<()> {
let mut conn = pool.acquire().await?;
match node_kind {
ScrapeNodeKind::LegacyMixnode { mix_id } => {
sqlx::query!(
@@ -26,7 +79,7 @@ pub(crate) async fn insert_node_packet_stats(
stats.packets_sent,
stats.packets_dropped,
)
.execute(&mut *conn)
.execute(tx.as_mut())
.await?;
}
ScrapeNodeKind::MixingNymNode { node_id }
@@ -43,7 +96,7 @@ pub(crate) async fn insert_node_packet_stats(
stats.packets_sent,
stats.packets_dropped,
)
.execute(&mut *conn)
.execute(tx.as_mut())
.await?;
}
}
@@ -52,12 +105,10 @@ pub(crate) async fn insert_node_packet_stats(
}
pub(crate) async fn get_raw_node_stats(
pool: &DbPool,
node: &ScraperNodeInfo,
tx: &mut Transaction<'static, sqlx::Sqlite>,
node_kind: &ScrapeNodeKind,
) -> Result<Option<NodeStats>> {
let mut conn = pool.acquire().await?;
let packets = match node.node_kind {
let packets = match node_kind {
// if no packets are found, it's fine to assume 0 because that's also
// SQL default value if none provided
ScrapeNodeKind::LegacyMixnode { mix_id } => {
@@ -75,7 +126,7 @@ pub(crate) async fn get_raw_node_stats(
"#,
mix_id
)
.fetch_optional(&mut *conn)
.fetch_optional(tx.as_mut())
.await?
}
ScrapeNodeKind::MixingNymNode { node_id }
@@ -94,7 +145,7 @@ pub(crate) async fn get_raw_node_stats(
"#,
node_id
)
.fetch_optional(&mut *conn)
.fetch_optional(tx.as_mut())
.await?
}
};
@@ -102,15 +153,13 @@ pub(crate) async fn get_raw_node_stats(
Ok(packets)
}
pub(crate) async fn insert_daily_node_stats(
pool: &DbPool,
node: &ScraperNodeInfo,
pub(crate) async fn insert_daily_node_stats_uncommitted(
tx: &mut Transaction<'static, sqlx::Sqlite>,
node_kind: &ScrapeNodeKind,
date_utc: &str,
packets: NodeStats,
) -> Result<()> {
let mut conn = pool.acquire().await?;
match node.node_kind {
match node_kind {
ScrapeNodeKind::LegacyMixnode { mix_id } => {
let total_stake = sqlx::query_scalar!(
r#"
@@ -121,7 +170,7 @@ pub(crate) async fn insert_daily_node_stats(
"#,
mix_id
)
.fetch_one(&mut *conn)
.fetch_one(tx.as_mut())
.await?;
sqlx::query!(
@@ -144,7 +193,7 @@ pub(crate) async fn insert_daily_node_stats(
packets.packets_sent,
packets.packets_dropped,
)
.execute(&mut *conn)
.execute(tx.as_mut())
.await?;
}
ScrapeNodeKind::MixingNymNode { node_id }
@@ -158,7 +207,7 @@ pub(crate) async fn insert_daily_node_stats(
"#,
node_id
)
.fetch_one(&mut *conn)
.fetch_one(tx.as_mut())
.await?;
sqlx::query!(
@@ -181,7 +230,7 @@ pub(crate) async fn insert_daily_node_stats(
packets.packets_sent,
packets.packets_dropped,
)
.execute(&mut *conn)
.execute(tx.as_mut())
.await?;
}
}
@@ -7,7 +7,7 @@ use crate::{
},
DbPool,
},
mixnet_scraper::helpers::NodeDescriptionResponse,
node_scraper::helpers::NodeDescriptionResponse,
utils::now_utc,
};
use anyhow::Result;
@@ -160,11 +160,11 @@ async fn submit_testrun(
.map(unix_timestamp_to_utc_rfc3339)
.unwrap_or_else(|| String::from("never"));
tracing::info!(
"✅ Testrun row_id {} for gateway {} complete (last assigned {}, created at {})",
gateway_id = gw_identity,
last_assigned = last_assigned,
created_at = created_at,
"✅ Testrun row_id {} for gateway complete",
assigned_testrun.id,
gw_identity,
last_assigned,
created_at
);
Ok(StatusCode::CREATED)
@@ -9,7 +9,7 @@ mod cli;
mod db;
mod http;
mod logging;
mod mixnet_scraper;
mod metrics_scraper;
mod monitor;
mod node_scraper;
mod testruns;
@@ -31,11 +31,18 @@ async fn main() -> anyhow::Result<()> {
let connection_url = args.database_url.clone();
tracing::debug!("Using config:\n{:#?}", args);
let storage = db::Storage::init(connection_url).await?;
let storage = db::Storage::init(connection_url, args.sqlx_busy_timeout_s).await?;
let db_pool = storage.pool_owned();
// Start the node scraper
let scraper = mixnet_scraper::Scraper::new(storage.pool_owned());
let scraper = node_scraper::DescriptionScraper::new(storage.pool_owned());
tokio::spawn(async move {
scraper.start().await;
});
let scraper = node_scraper::PacketScraper::new(
storage.pool_owned(),
args.packet_stats_max_concurrent_tasks,
);
tokio::spawn(async move {
scraper.start().await;
});
@@ -74,7 +81,8 @@ async fn main() -> anyhow::Result<()> {
let db_pool_scraper = storage.pool_owned();
tokio::spawn(async move {
node_scraper::spawn_in_background(db_pool_scraper, args_clone.nym_api_client_timeout).await;
metrics_scraper::spawn_in_background(db_pool_scraper, args_clone.nym_api_client_timeout)
.await;
tracing::info!("Started metrics scraper task");
});
@@ -0,0 +1,284 @@
use crate::db::{models::GatewaySessionsRecord, queries, DbPool};
use error::NodeScraperError;
use nym_network_defaults::{NymNetworkDetails, DEFAULT_NYM_NODE_HTTP_PORT};
use nym_node_requests::api::{client::NymNodeApiClientExt, v1::metrics::models::SessionStats};
use nym_validator_client::{
client::{NodeId, NymNodeDetails},
models::{DescribedNodeType, NymNodeDescription},
NymApiClient,
};
use time::OffsetDateTime;
use nym_statistics_common::types::SessionType;
use std::collections::HashMap;
use tokio::time::Duration;
use tracing::instrument;
mod error;
const FAILURE_RETRY_DELAY: Duration = Duration::from_secs(60);
const REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60 * 6);
const STALE_DURATION: Duration = Duration::from_secs(86400 * 365); //one year
#[instrument(level = "info", name = "metrics_scraper", skip_all)]
pub(crate) async fn spawn_in_background(db_pool: DbPool, nym_api_client_timeout: Duration) {
let network_defaults = nym_network_defaults::NymNetworkDetails::new_from_env();
loop {
tracing::info!("Refreshing node self-described metrics...");
if let Err(e) = run(&db_pool, &network_defaults, nym_api_client_timeout).await {
tracing::error!(
"Metrics collection failed: {e}, retrying in {}s...",
FAILURE_RETRY_DELAY.as_secs()
);
tokio::time::sleep(FAILURE_RETRY_DELAY).await;
} else {
tracing::info!(
"Metrics successfully collected, sleeping for {}s...",
REFRESH_INTERVAL.as_secs()
);
tokio::time::sleep(REFRESH_INTERVAL).await;
}
}
}
async fn run(
pool: &DbPool,
network_details: &NymNetworkDetails,
nym_api_client_timeout: Duration,
) -> anyhow::Result<()> {
let default_api_url = network_details
.endpoints
.first()
.expect("rust sdk mainnet default incorrectly configured")
.api_url()
.clone()
.expect("rust sdk mainnet default missing api_url");
let nym_api = nym_http_api_client::ClientBuilder::new_with_url(default_api_url)
.no_hickory_dns()
.with_timeout(nym_api_client_timeout)
.build::<&str>()?;
let api_client = NymApiClient::from(nym_api);
//SW TBC what nodes exactly need to be scraped, the skimmed node endpoint seems to return more nodes
let bonded_nodes = api_client.get_all_bonded_nym_nodes().await?;
let all_nodes = api_client.get_all_described_nodes().await?; //legacy node that did not upgrade the contract bond yet
tracing::debug!("Fetched {} total nodes", all_nodes.len());
let mut nodes_to_scrape: HashMap<NodeId, MetricsScrapingData> = bonded_nodes
.into_iter()
.map(|n| (n.node_id(), n.into()))
.collect();
all_nodes
.into_iter()
.filter(|n| n.contract_node_type != DescribedNodeType::LegacyMixnode)
.for_each(|n| {
nodes_to_scrape.entry(n.node_id).or_insert_with(|| n.into());
});
tracing::debug!("Will try to scrape {} nodes", nodes_to_scrape.len());
let mut session_records = Vec::new();
for n in nodes_to_scrape.into_values() {
if let Some(stat) = n.try_scrape_metrics().await {
session_records.push(prepare_session_data(stat, &n));
}
}
queries::insert_session_records(pool, session_records)
.await
.map(|_| {
tracing::debug!("Session info written to DB!");
})?;
let cut_off_date = (OffsetDateTime::now_utc() - STALE_DURATION).date();
queries::delete_old_records(pool, cut_off_date)
.await
.map(|_| {
tracing::debug!("Cleared old data before {}", cut_off_date);
})?;
Ok(())
}
#[derive(Debug)]
struct MetricsScrapingData {
host: String,
node_id: NodeId,
id_key: String,
port: Option<u16>,
}
impl MetricsScrapingData {
pub fn new(
host: impl Into<String>,
node_id: NodeId,
id_key: String,
port: Option<u16>,
) -> Self {
MetricsScrapingData {
host: host.into(),
node_id,
id_key,
port,
}
}
#[instrument(level = "info", name = "metrics_scraper", skip_all)]
async fn try_scrape_metrics(&self) -> Option<SessionStats> {
match self.try_get_client().await {
Ok(client) => {
match client.get_sessions_metrics().await {
Ok(session_stats) => {
if session_stats.update_time != OffsetDateTime::UNIX_EPOCH {
Some(session_stats)
} else {
//means no data
None
}
}
Err(e) => {
tracing::warn!("{e}");
None
}
}
}
Err(e) => {
tracing::warn!("{e}");
None
}
}
}
async fn try_get_client(&self) -> Result<nym_node_requests::api::Client, NodeScraperError> {
// first try the standard port in case the operator didn't put the node behind the proxy,
// then default https (443)
// finally default http (80)
let mut addresses_to_try = vec![
format!("http://{0}:{DEFAULT_NYM_NODE_HTTP_PORT}", self.host), // 'standard' nym-node
format!("https://{0}", self.host), // node behind https proxy (443)
format!("http://{0}", self.host), // node behind http proxy (80)
];
// note: I removed 'standard' legacy mixnode port because it should now be automatically pulled via
// the 'custom_port' since it should have been present in the contract.
if let Some(port) = self.port {
addresses_to_try.insert(0, format!("http://{0}:{port}", self.host));
}
for address in addresses_to_try {
// if provided host was malformed, no point in continuing
let client = match nym_node_requests::api::Client::builder(address).and_then(|b| {
b.with_timeout(Duration::from_secs(5))
.with_user_agent("node-status-api-metrics-scraper")
.no_hickory_dns()
.build()
}) {
Ok(client) => client,
Err(err) => {
return Err(NodeScraperError::MalformedHost {
host: self.host.to_string(),
node_id: self.node_id,
source: err,
});
}
};
if let Ok(health) = client.get_health().await {
if health.status.is_up() {
return Ok(client);
}
}
}
Err(NodeScraperError::NoHttpPortsAvailable {
host: self.host.to_string(),
node_id: self.node_id,
})
}
}
impl From<NymNodeDetails> for MetricsScrapingData {
fn from(value: NymNodeDetails) -> Self {
MetricsScrapingData::new(
value.bond_information.node.host.clone(),
value.node_id(),
value.bond_information.node.identity_key,
value.bond_information.node.custom_http_port,
)
}
}
impl From<NymNodeDescription> for MetricsScrapingData {
fn from(value: NymNodeDescription) -> Self {
MetricsScrapingData::new(
value.description.host_information.ip_address[0].to_string(),
value.node_id,
value.ed25519_identity_key().to_base58_string(),
None,
)
}
}
fn prepare_session_data(
stat: SessionStats,
node_data: &MetricsScrapingData,
) -> GatewaySessionsRecord {
let users_hashes = if !stat.unique_active_users_hashes.is_empty() {
Some(serde_json::to_string(&stat.unique_active_users_hashes).unwrap())
} else {
None
};
let vpn_durations = stat
.sessions
.iter()
.filter(|s| SessionType::from_string(&s.typ) == SessionType::Vpn)
.map(|s| s.duration_ms)
.collect::<Vec<_>>();
let mixnet_durations = stat
.sessions
.iter()
.filter(|s| SessionType::from_string(&s.typ) == SessionType::Mixnet)
.map(|s| s.duration_ms)
.collect::<Vec<_>>();
let unkown_durations = stat
.sessions
.iter()
.filter(|s| SessionType::from_string(&s.typ) == SessionType::Unknown)
.map(|s| s.duration_ms)
.collect::<Vec<_>>();
let vpn_sessions = if !vpn_durations.is_empty() {
Some(serde_json::to_string(&vpn_durations).unwrap())
} else {
None
};
let mixnet_sessions = if !mixnet_durations.is_empty() {
Some(serde_json::to_string(&mixnet_durations).unwrap())
} else {
None
};
let unknown_sessions = if !unkown_durations.is_empty() {
Some(serde_json::to_string(&unkown_durations).unwrap())
} else {
None
};
GatewaySessionsRecord {
gateway_identity_key: node_data.id_key.clone(),
node_id: node_data.node_id as i64,
day: stat.update_time.date(),
unique_active_clients: stat.unique_active_users as i64,
session_started: stat.sessions_started as i64,
users_hashes,
vpn_sessions,
mixnet_sessions,
unknown_sessions,
}
}
@@ -1,41 +1,36 @@
use super::helpers::scrape_and_store_description;
use anyhow::Result;
use sqlx::SqlitePool;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub mod helpers;
use anyhow::Result;
use helpers::{scrape_and_store_description, scrape_and_store_packet_stats};
use sqlx::SqlitePool;
use tracing::{debug, error, instrument, warn};
use crate::db::models::ScraperNodeInfo;
use crate::db::queries::get_nodes_for_scraping;
const DESCRIPTION_SCRAPE_INTERVAL: Duration = Duration::from_secs(60 * 60 * 4);
const PACKET_SCRAPE_INTERVAL: Duration = Duration::from_secs(60 * 60);
const QUEUE_CHECK_INTERVAL: Duration = Duration::from_millis(250);
const MAX_CONCURRENT_TASKS: usize = 5;
static TASK_COUNTER: AtomicUsize = AtomicUsize::new(0);
static TASK_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct Scraper {
pub struct DescriptionScraper {
pool: SqlitePool,
description_queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
packet_queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
}
impl Scraper {
impl DescriptionScraper {
pub fn new(pool: SqlitePool) -> Self {
Self {
pool,
description_queue: Arc::new(Mutex::new(Vec::new())),
packet_queue: Arc::new(Mutex::new(Vec::new())),
}
}
pub async fn start(&self) {
self.spawn_description_scraper().await;
self.spawn_packet_scraper().await;
}
async fn spawn_description_scraper(&self) {
@@ -53,22 +48,6 @@ impl Scraper {
});
}
async fn spawn_packet_scraper(&self) {
let pool = self.pool.clone();
let queue = self.packet_queue.clone();
tracing::info!("Starting packet scraper");
tokio::spawn(async move {
loop {
if let Err(e) = Self::run_packet_scraper(&pool, queue.clone()).await {
error!(name: "packet_scraper", "Packet scraper failed: {}", e);
}
debug!(name: "packet_scraper", "Sleeping for {}s", PACKET_SCRAPE_INTERVAL.as_secs());
tokio::time::sleep(PACKET_SCRAPE_INTERVAL).await;
}
});
}
#[instrument(level = "info", name = "description_scraper", skip_all)]
async fn run_description_scraper(
pool: &SqlitePool,
@@ -86,24 +65,6 @@ impl Scraper {
Ok(())
}
#[instrument(level = "info", name = "packet_scraper", skip_all)]
async fn run_packet_scraper(
pool: &SqlitePool,
queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
) -> Result<()> {
let nodes = get_nodes_for_scraping(pool).await?;
tracing::info!("Querying {} mixing nodes", nodes.len());
if let Ok(mut queue_lock) = queue.lock() {
queue_lock.extend(nodes);
} else {
warn!("Failed to acquire packet queue lock");
return Ok(());
}
Self::process_packet_queue(pool, queue).await;
Ok(())
}
async fn process_description_queue(pool: &SqlitePool, queue: Arc<Mutex<Vec<ScraperNodeInfo>>>) {
loop {
let running_tasks = TASK_COUNTER.load(Ordering::Relaxed);
@@ -147,50 +108,7 @@ impl Scraper {
tokio::time::sleep(QUEUE_CHECK_INTERVAL).await;
}
}
}
async fn process_packet_queue(pool: &SqlitePool, queue: Arc<Mutex<Vec<ScraperNodeInfo>>>) {
loop {
let running_tasks = TASK_COUNTER.load(Ordering::Relaxed);
if running_tasks < MAX_CONCURRENT_TASKS {
let node = {
if let Ok(mut queue_lock) = queue.lock() {
if queue_lock.is_empty() {
TASK_ID_COUNTER.store(0, Ordering::Relaxed);
break;
}
queue_lock.remove(0)
} else {
warn!("Failed to acquire packet queue lock");
break;
}
};
TASK_COUNTER.fetch_add(1, Ordering::Relaxed);
let task_id = TASK_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let pool = pool.clone();
tokio::spawn(async move {
match scrape_and_store_packet_stats(&pool, &node).await {
Ok(_) => debug!(
"📊 ✅ Packet stats task #{} for node {} complete",
task_id,
node.node_id()
),
Err(e) => debug!(
"📊 ❌ Packet stats task #{} for {} {} failed: {}",
task_id,
node.node_kind,
node.node_id(),
e
),
}
TASK_COUNTER.fetch_sub(1, Ordering::Relaxed);
});
} else {
tokio::time::sleep(QUEUE_CHECK_INTERVAL).await;
}
}
// TODO After all tasks complete, write results to the DB
}
}
@@ -1,8 +1,8 @@
use crate::{
db::{
models::{NodeStats, ScraperNodeInfo},
models::{InsertStatsRecord, NodeStats, ScrapeNodeKind, ScraperNodeInfo},
queries::{
get_raw_node_stats, insert_daily_node_stats, insert_node_packet_stats,
get_raw_node_stats, insert_daily_node_stats_uncommitted,
insert_scraped_node_description,
},
},
@@ -10,9 +10,8 @@ use crate::{
};
use ammonia::Builder;
use anyhow::{anyhow, Result};
use reqwest;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use sqlx::{SqlitePool, Transaction};
use std::time::Duration;
use time::UtcDateTime;
@@ -156,10 +155,7 @@ pub async fn scrape_and_store_description(pool: &SqlitePool, node: &ScraperNodeI
Ok(())
}
pub async fn scrape_and_store_packet_stats(
pool: &SqlitePool,
node: &ScraperNodeInfo,
) -> Result<()> {
pub async fn scrape_packet_stats(node: &ScraperNodeInfo) -> Result<InsertStatsRecord> {
let client = build_client()?;
let urls = node.contact_addresses();
@@ -187,19 +183,21 @@ pub async fn scrape_and_store_packet_stats(
anyhow::anyhow!("Failed to fetch description from any URL: {}", err_msg)
})?;
let timestamp = now_utc();
let timestamp_utc = timestamp.unix_timestamp();
insert_node_packet_stats(pool, &node.node_kind, &stats, timestamp_utc).await?;
let timestamp_utc = now_utc();
let unix_timestamp = timestamp_utc.unix_timestamp();
let result = InsertStatsRecord {
node_kind: node.node_kind.to_owned(),
timestamp_utc,
unix_timestamp,
stats,
};
// Update daily stats
update_daily_stats(pool, node, timestamp, &stats).await?;
Ok(())
Ok(result)
}
pub async fn update_daily_stats(
pool: &SqlitePool,
node: &ScraperNodeInfo,
pub async fn update_daily_stats_uncommitted(
tx: &mut Transaction<'static, sqlx::Sqlite>,
node_kind: &ScrapeNodeKind,
timestamp: UtcDateTime,
current_stats: &NodeStats,
) -> Result<()> {
@@ -211,7 +209,7 @@ pub async fn update_daily_stats(
);
// Get previous stats
let previous_stats = get_raw_node_stats(pool, node).await?;
let previous_stats = get_raw_node_stats(tx, node_kind).await?;
let (diff_received, diff_sent, diff_dropped) = if let Some(prev) = previous_stats {
(
@@ -223,9 +221,9 @@ pub async fn update_daily_stats(
(0, 0, 0) // No previous stats available
};
insert_daily_node_stats(
pool,
node,
insert_daily_node_stats_uncommitted(
tx,
node_kind,
&date_utc,
NodeStats {
packets_received: diff_received,
@@ -1,284 +1,6 @@
use crate::db::{models::GatewaySessionsRecord, queries, DbPool};
use error::NodeScraperError;
use nym_network_defaults::{NymNetworkDetails, DEFAULT_NYM_NODE_HTTP_PORT};
use nym_node_requests::api::{client::NymNodeApiClientExt, v1::metrics::models::SessionStats};
use nym_validator_client::{
client::{NodeId, NymNodeDetails},
models::{DescribedNodeType, NymNodeDescription},
NymApiClient,
};
use time::OffsetDateTime;
pub(crate) mod description;
pub(crate) mod helpers;
pub(crate) mod packet_stats;
use nym_statistics_common::types::SessionType;
use std::collections::HashMap;
use tokio::time::Duration;
use tracing::instrument;
mod error;
const FAILURE_RETRY_DELAY: Duration = Duration::from_secs(60);
const REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60 * 6);
const STALE_DURATION: Duration = Duration::from_secs(86400 * 365); //one year
#[instrument(level = "info", name = "metrics_scraper", skip_all)]
pub(crate) async fn spawn_in_background(db_pool: DbPool, nym_api_client_timeout: Duration) {
let network_defaults = nym_network_defaults::NymNetworkDetails::new_from_env();
loop {
tracing::info!("Refreshing node self-described metrics...");
if let Err(e) = run(&db_pool, &network_defaults, nym_api_client_timeout).await {
tracing::error!(
"Metrics collection failed: {e}, retrying in {}s...",
FAILURE_RETRY_DELAY.as_secs()
);
tokio::time::sleep(FAILURE_RETRY_DELAY).await;
} else {
tracing::info!(
"Metrics successfully collected, sleeping for {}s...",
REFRESH_INTERVAL.as_secs()
);
tokio::time::sleep(REFRESH_INTERVAL).await;
}
}
}
async fn run(
pool: &DbPool,
network_details: &NymNetworkDetails,
nym_api_client_timeout: Duration,
) -> anyhow::Result<()> {
let default_api_url = network_details
.endpoints
.first()
.expect("rust sdk mainnet default incorrectly configured")
.api_url()
.clone()
.expect("rust sdk mainnet default missing api_url");
let nym_api = nym_http_api_client::ClientBuilder::new_with_url(default_api_url)
.no_hickory_dns()
.with_timeout(nym_api_client_timeout)
.build::<&str>()?;
let api_client = NymApiClient::from(nym_api);
//SW TBC what nodes exactly need to be scraped, the skimmed node endpoint seems to return more nodes
let bonded_nodes = api_client.get_all_bonded_nym_nodes().await?;
let all_nodes = api_client.get_all_described_nodes().await?; //legacy node that did not upgrade the contract bond yet
tracing::debug!("Fetched {} total nodes", all_nodes.len());
let mut nodes_to_scrape: HashMap<NodeId, MetricsScrapingData> = bonded_nodes
.into_iter()
.map(|n| (n.node_id(), n.into()))
.collect();
all_nodes
.into_iter()
.filter(|n| n.contract_node_type != DescribedNodeType::LegacyMixnode)
.for_each(|n| {
nodes_to_scrape.entry(n.node_id).or_insert_with(|| n.into());
});
tracing::debug!("Will try to scrape {} nodes", nodes_to_scrape.len());
let mut session_records = Vec::new();
for n in nodes_to_scrape.into_values() {
if let Some(stat) = n.try_scrape_metrics().await {
session_records.push(prepare_session_data(stat, &n));
}
}
queries::insert_session_records(pool, session_records)
.await
.map(|_| {
tracing::debug!("Session info written to DB!");
})?;
let cut_off_date = (OffsetDateTime::now_utc() - STALE_DURATION).date();
queries::delete_old_records(pool, cut_off_date)
.await
.map(|_| {
tracing::debug!("Cleared old data before {}", cut_off_date);
})?;
Ok(())
}
#[derive(Debug)]
struct MetricsScrapingData {
host: String,
node_id: NodeId,
id_key: String,
port: Option<u16>,
}
impl MetricsScrapingData {
pub fn new(
host: impl Into<String>,
node_id: NodeId,
id_key: String,
port: Option<u16>,
) -> Self {
MetricsScrapingData {
host: host.into(),
node_id,
id_key,
port,
}
}
#[instrument(level = "info", name = "metrics_scraper", skip_all)]
async fn try_scrape_metrics(&self) -> Option<SessionStats> {
match self.try_get_client().await {
Ok(client) => {
match client.get_sessions_metrics().await {
Ok(session_stats) => {
if session_stats.update_time != OffsetDateTime::UNIX_EPOCH {
Some(session_stats)
} else {
//means no data
None
}
}
Err(e) => {
tracing::warn!("{e}");
None
}
}
}
Err(e) => {
tracing::warn!("{e}");
None
}
}
}
async fn try_get_client(&self) -> Result<nym_node_requests::api::Client, NodeScraperError> {
// first try the standard port in case the operator didn't put the node behind the proxy,
// then default https (443)
// finally default http (80)
let mut addresses_to_try = vec![
format!("http://{0}:{DEFAULT_NYM_NODE_HTTP_PORT}", self.host), // 'standard' nym-node
format!("https://{0}", self.host), // node behind https proxy (443)
format!("http://{0}", self.host), // node behind http proxy (80)
];
// note: I removed 'standard' legacy mixnode port because it should now be automatically pulled via
// the 'custom_port' since it should have been present in the contract.
if let Some(port) = self.port {
addresses_to_try.insert(0, format!("http://{0}:{port}", self.host));
}
for address in addresses_to_try {
// if provided host was malformed, no point in continuing
let client = match nym_node_requests::api::Client::builder(address).and_then(|b| {
b.with_timeout(Duration::from_secs(5))
.with_user_agent("node-status-api-metrics-scraper")
.no_hickory_dns()
.build()
}) {
Ok(client) => client,
Err(err) => {
return Err(NodeScraperError::MalformedHost {
host: self.host.to_string(),
node_id: self.node_id,
source: err,
});
}
};
if let Ok(health) = client.get_health().await {
if health.status.is_up() {
return Ok(client);
}
}
}
Err(NodeScraperError::NoHttpPortsAvailable {
host: self.host.to_string(),
node_id: self.node_id,
})
}
}
impl From<NymNodeDetails> for MetricsScrapingData {
fn from(value: NymNodeDetails) -> Self {
MetricsScrapingData::new(
value.bond_information.node.host.clone(),
value.node_id(),
value.bond_information.node.identity_key,
value.bond_information.node.custom_http_port,
)
}
}
impl From<NymNodeDescription> for MetricsScrapingData {
fn from(value: NymNodeDescription) -> Self {
MetricsScrapingData::new(
value.description.host_information.ip_address[0].to_string(),
value.node_id,
value.ed25519_identity_key().to_base58_string(),
None,
)
}
}
fn prepare_session_data(
stat: SessionStats,
node_data: &MetricsScrapingData,
) -> GatewaySessionsRecord {
let users_hashes = if !stat.unique_active_users_hashes.is_empty() {
Some(serde_json::to_string(&stat.unique_active_users_hashes).unwrap())
} else {
None
};
let vpn_durations = stat
.sessions
.iter()
.filter(|s| SessionType::from_string(&s.typ) == SessionType::Vpn)
.map(|s| s.duration_ms)
.collect::<Vec<_>>();
let mixnet_durations = stat
.sessions
.iter()
.filter(|s| SessionType::from_string(&s.typ) == SessionType::Mixnet)
.map(|s| s.duration_ms)
.collect::<Vec<_>>();
let unkown_durations = stat
.sessions
.iter()
.filter(|s| SessionType::from_string(&s.typ) == SessionType::Unknown)
.map(|s| s.duration_ms)
.collect::<Vec<_>>();
let vpn_sessions = if !vpn_durations.is_empty() {
Some(serde_json::to_string(&vpn_durations).unwrap())
} else {
None
};
let mixnet_sessions = if !mixnet_durations.is_empty() {
Some(serde_json::to_string(&mixnet_durations).unwrap())
} else {
None
};
let unknown_sessions = if !unkown_durations.is_empty() {
Some(serde_json::to_string(&unkown_durations).unwrap())
} else {
None
};
GatewaySessionsRecord {
gateway_identity_key: node_data.id_key.clone(),
node_id: node_data.node_id as i64,
day: stat.update_time.date(),
unique_active_clients: stat.unique_active_users as i64,
session_started: stat.sessions_started as i64,
users_hashes,
vpn_sessions,
mixnet_sessions,
unknown_sessions,
}
}
pub(crate) use description::DescriptionScraper;
pub(crate) use packet_stats::PacketScraper;
@@ -0,0 +1,138 @@
use super::helpers::scrape_packet_stats;
use sqlx::SqlitePool;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::task::JoinSet;
use tracing::{debug, error, info, instrument, warn};
use crate::db::models::{InsertStatsRecord, ScraperNodeInfo};
use crate::db::queries;
const PACKET_SCRAPE_INTERVAL: Duration = Duration::from_secs(60 * 60);
const QUEUE_CHECK_INTERVAL: Duration = Duration::from_millis(250);
static TASK_COUNTER: AtomicUsize = AtomicUsize::new(0);
static TASK_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct PacketScraper {
pool: SqlitePool,
max_concurrent_tasks: usize,
}
impl PacketScraper {
pub fn new(pool: SqlitePool, max_concurrent_tasks: usize) -> Self {
Self {
pool,
max_concurrent_tasks,
}
}
pub async fn start(&self) {
self.spawn_packet_scraper().await;
}
async fn spawn_packet_scraper(&self) {
let pool = self.pool.clone();
tracing::info!("Starting packet scraper");
let max_concurrent_tasks = self.max_concurrent_tasks;
tokio::spawn(async move {
loop {
if let Err(e) = Self::run_packet_scraper(&pool, max_concurrent_tasks).await {
error!(name: "packet_scraper", "Packet scraper failed: {}", e);
}
debug!(name: "packet_scraper", "Sleeping for {}s", PACKET_SCRAPE_INTERVAL.as_secs());
tokio::time::sleep(PACKET_SCRAPE_INTERVAL).await;
}
});
}
#[instrument(level = "info", name = "packet_scraper", skip_all)]
async fn run_packet_scraper(
pool: &SqlitePool,
max_concurrent_tasks: usize,
) -> anyhow::Result<()> {
let queue = queries::get_nodes_for_scraping(pool).await?;
tracing::info!("Adding {} nodes to the queue", queue.len(),);
let results = Self::process_packet_queue(queue, max_concurrent_tasks).await;
queries::batch_store_packet_stats(pool, results)
.await
.map_err(|err| anyhow::anyhow!("Failed to store packet stats to DB: {err}"))
}
async fn process_packet_queue(
queue: Vec<ScraperNodeInfo>,
max_concurrent_tasks: usize,
) -> Arc<Mutex<Vec<InsertStatsRecord>>> {
let mut queue = queue;
let results = Arc::new(Mutex::new(Vec::new()));
let mut task_set = JoinSet::new();
loop {
let running_tasks = TASK_COUNTER.load(Ordering::Relaxed);
if running_tasks < max_concurrent_tasks {
let node = {
if queue.is_empty() {
TASK_ID_COUNTER.store(0, Ordering::Relaxed);
break;
}
queue.remove(0)
};
TASK_COUNTER.fetch_add(1, Ordering::Relaxed);
let task_id = TASK_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let results_clone = Arc::clone(&results);
task_set.spawn(async move {
match scrape_packet_stats(&node).await {
Ok(result) => {
// each task contributes their result to a shared vec
results_clone.lock().await.push(result);
debug!(
"📊 ✅ Packet stats task #{} for node {} complete",
task_id,
node.node_id()
)
}
Err(e) => debug!(
"📊 ❌ Packet stats task #{} for {} {} failed: {}",
task_id,
node.node_kind,
node.node_id(),
e
),
}
TASK_COUNTER.fetch_sub(1, Ordering::Relaxed);
});
} else {
tokio::time::sleep(QUEUE_CHECK_INTERVAL).await;
}
}
// wait for all the tasks to complete before returning their results
let total_count = task_set.len();
let mut success_count = 0;
while let Some(res) = task_set.join_next().await {
if let Err(err) = res {
warn!("Packet stats task panicked: {err}");
} else {
success_count += 1;
}
}
let msg = format!(
"Successfully completed {}/{} tasks ",
success_count, total_count
);
if success_count != total_count {
warn!(msg);
} else {
info!(msg);
}
results
}
}
+60
View File
@@ -0,0 +1,60 @@
/* eslint-disable no-param-reassign */
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
framework: '@storybook/react',
core: {
builder: 'webpack5',
},
typescript: { reactDocgen: false },
// webpackFinal: async (config, { configType }) => {
// // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// // You can change the configuration based on that.
// // 'PRODUCTION' is used when building the static version of storybook.
webpackFinal: async (config) => {
config.module.rules.forEach((rule) => {
// look for SVG import rule and replace
// NOTE: the rule before modification is /\.(svg|ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/
if (rule.test?.toString().includes('svg')) {
rule.test = /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/;
}
});
// handle asset loading with this
config.module.rules.unshift({
test: /\.svg(\?.*)?$/i,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack'],
});
config.resolve.extensions = ['.tsx', '.ts', '.js'];
config.resolve.plugins = [new TsconfigPathsPlugin()];
config.plugins.push(
new ForkTsCheckerWebpackPlugin({
typescript: {
mode: 'write-references',
diagnosticOptions: {
semantic: true,
syntactic: true,
},
},
}),
);
if (!config.resolve.alias) {
config.resolve.alias = {};
}
config.resolve.alias['@tauri-apps/api'] = `${__dirname}/mocks/tauri`;
// Return the altered config
return config;
},
features: {
emotionAlias: false,
},
};
+8
View File
@@ -0,0 +1,8 @@
/**
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
module.exports = {
getVersion: () => undefined,
};
@@ -0,0 +1,8 @@
/**
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
module.exports = {
invoke: () => undefined,
}
@@ -0,0 +1,4 @@
/**
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
+113
View File
@@ -0,0 +1,113 @@
const delegations = [
{
mix_id: 1234,
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
amount: { amount: '10', denom: 'nym' },
owner: '',
block_height: BigInt(100),
cost_params: {
profit_margin_percent: '0.04',
interval_operating_cost: {
amount: '20',
denom: 'nym',
},
},
stake_saturation: '0.2',
avg_uptime_percent: 0.5,
accumulated_by_delegates: { amount: '0', denom: 'nym' },
accumulated_by_operator: { amount: '0', denom: 'nym' },
uses_vesting_contract_tokens: false,
pending_events: [],
mixnode_is_unbonding: false,
errors: null,
},
{
mix_id: 5678,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
unclaimed_rewards: { amount: '0.1', denom: 'nym' },
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
cost_params: {
profit_margin_percent: '0.04',
interval_operating_cost: {
amount: '60',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '0', denom: 'nym' },
accumulated_by_operator: { amount: '0', denom: 'nym' },
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: false,
errors: null,
},
];
/**
* This is a mock for Tauri's API package (@tauri-apps/api), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
module.exports = {
invoke: (operation, args) => {
switch (operation) {
case 'get_balance': {
return {
amount: {
amount: '100',
denom: 'nymt',
},
printable_balance: '100 NYMT',
};
}
case 'delegate_to_mixnode': {
return {
logs_json: '[]',
data_json: '{}',
transaction_hash: '12345',
};
}
case 'simulate_send': {
return {
amount: {
amount: '0.01',
denom: 'nym',
},
};
}
case 'get_delegation_summary': {
return {
delegations,
total_delegations: {
amount: '1000',
denom: 'nymt',
},
total_rewards: {
amount: '42',
denom: 'nymt',
},
};
}
case 'get_pending_delegation_events' : {
return [];
}
case 'migrate_vested_delegations': {
delegations[1].uses_vesting_contract_tokens = false;
return {};
}
}
console.error(
`Tauri cannot be used in Storybook. The operation requested was "${operation}". You can add mock responses to "nym_wallet/.storybook/mocks/tauri.js" if you need. The default response is "void".`,
);
return new Promise((resolve, reject) => {
reject(new Error(`Tauri operation ${operation} not available in storybook.`));
});
},
};
@@ -0,0 +1,10 @@
/**
* This is a mock for Tauri's API package (@tauri-apps/api/window), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
module.exports = {
appWindow: {
maximize: () => undefined,
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NymWalletThemeWithMode } from '../src/theme/NymWalletTheme';
import { Box } from '@mui/material';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
const withThemeProvider = (Story, context) => (
<div style={{ display: 'grid', height: '100%', gridTemplateColumns: '50% 50%' }}>
<div>
<NymWalletThemeWithMode mode="light">
<Box
p={4}
sx={{
display: 'grid',
gridTemplateRows: '80vh 2rem',
background: (theme) => theme.palette.background.default,
color: 'text.primary',
}}
>
<Box sx={{ overflowY: 'auto' }}>
<Story {...context} />
</Box>
<h4 style={{ textAlign: 'center' }}>Light mode</h4>
</Box>
</NymWalletThemeWithMode>
</div>
<div>
<NymWalletThemeWithMode mode="dark">
<Box
p={4}
sx={{
display: 'grid',
gridTemplateRows: '80vh 2rem',
background: (theme) => theme.palette.background.default,
color: 'text.primary',
}}
>
<Box sx={{ overflowY: 'auto' }}>
<Story {...context} />
</Box>
<h4 style={{ textAlign: 'center' }}>Dark mode</h4>
</Box>
</NymWalletThemeWithMode>
</div>
</div>
);
export const decorators = [withThemeProvider];
+21
View File
@@ -0,0 +1,21 @@
import { Theme } from '@mui/material/styles';
export const backDropStyles = (theme: Theme) => {
const { mode } = theme.palette;
return {
style: {
left: mode === 'light' ? '0' : '50%',
width: '50%',
},
};
};
export const modalStyles = (theme: Theme) => {
const { mode } = theme.palette;
return { left: mode === 'light' ? '25%' : '75%' };
};
export const dialogStyles = (theme: Theme) => {
const { mode } = theme.palette;
return { left: mode === 'light' ? '-50%' : '50%' };
};
View File
+7 -1
View File
@@ -10,7 +10,10 @@
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"prebuild": "yarn --cwd .. build",
"prestorybook": "yarn --cwd .. build",
"prewebpack:dev": "yarn --cwd .. build",
"storybook": "start-storybook -p 6006",
"storybook:build": "build-storybook",
"tauri:build": "yarn tauri build",
"tauri:dev": "yarn tauri dev",
"tauri:buildx86": "yarn tauri build --target x86_64-apple-darwin",
@@ -32,6 +35,7 @@
"@nymproject/node-tester": "^1.2.3",
"@nymproject/react": "^1.0.0",
"@nymproject/types": "^1.0.0",
"@storybook/react": "^6.5.15",
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-opener": "^2.2.6",
@@ -68,6 +72,7 @@
"@babel/preset-typescript": "^7.15.0",
"@nymproject/eslint-config-react-typescript": "^1.0.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@storybook/react": "^6.5.15",
"@svgr/webpack": "^6.1.1",
"@tauri-apps/cli": "^2.4.0",
"@testing-library/jest-dom": "^5.14.1",
@@ -100,7 +105,8 @@
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.2",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-storybook": "^0.5.12",
"favicons": "^7.0.2",
"favicons-webpack-plugin": "^5.0.2",
"file-loader": "^6.2.0",
@@ -0,0 +1,18 @@
import React from 'react';
import { Box } from '@mui/material';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { MockAccountsProvider } from 'src/context/mocks/accounts';
import { Accounts } from '../Accounts';
export default {
title: 'Wallet / Multi Account',
component: Accounts,
} as ComponentMeta<typeof Accounts>;
export const Default: ComponentStory<typeof Accounts> = () => (
<Box display="flex" alignContent="center">
<MockAccountsProvider>
<Accounts />
</MockAccountsProvider>
</Box>
);
@@ -0,0 +1,10 @@
import React from 'react';
import { Tutorial } from './Tutorial';
export default {
title: 'Buy/Tutorial',
component: Tutorial,
};
export const TutorialPage = () => <Tutorial />;
+26 -22
View File
@@ -1,32 +1,32 @@
import React from 'react';
import { Box, Typography, Grid, Link, Card, CardContent, Stack } from '@mui/material';
import { NymCard } from '..';
import BitfinexIcon from 'src/svg-icons/bitfinex.svg';
import KrakenIcon from 'src/svg-icons/kraken.svg';
import BybitIcon from 'src/svg-icons/bybit.svg';
import GateIcon from 'src/svg-icons/gate22.svg';
import HTXIcon from 'src/svg-icons/htx.svg';
import { NymCard } from '..';
const ExchangeCard = ({
name,
tokenType,
url,
IconComponent,
const ExchangeCard = ({
name,
tokenType,
url,
IconComponent
}: {
name: string;
tokenType: string;
url: string;
IconComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
}) => (
<Card
variant="outlined"
sx={{
<Card
variant="outlined"
sx={{
height: '100%',
transition: 'all 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 2,
},
}
}}
>
<CardContent sx={{ p: 3 }}>
@@ -51,17 +51,17 @@ const ExchangeCard = ({
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{tokenType}
</Typography>
<Link
href={url}
target="_blank"
<Link
href={url}
target="_blank"
variant="body2"
data-testid="link-get-nym"
sx={{
sx={{
textDecoration: 'underline',
fontWeight: 500,
'&:hover': {
textDecoration: 'none',
},
}
}}
>
GET NYM
@@ -78,40 +78,44 @@ export const Tutorial = () => {
name: 'Bitfinex',
tokenType: 'Native NYM, ERC-20',
url: 'https://www.bitfinex.com/',
IconComponent: BitfinexIcon,
IconComponent: BitfinexIcon
},
{
name: 'Kraken',
tokenType: 'Native NYM',
url: 'https://www.kraken.com/',
IconComponent: KrakenIcon,
IconComponent: KrakenIcon
},
{
name: 'Bybit',
tokenType: 'ERC-20',
url: 'https://www.bybit.com/en/',
IconComponent: BybitIcon,
IconComponent: BybitIcon
},
{
name: 'Gate.io',
tokenType: 'ERC-20',
url: 'https://www.gate.io/',
IconComponent: GateIcon,
IconComponent: GateIcon
},
{
name: 'HTX',
tokenType: 'ERC-20',
url: 'https://www.htx.com/',
IconComponent: HTXIcon,
IconComponent: HTXIcon
},
];
return (
<NymCard borderless title="Where you can get NYM tokens" sx={{ mt: 4 }}>
<NymCard
borderless
title="Where you can get NYM tokens"
sx={{ mt: 4 }}
>
<Typography mb={3} fontSize={14} sx={{ color: 'text.secondary' }}>
You can get NYM tokens from these exchanges
</Typography>
<Grid container spacing={3}>
{exchanges.map((exchange) => (
<Grid item xs={12} md={6} lg={4} key={exchange.name}>
@@ -0,0 +1,29 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { ConfirmTx } from './ConfirmTX';
import { ModalListItem } from './Modals/ModalListItem';
export default {
title: 'Wallet / Confirm Transaction',
component: ConfirmTx,
} as ComponentMeta<typeof ConfirmTx>;
const Template: ComponentStory<typeof ConfirmTx> = (args) => (
<ConfirmTx {...args}>
<ModalListItem label="Transaction type" value="Bond" divider />
<ModalListItem label="Current bond" value="100 NYM" divider />
<ModalListItem label="Additional bond" value="50 NYM" divider />
</ConfirmTx>
);
export const Default = Template.bind({});
Default.args = {
open: true,
header: 'Confirm transaction',
subheader: 'Confirm and proceed or cancel transaction',
fee: { amount: { amount: '0.001', denom: 'nym' }, fee: { Auto: null } },
onClose: () => {},
onConfirm: async () => {},
onPrev: () => {},
isStorybook: true,
};
+12 -2
View File
@@ -5,6 +5,15 @@ import { useTheme, Theme } from '@mui/material/styles';
import { SimpleModal } from './Modals/SimpleModal';
import { ModalFee } from './Modals/ModalFee';
import { ModalDivider } from './Modals/ModalDivider';
import { backDropStyles, modalStyles } from '../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme, isStorybook?: boolean, backdropProps?: object) =>
isStorybook
? {
backdropProps: { ...backDropStyles(theme), ...backdropProps },
sx: modalStyles(theme),
}
: {};
export const ConfirmTx: FCWithChildren<{
open: boolean;
@@ -14,9 +23,9 @@ export const ConfirmTx: FCWithChildren<{
onConfirm: () => Promise<void>;
onClose?: () => void;
onPrev: () => void;
isStorybook?: boolean;
children?: React.ReactNode;
}> = ({ open, fee, onConfirm, onClose, header, subheader, onPrev, children }) => {
}> = ({ open, fee, onConfirm, onClose, header, subheader, onPrev, children, isStorybook }) => {
const theme = useTheme();
return (
<SimpleModal
@@ -27,6 +36,7 @@ export const ConfirmTx: FCWithChildren<{
onOk={onConfirm}
onClose={onClose}
onBack={onPrev}
{...storybookStyles(theme, isStorybook)}
>
<Box sx={{ mt: 3 }}>
{children}
@@ -0,0 +1,40 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { useTheme } from '@mui/material/styles';
import { Button, Paper, Typography } from '@mui/material';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
import { OverSaturatedBlockerModal } from './DelegateBlocker';
export default {
title: 'Delegation/Components/Delegation Over Saturated Warning Modal',
component: OverSaturatedBlockerModal,
} as ComponentMeta<typeof OverSaturatedBlockerModal>;
export const Default = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<>
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Lorem ipsum</h2>
<Button variant="contained" onClick={handleClick} sx={{ mb: 3 }}>
Show modal
</Button>
<Typography>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis
sunt velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur
culpa exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
</Typography>
</Paper>
<OverSaturatedBlockerModal
open={open}
header="Node saturation: 114%"
onClose={() => setOpen(false)}
backdropProps={backDropStyles(theme)}
sx={modalStyles(theme)}
/>
</>
);
};
@@ -0,0 +1,19 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { DelegationActions } from './DelegationActions';
export default {
title: 'Delegation/Components/Delegation List Item Actions',
component: DelegationActions,
} as ComponentMeta<typeof DelegationActions>;
export const Default = () => <DelegationActions />;
export const RedeemingDisabled = () => <DelegationActions disableRedeemingRewards />;
export const PendingDelegation = () => <DelegationActions isPending={{ actionType: 'delegate', blockHeight: 1000 }} />;
export const PendingUndelegation = () => (
<DelegationActions isPending={{ actionType: 'undelegate', blockHeight: 1000 }} />
);
@@ -0,0 +1,311 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { DelegationWithEverything } from '@nymproject/types';
import { DelegationList } from './DelegationList';
export default {
title: 'Delegation/Components/Delegation List',
component: DelegationList,
} as ComponentMeta<typeof DelegationList>;
const explorerUrl = 'https://sandbox-explorer.nymtech.net/network-components/mixnodes';
export const items: DelegationWithEverything[] = [
{
mix_id: 1,
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
amount: { amount: '10', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(100),
stake_saturation: '0.25',
avg_uptime_percent: 0.5,
uses_vesting_contract_tokens: false,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 2,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '1010', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '200', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.43',
avg_uptime_percent: 0.22,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 3,
node_identity: '',
amount: { amount: '300', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '50',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '300', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 4,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '201', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '60',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '202', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 5,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '80',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 6,
node_identity: '',
amount: { amount: '202', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.8',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 7,
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
amount: { amount: '202', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.59',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(100),
stake_saturation: '0.5',
avg_uptime_percent: 0.5,
uses_vesting_contract_tokens: false,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 8,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.9',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 9,
node_identity: '',
amount: { amount: '1000', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.4',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.9',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 10,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 11,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.56',
avg_uptime_percent: 0.9,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 12,
node_identity: '',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
];
export const WithData = () => <DelegationList items={items} explorerUrl={explorerUrl} />;
export const Empty = () => <DelegationList items={[]} explorerUrl={explorerUrl} />;
export const OneItem = () => <DelegationList items={[items[0]]} explorerUrl={explorerUrl} />;
export const Loading = () => <DelegationList items={[]} isLoading explorerUrl={explorerUrl} />;
@@ -0,0 +1,190 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Paper, Button } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { Delegations } from './Delegations';
import { items } from './DelegationList.stories';
import { DelegationModal } from './DelegationModal';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
const explorerUrl = 'https://sandbox-explorer.nymtech.net';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
export default {
title: 'Delegation/Components/Delegation Modals',
component: Delegations,
} as ComponentMeta<typeof Delegations>;
const transaction = {
url: 'https://sandbox-blocks.nymtech.net/transactions/11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFB',
hash: '11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFB',
};
// Another transaction for Dark Theme to avoid duplicate key errors in rendering
const transactionForDarkTheme = {
url: 'https://sandbox-blocks.nymtech.net/transactions/11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFO',
hash: '11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CF0',
};
const Content: FCWithChildren<{ children: React.ReactElement<any, any>; handleClick: () => void }> = ({
children,
handleClick,
}) => (
<>
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Your Delegations</h2>
<Button variant="contained" onClick={handleClick} sx={{ mb: 3 }}>
Show modal
</Button>
<Delegations items={items} explorerUrl={explorerUrl} />
</Paper>
{children}
</>
);
export const Loading = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="loading"
action="delegate"
{...storybookStyles(theme)}
/>
</Content>
);
};
export const DelegateSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="delegate"
message="You delegated 5 NYM"
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const UndelegateSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="undelegate"
message="You undelegated 5 NYM"
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const RedeemSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="redeem"
message="42 NYM"
transactions={
theme.palette.mode === 'light'
? [transaction, transaction]
: [transactionForDarkTheme, transactionForDarkTheme]
}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const RedeemWithVestedSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="redeem"
message="42 NYM"
transactions={
theme.palette.mode === 'light'
? [transaction, transaction]
: [transactionForDarkTheme, transactionForDarkTheme]
}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const RedeemAllSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="redeem-all"
message="42 NYM"
transactions={
theme.palette.mode === 'light'
? [transaction, transaction]
: [transactionForDarkTheme, transactionForDarkTheme]
}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const Error = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="error"
action="redeem-all"
message="Minim esse veniam Lorem id velit Lorem eu eu est. Excepteur labore sunt do proident proident sint aliquip consequat Lorem sint non nulla ad excepteur."
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
{...storybookStyles(theme)}
/>
</Content>
);
};
@@ -0,0 +1,27 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Paper } from '@mui/material';
import { Delegations } from './Delegations';
import { items } from './DelegationList.stories';
const explorerUrl = 'https://sandbox-explorer.nymtech.net';
export default {
title: 'Delegation/Components/Delegations',
component: Delegations,
} as ComponentMeta<typeof Delegations>;
export const Default = () => (
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Your Delegations</h2>
<Delegations items={items} explorerUrl={explorerUrl} />
</Paper>
);
export const Empty = () => (
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Your Delegations</h2>
<Delegations items={[]} explorerUrl={explorerUrl} />
</Paper>
);
@@ -0,0 +1,144 @@
import React from 'react';
import { Button, Paper, Typography } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { DelegateModal } from './DelegateModal';
import { UndelegateModal } from './UndelegateModal';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
export default {
title: 'Delegation/Components/Action Modals',
};
const Background: FCWithChildren<{ onOpen: () => void }> = ({ onOpen }) => {
const theme = useTheme();
return (
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Lorem ipsum</h2>
<Button variant="contained" onClick={onOpen}>
Show modal
</Button>
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis sunt
velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur culpa
exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eiusmod occaecat aliquip ad
tempor. Labore amet laborum magna amet consequat dolor cupidatat in consequat sunt aliquip magna laboris tempor
culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna elit ut
mollit.
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Labore voluptate elit amet ipsum qui officia duis in et occaecat culpa ex do non labore mollit. Cillum cupidatat
duis ea dolore laboris laboris sunt duis anim consectetur cupidatat nulla ad minim sunt ea. Aliqua amet commodo
est irure sint magna sunt. Pariatur dolore commodo labore quis incididunt proident duis voluptate exercitation
in duis. Occaecat aliqua laboris reprehenderit nostrud est aute pariatur fugiat anim. Dolore sunt cillum ea
aliquip consectetur laborum ipsum qui veniam Lorem consectetur adipisicing velit magna aute. Amet tempor quis
excepteur minim culpa velit Lorem enim ad.
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Mollit laborum exercitation excepteur laboris adipisicing ipsum veniam cillum mollit voluptate do. Amet et anim
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt nisi nisi non excepteur dolor eiusmod aute aute
irure anim dolore ipsum et veniam.
</Typography>
</Paper>
);
};
export const Delegate = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Background onOpen={() => setOpen(true)} />
<DelegateModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
denom="nym"
estimatedReward={50.423}
accountBalance="425.2345053"
nodeUptimePercentage={99.28394}
profitMarginPercentage="11.12334234"
rewardInterval="monthlyish"
hasVestingContract={false}
{...storybookStyles(theme)}
/>
</>
);
};
export const DelegateBelowMinimum = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Background onOpen={() => setOpen(true)} />
<DelegateModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
denom="nym"
estimatedReward={425.2345053}
nodeUptimePercentage={99.28394}
profitMarginPercentage="11.12334234"
rewardInterval="monthlyish"
initialAmount="0.1"
hasVestingContract={false}
{...storybookStyles(theme)}
/>
</>
);
};
export const DelegateMore = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Background onOpen={() => setOpen(true)} />
<DelegateModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="Delegate more"
buttonText="Delegate more"
denom="nym"
estimatedReward={50.423}
accountBalance="425.2345053"
nodeUptimePercentage={99.28394}
profitMarginPercentage="11.12334234"
rewardInterval="monthlyish"
hasVestingContract={false}
{...storybookStyles(theme)}
/>
</>
);
};
export const Undelegate = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Background onOpen={() => setOpen(true)} />
<UndelegateModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
currency="nym"
amount={150}
mixId={1234}
identityKey="AA6RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujyxx"
usesVestingContractTokens={false}
{...storybookStyles(theme)}
/>
</>
);
};
@@ -0,0 +1,20 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Box } from '@mui/material';
import { BalanceWarning } from './FeeWarning';
export default {
title: 'Wallet / Balance warning',
component: BalanceWarning,
} as ComponentMeta<typeof BalanceWarning>;
const Template: ComponentStory<typeof BalanceWarning> = (args) => (
<Box mt={2} height={800}>
<BalanceWarning {...args} />
</Box>
);
export const WithWarning = Template.bind({});
WithWarning.args = {
fee: '200',
};
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { ErrorOutline } from '@mui/icons-material';
import { useTheme, Theme } from '@mui/material/styles';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Button } from '@mui/material';
import { ConfirmationModal } from './ConfirmationModal';
import { backDropStyles, dialogStyles } from '../../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: dialogStyles(theme),
});
export default {
title: 'Modals/ConfirmationModal',
component: ConfirmationModal,
} as ComponentMeta<typeof ConfirmationModal>;
const Template: ComponentStory<typeof ConfirmationModal> = (args) => {
const [open, setOpen] = useState(true);
const theme = useTheme();
return (
<>
<Button variant="outlined" onClick={() => setOpen(true)}>
Open confirmation dialog
</Button>
<ConfirmationModal
{...args}
open={open}
onClose={() => setOpen(false)}
onConfirm={() => setOpen(false)}
{...storybookStyles(theme)}
>
Dialog content.
</ConfirmationModal>
</>
);
};
export const withError: ComponentStory<typeof ConfirmationModal> = () => {
const [open, setOpen] = useState(true);
const theme = useTheme();
return (
<>
<Button variant="outlined" onClick={() => setOpen(true)}>
Open confirmation dialog
</Button>
<ConfirmationModal
title="An error occured"
confirmButton="Done"
open={open}
onClose={() => setOpen(false)}
onConfirm={() => setOpen(false)}
{...storybookStyles(theme)}
>
<ErrorOutline color="error" />
</ConfirmationModal>
</>
);
};
export const Default = Template.bind({});
Default.args = {
title: 'Confirmation Modal',
subTitle: '',
fullWidth: true,
confirmButton: 'Confirm',
maxWidth: 'xs',
disabled: false,
};
@@ -0,0 +1,260 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Button, Paper, Typography } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { SimpleModal } from './SimpleModal';
import { ModalDivider } from './ModalDivider';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
export default {
title: 'Modals/Simple Modal',
component: SimpleModal,
} as ComponentMeta<typeof SimpleModal>;
const BasePage: FCWithChildren<{ children: React.ReactElement<any, any>; handleClick: () => void }> = ({
children,
handleClick,
}) => (
<>
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Lorem ipsum</h2>
<Button variant="contained" onClick={handleClick} sx={{ mb: 3 }}>
Show modal
</Button>
<Typography>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis sunt
velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur culpa
exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
</Typography>
<Typography>
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eiusmod occaecat aliquip ad
tempor. Labore amet laborum magna amet consequat dolor cupidatat in consequat sunt aliquip magna laboris tempor
culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna elit ut
mollit.
</Typography>
<Typography>
Labore voluptate elit amet ipsum qui officia duis in et occaecat culpa ex do non labore mollit. Cillum cupidatat
duis ea dolore laboris laboris sunt duis anim consectetur cupidatat nulla ad minim sunt ea. Aliqua amet commodo
est irure sint magna sunt. Pariatur dolore commodo labore quis incididunt proident duis voluptate exercitation
in duis. Occaecat aliqua laboris reprehenderit nostrud est aute pariatur fugiat anim. Dolore sunt cillum ea
aliquip consectetur laborum ipsum qui veniam Lorem consectetur adipisicing velit magna aute. Amet tempor quis
excepteur minim culpa velit Lorem enim ad.
</Typography>
<Typography>
Mollit laborum exercitation excepteur laboris adipisicing ipsum veniam cillum mollit voluptate do. Amet et anim
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt nisi nisi non excepteur dolor eiusmod aute aute
irure anim dolore ipsum et veniam.
</Typography>
</Paper>
{children}
</>
);
export const Default = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
subHeader="This is a sub header"
okLabel="Click to continue"
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eius
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const NoSubheader = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
okLabel="Kaplow!"
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const hideCloseIcon = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
hideCloseIcon
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
okLabel="Kaplow!"
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const hideCloseIconAndDisplayErrorIcon = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
hideCloseIcon
displayErrorIcon
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This modal announces an error !"
okLabel="Kaplow!"
backdropProps={backDropStyles(theme)}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
...modalStyles(theme),
}}
headerStyles={{
width: '100%',
mb: 3,
textAlign: 'center',
color: 'error.main',
}}
subHeaderStyles={{ textAlign: 'center', color: 'text.primary', fontSize: 14, fontWeight: 400 }}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const withBackButton = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
hideCloseIcon
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
okLabel="Primary action"
onBack={() => setOpen(false)}
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const withBackButtonAndCustomLabel = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
hideCloseIcon
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
okLabel="Primary action"
onBack={() => setOpen(false)}
backLabel="Cancel"
backButtonFullWidth
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
@@ -0,0 +1,20 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Box } from '@mui/material';
import { MockMainContextProvider } from '../context/mocks/main';
import { NetworkSelector } from './NetworkSelector';
export default {
title: 'Wallet / Network Selector',
component: NetworkSelector,
} as ComponentMeta<typeof NetworkSelector>;
const Template: ComponentStory<typeof NetworkSelector> = () => (
<Box mt={2} height={800}>
<MockMainContextProvider>
<NetworkSelector />
</MockMainContextProvider>
</Box>
);
export const Default = Template.bind({});
+2 -2
View File
@@ -2,10 +2,10 @@ import React, { useContext } from 'react';
import { AppContext } from 'src/context';
import { ReceiveModal } from './ReceiveModal';
export const Receive = () => {
export const Receive = ({ hasStorybookStyles }: { hasStorybookStyles?: {} }) => {
const { showReceiveModal, handleShowReceiveModal } = useContext(AppContext);
if (showReceiveModal) return <ReceiveModal onClose={handleShowReceiveModal} />;
if (showReceiveModal) return <ReceiveModal onClose={handleShowReceiveModal} {...hasStorybookStyles} />;
return null;
};
@@ -0,0 +1,139 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Button, Paper } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { RedeemModal } from './RedeemModal';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
export default {
title: 'Rewards/Components/Redeem Modals',
component: RedeemModal,
} as ComponentMeta<typeof RedeemModal>;
const Content: FCWithChildren<{
setOpen: (value: boolean) => void;
}> = ({ setOpen }) => (
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Lorem ipsum</h2>
<Button variant="contained" onClick={() => setOpen(true)}>
Show modal
</Button>
<p>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis sunt
velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur culpa
exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
</p>
<p>
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eiusmod occaecat aliquip ad
tempor. Labore amet laborum magna amet consequat dolor cupidatat in consequat sunt aliquip magna laboris tempor
culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna elit ut
mollit.
</p>
<p>
Labore voluptate elit amet ipsum qui officia duis in et occaecat culpa ex do non labore mollit. Cillum cupidatat
duis ea dolore laboris laboris sunt duis anim consectetur cupidatat nulla ad minim sunt ea. Aliqua amet commodo
est irure sint magna sunt. Pariatur dolore commodo labore quis incididunt proident duis voluptate exercitation in
duis. Occaecat aliqua laboris reprehenderit nostrud est aute pariatur fugiat anim. Dolore sunt cillum ea aliquip
consectetur laborum ipsum qui veniam Lorem consectetur adipisicing velit magna aute. Amet tempor quis excepteur
minim culpa velit Lorem enim ad.
</p>
<p>
Mollit laborum exercitation excepteur laboris adipisicing ipsum veniam cillum mollit voluptate do. Amet et anim
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt nisi nisi non excepteur dolor eiusmod aute aute
irure anim dolore ipsum et veniam.
</p>
</Paper>
);
export const RedeemAllRewards = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Content setOpen={setOpen} />
<RedeemModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
message="Redeem all rewards"
denom="nym"
mixId={1234}
identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa"
amount={425.65843}
{...storybookStyles(theme)}
usesVestingTokens={false}
/>
</>
);
};
export const RedeemRewardForMixnode = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Content setOpen={setOpen} />
<RedeemModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
message="Claim rewards"
denom="nym"
mixId={1234}
identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa"
amount={425.65843}
{...storybookStyles(theme)}
usesVestingTokens={false}
/>
</>
);
};
export const FeeIsMoreThanAllRewards = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Content setOpen={setOpen} />
<RedeemModal
open={open}
onClose={() => setOpen(false)}
onOk={() => setOpen(false)}
message="Redeem all rewards"
denom="nym"
mixId={1234}
identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa"
amount={0.001}
{...storybookStyles(theme)}
usesVestingTokens={false}
/>
</>
);
};
export const FeeIsMoreThanMixnodeReward = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Content setOpen={setOpen} />
<RedeemModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
mixId={1234}
identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa"
message="Claim rewards"
denom="nym"
amount={0.001}
{...storybookStyles(theme)}
usesVestingTokens={false}
/>
</>
);
};
@@ -0,0 +1,28 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Paper } from '@mui/material';
import { RewardsSummary } from './RewardsSummary';
export default {
title: 'Rewards/Components/Rewards Summary',
component: RewardsSummary,
} as ComponentMeta<typeof RewardsSummary>;
export const Default = () => (
<Paper elevation={0} sx={{ px: 4, py: 2 }}>
<RewardsSummary totalDelegation="860.123 NYM" totalRewards="4.86723 NYM" />
</Paper>
);
export const Empty = () => (
<Paper elevation={0} sx={{ px: 4, py: 2 }}>
<RewardsSummary />
</Paper>
);
export const Loading = () => (
<Paper elevation={0} sx={{ px: 4, py: 2 }}>
<RewardsSummary isLoading />
</Paper>
);
@@ -0,0 +1,19 @@
import React from 'react';
import { Typography } from '@mui/material';
import { SelectionChance } from '@nymproject/types';
const colorMap: { [key in SelectionChance]: string } = {
Low: 'error.main',
Good: 'warning.main',
High: 'success.main',
};
const textMap: { [key in SelectionChance]: string } = {
Low: 'Low',
Good: 'Good',
High: 'High',
};
export const InclusionProbability = ({ probability }: { probability: SelectionChance }) => (
<Typography sx={{ color: colorMap[probability] }}>{textMap[probability]}</Typography>
);
@@ -0,0 +1,91 @@
import React, { useCallback } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import { Button, Grid, TextField, Typography } from '@mui/material';
import { useForm } from 'react-hook-form';
import { DefaultInputValues } from 'src/pages/bonding/node-settings/apy-playground';
import { inputValidationSchema } from './inputsValidationSchema';
export type InputFields = {
label: string;
name: 'profitMargin' | 'uptime' | 'bond' | 'delegations' | 'operatorCost' | 'uptime';
isPercentage?: boolean;
}[];
export type CalculateArgs = {
bond: string;
delegations: string;
uptime: string;
profitMargin: string;
operatorCost: string;
};
const inputFields: InputFields = [
{ label: 'Profit margin', name: 'profitMargin', isPercentage: true },
{ label: 'Operator cost', name: 'operatorCost' },
{ label: 'Bond', name: 'bond' },
{ label: 'Delegations', name: 'delegations' },
{ label: 'Uptime', name: 'uptime', isPercentage: true },
];
export const Inputs = ({
onCalculate,
defaultValues,
}: {
onCalculate: (args: CalculateArgs) => Promise<void>;
defaultValues: DefaultInputValues;
}) => {
const handleCalculate = useCallback(
async (args: CalculateArgs) => {
onCalculate({
bond: args.bond,
delegations: args.delegations,
uptime: args.uptime,
profitMargin: args.profitMargin,
operatorCost: args.operatorCost,
});
},
[onCalculate],
);
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: yupResolver(inputValidationSchema),
defaultValues,
});
return (
<Grid container spacing={3}>
{inputFields.map((field) => (
<Grid item xs={12} lg={2} key={field.name}>
<TextField
{...register(field.name)}
fullWidth
label={field.label}
name={field.name}
error={Boolean(errors[field.name])}
helperText={errors[field.name]?.message}
InputProps={{
endAdornment: <Typography sx={{ color: 'grey.600' }}>{field.isPercentage ? '%' : 'NYM'}</Typography>,
}}
InputLabelProps={{ shrink: true }}
/>
</Grid>
))}{' '}
<Grid item xs={12} lg={2}>
<Button
variant="contained"
disableElevation
onClick={handleSubmit(handleCalculate)}
size="large"
fullWidth
disabled={Boolean(Object.keys(errors).length)}
>
Calculate
</Button>
</Grid>
</Grid>
);
};
@@ -0,0 +1,32 @@
import React from 'react';
import { Card, CardContent, Divider, Stack, Typography } from '@mui/material';
import { SelectionChance } from '@nymproject/types';
import { InclusionProbability } from './InclusionProbability';
const computeSelectionProbability = (saturation: number): SelectionChance => {
if (saturation < 5) return 'Low';
if (saturation > 5 && saturation < 15) return 'Good';
return 'High';
};
export const NodeDetails = ({ saturation }: { saturation?: string }) => {
if (!saturation) return null;
return (
<Card variant="outlined" sx={{ p: 1 }}>
<CardContent>
<Stack direction="row" justifyContent="space-between">
<Typography fontWeight="medium">Stake saturation</Typography>
<Typography>{saturation || '- '}%</Typography>
</Stack>
<Divider sx={{ my: 1 }} />
<Stack direction="row" justifyContent="space-between">
<Typography fontWeight="medium">Selection probability</Typography>
<InclusionProbability probability={computeSelectionProbability(parseInt(saturation, 10))} />
</Stack>
</CardContent>
</Card>
);
};
@@ -0,0 +1,75 @@
import React from 'react';
import {
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@mui/material';
export type Results = {
operator: {
daily: string;
monthly: string;
yearly: string;
};
delegator: {
daily: string;
monthly: string;
yearly: string;
};
total: {
daily: string;
monthly: string;
yearly: string;
};
};
const tableHeader = [
{ title: 'Estimated rewards', bold: true },
{ title: 'Per day' },
{ title: 'Per month' },
{ title: 'Per year' },
];
export const ResultsTable = ({ results }: { results: Results }) => {
const tableRows = [
{ title: 'Total node reward', ...results.total },
{ title: 'Operator rewards', ...results.operator },
{ title: 'Delegator rewards', ...results.delegator },
];
return (
<Card variant="outlined" sx={{ p: 1 }}>
<CardContent>
<TableContainer>
<Table>
<TableHead>
<TableRow>
{tableHeader.map((header) => (
<TableCell>
<Typography fontWeight={header.bold ? 'bold' : 'regular'}>{header.title}</Typography>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{tableRows.map((row) => (
<TableRow>
<TableCell>{row.title}</TableCell>
<TableCell>{row.daily}</TableCell>
<TableCell>{row.monthly}</TableCell>
<TableCell>{row.yearly}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
);
};
@@ -0,0 +1,43 @@
import * as Yup from 'yup';
import { isGreaterThan, isLessThan } from 'src/utils';
export const inputValidationSchema = Yup.object().shape({
profitMargin: Yup.string()
.required('profit margin is a required field')
.test('Is valid profit margin value', (value, ctx) => {
const stringValueToNumber = Math.round(Number(value));
if (isGreaterThan(stringValueToNumber, -1) && isLessThan(stringValueToNumber, 101)) return true;
return ctx.createError({ message: 'Profit margin must be a number from 0 and 100' });
}),
uptime: Yup.string()
.required()
.test('Is valid uptime value', (value, ctx) => {
const stringValueToNumber = Math.round(Number(value));
if (stringValueToNumber && isGreaterThan(stringValueToNumber, 0) && isLessThan(stringValueToNumber, 101))
return true;
return ctx.createError({ message: 'Uptime must be a number between 0 and 100' });
}),
bond: Yup.string()
.required()
.test('Is valid bond value', (value, ctx) => {
if (Number(value)) return true;
return ctx.createError({ message: 'Bond must be a valid number' });
}),
delegations: Yup.string()
.required()
.test('Is valid delegation value', (value, ctx) => {
if (Number(value)) return true;
return ctx.createError({ message: 'Delegations must be a valid number' });
}),
operatorCost: Yup.string()
.required('operator cost is a required field')
.test('Is valid operator cost value', (value, ctx) => {
const stringValueToNumber = Math.round(Number(value));
if (isLessThan(stringValueToNumber, 0))
return ctx.createError({ message: 'Operator cost must be a valid number' });
return true;
}),
});
@@ -0,0 +1,86 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { useTheme, Theme } from '@mui/material/styles';
import { MockMainContextProvider } from 'src/context/mocks/main';
import { SendDetailsModal } from './SendDetailsModal';
import { SendSuccessModal } from './SendSuccessModal';
import { SendErrorModal } from './SendErrorModal';
import { SendInputModal } from './SendInputModal';
import { Send } from '.';
import { backDropStyles, modalStyles, dialogStyles } from '../../../.storybook/storiesStyles';
const storybookStylesModal = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
const storybookStylesDialog = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: dialogStyles(theme),
});
export default {
title: 'Send/Components',
component: SendDetailsModal,
} as ComponentMeta<typeof SendDetailsModal>;
export const SendInput = () => {
const theme = useTheme();
return (
<SendInputModal
toAddress=""
fromAddress="nymt1w8qp7zsxggvtxhpqpt6e329j42wtv07dm5ts8u"
denom="nym"
onNext={() => {}}
onClose={() => {}}
onAddressChange={() => {}}
onAmountChange={() => {}}
onUserFeesChange={() => {}}
onMemoChange={() => {}}
setShowMore={() => {}}
{...storybookStylesModal(theme)}
/>
);
};
export const SendDetails = () => {
const theme = useTheme();
return (
<SendDetailsModal
fromAddress="nymt1w8qp7zsxggvtxhpqpt6e329j42wtv07dm5ts8u"
toAddress="nymt1w8qp7zsxggvtxhpqpt6e329j42wtv07dm5ts8u"
fee={{ amount: { amount: '0.01', denom: 'nym' }, fee: { Auto: null } }}
denom="nym"
amount={{ amount: '100', denom: 'nym' }}
onPrev={() => {}}
onSend={() => {}}
onClose={() => {}}
{...storybookStylesModal(theme)}
/>
);
};
export const SendSuccess = () => {
const theme = useTheme();
return (
<SendSuccessModal
txDetails={{ amount: '100 NYM', txUrl: 'dummtUrl.com' }}
onClose={() => {}}
{...storybookStylesDialog(theme)}
/>
);
};
export const SendError = () => {
const theme = useTheme();
return <SendErrorModal onClose={() => {}} {...storybookStylesModal(theme)} />;
};
export const SendFlow = () => {
const theme = useTheme();
return (
<MockMainContextProvider>
<Send hasStorybookStyles={{ backdropProps: { ...backDropStyles(theme) }, sx: modalStyles(theme) }} />
</MockMainContextProvider>
);
};
+3 -1
View File
@@ -12,7 +12,7 @@ import { SendInputModal } from './SendInputModal';
import { SendSuccessModal } from './SendSuccessModal';
import { TTransactionDetails } from './types';
export const SendModal = ({ onClose }: { onClose: () => void }) => {
export const SendModal = ({ onClose, hasStorybookStyles }: { onClose: () => void; hasStorybookStyles?: {} }) => {
const [toAddress, setToAddress] = useState<string>('');
const [amount, setAmount] = useState<DecCoin>();
const [modal, setModal] = useState<'send' | 'send details'>('send');
@@ -108,6 +108,7 @@ export const SendModal = ({ onClose }: { onClose: () => void }) => {
onSend={handleSend}
denom={clientDetails?.display_mix_denom || 'nym'}
memo={memo}
{...hasStorybookStyles}
/>
);
@@ -129,6 +130,7 @@ export const SendModal = ({ onClose }: { onClose: () => void }) => {
onUserFeesChange={(value) => setUserFees(value)}
onMemoChange={(value) => setMemo(value)}
setShowMore={setShowMoreOptions}
{...hasStorybookStyles}
/>
);
};
+2 -2
View File
@@ -2,10 +2,10 @@ import React, { useContext } from 'react';
import { AppContext } from 'src/context';
import { SendModal } from './SendModal';
export const Send = () => {
export const Send = ({ hasStorybookStyles }: { hasStorybookStyles?: {} }) => {
const { showSendModal, handleShowSendModal } = useContext(AppContext);
if (showSendModal) return <SendModal onClose={handleShowSendModal} />;
if (showSendModal) return <SendModal onClose={handleShowSendModal} hasStorybookStyles={hasStorybookStyles} />;
return null;
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Box } from '@mui/material';
import { TokenPoolSelector } from './TokenPoolSelector';
import { MockMainContextProvider } from '../context/mocks/main';
export default {
title: 'Wallet / Token pool',
component: TokenPoolSelector,
} as ComponentMeta<typeof TokenPoolSelector>;
const Template: ComponentStory<typeof TokenPoolSelector> = (args) => (
<Box mt={2} height={800}>
<MockMainContextProvider>
<TokenPoolSelector {...args} />
</MockMainContextProvider>
</Box>
);
export const Default = Template.bind({});
Default.args = {
disabled: false,
onSelect: () => {},
};
+19 -1
View File
@@ -118,7 +118,25 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => {
};
};
const updateDelegation = async (newDelegation: DelegationWithEverything): Promise<TDelegationTransaction> => {
const updateDelegation = async (
newDelegation: DelegationWithEverything,
ignorePendingForStorybook?: boolean,
): Promise<TDelegationTransaction> => {
if (ignorePendingForStorybook) {
mockDelegations = mockDelegations.map((d) => {
if (d.node_identity === newDelegation.node_identity) {
return { ...newDelegation };
}
return d;
});
await recalculate();
triggerStateUpdate();
return {
transactionUrl:
'https://sandbox-blocks.nymtech.net/transactions/55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354',
};
}
await mockSleep(SLEEP_MS);
mockDelegations = mockDelegations.map((d) => {
if (d.node_identity === newDelegation.node_identity) {
@@ -0,0 +1,61 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Stack, TextField } from '@mui/material';
import { PasswordStrength } from './password-strength';
export default {
title: 'Wallet / Password Strength',
component: PasswordStrength,
} as ComponentMeta<typeof PasswordStrength>;
const Template: ComponentStory<typeof PasswordStrength> = ({ password, withWarnings, handleIsSafePassword }) => {
const [value, setValue] = React.useState(password);
return (
<Stack alignContent="center">
<TextField value={value} onChange={(e) => setValue(e.target.value)} sx={{ mb: 0.5 }} />
{!!password.length && (
<PasswordStrength handleIsSafePassword={handleIsSafePassword} withWarnings={withWarnings} password={password} />
)}
</Stack>
);
};
export const VeryStrong = Template.bind({});
VeryStrong.args = { password: 'fedgklnrf34£', withWarnings: true, handleIsSafePassword: () => undefined };
export const Strong = Template.bind({});
Strong.args = { password: '"56%abc123?@', withWarnings: true, handleIsSafePassword: () => undefined };
export const Average = Template.bind({});
Average.args = { password: '"abc123?', withWarnings: true, handleIsSafePassword: () => undefined };
export const Weak = Template.bind({});
Weak.args = { password: 'abc123?', withWarnings: true, handleIsSafePassword: () => undefined };
export const VeryWeak = Template.bind({});
VeryWeak.args = {
password: 'abc123',
withWarnings: true,
handleIsSafePassword: () => undefined,
};
export const WithName = Template.bind({});
WithName.args = {
password: 'fred',
withWarnings: true,
handleIsSafePassword: () => undefined,
};
export const WithSequence = Template.bind({});
WithSequence.args = {
password: '121212',
withWarnings: true,
handleIsSafePassword: () => undefined,
};
export const Default = Template.bind({});
Default.args = {
password: 'abc123',
withWarnings: true,
handleIsSafePassword: () => undefined,
};
@@ -0,0 +1,13 @@
import * as React from 'react';
import { BondingPage } from './Bonding';
import { MockBondingContextProvider } from '../../context/mocks/bonding';
export default {
title: 'Bonding/Flows/Mock',
};
export const Default = () => (
<MockBondingContextProvider>
<BondingPage />
</MockBondingContextProvider>
);
@@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { Box, Card, CardContent, CardHeader, Grid, Typography } from '@mui/material';
import { ResultsTable } from 'src/components/RewardsPlayground/ResultsTable';
import { getDelegationSummary } from 'src/requests';
import { NodeDetails } from 'src/components/RewardsPlayground/NodeDetail';
import { CalculateArgs, Inputs } from 'src/components/RewardsPlayground/Inputs';
import { useSnackbar } from 'notistack';
import { LoadingModal } from 'src/components/Modals/LoadingModal';
import { Console } from 'src/utils/console';
import { TBondedMixnode } from 'src/requests/mixnodeDetails';
import { computeEstimate, computeStakeSaturation, handleCalculatePeriodRewards } from './utils';
export type DefaultInputValues = {
profitMargin: string;
uptime: string;
bond: string;
delegations: string;
operatorCost: string;
};
export const ApyPlayground = ({ bondedNode }: { bondedNode: TBondedMixnode }) => {
const { enqueueSnackbar } = useSnackbar();
const [results, setResults] = useState({
total: { daily: '-', monthly: '-', yearly: '-' },
operator: { daily: '-', monthly: '-', yearly: '-' },
delegator: { daily: '-', monthly: '-', yearly: '-' },
});
const [defaultInputValues, setDefaultInputValues] = useState<DefaultInputValues>();
const [stakeSaturation, setStakeSaturation] = useState<string>();
const [isLoading, setIsLoading] = useState(true);
const initialise = async (node: TBondedMixnode) => {
try {
const delegations = await getDelegationSummary();
const { estimation } = await computeEstimate({
mixId: node.mixId,
uptime: node.uptime.toString(),
profitMargin: node.profitMargin,
operatorCost: node.operatorCost.amount,
totalDelegation: delegations.total_delegations.amount,
pledgeAmount: node.bond.amount,
});
setResults(
handleCalculatePeriodRewards({
estimatedOperatorReward: estimation.operator,
estimatedDelegatorsReward: estimation.delegates,
}),
);
setStakeSaturation(node.stakeSaturation);
setDefaultInputValues({
profitMargin: node.profitMargin,
uptime: (node.uptime || 0).toString(),
bond: node.bond.amount || '',
delegations: delegations.total_delegations.amount,
operatorCost: node.operatorCost.amount,
});
setIsLoading(false);
} catch (e) {
enqueueSnackbar(e as string, { variant: 'error' });
}
};
useEffect(() => {
if (bondedNode) {
initialise(bondedNode);
}
}, []);
if (isLoading) return <LoadingModal />;
const handleCalculateEstimate = async ({ bond, delegations, uptime, profitMargin, operatorCost }: CalculateArgs) => {
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { estimation, reward_params } = await computeEstimate({
mixId: bondedNode.mixId,
uptime,
profitMargin,
operatorCost,
totalDelegation: delegations,
pledgeAmount: bond,
});
const estimationResult = handleCalculatePeriodRewards({
estimatedOperatorReward: estimation.operator,
estimatedDelegatorsReward: estimation.delegates,
});
const computedStakeSaturation = computeStakeSaturation(
bond,
delegations,
reward_params.interval.stake_saturation_point,
);
setStakeSaturation(computedStakeSaturation);
setResults(estimationResult);
} catch (e) {
Console.log(e);
}
};
return (
<Box sx={{ p: 3 }}>
<Typography fontWeight="medium" sx={{ mb: 1 }}>
Playground
</Typography>
<Typography variant="body2" sx={{ color: 'grey.600', mb: 2 }}>
This is your parameters playground - change the parameters below to see the node specific estimations in the
table
</Typography>
{defaultInputValues && (
<Card variant="outlined" sx={{ p: 1, mb: 3 }}>
<CardHeader
title={
<Typography variant="body2" fontWeight="medium">
Estimation calculator
</Typography>
}
/>
<CardContent>
<Inputs onCalculate={handleCalculateEstimate} defaultValues={defaultInputValues} />
</CardContent>
</Card>
)}
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<ResultsTable results={results} />
</Grid>
<Grid item xs={12} md={4}>
<NodeDetails saturation={stakeSaturation} />
</Grid>
</Grid>
</Box>
);
};
@@ -0,0 +1,66 @@
import { decimalToPercentage, percentToDecimal } from '@nymproject/types';
import { computeMixnodeRewardEstimation } from 'src/requests';
const SCALE_FACTOR = 1_000_000;
export const computeStakeSaturation = (bond: string, delegations: string, stakeSaturationPoint: string) => {
const res = ((+bond + +delegations) * SCALE_FACTOR) / +stakeSaturationPoint;
return decimalToPercentage(res.toFixed(18).toString());
};
export const computeEstimate = async ({
mixId,
uptime,
pledgeAmount,
totalDelegation,
profitMargin,
operatorCost,
}: {
mixId: number;
uptime: string;
pledgeAmount: string;
totalDelegation: string;
profitMargin: string;
operatorCost: string;
}) => {
const computedEstimate = await computeMixnodeRewardEstimation({
mixId,
performance: percentToDecimal(uptime),
pledgeAmount: Math.round(+pledgeAmount * SCALE_FACTOR),
totalDelegation: Math.round(+totalDelegation * SCALE_FACTOR),
profitMarginPercent: percentToDecimal(profitMargin),
intervalOperatingCost: { denom: 'unym', amount: Math.round(+operatorCost * SCALE_FACTOR).toString() },
});
return computedEstimate;
};
export const handleCalculatePeriodRewards = ({
estimatedOperatorReward,
estimatedDelegatorsReward,
}: {
estimatedOperatorReward: string;
estimatedDelegatorsReward: string;
}) => {
const dailyOperatorReward = (+estimatedOperatorReward / SCALE_FACTOR) * 24; // epoch_reward * 1 epoch_per_hour * 24 hours
const dailyDelegatorReward = (+estimatedDelegatorsReward / SCALE_FACTOR) * 24;
const dailyTotal = dailyOperatorReward + dailyDelegatorReward;
return {
total: {
daily: dailyTotal.toFixed(3).toString(),
monthly: (dailyTotal * 30).toFixed(3).toString(),
yearly: (dailyTotal * 365).toFixed(3).toString(),
},
operator: {
daily: dailyOperatorReward.toFixed(3).toString(),
monthly: (dailyOperatorReward * 30).toFixed(3).toString(),
yearly: (dailyOperatorReward * 365).toFixed(3).toString(),
},
delegator: {
daily: dailyDelegatorReward.toFixed(3).toString(),
monthly: (dailyDelegatorReward * 30).toFixed(3).toString(),
yearly: (dailyDelegatorReward * 365).toFixed(3).toString(),
},
};
};
@@ -8,7 +8,7 @@ import { LoadingModal } from 'src/components/Modals/LoadingModal';
import { Results } from 'src/components/TestNode/Results';
import { ErrorModal } from 'src/components/Modals/ErrorModal';
import { PrintResults } from 'src/components/TestNode/PrintResults';
import { MAINNET_VALIDATOR_URL } from 'src/constants';
import { MAINNET_VALIDATOR_URL, QA_VALIDATOR_URL } from 'src/constants';
import { TestStatus } from 'src/components/TestNode/types';
import { isMixnode } from 'src/types';
@@ -63,7 +63,7 @@ export const NodeTestPage = () => {
const loadNodeTestClient = useCallback(async () => {
try {
const nodeTesterId = new Date().toISOString(); // make a new tester id for each session
const validator = network === 'MAINNET' ? MAINNET_VALIDATOR_URL : 'https://rpc.nymtech.net/api/';
const validator = network === 'MAINNET' ? MAINNET_VALIDATOR_URL : QA_VALIDATOR_URL;
const client = await createNodeTesterClient();
await client.tester.init(validator, nodeTesterId);
setNodeTestClient(client);
@@ -0,0 +1,10 @@
import React from 'react';
import { Tutorial } from '../../components/Buy/Tutorial';
export default {
title: 'Buy/Page',
component: Tutorial,
};
export const BuyPage = () => <Tutorial />;
@@ -0,0 +1,19 @@
import * as React from 'react';
import { DelegationPage } from './index';
import { MockDelegationContextProvider } from '../../context/mocks/delegations';
import { MockRewardsContextProvider } from '../../context/mocks/rewards';
import { MockMainContextProvider } from '../../context/mocks/main';
export default {
title: 'Delegation/Flows/Mock',
};
export const Default = () => (
<MockMainContextProvider>
<MockDelegationContextProvider>
<MockRewardsContextProvider>
<DelegationPage isStorybook />
</MockRewardsContextProvider>
</MockDelegationContextProvider>
</MockMainContextProvider>
);
+13 -3
View File
@@ -19,9 +19,18 @@ import { UndelegateModal } from '../../components/Delegation/UndelegateModal';
import { DelegationListItemActions } from '../../components/Delegation/DelegationActions';
import { RedeemModal } from '../../components/Rewards/RedeemModal';
import { DelegationModal, DelegationModalProps } from '../../components/Delegation/DelegationModal';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
import { VestingWarningModal } from '../../components/VestingWarningModal';
export const Delegation: FC = () => {
const storybookStyles = (theme: Theme, isStorybook?: boolean, backdropProps?: object) =>
isStorybook
? {
backdropProps: { ...backDropStyles(theme), ...backdropProps },
sx: modalStyles(theme),
}
: {};
export const Delegation: FC<{ isStorybook?: boolean }> = ({ isStorybook }) => {
const [showNewDelegationModal, setShowNewDelegationModal] = useState<boolean>(false);
const [showDelegateMoreModal, setShowDelegateMoreModal] = useState<boolean>(false);
const [showUndelegateModal, setShowUndelegateModal] = useState<boolean>(false);
@@ -463,6 +472,7 @@ export const Delegation: FC = () => {
accountBalance={balance?.printable_balance}
rewardInterval="weekly"
hasVestingContract={Boolean(originalVesting)}
{...storybookStyles(theme, isStorybook)}
/>
)}
@@ -538,10 +548,10 @@ export const Delegation: FC = () => {
);
};
export const DelegationPage: FC = () => (
export const DelegationPage: FC<{ isStorybook?: boolean }> = ({ isStorybook }) => (
<DelegationContextProvider>
<RewardsContextProvider>
<Delegation />
<Delegation isStorybook={isStorybook} />
</RewardsContextProvider>
</DelegationContextProvider>
);
@@ -0,0 +1,7 @@
import { Meta } from '@storybook/addon-docs';
<Meta title="Introduction" />
# Nym Wallet Storybook
This is the Storybook for the Nym Wallet.
@@ -0,0 +1,10 @@
import * as React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Playground } from '@nymproject/react/playground/Playground';
export default {
title: 'Playground',
component: Playground,
} as ComponentMeta<typeof Playground>;
export const AllControls = () => <Playground />;
+3 -1
View File
@@ -5,11 +5,13 @@
"noEmit": true
},
"include": [
".storybook/*.js",
"webpack.*.js",
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
"src/**/*.tsx",
"src/**/*.stories.*",
],
"exclude": [
"node_modules",