Files
ObsidianDragon/src/ui/windows/peers_tab.cpp
T
DanSandClaude Opus 4.8 534120d3de feat(peers): success toasts for ban/unban/clear + confirm the bulk Clear-All
Peer ban actions only toasted on failure; success was silent. Added success
toasts for ban/unban/clear-all, and the bulk "Clear All Bans" now takes a
confirming second click (per-row unban stays one-click). The setban/clearbanned
RPC calls are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:35:32 -05:00

961 lines
50 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;
static bool s_confirm_clear_bans = false; // inline confirm for the bulk Clear-All-Bans action
// 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()) {
// Connected to the daemon but zero peers — mirror the node-status banner's actionable
// hint so this isn't just a bare "No connected peers" dead-end.
material::DrawEmptyState(ICON_MD_WIFI_TETHERING, TR("peers_no_connected"),
TR("node_banner_no_peers_detail"));
} 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 (!s_confirm_clear_bans) {
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button")))
s_confirm_clear_bans = true; // bulk action takes a confirming second click
} else {
char cb[96];
snprintf(cb, sizeof(cb), TR("peers_clear_bans_confirm"), (int)state.bannedPeers.size());
ImGui::TextColored(material::WarningVec4(), "%s", cb);
ImGui::SameLine();
if (TactileSmallButton(TR("peers_clear_bans_confirm_yes"), S.resolveFont("button"))) {
app->clearBans();
s_confirm_clear_bans = false;
}
ImGui::SameLine();
if (TactileSmallButton(TR("cancel"), S.resolveFont("button")))
s_confirm_clear_bans = false;
}
ImGui::EndDisabled();
} else {
s_confirm_clear_bans = false; // no stale confirm across tab toggles / after bans clear
}
ImGui::EndChild(); // ##PeersScroll
}
} // namespace ui
} // namespace dragonx