From 450d34d5522a76fe54cb9d3cde8c1b18fbb695ff Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Sun, 29 Mar 2026 18:22:56 -0600 Subject: [PATCH] changes --- docs-es/v2.0/node-manager-ui-es.md | 3 + docs/v2.0/node-manager-ui.md | 3 + .../src/node_manager/js_build/src/circuit.js | 73 +++++----- .../src/graphs/circuit_device_chart_theme.js | 136 ++++++++++++++++++ .../src/graphs/circuit_retransmit_graph.js | 14 +- .../src/graphs/circuit_throughput_graph.js | 14 +- .../js_build/src/graphs/device_ping_graph.js | 11 ++ .../src/graphs/windowed_latency_histogram.js | 11 ++ .../src/node_manager/static2/circuit.html | 18 ++- .../src/node_manager/static2/node_manager.css | 107 ++++++++++++++ .../single_user_channels/flows_by_circuit.rs | 114 ++++++++++++++- .../flow_data/flow_tracker.rs | 13 +- 12 files changed, 456 insertions(+), 61 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_device_chart_theme.js diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 4997ef9b..5c18c91c 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -65,11 +65,14 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - Las páginas de circuito combinan comportamiento de colas, throughput en vivo, RTT, retransmisiones y troubleshooting por flujo para un suscriptor/circuito individual. - Cuando los metadatos de la integración informan la velocidad Ethernet negociada del CPE, la fila `Max` puede mostrar una insignia de advertencia como `100M`; al pasar el cursor sobre la insignia se explica cuándo LibreQoS redujo automáticamente el shaping por debajo del plan solicitado para respetar ese límite del puerto, y al hacer clic en la insignia se abre la página de revisión Ethernet. - `Queue Dynamics` muestra el comportamiento del throughput y RTT del circuito a lo largo del tiempo, incluyendo un KPI de `Active Flows` basado en la misma ventana reciente usada por la tabla `Traffic Flows`. +- `Devices` usa el mismo tratamiento de panel/tarjeta envolvente que las otras pestañas del Circuit page, manteniendo al mismo tiempo la tabla de detalle por dispositivo y el diseño 2x2 de gráficos en vivo. - `Queue Stats` muestra los 3 minutos más recientes del historial en vivo de la cola del circuito como muestras scatter crudas de 1 segundo, incluyendo backlog, delay, longitud de cola, tráfico, marcas ECN y drops. - Los gráficos de Queue Stats usan hover sincronizado para inspeccionar el mismo segundo en todos los gráficos de cola al mismo tiempo. - `Queue Tree` muestra la ruta ascendente de colas del circuito, incluyendo un resumen de ruta y contexto de throughput, retransmisiones y latencia para cada nodo aguas arriba. - `Traffic Flows` es una tabla operativa de flujos recientes, no una vista de historial a largo plazo. - `Traffic Flows` incluye paginación y un filtro `Hide Small Flows` para que los circuitos grandes y ocupados sigan siendo utilizables sin intentar renderizar cada fila. +- La tasa actual de `Traffic Flows` usa tasas de visualización suministradas por el backend y conscientes del plan, por lo que la página de circuito no tiene que inferir tasas por flujo a partir de deltas de bytes calculadas en el cliente. +- El texto largo en las columnas `Protocol`, `ASN` y `Country` se recorta con puntos suspensivos para mantener estable la altura de cada fila; el valor completo sigue disponible al pasar el cursor. - `Flow Sankey` enfatiza los flujos recientes más activos en lugar de todos los flujos retenidos más antiguos. ### Ethernet Caps diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index 5f05c06b..c9c5503c 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -68,11 +68,14 @@ This page documents key WebUI (Node Manager) views and operational behavior in t - Circuit pages combine queue behavior, live throughput, RTT, retransmits, and per-flow troubleshooting for an individual subscriber/circuit. - When integration metadata reports a negotiated CPE Ethernet speed, the `Max` row can show a warning badge such as `100M`; hovering the badge explains when LibreQoS auto-capped shaping below the requested plan to stay within that port speed, and clicking the badge opens the Ethernet review page. - `Queue Dynamics` shows circuit throughput and RTT behavior over time, including an `Active Flows` KPI based on the same recent flow window used by the `Traffic Flows` table. +- `Devices` uses the same enclosed panel/card treatment as the other Circuit tabs, while keeping the per-device detail table and 2x2 live chart layout. - `Queue Stats` shows the most recent 3 minutes of live queue history for the circuit as raw 1-second scatter samples, including backlog, delay, queue length, traffic, ECN marks, and drops. - Queue Stats charts use synchronized hover so operators can inspect the same second across all queue charts together. - `Queue Tree` shows the circuit's live upstream queue path, including a path summary and per-node throughput, retransmit, and latency context. - `Traffic Flows` is a recent-flow operational table rather than a long-term history view. - `Traffic Flows` includes paging and a `Hide Small Flows` filter so large busy circuits remain usable without trying to render every row at once. +- `Traffic Flows` current-rate display uses backend-provided, plan-aware display rates so the Circuit page does not have to infer per-flow rates from client-side byte deltas. +- Long text in the `Protocol`, `ASN`, and `Country` columns is truncated with an ellipsis to keep row height stable; the full value remains available on hover. - `Flow Sankey` emphasizes the hottest recent flows rather than every older retained flow. ### Ethernet Caps diff --git a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js index d7cdd3bc..e2f382c2 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js @@ -1309,21 +1309,12 @@ function initFlowFilters() { } let movingAverages = new Map(); -let prevFlowBytes = new Map(); -let tickCount = 0; let trafficSortColumn = 'rate'; // Default sort by rate let trafficSortDirection = 'desc'; // 'asc' or 'desc' let latestTrafficRows = []; let trafficCurrentPage = 1; let trafficPageSize = DEFAULT_TRAFFIC_PAGE_SIZE; -function diffToNumber(current, previous, fallback = 0) { - if (typeof current === "bigint" && typeof previous === "bigint") { - return toNumber(current - previous, fallback); - } - return toNumber(current, fallback) - toNumber(previous, fallback); -} - function formatQooScore(score0to100, fallback = "-") { if (score0to100 === null || score0to100 === undefined) { return fallback; @@ -1357,6 +1348,22 @@ function formatRttPair(p50Nanos, p95Nanos) { return formatRttNanos(p50) + " / " + scaleNanos(p95); } +function truncatedTrafficCell(text, cellClass) { + const td = document.createElement("td"); + if (cellClass) { + td.classList.add(cellClass); + } + + const value = String(text || ""); + td.title = value; + + const span = document.createElement("span"); + span.classList.add("lqos-table-cell-ellipsis"); + span.textContent = value; + td.appendChild(span); + return td; +} + function visibleTrafficRows() { if (!hideSmallFlowsEnabled()) { return latestTrafficRows; @@ -1410,7 +1417,6 @@ function updateTrafficCountBadge() { } function ingestTrafficRows(msg) { - tickCount++; const rows = []; const seenFlowKeys = new Set(); const flowList = Array.isArray(msg?.flows) ? msg.flows : []; @@ -1419,28 +1425,11 @@ function ingestTrafficRows(msg) { const flowKey = `${flow?.[0]?.protocol_name || ""}${flow?.[0]?.row_id || ""}`; seenFlowKeys.add(flowKey); - let down = toNumber(flow?.[1]?.rate_estimate_bps?.down, 0); - let up = toNumber(flow?.[1]?.rate_estimate_bps?.up, 0); - const prev = prevFlowBytes.get(flowKey); - if (prev) { - const ticks = tickCount - prev.tick; - if (ticks === 1) { - down = diffToNumber(flow[1].bytes_sent.down, prev.downBytes, 0) * 8; - up = diffToNumber(flow[1].bytes_sent.up, prev.upBytes, 0) * 8; - } else if (ticks > 1) { - down = diffToNumber(flow[1].bytes_sent.down, prev.downBytes, 0) * 8 / ticks; - up = diffToNumber(flow[1].bytes_sent.up, prev.upBytes, 0) * 8 / ticks; - } - } + let down = toNumber(flow?.[1]?.display_rate_bps?.down, toNumber(flow?.[1]?.rate_estimate_bps?.down, 0)); + let up = toNumber(flow?.[1]?.display_rate_bps?.up, toNumber(flow?.[1]?.rate_estimate_bps?.up, 0)); if (down < 0) down = 0; if (up < 0) up = 0; - prevFlowBytes.set(flowKey, { - downBytes: flow?.[1]?.bytes_sent?.down, - upBytes: flow?.[1]?.bytes_sent?.up, - tick: tickCount, - }); - const currentRate = down + up; const average = movingAverages.get(flowKey) || { values: [], total: 0 }; average.values.push(currentRate); @@ -1503,9 +1492,8 @@ function ingestTrafficRows(msg) { }); }); - Array.from(prevFlowBytes.keys()).forEach((key) => { + Array.from(movingAverages.keys()).forEach((key) => { if (!seenFlowKeys.has(key)) { - prevFlowBytes.delete(key); movingAverages.delete(key); } }); @@ -1530,7 +1518,7 @@ function renderTrafficTab() { tableWrap.classList.add("lqos-table-wrap"); let table = document.createElement("table"); - table.classList.add("lqos-table", "lqos-table-tight"); + table.classList.add("lqos-table", "lqos-table-tight", "lqos-circuit-traffic-table"); let thead = document.createElement("thead", "small"); thead.style.fontSize = "0.8em"; @@ -1581,7 +1569,7 @@ function renderTrafficTab() { row.classList.add("small"); row.style.opacity = rowData.opacity; - row.appendChild(simpleRow(rowData.protocolName)); + row.appendChild(truncatedTrafficCell(rowData.protocolName, "lqos-circuit-traffic-protocol-cell")); row.appendChild(simpleRowHtml(formatThroughput(rowData.downBps, plan.down))); row.appendChild(simpleRowHtml(formatThroughput(rowData.upBps, plan.up))); row.appendChild(simpleRow(scaleNumber(rowData.bytesSentDown))); @@ -1594,8 +1582,8 @@ function renderTrafficTab() { row.appendChild(simpleRowHtml(formatRttNanos(rowData.rttUpNanos))); row.appendChild(simpleRowHtml(formatQooScore(rowData.qooDown))); row.appendChild(simpleRowHtml(formatQooScore(rowData.qooUp))); - row.appendChild(simpleRow(rowData.asnName)); - row.appendChild(simpleRow(rowData.asnCountry)); + row.appendChild(truncatedTrafficCell(rowData.asnName, "lqos-circuit-traffic-asn-cell")); + row.appendChild(truncatedTrafficCell(rowData.asnCountry, "lqos-circuit-traffic-country-cell")); row.appendChild(simpleRow(rowData.remoteIp)); const td = document.createElement("td"); @@ -1769,12 +1757,16 @@ function initialDevices(circuits) { outer.classList.add("col-12", "mb-3"); target.appendChild(outer); + let card = document.createElement("div"); + card.classList.add("lqos-circuit-device-card"); + outer.appendChild(card); + let row = document.createElement("div"); row.classList.add("row", "g-2"); - outer.appendChild(row); + card.appendChild(row); let d = document.createElement("div"); - d.classList.add("col-3"); + d.classList.add("col-12", "col-xl-5", "col-xxl-4", "lqos-circuit-device-summary"); row.appendChild(d); // Device Information Section @@ -1977,18 +1969,19 @@ function initialDevices(circuits) { // Graph container (2x2) let graphCol = document.createElement("div"); - graphCol.classList.add("col-9"); + graphCol.classList.add("col-12", "col-xl-7", "col-xxl-8", "lqos-circuit-device-graphs"); row.appendChild(graphCol); let graphRow = document.createElement("div"); - graphRow.classList.add("row", "g-2"); + graphRow.classList.add("row", "g-2", "lqos-circuit-device-graphs-row"); graphCol.appendChild(graphRow); function addGraph(divId, graphFactory) { let col = document.createElement("div"); - col.classList.add("col-6"); + col.classList.add("col-12", "col-md-6"); let div = document.createElement("div"); div.id = divId; + div.classList.add("lqos-circuit-device-graph"); div.style.height = "250px"; div.innerHTML = loadingBlockHtml("Loading chart…", "lqos-loading-block-sm"); col.appendChild(div); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_device_chart_theme.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_device_chart_theme.js new file mode 100644 index 00000000..91f1d2b8 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_device_chart_theme.js @@ -0,0 +1,136 @@ +function isLightTheme() { + return document.documentElement.getAttribute("data-bs-theme") === "light"; +} + +export function getCircuitDeviceChartTheme() { + if (!isLightTheme()) { + return { + titleText: "rgba(228, 236, 250, 0.9)", + axisText: "rgba(216, 226, 244, 0.66)", + axisName: "rgba(216, 226, 244, 0.58)", + axisLine: "rgba(216, 226, 244, 0.24)", + axisTick: "rgba(216, 226, 244, 0.18)", + splitLine: "rgba(216, 226, 244, 0.05)", + legendText: "rgba(228, 236, 250, 0.74)", + tooltipBackground: "rgba(12, 19, 30, 0.94)", + tooltipBorder: "rgba(143, 176, 255, 0.22)", + tooltipText: "rgba(236, 242, 252, 0.96)", + tooltipAxisLabelBackground: "rgba(23, 38, 58, 0.96)", + }; + } + + return { + titleText: "rgba(34, 48, 69, 0.92)", + axisText: "rgba(54, 67, 86, 0.72)", + axisName: "rgba(54, 67, 86, 0.62)", + axisLine: "rgba(77, 94, 118, 0.26)", + axisTick: "rgba(77, 94, 118, 0.18)", + splitLine: "rgba(77, 94, 118, 0.08)", + legendText: "rgba(34, 48, 69, 0.72)", + tooltipBackground: "rgba(249, 251, 255, 0.98)", + tooltipBorder: "rgba(79, 121, 217, 0.18)", + tooltipText: "rgba(34, 48, 69, 0.94)", + tooltipAxisLabelBackground: "rgba(232, 239, 248, 0.98)", + }; +} + +function applyAxisTheme(axis, theme) { + if (!axis) { + return; + } + axis.axisLabel = { + ...(axis.axisLabel || {}), + color: theme.axisText, + }; + axis.axisLine = { + ...(axis.axisLine || {}), + lineStyle: { + ...((axis.axisLine && axis.axisLine.lineStyle) || {}), + color: theme.axisLine, + }, + }; + axis.axisTick = { + ...(axis.axisTick || {}), + lineStyle: { + ...((axis.axisTick && axis.axisTick.lineStyle) || {}), + color: theme.axisTick, + }, + }; + axis.nameTextStyle = { + ...(axis.nameTextStyle || {}), + color: theme.axisName, + }; + if (axis.splitLine !== false) { + axis.splitLine = { + ...(axis.splitLine || {}), + lineStyle: { + ...((axis.splitLine && axis.splitLine.lineStyle) || {}), + color: theme.splitLine, + }, + }; + } +} + +export function applyCircuitDeviceChartTheme(option, { hasLegend = false } = {}) { + if (!option) { + return option; + } + + const theme = getCircuitDeviceChartTheme(); + + option.title = { + ...(option.title || {}), + textStyle: { + ...((option.title && option.title.textStyle) || {}), + color: theme.titleText, + fontWeight: 600, + }, + }; + + if (Array.isArray(option.xAxis)) { + option.xAxis.forEach((axis) => applyAxisTheme(axis, theme)); + } else { + applyAxisTheme(option.xAxis, theme); + } + + if (Array.isArray(option.yAxis)) { + option.yAxis.forEach((axis) => applyAxisTheme(axis, theme)); + } else { + applyAxisTheme(option.yAxis, theme); + } + + if (hasLegend && option.legend) { + option.legend = { + ...option.legend, + textStyle: { + ...((option.legend && option.legend.textStyle) || {}), + color: theme.legendText, + }, + }; + } + + if (option.tooltip) { + option.tooltip = { + ...option.tooltip, + backgroundColor: theme.tooltipBackground, + borderColor: theme.tooltipBorder, + borderWidth: 1, + textStyle: { + ...((option.tooltip && option.tooltip.textStyle) || {}), + color: theme.tooltipText, + }, + axisPointer: option.tooltip.axisPointer + ? { + ...option.tooltip.axisPointer, + label: { + ...((option.tooltip.axisPointer && option.tooltip.axisPointer.label) || {}), + backgroundColor: theme.tooltipAxisLabelBackground, + color: theme.tooltipText, + }, + } + : option.tooltip.axisPointer, + }; + } + + return option; +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_retransmit_graph.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_retransmit_graph.js index b5d7db48..6d32a5e5 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_retransmit_graph.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_retransmit_graph.js @@ -1,5 +1,6 @@ import {DashboardGraph} from "./dashboard_graph"; import {scaleNumber} from "../lq_js_common/helpers/scaling"; +import {applyCircuitDeviceChartTheme} from "./circuit_device_chart_theme"; // Simple time formatter (HH:MM:SS) function formatTime(ts) { @@ -13,6 +14,9 @@ const RING_SIZE = 60 * 5; // 5 Minutes export class CircuitRetransmitGraph extends DashboardGraph { constructor(id, title) { super(id); + if (this.dom && this.dom.classList) { + this.dom.classList.remove("muted"); + } this.title = title; this.ringbuffer = new RingBuffer(RING_SIZE); @@ -46,9 +50,6 @@ export class CircuitRetransmitGraph extends DashboardGraph { } } ], - textStyle: { - color: '#aaa' - }, }, xAxis: { type: 'category', @@ -89,9 +90,6 @@ export class CircuitRetransmitGraph extends DashboardGraph { trigger: 'axis', axisPointer: { type: 'cross', - label: { - backgroundColor: '#6a7985' - } }, formatter: (params) => { if (!params || params.length === 0) return ''; @@ -105,6 +103,7 @@ export class CircuitRetransmitGraph extends DashboardGraph { } }, } + applyCircuitDeviceChartTheme(this.option, { hasLegend: true }); this.option && this.chart.setOption(this.option); } @@ -114,6 +113,7 @@ export class CircuitRetransmitGraph extends DashboardGraph { this.option.legend.data[1].itemStyle.color = window.graphPalette[1]; this.option.series[0].lineStyle.color = window.graphPalette[0]; this.option.series[1].lineStyle.color = window.graphPalette[1]; + applyCircuitDeviceChartTheme(this.option, { hasLegend: true }); this.chart.setOption(this.option); } @@ -171,4 +171,4 @@ class RingBuffer { const bufferIdx = (this.head + idx) % this.size; return this.data[bufferIdx][2]; } -} \ No newline at end of file +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_throughput_graph.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_throughput_graph.js index e570cf01..5d4ada12 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_throughput_graph.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/circuit_throughput_graph.js @@ -1,5 +1,6 @@ import {DashboardGraph} from "./dashboard_graph"; import {scaleNumber} from "../lq_js_common/helpers/scaling"; +import {applyCircuitDeviceChartTheme} from "./circuit_device_chart_theme"; // Helper to format time as HH:MM:SS function formatTime(ts) { @@ -13,6 +14,9 @@ const RING_SIZE = 60 * 5; // 5 Minutes export class CircuitTotalGraph extends DashboardGraph { constructor(id, title) { super(id); + if (this.dom && this.dom.classList) { + this.dom.classList.remove("muted"); + } this.title = title; this.ringbuffer = new RingBuffer(RING_SIZE); @@ -46,9 +50,6 @@ export class CircuitTotalGraph extends DashboardGraph { } } ], - textStyle: { - color: '#aaa' - }, }, xAxis: { type: 'category', @@ -86,9 +87,6 @@ export class CircuitTotalGraph extends DashboardGraph { trigger: 'axis', axisPointer: { type: 'cross', - label: { - backgroundColor: '#6a7985' - } }, formatter: function(params) { console.log(params); @@ -103,6 +101,7 @@ export class CircuitTotalGraph extends DashboardGraph { } }, } + applyCircuitDeviceChartTheme(this.option, { hasLegend: true }); this.option && this.chart.setOption(this.option); } @@ -112,6 +111,7 @@ export class CircuitTotalGraph extends DashboardGraph { this.option.legend.data[1].itemStyle.color = window.graphPalette[1]; this.option.series[0].lineStyle.color = window.graphPalette[0]; this.option.series[1].lineStyle.color = window.graphPalette[1]; + applyCircuitDeviceChartTheme(this.option, { hasLegend: true }); this.chart.setOption(this.option); } @@ -185,4 +185,4 @@ class RingBuffer { let logicalIdx = (this.head + idx) % this.size; return this.data[logicalIdx][2]; } -} \ No newline at end of file +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/device_ping_graph.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/device_ping_graph.js index f021e498..bd1ee936 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/device_ping_graph.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/device_ping_graph.js @@ -1,10 +1,14 @@ import {DashboardGraph} from "./dashboard_graph"; import {lerpGreenToRedViaOrange} from "../helpers/scaling"; import {toNumber} from "../lq_js_common/helpers/scaling"; +import {applyCircuitDeviceChartTheme} from "./circuit_device_chart_theme"; export class DevicePingHistogram extends DashboardGraph { constructor(id) { super(id); + if (this.dom && this.dom.classList) { + this.dom.classList.remove("muted"); + } let d = []; let axis = []; for (let i=0; i<20; i++) { @@ -32,10 +36,17 @@ export class DevicePingHistogram extends DashboardGraph { type: 'bar', } }; + applyCircuitDeviceChartTheme(this.option); this.option && this.chart.setOption(this.option); this.chart.hideLoading(); } + onThemeChange() { + super.onThemeChange(); + applyCircuitDeviceChartTheme(this.option); + this.chart.setOption(this.option); + } + update(ping_time_nanos) { this.chart.hideLoading(); let ping_time_ms = toNumber(ping_time_nanos, 0) / 1000000; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/windowed_latency_histogram.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/windowed_latency_histogram.js index 4ae3ce3b..2fe07e19 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/windowed_latency_histogram.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/windowed_latency_histogram.js @@ -1,10 +1,14 @@ import {DashboardGraph} from "./dashboard_graph"; import {lerpGreenToRedViaOrange} from "../helpers/scaling"; import {toNumber} from "../lq_js_common/helpers/scaling"; +import {applyCircuitDeviceChartTheme} from "./circuit_device_chart_theme"; export class WindowedLatencyHistogram extends DashboardGraph { constructor(id, title = "Latency Histogram", windowMs = 300000) { super(id); + if (this.dom && this.dom.classList) { + this.dom.classList.remove("muted"); + } this.windowMs = windowMs; this.samples = []; this.sampleHead = 0; @@ -44,10 +48,17 @@ export class WindowedLatencyHistogram extends DashboardGraph { }, }; + applyCircuitDeviceChartTheme(this.option); this.option && this.chart.setOption(this.option); this.chart.hideLoading(); } + onThemeChange() { + super.onThemeChange(); + applyCircuitDeviceChartTheme(this.option); + this.chart.setOption(this.option); + } + updateMs(pingOrRttMs) { const now = Date.now(); const ms = toNumber(pingOrRttMs, 0); diff --git a/src/rust/lqosd/src/node_manager/static2/circuit.html b/src/rust/lqosd/src/node_manager/static2/circuit.html index b6a8fdd3..133098fc 100644 --- a/src/rust/lqosd/src/node_manager/static2/circuit.html +++ b/src/rust/lqosd/src/node_manager/static2/circuit.html @@ -185,11 +185,19 @@
-
-
-
- - Loading devices… +
+
+
+
Devices
+

