iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 1924a69100a9a9012e6682cb188779c796f31155
parent e327e8555908eaeeec857ab5f3422a4f602162cb
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon, 24 Aug 2026 10:09:32 +0200

Block management UI while syncing

Diffstat:
Msrc/adapters/http/index_html.rs | 26++++++++++++++++++++++++--
Msrc/main_tests.rs | 11+++++++++++
Mwww/assets/iuna-ui.js | 56+++++++++++++++++++++++++++++++++++++++++++++++---------
3 files changed, 82 insertions(+), 11 deletions(-)

diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -98,6 +98,17 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .page-title { margin-bottom: 16px; } .setup-overlay { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 22px; background: rgba(8, 9, 10, .72); backdrop-filter: blur(8px); } .transaction-overlay { z-index: 40; } + .sync-overlay { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 22px; background: #0f1012; } + .sync-screen { width: min(460px, 100%); display: grid; justify-items: center; gap: 18px; text-align: center; } + .sync-mark { width: 54px; height: 54px; display: grid; place-items: center; border: 1px solid #566d25; border-radius: 14px; background: #1c2516; box-shadow: 0 18px 48px rgba(0, 0, 0, .32); } + .sync-spinner { width: 25px; height: 25px; border: 3px solid rgba(213, 245, 95, .2); border-top-color: #d5f55f; border-radius: 999px; animation: sync-spin .9s linear infinite; } + .sync-screen h1 { margin: 0; font-size: clamp(25px, 5vw, 36px); } + .sync-copy { margin: -8px 0 0; color: #9fa8ad; line-height: 1.5; } + .sync-progress { width: 100%; height: 9px; overflow: hidden; border: 1px solid #30373b; border-radius: 999px; background: #191c1f; } + .sync-progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #8de9cd, #d5f55f); transition: width .35s ease; } + .sync-progress-label { color: #d5f55f; font-size: 14px; font-weight: 850; font-variant-numeric: tabular-nums; } + @keyframes sync-spin { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { .sync-spinner { animation-duration: 1.8s; } .sync-progress-fill { transition: none; } } .setup-modal { width: min(980px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 18px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .42); } .setup-modal-head { display: grid; gap: 5px; margin-bottom: 16px; } .setup-modal-head h2 { margin: 0; font-size: 24px; } @@ -456,11 +467,11 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=110"></script> + <script defer src="/assets/iuna-ui.js?v=111"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> - <div class="app-shell"> + <div class="app-shell" :inert="syncingNode()"> <aside class="sidebar" aria-label="iuna navigation"> <div class="brand-mark" title="iuna" aria-label="iuna"><svg viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle class="mark-dot" cx="9.4" cy="7.6" r="2.8"></circle><path class="mark-loop" d="M9.4 13v7.1c0 3.7 2.9 6.4 6.6 6.4s6.6-2.7 6.6-6.4V13"></path></svg></div> <nav class="side-nav"> @@ -1250,6 +1261,17 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </section> </main> </div> + <div class="sync-overlay" x-show="syncingNode()" role="status" aria-live="polite" aria-label="Blockchain synchronization in progress"> + <section class="sync-screen"> + <div class="sync-mark" aria-hidden="true"><div class="sync-spinner"></div></div> + <h1>Synchronizing blockchain</h1> + <p class="sync-copy">Your node is catching up with the network. The interface will unlock automatically when synchronization is complete.</p> + <div class="sync-progress" role="progressbar" aria-label="Blockchain sync progress" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="Math.round(syncProgressPercent())"> + <div class="sync-progress-fill" :style="`width: ${syncProgressPercent()}%`"></div> + </div> + <div class="sync-progress-label" x-text="syncProgressLabel()"></div> + </section> + </div> <div class="setup-overlay transaction-overlay" x-show="chainResetModalOpen" x-transition.opacity @click.self="closeChainResetModal()" role="dialog" aria-modal="true" aria-labelledby="chain-reset-title"> <section class="tx-modal"> <div class="tx-modal-head"> diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -78,6 +78,17 @@ fn management_ui_warns_when_estimated_fees_exceed_amounts() { assert!(javascript.contains("estimated fee IUNA ${this.amountLabel(fee)} exceeds")); } +#[test] +fn management_ui_blocks_interaction_while_the_node_is_syncing() { + let html = include_str!("adapters/http/index_html.rs"); + let javascript = include_str!("../www/assets/iuna-ui.js"); + assert!(html.contains(":inert=\"syncingNode()\"")); + assert!(html.contains("Synchronizing blockchain")); + assert!(html.contains("syncProgressPercent()")); + assert!(javascript.contains("this.networkHealth.state === \"syncing\"")); + assert!(javascript.contains("Syncing ${this.syncCurrentHeight().toLocaleString()} of")); +} + fn ledger_with_one_spendable_iuna(wallet: &Wallet) -> Ledger { let mut genesis = BTreeMap::new(); genesis.insert(wallet.address().to_string(), 3); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -33,6 +33,7 @@ window.iunaApp = function iunaApp() { return 100; })(), networkHealth: {}, + networkHealthLoaded: false, uiMode: (() => { try { return localStorage.getItem("iunaUiMode") === "advanced" ? "advanced" : "basic"; @@ -660,15 +661,20 @@ window.iunaApp = function iunaApp() { if (tab === "chain") pagedDatasets.push("mempool"); if (tab === "p2p") pagedDatasets.push("peer"); try { - const [config, status, blocks, p2pMetrics, blockchainMetrics] = await Promise.all([ + const [config, status, networkHealth, blocks, p2pMetrics, blockchainMetrics] = await Promise.all([ this.fetchJson("/api/config"), this.fetchJson("/api/status"), + this.refreshNetworkHealth({ silent: options.silent === true }), shouldLoadBlocks ? this.fetchJson("/api/blocks?limit=30") : Promise.resolve(null), shouldLoadP2pMetrics ? this.fetchJson("/api/p2p/metrics") : Promise.resolve(this.p2pMetrics), Promise.resolve(this.blockchainMetrics), ]); const previousChainHeight = this.status.chain?.height; this.status = status; + if (networkHealth) { + this.networkHealth = networkHealth; + this.networkHealthLoaded = true; + } this.config = config; this.syncConfigState({ addressBookVersion }); if (!this.allowedTabs().includes(this.tab)) { @@ -696,7 +702,6 @@ window.iunaApp = function iunaApp() { this.lastUpdated = new Date(); this.syncMiningEvents({ status, blocks }); this.scheduleFeeEstimates(); - this.refreshNetworkHealth({ silent: options.silent === true }); await Promise.all( pagedDatasets.map((kind) => this.refreshPagedDataset(kind, { silent: options.silent === true }) @@ -721,10 +726,15 @@ window.iunaApp = function iunaApp() { this.shellRefreshPromise = Promise.all([ this.fetchJson("/api/config"), this.fetchJson("/api/status"), + this.refreshNetworkHealth({ silent: true }), ]) - .then(async ([config, status]) => { + .then(async ([config, status, networkHealth]) => { const previousChainHeight = this.status.chain?.height; this.status = status; + if (networkHealth) { + this.networkHealth = networkHealth; + this.networkHealthLoaded = true; + } this.config = config; this.syncConfigState({ addressBookVersion }); if (!this.allowedTabs().includes(this.tab)) { @@ -748,7 +758,6 @@ window.iunaApp = function iunaApp() { this.lastUpdated = new Date(); this.syncMiningEvents({ status, blocks: null }); this.scheduleFeeEstimates(); - this.refreshNetworkHealth({ silent: true }); }) .catch((error) => { if (options.silent !== true) this.showFlash(error.message, "error"); @@ -760,13 +769,10 @@ window.iunaApp = function iunaApp() { }, async refreshNetworkHealth(options = {}) { - if (!this.canUseProtectedApi()) return; + if (!this.canUseProtectedApi()) return null; if (this.networkHealthPromise) return this.networkHealthPromise; this.networkHealthPromise = this.fetchJson("/api/network/health") - .then((networkHealth) => { - this.networkHealth = networkHealth; - return networkHealth; - }) + .then((networkHealth) => networkHealth) .catch((error) => { if (options.silent !== true) this.showFlash(error.message, "error"); return null; @@ -2960,6 +2966,38 @@ window.iunaApp = function iunaApp() { return "error"; }, + syncingNode() { + return ( + this.canUseProtectedApi() && + this.config.setup_complete === true && + this.networkHealthLoaded && + this.networkHealth.state === "syncing" + ); + }, + + syncCurrentHeight() { + const height = Number(this.networkHealth.local_height ?? this.status.chain?.height ?? 0); + return Number.isFinite(height) && height >= 0 ? Math.floor(height) : 0; + }, + + syncTargetHeight() { + const target = Number(this.networkHealth.best_known_height ?? this.syncCurrentHeight()); + return Number.isFinite(target) && target >= 0 + ? Math.max(this.syncCurrentHeight(), Math.floor(target)) + : this.syncCurrentHeight(); + }, + + syncProgressPercent() { + const current = this.syncCurrentHeight(); + const target = this.syncTargetHeight(); + if (target <= 0) return 0; + return Math.max(0, Math.min(100, (current / target) * 100)); + }, + + syncProgressLabel() { + return `Syncing ${this.syncCurrentHeight().toLocaleString()} of ${this.syncTargetHeight().toLocaleString()}`; + }, + networkLagLabel() { const lag = this.networkHealth.lag_blocks; if (typeof lag !== "number") return "-";