Files
ObsidianDragon/src/ui/windows/peers_tab.cpp
T
DanSandClaude Opus 5.5 296c04386c perf(mining): sample daemon memory off the UI thread
The daemon's RSS was read on the UI thread: a scan of every /proc entry
on Linux (1-7 ms), a forked ps on macOS (up to ~30 ms). The mining poll
probed every 1-5 s on every tab, and the Mining tab's memory bar probed
again every 1.5 s.

util::BackgroundSampler now runs the probe on one lazily started thread
and caches the result. getDaemonMemoryUsageMB() returns the last sample
and asks for a new one at most every 3 s, and only while the Mining tab
memory bar or the Peers tab Memory cell is drawn: nothing on other tabs
or while minimized. EmbeddedDaemon::memoryProbe() hands the sampler a
pid snapshot (on Windows the value read from the owned process handle),
so nothing off-thread touches daemon state. The mining poll does no I/O
any more; it carries the last sample. Both shutdown paths stop the
sampler.

Fixed along the way: an unreadable /proc made the directory iterator
throw on the UI thread; the scan now just stops.

User-visible:
- the Peers tab Memory cell keeps updating during sync and shows an
  external daemon's memory (it showed the mining poll's value, which
  pauses during sync and covers only the embedded daemon);
- the Mining tab memory bar refreshes every ~3 s (was 1.5 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00

942 lines
49 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "peers_tab.h"
#include "../../app.h"
#include "../../data/wallet_state.h"
#include "../../util/i18n.h"
#include "mining_tab_helpers.h"
#include "../theme.h"
#include "../effects/imgui_acrylic.h"
#include "../effects/low_spec.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../material/colors.h"
#include "../layout.h"
#include "../notifications.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <string>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <unordered_set>
namespace dragonx {
namespace ui {
using namespace material;
// Track selected peer for ban action
static int s_selected_peer_idx = -1;
static int s_selected_banned_idx = -1;
// Helper: Extract IP without port
static std::string ExtractIP(const std::string& addr)
{
if (addr.empty()) return addr;
std::string ip = addr;
if (ip[0] == '[') {
auto pos = ip.rfind("]:");
if (pos != std::string::npos) ip = ip.substr(1, pos - 1);
} else {
auto pos = ip.rfind(':');
if (pos != std::string::npos) ip = ip.substr(0, pos);
}
return ip;
}
// Known seed/addnode IPs for the DragonX network.
// These are the official seed nodes that the daemon connects to on startup.
static bool IsSeedNode(const std::string& addr) {
static const std::unordered_set<std::string> seeds = {
"176.126.87.241", // embedded daemon -addnode
"94.72.112.24", // node1.hush.is
"37.60.252.160", // node2.hush.is
"176.57.70.185", // node3.hush.is / node6.hush.is
"185.213.209.89", // node4.hush.is
"137.74.4.198", // node5.hush.is
"18.193.113.121", // node7.hush.is
"38.60.224.94", // node8.hush.is
};
return seeds.count(ExtractIP(addr)) > 0;
}
// Draw one label/value stat cell in an info card. The caption label sits on the first line
// (OnSurfaceMedium); the value sits below it (valueFont) — or an em-dash "—" in the disabled
// color when the value is null/empty. Matches the hand-positioned blocks it replaces exactly:
// value baseline is ry + capFont->LegacySize + spacingXs().
static void drawStatCell(ImDrawList* dl, float cx, float ry, ImFont* capFont, ImFont* valueFont,
const char* label, const char* valueText, ImU32 valueCol)
{
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), label);
float valY = ry + capFont->LegacySize + Layout::spacingXs();
if (valueText && valueText[0]) {
dl->AddText(valueFont, valueFont->LegacySize, ImVec2(cx, valY), valueCol, valueText);
} else {
dl->AddText(valueFont, valueFont->LegacySize, ImVec2(cx, valY), OnSurfaceDisabled(), "\xE2\x80\x94");
}
}
// Draw a subtle horizontal divider between info-card rows, inset to the card's rounded corners.
static void drawCardDivider(ImDrawList* dl, float leftX, float rightX, float y, float rounding, float dp)
{
dl->AddLine(ImVec2(leftX + rounding * 0.5f, y),
ImVec2(rightX - rounding * 0.5f, y),
WithAlpha(OnSurface(), 15), 1.0f * dp);
}
void RenderPeersTab(App* app)
{
auto& S = schema::UI();
auto peerTable = S.table("tabs.peers", "peer-table");
auto bannedTable = S.table("tabs.peers", "banned-table");
const auto& state = app->getWalletState();
// Scrollable child to contain all content within available space
ImVec2 peersAvail = ImGui::GetContentRegionAvail();
// NoScrollWithMouse: the inner ##PeersList owns wheel scrolling (via ApplySmoothScroll). Without
// this, a wheel over the list would scroll BOTH the list (smooth-scroll) and this outer container
// (ImGui forwards the NoScrollWithMouse child's wheel to its scrollable ancestor) — a double-scroll.
// Safe because the peer panel is sized to fill the remaining height, so this outer never overflows.
ImGui::BeginChild("##PeersScroll", peersAvail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Responsive: scale factors per frame
float availWidth = ImGui::GetContentRegionAvail().x;
float hs = Layout::hScale(availWidth);
float pad = Layout::cardInnerPadding();
float gap = Layout::cardGap();
ImDrawList* dl = ImGui::GetWindowDrawList();
GlassPanelSpec glassSpec;
glassSpec.rounding = Layout::glassRounding();
ImFont* ovFont = Type().overline();
ImFont* capFont = Type().caption();
ImFont* sub1 = Type().subtitle1();
ImFont* body2 = Type().body2();
char buf[128];
// ================================================================
// BLOCKCHAIN & PEERS CARDS — Side by side
// ================================================================
float infoCardsH = 0;
{
const auto& mining = state.mining;
// Compute peer stats
int totalPeers = (int)state.peers.size();
int inboundCount = 0;
int outboundCount = 0;
double totalPing = 0;
int64_t totalBytesSent = 0, totalBytesRecv = 0;
int tlsCount = 0;
for (const auto& p : state.peers) {
if (p.inbound) inboundCount++;
else outboundCount++;
totalPing += p.pingtime;
totalBytesSent += p.bytessent;
totalBytesRecv += p.bytesrecv;
if (!p.tls_cipher.empty()) tlsCount++;
}
double avgPing = totalPeers > 0 ? (totalPing / totalPeers) * 1000.0 : 0;
// Format bytes helper
auto fmtBytes = [](int64_t bytes) -> std::string {
char b[32];
if (bytes >= 1073741824LL)
snprintf(b, sizeof(b), "%.1f GB", bytes / 1073741824.0);
else if (bytes >= 1048576LL)
snprintf(b, sizeof(b), "%.1f MB", bytes / 1048576.0);
else if (bytes >= 1024LL)
snprintf(b, sizeof(b), "%.0f KB", bytes / 1024.0);
else
snprintf(b, sizeof(b), "%lld B", (long long)bytes);
return b;
};
// Blockchain card: 5 rows, Peers card: 4 rows (2 cols per row)
float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
float headerH = ovFont->LegacySize + Layout::spacingSm();
float dividerH = 1.0f * Layout::dpiScale();
// Use 5 rows for blockchain card (peers card will have empty space at bottom)
float cardInnerH = pad * 0.5f + headerH + rowH * 5 + (Layout::spacingSm() + dividerH) * 4 + pad * 0.5f;
infoCardsH = cardInnerH;
float cardW = (availWidth - gap) * 0.5f;
ImVec2 basePos = ImGui::GetCursorScreenPos();
float dp = Layout::dpiScale();
// ================================================================
// BLOCKCHAIN CARD (left)
// ================================================================
{
ImVec2 cardMin = basePos;
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + infoCardsH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
// Card header
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("peers_blockchain"));
float colW = (cardW - pad * 2) / 2.0f;
float leftX = cardMin.x + pad;
float rightX = cardMin.x + pad + colW;
float ry = cardMin.y + pad * 0.5f + headerH;
// Advance ry past a row and draw a divider before the next one. Keeps the row
// stepping + divider in one place (was a per-row copy + a duplicate divider lambda).
auto stepRow = [&]() {
ry += rowH + Layout::spacingSm() * 0.5f;
drawCardDivider(dl, cardMin.x, cardMax.x, ry, glassSpec.rounding, dp);
ry += Layout::spacingSm() * 0.5f + dividerH;
};
// Row 1: Blocks | Longest Chain
{
// Blocks — bespoke: block number + "(X left)" in a second color
float cx = leftX;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_blocks"));
int blocks = state.sync.blocks;
if (blocks > 0) {
int chainTip = state.longestchain > 0 ? state.longestchain : state.sync.headers;
int blocksLeft = chainTip - blocks;
if (blocksLeft < 0) blocksLeft = 0;
if (blocksLeft > 0) {
float valY = ry + capFont->LegacySize + Layout::spacingXs();
// Draw block number in normal color
char blockStr[32];
snprintf(blockStr, sizeof(blockStr), "%d ", blocks);
ImVec2 numSz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, blockStr);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, valY), OnSurface(), blockStr);
// Draw "(X left)" in warning color
char leftStr[32];
snprintf(leftStr, sizeof(leftStr), TR("peers_blocks_left"), blocksLeft);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + numSz.x, valY + (sub1->LegacySize - capFont->LegacySize) * 0.5f),
Warning(), leftStr);
} else {
snprintf(buf, sizeof(buf), "%d", blocks);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
}
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurfaceDisabled(), "\xE2\x80\x94");
}
// Longest Chain
if (state.longestchain > 0) {
snprintf(buf, sizeof(buf), "%d", state.longestchain);
int localHeight = mining.blocks > 0 ? mining.blocks : state.sync.blocks;
ImU32 chainCol = (localHeight >= state.longestchain) ? Success() : Warning();
drawStatCell(dl, rightX, ry, capFont, sub1, TR("peers_longest_chain"), buf, chainCol);
} else {
drawStatCell(dl, rightX, ry, capFont, sub1, TR("peers_longest_chain"), nullptr, OnSurface());
}
}
stepRow();
// Row 2: Hashrate | Difficulty
{
std::string hr = mining.networkHashrate > 0 ? FormatHashrate(mining.networkHashrate) : std::string();
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_hashrate"),
hr.empty() ? nullptr : hr.c_str(), Success());
if (mining.difficulty > 0) snprintf(buf, sizeof(buf), "%.4f", mining.difficulty);
drawStatCell(dl, rightX, ry, capFont, sub1, TR("difficulty"),
mining.difficulty > 0 ? buf : nullptr, OnSurface());
}
stepRow();
// Row 3: Notarized | Protocol
{
if (state.notarized > 0) snprintf(buf, sizeof(buf), "%d", state.notarized);
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_notarized"),
state.notarized > 0 ? buf : nullptr, OnSurface());
if (state.protocol_version > 0) snprintf(buf, sizeof(buf), "%d", state.protocol_version);
drawStatCell(dl, rightX, ry, capFont, sub1, TR("peers_protocol"),
state.protocol_version > 0 ? buf : nullptr, OnSurface());
}
stepRow();
// Row 4: Version | Memory
{
if (state.daemon_version > 0) {
int major = state.daemon_version / 1000000;
int minor = (state.daemon_version / 10000) % 100;
int patch = (state.daemon_version / 100) % 100;
snprintf(buf, sizeof(buf), "%d.%d.%d", major, minor, patch);
}
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_version"),
state.daemon_version > 0 ? buf : nullptr, OnSurface());
// The cached off-thread sample (refreshed every ~3 s while this tab or the Mining tab asks). Also
// keeps updating during sync and shows an external daemon's memory (process scan).
double memMb = app->getDaemonMemoryUsageMB();
if (memMb > 0) {
if (memMb >= 1024.0)
snprintf(buf, sizeof(buf), "%.1f GB", memMb / 1024.0);
else
snprintf(buf, sizeof(buf), "%.0f MB", memMb);
}
drawStatCell(dl, rightX, ry, capFont, sub1, TR("peers_memory"),
memMb > 0 ? buf : nullptr, OnSurface());
}
stepRow();
// Row 5: Longest Chain | Best Block
{
// Longest Chain
if (state.longestchain > 0) {
snprintf(buf, sizeof(buf), "%d", state.longestchain);
// Color green if local matches longest, warning if behind
int localHeight = mining.blocks > 0 ? mining.blocks : state.sync.blocks;
ImU32 chainCol = (localHeight >= state.longestchain) ? Success() : Warning();
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_longest"), buf, chainCol);
} else {
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_longest"), nullptr, OnSurface());
}
// Best Block — bespoke: truncated hash with click-to-copy + hover underline
float cx = rightX;
float valY = ry + capFont->LegacySize + Layout::spacingXs();
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_best_block"));
if (!state.sync.best_blockhash.empty()) {
// Truncate hash to fit: first 6 + "..." + last 6
std::string hash = state.sync.best_blockhash;
std::string truncHash;
if (hash.length() > 15) {
truncHash = hash.substr(0, 6) + "..." + hash.substr(hash.length() - 6);
} else {
truncHash = hash;
}
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, valY), OnSurface(), truncHash.c_str());
// Click to copy full hash
ImVec2 hashSz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, truncHash.c_str());
ImVec2 savedCursor = ImGui::GetCursorScreenPos();
std::string copyTip = std::string(TR("peers_click_copy")) + " " + hash;
if (material::CopyableTextOverlay("##BestBlockCopy", ImVec2(cx, valY), hashSz,
sub1->LegacySize, hash.c_str(), copyTip.c_str(),
ImVec2(Layout::spacingSm(), 2 * dp))) {
ui::Notifications::instance().info(TR("peers_hash_copied"));
}
ImGui::SetCursorScreenPos(savedCursor);
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, valY), OnSurfaceDisabled(), "\xE2\x80\x94");
}
}
}
// ================================================================
// PEERS CARD (right)
// ================================================================
{
ImVec2 cardMin(basePos.x + cardW + gap, basePos.y);
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + infoCardsH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
// Card header
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("peers_upper"));
float colW = (cardW - pad * 2) / 2.0f;
float leftX = cardMin.x + pad;
float rightX = cardMin.x + pad + colW;
float ry = cardMin.y + pad * 0.5f + headerH;
// Advance ry past a row and draw a divider before the next one (see blockchain card).
auto stepRow = [&]() {
ry += rowH + Layout::spacingSm() * 0.5f;
drawCardDivider(dl, cardMin.x, cardMax.x, ry, glassSpec.rounding, dp);
ry += Layout::spacingSm() * 0.5f + dividerH;
};
// Row 1: Connected | In/Out
{
snprintf(buf, sizeof(buf), "%d", totalPeers);
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_connected"), buf, OnSurface());
snprintf(buf, sizeof(buf), "%d / %d", inboundCount, outboundCount);
drawStatCell(dl, rightX, ry, capFont, sub1, TR("peers_in_out"), buf, OnSurface());
}
stepRow();
// Row 2: TLS | Avg Ping
{
// TLS — bespoke: appends a check icon when every peer is TLS
float cx = leftX;
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), "TLS");
float valY = ry + capFont->LegacySize + Layout::spacingXs();
if (totalPeers > 0) {
snprintf(buf, sizeof(buf), "%d / %d", tlsCount, totalPeers);
ImU32 tlsCol = (tlsCount == totalPeers) ? Success() : OnSurface();
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, valY), tlsCol, buf);
if (tlsCount == totalPeers) {
ImFont* iconFont = Type().iconSmall();
ImVec2 txtSize = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf);
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx + txtSize.x + 4 * dp, valY), Success(), ICON_MD_CHECK);
}
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, valY), OnSurfaceDisabled(), "\xE2\x80\x94");
}
// Avg Ping — color by latency
ImU32 pingCol;
if (avgPing < 100) pingCol = Success();
else if (avgPing < 500) pingCol = Warning();
else pingCol = Error();
snprintf(buf, sizeof(buf), "%.0f ms", avgPing);
drawStatCell(dl, rightX, ry, capFont, sub1, TR("peers_avg_ping"), buf, pingCol);
}
stepRow();
// Row 3: Received | Sent
{
std::string recvStr = fmtBytes(totalBytesRecv);
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_received"), recvStr.c_str(), OnSurface());
std::string sentStr = fmtBytes(totalBytesSent);
drawStatCell(dl, rightX, ry, capFont, sub1, TR("peers_sent"), sentStr.c_str(), OnSurface());
}
stepRow();
// Row 4: P2P Port | Banned
{
if (state.p2p_port > 0) snprintf(buf, sizeof(buf), "%d", state.p2p_port);
drawStatCell(dl, leftX, ry, capFont, sub1, TR("peers_p2p_port"),
state.p2p_port > 0 ? buf : nullptr, OnSurface());
size_t bannedCount = state.bannedPeers.size();
snprintf(buf, sizeof(buf), "%zu", bannedCount);
ImU32 bannedCol = (bannedCount > 0) ? Warning() : OnSurface();
drawStatCell(dl, rightX, ry, capFont, sub1, TR("peers_banned"), buf, bannedCol);
}
}
ImGui::SetCursorScreenPos(basePos);
ImGui::Dummy(ImVec2(availWidth, infoCardsH));
ImGui::Dummy(ImVec2(0, gap));
}
// ================================================================
// Compute remaining space for peer list + footer
// ================================================================
float footerH = ImGui::GetFrameHeight() + Layout::spacingSm();
float toggleH = body2->LegacySize + Layout::spacingMd() * 2 + Layout::spacingSm();
float remainForPeers = std::max(60.0f, peersAvail.y - (ImGui::GetCursorScreenPos().y - ImGui::GetWindowPos().y) - footerH - Layout::spacingSm());
float peerPanelHeight = remainForPeers - toggleH;
peerPanelHeight = std::max(S.drawElement("tabs.peers", "peer-panel-min-height").size, peerPanelHeight);
// ================================================================
// PEERS — Single glass card with Connected / Banned toggle
// ================================================================
static bool s_show_banned = false;
{
// Toggle header: "Connected (N)" / "Banned (N)"
float toggleY = ImGui::GetCursorScreenPos().y;
{
char connLabel[64], banLabel[64];
// Format strings use %d — cast size_t down to int (a 64-bit size_t passed to %d
// is a format/type mismatch / UB; peer counts comfortably fit in int).
snprintf(connLabel, sizeof(connLabel), TR("peers_connected_count"), (int)state.peers.size());
snprintf(banLabel, sizeof(banLabel), TR("peers_banned_count"), (int)state.bannedPeers.size());
ImVec2 connSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, connLabel);
ImVec2 banSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, banLabel);
float tabGap = Layout::spacingXl();
float tabStartX = ImGui::GetCursorScreenPos().x + pad;
float dp = Layout::dpiScale();
// Connected tab
ImVec2 connPos(tabStartX, toggleY);
ImU32 connCol = s_show_banned ? OnSurfaceDisabled() : OnSurface();
dl->AddText(body2, body2->LegacySize, ImVec2(connPos.x, connPos.y + Layout::spacingMd() * 0.5f), connCol, connLabel);
if (!s_show_banned) {
float underY = connPos.y + Layout::spacingMd() * 0.5f + body2->LegacySize + 3.0f * dp;
dl->AddRectFilled(ImVec2(connPos.x, underY), ImVec2(connPos.x + connSz.x, underY + 2.0f * dp), Primary(), 1.0f * dp);
}
ImGui::SetCursorScreenPos(connPos);
if (ImGui::InvisibleButton("##tabConn", ImVec2(connSz.x, toggleH))) {
s_show_banned = false;
}
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
// Banned tab
float banX = tabStartX + connSz.x + tabGap;
ImVec2 banPos(banX, toggleY);
ImU32 banCol = s_show_banned ? OnSurface() : OnSurfaceDisabled();
dl->AddText(body2, body2->LegacySize, ImVec2(banPos.x, banPos.y + Layout::spacingMd() * 0.5f), banCol, banLabel);
if (s_show_banned) {
float underY = banPos.y + Layout::spacingMd() * 0.5f + body2->LegacySize + 3.0f * dp;
dl->AddRectFilled(ImVec2(banPos.x, underY), ImVec2(banPos.x + banSz.x, underY + 2.0f * dp), Primary(), 1.0f * dp);
}
ImGui::SetCursorScreenPos(banPos);
if (ImGui::InvisibleButton("##tabBan", ImVec2(banSz.x, toggleH))) {
s_show_banned = true;
}
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
// Refresh button — top-right, glass panel style (similar to mining button)
{
bool isRefreshing = app->isPeerRefreshInProgress();
ImFont* iconFont = Type().iconMed();
float iconSz = iconFont->LegacySize;
const char* label = isRefreshing ? TR("peers_refreshing") : TR("peers_refresh");
ImVec2 lblSz = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, label);
// Use the wider label so button doesn't resize between states
ImVec2 altSz = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0,
isRefreshing ? TR("peers_refresh") : TR("peers_refreshing"));
float maxLblW = std::max(lblSz.x, altSz.x);
float padH = Layout::spacingSm();
float padV = Layout::spacingSm();
float btnW = padH + iconSz + Layout::spacingXs() + maxLblW + padH;
float btnH = std::max(iconSz, lblSz.y) + padV * 2;
float btnX = ImGui::GetWindowPos().x + availWidth - btnW - Layout::spacingSm();
float btnY = toggleY + (toggleH - btnH) * 0.5f;
ImVec2 bMin(btnX, btnY);
ImVec2 bMax(btnX + btnW, btnY + btnH);
bool btnHovered = material::IsRectHovered(bMin, bMax);
// Glass panel background
GlassPanelSpec btnGlass;
btnGlass.rounding = Layout::glassRounding();
if (isRefreshing) {
float pulse = effects::isLowSpecMode()
? 0.5f
: 0.5f + 0.5f * (float)std::sin((double)ImGui::GetTime() * 4.0);
btnGlass.fillAlpha = (int)(15 + 25 * pulse);
} else {
btnGlass.fillAlpha = btnHovered ? 30 : 18;
}
DrawGlassPanel(dl, bMin, bMax, btnGlass);
// Hover highlight
if (btnHovered && !isRefreshing) {
dl->AddRectFilled(bMin, bMax, WithAlpha(Primary(), 20), btnGlass.rounding);
}
// Icon: spinner while refreshing, refresh icon otherwise
float cx = bMin.x + padH + iconSz * 0.5f;
float cy = bMin.y + btnH * 0.5f;
if (isRefreshing) {
// Spinning arc spinner (same style as mining toggle)
float spinnerR = iconSz * 0.5f;
float thickness = std::max(1.5f, spinnerR * 0.18f);
float time = (float)ImGui::GetTime();
// Track circle (faint)
dl->AddCircle(ImVec2(cx, cy), spinnerR, WithAlpha(Primary(), 40), 0, thickness);
// Animated arc
float rotation = fmodf(time * 2.0f * IM_PI / 1.4f, IM_PI * 2.0f);
float cycleTime = fmodf(time, 1.333f);
float arcLength = (cycleTime < 0.666f)
? (cycleTime / 0.666f) * 0.75f + 0.1f
: ((1.333f - cycleTime) / 0.666f) * 0.75f + 0.1f;
float startAngle = rotation - IM_PI * 0.5f;
float endAngle = startAngle + IM_PI * 2.0f * arcLength;
int segments = (int)(32 * arcLength) + 1;
float angleStep = (endAngle - startAngle) / segments;
ImU32 arcCol = Primary();
for (int si = 0; si < segments; si++) {
float a1 = startAngle + angleStep * si;
float a2 = startAngle + angleStep * (si + 1);
ImVec2 p1(cx + cosf(a1) * spinnerR, cy + sinf(a1) * spinnerR);
ImVec2 p2(cx + cosf(a2) * spinnerR, cy + sinf(a2) * spinnerR);
dl->AddLine(p1, p2, arcCol, thickness);
}
} else {
// Static refresh icon
ImU32 iconCol = btnHovered ? OnSurface() : OnSurfaceMedium();
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(cx - iSz.x * 0.5f, cy - iSz.y * 0.5f),
iconCol, ICON_MD_REFRESH);
}
// Label to the right of icon
{
ImU32 lblCol;
if (isRefreshing) {
float pulse = effects::isLowSpecMode()
? 0.7f
: 0.5f + 0.5f * (float)std::sin((double)ImGui::GetTime() * 3.0);
lblCol = WithAlpha(Primary(), (int)(120 + 135 * pulse));
} else {
lblCol = btnHovered ? OnSurface() : WithAlpha(OnSurface(), 160);
}
float labelAreaX = bMin.x + padH + iconSz + Layout::spacingXs();
float lblX = labelAreaX + (maxLblW - lblSz.x) * 0.5f;
float lblY = cy - lblSz.y * 0.5f;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(lblX, lblY), lblCol, label);
}
// Invisible button for click handling
ImGui::SetCursorScreenPos(bMin);
ImGui::PushID("##peersRefresh");
if (ImGui::InvisibleButton("##btn", ImVec2(btnW, btnH))) {
if (!isRefreshing) {
// Peers only: refreshNow() also forced a full wallet re-scan (every z-address)
// for a button that says "Refresh peer list".
app->refreshPeerInfo();
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (!isRefreshing)
material::Tooltip("%s", TR("peers_refresh_tooltip"));
}
ImGui::PopID();
}
ImGui::SetCursorScreenPos(ImVec2(ImGui::GetWindowPos().x, toggleY + toggleH));
}
// Glass panel background
ImVec2 panelMin = ImGui::GetCursorScreenPos();
ImVec2 panelMax(panelMin.x + availWidth, panelMin.y + peerPanelHeight);
DrawGlassPanel(dl, panelMin, panelMax, glassSpec);
// Scroll-edge mask state
float listScrollY = 0.0f, listScrollMaxY = 0.0f;
int listParentVtx = dl->VtxBuffer.Size;
ImGui::BeginChild("##PeersList", ImVec2(availWidth, peerPanelHeight), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* listChildDL = ImGui::GetWindowDrawList();
int listChildVtx = listChildDL->VtxBuffer.Size;
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (!s_show_banned) {
// ---- Connected Peers ----
if (!app->isConnected()) {
material::DrawEmptyState(ICON_MD_CLOUD_OFF, TR("not_connected"));
} else if (state.peers.empty()) {
material::DrawEmptyState(ICON_MD_WIFI_TETHERING, TR("peers_no_connected"));
} else {
const float dp = ui::Layout::dpiScale();
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg();
float rowInset = Layout::spacingLg();
float innerW = ImGui::GetContentRegionAvail().x - rowInset * 2;
listScrollY = ImGui::GetScrollY();
listScrollMaxY = ImGui::GetScrollMaxY();
for (size_t i = 0; i < state.peers.size(); i++) {
const auto& peer = state.peers[i];
bool is_selected = (s_selected_peer_idx == static_cast<int>(i));
ImGui::PushID(static_cast<int>(i));
ImVec2 rawRowPos = ImGui::GetCursorScreenPos();
ImVec2 rowPos(rawRowPos.x + rowInset, rawRowPos.y);
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
if (is_selected) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), S.drawElement("tabs.peers", "row-selection-rounding").size * dp);
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + S.drawElement("tabs.peers", "row-accent-width").size * dp, rowEnd.y), Primary(), S.drawElement("tabs.peers", "row-accent-rounding").size * dp);
}
bool hovered = material::IsRectHovered(rowPos, rowEnd);
if (hovered && !is_selected) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.peers", "row-selection-rounding").size * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
float cx = rowPos.x + pad;
float cy = rowPos.y + Layout::spacingSm();
double ping_ms = peer.pingtime * 1000.0;
ImU32 dotCol;
if (ping_ms < 100) dotCol = Success();
else if (ping_ms < 500) dotCol = Warning();
else dotCol = Error();
float pingDotR = S.drawElement("tabs.peers", "ping-dot-radius-base").size + S.drawElement("tabs.peers", "ping-dot-radius-scale").size * hs;
dl->AddCircleFilled(ImVec2(cx + S.drawElement("tabs.peers", "ping-dot-x-offset").size * dp, cy + body2->LegacySize * 0.5f), pingDotR, dotCol);
float addrX = cx + S.drawElement("tabs.peers", "address-x-offset").size * dp;
// Reserve the line-1 trailing zone (ping + direction pill live on the far right,
// the nearest being the ping at innerW - pingW - spacingXl*3). A long IPv6+port
// addr must clip before it so it can't run under those. Mirror the ping formula
// conservatively and leave a gap.
float line1RightLimit = rowPos.x + innerW - Layout::spacingXl() * 3 -
capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, "0000ms").x -
Layout::spacingSm();
dl->PushClipRect(ImVec2(addrX, rowPos.y), ImVec2(line1RightLimit, rowEnd.y), true);
dl->AddText(body2, body2->LegacySize, ImVec2(addrX, cy), OnSurface(), peer.addr.c_str());
dl->PopClipRect();
// Seed node icon — rendered right after the IP address, but never past the
// reserved trailing zone (a long addr would otherwise push it into the ping text).
if (IsSeedNode(peer.addr)) {
ImVec2 addrSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, peer.addr.c_str());
ImFont* iconFont = Type().iconSmall();
float iconY = cy + (body2->LegacySize - iconFont->LegacySize) * 0.5f;
float seedIconX = std::min(addrX + addrSz.x + Layout::spacingSm(),
line1RightLimit - iconFont->LegacySize);
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(seedIconX, iconY), WithAlpha(Success(), 200), ICON_MD_GRASS);
}
{
const char* dirLabel = peer.inbound ? TR("peers_dir_in") : TR("peers_dir_out");
ImU32 dirBg = peer.inbound ? WithAlpha(Success(), 30) : WithAlpha(Secondary(), 30);
ImU32 dirFg = peer.inbound ? WithAlpha(Success(), 200) : WithAlpha(Secondary(), 200);
ImVec2 dirSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, dirLabel);
float dirX = rowPos.x + innerW - dirSz.x - Layout::spacingXl();
ImVec2 pillMin(dirX - S.drawElement("tabs.peers", "dir-pill-padding").size * dp, cy + S.drawElement("tabs.peers", "dir-pill-y-offset").size * dp);
ImVec2 pillMax(dirX + dirSz.x + S.drawElement("tabs.peers", "dir-pill-padding").size * dp, cy + capFont->LegacySize + S.drawElement("tabs.peers", "dir-pill-y-bottom").size * dp);
dl->AddRectFilled(pillMin, pillMax, dirBg, S.drawElement("tabs.peers", "dir-pill-rounding").size * dp);
dl->AddText(capFont, capFont->LegacySize, ImVec2(dirX, cy + 2 * dp), dirFg, dirLabel);
}
{
char pingBuf[32];
snprintf(pingBuf, sizeof(pingBuf), "%.0fms", ping_ms);
ImVec2 pingSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pingBuf);
float pingX = rowPos.x + innerW - pingSz.x - Layout::spacingXl() * 3;
dl->AddText(capFont, capFont->LegacySize, ImVec2(pingX, cy + 2), dotCol, pingBuf);
}
float cy2 = cy + body2->LegacySize + Layout::spacingXs();
float subverX = cx + S.drawElement("tabs.peers", "address-x-offset").size * dp;
// Reserve the line-2 trailing widths up front so an untrusted, arbitrarily long
// remote subver cannot push the TLS badge / ban-score off the row. Order right→left:
// ban-score pinned far right, then the TLS/no-TLS badge, then the clipped subver text.
float line2RightLimit = rowPos.x + innerW - Layout::spacingLg();
float banScoreLeftX = line2RightLimit;
char banBuf[16];
ImU32 banCol = 0;
bool haveBanScore = peer.banscore > 0;
if (haveBanScore) {
snprintf(banBuf, sizeof(banBuf), TR("peers_ban_score"), peer.banscore);
banCol = peer.banscore > 50 ? Error() : Warning();
float banW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, banBuf).x;
banScoreLeftX = line2RightLimit - banW;
// The TLS/no-TLS badge sits to the LEFT of the ban score with a gap.
line2RightLimit = banScoreLeftX - Layout::spacingLg();
}
float tlsBadgeW = std::max(S.drawElement("tabs.peers", "tls-badge-min-width").size, S.drawElement("tabs.peers", "tls-badge-width").size * hs);
float noTlsW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, TR("peers_no_tls")).x;
float badgeW = peer.tls_cipher.empty() ? noTlsW : tlsBadgeW;
// Hard right boundary for the subver text column, leaving room for the badge.
float subverMaxX = line2RightLimit - badgeW - Layout::spacingSm();
if (subverMaxX < subverX) subverMaxX = subverX;
float verW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, peer.subver.c_str()).x;
dl->PushClipRect(ImVec2(subverX, rowPos.y), ImVec2(subverMaxX, rowEnd.y), true);
dl->AddText(capFont, capFont->LegacySize, ImVec2(subverX, cy2),
OnSurfaceDisabled(), peer.subver.c_str());
dl->PopClipRect();
// Pin the badge just after the (clipped) subver, but never past the reserved zone.
float badgeX = std::min(subverX + verW + Layout::spacingSm(), subverMaxX + Layout::spacingSm());
if (!peer.tls_cipher.empty()) {
ImU32 tlsBg = WithAlpha(Success(), 25);
ImU32 tlsFg = WithAlpha(Success(), 200);
ImVec2 tlsMin(badgeX, cy2);
ImVec2 tlsMax(tlsMin.x + tlsBadgeW, tlsMin.y + capFont->LegacySize + 2 * dp);
dl->AddRectFilled(tlsMin, tlsMax, tlsBg, S.drawElement("tabs.peers", "tls-badge-rounding").size * dp);
dl->AddText(capFont, capFont->LegacySize, ImVec2(tlsMin.x + 4 * dp, cy2 + 1 * dp), tlsFg, "TLS");
} else {
dl->AddText(capFont, capFont->LegacySize, ImVec2(badgeX, cy2),
WithAlpha(Error(), 140), TR("peers_no_tls"));
}
if (haveBanScore) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(banScoreLeftX, cy2), banCol, banBuf);
}
ImGui::InvisibleButton("##peerRow", ImVec2(innerW, rowH));
if (ImGui::IsItemClicked(0)) {
s_selected_peer_idx = static_cast<int>(i);
}
const auto& acrylicTheme = GetCurrentAcrylicTheme();
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme.menu)) {
ImGui::Text(TR("peers_peer_label"), peer.addr.c_str());
ImGui::Separator();
if (ImGui::MenuItem(TR("copy_address"))) {
ImGui::SetClipboardText(peer.addr.c_str());
}
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
}
ImGui::Separator();
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
app->banPeer(ExtractIP(peer.addr), 86400);
}
effects::ImGuiAcrylic::EndAcrylicPopup();
}
if (ImGui::IsItemHovered()) {
material::BeginTooltip();
ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(8, 3));
if (ImGui::BeginTable("##PeerTT", 2, ImGuiTableFlags_SizingFixedFit)) {
auto TTRow = [](const char* label, const char* value) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", label);
ImGui::TableNextColumn();
ImGui::Text("%s", value);
};
char ttBuf[128];
snprintf(ttBuf, sizeof(ttBuf), "%d", peer.id);
TTRow(TR("peers_tt_id"), ttBuf);
TTRow(TR("peers_tt_services"), peer.services.c_str());
snprintf(ttBuf, sizeof(ttBuf), "%d", peer.startingheight);
TTRow(TR("peers_tt_start_height"), ttBuf);
// %lld + (long long) — %ld truncates int64_t on Windows (LLP64).
snprintf(ttBuf, sizeof(ttBuf), "%lld bytes", (long long)peer.bytessent);
TTRow(TR("peers_tt_sent"), ttBuf);
snprintf(ttBuf, sizeof(ttBuf), "%lld bytes", (long long)peer.bytesrecv);
TTRow(TR("peers_tt_received"), ttBuf);
snprintf(ttBuf, sizeof(ttBuf), "%d / %d", peer.synced_headers, peer.synced_blocks);
TTRow(TR("peers_tt_synced"), ttBuf);
if (!peer.tls_cipher.empty())
TTRow(TR("peers_tt_tls_cipher"), peer.tls_cipher.c_str());
ImGui::EndTable();
}
ImGui::PopStyleVar();
material::EndTooltip();
}
if (i < state.peers.size() - 1) {
ImVec2 divStart = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(divStart.x + pad + 18 * dp, divStart.y),
ImVec2(divStart.x + innerW - pad, divStart.y),
IM_COL32(255, 255, 255, 15));
}
ImGui::PopID();
}
}
} else {
// ---- Banned Peers ----
if (!app->isConnected()) {
material::DrawEmptyState(ICON_MD_CLOUD_OFF, TR("not_connected"));
} else if (state.bannedPeers.empty()) {
material::DrawEmptyState(ICON_MD_BLOCK, TR("peers_no_banned"));
} else {
const float dp = ui::Layout::dpiScale();
float rowH = capFont->LegacySize + S.drawElement("tabs.peers", "banned-row-height-padding").size * dp;
float rowInsetB = pad;
float innerW = ImGui::GetContentRegionAvail().x - rowInsetB * 2;
listScrollY = ImGui::GetScrollY();
listScrollMaxY = ImGui::GetScrollMaxY();
for (size_t i = 0; i < state.bannedPeers.size(); i++) {
const auto& banned = state.bannedPeers[i];
bool is_selected = (s_selected_banned_idx == static_cast<int>(i));
ImGui::PushID(static_cast<int>(i));
ImVec2 rawRowPosB = ImGui::GetCursorScreenPos();
ImVec2 rowPos(rawRowPosB.x + rowInsetB, rawRowPosB.y);
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
if (is_selected) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), S.drawElement("tabs.peers", "banned-row-rounding").size * dp);
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + S.drawElement("tabs.peers", "row-accent-width").size * dp, rowEnd.y), WithAlpha(Error(), 200), S.drawElement("tabs.peers", "banned-accent-rounding").size * dp);
}
if (material::IsRectHovered(rowPos, rowEnd) && !is_selected) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.peers", "banned-row-rounding").size * dp);
}
float cx = rowPos.x + pad;
float cy = rowPos.y + Layout::spacingXs();
float banDotR = S.drawElement("tabs.peers", "ban-dot-radius-base").size + S.drawElement("tabs.peers", "ban-dot-radius-scale").size * hs;
dl->AddCircleFilled(ImVec2(cx + S.drawElement("tabs.peers", "ban-dot-x-offset").size * dp, cy + capFont->LegacySize * 0.4f), banDotR, WithAlpha(Error(), 200));
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + S.drawElement("tabs.peers", "banned-address-x-offset").size * dp, cy),
OnSurfaceDisabled(), banned.address.c_str());
std::string banUntil = banned.getBannedUntilString();
ImVec2 banSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, banUntil.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowPos.x + innerW - banSz.x - Layout::spacingXl() * 5, cy),
OnSurfaceDisabled(), banUntil.c_str());
float btnX = rowPos.x + innerW - Layout::spacingXl() * S.drawElement("tabs.peers", "unban-btn-right-offset-multiplier").size;
ImGui::SetCursorScreenPos(ImVec2(btnX, cy - 1));
if (TactileSmallButton(TR("peers_unban"), S.resolveFont("button"))) {
app->unbanPeer(banned.address);
}
ImGui::SetCursorScreenPos(rowPos);
ImGui::InvisibleButton("##bannedRow", ImVec2(innerW - S.drawElement("tabs.peers", "banned-row-btn-reserve").size * dp, rowH));
if (ImGui::IsItemClicked(0)) {
s_selected_banned_idx = static_cast<int>(i);
}
const auto& acrylicTheme2 = GetCurrentAcrylicTheme();
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
if (ImGui::MenuItem(TR("copy_address"))) {
ImGui::SetClipboardText(banned.address.c_str());
}
if (ImGui::MenuItem(TR("peers_unban"))) {
app->unbanPeer(banned.address);
}
effects::ImGuiAcrylic::EndAcrylicPopup();
}
ImGui::SetCursorScreenPos(ImVec2(rawRowPosB.x, rowEnd.y));
if (i < state.bannedPeers.size() - 1) {
ImVec2 divStart = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(divStart.x + pad + 8 * dp, divStart.y),
ImVec2(divStart.x + innerW - pad, divStart.y),
IM_COL32(255, 255, 255, 15));
}
ImGui::PopID();
}
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::EndChild();
// CSS-style clipping mask
{
float fadeFont = s_show_banned ? capFont->LegacySize : body2->LegacySize;
float fadeZone = std::min(fadeFont * 3.0f, peerPanelHeight * 0.18f);
ApplyScrollEdgeMask(dl, listParentVtx, listChildDL, listChildVtx,
panelMin.y, panelMax.y, fadeZone, listScrollY, listScrollMaxY);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
// ================================================================
// Footer — Clear Bans (material styled)
// ================================================================
if (s_show_banned && !state.bannedPeers.empty()) {
ImGui::BeginDisabled(!app->isConnected());
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button"))) {
app->clearBans();
}
ImGui::EndDisabled();
}
ImGui::EndChild(); // ##PeersScroll
}
} // namespace ui
} // namespace dragonx