feat(ux): robust sync ETA, persist Explorer settings, gate export-all out of lite

- Sync ETA: a new pure util::SyncRateEstimator (App-owned) replaces the
  function-local statics that went stale after a rescan/restart (producing the
  "412h" ETA). It self-resets on a height drop and on connect/disconnect/rescan/
  restart (via an atomic flag, since onConnected can run off-thread), formats
  with days, and uses the Warning() token.
- Settings: the Explorer-tab URL fields + custom-fees/fetch-prices checkboxes
  now persist on edit — they were the only controls still relying on the
  About-only Save button that a user on this tab never sees.
- Export-all-keys: hidden in the lite build (which has its own key export),
  instead of dead-ending on "Not connected to the daemon".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 13:02:46 -05:00
co-authored by Claude Opus 4.8
parent 8fe20f6385
commit f3f0b32a48
5 changed files with 99 additions and 42 deletions
+13 -37
View File
@@ -3084,45 +3084,19 @@ void App::renderStatusBar()
int blocksLeft = chainTip - state_.sync.blocks;
if (blocksLeft < 0) blocksLeft = 0;
// Calculate sync speed (blocks/sec) using a smoothed rolling average
static int s_prev_blocks = 0;
static double s_prev_time = 0.0;
static double s_blocks_per_sec = 0.0;
double now = ImGui::GetTime();
if (s_prev_time == 0.0) {
// First sample — seed baseline, don't compute rate yet
s_prev_blocks = state_.sync.blocks;
s_prev_time = now;
} else if (state_.sync.blocks > s_prev_blocks) {
double dt = now - s_prev_time;
if (dt > 0.5) {
double raw = (state_.sync.blocks - s_prev_blocks) / dt;
// Exponential smoothing (alpha ~0.3 per update)
s_blocks_per_sec = s_blocks_per_sec > 0.0
? s_blocks_per_sec * 0.7 + raw * 0.3
: raw;
s_prev_blocks = state_.sync.blocks;
s_prev_time = now;
}
} else if (now - s_prev_time > 10.0) {
// No new blocks for 10s — decay the displayed rate
s_blocks_per_sec *= 0.5;
s_prev_time = now;
}
if (s_blocks_per_sec > 0.1) {
int eta_sec = (int)(blocksLeft / s_blocks_per_sec);
// App-owned smoothed sync-rate estimator: self-resets after a rescan/restart height drop (and on
// connect/disconnect via the pending flag), so the ETA never goes stale/absurd ("412h"); days-aware.
if (sync_eta_reset_pending_.exchange(false)) sync_eta_.reset();
sync_eta_.update(state_.sync.blocks, ImGui::GetTime());
const ImVec4 syncCol = ImGui::ColorConvertU32ToFloat4(ui::material::Warning());
const long long etaSec = sync_eta_.etaSeconds(blocksLeft);
if (etaSec >= 0) {
char eta[32];
if (eta_sec >= 3600)
snprintf(eta, sizeof(eta), "%dh %dm", eta_sec / 3600, (eta_sec % 3600) / 60);
else if (eta_sec >= 60)
snprintf(eta, sizeof(eta), "%dm %ds", eta_sec / 60, eta_sec % 60);
else
snprintf(eta, sizeof(eta), "%ds", eta_sec);
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_eta"),
state_.sync.verification_progress * 100.0, blocksLeft, s_blocks_per_sec, eta);
util::formatEta(etaSec, eta, sizeof(eta));
ImGui::TextColored(syncCol, TR("sb_syncing_eta"),
state_.sync.verification_progress * 100.0, blocksLeft, sync_eta_.rate(), eta);
} else {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_basic"),
ImGui::TextColored(syncCol, TR("sb_syncing_basic"),
state_.sync.verification_progress * 100.0, blocksLeft);
}
} else if (state_.connected && !isLiteBuild()) {
@@ -6337,6 +6311,7 @@ void App::rescanBlockchain()
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
state_.sync.rescanning = true;
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // rescan drops height → reset ETA
rescan_confirmed_active_ = false;
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
state_.sync.rescan_progress = 0.0f;
@@ -8136,6 +8111,7 @@ std::string App::buildDiagnosticsReport()
void App::restartDaemon()
{
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
return;
+5
View File
@@ -42,6 +42,7 @@
#include "resources/provision_state.h"
#include "util/background_sampler.h"
#include "util/coalescing_writer.h"
#include "util/sync_eta.h"
#include "util/fetch_backoff.h"
#include "util/idle_tracker.h"
#include "util/pool_stats_service.h"
@@ -1752,6 +1753,10 @@ private:
std::atomic<bool> mining_toggle_in_progress_{false};
// Mining error state (main-thread only — every writer is in a MainCb or render).
std::string mining_last_error_;
// Sync-ETA estimator (UI thread only). onConnected/onDisconnected may run off-thread, so they set
// the atomic flag and the status bar consumes it before update().
util::SyncRateEstimator sync_eta_;
std::atomic<bool> sync_eta_reset_pending_{false};
bool mining_error_av_ = false;
void setMiningError(std::string msg, bool av = false) { mining_last_error_ = std::move(msg); mining_error_av_ = av; }
+2
View File
@@ -741,6 +741,7 @@ void App::tryConnect()
void App::onConnected()
{
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // fresh connect → recompute sync ETA
++rpc_session_;
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
state_.connected = true;
@@ -854,6 +855,7 @@ void App::onConnected()
void App::onDisconnected(const std::string& reason)
{
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
// Persist what the history snapshot is still missing while the rows, the address set (the cache identity),
// the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the
// session reset below empty them, which would turn a later flush into a silent no-op.
+12 -2
View File
@@ -1548,9 +1548,13 @@ void RenderSettingsPage(App* app) {
if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key"));
last = cy + bh; cy += bh + bgp;
// Export-all only works over the full-node RPC; hide it in lite (which has its own key export
// in the lite wallet section) rather than dead-ending on "Not connected to the daemon".
if (fullNode) {
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
last = cy + bh; cy += bh + bgp;
}
if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv"));
last = cy + bh;
@@ -2727,7 +2731,10 @@ void RenderSettingsPage(App* app) {
ImGui::TextUnformatted(TR("transaction_url"));
ImGui::SameLine(urlRowX + urlLblW);
ImGui::SetNextItemWidth(urlInputW);
// These Explorer-tab controls persist on edit — they were the only ones still relying on the
// About-only Save button, which a user on this tab never sees.
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
@@ -2737,6 +2744,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(urlRowX + urlLblW);
ImGui::SetNextItemWidth(urlInputW);
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
ImGui::PopFont();
@@ -2754,10 +2762,12 @@ void RenderSettingsPage(App* app) {
// Checkboxes + Block Explorer button (button wraps to its own row when it won't fit).
const float expRowRight = ImGui::GetCursorScreenPos().x + contentW;
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
if (ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
ImGui::SameLine(0, Layout::spacingLg());
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
if (ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
+64
View File
@@ -0,0 +1,64 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// A tiny, pure block-sync rate estimator for the status-bar / peers-card ETA. Pure (no ImGui, no App):
// App owns one instance, feeds it the current height + a monotonic clock once per frame while syncing,
// and resets it on connect / disconnect / rescan / restart so a height drop never produces a stale or
// absurd ("412h") ETA.
#pragma once
#include <cstddef>
#include <cstdio>
namespace dragonx {
namespace util {
class SyncRateEstimator {
public:
void reset() { prevBlocks_ = -1; prevTime_ = 0.0; rate_ = 0.0; }
// Call once per frame while syncing. `now` is monotonic seconds (e.g. ImGui::GetTime()).
void update(int blocks, double now) {
// First sample, a rescan/restart height drop, or a clock reset → re-baseline, don't compute.
if (prevBlocks_ < 0 || blocks < prevBlocks_ || now < prevTime_) {
prevBlocks_ = blocks; prevTime_ = now; rate_ = 0.0; return;
}
const double dt = now - prevTime_;
if (blocks > prevBlocks_) {
if (dt > 0.5) {
const double raw = (blocks - prevBlocks_) / dt;
rate_ = rate_ > 0.0 ? rate_ * 0.7 + raw * 0.3 : raw; // smooth (same alpha as the old code)
prevBlocks_ = blocks; prevTime_ = now;
}
} else if (dt > 10.0) { // stalled: decay and re-baseline the clock
rate_ *= 0.5; prevTime_ = now;
if (rate_ < 0.1) rate_ = 0.0;
}
}
double rate() const { return rate_; } // blocks/sec, 0 when unknown
// <= 0 when unknown. Capped at ~1 year so a tiny rate can't overflow.
long long etaSeconds(int blocksLeft) const {
if (rate_ <= 0.1 || blocksLeft <= 0) return -1;
const double s = blocksLeft / rate_;
return s > 3.15e7 ? 31500000LL : static_cast<long long>(s);
}
private:
int prevBlocks_ = -1; // -1 = unset (distinct from a legitimate height 0)
double prevTime_ = 0.0;
double rate_ = 0.0;
};
// Human ETA with days, e.g. "3d 4h", "5h 12m", "7m 9s", "42s". English unit letters (as the old code).
inline void formatEta(long long s, char* out, std::size_t n) {
if (s >= 86400) std::snprintf(out, n, "%lldd %lldh", s / 86400, (s % 86400) / 3600);
else if (s >= 3600) std::snprintf(out, n, "%lldh %lldm", s / 3600, (s % 3600) / 60);
else if (s >= 60) std::snprintf(out, n, "%lldm %llds", s / 60, s % 60);
else std::snprintf(out, n, "%llds", s);
}
} // namespace util
} // namespace dragonx