From ca658303dbed5a1dd0fa32f9c3ac3c1c8037dece Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Wed, 9 Sep 2026 13:12:27 -0700 Subject: [PATCH 1/5] qml: Introduce responsive size classes Introduce a SizeClass singleton that centralizes compact and regular width and height breakpoints. Views can use the shared helpers to adapt their layouts without duplicating breakpoint logic. --- qml/bitcoin_qml.qrc | 1 + qml/controls/SizeClass.qml | 22 ++++++++++++++++++++++ qml/controls/qmldir | 1 + 3 files changed, 24 insertions(+) create mode 100644 qml/controls/SizeClass.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 7f422368eb..0bdf71504d 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -97,6 +97,7 @@ controls/qmldir controls/SendOptionsPopup.qml controls/SegmentedPicker.qml + controls/SizeClass.qml controls/Setting.qml controls/SettingsHeader.qml controls/SettingsPage.qml diff --git a/qml/controls/SizeClass.qml b/qml/controls/SizeClass.qml new file mode 100644 index 0000000000..89fdd48d64 --- /dev/null +++ b/qml/controls/SizeClass.qml @@ -0,0 +1,22 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +pragma Singleton +import QtQuick 2.15 + +QtObject { + readonly property int compact: 0 + readonly property int regular: 1 + + property real compactWidthMax: 600 + property real compactHeightMax: 500 + + function widthClassFor(width) { + return width <= compactWidthMax ? compact : regular + } + + function heightClassFor(height) { + return height <= compactHeightMax ? compact : regular + } +} diff --git a/qml/controls/qmldir b/qml/controls/qmldir index d57fb8673e..b2e1cf5dc4 100644 --- a/qml/controls/qmldir +++ b/qml/controls/qmldir @@ -1 +1,2 @@ +singleton SizeClass 1.0 SizeClass.qml singleton Theme 1.0 Theme.qml From 5e5e198f93542208af80e32f19c60c3e6a637777 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Wed, 9 Sep 2026 13:14:32 -0700 Subject: [PATCH 2/5] qml: add adaptive navigation split view component Add a reusable primary-detail container that displays both columns at regular widths and navigates between them at compact widths. Support configurable column sizing, separators, and animated transitions. Test regular sizing, compact navigation, full-height separators, and transitions between size classes. --- qml/bitcoin_qml.qrc | 1 + qml/controls/NavigationSplitView.qml | 99 ++++++++++++++++++++++++++++ test/qml/bitcoin_qmltests.qrc | 1 + test/qml/tst_navigationsplitview.qml | 87 ++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 qml/controls/NavigationSplitView.qml create mode 100644 test/qml/tst_navigationsplitview.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 0bdf71504d..57e0711d8c 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -83,6 +83,7 @@ controls/NavigationBar.qml controls/NavigationBar2.qml controls/NavigationTab.qml + controls/NavigationSplitView.qml controls/OptionButton.qml controls/OptionSwitch.qml controls/OutlineButton.qml diff --git a/qml/controls/NavigationSplitView.qml b/qml/controls/NavigationSplitView.qml new file mode 100644 index 0000000000..922f6c2f0b --- /dev/null +++ b/qml/controls/NavigationSplitView.qml @@ -0,0 +1,99 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 + +Item { + id: root + + enum Column { + Primary, + Detail + } + + property Component primaryComponent + property Component detailComponent + + property int compactColumn: NavigationSplitView.Primary + property real primaryMinimumWidth: 280 + property real primaryPreferredWidth: 320 + property real primaryMaximumWidth: 498 + property real primaryWidthRatio: 0.4 + property real detailMinimumWidth: 240 + property real separatorWidth: 1 + property color separatorColor: Theme.color.neutral2 + property int transitionDuration: 300 + + readonly property bool isCompact: SizeClass.widthClassFor(width) === SizeClass.compact + readonly property real effectivePrimaryWidth: { + const preferred = Math.max(root.primaryPreferredWidth, root.width * root.primaryWidthRatio) + const constrained = Math.min(root.primaryMaximumWidth, Math.max(root.primaryMinimumWidth, preferred)) + return Math.max(0, Math.min(constrained, + root.width - root.separatorWidth - root.detailMinimumWidth)) + } + readonly property alias primaryItem: primaryLoader.item + readonly property alias detailItem: detailLoader.item + + function showPrimary() { + root.compactColumn = NavigationSplitView.Primary + } + + function showDetail() { + root.compactColumn = NavigationSplitView.Detail + } + + clip: true + + Loader { + id: primaryLoader + objectName: "navigationSplitPrimary" + sourceComponent: root.primaryComponent + x: root.isCompact + ? (root.compactColumn === NavigationSplitView.Primary ? 0 : -root.width) + : 0 + width: root.isCompact ? root.width : root.effectivePrimaryWidth + height: root.height + enabled: !root.isCompact || root.compactColumn === NavigationSplitView.Primary + + Behavior on x { + enabled: root.isCompact + NumberAnimation { + duration: root.transitionDuration + easing.type: Easing.InOutCubic + } + } + } + + Rectangle { + id: separator + objectName: "navigationSplitSeparator" + visible: !root.isCompact + x: primaryLoader.width + width: root.separatorWidth + height: root.height + color: root.separatorColor + } + + Loader { + id: detailLoader + objectName: "navigationSplitDetail" + sourceComponent: root.detailComponent + x: root.isCompact + ? (root.compactColumn === NavigationSplitView.Detail ? 0 : root.width) + : primaryLoader.width + root.separatorWidth + width: root.isCompact + ? root.width + : Math.max(0, root.width - primaryLoader.width - root.separatorWidth) + height: root.height + enabled: !root.isCompact || root.compactColumn === NavigationSplitView.Detail + + Behavior on x { + enabled: root.isCompact + NumberAnimation { + duration: root.transitionDuration + easing.type: Easing.InOutCubic + } + } + } +} diff --git a/test/qml/bitcoin_qmltests.qrc b/test/qml/bitcoin_qmltests.qrc index aa1287754f..1e3c200490 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -25,6 +25,7 @@ tst_mainrouting.qml tst_mempoolinformationrows.qml tst_navbutton.qml + tst_navigationsplitview.qml tst_nodefeedback.qml tst_onboarding_datadir.qml tst_peeractions.qml diff --git a/test/qml/tst_navigationsplitview.qml b/test/qml/tst_navigationsplitview.qml new file mode 100644 index 0000000000..18f065e6dd --- /dev/null +++ b/test/qml/tst_navigationsplitview.qml @@ -0,0 +1,87 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtTest 1.2 +import "../../qml/controls" + +TestCase { + name: "NavigationSplitView" + when: windowShown + width: 960 + height: 720 + + Item { id: host; anchors.fill: parent } + + Component { + id: splitComponent + NavigationSplitView { + width: 900 + height: 600 + transitionDuration: 0 + primaryMinimumWidth: 280 + primaryPreferredWidth: 320 + primaryMaximumWidth: 498 + primaryWidthRatio: 0.4 + detailMinimumWidth: 240 + primaryComponent: Rectangle { objectName: "testPrimary" } + detailComponent: Rectangle { objectName: "testDetail" } + } + } + + function createSplit() { + const split = createTemporaryObject(splitComponent, host) + verify(split !== null) + wait(0) + return split + } + + function test_regularUsesFixedColumnsAndFullHeightSeparator() { + const split = createSplit() + const primary = findChild(split, "navigationSplitPrimary") + const detail = findChild(split, "navigationSplitDetail") + compare(split.isCompact, false) + compare(primary.width, 360) + compare(primary.x, 0) + compare(detail.x, 361) + compare(detail.width, 539) + + const separator = findChild(split, "navigationSplitSeparator") + verify(separator !== null) + compare(separator.width, 1) + compare(separator.height, 600) + } + + function test_compactNavigatesAndRetainsDetailAcrossResize() { + const split = createSplit() + const primary = findChild(split, "navigationSplitPrimary") + const detail = findChild(split, "navigationSplitDetail") + split.width = 390 + wait(0) + compare(split.isCompact, true) + compare(primary.x, 0) + compare(detail.x, 390) + + split.showDetail() + wait(0) + compare(primary.x, -390) + compare(detail.x, 0) + + split.width = 900 + wait(0) + compare(split.isCompact, false) + compare(primary.x, 0) + verify(detail.x > 0) + + split.width = 390 + wait(0) + compare(split.isCompact, true) + compare(detail.x, 0) + + split.showPrimary() + wait(0) + compare(primary.x, 0) + compare(detail.x, 390) + } +} From 90bf9a008dedd1acbc45e6eeaf50bf7485b3b718 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Wed, 9 Sep 2026 13:17:39 -0700 Subject: [PATCH 3/5] qml: refine popup and context menu presentation --- qml/components/AlertPopup.qml | 56 ++++++++++++++++++++++++++---- qml/controls/ContextMenu.qml | 6 ++-- qml/controls/ContextMenuPicker.qml | 5 +-- qml/controls/Theme.qml | 6 ++++ test/qml/tst_contextmenu.qml | 18 ++++++++++ test/qml/tst_contextmenupicker.qml | 9 +++++ test/qml/tst_nodefeedback.qml | 9 +++++ 7 files changed, 99 insertions(+), 10 deletions(-) diff --git a/qml/components/AlertPopup.qml b/qml/components/AlertPopup.qml index b5e75f1bfe..c7e8fb02b6 100644 --- a/qml/components/AlertPopup.qml +++ b/qml/components/AlertPopup.qml @@ -15,15 +15,57 @@ Popup { property string title: "" property string message: "" property string messageObjectName: "alertMessage" + property real verticalOffset: 0 default property alias actions: actionStore.data property var visibleActions: [defaultAction] modal: true + dim: true padding: 0 - anchors.centerIn: parent width: parent ? Math.min(parent.width - 40, 360) : 360 implicitHeight: columnLayout.implicitHeight + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) + verticalOffset : verticalOffset + + Overlay.modal: Rectangle { + objectName: "alertPopupDimmer" + color: Qt.rgba(0, 0, 0, 0.5) + } + + enter: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: 300 + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "verticalOffset" + from: -30 + to: 0 + duration: 300 + easing.type: Easing.OutCubic + } + } + + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: 250 + easing.type: Easing.InCubic + } + NumberAnimation { + property: "verticalOffset" + from: 0 + to: -30 + duration: 250 + easing.type: Easing.InCubic + } + } property Item actionStoreItem: Item { id: actionStore @@ -39,9 +81,10 @@ Popup { onOpened: refreshActions() background: Rectangle { - color: Theme.color.background - radius: 8 - border.color: Theme.color.neutral4 + objectName: "alertPopupSurface" + color: Theme.color.neutral1 + radius: 10 + border.color: Theme.color.neutral2 border.width: 1 } @@ -64,6 +107,7 @@ Popup { Separator { Layout.fillWidth: true + color: Theme.color.neutral2 } CoreText { @@ -112,10 +156,10 @@ Popup { textHoverColor: textColor textPressedColor: textColor backgroundColor: alertAction.role === AlertAction.Cancel - ? Theme.color.background + ? Theme.color.neutral1 : alertAction.role === AlertAction.Destructive ? Theme.color.red : Theme.color.orange backgroundHoverColor: alertAction.role === AlertAction.Cancel - ? Theme.color.background + ? Theme.color.neutral1 : alertAction.role === AlertAction.Destructive ? Qt.lighter(Theme.color.red, 1.1) : Theme.color.orangeLight1 backgroundPressedColor: alertAction.role === AlertAction.Cancel ? Theme.color.neutral2 diff --git a/qml/controls/ContextMenu.qml b/qml/controls/ContextMenu.qml index f77e222de5..d8dbf5c830 100644 --- a/qml/controls/ContextMenu.qml +++ b/qml/controls/ContextMenu.qml @@ -14,6 +14,7 @@ Popup { property int itemSpacing: 0 property int menuPadding: 6 property color backgroundColor: Theme.color.neutral1 + readonly property alias titleItem: _title default property alias menuItems: _column.data @@ -57,6 +58,7 @@ Popup { function _closeMenu() { root.close() } CoreText { + id: _title visible: root.title !== "" Layout.fillWidth: true Layout.leftMargin: 10 @@ -65,8 +67,8 @@ Popup { Layout.bottomMargin: visible ? 4 : 0 text: root.title horizontalAlignment: Text.AlignLeft - font: Theme.text.heading.font - lineHeight: Theme.text.heading.lineHeight + font: Theme.text.captionStrong.font + lineHeight: Theme.text.captionStrong.lineHeight lineHeightMode: Text.FixedHeight wrap: false color: Theme.color.neutral6 diff --git a/qml/controls/ContextMenuPicker.qml b/qml/controls/ContextMenuPicker.qml index f767b9d490..32812578c7 100644 --- a/qml/controls/ContextMenuPicker.qml +++ b/qml/controls/ContextMenuPicker.qml @@ -23,6 +23,7 @@ Item { property int iconSize: 18 property int rowHeight: 36 property int subtitleRowHeight: 52 + readonly property alias titleItem: _title signal activated(var value) @@ -77,8 +78,8 @@ Item { Layout.bottomMargin: visible ? 4 : 0 text: root.title horizontalAlignment: Text.AlignLeft - font: Theme.text.heading.font - lineHeight: Theme.text.heading.lineHeight + font: Theme.text.captionStrong.font + lineHeight: Theme.text.captionStrong.lineHeight lineHeightMode: Text.FixedHeight wrap: false color: Theme.color.neutral6 diff --git a/qml/controls/Theme.qml b/qml/controls/Theme.qml index d7d8344dff..0c827f5e88 100644 --- a/qml/controls/Theme.qml +++ b/qml/controls/Theme.qml @@ -217,6 +217,12 @@ Control { pixelSize: 13 lineHeight: 19 } + readonly property TextStyle captionStrong: TextStyle { + family: textSetRoot.family + styleName: "Semi Bold" + pixelSize: 13 + lineHeight: 19 + } // Controls readonly property TextStyle button: TextStyle { diff --git a/test/qml/tst_contextmenu.qml b/test/qml/tst_contextmenu.qml index 9763d2fdf8..b1fe1f71f2 100644 --- a/test/qml/tst_contextmenu.qml +++ b/test/qml/tst_contextmenu.qml @@ -28,6 +28,17 @@ TestCase { } } + Component { + id: titledMenuComponent + + ContextMenu { + x: 20 + y: 20 + title: "Section" + ContextMenuButton { text: "Action" } + } + } + Component { id: menuWithButtonComponent @@ -90,6 +101,13 @@ TestCase { compare(menu.background.color, Theme.color.neutral1) } + function test_section_title_uses_bold_caption_typography() { + const menu = openMenu(titledMenuComponent) + compare(menu.titleItem.font.pixelSize, Theme.text.captionStrong.pixelSize) + compare(menu.titleItem.font.styleName, "Semi Bold") + compare(menu.titleItem.lineHeight, Theme.text.captionStrong.lineHeight) + } + function test_escape_closes_focused_menu() { const menu = openMenu(emptyMenuComponent) tryCompare(menu, "activeFocus", true) diff --git a/test/qml/tst_contextmenupicker.qml b/test/qml/tst_contextmenupicker.qml index d1bc31fb9a..ba81a04a6b 100644 --- a/test/qml/tst_contextmenupicker.qml +++ b/test/qml/tst_contextmenupicker.qml @@ -121,6 +121,15 @@ TestCase { compare(picker.itemAtIndex(0).rowValue, "date") } + function test_section_title_uses_bold_caption_typography() { + const host_layout = createTemporaryObject(objectsPickerComponent, host) + verify(host_layout !== null) + const picker = host_layout.picker + compare(picker.titleItem.font.pixelSize, Theme.text.captionStrong.pixelSize) + compare(picker.titleItem.font.styleName, "Semi Bold") + compare(picker.titleItem.lineHeight, Theme.text.captionStrong.lineHeight) + } + function test_activating_row_emits_without_replacing_currentValue_binding() { const host_layout = createTemporaryObject(objectsPickerComponent, host) verify(host_layout !== null) diff --git a/test/qml/tst_nodefeedback.qml b/test/qml/tst_nodefeedback.qml index 29b63c78ad..7632af21cb 100644 --- a/test/qml/tst_nodefeedback.qml +++ b/test/qml/tst_nodefeedback.qml @@ -130,6 +130,15 @@ TestCase { compare(message.font.pixelSize, Theme.text.description.pixelSize) compare(message.lineHeight, Theme.text.description.lineHeight) + const surface = findChild(popup, "alertPopupSurface") + verify(surface !== null) + compare(surface.color, Theme.color.neutral1) + compare(surface.border.color, Theme.color.neutral2) + compare(surface.radius, 10) + compare(popup.dim, true) + verify(popup.enter !== null) + verify(popup.exit !== null) + compare(popup.visibleActions.length, 2) compare(popup.visibleActions[1].buttonObjectName, "alertDeleteButton") const deleteButton = waitForChild(testWindow.contentItem, "alertDeleteButton") From 80a8b8d852d50c188142bfcd355f96c19655ee5e Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Wed, 9 Sep 2026 13:25:24 -0700 Subject: [PATCH 4/5] qml: expose peer data and add support for search, filtering and sorting Expose transport, session, relay, bandwidth, and address-processing details required by the redesigned peer views. Add peer search, filtering, sort direction, result counts, and stable detail-model lookup to the peer list proxy. Cover the additional roles, formatting, filters, ordering, and peer lookup behavior with unit tests. --- qml/models/peerdetailsmodel.cpp | 3 +- qml/models/peerdetailsmodel.h | 12 ++ qml/models/peerlistmodel.cpp | 3 + qml/models/peerlistmodel.h | 1 + qml/models/peerlistsortproxy.cpp | 185 +++++++++++++++++++++++++++++-- qml/models/peerlistsortproxy.h | 41 +++++++ qml/peerstatsutil.cpp | 5 + qml/peerstatsutil.h | 1 + test/test_peerlistmodel.cpp | 63 ++++++++++- test/test_peerstatsutil.cpp | 8 ++ 10 files changed, 309 insertions(+), 13 deletions(-) diff --git a/qml/models/peerdetailsmodel.cpp b/qml/models/peerdetailsmodel.cpp index 4a5340ea8b..8f03071c06 100644 --- a/qml/models/peerdetailsmodel.cpp +++ b/qml/models/peerdetailsmodel.cpp @@ -5,7 +5,8 @@ #include PeerDetailsModel::PeerDetailsModel(const CNodeCombinedStats* nodeStats, PeerListModel* parent) -: m_node_id{static_cast(nodeStats->nodeStats.nodeid)} +: QObject(parent) +, m_node_id{static_cast(nodeStats->nodeStats.nodeid)} , m_addr{nodeStats->nodeStats.addr} , m_combinedStats{nodeStats} , m_model{parent} diff --git a/qml/models/peerdetailsmodel.h b/qml/models/peerdetailsmodel.h index 1e5c70cdd9..7abdb46dc4 100644 --- a/qml/models/peerdetailsmodel.h +++ b/qml/models/peerdetailsmodel.h @@ -20,11 +20,17 @@ class PeerDetailsModel : public QObject Q_PROPERTY(QString address READ address NOTIFY dataChanged) Q_PROPERTY(QString addressLocal READ addressLocal NOTIFY dataChanged) Q_PROPERTY(QString type READ type NOTIFY dataChanged) + Q_PROPERTY(QString network READ network NOTIFY dataChanged) + Q_PROPERTY(QString transport READ transport NOTIFY dataChanged) + Q_PROPERTY(QString sessionId READ sessionId NOTIFY dataChanged) Q_PROPERTY(QString version READ version NOTIFY dataChanged) Q_PROPERTY(QString userAgent READ userAgent NOTIFY dataChanged) Q_PROPERTY(QString services READ services NOTIFY dataChanged) Q_PROPERTY(bool transactionRelay READ transactionRelay NOTIFY dataChanged) Q_PROPERTY(bool addressRelay READ addressRelay NOTIFY dataChanged) + Q_PROPERTY(bool highBandwidth READ highBandwidth NOTIFY dataChanged) + Q_PROPERTY(QString addressesProcessed READ addressesProcessed NOTIFY dataChanged) + Q_PROPERTY(QString addressesRateLimited READ addressesRateLimited NOTIFY dataChanged) Q_PROPERTY(QString startingHeight READ startingHeight NOTIFY dataChanged) Q_PROPERTY(QString syncedHeaders READ syncedHeaders NOTIFY dataChanged) Q_PROPERTY(QString syncedBlocks READ syncedBlocks NOTIFY dataChanged) @@ -49,11 +55,17 @@ class PeerDetailsModel : public QObject QString address() const { return QString::fromStdString(m_combinedStats->nodeStats.m_addr_name); } QString addressLocal() const { return QString::fromStdString(m_combinedStats->nodeStats.addrLocal); } QString type() const { return PeerStatsUtil::ConnectionTypeToQString(m_combinedStats->nodeStats.m_conn_type, /*prepend_direction=*/true); } + QString network() const { return PeerStatsUtil::NetworkToQString(m_combinedStats->nodeStats.m_network); } + QString transport() const { return PeerStatsUtil::TransportToQString(m_combinedStats->nodeStats.m_transport_type); } + QString sessionId() const { return QString::fromStdString(m_combinedStats->nodeStats.m_session_id); } QString version() const { return QString::number(m_combinedStats->nodeStats.nVersion); } QString userAgent() const { return QString::fromStdString(m_combinedStats->nodeStats.cleanSubVer); } QString services() const { return PeerStatsUtil::FormatServicesStr(m_combinedStats->nodeStateStats.their_services); } bool transactionRelay() const { return m_combinedStats->nodeStateStats.m_relay_txs; } bool addressRelay() const { return m_combinedStats->nodeStateStats.m_addr_relay_enabled; } + bool highBandwidth() const { return m_combinedStats->nodeStats.m_bip152_highbandwidth_to || m_combinedStats->nodeStats.m_bip152_highbandwidth_from; } + QString addressesProcessed() const { return QString::number(m_combinedStats->nodeStateStats.m_addr_processed); } + QString addressesRateLimited() const { return QString::number(m_combinedStats->nodeStateStats.m_addr_rate_limited); } QString startingHeight() const { return tr("N/A"); } QString syncedHeaders() const { return QString::number(m_combinedStats->nodeStateStats.nSyncHeight); } QString syncedBlocks() const { return QString::number(m_combinedStats->nodeStateStats.nCommonHeight); } diff --git a/qml/models/peerlistmodel.cpp b/qml/models/peerlistmodel.cpp index 390bb2d956..f9672939a7 100644 --- a/qml/models/peerlistmodel.cpp +++ b/qml/models/peerlistmodel.cpp @@ -72,6 +72,8 @@ QVariant PeerListModel::data(const QModelIndex& index, int role) const return PeerStatsUtil::FormatBytes(rec.nodeStats.nRecvBytes); case Subversion: return QString::fromStdString(rec.nodeStats.cleanSubVer); + case Transport: + return PeerStatsUtil::TransportToQString(rec.nodeStats.m_transport_type); case StatsRole: return QVariant::fromValue(&rec); } @@ -92,6 +94,7 @@ QHash PeerListModel::roleNames() const roles[Sent] = "sent"; roles[Received] = "received"; roles[Subversion] = "subversion"; + roles[Transport] = "transport"; roles[StatsRole] = "stats"; return roles; } diff --git a/qml/models/peerlistmodel.h b/qml/models/peerlistmodel.h index 54c9ac231a..0f2f8f13ad 100644 --- a/qml/models/peerlistmodel.h +++ b/qml/models/peerlistmodel.h @@ -54,6 +54,7 @@ class PeerListModel : public QAbstractListModel Sent, Received, Subversion, + Transport, }; int rowCount(const QModelIndex& parent = QModelIndex()) const override; diff --git a/qml/models/peerlistsortproxy.cpp b/qml/models/peerlistsortproxy.cpp index c876fc97be..8809be475f 100644 --- a/qml/models/peerlistsortproxy.cpp +++ b/qml/models/peerlistsortproxy.cpp @@ -7,12 +7,63 @@ #include #include +namespace { +QString DirectionKey(const CNodeStats& stats) +{ + return stats.fInbound ? QStringLiteral("inbound") : QStringLiteral("outbound"); +} + +QString ConnectionTypeKey(ConnectionType type) +{ + switch (type) { + case ConnectionType::INBOUND: return QStringLiteral("inbound"); + case ConnectionType::OUTBOUND_FULL_RELAY: return QStringLiteral("full-relay"); + case ConnectionType::MANUAL: return QStringLiteral("manual"); + case ConnectionType::FEELER: return QStringLiteral("feeler"); + case ConnectionType::BLOCK_RELAY: return QStringLiteral("block-relay"); + case ConnectionType::ADDR_FETCH: return QStringLiteral("address-fetch"); + case ConnectionType::PRIVATE_BROADCAST: return QStringLiteral("private-broadcast"); + } + return {}; +} + +QString NetworkKey(Network network) +{ + switch (network) { + case NET_UNROUTABLE: return QStringLiteral("unroutable"); + case NET_IPV4: return QStringLiteral("ipv4"); + case NET_IPV6: return QStringLiteral("ipv6"); + case NET_ONION: return QStringLiteral("onion"); + case NET_I2P: return QStringLiteral("i2p"); + case NET_CJDNS: return QStringLiteral("cjdns"); + case NET_INTERNAL: return QStringLiteral("internal"); + case NET_MAX: break; + } + return {}; +} + +QString TransportKey(TransportProtocolType transport) +{ + switch (transport) { + case TransportProtocolType::DETECTING: return QStringLiteral("detecting"); + case TransportProtocolType::V1: return QStringLiteral("v1"); + case TransportProtocolType::V2: return QStringLiteral("v2"); + } + return {}; +} +} // namespace + PeerListSortProxy::PeerListSortProxy(QObject* parent) : QSortFilterProxyModel(parent) { m_sort_role = PeerListModel::NetNodeId; setSortRole(m_sort_role); setDynamicSortFilter(true); + + const auto notify_count = [this] { Q_EMIT countChanged(); }; + connect(this, &QAbstractItemModel::rowsInserted, this, notify_count); + connect(this, &QAbstractItemModel::rowsRemoved, this, notify_count); + connect(this, &QAbstractItemModel::modelReset, this, notify_count); } QHash PeerListSortProxy::roleNames() const @@ -37,14 +88,45 @@ int PeerListSortProxy::RoleNameToRole(const QString & name) const QVariant PeerListSortProxy::data(const QModelIndex& index, int role) const { if (role == PeerListModel::StatsRole) { - auto stats = QSortFilterProxyModel::data(index, role); - auto details = new PeerDetailsModel(stats.value(), qobject_cast(sourceModel())); - return QVariant::fromValue(details); + return QVariant::fromValue(peerDetailsAt(index.row())); } return QSortFilterProxyModel::data(index, role); } +PeerDetailsModel* PeerListSortProxy::peerDetailsAt(int row) const +{ + if (row < 0 || row >= rowCount() || !sourceModel()) return nullptr; + + const QModelIndex proxy_index = index(row, 0); + const QModelIndex source_index = mapToSource(proxy_index); + const qint64 node_id = sourceModel()->data(source_index, PeerListModel::NetNodeId).toLongLong(); + if (auto existing = m_detail_models.value(node_id)) return existing; + + const auto stats = sourceModel()->data(source_index, PeerListModel::StatsRole) + .value(); + auto* peer_model = qobject_cast(sourceModel()); + if (!stats || !peer_model) return nullptr; + + auto* details = new PeerDetailsModel(stats, peer_model); + m_detail_models.insert(node_id, details); + connect(details, &PeerDetailsModel::disconnected, this, [this, node_id, details] { + if (m_detail_models.value(node_id) == details) m_detail_models.remove(node_id); + details->deleteLater(); + }); + return details; +} + +int PeerListSortProxy::indexOfNodeId(qint64 node_id) const +{ + for (int row = 0; row < rowCount(); ++row) { + if (QSortFilterProxyModel::data(index(row, 0), PeerListModel::NetNodeId).toLongLong() == node_id) { + return row; + } + } + return -1; +} + QString PeerListSortProxy::sortBy() const { return m_sort_by; @@ -57,10 +139,93 @@ void PeerListSortProxy::setSortBy(const QString & roleName) m_sort_by = roleName; m_sort_role = RoleNameToRole(roleName); setSortRole(m_sort_role); - sort(0); + sort(0, m_sort_ascending ? Qt::AscendingOrder : Qt::DescendingOrder); Q_EMIT sortByChanged(roleName); } +void PeerListSortProxy::setSortAscending(bool ascending) +{ + if (m_sort_ascending == ascending) return; + m_sort_ascending = ascending; + sort(0, m_sort_ascending ? Qt::AscendingOrder : Qt::DescendingOrder); + Q_EMIT sortAscendingChanged(ascending); +} + +void PeerListSortProxy::setSearchText(const QString& search_text) +{ + if (m_search_text == search_text) return; + m_search_text = search_text; + invalidateFilter(); + Q_EMIT searchTextChanged(search_text); +} + +void PeerListSortProxy::setDirectionFilters(const QStringList& filters) +{ + if (m_direction_filters == filters) return; + m_direction_filters = filters; + invalidateFilter(); + Q_EMIT directionFiltersChanged(filters); +} + +void PeerListSortProxy::setConnectionTypeFilters(const QStringList& filters) +{ + if (m_connection_type_filters == filters) return; + m_connection_type_filters = filters; + invalidateFilter(); + Q_EMIT connectionTypeFiltersChanged(filters); +} + +void PeerListSortProxy::setNetworkFilters(const QStringList& filters) +{ + if (m_network_filters == filters) return; + m_network_filters = filters; + invalidateFilter(); + Q_EMIT networkFiltersChanged(filters); +} + +void PeerListSortProxy::setTransportFilters(const QStringList& filters) +{ + if (m_transport_filters == filters) return; + m_transport_filters = filters; + invalidateFilter(); + Q_EMIT transportFiltersChanged(filters); +} + +bool PeerListSortProxy::filterAcceptsRow(int source_row, const QModelIndex& source_parent) const +{ + if (!sourceModel()) return false; + const QModelIndex source_index = sourceModel()->index(source_row, 0, source_parent); + const auto* stats = sourceModel()->data(source_index, PeerListModel::StatsRole) + .value(); + if (!stats) return false; + + if (!m_direction_filters.isEmpty() && !m_direction_filters.contains(DirectionKey(stats->nodeStats))) return false; + if (!m_connection_type_filters.isEmpty() + && !m_connection_type_filters.contains(ConnectionTypeKey(stats->nodeStats.m_conn_type))) return false; + if (!m_network_filters.isEmpty() && !m_network_filters.contains(NetworkKey(stats->nodeStats.m_network))) return false; + if (!m_transport_filters.isEmpty() + && !m_transport_filters.contains(TransportKey(stats->nodeStats.m_transport_type))) return false; + + const QString needle = m_search_text.trimmed(); + if (needle.isEmpty()) return true; + + const QList searchable_roles{ + PeerListModel::NetNodeId, + PeerListModel::Address, + PeerListModel::Direction, + PeerListModel::ConnectionType, + PeerListModel::Network, + PeerListModel::Transport, + PeerListModel::Subversion, + PeerListModel::Sent, + PeerListModel::Received, + }; + for (const int role : searchable_roles) { + if (sourceModel()->data(source_index, role).toString().contains(needle, Qt::CaseInsensitive)) return true; + } + return false; +} + bool PeerListSortProxy::lessThan(const QModelIndex& left_index, const QModelIndex& right_index) const { const CNodeStats left_stats = Assert(sourceModel()->data(left_index, PeerListModel::StatsRole).value())->nodeStats; @@ -72,13 +237,13 @@ bool PeerListSortProxy::lessThan(const QModelIndex& left_index, const QModelInde case PeerListModel::Age: return left_stats.m_connected > right_stats.m_connected; case PeerListModel::Address: - return left_stats.m_addr_name.compare(right_stats.m_addr_name) < 0; + return sourceModel()->data(left_index, m_sort_role).toString().localeAwareCompare( + sourceModel()->data(right_index, m_sort_role).toString()) < 0; case PeerListModel::Direction: - return left_stats.fInbound > right_stats.fInbound; case PeerListModel::ConnectionType: - return left_stats.m_conn_type < right_stats.m_conn_type; case PeerListModel::Network: - return left_stats.m_network < right_stats.m_network; + return sourceModel()->data(left_index, m_sort_role).toString().localeAwareCompare( + sourceModel()->data(right_index, m_sort_role).toString()) < 0; case PeerListModel::Ping: return left_stats.m_min_ping_time < right_stats.m_min_ping_time; case PeerListModel::Sent: @@ -86,7 +251,9 @@ bool PeerListSortProxy::lessThan(const QModelIndex& left_index, const QModelInde case PeerListModel::Received: return left_stats.nRecvBytes < right_stats.nRecvBytes; case PeerListModel::Subversion: - return left_stats.cleanSubVer.compare(right_stats.cleanSubVer) < 0; + case PeerListModel::Transport: + return sourceModel()->data(left_index, m_sort_role).toString().localeAwareCompare( + sourceModel()->data(right_index, m_sort_role).toString()) < 0; } return false; } diff --git a/qml/models/peerlistsortproxy.h b/qml/models/peerlistsortproxy.h index 4b01e94f07..bcccc74d5f 100644 --- a/qml/models/peerlistsortproxy.h +++ b/qml/models/peerlistsortproxy.h @@ -8,13 +8,24 @@ #include #include #include +#include #include +#include #include +class PeerDetailsModel; + class PeerListSortProxy : public QSortFilterProxyModel { Q_OBJECT Q_PROPERTY(QString sortBy READ sortBy WRITE setSortBy NOTIFY sortByChanged) + Q_PROPERTY(bool sortAscending READ sortAscending WRITE setSortAscending NOTIFY sortAscendingChanged) + Q_PROPERTY(QString searchText READ searchText WRITE setSearchText NOTIFY searchTextChanged) + Q_PROPERTY(QStringList directionFilters READ directionFilters WRITE setDirectionFilters NOTIFY directionFiltersChanged) + Q_PROPERTY(QStringList connectionTypeFilters READ connectionTypeFilters WRITE setConnectionTypeFilters NOTIFY connectionTypeFiltersChanged) + Q_PROPERTY(QStringList networkFilters READ networkFilters WRITE setNetworkFilters NOTIFY networkFiltersChanged) + Q_PROPERTY(QStringList transportFilters READ transportFilters WRITE setTransportFilters NOTIFY transportFiltersChanged) + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) public: explicit PeerListSortProxy(QObject* parent); @@ -23,18 +34,48 @@ class PeerListSortProxy : public QSortFilterProxyModel QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; QString sortBy() const; + bool sortAscending() const { return m_sort_ascending; } + QString searchText() const { return m_search_text; } + QStringList directionFilters() const { return m_direction_filters; } + QStringList connectionTypeFilters() const { return m_connection_type_filters; } + QStringList networkFilters() const { return m_network_filters; } + QStringList transportFilters() const { return m_transport_filters; } + + Q_INVOKABLE PeerDetailsModel* peerDetailsAt(int row) const; + Q_INVOKABLE int indexOfNodeId(qint64 node_id) const; public Q_SLOTS: void setSortBy(const QString & roleName); + void setSortAscending(bool ascending); + void setSearchText(const QString& search_text); + void setDirectionFilters(const QStringList& filters); + void setConnectionTypeFilters(const QStringList& filters); + void setNetworkFilters(const QStringList& filters); + void setTransportFilters(const QStringList& filters); Q_SIGNALS: void sortByChanged(const QString & roleName); + void sortAscendingChanged(bool ascending); + void searchTextChanged(const QString& search_text); + void directionFiltersChanged(const QStringList& filters); + void connectionTypeFiltersChanged(const QStringList& filters); + void networkFiltersChanged(const QStringList& filters); + void transportFiltersChanged(const QStringList& filters); + void countChanged(); private: bool lessThan(const QModelIndex& left_index, const QModelIndex& right_index) const override; + bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override; int RoleNameToRole(const QString & name) const; int m_sort_role{0}; QString m_sort_by; + bool m_sort_ascending{true}; + QString m_search_text; + QStringList m_direction_filters; + QStringList m_connection_type_filters; + QStringList m_network_filters; + QStringList m_transport_filters; + mutable QHash> m_detail_models; }; #endif // BITCOIN_QML_MODELS_PEERLISTSORTPROXY_H diff --git a/qml/peerstatsutil.cpp b/qml/peerstatsutil.cpp index 5887029cae..46580aa80b 100644 --- a/qml/peerstatsutil.cpp +++ b/qml/peerstatsutil.cpp @@ -52,6 +52,11 @@ QString NetworkToQString(Network net) assert(false); } +QString TransportToQString(TransportProtocolType transport) +{ + return QString::fromStdString(TransportTypeAsString(transport)); +} + QString FormatDurationStr(std::chrono::nanoseconds dur) { const auto d{std::chrono::duration_cast(dur)}; diff --git a/qml/peerstatsutil.h b/qml/peerstatsutil.h index 4ee47227bc..7e0dd537b0 100644 --- a/qml/peerstatsutil.h +++ b/qml/peerstatsutil.h @@ -16,6 +16,7 @@ namespace PeerStatsUtil { QString ConnectionTypeToQString(ConnectionType conn_type, bool prepend_direction); QString NetworkToQString(Network net); +QString TransportToQString(TransportProtocolType transport); QString FormatDurationStr(std::chrono::nanoseconds dur); QString FormatPeerAge(NodeClock::time_point time_connected); QString FormatServicesStr(quint64 mask); diff --git a/test/test_peerlistmodel.cpp b/test/test_peerlistmodel.cpp index 7ee9b7fac7..6fab230c0e 100644 --- a/test/test_peerlistmodel.cpp +++ b/test/test_peerlistmodel.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -36,6 +37,7 @@ CNodeStats MakeNodeStats(NodeId node_id, std::string address, bool inbound, Conn stats.nSendBytes = 1'200; stats.nRecvBytes = 900; stats.cleanSubVer = "/Satoshi:28.0.0/"; + stats.m_transport_type = TransportProtocolType::V1; return stats; } @@ -57,7 +59,12 @@ private Q_SLOTS: void PeerListModelTests::mapsRoleData() { - const auto stats{MakeStats({MakeNodeStats(7, "127.0.0.1:8333", false, ConnectionType::OUTBOUND_FULL_RELAY, NET_IPV4)})}; + auto stats{MakeStats({MakeNodeStats(7, "127.0.0.1:8333", false, ConnectionType::OUTBOUND_FULL_RELAY, NET_IPV4)})}; + std::get<0>(stats[0]).m_session_id = "043604a60a54b3f5"; + std::get<0>(stats[0]).m_bip152_highbandwidth_to = true; + std::get<2>(stats[0]).m_addr_relay_enabled = true; + std::get<2>(stats[0]).m_addr_processed = 1'076; + std::get<2>(stats[0]).m_addr_rate_limited = 3; MockNode node; node.get_nodes_stats_fn = [&](interfaces::Node::NodesStats& result) { result = stats; @@ -75,6 +82,7 @@ void PeerListModelTests::mapsRoleData() QCOMPARE(roles.value(PeerListModel::NetNodeId), QByteArray{"nodeId"}); QCOMPARE(roles.value(PeerListModel::Address), QByteArray{"address"}); QCOMPARE(roles.value(PeerListModel::ConnectionType), QByteArray{"connectionType"}); + QCOMPARE(roles.value(PeerListModel::Transport), QByteArray{"transport"}); QCOMPARE(roles.value(PeerListModel::StatsRole), QByteArray{"stats"}); QCOMPARE(model.data(index, PeerListModel::NetNodeId).toLongLong(), 7LL); @@ -86,12 +94,20 @@ void PeerListModelTests::mapsRoleData() QCOMPARE(model.data(index, PeerListModel::Sent).toString(), QString{"1 kB"}); QCOMPARE(model.data(index, PeerListModel::Received).toString(), QString{"900 B"}); QCOMPARE(model.data(index, PeerListModel::Subversion).toString(), QString{"/Satoshi:28.0.0/"}); + QCOMPARE(model.data(index, PeerListModel::Transport).toString(), QString{"v1"}); QVERIFY(!model.data(index, PeerListModel::Age).toString().isEmpty()); const CNodeCombinedStats* stats_ptr = model.data(index, PeerListModel::StatsRole).value(); QVERIFY(stats_ptr != nullptr); QCOMPARE(stats_ptr->nodeStats.nodeid, 7); + PeerDetailsModel details{stats_ptr, &model}; + QCOMPARE(details.sessionId(), QString{"043604a60a54b3f5"}); + QVERIFY(details.highBandwidth()); + QVERIFY(details.addressRelay()); + QCOMPARE(details.addressesProcessed(), QString{"1076"}); + QCOMPARE(details.addressesRateLimited(), QString{"3"}); + QCOMPARE(model.flags(QModelIndex{}), Qt::NoItemFlags); QVERIFY(model.flags(index).testFlag(Qt::ItemIsSelectable)); QVERIFY(model.flags(index).testFlag(Qt::ItemIsEnabled)); @@ -261,12 +277,53 @@ void PeerListModelTests::sortProxySortsByRoles() assert_sort("age", [](const CNodeStats& left, const CNodeStats& right) { return left.m_connected > right.m_connected; }); assert_sort("address", [](const CNodeStats& left, const CNodeStats& right) { return left.m_addr_name.compare(right.m_addr_name) < 0; }); assert_sort("direction", [](const CNodeStats& left, const CNodeStats& right) { return left.fInbound > right.fInbound; }); - assert_sort("connectionType", [](const CNodeStats& left, const CNodeStats& right) { return left.m_conn_type < right.m_conn_type; }); - assert_sort("network", [](const CNodeStats& left, const CNodeStats& right) { return left.m_network < right.m_network; }); + assert_sort("connectionType", [](const CNodeStats& left, const CNodeStats& right) { + return PeerStatsUtil::ConnectionTypeToQString(left.m_conn_type, false).localeAwareCompare( + PeerStatsUtil::ConnectionTypeToQString(right.m_conn_type, false)) < 0; + }); + assert_sort("network", [](const CNodeStats& left, const CNodeStats& right) { + return PeerStatsUtil::NetworkToQString(left.m_network).localeAwareCompare( + PeerStatsUtil::NetworkToQString(right.m_network)) < 0; + }); assert_sort("ping", [](const CNodeStats& left, const CNodeStats& right) { return left.m_min_ping_time < right.m_min_ping_time; }); assert_sort("sent", [](const CNodeStats& left, const CNodeStats& right) { return left.nSendBytes < right.nSendBytes; }); assert_sort("received", [](const CNodeStats& left, const CNodeStats& right) { return left.nRecvBytes < right.nRecvBytes; }); assert_sort("subversion", [](const CNodeStats& left, const CNodeStats& right) { return left.cleanSubVer.compare(right.cleanSubVer) < 0; }); + assert_sort("transport", [](const CNodeStats& left, const CNodeStats& right) { + return PeerStatsUtil::TransportToQString(left.m_transport_type).localeAwareCompare( + PeerStatsUtil::TransportToQString(right.m_transport_type)) < 0; + }); + + proxy.setSortBy("nodeId"); + proxy.setSortAscending(false); + QCOMPARE(proxy.data(proxy.index(0, 0), PeerListModel::NetNodeId).toLongLong(), 30LL); + + proxy.setSearchText("10.0.0.10"); + QCOMPARE(proxy.rowCount(), 1); + QCOMPARE(proxy.data(proxy.index(0, 0), PeerListModel::NetNodeId).toLongLong(), 20LL); + proxy.setSearchText({}); + + proxy.setDirectionFilters({QStringLiteral("outbound")}); + QCOMPARE(proxy.rowCount(), 2); + proxy.setNetworkFilters({QStringLiteral("onion")}); + QCOMPARE(proxy.rowCount(), 1); + QCOMPARE(proxy.data(proxy.index(0, 0), PeerListModel::NetNodeId).toLongLong(), 30LL); + proxy.setDirectionFilters({}); + proxy.setNetworkFilters({}); + + proxy.setDirectionFilters({QStringLiteral("inbound"), QStringLiteral("outbound")}); + QCOMPARE(proxy.rowCount(), 3); + proxy.setConnectionTypeFilters({QStringLiteral("manual"), QStringLiteral("block-relay")}); + QCOMPARE(proxy.rowCount(), 2); + proxy.setDirectionFilters({}); + proxy.setConnectionTypeFilters({}); + + const int node_20_row = proxy.indexOfNodeId(20); + QVERIFY(node_20_row >= 0); + auto* details = proxy.peerDetailsAt(node_20_row); + QVERIFY(details != nullptr); + QCOMPARE(details->nodeId(), 20); + QCOMPARE(proxy.peerDetailsAt(node_20_row), details); QCOMPARE(node.calls.getNodesStats.load(), 1); } diff --git a/test/test_peerstatsutil.cpp b/test/test_peerstatsutil.cpp index 4fda40721e..dff2ecf8db 100644 --- a/test/test_peerstatsutil.cpp +++ b/test/test_peerstatsutil.cpp @@ -21,6 +21,7 @@ class PeerStatsUtilTests : public QObject private Q_SLOTS: void connectionType_toQString(); void network_toQString(); + void transport_toQString(); void formatDuration(); void formatPeerAge(); void formatServices(); @@ -45,6 +46,13 @@ void PeerStatsUtilTests::network_toQString() QCOMPARE(PeerStatsUtil::NetworkToQString(NET_I2P), QString("I2P")); } +void PeerStatsUtilTests::transport_toQString() +{ + QCOMPARE(PeerStatsUtil::TransportToQString(TransportProtocolType::DETECTING), QString("detecting")); + QCOMPARE(PeerStatsUtil::TransportToQString(TransportProtocolType::V1), QString("v1")); + QCOMPARE(PeerStatsUtil::TransportToQString(TransportProtocolType::V2), QString("v2")); +} + void PeerStatsUtilTests::formatDuration() { QCOMPARE(PeerStatsUtil::FormatDurationStr(0s), QString("0 s")); From 9b47bc38a0a75874cc8913a0baa99c6ffdfe52c7 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Wed, 9 Sep 2026 13:31:23 -0700 Subject: [PATCH 5/5] qml: redesign peers list with adaptive split navigation Redesign the peer list with search, filters, sorting, traffic details, and contextual peer actions. --- qml/bitcoin_qml.qrc | 4 +- qml/components/BannedPeersPopup.qml | 237 ++++++++ qml/components/PeerActionsMenu.qml | 45 ++ qml/pages/MainWindow.qml | 20 +- qml/pages/node/BannedPeers.qml | 136 ----- qml/pages/node/PeerDetails.qml | 696 ++++++++++++----------- qml/pages/node/Peers.qml | 827 ++++++++++++++++++++-------- qml/pages/node/PeersView.qml | 119 ++++ qml/pages/wallet/DesktopWallets.qml | 26 +- test/functional/qml_test_peers.py | 21 +- test/qml/qml_tests_main.cpp | 103 +++- test/qml/tst_peeractions.qml | 321 ++++++++++- 12 files changed, 1752 insertions(+), 803 deletions(-) create mode 100644 qml/components/BannedPeersPopup.qml create mode 100644 qml/components/PeerActionsMenu.qml delete mode 100644 qml/pages/node/BannedPeers.qml create mode 100644 qml/pages/node/PeersView.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 57e0711d8c..e56c2b1f69 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -5,6 +5,7 @@ components/AddressLabel.qml components/AlertAction.qml components/AlertPopup.qml + components/BannedPeersPopup.qml components/BitcoinAddressDisplayField.qml components/BitcoinAddressInputField.qml components/BitcoinAmountDisplayField.qml @@ -45,6 +46,7 @@ components/TotalBytesIndicator.qml components/DetailEditRow.qml components/PaymentDetailOptionsPopup.qml + components/PeerActionsMenu.qml components/QRCodePopup.qml components/ReceiveOptionsPopup.qml components/Tooltip.qml @@ -115,11 +117,11 @@ pages/initerrormessage.qml pages/MainWindow.qml pages/preinit.qml - pages/node/BannedPeers.qml pages/node/CommandConsole.qml pages/node/NetworkTraffic.qml pages/node/NodeRunner.qml pages/node/Peers.qml + pages/node/PeersView.qml pages/node/PeerDetails.qml pages/node/Shutdown.qml pages/onboarding/OnboardingBlockclock.qml diff --git a/qml/components/BannedPeersPopup.qml b/qml/components/BannedPeersPopup.qml new file mode 100644 index 0000000000..f91c672fc3 --- /dev/null +++ b/qml/components/BannedPeersPopup.qml @@ -0,0 +1,237 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 + +import "../controls" + +Popup { + id: root + objectName: "bannedPeersPopup" + + property var model: banListModel + readonly property bool compact: parent ? parent.width <= SizeClass.compactWidthMax : false + readonly property color modalOverlayColor: Qt.rgba(0, 0, 0, 0.4) + property real verticalOffset: 0 + + function unbanPeer(row) { + if (!root.model.unbanAt(row)) { + unbanActionError.message = qsTr("Could not unban peer. The ban list may have changed.") + unbanActionError.open() + } + } + + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) + verticalOffset : verticalOffset + width: Math.min(640, parent ? parent.width - 40 : 640) + height: Math.min(implicitHeight, parent ? parent.height - 40 : implicitHeight) + modal: true + focus: true + leftPadding: root.compact ? 24 : 40 + rightPadding: root.compact ? 24 : 40 + topPadding: 30 + bottomPadding: 30 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + enter: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: 300 + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "verticalOffset" + from: -30 + to: 0 + duration: 300 + easing.type: Easing.OutCubic + } + } + + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: 250 + easing.type: Easing.InCubic + } + NumberAnimation { + property: "verticalOffset" + from: 0 + to: -30 + duration: 250 + easing.type: Easing.InCubic + } + } + + Overlay.modal: Rectangle { + color: root.modalOverlayColor + opacity: root.opacity + } + + background: Rectangle { + objectName: "bannedPeersPopupSurface" + color: Theme.color.neutral1 + border.color: Theme.color.neutral2 + border.width: 1 + radius: 10 + } + + contentItem: ColumnLayout { + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.bottomMargin: 12 + + Header { + Layout.fillWidth: true + header: qsTr("Banned peers") + headerBold: true + center: false + } + + IconButton { + id: closeButton + objectName: "bannedPeersCloseButton" + size: 28 + iconSize: 12 + iconSource: "image://images/cross" + iconColor: Theme.color.neutral8 + Accessible.name: qsTr("Close") + background: Rectangle { + radius: 5 + color: closeButton.down || closeButton.hovered + ? Theme.color.neutral2 : Theme.color.neutral1 + } + onClicked: root.close() + } + } + + CoreText { + Layout.fillWidth: true + Layout.bottomMargin: 20 + text: qsTr("These peers are blocked from connecting to your node.") + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral6 + horizontalAlignment: Text.AlignLeft + wrapMode: Text.WordWrap + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(360, Math.max(72, bannedPeersList.contentHeight)) + color: Theme.color.neutral2 + radius: 12 + clip: true + + ListView { + id: bannedPeersList + objectName: "bannedPeersList" + anchors.fill: parent + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.model + + delegate: ItemDelegate { + id: bannedPeerRow + required property string address + required property string banUntil + required property int index + width: bannedPeersList.width + height: 72 + leftPadding: 16 + rightPadding: 12 + topPadding: 10 + bottomPadding: 10 + hoverEnabled: AppMode.isDesktop + background: Item { + Rectangle { + anchors.fill: parent + anchors.margins: 4 + radius: 10 + color: bannedPeerRow.down || bannedPeerRow.hovered + ? Theme.color.neutral3 : "transparent" + } + Separator { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + visible: bannedPeerRow.index < bannedPeersList.count - 1 + color: Theme.color.neutral3 + } + } + contentItem: RowLayout { + spacing: 12 + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 3 + CoreText { + Layout.fillWidth: true + text: bannedPeerRow.address + font: Theme.text.monoDescription.font + lineHeight: Theme.text.monoDescription.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideMiddle + wrap: false + } + CoreText { + Layout.fillWidth: true + text: qsTr("Until %1").arg(bannedPeerRow.banUntil) + font: Theme.text.caption.font + lineHeight: Theme.text.caption.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral6 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + wrap: false + } + } + OutlineButton { + objectName: "unbanButton_" + bannedPeerRow.index + bold: false + horizontalPadding: 18 + text: qsTr("Unban") + onClicked: root.unbanPeer(bannedPeerRow.index) + } + } + } + } + + CoreText { + anchors.centerIn: parent + visible: bannedPeersList.count === 0 + text: qsTr("No banned peers") + font: Theme.text.description.font + color: Theme.color.neutral6 + } + } + } + + AlertPopup { + id: unbanActionError + objectName: "unbanActionErrorPopup" + parent: root.parent ? root.parent : root + title: qsTr("Peer action failed") + messageObjectName: "actionErrorMessage" + + AlertAction { + text: qsTr("OK") + buttonObjectName: "actionErrorCloseButton" + } + } +} diff --git a/qml/components/PeerActionsMenu.qml b/qml/components/PeerActionsMenu.qml new file mode 100644 index 0000000000..0ceb6be8d7 --- /dev/null +++ b/qml/components/PeerActionsMenu.qml @@ -0,0 +1,45 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 + +import "../controls" + +ContextMenu { + id: root + + signal banRequested(int duration, string label) + signal disconnectRequested + + title: qsTr("Ban peer") + minMenuWidth: 210 + + ContextMenuButton { + objectName: "peerBanDuration_3600" + text: qsTr("1 hour") + onTriggered: root.banRequested(3600, text) + } + ContextMenuButton { + objectName: "peerBanDuration_86400" + text: qsTr("1 day") + onTriggered: root.banRequested(86400, text) + } + ContextMenuButton { + objectName: "peerBanDuration_604800" + text: qsTr("1 week") + onTriggered: root.banRequested(604800, text) + } + ContextMenuButton { + objectName: "peerBanDuration_31536000" + text: qsTr("1 year") + onTriggered: root.banRequested(31536000, text) + } + ContextMenuDivider { } + ContextMenuButton { + objectName: "peerDisconnectButton" + text: qsTr("Disconnect") + role: ContextMenuButton.Destructive + onTriggered: root.disconnectRequested() + } +} diff --git a/qml/pages/MainWindow.qml b/qml/pages/MainWindow.qml index 2c4f6b16c1..4a1f9cae99 100644 --- a/qml/pages/MainWindow.qml +++ b/qml/pages/MainWindow.qml @@ -331,29 +331,11 @@ ApplicationWindow { } Component { id: peersPage - Peers { + PeersView { onBack: { nodeStack.pop() peerTableModel.stopAutoRefresh() } - onPeerSelected: (peerDetails) => { - nodeStack.push(peerDetailsPage, {"details": peerDetails}) - } - onBannedPeers: { - nodeStack.push(bannedPeersPage) - } - } - } - Component { - id: peerDetailsPage - PeerDetails { - onBack: nodeStack.pop() - } - } - Component { - id: bannedPeersPage - BannedPeers { - onBack: nodeStack.pop() } } } diff --git a/qml/pages/node/BannedPeers.qml b/qml/pages/node/BannedPeers.qml deleted file mode 100644 index d2ef995ee3..0000000000 --- a/qml/pages/node/BannedPeers.qml +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) 2026 The Bitcoin Core developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. - -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import org.bitcoincore.qt 1.0 -import "../../controls" -import "../../components" - -Page { - id: root - objectName: "bannedPeers" - signal back() - background: null - - function unbanPeer(row) { - if (!banListModel.unbanAt(row)) { - unbanActionError.message = qsTr("Could not unban peer. The ban list may have changed.") - unbanActionError.open() - } - } - - header: NavigationBar2 { - leftItem: NavButton { - objectName: "bannedPeersBackButton" - iconSource: "image://images/caret-left" - text: qsTr("Back") - onClicked: root.back() - } - centerItem: Header { - headerBold: true - headerSize: 18 - header: qsTr("Banned peers") - } - } - - ListView { - id: listView - objectName: "bannedPeersList" - clip: true - width: Math.min(parent.width - 40, 450) - height: parent.height - anchors.horizontalCenter: parent.horizontalCenter - model: banListModel - spacing: 15 - - header: ColumnLayout { - width: listView.width - spacing: 0 - CoreText { - Layout.fillWidth: true - Layout.topMargin: 10 - Layout.bottomMargin: 20 - text: qsTr("You banned these peers from connecting to your node.") - font.pixelSize: 13 - color: Theme.color.neutral7 - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - } - - delegate: ItemDelegate { - required property string address - required property string banUntil - required property int index - - leftPadding: 0 - rightPadding: 0 - topPadding: 0 - bottomPadding: 14 - width: listView.width - background: Item { - Separator { - anchors.bottom: parent.bottom - width: parent.width - } - } - - contentItem: RowLayout { - ColumnLayout { - Layout.fillWidth: true - spacing: 4 - CoreText { - Layout.fillWidth: true - text: address - font.pixelSize: 15 - color: Theme.color.neutral9 - elide: Text.ElideMiddle - horizontalAlignment: Text.AlignLeft - } - CoreText { - Layout.fillWidth: true - text: qsTr("Until %1").arg(banUntil) - font.pixelSize: 13 - color: Theme.color.neutral7 - horizontalAlignment: Text.AlignLeft - } - } - OutlineButton { - objectName: "unbanButton_" + index - bold: false - horizontalPadding: 24 - text: qsTr("Unban") - onClicked: root.unbanPeer(index) - } - } - } - - footer: Loader { - width: listView.width - height: 80 - active: listView.count === 0 - visible: active - sourceComponent: CoreText { - anchors.centerIn: parent - text: qsTr("No banned peers.") - color: Theme.color.neutral7 - font.pixelSize: 15 - } - } - } - - AlertPopup { - id: unbanActionError - objectName: "unbanActionErrorPopup" - title: qsTr("Peer action failed") - messageObjectName: "actionErrorMessage" - - AlertAction { - text: qsTr("OK") - buttonObjectName: "actionErrorCloseButton" - } - } -} diff --git a/qml/pages/node/PeerDetails.qml b/qml/pages/node/PeerDetails.qml index da5fe7eb37..7ebca0fe08 100644 --- a/qml/pages/node/PeerDetails.qml +++ b/qml/pages/node/PeerDetails.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2024 The Bitcoin Core developers +// Copyright (c) 2024-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -12,424 +12,402 @@ import "../../components" Page { id: root objectName: "peerDetails" - signal back() - property PeerDetailsModel details + signal back + signal peerDisconnected(int nodeId) + property PeerDetailsModel details + property bool compact: width <= SizeClass.compactWidthMax + property Item popupParent: null + property int sectionIndex: 0 + property int pendingBanDuration: 3600 + property string pendingBanLabel: qsTr("1 hour") + property real maximumContentWidth: 840 + property real contentHorizontalPadding: width >= 900 ? 56 : width >= 640 ? 40 : 24 + + readonly property var informationRows: [ + { label: qsTr("Address"), value: available(details ? details.address : ""), mono: true }, + { label: qsTr("Via"), value: available(details ? details.addressLocal : ""), mono: true }, + { label: qsTr("Direction / type"), value: directionAndType(), mono: false }, + { label: qsTr("Network / transport"), value: joined([details ? details.network : "", details ? details.transport : ""]), mono: false }, + { label: qsTr("Session ID"), value: available(details ? details.sessionId : ""), mono: true }, + { label: qsTr("Permissions"), value: defaultValue(details ? details.permission : "", qsTr("Default")), mono: false }, + { label: qsTr("Version"), value: available(details ? details.version : ""), mono: true }, + { label: qsTr("User agent"), value: available(details ? details.userAgent : ""), mono: true }, + { label: qsTr("Services"), value: servicesValue(), mono: false }, + { label: qsTr("Transaction relay"), value: yesNo(details && details.transactionRelay), mono: false }, + { label: qsTr("Mapped AS"), value: mappedAsValue(), mono: true } + ] + readonly property var blockRelayRows: [ + { label: qsTr("Starting block"), value: available(details ? details.startingHeight : ""), mono: true }, + { label: qsTr("Synced headers"), value: heightValue(details ? details.syncedHeaders : ""), mono: true }, + { label: qsTr("Synced blocks"), value: heightValue(details ? details.syncedBlocks : ""), mono: true }, + { label: qsTr("High bandwidth"), value: yesNo(details && details.highBandwidth), mono: false } + ] + readonly property var addressRelayRows: { + const rows = [ + { label: qsTr("Address relay"), value: yesNo(details && details.addressRelay), mono: false } + ] + if (details && details.addressRelay) { + rows.push({ label: qsTr("Addresses processed"), value: available(details.addressesProcessed), mono: true }) + rows.push({ label: qsTr("Addresses rate-limited"), value: available(details.addressesRateLimited), mono: true }) + } + return rows + } + readonly property var trafficRows: [ + { label: qsTr("Connection time"), value: available(details ? details.connectionDuration : ""), mono: false }, + { label: qsTr("Last send"), value: elapsedValue(details ? details.lastSend : ""), mono: false }, + { label: qsTr("Last receive"), value: elapsedValue(details ? details.lastReceived : ""), mono: false }, + { label: qsTr("Sent"), value: totalValue(details ? details.bytesSent : ""), mono: true }, + { label: qsTr("Received"), value: totalValue(details ? details.bytesReceived : ""), mono: true }, + { label: qsTr("Ping time"), value: available(details ? details.pingTime : ""), mono: false }, + { label: qsTr("Ping wait"), value: available(details ? details.pingWait : ""), mono: false }, + { label: qsTr("Minimum ping"), value: available(details ? details.pingMin : ""), mono: false }, + { label: qsTr("Time offset"), value: available(details ? details.timeOffset : ""), mono: false } + ] + + background: Rectangle { color: Theme.color.neutral0 } + + function unavailable(value) { + return value === undefined || value === null || String(value).length === 0 || value === "N/A" + } + function available(value) { return unavailable(value) ? "—" : value } + function defaultValue(value, fallback) { return unavailable(value) ? fallback : value } + function yesNo(value) { return value ? qsTr("Yes") : qsTr("No") } + function joined(values) { + const present = [] + for (let i = 0; i < values.length; ++i) if (!unavailable(values[i])) present.push(values[i]) + return present.length > 0 ? present.join(" · ") : "—" + } + function directionAndType() { + if (!details) return "—" + let type = details.type + if (type.indexOf(details.direction) === 0) type = type.slice(details.direction.length).trim() + return joined([details.direction, type]) + } + function servicesValue() { + if (!details || unavailable(details.services)) return "—" + return details.services.replace(/\|/g, " · ").replace(/, /g, " · ") + } + function mappedAsValue() { + if (!details || unavailable(details.mappedAS)) return "—" + return String(details.mappedAS).indexOf("AS") === 0 ? details.mappedAS : "AS" + details.mappedAS + } + function heightValue(value) { + return unavailable(value) || Number(value) < 0 ? "—" : Number(value).toLocaleString(Qt.locale(), "f", 0) + } + function elapsedValue(value) { return unavailable(value) ? "—" : qsTr("%1 ago").arg(value) } + function totalValue(value) { return unavailable(value) ? "—" : qsTr("%1 total").arg(value) } function showActionError(message) { peerActionError.message = message peerActionError.open() } + function disconnectPeer() { + if (details && nodeModel.disconnectPeer(details.nodeId)) { + peerTableModel.refresh() + } else { + showActionError(qsTr("Could not disconnect peer. The peer may already be disconnected or the node state may have changed.")) + } + } + function requestDisconnect() { + disconnectConfirmation.open() + } + function requestBan(duration, label) { + pendingBanDuration = duration + pendingBanLabel = label + banConfirmation.open() + } + function confirmBan() { + if (details && nodeModel.banPeer(details.rawAddress, pendingBanDuration)) { + peerTableModel.refresh() + banListModel.refresh() + } else { + showActionError(qsTr("Could not ban peer. The peer may already be disconnected or the node state may have changed.")) + } + } Connections { target: details function onDisconnected() { - root.back() - } - } - - background: null - header: NavigationBar2 { - leftItem: NavButton { - iconSource: "image://images/caret-left" - text: qsTr("Back") - onClicked: root.back() - } - centerItem: Header { - headerBold: true - headerSize: 18 - header: qsTr("Peer %1").arg(details.nodeId) + const disconnectedId = root.details ? root.details.nodeId : -1 + root.peerDisconnected(disconnectedId) } } - ScrollView { - id: scrollView - width: parent.width - height: parent.height - clip: true - contentWidth: width - - Column { - width: Math.min(parent.width - 40, 450) - anchors.horizontalCenter: parent.horizontalCenter - spacing: 10 - topPadding: 30 - bottomPadding: 30 + Item { + objectName: "peerDetailsContentFrame" + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: root.compact ? 12 : 28 + anchors.bottomMargin: root.compact ? 16 : 28 + width: Math.max(0, Math.min( + parent.width - root.contentHorizontalPadding * 2, + root.maximumContentWidth)) - CoreText { - text: qsTr("Information"); - bold: true; - font.pixelSize: 18; - horizontalAlignment: Qt.AlignLeft; - color: Theme.color.neutral9; + ColumnLayout { + anchors.fill: parent + spacing: 16 + + NavButton { + objectName: "peerDetailsBackButton" + visible: root.compact + Layout.alignment: Qt.AlignLeft + Layout.leftMargin: -10 + Layout.preferredHeight: visible ? implicitHeight : 0 + iconSource: "image://images/caret-left" + text: qsTr("Peers") + bold: false + onClicked: root.back() } - Column { - width: parent.width - bottomPadding: 5 - - KeyValueRow { key: KeyText { text: qsTr("Address"); } value: ValText { text: details.address; color: Theme.color.neutral9; }} - KeyValueRow { key: KeyText { text: qsTr("VIA"); } value: ValText { text: details.addressLocal; color: Theme.color.neutral9; }} - KeyValueRow { key: KeyText { text: qsTr("Type"); } value: ValText { text: details.type; color: Theme.color.neutral9; }} - KeyValueRow { - id: permissionsRow - property string permissionsValue: details.permission - property bool isPermissioned: permissionsValue != "N/A" - key: KeyText { - text: qsTr("Permissions"); - active: permissionsRow.isPermissioned - } - value: Loader { - sourceComponent: permissionsRow.isPermissioned ? permissioned : notPermissioned - } - Component { - id: permissioned - ValText { text: permissionsRow.permissionsValue; } - } - Component { - id: notPermissioned - Row { - IconButton { - iconLocation: "image://images/minus"; - icon.color: Theme.color.neutral6 - } - } - } - } - KeyValueRow { key: KeyText { text: qsTr("Version"); } value: ValText { text: details.version; }} - KeyValueRow { key: KeyText { text: qsTr("User agent"); } value: ValText { text: details.userAgent; }} - KeyValueRow { key: KeyText { text: qsTr("Services"); } value: ValText { text: details.services; }} - KeyValueRow { - id: transactionRelayRow - property bool isTransactionRelay: details.transactionRelay - key: KeyText { text: qsTr("Transaction relay"); } - value: Row { - IconButton { - iconLocation: transactionRelayRow.isTransactionRelay ? "image://images/check" : "image://images/cross" - anchors.verticalCenter: parent.verticalCenter + RowLayout { + Layout.fillWidth: true + spacing: 12 + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 3 + RowLayout { + Layout.fillWidth: true + spacing: 10 + CoreText { + text: details ? qsTr("Peer #%1").arg(details.nodeId) : qsTr("Peer") + font: Theme.text.headline.font + lineHeight: Theme.text.headline.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignLeft + wrap: false } } - } - KeyValueRow { - id: addressRelayRow - property bool isAddressRelay: details.addressRelay - key: KeyText { text: qsTr("Address relay"); } - value: Row { - IconButton { - iconLocation: addressRelayRow.isAddressRelay ? "image://images/check" : "image://images/cross" - anchors.verticalCenter: parent.verticalCenter - } + CoreText { + Layout.fillWidth: true + text: details ? details.address : "" + font: Theme.text.monoDescription.font + lineHeight: Theme.text.monoDescription.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral6 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideMiddle + wrap: false } } - KeyValueRow { - id: asRow - property string mappedASValue: details.mappedAS - property bool isMappedAS: mappedASValue != "N/A" - key: KeyText { - text: qsTr("Mapped AS"); - active: asRow.isMappedAS - } - - value: Loader { - sourceComponent: asRow.isMappedAS ? mappedAs : notMappedAs - } - Component { - id: mappedAs - ValText { text: asRow.mappedASValue; } + IconButton { + id: actionButton + objectName: "peerActionsButton" + Layout.alignment: Qt.AlignTop + size: 40 + iconSize: 20 + focusPolicy: Qt.StrongFocus + iconSource: "image://images/ellipsis" + iconColor: Theme.color.neutral8 + onClicked: actionMenu.open() + FocusBorder { + objectName: "peerActionsButtonFocusBorder" + visible: actionButton.visualFocus + borderRadius: 12 + z: 1 } - - Component { - id: notMappedAs - Row { - IconButton { - iconLocation: "image://images/minus"; - icon.color: Theme.color.neutral6 - anchors.verticalCenter: parent.verticalCenter - } - } + PeerActionsMenu { + id: actionMenu + objectName: "peerActionsMenu" + y: actionButton.height + 4 + x: actionButton.width - width + onBanRequested: (duration, label) => root.requestBan(duration, label) + onDisconnectRequested: root.requestDisconnect() } } } - CoreText { - text: qsTr("Block data"); - bold: true; - font.pixelSize: 18; - horizontalAlignment: Qt.AlignLeft; - color: Theme.color.neutral9; - } - - Column { - width: parent.width - bottomPadding: 5 - KeyValueRow { key: KeyText { text: qsTr("Starting block"); } value: ValText { text: details.startingHeight; }} - KeyValueRow { key: KeyText { text: qsTr("Synced headers"); } value: ValText { text: details.syncedHeaders; }} - KeyValueRow { key: KeyText { text: qsTr("Synced blocks"); } value: ValText { text: details.syncedBlocks; }} + SegmentedPicker { + objectName: "peerDetailsSections" + Layout.fillWidth: true + model: [qsTr("Information"), qsTr("Relay data"), qsTr("Network traffic")] + currentIndex: root.sectionIndex + onSelected: (index) => root.sectionIndex = index } - CoreText { - text: qsTr("Network traffic"); - bold: true; - font.pixelSize: 18; - horizontalAlignment: Qt.AlignLeft; - color: Theme.color.neutral9; - } - Column { - width: parent.width - bottomPadding: 5 - KeyValueRow { - key: KeyText { text: qsTr("Direction"); } - value: Row { - IconButton { - iconLocation: details.direction === "Inbound" ? "image://images/arrow-down" : "image://images/arrow-up" - icon.height: 9 - anchors.verticalCenter: parent.verticalCenter - } - ValText { - text: details.direction - anchors.verticalCenter: parent.verticalCenter - } - } - } - KeyValueRow { key: KeyText { text: qsTr("Connection time"); } value: NetStatValue { text: details.connectionDuration; }} - KeyValueRow { key: KeyText { text: qsTr("Last send"); } value: NetStatValue { text: details.lastSend + qsTr(" ago"); }} - KeyValueRow { key: KeyText { text: qsTr("Last receive"); } value: NetStatValue { text: details.lastReceived + qsTr(" ago"); }} - KeyValueRow { key: KeyText { text: qsTr("Sent"); } value: NetStatValue { text: details.bytesSent + qsTr(" total"); }} - KeyValueRow { key: KeyText { text: qsTr("Received"); } value: NetStatValue { text: details.bytesReceived + qsTr(" total"); }} - KeyValueRow { key: KeyText { text: qsTr("Ping time"); } value: NetStatValue { text: details.pingTime; }} - KeyValueRow { - id: pingWaitRow - property string pingWaitValue: details.pingWait - property bool isPingWait: pingWaitValue != "N/A" - key: KeyText { - text: qsTr("Ping wait"); - active: pingWaitRow.isPingWait - } + ScrollView { + id: tableScroll + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth - value: Loader { - sourceComponent: pingWaitRow.isPingWait ? pingWait : notPingWait - } + Column { + width: tableScroll.availableWidth + spacing: 16 - Component { - id: pingWait - ValText { text: pingWaitRow.pingWaitValue; } + PeerTable { + objectName: "peerInformationTable" + width: parent.width + visible: root.sectionIndex === 0 + rows: root.informationRows } - Component { - id: notPingWait - Row { - Button { - padding: 0 - display: AbstractButton.IconOnly - height: 21 - width: 21 - icon.source: "image://images/minus" - icon.color: Theme.color.neutral6 - icon.height: 21 - icon.width: 21 - background: null - } + Column { + width: parent.width + visible: root.sectionIndex === 1 + spacing: 16 + + PeerSection { + objectName: "peerBlocksSection" + width: parent.width + title: qsTr("Blocks") + rows: root.blockRelayRows } - } - } - KeyValueRow { key: KeyText { text: qsTr("Min ping"); } value: NetStatValue { text: details.pingMin; }} - KeyValueRow { key: KeyText {text: qsTr("Time offset"); } value: NetStatValue { text: details.timeOffset; }} - } - - RowLayout { - width: parent.width - spacing: 10 - - OutlineButton { - objectName: "peerDisconnectButton" - Layout.fillWidth: true - Layout.preferredWidth: 0 - text: qsTr("Disconnect") - bold: false - onClicked: { - if (nodeModel.disconnectPeer(details.nodeId)) { - peerTableModel.refresh() - } else { - root.showActionError(qsTr("Could not disconnect peer. The peer may already be disconnected or the node state may have changed.")) + PeerSection { + objectName: "peerAddressesSection" + width: parent.width + title: qsTr("Addresses") + rows: root.addressRelayRows } } - } - OutlineButton { - objectName: "peerBanButton" - Layout.fillWidth: true - Layout.preferredWidth: 0 - text: qsTr("Ban") - bold: false - onClicked: banPopup.open() + PeerTable { + objectName: "peerNetworkTrafficTable" + width: parent.width + visible: root.sectionIndex === 2 + rows: root.trafficRows + } } } } } - Popup { - id: banPopup - objectName: "banPopup" - anchors.centerIn: parent - modal: true - padding: 20 - width: Math.min(root.width - 40, 350) - background: Rectangle { - color: Theme.color.background - radius: 8 - border.color: Theme.color.neutral3 - border.width: 1 + AlertPopup { + id: banConfirmation + objectName: "banConfirmationPopup" + parent: root.popupParent ? root.popupParent : root + title: qsTr("Ban peer?") + message: details + ? qsTr("Ban %1 for %2?").arg(details.address).arg(root.pendingBanLabel) + : qsTr("Ban this peer for %1?").arg(root.pendingBanLabel) + AlertAction { + text: qsTr("Cancel") + role: AlertAction.Cancel + buttonObjectName: "banCancelButton" + } + AlertAction { + text: qsTr("Ban") + role: AlertAction.Destructive + buttonObjectName: "banConfirmButton" + onTriggered: root.confirmBan() } + } - property int selectedDuration: 3600 + AlertPopup { + id: disconnectConfirmation + objectName: "disconnectConfirmationPopup" + parent: root.popupParent ? root.popupParent : root + title: qsTr("Disconnect peer?") + message: details + ? qsTr("Disconnect from %1? The peer may reconnect automatically.").arg(details.address) + : qsTr("Disconnect this peer? The peer may reconnect automatically.") + AlertAction { + text: qsTr("Cancel") + role: AlertAction.Cancel + buttonObjectName: "disconnectCancelButton" + } + AlertAction { + text: qsTr("Disconnect") + role: AlertAction.Destructive + buttonObjectName: "disconnectConfirmButton" + onTriggered: root.disconnectPeer() + } + } - readonly property var durations: [ - { label: qsTr("1 hour"), secs: 3600 }, - { label: qsTr("1 day"), secs: 86400 }, - { label: qsTr("1 week"), secs: 604800 }, - { label: qsTr("1 year"), secs: 31536000 } - ] + AlertPopup { + id: peerActionError + objectName: "peerActionErrorPopup" + parent: root.popupParent ? root.popupParent : root + title: qsTr("Peer action failed") + messageObjectName: "actionErrorMessage" + AlertAction { text: qsTr("OK"); buttonObjectName: "actionErrorCloseButton" } + } + component PeerTable: Rectangle { + id: table + required property var rows + implicitHeight: tableColumn.implicitHeight + color: Theme.color.neutral1 + radius: 12 + border.width: 0 + clip: true ColumnLayout { + id: tableColumn width: parent.width spacing: 0 - - CoreText { - Layout.fillWidth: true - Layout.bottomMargin: 16 - text: qsTr("Ban this peer") - bold: true - font.pixelSize: 18 - color: Theme.color.neutral9 - horizontalAlignment: Qt.AlignHCenter - } - Repeater { - model: banPopup.durations - delegate: Column { + model: table.rows + delegate: ColumnLayout { + id: rowDelegate + required property int index + required property var modelData Layout.fillWidth: true - - Separator { width: parent.width } - - ItemDelegate { - id: durationRow - objectName: "banDurationRow_" + modelData.secs - width: parent.width - leftPadding: 8 - rightPadding: 8 - hoverEnabled: AppMode.isDesktop - background: null - contentItem: RowLayout { - CoreText { - Layout.fillWidth: true - text: modelData.label - font.pixelSize: 16 - color: durationRow.hovered ? Theme.color.orangeLight1 : Theme.color.neutral9 - horizontalAlignment: Text.AlignLeft - verticalAlignment: Text.AlignVCenter - } - IconButton { - opacity: banPopup.selectedDuration === modelData.secs ? 1 : 0 - iconLocation: "image://images/check" - icon.color: durationRow.hovered ? Theme.color.orangeLight1 : Theme.color.neutral9 - } + spacing: 0 + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: root.compact ? 14 : 20 + Layout.rightMargin: root.compact ? 14 : 20 + Layout.minimumHeight: root.compact ? 50 : 48 + spacing: 16 + CoreText { + Layout.preferredWidth: root.compact ? 112 : 150 + text: modelData.label + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral6 + horizontalAlignment: Text.AlignLeft + wrap: false } - onClicked: banPopup.selectedDuration = modelData.secs - } - } - } - - Separator { Layout.fillWidth: true; Layout.bottomMargin: 16 } - - RowLayout { - Layout.fillWidth: true - spacing: 10 - - OutlineButton { - Layout.fillWidth: true - Layout.preferredWidth: 0 - text: qsTr("Cancel") - onClicked: banPopup.close() - } - - ContinueButton { - objectName: "banConfirmButton" - Layout.fillWidth: true - Layout.preferredWidth: 0 - text: qsTr("Ban") - onClicked: { - banPopup.close() - if (nodeModel.banPeer(details.rawAddress, banPopup.selectedDuration)) { - peerTableModel.refresh() - banListModel.refresh() - } else { - root.showActionError(qsTr("Could not ban peer. The peer may already be disconnected or the node state may have changed.")) + CoreText { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: modelData.value + font: modelData.mono ? Theme.text.monoDescription.font : Theme.text.description.font + lineHeight: modelData.mono ? Theme.text.monoDescription.lineHeight : Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral8 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideMiddle + wrap: false } } + Separator { + Layout.fillWidth: true + Layout.leftMargin: root.compact ? 14 : 20 + Layout.rightMargin: root.compact ? 14 : 20 + visible: rowDelegate.index < table.rows.length - 1 + color: Theme.color.neutral2 + } } } } } - AlertPopup { - id: peerActionError - objectName: "peerActionErrorPopup" - title: qsTr("Peer action failed") - messageObjectName: "actionErrorMessage" - - AlertAction { - text: qsTr("OK") - buttonObjectName: "actionErrorCloseButton" - } - } - - component KeyText: CoreText { - property bool active: true - color: active ? Theme.color.neutral9 : Theme.color.neutral6 - horizontalAlignment: Qt.AlignLeft - verticalAlignment: Text.AlignVCenter - } + component PeerSection: Column { + required property string title + required property var rows + spacing: 8 - component ValText: CoreText { - property bool active: true - color: active ? Theme.color.neutral8 : Theme.color.neutral6 - horizontalAlignment: Qt.AlignLeft - verticalAlignment: Text.AlignVCenter - } - - component IconButton: Button { - id: iconButton - property alias iconLocation: iconButton.icon.source - padding: 0 - display: AbstractButton.IconOnly - height: 21 - width: 21 - icon.color: Theme.color.neutral9 - icon.height: 21 - icon.width: 21 - background: null - } - - component NetStatIndicator: Button { - width: 21 - height: 21 - background: Rectangle { - width: 8 - height: 8 - radius: 4 - anchors.centerIn: parent - color: Theme.color.green + CoreText { + objectName: parent.objectName + "Title" + width: parent.width + leftPadding: root.compact ? 4 : 8 + text: parent.title + font: Theme.text.subheading.font + lineHeight: Theme.text.subheading.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral8 + horizontalAlignment: Text.AlignLeft } - } - - component NetStatValue: Row { - property alias text: valText.text - spacing: 0 - NetStatIndicator {} - ValText { - id: valText - anchors.verticalCenter: parent.verticalCenter + PeerTable { + width: parent.width + rows: parent.rows } } } diff --git a/qml/pages/node/Peers.qml b/qml/pages/node/Peers.qml index 2397a3fd81..fa113497e4 100644 --- a/qml/pages/node/Peers.qml +++ b/qml/pages/node/Peers.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2023 The Bitcoin Core developers +// Copyright (c) 2023-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -10,15 +10,54 @@ import "../../controls" import "../../components" Page { + id: root + objectName: "peersList" + signal back signal peerSelected(PeerDetailsModel peerDetails) - signal bannedPeers - id: root - objectName: "peers" - background: null - property bool showHeader: true + property bool compact: width <= SizeClass.compactWidthMax + property bool showHeader: false property bool showBackButton: true + property Item popupParent: null + property int selectedNodeId: -1 + property var contextPeerDetails: null + property int pendingContextBanDuration: 3600 + property string pendingContextBanLabel: qsTr("1 hour") + + readonly property var directionOptions: [ + { text: qsTr("All"), value: "" }, + { text: qsTr("Inbound"), value: "inbound" }, + { text: qsTr("Outbound"), value: "outbound" } + ] + readonly property var connectionTypeOptions: [ + { text: qsTr("All"), value: "" }, + { text: qsTr("Full relay"), value: "full-relay" }, + { text: qsTr("Block relay"), value: "block-relay" }, + { text: qsTr("Manual"), value: "manual" } + ] + readonly property var networkOptions: [ + { text: qsTr("All"), value: "" }, + { text: qsTr("IPv4"), value: "ipv4" }, + { text: qsTr("IPv6"), value: "ipv6" }, + { text: qsTr("Onion"), value: "onion" }, + { text: qsTr("I2P"), value: "i2p" } + ] + readonly property var transportOptions: [ + { text: qsTr("All"), value: "" }, + { text: qsTr("v1"), value: "v1" }, + { text: qsTr("v2"), value: "v2" } + ] + readonly property var sortOptions: [ + { text: qsTr("Peer ID"), value: "nodeId" }, + { text: qsTr("Address"), value: "address" }, + { text: qsTr("Connection type"), value: "connectionType" }, + { text: qsTr("User agent"), value: "subversion" }, + { text: qsTr("Sent"), value: "sent" }, + { text: qsTr("Received"), value: "received" } + ] + + background: Rectangle { color: Theme.color.neutral0 } header: NavigationBar2 { visible: root.showHeader @@ -39,31 +78,150 @@ Page { AppSettings { id: settings property string peerListSortBy: "nodeId" + property bool peerListSortAscending: true + property string peerDirectionFilters: "" + property string peerConnectionTypeFilters: "" + property string peerNetworkFilters: "" + property string peerTransportFilters: "" + } + + function decodeFilter(value, options) { + if (value.length === 0) return [] + const candidate = value.split(",")[0] + for (let i = 0; i < options.length; ++i) { + if (options[i].value === candidate) return candidate.length === 0 ? [] : [candidate] + } + return [] + } + function selectedFilter(filters) { return filters.length > 0 ? filters[0] : "" } + function validSort(value) { + for (let i = 0; i < sortOptions.length; ++i) { + if (sortOptions[i].value === value) return value + } + return "nodeId" + } + function setFilter(group, value) { + const values = value.length === 0 ? [] : [value] + if (group === "direction") peerListModelProxy.directionFilters = values + else if (group === "connectionType") peerListModelProxy.connectionTypeFilters = values + else if (group === "network") peerListModelProxy.networkFilters = values + else if (group === "transport") peerListModelProxy.transportFilters = values + storeFilters() + } + function storeFilters() { + settings.peerDirectionFilters = peerListModelProxy.directionFilters.join(",") + settings.peerConnectionTypeFilters = peerListModelProxy.connectionTypeFilters.join(",") + settings.peerNetworkFilters = peerListModelProxy.networkFilters.join(",") + settings.peerTransportFilters = peerListModelProxy.transportFilters.join(",") + } + function filterCount() { + return peerListModelProxy.directionFilters.length + + peerListModelProxy.connectionTypeFilters.length + + peerListModelProxy.networkFilters.length + + peerListModelProxy.transportFilters.length + } + function joinedDetails(values) { + const present = [] + for (let i = 0; i < values.length; ++i) { + if (values[i] !== undefined && values[i] !== null && String(values[i]).length > 0) present.push(values[i]) + } + return present.join(" · ") + } + function openPeerActionsMenu(peerDetails, sourceItem, localPosition) { + if (!peerDetails) return + contextPeerDetails = peerDetails + const position = sourceItem.mapToItem( + root, + localPosition.x, + localPosition.y) + peerRowActionsMenu.x = Math.max(12, Math.min( + position.x, + root.width - peerRowActionsMenu.width - 12)) + peerRowActionsMenu.y = Math.max(12, Math.min( + position.y, + root.height - peerRowActionsMenu.height - 12)) + peerRowActionsMenu.open() + } + function requestContextPeerBan(duration, label) { + pendingContextBanDuration = duration + pendingContextBanLabel = label + peerListBanConfirmation.open() + } + function confirmContextPeerBan() { + if (contextPeerDetails + && nodeModel.banPeer(contextPeerDetails.rawAddress, pendingContextBanDuration)) { + peerTableModel.refresh() + banListModel.refresh() + } else { + showPeerListActionError(qsTr("Could not ban peer. The peer may already be disconnected or the node state may have changed.")) + } + } + function disconnectContextPeer() { + if (contextPeerDetails && nodeModel.disconnectPeer(contextPeerDetails.nodeId)) { + peerTableModel.refresh() + } else { + showPeerListActionError(qsTr("Could not disconnect peer. The peer may already be disconnected or the node state may have changed.")) + } + } + function requestContextPeerDisconnect() { + peerListDisconnectConfirmation.open() + } + function showPeerListActionError(message) { + peerListActionError.message = message + peerListActionError.open() } Component.onCompleted: { - peerListModelProxy.sortBy = settings.peerListSortBy + peerListModelProxy.searchText = "" + peerListModelProxy.sortAscending = settings.peerListSortAscending + peerListModelProxy.sortBy = validSort(settings.peerListSortBy) + settings.peerListSortBy = peerListModelProxy.sortBy + peerListModelProxy.directionFilters = decodeFilter(settings.peerDirectionFilters, directionOptions) + peerListModelProxy.connectionTypeFilters = decodeFilter(settings.peerConnectionTypeFilters, connectionTypeOptions) + peerListModelProxy.networkFilters = decodeFilter(settings.peerNetworkFilters, networkOptions) + peerListModelProxy.transportFilters = decodeFilter(settings.peerTransportFilters, transportOptions) + storeFilters() } - ListView { - id: listView - clip: true - width: Math.min(parent.width - 40, 450) - height: parent.height - anchors.horizontalCenter: parent.horizontalCenter - model: peerListModelProxy - spacing: 15 + Item { + objectName: "peersContentFrame" + anchors.fill: parent + anchors.leftMargin: root.compact ? 14 : 24 + anchors.rightMargin: root.compact ? 14 : 24 + anchors.topMargin: root.compact ? 16 : 28 + anchors.bottomMargin: root.compact ? 16 : 24 + + ColumnLayout { + anchors.fill: parent + spacing: 16 - header: ColumnLayout { - spacing: 20 - width: parent.width - CoreText { - id: description + RowLayout { Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - text: qsTr("Peers are nodes you exchange data with.") - font.pixelSize: 13 - color: Theme.color.neutral7 + spacing: 12 + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + CoreText { + Layout.fillWidth: true + text: qsTr("Peers") + font: Theme.text.headline.font + lineHeight: Theme.text.headline.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + color: Theme.color.neutral9 + } + CoreText { + objectName: "peersDescriptionLabel" + Layout.fillWidth: true + text: qsTr("Peers are nodes you exchange transaction data with.") + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + color: Theme.color.neutral6 + wrapMode: Text.WordWrap + } + } } InfoBanner { @@ -73,252 +231,455 @@ Page { iconSource: "image://images/network-light" title: qsTr("No network connection") message: qsTr("Peer connections will resume when your device is back online.") + contentMargin: 16 bannerLayout: InfoBanner.Layout.Horizontal } - Flickable { - id: sortSelection + RowLayout { Layout.fillWidth: true - Layout.bottomMargin: 30 - Layout.alignment: Qt.AlignHCenter - height: toggleButtons.height - contentWidth: toggleButtons.width - boundsMovement: width == toggleButtons.width ? - Flickable.StopAtBound : Flickable.FollowBoundsBehavior - RowLayout { - id: toggleButtons - spacing: 10 - ToggleButton { - text: qsTr("ID") - autoExclusive: true - checked: settings.peerListSortBy === "nodeId" - onClicked: { - peerListModelProxy.sortBy = "nodeId" - settings.peerListSortBy = "nodeId" - } + spacing: 10 + TextField { + id: searchField + objectName: "peerSearchField" + Layout.fillWidth: true + implicitHeight: 40 + leftPadding: 42 + rightPadding: 14 + placeholderText: qsTr("Search peers") + placeholderTextColor: Theme.color.neutral5 + color: Theme.color.neutral9 + font: Theme.text.description.font + selectByMouse: true + activeFocusOnTab: true + KeyNavigation.tab: filterButton + KeyNavigation.priority: KeyNavigation.BeforeItem + onTextChanged: peerListModelProxy.searchText = text + background: Rectangle { + radius: 8 + color: Theme.color.neutral1 + border.width: 0 + } + Icon { + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + source: "image://images/search" + color: Theme.color.neutral6 + size: 18 } - ToggleButton { - text: qsTr("Direction") - autoExclusive: true - checked: settings.peerListSortBy === "direction" - onClicked: { - peerListModelProxy.sortBy = "direction" - settings.peerListSortBy = "direction" + } + + Button { + id: filterButton + objectName: "peerFilterButton" + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + hoverEnabled: AppMode.isDesktop + focusPolicy: Qt.StrongFocus + KeyNavigation.tab: sortButton + KeyNavigation.backtab: searchField + KeyNavigation.priority: KeyNavigation.BeforeItem + Accessible.name: root.filterCount() > 0 + ? qsTr("Filter peers, %1 active").arg(root.filterCount()) : qsTr("Filter peers") + background: Rectangle { + radius: 8 + color: filterButton.down ? Theme.color.neutral3 + : filterButton.hovered ? Theme.color.neutral2 : Theme.color.neutral1 + border.width: 0 + FocusBorder { + objectName: "peerFilterButtonFocusBorder" + visible: filterButton.visualFocus + borderRadius: 12 } } - ToggleButton { - text: qsTr("User Agent") - autoExclusive: true - checked: settings.peerListSortBy === "subversion" - onClicked: { - peerListModelProxy.sortBy = "subversion" - settings.peerListSortBy = "subversion" + contentItem: Item { + Canvas { + anchors.centerIn: parent + width: 20; height: 18 + onPaint: { + const context = getContext("2d") + context.clearRect(0, 0, width, height) + context.strokeStyle = Theme.color.neutral8 + context.lineWidth = 2 + context.lineCap = "round" + context.beginPath() + context.moveTo(2, 3); context.lineTo(18, 3) + context.moveTo(5, 9); context.lineTo(15, 9) + context.moveTo(8, 15); context.lineTo(12, 15) + context.stroke() + } } } - ToggleButton { - text: qsTr("Type") - autoExclusive: true - checked: settings.peerListSortBy === "connectionType" - onClicked: { - peerListModelProxy.sortBy = "connectionType" - settings.peerListSortBy = "connectionType" + onClicked: filterMenu.open() + + ContextMenu { + id: filterMenu + objectName: "peerFilterMenu" + y: filterButton.height + 6 + x: filterButton.width - width + minMenuWidth: 260 + ContextMenuPicker { + title: qsTr("Direction") + model: root.directionOptions + rowHeight: 32 + currentValue: root.selectedFilter(peerListModelProxy.directionFilters) + onActivated: (value) => root.setFilter("direction", value) + } + ContextMenuDivider { verticalMargin: 3 } + ContextMenuPicker { + title: qsTr("Connection") + model: root.connectionTypeOptions + rowHeight: 32 + currentValue: root.selectedFilter(peerListModelProxy.connectionTypeFilters) + onActivated: (value) => root.setFilter("connectionType", value) + } + ContextMenuDivider { verticalMargin: 3 } + ContextMenuPicker { + title: qsTr("Network") + model: root.networkOptions + rowHeight: 32 + currentValue: root.selectedFilter(peerListModelProxy.networkFilters) + onActivated: (value) => root.setFilter("network", value) + } + ContextMenuDivider { verticalMargin: 3 } + ContextMenuPicker { + title: qsTr("Transport") + model: root.transportOptions + rowHeight: 32 + currentValue: root.selectedFilter(peerListModelProxy.transportFilters) + onActivated: (value) => root.setFilter("transport", value) } } - ToggleButton { - text: qsTr("Ip") - autoExclusive: true - checked: settings.peerListSortBy === "address" - onClicked: { - peerListModelProxy.sortBy = "address" - settings.peerListSortBy = "address" + } + + Button { + id: sortButton + objectName: "peerSortButton" + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + hoverEnabled: AppMode.isDesktop + focusPolicy: Qt.StrongFocus + KeyNavigation.tab: listView.count > 0 ? listView.itemAtIndex(0) : null + KeyNavigation.backtab: filterButton + KeyNavigation.priority: KeyNavigation.BeforeItem + Accessible.name: qsTr("Sort peers") + background: Rectangle { + radius: 8 + color: sortButton.down ? Theme.color.neutral3 + : sortButton.hovered ? Theme.color.neutral2 : Theme.color.neutral1 + border.width: 0 + FocusBorder { + objectName: "peerSortButtonFocusBorder" + visible: sortButton.visualFocus + borderRadius: 12 } } - ToggleButton { - text: qsTr("Network") - autoExclusive: true - checked: settings.peerListSortBy === "network" - onClicked: { - peerListModelProxy.sortBy = "network" - settings.peerListSortBy = "network" + contentItem: Icon { + source: "image://images/flip-vertical" + color: Theme.color.neutral8 + size: 20 + } + onClicked: sortMenu.open() + ContextMenu { + id: sortMenu + objectName: "peerSortMenu" + y: sortButton.height + 6 + x: sortButton.width - width + minMenuWidth: 250 + ContextMenuPicker { + title: qsTr("Sort by") + model: root.sortOptions + currentValue: peerListModelProxy.sortBy + onActivated: (value) => { + peerListModelProxy.sortBy = value + settings.peerListSortBy = value + } + } + ContextMenuDivider { } + ContextMenuPicker { + model: [ + { text: qsTr("Ascending"), value: true }, + { text: qsTr("Descending"), value: false } + ] + currentValue: peerListModelProxy.sortAscending + onActivated: (value) => { + peerListModelProxy.sortAscending = value + settings.peerListSortAscending = value + } } } } } - } - footer: Item { - width: listView.width - height: footerColumn.height + 10 + Rectangle { + objectName: "peerListCard" + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 120 + color: Theme.color.neutral1 + radius: 12 + border.width: 0 + clip: true - ColumnLayout { - id: footerColumn - width: parent.width - spacing: 0 + ListView { + id: listView + objectName: "peerListView" + anchors.fill: parent + clip: true + boundsBehavior: Flickable.StopAtBounds + model: peerListModelProxy + delegate: ItemDelegate { + id: delegate + required property int index + objectName: "peerListItem_" + nodeId + required property int nodeId + required property string address + required property string subversion + required property string direction + required property string connectionType + required property string network + required property string transport + required property string sent + required property string received + width: listView.width + height: 112 + leftPadding: 12; rightPadding: 12; topPadding: 12; bottomPadding: 12 + hoverEnabled: AppMode.isDesktop + focusPolicy: Qt.StrongFocus + KeyNavigation.backtab: delegate.index === 0 + ? sortButton : listView.itemAtIndex(delegate.index - 1) + KeyNavigation.priority: KeyNavigation.BeforeItem + onClicked: root.peerSelected(peerListModelProxy.peerDetailsAt(index)) + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: (eventPoint) => root.openPeerActionsMenu( + peerListModelProxy.peerDetailsAt(delegate.index), + delegate, + eventPoint.position) + } + background: Item { + Rectangle { + anchors.fill: parent + anchors.margins: 4 + radius: 16 + color: delegate.down ? Theme.color.neutral3 + : ((!root.compact && root.selectedNodeId === delegate.nodeId) || delegate.hovered) + ? Theme.color.neutral2 : "transparent" + } + Rectangle { + anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom + anchors.leftMargin: 12; anchors.rightMargin: 12 + height: 1 + visible: delegate.index < peerListModelProxy.count - 1 + color: Theme.color.neutral2 + } + FocusBorder { + objectName: delegate.objectName + "FocusBorder" + visible: delegate.visualFocus + borderRadius: 18 + topMargin: 2 + bottomMargin: 2 + leftMargin: 2 + rightMargin: 2 + } + } + contentItem: RowLayout { + spacing: 12 + Rectangle { + Layout.alignment: Qt.AlignTop + Layout.preferredWidth: Math.max(42, idText.implicitWidth + 16) + Layout.preferredHeight: 32 + radius: 8 + color: Theme.color.neutral2 + CoreText { + id: idText + anchors.centerIn: parent + text: "#" + delegate.nodeId + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral8 + wrap: false + } + } + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 2 + CoreText { + Layout.fillWidth: true + text: delegate.address + font: Theme.text.monoDescription.font + lineHeight: Theme.text.monoDescription.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + color: Theme.color.neutral9 + elide: Text.ElideMiddle + wrap: false + } + CoreText { + Layout.fillWidth: true + text: root.joinedDetails([delegate.direction, + delegate.connectionType === delegate.direction ? "" : delegate.connectionType, + delegate.network, delegate.transport]) + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + color: Theme.color.neutral7 + elide: Text.ElideRight + wrap: false + } + CoreText { + Layout.fillWidth: true + text: delegate.subversion + font: Theme.text.monoCaption.font + lineHeight: Theme.text.monoCaption.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + color: Theme.color.neutral6 + elide: Text.ElideRight + wrap: false + } + RowLayout { + Layout.fillWidth: true + spacing: 12 - Loader { - Layout.fillWidth: true - height: 75 - active: nodeModel.numOutboundPeers < nodeModel.maxNumOutboundPeers - visible: active - sourceComponent: Item { - width: parent.width - height: 75 - RowLayout { - anchors.centerIn: parent - spacing: 20 - PeersIndicator { - paused: false - numOutboundPeers: nodeModel.numOutboundPeers - maxNumOutboundPeers: nodeModel.maxNumOutboundPeers + CoreText { + objectName: delegate.objectName + "Sent" + text: "↑ " + delegate.sent + font: Theme.text.monoCaption.font + lineHeight: Theme.text.monoCaption.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.purple + wrap: false + } + CoreText { + objectName: delegate.objectName + "Received" + text: "↓ " + delegate.received + font: Theme.text.monoCaption.font + lineHeight: Theme.text.monoCaption.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.blue + wrap: false + } + Item { + Layout.fillWidth: true + } + } } - CoreText { - text: qsTr("Looking for %1 more peer(s)").arg( - nodeModel.maxNumOutboundPeers - nodeModel.numOutboundPeers) - font.pixelSize: 15 - color: Theme.color.neutral7 + CaretRightIcon { + Layout.alignment: Qt.AlignVCenter + color: Theme.color.neutral6 + size: 12 } } } } + CoreText { + anchors.centerIn: parent + visible: peerListModelProxy.count === 0 + text: searchField.text.length > 0 || root.filterCount() > 0 + ? qsTr("No peers match your filters") : qsTr("No peers connected") + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral6 + } + } + + Column { + Layout.fillWidth: true + spacing: 4 + + CoreText { + objectName: "connectedPeersLabel" + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("%1 connected").arg(nodeModel.numPeers) + font: Theme.text.caption.font + lineHeight: Theme.text.caption.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignHCenter + color: Theme.color.neutral6 + } TextButton { objectName: "viewBannedPeersButton" - Layout.alignment: Qt.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter visible: banListModel.count > 0 - text: qsTr("View %1 banned %2").arg(banListModel.count).arg(banListModel.count === 1 ? qsTr("peer") : qsTr("peers")) + text: qsTr("View %1 banned %2").arg(banListModel.count).arg( + banListModel.count === 1 ? qsTr("peer") : qsTr("peers")) textSize: 13 bold: false - onClicked: root.bannedPeers() + onClicked: bannedPeersPopup.open() } } } + } - delegate: ItemDelegate { - id: delegate - objectName: "peerListItem_" + nodeId - required property int nodeId; - required property string address; - required property string subversion; - required property string direction; - required property string connectionType; - required property string network; - required property PeerDetailsModel stats; - readonly property color stateColor: { - if (delegate.down) { - return Theme.color.orange - } else if (delegate.hovered) { - return Theme.color.orangeLight1 - } - return Theme.color.neutral9 - } - Connections { - target: peerListModelProxy - function onSortByChanged(roleName) { - setTextByRole(roleName) - } - function onDataChanged(startIndex, endIndex) { - setTextByRole(peerListModelProxy.sortBy) - } - } + PeerActionsMenu { + id: peerRowActionsMenu + objectName: "peerRowActionsMenu" + onBanRequested: (duration, label) => root.requestContextPeerBan(duration, label) + onDisconnectRequested: root.requestContextPeerDisconnect() + } - Component.onCompleted: { - setTextByRole(peerListModelProxy.sortBy) - } + AlertPopup { + id: peerListBanConfirmation + objectName: "peerListBanConfirmationPopup" + parent: root.popupParent ? root.popupParent : root + title: qsTr("Ban peer?") + message: root.contextPeerDetails + ? qsTr("Ban %1 for %2?").arg(root.contextPeerDetails.address).arg(root.pendingContextBanLabel) + : qsTr("Ban this peer for %1?").arg(root.pendingContextBanLabel) + AlertAction { + text: qsTr("Cancel") + role: AlertAction.Cancel + buttonObjectName: "peerListBanCancelButton" + } + AlertAction { + text: qsTr("Ban") + role: AlertAction.Destructive + buttonObjectName: "peerListBanConfirmButton" + onTriggered: root.confirmContextPeerBan() + } + } - function setTextByRole(roleName) { - if (roleName == "nodeId") { - primary.text = "#" + nodeId - secondary.text = direction - tertiary.text = address - quaternary.text = subversion - } else if (roleName == "direction") { - primary.text = direction - secondary.text = "#" + nodeId - tertiary.text = address - quaternary.text = subversion - } else if (roleName == "subversion") { - primary.text = subversion - secondary.text = "#" + nodeId - tertiary.text = address - quaternary.text = direction - } else if (roleName == "address") { - primary.text = address - secondary.text = direction - tertiary.text = "#" + nodeId - quaternary.text = subversion - } else if (roleName == "connectionType") { - primary.text = connectionType - secondary.text = direction - tertiary.text = address - quaternary.text = subversion - } else if (roleName == "network") { - primary.text = network - secondary.text = direction - tertiary.text = address - quaternary.text = subversion - } else { - primary.text = "#" + nodeId - secondary.text = direction - tertiary.text = address - quaternary.text = subversion - } - } - leftPadding: 0 - rightPadding: 0 - topPadding: 0 - bottomPadding: 14 - width: listView.width - background: Item { - Separator { - anchors.bottom: parent.bottom - width: parent.width - } - } - onClicked: { - root.peerSelected(stats) - } - contentItem: ColumnLayout { - RowLayout { - Layout.fillWidth: true - spacing: 15 - CoreText { - Layout.alignment: Qt.AlignLeft - Layout.fillWidth: true - Layout.preferredWidth: 0 - id: primary - font.pixelSize: 18 - color: delegate.stateColor - elide: Text.ElideMiddle - wrapMode: Text.NoWrap - horizontalAlignment: Text.AlignLeft - } - CoreText { - Layout.alignment: Qt.AlignRight - id: secondary - font.pixelSize: 18 - color: delegate.stateColor - } - } - RowLayout { - CoreText { - Layout.alignment: Qt.AlignLeft - Layout.fillWidth: true - Layout.preferredWidth: 0 - id: tertiary - font.pixelSize: 15 - color: Theme.color.neutral7 - elide: Text.ElideMiddle - wrapMode: Text.NoWrap - horizontalAlignment: Text.AlignLeft - } - CoreText { - Layout.alignment: Qt.AlignRight - id: quaternary - font.pixelSize: 15 - color: Theme.color.neutral7 - } - } - } + AlertPopup { + id: peerListActionError + objectName: "peerListActionErrorPopup" + parent: root.popupParent ? root.popupParent : root + title: qsTr("Peer action failed") + messageObjectName: "peerListActionErrorMessage" + AlertAction { + text: qsTr("OK") + buttonObjectName: "peerListActionErrorCloseButton" } } + + AlertPopup { + id: peerListDisconnectConfirmation + objectName: "peerListDisconnectConfirmationPopup" + parent: root.popupParent ? root.popupParent : root + title: qsTr("Disconnect peer?") + message: root.contextPeerDetails + ? qsTr("Disconnect from %1? The peer may reconnect automatically.").arg(root.contextPeerDetails.address) + : qsTr("Disconnect this peer? The peer may reconnect automatically.") + AlertAction { + text: qsTr("Cancel") + role: AlertAction.Cancel + buttonObjectName: "peerListDisconnectCancelButton" + } + AlertAction { + text: qsTr("Disconnect") + role: AlertAction.Destructive + buttonObjectName: "peerListDisconnectConfirmButton" + onTriggered: root.disconnectContextPeer() + } + } + + BannedPeersPopup { + id: bannedPeersPopup + parent: root.popupParent ? root.popupParent : Overlay.overlay + } } diff --git a/qml/pages/node/PeersView.qml b/qml/pages/node/PeersView.qml new file mode 100644 index 0000000000..fdb2bc772a --- /dev/null +++ b/qml/pages/node/PeersView.qml @@ -0,0 +1,119 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import org.bitcoincore.qt 1.0 +import "../../controls" + +Page { + id: root + objectName: "peers" + + signal back + + property bool showHeader: true + property bool showBackButton: true + property int selectedNodeId: -1 + property PeerDetailsModel selectedDetails + + background: Rectangle { color: Theme.color.neutral0 } + + header: NavigationBar2 { + visible: root.showHeader + leftItem: NavButton { + objectName: "peersBackButton" + visible: root.showBackButton + iconSource: "image://images/caret-left" + text: qsTr("Back") + onClicked: root.back() + } + centerItem: Header { headerBold: true; headerSize: 18; header: qsTr("Peers") } + } + + function selectPeer(peerDetails) { + if (!peerDetails) return + selectedDetails = peerDetails + selectedNodeId = peerDetails.nodeId + splitView.showDetail() + } + + function reconcileSelection() { + if (selectedNodeId >= 0 && peerListModelProxy.indexOfNodeId(selectedNodeId) >= 0) return + + selectedNodeId = -1 + selectedDetails = null + if (!splitView.isCompact && peerListModelProxy.count > 0) { + selectPeer(peerListModelProxy.peerDetailsAt(0)) + } else if (splitView.isCompact) { + splitView.showPrimary() + } + } + + Component.onCompleted: Qt.callLater(root.reconcileSelection) + + Connections { + target: peerListModelProxy + function onCountChanged() { Qt.callLater(root.reconcileSelection) } + } + + NavigationSplitView { + id: splitView + objectName: "peersNavigationSplitView" + anchors.fill: parent + primaryMinimumWidth: 320 + primaryPreferredWidth: 430 + primaryMaximumWidth: 498 + primaryWidthRatio: 0.38 + detailMinimumWidth: 280 + separatorColor: Theme.color.neutral2 + + onIsCompactChanged: { + if (!isCompact) Qt.callLater(root.reconcileSelection) + } + + primaryComponent: Component { + Peers { + compact: splitView.isCompact + showHeader: false + popupParent: root + selectedNodeId: root.selectedNodeId + onPeerSelected: (peerDetails) => root.selectPeer(peerDetails) + } + } + + detailComponent: Component { + Item { + PeerDetails { + anchors.fill: parent + visible: root.selectedDetails !== null + compact: splitView.isCompact + popupParent: root + details: root.selectedDetails + onBack: splitView.showPrimary() + onPeerDisconnected: (nodeId) => { + if (nodeId === root.selectedNodeId) { + root.selectedNodeId = -1 + root.selectedDetails = null + if (splitView.isCompact) splitView.showPrimary() + Qt.callLater(root.reconcileSelection) + } + } + } + CoreText { + objectName: "noPeerDetailsLabel" + anchors.centerIn: parent + visible: root.selectedDetails === null + text: nodeModel.numPeers === 0 + ? qsTr("No peers connected") + : peerListModelProxy.count === 0 + ? qsTr("No peers match your search or filters") + : qsTr("Select a peer to view its details") + font: Theme.text.description.font + color: Theme.color.neutral6 + } + } + } + } +} diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml index a32c2f5e31..5a539e3aac 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -310,29 +310,9 @@ Page { showNetworkIndicator: false } } - PageStack { - id: peersStack - initialItem: Peers { - showBackButton: false - onPeerSelected: (peerDetails) => { - peersStack.push(peerDetailsComp, {"details": peerDetails}) - } - onBannedPeers: { - peersStack.push(bannedPeersComp) - } - } - Component { - id: peerDetailsComp - PeerDetails { - onBack: peersStack.pop() - } - } - Component { - id: bannedPeersComp - BannedPeers { - onBack: peersStack.pop() - } - } + PeersView { + showHeader: false + showBackButton: false } Item { Loader { diff --git a/test/functional/qml_test_peers.py b/test/functional/qml_test_peers.py index 12dad9ad46..fe89b0dcdb 100644 --- a/test/functional/qml_test_peers.py +++ b/test/functional/qml_test_peers.py @@ -490,6 +490,8 @@ def test_disconnect_peer(gui, harness, node_id): _open_peer_details(gui, node_id) + gui.click("peerActionsButton") + gui.wait_for_property("peerDisconnectButton", "visible", True) gui.click("peerDisconnectButton") print(" Clicked Disconnect") @@ -501,8 +503,8 @@ def test_disconnect_peer(gui, harness, node_id): peers = harness.rpc_call("getpeerinfo") assert peers == [], f"Expected no peers after disconnect, got: {peers}" print(" PASSED: peer is disconnected") - # PeerDetails.qml automatically calls root.back() on the onDisconnected - # signal, so no manual navigation is needed here. + # PeersView returns compact layouts to the list when the selected peer's + # disconnected signal arrives, so no manual navigation is needed here. def test_ban_peer(gui, harness, node_id, duration_secs, duration_label): @@ -510,11 +512,11 @@ def test_ban_peer(gui, harness, node_id, duration_secs, duration_label): _open_peer_details(gui, node_id) - gui.click("peerBanButton") - gui.wait_for_property(f"banDurationRow_{duration_secs}", "visible", True) - - gui.click(f"banDurationRow_{duration_secs}") + gui.click("peerActionsButton") + gui.wait_for_property(f"peerBanDuration_{duration_secs}", "visible", True) + gui.click(f"peerBanDuration_{duration_secs}") + gui.wait_for_property("banConfirmationPopup", "opened", True) gui.click("banConfirmButton") print(f" Confirmed ban ({duration_label})") @@ -668,9 +670,10 @@ def test_ban_one_of_two_peers(gui, harness): gui.wait_for_property(f"peerListItem_{target_id}", "visible", True, timeout_ms=PEER_LIST_ITEM_VISIBLE_TIMEOUT_MS) _open_peer_details(gui, target_id) - gui.click("peerBanButton") - gui.wait_for_property("banDurationRow_3600", "visible", True) - gui.click("banDurationRow_3600") + gui.click("peerActionsButton") + gui.wait_for_property("peerBanDuration_3600", "visible", True) + gui.click("peerBanDuration_3600") + gui.wait_for_property("banConfirmationPopup", "opened", True) gui.click("banConfirmButton") print(f" Banned peer {target_id} (subnet: 127.0.0.1/32)") diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 8185d5d38e..17c72a33bd 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -88,12 +88,18 @@ class MockPeerDetailsModel : public QObject Q_PROPERTY(QString address MEMBER m_address CONSTANT) Q_PROPERTY(QString addressLocal MEMBER m_address_local CONSTANT) Q_PROPERTY(QString type MEMBER m_type CONSTANT) + Q_PROPERTY(QString network MEMBER m_network CONSTANT) + Q_PROPERTY(QString transport MEMBER m_transport CONSTANT) + Q_PROPERTY(QString sessionId MEMBER m_session_id CONSTANT) Q_PROPERTY(QString permission MEMBER m_permission CONSTANT) Q_PROPERTY(QString version MEMBER m_version CONSTANT) Q_PROPERTY(QString userAgent MEMBER m_user_agent CONSTANT) Q_PROPERTY(QString services MEMBER m_services CONSTANT) Q_PROPERTY(bool transactionRelay MEMBER m_transaction_relay CONSTANT) Q_PROPERTY(bool addressRelay MEMBER m_address_relay CONSTANT) + Q_PROPERTY(bool highBandwidth MEMBER m_high_bandwidth CONSTANT) + Q_PROPERTY(QString addressesProcessed MEMBER m_addresses_processed CONSTANT) + Q_PROPERTY(QString addressesRateLimited MEMBER m_addresses_rate_limited CONSTANT) Q_PROPERTY(QString mappedAS MEMBER m_mapped_as CONSTANT) Q_PROPERTY(QString startingHeight MEMBER m_starting_height CONSTANT) Q_PROPERTY(QString syncedHeaders MEMBER m_synced_headers CONSTANT) @@ -115,12 +121,18 @@ class MockPeerDetailsModel : public QObject QString m_address{QStringLiteral("127.0.0.1:8333")}; QString m_address_local{QStringLiteral("127.0.0.1:18444")}; QString m_type{QStringLiteral("Outbound Full Relay")}; + QString m_network{QStringLiteral("IPv4")}; + QString m_transport{QStringLiteral("v2")}; + QString m_session_id{QStringLiteral("043604a60a54b3f5")}; QString m_permission{QStringLiteral("N/A")}; QString m_version{QStringLiteral("70016")}; QString m_user_agent{QStringLiteral("/Satoshi:test/")}; QString m_services{QStringLiteral("NETWORK|WITNESS")}; bool m_transaction_relay{true}; bool m_address_relay{false}; + bool m_high_bandwidth{true}; + QString m_addresses_processed{QStringLiteral("1076")}; + QString m_addresses_rate_limited{QStringLiteral("0")}; QString m_mapped_as{QStringLiteral("N/A")}; QString m_starting_height{QStringLiteral("100")}; QString m_synced_headers{QStringLiteral("200")}; @@ -2448,8 +2460,27 @@ class MockPeerListModelProxy : public QAbstractListModel { Q_OBJECT Q_PROPERTY(QString sortBy READ sortBy WRITE setSortBy NOTIFY sortByChanged) + Q_PROPERTY(bool sortAscending MEMBER m_sort_ascending NOTIFY sortAscendingChanged) + Q_PROPERTY(QString searchText MEMBER m_search_text NOTIFY searchTextChanged) + Q_PROPERTY(QStringList directionFilters MEMBER m_direction_filters NOTIFY directionFiltersChanged) + Q_PROPERTY(QStringList connectionTypeFilters MEMBER m_connection_type_filters NOTIFY connectionTypeFiltersChanged) + Q_PROPERTY(QStringList networkFilters MEMBER m_network_filters NOTIFY networkFiltersChanged) + Q_PROPERTY(QStringList transportFilters MEMBER m_transport_filters NOTIFY transportFiltersChanged) + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) public: + enum Roles { + NodeIdRole = Qt::UserRole, + AddressRole, + SubversionRole, + DirectionRole, + ConnectionTypeRole, + NetworkRole, + TransportRole, + SentRole, + ReceivedRole, + }; + QString sortBy() const { return m_sort_by; } void setSortBy(const QString& value) { @@ -2461,22 +2492,84 @@ class MockPeerListModelProxy : public QAbstractListModel int rowCount(const QModelIndex& parent = QModelIndex{}) const override { Q_UNUSED(parent); - return 0; + return m_peer_count; } QVariant data(const QModelIndex& index, int role) const override { - Q_UNUSED(index); - Q_UNUSED(role); - return {}; + if (!index.isValid() || index.row() < 0 || index.row() >= m_peer_count) return {}; + switch (role) { + case NodeIdRole: return index.row(); + case AddressRole: return QStringLiteral("127.0.0.1:8333"); + case SubversionRole: return QStringLiteral("/Satoshi:test/"); + case DirectionRole: return QStringLiteral("Outbound"); + case ConnectionTypeRole: return QStringLiteral("Full relay"); + case NetworkRole: return QStringLiteral("IPv4"); + case TransportRole: return QStringLiteral("v2"); + case SentRole: return QStringLiteral("1 kB"); + case ReceivedRole: return QStringLiteral("2 kB"); + default: return {}; + } + } + + QHash roleNames() const override + { + return { + {NodeIdRole, "nodeId"}, + {AddressRole, "address"}, + {SubversionRole, "subversion"}, + {DirectionRole, "direction"}, + {ConnectionTypeRole, "connectionType"}, + {NetworkRole, "network"}, + {TransportRole, "transport"}, + {SentRole, "sent"}, + {ReceivedRole, "received"}, + }; + } + + Q_INVOKABLE void setPeerCountForTest(int count) + { + if (m_peer_count == count) return; + beginResetModel(); + m_peer_count = std::max(0, count); + endResetModel(); + Q_EMIT countChanged(); + } + + Q_INVOKABLE QObject* peerDetailsAt(int row) const + { + return row >= 0 && row < m_peer_count ? m_peer_details : nullptr; + } + + void setPeerDetailsForTest(QObject* peer_details) { m_peer_details = peer_details; } + + Q_INVOKABLE int indexOfNodeId(qint64 node_id) const + { + Q_UNUSED(node_id); + return -1; } Q_SIGNALS: void sortByChanged(const QString& roleName); + void sortAscendingChanged(); + void searchTextChanged(); + void directionFiltersChanged(); + void connectionTypeFiltersChanged(); + void networkFiltersChanged(); + void transportFiltersChanged(); + void countChanged(); void dataChanged(int startIndex, int endIndex); private: + int m_peer_count{0}; + QObject* m_peer_details{nullptr}; QString m_sort_by{QStringLiteral("nodeId")}; + bool m_sort_ascending{true}; + QString m_search_text; + QStringList m_direction_filters; + QStringList m_connection_type_filters; + QStringList m_network_filters; + QStringList m_transport_filters; }; class MockBanListModel : public QAbstractListModel @@ -3397,6 +3490,8 @@ public Q_SLOTS: static MockPeerListModelProxy peer_list_model_proxy; static MockBanListModel ban_list_model; static MockPeerDetailsModel peer_details_model; + QQmlEngine::setObjectOwnership(&peer_details_model, QQmlEngine::CppOwnership); + peer_list_model_proxy.setPeerDetailsForTest(&peer_details_model); static MockWalletQmlModelTransaction wallet_transaction; static MockPaymentRequest payment_request; static MockSendRecipient send_recipient; diff --git a/test/qml/tst_peeractions.qml b/test/qml/tst_peeractions.qml index 8ee1336df9..9fab4bbb25 100644 --- a/test/qml/tst_peeractions.qml +++ b/test/qml/tst_peeractions.qml @@ -5,6 +5,7 @@ import QtQuick 2.15 import QtQuick.Window 2.15 import QtTest 1.2 +import "../../qml/controls" import "../../qml/pages/node" TestCase { @@ -31,19 +32,19 @@ TestCase { } Component { - id: bannedPeersComponent + id: peersComponent - BannedPeers { + Peers { width: 460 height: 680 } } Component { - id: peersComponent + id: peersViewComponent - Peers { - width: 460 + PeersView { + width: 900 height: 680 } } @@ -53,6 +54,7 @@ TestCase { networkStatusModel.setNetworkOfflineForTest(false) peerTableModel.resetTestState() banListModel.resetTestState() + peerListModelProxy.setPeerCountForTest(0) } function createPeerDetailsPage() { @@ -62,20 +64,41 @@ TestCase { return page } - function createBannedPeersPage() { - const page = createTemporaryObject(bannedPeersComponent, testWindow.contentItem) + function createPeersPage() { + const page = createTemporaryObject(peersComponent, testWindow.contentItem) verify(page !== null) wait(0) return page } - function createPeersPage() { - const page = createTemporaryObject(peersComponent, testWindow.contentItem) + function createPeersViewPage() { + const page = createTemporaryObject(peersViewComponent, testWindow.contentItem) verify(page !== null) wait(0) return page } + function waitForChild(parent, objectName) { + for (let i = 0; i < 20; ++i) { + const child = findChild(parent, objectName) + if (child !== null) return child + wait(25) + } + return null + } + + function openBanConfirmation(page, durationObjectName) { + const duration = findChild(page, durationObjectName) + const confirmation = findChild(page, "banConfirmationPopup") + verify(duration !== null) + verify(confirmation !== null) + + duration.clicked() + tryCompare(confirmation, "opened", true) + verify(waitForChild(testWindow.contentItem, "banConfirmButton") !== null) + return confirmation + } + function verifyPeerActionError(page, expectedText) { const popup = findChild(page, "peerActionErrorPopup") verify(popup !== null) @@ -94,20 +117,270 @@ TestCase { verify(message.text.indexOf("Could not unban peer.") >= 0) } + function createPeersPageWithBannedPopup() { + const page = createPeersPage() + const openButton = findChild(page, "viewBannedPeersButton") + const popup = findChild(page, "bannedPeersPopup") + verify(openButton !== null) + verify(popup !== null) + openButton.clicked() + tryCompare(popup, "opened", true) + return page + } + function test_disconnect_success_refreshes_peer_table_without_error() { const page = createPeerDetailsPage() const button = findChild(page, "peerDisconnectButton") + const confirmation = findChild(page, "disconnectConfirmationPopup") const popup = findChild(page, "peerActionErrorPopup") verify(button !== null) + verify(confirmation !== null) verify(popup !== null) button.clicked() + tryCompare(confirmation, "opened", true) + compare(nodeModel.disconnectPeerCalls, 0) + const confirm = waitForChild(testWindow.contentItem, "disconnectConfirmButton") + verify(confirm !== null) + confirm.clicked() compare(nodeModel.disconnectPeerCalls, 1) compare(peerTableModel.refreshCalls, 1) compare(popup.opened, false) } + function test_peer_details_uses_segmented_sections_and_ellipsis_actions() { + const page = createPeerDetailsPage() + const contentFrame = findChild(page, "peerDetailsContentFrame") + const sections = findChild(page, "peerDetailsSections") + const blockSection = findChild(page, "peerDetailsSectionsOption_1") + const blocks = findChild(page, "peerBlocksSection") + const addresses = findChild(page, "peerAddressesSection") + const actionsButton = findChild(page, "peerActionsButton") + const actionsMenu = findChild(page, "peerActionsMenu") + verify(sections !== null) + verify(contentFrame !== null) + verify(blockSection !== null) + verify(blocks !== null) + verify(addresses !== null) + verify(actionsButton !== null) + verify(actionsMenu !== null) + verify(findChild(page, "peerCopyButton") === null) + + blockSection.clicked() + compare(page.sectionIndex, 1) + compare(blockSection.text, "Relay data") + compare(blocks.visible, true) + compare(addresses.visible, true) + compare(page.informationRows[4].label, "Session ID") + compare(page.informationRows[4].value, testPeerDetailsModel.sessionId) + compare(page.blockRelayRows[3].label, "High bandwidth") + compare(page.blockRelayRows[3].value, "Yes") + compare(page.addressRelayRows[0].label, "Address relay") + compare(page.addressRelayRows[0].value, "No") + compare(page.addressRelayRows.length, 1) + + actionsButton.clicked() + tryCompare(actionsMenu, "opened", true) + verify(findChild(actionsMenu, "peerDisconnectButton") !== null) + verify(findChild(actionsMenu, "peerBanDuration_3600") !== null) + verify(findChild(actionsMenu, "peerBanDuration_86400") !== null) + verify(findChild(actionsMenu, "peerBanDuration_604800") !== null) + verify(findChild(actionsMenu, "peerBanDuration_31536000") !== null) + verify(findChild(actionsMenu, "peerBanButton") === null) + + page.width = 1200 + wait(0) + compare(contentFrame.width, 840) + compare(contentFrame.x, 180) + } + + function test_peer_toolbar_tabs_into_list_with_focus_rings() { + peerListModelProxy.setPeerCountForTest(1) + const page = createPeersPage() + const search = findChild(page, "peerSearchField") + const filter = findChild(page, "peerFilterButton") + const sort = findChild(page, "peerSortButton") + const row = waitForChild(page, "peerListItem_0") + const filterRing = findChild(page, "peerFilterButtonFocusBorder") + const sortRing = findChild(page, "peerSortButtonFocusBorder") + const rowRing = waitForChild(page, "peerListItem_0FocusBorder") + verify(search !== null) + verify(filter !== null) + verify(sort !== null) + verify(row !== null) + verify(filterRing !== null) + verify(sortRing !== null) + verify(rowRing !== null) + + search.forceActiveFocus(Qt.TabFocusReason) + keyClick(Qt.Key_Tab) + tryCompare(filter, "activeFocus", true) + compare(filter.visualFocus, true) + tryCompare(filterRing, "visible", true) + + keyClick(Qt.Key_Tab) + tryCompare(sort, "activeFocus", true) + compare(sort.visualFocus, true) + tryCompare(sortRing, "visible", true) + + keyClick(Qt.Key_Tab) + tryCompare(row, "activeFocus", true) + compare(row.visualFocus, true) + tryCompare(rowRing, "visible", true) + } + + function test_peer_row_places_traffic_on_its_own_colored_line() { + peerListModelProxy.setPeerCountForTest(1) + const page = createPeersPage() + const sent = waitForChild(page, "peerListItem_0Sent") + const received = waitForChild(page, "peerListItem_0Received") + verify(sent !== null) + verify(received !== null) + + compare(sent.text, "↑ 1 kB") + compare(received.text, "↓ 2 kB") + compare(sent.color, Theme.color.purple) + compare(received.color, Theme.color.blue) + compare(Math.round(sent.y), Math.round(received.y)) + verify(received.x > sent.x + sent.width) + } + + function test_peers_heading_description_and_regular_padding_are_stable() { + const page = createPeersPage() + const contentFrame = findChild(page, "peersContentFrame") + const description = findChild(page, "peersDescriptionLabel") + const listCard = findChild(page, "peerListCard") + const connected = findChild(page, "connectedPeersLabel") + const banned = findChild(page, "viewBannedPeersButton") + verify(contentFrame !== null) + verify(description !== null) + verify(listCard !== null) + verify(connected !== null) + verify(banned !== null) + compare(description.text, "Peers are nodes you exchange transaction data with.") + const listBottom = listCard.mapToItem(page, 0, listCard.height) + const connectedTop = connected.mapToItem(page, 0, 0) + const bannedTop = banned.mapToItem(page, 0, 0) + const connectedCenter = connected.mapToItem(page, connected.width / 2, 0) + const bannedCenter = banned.mapToItem(page, banned.width / 2, 0) + verify(connectedTop.y >= listBottom.y) + verify(bannedTop.y > connectedTop.y) + compare(Math.round(connectedCenter.x), Math.round(page.width / 2)) + compare(Math.round(bannedCenter.x), Math.round(page.width / 2)) + + page.compact = false + page.width = 449 + wait(0) + compare(contentFrame.x, 24) + compare(contentFrame.width, 401) + + page.width = 450 + wait(0) + compare(contentFrame.x, 24) + compare(contentFrame.width, 402) + } + + function test_peer_detail_options_button_has_keyboard_focus_ring() { + const page = createPeerDetailsPage() + const button = findChild(page, "peerActionsButton") + const ring = findChild(page, "peerActionsButtonFocusBorder") + verify(button !== null) + verify(ring !== null) + + button.forceActiveFocus(Qt.TabFocusReason) + tryCompare(button, "visualFocus", true) + tryCompare(ring, "visible", true) + } + + function test_right_click_peer_row_opens_shared_actions_menu() { + peerListModelProxy.setPeerCountForTest(1) + const page = createPeersPage() + const row = waitForChild(page, "peerListItem_0") + const menu = findChild(page, "peerRowActionsMenu") + verify(row !== null) + verify(menu !== null) + + mouseClick(row, row.width / 2, row.height / 2, Qt.RightButton) + + tryCompare(menu, "opened", true) + compare(page.contextPeerDetails, testPeerDetailsModel) + verify(findChild(menu, "peerBanDuration_3600") !== null) + verify(findChild(menu, "peerBanDuration_31536000") !== null) + const disconnect = findChild(menu, "peerDisconnectButton") + verify(disconnect !== null) + disconnect.clicked() + const confirmation = findChild(page, "peerListDisconnectConfirmationPopup") + verify(confirmation !== null) + tryCompare(confirmation, "opened", true) + compare(nodeModel.disconnectPeerCalls, 0) + const confirm = waitForChild(testWindow.contentItem, "peerListDisconnectConfirmButton") + verify(confirm !== null) + confirm.clicked() + compare(nodeModel.disconnectPeerCalls, 1) + compare(peerTableModel.refreshCalls, 1) + tryCompare(confirmation, "opened", false) + wait(300) + } + + function test_right_click_peer_row_uses_ban_confirmation() { + peerListModelProxy.setPeerCountForTest(1) + const page = createPeersPage() + const row = waitForChild(page, "peerListItem_0") + const menu = findChild(page, "peerRowActionsMenu") + const confirmation = findChild(page, "peerListBanConfirmationPopup") + verify(row !== null) + verify(menu !== null) + verify(confirmation !== null) + + mouseClick(row, row.width / 2, row.height / 2, Qt.RightButton) + tryCompare(menu, "opened", true) + findChild(menu, "peerBanDuration_3600").clicked() + tryCompare(confirmation, "opened", true) + const confirm = waitForChild(testWindow.contentItem, "peerListBanConfirmButton") + verify(confirm !== null) + confirm.clicked() + + compare(nodeModel.banPeerCalls, 1) + compare(peerTableModel.refreshCalls, 1) + compare(banListModel.refreshCalls, 1) + tryCompare(confirmation, "opened", false) + wait(300) + } + + function test_split_view_alert_is_centered_in_peers_container() { + peerListModelProxy.setPeerCountForTest(1) + const page = createPeersViewPage() + const details = findChild(page, "peerDetails") + verify(details !== null) + tryCompare(details, "visible", true) + const banDuration = findChild(details, "peerBanDuration_3600") + const confirmation = findChild(details, "banConfirmationPopup") + verify(banDuration !== null) + verify(confirmation !== null) + + banDuration.clicked() + + tryCompare(confirmation, "opened", true) + compare(confirmation.parent, page) + compare(confirmation.x, Math.round((page.width - confirmation.width) / 2)) + compare(confirmation.y, Math.round((page.height - confirmation.height) / 2)) + confirmation.close() + tryCompare(confirmation, "opened", false) + wait(300) + } + + function test_empty_wide_view_hides_details_and_explains_empty_state() { + const page = createPeersViewPage() + const details = findChild(page, "peerDetails") + const emptyLabel = findChild(page, "noPeerDetailsLabel") + verify(details !== null) + verify(emptyLabel !== null) + compare(details.visible, false) + compare(emptyLabel.visible, true) + compare(emptyLabel.text, "No peers connected") + } + function test_peers_offline_banner_follows_network_status() { const page = createPeersPage() const banner = findChild(page, "peersOfflineBanner") @@ -122,9 +395,16 @@ TestCase { nodeModel.disconnectPeerResult = false const page = createPeerDetailsPage() const button = findChild(page, "peerDisconnectButton") + const confirmation = findChild(page, "disconnectConfirmationPopup") verify(button !== null) + verify(confirmation !== null) button.clicked() + tryCompare(confirmation, "opened", true) + compare(nodeModel.disconnectPeerCalls, 0) + const confirm = waitForChild(testWindow.contentItem, "disconnectConfirmButton") + verify(confirm !== null) + confirm.clicked() compare(nodeModel.disconnectPeerCalls, 1) compare(peerTableModel.refreshCalls, 0) @@ -133,7 +413,8 @@ TestCase { function test_ban_success_refreshes_peer_and_ban_lists_without_error() { const page = createPeerDetailsPage() - const button = findChild(page, "banConfirmButton") + const confirmation = openBanConfirmation(page, "peerBanDuration_86400") + const button = findChild(testWindow.contentItem, "banConfirmButton") const popup = findChild(page, "peerActionErrorPopup") verify(button !== null) verify(popup !== null) @@ -141,6 +422,7 @@ TestCase { button.clicked() compare(nodeModel.banPeerCalls, 1) + compare(confirmation.opened, false) compare(peerTableModel.refreshCalls, 1) compare(banListModel.refreshCalls, 1) compare(popup.opened, false) @@ -149,7 +431,8 @@ TestCase { function test_ban_failure_opens_error_popup() { nodeModel.banPeerResult = false const page = createPeerDetailsPage() - const button = findChild(page, "banConfirmButton") + openBanConfirmation(page, "peerBanDuration_3600") + const button = findChild(testWindow.contentItem, "banConfirmButton") verify(button !== null) button.clicked() @@ -161,8 +444,8 @@ TestCase { } function test_unban_success_leaves_refresh_to_the_model_without_error() { - const page = createBannedPeersPage() - const button = findChild(page, "unbanButton_0") + const page = createPeersPageWithBannedPopup() + const button = waitForChild(testWindow.contentItem, "unbanButton_0") const popup = findChild(page, "unbanActionErrorPopup") verify(button !== null) verify(popup !== null) @@ -176,8 +459,8 @@ TestCase { function test_unban_failure_opens_error_popup() { banListModel.unbanResult = false - const page = createBannedPeersPage() - const button = findChild(page, "unbanButton_0") + const page = createPeersPageWithBannedPopup() + const button = waitForChild(testWindow.contentItem, "unbanButton_0") verify(button !== null) button.clicked() @@ -189,8 +472,8 @@ TestCase { function test_unban_survives_synchronous_model_reset() { banListModel.resetOnUnban = true - const page = createBannedPeersPage() - const button = findChild(page, "unbanButton_0") + const page = createPeersPageWithBannedPopup() + const button = waitForChild(testWindow.contentItem, "unbanButton_0") const popup = findChild(page, "unbanActionErrorPopup") verify(button !== null) verify(popup !== null) @@ -205,8 +488,8 @@ TestCase { function test_unban_failure_with_synchronous_model_reset_opens_error_popup() { banListModel.resetOnUnban = true banListModel.unbanResult = false - const page = createBannedPeersPage() - const button = findChild(page, "unbanButton_0") + const page = createPeersPageWithBannedPopup() + const button = waitForChild(testWindow.contentItem, "unbanButton_0") verify(button !== null) button.clicked()