diff --git a/src/qt/forms/debugwindow.ui b/src/qt/forms/debugwindow.ui index eccea143189..7272aeeba48 100644 --- a/src/qt/forms/debugwindow.ui +++ b/src/qt/forms/debugwindow.ui @@ -665,20 +665,29 @@ - 1 + 0 - 288 + 2400 + + + 200 - 12 + 200 - 6 + 0 Qt::Horizontal + + QSlider::TicksBelow + + + 200 + @@ -694,16 +703,6 @@ - - - - &Reset - - - false - - - diff --git a/src/qt/guiutil.cpp b/src/qt/guiutil.cpp index c1bf5a56032..26568e1ba2e 100644 --- a/src/qt/guiutil.cpp +++ b/src/qt/guiutil.cpp @@ -833,6 +833,40 @@ QString formatBytes(uint64_t bytes) return QObject::tr("%1 GB").arg(bytes / 1'000'000'000); } +QString formatBytesps(float val) +{ + if (val < 10) + //: "Bytes per second" + return QObject::tr("%1 B/s").arg(0.01 * int(val * 100 + 0.5)); + if (val < 100) + //: "Bytes per second" + return QObject::tr("%1 B/s").arg(0.1 * int(val * 10 + 0.5)); + if (val < 1'000) + //: "Bytes per second" + return QObject::tr("%1 B/s").arg(int(val + 0.5)); + if (val < 10'000) + //: "Kilobytes per second" + return QObject::tr("%1 kB/s").arg(0.01 * int(val / 10 + 0.5)); + if (val < 100'000) + //: "Kilobytes per second" + return QObject::tr("%1 kB/s").arg(0.1 * int(val / 100 + 0.5)); + if (val < 1'000'000) + //: "Kilobytes per second" + return QObject::tr("%1 kB/s").arg(int(val / 1'000 + 0.5)); + if (val < 10'000'000) + //: "Megabytes per second" + return QObject::tr("%1 MB/s").arg(0.01 * int(val / 10'000 + 0.5)); + if (val < 100'000'000) + //: "Megabytes per second" + return QObject::tr("%1 MB/s").arg(0.1 * int(val / 100'000 + 0.5)); + if (val < 10'000'000'000) + //: "Megabytes per second" + return QObject::tr("%1 MB/s").arg(long(val / 1'000'000 + 0.5)); + + //: "Gigabytes per second" + return QObject::tr("%1 GB/s").arg(long(val / 1'000'000'000 + 0.5)); +} + qreal calculateIdealFontSize(int width, const QString& text, QFont font, qreal minPointSize, qreal font_size) { while(font_size >= minPointSize) { font.setPointSizeF(font_size); diff --git a/src/qt/guiutil.h b/src/qt/guiutil.h index 1b493430731..43b558c5d88 100644 --- a/src/qt/guiutil.h +++ b/src/qt/guiutil.h @@ -246,6 +246,7 @@ namespace GUIUtil QString formatNiceTimeOffset(qint64 secs); QString formatBytes(uint64_t bytes); + QString formatBytesps(float bytes); qreal calculateIdealFontSize(int width, const QString& text, QFont font, qreal minPointSize = 4, qreal startPointSize = 14); diff --git a/src/qt/rpcconsole.cpp b/src/qt/rpcconsole.cpp index 9d7c17ac911..9b7f4ff7cc7 100644 --- a/src/qt/rpcconsole.cpp +++ b/src/qt/rpcconsole.cpp @@ -53,7 +53,6 @@ using util::Join; const int CONSOLE_HISTORY = 50; -const int INITIAL_TRAFFIC_GRAPH_MINS = 30; const QSize FONT_RANGE(4, 40); const char fontSizeSettingsKey[] = "consoleFontSize"; @@ -566,7 +565,6 @@ RPCConsole::RPCConsole(interfaces::Node& node, const PlatformStyle *_platformSty connect(ui->clearButton, &QAbstractButton::clicked, [this] { clear(); }); connect(ui->fontBiggerButton, &QAbstractButton::clicked, this, &RPCConsole::fontBigger); connect(ui->fontSmallerButton, &QAbstractButton::clicked, this, &RPCConsole::fontSmaller); - connect(ui->btnClearTrafficGraph, &QPushButton::clicked, ui->trafficGraph, &TrafficGraphWidget::clear); // disable the wallet selector by default ui->WalletSelector->setVisible(false); @@ -578,7 +576,7 @@ RPCConsole::RPCConsole(interfaces::Node& node, const PlatformStyle *_platformSty // based timer interface m_node.rpcSetTimerInterfaceIfUnset(rpcTimerInterface); - setTrafficGraphRange(INITIAL_TRAFFIC_GRAPH_MINS); + setTrafficGraphRange(1); // 1 is the lowest setting (0 bumps up) updateDetailWidget(); consoleFontSize = settings.value(fontSizeSettingsKey, QFont().pointSize()).toInt(); @@ -1166,21 +1164,44 @@ void RPCConsole::scrollToEnd() void RPCConsole::on_sldGraphRange_valueChanged(int value) { - const int multiplier = 5; // each position on the slider represents 5 min - int mins = value * multiplier; - setTrafficGraphRange(mins); + setTrafficGraphRange((value + 100) / 200 + 1); } -void RPCConsole::setTrafficGraphRange(int mins) +void RPCConsole::setTrafficGraphRange(int value) { - ui->trafficGraph->setGraphRange(std::chrono::minutes{mins}); + int mins = ui->trafficGraph->setGraphRange(value); + if (value) + m_set_slider_value = (value - 1) * 200; + else { + // When bumping, calculate the proper slider position based on the traffic graph's new value + unsigned int new_graph_value = ui->trafficGraph->getCurrentRangeIndex() + 1; // +1 because the index is 0-based + m_set_slider_value = (new_graph_value - 1) * 200; + ui->sldGraphRange->blockSignals(true); + ui->sldGraphRange->setValue(m_set_slider_value); + ui->sldGraphRange->blockSignals(false); + } ui->lblGraphRange->setText(GUIUtil::formatDurationStr(std::chrono::minutes{mins})); } +void RPCConsole::on_sldGraphRange_sliderReleased() +{ + ui->sldGraphRange->setValue(m_set_slider_value); + m_slider_in_use = false; +} + +void RPCConsole::on_sldGraphRange_sliderPressed() { m_slider_in_use = true; } + void RPCConsole::updateTrafficStats(quint64 totalBytesIn, quint64 totalBytesOut) { - ui->lblBytesIn->setText(GUIUtil::formatBytes(totalBytesIn)); - ui->lblBytesOut->setText(GUIUtil::formatBytes(totalBytesOut)); + if (!m_slider_in_use && ui->trafficGraph->graphRangeBump()) + setTrafficGraphRange(0); // bump it up + + // Add baseline values to the current node values + quint64 totalIn = totalBytesIn + ui->trafficGraph->getBaselineBytesRecv(); + quint64 totalOut = totalBytesOut + ui->trafficGraph->getBaselineBytesSent(); + + ui->lblBytesIn->setText(GUIUtil::formatBytes(totalIn)); + ui->lblBytesOut->setText(GUIUtil::formatBytes(totalOut)); } void RPCConsole::updateDetailWidget() diff --git a/src/qt/rpcconsole.h b/src/qt/rpcconsole.h index 894ecb1fdf5..a75813f7f57 100644 --- a/src/qt/rpcconsole.h +++ b/src/qt/rpcconsole.h @@ -90,6 +90,8 @@ private Q_SLOTS: void on_openDebugLogfileButton_clicked(); /** change the time range of the network traffic graph */ void on_sldGraphRange_valueChanged(int value); + void on_sldGraphRange_sliderReleased(); + void on_sldGraphRange_sliderPressed(); /** update traffic statistics */ void updateTrafficStats(quint64 totalBytesIn, quint64 totalBytesOut); void resizeEvent(QResizeEvent *event) override; @@ -146,10 +148,9 @@ public Q_SLOTS: } const ts; void startExecutor(); - void setTrafficGraphRange(int mins); + void setTrafficGraphRange(int value); - enum ColumnWidths - { + enum ColumnWidths { ADDRESS_COLUMN_WIDTH = 200, SUBVERSION_COLUMN_WIDTH = 150, PING_COLUMN_WIDTH = 80, @@ -177,6 +178,8 @@ public Q_SLOTS: bool m_is_executing{false}; QByteArray m_peer_widget_header_state; QByteArray m_banlist_widget_header_state; + bool m_slider_in_use{false}; + int m_set_slider_value{0}; /** Update UI with latest network info from model. */ void updateNetworkState(); diff --git a/src/qt/trafficgraphwidget.cpp b/src/qt/trafficgraphwidget.cpp index fb6f2cb4642..ccbd809b5f1 100644 --- a/src/qt/trafficgraphwidget.cpp +++ b/src/qt/trafficgraphwidget.cpp @@ -5,12 +5,13 @@ #include #include #include +#include #include #include #include #include - +#include #include #include @@ -20,152 +21,631 @@ #define YMARGIN 10 TrafficGraphWidget::TrafficGraphWidget(QWidget* parent) - : QWidget(parent), - vSamplesIn(), - vSamplesOut() + : QWidget(parent) { - timer = new QTimer(this); - connect(timer, &QTimer::timeout, this, &TrafficGraphWidget::updateRates); + m_timer = new QTimer(this); + connect(m_timer, &QTimer::timeout, this, &TrafficGraphWidget::updateStuff); + m_timer->setInterval(75); + m_timer->start(); + setMouseTracking(true); } void TrafficGraphWidget::setClientModel(ClientModel *model) { - clientModel = model; + m_client_model = model; if(model) { - nLastBytesIn = model->node().getTotalBytesRecv(); - nLastBytesOut = model->node().getTotalBytesSent(); + m_data_dir = model->dataDir().toStdString(); + m_node = &model->node(); // Cache the node interface + + if (m_samples_in[0].empty() && m_samples_out[0].empty()) { + loadData(); + } + } else { + // Save data when model is being disconnected during shutdown + saveData(); } } -std::chrono::minutes TrafficGraphWidget::getGraphRange() const { return m_range; } +int TrafficGraphWidget::yValue(float value) const +{ + int h = height() - YMARGIN * 2; + return YMARGIN + h - (h * 1.0 * (m_toggle ? (std::pow(value, 0.30102) / std::pow(m_fmax, 0.30102)) : (value / m_fmax))); +} -void TrafficGraphWidget::paintPath(QPainterPath &path, QQueue &samples) +int TrafficGraphWidget::paintPath(QPainterPath& path, const QQueue& samples) { - int sampleCount = samples.size(); - if(sampleCount > 0) { - int h = height() - YMARGIN * 2, w = width() - XMARGIN * 2; - int x = XMARGIN + w; - path.moveTo(x, YMARGIN + h); - for(int i = 0; i < sampleCount; ++i) { - x = XMARGIN + w - w * i / DESIRED_SAMPLES; - int y = YMARGIN + h - (int)(h * samples.at(i) / fMax); - path.lineTo(x, y); + int sample_count = std::min(int(DESIRED_SAMPLES * m_range / m_values[m_value]), int(samples.size())) - 1; + if (sample_count <= 0) return 0; + int h = height() - YMARGIN * 2, w = width() - XMARGIN * 2; + int x = XMARGIN + w, i; + path.moveTo(x + 1, YMARGIN + h); // Overscan by 1 pixel to hide bright line + for (i = 0; i <= sample_count; ++i) { + if (i < 1) path.lineTo(x + 1, yValue(samples.at(0))); // Overscan by 1 pixel to the right + double ratio = static_cast(i) * m_values[m_value] / m_range / (DESIRED_SAMPLES - 1); + x = XMARGIN + static_cast(w - w * ratio + 0.5); + if (i == sample_count && (sample_count < samples.size() - 1 || samples.size() >= DESIRED_SAMPLES)) { + path.lineTo(x, yValue(samples.at(i))); + x = XMARGIN - 1; // Overscan by one pixel to the left } - path.lineTo(x, YMARGIN + h); + path.lineTo(x, yValue(samples.at(i))); + } + path.lineTo(x, YMARGIN + h); + + return x; +} + +void TrafficGraphWidget::focusSlider() +{ + QWidget* parent = parentWidget(); + if (parent) { + QSlider* slider = parent->findChild("sldGraphRange"); + if (slider) slider->setFocus(Qt::OtherFocusReason); + } +} + +void TrafficGraphWidget::mousePressEvent(QMouseEvent* event) +{ + QWidget::mousePressEvent(event); + focusSlider(); + m_toggle = !m_toggle; + m_update = true; + update(); +} + +void TrafficGraphWidget::leaveEvent(QEvent* event) +{ + QWidget::leaveEvent(event); + if (!m_tt_point) return; + m_tt_point = 0; + m_update = true; + update(); +} + +void TrafficGraphWidget::mouseMoveEvent(QMouseEvent* event) +{ + QWidget::mouseMoveEvent(event); + static int last_x = -1, last_y = -1; + QPointF pos = event->position(); + QPointF globalPos = event->globalPosition(); + int x = qRound(pos.x()), y = qRound(pos.y()); + m_x_offset = qRound(globalPos.x()) - x; + m_y_offset = qRound(globalPos.y()) - y; + if (last_x == x && last_y == y) return; // Do nothing if mouse hasn't moved + int w = width() - XMARGIN * 2; + int i = (w + XMARGIN - x) * (DESIRED_SAMPLES - 1) / w, closest_i = 0; + int sampleSize = m_time_stamp[m_value].size(); + unsigned int smallest_distance = 50; + bool is_in_series = true; + for (int test_i = std::max(i - 3, 0); test_i < std::min(i + 9, sampleSize); test_i++) { + float in_val = m_samples_in[m_value].at(test_i), out_val = m_samples_out[m_value].at(test_i); + int y_in = yValue(in_val), y_out = yValue(out_val); + unsigned int distance_in = abs(y - y_in), distance_out = abs(y - y_out); + unsigned int min_distance = std::min(distance_in, distance_out) + abs(test_i - i); + if (min_distance < smallest_distance) { + smallest_distance = min_distance; + closest_i = test_i + 1; + is_in_series = (distance_in < distance_out); + } + } + if (m_tt_point != closest_i || m_tt_in_series != is_in_series) { + m_tt_point = closest_i; + m_tt_in_series = is_in_series; + m_update = true; + update(); // Calls paintEvent() to draw or delete the highlighted point + } + last_x = x; + last_y = y; +} + +void TrafficGraphWidget::drawTooltipPoint(QPainter& painter) +{ + int w = width() - XMARGIN * 2; + double ratio = static_cast(m_tt_point-1) * m_values[m_value] / m_range / (DESIRED_SAMPLES-1); + int x = XMARGIN + static_cast(w - w * ratio + 0.5); + float in_sample = m_samples_in[m_value].at(m_tt_point-1); + float out_sample = m_samples_out[m_value].at(m_tt_point-1); + float selected_sample = m_tt_in_series ? in_sample : out_sample; + int y = yValue(selected_sample); + painter.setPen(Qt::yellow); + painter.drawEllipse(QPointF(x, y), 3, 3); + QString str_tt; + int64_t sample_time = 0; + if (m_tt_point < m_time_stamp[m_value].size()) + sample_time = m_time_stamp[m_value].at(m_tt_point); + if (!sample_time) // Either the oldest sample or the first ever sample + sample_time = m_time_stamp[m_value].at(m_tt_point - 1); + int age = TicksSinceEpoch(SystemClock::now()) - sample_time / 1000; + if (age < 60 * 60 * 23) + str_tt += QString::fromStdString(FormatISO8601Time(sample_time / 1000)); + else + str_tt += QString::fromStdString(FormatISO8601DateTime(sample_time / 1000)); + int duration = (m_time_stamp[m_value].at(m_tt_point - 1) - sample_time); + if (duration > 0) { + if (duration > 9999) + str_tt += " +" + GUIUtil::formatDurationStr(std::chrono::seconds{(duration + 500) / 1000}); + else + str_tt += " +" + GUIUtil::formatPingTime(std::chrono::microseconds{duration * 1000}); + } + str_tt += "\n " + tr("In") + " " + GUIUtil::formatBytesps(m_samples_in[m_value].at(m_tt_point-1) * 1000) + + "\n" + tr("Out") + " " + GUIUtil::formatBytesps(m_samples_out[m_value].at(m_tt_point-1) * 1000); + + // Line below allows ToolTip to move faster than the default ToolTip timeout (10 seconds). + QToolTip::showText(QPoint(x + m_x_offset, y + m_y_offset), str_tt + "."); + QToolTip::showText(QPoint(x + m_x_offset, y + m_y_offset), str_tt); + m_tt_time = GetTime(); +} + +// Helper function to draw text with outline +void DrawOutlinedText(QPainter& painter, int y, const QString& text, int opacity) +{ + // Draw the outline by drawing the text multiple times with small offsets + if (opacity) { + painter.setPen(Qt::black); + for (int dx = -1; dx <= 1; dx++) + for (int dy = -1; dy <= 1; dy++) + if (dx != 0 || dy != 0) + painter.drawText(XMARGIN + dx, y + dy - 2, text); } + + // Draw the main text + painter.setPen(Qt::white); + painter.drawText(XMARGIN, y - 2, text); } void TrafficGraphWidget::paintEvent(QPaintEvent *) { + m_update = false; QPainter painter(this); + int hgt = height(), wid = width(); painter.fillRect(rect(), Qt::black); - if(fMax <= 0.0f) return; - - QColor axisCol(Qt::gray); - int h = height() - YMARGIN * 2; - painter.setPen(axisCol); - painter.drawLine(XMARGIN, YMARGIN + h, width() - XMARGIN, YMARGIN + h); - // decide what order of magnitude we are - int base = std::floor(std::log10(fMax)); - float val = std::pow(10.0f, base); - - const QString units = tr("kB/s"); - const float yMarginText = 2.0; + int base = std::floor(std::log10(m_fmax)); + float val = std::pow(10.0f, base); // kB/s // draw lines + QColor axisCol(Qt::gray); painter.setPen(axisCol); - painter.drawText(XMARGIN, YMARGIN + h - h * val / fMax-yMarginText, QString("%1 %2").arg(val).arg(units)); - for(float y = val; y < fMax; y += val) { - int yy = YMARGIN + h - h * y / fMax; - painter.drawLine(XMARGIN, yy, width() - XMARGIN, yy); - } - // if we drew 3 or fewer lines, break them up at the next lower order of magnitude - if(fMax / val <= 3.0f) { - axisCol = axisCol.darker(); - val = pow(10.0f, base - 1); - painter.setPen(axisCol); - painter.drawText(XMARGIN, YMARGIN + h - h * val / fMax-yMarginText, QString("%1 %2").arg(val).arg(units)); + for(float y = val; y < m_fmax; y += val) { + int yy = yValue(y); + painter.drawLine(XMARGIN, yy, wid - XMARGIN, yy); + } + + // if we drew 10 (or 3 when toggles) or fewer lines, break them up at the next lower order of magnitude + if (m_fmax / val <= (m_toggle ? 10.0f : 3.0f)) { + val /= 10; + painter.setPen(axisCol.darker()); int count = 1; - for(float y = val; y < fMax; y += val, count++) { + for (float y = val; y < (!m_toggle || m_fmax / val < 20 ? m_fmax : val*10); y += val, count++) { // don't overwrite lines drawn above - if(count % 10 == 0) - continue; - int yy = YMARGIN + h - h * y / fMax; - painter.drawLine(XMARGIN, yy, width() - XMARGIN, yy); + if (count % 10 == 0) continue; + int yy = yValue(y); + painter.drawLine(XMARGIN, yy, wid - XMARGIN, yy); + } + if (m_toggle) { + int yy = yValue(val * 0.1); + painter.setPen(axisCol.darker().darker()); + painter.drawLine(XMARGIN, yy, wid - XMARGIN, yy); } } painter.setRenderHint(QPainter::Antialiasing); - if(!vSamplesIn.empty()) { + if (m_samples_in[m_value].size()) { QPainterPath p; - paintPath(p, vSamplesIn); + paintPath(p, m_samples_in[m_value]); painter.fillPath(p, QColor(0, 255, 0, 128)); painter.setPen(Qt::green); painter.drawPath(p); } - if(!vSamplesOut.empty()) { + int x = 0; + if (m_samples_out[m_value].size()) { QPainterPath p; - paintPath(p, vSamplesOut); + x = paintPath(p, m_samples_out[m_value]); painter.fillPath(p, QColor(255, 0, 0, 128)); painter.setPen(Qt::red); painter.drawPath(p); } + + // Draw black bars and lines to mask the overscanned edges of the graph + painter.fillRect(0, 0, XMARGIN - 1, hgt, Qt::black); + painter.fillRect(wid - XMARGIN + 1, 0, XMARGIN, hgt, Qt::black); + painter.setPen(Qt::black); + painter.drawLine(XMARGIN - 1, 0, XMARGIN - 1, hgt); // Antialiased lines to create some blur + painter.drawLine(wid - XMARGIN + 1, 0, wid - XMARGIN + 1, hgt); + + // Draw the bottom axis line after the graph + painter.setPen(axisCol); + painter.setRenderHint(QPainter::Antialiasing, false); + painter.drawLine(XMARGIN, hgt - YMARGIN, wid - XMARGIN, hgt - YMARGIN); + + int opacity = 0; // Opacity of the black outline around the text + if (x < 70) opacity = 255; + // Draw outlined text for speed labels + DrawOutlinedText(painter, yValue(val*10), GUIUtil::formatBytesps(val * 10000), opacity); + DrawOutlinedText(painter, yValue(val), GUIUtil::formatBytesps(val * 1000), opacity); + if (m_toggle) DrawOutlinedText(painter, yValue(val/10), GUIUtil::formatBytesps(val * 100), opacity); + + if (m_tt_point && m_tt_point <= m_time_stamp[m_value].size()) drawTooltipPoint(painter); + else QToolTip::hideText(); } -void TrafficGraphWidget::updateRates() +void TrafficGraphWidget::updateFmax() { - if(!clientModel) return; + float tmax = 0.0f; + for (const float f : m_samples_in[m_new_value]) + if (f > tmax) tmax = f; + for (const float f : m_samples_out[m_new_value]) + if (f > tmax) tmax = f; + m_new_fmax = std::max(tmax, 0.0001f); +} + +/** + * Smoothly updates a value with acceleration/deceleration for animation. + * + * @param target The target value to approach + * @param current The current value that will be updated + * @param increment The current rate of change (velocity), updated by this function + * @param length The scale factor for controlling animation speed + * @return true if the value was updated, false otherwise + * + * This implements a simple physics-based approach to animation: + * - If moving too slowly, accelerate + * - If moving too quickly, decelerate + * - If close enough to target, snap to it + */ +bool UpdateNum(float target, float& current, float& increment, int length) +{ + if (current == target) return false; - quint64 bytesIn = clientModel->node().getTotalBytesRecv(), - bytesOut = clientModel->node().getTotalBytesSent(); - float in_rate_kilobytes_per_sec = static_cast(bytesIn - nLastBytesIn) / timer->interval(); - float out_rate_kilobytes_per_sec = static_cast(bytesOut - nLastBytesOut) / timer->interval(); - vSamplesIn.push_front(in_rate_kilobytes_per_sec); - vSamplesOut.push_front(out_rate_kilobytes_per_sec); - nLastBytesIn = bytesIn; - nLastBytesOut = bytesOut; + const float threshold = abs(0.8f * current) / length; + const float diff = target - current; - while(vSamplesIn.size() > DESIRED_SAMPLES) { - vSamplesIn.pop_back(); + // Initialize or adjust increment based on current state + if (abs(increment) <= threshold) { // allow equal to as current and increment could be zero + increment = ((current + 1) * (diff > 0 ? 1.0f : -1.0f)) / length; // +1s are to get it started even if current is zero + if (abs(increment) > abs(diff)) { // Only check this when creating an increment + increment = 0; // We have arrived at the target + current = target; + return true; + } + } else { + // Adjust increment based on distance to target + if ((increment > 0 && current + increment * 2 > target) || + (increment < 0 && current + increment * 2 < target)) { + increment *= 0.5f; + } else if ((increment > 0 && current + increment * 8 < target) || + (increment < 0 && current + increment * 8 > target)) { + increment *= 2.0f; + } } - while(vSamplesOut.size() > DESIRED_SAMPLES) { - vSamplesOut.pop_back(); + + // Update current value if increment is significant + if (abs(increment) >= threshold) { + current += increment; + } else if ((increment >= 0 && target > current) || (increment <= 0 && target < current)) { + current = target; + increment = 0; } - float tmax = 0.0f; - for (const float f : vSamplesIn) { - if(f > tmax) tmax = f; + // Ensure minimum value for graph display + if (current <= 0.0f) current = 0.0001f; + + return true; +} + +void TrafficGraphWidget::updateStuff() +{ + if (!m_client_model) return; + + int64_t expected_gap = m_timer->interval(); + int64_t now = TicksSinceEpoch(SystemClock::now()); + bool latest_bytes = false; + quint64 bytes_in = 0, bytes_out = 0; + + // Check for new sample and update display if a new sample is taken for current range + for (int i = 0; i < VALUES_SIZE; i++) { + int64_t msecs_per_sample = static_cast(m_values[i]) * 60000 / DESIRED_SAMPLES; + if (now > (m_last_time[i] + msecs_per_sample - expected_gap / 2)) { + if (!latest_bytes) { + latest_bytes = true; + bytes_in = m_client_model->node().getTotalBytesRecv() + m_baseline_bytes_recv; + bytes_out = m_client_model->node().getTotalBytesSent() + m_baseline_bytes_sent; + } + updateRates(i, now, bytes_in, bytes_out); + if (i == m_value) { + if (m_tt_point && m_tt_point <= DESIRED_SAMPLES) { + m_tt_point++; // Move the selected point to the left + if (m_tt_point > DESIRED_SAMPLES) m_tt_point = 0; + } + m_update = true; + } + if (i == m_new_value) updateFmax(); + } + } + + // Update display due to transition between ranges or new fmax + static float y_increment = 0, x_increment = 0; + if (UpdateNum(m_new_fmax, m_fmax, y_increment, 300)) m_update = true; + int next_m_value = m_value; + if (UpdateNum(m_values[m_new_value], m_range, x_increment, 500)) { + m_update = true; + if (m_values[m_new_value] > m_range && m_values[m_value] < m_range) { + next_m_value = m_value + 1; + } else if (m_new_value < m_value && m_values[m_value - 1] > m_range * 0.99) + next_m_value = m_value - 1; + } else if (m_value != m_new_value) { + m_update = true; + next_m_value = m_new_value; } - for (const float f : vSamplesOut) { - if(f > tmax) tmax = f; + + if (next_m_value != m_value) { + m_tt_point = findClosestPointByTimestamp(next_m_value); + m_value = next_m_value; + } + + static bool last_m_toggle = m_toggle; + if (!QToolTip::isVisible()) { + if (m_tt_point) { // Remove the yellow circle if the ToolTip has gone due to mouse moving elsewhere. + if (last_m_toggle == m_toggle) m_tt_point = 0; + else last_m_toggle = m_toggle; + m_update = true; + } + } else if (m_tt_point && GetTime() >= m_tt_time + 9) m_update = true; + + if (m_update) update(); + static bool graph_visible = false; + if (isVisible() && !window()->isMinimized()) { + if (!graph_visible) focusSlider(); + graph_visible = true; + } else graph_visible = false; +} + +void TrafficGraphWidget::updateRates(int i, int64_t now, quint64 bytes_in, quint64 bytes_out) +{ + int64_t actual_gap = now - m_last_time[i]; + float in_rate_kilobytes_per_msec = static_cast(bytes_in - m_last_bytes_in[i]) / actual_gap; + float out_rate_kilobytes_per_msec = static_cast(bytes_out - m_last_bytes_out[i]) / actual_gap; + m_samples_in[i].push_front(in_rate_kilobytes_per_msec); + m_samples_out[i].push_front(out_rate_kilobytes_per_msec); + m_time_stamp[i].push_front(now); + m_last_bytes_in[i] = bytes_in; + m_last_bytes_out[i] = bytes_out; + m_last_time[i] = now; + static int8_t full[VALUES_SIZE] = {}; + if (full[i] == 0 && m_time_stamp[i].size() <= DESIRED_SAMPLES) full[i] = -1; + while (m_time_stamp[i].size() > DESIRED_SAMPLES) { + if (m_value == i && i < VALUES_SIZE - 1 && full[i] < 0) m_bump = true; + full[i] = 1; + m_samples_in[i].pop_back(); + m_samples_out[i].pop_back(); + m_time_stamp[i].pop_back(); + } +} + +int TrafficGraphWidget::setGraphRange(int value) +{ + // value is the array marker plus 1 (as zero is reserved for bumping up) + if (!value) { // bump + m_bump = false; // Clear the bump flag + value = m_value + 1; + } else + value--; // get the array marker + int old_value = m_new_value; + m_new_value = std::min(value, VALUES_SIZE - 1); + if (m_new_value != old_value) updateFmax(); + + return m_values[m_new_value]; +} + +void TrafficGraphWidget::saveData() +{ + if (m_time_stamp[0].empty() || m_data_dir.empty()) return; + try { + fs::path pathTrafficGraph = fs::path(m_data_dir.c_str()) / "trafficgraph.dat"; + FILE* file = fsbridge::fopen(pathTrafficGraph, "wb"); + if (!file) { + LogPrintf("TrafficGraphWidget: Failed to open file for writing: %s\n", pathTrafficGraph.generic_string()); + throw std::runtime_error("Failed to open file"); + } + AutoFile fileout(file); + if (fileout.IsNull()) throw std::runtime_error("File stream is null"); + fileout << static_cast(1); // Version 1 + + // Get current node values and add them to our baseline + if (m_node) { + m_baseline_bytes_recv += m_node->getTotalBytesRecv(); + m_baseline_bytes_sent += m_node->getTotalBytesSent(); + } + + fileout << VARINT(m_baseline_bytes_recv) << VARINT(m_baseline_bytes_sent); + + for (unsigned int i = 0; i < VALUES_SIZE; i++) { + fileout << VARINT(m_last_bytes_in[i]) << VARINT(m_last_bytes_out[i]); + + fileout << VARINT(static_cast(m_time_stamp[i].size())); + + for (int j = 0; j < m_time_stamp[i].size(); j++) { + fileout << static_cast(m_time_stamp[i].at(j)); + } + + for (int j = 0; j < m_samples_in[i].size(); j++) { + float value = m_samples_in[i].at(j); + uint32_t uint_value; + memcpy(&uint_value, &value, sizeof(float)); // IEEE 754 + fileout << uint_value; + } + + for (int j = 0; j < m_samples_out[i].size(); j++) { + float value = m_samples_out[i].at(j); + uint32_t uint_value; + memcpy(&uint_value, &value, sizeof(float)); // IEEE 754 + fileout << uint_value; + } + } + + fileout.fclose(); + LogPrintf("TrafficGraphWidget: Successfully saved traffic graph data to %s\n", pathTrafficGraph.generic_string()); + } catch (const std::exception& e) { + LogPrintf("TrafficGraphWidget: Error saving data: %s (path: %s)\n", + e.what(), m_data_dir); + } +} + +bool TrafficGraphWidget::loadDataFromBinary() +{ + try { + fs::path pathTrafficGraph = fs::path(m_data_dir.c_str()) / "trafficgraph.dat"; + LogPrintf("TrafficGraphWidget: Attempting to load data from %s\n", pathTrafficGraph.generic_string()); + + FILE* file = fsbridge::fopen(pathTrafficGraph, "rb"); + if (!file) { + LogPrintf("TrafficGraphWidget: File not found or could not be opened\n"); + return false; + } + AutoFile filein(file); + if (filein.IsNull()) return false; + + int version; + filein >> version; + if (version < 1 || version > 1) return false; + + filein >> VARINT(m_baseline_bytes_recv) >> VARINT(m_baseline_bytes_sent); + + uint64_t current_time = TicksSinceEpoch(SystemClock::now()); + + for (unsigned int i = 0; i < VALUES_SIZE; i++) { + filein >> VARINT(m_last_bytes_in[i]) >> VARINT(m_last_bytes_out[i]); + + uint16_t samplesSize; + filein >> VARINT(samplesSize); + + for (unsigned int j = 0; j < samplesSize; j++) { + static uint64_t last_time_ms; + uint64_t time_ms; + filein >> time_ms; + if (!j) m_last_time[i] = last_time_ms = time_ms; + if (time_ms > last_time_ms || time_ms > current_time) return false; // Abort load if data invalid or in future + m_time_stamp[i].push_back(static_cast(time_ms)); + last_time_ms = time_ms; + } + + for (unsigned int j = 0; j < samplesSize; j++) { + uint32_t uint_value; + filein >> uint_value; + float value; + memcpy(&value, &uint_value, sizeof(float)); + m_samples_in[i].push_back(value); + } + + for (unsigned int j = 0; j < samplesSize; j++) { + uint32_t uint_value; + filein >> uint_value; + float value; + memcpy(&value, &uint_value, sizeof(float)); + m_samples_out[i].push_back(value); + } + } + filein.fclose(); + return true; + + } catch (const std::exception& e) { + LogPrintf("TrafficGraphWidget: Error loading data: %s\n", e.what()); + return false; } - fMax = tmax; - update(); } -void TrafficGraphWidget::setGraphRange(std::chrono::minutes new_range) +bool TrafficGraphWidget::loadData() { - m_range = new_range; - const auto msecs_per_sample{std::chrono::duration_cast(m_range) / DESIRED_SAMPLES}; - timer->stop(); - timer->setInterval(msecs_per_sample); + bool success = loadDataFromBinary(); + + if (!success) { // Zero the values + LogPrintf("TrafficGraphWidget: Saved traffic data was invalid.\n"); + m_baseline_bytes_recv = m_baseline_bytes_sent = 0; + for (int i = 0; i < VALUES_SIZE; i++) { + m_last_bytes_in[i] = m_last_bytes_out[i] = m_last_time[i] = 0; + m_samples_in[i].clear(); + m_samples_out[i].clear(); + m_time_stamp[i].clear(); + } + return false; + } - clear(); + // If we successfully loaded data, determine the correct band to use + int firstNonFullBand = VALUES_SIZE - 1; + + for (int i = 0; i < VALUES_SIZE; i++) { + if (m_time_stamp[i].size() < DESIRED_SAMPLES) { + firstNonFullBand = i; + break; + } + } + + if (firstNonFullBand) { // not the first band + m_value = firstNonFullBand - 1; // Minus one as we're bumping it + m_bump = true; // Set the slider to the new range + } + + return true; } -void TrafficGraphWidget::clear() +int TrafficGraphWidget::findClosestPointByTimestamp(int dst_range) const { - timer->stop(); + if (!m_tt_point || m_tt_point > m_time_stamp[m_value].size() || + m_time_stamp[dst_range].empty()) { + return 0; + } + + int src_point = m_tt_point - 1; + bool is_peak = false, is_dip = false; + float src_value = m_tt_in_series ? m_samples_in[m_value].at(src_point) : + m_samples_out[m_value].at(src_point); + int64_t src_timestamp = m_time_stamp[m_value].at(src_point); + + if (src_point > 0 && src_point < m_time_stamp[m_value].size() - 1) { + float prev_value = m_tt_in_series ? m_samples_in[m_value].at(src_point - 1) : + m_samples_out[m_value].at(src_point - 1); + float next_value = m_tt_in_series ? m_samples_in[m_value].at(src_point + 1) : + m_samples_out[m_value].at(src_point + 1); + + is_peak = src_value > prev_value && src_value > next_value; + is_dip = src_value < prev_value && src_value < next_value; + } + + int dst_point = 0; + uint64_t avg_sample_interval = (m_values[dst_range] * 60 * 1000) / DESIRED_SAMPLES; + int64_t time_window = avg_sample_interval * 3; + int64_t min_difference = time_window * 2; + + // Find the nearest point timestamp-wise + for (int i = 0; i < m_time_stamp[dst_range].size(); ++i) { + auto diff = std::abs(m_time_stamp[dst_range].at(i) - src_timestamp); + if (diff < min_difference) { + min_difference = diff; + dst_point = i; + } + } + + // Exit early if no point found or not a peak nor a dip + if (!dst_point || (!is_peak && !is_dip)) return dst_point; - vSamplesOut.clear(); - vSamplesIn.clear(); - fMax = 0.0f; + // If a peak/dip, snap to a nearby peak/dip if one exists + float dst_value = m_tt_in_series ? m_samples_in[dst_range].at(dst_point - 1) : + m_samples_out[dst_range].at(dst_point - 1); + float best_value = dst_value; + int best_point = dst_point - 1; - if(clientModel) { - nLastBytesIn = clientModel->node().getTotalBytesRecv(); - nLastBytesOut = clientModel->node().getTotalBytesSent(); + for (int i = best_point - 3; i <= best_point + 3; ++i) { + if (i < 0 || i >= m_time_stamp[dst_range].size()) continue; + if (std::abs(m_time_stamp[dst_range].at(i) - src_timestamp) > time_window) continue; + float value = m_tt_in_series ? m_samples_in[dst_range].at(i) : m_samples_out[dst_range].at(i); + if (is_peak && value > best_value) { + dst_point = i + 1; + best_value = value; + } else if (is_dip && value < best_value) { + dst_point = i + 1; + best_value = value; + } } - timer->start(); + + return dst_point; } diff --git a/src/qt/trafficgraphwidget.h b/src/qt/trafficgraphwidget.h index 5e5557ec82a..e6e7382c6b6 100644 --- a/src/qt/trafficgraphwidget.h +++ b/src/qt/trafficgraphwidget.h @@ -5,8 +5,10 @@ #ifndef BITCOIN_QT_TRAFFICGRAPHWIDGET_H #define BITCOIN_QT_TRAFFICGRAPHWIDGET_H -#include +#include +#include #include +#include #include @@ -17,34 +19,68 @@ class QPaintEvent; class QTimer; QT_END_NAMESPACE +static constexpr int VALUES_SIZE = 13; + class TrafficGraphWidget : public QWidget { Q_OBJECT public: - explicit TrafficGraphWidget(QWidget *parent = nullptr); - void setClientModel(ClientModel *model); - std::chrono::minutes getGraphRange() const; + explicit TrafficGraphWidget(QWidget* parent = nullptr); + void setClientModel(ClientModel* model); + bool graphRangeBump() const { return m_bump; } + unsigned int getCurrentRangeIndex() const { return m_new_value; } + quint64 getBaselineBytesRecv() const { return m_baseline_bytes_recv; } + quint64 getBaselineBytesSent() const { return m_baseline_bytes_sent; } protected: - void paintEvent(QPaintEvent *) override; + void paintEvent(QPaintEvent*) override; + int yValue(float) const; + void mouseMoveEvent(QMouseEvent*) override; + void mousePressEvent(QMouseEvent*) override; + void leaveEvent(QEvent*) override; + int findClosestPointByTimestamp(int) const; public Q_SLOTS: - void updateRates(); - void setGraphRange(std::chrono::minutes new_range); - void clear(); + void updateStuff(); + int setGraphRange(int); private: - void paintPath(QPainterPath &path, QQueue &samples); - - QTimer* timer{nullptr}; - float fMax{0.0f}; - std::chrono::minutes m_range{0}; - QQueue vSamplesIn; - QQueue vSamplesOut; - quint64 nLastBytesIn{0}; - quint64 nLastBytesOut{0}; - ClientModel* clientModel{nullptr}; + void saveData(); + int paintPath(QPainterPath&, const QQueue&); + bool loadDataFromBinary(); + bool loadData(); + void updateFmax(); + void updateRates(int, int64_t, quint64, quint64); + void focusSlider(); + void drawTooltipPoint(QPainter&); + + QTimer* m_timer{nullptr}; + float m_fmax{1.1f}; + float m_new_fmax{1.1f}; + float m_range{0}; + QQueue m_samples_in[VALUES_SIZE] = {}; + QQueue m_samples_out[VALUES_SIZE] = {}; + QQueue m_time_stamp[VALUES_SIZE] = {}; + quint64 m_last_bytes_in[VALUES_SIZE] = {}; + quint64 m_last_bytes_out[VALUES_SIZE] = {}; + int64_t m_last_time[VALUES_SIZE] = {}; + ClientModel* m_client_model{nullptr}; + int m_value{0}; + int m_new_value{0}; + bool m_bump{false}; + bool m_toggle{true}; // Default to logarithmic + bool m_update{false}; // whether to redraw graph + int m_tt_point{0}; // 0 = no tooltip (array index + 1) + bool m_tt_in_series{true}; // true = in, false = out + int m_x_offset{0}; + int m_y_offset{0}; + int64_t m_tt_time{0}; + int m_values[VALUES_SIZE] = {5, 10, 20, 45, 90, 3*60, 6*60, 12*60, 24*60, 3*24*60, 7*24*60, 14*24*60, 28*24*60}; + std::string m_data_dir; + interfaces::Node* m_node; + quint64 m_baseline_bytes_recv{0}; + quint64 m_baseline_bytes_sent{0}; }; #endif // BITCOIN_QT_TRAFFICGRAPHWIDGET_H diff --git a/src/util/time.cpp b/src/util/time.cpp index cafc27e0d05..5507caff972 100644 --- a/src/util/time.cpp +++ b/src/util/time.cpp @@ -75,6 +75,14 @@ void MockableSteadyClock::ClearMockTime() int64_t GetTime() { return GetTime().count(); } +std::string FormatISO8601Time(int64_t nTime) +{ + const std::chrono::sys_seconds secs{std::chrono::seconds{nTime}}; + const auto days{std::chrono::floor(secs)}; + const std::chrono::hh_mm_ss hms{secs - days}; + return strprintf("%02i:%02i:%02iZ", hms.hours().count(), hms.minutes().count(), hms.seconds().count()); +} + std::string FormatISO8601DateTime(int64_t nTime) { const std::chrono::sys_seconds secs{std::chrono::seconds{nTime}}; diff --git a/src/util/time.h b/src/util/time.h index c43b306ff24..ef7e8ac00cb 100644 --- a/src/util/time.h +++ b/src/util/time.h @@ -130,6 +130,7 @@ T GetTime() * ISO 8601 formatting is preferred. Use the FormatISO8601{DateTime,Date} * helper functions if possible. */ +std::string FormatISO8601Time(int64_t nTime); std::string FormatISO8601DateTime(int64_t nTime); std::string FormatISO8601Date(int64_t nTime); std::optional ParseISO8601DateTime(std::string_view str);