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:
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) {