feat(diagnostics): persistent node/RPC error banner at top of content (Foundation QoL)

A persistent horizontal strip now appears at the top of the content column whenever the
wallet can't reach its node — unlike the transient toasts it stays up for as long as the
fault persists, so an offline wallet is never silently mistaken for a working one.

The show/severity/action decision is a pure, unit-tested function
(ui/node_status_banner.h::evaluateNodeStatusBanner) fed a state snapshot by the new
App::renderNodeStatusBanner(). Three cases:
  - full-node offline        -> amber, "Reconnect"    (App::tryConnect)
  - embedded daemon crashed
    & auto-restart gave up    -> red,   "Restart node" (App::restartDaemon)
  - lite wallet open failed   -> 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. Banner height lives in res/themes/ui.toml (banners.node-status); colours come from the
material semantic palette; the detail text is ellipsis-clipped so it can't push the action
button off-screen. Drawn before the content edge-fade vertex capture so it stays fully opaque.

New i18n keys (node_banner_*). Build-clean both variants; ctest 1/1 (adds testNodeStatusBanner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 20:21:05 -05:00
parent 940dd21464
commit e779ded2e8
7 changed files with 347 additions and 1 deletions

View File

@@ -32,6 +32,7 @@
#include "ui/windows/mining_benchmark.h"
#include "ui/windows/mining_pool_panel.h"
#include "ui/windows/mining_tab_helpers.h"
#include "ui/node_status_banner.h"
#include "util/address_validation.h"
#include "util/amount_format.h"
#include "util/payment_uri.h"
@@ -2547,6 +2548,81 @@ void testConsoleSecretRedaction()
EXPECT_EQ(RedactConsoleCommand("getwalletinfo"), std::string("getwalletinfo"));
}
void testNodeStatusBanner()
{
using namespace dragonx::ui;
// Connected full node → no banner.
{
NodeBannerInputs in; in.connected = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
}
// Expected startup phases own the screen (loading/warmup overlay) → no banner.
{
NodeBannerInputs in; in.warming_up = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
NodeBannerInputs in2; in2.daemon_initializing = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in2).show);
NodeBannerInputs in3; in3.connection_in_progress = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(in3).show);
}
// Genuinely offline full node → amber, reconnect offered, detail passed through.
{
NodeBannerInputs in;
in.connection_status = "Lost connection to daemon";
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline);
EXPECT_TRUE(s.action == NodeBannerAction::Reconnect);
EXPECT_EQ(s.detail, std::string("Lost connection to daemon"));
}
// Embedded daemon crashed and auto-restart gave up → red, restart offered, lastError preferred.
{
NodeBannerInputs in;
in.using_embedded_daemon = true;
in.has_daemon_controller = true;
in.daemon_running = false;
in.daemon_crash_count = kNodeBannerCrashGiveUpCount;
in.daemon_last_error = "exit code 134";
in.connection_status = "Daemon crashed 3 times";
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
EXPECT_TRUE(s.reason == NodeBannerReason::DaemonCrashed);
EXPECT_TRUE(s.action == NodeBannerAction::RestartNode);
EXPECT_EQ(s.detail, std::string("exit code 134"));
}
// Below the give-up threshold it's still just an offline/reconnect banner, not the crash one.
{
NodeBannerInputs in;
in.using_embedded_daemon = true;
in.has_daemon_controller = true;
in.daemon_running = false;
in.daemon_crash_count = kNodeBannerCrashGiveUpCount - 1;
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline);
EXPECT_TRUE(s.action == NodeBannerAction::Reconnect);
}
// Lite: an open failure shows a red, action-less banner; no failure → nothing.
{
NodeBannerInputs in; in.lite = true; in.connected = false;
in.lite_open_error = "wallet.dat is corrupt";
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.show);
EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
EXPECT_TRUE(s.reason == NodeBannerReason::LiteOpenFailed);
EXPECT_TRUE(s.action == NodeBannerAction::None);
EXPECT_EQ(s.detail, std::string("wallet.dat is corrupt"));
NodeBannerInputs clean; clean.lite = true; clean.connected = false; // no error yet
EXPECT_TRUE(!evaluateNodeStatusBanner(clean).show);
NodeBannerInputs open; open.lite = true; open.connected = true;
EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
}
}
void testLoggerFileSink()
{
using dragonx::util::Logger;
@@ -6934,6 +7010,7 @@ int main()
testIsLocalHost();
testAllowsPlaintextRemote();
testConsoleSecretRedaction();
testNodeStatusBanner();
testLoggerFileSink();
testDaemonLifecycleExecution();
testDaemonLifecycleAdapters();