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

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