feat(diagnostics): refresh-staleness badge on the Total Balance card (W6-2)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 20:48:56 -05:00
parent e779ded2e8
commit c7c3440a7b
5 changed files with 109 additions and 2 deletions

View File

@@ -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):

48
src/ui/staleness_badge.h Normal file
View File

@@ -0,0 +1,48 @@
#pragma once
#include <cstdint>
// 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

View File

@@ -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 <toml++/toml.hpp>
@@ -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

View File

@@ -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 "

View File

@@ -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();