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:
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));
},