iuna

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

commit 7b998f3949f793b7201dfb9dca71337f476d4cab
parent 71325b1bbad0f2ead41b859cf0b3172e71a7e8b4
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 11 Aug 2026 06:30:33 +0200

Improve initial chain and metrics loading

Diffstat:
Msrc/adapters/chain_store.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/chain_store/tests.rs | 27+++++++++++++++++++++++++++
Msrc/adapters/http.rs | 13+++++++++++--
Msrc/adapters/http/api.rs | 24+++++++++++++++---------
Msrc/adapters/http/index_html.rs | 26+++++++++++++++++++++++++-
Msrc/adapters/http/types.rs | 5+++++
Mwww/assets/iuna-ui.js | 17++++++++++++++++-
7 files changed, 145 insertions(+), 13 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -237,6 +237,52 @@ ORDER BY height ASC }) } + pub fn load_recent_metrics(&self, limit: usize) -> 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, known_wallet_addresses, 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 DESC +LIMIT ?1 +"#, + ) + .context("failed to prepare recent block metrics query")?; + let rows = statement + .query_map([limit as u64], |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)?, + known_wallet_addresses: row.get(6)?, + transaction_count: row.get(7)?, + transfer_count: row.get(8)?, + burn_count: row.get(9)?, + mine_count: row.get(10)?, + burned_amount: row.get(11)?, + total_burned_amount: row.get(12)?, + fees_amount: row.get(13)?, + reward_amount: row.get(14)?, + vdf_rounds: row.get(15)?, + finalizer_rank: row.get(16)?, + }) + }) + .context("failed to load recent block metrics")?; + let mut rows = rows + .collect::<std::result::Result<Vec<_>, _>>() + .context("failed to read recent block metrics rows")?; + rows.reverse(); + Ok(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()))?; diff --git a/src/adapters/chain_store/tests.rs b/src/adapters/chain_store/tests.rs @@ -172,6 +172,33 @@ fn sqlite_chain_store_saves_and_clears_block_metrics() { } #[test] +fn sqlite_chain_store_loads_recent_metrics_in_height_order() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("recent-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(); + + for timestamp_ms in [1_000, 2_000, 3_000] { + let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&wallet, timestamp_ms).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + } + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + + let metrics = store.load_recent_metrics(2).unwrap(); + + assert_eq!( + metrics.iter().map(|row| row.height).collect::<Vec<_>>(), + vec![2, 3] + ); +} + +#[test] fn sqlite_chain_store_migrates_known_wallet_address_metrics_column() { let dir = tempdir().unwrap(); let path = dir.path().join("chain.sqlite3"); diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -98,8 +98,8 @@ const PEER_STALE_AFTER_MS: u64 = 20 * 60 * 1_000; mod types; use types::{ ActionResponse, AuthForm, AuthStatusResponse, BlocksQuery, ChangePasswordForm, ConfigForm, - ConfigResponse, MempoolCounts, MetricsResponse, NetworkHealthResponse, Page, PageQuery, - UiBlock, UiTransaction, WalletTransactionFilters, WalletTransactionRow, + ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, NetworkHealthResponse, Page, + PageQuery, UiBlock, UiTransaction, WalletTransactionFilters, WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, }; #[cfg(test)] @@ -126,6 +126,7 @@ pub async fn serve( auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), ui_cache: Arc::new(Mutex::new(UiChainCache::default())), }; + tokio::spawn(prewarm_chain_view_cache(state.clone())); tokio::spawn(run_owned_blinded_outbox_persistence(state.clone())); let app = Router::new() .route("/", get(index)) @@ -205,6 +206,14 @@ pub async fn serve( .context("serving HTTP management UI") } +async fn prewarm_chain_view_cache(state: HttpState) { + let snapshot = { + let node = state.node.lock().await; + node.chain_snapshot() + }; + let _ = cached_chain_view(&state, &snapshot).await; +} + async fn api_config_form( State(state): State<HttpState>, Form(form): Form<ConfigForm>, diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -13,9 +13,9 @@ use crate::{ }; use super::{ - BlocksQuery, ConfigResponse, MempoolCounts, MetricsResponse, NetworkHealthResponse, Page, - PageQuery, UiBlock, UiTransaction, WalletTransactionFilters, WalletTransactionRow, - WalletTransactionsQuery, WalletUtxoRow, + BlocksQuery, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, + NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, WalletTransactionFilters, + WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, }; use super::{ DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, @@ -254,7 +254,10 @@ pub(super) async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pM Json(state.gossip.metrics()) } -pub(super) async fn api_metrics(State(state): State<HttpState>) -> Json<MetricsResponse> { +pub(super) async fn api_metrics( + State(state): State<HttpState>, + Query(query): Query<MetricsQuery>, +) -> Json<MetricsResponse> { let enabled = state.ui_config.lock().await.keep_track_of_metrics; if !enabled { return Json(MetricsResponse { @@ -264,11 +267,14 @@ pub(super) async fn api_metrics(State(state): State<HttpState>) -> Json<MetricsR }); } 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(); + let rows = tokio::task::spawn_blocking(move || match query.limit { + Some(limit) => store.load_recent_metrics(limit.clamp(1, DATASET_LIMIT)), + None => store.load_metrics(), + }) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_default(); Json(metrics_response(enabled, rows)) } diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -873,6 +873,20 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="block-miner" x-text="blockFinalizerLabel(block)"></div> </button> </template> + <template x-if="loadingInitialBlocks && blocks.length === 0"> + <div> + <div class="block-card skeleton-card" aria-hidden="true"> + <div class="skeleton-line short"></div> + <div class="skeleton-line medium"></div> + <div class="skeleton-line long"></div> + </div> + <div class="block-card skeleton-card" aria-hidden="true"> + <div class="skeleton-line medium"></div> + <div class="skeleton-line short"></div> + <div class="skeleton-line long"></div> + </div> + </div> + </template> <template x-if="loadingOlder"> <div class="block-card skeleton-card" aria-hidden="true"> <div class="skeleton-line short"></div> @@ -1010,7 +1024,17 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <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" x-show="loadingMetrics && metricsCharts().length === 0"> + <article class="metric-chart-card skeleton-card" aria-hidden="true"> + <div class="metric-chart-head"><div class="skeleton-line medium"></div><div class="skeleton-line short"></div></div> + <div class="metric-chart-frame"><div class="skeleton-line long"></div></div> + </article> + <article class="metric-chart-card skeleton-card" aria-hidden="true"> + <div class="metric-chart-head"><div class="skeleton-line short"></div><div class="skeleton-line medium"></div></div> + <div class="metric-chart-frame"><div class="skeleton-line long"></div></div> + </article> + </div> + <div class="metrics-empty" x-show="metricsCharts().length === 0 && !loadingMetrics">No metrics collected yet</div> <div class="metrics-grid"> <template x-for="chart in metricsCharts()" :key="chart.id"> <article class="metric-chart-card"> diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -138,6 +138,11 @@ pub(super) struct BlocksQuery { pub(super) limit: Option<usize>, } +#[derive(Debug, Deserialize)] +pub(super) struct MetricsQuery { + pub(super) limit: Option<usize>, +} + #[derive(Debug, Default, Deserialize)] pub(super) struct PageQuery { pub(super) offset: Option<usize>, diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -7,6 +7,7 @@ window.iunaApp = function iunaApp() { selectedByteBlock: null, selectedTransaction: null, selectedBurnLeaderBlock: null, + loadingInitialBlocks: false, loadingOlder: false, hasMoreBlocks: true, walletTxs: [], @@ -15,6 +16,7 @@ window.iunaApp = function iunaApp() { peers: [], p2pMetrics: {}, blockchainMetrics: { enabled: false, latest: null, charts: [] }, + loadingMetrics: false, metricHover: null, metricsRange: (() => { try { @@ -623,6 +625,8 @@ window.iunaApp = function iunaApp() { const shouldLoadBlocks = tab === "chain" || tab === "mining"; const shouldLoadP2pMetrics = tab === "p2p"; const shouldLoadMetrics = tab === "metrics"; + if (shouldLoadBlocks && this.blocks.length === 0) this.loadingInitialBlocks = true; + if (shouldLoadMetrics && this.metricsCharts().length === 0) this.loadingMetrics = true; const pagedDatasets = []; if (tab === "wallet") pagedDatasets.push("walletTx", "walletUtxo"); if (tab === "chain") pagedDatasets.push("mempool"); @@ -633,7 +637,7 @@ window.iunaApp = function iunaApp() { this.fetchJson("/api/status"), shouldLoadBlocks ? this.fetchJson("/api/blocks?limit=30") : Promise.resolve(null), shouldLoadP2pMetrics ? this.fetchJson("/api/p2p/metrics") : Promise.resolve(this.p2pMetrics), - shouldLoadMetrics ? this.fetchJson("/api/metrics") : Promise.resolve(this.blockchainMetrics), + shouldLoadMetrics ? this.fetchJson(this.metricsPath()) : Promise.resolve(this.blockchainMetrics), this.fetchJson("/api/network/health"), ]); const previousChainHeight = this.status.chain?.height; @@ -678,6 +682,9 @@ window.iunaApp = function iunaApp() { return; } this.showFlash(error.message, "error"); + } finally { + if (shouldLoadBlocks) this.loadingInitialBlocks = false; + if (shouldLoadMetrics) this.loadingMetrics = false; } }, @@ -1655,6 +1662,10 @@ window.iunaApp = function iunaApp() { return this.blockchainMetrics?.latest || {}; }, + metricsPath() { + return this.metricsRange === "all" ? "/api/metrics" : `/api/metrics?limit=${this.metricsRange}`; + }, + setMetricsRange(range) { this.metricsRange = range === 1000 || range === "all" ? range : 100; this.metricHover = null; @@ -1663,6 +1674,10 @@ window.iunaApp = function iunaApp() { } catch { // Non-persistent filtering is fine when storage is unavailable. } + if (this.tab === "metrics") { + this.blockchainMetrics = { enabled: this.blockchainMetrics?.enabled ?? true, latest: this.blockchainMetrics?.latest ?? null, charts: [] }; + this.refresh({ force: true }); + } }, metricChartPoints(chart) {