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:
+13
-37
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user