diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 9dcb95c1f8..fd13930473 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -41,6 +41,7 @@ QT_MOC_CPP = \ qml/models/moc_networktraffictower.cpp \ qml/models/moc_nodemodel.cpp \ qml/models/moc_options_model.cpp \ + qml/models/moc_peerdetailsmodel.cpp \ qml/models/moc_peerlistsortproxy.cpp \ qml/moc_appmode.cpp \ qt/moc_addressbookpage.cpp \ @@ -120,6 +121,7 @@ BITCOIN_QT_H = \ qml/models/networktraffictower.h \ qml/models/nodemodel.h \ qml/models/options_model.h \ + qml/models/peerdetailsmodel.h \ qml/models/peerlistsortproxy.h \ qml/appmode.h \ qml/bitcoin.h \ @@ -307,6 +309,7 @@ BITCOIN_QML_BASE_CPP = \ qml/models/networktraffictower.cpp \ qml/models/nodemodel.cpp \ qml/models/options_model.cpp \ + qml/models/peerdetailsmodel.cpp \ qml/models/peerlistsortproxy.cpp \ qml/imageprovider.cpp \ qml/util.cpp @@ -384,6 +387,7 @@ QML_RES_QML = \ qml/pages/node/NodeRunner.qml \ qml/pages/node/NodeSettings.qml \ qml/pages/node/Peers.qml \ + qml/pages/node/PeerDetails.qml \ qml/pages/node/Shutdown.qml \ qml/pages/onboarding/OnboardingBlockclock.qml \ qml/pages/onboarding/OnboardingConnection.qml \ diff --git a/src/qml/bitcoin.cpp b/src/qml/bitcoin.cpp index 10e8a959d0..ba5f95790c 100644 --- a/src/qml/bitcoin.cpp +++ b/src/qml/bitcoin.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -292,6 +293,8 @@ int QmlGuiMain(int argc, char* argv[]) qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "AppMode", &app_mode); qmlRegisterType("org.bitcoincore.qt", 1, 0, "BlockClockDial"); qmlRegisterType("org.bitcoincore.qt", 1, 0, "LineGraph"); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "PeerDetailsModel", ""); + engine.load(QUrl(QStringLiteral("qrc:///qml/pages/main.qml"))); if (engine.rootObjects().isEmpty()) { diff --git a/src/qml/bitcoin_qml.qrc b/src/qml/bitcoin_qml.qrc index 591615b1ca..317b5fc59d 100644 --- a/src/qml/bitcoin_qml.qrc +++ b/src/qml/bitcoin_qml.qrc @@ -45,6 +45,7 @@ pages/node/NodeRunner.qml pages/node/NodeSettings.qml pages/node/Peers.qml + pages/node/PeerDetails.qml pages/node/Shutdown.qml pages/onboarding/OnboardingBlockclock.qml pages/onboarding/OnboardingConnection.qml diff --git a/src/qml/models/peerdetailsmodel.cpp b/src/qml/models/peerdetailsmodel.cpp new file mode 100644 index 0000000000..cf764cd711 --- /dev/null +++ b/src/qml/models/peerdetailsmodel.cpp @@ -0,0 +1,56 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +PeerDetailsModel::PeerDetailsModel(CNodeCombinedStats* nodeStats, PeerTableModel* parent) +: m_combinedStats{nodeStats} +, m_model{parent} +, m_disconnected{false} +{ + for (int row = 0; row < m_model->rowCount(); ++row) { + QModelIndex index = m_model->index(row, 0); + int nodeIdInRow = m_model->data(index, PeerTableModel::NetNodeId).toInt(); + if (nodeIdInRow == m_combinedStats->nodeStats.nodeid) { + m_row = row; + break; + } + } + connect(parent, &PeerTableModel::rowsRemoved, this, &PeerDetailsModel::onModelRowsRemoved); + connect(parent, &PeerTableModel::dataChanged, this, &PeerDetailsModel::onModelDataChanged); +} + +void PeerDetailsModel::onModelRowsRemoved(const QModelIndex& parent, int first, int last) +{ + for (int row = first; row <= last; ++row) { + QModelIndex index = m_model->index(row, 0, parent); + int nodeIdInRow = m_model->data(index, PeerTableModel::NetNodeId).toInt(); + if (nodeIdInRow == this->nodeId()) { + if (!m_disconnected) { + m_disconnected = true; + Q_EMIT disconnected(); + } + break; + } + } +} + +void PeerDetailsModel::onModelDataChanged(const QModelIndex& /* top_left */, const QModelIndex& /* bottom_right */) +{ + if (m_model->data(m_model->index(m_row, 0), PeerTableModel::NetNodeId).isNull() || + m_model->data(m_model->index(m_row, 0), PeerTableModel::NetNodeId).toInt() != nodeId()) { + if (!m_disconnected) { + m_disconnected = true; + Q_EMIT disconnected(); + } + return; + } + + m_combinedStats = m_model->data(m_model->index(m_row, 0), PeerTableModel::StatsRole).value(); + + // Only update when all information is available + if (m_combinedStats && m_combinedStats->fNodeStateStatsAvailable) { + Q_EMIT dataChanged(); + } +} diff --git a/src/qml/models/peerdetailsmodel.h b/src/qml/models/peerdetailsmodel.h new file mode 100644 index 0000000000..2d91a5d0ac --- /dev/null +++ b/src/qml/models/peerdetailsmodel.h @@ -0,0 +1,93 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_PEERDETAILSMODEL_H +#define BITCOIN_QML_MODELS_PEERDETAILSMODEL_H + +#include + +#include +#include +#include +#include + +class PeerDetailsModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(int nodeId READ nodeId NOTIFY dataChanged) + 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 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(QString startingHeight READ startingHeight NOTIFY dataChanged) + Q_PROPERTY(QString syncedHeaders READ syncedHeaders NOTIFY dataChanged) + Q_PROPERTY(QString syncedBlocks READ syncedBlocks NOTIFY dataChanged) + Q_PROPERTY(QString direction READ direction NOTIFY dataChanged) + Q_PROPERTY(QString lastSend READ lastSend NOTIFY dataChanged) + Q_PROPERTY(QString lastReceived READ lastReceived NOTIFY dataChanged) + Q_PROPERTY(QString bytesSent READ bytesSent NOTIFY dataChanged) + Q_PROPERTY(QString bytesReceived READ bytesReceived NOTIFY dataChanged) + Q_PROPERTY(QString pingTime READ pingTime NOTIFY dataChanged) + Q_PROPERTY(QString pingWait READ pingWait NOTIFY dataChanged) + Q_PROPERTY(QString pingMin READ pingMin NOTIFY dataChanged) + Q_PROPERTY(QString timeOffset READ timeOffset NOTIFY dataChanged) + Q_PROPERTY(QString mappedAS READ mappedAS NOTIFY dataChanged) + Q_PROPERTY(QString permission READ permission NOTIFY dataChanged) + +public: + explicit PeerDetailsModel(CNodeCombinedStats* nodeStats, PeerTableModel* model); + + int nodeId() const { return m_combinedStats->nodeStats.nodeid; } + 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 GUIUtil::ConnectionTypeToQString(m_combinedStats->nodeStats.m_conn_type, /*prepend_direction=*/true); } + QString version() const { return QString::number(m_combinedStats->nodeStats.nVersion); } + QString userAgent() const { return QString::fromStdString(m_combinedStats->nodeStats.cleanSubVer); } + QString services() const { return GUIUtil::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; } + QString startingHeight() const { return QString::number(m_combinedStats->nodeStateStats.m_starting_height); } + QString syncedHeaders() const { return QString::number(m_combinedStats->nodeStateStats.nSyncHeight); } + QString syncedBlocks() const { return QString::number(m_combinedStats->nodeStateStats.nCommonHeight); } + QString direction() const { return QString::fromStdString(m_combinedStats->nodeStats.fInbound ? "Inbound" : "Outbound"); } + QString lastSend() const { return GUIUtil::formatDurationStr(GetTime() - m_combinedStats->nodeStats.m_last_send); } + QString lastReceived() const { return GUIUtil::formatDurationStr(GetTime() - m_combinedStats->nodeStats.m_last_recv); } + QString bytesSent() const { return GUIUtil::formatBytes(m_combinedStats->nodeStats.nSendBytes); } + QString bytesReceived() const { return GUIUtil::formatBytes(m_combinedStats->nodeStats.nRecvBytes); } + QString pingTime() const { return GUIUtil::formatPingTime(m_combinedStats->nodeStats.m_last_ping_time); } + QString pingMin() const { return GUIUtil::formatPingTime(m_combinedStats->nodeStats.m_min_ping_time); } + QString pingWait() const { return GUIUtil::formatPingTime(m_combinedStats->nodeStateStats.m_ping_wait); } + QString timeOffset() const { return GUIUtil::formatTimeOffset(m_combinedStats->nodeStats.nTimeOffset); } + QString mappedAS() const { return m_combinedStats->nodeStats.m_mapped_as != 0 ? QString::number(m_combinedStats->nodeStats.m_mapped_as) : tr("N/A"); } + QString permission() const { + if (m_combinedStats->nodeStats.m_permission_flags == NetPermissionFlags::None) { + return tr("N/A"); + } + QStringList permissions; + for (const auto& permission : NetPermissions::ToStrings(m_combinedStats->nodeStats.m_permission_flags)) { + permissions.append(QString::fromStdString(permission)); + } + return permissions.join(" & "); + } + +Q_SIGNALS: + void dataChanged(); + void disconnected(); + +private Q_SLOTS: + void onModelRowsRemoved(const QModelIndex& parent, int first, int last); + void onModelDataChanged(const QModelIndex& top_left, const QModelIndex& bottom_right); + +private: + int m_row; + CNodeCombinedStats* m_combinedStats; + PeerTableModel* m_model; + bool m_disconnected; +}; + +#endif // BITCOIN_QML_MODELS_PEERDETAILSMODEL_H diff --git a/src/qml/models/peerlistsortproxy.cpp b/src/qml/models/peerlistsortproxy.cpp index b566672458..8e1ad9b363 100644 --- a/src/qml/models/peerlistsortproxy.cpp +++ b/src/qml/models/peerlistsortproxy.cpp @@ -3,6 +3,7 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include PeerListSortProxy::PeerListSortProxy(QObject* parent) @@ -23,6 +24,7 @@ QHash PeerListSortProxy::roleNames() const roles[PeerTableModel::Sent] = "sent"; roles[PeerTableModel::Received] = "received"; roles[PeerTableModel::Subversion] = "subversion"; + roles[PeerTableModel::StatsRole] = "stats"; return roles; } @@ -40,6 +42,10 @@ int PeerListSortProxy::RoleNameToIndex(const QString & name) const QVariant PeerListSortProxy::data(const QModelIndex& index, int role) const { if (role == PeerTableModel::StatsRole) { + auto stats = PeerTableSortProxy::data(index, role); + auto details = new PeerDetailsModel(stats.value(), qobject_cast(sourceModel())); + return QVariant::fromValue(details); + } else if (role == PeerTableModel::NetNodeId) { return PeerTableSortProxy::data(index, role); } diff --git a/src/qml/pages/node/NodeSettings.qml b/src/qml/pages/node/NodeSettings.qml index 87a5f5de4b..8ccdeea652 100644 --- a/src/qml/pages/node/NodeSettings.qml +++ b/src/qml/pages/node/NodeSettings.qml @@ -190,6 +190,17 @@ Item { nodeSettingsView.pop() peerTableModel.stopAutoRefresh(); } + onPeerSelected: (peerDetails) => { + nodeSettingsView.push(peer_details, {"details": peerDetails}) + } + } + } + Component { + id: peer_details + PeerDetails { + onBackClicked: { + nodeSettingsView.pop() + } } } Component { diff --git a/src/qml/pages/node/PeerDetails.qml b/src/qml/pages/node/PeerDetails.qml new file mode 100644 index 0000000000..47be8eefb3 --- /dev/null +++ b/src/qml/pages/node/PeerDetails.qml @@ -0,0 +1,113 @@ +// Copyright (c) 2024 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 { + signal backClicked() + + property PeerDetailsModel details + + Connections { + target: details + function onDisconnected() { + root.backClicked() + } + } + + id: root + background: null + + header: NavigationBar2 { + leftItem: NavButton { + iconSource: "image://images/caret-left" + text: qsTr("Back") + onClicked: root.backClicked() + } + centerItem: Header { + headerBold: true + headerSize: 18 + header: qsTr("Peer " + details.nodeId) + } + } + + component PeerKeyValueRow: Row { + width: parent.width + property string key: "" + property string value: "" + CoreText { + color: Theme.color.neutral9; + text: key; + width: 125; + horizontalAlignment: Qt.AlignLeft; + } + CoreText { + color: Theme.color.neutral9; + elide: Text.ElideRight; + wrapMode: Text.WordWrap; + text: value + width: parent.width - 125; + horizontalAlignment: Qt.AlignLeft; + } + } + + 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 + + CoreText { text: "Information"; bold: true; font.pixelSize: 18; horizontalAlignment: Qt.AlignLeft; } + Column { + width: parent.width + spacing: 5 + PeerKeyValueRow { key: qsTr("Address"); value: details.address } + PeerKeyValueRow { key: qsTr("VIA"); value: details.addressLocal } + PeerKeyValueRow { key: qsTr("Type"); value: details.type } + PeerKeyValueRow { key: qsTr("Permission"); value: details.permission } + PeerKeyValueRow { key: qsTr("Version"); value: details.version } + PeerKeyValueRow { key: qsTr("User agent"); value: details.userAgent } + PeerKeyValueRow { key: qsTr("Services"); value: details.services } + PeerKeyValueRow { key: qsTr("Transaction relay"); value: details.transactionRelay } + PeerKeyValueRow { key: qsTr("Address relay"); value: details.addressRelay } + PeerKeyValueRow { key: qsTr("Mapped AS"); value: details.mappedAS } + } + + CoreText { text: "Block data"; bold: true; font.pixelSize: 18; horizontalAlignment: Qt.AlignLeft; } + Column { + width: parent.width + spacing: 5 + PeerKeyValueRow { key: qsTr("Starting block"); value: details.startingHeight } + PeerKeyValueRow { key: qsTr("Synced headers"); value: details.syncedHeaders } + PeerKeyValueRow { key: qsTr("Synced blocks"); value: details.syncedBlocks } + } + + CoreText { text: "Network traffic"; bold: true; font.pixelSize: 18; horizontalAlignment: Qt.AlignLeft; } + Column { + width: parent.width + spacing: 5 + PeerKeyValueRow { key: qsTr("Direction"); value: details.direction } + PeerKeyValueRow { key: qsTr("Last send"); value: details.lastSend } + PeerKeyValueRow { key: qsTr("Last receive"); value: details.lastReceived } + PeerKeyValueRow { key: qsTr("Sent"); value: details.bytesSent } + PeerKeyValueRow { key: qsTr("Received"); value: details.bytesReceived } + PeerKeyValueRow { key: qsTr("Ping time"); value: details.pingTime } + PeerKeyValueRow { key: qsTr("Ping wait"); value: details.pingWait } + PeerKeyValueRow { key: qsTr("Min ping"); value: details.pingMin } + PeerKeyValueRow { key: qsTr("Time offset"); value: details.timeOffset } + } + } + } +} diff --git a/src/qml/pages/node/Peers.qml b/src/qml/pages/node/Peers.qml index a8b0173aa9..173029ec7d 100644 --- a/src/qml/pages/node/Peers.qml +++ b/src/qml/pages/node/Peers.qml @@ -12,6 +12,7 @@ import "../../components" Page { signal backClicked + signal peerSelected(PeerDetailsModel peerDetails) id: root background: null @@ -161,6 +162,7 @@ Page { 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 @@ -232,6 +234,9 @@ Page { width: parent.width } } + onClicked: { + root.peerSelected(stats) + } contentItem: ColumnLayout { RowLayout { Layout.fillWidth: true