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:
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 "-";