diff --git a/qml/bitcoin.cpp b/qml/bitcoin.cpp index 6b133cce30..c02b985c4a 100644 --- a/qml/bitcoin.cpp +++ b/qml/bitcoin.cpp @@ -592,7 +592,7 @@ int QmlGuiMain(int argc, char* argv[]) }, Qt::QueuedConnection); QObject::connect(&init_executor, &QmlInitExecutor::runawayException, &node_model, &NodeModel::handleRunawayException); - NetworkTrafficTower network_traffic_tower{node_model}; + NetworkTrafficTower network_traffic_tower{*node}; NetworkStatusModel network_status_model; #ifdef __ANDROID__ AndroidNotifier android_notifier{node_model}; diff --git a/qml/components/NetworkTrafficGraph.qml b/qml/components/NetworkTrafficGraph.qml index 3d4ded4581..4505da535f 100644 --- a/qml/components/NetworkTrafficGraph.qml +++ b/qml/components/NetworkTrafficGraph.qml @@ -14,7 +14,7 @@ Item { id: root property alias backgroundColor: trafficGraph.backgroundColor property alias borderColor: trafficGraph.borderColor - property alias fillColor: trafficGraph.fillColor + property alias fillColor: trafficGraph.gradientColor property alias lineColor: trafficGraph.lineColor property alias markerLineColor: trafficGraph.markerLineColor property alias maxSamples: trafficGraph.maxSamples @@ -31,7 +31,7 @@ Item { width: root.width backgroundColor: root.backgroundColor borderColor: root.borderColor - fillColor: root.fillColor + gradientColor: root.fillColor lineColor: root.lineColor markerLineColor: root.markerLineColor maxSamples: root.maxSamples @@ -46,7 +46,7 @@ Item { ColorAnimation { duration: 150 } } - Behavior on fillColor { + Behavior on gradientColor { ColorAnimation { duration: 150 } } diff --git a/qml/controls/linegraph.cpp b/qml/controls/linegraph.cpp index 6c59d0d919..8d1b776ddf 100644 --- a/qml/controls/linegraph.cpp +++ b/qml/controls/linegraph.cpp @@ -17,13 +17,13 @@ LineGraph::LineGraph(QQuickItem *parent) : QQuickPaintedItem(parent) { - setFillColor(m_background_color); + setGradientColor(m_background_color); } void LineGraph::setBackgroundColor(QColor color) { m_background_color = color; - setFillColor(color); + setGradientColor(color); } void LineGraph::setBorderColor(QColor color) @@ -32,9 +32,9 @@ void LineGraph::setBorderColor(QColor color) update(); } -void LineGraph::setFillColor(QColor color) +void LineGraph::setGradientColor(QColor color) { - m_fill_color = color; + m_gradient_color = color; update(); } @@ -135,7 +135,7 @@ void LineGraph::paintTraffic(QPainter * painter) void LineGraph::setupGradient(QPainterPath * painter_path) { QLinearGradient gradient(painter_path->boundingRect().topLeft(), painter_path->boundingRect().bottomLeft()); - gradient.setColorAt(0, QColor(m_fill_color.red(), m_fill_color.green(), m_fill_color.blue(), 191)); + gradient.setColorAt(0, QColor(m_gradient_color.red(), m_gradient_color.green(), m_gradient_color.blue(), 191)); gradient.setColorAt(1, QColor("transparent")); m_fill_gradient = gradient; } diff --git a/qml/controls/linegraph.h b/qml/controls/linegraph.h index 356a7b9701..b9d0ccbf82 100644 --- a/qml/controls/linegraph.h +++ b/qml/controls/linegraph.h @@ -16,7 +16,7 @@ class LineGraph : public QQuickPaintedItem Q_OBJECT Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor) Q_PROPERTY(QColor borderColor READ borderColor WRITE setBorderColor) - Q_PROPERTY(QColor fillColor READ fillColor WRITE setFillColor) + Q_PROPERTY(QColor gradientColor READ gradientColor WRITE setGradientColor) Q_PROPERTY(QColor lineColor READ lineColor WRITE setLineColor) Q_PROPERTY(QColor markerLineColor READ markerLineColor WRITE setMarkerLineColor) Q_PROPERTY(int maxSamples READ maxSamples WRITE setMaxSamples) @@ -29,7 +29,7 @@ class LineGraph : public QQuickPaintedItem QColor backgroundColor() const { return m_background_color; }; QColor borderColor() const { return m_border_color; }; - QColor fillColor() const { return m_fill_color; }; + QColor gradientColor() const { return m_gradient_color; }; QColor lineColor() const { return m_line_color; }; QColor markerLineColor() const { return m_marker_line_color; }; int maxSamples() const { return m_max_samples; }; @@ -39,7 +39,7 @@ class LineGraph : public QQuickPaintedItem public Q_SLOTS: void setBackgroundColor(QColor color); void setBorderColor(QColor color); - void setFillColor(QColor color); + void setGradientColor(QColor color); void setLineColor(QColor color); void setMarkerLineColor(QColor color); void setMaxSamples(int max_samples); @@ -55,7 +55,7 @@ class LineGraph : public QQuickPaintedItem QColor m_background_color{"#2D2D2D"}; QColor m_border_color{"#000000"}; - QColor m_fill_color{"#000000"}; + QColor m_gradient_color{"#000000"}; QLinearGradient m_fill_gradient{0, 0, 0, 0}; QColor m_line_color{"#000000"}; QColor m_marker_line_color{"#000000"}; diff --git a/qml/models/networktraffictower.cpp b/qml/models/networktraffictower.cpp index 9a88bff89d..a000d7058c 100644 --- a/qml/models/networktraffictower.cpp +++ b/qml/models/networktraffictower.cpp @@ -1,148 +1,329 @@ -// 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. #include +#include +#include + +#include +#include +#include +#include + #include #include #include -#define MAX_SAMPLES 86400 +namespace { +constexpr int MAX_SAMPLES{86400}; +constexpr int DEFAULT_FILTER_WINDOW_SIZE{30}; -NetworkTrafficTower::NetworkTrafficTower(NodeModel& node) - : m_node{node} -{ - QTimer* timer = new QTimer(); - connect(timer, &QTimer::timeout, this, &NetworkTrafficTower::updateTrafficStats); - timer->start(1000); +struct TrafficSnapshot { + quint64 generation{0}; + quint64 total_bytes_received{0}; + quint64 total_bytes_sent{0}; + float max_received_rate_bps{0.0f}; + float max_sent_rate_bps{0.0f}; + QQueue received_rate_list; + QQueue sent_rate_list; +}; - QThread* timer_thread = new QThread; - timer->moveToThread(timer_thread); - timer_thread->start(); - - m_filter_window_size = 30; -} - -void NetworkTrafficTower::setTotalBytesReceived(float new_total) +quint64 NonNegativeTotal(int64_t total) { - m_total_bytes_received = new_total; - Q_EMIT totalBytesReceivedChanged(); + return total > 0 ? static_cast(total) : 0; } -void NetworkTrafficTower::setTotalBytesSent(float new_total) +float RateDelta(quint64 current, quint64 previous) { - m_total_bytes_sent = new_total; - Q_EMIT totalBytesSentChanged(); + return current >= previous ? static_cast(current - previous) : 0.0f; } +} // namespace -void NetworkTrafficTower::setMaxReceivedRateBps(float new_max) +class NetworkTrafficWorker : public QObject { - m_max_received_rate_bps = new_max; - Q_EMIT maxReceivedRateBpsChanged(); -} +public: + using PublishFn = std::function; -void NetworkTrafficTower::setMaxSentRateBps(float new_max) -{ - m_max_sent_rate_bps = new_max; - Q_EMIT maxSentRateBpsChanged(); -} + NetworkTrafficWorker(interfaces::Node& node, int sample_interval_ms, PublishFn publish) + : m_node{node} + , m_publish{std::move(publish)} + , m_timer{new QTimer(this)} + { + m_timer->setInterval(std::max(1, sample_interval_ms)); + connect(m_timer, &QTimer::timeout, this, [this] { + sample(); + }); + } -void NetworkTrafficTower::updateFilterWindowSize(int new_size) -{ - if (!(m_filter_window_size == new_size)) { - m_filter_window_size = new_size; + void start() + { + util::ThreadRename("qml-netstats"); + sample(); + m_timer->start(); + } - if (!m_received_rate_list.isEmpty()) { - recalculateSmoothedRateList(&m_received_rate_list, &m_smoothed_received_rate_list); - float new_max_received_rate = calculateMaxRateBps(&m_smoothed_received_rate_list); + void stop() + { + m_timer->stop(); + } - setMaxReceivedRateBps(new_max_received_rate); - Q_EMIT receivedRateListChanged(); + void setActive(bool active, quint64 generation) + { + m_active = active; + m_generation = generation; + if (!m_active) { + // Raw samples continue in the background. Derived history is rebuilt + // on the worker when the page becomes active again. + m_smoothed_history_valid = false; + return; } - if (!m_sent_rate_list.isEmpty()) { - recalculateSmoothedRateList(&m_sent_rate_list, &m_smoothed_sent_rate_list); - float new_max_sent_rate = calculateMaxRateBps(&m_smoothed_sent_rate_list); - setMaxSentRateBps(new_max_sent_rate); - Q_EMIT sentRateListChanged(); + ensureSmoothedHistory(); + publishSnapshot(); + } + + void setFilterWindowSize(int window_size) + { + const int clamped_size{std::clamp(window_size, 1, MAX_SAMPLES)}; + if (m_filter_window_size == clamped_size) return; + + m_filter_window_size = clamped_size; + m_smoothed_history_valid = false; + if (m_active) { + ensureSmoothedHistory(); + publishSnapshot(); } } - Q_EMIT sentRateListChanged(); -} -float NetworkTrafficTower::applyMovingAverageFilter(QQueue * rate_list) -{ - int filter_window_size = std::min(static_cast(rate_list->size()), m_filter_window_size); - float sum = 0.0f; - for (int i = 0; i < filter_window_size; ++i) { - sum += rate_list->at(i); +private: + float currentMovingAverage(const QQueue& rates) const + { + const qsizetype count{std::min(rates.size(), m_filter_window_size)}; + if (count == 0) return 0.0f; + + double sum{0.0}; + for (qsizetype i = 0; i < count; ++i) { + sum += rates.at(i); + } + return static_cast(sum / count); } - return sum / filter_window_size; -} + QQueue calculateSmoothedHistory(const QQueue& rates) const + { + QQueue smoothed; + if (rates.isEmpty()) return smoothed; -float NetworkTrafficTower::calculateMaxRateBps(QQueue * smoothed_rate_list) -{ - float max_rate_bps = 0.0f; - int lookback = std::min(static_cast(smoothed_rate_list->size()) - 1, m_filter_window_size * 10); - for (int i = lookback; i > 0; --i) { - if (smoothed_rate_list->at(i) > max_rate_bps) { - max_rate_bps = smoothed_rate_list->at(i); + smoothed.reserve(rates.size()); + const qsizetype window_size{std::min(rates.size(), m_filter_window_size)}; + double window_sum{0.0}; + for (qsizetype i = 0; i < window_size; ++i) { + window_sum += rates.at(i); } + + for (qsizetype i = 0; i < rates.size(); ++i) { + const qsizetype sample_count{std::min(window_size, rates.size() - i)}; + smoothed.push_back(static_cast(window_sum / sample_count)); + + window_sum -= rates.at(i); + const qsizetype incoming_index{i + window_size}; + if (incoming_index < rates.size()) { + window_sum += rates.at(incoming_index); + } + } + return smoothed; } - return max_rate_bps; -} -void NetworkTrafficTower::recalculateSmoothedRateList(QQueue * rate_list, QQueue * smoothed_rate_list) -{ - smoothed_rate_list->clear(); - QQueue temp_list; - for (int i = rate_list->size() - 1; i > 0; --i) { - temp_list.push_front(rate_list->at(i)); - float smoothed_rate = applyMovingAverageFilter(&temp_list); - smoothed_rate_list->push_front(smoothed_rate); + float calculateMaxRate(const QQueue& smoothed_rates) const + { + const qsizetype lookback{std::min(smoothed_rates.size(), m_filter_window_size * 10)}; + float max_rate{0.0f}; + for (qsizetype i = 0; i < lookback; ++i) { + max_rate = std::max(max_rate, smoothed_rates.at(i)); + } + return max_rate; } -} -void NetworkTrafficTower::updateTrafficStats() -{ - float new_total_bytes_received = m_node.getTotalBytesReceived(); - float new_total_bytes_sent = m_node.getTotalBytesSent(); + QQueue visibleHistory(const QQueue& smoothed_rates) const + { + const qsizetype sample_count{std::min(smoothed_rates.size(), m_filter_window_size * 10)}; + QQueue visible_rates; + visible_rates.reserve(sample_count); + for (qsizetype i = 0; i < sample_count; ++i) { + visible_rates.push_back(smoothed_rates.at(i)); + } + return visible_rates; + } + + void ensureSmoothedHistory() + { + if (m_smoothed_history_valid) return; + + m_smoothed_received_rate_list = calculateSmoothedHistory(m_received_rate_list); + m_smoothed_sent_rate_list = calculateSmoothedHistory(m_sent_rate_list); + m_max_received_rate_bps = calculateMaxRate(m_smoothed_received_rate_list); + m_max_sent_rate_bps = calculateMaxRate(m_smoothed_sent_rate_list); + m_smoothed_history_valid = true; + } + + void appendSmoothedSample() + { + m_smoothed_received_rate_list.push_front(currentMovingAverage(m_received_rate_list)); + m_smoothed_sent_rate_list.push_front(currentMovingAverage(m_sent_rate_list)); + while (m_smoothed_received_rate_list.size() > m_received_rate_list.size()) { + m_smoothed_received_rate_list.pop_back(); + } + while (m_smoothed_sent_rate_list.size() > m_sent_rate_list.size()) { + m_smoothed_sent_rate_list.pop_back(); + } + m_max_received_rate_bps = calculateMaxRate(m_smoothed_received_rate_list); + m_max_sent_rate_bps = calculateMaxRate(m_smoothed_sent_rate_list); + } - float rate_received_bps = (new_total_bytes_received - m_total_bytes_received); - float rate_sent_bps = (new_total_bytes_sent - m_total_bytes_sent); + void sample() + { + const quint64 total_received{NonNegativeTotal(m_node.getTotalBytesRecv())}; + const quint64 total_sent{NonNegativeTotal(m_node.getTotalBytesSent())}; - setTotalBytesSent(new_total_bytes_sent); - setTotalBytesReceived(new_total_bytes_received); + if (!m_has_baseline) { + m_has_baseline = true; + m_previous_total_bytes_received = total_received; + m_previous_total_bytes_sent = total_sent; + m_total_bytes_received = total_received; + m_total_bytes_sent = total_sent; + if (m_active) publishSnapshot(); + return; + } - m_received_rate_list.push_front(rate_received_bps); - m_sent_rate_list.push_front(rate_sent_bps); + m_received_rate_list.push_front(RateDelta(total_received, m_previous_total_bytes_received)); + m_sent_rate_list.push_front(RateDelta(total_sent, m_previous_total_bytes_sent)); + m_previous_total_bytes_received = total_received; + m_previous_total_bytes_sent = total_sent; + m_total_bytes_received = total_received; + m_total_bytes_sent = total_sent; - float smoothed_received_rate_bps = applyMovingAverageFilter(&m_received_rate_list); - float smoothed_sent_rate_bps = applyMovingAverageFilter(&m_sent_rate_list); + while (m_received_rate_list.size() > MAX_SAMPLES) { + m_received_rate_list.pop_back(); + } + while (m_sent_rate_list.size() > MAX_SAMPLES) { + m_sent_rate_list.pop_back(); + } - m_smoothed_received_rate_list.push_front(smoothed_received_rate_bps); - m_smoothed_sent_rate_list.push_front(smoothed_sent_rate_bps); + if (!m_active) { + m_smoothed_history_valid = false; + return; + } - while (m_received_rate_list.size() > MAX_SAMPLES) { - m_received_rate_list.pop_back(); - m_smoothed_received_rate_list.pop_back(); + if (m_smoothed_history_valid) { + appendSmoothedSample(); + } else { + ensureSmoothedHistory(); + } + publishSnapshot(); } - while (m_sent_rate_list.size() > MAX_SAMPLES) { - m_sent_rate_list.pop_back(); - m_smoothed_sent_rate_list.pop_back(); + void publishSnapshot() + { + if (!m_active) return; + + m_publish(TrafficSnapshot{ + m_generation, + m_total_bytes_received, + m_total_bytes_sent, + m_max_received_rate_bps, + m_max_sent_rate_bps, + visibleHistory(m_smoothed_received_rate_list), + visibleHistory(m_smoothed_sent_rate_list), + }); } - float new_max_received_rate_bps = calculateMaxRateBps(&m_smoothed_received_rate_list); - float new_max_sent_rate_bps = calculateMaxRateBps(&m_smoothed_sent_rate_list); + interfaces::Node& m_node; + PublishFn m_publish; + QTimer* m_timer; + bool m_active{false}; + bool m_has_baseline{false}; + bool m_smoothed_history_valid{false}; + quint64 m_generation{0}; + quint64 m_previous_total_bytes_received{0}; + quint64 m_previous_total_bytes_sent{0}; + quint64 m_total_bytes_received{0}; + quint64 m_total_bytes_sent{0}; + int m_filter_window_size{DEFAULT_FILTER_WINDOW_SIZE}; + float m_max_received_rate_bps{0.0f}; + float m_max_sent_rate_bps{0.0f}; + QQueue m_received_rate_list; + QQueue m_smoothed_received_rate_list; + QQueue m_sent_rate_list; + QQueue m_smoothed_sent_rate_list; +}; + +NetworkTrafficTower::NetworkTrafficTower(interfaces::Node& node, int sample_interval_ms) + : m_worker_thread{new QThread(this)} +{ + m_worker = new NetworkTrafficWorker(node, sample_interval_ms, [this](TrafficSnapshot snapshot) { + QMetaObject::invokeMethod(this, [this, snapshot = std::move(snapshot)]() mutable { + if (!m_active || snapshot.generation != m_activation_generation) return; + + if (m_total_bytes_received != snapshot.total_bytes_received) { + m_total_bytes_received = snapshot.total_bytes_received; + Q_EMIT totalBytesReceivedChanged(); + } + if (m_total_bytes_sent != snapshot.total_bytes_sent) { + m_total_bytes_sent = snapshot.total_bytes_sent; + Q_EMIT totalBytesSentChanged(); + } + if (m_max_received_rate_bps != snapshot.max_received_rate_bps) { + m_max_received_rate_bps = snapshot.max_received_rate_bps; + Q_EMIT maxReceivedRateBpsChanged(); + } + if (m_max_sent_rate_bps != snapshot.max_sent_rate_bps) { + m_max_sent_rate_bps = snapshot.max_sent_rate_bps; + Q_EMIT maxSentRateBpsChanged(); + } + + m_received_rate_list = std::move(snapshot.received_rate_list); + m_sent_rate_list = std::move(snapshot.sent_rate_list); + Q_EMIT receivedRateListChanged(); + Q_EMIT sentRateListChanged(); + }, Qt::QueuedConnection); + }); + m_worker->moveToThread(m_worker_thread); + connect(m_worker_thread, &QThread::finished, m_worker, &QObject::deleteLater); + m_worker_thread->start(); + QMetaObject::invokeMethod(m_worker, [worker = m_worker] { + worker->start(); + }, Qt::QueuedConnection); +} + +NetworkTrafficTower::~NetworkTrafficTower() +{ + if (!m_worker_thread || !m_worker_thread->isRunning()) return; - setTotalBytesSent(new_total_bytes_sent); - setTotalBytesReceived(new_total_bytes_received); - setMaxReceivedRateBps(new_max_received_rate_bps); - setMaxSentRateBps(new_max_sent_rate_bps); + QMetaObject::invokeMethod(m_worker, [worker = m_worker] { + worker->stop(); + }, Qt::BlockingQueuedConnection); + m_worker_thread->quit(); + m_worker_thread->wait(); + m_worker = nullptr; +} + +void NetworkTrafficTower::setActive(bool active) +{ + if (m_active == active) return; + + m_active = active; + ++m_activation_generation; + Q_EMIT activeChanged(); - Q_EMIT receivedRateListChanged(); - Q_EMIT sentRateListChanged(); + QMetaObject::invokeMethod(m_worker, [worker = m_worker, active, generation = m_activation_generation] { + worker->setActive(active, generation); + }, Qt::QueuedConnection); +} + +void NetworkTrafficTower::updateFilterWindowSize(int new_size) +{ + QMetaObject::invokeMethod(m_worker, [worker = m_worker, new_size] { + worker->setFilterWindowSize(new_size); + }, Qt::QueuedConnection); } diff --git a/qml/models/networktraffictower.h b/qml/models/networktraffictower.h index 624a3d42a8..a198c879a0 100644 --- a/qml/models/networktraffictower.h +++ b/qml/models/networktraffictower.h @@ -5,40 +5,48 @@ #ifndef BITCOIN_QML_MODELS_NETWORKTRAFFICTOWER_H #define BITCOIN_QML_MODELS_NETWORKTRAFFICTOWER_H -#include - #include #include +namespace interfaces { +class Node; +} + +class NetworkTrafficWorker; +class QThread; + class NetworkTrafficTower : public QObject { Q_OBJECT - Q_PROPERTY(float totalBytesReceived READ totalBytesReceived NOTIFY totalBytesReceivedChanged) - Q_PROPERTY(float totalBytesSent READ totalBytesSent NOTIFY totalBytesSentChanged) + // Raw samples are always retained by the worker. Active only controls + // whether derived history snapshots are copied to the GUI thread. + Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged) + Q_PROPERTY(quint64 totalBytesReceived READ totalBytesReceived NOTIFY totalBytesReceivedChanged) + Q_PROPERTY(quint64 totalBytesSent READ totalBytesSent NOTIFY totalBytesSentChanged) Q_PROPERTY(float maxReceivedRateBps READ maxReceivedRateBps NOTIFY maxReceivedRateBpsChanged) Q_PROPERTY(float maxSentRateBps READ maxSentRateBps NOTIFY maxSentRateBpsChanged) Q_PROPERTY(QQueue receivedRateList READ receivedRateList NOTIFY receivedRateListChanged) Q_PROPERTY(QQueue sentRateList READ sentRateList NOTIFY sentRateListChanged) public: - explicit NetworkTrafficTower(NodeModel& node); + explicit NetworkTrafficTower(interfaces::Node& node, int sample_interval_ms = 1000); + ~NetworkTrafficTower() override; - float totalBytesReceived() const { return m_total_bytes_received; } - float totalBytesSent() const { return m_total_bytes_sent; } + bool active() const { return m_active; } + quint64 totalBytesReceived() const { return m_total_bytes_received; } + quint64 totalBytesSent() const { return m_total_bytes_sent; } float maxReceivedRateBps() const { return m_max_received_rate_bps; } float maxSentRateBps() const { return m_max_sent_rate_bps; } - QQueue receivedRateList() { return m_smoothed_received_rate_list; } - QQueue sentRateList() { return m_smoothed_sent_rate_list; } + QQueue receivedRateList() const { return m_received_rate_list; } + QQueue sentRateList() const { return m_sent_rate_list; } public Q_SLOTS: - void setTotalBytesReceived(float new_total); - void setTotalBytesSent(float new_total); - void setMaxReceivedRateBps(float new_max); - void setMaxSentRateBps(float new_max); + void setActive(bool active); Q_INVOKABLE void updateFilterWindowSize(int new_size); Q_SIGNALS: + void activeChanged(); void totalBytesReceivedChanged(); void totalBytesSentChanged(); void maxReceivedRateBpsChanged(); @@ -47,21 +55,16 @@ public Q_SLOTS: void sentRateListChanged(); private: - float applyMovingAverageFilter(QQueue * rate_list); - float calculateMaxRateBps(QQueue * smoothed_rate_list); - void recalculateSmoothedRateList(QQueue * raw_rate_list, QQueue * smoothed_rate_list); - void updateTrafficStats(); - - NodeModel& m_node; - int m_filter_window_size; - float m_total_bytes_received{0.0f}; - float m_total_bytes_sent{0.0f}; + NetworkTrafficWorker* m_worker{nullptr}; + QThread* m_worker_thread{nullptr}; + bool m_active{false}; + quint64 m_activation_generation{0}; + quint64 m_total_bytes_received{0}; + quint64 m_total_bytes_sent{0}; float m_max_received_rate_bps{0.0f}; float m_max_sent_rate_bps{0.0f}; QQueue m_received_rate_list; - QQueue m_smoothed_received_rate_list; QQueue m_sent_rate_list; - QQueue m_smoothed_sent_rate_list; }; #endif // BITCOIN_QML_MODELS_NETWORKTRAFFICTOWER_H diff --git a/qml/pages/node/NetworkTraffic.qml b/qml/pages/node/NetworkTraffic.qml index 7752dc0b99..db477b5d86 100644 --- a/qml/pages/node/NetworkTraffic.qml +++ b/qml/pages/node/NetworkTraffic.qml @@ -12,6 +12,7 @@ import org.bitcoincore.qt 1.0 InformationPage { id: root + objectName: "networkTrafficPage" property int trafficGraphScale: 300 property bool showBackButton: true @@ -170,4 +171,10 @@ InformationPage { } return bytes.toFixed(0) + " " + suffixes[index]; } + + Component.onCompleted: { + networkTrafficTower.updateFilterWindowSize(root.trafficGraphScale / 10) + networkTrafficTower.active = true + } + Component.onDestruction: networkTrafficTower.active = false } diff --git a/qml/pages/node/NodeSettings.qml b/qml/pages/node/NodeSettings.qml index 9b314415b0..685eaecb61 100644 --- a/qml/pages/node/NodeSettings.qml +++ b/qml/pages/node/NodeSettings.qml @@ -273,7 +273,12 @@ Page { SettingsWindowBehavior { showBackButton: false } SettingsStorage { showBackButton: false } SettingsConnection { showBackButton: false } - NetworkTraffic { showBackButton: false; showHeader: false } + Loader { + id: networkTrafficLoader + objectName: "networkTrafficLoader" + active: root.visible && root.currentSection === 6 + sourceComponent: NetworkTraffic { showBackButton: false; showHeader: false } + } MempoolInformationSettings { showBackButton: false } SettingsDebugLog { showBackButton: false } PageStack { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 097eb6b7ce..89a712b800 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(bitcoinqml_unit_tests test_nodemodel.cpp test_networkstyle.cpp test_networkstatusmodel.cpp + test_networktraffictower.cpp test_qmlinitexecutor_api.cpp test_guiargs.cpp test_options_model.cpp diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 25f5b19636..c4f7ced362 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -2372,6 +2372,7 @@ class MockPeerTableModel : public QObject class MockNetworkTrafficTower : public QObject { Q_OBJECT + Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged) Q_PROPERTY(quint64 totalBytesReceived MEMBER m_total_bytes_received NOTIFY totalBytesReceivedChanged) Q_PROPERTY(quint64 totalBytesSent MEMBER m_total_bytes_sent NOTIFY totalBytesSentChanged) Q_PROPERTY(double maxReceivedRateBps MEMBER m_max_received_rate_bps NOTIFY maxReceivedRateBpsChanged) @@ -2381,6 +2382,15 @@ class MockNetworkTrafficTower : public QObject Q_PROPERTY(int lastFilterWindowSize MEMBER m_last_filter_window_size NOTIFY lastFilterWindowSizeChanged) public: + bool active() const { return m_active; } + void setActive(bool active) + { + if (m_active == active) return; + m_active = active; + Q_EMIT activeChanged(); + } + + bool m_active{false}; quint64 m_total_bytes_received{1'000}; quint64 m_total_bytes_sent{2'000}; double m_max_received_rate_bps{100.0}; @@ -2396,6 +2406,7 @@ class MockNetworkTrafficTower : public QObject } Q_SIGNALS: + void activeChanged(); void totalBytesReceivedChanged(); void totalBytesSentChanged(); void maxReceivedRateBpsChanged(); @@ -3111,6 +3122,7 @@ public Q_SLOTS: engine->rootContext()->setContextProperty(QStringLiteral("nodeModel"), &node_model); engine->rootContext()->setContextProperty(QStringLiteral("peerTableModel"), &peer_table_model); engine->rootContext()->setContextProperty(QStringLiteral("networkTrafficTower"), &network_traffic_tower); + engine->rootContext()->setContextProperty(QStringLiteral("testNetworkTrafficTower"), &network_traffic_tower); engine->rootContext()->setContextProperty(QStringLiteral("networkStatusModel"), &network_status_model); engine->rootContext()->setContextProperty(QStringLiteral("peerListModelProxy"), &peer_list_model_proxy); engine->rootContext()->setContextProperty(QStringLiteral("banListModel"), &ban_list_model); diff --git a/test/qml/tst_nodesettings.qml b/test/qml/tst_nodesettings.qml index d6fe3cd949..00e3a8140e 100644 --- a/test/qml/tst_nodesettings.qml +++ b/test/qml/tst_nodesettings.qml @@ -39,6 +39,7 @@ TestCase { nodeModel.mempoolInformationAvailable = true AppMode.walletEnabled = true AppMode.isDesktop = true + testNetworkTrafficTower.active = false } function createNodeSettingsPage() { @@ -85,6 +86,36 @@ TestCase { compare(page.currentSection, 5) } + function test_network_traffic_only_publishes_while_selected() { + const page = createNodeSettingsPage() + + compare(testNetworkTrafficTower.active, false) + verify(findChild(page, "networkTrafficPage") === null) + + const networkTrafficItem = findChild(page, "settings_networktraffic") + verify(networkTrafficItem !== null) + mouseClick(networkTrafficItem, networkTrafficItem.width / 2, networkTrafficItem.height / 2) + tryCompare(page, "currentSection", 6) + tryCompare(testNetworkTrafficTower, "active", true) + verify(findChild(page, "networkTrafficPage") !== null) + + // Leaving Settings unloads both graphs and suppresses worker snapshots, + // while the C++ sampler continues retaining raw history off-thread. + page.visible = false + tryCompare(testNetworkTrafficTower, "active", false) + tryVerify(function() { return findChild(page, "networkTrafficPage") === null }) + + page.visible = true + tryCompare(testNetworkTrafficTower, "active", true) + verify(findChild(page, "networkTrafficPage") !== null) + + const displayItem = findChild(page, "settings_display") + verify(displayItem !== null) + mouseClick(displayItem, displayItem.width / 2, displayItem.height / 2) + tryCompare(page, "currentSection", 2) + tryCompare(testNetworkTrafficTower, "active", false) + tryVerify(function() { return findChild(page, "networkTrafficPage") === null }) + } function test_wallet_section_hidden_when_disabled() { AppMode.walletEnabled = false diff --git a/test/test_networktraffictower.cpp b/test/test_networktraffictower.cpp new file mode 100644 index 0000000000..8b90e63fa1 --- /dev/null +++ b/test/test_networktraffictower.cpp @@ -0,0 +1,187 @@ +// 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. + +#include + +#include +#include + +#include + +#include + +#include +#include + +namespace { +using ::testing::Invoke; +using ::testing::NiceMock; + +constexpr int TEST_SAMPLE_INTERVAL_MS{10}; +constexpr int ASYNC_TIMEOUT_MS{1'000}; +} // namespace + +class NetworkTrafficTowerTests : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void samplesOffGuiThreadWithoutPublishingWhileInactive(); + void activeControlsPublishingWithoutDiscardingBackgroundHistory(); + void filterWindowChangesPreserveTotalsAndHistory(); + void stopsSamplingWhenDestroyedWhileActive(); +}; + +void NetworkTrafficTowerTests::samplesOffGuiThreadWithoutPublishingWhileInactive() +{ + NiceMock node; + std::atomic received_calls{0}; + std::atomic sent_calls{0}; + std::atomic sampled_on_gui_thread{false}; + QThread* const gui_thread{QThread::currentThread()}; + + ON_CALL(node, getTotalBytesRecv()).WillByDefault(Invoke([&] { + ++received_calls; + if (QThread::currentThread() == gui_thread) sampled_on_gui_thread = true; + return int64_t{1'000}; + })); + ON_CALL(node, getTotalBytesSent()).WillByDefault(Invoke([&] { + ++sent_calls; + if (QThread::currentThread() == gui_thread) sampled_on_gui_thread = true; + return int64_t{2'000}; + })); + + { + NetworkTrafficTower tower{node, TEST_SAMPLE_INTERVAL_MS}; + QSignalSpy received_list_spy{&tower, &NetworkTrafficTower::receivedRateListChanged}; + QSignalSpy sent_list_spy{&tower, &NetworkTrafficTower::sentRateListChanged}; + + QTRY_VERIFY_WITH_TIMEOUT(received_calls.load() >= 3, ASYNC_TIMEOUT_MS); + QTRY_VERIFY_WITH_TIMEOUT(sent_calls.load() >= 3, ASYNC_TIMEOUT_MS); + QVERIFY(!sampled_on_gui_thread.load()); + QVERIFY(!tower.active()); + QVERIFY(tower.receivedRateList().isEmpty()); + QVERIFY(tower.sentRateList().isEmpty()); + QCOMPARE(received_list_spy.count(), 0); + QCOMPARE(sent_list_spy.count(), 0); + } + + const int calls_after_destruction{received_calls.load()}; + QTest::qWait(TEST_SAMPLE_INTERVAL_MS * 3); + QCOMPARE(received_calls.load(), calls_after_destruction); +} + +void NetworkTrafficTowerTests::activeControlsPublishingWithoutDiscardingBackgroundHistory() +{ + NiceMock node; + std::atomic total_received{1'000}; + std::atomic total_sent{2'000}; + std::atomic received_calls{0}; + + ON_CALL(node, getTotalBytesRecv()).WillByDefault(Invoke([&] { + ++received_calls; + return total_received.load(); + })); + ON_CALL(node, getTotalBytesSent()).WillByDefault(Invoke([&] { + return total_sent.load(); + })); + + NetworkTrafficTower tower{node, TEST_SAMPLE_INTERVAL_MS}; + QSignalSpy received_list_spy{&tower, &NetworkTrafficTower::receivedRateListChanged}; + QSignalSpy sent_list_spy{&tower, &NetworkTrafficTower::sentRateListChanged}; + + tower.setActive(true); + QTRY_COMPARE_WITH_TIMEOUT(tower.totalBytesReceived(), quint64{1'000}, ASYNC_TIMEOUT_MS); + QTRY_COMPARE_WITH_TIMEOUT(tower.totalBytesSent(), quint64{2'000}, ASYNC_TIMEOUT_MS); + QTRY_VERIFY_WITH_TIMEOUT(!tower.receivedRateList().isEmpty(), ASYNC_TIMEOUT_MS); + QTRY_VERIFY_WITH_TIMEOUT(!tower.sentRateList().isEmpty(), ASYNC_TIMEOUT_MS); + + tower.setActive(false); + const quint64 published_total{tower.totalBytesReceived()}; + const qsizetype published_history_size{tower.receivedRateList().size()}; + const qsizetype received_signals_before_hidden_samples{received_list_spy.count()}; + const qsizetype sent_signals_before_hidden_samples{sent_list_spy.count()}; + const int calls_before_hidden_samples{received_calls.load()}; + + total_received = 1'300; + total_sent = 2'600; + QTRY_VERIFY_WITH_TIMEOUT(received_calls.load() >= calls_before_hidden_samples + 3, ASYNC_TIMEOUT_MS); + QCoreApplication::processEvents(); + + QCOMPARE(tower.totalBytesReceived(), published_total); + QCOMPARE(tower.receivedRateList().size(), published_history_size); + QCOMPARE(received_list_spy.count(), received_signals_before_hidden_samples); + QCOMPARE(sent_list_spy.count(), sent_signals_before_hidden_samples); + + tower.setActive(true); + QTRY_COMPARE_WITH_TIMEOUT(tower.totalBytesReceived(), quint64{1'300}, ASYNC_TIMEOUT_MS); + QTRY_COMPARE_WITH_TIMEOUT(tower.totalBytesSent(), quint64{2'600}, ASYNC_TIMEOUT_MS); + QTRY_VERIFY_WITH_TIMEOUT(tower.receivedRateList().size() > published_history_size, ASYNC_TIMEOUT_MS); + QTRY_VERIFY_WITH_TIMEOUT(received_list_spy.count() > received_signals_before_hidden_samples, ASYNC_TIMEOUT_MS); + QTRY_VERIFY_WITH_TIMEOUT(sent_list_spy.count() > sent_signals_before_hidden_samples, ASYNC_TIMEOUT_MS); +} + +void NetworkTrafficTowerTests::filterWindowChangesPreserveTotalsAndHistory() +{ + NiceMock node; + std::atomic received_calls{0}; + + ON_CALL(node, getTotalBytesRecv()).WillByDefault(Invoke([&] { + ++received_calls; + return int64_t{1'000}; + })); + ON_CALL(node, getTotalBytesSent()).WillByDefault(Invoke([] { + return int64_t{2'000}; + })); + + NetworkTrafficTower tower{node, TEST_SAMPLE_INTERVAL_MS}; + tower.setActive(true); + QTRY_VERIFY_WITH_TIMEOUT(received_calls.load() >= 25, ASYNC_TIMEOUT_MS); + QTRY_VERIFY_WITH_TIMEOUT(tower.receivedRateList().size() > 20, ASYNC_TIMEOUT_MS); + + tower.updateFilterWindowSize(1); + QTRY_VERIFY_WITH_TIMEOUT(tower.receivedRateList().size() <= 10, ASYNC_TIMEOUT_MS); + const qsizetype short_history_size{tower.receivedRateList().size()}; + QVERIFY(short_history_size > 0); + QCOMPARE(tower.totalBytesReceived(), quint64{1'000}); + QCOMPARE(tower.totalBytesSent(), quint64{2'000}); + + tower.updateFilterWindowSize(2); + QTRY_VERIFY_WITH_TIMEOUT(tower.receivedRateList().size() > short_history_size, ASYNC_TIMEOUT_MS); + QVERIFY(tower.receivedRateList().size() <= 20); + QCOMPARE(tower.totalBytesReceived(), quint64{1'000}); + QCOMPARE(tower.totalBytesSent(), quint64{2'000}); +} + +void NetworkTrafficTowerTests::stopsSamplingWhenDestroyedWhileActive() +{ + NiceMock node; + std::atomic received_calls{0}; + + ON_CALL(node, getTotalBytesRecv()).WillByDefault(Invoke([&] { + ++received_calls; + return int64_t{1'000}; + })); + ON_CALL(node, getTotalBytesSent()).WillByDefault(Invoke([] { + return int64_t{2'000}; + })); + + { + NetworkTrafficTower tower{node, TEST_SAMPLE_INTERVAL_MS}; + tower.setActive(true); + QTRY_VERIFY_WITH_TIMEOUT(received_calls.load() >= 3, ASYNC_TIMEOUT_MS); + QVERIFY(tower.active()); + } + + const int calls_after_destruction{received_calls.load()}; + QTest::qWait(TEST_SAMPLE_INTERVAL_MS * 3); + QCOMPARE(received_calls.load(), calls_after_destruction); +} + +#ifdef BITCOINQML_NO_TEST_MAIN +BITCOINQML_REGISTER_QT_TEST(NetworkTrafficTowerTests) +#else +QTEST_MAIN(NetworkTrafficTowerTests) +#endif +#include "test_networktraffictower.moc"