feat(lite): Console tab with connection + open/create diagnostics

The lite variant had no visibility into why a wallet failed to open — just a
"disconnected" spinner. Add a lite-only Console tab (full-node keeps its RPC
console) that shows a live diagnostic log.

- LiteDiagnostics: a small thread-safe, bounded ring buffer (header-only). The
  controller writes to it from its background threads: each failover server
  attempt and result, wallet open/create/restore outcomes, sync start, and
  blocked-open reasons. The App logs controller (re)builds with the preferred
  server.
- lite_console_tab: a terminal-styled, read-only view of the log (newest at the
  bottom, error/success lines coloured) with Clear / Copy / Auto-scroll. Reachable
  even when the wallet is locked (it's diagnostics, no secrets). Registered as
  NavPage::LiteConsole, gated lite-only via WalletUiSurface::LiteConsole.

A unit test drives an open-with-failover and asserts the log records the
connection attempt and the successful open. Built clean for full-node, lite, and
Windows cross-compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 18:46:25 -05:00
parent dbeae3ac98
commit 85a1080b52
10 changed files with 258 additions and 7 deletions

View File

@@ -0,0 +1,86 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// lite_diagnostics.h — a small, thread-safe in-memory log of lite-wallet diagnostic events
// (server connection attempts, wallet open/create/restore, sync milestones, errors). Written
// from the controller's background threads and the App; read by the lite Console tab. Bounded
// so it can't grow without limit. Header-only (a Meyers singleton) so both the wallet layer
// and the UI can use it with no extra link target.
#pragma once
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <ctime>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
namespace dragonx {
namespace wallet {
class LiteDiagnostics {
public:
static LiteDiagnostics& instance() {
static LiteDiagnostics inst;
return inst;
}
// Append a timestamped line (thread-safe). Safe to call from background threads.
void log(const std::string& message) {
std::string line = timestamp();
line += message;
std::lock_guard<std::mutex> lk(mutex_);
lines_.push_back(std::move(line));
if (lines_.size() > kMaxLines) lines_.pop_front();
++generation_;
}
// Copy of the current lines (oldest first).
std::vector<std::string> snapshot() const {
std::lock_guard<std::mutex> lk(mutex_);
return std::vector<std::string>(lines_.begin(), lines_.end());
}
// Monotonic counter bumped on every change — lets readers re-snapshot only when it differs.
std::uint64_t generation() const {
std::lock_guard<std::mutex> lk(mutex_);
return generation_;
}
void clear() {
std::lock_guard<std::mutex> lk(mutex_);
lines_.clear();
++generation_;
}
private:
LiteDiagnostics() = default;
static std::string timestamp() {
std::time_t t = std::time(nullptr);
std::tm tmv{};
#ifdef _WIN32
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
char buf[16];
std::snprintf(buf, sizeof(buf), "%02d:%02d:%02d ", tmv.tm_hour, tmv.tm_min, tmv.tm_sec);
return std::string(buf);
}
static constexpr std::size_t kMaxLines = 500;
mutable std::mutex mutex_;
std::deque<std::string> lines_;
std::uint64_t generation_ = 0;
};
// Convenience: append a lite diagnostic line.
inline void liteLog(const std::string& message) { LiteDiagnostics::instance().log(message); }
} // namespace wallet
} // namespace dragonx

View File

@@ -4,6 +4,7 @@
#include "lite_wallet_controller.h"
#include "lite_diagnostics.h"
#include "../data/wallet_state.h"
#include <algorithm>
@@ -279,11 +280,16 @@ std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result)
{
status_ = result.status;
const std::string op = liteWalletLifecycleOperationName(result.operation);
if (result.walletReady) {
liteLog(op + ": wallet ready");
walletOpen_ = true;
if (persist_) persist_();
startSync(); // begin background sync on the backend
startWorker(); // begin periodic refresh -> WalletState (via takeRefreshedModel)
} else if (result.attempted) {
liteLog(op + " failed: " +
(result.error.empty() ? result.status.message : result.error));
}
}
@@ -312,6 +318,7 @@ bool LiteWalletController::beginOpenExisting()
status_ = WalletBackendStatus{WalletBackendState::Error,
reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0};
lastOpenError_ = status_.message;
liteLog("Open blocked: " + lastOpenError_);
return false;
}
auto servers = failoverServerUrls();
@@ -319,6 +326,7 @@ bool LiteWalletController::beginOpenExisting()
status_ = WalletBackendStatus{WalletBackendState::Error,
"no usable lite servers are configured", {}, {}, 0.0};
lastOpenError_ = status_.message;
liteLog("Open blocked: " + lastOpenError_);
return false;
}
@@ -329,6 +337,7 @@ bool LiteWalletController::beginOpenExisting()
}
openRunning_->store(true);
status_ = WalletBackendStatus{WalletBackendState::Connecting, "opening wallet", {}, {}, 0.0};
liteLog("Opening wallet — trying " + std::to_string(servers.size()) + " server(s)");
// Capture only shared refs + value copies (never `this`) so the thread can safely outlive us.
auto bridge = bridge_;
@@ -340,14 +349,18 @@ bool LiteWalletController::beginOpenExisting()
outcome.error = "could not reach any lite server";
for (const auto& url : servers) {
if (!bridge) break;
liteLog(" connecting to " + url + " ...");
// initialize_existing loads the wallet file but contacts the server to start the
// light client; ok && non-empty value == ready (mirrors the lifecycle's success test).
const auto call = bridge->initializeExisting(/*dangerous=*/false, url);
if (call.ok && !call.value.empty()) {
liteLog(" " + url + ": connected");
outcome.ok = true;
outcome.serverUrl = url;
break;
}
const std::string why = call.error.empty() ? "unreachable" : call.error;
liteLog(" " + url + ": " + why);
if (!call.error.empty()) outcome.error = call.error;
}
{
@@ -374,12 +387,14 @@ void LiteWalletController::pumpAsyncOpen()
walletOpen_ = true;
lastOpenError_.clear();
status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0};
liteLog("Wallet opened via " + outcome.serverUrl);
if (persist_) persist_();
startSync(); // begin background sync on the backend
startWorker(); // begin periodic refresh -> WalletState
} else {
lastOpenError_ = outcome.error;
status_ = WalletBackendStatus{WalletBackendState::Error, outcome.error, {}, {}, 0.0};
liteLog("Open failed: " + outcome.error);
}
}
@@ -387,6 +402,7 @@ void LiteWalletController::startSync()
{
if (syncLaunched_.exchange(true)) return;
syncStarted_ = true;
liteLog("Background sync started");
// The backend `sync` command is a blocking, uninterruptible full chain scan, so run it on
// a detached thread. Capture shared refs (not the controller) so it is safe to outlive us.
auto bridge = bridge_;

View File

@@ -43,7 +43,8 @@ enum class WalletUiSurface {
BootstrapDownload,
SetupWizard,
NodeSettings,
LiteNetwork // lite-wallet-only server browser (replaces Peers in lite builds)
LiteNetwork, // lite-wallet-only server browser (replaces Peers in lite builds)
LiteConsole // lite-wallet-only diagnostics console (full-node uses the RPC Console)
};
struct WalletCapabilities {
@@ -155,6 +156,7 @@ constexpr bool isUiSurfaceAvailable(const WalletCapabilities& capabilities,
case WalletUiSurface::Explorer:
return capabilities.fullNodePagesAvailable;
case WalletUiSurface::LiteNetwork:
case WalletUiSurface::LiteConsole:
return !capabilities.fullNodePagesAvailable; // lite builds only
case WalletUiSurface::BootstrapDownload:
case WalletUiSurface::SetupWizard: