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:
@@ -0,0 +1,111 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// Persistent node-connectivity banner shown at the top of the content column when the wallet
|
||||
// cannot reach its node. Distinct from the transient toast notifications: it stays visible for
|
||||
// as long as the fault persists, so an offline wallet is never silently mistaken for a working
|
||||
// one. The decision (whether to show, how severe, which action) is a pure function of a state
|
||||
// snapshot so it can be unit-tested; App::renderNodeStatusBanner() feeds it the live state and
|
||||
// draws the strip. See src/app.cpp.
|
||||
namespace dragonx::ui {
|
||||
|
||||
// Visual weight. Warning (amber) = recoverable / a reconnect is offered; Error (red) = a hard
|
||||
// fault the user must act on (the daemon gave up crashing, or a lite wallet failed to open).
|
||||
enum class NodeBannerSeverity {
|
||||
Warning,
|
||||
Error,
|
||||
};
|
||||
|
||||
// What the banner's action button does. App maps this to the concrete call.
|
||||
enum class NodeBannerAction {
|
||||
None, // no button — nothing the user can usefully do from here
|
||||
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
||||
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
||||
};
|
||||
|
||||
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
||||
// already-human-readable status text (connection_status_ / daemon lastError / lite open error).
|
||||
enum class NodeBannerReason {
|
||||
None,
|
||||
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
||||
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
||||
LiteOpenFailed, // lite build: the wallet failed to open
|
||||
};
|
||||
|
||||
struct NodeBannerState {
|
||||
bool show = false;
|
||||
NodeBannerSeverity severity = NodeBannerSeverity::Warning;
|
||||
NodeBannerReason reason = NodeBannerReason::None;
|
||||
NodeBannerAction action = NodeBannerAction::None;
|
||||
std::string detail; // passthrough status/error text (may be empty)
|
||||
};
|
||||
|
||||
// Snapshot of the connection state the banner reads. Plain values so the decision is testable
|
||||
// without an App instance.
|
||||
struct NodeBannerInputs {
|
||||
bool lite = false; // lite build (no embedded daemon / RPC)
|
||||
bool connected = false; // state_.connected — the master "online" flag
|
||||
bool warming_up = false; // daemon reachable, RPC warmup (code -28)
|
||||
bool daemon_initializing = false; // daemon launching / block index loading
|
||||
bool connection_in_progress = false; // a connect attempt is actively running
|
||||
|
||||
// Full-node embedded-daemon crash signal.
|
||||
bool using_embedded_daemon = false;
|
||||
bool has_daemon_controller = false;
|
||||
bool daemon_running = false;
|
||||
int daemon_crash_count = 0;
|
||||
|
||||
std::string connection_status; // human-readable status line (already translated)
|
||||
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||
};
|
||||
|
||||
// Auto-restart give-up threshold — mirrors the crash cap in app_network.cpp's connect loop.
|
||||
inline constexpr int kNodeBannerCrashGiveUpCount = 3;
|
||||
|
||||
inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
||||
NodeBannerState s;
|
||||
|
||||
if (in.lite) {
|
||||
// Lite has no daemon/RPC; "online" == wallet open. Only a genuine open failure is a
|
||||
// fault worth a persistent banner (a not-yet-created wallet is handled by the normal
|
||||
// "No wallet open" prompt, and leaves lite_open_error empty).
|
||||
if (!in.connected && !in.lite_open_error.empty()) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Error;
|
||||
s.reason = NodeBannerReason::LiteOpenFailed;
|
||||
s.action = NodeBannerAction::None;
|
||||
s.detail = in.lite_open_error;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
|
||||
// the screen, so no banner. An active connect attempt likewise shows progress, not an
|
||||
// error — don't flicker a banner over it.
|
||||
if (in.connected) return s;
|
||||
if (in.warming_up || in.daemon_initializing) return s;
|
||||
if (in.connection_in_progress) return s;
|
||||
|
||||
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
||||
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
||||
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
||||
in.daemon_crash_count >= kNodeBannerCrashGiveUpCount) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Error;
|
||||
s.reason = NodeBannerReason::DaemonCrashed;
|
||||
s.action = NodeBannerAction::RestartNode;
|
||||
s.detail = !in.daemon_last_error.empty() ? in.daemon_last_error : in.connection_status;
|
||||
return s;
|
||||
}
|
||||
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Warning;
|
||||
s.reason = NodeBannerReason::FullNodeOffline;
|
||||
s.action = NodeBannerAction::Reconnect;
|
||||
s.detail = in.connection_status;
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace dragonx::ui
|
||||
Reference in New Issue
Block a user