feat(merge): run per-address consolidation as a paced background job
"Keep in each address" posted one task that called z_mergetoaddress for every batch of every address back to back; each call is a whole-wallet scan under cs_main, and the daemon runs the ops one at a time anyway. It is now an App-owned services::ConsolidationRun: - one RPC at a time; the next merge only after the previous batch left the queue and a gap as long as the last call took (2-120 s); - pauses while the node syncs, the wallet is locked, a send of ours is in progress, or another wallet scan is running; - failures classified: nothing that may have queued is ever resent; a breaker stops after 3 dead addresses or 3 failed ops in a row; - each merge is charged to the scan budget; one re-read and one forced balance at the end; - it keeps running when the dialog closes: a status-bar chip, a run view with progress, the wait reason and a Stop button. The "Into one" merge can no longer be confirmed twice while its call is in flight. fake_dragonxd gains FAKE_SCAN_SECS, FAKE_MERGE_SERIAL and FAKE_MERGE_FAIL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+173
-188
@@ -17,6 +17,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <chrono>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
@@ -54,13 +55,9 @@ static bool s_per_address = false;
|
||||
static std::vector<services::NoteGroup> s_groups; // spendable shielded notes by address (scope load)
|
||||
static std::set<std::string> s_selected; // addresses ticked for per-address consolidation
|
||||
static std::set<std::string> s_unticked; // unticked by the user this opening: kept across a re-scan
|
||||
struct PerAddressRun {
|
||||
services::AddressRun run;
|
||||
std::vector<int> state; // per opid: 0 = running, 1 = done, 2 = failed, 3 = gone from the daemon (unknown)
|
||||
std::string failure; // first failed op's message
|
||||
};
|
||||
static std::vector<PerAddressRun> s_runs; // after submit
|
||||
static bool s_runs_submitting = false;
|
||||
// The per-address run itself is App's (App::consolidation()): it keeps going when the dialog closes. A finished
|
||||
// one is dismissed when the dialog next opens, so the form starts fresh.
|
||||
static bool s_dismiss_finished = false;
|
||||
// The op-status polls: one in flight at a time, cleared only by its reply, even across a close and
|
||||
// reopen; a reply for an earlier opening (another session) changes nothing else.
|
||||
static bool s_poll_in_flight = false;
|
||||
@@ -75,8 +72,6 @@ static void resetTransient()
|
||||
s_status_message.clear();
|
||||
s_operation_id.clear();
|
||||
s_creating_addr = false;
|
||||
s_runs.clear();
|
||||
s_runs_submitting = false;
|
||||
}
|
||||
|
||||
// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a
|
||||
@@ -86,10 +81,13 @@ static void loadScope(App* app)
|
||||
{
|
||||
if (!app || !app->worker()) return;
|
||||
const std::uint32_t gen = services::scopeScanBegin(s_scan, services::steadySeconds());
|
||||
app->worker()->post([app, rpc = app->rpc(), gen]() -> rpc::RPCWorker::MainCb {
|
||||
// Two whole-wallet scans the user asked for: never held back, but charged to the scan budget.
|
||||
const services::ScanTicket ticket = app->beginAdhocWalletScan();
|
||||
app->worker()->post([app, rpc = app->rpc(), gen, ticket]() -> rpc::RPCWorker::MainCb {
|
||||
int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error;
|
||||
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
|
||||
std::vector<services::NoteGroup> groups;
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
if (!rpc) throw std::runtime_error("Not connected");
|
||||
// On a large wallet each scan can outlast the default 30 s; give them 120 s, counted as
|
||||
@@ -113,7 +111,9 @@ static void loadScope(App* app)
|
||||
error = "unknown error";
|
||||
kind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
return [tC, zC, tA, zA, error, kind, gen, groups]() {
|
||||
const double costMs = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
return [app, ticket, costMs, tC, zC, tA, zA, error, kind, gen, groups]() {
|
||||
app->endAdhocWalletScan(ticket, costMs, services::chatScanOutcome(error.empty(), kind));
|
||||
if (!services::scopeScanFinish(s_scan, gen, error, kind, services::steadySeconds())) return;
|
||||
s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA;
|
||||
s_groups = groups;
|
||||
@@ -141,6 +141,7 @@ void ShieldDialog::show(Mode mode)
|
||||
s_consolidate = false; // reset preset flags so stale statics don't leak across opens
|
||||
s_src = 2;
|
||||
resetTransient();
|
||||
s_dismiss_finished = true;
|
||||
s_from_address[0] = '\0';
|
||||
if (mode == Mode::ShieldCoinbase) strncpy(s_from_address, "*", sizeof(s_from_address));
|
||||
s_to_address[0] = '\0';
|
||||
@@ -228,6 +229,7 @@ static void autoSelectDestination(const WalletState& state)
|
||||
// AND kicks the modal's own live-progress poll.
|
||||
static void submitOperation(App* app)
|
||||
{
|
||||
if (!app->worker()) return; // before the pending state, so a missing worker can't wedge it
|
||||
s_operation_pending = true;
|
||||
s_op_terminal = false;
|
||||
s_status_message = TR("shield_submitting");
|
||||
@@ -267,12 +269,15 @@ static void submitOperation(App* app)
|
||||
else fromAddrs = { "*" };
|
||||
std::string to(s_to_address);
|
||||
double fee = s_fee; int limit = s_utxo_limit;
|
||||
if (!app->worker()) return;
|
||||
app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
// The merge's GetFilteredNotes is a whole-wallet scan: charged to the scan budget, never held back.
|
||||
const services::ScanTicket ticket = app->beginAdhocWalletScan();
|
||||
app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit, ticket]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json addrs = nlohmann::json::array();
|
||||
for (const auto& a : fromAddrs) addrs.push_back(a);
|
||||
nlohmann::json result; std::string error;
|
||||
bool uncertain = false;
|
||||
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
if (!rpc) throw std::runtime_error("Not connected");
|
||||
// The daemon scans the whole wallet before it returns the opid; on a large wallet that can
|
||||
@@ -281,15 +286,22 @@ static void submitOperation(App* app)
|
||||
App::MaintenanceCallScope maint(*app);
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate");
|
||||
// fromaddrs, toaddr, fee, transparent_limit, shielded_limit — cap both to the batch size.
|
||||
result = rpc->call("z_mergetoaddress", {addrs, to, fee, limit, limit}, services::kScopeCallTimeoutSec);
|
||||
result = rpc->call("z_mergetoaddress", {addrs, to, fee, limit, limit}, services::kRunCallTimeoutSec);
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
if (error.empty()) error = "error";
|
||||
kind = rpc::rpcFailureKindOf(e);
|
||||
// Sent, then cut off with no reply: our own abort on a lifecycle disconnect, the reply timeout
|
||||
// or a dropped connection. The daemon keeps running the handler and may still queue the merge.
|
||||
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e))
|
||||
uncertain = t->requestSent && t->httpStatus == 0;
|
||||
} catch (...) {
|
||||
error = "unknown error";
|
||||
kind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
return [app, result, error, uncertain]() {
|
||||
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
return [app, result, error, uncertain, ticket, kind, ms]() {
|
||||
app->endAdhocWalletScan(ticket, ms, services::chatScanOutcome(error.empty(), kind));
|
||||
if (error.empty()) {
|
||||
s_operation_id = result.value("opid", "");
|
||||
s_status_message = TR("merge_progress");
|
||||
@@ -318,134 +330,17 @@ static std::vector<services::NoteGroup> selectedGroups()
|
||||
return out;
|
||||
}
|
||||
|
||||
// Per-address consolidation. One background job submits, address by address, merges of each address's
|
||||
// notes back into that address — several batches when it holds more notes than the batch size.
|
||||
// z_mergetoaddress locks a batch's notes before it returns, so back-to-back calls take disjoint notes.
|
||||
// Per-address consolidation: App runs it (App::startConsolidation): paced, one merge at a time, and it keeps
|
||||
// going when this dialog closes. App toasts any refusal.
|
||||
static void submitPerAddress(App* app)
|
||||
{
|
||||
const auto groups = selectedGroups();
|
||||
if (groups.empty() || !app->worker()) return;
|
||||
s_runs_submitting = true;
|
||||
s_operation_pending = true;
|
||||
s_op_terminal = false;
|
||||
s_status_message = TR("shield_submitting");
|
||||
s_last_poll = ImGui::GetTime();
|
||||
const double fee = s_fee;
|
||||
const int limit = s_utxo_limit;
|
||||
app->worker()->post([app, rpc = app->rpc(), groups, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<services::AddressRun> runs;
|
||||
for (const auto& g : groups) {
|
||||
runs.push_back(services::consolidateInPlace(g.address, [&](const std::string& addr) {
|
||||
services::MergeCallResult r;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place");
|
||||
// fromaddrs = [addr], toaddr = addr: the address's notes merge back into it.
|
||||
const nlohmann::json res = rpc->call("z_mergetoaddress",
|
||||
{nlohmann::json::array({addr}), addr, fee, limit, limit});
|
||||
r.opid = res.value("opid", std::string());
|
||||
r.merging = res.value("mergingNotes", 0);
|
||||
r.remaining = res.value("remainingNotes", 0);
|
||||
if (r.opid.empty()) r.error = "no operation id";
|
||||
} catch (const std::exception& e) { r.error = e.what(); }
|
||||
return r;
|
||||
}));
|
||||
}
|
||||
return [app, runs]() {
|
||||
s_runs_submitting = false;
|
||||
s_runs.clear();
|
||||
int submitted = 0;
|
||||
for (const auto& r : runs) {
|
||||
PerAddressRun pr;
|
||||
pr.run = r;
|
||||
pr.state.assign(r.opids.size(), 0);
|
||||
for (const auto& id : r.opids) { app->trackOperation(id); ++submitted; }
|
||||
s_runs.push_back(std::move(pr));
|
||||
}
|
||||
if (submitted > 0) {
|
||||
s_status_message = TR("merge_progress");
|
||||
Notifications::instance().success(TR("merge_started"));
|
||||
} else {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = TR("merge_keep_each_nothing_submitted");
|
||||
Notifications::instance().error(TR("merge_keep_each_nothing_submitted"));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Progress of a per-address run: poll every ~2s until each batch's operation is done or failed. Filtered
|
||||
// to the batches still running, as the shared poller does (unfiltered if the daemon rejected the filter).
|
||||
static void pollPerAddress(App* app)
|
||||
{
|
||||
if (s_runs.empty() || s_op_terminal || s_poll_in_flight || !app->worker()) return;
|
||||
const double now = ImGui::GetTime();
|
||||
if (now - s_last_poll < 2.0) return;
|
||||
std::vector<std::string> running;
|
||||
for (const auto& r : s_runs)
|
||||
for (size_t i = 0; i < r.run.opids.size(); ++i)
|
||||
if (r.state[i] == 0) running.push_back(r.run.opids[i]);
|
||||
const auto filter = services::NetworkRefreshService::buildOperationFilterParams(running);
|
||||
if (!filter) return;
|
||||
s_last_poll = now;
|
||||
s_poll_in_flight = true;
|
||||
const unsigned session = s_poll_session;
|
||||
nlohmann::json params = app->opidFilterUnsupported() ? nlohmann::json::array() : *filter;
|
||||
app->worker()->post([rpc = app->rpc(), params = std::move(params), session]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json result; std::string error;
|
||||
try {
|
||||
if (!rpc) throw std::runtime_error("Not connected");
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate in place status");
|
||||
result = rpc->call("z_getoperationstatus", params);
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [result, error, session]() {
|
||||
s_poll_in_flight = false;
|
||||
if (session != s_poll_session) return; // a reply for an earlier opening
|
||||
if (!error.empty() || !result.is_array()) return; // transient — retry next tick
|
||||
std::set<std::string> reported;
|
||||
for (const auto& op : result) {
|
||||
if (!op.is_object() || !op.contains("id") || !op["id"].is_string()) continue;
|
||||
const std::string id = op["id"].get<std::string>();
|
||||
reported.insert(id);
|
||||
const std::string status = (op.contains("status") && op["status"].is_string())
|
||||
? op["status"].get<std::string>() : std::string();
|
||||
for (auto& r : s_runs) {
|
||||
for (size_t i = 0; i < r.run.opids.size(); ++i) {
|
||||
if (r.run.opids[i] != id || r.state[i] != 0) continue;
|
||||
if (status == "success") r.state[i] = 1;
|
||||
else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
||||
r.state[i] = 2;
|
||||
if (r.failure.empty() && op.contains("error") && op["error"].is_object() &&
|
||||
op["error"].contains("message") && op["error"]["message"].is_string())
|
||||
r.failure = op["error"]["message"].get<std::string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A valid reply without one of our running ops: the daemon no longer holds it (restart, or
|
||||
// popped by z_getoperationresult); its outcome can't be confirmed. One poll at a time, and
|
||||
// states change only here, so the running set now is the set that was asked for.
|
||||
for (auto& r : s_runs)
|
||||
for (size_t i = 0; i < r.run.opids.size(); ++i)
|
||||
if (r.state[i] == 0 && !reported.count(r.run.opids[i])) r.state[i] = 3;
|
||||
bool allDone = true;
|
||||
int failed = 0, unknown = 0;
|
||||
for (const auto& r : s_runs)
|
||||
for (int st : r.state) { if (st == 0) allDone = false; if (st == 2) ++failed; if (st == 3) ++unknown; }
|
||||
if (!allDone) return;
|
||||
s_operation_pending = false;
|
||||
s_op_terminal = true;
|
||||
if (failed > 0) {
|
||||
s_status_message = TR("merge_keep_each_some_failed");
|
||||
Notifications::instance().error(TR("merge_keep_each_some_failed"));
|
||||
} else if (unknown > 0) {
|
||||
s_status_message = TR("send_status_unconfirmed");
|
||||
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
||||
} else {
|
||||
s_status_message = TR("shield_completed");
|
||||
Notifications::instance().success(TR("shield_merge_done"));
|
||||
}
|
||||
};
|
||||
});
|
||||
if (groups.empty()) return;
|
||||
if (app->startConsolidation(groups, s_fee, s_utxo_limit)) {
|
||||
s_confirm = false;
|
||||
s_status_message.clear(); // an earlier "Into one" error / uncertain text belongs to that op
|
||||
s_op_terminal = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal
|
||||
@@ -547,12 +442,16 @@ void ShieldDialog::render(App* app)
|
||||
const auto& state = app->getWalletState();
|
||||
autoSelectDestination(state);
|
||||
pollOperation(app);
|
||||
pollPerAddress(app);
|
||||
const services::ConsolidationRun& run = app->consolidation();
|
||||
if (s_dismiss_finished) { // a finished run from an earlier opening: start with a fresh form
|
||||
s_dismiss_finished = false;
|
||||
app->dismissConsolidation();
|
||||
}
|
||||
if (isMerge) {
|
||||
services::ScopeInputs in;
|
||||
in.mergeMode = true;
|
||||
in.busyWithOp = !s_operation_id.empty() || s_operation_pending || !s_runs.empty();
|
||||
in.nodeReady = app->walletScanReady();
|
||||
in.busyWithOp = !s_operation_id.empty() || s_operation_pending || run.hasRun();
|
||||
in.nodeReady = app->walletScanReady() && !app->walletScanInFlight(); // don't stack on another wallet scan
|
||||
in.now = services::steadySeconds();
|
||||
if (services::scopeScanWant(s_scan, in) == services::ScopeWant::Post) loadScope(app);
|
||||
}
|
||||
@@ -563,9 +462,13 @@ void ShieldDialog::render(App* app)
|
||||
: TR("shield_description"));
|
||||
ImGui::Spacing();
|
||||
|
||||
// Submitted — inputs frozen, showing progress.
|
||||
const bool opInFlight = !s_operation_id.empty() || s_runs_submitting || !s_runs.empty();
|
||||
// Submitted — inputs frozen, showing progress. s_operation_pending covers the wait for the opid (up to
|
||||
// 120 s), when Confirm used to stay armed.
|
||||
const bool opInFlight = s_operation_pending || !s_operation_id.empty() || (isMerge && run.hasRun());
|
||||
const bool perAddress = isMerge && s_per_address;
|
||||
const float rowH = ImGui::GetFrameHeightWithSpacing();
|
||||
int maxRows = static_cast<int>(S.drawElement("dialogs.shield", "list-max-rows").size);
|
||||
if (maxRows < 1) maxRows = 7;
|
||||
|
||||
// ── Merge: scope + source selector ──────────────────────────────────────────────────────────
|
||||
if (isMerge && !opInFlight) {
|
||||
@@ -620,8 +523,8 @@ void ShieldDialog::render(App* app)
|
||||
if (s_scan.phase == services::ScopePhase::Loaded && listed == 0) {
|
||||
wrappedCaption(material::Warning(), TR("merge_keep_each_none"));
|
||||
} else if (listed > 0) {
|
||||
const float rowH = ImGui::GetFrameHeightWithSpacing();
|
||||
const float listH = std::min(static_cast<float>(listed), 7.0f) * rowH + ImGui::GetStyle().WindowPadding.y * 2.0f;
|
||||
const float listH = std::min(static_cast<float>(listed), static_cast<float>(maxRows)) * rowH +
|
||||
ImGui::GetStyle().WindowPadding.y * 2.0f;
|
||||
if (ImGui::BeginChild("##ConsolidateEach", ImVec2(-1, listH), ImGuiChildFlags_Borders)) {
|
||||
for (const auto& g : s_groups) {
|
||||
if (g.notes < 2) continue;
|
||||
@@ -751,51 +654,98 @@ void ShieldDialog::render(App* app)
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Per-address progress ────────────────────────────────────────────────────────────────────
|
||||
if (!s_runs.empty()) {
|
||||
bool leftovers = false;
|
||||
for (const auto& r : s_runs) {
|
||||
const std::string label = app->getAddressLabel(r.run.address);
|
||||
const char* name = label.empty() ? nullptr : label.c_str();
|
||||
const std::string shortA = shortAddr(r.run.address);
|
||||
int done = 0, failed = 0, unknown = 0;
|
||||
for (int st : r.state) { if (st == 1) ++done; if (st == 2) ++failed; if (st == 3) ++unknown; }
|
||||
const int batches = static_cast<int>(r.run.opids.size());
|
||||
char line[320];
|
||||
ImU32 col = material::OnSurfaceMedium();
|
||||
if (batches == 0) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_not_started"), name ? name : shortA.c_str(),
|
||||
r.run.error.c_str());
|
||||
col = material::Error();
|
||||
} else if (failed > 0) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_failed"), name ? name : shortA.c_str(),
|
||||
r.failure.c_str());
|
||||
col = material::Error();
|
||||
} else if (unknown > 0 && done + unknown == batches) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_unconfirmed"), name ? name : shortA.c_str());
|
||||
col = material::Warning();
|
||||
} else if (done == batches) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_done"), name ? name : shortA.c_str(),
|
||||
r.run.notesMerged, batches);
|
||||
col = material::Success();
|
||||
} else {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_running"), name ? name : shortA.c_str(),
|
||||
r.run.notesMerged, done, batches);
|
||||
// ── Per-address run (App-owned) ────────────────────────────────────────────────────────────
|
||||
if (isMerge && run.hasRun()) {
|
||||
const services::RunProgress p = run.progress();
|
||||
const services::RunSummary sum = run.summary();
|
||||
const bool live = run.active();
|
||||
if (live) {
|
||||
char cap[400];
|
||||
if (p.stopping) std::snprintf(cap, sizeof(cap), "%s", TR("merge_run_stopping"));
|
||||
else std::snprintf(cap, sizeof(cap), TR("merge_run_progress_fmt"), p.batchesSubmitted,
|
||||
std::max(1, p.batchesEstimated), p.notesMerged);
|
||||
wrappedCaption(material::OnSurfaceMedium(), cap);
|
||||
if (!p.stopping) {
|
||||
if (const char* key = services::runWaitKey(run.lastWait())) wrappedCaption(material::OnSurfaceMedium(), TR(key));
|
||||
}
|
||||
wrappedCaption(col, line);
|
||||
if (batches > 1 || r.run.remaining >= 2) leftovers = true;
|
||||
}
|
||||
ImGui::ProgressBar(static_cast<float>(p.batchesDone) / static_cast<float>(std::max(1, p.batchesEstimated)),
|
||||
ImVec2(-1, 0));
|
||||
const auto& addrs = run.addresses();
|
||||
const float runListH = std::min(static_cast<float>(addrs.size()), static_cast<float>(maxRows)) * rowH +
|
||||
ImGui::GetStyle().WindowPadding.y * 2.0f;
|
||||
bool leftovers = false;
|
||||
if (ImGui::BeginChild("##ConsolidateRuns", ImVec2(-1, runListH), ImGuiChildFlags_Borders)) {
|
||||
for (const auto& a : addrs) {
|
||||
const std::string label = app->getAddressLabel(a.group.address);
|
||||
const std::string name = label.empty() ? shortAddr(a.group.address) : label;
|
||||
const int nOps = static_cast<int>(a.run.opids.size());
|
||||
char line[400];
|
||||
ImU32 col = material::OnSurfaceMedium();
|
||||
if (nOps == 0 && !a.done) {
|
||||
// Stopping: an address never called will not be sent; one with a call in flight still may be.
|
||||
const bool willRun = live && !(p.stopping && a.outcome == services::AddrOutcome::Pending);
|
||||
std::snprintf(line, sizeof(line), willRun ? TR("merge_run_status_waiting") : TR("merge_run_status_not_run"),
|
||||
name.c_str(), a.group.notes);
|
||||
} else if (a.outcome == services::AddrOutcome::Uncertain) { // a call or batch whose result is unknown
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_unconfirmed"), name.c_str());
|
||||
col = material::Warning();
|
||||
} else if (nOps == 0) {
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_not_started"), name.c_str(),
|
||||
a.run.error.empty() ? TR("shield_unknown_error") : a.run.error.c_str());
|
||||
col = material::Error();
|
||||
} else if (a.outcome == services::AddrOutcome::Failed) {
|
||||
const std::string why = !a.failure.empty() ? a.failure
|
||||
: !a.run.error.empty() ? a.run.error : std::string(TR("shield_unknown_error"));
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_failed"), name.c_str(), why.c_str());
|
||||
col = material::Error();
|
||||
} else if ((a.done || !live) && a.opsDone == nOps) { // an ended run: every batch is terminal
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_done"), name.c_str(), a.run.notesMerged, nOps);
|
||||
col = a.done ? material::Success() : material::OnSurfaceMedium(); // stopped part-way: not complete
|
||||
} else { // only while live: batches outstanding or more to submit
|
||||
std::snprintf(line, sizeof(line), TR("merge_keep_each_status_running"), name.c_str(), a.run.notesMerged,
|
||||
a.opsDone, nOps);
|
||||
}
|
||||
wrappedCaption(col, line);
|
||||
if (nOps > 1 || a.run.remaining >= 2) leftovers = true;
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
if (leftovers) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||
ImGui::TextWrapped("%s", TR("merge_keep_each_run_again"));
|
||||
ImGui::PopStyleColor();
|
||||
wrappedCaption(material::OnSurfaceMedium(), TR("merge_keep_each_run_again"));
|
||||
}
|
||||
if (!live) { // below the rows: merge_keep_each_some_failed says "see the list above"
|
||||
std::string endLine;
|
||||
ImU32 endCol = material::OnSurfaceMedium();
|
||||
switch (services::runVerdict(sum)) {
|
||||
case services::RunVerdict::Clean: endLine = TR("shield_completed"); endCol = material::Success(); break;
|
||||
case services::RunVerdict::NothingSubmitted: endLine = TR("merge_keep_each_nothing_submitted"); endCol = material::Error(); break;
|
||||
case services::RunVerdict::SomeFailed: endLine = TR("merge_keep_each_some_failed"); endCol = material::Error(); break;
|
||||
case services::RunVerdict::Unconfirmed: endLine = TR("send_status_unconfirmed"); endCol = material::Warning(); break;
|
||||
case services::RunVerdict::Stopped: endLine = TR("merge_run_stopped_toast"); break;
|
||||
case services::RunVerdict::Halted: {
|
||||
std::string err = sum.firstError.empty() ? std::string(TR("shield_unknown_error")) : sum.firstError;
|
||||
if (err.size() > 160) err = err.substr(0, 160) + "...";
|
||||
char hb[512];
|
||||
std::snprintf(hb, sizeof(hb), TR("merge_run_halted_fmt"), err.c_str());
|
||||
endLine = hb;
|
||||
endCol = material::Error();
|
||||
break;
|
||||
}
|
||||
case services::RunVerdict::Aborted: endLine = TR("merge_run_aborted_toast"); endCol = material::Warning(); break;
|
||||
default: break;
|
||||
}
|
||||
if (!endLine.empty()) {
|
||||
ImGui::Spacing();
|
||||
wrappedCaption(endCol, endLine.c_str());
|
||||
}
|
||||
}
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
||||
if (!s_status_message.empty()) {
|
||||
if (!s_status_message.empty() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's
|
||||
if (s_operation_pending && !s_op_terminal)
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str());
|
||||
else
|
||||
@@ -808,11 +758,34 @@ void ShieldDialog::render(App* app)
|
||||
const bool syncing = state.sync.syncing;
|
||||
const bool haveDest = s_to_address[0] != '\0';
|
||||
|
||||
if (isMerge && run.hasRun()) {
|
||||
// The run's footer: Stop + Close while it runs (it keeps going in the background), Done once it ended.
|
||||
ImFont* bf = S.resolveFont(cancelBtn.font);
|
||||
if (run.active()) {
|
||||
material::BeginOverlayDialogFooter((shieldBtn.width + cancelBtn.width) * dp + ImGui::GetStyle().ItemSpacing.x,
|
||||
/*drawSeparator=*/false);
|
||||
ImGui::BeginDisabled(run.stopping());
|
||||
if (material::TactileButton(TR("merge_run_stop"), ImVec2(shieldBtn.width * dp, 0), S.resolveFont(shieldBtn.font)))
|
||||
app->stopConsolidation();
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(TR("close"), ImVec2(cancelBtn.width * dp, 0), bf)) s_open = false;
|
||||
} else {
|
||||
material::BeginOverlayDialogFooter(cancelBtn.width * dp, /*drawSeparator=*/false);
|
||||
if (material::TactileButton(TR("done"), ImVec2(cancelBtn.width * dp, 0), bf)) {
|
||||
app->dismissConsolidation();
|
||||
s_open = false;
|
||||
}
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (opInFlight) {
|
||||
// After submit: just a Close button (progress shows above; op continues in the background).
|
||||
material::BeginOverlayDialogFooter(cancelBtn.width, /*drawSeparator=*/false);
|
||||
material::BeginOverlayDialogFooter(cancelBtn.width * dp, /*drawSeparator=*/false);
|
||||
if (material::TactileButton(s_op_terminal ? TR("done") : TR("close"),
|
||||
ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font)))
|
||||
ImVec2(cancelBtn.width * dp, 0), S.resolveFont(cancelBtn.font)))
|
||||
s_open = false;
|
||||
material::EndOverlayDialog();
|
||||
return;
|
||||
@@ -848,11 +821,11 @@ void ShieldDialog::render(App* app)
|
||||
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
|
||||
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
|
||||
|
||||
float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x;
|
||||
float footerBtnW = (shieldBtn.width + cancelBtn.width) * dp + ImGui::GetStyle().ItemSpacing.x;
|
||||
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
|
||||
|
||||
if (!can_submit) ImGui::BeginDisabled();
|
||||
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
|
||||
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width * dp, 0), S.resolveFont(shieldBtn.font))) {
|
||||
if (s_confirm) { if (perAddress) submitPerAddress(app); else submitOperation(app); }
|
||||
else { s_confirm = true; } // first click → show the confirm summary
|
||||
}
|
||||
@@ -860,13 +833,25 @@ void ShieldDialog::render(App* app)
|
||||
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
||||
if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
|
||||
else if (syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
|
||||
else if (perAddress && s_scan.phase != services::ScopePhase::Loaded) {
|
||||
// Why the list isn't there yet, not "select addresses".
|
||||
char why[320];
|
||||
if (s_scan.phase == services::ScopePhase::Failed) {
|
||||
const std::string reason = s_scan.error.size() > 160 ? s_scan.error.substr(0, 160) + "..." : s_scan.error;
|
||||
std::snprintf(why, sizeof(why), TR("merge_scope_failed_fmt"), reason.c_str());
|
||||
} else {
|
||||
std::snprintf(why, sizeof(why), "%s", s_scan.phase == services::ScopePhase::Loading ? TR("merge_scope_loading")
|
||||
: state.isLocked() ? TR("merge_wait_locked") : TR("merge_waiting_node"));
|
||||
}
|
||||
material::Tooltip("%s", why);
|
||||
}
|
||||
else if (perAddress) material::Tooltip("%s", TR("merge_keep_each_select"));
|
||||
else if (!haveDest) material::Tooltip("%s", TR("shield_select_z"));
|
||||
else if (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable"));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(secondaryLabel, ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
|
||||
if (material::TactileButton(secondaryLabel, ImVec2(cancelBtn.width * dp, 0), S.resolveFont(cancelBtn.font))) {
|
||||
if (s_confirm) s_confirm = false; // Back → return to the form
|
||||
else s_open = false; // Cancel → close
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user