refactor(console): unify lite + full-node console behind a pluggable executor

The two consoles were separate implementations (the rich full-node ConsoleTab and a
minimal RenderLiteConsoleTab). Introduce ConsoleCommandExecutor to abstract the only
real differences — command execution + log source — so both variants share the one rich
terminal (the lite console now gains selection, zoom, copy, and JSON-colored output).

- console_command_executor.{h,cpp}: interface + FullNodeConsoleExecutor (dragonxd log
  ingestion + RPC command execution, result queue) and LiteConsoleExecutor (lite
  diagnostics ring + backend runConsoleCommand/takeConsoleResult, connection/sync status).
- ConsoleTab::render() now takes a ConsoleCommandExecutor&; log ingestion, command
  submission, and command results flow through it. The RPC command-reference popup and
  the daemon/errors/rpc-trace/app filter toggles are gated on the executor's capabilities;
  the toolbar status dot + a status header come from the executor.
- Safety preserved: `clear`/`cls` is intercepted as a view-only clear in the shared UI so
  it is NEVER forwarded (the lite backend's `clear` wipes tx history); command output is
  not persisted to diagnostics.
- App owns the executor (created per variant) and routes both NavPages to console_tab_;
  lite_console_tab.{h,cpp} deleted.

Needs runtime verification of BOTH consoles (full-node RPC + lite: clear, seed/export
secrecy, sync status, command results).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 15:59:17 -05:00
parent 1266b6e68e
commit b5001ad4db
10 changed files with 594 additions and 570 deletions

View File

@@ -6,6 +6,7 @@
// tab completion, daemon log display, and color-coded output.
#include "console_tab.h"
#include "console_command_executor.h"
#include "console_command_reference.h"
#include "console_input_model.h"
#include "console_output_model.h"
@@ -23,6 +24,7 @@
#include "../../util/i18n.h"
#include <imgui.h>
#include <cctype>
#include <cstring>
#include <sstream>
#include <algorithm>
@@ -143,7 +145,7 @@ ConsoleTab::~ConsoleTab()
if (s_rpc_trace_console == this) s_rpc_trace_console = nullptr;
}
void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc::RPCWorker* worker, daemon::XmrigManager* xmrig)
void ConsoleTab::render(ConsoleCommandExecutor& exec)
{
using namespace material;
@@ -173,103 +175,25 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
}
}
// Check for daemon state changes
if (daemon) {
auto current_state = daemon->getState();
// Show message when daemon starts
if (current_state == daemon::EmbeddedDaemon::State::Starting &&
last_daemon_state_ == daemon::EmbeddedDaemon::State::Stopped) {
addLine("", COLOR_RESULT);
addLine(TR("console_starting_node"), COLOR_INFO);
addLine(TR("console_capturing_output"), COLOR_INFO);
addLine("", COLOR_RESULT);
shown_startup_message_ = true;
}
else if (current_state == daemon::EmbeddedDaemon::State::Running &&
last_daemon_state_ != daemon::EmbeddedDaemon::State::Running) {
addLine(TR("console_daemon_started"), COLOR_INFO);
}
else if (current_state == daemon::EmbeddedDaemon::State::Stopped &&
last_daemon_state_ == daemon::EmbeddedDaemon::State::Running) {
addLine("", COLOR_RESULT);
addLine(TR("console_daemon_stopped"), COLOR_INFO);
}
else if (current_state == daemon::EmbeddedDaemon::State::Error) {
addLine(std::string(TR("console_daemon_error")) + daemon->getLastError() + " ===", COLOR_ERROR);
}
last_daemon_state_ = current_state;
// Pull passive log lines (daemon/xmrig output, or the lite diagnostics ring) and any
// completed command results from the backend executor.
exec.pollLogLines([this](const std::string& l, ImU32 c) { addLine(l, c); });
{
std::string result;
bool isError = false;
while (exec.pollResult(result, isError)) addFormattedResult(result, isError);
}
// Track RPC connection state and show a message when connected
if (rpc) {
bool connected_now = rpc->isConnected();
if (connected_now && !last_rpc_connected_) {
addLine(TR("console_connected"), COLOR_INFO);
} else if (!connected_now && last_rpc_connected_) {
addLine(TR("console_disconnected"), COLOR_ERROR);
}
last_rpc_connected_ = connected_now;
}
// Check for new daemon output — always capture so toggle works as a live filter
if (daemon) {
std::string new_output = daemon->getOutputSince(last_daemon_output_size_);
if (!new_output.empty()) {
// Split by newlines and add each line
std::istringstream stream(new_output);
std::string line;
while (std::getline(stream, line)) {
if (!line.empty()) {
// Color based on content: [ERROR] -> red, [WARN] -> warning color
ImU32 lineColor = COLOR_DAEMON;
if (line.find("[ERROR]") != std::string::npos ||
line.find("error:") != std::string::npos ||
line.find("Error:") != std::string::npos) {
lineColor = COLOR_ERROR;
}
addLine("[daemon] " + line, lineColor);
}
}
}
}
// Check for new xmrig output (pool mining)
if (xmrig && xmrig->isRunning()) {
std::string new_output = xmrig->getOutputSince(last_xmrig_output_size_);
if (!new_output.empty()) {
std::istringstream stream(new_output);
std::string line;
while (std::getline(stream, line)) {
if (!line.empty()) {
// Color xmrig output - errors in red, accepted shares in green
ImU32 lineColor = COLOR_DAEMON;
if (line.find("error") != std::string::npos ||
line.find("ERROR") != std::string::npos ||
line.find("failed") != std::string::npos) {
lineColor = COLOR_ERROR;
} else if (line.find("accepted") != std::string::npos) {
lineColor = COLOR_INFO;
}
addLine("[xmrig] " + line, lineColor);
}
}
}
} else if (!xmrig || !xmrig->isRunning()) {
// Reset offset when xmrig stops so we get fresh output next time
if (last_xmrig_output_size_ != 0) {
last_xmrig_output_size_ = 0;
}
}
// Main console layout
ImGui::BeginChild("ConsoleContainer", ImVec2(0, 0), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
// Optional status header (lite: sync + last error) above the toolbar.
renderStatusHeader(exec);
// Toolbar
renderToolbar(daemon);
renderToolbar(exec);
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Output area (scrollable) — glass panel background
@@ -384,8 +308,8 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Input area
renderInput(rpc, worker);
renderInput(exec);
ImGui::EndChild();
}
@@ -397,7 +321,7 @@ void ConsoleTab::renderCommandsPopupModal()
renderCommandsPopup();
}
void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
{
using namespace material;
ImDrawList* dl = ImGui::GetWindowDrawList();
@@ -414,56 +338,29 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (toolbarH - ImGui::GetFrameHeight()) * 0.5f);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + Layout::spacingMd());
// Daemon status with colored dot
if (daemon) {
auto state = daemon->getState();
const char* status_text = TR("console_status_unknown");
ImU32 dotCol = IM_COL32(150, 150, 150, 255);
bool pulse = false;
// Backend status with colored dot (daemon state / lite connection).
{
ConsoleStatusLine st = exec.toolbarStatus();
if (!st.text.empty()) {
ImVec2 cp = ImGui::GetCursorScreenPos();
float dotR = schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size * Layout::hScale();
float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f;
float dotX = cp.x + dotR + 2;
switch (state) {
case daemon::EmbeddedDaemon::State::Stopped:
status_text = TR("console_status_stopped");
dotCol = IM_COL32(150, 150, 150, 255);
break;
case daemon::EmbeddedDaemon::State::Starting:
status_text = TR("console_status_starting");
dotCol = Warning();
pulse = true;
break;
case daemon::EmbeddedDaemon::State::Running:
status_text = TR("console_status_running");
dotCol = Success();
break;
case daemon::EmbeddedDaemon::State::Stopping:
status_text = TR("console_status_stopping");
dotCol = Warning();
pulse = true;
break;
case daemon::EmbeddedDaemon::State::Error:
status_text = TR("console_status_error");
dotCol = Error();
break;
}
if (st.pulse) {
float a = schema::UI().drawElement("animations", "pulse-base-glow").size + schema::UI().drawElement("animations", "pulse-amp-glow").size * (float)std::sin((double)ImGui::GetTime() * schema::UI().drawElement("animations", "pulse-speed-fast").size);
ImU32 pCol = (st.color & 0x00FFFFFF) | ((ImU32)(255 * a) << 24);
dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, pCol);
} else {
dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, st.color);
}
ImVec2 cp = ImGui::GetCursorScreenPos();
float dotR = schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size * Layout::hScale();
float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f;
float dotX = cp.x + dotR + 2;
if (pulse) {
float a = schema::UI().drawElement("animations", "pulse-base-glow").size + schema::UI().drawElement("animations", "pulse-amp-glow").size * (float)std::sin((double)ImGui::GetTime() * schema::UI().drawElement("animations", "pulse-speed-fast").size);
ImU32 pCol = (dotCol & 0x00FFFFFF) | ((ImU32)(255 * a) << 24);
dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, pCol);
ImGui::Dummy(ImVec2(dotR * 2 + 6, 0));
ImGui::SameLine();
Type().textColored(TypeStyle::Caption, st.color, st.text.c_str());
} else {
dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, dotCol);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("console_no_daemon"));
}
ImGui::Dummy(ImVec2(dotR * 2 + 6, 0));
ImGui::SameLine();
Type().textColored(TypeStyle::Caption, dotCol, status_text);
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("console_no_daemon"));
}
ImGui::SameLine();
@@ -482,6 +379,8 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
ImGui::Spacing();
ImGui::SameLine();
// Log filters (daemon/errors/rpc-trace/app) apply only to a daemon log source.
if (exec.hasLogFilters()) {
// Daemon messages toggle
{
static bool s_prev_daemon_enabled = true;
@@ -554,6 +453,8 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
ImGui::Spacing();
ImGui::SameLine();
} // exec.hasLogFilters()
// Clear button
if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
clear();
@@ -587,15 +488,16 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
ImGui::SameLine();
// Commands reference button
if (TactileButton(TR("console_commands"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
show_commands_popup_ = true;
// Commands reference button (full-node RPC reference only)
if (exec.hasRpcReference()) {
if (TactileButton(TR("console_commands"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
show_commands_popup_ = true;
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("console_show_rpc_ref"));
}
ImGui::SameLine();
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("console_show_rpc_ref"));
}
ImGui::SameLine();
// Line count
{
@@ -1192,7 +1094,7 @@ void ConsoleTab::clearSelection()
sel_end_ = {-1, 0};
}
void ConsoleTab::renderInput(rpc::RPCClient* rpc, rpc::RPCWorker* worker)
void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
{
using namespace material;
@@ -1269,15 +1171,49 @@ void ConsoleTab::renderInput(rpc::RPCClient* rpc, rpc::RPCWorker* worker)
return 0;
};
const bool busy = exec.busy();
if (busy) ImGui::BeginDisabled();
if (ImGui::InputText("##ConsoleInput", input_buffer_, sizeof(input_buffer_), flags, callback, this)) {
std::string cmd(input_buffer_);
// Trim surrounding whitespace.
size_t tb = cmd.find_first_not_of(" \t");
if (tb == std::string::npos) cmd.clear(); else cmd = cmd.substr(tb);
while (!cmd.empty() && (cmd.back() == ' ' || cmd.back() == '\t')) cmd.pop_back();
if (!cmd.empty()) {
executeCommand(cmd, rpc, worker);
addLine("> " + cmd, COLOR_COMMAND);
AppendConsoleHistory(command_history_, cmd, 100);
history_index_ = -1;
// First token, lowercased, for built-in interception.
std::string first;
{
size_t fb = cmd.find_first_not_of(" \t");
size_t fe = cmd.find_first_of(" \t", fb);
first = cmd.substr(fb, fe == std::string::npos ? std::string::npos : fe - fb);
std::transform(first.begin(), first.end(), first.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
}
auto add = [this](const std::string& l, ImU32 c) { addLine(l, c); };
if (first == "clear" || first == "cls") {
// View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
clear();
clearSelection();
} else if (first == "help") {
exec.printHelp(add);
} else if (first == "quit" || first == "exit") {
addLine(TR("console_quit_note"), COLOR_INFO);
} else if (!exec.isReady()) {
addLine(TR("console_not_connected"), COLOR_ERROR);
} else {
exec.submit(cmd);
}
input_buffer_[0] = '\0';
reclaim_focus = true;
}
}
if (busy) ImGui::EndDisabled();
ImGui::PopItemWidth();
// Auto-focus on input
@@ -1480,106 +1416,37 @@ void ConsoleTab::renderCommandsPopup()
material::EndOverlayDialog();
}
void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc::RPCWorker* worker)
void ConsoleTab::addFormattedResult(const std::string& result, bool is_error)
{
using namespace material;
// Add to history (avoid duplicates)
AppendConsoleHistory(command_history_, cmd, 100);
history_index_ = -1;
// Echo command
addLine("> " + cmd, COLOR_COMMAND);
// Handle built-in commands
if (cmd == "clear") {
clear();
return;
}
if (cmd == "help") {
addLine(TR("console_available_commands"), COLOR_INFO);
addLine(TR("console_help_clear"), COLOR_RESULT);
addLine(TR("console_help_help"), COLOR_RESULT);
addLine("", COLOR_RESULT);
addLine(TR("console_common_rpc"), COLOR_INFO);
addLine(TR("console_help_getinfo"), COLOR_RESULT);
addLine(TR("console_help_getbalance"), COLOR_RESULT);
addLine(TR("console_help_gettotalbalance"), COLOR_RESULT);
addLine(TR("console_help_getblockcount"), COLOR_RESULT);
addLine(TR("console_help_getpeerinfo"), COLOR_RESULT);
addLine(TR("console_help_setgenerate"), COLOR_RESULT);
addLine(TR("console_help_getmininginfo"), COLOR_RESULT);
addLine(TR("console_help_stop"), COLOR_RESULT);
addLine("", COLOR_RESULT);
addLine(TR("console_click_commands"), COLOR_INFO);
addLine(TR("console_tab_completion"), COLOR_INFO);
return;
}
// Execute RPC command
if (!rpc || !rpc->isConnected()) {
addLine(TR("console_not_connected"), COLOR_ERROR);
return;
}
auto call = BuildConsoleRpcCall(cmd);
if (!call.valid) return;
ImU32 json_key_col = WithAlpha(Secondary(), 255);
ImU32 json_str_col = WithAlpha(Success(), 255);
ImU32 json_num_col = WithAlpha(Warning(), 255);
ImU32 json_brace_col = IM_COL32(200, 200, 200, 150);
std::string method = call.method;
nlohmann::json params = call.params;
// Execute RPC call on worker thread to avoid blocking UI
if (worker) {
// Capture 'this' for addLine calls in MainCb (runs on main thread, ConsoleTab outlives callbacks)
auto self = this;
worker->post([rpc, method, params, self]() -> rpc::RPCWorker::MainCb {
std::string result_str;
bool is_error = false;
try {
rpc::RPCClient::TraceScope trace("Console tab / User command");
result_str = rpc->callRaw(method, params);
} catch (const std::exception& e) {
result_str = e.what();
is_error = true;
}
return [result_str, is_error, self]() {
// Process results on main thread where ImGui colors are available
using namespace material;
ImU32 json_key_col = WithAlpha(Secondary(), 255);
ImU32 json_str_col = WithAlpha(Success(), 255);
ImU32 json_num_col = WithAlpha(Warning(), 255);
ImU32 json_brace_col = IM_COL32(200, 200, 200, 150);
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, is_error)) {
ImU32 lineCol = COLOR_RESULT;
switch (resultLine.role) {
case ConsoleResultLineRole::Error: lineCol = COLOR_ERROR; break;
case ConsoleResultLineRole::JsonKey: lineCol = json_key_col; break;
case ConsoleResultLineRole::JsonString: lineCol = json_str_col; break;
case ConsoleResultLineRole::JsonNumber: lineCol = json_num_col; break;
case ConsoleResultLineRole::JsonBrace: lineCol = json_brace_col; break;
case ConsoleResultLineRole::Result: break;
}
self->addLine(resultLine.text, lineCol);
}
};
});
} else {
// Fallback: synchronous execution if no worker available
try {
rpc::RPCClient::TraceScope trace("Console tab / User command");
std::string result_str = rpc->callRaw(method, params);
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, false)) {
addLine(resultLine.text, COLOR_RESULT);
}
} catch (const std::exception& e) {
for (const auto& resultLine : FormatConsoleRpcResultLines(e.what(), true)) {
addLine(resultLine.text, COLOR_ERROR);
}
for (const auto& resultLine : FormatConsoleRpcResultLines(result, is_error)) {
ImU32 lineCol = COLOR_RESULT;
switch (resultLine.role) {
case ConsoleResultLineRole::Error: lineCol = COLOR_ERROR; break;
case ConsoleResultLineRole::JsonKey: lineCol = json_key_col; break;
case ConsoleResultLineRole::JsonString: lineCol = json_str_col; break;
case ConsoleResultLineRole::JsonNumber: lineCol = json_num_col; break;
case ConsoleResultLineRole::JsonBrace: lineCol = json_brace_col; break;
case ConsoleResultLineRole::Result: break;
}
addLine(resultLine.text, lineCol);
}
}
void ConsoleTab::renderStatusHeader(ConsoleCommandExecutor& exec)
{
using namespace material;
auto lines = exec.statusLines();
for (const auto& sl : lines)
Type().textColored(TypeStyle::Caption, sl.color, sl.text.c_str());
if (!lines.empty()) ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
void ConsoleTab::addLine(const std::string& line, ImU32 color)
{
std::lock_guard<std::mutex> lock(lines_mutex_);
@@ -1635,10 +1502,9 @@ void ConsoleTab::clear()
{
std::lock_guard<std::mutex> lock(lines_mutex_);
lines_.clear();
last_daemon_output_size_ = 0;
last_xmrig_output_size_ = 0;
}
// addLine() takes the lock itself, so call it outside the locked scope
// The executor keeps its own log cursors, so a view-clear stays cleared (new output
// still appends). addLine() takes the lock itself, so call it outside the locked scope.
addLine(TR("console_cleared"), COLOR_INFO);
}