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:
+141
@@ -68,6 +68,7 @@
|
||||
#include "ui/material/draw_helpers.h"
|
||||
#include "ui/widgets/copy_field.h"
|
||||
#include "ui/notifications.h"
|
||||
#include "ui/node_status_banner.h"
|
||||
#include "util/i18n.h"
|
||||
#include "util/connect_stall.h"
|
||||
#include "util/platform.h"
|
||||
@@ -1794,6 +1795,11 @@ void App::render()
|
||||
|
||||
ImGui::BeginChild("##ContentArea", ImVec2(0, contentH), false, contentFlags);
|
||||
|
||||
// Persistent node/RPC error banner — drawn first (before the edge-fade vertex capture below,
|
||||
// so it stays fully opaque) and above every page / overlay in the content column. It renders
|
||||
// nothing and consumes no space while the node is reachable.
|
||||
renderNodeStatusBanner();
|
||||
|
||||
// Capture vertex start for edge fade mask
|
||||
ImDrawList* caDL = ImGui::GetWindowDrawList();
|
||||
int caVtxStart = caDL->VtxBuffer.Size;
|
||||
@@ -2142,6 +2148,141 @@ void App::render()
|
||||
ui::material::LatchBlurOverlayActive();
|
||||
}
|
||||
|
||||
void App::renderNodeStatusBanner()
|
||||
{
|
||||
namespace m = ui::material;
|
||||
|
||||
// Suppress during flows that legitimately have no connection, so the banner never contradicts
|
||||
// an overlay the app is already showing: the first-run wizard (no daemon started yet), a
|
||||
// wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and
|
||||
// shutdown. tryConnect() sets connection_in_progress_ before the first render on normal
|
||||
// startup, so the ordinary boot path is covered by the evaluator's own in-progress guard.
|
||||
if (capture_mode_ || isShuttingDown()) return;
|
||||
if (getWizardPhase() != WizardPhase::None) return;
|
||||
if (wallet_switch_phase_.load() != 0) return;
|
||||
if (daemon_restarting_.load()) return;
|
||||
|
||||
ui::NodeBannerInputs in;
|
||||
in.lite = isLiteBuild();
|
||||
in.connected = state_.connected;
|
||||
in.warming_up = state_.warming_up;
|
||||
in.daemon_initializing = state_.daemon_initializing;
|
||||
in.connection_in_progress = connection_in_progress_;
|
||||
in.using_embedded_daemon = isUsingEmbeddedDaemon();
|
||||
in.has_daemon_controller = (daemon_controller_ != nullptr);
|
||||
in.daemon_running = isEmbeddedDaemonRunning();
|
||||
in.daemon_crash_count = daemon_controller_ ? daemon_controller_->crashCount() : 0;
|
||||
in.connection_status = connection_status_;
|
||||
in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string();
|
||||
in.lite_open_error = lite_open_error_;
|
||||
|
||||
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
||||
if (!banner.show) return;
|
||||
|
||||
const auto& S = ui::schema::UI();
|
||||
const float minH = S.drawElement("banners.node-status", "min-height").size;
|
||||
const float baseH = S.drawElement("banners.node-status", "height").size;
|
||||
const float bannerH = std::max(minH, baseH * ui::Layout::vScale());
|
||||
|
||||
const bool isError = (banner.severity == ui::NodeBannerSeverity::Error);
|
||||
const ImU32 sevCol = isError ? m::Error() : m::Warning();
|
||||
const ImU32 bgCol = m::WithAlphaF(sevCol, isError ? 0.20f : 0.15f);
|
||||
|
||||
// Translated headline for the reason; `detail` is the live status text (may be empty).
|
||||
const char* title;
|
||||
const char* icon;
|
||||
switch (banner.reason) {
|
||||
case ui::NodeBannerReason::DaemonCrashed:
|
||||
title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break;
|
||||
case ui::NodeBannerReason::LiteOpenFailed:
|
||||
title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break;
|
||||
case ui::NodeBannerReason::FullNodeOffline:
|
||||
default:
|
||||
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||
}
|
||||
|
||||
const char* actionLabel = nullptr;
|
||||
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
||||
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
||||
|
||||
const float padX = ui::Layout::spacingLg();
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgCol));
|
||||
ImGui::BeginChild("##NodeStatusBanner",
|
||||
ImVec2(ImGui::GetContentRegionAvail().x, bannerH), false,
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
const float winW = ImGui::GetWindowSize().x;
|
||||
|
||||
ImFont* icoFont = m::Type().iconSmall();
|
||||
ImFont* txtFont = m::Type().body2();
|
||||
|
||||
// Icon — centered on its own metrics.
|
||||
ImGui::SetCursorPos(ImVec2(padX, (bannerH - icoFont->LegacySize) * 0.5f));
|
||||
ImGui::PushFont(icoFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
|
||||
ImGui::TextUnformatted(icon);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
|
||||
const float txtCy = (bannerH - txtFont->LegacySize) * 0.5f;
|
||||
|
||||
// Right-aligned action button geometry (measured first so the detail text can be clipped to
|
||||
// never run underneath it).
|
||||
float btnW = 0.0f, btnH = 0.0f, actionReserve = 0.0f;
|
||||
if (actionLabel) {
|
||||
btnH = std::max(0.0f, bannerH - ui::Layout::spacingSm() * 2.0f);
|
||||
btnW = ImGui::CalcTextSize(actionLabel).x + ui::Layout::spacingLg() * 1.6f;
|
||||
actionReserve = btnW + padX + ui::Layout::spacingMd();
|
||||
}
|
||||
|
||||
// Title.
|
||||
ImGui::SameLine(0.0f, ui::Layout::spacingSm());
|
||||
ImGui::SetCursorPosY(txtCy);
|
||||
ImGui::PushFont(txtFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
|
||||
ImGui::TextUnformatted(title);
|
||||
ImGui::PopStyleColor();
|
||||
|
||||
// Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen.
|
||||
if (!banner.detail.empty()) {
|
||||
ImGui::SameLine(0.0f, ui::Layout::spacingSm());
|
||||
ImGui::SetCursorPosY(txtCy);
|
||||
const float budget = winW - ImGui::GetCursorPosX() - actionReserve;
|
||||
if (budget > ImGui::CalcTextSize("W").x) {
|
||||
const std::string prefix = "\xC2\xB7 "; // "· "
|
||||
std::string detail = banner.detail;
|
||||
std::string shown = prefix + detail;
|
||||
if (ImGui::CalcTextSize(shown.c_str()).x > budget) {
|
||||
const std::string ell = "\xE2\x80\xA6"; // "…"
|
||||
while (!detail.empty() &&
|
||||
ImGui::CalcTextSize((prefix + detail + ell).c_str()).x > budget) {
|
||||
detail.pop_back();
|
||||
while (!detail.empty() &&
|
||||
(static_cast<unsigned char>(detail.back()) & 0xC0) == 0x80)
|
||||
detail.pop_back(); // drop the whole trailing UTF-8 code point
|
||||
}
|
||||
shown = prefix + detail + ell;
|
||||
}
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(shown.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
}
|
||||
ImGui::PopFont();
|
||||
|
||||
// Action button.
|
||||
if (actionLabel) {
|
||||
ImGui::SetCursorPos(ImVec2(winW - btnW - padX, (bannerH - btnH) * 0.5f));
|
||||
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
||||
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
||||
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
void App::renderStatusBar()
|
||||
{
|
||||
// Status bar layout from unified UI schema
|
||||
|
||||
@@ -1295,6 +1295,10 @@ private:
|
||||
|
||||
// Private methods - rendering
|
||||
void renderStatusBar();
|
||||
// Persistent node/RPC error strip at the top of the content column when the wallet can't
|
||||
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
|
||||
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
|
||||
void renderNodeStatusBanner();
|
||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||
void renderImportKeyDialog();
|
||||
|
||||
@@ -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
|
||||
@@ -1309,6 +1309,12 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["sb_connecting_err"] = "Connecting to daemon — %s";
|
||||
strings_["sb_daemon_crashed"] = "Daemon crashed %d times";
|
||||
strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd";
|
||||
// Persistent node-status banner (App::renderNodeStatusBanner).
|
||||
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
|
||||
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
|
||||
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
||||
strings_["node_banner_reconnect"] = "Reconnect";
|
||||
strings_["node_banner_restart"] = "Restart node";
|
||||
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 "
|
||||
|
||||
Reference in New Issue
Block a user