Files
ObsidianDragon/src/ui/node_status_banner.h
T
DanSandClaude Opus 5.5 1215ded1a0 feat(ui): say "Node busy" instead of "Launching dragonxd"; honest quit copy
A node rebuilding note witnesses for an hour was shown as "Starting
DragonX node... Launching dragonxd", which invited closing it.

- Overlay, a new NodeBusy banner and the status bar now say what the node
  is doing (rebuilding witnesses / catching up on blocks / up but not
  answering), with the elapsed time and Phase-1 block progress. A rebuild
  during startup warm-up replaces the stale "-28 Activating best chain"
  text, and the overlay re-applies its text once a second instead of only
  on a connect attempt (30s apart while the node is busy).
- Quit prompt: elapsed time, "Keep node running & quit" is the default,
  "Stop anyway & quit" becomes "Stop node & wait" (it no longer kills).
- A reinstall skipped because the node is still running now notifies.
- Status bar: don't repeat the warm-up status on the right, and don't
  print "Connected" there in non-English locales.
- i18n: 31 new keys in all 8 languages; 10 dead force-quit/old-modal keys
  removed; CJK subset rebuilt for the new glyphs.
- Tests: chooseDaemonStatus, NodeBusy banner priority, and every new key
  present in all 8 JSONs with matching printf conversions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:26:32 -05:00

152 lines
7.3 KiB
C++

#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
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
};
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
// Peer connectivity: a full node connected to the daemon but with NO peer-derived tip
// (longestchain == 0 -> tip_known false) has no peers, so it silently stalls at a stale height
// while balance/sync look "done". `blocks` gates the check so we don't flag the brief window
// before the first chain-info refresh. (audit: no-peer-banner)
bool tip_known = true; // sync.tip_known — a peer-derived network tip is known
int blocks = 0; // sync.blocks — >0 once chain info has been fetched
// 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;
// The node is running but busy with something it can't be interrupted out of (a witness rebuild, the
// startup replay) or too busy to answer RPC. Wallet data may be stale; a reconnect would only add
// requests, so the banner offers no action. node_busy_detail is the live, translated description.
bool node_busy = false;
std::string node_busy_detail;
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, busy. Before the startup-phase returns below: the overlay sets daemon_initializing while
// the node is busy too, and the banner must still say why the wallet isn't updating. No action: a
// "Reconnect" would only queue more requests behind the busy node.
if (in.node_busy) {
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::NodeBusy;
s.action = NodeBannerAction::None;
s.detail = in.node_busy_detail;
return s;
}
// Full node. 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. (These return even if `connected` has flipped early, matching prior behavior.)
if (in.warming_up || in.daemon_initializing) return s;
if (in.connection_in_progress) return s;
// Connected to the daemon (RPC works) but with no peer-derived tip → the node has no peers and
// cannot learn about new blocks; it silently stalls at a stale height while everything looks
// synced. Surface it as a recoverable warning (reconnect won't help, so no action button).
if (in.connected) {
if (!in.tip_known && in.blocks > 0) {
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::NoPeers;
s.action = NodeBannerAction::None;
s.detail = in.connection_status;
}
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