diff --git a/components/PeersTable.qml b/components/PeersTable.qml
new file mode 100644
index 0000000000..a677914045
--- /dev/null
+++ b/components/PeersTable.qml
@@ -0,0 +1,248 @@
+// Copyright (c) 2026, The Monero Project
+//
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without modification, are
+// permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this list of
+// conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice, this list
+// of conditions and the following disclaimer in the documentation and/or other
+// materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its contributors may be
+// used to endorse or promote products derived from this software without specific
+// prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
+// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import QtQuick 2.9
+import QtQuick.Layouts 1.1
+
+import "." as MoneroComponents
+import "effects/" as MoneroEffects
+import "../js/Utils.js" as Utils
+import moneroComponents.Clipboard 1.0
+
+ColumnLayout {
+ id: peersTable
+ spacing: 0
+
+ property var model
+ property bool loading: model ? model.loading : false
+ property int hoveredIndex: -1
+
+ Clipboard {
+ id: clipboard
+ }
+
+ function formatBytes(bytes) {
+ if (bytes < 1024)
+ return bytes + " B";
+ if (bytes < 1024 * 1024)
+ return (bytes / 1024).toFixed(1) + " KB";
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
+ }
+
+ GridLayout {
+ Layout.fillWidth: true
+ columns: 8
+ columnSpacing: 24
+ rowSpacing: 6
+
+ MoneroComponents.TextPlain {
+ Layout.row: 0
+ Layout.column: 0
+ font.bold: true
+ text: qsTr("Direction") + translationManager.emptyString
+ color: MoneroComponents.Style.dimmedFontColor
+ }
+
+ MoneroComponents.TextPlain {
+ Layout.row: 0
+ Layout.column: 1
+ Layout.maximumWidth: 220
+ font.bold: true
+ text: qsTr("Address") + translationManager.emptyString
+ color: MoneroComponents.Style.dimmedFontColor
+ }
+
+ MoneroComponents.TextPlain {
+ Layout.row: 0
+ Layout.column: 2
+ font.bold: true
+ text: qsTr("Network") + translationManager.emptyString
+ color: MoneroComponents.Style.dimmedFontColor
+ }
+
+ MoneroComponents.TextPlain {
+ Layout.row: 0
+ Layout.column: 3
+ font.bold: true
+ text: qsTr("Block height") + translationManager.emptyString
+ color: MoneroComponents.Style.dimmedFontColor
+ }
+
+ MoneroComponents.TextPlain {
+ Layout.row: 0
+ Layout.column: 4
+ font.bold: true
+ text: qsTr("Connected since") + translationManager.emptyString
+ color: MoneroComponents.Style.dimmedFontColor
+ }
+
+ MoneroComponents.TextPlain {
+ Layout.row: 0
+ Layout.column: 5
+ font.bold: true
+ text: qsTr("Time connected") + translationManager.emptyString
+ color: MoneroComponents.Style.dimmedFontColor
+ }
+
+ MoneroComponents.TextPlain {
+ Layout.row: 0
+ Layout.column: 6
+ font.bold: true
+ text: qsTr("Sent") + translationManager.emptyString
+ color: MoneroComponents.Style.dimmedFontColor
+ }
+
+ MoneroComponents.TextPlain {
+ Layout.row: 0
+ Layout.column: 7
+ font.bold: true
+ text: qsTr("Received") + translationManager.emptyString
+ color: MoneroComponents.Style.dimmedFontColor
+ }
+
+ Rectangle {
+ Layout.row: 1
+ Layout.column: 0
+ Layout.columnSpan: 8
+ Layout.fillWidth: true
+ Layout.topMargin: 2
+ Layout.bottomMargin: 2
+ height: 1
+ color: MoneroComponents.Style.dividerColor
+ opacity: MoneroComponents.Style.dividerOpacity
+ }
+
+ Repeater {
+ model: peersTable.model
+ Rectangle {
+ Layout.row: index + 2
+ Layout.column: 0
+ Layout.columnSpan: 8
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: peersTable.hoveredIndex === index ? MoneroComponents.Style.titleBarButtonHoverColor : "transparent"
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ hoverEnabled: true
+ onEntered: peersTable.hoveredIndex = index
+ onExited: if (peersTable.hoveredIndex === index) peersTable.hoveredIndex = -1
+ onClicked: {
+ clipboard.setText(address);
+ appWindow.showStatusMessage(qsTr("Address copied to clipboard"), 3);
+ }
+ }
+ }
+ }
+
+ Repeater {
+ id: peersRepeater
+ model: peersTable.model
+ MoneroComponents.TextPlain {
+ Layout.row: index + 2
+ Layout.column: 0
+ text: incoming ? qsTr("Inbound") : qsTr("Outbound")
+ color: MoneroComponents.Style.defaultFontColor
+ }
+ }
+ Repeater {
+ model: peersTable.model
+ MoneroComponents.TextPlain {
+ Layout.row: index + 2
+ Layout.column: 1
+ Layout.maximumWidth: 220
+ text: address
+ elide: Text.ElideMiddle
+ color: MoneroComponents.Style.defaultFontColor
+ }
+ }
+ Repeater {
+ model: peersTable.model
+ MoneroComponents.TextPlain {
+ Layout.row: index + 2
+ Layout.column: 2
+ text: addressType
+ color: MoneroComponents.Style.defaultFontColor
+ }
+ }
+ Repeater {
+ model: peersTable.model
+ MoneroComponents.TextPlain {
+ Layout.row: index + 2
+ Layout.column: 3
+ text: blockHeight
+ color: MoneroComponents.Style.defaultFontColor
+ }
+ }
+ Repeater {
+ model: peersTable.model
+ MoneroComponents.TextPlain {
+ Layout.row: index + 2
+ Layout.column: 4
+ text: Qt.formatDateTime(new Date((Date.now() / 1000 - liveTime) * 1000), "yyyy-MM-dd hh:mm")
+ color: MoneroComponents.Style.defaultFontColor
+ }
+ }
+ Repeater {
+ model: peersTable.model
+ MoneroComponents.TextPlain {
+ Layout.row: index + 2
+ Layout.column: 5
+ text: Utils.ago(Date.now() / 1000 - liveTime)
+ color: MoneroComponents.Style.defaultFontColor
+ }
+ }
+ Repeater {
+ model: peersTable.model
+ MoneroComponents.TextPlain {
+ Layout.row: index + 2
+ Layout.column: 6
+ text: peersTable.formatBytes(sendCount)
+ color: MoneroComponents.Style.defaultFontColor
+ }
+ }
+ Repeater {
+ model: peersTable.model
+ MoneroComponents.TextPlain {
+ Layout.row: index + 2
+ Layout.column: 7
+ text: peersTable.formatBytes(recvCount)
+ color: MoneroComponents.Style.defaultFontColor
+ }
+ }
+ }
+
+ MoneroComponents.TextPlain {
+ Layout.topMargin: 8
+ color: MoneroComponents.Style.dimmedFontColor
+ visible: peersRepeater.count === 0 && !peersTable.loading
+ text: qsTr("No connected peers") + translationManager.emptyString
+ }
+}
diff --git a/pages/settings/SettingsNode.qml b/pages/settings/SettingsNode.qml
index 28480419ff..f51e3bffab 100644
--- a/pages/settings/SettingsNode.qml
+++ b/pages/settings/SettingsNode.qml
@@ -33,12 +33,38 @@ import FontAwesome 1.0
import "../../components" as MoneroComponents
import "../../components/effects" as MoneroEffects
+import moneroComponents.PeersModel 1.0
Rectangle{
color: "transparent"
Layout.fillWidth: true
property alias nodeHeight: root.height
+ property bool nodePageActive: false
+
+ function onPageCompleted() {
+ nodePageActive = true;
+ if (appWindow.daemonRunning) {
+ peersModel.refresh(appWindow.currentDaemonAddress);
+ }
+ }
+
+ function onPageClosed() {
+ nodePageActive = false;
+ }
+
+ PeersModel {
+ id: peersModel
+ }
+
+ Timer {
+ id: peersRefreshTimer
+ interval: 5000
+ repeat: true
+ running: nodePageActive && appWindow.daemonRunning
+ onTriggered: peersModel.refresh(appWindow.currentDaemonAddress)
+ }
+
/* main layout */
ColumnLayout {
id: root
@@ -352,6 +378,20 @@ Rectangle{
}
}
}
+
+ MoneroComponents.TextPlain {
+ Layout.topMargin: 20
+ font.pixelSize: 18
+ color: MoneroComponents.Style.defaultFontColor
+ visible: appWindow.daemonRunning
+ text: qsTr("Connected peers") + translationManager.emptyString
+ }
+
+ MoneroComponents.PeersTable {
+ Layout.fillWidth: true
+ visible: appWindow.daemonRunning
+ model: peersModel
+ }
}
}
}
diff --git a/qml.qrc b/qml.qrc
index 9223423dc8..01e049d05b 100644
--- a/qml.qrc
+++ b/qml.qrc
@@ -10,6 +10,7 @@
components/NavbarItem.qml
components/RemoteNodeDialog.qml
components/RemoteNodeList.qml
+ components/PeersTable.qml
components/SettingsListItem.qml
components/Slider.qml
components/Tooltip.qml
diff --git a/src/daemon/PeersModel.cpp b/src/daemon/PeersModel.cpp
new file mode 100644
index 0000000000..19381596ce
--- /dev/null
+++ b/src/daemon/PeersModel.cpp
@@ -0,0 +1,160 @@
+// Copyright (c) 2026, The Monero Project
+//
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without modification, are
+// permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this list of
+// conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice, this list
+// of conditions and the following disclaimer in the documentation and/or other
+// materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its contributors may be
+// used to endorse or promote products derived from this software without specific
+// prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
+// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "PeersModel.h"
+
+#include
+#include
+
+#include "net/http_client.h"
+#include "net/enums.h"
+#include "rpc/core_rpc_server_commands_defs.h"
+#include "storages/http_abstract_invoke.h"
+
+namespace {
+
+QString addressTypeToString(uint8_t addressType)
+{
+ switch (static_cast(addressType))
+ {
+ case epee::net_utils::address_type::ipv4:
+ case epee::net_utils::address_type::ipv6:
+ return QStringLiteral("Clearnet");
+ case epee::net_utils::address_type::i2p:
+ return QStringLiteral("I2P");
+ case epee::net_utils::address_type::tor:
+ return QStringLiteral("Tor");
+ default:
+ return QStringLiteral("Unknown");
+ }
+}
+
+} // namespace
+
+PeersModel::PeersModel(QObject *parent)
+ : QAbstractListModel(parent)
+ , m_scheduler(this)
+{
+}
+
+PeersModel::~PeersModel()
+{
+ m_scheduler.shutdownWaitForFinished();
+}
+
+bool PeersModel::loading() const
+{
+ return m_loading;
+}
+
+void PeersModel::refresh(const QString &daemonAddress)
+{
+ if (m_loading)
+ return;
+
+ m_loading = true;
+ emit loadingChanged();
+
+ m_scheduler.run([this, daemonAddress] {
+ QVariantList peers;
+
+ const int splitIndex = daemonAddress.lastIndexOf(':');
+ const QString host = splitIndex >= 0 ? daemonAddress.left(splitIndex) : daemonAddress;
+ const QString port = splitIndex >= 0 ? daemonAddress.mid(splitIndex + 1) : QString();
+
+ epee::net_utils::http::http_simple_client httpClient;
+ httpClient.set_server(host.toStdString(), port.toStdString(), {});
+
+ cryptonote::COMMAND_RPC_GET_CONNECTIONS::request req = AUTO_VAL_INIT(req);
+ cryptonote::COMMAND_RPC_GET_CONNECTIONS::response res = AUTO_VAL_INIT(res);
+
+ bool ok = epee::net_utils::invoke_http_json_rpc("/json_rpc", "get_connections", req, res, httpClient, std::chrono::seconds(3));
+ if (ok)
+ {
+ for (const cryptonote::connection_info &conn : res.connections)
+ {
+ QVariantMap peer;
+ peer.insert(QStringLiteral("incoming"), conn.incoming);
+ peer.insert(QStringLiteral("address"), QString::fromStdString(conn.address));
+ peer.insert(QStringLiteral("addressType"), addressTypeToString(conn.address_type));
+ peer.insert(QStringLiteral("blockHeight"), static_cast(conn.height));
+ peer.insert(QStringLiteral("liveTime"), static_cast(conn.live_time));
+ peer.insert(QStringLiteral("recvCount"), static_cast(conn.recv_count));
+ peer.insert(QStringLiteral("sendCount"), static_cast(conn.send_count));
+ peers.append(peer);
+ }
+ }
+ else
+ {
+ qDebug() << "PeersModel: get_connections RPC call failed for" << daemonAddress;
+ }
+
+ QMetaObject::invokeMethod(this, "onConnectionsFetched", Qt::QueuedConnection, Q_ARG(QVariantList, peers));
+ });
+}
+
+void PeersModel::onConnectionsFetched(const QVariantList &peers)
+{
+ beginResetModel();
+ m_peers = peers;
+ endResetModel();
+
+ m_loading = false;
+ emit loadingChanged();
+}
+
+QVariant PeersModel::data(const QModelIndex &index, int role) const
+{
+ if (index.row() < 0 || index.row() >= m_peers.size())
+ return QVariant();
+
+ const QString key = QString::fromLatin1(roleNames().value(role));
+ if (key.isEmpty())
+ return QVariant();
+
+ return m_peers.at(index.row()).toMap().value(key);
+}
+
+int PeersModel::rowCount(const QModelIndex &parent) const
+{
+ Q_UNUSED(parent)
+ return m_peers.size();
+}
+
+QHash PeersModel::roleNames() const
+{
+ QHash roles = QAbstractListModel::roleNames();
+ roles.insert(IncomingRole, "incoming");
+ roles.insert(AddressRole, "address");
+ roles.insert(AddressTypeRole, "addressType");
+ roles.insert(HeightRole, "blockHeight");
+ roles.insert(LiveTimeRole, "liveTime");
+ roles.insert(RecvCountRole, "recvCount");
+ roles.insert(SendCountRole, "sendCount");
+ return roles;
+}
diff --git a/src/daemon/PeersModel.h b/src/daemon/PeersModel.h
new file mode 100644
index 0000000000..32f56b2618
--- /dev/null
+++ b/src/daemon/PeersModel.h
@@ -0,0 +1,74 @@
+// Copyright (c) 2026, The Monero Project
+//
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without modification, are
+// permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this list of
+// conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice, this list
+// of conditions and the following disclaimer in the documentation and/or other
+// materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its contributors may be
+// used to endorse or promote products derived from this software without specific
+// prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
+// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#pragma once
+
+#include
+#include
+
+#include "qt/FutureScheduler.h"
+
+class PeersModel : public QAbstractListModel
+{
+ Q_OBJECT
+ Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
+
+public:
+ enum PeerRole {
+ IncomingRole = Qt::UserRole + 1,
+ AddressRole,
+ AddressTypeRole,
+ HeightRole,
+ LiveTimeRole,
+ RecvCountRole,
+ SendCountRole
+ };
+ Q_ENUM(PeerRole)
+
+ explicit PeersModel(QObject *parent = nullptr);
+ ~PeersModel();
+
+ Q_INVOKABLE void refresh(const QString &daemonAddress);
+
+ bool loading() const;
+
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+ QHash roleNames() const override;
+
+signals:
+ void loadingChanged();
+
+private slots:
+ void onConnectionsFetched(const QVariantList &peers);
+
+private:
+ QVariantList m_peers;
+ bool m_loading = false;
+ mutable FutureScheduler m_scheduler;
+};
diff --git a/src/main/main.cpp b/src/main/main.cpp
index 21993eaf57..13c61e886b 100644
--- a/src/main/main.cpp
+++ b/src/main/main.cpp
@@ -79,6 +79,7 @@
// IOS exclusions
#ifndef Q_OS_IOS
#include "daemon/DaemonManager.h"
+#include "daemon/PeersModel.h"
#include "p2pool/P2PoolManager.h"
#endif
@@ -416,6 +417,7 @@ Verify update binary using 'shasum'-compatible (SHA256 algo) output signed by tw
"DaemonManager can't be instantiated directly");
qmlRegisterUncreatableType("moneroComponents.P2PoolManager", 1, 0, "P2PoolManager",
"P2PoolManager can't be instantiated directly");
+ qmlRegisterType("moneroComponents.PeersModel", 1, 0, "PeersModel");
#endif
qmlRegisterUncreatableType("moneroComponents.AddressBookModel", 1, 0, "AddressBookModel",
"AddressBookModel can't be instantiated directly");