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:
2026-08-02 20:57:02 -05:00
parent c7c3440a7b
commit 4b3f0fa92b
6 changed files with 239 additions and 7 deletions

View File

@@ -2283,6 +2283,92 @@ void App::renderNodeStatusBanner()
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()
{
// Status bar layout from unified UI schema
@@ -2559,9 +2645,78 @@ void App::renderStatusBar()
float cbX = occupiedX - cbW - gap;
ImGui::SameLine(cbX);
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
ImGui::SameLine(versionX);
ImGui::Text("%s", versionBuf);