From c7c3440a7bcd1177d8338f9bacf8283a1f5b51d8 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 2 Aug 2026 20:48:56 -0500 Subject: [PATCH] feat(diagnostics): refresh-staleness badge on the Total Balance card (W6-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the wallet is connected but the balance has quietly stopped refreshing — a busy daemon can fail z_gettotalbalance without dropping the whole connection (only *both* core RPCs failing 3x triggers a disconnect) — the old number sits on screen while the node-status banner stays hidden. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber, escalating to red past 3 min) so the stale value isn't silently trusted; hovering explains it and points at the node connection. No refresh-path changes: WalletState::last_balance_update is already stamped only on a successful fetch (network_refresh_service.cpp), so the badge reads it and computes age against the same std::time clock via util::formatTimeAgoShort. The decision is a pure, unit-tested helper (ui/staleness_badge.h::evaluateStalenessBadge, 45s/180s thresholds) gated on connected so it never contradicts the banner. Closes P2 (5/5). Build-clean; ctest 1/1 (adds testStalenessBadge). Co-Authored-By: Claude Opus 4.8 --- docs/wallet-hardening.md | 5 ++-- src/ui/staleness_badge.h | 48 ++++++++++++++++++++++++++++++++++ src/ui/windows/balance_tab.cpp | 20 ++++++++++++++ src/util/i18n.cpp | 5 ++++ tests/test_phase4.cpp | 33 +++++++++++++++++++++++ 5 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/ui/staleness_badge.h diff --git a/docs/wallet-hardening.md b/docs/wallet-hardening.md index 84f5631..790bad1 100644 --- a/docs/wallet-hardening.md +++ b/docs/wallet-hardening.md @@ -20,8 +20,8 @@ Status legend: ☐ not started · ◐ in progress · ☑ landed & verified | **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ | | **P1-A** | W3-1, W3-2, W3-4 ✓ · W3-3 ⚑ | Migrate-to-seed correctness (fund-adjacent) | ◐ 3/4 | | **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ | -| **P2** | W5-1, W5-2, W6-1, W6-3 ✓ · W6-2 ☐ | Stale state & lite save-failure surfacing | ◐ 4/5 | -| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner ✓, alert-history/staleness ☐ | Diagnostics foundation + QoL bundle | ◐ | +| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 | +| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge ✓, alert-history ☐ | Diagnostics foundation + QoL bundle | ◐ | --- @@ -112,6 +112,7 @@ Land W7-2 first — it unblocks the rest. ## Progress log +- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).** - **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge. - **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge. - **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works): diff --git a/src/ui/staleness_badge.h b/src/ui/staleness_badge.h new file mode 100644 index 0000000..5e01a34 --- /dev/null +++ b/src/ui/staleness_badge.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +// Refresh-staleness badge (finding W6-2). The wallet stamps WalletState::last_balance_update only on +// a *successful* balance fetch (see services/network_refresh_service.cpp), so a busy daemon that fails +// z_gettotalbalance without dropping the whole connection leaves the old balance on screen with a +// frozen timestamp — and the node-status banner (which only fires on a full disconnect) stays hidden. +// This badge is the surface that reflects that "connected but the number may be out of date" state. +// +// The decision is a pure function of (last-success timestamp, now, connected) so it is unit-testable; +// balance_tab.cpp draws the pill. Both use the same std::time(nullptr) wall-clock the refresh path +// stamps with, so age = now - last_update is consistent. +namespace dragonx::ui { + +enum class StalenessSeverity { + Warning, // amber — noticeably behind + Error, // red — very stale, something is likely wrong +}; + +struct StalenessBadge { + bool show = false; + StalenessSeverity severity = StalenessSeverity::Warning; + std::int64_t seconds_old = 0; +}; + +// Balance refreshes every ~2s on the Overview profile (and ~10s while syncing), so tens of seconds +// with no successful update means refreshes are failing, not merely slow. +inline constexpr std::int64_t kStaleAfterSeconds = 45; +inline constexpr std::int64_t kVeryStaleAfterSeconds = 180; + +inline StalenessBadge evaluateStalenessBadge(std::int64_t last_update, std::int64_t now, bool connected) { + StalenessBadge b; + // Offline is the node-status banner's job; don't double up. A zero stamp means "never updated + // this session" (fresh start) or "reset on disconnect" — nothing to be stale about yet. + if (!connected || last_update <= 0) return b; + + std::int64_t age = now - last_update; + if (age < 0) age = 0; // clock skew guard + if (age < kStaleAfterSeconds) return b; + + b.show = true; + b.seconds_old = age; + b.severity = (age >= kVeryStaleAfterSeconds) ? StalenessSeverity::Error : StalenessSeverity::Warning; + return b; +} + +} // namespace dragonx::ui diff --git a/src/ui/windows/balance_tab.cpp b/src/ui/windows/balance_tab.cpp index 46c8aa6..1e07228 100644 --- a/src/ui/windows/balance_tab.cpp +++ b/src/ui/windows/balance_tab.cpp @@ -26,6 +26,7 @@ #include "../effects/imgui_acrylic.h" #include "../sidebar.h" #include "../notifications.h" +#include "../staleness_badge.h" #include "../../embedded/IconsMaterialDesign.h" #include "imgui.h" #include @@ -421,6 +422,25 @@ static void RenderBalanceClassic(App* app) dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + 12 * dp, cy), WithAlpha(Success(), 200), buf); + } else { + // Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance + // hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without + // dropping the whole connection). The node banner only covers full disconnects, so + // this pill is the sole signal that the shown number may be out of date. + StalenessBadge badge = evaluateStalenessBadge( + state.last_balance_update, std::time(nullptr), state.connected); + if (badge.show) { + const bool err = (badge.severity == StalenessSeverity::Error); + ImU32 fg = err ? Error() : Warning(); + ImU32 bg = WithAlpha(fg, 38); + ImU32 bd = WithAlpha(fg, 90); + snprintf(buf, sizeof(buf), "%s %s", + TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str()); + ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd); + // Hover → explain what stale means and how old the data actually is. + if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y))) + Tooltip("%s", TR("data_stale_tooltip")); + } } // Hover glow diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 08918da..e640503 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1315,6 +1315,11 @@ void I18n::loadBuiltinEnglish() strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet"; strings_["node_banner_reconnect"] = "Reconnect"; strings_["node_banner_restart"] = "Restart node"; + // Refresh-staleness badge (W6-2) on the Total Balance card. + strings_["data_stale_prefix"] = "Updated"; + strings_["data_stale_tooltip"] = + "Balance may be out of date — the wallet hasn't received a fresh update recently. " + "Check your node connection."; strings_["daemon_port_busy_warn"] = "Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. " "Close the program using it (or free the port), then restart — the wallet can't start " diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index dac09ff..421d818 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -33,6 +33,7 @@ #include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_tab_helpers.h" #include "ui/node_status_banner.h" +#include "ui/staleness_badge.h" #include "util/address_validation.h" #include "util/amount_format.h" #include "util/payment_uri.h" @@ -2623,6 +2624,37 @@ void testNodeStatusBanner() } } +void testStalenessBadge() +{ + using namespace dragonx::ui; + const int64_t now = 1'000'000; + + // Disconnected → banner's job, never a badge. + EXPECT_TRUE(!evaluateStalenessBadge(now - 999, now, /*connected=*/false).show); + // Never updated this session (0 stamp, e.g. fresh start / reset on disconnect) → nothing. + EXPECT_TRUE(!evaluateStalenessBadge(0, now, true).show); + // Fresh (just under the threshold) → no badge. + EXPECT_TRUE(!evaluateStalenessBadge(now - (kStaleAfterSeconds - 1), now, true).show); + // At the threshold → amber badge, age reported. + { + StalenessBadge b = evaluateStalenessBadge(now - kStaleAfterSeconds, now, true); + EXPECT_TRUE(b.show); + EXPECT_TRUE(b.severity == StalenessSeverity::Warning); + EXPECT_EQ(b.seconds_old, (int64_t)kStaleAfterSeconds); + } + // Past the very-stale threshold → red. + { + StalenessBadge b = evaluateStalenessBadge(now - kVeryStaleAfterSeconds, now, true); + EXPECT_TRUE(b.show); + EXPECT_TRUE(b.severity == StalenessSeverity::Error); + } + // Clock skew (future timestamp) is clamped to age 0 → no badge, no negative age. + { + StalenessBadge b = evaluateStalenessBadge(now + 100, now, true); + EXPECT_TRUE(!b.show); + } +} + void testLoggerFileSink() { using dragonx::util::Logger; @@ -7011,6 +7043,7 @@ int main() testAllowsPlaintextRemote(); testConsoleSecretRedaction(); testNodeStatusBanner(); + testStalenessBadge(); testLoggerFileSink(); testDaemonLifecycleExecution(); testDaemonLifecycleAdapters();