From b64d57f6a4d8da8c72d48ed9ea8587201bf35805 Mon Sep 17 00:00:00 2001 From: "Valentin V. Bartenev" Date: Sat, 9 May 2026 19:58:36 +0300 Subject: [PATCH] Web: split "packets" series into separate RX/TX components The "packets" trend series previously emitted a single combined packet-count delta per interval. This change breaks it into two separate values so the sparkline can show directional traffic. Backend (StatsHistory::buildSeriesJson): - Add a dedicated branch for series == "packets" in the second (JSON-emission) loop. - Each point is now emitted as [uptime_secs, rx_delta, tx_delta] instead of [uptime_secs, total_delta]. - rx_delta / tx_delta are computed as non-wrapping uint32_t differences of packets_recv / packets_sent between consecutive samples, consistent with the existing buildPointValue logic. - Buffer overflow guard updated from +24 to +40 bytes to cover the wider three-integer format. Frontend (WebPanelServer / inline JS): - drawSparkline: for "packets", compute Y scale and bar height from rx+tx total; render a stacked bar with TX on top (cyan, #06b6d4 / hover #22d3ee) and RX below (violet, #8b5cf6 / hover #a78bfa), proportioned by the rx:tx ratio. - bindSparkHover tooltip: show "RX: N TX: M" for "packets" instead of the generic formatTrendValue output. - Remove the now-unused sparkStrokeColor early-return for "packets" (stroke color is not used in the bar path). --- src/helpers/StatsHistory.cpp | 55 +++++++++++++++++++++--------- src/helpers/web/WebPanelServer.cpp | 54 +++++++++++++++++++++-------- 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index 058988b9..7f2c129b 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -1330,25 +1330,48 @@ bool StatsHistory::buildSeriesJson(const char* series, char* buffer, size_t buff emitted = 0; size_t valid_emitted = 0; have_previous = false; - for (size_t i = 0; i < _sample_count && emitted < points; i += step, ++emitted) { - if (!getSampleFromOldest(i, sample)) { - break; - } - int value = 0; - const bool have_value = buildPointValue(sample, have_previous ? &previous : nullptr, series, value); - if (have_value) { - offset += snprintf(&buffer[offset], buffer_size - offset, - "%s[%lu,%d]", - valid_emitted == 0 ? "" : ",", - static_cast(sample.uptime_secs), - value); - valid_emitted++; - if (offset + 24 >= buffer_size) { + if (strcmp(series, "packets") == 0) { + for (size_t i = 0; i < _sample_count && emitted < points; i += step, ++emitted) { + if (!getSampleFromOldest(i, sample)) { break; } + const int rx = (have_previous && sample.packets_recv >= previous.packets_recv) + ? (int)(sample.packets_recv - previous.packets_recv) : 0; + const int tx = (have_previous && sample.packets_sent >= previous.packets_sent) + ? (int)(sample.packets_sent - previous.packets_sent) : 0; + offset += snprintf(&buffer[offset], buffer_size - offset, + "%s[%lu,%d,%d]", + valid_emitted == 0 ? "" : ",", + static_cast(sample.uptime_secs), + rx, tx); + valid_emitted++; + if (offset + 40 >= buffer_size) { + break; + } + previous = sample; + have_previous = true; + } + } else { + for (size_t i = 0; i < _sample_count && emitted < points; i += step, ++emitted) { + if (!getSampleFromOldest(i, sample)) { + break; + } + int value = 0; + const bool have_value = buildPointValue(sample, have_previous ? &previous : nullptr, series, value); + if (have_value) { + offset += snprintf(&buffer[offset], buffer_size - offset, + "%s[%lu,%d]", + valid_emitted == 0 ? "" : ",", + static_cast(sample.uptime_secs), + value); + valid_emitted++; + if (offset + 24 >= buffer_size) { + break; + } + } + previous = sample; + have_previous = true; } - previous = sample; - have_previous = true; } snprintf(&buffer[offset], buffer_size - offset, "]}"); diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 68004a7d..cf2f678d 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -1804,7 +1804,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( return Math.round(value) + " B"; } function sparkStrokeColor(key, points) { - if (key === "packets") return "#d97706"; if (key === "mcu_temp") { const values = Array.isArray(points) ? points.map((item) => item && item[1]).filter((v) => Number.isFinite(v)) @@ -1829,7 +1828,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( return "#2f8f4e"; } function sparkHoverColor(key, baseColor) { - if (key === "packets") return "#f59e0b"; + if (key === "packets") return "#a78bfa"; if (key === "gps_satellites") return "#48b267"; return baseColor || "#2f8f4e"; } @@ -1906,7 +1905,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( ctx.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0); ctx.clearRect(0, 0, width, height); if (!Array.isArray(points) || points.length < 1) return; - const values = points.map((item) => item[1]).filter((value) => Number.isFinite(value)); + const values = points.map((item) => + key === "packets" ? ((item[1] || 0) + (item[2] || 0)) : item[1] + ).filter((value) => Number.isFinite(value)); if (values.length < 1) return; const range = sparkValueRange(key, values); const minValue = range.min; @@ -1944,21 +1945,40 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( x: (key === "packets" || key === "gps_satellites") ? (plotLeft + (slotWidth * index) + (slotWidth / 2)) : ((index / Math.max(1, points.length - 1)) * (plotRight - plotLeft) + plotLeft), - y: scaleY(point[1]) + y: (key === "packets") + ? scaleY((point[1] || 0) + (point[2] || 0)) + : scaleY(point[1]) })); const strokeColor = sparkStrokeColor(key, points); if (key === "packets" || key === "gps_satellites") { const barWidth = Math.max(3, Math.min(18, slotWidth * 0.68)); - const hoverColor = sparkHoverColor(key, strokeColor); - coords.forEach((point, index) => { - const left = plotLeft + (slotWidth * index) + ((slotWidth - barWidth) / 2); - const top = point.y; - const barHeight = Math.max(0, plotBottom - top); - ctx.fillStyle = Number.isInteger(hoverIndex) && hoverIndex === index ? hoverColor : strokeColor; - if (barHeight > 0) { - ctx.fillRect(left, top, barWidth, barHeight); - } - }); + if (key === "packets") { + coords.forEach((point, index) => { + const left = plotLeft + (slotWidth * index) + ((slotWidth - barWidth) / 2); + const isHover = Number.isInteger(hoverIndex) && hoverIndex === index; + const rx = points[index][1] || 0; + const tx = points[index][2] || 0; + const totalTop = point.y; + const totalH = Math.max(0, plotBottom - totalTop); + if (totalH > 0) { + const rxH = Math.round(totalH * ((rx + tx) > 0 ? rx / (rx + tx) : 0.5)); + const txH = totalH - rxH; + ctx.fillStyle = isHover ? "#22d3ee" : "#06b6d4"; + ctx.fillRect(left, totalTop, barWidth, txH); + ctx.fillStyle = isHover ? "#a78bfa" : "#8b5cf6"; + ctx.fillRect(left, totalTop + txH, barWidth, rxH); + } + }); + } else { + const hoverColor = sparkHoverColor(key, strokeColor); + coords.forEach((point, index) => { + const left = plotLeft + (slotWidth * index) + ((slotWidth - barWidth) / 2); + const top = point.y; + const barHeight = Math.max(0, plotBottom - top); + ctx.fillStyle = Number.isInteger(hoverIndex) && hoverIndex === index ? hoverColor : strokeColor; + if (barHeight > 0) ctx.fillRect(left, top, barWidth, barHeight); + }); + } return; } ctx.lineWidth = 2; @@ -1987,7 +2007,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( tooltip.classList.remove("visible"); return; } - tooltip.textContent = formatTrendValue(key, points[index][1]); + if (key === "packets") { + tooltip.textContent = "RX: " + (points[index][1] || 0) + " TX: " + (points[index][2] || 0); + } else { + tooltip.textContent = formatTrendValue(key, points[index][1]); + } tooltip.classList.add("visible"); }; canvas.onmousemove = (event) => {