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>
This commit is contained in:
2026-10-02 10:26:32 -05:00
co-authored by Claude Opus 5.5
parent 0f25c67600
commit 1215ded1a0
17 changed files with 822 additions and 133 deletions
+114 -10
View File
@@ -946,6 +946,18 @@ void App::update()
pumpRpcBusyCanary();
const bool rpcBusy = rpc_busy_gate_->busy();
// Keep the "node starting / warming up / busy" overlay current between connect attempts and polls,
// unless something else has replaced the status (see init_status_title_, applyWarmupText).
if (ImGui::GetTime() >= status_refresh_at_) {
status_refresh_at_ = ImGui::GetTime() + 1.0;
if (state_.warming_up && !warmup_rpc_message_.empty() && connection_status_ == state_.warmup_status) {
applyWarmupText(warmup_rpc_message_);
} else if (state_.daemon_initializing && !state_.connected && !init_status_title_.empty() &&
connection_status_ == init_status_title_) {
applyDaemonInitStatus(init_status_busy_);
}
}
// Fast refresh (mining stats + daemon memory) every second
// Skip when wallet is locked — no need to poll, and queued tasks
// would delay the PIN unlock worker task.
@@ -1629,6 +1641,8 @@ void App::render()
snprintf(deletedMsg, sizeof(deletedMsg), TR("appx_blockchain_data_deleted"), deletedN);
ui::Notifications::instance().success(deletedMsg);
}
if (pending_reinstall_node_busy_.exchange(false, std::memory_order_relaxed))
ui::Notifications::instance().warning(TR("appx_daemon_reinstall_node_busy"), 12.0f);
}
@@ -2315,6 +2329,9 @@ void App::renderNodeStatusBanner()
in.lite_open_error = lite_open_error_;
in.tip_known = state_.sync.tip_known;
in.blocks = state_.sync.blocks;
const NodeBusyText busy = nodeBusyText();
in.node_busy = busy.busy;
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
if (!banner.show) return;
@@ -2349,6 +2366,8 @@ void App::renderNodeStatusBanner()
title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break;
case ui::NodeBannerReason::NoPeers:
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
case ui::NodeBannerReason::NodeBusy:
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
case ui::NodeBannerReason::FullNodeOffline:
default:
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
@@ -2672,6 +2691,9 @@ void App::renderStatusBar()
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", TR("sb_rescanning"), dotStr);
}
} else if (rpc_busy_gate_->busy()) {
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
} else if (state_.sync.syncing) {
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
int blocksLeft = chainTip - state_.sync.blocks;
@@ -2803,7 +2825,11 @@ void App::renderStatusBar()
// with a small gap.
float gap = sbSectionGap;
float occupiedX = versionX; // leftmost X used by the version + connection status so far
if (!connection_status_.empty() && connection_status_ != "Connected") {
// The left of the bar already shows the warmup / "node busy" status; don't print it twice.
const bool shownOnLeft = (state_.warming_up || state_.daemon_initializing) &&
connection_status_ == state_.warmup_status;
if (!connection_status_.empty() && connection_status_ != "Connected" &&
connection_status_ != TR("connected") && !shownOnLeft) {
// During a post-repair rescan the raw status is a scary "RPC request failed: Timeout" — show a
// calm line instead (the rescan legitimately can't answer RPC yet).
const std::string statusBase = post_recovery_rescan_ ? std::string(TR("sb_finishing_repair"))
@@ -5833,6 +5859,7 @@ void App::reinstallBundledDaemon()
if (stillUp()) {
DEBUG_LOGF("[App] Reinstall bundled daemon: aborted — a dragonxd process is still running; "
"binaries left untouched and no new node started\n");
pending_reinstall_node_busy_.store(true, std::memory_order_relaxed); // told on the UI thread
return;
}
// Overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones.
@@ -6084,6 +6111,70 @@ void App::feedDaemonBusy(const std::string& chunk)
if (daemon_busy_reset_pending_.exchange(false)) daemon_busy_.reset();
daemon_busy_.feed(chunk);
node_rebuilding_ = daemon_busy_.rebuilding();
if (!daemon_busy_.busy()) busy_first_seen_ = {};
else if (busy_first_seen_ == std::chrono::steady_clock::time_point{}) busy_first_seen_ = std::chrono::steady_clock::now();
}
static std::string formatBusyDuration(double secs)
{
const long s = secs > 0 ? static_cast<long>(secs) : 0;
char buf[32];
if (s >= 3600) snprintf(buf, sizeof(buf), "%ld:%02ld:%02ld", s / 3600, (s / 60) % 60, s % 60);
else snprintf(buf, sizeof(buf), "%ld:%02ld", s / 60, s % 60);
return buf;
}
App::NodeBusyText App::nodeBusyText() const
{
NodeBusyText out;
// The tracker reads OUR node's output; once that process is gone its last state means nothing.
const bool ours = daemon_controller_ && daemon_controller_->isRunning();
const bool rebuilding = ours && daemon_busy_.rebuilding();
const bool replaying = ours && daemon_busy_.replaying() && !rebuilding;
const bool rpcBusy = rpc_busy_gate_->busy();
if (!rebuilding && !replaying && !rpcBusy) return out;
out.busy = true;
out.rebuilding = rebuilding;
// Running time: from the node's own (UTC) log timestamp when there is one, else since we first saw it.
double secs = rpc_busy_gate_->busySeconds();
if (rebuilding || replaying) {
const std::int64_t startUtc = rebuilding ? daemon_busy_.rebuildStartUtc() : daemon_busy_.replayStartUtc();
if (startUtc > 0) {
secs = static_cast<double>(static_cast<std::int64_t>(std::time(nullptr)) - startUtc);
} else if (busy_first_seen_ != std::chrono::steady_clock::time_point{}) {
secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - busy_first_seen_).count();
}
}
out.elapsed = formatBusyDuration(secs);
char buf[512];
std::string progress;
if (rebuilding) {
out.title = TR("node_busy_rebuild_title");
snprintf(buf, sizeof(buf), TR("node_busy_rebuild_desc"), out.elapsed.c_str());
out.detail = buf;
// Phase 1 logs a line per 5000 blocks read; Phase 2 (extending the witnesses) logs nothing.
const long done = daemon_busy_.readDone(), total = daemon_busy_.readTotal();
const bool reading = total > 0 && done + 5000 < total;
if (reading) snprintf(buf, sizeof(buf), TR("node_busy_rebuild_reading"), done, total);
else snprintf(buf, sizeof(buf), "%s", TR("node_busy_rebuild_extending"));
out.detail += " ";
out.detail += buf;
if (reading) progress = buf;
} else if (replaying) {
out.title = TR("node_busy_replay_title");
snprintf(buf, sizeof(buf), TR("node_busy_replay_desc"), out.elapsed.c_str());
out.detail = buf;
} else {
out.title = TR("node_busy_rpc_title");
snprintf(buf, sizeof(buf), TR("node_busy_rpc_desc"), out.elapsed.c_str());
out.detail = buf;
}
snprintf(buf, sizeof(buf), TR("node_busy_running"), out.elapsed.c_str());
out.compact = buf;
if (!progress.empty()) out.compact += " · " + progress;
return out;
}
void App::resetDaemonBusy()
@@ -6142,17 +6233,35 @@ void App::renderDaemonStopConfirm()
bool proceed = false;
if (ImGui::BeginPopupModal("##DaemonStopConfirm", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
// Honest about what each choice does now that the wallet never kills the node: stopping waits for
// it to finish or abort the rebuild (Phase 1 aborts and is redone on the next start; Phase 2 can't
// be interrupted). Keeping it running loses nothing, so it is the recommended, focused default.
const NodeBusyText busy = nodeBusyText();
std::string elapsed = busy.elapsed;
if (!busy.busy && rpc_busy_since_ != std::chrono::steady_clock::time_point{})
elapsed = formatBusyDuration(std::chrono::duration<double>(
std::chrono::steady_clock::now() - rpc_busy_since_).count());
const bool rebuilding = busy.rebuilding;
if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1());
ImGui::TextUnformatted(TR("appx_node_rebuilding_witness_cache"));
ImGui::TextUnformatted(TR(rebuilding ? "stopconfirm_rebuild_title" : "stopconfirm_busy_title"));
if (Type().subtitle1()) ImGui::PopFont();
ImGui::Spacing();
char body[1024];
snprintf(body, sizeof(body), TR(rebuilding ? "stopconfirm_rebuild_body" : "stopconfirm_busy_body"),
elapsed.empty() ? "0:00" : elapsed.c_str());
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f);
ImGui::TextUnformatted(TR("appx_stopping_node_discards_rebuild"));
ImGui::TextUnformatted(body);
ImGui::PopTextWrapPos();
ImGui::Spacing();
ImGui::Spacing();
if (TactileButton(TR("appx_keep_node_running_and_quit"), ImVec2(0, 0))) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Primary(), 210)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Primary(), 160)));
const bool keep = TactileButton(TR("appx_keep_node_running_and_quit"), ImVec2(0, 0));
ImGui::PopStyleColor(3);
ImGui::SetItemDefaultFocus();
if (keep) {
shutdown_keep_daemon_override_ = true;
shutdown_confirmed_ = true;
daemon_stop_confirm_open_ = false;
@@ -6160,12 +6269,7 @@ void App::renderDaemonStopConfirm()
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160)));
const bool stopAnyway = TactileButton(TR("appx_stop_anyway_and_quit"), ImVec2(0, 0));
ImGui::PopStyleColor(3);
if (stopAnyway) {
if (TactileButton(TR("stopconfirm_stop_wait"), ImVec2(0, 0))) {
shutdown_confirmed_ = true;
daemon_stop_confirm_open_ = false;
proceed = true;