feat(diagnostics): persistent alert history with a status-bar bell (Foundation QoL)
Toasts fade in 1-4s, so anything that scrolled past was gone. Notifications now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (AlertRecord) — separate from the 5-item live-toast deque — plus a monotonic total_pushed_ counter. A bell in the status-bar right cluster opens an upward popup listing recent alerts newest-first: severity icon + colour (reusing the toast palette), the message, and a relative age (formatTimeAgoShort), with a Clear-all action. An unread dot on the bell, coloured by the most-severe unseen alert, marks alerts that arrived since the panel was last opened — driven by totalPushed() deltas so it survives capping/clearing. Thread note: every push is on the UI thread (RPC results run as main-thread MainCb callbacks), matching this class's existing lock-free model; documented as a no-raw-worker-thread invariant. New i18n keys (alerts_*). Build-clean; ctest 1/1 (adds testNotificationHistory: retention, order, cap, monotonic counter, clear). Closes the QoL bundle and the Foundation tier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
|
|||||||
| **P1-A** | W3-1, W3-2, W3-4 ✓ · W3-3 ⚑ | Migrate-to-seed correctness (fund-adjacent) | ◐ 3/4 |
|
| **P1-A** | W3-1, W3-2, W3-4 ✓ · W3-3 ⚑ | Migrate-to-seed correctness (fund-adjacent) | ◐ 3/4 |
|
||||||
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
|
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
|
||||||
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
|
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
|
||||||
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge ✓, alert-history ☐ | Diagnostics foundation + QoL bundle | ◐ |
|
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,6 +112,7 @@ Land W7-2 first — it unblocks the rest.
|
|||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
|
- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 1–4s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
|
||||||
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
|
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
|
||||||
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
|
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
|
||||||
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
|
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
|
||||||
|
|||||||
155
src/app.cpp
155
src/app.cpp
@@ -2283,6 +2283,92 @@ void App::renderNodeStatusBanner()
|
|||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::renderAlertHistoryPanel()
|
||||||
|
{
|
||||||
|
namespace m = ui::material;
|
||||||
|
const float dp = ui::Layout::dpiScale();
|
||||||
|
auto& notes = ui::Notifications::instance();
|
||||||
|
const auto& hist = notes.history();
|
||||||
|
const float innerW = ImGui::GetContentRegionAvail().x;
|
||||||
|
const float padX = 8.0f * dp;
|
||||||
|
|
||||||
|
ImFont* icoF = m::Type().iconSmall();
|
||||||
|
ImFont* txtF = m::Type().caption();
|
||||||
|
|
||||||
|
// Header: "Recent alerts" on the left, a Clear-all icon button on the right.
|
||||||
|
ImGui::SetCursorPosX(padX);
|
||||||
|
ImGui::PushFont(txtF);
|
||||||
|
ImGui::TextDisabled("%s", TR("alerts_recent"));
|
||||||
|
ImGui::PopFont();
|
||||||
|
if (!hist.empty()) {
|
||||||
|
const float clrW = icoF->LegacySize + 8.0f * dp;
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetCursorPosX(innerW - clrW);
|
||||||
|
m::IconButtonStyle cst;
|
||||||
|
cst.color = m::OnSurfaceMedium();
|
||||||
|
cst.hoverColor = m::OnSurface();
|
||||||
|
cst.hoverBg = m::StateHover();
|
||||||
|
cst.bgRounding = 4.0f * dp;
|
||||||
|
cst.tooltip = TR("alerts_clear");
|
||||||
|
if (m::IconButton("##ClearAlerts", ICON_MD_CLEAR_ALL, icoF,
|
||||||
|
ImVec2(clrW, icoF->LegacySize + 4.0f * dp), cst)) {
|
||||||
|
notes.clearHistory();
|
||||||
|
alerts_seen_total_ = notes.totalPushed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
if (hist.empty()) {
|
||||||
|
ImGui::SetCursorPosX(padX);
|
||||||
|
ImGui::PushFont(txtF);
|
||||||
|
ImGui::TextDisabled("%s", TR("alerts_none"));
|
||||||
|
ImGui::PopFont();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollable list, newest first. Height adapts to the entry count but caps so a busy session
|
||||||
|
// scrolls inside the panel instead of blowing past the popup's max height.
|
||||||
|
const float perEntry = txtF->LegacySize * 2.0f + 14.0f * dp; // message line + time line + spacing
|
||||||
|
const float listH = std::min(300.0f * dp, static_cast<float>(hist.size()) * perEntry);
|
||||||
|
ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false);
|
||||||
|
int idx = 0;
|
||||||
|
for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) {
|
||||||
|
const ui::AlertRecord& a = *it;
|
||||||
|
ImU32 col; const char* icon;
|
||||||
|
switch (a.type) {
|
||||||
|
case ui::NotificationType::Success: col = m::Success(); icon = ICON_MD_CHECK_CIRCLE; break;
|
||||||
|
case ui::NotificationType::Warning: col = m::Warning(); icon = ICON_MD_WARNING; break;
|
||||||
|
case ui::NotificationType::Error: col = m::Error(); icon = ICON_MD_ERROR; break;
|
||||||
|
case ui::NotificationType::Info:
|
||||||
|
default: col = m::Primary(); icon = ICON_MD_INFO; break;
|
||||||
|
}
|
||||||
|
ImGui::PushID(idx);
|
||||||
|
// Icon + message (message wraps in the remaining width).
|
||||||
|
ImGui::SetCursorPosX(padX);
|
||||||
|
ImGui::PushFont(icoF);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, col);
|
||||||
|
ImGui::TextUnformatted(icon);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopFont();
|
||||||
|
ImGui::SameLine(0.0f, 6.0f * dp);
|
||||||
|
ImGui::PushFont(txtF);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurface());
|
||||||
|
ImGui::PushTextWrapPos(innerW - padX);
|
||||||
|
ImGui::TextWrapped("%s", a.message.c_str());
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
// Relative age, dim, indented under the message.
|
||||||
|
ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled());
|
||||||
|
ImGui::TextUnformatted(util::formatTimeAgoShort(a.epoch).c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopFont();
|
||||||
|
ImGui::PopID();
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
ImGui::EndChild();
|
||||||
|
}
|
||||||
|
|
||||||
void App::renderStatusBar()
|
void App::renderStatusBar()
|
||||||
{
|
{
|
||||||
// Status bar layout from unified UI schema
|
// Status bar layout from unified UI schema
|
||||||
@@ -2559,9 +2645,78 @@ void App::renderStatusBar()
|
|||||||
float cbX = occupiedX - cbW - gap;
|
float cbX = occupiedX - cbW - gap;
|
||||||
ImGui::SameLine(cbX);
|
ImGui::SameLine(cbX);
|
||||||
ImGui::TextUnformatted(cb.c_str());
|
ImGui::TextUnformatted(cb.c_str());
|
||||||
|
occupiedX = cbX;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alert-history bell — leftmost item of the right cluster. Opens a panel of recent alerts,
|
||||||
|
// including ones whose toast already faded; an unread dot marks alerts that arrived since
|
||||||
|
// the panel was last opened.
|
||||||
|
{
|
||||||
|
const float dp = ui::Layout::dpiScale();
|
||||||
|
auto& notes = ui::Notifications::instance();
|
||||||
|
ImFont* bellFont = ui::material::Type().iconSmall();
|
||||||
|
const bool anyHist = notes.hasHistory();
|
||||||
|
const char* bellGlyph = anyHist ? ICON_MD_NOTIFICATIONS : ICON_MD_NOTIFICATIONS_NONE;
|
||||||
|
|
||||||
|
ImGui::PushFont(bellFont);
|
||||||
|
const float glyphW = ImGui::CalcTextSize(bellGlyph).x;
|
||||||
|
ImGui::PopFont();
|
||||||
|
const float bellW = glyphW + 10.0f * dp;
|
||||||
|
const float bellH = bellFont->LegacySize + 4.0f * dp;
|
||||||
|
const float bellX = occupiedX - bellW - gap;
|
||||||
|
|
||||||
|
ImGui::SameLine(bellX);
|
||||||
|
ui::material::IconButtonStyle st;
|
||||||
|
st.color = ui::material::OnSurfaceMedium();
|
||||||
|
st.hoverColor = ui::material::OnSurface();
|
||||||
|
st.hoverBg = ui::material::StateHover();
|
||||||
|
st.bgRounding = 4.0f * dp;
|
||||||
|
st.tooltip = TR("alerts_history_tooltip");
|
||||||
|
const bool clicked = ui::material::IconButton("##AlertBell", bellGlyph, bellFont,
|
||||||
|
ImVec2(bellW, bellH), st);
|
||||||
|
const ImVec2 bellMin = ImGui::GetItemRectMin();
|
||||||
|
const ImVec2 bellMax = ImGui::GetItemRectMax();
|
||||||
|
|
||||||
|
// Unread dot: alerts pushed since the panel was last opened, coloured by the most
|
||||||
|
// severe unseen alert. totalPushed() is monotonic, so this survives capping/clearing.
|
||||||
|
const std::uint64_t unseen = notes.totalPushed() - alerts_seen_total_;
|
||||||
|
if (unseen > 0 && anyHist) {
|
||||||
|
const auto& h = notes.history();
|
||||||
|
size_t scan = (unseen < h.size()) ? static_cast<size_t>(unseen) : h.size();
|
||||||
|
bool anyErr = false, anyWarn = false;
|
||||||
|
for (size_t i = 0; i < scan; ++i) {
|
||||||
|
auto t = h[h.size() - 1 - i].type;
|
||||||
|
if (t == ui::NotificationType::Error) { anyErr = true; break; }
|
||||||
|
if (t == ui::NotificationType::Warning) anyWarn = true;
|
||||||
|
}
|
||||||
|
ImU32 dotCol = anyErr ? ui::material::Error()
|
||||||
|
: anyWarn ? ui::material::Warning()
|
||||||
|
: ui::material::Primary();
|
||||||
|
const float r = 3.0f * dp;
|
||||||
|
ImGui::GetWindowDrawList()->AddCircleFilled(
|
||||||
|
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
|
||||||
|
ImGui::OpenPopup("##AlertHistoryPopup");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status bar sits at the window bottom, so grow the popup UPWARD from the bell
|
||||||
|
// (pivot bottom-left → the anchor point becomes the popup's bottom-left corner).
|
||||||
|
ImGui::SetNextWindowPos(ImVec2(bellMin.x, bellMin.y - 4.0f * dp),
|
||||||
|
ImGuiCond_Always, ImVec2(0.0f, 1.0f));
|
||||||
|
const float panelW = 320.0f * dp;
|
||||||
|
ImGui::SetNextWindowSizeConstraints(ImVec2(panelW, 0), ImVec2(panelW, 360.0f * dp));
|
||||||
|
if (ImGui::BeginPopup("##AlertHistoryPopup")) {
|
||||||
|
renderAlertHistoryPanel();
|
||||||
|
ImGui::EndPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
occupiedX = bellX;
|
||||||
|
}
|
||||||
|
|
||||||
// Version always at far right
|
// Version always at far right
|
||||||
ImGui::SameLine(versionX);
|
ImGui::SameLine(versionX);
|
||||||
ImGui::Text("%s", versionBuf);
|
ImGui::Text("%s", versionBuf);
|
||||||
|
|||||||
@@ -1030,6 +1030,7 @@ private:
|
|||||||
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
|
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
|
||||||
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
|
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
|
||||||
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
|
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
|
||||||
|
std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
|
||||||
|
|
||||||
// Current page (sidebar navigation)
|
// Current page (sidebar navigation)
|
||||||
ui::NavPage current_page_ = ui::NavPage::Overview;
|
ui::NavPage current_page_ = ui::NavPage::Overview;
|
||||||
@@ -1299,6 +1300,9 @@ private:
|
|||||||
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
|
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
|
||||||
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
|
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
|
||||||
void renderNodeStatusBanner();
|
void renderNodeStatusBanner();
|
||||||
|
// Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
|
||||||
|
// newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
|
||||||
|
void renderAlertHistoryPanel();
|
||||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||||
void renderImportKeyDialog();
|
void renderImportKeyDialog();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <ctime>
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
#include "schema/ui_schema.h"
|
#include "schema/ui_schema.h"
|
||||||
|
|
||||||
@@ -22,6 +24,15 @@ enum class NotificationType {
|
|||||||
Error
|
Error
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A retained alert for the persistent history panel. Unlike a live Notification (which fades and is
|
||||||
|
// erased within seconds), this keeps a wall-clock epoch so its age can be shown as "3m ago" long
|
||||||
|
// after the toast is gone. See App::renderAlertHistoryPanel.
|
||||||
|
struct AlertRecord {
|
||||||
|
std::string message;
|
||||||
|
NotificationType type;
|
||||||
|
std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display
|
||||||
|
};
|
||||||
|
|
||||||
struct Notification {
|
struct Notification {
|
||||||
std::string message;
|
std::string message;
|
||||||
NotificationType type;
|
NotificationType type;
|
||||||
@@ -84,20 +95,30 @@ public:
|
|||||||
|
|
||||||
void push(const std::string& message, NotificationType type, float duration = 5.0f) {
|
void push(const std::string& message, NotificationType type, float duration = 5.0f) {
|
||||||
notifications_.emplace_back(message, type, duration);
|
notifications_.emplace_back(message, type, duration);
|
||||||
|
|
||||||
|
// Retain a copy in the persistent history (the toast above will fade in seconds; this
|
||||||
|
// survives so the user can review what happened). Thread note: every push is on the UI
|
||||||
|
// thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock,
|
||||||
|
// consistent with the rest of this class. Do NOT push from a raw worker thread.
|
||||||
|
history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr))});
|
||||||
|
++total_pushed_;
|
||||||
|
while (history_.size() > kMaxHistory) {
|
||||||
|
history_.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
// Log errors and warnings (debug-only output)
|
// Log errors and warnings (debug-only output)
|
||||||
if (type == NotificationType::Error) {
|
if (type == NotificationType::Error) {
|
||||||
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
|
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
|
||||||
} else if (type == NotificationType::Warning) {
|
} else if (type == NotificationType::Warning) {
|
||||||
DEBUG_LOGF("[WARN] Notification: %s\n", message.c_str());
|
DEBUG_LOGF("[WARN] Notification: %s\n", message.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward errors and warnings to console callback
|
// Forward errors and warnings to console callback
|
||||||
if (console_callback_ && (type == NotificationType::Error || type == NotificationType::Warning)) {
|
if (console_callback_ && (type == NotificationType::Error || type == NotificationType::Warning)) {
|
||||||
const char* prefix = (type == NotificationType::Error) ? "[ERROR] " : "[WARN] ";
|
const char* prefix = (type == NotificationType::Error) ? "[ERROR] " : "[WARN] ";
|
||||||
console_callback_(prefix + message, type == NotificationType::Error);
|
console_callback_(prefix + message, type == NotificationType::Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit max notifications
|
// Limit max notifications
|
||||||
while (notifications_.size() > max_notifications_) {
|
while (notifications_.size() > max_notifications_) {
|
||||||
notifications_.pop_front();
|
notifications_.pop_front();
|
||||||
@@ -122,21 +143,34 @@ public:
|
|||||||
void clear() {
|
void clear() {
|
||||||
notifications_.clear();
|
notifications_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setMaxNotifications(size_t max) {
|
void setMaxNotifications(size_t max) {
|
||||||
max_notifications_ = max;
|
max_notifications_ = max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Persistent alert history (for the status-bar bell panel) ──
|
||||||
|
/// Retained alerts, oldest first (capped at kMaxHistory; the toast deque is separate).
|
||||||
|
const std::deque<AlertRecord>& history() const { return history_; }
|
||||||
|
bool hasHistory() const { return !history_.empty(); }
|
||||||
|
void clearHistory() { history_.clear(); }
|
||||||
|
/// Monotonic count of every alert ever pushed this session — survives capping/clearing, so it is
|
||||||
|
/// the correct basis for an "unseen since last opened" count (deque size is not).
|
||||||
|
std::uint64_t totalPushed() const { return total_pushed_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Notifications() = default;
|
Notifications() = default;
|
||||||
~Notifications() = default;
|
~Notifications() = default;
|
||||||
Notifications(const Notifications&) = delete;
|
Notifications(const Notifications&) = delete;
|
||||||
Notifications& operator=(const Notifications&) = delete;
|
Notifications& operator=(const Notifications&) = delete;
|
||||||
|
|
||||||
std::deque<Notification> notifications_;
|
std::deque<Notification> notifications_;
|
||||||
size_t max_notifications_ = 5;
|
size_t max_notifications_ = 5;
|
||||||
std::function<void(const std::string&, bool)> console_callback_;
|
std::function<void(const std::string&, bool)> console_callback_;
|
||||||
|
|
||||||
|
std::deque<AlertRecord> history_;
|
||||||
|
std::uint64_t total_pushed_ = 0;
|
||||||
|
static constexpr size_t kMaxHistory = 100;
|
||||||
|
|
||||||
static float schemaDuration(const char* key, float fallback) {
|
static float schemaDuration(const char* key, float fallback) {
|
||||||
float v = schema::UI().drawElement("components.notifications", key).size;
|
float v = schema::UI().drawElement("components.notifications", key).size;
|
||||||
return v > 0.0f ? v : fallback;
|
return v > 0.0f ? v : fallback;
|
||||||
|
|||||||
@@ -1320,6 +1320,11 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["data_stale_tooltip"] =
|
strings_["data_stale_tooltip"] =
|
||||||
"Balance may be out of date — the wallet hasn't received a fresh update recently. "
|
"Balance may be out of date — the wallet hasn't received a fresh update recently. "
|
||||||
"Check your node connection.";
|
"Check your node connection.";
|
||||||
|
// Persistent alert-history panel (status-bar bell).
|
||||||
|
strings_["alerts_history_tooltip"] = "Recent alerts";
|
||||||
|
strings_["alerts_recent"] = "RECENT ALERTS";
|
||||||
|
strings_["alerts_none"] = "No alerts yet";
|
||||||
|
strings_["alerts_clear"] = "Clear alert history";
|
||||||
strings_["daemon_port_busy_warn"] =
|
strings_["daemon_port_busy_warn"] =
|
||||||
"Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. "
|
"Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. "
|
||||||
"Close the program using it (or free the port), then restart — the wallet can't start "
|
"Close the program using it (or free the port), then restart — the wallet can't start "
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
#include "ui/windows/mining_tab_helpers.h"
|
#include "ui/windows/mining_tab_helpers.h"
|
||||||
#include "ui/node_status_banner.h"
|
#include "ui/node_status_banner.h"
|
||||||
#include "ui/staleness_badge.h"
|
#include "ui/staleness_badge.h"
|
||||||
|
#include "ui/notifications.h"
|
||||||
#include "util/address_validation.h"
|
#include "util/address_validation.h"
|
||||||
#include "util/amount_format.h"
|
#include "util/amount_format.h"
|
||||||
#include "util/payment_uri.h"
|
#include "util/payment_uri.h"
|
||||||
@@ -2655,6 +2656,37 @@ void testStalenessBadge()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testNotificationHistory()
|
||||||
|
{
|
||||||
|
using dragonx::ui::Notifications;
|
||||||
|
using dragonx::ui::NotificationType;
|
||||||
|
auto& n = Notifications::instance();
|
||||||
|
n.clearHistory();
|
||||||
|
auto base = n.totalPushed(); // monotonic counter is NOT reset by clearHistory()
|
||||||
|
|
||||||
|
n.push("first", NotificationType::Info, 5.0f);
|
||||||
|
n.push("second", NotificationType::Error, 5.0f);
|
||||||
|
EXPECT_EQ((int)n.history().size(), 2);
|
||||||
|
EXPECT_TRUE(n.hasHistory());
|
||||||
|
// Oldest first, newest last; type + wall-clock stamp retained.
|
||||||
|
EXPECT_EQ(n.history().front().message, std::string("first"));
|
||||||
|
EXPECT_EQ(n.history().back().message, std::string("second"));
|
||||||
|
EXPECT_TRUE(n.history().back().type == NotificationType::Error);
|
||||||
|
EXPECT_TRUE(n.history().back().epoch > 0);
|
||||||
|
EXPECT_EQ((int)(n.totalPushed() - base), 2);
|
||||||
|
|
||||||
|
// Cap at 100: push past it → size caps, oldest entries drop, totalPushed keeps counting.
|
||||||
|
for (int i = 0; i < 150; ++i) n.push("bulk", NotificationType::Info, 5.0f);
|
||||||
|
EXPECT_EQ((int)n.history().size(), 100);
|
||||||
|
EXPECT_EQ((int)(n.totalPushed() - base), 152);
|
||||||
|
EXPECT_EQ(n.history().front().message, std::string("bulk")); // the two originals fell off
|
||||||
|
|
||||||
|
n.clearHistory();
|
||||||
|
EXPECT_TRUE(!n.hasHistory());
|
||||||
|
EXPECT_EQ((int)n.history().size(), 0);
|
||||||
|
EXPECT_EQ((int)(n.totalPushed() - base), 152); // clearing doesn't rewind the counter
|
||||||
|
}
|
||||||
|
|
||||||
void testLoggerFileSink()
|
void testLoggerFileSink()
|
||||||
{
|
{
|
||||||
using dragonx::util::Logger;
|
using dragonx::util::Logger;
|
||||||
@@ -7044,6 +7076,7 @@ int main()
|
|||||||
testConsoleSecretRedaction();
|
testConsoleSecretRedaction();
|
||||||
testNodeStatusBanner();
|
testNodeStatusBanner();
|
||||||
testStalenessBadge();
|
testStalenessBadge();
|
||||||
|
testNotificationHistory();
|
||||||
testLoggerFileSink();
|
testLoggerFileSink();
|
||||||
testDaemonLifecycleExecution();
|
testDaemonLifecycleExecution();
|
||||||
testDaemonLifecycleAdapters();
|
testDaemonLifecycleAdapters();
|
||||||
|
|||||||
Reference in New Issue
Block a user