Per-device live throughput, RTT, retransmits, and address inventory for this circuit.

+
+
+
+
+
+ + Loading devices… +
diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 4e5d00ef..f21b0562 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -421,6 +421,11 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { display: block; max-width: 100%; } +.flow-rtt-exclude-btn.btn.btn-sm { + padding-top: 0.175rem; + padding-bottom: 0.175rem; + line-height: 1.1; +} .lqos-circuit-gauge-panel { display: flex; align-items: center; @@ -715,6 +720,73 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { font-weight: 600; line-height: 1.35; } +.lqos-circuit-devices-panel { + margin-top: 1rem; + border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent); + border-radius: var(--lqos-radius-md); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--lqos-surface-2) 88%, transparent) 0%, + var(--lqos-surface-solid) 100% + ); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 12px 24px rgba(6, 12, 20, 0.12); + padding: 0.95rem; +} +.lqos-circuit-devices-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.9rem; +} +.lqos-circuit-devices-header-copy { + min-width: 0; +} +.lqos-circuit-devices-header-copy h5 { + margin-bottom: 0.25rem; +} +.lqos-circuit-devices-meta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.4rem; +} +.lqos-circuit-devices-meta-pill { + display: inline-flex; + align-items: center; + padding: 0.34rem 0.72rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 84%, transparent); + background: color-mix(in srgb, var(--lqos-surface-2) 72%, transparent); + color: var(--bs-secondary-color); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} +.lqos-circuit-device-card { + border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent); + border-radius: var(--lqos-radius-sm); + background: color-mix(in srgb, var(--lqos-surface-2) 66%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 0.8rem; +} +.lqos-circuit-device-summary, +.lqos-circuit-device-graphs { + min-width: 0; +} +.lqos-circuit-device-summary h5 { + margin-bottom: 0.5rem; +} +.lqos-circuit-device-graph { + border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent); + border-radius: var(--lqos-radius-sm); + background: color-mix(in srgb, var(--lqos-surface-2) 76%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 0.35rem; +} .lqos-funnel-panel { margin-top: 1rem; border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent); @@ -889,9 +961,11 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { .lqos-circuit-activity-chart { height: 296px; } + .lqos-circuit-devices-header, .lqos-cake-header { flex-direction: column; } + .lqos-circuit-devices-meta, .lqos-cake-meta { justify-content: flex-start; } @@ -923,9 +997,11 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { .lqos-circuit-activity-kpi-value { font-size: 1.05rem; } + .lqos-circuit-devices-panel, .lqos-cake-panel { padding: 0.8rem; } + .lqos-circuit-device-card, .lqos-funnel-panel, .lqos-funnel-node-card, .lqos-funnel-path-card { @@ -1194,6 +1270,37 @@ h5 { width: 7.5rem; max-width: 7.5rem; } +.lqos-circuit-traffic-table { + width: 100%; + table-layout: fixed; +} + +.lqos-circuit-traffic-table th, +.lqos-circuit-traffic-table td { + white-space: nowrap; +} + +.lqos-circuit-traffic-table .lqos-circuit-traffic-protocol-cell { + width: 10rem; + max-width: 10rem; +} + +.lqos-circuit-traffic-table .lqos-circuit-traffic-asn-cell { + width: 12rem; + max-width: 12rem; +} + +.lqos-circuit-traffic-table .lqos-circuit-traffic-country-cell { + width: 8rem; + max-width: 8rem; +} + +.lqos-circuit-traffic-table .lqos-circuit-traffic-protocol-cell .lqos-table-cell-ellipsis, +.lqos-circuit-traffic-table .lqos-circuit-traffic-asn-cell .lqos-table-cell-ellipsis, +.lqos-circuit-traffic-table .lqos-circuit-traffic-country-cell .lqos-table-cell-ellipsis { + max-width: 100%; +} + .lqos-tree-stack { display: flex; flex-direction: column; diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels/flows_by_circuit.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels/flows_by_circuit.rs index 7c4c3667..e1f0c27c 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels/flows_by_circuit.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels/flows_by_circuit.rs @@ -3,6 +3,7 @@ use crate::shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}; use crate::throughput_tracker::flow_data::{ ALL_FLOWS, FlowAnalysis, FlowbeeLocalData, get_asn_name_and_country, }; +use lqos_utils::units::DownUpOrder; use lqos_utils::hash_to_i64; use lqos_utils::unix_time::time_since_boot; use std::time::Duration; @@ -10,6 +11,65 @@ use tokio::time::MissedTickBehavior; use tracing::debug; const RECENT_CIRCUIT_FLOWS_WINDOW_NANOS: u64 = 30 * 1_000_000_000; +const FLOW_RATE_SANITY_MULTIPLIER: f64 = 2.0; +const FLOW_RATE_SANITY_FLOOR_BPS: u64 = 25_000_000; + +fn circuit_display_rate_ceiling_bps( + shaped: &lqos_config::ConfigShapedDevices, + circuit_hash: i64, +) -> Option> { + let mut max_down_mbps = 0.0_f32; + let mut max_up_mbps = 0.0_f32; + + for device in &shaped.devices { + if device.circuit_hash != circuit_hash { + continue; + } + max_down_mbps = max_down_mbps.max(device.download_max_mbps); + max_up_mbps = max_up_mbps.max(device.upload_max_mbps); + } + + if max_down_mbps <= 0.0 && max_up_mbps <= 0.0 { + return None; + } + + Some(DownUpOrder { + down: sanitized_plan_ceiling_bps(max_down_mbps), + up: sanitized_plan_ceiling_bps(max_up_mbps), + }) +} + +const fn clamp_u64_to_u32(value: u64) -> u32 { + if value > u32::MAX as u64 { + u32::MAX + } else { + value as u32 + } +} + +fn sanitized_plan_ceiling_bps(plan_mbps: f32) -> u32 { + if !plan_mbps.is_finite() || plan_mbps <= 0.0 { + return clamp_u64_to_u32(FLOW_RATE_SANITY_FLOOR_BPS); + } + + let scaled = (plan_mbps as f64 * 1_000_000.0 * FLOW_RATE_SANITY_MULTIPLIER).round(); + let scaled = scaled.max(FLOW_RATE_SANITY_FLOOR_BPS as f64) as u64; + clamp_u64_to_u32(scaled) +} + +fn display_rate_bps( + local: &FlowbeeLocalData, + ceiling_bps: Option>, +) -> DownUpOrder { + let Some(ceiling_bps) = ceiling_bps else { + return local.rate_estimate_bps; + }; + + DownUpOrder { + down: local.rate_estimate_bps.down.min(ceiling_bps.down), + up: local.rate_estimate_bps.up.min(ceiling_bps.up), + } +} fn recent_flows_by_circuit( circuit_id: &str, @@ -17,6 +77,7 @@ fn recent_flows_by_circuit( let circuit_hash = hash_to_i64(circuit_id); let shaped = SHAPED_DEVICES.load(); let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let display_rate_ceiling = circuit_display_rate_ceiling_bps(&shaped, circuit_hash); if let Ok(now) = time_since_boot() { let now_as_nanos = Duration::from(now).as_nanos() as u64; let recent_cutoff = now_as_nanos.saturating_sub(RECENT_CIRCUIT_FLOWS_WINDOW_NANOS); @@ -51,6 +112,9 @@ fn recent_flows_by_circuit( geo.country, ); + let mut local = local.clone(); + local.set_display_rate_bps(Some(display_rate_bps(&local, display_rate_ceiling))); + Some(( FlowbeeKeyTransit { remote_ip: remote_ip_str, @@ -64,7 +128,7 @@ fn recent_flows_by_circuit( protocol_name: analysis.protocol_analysis.to_string(), last_seen_nanos: now_as_nanos.saturating_sub(local.last_seen), }, - local.clone(), + local, *analysis, )) }) @@ -101,3 +165,51 @@ pub(super) async fn flows_by_circuit( ticker.tick().await; } } + +#[cfg(test)] +mod tests { + use super::{ + DownUpOrder, FlowbeeLocalData, display_rate_bps, sanitized_plan_ceiling_bps, + }; + + #[test] + fn sanitized_plan_ceiling_uses_multiplier_with_floor() { + assert_eq!(sanitized_plan_ceiling_bps(50.0), 100_000_000); + assert_eq!(sanitized_plan_ceiling_bps(1.0), 25_000_000); + } + + #[test] + fn display_rate_is_clamped_to_circuit_plan_ceiling() { + let mut local = FlowbeeLocalData { + start_time: 0, + last_seen: 0, + bytes_sent: DownUpOrder::default(), + packets_sent: DownUpOrder::default(), + rate_estimate_bps: DownUpOrder::default(), + display_rate_bps: None, + tcp_retransmits: DownUpOrder::default(), + end_status: 0, + tos: 0, + tc_handle: 0, + cpu: 0, + circuit_hash: None, + device_hash: None, + tcp_info: None, + }; + local.set_rate_estimate_bps(DownUpOrder { + down: u32::MAX, + up: 100_000_000, + }); + + let display = display_rate_bps( + &local, + Some(DownUpOrder { + down: 100_000_000, + up: 25_000_000, + }), + ); + + assert_eq!(display.down, 100_000_000); + assert_eq!(display.up, 25_000_000); + } +} diff --git a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_tracker.rs b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_tracker.rs index 5253faff..12875b38 100644 --- a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_tracker.rs +++ b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_tracker.rs @@ -77,6 +77,9 @@ pub struct FlowbeeLocalData { pub packets_sent: DownUpOrder, /// Rate estimate pub rate_estimate_bps: DownUpOrder, + /// Optional UI-oriented display rate. This is populated by specific + /// websocket/API surfaces that need plan-aware presentation guards. + pub display_rate_bps: Option>, /// TCP Retransmission count (also counts duplicates) pub tcp_retransmits: DownUpOrder, /// Has the connection ended? @@ -102,12 +105,15 @@ impl Serialize for FlowbeeLocalData { S: Serializer, { // Note: Keep this wire format stable (UI compatibility) while we refactor internal storage. - let mut state = serializer.serialize_struct("FlowbeeLocalData", 13)?; + let mut state = serializer.serialize_struct("FlowbeeLocalData", 14)?; state.serialize_field("start_time", &self.start_time)?; state.serialize_field("last_seen", &self.last_seen)?; state.serialize_field("bytes_sent", &self.bytes_sent)?; state.serialize_field("packets_sent", &self.packets_sent)?; state.serialize_field("rate_estimate_bps", &self.rate_estimate_bps)?; + if let Some(display_rate_bps) = &self.display_rate_bps { + state.serialize_field("display_rate_bps", display_rate_bps)?; + } state.serialize_field("tcp_retransmits", &self.tcp_retransmits)?; state.serialize_field("end_status", &self.end_status)?; state.serialize_field("tos", &self.tos)?; @@ -133,6 +139,7 @@ impl FlowbeeLocalData { bytes_sent: data.bytes_sent, packets_sent: data.packets_sent, rate_estimate_bps: data.rate_estimate_bps, + display_rate_bps: None, tcp_retransmits: data.tcp_retransmits, end_status: data.end_status, tos: data.tos, @@ -302,6 +309,10 @@ impl FlowbeeLocalData { self.rate_estimate_bps = rate_estimate_bps; } + pub fn set_display_rate_bps(&mut self, display_rate_bps: Option>) { + self.display_rate_bps = display_rate_bps; + } + pub fn set_tcp_retransmits(&mut self, tcp_retransmits: DownUpOrder) { self.tcp_retransmits = tcp_retransmits; }