iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit b74a4423e2270382673df8aa4fa3d7683e6d0a33
parent 77338fe88113c5f703c1fe6cabe51dfce10dccbe
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 30 Jul 2026 09:57:16 +0200

Add optional blockchain metrics tracking

Diffstat:
Msrc/adapters/chain_store.rs | 365++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/adapters/config_store.rs | 10++++++++++
Msrc/adapters/http.rs | 397++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/domain.rs | 4++++
Msrc/main.rs | 37+++++++++++++++++++++++++++++--------
Mwww/assets/iuna-ui.js | 273++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 1070 insertions(+), 16 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -7,7 +7,9 @@ use std::{ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension, params}; -use crate::domain::ChainSnapshot; +use serde::Serialize; + +use crate::domain::{Amount, ChainSnapshot, Ledger, Transaction}; const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS chain_snapshots ( @@ -17,8 +19,48 @@ CREATE TABLE IF NOT EXISTS chain_snapshots ( snapshot_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS block_metrics ( + height INTEGER PRIMARY KEY, + block_hash TEXT NOT NULL, + timestamp_ms INTEGER NOT NULL, + block_time_ms INTEGER, + mine_difficulty_bits INTEGER NOT NULL, + circulating_supply INTEGER NOT NULL, + transaction_count INTEGER NOT NULL, + transfer_count INTEGER NOT NULL, + burn_count INTEGER NOT NULL, + mine_count INTEGER NOT NULL, + burned_amount INTEGER NOT NULL, + total_burned_amount INTEGER NOT NULL, + fees_amount INTEGER NOT NULL, + reward_amount INTEGER NOT NULL, + vdf_rounds INTEGER NOT NULL, + finalizer_rank INTEGER NOT NULL +); "#; +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockMetricRow { + pub height: u64, + pub block_hash: String, + pub timestamp_ms: u64, + pub block_time_ms: Option<u64>, + pub mine_difficulty_bits: u32, + pub circulating_supply: Amount, + pub transaction_count: u64, + pub transfer_count: u64, + pub burn_count: u64, + pub mine_count: u64, + pub burned_amount: Amount, + pub total_burned_amount: Amount, + pub fees_amount: Amount, + pub reward_amount: Amount, + pub vdf_rounds: u32, + pub finalizer_rank: u32, +} + #[derive(Clone, Debug)] pub struct SqliteChainStore { path: PathBuf, @@ -70,13 +112,25 @@ impl SqliteChainStore { } pub fn save(&self, snapshot: &ChainSnapshot) -> Result<()> { + self.save_with_metrics(snapshot, false) + } + + pub fn save_with_metrics(&self, snapshot: &ChainSnapshot, keep_metrics: bool) -> Result<()> { let (height, tip_hash) = snapshot_tip(snapshot).context("cannot persist empty chain")?; let snapshot_json = serde_json::to_string(snapshot).context("failed to serialize chain snapshot")?; let updated_at_ms = unix_ms(); + let metrics = if keep_metrics { + Some(metrics_from_snapshot(snapshot)?) + } else { + None + }; - self.with_connection(|connection| { - connection + self.with_connection_mut(|connection| { + let transaction = connection + .transaction() + .context("failed to start chain persistence transaction")?; + transaction .execute( r#" INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms) @@ -90,10 +144,81 @@ ON CONFLICT(id) DO UPDATE SET params![height, tip_hash, snapshot_json, updated_at_ms], ) .context("failed to persist chain snapshot")?; + match metrics { + Some(metrics) => replace_metrics(&transaction, &metrics)?, + None => clear_metrics_in_transaction(&transaction)?, + } + transaction + .commit() + .context("failed to commit chain persistence transaction")?; + Ok(()) + }) + } + + pub fn replace_metrics_for_snapshot(&self, snapshot: &ChainSnapshot) -> Result<()> { + let metrics = metrics_from_snapshot(snapshot)?; + self.with_connection_mut(|connection| { + let transaction = connection + .transaction() + .context("failed to start metrics transaction")?; + replace_metrics(&transaction, &metrics)?; + transaction + .commit() + .context("failed to commit metrics transaction")?; Ok(()) }) } + pub fn clear_metrics(&self) -> Result<()> { + self.with_connection(|connection| { + connection + .execute("DELETE FROM block_metrics", []) + .context("failed to delete block metrics")?; + Ok(()) + }) + } + + pub fn load_metrics(&self) -> Result<Vec<BlockMetricRow>> { + self.with_connection(|connection| { + let mut statement = connection + .prepare( + r#" +SELECT height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits, + circulating_supply, transaction_count, transfer_count, burn_count, + mine_count, burned_amount, total_burned_amount, fees_amount, reward_amount, + vdf_rounds, finalizer_rank +FROM block_metrics +ORDER BY height ASC +"#, + ) + .context("failed to prepare block metrics query")?; + let rows = statement + .query_map([], |row| { + Ok(BlockMetricRow { + height: row.get(0)?, + block_hash: row.get(1)?, + timestamp_ms: row.get(2)?, + block_time_ms: row.get(3)?, + mine_difficulty_bits: row.get(4)?, + circulating_supply: row.get(5)?, + transaction_count: row.get(6)?, + transfer_count: row.get(7)?, + burn_count: row.get(8)?, + mine_count: row.get(9)?, + burned_amount: row.get(10)?, + total_burned_amount: row.get(11)?, + fees_amount: row.get(12)?, + reward_amount: row.get(13)?, + vdf_rounds: row.get(14)?, + finalizer_rank: row.get(15)?, + }) + }) + .context("failed to load block metrics")?; + rows.collect::<std::result::Result<Vec<_>, _>>() + .context("failed to read block metrics rows") + }) + } + fn with_connection<T>(&self, work: impl FnOnce(&Connection) -> Result<T>) -> Result<T> { let connection = Connection::open(&self.path) .with_context(|| format!("failed to open chain database {}", self.path.display()))?; @@ -107,6 +232,171 @@ PRAGMA synchronous = NORMAL; .context("failed to configure chain database")?; work(&connection) } + + fn with_connection_mut<T>(&self, work: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> { + let mut connection = Connection::open(&self.path) + .with_context(|| format!("failed to open chain database {}", self.path.display()))?; + connection + .execute_batch( + r#" +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +"#, + ) + .context("failed to configure chain database")?; + work(&mut connection) + } +} + +fn replace_metrics( + transaction: &rusqlite::Transaction<'_>, + metrics: &[BlockMetricRow], +) -> Result<()> { + clear_metrics_in_transaction(transaction)?; + for metric in metrics { + transaction + .execute( + r#" +INSERT INTO block_metrics ( + height, block_hash, timestamp_ms, block_time_ms, mine_difficulty_bits, + circulating_supply, transaction_count, transfer_count, burn_count, mine_count, + burned_amount, total_burned_amount, fees_amount, reward_amount, vdf_rounds, + finalizer_rank +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) +"#, + params![ + metric.height, + metric.block_hash, + metric.timestamp_ms, + metric.block_time_ms, + metric.mine_difficulty_bits, + metric.circulating_supply, + metric.transaction_count, + metric.transfer_count, + metric.burn_count, + metric.mine_count, + metric.burned_amount, + metric.total_burned_amount, + metric.fees_amount, + metric.reward_amount, + metric.vdf_rounds, + metric.finalizer_rank, + ], + ) + .with_context(|| format!("failed to insert metrics for block {}", metric.height))?; + } + Ok(()) +} + +fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Result<()> { + transaction + .execute("DELETE FROM block_metrics", []) + .context("failed to clear old block metrics")?; + Ok(()) +} + +fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>> { + let ledger = Ledger::from_persisted_snapshot(snapshot.clone()) + .context("failed to rebuild ledger for metrics")?; + let mut circulating_supply = + snapshot + .genesis_allocations + .values() + .try_fold(0_u64, |total, amount| { + total + .checked_add(*amount) + .context("genesis allocation total overflows") + })?; + let mut total_burned_amount = 0_u64; + let mut rows = Vec::with_capacity(snapshot.blocks.len()); + let mut previous_timestamp_ms = None; + + for block in &snapshot.blocks { + let mut transfer_count = 0_u64; + let mut burn_count = 0_u64; + let mut mine_count = 0_u64; + let mut burned_amount = 0_u64; + let mut mine_issued_amount = 0_u64; + let mut fees_amount = 0_u64; + + for transaction in &block.transactions { + fees_amount = fees_amount + .checked_add(transaction.fee()) + .context("block metric fees overflow")?; + match transaction { + Transaction::Transfer { .. } => transfer_count += 1, + Transaction::Burn { amount, .. } => { + burn_count += 1; + burned_amount = burned_amount + .checked_add(*amount) + .context("block metric burns overflow")?; + } + Transaction::Mine { output, fee, .. } => { + mine_count += 1; + mine_issued_amount = mine_issued_amount + .checked_add(output.amount) + .and_then(|amount| amount.checked_add(*fee)) + .context("block metric mine issuance overflow")?; + } + } + } + + total_burned_amount = total_burned_amount + .checked_add(burned_amount) + .context("total burned metric overflows")?; + + let issued_amount = block_issued_amount(block.height, block.reward, mine_issued_amount) + .with_context(|| { + format!("block metric issuance overflows at block {}", block.height) + })?; + circulating_supply = circulating_supply + .checked_add(issued_amount) + .with_context(|| { + format!( + "circulating supply metric overflows while adding issuance at block {}", + block.height + ) + })?; + circulating_supply = circulating_supply + .checked_sub(burned_amount) + .with_context(|| { + format!( + "circulating supply metric underflows while subtracting burns at block {}", + block.height + ) + })?; + let block_time_ms = + previous_timestamp_ms.map(|previous| block.timestamp_ms.saturating_sub(previous)); + previous_timestamp_ms = Some(block.timestamp_ms); + rows.push(BlockMetricRow { + height: block.height, + block_hash: block.hash.clone(), + timestamp_ms: block.timestamp_ms, + block_time_ms, + mine_difficulty_bits: ledger.mine_difficulty_bits_at_height(block.height), + circulating_supply, + transaction_count: block.transactions.len() as u64, + transfer_count, + burn_count, + mine_count, + burned_amount, + total_burned_amount, + fees_amount, + reward_amount: block.reward, + vdf_rounds: block.vdf_rounds, + finalizer_rank: block.finalizer_rank, + }); + } + Ok(rows) +} + +fn block_issued_amount( + height: u64, + reward_amount: Amount, + mine_issued_amount: Amount, +) -> Option<Amount> { + let genesis_reward = if height == 0 { reward_amount } else { 0 }; + mine_issued_amount.checked_add(genesis_reward) } fn snapshot_tip(snapshot: &ChainSnapshot) -> Option<(u64, String)> { @@ -129,7 +419,7 @@ mod tests { use tempfile::tempdir; - use crate::domain::{GenesisBurn, Ledger, Wallet}; + use crate::domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet}; use super::SqliteChainStore; @@ -173,6 +463,73 @@ mod tests { } #[test] + fn sqlite_chain_store_saves_and_clears_block_metrics() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("metrics-alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + let burn = ledger.build_burn(&wallet, 2, 1).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + let metrics = store.load_metrics().unwrap(); + + assert_eq!(metrics.last().unwrap().height, 1); + assert_eq!(metrics.last().unwrap().burn_count, 1); + assert_eq!(metrics.last().unwrap().burned_amount, 2); + assert_eq!(metrics.last().unwrap().fees_amount, 1); + assert_eq!(metrics.last().unwrap().circulating_supply, BLOCK_REWARD + 7); + + store.clear_metrics().unwrap(); + assert!(store.load_metrics().unwrap().is_empty()); + } + + #[test] + fn sqlite_chain_store_metrics_include_genesis_reward_when_burn_consumes_allocation() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("metrics-genesis-reward"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 1); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + let metrics = store.load_metrics().unwrap(); + + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].height, 0); + assert_eq!(metrics[0].burned_amount, 1); + assert_eq!(metrics[0].reward_amount, BLOCK_REWARD); + assert_eq!(metrics[0].circulating_supply, BLOCK_REWARD); + } + + #[test] + fn sqlite_chain_store_disabled_metrics_save_deletes_old_metrics() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("metrics-cleanup"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + assert!(!store.load_metrics().unwrap().is_empty()); + + store.save_with_metrics(&ledger.snapshot(), false).unwrap(); + assert!(store.load_metrics().unwrap().is_empty()); + } + + #[test] fn sqlite_chain_store_reports_invalid_snapshot_json() { let dir = tempdir().unwrap(); let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -26,6 +26,7 @@ pub struct UiConfig { pub burn_per_block: Amount, pub burn_fee: Amount, pub pow_mine_fee: Amount, + pub keep_track_of_metrics: bool, pub peers: Vec<String>, } @@ -39,6 +40,7 @@ impl Default for UiConfig { burn_per_block: 0, burn_fee: DEFAULT_BURN_FEE, pow_mine_fee: MINE_FINALIZER_FEE, + keep_track_of_metrics: false, peers: Vec::new(), } } @@ -63,6 +65,8 @@ struct ConfigFile { #[serde(default)] pow_mine_fee: Option<Amount>, #[serde(default)] + keep_track_of_metrics: bool, + #[serde(default)] peers: Vec<String>, } @@ -92,6 +96,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { burn_per_block: config.burn_per_block, burn_fee: Some(config.burn_fee), pow_mine_fee: Some(config.pow_mine_fee), + keep_track_of_metrics: config.keep_track_of_metrics, peers: config.peers.clone(), }; let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?; @@ -140,6 +145,7 @@ fn load(path: &Path) -> Result<UiConfig> { .pow_mine_fee .map(|fee| fee.saturating_mul(scale)) .unwrap_or(MINE_FINALIZER_FEE), + keep_track_of_metrics: stored.keep_track_of_metrics, peers: stored.peers, }) } @@ -182,6 +188,7 @@ mod tests { assert!(stored.contains("\"burn_per_block\": 0")); assert!(stored.contains("\"burn_fee\": 1")); assert!(stored.contains("\"pow_mine_fee\": 1000000")); + assert!(stored.contains("\"keep_track_of_metrics\": false")); assert!(stored.contains("\"peers\": []")); } @@ -200,6 +207,7 @@ mod tests { burn_per_block: 50 * MICRO_IUNA, burn_fee: 3 * MICRO_IUNA, pow_mine_fee: 2 * MICRO_IUNA, + keep_track_of_metrics: true, peers: vec!["127.0.0.1:9444".to_string()], }, ) @@ -213,6 +221,7 @@ mod tests { assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); assert_eq!(config.burn_fee, 3 * MICRO_IUNA); assert_eq!(config.pow_mine_fee, 2 * MICRO_IUNA); + assert!(config.keep_track_of_metrics); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); } @@ -251,6 +260,7 @@ mod tests { assert_eq!(config.burn_per_block, 0); assert_eq!(config.burn_fee, DEFAULT_BURN_FEE); assert_eq!(config.pow_mine_fee, MINE_FINALIZER_FEE); + assert!(!config.keep_track_of_metrics); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); } diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -23,6 +23,7 @@ use tokio::{net::TcpListener, sync::Mutex}; use crate::{ adapters::{ + chain_store::{BlockMetricRow, SqliteChainStore}, config_store, config_store::UiConfig, p2p::{GossipNetwork, P2pMetrics}, @@ -55,6 +56,7 @@ struct HttpState { gossip: GossipNetwork, ui_config: Arc<Mutex<UiConfig>>, config_path: PathBuf, + chain_store: SqliteChainStore, wallet_path: PathBuf, stratum: StratumStatus, auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>, @@ -78,6 +80,7 @@ struct AuthBackoff { pub struct ServeOptions { pub config_path: PathBuf, + pub chain_store: SqliteChainStore, pub wallet_path: PathBuf, pub stratum: StratumStatus, pub addr: SocketAddr, @@ -136,6 +139,11 @@ struct PowMiningForm { } #[derive(Debug, Deserialize)] +struct MetricsSettingsForm { + enabled: bool, +} + +#[derive(Debug, Deserialize)] struct TransferForm { to: String, amount: Amount, @@ -215,6 +223,39 @@ struct ActionResponse { error: Option<String>, } +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct MetricsResponse { + enabled: bool, + latest: Option<BlockMetricRow>, + charts: Vec<MetricsChart>, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct MetricsChart { + id: &'static str, + title: &'static str, + unit: &'static str, + value_kind: MetricsValueKind, + points: Vec<MetricsPoint>, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct MetricsPoint { + height: u64, + value: f64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +enum MetricsValueKind { + Number, + Seconds, + Iuna, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct FeeEstimateResponse { @@ -319,6 +360,7 @@ pub async fn serve( gossip, ui_config, config_path: options.config_path, + chain_store: options.chain_store, wallet_path: options.wallet_path, stratum: options.stratum, auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), @@ -326,6 +368,7 @@ pub async fn serve( }; let app = Router::new() .route("/", get(index)) + .route("/favicon.ico", get(favicon)) .route("/assets/alpine.min.js", get(alpine_js)) .route("/assets/iuna-ui.js", get(app_js)) .route("/api/auth/status", get(api_auth_status)) @@ -351,6 +394,7 @@ pub async fn serve( .route("/api/fee-estimate/burn", post(api_burn_fee_estimate_form)) .route("/api/fee-estimate/mine", post(api_mine_fee_estimate_form)) .route("/api/mempool", get(api_mempool)) + .route("/api/metrics", get(api_metrics)) .route("/api/network/health", get(api_network_health)) .route( "/api/peers", @@ -364,6 +408,7 @@ pub async fn serve( post(api_burn_per_block_form), ) .route("/api/settings/pow-mining", post(api_pow_mining_form)) + .route("/api/settings/metrics", post(api_metrics_settings_form)) .route("/api/transfer", post(api_transfer_form)) .route("/settings/burn-per-block", post(burn_per_block_form)) .route("/transfer", post(transfer_form)) @@ -389,6 +434,13 @@ async fn index() -> Html<&'static str> { Html(INDEX_HTML) } +async fn favicon() -> impl IntoResponse { + ( + StatusCode::NO_CONTENT, + [(header::CACHE_CONTROL, "public, max-age=86400")], + ) +} + async fn alpine_js() -> impl IntoResponse { ( [( @@ -442,6 +494,7 @@ async fn require_auth_middleware( fn auth_exempt_path(path: &str) -> bool { path == "/" + || path == "/favicon.ico" || path == "/assets/alpine.min.js" || path == "/assets/iuna-ui.js" || path == "/api/auth/status" @@ -710,6 +763,24 @@ async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pMetrics> { Json(state.gossip.metrics()) } +async fn api_metrics(State(state): State<HttpState>) -> Json<MetricsResponse> { + let enabled = state.ui_config.lock().await.keep_track_of_metrics; + if !enabled { + return Json(MetricsResponse { + enabled, + latest: None, + charts: Vec::new(), + }); + } + let store = state.chain_store.clone(); + let rows = tokio::task::spawn_blocking(move || store.load_metrics()) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_default(); + Json(metrics_response(enabled, rows)) +} + async fn api_network_health(State(state): State<HttpState>) -> Json<NetworkHealthResponse> { let status = state.node.lock().await.status(); let peers = state.peers.lock().await.list(); @@ -794,6 +865,13 @@ async fn api_pow_mining_form( action_json(set_pow_mining(&state, form.enabled, MINE_FINALIZER_FEE).await) } +async fn api_metrics_settings_form( + State(state): State<HttpState>, + Form(form): Form<MetricsSettingsForm>, +) -> Json<ActionResponse> { + action_json(set_keep_track_of_metrics(&state, form.enabled).await) +} + async fn burn_per_block_form( State(state): State<HttpState>, Form(form): Form<BurnSettingsForm>, @@ -912,6 +990,45 @@ async fn persist_pow_mining_config( config_store::save(config_path, &config) } +async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<()> { + if enabled { + let snapshot = { + let node = state.node.lock().await; + node.has_real_chain().then(|| node.chain_snapshot()) + }; + if let Some(snapshot) = snapshot { + replace_metrics_for_snapshot(&state.chain_store, snapshot).await?; + } else { + clear_metrics(&state.chain_store).await?; + } + } else { + clear_metrics(&state.chain_store).await?; + } + + let mut config = state.ui_config.lock().await; + config.keep_track_of_metrics = enabled; + config_store::save(&state.config_path, &config) +} + +async fn replace_metrics_for_snapshot( + store: &SqliteChainStore, + snapshot: crate::domain::ChainSnapshot, +) -> Result<()> { + let store = store.clone(); + tokio::task::spawn_blocking(move || store.replace_metrics_for_snapshot(&snapshot)) + .await + .context("metrics worker failed")??; + Ok(()) +} + +async fn clear_metrics(store: &SqliteChainStore) -> Result<()> { + let store = store.clone(); + tokio::task::spawn_blocking(move || store.clear_metrics()) + .await + .context("metrics cleanup worker failed")??; + Ok(()) +} + async fn add_peer(state: &HttpState, peer: String) -> Result<()> { let peer = validate_peer_address(peer)?; let addresses = { @@ -950,6 +1067,125 @@ fn network_health(status: &NodeStatus, peers: &[PeerInfo]) -> NetworkHealthRespo network_health_at(status, peers, now_ms()) } +fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse { + let latest = rows.last().cloned(); + MetricsResponse { + enabled, + latest, + charts: vec![ + metrics_chart( + "block-time", + "Time per block", + "s", + MetricsValueKind::Seconds, + &rows, + |row| row.block_time_ms.map(|ms| ms as f64 / 1_000.0), + ), + metrics_chart( + "difficulty", + "Difficulty", + "bits", + MetricsValueKind::Number, + &rows, + |row| Some(row.mine_difficulty_bits as f64), + ), + metrics_chart( + "supply", + "IUNA in circulation", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.circulating_supply)), + ), + metrics_chart( + "transactions", + "Transactions", + "tx", + MetricsValueKind::Number, + &rows, + |row| Some(row.transaction_count as f64), + ), + metrics_chart( + "burn-count", + "Burn transactions", + "burns", + MetricsValueKind::Number, + &rows, + |row| Some(row.burn_count as f64), + ), + metrics_chart( + "burn-amount", + "Burn amount", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.burned_amount)), + ), + metrics_chart( + "total-burn", + "Total burn", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.total_burned_amount)), + ), + metrics_chart( + "fees", + "Fees", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.fees_amount)), + ), + metrics_chart( + "mine-actions", + "Mine actions", + "mine", + MetricsValueKind::Number, + &rows, + |row| Some(row.mine_count as f64), + ), + metrics_chart( + "vdf-rounds", + "VDF rounds", + "rounds", + MetricsValueKind::Number, + &rows, + |row| Some(row.vdf_rounds as f64), + ), + ], + } +} + +fn metrics_chart( + id: &'static str, + title: &'static str, + unit: &'static str, + value_kind: MetricsValueKind, + rows: &[BlockMetricRow], + value: impl Fn(&BlockMetricRow) -> Option<f64>, +) -> MetricsChart { + MetricsChart { + id, + title, + unit, + value_kind, + points: rows + .iter() + .filter_map(|row| { + value(row).map(|value| MetricsPoint { + height: row.height, + value, + }) + }) + .collect(), + } +} + +fn micro_iuna_as_iuna(amount: Amount) -> f64 { + amount as f64 / 1_000_000.0 +} + fn network_health_at( status: &NodeStatus, peers: &[PeerInfo], @@ -2045,9 +2281,11 @@ const INDEX_HTML: &str = r#"<!doctype html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="icon" href="data:,"> <title>iuna</title> <style> [x-cloak] { display: none !important; } + .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; clip-path: inset(50%) !important; white-space: nowrap !important; } :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -2175,6 +2413,30 @@ const INDEX_HTML: &str = r#"<!doctype html> .settings-mode-title { color: #e8edf0; font-size: 15px; font-weight: 850; } .settings-form { display: grid; gap: 10px; align-items: stretch; } .settings-form label, .settings-form input { width: 100%; } + .metrics-shell { display: grid; gap: 12px; } + .metrics-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } + .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 430px), 1fr)); gap: 12px; } + .metric-chart-card { display: grid; gap: 10px; min-width: 0; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; background: #181b1f; } + .metric-chart-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; } + .metric-chart-title { margin: 0; color: #e8edf0; font-size: 14px; font-weight: 850; } + .metric-chart-value { color: #d5f55f; font-size: 13px; font-weight: 850; font-variant-numeric: tabular-nums; } + .metric-chart-frame { display: grid; grid-template-columns: 50px minmax(0, 1fr); grid-template-rows: 156px 18px; column-gap: 6px; row-gap: 4px; min-width: 0; } + .metric-chart-plot { position: relative; min-width: 0; } + .metric-chart-svg { width: 100%; height: 156px; display: block; border: 1px solid #2f363c; border-radius: 8px; background: #111316; } + .metric-chart-gridline { stroke: #3a4248; stroke-width: .8; stroke-dasharray: 3 7; opacity: .58; } + .metric-chart-axis { stroke: #3a4248; stroke-width: 1.2; } + .metric-chart-axis-label { color: #8d989f; font-size: 10px; font-weight: 750; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .metric-chart-y-axis { position: relative; min-width: 0; } + .metric-chart-y-axis .metric-chart-axis-label { position: absolute; right: 0; transform: translateY(-50%); max-width: 100%; } + .metric-chart-x-axis { position: relative; grid-column: 2; min-width: 0; overflow: visible; } + .metric-chart-x-axis .metric-chart-axis-label { position: absolute; top: 0; transform: translateX(-50%); } + .metric-chart-line { fill: none; stroke: #d5f55f; stroke-width: 2.2; stroke-linejoin: round; stroke-linecap: round; } + .metric-chart-points { position: absolute; inset: 0; } + .metric-chart-point-hit { position: absolute; width: 18px; height: 18px; border: 0; border-radius: 50%; padding: 0; background: transparent; cursor: crosshair; transform: translate(-50%, -50%); } + .metric-chart-point-hit::after { content: ""; position: absolute; left: 50%; top: 50%; width: 5px; height: 5px; border-radius: 50%; background: #d5f55f; opacity: .2; transform: translate(-50%, -50%); transition: opacity .12s ease, width .12s ease, height .12s ease; } + .metric-chart-point-hit:hover::after, .metric-chart-point-hit:focus-visible::after { width: 8px; height: 8px; opacity: 1; } + .metric-chart-tooltip { position: absolute; z-index: 1; max-width: min(180px, 80%); border: 1px solid #566d25; border-radius: 6px; padding: 5px 7px; background: #202615; color: #e8edf0; font-size: 11px; font-weight: 850; font-variant-numeric: tabular-nums; line-height: 1.25; pointer-events: none; box-shadow: 0 8px 20px rgba(0, 0, 0, .28); white-space: nowrap; } + .metrics-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 14px; color: #8d989f; background: #111316; } .wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } .wallet-actions { display: grid; gap: 12px; } .advanced-toggle { flex-basis: 100%; width: max-content; align-self: flex-start; border-color: #3a4248; padding: 4px 7px; background: #202328; color: #9fa8ad; font-size: 12px; } @@ -2353,13 +2615,14 @@ const INDEX_HTML: &str = r#"<!doctype html> header, .split, .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; } header { display: grid; } .settings-mode-row { align-items: flex-start; } + .metrics-grid { grid-template-columns: 1fr; } input { min-width: 0; width: 100%; } .switch input { width: auto; } .seed-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=61"></script> + <script defer src="/assets/iuna-ui.js?v=64"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -2383,6 +2646,10 @@ const INDEX_HTML: &str = r#"<!doctype html> <svg class="chain-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="1.5" y="9" width="5.5" height="5.5"></rect><rect x="9.25" y="9" width="5.5" height="5.5"></rect><rect x="17" y="9" width="5.5" height="5.5"></rect></svg> <span>Chain</span> </button> + <button class="nav-button" x-show="config.keep_track_of_metrics" :class="{ active: tab === 'metrics' }" @click="setTab('metrics')" type="button" title="Metrics" aria-label="Metrics"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l3-4 3 2 4-7"></path><path d="M7 17h10"></path></svg> + <span>Metrics</span> + </button> </nav> <button class="settings-button" :class="{ active: tab === 'settings' }" type="button" @click="setTab('settings')" title="Settings" aria-label="Settings"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9.7 3.2 9.2 5.5a7.2 7.2 0 0 0-1.4.8L5.6 5.6 3.2 9.8l1.7 1.6a7.8 7.8 0 0 0 0 1.6l-1.7 1.6 2.4 4.2 2.2-.7a7.2 7.2 0 0 0 1.4.8l.5 2.3h4.8l.5-2.3a7.2 7.2 0 0 0 1.4-.8l2.2.7 2.4-4.2-1.7-1.6a7.8 7.8 0 0 0 0-1.6L21 9.8l-2.4-4.2-2.2.7a7.2 7.2 0 0 0-1.4-.8l-.5-2.3H9.7Z"></path><circle cx="12" cy="12.2" r="3.1"></circle></svg> @@ -2805,6 +3072,53 @@ const INDEX_HTML: &str = r#"<!doctype html> </section> </div> </section> + <section x-show="tab === 'metrics'"> + <div class="metrics-shell"> + <div class="metrics-summary"> + <div class="metric"><div class="label">Latest block</div><div class="value" x-text="metricsLatest().height ?? '-'"></div></div> + <div class="metric"><div class="label">Supply</div><div class="value" x-text="metricAmountLabel(metricsLatest().circulatingSupply)"></div></div> + <div class="metric"><div class="label">Total burned</div><div class="value" x-text="metricAmountLabel(metricsLatest().totalBurnedAmount)"></div></div> + <div class="metric"><div class="label">Difficulty</div><div class="value" x-text="metricsLatest().mineDifficultyBits ?? '-'"></div></div> + </div> + <div class="metrics-empty" x-show="metricsCharts().length === 0">No metrics collected yet</div> + <div class="metrics-grid"> + <template x-for="chart in metricsCharts()" :key="chart.id"> + <article class="metric-chart-card"> + <div class="metric-chart-head"> + <h3 class="metric-chart-title" x-text="chart.title"></h3> + <div class="metric-chart-value" x-text="metricLatestValueLabel(chart)"></div> + </div> + <div class="metric-chart-frame"> + <div class="metric-chart-y-axis"> + <template x-for="tick in metricYAxisTicks(chart)" :key="`${chart.id}-y-${tick}`"> + <span class="metric-chart-axis-label" :style="metricYAxisLabelStyle(chart, tick)" x-text="metricAxisValueLabel(chart, tick)"></span> + </template> + </div> + <div class="metric-chart-plot" @mousemove="setMetricHoverFromPlot(chart, $event)" @mouseleave="clearMetricHover(chart)"> + <svg class="metric-chart-svg" viewBox="0 0 300 148" preserveAspectRatio="none" role="img" :aria-label="chart.title"> + <path class="metric-chart-gridline" :d="metricGridPath(chart)"></path> + <line class="metric-chart-axis" x1="4" y1="8" x2="4" y2="132"></line> + <line class="metric-chart-axis" x1="4" y1="132" x2="296" y2="132"></line> + <polyline class="metric-chart-line" :points="metricChartPoints(chart)"></polyline> + </svg> + <div class="metric-chart-points"> + <template x-for="marker in metricChartPointMarkers(chart)" :key="`${chart.id}-point-${marker.height}`"> + <button class="metric-chart-point-hit" type="button" :style="metricPointStyle(marker)" :title="marker.label" @focus="setMetricHover(chart, marker)" @blur="clearMetricHover(chart)" :aria-label="marker.label"></button> + </template> + </div> + <div class="metric-chart-tooltip" x-cloak x-show="metricHover?.chartId === chart.id" :style="metricTooltipStyle(chart)" x-text="metricTooltipLabel(chart)"></div> + </div> + <div class="metric-chart-x-axis"> + <template x-for="tick in metricXAxisTicks(chart)" :key="`${chart.id}-x-${tick}`"> + <span class="metric-chart-axis-label" :style="metricXAxisLabelStyle(chart, tick)" x-text="`#${tick}`"></span> + </template> + </div> + </div> + </article> + </template> + </div> + </div> + </section> <section x-show="tab === 'settings'"> <div class="settings-grid"> <div class="panel"> @@ -2825,9 +3139,23 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> </div> <div class="panel"> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title">Keep track of metrics</div> + <div class="muted" x-text="keepTrackOfMetrics ? 'Metrics are stored per block.' : 'Metrics storage is off.'"></div> + </div> + <label class="toggle-switch" :class="{ active: keepTrackOfMetrics }"> + <input type="checkbox" :checked="keepTrackOfMetrics" @change="setKeepTrackOfMetrics($event.target.checked)"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="keepTrackOfMetrics ? 'On' : 'Off'"></span> + </label> + </div> + </div> + <div class="panel"> <h3>Change Password</h3> <div class="setup-feedback" :class="settingsFeedback?.kind" x-show="settingsFeedback" x-transition x-text="settingsFeedback?.message"></div> <form class="settings-form" @submit.prevent="changePassword"> + <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> <label>Current password<input x-model="settingsOldPassword" type="password" autocomplete="current-password" required></label> <label>New password<input x-model="settingsNewPassword" type="password" autocomplete="new-password" minlength="12" required></label> <label>Confirm new password<input x-model="settingsPasswordConfirm" type="password" autocomplete="new-password" minlength="12" required></label> @@ -2848,11 +3176,13 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="setup-feedback" :class="authFeedback?.kind" x-show="authFeedback" x-transition x-text="authFeedback?.message"></div> <form x-show="!auth.configured" @submit.prevent="setupPassword"> + <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> <label>Password<input x-model="authPassword" type="password" autocomplete="new-password" minlength="12" required></label> <label>Confirm password<input x-model="authPasswordConfirm" type="password" autocomplete="new-password" minlength="12" required></label> <div class="setup-actions"><button class="primary" type="submit">Set password</button></div> </form> <form x-show="auth.configured && !auth.authenticated" @submit.prevent="login"> + <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> <label>Password<input x-model="loginPassword" type="password" autocomplete="current-password" required></label> <div class="setup-actions"><button class="primary" type="submit">Unlock</button></div> </form> @@ -3067,7 +3397,10 @@ mod tests { use tower::ServiceExt; use crate::{ - adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store}, + adapters::{ + chain_store::SqliteChainStore, config_store, config_store::UiConfig, + p2p::GossipNetwork, wallet_store, + }, app::{NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, domain::{ Block, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction, @@ -3185,6 +3518,24 @@ mod tests { } #[tokio::test] + async fn favicon_is_public_before_authentication_setup() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state( + dir.path().join("config.json"), + UiConfig { + auth_password_hash: None, + ..UiConfig::default() + }, + ) + .await; + let app = auth_test_app(state); + + let response = http_request(app, Method::GET, "/favicon.ico", None, "").await; + + assert_eq!(response.status, StatusCode::NO_CONTENT); + } + + #[tokio::test] async fn protected_endpoints_require_valid_session_after_authentication_setup() { let dir = tempfile::tempdir().unwrap(); let password = "correct horse battery staple"; @@ -3885,6 +4236,8 @@ mod tests { let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); let peers = Arc::new(Mutex::new(PeerBook::default())); let gossip = GossipNetwork::new_for_tests(node.clone(), peers.clone()); + let chain_store = SqliteChainStore::open(config_path.with_file_name("chain.sqlite3")) + .expect("test chain store should open"); HttpState { node, peers, @@ -3893,6 +4246,7 @@ mod tests { config_store::load_or_create(&config_path).unwrap(), )), config_path, + chain_store, wallet_path, stratum: StratumStatus { enabled: false, @@ -3912,6 +4266,7 @@ mod tests { "/api/auth/change-password", post(api_auth_change_password_form), ) + .route("/favicon.ico", get(super::favicon)) .route("/api/protected", get(protected_auth_test_endpoint)) .layer(middleware::from_fn_with_state( state.clone(), @@ -4024,6 +4379,44 @@ mod tests { } #[tokio::test] + async fn metrics_setting_persists_config_and_clears_rows_when_disabled() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state( + config_path.clone(), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + let wallet = Wallet::from_seed("metrics-setting-wallet"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let ledger = Ledger::new_with_genesis_burns( + genesis, + vec![crate::domain::GenesisBurn::new(wallet.address(), 1)], + 1, + ) + .unwrap(); + *state.node.lock().await = NodeCore::from_ledger(wallet, ledger, 0); + + super::set_keep_track_of_metrics(&state, true) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.keep_track_of_metrics); + assert!(!state.chain_store.load_metrics().unwrap().is_empty()); + + super::set_keep_track_of_metrics(&state, false) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.keep_track_of_metrics); + assert!(state.chain_store.load_metrics().unwrap().is_empty()); + } + + #[tokio::test] async fn pow_mining_config_persistence_updates_config_file() { let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.json"); diff --git a/src/domain.rs b/src/domain.rs @@ -1582,6 +1582,10 @@ impl Ledger { self.mine_difficulty_bits_for_anchor_height(self.tip().height) } + pub fn mine_difficulty_bits_at_height(&self, height: u64) -> u32 { + self.mine_difficulty_bits_for_anchor_height(height.min(self.tip().height)) + } + pub fn balance_of(&self, address: &str) -> Amount { self.utxos .values() diff --git a/src/main.rs b/src/main.rs @@ -86,7 +86,12 @@ async fn main() -> Result<()> { let peers: SharedPeerBook = Arc::new(Mutex::new(PeerBook::from_addresses(peers))); if has_chain { let initial_snapshot = { node.lock().await.chain_snapshot() }; - persist_chain_snapshot(&chain_store, initial_snapshot).await?; + persist_chain_snapshot( + &chain_store, + initial_snapshot, + ui_config.lock().await.keep_track_of_metrics, + ) + .await?; } println!("iuna wallet: {}", node.lock().await.wallet_address()); @@ -122,8 +127,9 @@ async fn main() -> Result<()> { let persistence_node = Arc::clone(&node); let persistence_store = chain_store.clone(); + let persistence_config = Arc::clone(&ui_config); tokio::spawn(async move { - run_chain_persistence(persistence_node, persistence_store).await; + run_chain_persistence(persistence_node, persistence_store, persistence_config).await; }); let finalizer_node = Arc::clone(&node); @@ -149,6 +155,7 @@ async fn main() -> Result<()> { ui_config, http::ServeOptions { config_path, + chain_store, wallet_path, stratum: stratum_status, addr: opts.http_addr, @@ -641,13 +648,18 @@ async fn run_peer_sync(node: SharedNode, gossip: p2p::GossipNetwork, debug: bool } } -async fn run_chain_persistence(node: SharedNode, store: SqliteChainStore) { - run_chain_persistence_with_interval(node, store, Duration::from_secs(2)).await; +async fn run_chain_persistence( + node: SharedNode, + store: SqliteChainStore, + ui_config: Arc<Mutex<config_store::UiConfig>>, +) { + run_chain_persistence_with_interval(node, store, ui_config, Duration::from_secs(2)).await; } async fn run_chain_persistence_with_interval( node: SharedNode, store: SqliteChainStore, + ui_config: Arc<Mutex<config_store::UiConfig>>, interval: Duration, ) { let mut last_saved_tip: Option<String> = None; @@ -667,7 +679,8 @@ async fn run_chain_persistence_with_interval( continue; } - match persist_chain_snapshot(&store, snapshot).await { + let keep_metrics = ui_config.lock().await.keep_track_of_metrics; + match persist_chain_snapshot(&store, snapshot, keep_metrics).await { Ok(()) => last_saved_tip = Some(tip_hash), Err(error) if debug_logging_enabled() => { eprintln!("chain persistence failed: {error:#}") @@ -677,9 +690,13 @@ async fn run_chain_persistence_with_interval( } } -async fn persist_chain_snapshot(store: &SqliteChainStore, snapshot: ChainSnapshot) -> Result<()> { +async fn persist_chain_snapshot( + store: &SqliteChainStore, + snapshot: ChainSnapshot, + keep_metrics: bool, +) -> Result<()> { let store = store.clone(); - tokio::task::spawn_blocking(move || store.save(&snapshot)) + tokio::task::spawn_blocking(move || store.save_with_metrics(&snapshot, keep_metrics)) .await .context("chain persistence worker failed")??; Ok(()) @@ -1176,13 +1193,15 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0) DEFAULT_BURN_PER_BLOCK, ))); let initial_snapshot = { node.lock().await.chain_snapshot() }; - persist_chain_snapshot(&store, initial_snapshot) + persist_chain_snapshot(&store, initial_snapshot, false) .await .unwrap(); + let ui_config = Arc::new(Mutex::new(UiConfig::default())); let persistence_task = tokio::spawn(run_chain_persistence_with_interval( Arc::clone(&node), store.clone(), + ui_config, Duration::from_millis(10), )); { @@ -1214,10 +1233,12 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0) let wallet = Wallet::from_seed("background-persistence-setup"); let ledger = Ledger::new(BTreeMap::new(), 1); let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); + let ui_config = Arc::new(Mutex::new(UiConfig::default())); let persistence_task = tokio::spawn(run_chain_persistence_with_interval( Arc::clone(&node), store.clone(), + ui_config, Duration::from_millis(10), )); tokio::time::sleep(Duration::from_millis(50)).await; diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -12,6 +12,8 @@ window.iunaApp = function iunaApp() { mempool: [], peers: [], p2pMetrics: {}, + blockchainMetrics: { enabled: false, latest: null, charts: [] }, + metricHover: null, networkHealth: {}, uiMode: (() => { try { @@ -34,6 +36,7 @@ window.iunaApp = function iunaApp() { settingsNewPassword: "", settingsPasswordConfirm: "", settingsFeedback: null, + keepTrackOfMetrics: false, setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false }, setupWalletMode: "create", setupSeedStep: "write", @@ -117,9 +120,13 @@ window.iunaApp = function iunaApp() { }, allowedTabs() { - return this.advancedMode() + const tabs = this.advancedMode() ? ["wallet", "mining", "p2p", "chain", "settings"] : ["wallet", "chain", "settings"]; + if (this.config.keep_track_of_metrics) { + tabs.splice(tabs.indexOf("chain") + 1, 0, "metrics"); + } + return tabs; }, basicMode() { @@ -150,6 +157,7 @@ window.iunaApp = function iunaApp() { mining: "Mining", p2p: "P2P", chain: "Chain", + metrics: "Metrics", settings: "Settings", }[this.tab] || "iuna"; }, @@ -477,7 +485,7 @@ window.iunaApp = function iunaApp() { async refresh() { try { - const [config, status, blocks, walletTxs, walletUtxos, mempool, peers, p2pMetrics, networkHealth] = await Promise.all([ + const [config, status, blocks, walletTxs, walletUtxos, mempool, peers, p2pMetrics, blockchainMetrics, networkHealth] = await Promise.all([ this.fetchJson("/api/config"), this.fetchJson("/api/status"), this.fetchJson("/api/blocks?limit=30"), @@ -486,9 +494,14 @@ window.iunaApp = function iunaApp() { this.fetchJson("/api/mempool"), this.fetchJson("/api/peers"), this.fetchJson("/api/p2p/metrics"), + this.fetchJson("/api/metrics"), this.fetchJson("/api/network/health"), ]); this.config = config; + this.keepTrackOfMetrics = config.keep_track_of_metrics === true; + if (!this.allowedTabs().includes(this.tab)) { + this.setTab("wallet"); + } if (!this.config.setup_complete) { await this.refreshWalletSetup(); } @@ -500,6 +513,7 @@ window.iunaApp = function iunaApp() { this.mempool = mempool; this.peers = peers; this.p2pMetrics = p2pMetrics; + this.blockchainMetrics = blockchainMetrics; this.networkHealth = networkHealth; this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; @@ -887,6 +901,25 @@ window.iunaApp = function iunaApp() { } }, + async setKeepTrackOfMetrics(enabled) { + const previous = this.keepTrackOfMetrics; + try { + this.keepTrackOfMetrics = enabled; + await this.postForm( + "/api/settings/metrics", + { enabled }, + enabled ? "Metrics tracking turned on" : "Metrics tracking turned off" + ); + await this.refreshConfig(); + if (!enabled && this.tab === "metrics") { + this.setTab("settings"); + } + } catch (error) { + this.keepTrackOfMetrics = previous; + this.showFlash(error.message, "error"); + } + }, + automaticBurnFeeDraft() { return this.parseiunaAmount(this.burnFeeDraft); }, @@ -909,6 +942,238 @@ window.iunaApp = function iunaApp() { return this.status.mining?.last_auto_pow_mine_status || "Waiting for next automatic PoW mining tick"; }, + metricsCharts() { + return Array.isArray(this.blockchainMetrics?.charts) ? this.blockchainMetrics.charts : []; + }, + + metricsLatest() { + return this.blockchainMetrics?.latest || {}; + }, + + metricChartPoints(chart) { + const points = this.metricVisiblePoints(chart); + if (points.length === 0) return ""; + const bounds = this.metricChartBounds(chart); + return points + .map((point) => { + const x = this.metricXAxisPositionFromBounds(bounds, Number(point.height)); + const y = this.metricYAxisPositionFromBounds(bounds, Number(point.value)); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + }, + + metricChartPointMarkers(chart) { + const points = this.metricVisiblePoints(chart); + if (points.length === 0) return []; + const bounds = this.metricChartBounds(chart); + return points.map((point) => { + const height = Number(point.height); + const value = Number(point.value); + return { + height, + value, + x: this.metricXAxisPositionFromBounds(bounds, height), + y: this.metricYAxisPositionFromBounds(bounds, value), + label: this.metricPointLabel(chart, point), + }; + }); + }, + + metricGridPath(chart) { + const yLines = this.metricYAxisTicks(chart).map((tick) => { + const y = this.metricYAxisPositionFromBounds(this.metricChartBounds(chart), Number(tick)).toFixed(1); + return `M4 ${y} H296`; + }); + const xLines = this.metricXAxisTicks(chart).map((tick) => { + const x = this.metricXAxisPositionFromBounds(this.metricChartBounds(chart), Number(tick)).toFixed(1); + return `M${x} 8 V132`; + }); + return [...yLines, ...xLines].join(" "); + }, + + metricVisiblePoints(chart) { + const points = Array.isArray(chart?.points) ? chart.points : []; + return points.filter((point) => Number.isFinite(Number(point.value))); + }, + + metricLatestValueLabel(chart) { + const points = this.metricVisiblePoints(chart); + if (points.length === 0) return "-"; + return this.metricValueLabel(chart, points[points.length - 1].value); + }, + + metricChartBounds(chart) { + const points = this.metricVisiblePoints(chart); + const heights = points.map((point) => Number(point.height)); + const values = points.map((point) => Number(point.value)); + const valueTicks = this.niceTicks(Math.min(...values), Math.max(...values), 5); + return { + minHeight: Math.min(...heights), + maxHeight: Math.max(...heights), + minValue: Math.min(...valueTicks), + maxValue: Math.max(...valueTicks), + }; + }, + + metricYAxisTicks(chart) { + const points = this.metricVisiblePoints(chart); + if (points.length === 0) return []; + const values = points.map((point) => Number(point.value)); + return this.niceTicks(Math.min(...values), Math.max(...values), 5).reverse(); + }, + + metricXAxisTicks(chart) { + const points = this.metricVisiblePoints(chart); + if (points.length === 0) return []; + const heights = points.map((point) => Number(point.height)); + const minHeight = Math.min(...heights); + const maxHeight = Math.max(...heights); + if (minHeight === maxHeight) return [minHeight]; + return this.niceTicks(minHeight, maxHeight, 5) + .map((tick) => Math.round(tick)) + .filter((tick) => tick >= minHeight && tick <= maxHeight) + .filter((tick, index, ticks) => ticks.indexOf(tick) === index); + }, + + niceTicks(minValue, maxValue, maxTicks = 5) { + const min = Number(minValue); + const max = Number(maxValue); + if (!Number.isFinite(min) || !Number.isFinite(max)) return []; + if (min === max) { + if (min === 0) return [0]; + const step = this.niceTickStep(Math.abs(min) / Math.max(1, maxTicks - 1)); + const tickMin = Math.floor(Math.min(0, min) / step) * step; + const tickMax = Math.ceil(max / step) * step; + return this.tickRange(tickMin, tickMax, step); + } + const range = this.niceTickStep((max - min) / Math.max(1, maxTicks - 1)); + const tickMin = Math.floor(min / range) * range; + const tickMax = Math.ceil(max / range) * range; + return this.tickRange(tickMin, tickMax, range); + }, + + niceTickStep(value) { + if (!Number.isFinite(value) || value <= 0) return 1; + const exponent = Math.floor(Math.log10(value)); + const fraction = value / Math.pow(10, exponent); + const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10; + return niceFraction * Math.pow(10, exponent); + }, + + tickRange(min, max, step) { + if (!Number.isFinite(step) || step <= 0) return []; + const precision = Math.max(0, Math.ceil(-Math.log10(step)) + 2); + const ticks = []; + for (let tick = min; tick <= max + step / 2; tick += step) { + ticks.push(Number(tick.toFixed(precision))); + if (ticks.length > 8) break; + } + return ticks; + }, + + metricYAxisPositionFromBounds(bounds, value) { + const valueRange = Math.max(1, bounds.maxValue - bounds.minValue); + return 132 - ((value - bounds.minValue) / valueRange) * 124; + }, + + metricXAxisPositionFromBounds(bounds, height) { + const heightRange = Math.max(1, bounds.maxHeight - bounds.minHeight); + return 4 + ((height - bounds.minHeight) / heightRange) * 292; + }, + + metricYAxisLabelStyle(chart, value) { + const y = this.metricYAxisPositionFromBounds(this.metricChartBounds(chart), Number(value)); + return `top: ${(y / 148) * 100}%`; + }, + + metricXAxisLabelStyle(chart, height) { + const x = this.metricXAxisPositionFromBounds(this.metricChartBounds(chart), Number(height)); + return `left: ${(x / 300) * 100}%`; + }, + + metricPointStyle(marker) { + return `left: ${(marker.x / 300) * 100}%; top: ${(marker.y / 148) * 100}%;`; + }, + + setMetricHover(chart, marker) { + this.metricHover = { + chartId: chart.id, + height: marker.height, + value: marker.value, + x: marker.x, + y: marker.y, + label: marker.label, + }; + }, + + setMetricHoverFromPlot(chart, event) { + const markers = this.metricChartPointMarkers(chart); + if (markers.length === 0) { + this.clearMetricHover(chart); + return; + } + const rect = event.currentTarget.getBoundingClientRect(); + const relativeX = Math.min(Math.max(event.clientX - rect.left, 0), rect.width); + const chartX = (relativeX / Math.max(1, rect.width)) * 300; + const nearest = markers.reduce((best, marker) => { + const distance = Math.abs(marker.x - chartX); + return !best || distance < best.distance ? { marker, distance } : best; + }, null)?.marker; + if (nearest) { + this.setMetricHover(chart, nearest); + } + }, + + clearMetricHover(chart) { + if (this.metricHover?.chartId === chart.id) { + this.metricHover = null; + } + }, + + metricTooltipLabel(chart) { + return this.metricHover?.chartId === chart.id ? this.metricHover.label : ""; + }, + + metricTooltipStyle(chart) { + const hover = this.metricHover; + if (!hover || hover.chartId !== chart.id) return ""; + const left = (hover.x / 300) * 100; + const top = (hover.y / 148) * 100; + const xShift = hover.x > 238 ? "-100%" : hover.x < 62 ? "0" : "-50%"; + const yShift = hover.y < 34 ? "12px" : "-115%"; + return `left: ${left}%; top: ${top}%; transform: translate(${xShift}, ${yShift});`; + }, + + metricPointLabel(chart, point) { + return `#${point.height}: ${this.metricValueLabel(chart, point.value)}`; + }, + + metricAxisValueLabel(chart, value) { + const number = Number(value); + if (!Number.isFinite(number)) return "-"; + if (chart?.valueKind === "seconds") return `${this.compactNumber(number)}s`; + return this.compactNumber(number); + }, + + metricValueLabel(chart, value) { + const number = Number(value); + if (!Number.isFinite(number)) return "-"; + if (chart?.valueKind === "iuna") return `IUNA ${this.compactNumber(number)}`; + if (chart?.valueKind === "seconds") return `${this.compactNumber(number)} s`; + return `${this.compactNumber(number)}${chart?.unit ? ` ${chart.unit}` : ""}`; + }, + + compactNumber(value) { + const number = Number(value); + if (!Number.isFinite(number)) return "-"; + if (Math.abs(number) >= 1000) { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(number); + } + if (Number.isInteger(number)) return String(number); + return number.toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); + }, + amountLabel(value) { const microiuna = Math.max(0, Math.trunc(Number(value) || 0)); const whole = Math.floor(microiuna / 1000000); @@ -916,6 +1181,10 @@ window.iunaApp = function iunaApp() { return fractional ? `${whole}.${fractional}` : `${whole}`; }, + metricAmountLabel(value) { + return value === null || value === undefined ? "-" : `IUNA ${this.amountLabel(value)}`; + }, + amountNumber(value) { return Number(this.amountLabel(value)); },