diff --git a/.github/workflows/lint_python.yml b/.github/workflows/lint_python.yml index e4db964e..b3851394 100644 --- a/.github/workflows/lint_python.yml +++ b/.github/workflows/lint_python.yml @@ -4,8 +4,8 @@ jobs: lint_python: runs-on: lq_run steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install --upgrade pip wheel diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4574ff56..815faaf3 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -4,23 +4,41 @@ on: [push] jobs: build: - strategy: - matrix: - os: [ubuntu-latest] runs-on: [ lq_run ] steps: - uses: actions/checkout@v4 - name: Set Version ENV - run: pushd src; echo "::set-env name=VERSION::$(cat VERSION_STRING)" - env: - ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' + run: echo "VERSION=$(cat src/VERSION_STRING)" >> "$GITHUB_ENV" - name: Check env var run: | echo ${{ env.VERSION }} - name: Install dependencies - run: sudo /bin/apt-get update; sudo /bin/apt-get install --no-install-recommends python3-pip clang gcc gcc-multilib llvm libelf-dev git nano graphviz esbuild curl screen llvm pkg-config linux-tools-common linux-tools-`/bin/uname r` libbpf-dev mold curl unzip - if: matrix.os == 'ubuntu-latest' + run: | + sudo /bin/apt-get update + sudo /bin/apt-get install --no-install-recommends \ + python3-pip clang gcc gcc-multilib llvm libelf-dev graphviz esbuild \ + pkg-config libbpf-dev mold unzip curl ca-certificates + + BPFTOOL_VERSION="7.7.0" + case "$(uname -m)" in + x86_64) BPFTOOL_ARCH="amd64" ;; + aarch64) BPFTOOL_ARCH="arm64" ;; + *) + echo "Unsupported architecture for upstream bpftool binary: $(uname -m)" >&2 + exit 1 + ;; + esac + + BPFTOOL_TARBALL="bpftool-v${BPFTOOL_VERSION}-${BPFTOOL_ARCH}.tar.gz" + BPFTOOL_BASE_URL="https://github.com/libbpf/bpftool/releases/download/v${BPFTOOL_VERSION}" + curl -fsSLO "${BPFTOOL_BASE_URL}/${BPFTOOL_TARBALL}" + curl -fsSLO "${BPFTOOL_BASE_URL}/${BPFTOOL_TARBALL}.sha256sum" + sha256sum -c "${BPFTOOL_TARBALL}.sha256sum" + tar -xzf "${BPFTOOL_TARBALL}" + sudo install -m 0755 bpftool /usr/local/bin/bpftool + command -v bpftool + bpftool version - name: Build run: pushd src/rust; cargo build --verbose --all; popd - name: Run tests @@ -35,13 +53,10 @@ jobs: path: src/dist/libreqos_${{ env.VERSION }}.-1_amd64.deb audit: - strategy: - matrix: - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install tools run: pushd src/rust; cargo install cargo-audit; popd - name: Audit for CVEs diff --git a/docs-es/v2.0/TechnicalDocs/integrations.md b/docs-es/v2.0/TechnicalDocs/integrations.md index 44c6bce7..43bbb73d 100644 --- a/docs-es/v2.0/TechnicalDocs/integrations.md +++ b/docs-es/v2.0/TechnicalDocs/integrations.md @@ -127,6 +127,8 @@ If a network.json file exists, it will not be overwritten, unless you set ```alw ShapedDevices.csv will be overwritten every time the UISP integration is run. +Cuando varios sitios cliente de UISP comparten el mismo nombre, LibreQoS ahora intenta diferenciar los nombres visibles generados para circuitos/sitios con un sufijo más humano, como el primer segmento de la dirección, recurriendo al nombre del servicio y luego a un ID corto solo cuando hace falta. La identidad estable del circuito sigue viniendo del ID de sitio/servicio de UISP, no del nombre visible. + To ensure the network.json is always overwritten with the newest version pulled in by the integration, please edit `/etc/lqos.conf` with the command `sudo nano /etc/lqos.conf`. Edit the file to set the value of `always_overwrite_network_json` to `true`. Then, run `sudo systemctl restart lqosd`. diff --git a/docs-es/v2.0/configuration-es.md b/docs-es/v2.0/configuration-es.md index a27d3219..89c7b907 100644 --- a/docs-es/v2.0/configuration-es.md +++ b/docs-es/v2.0/configuration-es.md @@ -25,6 +25,8 @@ Para la mayoría de operadores: La mayoría de cambios operativos diarios se realizan en la WebUI (`http://tu_ip_del_shaper:9123/config_general.html`). +Las versiones actuales usan paneles compartidos de configuración en las páginas General, RTT, Queues, TreeGuard, Network Mode, valores por defecto de integración, Network Layout, Insight, páginas de integración por proveedor, IP Ranges, Flow Tracking y Shaped Devices para que los ajustes del nodo y la afinación operativa sigan la misma presentación visual que el resto de Node Manager. La página de TreeGuard comienza con un resumen del comportamiento actual en lugar de un banner permanente de advertencia. + ### Dónde en la WebUI - Ajustes generales: `Configuration -> General` diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 511d4612..d219e001 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -7,42 +7,43 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta ### Dashboard - Resumen por widgets de throughput, retransmisiones, RTT, flujos y actividad de colas. - El contenido puede variar según versión y funciones habilitadas. -- Executive Summary ofrece una vista operativa compacta para redes grandes, con páginas de detalle para heatmaps y rankings ejecutivos. -- Bakery ofrece una pestaña dedicada para el estado de aplicación de colas, eventos recientes de Bakery paginados, resultados de seguridad/preflight de qdisc, progreso de cambios en vivo sobre circuitos y operaciones recientes de Bakery centradas en circuitos. -- Las pestañas de Bakery y TreeGuard incluyen un resumen de alto nivel del pipeline/control loop antes de las tablas detalladas. -- El widget `Pipeline` de Bakery muestra las etapas actuales del control de colas, incluyendo el estado activo de aplicación, el estado de verificación y la temporización del intervalo de TC. -- `Runtime Operations` resume mutaciones en vivo de topología entre TreeGuard y Bakery, trabajo de limpieza diferida, fallos, subárboles marcados como `Dirty` y si los cambios incrementales están congelados hasta un full reload. -- `Recent Bakery Events` muestra por defecto operaciones agrupadas orientadas al operador, y deja el log crudo de eventos de Bakery en una vista separada de `Event Log` cuando hace falta troubleshooting detallado. -- `TreeGuard Activity` muestra por defecto operaciones agrupadas orientadas al operador, incluyendo lotes consolidados de cambios SQM, y deja el log crudo de eventos de TreeGuard en una vista separada de `Event Log` cuando hace falta troubleshooting detallado. -- `TreeGuard Control Loop` muestra el estado actual de observar/evaluar/actuar sin repetir acciones recientes que ya aparecen en TreeGuard Activity. -- `TreeGuard Decision Impact` se centra en el impacto actual y en las advertencias o errores actuales, en lugar de repetir acciones recientes. -- `TreeGuard State Mix` muestra nodos gestionados, virtualización en runtime, circuitos gestionados y la mezcla actual de circuitos `cake / mixed / fq_codel`. +- Executive Summary ofrece una vista operativa compacta para redes grandes, con un `Network Snapshot` centrado en throughput, inventario y estado de Insight, además de páginas de detalle para heatmaps y rankings ejecutivos. +- Bakery ofrece una pestaña dedicada para el estado de aplicación de colas, resultados de seguridad/preflight de qdisc, progreso de cambios en vivo sobre circuitos y operaciones recientes de Bakery. +- Las pestañas de Bakery y TreeGuard presentan un resumen de alto nivel del pipeline o control loop antes de las tablas más detalladas. +- El widget `Pipeline` de Bakery muestra las etapas del control de colas, el estado de aplicación, el estado de verificación y la temporización del intervalo de TC. +- `Runtime Operations` resume mutaciones de topología entre TreeGuard y Bakery, trabajo de limpieza diferida, fallos y subárboles que esperan un full reload. +- `Recent Bakery Events` prioriza operaciones agrupadas, con el historial detallado disponible cuando hace falta troubleshooting más profundo. +- `TreeGuard Activity` prioriza operaciones agrupadas, incluyendo lotes de cambios SQM, con el historial detallado disponible cuando hace falta troubleshooting más profundo. +- `TreeGuard Control Loop` muestra el estado actual de observar/evaluar/actuar. +- `TreeGuard Decision Impact` se centra en el impacto actual y en las advertencias o errores activos. +- `TreeGuard State Mix` muestra nodos gestionados, virtualización en runtime, circuitos gestionados y la mezcla de circuitos `cake / mixed / fq_codel`. - El preflight de qdisc de Bakery resume el uso planificado de qdisc por interfaz y el margen de presupuesto antes de aplicar cambios. - Algunos gráficos pueden tardar un poco en poblarse al abrir una pestaña por primera vez, especialmente en sistemas ocupados o inmediatamente después de reiniciar servicios. -- Durante un full reload de Bakery, las tarjetas de conteo de colas mantienen los últimos valores conocidos de HTB/CAKE/fq-codel y los marcan como `Reloading`. +- Durante un full reload de Bakery, las tarjetas de conteo de colas pueden seguir mostrando temporalmente los últimos valores conocidos de HTB/CAKE/fq-codel y marcarlos como `Reloading`. ### Vista de árbol de red - Vista jerárquica de nodos/circuitos desde la perspectiva del shaper. - Útil para identificar cuellos de botella y patrones de utilización padre/hijo. - Las páginas de detalle del árbol muestran una ruta tipo breadcrumb, conteos de rama e indicadores de estado para el nodo seleccionado. -- `Node Details` resume el tipo de nodo seleccionado, el tamaño de la rama, las velocidades configuradas y la velocidad efectiva actual. -- `Node Snapshot` ofrece un resumen visual rápido del throughput actual y el QoO del nodo seleccionado. +- `Node Details` resume el tipo de nodo seleccionado, el tamaño de la rama, las velocidades configuradas y la velocidad efectiva. +- `Node Snapshot` ofrece un resumen visual rápido del throughput y el QoO del nodo seleccionado. - Los circuitos adjuntos se muestran en una tabla dedicada para el nodo seleccionado. +- La columna de IP de circuitos adjuntos mantiene las filas compactas mostrando una dirección inline y colapsando las adicionales como `+X`, mientras la lista completa sigue disponible al pasar el cursor. +- Los circuitos adjuntos limitados por Ethernet pueden mostrar insignias `10M`, `100M` o `1G` junto al valor de `Plan (Mbps)`; al pasar el cursor se explica el auto-cap y al hacer clic en la insignia se abre la página dedicada de revisión Ethernet. - Los administradores pueden guardar o limpiar valores de `Operator Override` cuando el nodo admite overrides a nivel de nodo. Los usuarios de solo lectura y los nodos no compatibles siguen mostrando los valores actuales sin controles de edición. ### Site Map - Mapa operativo plano de sitios y APs usando geodatos importados de nodos. - Usa QoO por defecto con un selector alternativo para RTT, mientras el tamaño del marcador refleja el throughput combinado reciente. -- Usa un promedio del lado cliente de 30 segundos a partir de `NetworkTree`, sin agregar trabajo de rollup en el backend. - Los APs pueden heredar coordenadas del sitio padre solo para visualización cuando faltan coordenadas explícitas. - Los marcadores de sitios cercanos se agrupan y se expanden al acercar el mapa o seleccionar un grupo. - Los APs sin coordenadas explícitas se representan a través de su sitio padre y pueden desplegarse temporalmente alrededor del sitio seleccionado para inspección. - Los sitios visibles y sin agrupar muestran etiquetas al acercar el mapa, y el sitio seleccionado mantiene su etiqueta visible mientras se inspecciona. - Cuando el modo de redacción del navegador está habilitado, Site Map reemplaza los nombres de sitios mostrados por `[redacted]` sin modificar los datos reales de topología. - El encuadre inicial del mapa prioriza las coordenadas de los sitios para la vista inicial, usando coordenadas de AP solo cuando todavía no hay sitios mapeados. -- En modo oscuro, la capa raster del mapa base se atenúa y se tiñe del lado cliente hacia una paleta azul/cian similar a la de Flow Globe, para mantener visibles carreteras y geografía sin el brillo del mapa claro. -- Usa un mapa base local con estilo LibreQoS con bordes de país/estado, costas, lagos principales, ríos principales, áreas marinas, superposiciones sutiles de regiones físicas y contexto de autopistas principales a mayor zoom para orientación geográfica. -- Site Map utiliza una capa local de carreteras derivada de Natural Earth para ayudar con la orientación, manteniendo el resto del mapa base discreto y operativo. +- Site Map usa una capa raster de OpenStreetMap alojada por Insight. +- En modo oscuro, el mapa base se atenúa y se tiñe hacia una paleta azul/cian para mantener visibles carreteras y geografía sin el brillo del mapa claro. +- Site Map depende del acceso saliente a `https://insight.libreqos.com` para el arranque inicial del mapa y la obtención de tiles. ### Flow Globe - Visualización geográfica de flujos basada en la geolocalización de endpoints. @@ -55,24 +56,33 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta ### ASN Analysis - Página operativa ASN en vivo que combina un ranking top-20 de ASN, gráfico de burbujas latencia-vs-tráfico, franja mínima de KPIs del ASN seleccionado, gráfico de tendencia ASN de 15 minutos y la sección integrada de Flow Evidence. - Soporta modos de ranking `Impact` y `Throughput`, manteniendo la evidencia de flujos ASN en la misma página. -- El contexto ejecutivo ASN se obtiene mediante requests acotados al ASN, en lugar de suscribirse a un feed completo de heatmaps ejecutivos. - Los marcadores antiguos de `ASN Explorer` siguen funcionando mediante redirección. - Resultados vacíos suelen indicar poco dato reciente, no necesariamente falla. ### Página de circuito - 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`. -- `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. +- `Top ASNs` resume los ASN recientes más activos del circuito a partir de esa misma ventana de flujos en vivo y ordena por tasa actual por defecto. +- `Devices` muestra tablas de detalle por dispositivo y gráficos en vivo de throughput, retransmisiones y latencia. +- `Queue Stats` muestra historial reciente de la cola del circuito, 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` se limita a valores plausibles y coherentes con el plan del circuito. +- 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 +- La página de revisión Ethernet es una tabla ligera para operadores con los circuitos reducidos automáticamente porque la velocidad Ethernet detectada quedó por debajo del plan solicitado. +- Intencionalmente no aparece en la navegación principal; los operadores llegan a ella haciendo clic en las insignias de advertencia Ethernet de la página de circuito o de la tabla de circuitos adjuntos del árbol. +- La página soporta búsqueda, filtro por tier (`10M`, `100M`, `1G+`) y paginación sobre los circuitos auto-capped. + ### Árbol/ponderación de CPU - Muestra distribución de colas/circuitos por núcleo de CPU. - Ayuda a validar comportamiento de binpacking y balance de carga. -- CPU Affinity muestra por defecto el conjunto de CPUs de shaping detectado, de modo que los E-cores híbridos / núcleos del host excluidos queden ocultos salvo que el operador decida mostrarlos. +- CPU Affinity comienza mostrando solo los CPUs de shaping, y los núcleos excluidos o solo-del-host pueden mostrarse cuando hace falta. ### Editor de Shaped Devices - Editor CRUD para `ShapedDevices.csv`. diff --git a/docs/v2.0/configuration.md b/docs/v2.0/configuration.md index 401708f4..03c57788 100644 --- a/docs/v2.0/configuration.md +++ b/docs/v2.0/configuration.md @@ -27,6 +27,8 @@ For most operators: Most day-to-day LibreQoS configuration is handled in WebUI (`http://your_shaper_ip:9123/config_general.html`). +Current builds use shared configuration panels on the General, RTT, Queues, TreeGuard, Network Mode, Integration Defaults, Network Layout, Insight, provider-specific integration pages, IP Ranges, Flow Tracking, and Shaped Devices pages so node settings and operational tuning follow the same surface styling as the rest of Node Manager. The TreeGuard page starts with a live behavior summary rather than a permanent warning banner. + ### Where in WebUI - General settings: `Configuration -> General` diff --git a/docs/v2.0/integrations-uisp.md b/docs/v2.0/integrations-uisp.md index ae20d8e2..25001243 100644 --- a/docs/v2.0/integrations-uisp.md +++ b/docs/v2.0/integrations-uisp.md @@ -173,6 +173,8 @@ If a `network.json` file exists, it is only overwritten when `always_overwrite_n ShapedDevices.csv will be overwritten every time the UISP integration is run. +When UISP client sites share the same name, LibreQoS now tries to disambiguate the generated circuit/site display names with a human-friendly suffix such as the first street-address segment, falling back to service name and then a short ID only when needed. Stable circuit identity still comes from the UISP site/service ID, not the display name. + For integration-driven deployments, keep `always_overwrite_network_json = true` so topology stays aligned with UISP on each refresh cycle. You have the option to run `uisp_integration` automatically on boot and every X minutes (set by the parameter `queue_refresh_interval_mins`), which is highly recommended. This can be enabled by setting ```enable_uisp = true``` in `/etc/lqos.conf`. Once set, run `sudo systemctl restart lqos_scheduler`. diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index a212ed91..4f2dec78 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -7,27 +7,29 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### Dashboard - Widget-based overview of throughput, retransmits, RTT, flow counts, and queue activity. - Dashboard content can vary by version and enabled features. -- Executive Summary provides a compact operational view for large networks, with drilldown pages for executive heatmaps and leaderboards. -- Bakery provides dedicated status for queue apply state, paginated recent Bakery events, qdisc safety/preflight results, circuit live-change progress, and recent circuit-scoped Bakery operations. -- The Bakery and TreeGuard tabs include a high-level pipeline/control-loop summary before the detailed tables. -- The Bakery `Pipeline` widget shows the current queue-control stages, including active apply state, verification state, and TC interval timing. -- `Runtime Operations` summarizes live TreeGuard/Bakery topology mutations, deferred cleanup work, failures, dirty subtrees, and whether incremental changes are frozen pending a full reload. -- `Recent Bakery Events` defaults to merged operator-facing operations, with the raw Bakery event log available in a separate `Event Log` view when detailed troubleshooting is needed. -- `TreeGuard Activity` defaults to grouped operator-facing operations, including consolidated SQM change batches, with the raw TreeGuard event log available in a separate `Event Log` view when detailed troubleshooting is needed. -- `TreeGuard Control Loop` shows the current observe/evaluate/act state without repeating recent actions already visible in TreeGuard Activity. -- `TreeGuard Decision Impact` focuses on current impact and current warnings or errors, rather than replaying recent actions. -- `TreeGuard State Mix` shows managed nodes, runtime virtualization, managed circuits, and the current `cake / mixed / fq_codel` circuit SQM split. +- Executive Summary provides a compact operational view for large networks, with a `Network Snapshot` focused on throughput, inventory, and Insight state plus drilldown pages for executive heatmaps and leaderboards. +- Bakery provides dedicated status for queue apply state, qdisc safety/preflight results, circuit live-change progress, and recent Bakery operations. +- The Bakery and TreeGuard tabs present a high-level pipeline or control-loop summary ahead of the more detailed tables. +- The Bakery `Pipeline` widget shows queue-control stages, apply state, verification state, and TC interval timing. +- `Runtime Operations` summarizes TreeGuard/Bakery topology mutations, deferred cleanup work, failures, and subtrees waiting for a full reload. +- `Recent Bakery Events` emphasizes grouped operations, with detailed event history available when deeper troubleshooting is needed. +- `TreeGuard Activity` emphasizes grouped operations, including SQM change batches, with detailed event history available when deeper troubleshooting is needed. +- `TreeGuard Control Loop` shows the current observe/evaluate/act state. +- `TreeGuard Decision Impact` focuses on current impact and any active warnings or errors. +- `TreeGuard State Mix` shows managed nodes, runtime virtualization, managed circuits, and the `cake / mixed / fq_codel` circuit SQM split. - Bakery qdisc preflight summarizes planned per-interface qdisc usage and budget headroom before apply. - Some charts may take a short time to populate after first opening a tab, especially on busy systems or immediately after a service restart. -- During a Bakery full reload, queue-count cards keep showing the last known HTB/CAKE/fq-codel values and mark them as `Reloading`. +- During a Bakery full reload, queue-count cards can temporarily continue showing the last known HTB/CAKE/fq-codel values and mark them as `Reloading`. ### Network Tree Overview - Hierarchical topology view of nodes/circuits from the shaper perspective. - Useful for spotting bottlenecks and parent/child utilization patterns. - Tree detail pages show a breadcrumb path, branch counts, and status indicators for the selected node. -- `Node Details` summarizes the selected node’s type, branch size, configured rates, and current effective rate. -- `Node Snapshot` provides a quick visual summary of current throughput and QoO for the selected node. +- `Node Details` summarizes the selected node’s type, branch size, configured rates, and effective rate. +- `Node Snapshot` provides a quick visual summary of throughput and QoO for the selected node. - Attached circuits are shown in a dedicated table for the selected node. +- The attached-circuits IP column keeps rows compact by showing one address inline and collapsing additional addresses as `+X`, with the full list still available on hover. +- Ethernet-limited attached circuits can show inline `10M`, `100M`, or `1G` warning badges beside the `Plan (Mbps)` value; hovering explains the auto-cap and clicking the badge opens the dedicated Ethernet review page. - Administrators can save or clear `Operator Override` values where node-level overrides are supported. Read-only users and unsupported nodes continue to display current values without edit controls. - Tree-page operator rate edits write operator-owned overrides to `lqos_overrides.json`; they do not rewrite legacy integration bandwidth CSV files. - Tree-page operator rate edits require an administrator session, a stable node ID, and a non-generated node. Generated/integration-synthetic nodes remain read-only in this editor. @@ -35,7 +37,6 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### Site Map - Flat operational map of Sites and APs using imported node geodata. - Defaults to QoO coloring with an RTT toggle, while marker size reflects recent combined throughput. -- Uses a 30-second client-side average from `NetworkTree` data rather than adding backend rollup work. - APs can inherit parent site coordinates for display when explicit AP coordinates are missing. - Nearby site markers cluster and expand as the operator zooms or selects a cluster. - APs without explicit coordinates are represented through their parent site and can be expanded temporarily around the selected site for inspection. @@ -58,24 +59,33 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### ASN Analysis - Live ASN operations page combining a top-20 ASN leaderboard, latency-vs-traffic bubble chart, selected ASN KPI strip, 15-minute ASN trend chart, and embedded Flow Evidence. - Supports `Impact` and `Throughput` ranking modes while keeping ASN flow evidence on the same page. -- ASN executive context is fetched through bounded ASN-focused requests rather than a full executive heatmap subscription. - Older `ASN Explorer` bookmarks continue to work through a redirect. - Empty results usually indicate low recent data volume rather than failure. ### Circuit page - 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. -- `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. +- `Top ASNs` summarizes the busiest recent ASNs on the circuit from that same live flow window and sorts by current rate by default. +- `Devices` shows per-device detail tables and live charts for throughput, retransmits, and latency. +- `Queue Stats` shows recent live queue history for the circuit, 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 is limited to plausible, plan-aware values for the circuit. +- 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 +- The Ethernet review page is a lightweight operator table of circuits automatically down-rated because detected Ethernet speed was below the requested plan. +- It is intentionally not in the main navigation; operators reach it by clicking Ethernet warning badges on the Circuit page or Tree attached-circuits table. +- The page supports search, tier filtering (`10M`, `100M`, `1G+`), and paging across auto-capped circuits. + ### CPU Tree / CPU Weights - Shows queue/circuit distribution by CPU core. - Helps evaluate binpacking and load distribution behavior. -- CPU Affinity defaults to the detected shaping CPU set, so excluded hybrid E-cores and other non-shaping host cores stay hidden unless the operator explicitly opts to show them. +- CPU Affinity starts with shaping CPUs only, while excluded or host-only cores can be shown when needed. ### Shaped Devices Editor - CRUD editor for `ShapedDevices.csv`. diff --git a/src/bakery_integration_test.py b/src/bakery_integration_test.py index fd1884ee..bfbeeaa1 100644 --- a/src/bakery_integration_test.py +++ b/src/bakery_integration_test.py @@ -152,7 +152,8 @@ MAPPED_DECISION_PAT = re.compile( r"Bakery mapped circuit decision \(([^)]+)\): requested=(\d+), allowed=(\d+), dropped=(\d+)," ) NO_CHANGES_PAT = re.compile(r"No changes detected in batch, skipping processing\.") -TEST_FAULT_ONCE_PATH = "/tmp/lqos_bakery_fail_purpose_once.txt" +# Test-only fault marker path. +TEST_FAULT_ONCE_PATH = "/tmp/lqos_bakery_fail_purpose_once.txt" # nosec B108 @dataclass @@ -2573,7 +2574,8 @@ def check_no_hidden_incremental_failures(step_started_at: float) -> Tuple[bool, matches = [line for line in lines if any(p.search(line) for p in patterns)] - last_error_path = "/tmp/lqos_bakery_last_error.txt" + # Test-only Bakery artifact path. + last_error_path = "/tmp/lqos_bakery_last_error.txt" # nosec B108 if os.path.exists(last_error_path): try: if os.path.getmtime(last_error_path) >= step_started_at: @@ -2707,7 +2709,8 @@ def collect_failure_diagnostics( except Exception as e: msgs.append(f"diagnostics: failed tc dump for interface_b: {e}") - last_error_path = "/tmp/lqos_bakery_last_error.txt" + # Test-only Bakery artifact path. + last_error_path = "/tmp/lqos_bakery_last_error.txt" # nosec B108 if os.path.exists(last_error_path): try: with open(last_error_path, "r", errors="replace") as f: diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 7ed3500d..fb8ccf7f 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2029,6 +2029,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "toml", "tracing", ] diff --git a/src/rust/lqos_bakery/Cargo.toml b/src/rust/lqos_bakery/Cargo.toml index da7fe67e..93ca5c97 100644 --- a/src/rust/lqos_bakery/Cargo.toml +++ b/src/rust/lqos_bakery/Cargo.toml @@ -15,3 +15,6 @@ parking_lot.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true + +[dev-dependencies] +toml.workspace = true diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index 074ba712..bc913c83 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -1246,26 +1246,18 @@ struct GroupedBakeryEventLimiter { } impl GroupedBakeryEventLimiter { - fn emit( + fn emit_with_site_name( &mut self, group_key: impl Into, event: &str, status: &str, + site: Option<(i64, Option)>, summary: String, suppression_summary: String, ) { - self.emit_with_site(group_key, event, status, None, summary, suppression_summary); - } - - fn emit_with_site( - &mut self, - group_key: impl Into, - event: &str, - status: &str, - site_hash: Option, - summary: String, - suppression_summary: String, - ) { + let (site_hash, site_name) = site.map_or((None, None), |(site_hash, site_name)| { + (Some(site_hash), site_name) + }); let state = self.groups .entry(group_key.into()) @@ -1277,7 +1269,7 @@ impl GroupedBakeryEventLimiter { suppression_summary, }); if state.emitted < BAKERY_GROUPED_EVENT_DETAIL_LIMIT { - push_bakery_event_with_site(event, status, site_hash, summary); + push_bakery_event_with_site_name(event, status, site_hash, site_name, summary); state.emitted += 1; } else { state.suppressed += 1; @@ -2166,6 +2158,15 @@ pub fn bakery_live_tree_mutation_blocker() -> Option { live_tree_mutation_blocker_for_config(&config) } +/// Overrides Bakery's shaping-tree-active flag for tests and restores callers' access to the +/// previous value. +/// +/// This function has side effects: it mutates process-global Bakery runtime state. +#[doc(hidden)] +pub fn set_shaping_tree_active_for_tests(active: bool) -> bool { + SHAPING_TREE_ACTIVE.swap(active, Ordering::Relaxed) +} + fn current_mq_layout( batch: &[Arc], config: &Arc, @@ -2542,6 +2543,13 @@ fn runtime_site_label(site_hash: i64, site_name: Option<&str>) -> String { } } +fn runtime_site_display_name(site_hash: i64, site_name: Option<&str>) -> String { + match site_name { + Some(name) if !name.is_empty() => name.to_string(), + _ => site_hash.to_string(), + } +} + fn runtime_circuit_label(circuit_hash: i64, circuit_name: Option<&str>) -> String { match circuit_name { Some(name) if !name.is_empty() => format!("{name} ({circuit_hash})"), @@ -8375,6 +8383,9 @@ fn flush_deferred_runtime_site_prunes( ); for site_hash in pending_site_hashes { if let Some(state) = virtualized_sites.get_mut(&site_hash) { + let site_name = state.site_name.clone(); + let site_display = + runtime_site_display_name(site_hash, Some(site_name.as_str())); let retry_at = now_unix.saturating_add(RUNTIME_SITE_PRUNE_RETRY_SECONDS); state.next_prune_attempt_unix = retry_at; if let Some(operation) = runtime_node_operations.get_mut(&site_hash) { @@ -8389,13 +8400,14 @@ fn flush_deferred_runtime_site_prunes( Some(summary.clone()), None, ); - grouped_events.emit( + grouped_events.emit_with_site_name( format!("runtime_site_prune_dirty|down_snapshot|{summary}"), "runtime_site_prune_dirty", "error", + Some((site_hash, Some(site_name.clone()))), format!( "Deferred runtime site prune for site {} marked Dirty after snapshot failure: {}", - site_hash, summary + site_display, summary ), format!( "runtime site prune Dirty events due to downlink snapshot failure: {}", @@ -8411,15 +8423,16 @@ fn flush_deferred_runtime_site_prunes( Some(summary.clone()), Some(retry_at), ); - grouped_events.emit( + grouped_events.emit_with_site_name( format!("runtime_site_prune_retry|down_snapshot|{summary}"), "runtime_site_prune_retry", "warning", + Some((site_hash, Some(site_name))), format!( "Deferred runtime site prune retry {}/{} for site {} postponed: {}", operation.attempt_count, RUNTIME_SITE_PRUNE_MAX_ATTEMPTS, - site_hash, + site_display, summary ), format!( @@ -8443,6 +8456,9 @@ fn flush_deferred_runtime_site_prunes( ); for site_hash in pending_site_hashes { if let Some(state) = virtualized_sites.get_mut(&site_hash) { + let site_name = state.site_name.clone(); + let site_display = + runtime_site_display_name(site_hash, Some(site_name.as_str())); let retry_at = now_unix.saturating_add(RUNTIME_SITE_PRUNE_RETRY_SECONDS); state.next_prune_attempt_unix = retry_at; if let Some(operation) = runtime_node_operations.get_mut(&site_hash) { @@ -8457,13 +8473,14 @@ fn flush_deferred_runtime_site_prunes( Some(summary.clone()), None, ); - grouped_events.emit( + grouped_events.emit_with_site_name( format!("runtime_site_prune_dirty|up_snapshot|{summary}"), "runtime_site_prune_dirty", "error", + Some((site_hash, Some(site_name.clone()))), format!( "Deferred runtime site prune for site {} marked Dirty after snapshot failure: {}", - site_hash, summary + site_display, summary ), format!( "runtime site prune Dirty events due to uplink snapshot failure: {}", @@ -8479,15 +8496,16 @@ fn flush_deferred_runtime_site_prunes( Some(summary.clone()), Some(retry_at), ); - grouped_events.emit( + grouped_events.emit_with_site_name( format!("runtime_site_prune_retry|up_snapshot|{summary}"), "runtime_site_prune_retry", "warning", + Some((site_hash, Some(site_name))), format!( "Deferred runtime site prune retry {}/{} for site {} postponed: {}", operation.attempt_count, RUNTIME_SITE_PRUNE_MAX_ATTEMPTS, - site_hash, + site_display, summary ), format!( @@ -8557,6 +8575,8 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( if state.lifecycle == RuntimeVirtualizedBranchLifecycle::CutoverPending && state.active_branch_hides_original_site() { + let site_name = state.site_name.clone(); + let site_display = runtime_site_display_name(site_hash, Some(site_name.as_str())); debug!( "Bakery: evaluating runtime cutover activation for site {}", site_hash @@ -8574,14 +8594,14 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( None, ); } - grouped_events.emit_with_site( + grouped_events.emit_with_site_name( "runtime_cutover_completed|completed", "runtime_cutover_completed", "info", - Some(site_hash), + Some((site_hash, Some(site_name.clone()))), format!( "Runtime cutover completed for site {}; shadow branch is active and original branch is standby.", - site_hash + site_display ), "runtime cutover completion events".to_string(), ); @@ -8604,14 +8624,14 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( Some(summary.clone()), None, ); - grouped_events.emit_with_site( + grouped_events.emit_with_site_name( format!("runtime_cutover_dirty|activation_failed|{summary}"), "runtime_cutover_dirty", "error", - Some(site_hash), + Some((site_hash, Some(site_name.clone()))), format!( "Runtime cutover for site {} marked Dirty after {} attempts: {}", - site_hash, operation.attempt_count, summary + site_display, operation.attempt_count, summary ), format!( "runtime cutover Dirty events due to activation verification failure: {}", @@ -8634,16 +8654,16 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( Some(summary.clone()), Some(retry_at), ); - grouped_events.emit_with_site( + grouped_events.emit_with_site_name( format!("runtime_cutover_retry|activation_failed|{summary}"), "runtime_cutover_retry", "warning", - Some(site_hash), + Some((site_hash, Some(site_name.clone()))), format!( "Runtime cutover retry {}/{} for site {} waiting for active/standby convergence: {}", operation.attempt_count, RUNTIME_SITE_PRUNE_MAX_ATTEMPTS, - site_hash, + site_display, summary ), format!( @@ -8681,14 +8701,14 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( None, ); } - grouped_events.emit_with_site( + grouped_events.emit_with_site_name( "runtime_site_prune_completed|completed", "runtime_site_prune_completed", "info", - Some(site_hash), + Some((site_hash, Some(state.site_name.clone()))), format!( "Deferred runtime site prune completed for site {}.", - site_hash + runtime_site_display_name(site_hash, Some(state.site_name.as_str())) ), "runtime site prune completion events".to_string(), ); @@ -8713,14 +8733,15 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( Some(now_unix), ); } - grouped_events.emit_with_site( + grouped_events.emit_with_site_name( format!("runtime_site_prune_progress|{summary}"), "runtime_site_prune_progress", "info", - Some(site_hash), + Some((site_hash, Some(state.site_name.clone()))), format!( "Deferred runtime site prune for site {} made progress: {}", - site_hash, summary + runtime_site_display_name(site_hash, Some(state.site_name.as_str())), + summary ), format!("runtime site prune progress events: {}", summary), ); @@ -8763,14 +8784,16 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( Some(summary.clone()), None, ); - grouped_events.emit_with_site( + grouped_events.emit_with_site_name( format!("runtime_site_prune_dirty|execute_failed|{summary}"), "runtime_site_prune_dirty", "error", - Some(site_hash), + Some((site_hash, Some(state.site_name.clone()))), format!( "Deferred runtime site prune for site {} marked Dirty after {} attempts: {}", - site_hash, operation.attempt_count, summary + runtime_site_display_name(site_hash, Some(state.site_name.as_str())), + operation.attempt_count, + summary ), format!( "runtime site prune Dirty events due to execution failure: {}", @@ -8788,16 +8811,19 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( Some(summary.clone()), Some(retry_at), ); - grouped_events.emit_with_site( + grouped_events.emit_with_site_name( format!("runtime_site_prune_retry|execute_failed|{summary}"), "runtime_site_prune_retry", "warning", - Some(site_hash), + Some((site_hash, Some(state.site_name.clone()))), format!( "Deferred runtime site prune retry {}/{} for site {} failed: {}", operation.attempt_count, RUNTIME_SITE_PRUNE_MAX_ATTEMPTS, - site_hash, + runtime_site_display_name( + site_hash, + Some(state.site_name.as_str()) + ), summary ), format!( @@ -8809,14 +8835,15 @@ fn flush_deferred_runtime_site_prunes_with_snapshots( } else { state.next_prune_attempt_unix = now_unix.saturating_add(RUNTIME_SITE_PRUNE_RETRY_SECONDS); - grouped_events.emit_with_site( + grouped_events.emit_with_site_name( format!("runtime_site_prune_retry|outside_tracking|{summary}"), "runtime_site_prune_retry", "warning", - Some(site_hash), + Some((site_hash, Some(state.site_name.clone()))), format!( "Deferred runtime site prune for site {} failed outside operation tracking: {}", - site_hash, summary + runtime_site_display_name(site_hash, Some(state.site_name.as_str())), + summary ), format!( "runtime site prune retry events outside operation tracking: {}", @@ -9734,12 +9761,46 @@ mod tests { crate::test_state_lock() } + fn install_bakery_test_config() { + static TEST_CONFIG_PATH: std::sync::OnceLock = + std::sync::OnceLock::new(); + + let config_path = TEST_CONFIG_PATH.get_or_init(|| { + let runtime_dir = std::env::temp_dir().join("lqos_bakery_test_runtime"); + std::fs::create_dir_all(&runtime_dir) + .expect("create lqos_bakery test runtime directory"); + + let config = lqos_config::Config { + lqos_directory: runtime_dir.display().to_string(), + bridge: Some(lqos_config::BridgeConfig { + use_xdp_bridge: false, + to_internet: "lo".to_string(), + to_network: "lo".to_string(), + }), + ..lqos_config::Config::default() + }; + + let config_path = std::env::temp_dir().join("lqos_bakery_test_lqos.conf"); + let raw = toml::to_string_pretty(&config).expect("serialize lqos_bakery test config"); + std::fs::write(&config_path, raw).expect("write lqos_bakery test config"); + config_path + }); + + // SAFETY: these unit tests call this helper only while holding the bakery test lock, + // so the process environment is mutated in a serialized way within this test binary. + unsafe { + std::env::set_var("LQOS_CONFIG", config_path); + } + lqos_config::clear_cached_config(); + } + fn reset_bakery_test_state() { *telemetry_state().write() = BakeryTelemetryState::default(); MQ_CREATED.store(false, Ordering::Relaxed); SHAPING_TREE_ACTIVE.store(false, Ordering::Relaxed); FIRST_COMMIT_APPLIED.store(false, Ordering::Relaxed); FULL_RELOAD_IN_PROGRESS.store(false, Ordering::Relaxed); + install_bakery_test_config(); } fn mk_add_circuit(hash: i64, ip_addresses: &str) -> Arc { @@ -11913,6 +11974,35 @@ mod tests { ); } + #[test] + fn grouped_runtime_site_events_retain_human_site_name() { + let _guard = bakery_test_lock().lock().expect("lock"); + reset_bakery_test_state(); + + let site_hash = 3571466403324592518i64; + let site_name = "JR_AP_TR_F".to_string(); + let mut grouped = GroupedBakeryEventLimiter::default(); + grouped.emit_with_site_name( + "runtime_cutover_completed|completed", + "runtime_cutover_completed", + "info", + Some((site_hash, Some(site_name.clone()))), + format!( + "Runtime cutover completed for site {}; shadow branch is active and original branch is standby.", + runtime_site_display_name(site_hash, Some(site_name.as_str())) + ), + "runtime cutover completion events".to_string(), + ); + grouped.flush(); + + let activity = bakery_activity_snapshot(); + assert_eq!(activity.len(), 1); + assert_eq!(activity[0].site_hash, Some(site_hash)); + assert_eq!(activity[0].site_name.as_deref(), Some(site_name.as_str())); + assert!(activity[0].summary.contains(site_name.as_str())); + assert!(!activity[0].summary.contains(&format!("site {site_hash}"))); + } + #[test] fn qdisc_budget_estimate_counts_fq_codel_leaf_qdiscs_separately() { let config = Arc::new(Config::default()); diff --git a/src/rust/lqos_config/src/circuit_ethernet_metadata.rs b/src/rust/lqos_config/src/circuit_ethernet_metadata.rs new file mode 100644 index 00000000..784d6a5c --- /dev/null +++ b/src/rust/lqos_config/src/circuit_ethernet_metadata.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +/// Runtime metadata filename for circuit Ethernet advisories emitted by integrations. +pub const CIRCUIT_ETHERNET_METADATA_FILENAME: &str = "circuit_ethernet_metadata.json"; + +/// Collection of circuit Ethernet advisories keyed by circuit identity. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct CircuitEthernetMetadataFile { + /// Per-circuit Ethernet advisory entries. + pub circuits: Vec, +} + +/// Describes a detected negotiated Ethernet speed and any automatic shaping cap applied to a circuit. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct CircuitEthernetMetadata { + /// Circuit identifier as emitted to `ShapedDevices.csv`. + pub circuit_id: String, + /// Human-readable circuit name for UI display. + pub circuit_name: String, + /// Device IDs considered when determining the circuit Ethernet limit. + pub device_ids: Vec, + /// Integration/source that produced the advisory. + pub source: String, + /// Negotiated Ethernet speed in Mbps for the limiting device/interface. + pub negotiated_ethernet_mbps: u64, + /// Requested download max before any Ethernet-based cap was applied. + pub requested_download_mbps: f32, + /// Requested upload max before any Ethernet-based cap was applied. + pub requested_upload_mbps: f32, + /// Applied download max after Ethernet-based capping. + pub applied_download_mbps: f32, + /// Applied upload max after Ethernet-based capping. + pub applied_upload_mbps: f32, + /// Whether the Ethernet advisory reduced at least one shaping direction. + pub auto_capped: bool, + /// Device ID of the limiting device when known. + pub limiting_device_id: Option, + /// Device name of the limiting device when known. + pub limiting_device_name: Option, + /// Interface name that reported the limiting Ethernet speed when known. + pub limiting_interface_name: Option, +} diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index 10793053..646d78af 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used)] #![warn(missing_docs)] pub mod authentication; +mod circuit_ethernet_metadata; mod cpu_topology; mod etc; mod network_json; @@ -17,6 +18,9 @@ mod qoo_profiles; mod shaped_devices; pub use authentication::{AuthenticatedUser, UserRole, WebUser, WebUsers}; +pub use circuit_ethernet_metadata::{ + CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadata, CircuitEthernetMetadataFile, +}; pub use cpu_topology::{ CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus, }; diff --git a/src/rust/lqos_sys/build.rs b/src/rust/lqos_sys/build.rs index a02b60a9..15d79dae 100644 --- a/src/rust/lqos_sys/build.rs +++ b/src/rust/lqos_sys/build.rs @@ -32,6 +32,37 @@ fn command_warnings_errors_only(section: &str, command_result: &std::io::Result< } } +fn require_successful_output(section: &str, command_result: std::io::Result) -> Output { + match command_result { + Ok(output) => { + if !output.stderr.is_empty() { + println!( + "cargo:warning=[{section}] {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + if !output.status.success() { + panic!( + "{section} failed with status {}", + output + .status + .code() + .map_or_else(|| "signal".to_string(), |code| code.to_string()) + ); + } + + output + } + Err(error) => { + panic!( + "{section} is required to build lqos_sys but was not available: {error}. \ +Install bpftool (or provide it in PATH) before building." + ); + } + } +} + fn main() { let out_dir = env::var_os("OUT_DIR").unwrap(); @@ -84,7 +115,8 @@ fn main() { .args(["gen", "skeleton", &link_target]) .output(); command_warnings_errors_only("bpf skel", &skel_result); - let header_file = String::from_utf8(skel_result.unwrap().stdout).unwrap(); + let skel_output = require_successful_output("bpftool gen skeleton", skel_result); + let header_file = String::from_utf8(skel_output.stdout).unwrap(); std::fs::write(skel_target, header_file).unwrap(); // 4: Copy the wrapper to our out dir diff --git a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt index b8d41373..8562b12c 100644 --- a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt +++ b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt @@ -8,6 +8,7 @@ help.js unknown-ips.js configuration.js circuit.js +ethernet_caps.js site_map.js flow_map.js all_tree_sankey.js 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 1991c2df..0047a3ae 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 @@ -39,7 +39,6 @@ let plan = null; let channelLink = null; let cakeChannel = null; let pinger = null; -let flowChannel = null; let funnelSubscription = null; let speedometer = null; let qooGauge = null; @@ -56,18 +55,29 @@ let funnelParentNodeName = null; let excludeRttToggle = null; let excludeRttLastValue = false; let excludeRttBusy = false; -let latestFlowMsg = null; let latestCakeMsg = null; let cakeGraphs = null; let cakeQueueUnavailable = false; let circuitSqmOverride = ""; let queuingActivityGraph = null; let latestCircuitDevices = []; +let circuitConfigDevices = []; +let latestCircuitSummary = null; let latestCircuitQooScore = null; +let latestSankeyFlowMsg = { flows: [] }; +let latestTopAsnData = { total_asns: 0, rows: [] }; +let latestTrafficPage = null; let queuingActivityDirection = "down"; let deviceGraphSpecs = []; let deviceGraphsInitialized = false; -const QUEUING_ACTIVITY_RTT_FLOOR_BPS = 200_000; +let devicePollTimer = null; +let deviceRequestInFlight = false; +let sankeyPollTimer = null; +let sankeyRequestInFlight = false; +let topAsnPollTimer = null; +let topAsnRequestInFlight = false; +let trafficPollTimer = null; +let trafficRequestInFlight = false; const DEFAULT_RTT_THRESHOLDS = { green_ms: 0, yellow_ms: 100, red_ms: 200 }; let currentRttThresholds = { ...DEFAULT_RTT_THRESHOLDS }; const wsClient = get_ws_client(); @@ -75,12 +85,70 @@ const RECENT_TRAFFIC_FLOW_WINDOW_NANOS = 30_000_000_000; const TRAFFIC_FLOW_HIDE_THRESHOLD_BPS = 1024 * 1024; const DEFAULT_TRAFFIC_PAGE_SIZE = 100; -function retransmitCountFromSample(sample) { - return toNumber(sample?.retransmits, 0); +function formatEthernetPortLabel(mbps) { + const value = toNumber(mbps, 0); + if (value >= 1000 && value % 1000 === 0) { + return `${value / 1000}G`; + } + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}G`; + } + return `${Math.round(value)}M`; } -function tcpPacketCountFromSample(sample) { - return toNumber(sample?.packets, 0); +function ethernetCapsPageHref(advisory) { + const portLabel = encodeURIComponent(formatEthernetPortLabel(advisory?.negotiated_ethernet_mbps)); + return `/ethernet_caps.html?tier=${portLabel}`; +} + +function ethernetTooltipHtml(advisory) { + return ( + `Requested plan ${formatMbps(advisory.requested_download_mbps)} / ${formatMbps(advisory.requested_upload_mbps)} exceeded detected Ethernet speed. ` + + `Shaping auto-capped to ${formatMbps(advisory.applied_download_mbps)} / ${formatMbps(advisory.applied_upload_mbps)}.` + ); +} + +function formatPlanSpeedPair(downloadMbps, uploadMbps) { + const down = toNumber(downloadMbps, 0).toFixed(1); + const up = toNumber(uploadMbps, 0).toFixed(1); + return `${down} / ${up} Mbps`; +} + +function renderEthernetAdvisory(advisory) { + const badge = document.getElementById("ethernetAdvisoryBadge"); + const badgeText = document.getElementById("ethernetAdvisoryBadgeText"); + if (!badge || !badgeText) { + return; + } + + const disposeTooltip = () => { + if (typeof bootstrap === "undefined" || !bootstrap.Tooltip) { + return; + } + const existing = bootstrap.Tooltip.getInstance?.(badge); + existing?.dispose(); + }; + + if (!advisory?.auto_capped) { + badge.classList.add("d-none"); + badgeText.textContent = ""; + badge.removeAttribute("title"); + badge.removeAttribute("data-bs-original-title"); + disposeTooltip(); + return; + } + + const portLabel = formatEthernetPortLabel(advisory.negotiated_ethernet_mbps); + const note = ethernetTooltipHtml(advisory); + + badgeText.textContent = portLabel; + badge.classList.remove("d-none"); + badge.href = ethernetCapsPageHref(advisory); + badge.setAttribute("title", note); + badge.setAttribute("data-bs-original-title", note); + badge.setAttribute("aria-label", `Review ${portLabel} Ethernet-limited circuits`); + disposeTooltip(); + initTooltipsWithin(badge.parentElement || document); } function retransmitPacketsForNode(node, direction) { @@ -126,6 +194,13 @@ function runWhenRenderable(el, callback, attempts = 10) { }, 50); } +function clearPollingTimer(timerId) { + if (timerId !== null) { + window.clearInterval(timerId); + } + return null; +} + function loadingBlockHtml(label, sizeClass = "") { const size = sizeClass ? ` ${sizeClass}` : ""; return `
${label}
`; @@ -189,71 +264,13 @@ function formatBitsPerSecondLabel(bitsPerSecond) { return `${scaleNumber(bitsPerSecond, 1)}bps`; } -function medianOfSorted(values) { - if (!Array.isArray(values) || values.length === 0) { - return null; - } - const middle = Math.floor(values.length / 2); - if (values.length % 2 === 1) { - return values[middle]; - } - return (values[middle - 1] + values[middle]) / 2; -} - -function weightedMedian(entries) { - if (!Array.isArray(entries) || entries.length === 0) { - return null; - } - - const totalWeight = entries.reduce((sum, entry) => sum + Math.max(0, toNumber(entry.weight, 0)), 0); - if (!(totalWeight > 0)) { - return null; - } - - const threshold = totalWeight / 2; - let running = 0; - for (const entry of entries) { - running += Math.max(0, toNumber(entry.weight, 0)); - if (running >= threshold) { - return toNumber(entry.value, null); - } - } - - return toNumber(entries[entries.length - 1].value, null); -} - function currentCircuitRttP50Ms(direction) { const directional = direction === "up" ? "up" : "down"; - const weightedEntries = []; - const fallbackValues = []; - - latestCircuitDevices.forEach((device) => { - const currentP50Nanos = toNumber(device?.rtt_current_p50_nanos?.[directional], 0); - if (!(currentP50Nanos > 0)) { - return; - } - - const throughputBps = toNumber(device?.bytes_per_second?.[directional], 0) * 8; - const currentP50Ms = currentP50Nanos / 1_000_000.0; - fallbackValues.push(currentP50Ms); - - if (throughputBps > QUEUING_ACTIVITY_RTT_FLOOR_BPS) { - weightedEntries.push({ - value: currentP50Ms, - weight: throughputBps, - }); - } - }); - - weightedEntries.sort((a, b) => a.value - b.value); - const weighted = weightedMedian(weightedEntries); - if (Number.isFinite(weighted)) { - return weighted; + const nanos = toNumber(latestCircuitSummary?.rtt_current_p50_nanos?.[directional], 0); + if (!(nanos > 0)) { + return null; } - - fallbackValues.sort((a, b) => a - b); - const fallbackMedian = medianOfSorted(fallbackValues); - return Number.isFinite(fallbackMedian) ? fallbackMedian : null; + return nanos / 1_000_000.0; } function formatCircuitRttLabel(rttMs) { @@ -295,7 +312,7 @@ function loadRttThresholds() { } function currentActiveFlowCount() { - return latestTrafficRows.length; + return toNumber(latestCircuitSummary?.active_flow_count, 0); } function currentDirectionValue(pair, direction, fallback = 0) { @@ -303,9 +320,7 @@ function currentDirectionValue(pair, direction, fallback = 0) { } function currentQueuingActivitySnapshot() { - const throughputBps = latestCircuitDevices.reduce((sum, device) => { - return sum + (currentDirectionValue(device?.bytes_per_second, queuingActivityDirection, 0) * 8); - }, 0); + const throughputBps = currentDirectionValue(latestCircuitSummary?.bytes_per_second, queuingActivityDirection, 0) * 8; const ceilingMbps = currentDirectionValue(plan, queuingActivityDirection, 0); const ceilingBps = ceilingMbps * 1_000_000.0; const atCeiling = ceilingBps > 0 && throughputBps >= (ceilingBps * 0.95); @@ -345,23 +360,16 @@ function updateQueuingActivityCards() { } function pushQueuingActivitySample() { - if (!queuingActivityGraph || !latestCircuitDevices.length || !plan) { + if (!queuingActivityGraph || !latestCircuitSummary || !plan) { updateQueuingActivityCards(); return; } - const downThroughputBps = latestCircuitDevices.reduce((sum, device) => { - return sum + (currentDirectionValue(device?.bytes_per_second, "down", 0) * 8); - }, 0); - const upThroughputBps = latestCircuitDevices.reduce((sum, device) => { - return sum + (currentDirectionValue(device?.bytes_per_second, "up", 0) * 8); - }, 0); - queuingActivityGraph.pushSample({ timestamp: Date.now(), throughputBps: { - down: downThroughputBps, - up: upThroughputBps, + down: currentDirectionValue(latestCircuitSummary?.bytes_per_second, "down", 0) * 8, + up: currentDirectionValue(latestCircuitSummary?.bytes_per_second, "up", 0) * 8, }, ceilingBps: { down: currentDirectionValue(plan, "down", 0) * 1_000_000.0, @@ -423,6 +431,14 @@ function isTrafficTabActive() { return document.getElementById("traffic-tab")?.classList.contains("active") ?? false; } +function isDevicesTabActive() { + return document.getElementById("devs-tab")?.classList.contains("active") ?? false; +} + +function isTopAsnTabActive() { + return document.getElementById("top-asns-tab")?.classList.contains("active") ?? false; +} + function isSankeyTabActive() { return document.getElementById("sankey-tab")?.classList.contains("active") ?? false; } @@ -446,7 +462,7 @@ function ensureFlowSankey() { return; } flowSankey = new FlowsSankey("flowSankey"); - applyFlowSankeyMessage(latestFlowMsg); + applyFlowSankeyMessage(latestSankeyFlowMsg); }); } @@ -469,10 +485,7 @@ function initializeDeviceGraphs() { }); deviceGraphsInitialized = true; if (latestCircuitDevices.length > 0) { - fillLiveDevices(latestCircuitDevices); - if (speedometer && totalThroughput && totalRetransmits) { - updateSpeedometer(latestCircuitDevices); - } + applyDeviceLiveData(latestCircuitDevices); } } @@ -895,23 +908,33 @@ function initTabLifecycle(parentNode) { if (target === "#queuing") { ensureQueuingActivityGraph(); updateQueuingActivityCards(); + syncCircuitDetailSubscriptions(); return; } if (target === "#devs") { initializeDeviceGraphs(); resizeDeviceGraphs(); + syncCircuitDetailSubscriptions(); return; } if (target === "#sankey") { ensureFlowSankey(); - applyFlowSankeyMessage(latestFlowMsg); + applyFlowSankeyMessage(latestSankeyFlowMsg); + syncCircuitDetailSubscriptions(); + return; + } + if (target === "#top-asns") { + renderTopAsnTab(); + syncCircuitDetailSubscriptions(); return; } if (target === "#traffic") { renderTrafficTab(); + syncCircuitDetailSubscriptions(); return; } if (target === "#funnel") { + syncCircuitDetailSubscriptions(); if (!funnelInitialized) { funnelInitialized = true; initialFunnel(parentNode); @@ -923,6 +946,7 @@ function initTabLifecycle(parentNode) { if (target === "#cake") { ensureCakeGraphs(); applyCakeMessage(latestCakeMsg); + syncCircuitDetailSubscriptions(); } }); }); @@ -931,6 +955,7 @@ function initTabLifecycle(parentNode) { window.requestAnimationFrame(() => { ensureQueuingActivityGraph(); updateQueuingActivityCards(); + syncCircuitDetailSubscriptions(); }); } @@ -1016,11 +1041,73 @@ function requestCircuitById(onSuccess, onError) { if (onError) onError(); return; } - onSuccess(msg.devices || []); + const payload = msg.data || {}; + onSuccess(payload); }); wsClient.send({ CircuitById: { id: circuit_id } }); } +function applyCircuitSummary(summary) { + latestCircuitSummary = summary || null; + latestCircuitQooScore = toNumber(summary?.qoo_score, NaN); + if (!Number.isFinite(latestCircuitQooScore)) { + latestCircuitQooScore = null; + } + if (excludeRttToggle && summary?.rtt_excluded !== undefined) { + excludeRttLastValue = !!summary.rtt_excluded; + excludeRttToggle.checked = excludeRttLastValue; + } + if (speedometer) { + speedometer.update( + currentDirectionValue(summary?.bytes_per_second, "down", 0) * 8, + currentDirectionValue(summary?.bytes_per_second, "up", 0) * 8, + currentDirectionValue(plan, "down", 0), + currentDirectionValue(plan, "up", 0) + ); + } + if (totalThroughput) { + totalThroughput.update( + currentDirectionValue(summary?.bytes_per_second, "down", 0) * 8, + currentDirectionValue(summary?.bytes_per_second, "up", 0) * 8 + ); + } + if (totalRetransmits) { + totalRetransmits.update( + retransmitFractionFromSample(summary?.tcp_retransmit_sample?.down) * 100.0, + retransmitFractionFromSample(summary?.tcp_retransmit_sample?.up) * 100.0 + ); + } + if (qooGauge !== null) { + qooGauge.update(summary?.qoo_score); + } + updateTopAsnCountBadge(); + updateTrafficCountBadge(); + pushQueuingActivitySample(); +} + +function applyDeviceLiveData(devices) { + latestCircuitDevices = devices || []; + fillLiveDevices(latestCircuitDevices); + + latestCircuitDevices.forEach((device) => { + const throughputGraph = deviceGraphs["throughputGraph_" + device.device_id]; + if (throughputGraph !== undefined) { + throughputGraph.update( + toNumber(device.bytes_per_second?.down, 0) * 8, + toNumber(device.bytes_per_second?.up, 0) * 8 + ); + } + + const retransmitGraph = deviceGraphs["tcpRetransmitsGraph_" + device.device_id]; + if (retransmitGraph !== undefined) { + retransmitGraph.update( + retransmitFractionFromSample(device.tcp_retransmit_sample?.down) * 100.0, + retransmitFractionFromSample(device.tcp_retransmit_sample?.up) * 100.0 + ); + } + }); +} + function initExcludeRttToggle() { excludeRttToggle = document.getElementById("excludeRttToggle"); if (!excludeRttToggle) return; @@ -1055,30 +1142,15 @@ function initExcludeRttToggle() { }); } -function connectPrivateChannel() { +function connectCircuitSummaryChannel() { channelLink = new DirectChannel({ CircuitWatcher: { circuit: circuit_id } }, (msg) => { - latestCircuitQooScore = toNumber(msg.qoo_score, NaN); - if (!Number.isFinite(latestCircuitQooScore)) { - latestCircuitQooScore = null; + if (msg?.data) { + applyCircuitSummary(msg.data); } - if (msg.devices !== null) { - latestCircuitDevices = msg.devices || []; - fillLiveDevices(msg.devices); - updateSpeedometer(msg.devices); - pushQueuingActivitySample(); - if (excludeRttToggle && msg.rtt_excluded !== undefined) { - excludeRttLastValue = !!msg.rtt_excluded; - excludeRttToggle.checked = excludeRttLastValue; - } - } - if (qooGauge !== null) { - qooGauge.update(msg.qoo_score); - } - updateQueuingActivityCards(); }); } @@ -1095,7 +1167,7 @@ function fullIpList(circuits) { return ipList; } -function connectPingers(circuits) { +function startPingMonitor(circuits) { let ipList = fullIpList(circuits); pinger = new DirectChannel({ @@ -1161,28 +1233,149 @@ function connectPingers(circuits) { }); } -function connectFlowChannel() { - flowChannel = new DirectChannel({ - FlowsByCircuit: { - circuit: circuit_id +function stopPingMonitor() { + if (pinger) { + wsClient.send({ Private: { StopPingMonitorWatch: null } }); + pinger.close(); + pinger = null; + } +} + +function requestCircuitDevicesSnapshot() { + if (deviceRequestInFlight) { + return; + } + deviceRequestInFlight = true; + listenOnce("CircuitDevicesResult", (msg) => { + deviceRequestInFlight = false; + if (!msg?.data?.ok || msg.data.circuit_id !== circuit_id) { + return; } - }, (msg) => { - latestFlowMsg = msg; - ingestTrafficRows(msg); + applyDeviceLiveData(msg.data.devices || []); + }); + wsClient.send({ CircuitDevices: { circuit: circuit_id } }); +} + +function requestCircuitFlowSankey() { + if (sankeyRequestInFlight) { + return; + } + sankeyRequestInFlight = true; + listenOnce("CircuitFlowSankeyResult", (msg) => { + sankeyRequestInFlight = false; + if (msg?.circuit_id !== circuit_id) { + return; + } + latestSankeyFlowMsg = { flows: Array.isArray(msg.flows) ? msg.flows : [] }; if (isSankeyTabActive()) { ensureFlowSankey(); - applyFlowSankeyMessage(msg); + applyFlowSankeyMessage(latestSankeyFlowMsg); } else { - $("#activeFlowCount").text(getRenderableSankeyFlowCount(msg)); + $("#activeFlowCount").text(getRenderableSankeyFlowCount(latestSankeyFlowMsg)); } + }); + wsClient.send({ CircuitFlowSankey: { circuit: circuit_id } }); +} + +function requestCircuitTopAsns() { + if (topAsnRequestInFlight) { + return; + } + topAsnRequestInFlight = true; + listenOnce("CircuitTopAsnsResult", (msg) => { + topAsnRequestInFlight = false; + if (msg?.circuit_id !== circuit_id) { + return; + } + latestTopAsnData = msg.data || { total_asns: 0, rows: [] }; + if (isTopAsnTabActive()) { + renderTopAsnTab(); + } else { + updateTopAsnCountBadge(); + } + }); + wsClient.send({ + CircuitTopAsns: { + query: { + circuit: circuit_id, + hide_small: hideSmallFlowsEnabled(), + }, + }, + }); +} + +function requestTrafficFlowsPage() { + if (trafficRequestInFlight) { + return; + } + trafficRequestInFlight = true; + const query = { + circuit: circuit_id, + page: trafficCurrentPage, + page_size: trafficPageSize, + hide_small: hideSmallFlowsEnabled(), + sort_column: trafficSortColumn, + sort_direction: trafficSortDirection, + }; + listenOnce("CircuitTrafficFlowsPageResult", (msg) => { + trafficRequestInFlight = false; + if (msg?.circuit_id !== circuit_id) { + return; + } + latestTrafficPage = msg.data || null; + trafficCurrentPage = Math.max(1, toNumber(latestTrafficPage?.query?.page, query.page)); + trafficPageSize = Math.max(1, toNumber(latestTrafficPage?.query?.page_size, query.page_size)); if (isTrafficTabActive()) { renderTrafficTab(); } else { updateTrafficCountBadge(); updateTrafficPaginationControls(); } - updateQueuingActivityCards(); }); + wsClient.send({ CircuitTrafficFlowsPage: { query } }); +} + +function syncCircuitDetailSubscriptions(circuits = null) { + const pingSourceCircuits = Array.isArray(circuits) && circuits.length > 0 ? circuits : circuitConfigDevices; + if (isDevicesTabActive()) { + if (!pinger && Array.isArray(pingSourceCircuits) && pingSourceCircuits.length > 0) { + startPingMonitor(pingSourceCircuits); + } + requestCircuitDevicesSnapshot(); + if (devicePollTimer === null) { + devicePollTimer = window.setInterval(requestCircuitDevicesSnapshot, 1000); + } + } else { + devicePollTimer = clearPollingTimer(devicePollTimer); + stopPingMonitor(); + } + + if (isSankeyTabActive()) { + requestCircuitFlowSankey(); + if (sankeyPollTimer === null) { + sankeyPollTimer = window.setInterval(requestCircuitFlowSankey, 1000); + } + } else { + sankeyPollTimer = clearPollingTimer(sankeyPollTimer); + } + + if (isTopAsnTabActive()) { + requestCircuitTopAsns(); + if (topAsnPollTimer === null) { + topAsnPollTimer = window.setInterval(requestCircuitTopAsns, 1000); + } + } else { + topAsnPollTimer = clearPollingTimer(topAsnPollTimer); + } + + if (isTrafficTabActive()) { + requestTrafficFlowsPage(); + if (trafficPollTimer === null) { + trafficPollTimer = window.setInterval(requestTrafficFlowsPage, 1000); + } + } else { + trafficPollTimer = clearPollingTimer(trafficPollTimer); + } } function initFlowFilters() { @@ -1195,7 +1388,20 @@ function initFlowFilters() { if (hideSmallFlows) { hideSmallFlows.addEventListener("change", () => { trafficCurrentPage = 1; - renderTrafficTab(); + if (isTrafficTabActive()) { + requestTrafficFlowsPage(); + } else { + updateTrafficCountBadge(); + updateTrafficPaginationControls(); + } + if (isTopAsnTabActive()) { + requestCircuitTopAsns(); + } else { + updateTopAsnCountBadge(); + } + if (isSankeyTabActive()) { + requestCircuitFlowSankey(); + } }); } if (pageSize) { @@ -1204,14 +1410,14 @@ function initFlowFilters() { const parsed = parseInt(pageSize.value, 10); trafficPageSize = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TRAFFIC_PAGE_SIZE; trafficCurrentPage = 1; - renderTrafficTab(); + requestTrafficFlowsPage(); }); } if (prev) { prev.addEventListener("click", () => { if (trafficCurrentPage > 1) { trafficCurrentPage--; - renderTrafficTab(); + requestTrafficFlowsPage(); } }); } @@ -1220,7 +1426,7 @@ function initFlowFilters() { const totalPages = getTrafficTotalPages(); if (trafficCurrentPage < totalPages) { trafficCurrentPage++; - renderTrafficTab(); + requestTrafficFlowsPage(); } }); } @@ -1241,22 +1447,13 @@ 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 topAsnSortColumn = 'rate'; +let topAsnSortDirection = 'desc'; 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; @@ -1290,34 +1487,33 @@ function formatRttPair(p50Nanos, p95Nanos) { return formatRttNanos(p50) + " / " + scaleNanos(p95); } -function visibleTrafficRows() { - if (!hideSmallFlowsEnabled()) { - return latestTrafficRows; +function truncatedTrafficCell(text, cellClass) { + const td = document.createElement("td"); + if (cellClass) { + td.classList.add(cellClass); } - return latestTrafficRows.filter((row) => row.downBps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS || row.upBps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS); + + 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() { + return Array.isArray(latestTrafficPage?.rows) ? latestTrafficPage.rows : []; } function hideSmallFlowsEnabled() { return document.getElementById("hideSmallFlows")?.checked ?? false; } -function sortTrafficRows(rows) { - rows.sort((a, b) => { - let aVal = a.sortKeys[trafficSortColumn]; - let bVal = b.sortKeys[trafficSortColumn]; - if (typeof aVal === "string" && typeof bVal === "string") { - aVal = aVal.toLowerCase(); - bVal = bVal.toLowerCase(); - } - if (trafficSortDirection === "asc") { - return aVal < bVal ? -1 : aVal > bVal ? 1 : 0; - } - return aVal > bVal ? -1 : aVal < bVal ? 1 : 0; - }); -} - function getTrafficTotalPages() { - return Math.max(1, Math.ceil(visibleTrafficRows().length / trafficPageSize)); + const totalRows = toNumber(latestTrafficPage?.total_rows, 0); + return Math.max(1, Math.ceil(totalRows / trafficPageSize)); } function updateTrafficPaginationControls() { @@ -1339,111 +1535,136 @@ function updateTrafficPaginationControls() { } function updateTrafficCountBadge() { - $("#trafficFlowCount").text(visibleTrafficRows().length); + $("#trafficFlowCount").text(toNumber(latestTrafficPage?.total_rows, latestCircuitSummary?.active_flow_count ?? 0)); } -function ingestTrafficRows(msg) { - tickCount++; - const rows = []; - const seenFlowKeys = new Set(); - const flowList = Array.isArray(msg?.flows) ? msg.flows : []; +function updateTopAsnCountBadge() { + $("#topAsnCount").text(toNumber(latestTopAsnData?.total_asns, latestCircuitSummary?.active_asn_count ?? 0)); +} - flowList.forEach((flow) => { - const flowKey = `${flow?.[0]?.protocol_name || ""}${flow?.[0]?.row_id || ""}`; - seenFlowKeys.add(flowKey); +function sortTopAsnRows(rows) { + rows.sort((a, b) => { + const asc = topAsnSortDirection === "asc"; + const normalize = (value) => typeof value === "string" ? value.toLowerCase() : value; + let aVal; + let bVal; + switch (topAsnSortColumn) { + case "asn": + aVal = normalize(a.asn_name); + bVal = normalize(b.asn_name); + break; + case "country": + aVal = normalize(a.asn_country); + bVal = normalize(b.asn_country); + break; + case "bytes": + aVal = a.bytes_sent_down + a.bytes_sent_up; + bVal = b.bytes_sent_down + b.bytes_sent_up; + break; + case "packets": + aVal = a.packets_sent_down + a.packets_sent_up; + bVal = b.packets_sent_down + b.packets_sent_up; + break; + case "retransmits": + aVal = a.retransmit_down_pct + a.retransmit_up_pct; + bVal = b.retransmit_down_pct + b.retransmit_up_pct; + break; + case "flows": + aVal = a.flow_count; + bVal = b.flow_count; + break; + case "rate": + default: + aVal = a.down_bps + a.up_bps; + bVal = b.down_bps + b.up_bps; + break; + } + if (aVal === bVal) { + return 0; + } + return asc ? (aVal < bVal ? -1 : 1) : (aVal > bVal ? -1 : 1); + }); +} - 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; +function renderTopAsnTab() { + const target = document.getElementById("topAsnsTable"); + if (!target) { + return; + } + + const displayRows = Array.isArray(latestTopAsnData?.rows) ? latestTopAsnData.rows.slice() : []; + sortTopAsnRows(displayRows); + + const tableWrap = document.createElement("div"); + tableWrap.classList.add("lqos-table-wrap"); + + const table = document.createElement("table"); + table.classList.add("lqos-table", "lqos-table-tight", "lqos-circuit-traffic-table"); + const thead = document.createElement("thead", "small"); + thead.style.fontSize = "0.8em"; + + const createSortableHeader = (text, sortKey, colspan = 1) => { + const th = theading(text, colspan); + th.style.cursor = "pointer"; + th.onclick = () => { + if (topAsnSortColumn === sortKey) { + topAsnSortDirection = topAsnSortDirection === "asc" ? "desc" : "asc"; + } else { + topAsnSortColumn = sortKey; + topAsnSortDirection = "desc"; } + renderTopAsnTab(); + }; + if (topAsnSortColumn === sortKey) { + th.innerHTML += topAsnSortDirection === "asc" ? " ▲" : " ▼"; } - if (down < 0) down = 0; - if (up < 0) up = 0; + return th; + }; - prevFlowBytes.set(flowKey, { - downBytes: flow?.[1]?.bytes_sent?.down, - upBytes: flow?.[1]?.bytes_sent?.up, - tick: tickCount, + thead.appendChild(createSortableHeader("ASN", "asn")); + thead.appendChild(createSortableHeader("Country", "country")); + thead.appendChild(createSortableHeader("Current Rate (d/u)", "rate", 2)); + thead.appendChild(createSortableHeader("Total Bytes (d/u)", "bytes", 2)); + thead.appendChild(createSortableHeader("Total Packets (d/u)", "packets", 2)); + thead.appendChild(createSortableHeader("TCP rxmit (d/u)", "retransmits", 2)); + thead.appendChild(createSortableHeader("Flows", "flows")); + table.appendChild(thead); + + const tbody = document.createElement("tbody"); + if (displayRows.length === 0) { + const empty = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 11; + td.classList.add("text-center", "text-muted", "small"); + td.textContent = "No recent ASN activity available for this circuit."; + empty.appendChild(td); + tbody.appendChild(empty); + } else { + displayRows.forEach((rowData) => { + const row = document.createElement("tr"); + row.classList.add("small"); + + row.appendChild(truncatedTrafficCell(rowData.asn_name, "lqos-circuit-traffic-asn-cell")); + row.appendChild(truncatedTrafficCell(rowData.asn_country, "lqos-circuit-traffic-country-cell")); + row.appendChild(simpleRowHtml(formatThroughput(rowData.down_bps, plan.down))); + row.appendChild(simpleRowHtml(formatThroughput(rowData.up_bps, plan.up))); + row.appendChild(simpleRow(scaleNumber(rowData.bytes_sent_down))); + row.appendChild(simpleRow(scaleNumber(rowData.bytes_sent_up))); + row.appendChild(simpleRow(scaleNumber(rowData.packets_sent_down))); + row.appendChild(simpleRow(scaleNumber(rowData.packets_sent_up))); + row.appendChild(simpleRowHtml(rowData.retransmit_down_pct > 0 ? formatRetransmitFraction(rowData.retransmit_down_pct) : "-")); + row.appendChild(simpleRowHtml(rowData.retransmit_up_pct > 0 ? formatRetransmitFraction(rowData.retransmit_up_pct) : "-")); + row.appendChild(simpleRow(scaleNumber(rowData.flow_count))); + + tbody.appendChild(row); }); + } - const currentRate = down + up; - const average = movingAverages.get(flowKey) || { values: [], total: 0 }; - average.values.push(currentRate); - average.total += currentRate; - if (average.values.length > 10) { - average.total -= average.values.shift(); - } - movingAverages.set(flowKey, average); - - const lastSeenNanos = toNumber(flow?.[0]?.last_seen_nanos, 0); - if (lastSeenNanos > RECENT_TRAFFIC_FLOW_WINDOW_NANOS) { - return; - } - - const tcpRetransmitsDown = toNumber(flow?.[1]?.tcp_retransmits?.down, 0); - const tcpRetransmitsUp = toNumber(flow?.[1]?.tcp_retransmits?.up, 0); - const packetsSentDown = toNumber(flow?.[1]?.packets_sent?.down, 0); - const packetsSentUp = toNumber(flow?.[1]?.packets_sent?.up, 0); - const retransmitDownPct = tcpRetransmitsDown > 0 && packetsSentDown > 0 ? tcpRetransmitsDown / packetsSentDown : 0; - const retransmitUpPct = tcpRetransmitsUp > 0 && packetsSentUp > 0 ? tcpRetransmitsUp / packetsSentUp : 0; - const bytesSentDown = toNumber(flow?.[1]?.bytes_sent?.down, 0); - const bytesSentUp = toNumber(flow?.[1]?.bytes_sent?.up, 0); - const rttDownNanos = toNumber(flow?.[1]?.rtt?.[0]?.nanoseconds, 0); - const rttUpNanos = toNumber(flow?.[1]?.rtt?.[1]?.nanoseconds, 0); - const qoq = flow?.[1]?.qoq || null; - const qooDown = qoq ? qoq.download_total : null; - const qooUp = qoq ? qoq.upload_total : null; - const remoteIp = String(flow?.[0]?.remote_ip || "").trim(); - - rows.push({ - protocolName: flow?.[0]?.protocol_name || "", - downBps: down, - upBps: up, - bytesSentDown, - bytesSentUp, - packetsSentDown, - packetsSentUp, - retransmitDownPct, - retransmitUpPct, - rttDownNanos, - rttUpNanos, - qooDown, - qooUp, - asnName: flow?.[0]?.asn_name || "", - asnCountry: flow?.[0]?.asn_country || "", - remoteIp, - opacity: 1.0 - Math.min(1, lastSeenNanos / RECENT_TRAFFIC_FLOW_WINDOW_NANOS), - sortKeys: { - protocol: flow?.[0]?.protocol_name || "", - rate: average.values.length > 0 ? average.total / average.values.length : currentRate, - bytes: bytesSentDown + bytesSentUp, - packets: packetsSentDown + packetsSentUp, - retransmits: retransmitDownPct + retransmitUpPct, - rtt: rttDownNanos + rttUpNanos, - qoo: (typeof qooDown === "number" ? qooDown : 0) + (typeof qooUp === "number" ? qooUp : 0), - asn: flow?.[0]?.asn_name || "", - country: flow?.[0]?.asn_country || "", - ip: remoteIp, - }, - }); - }); - - Array.from(prevFlowBytes.keys()).forEach((key) => { - if (!seenFlowKeys.has(key)) { - prevFlowBytes.delete(key); - movingAverages.delete(key); - } - }); - - latestTrafficRows = rows; + table.appendChild(tbody); + tableWrap.appendChild(table); + clearDiv(target); + target.appendChild(tableWrap); + updateTopAsnCountBadge(); } function renderTrafficTab() { @@ -1453,17 +1674,15 @@ function renderTrafficTab() { } const visibleRows = visibleTrafficRows().slice(); - sortTrafficRows(visibleRows); - const totalPages = Math.max(1, Math.ceil(visibleRows.length / trafficPageSize)); + const totalPages = Math.max(1, Math.ceil(toNumber(latestTrafficPage?.total_rows, 0) / trafficPageSize)); trafficCurrentPage = Math.min(Math.max(1, trafficCurrentPage), totalPages); - const startIndex = (trafficCurrentPage - 1) * trafficPageSize; - const pagedRows = visibleRows.slice(startIndex, startIndex + trafficPageSize); + const pagedRows = visibleRows; let tableWrap = document.createElement("div"); 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"; @@ -1478,7 +1697,7 @@ function renderTrafficTab() { trafficSortDirection = "desc"; } trafficCurrentPage = 1; - renderTrafficTab(); + requestTrafficFlowsPage(); }; if (trafficSortColumn === sortKey) { th.innerHTML += trafficSortDirection === "asc" ? " ▲" : " ▼"; @@ -1512,24 +1731,24 @@ function renderTrafficTab() { pagedRows.forEach((rowData) => { let row = document.createElement("tr"); row.classList.add("small"); - row.style.opacity = rowData.opacity; + row.style.opacity = toNumber(rowData.opacity, 1); - row.appendChild(simpleRow(rowData.protocolName)); - row.appendChild(simpleRowHtml(formatThroughput(rowData.downBps, plan.down))); - row.appendChild(simpleRowHtml(formatThroughput(rowData.upBps, plan.up))); - row.appendChild(simpleRow(scaleNumber(rowData.bytesSentDown))); - row.appendChild(simpleRow(scaleNumber(rowData.bytesSentUp))); - row.appendChild(simpleRow(scaleNumber(rowData.packetsSentDown))); - row.appendChild(simpleRow(scaleNumber(rowData.packetsSentUp))); - row.appendChild(simpleRowHtml(rowData.retransmitDownPct > 0 ? formatRetransmitFraction(rowData.retransmitDownPct) : "-")); - row.appendChild(simpleRowHtml(rowData.retransmitUpPct > 0 ? formatRetransmitFraction(rowData.retransmitUpPct) : "-")); - row.appendChild(simpleRowHtml(formatRttNanos(rowData.rttDownNanos))); - 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(simpleRow(rowData.remoteIp)); + row.appendChild(truncatedTrafficCell(rowData.protocol_name, "lqos-circuit-traffic-protocol-cell")); + row.appendChild(simpleRowHtml(formatThroughput(rowData.down_bps, plan.down))); + row.appendChild(simpleRowHtml(formatThroughput(rowData.up_bps, plan.up))); + row.appendChild(simpleRow(scaleNumber(rowData.bytes_sent_down))); + row.appendChild(simpleRow(scaleNumber(rowData.bytes_sent_up))); + row.appendChild(simpleRow(scaleNumber(rowData.packets_sent_down))); + row.appendChild(simpleRow(scaleNumber(rowData.packets_sent_up))); + row.appendChild(simpleRowHtml(rowData.retransmit_down_pct > 0 ? formatRetransmitFraction(rowData.retransmit_down_pct) : "-")); + row.appendChild(simpleRowHtml(rowData.retransmit_up_pct > 0 ? formatRetransmitFraction(rowData.retransmit_up_pct) : "-")); + row.appendChild(simpleRowHtml(formatRttNanos(rowData.rtt_down_nanos))); + row.appendChild(simpleRowHtml(formatRttNanos(rowData.rtt_up_nanos))); + row.appendChild(simpleRowHtml(formatQooScore(rowData.qoo_down))); + row.appendChild(simpleRowHtml(formatQooScore(rowData.qoo_up))); + row.appendChild(truncatedTrafficCell(rowData.asn_name, "lqos-circuit-traffic-asn-cell")); + row.appendChild(truncatedTrafficCell(rowData.asn_country, "lqos-circuit-traffic-country-cell")); + row.appendChild(simpleRow(rowData.remote_ip)); const td = document.createElement("td"); td.classList.add("text-center"); @@ -1537,9 +1756,9 @@ function renderTrafficTab() { button.type = "button"; button.className = "btn btn-outline-secondary btn-sm flow-rtt-exclude-btn"; button.textContent = "Exclude"; - button.disabled = !rowData.remoteIp; + button.disabled = !rowData.remote_ip; button.title = "Open a wizard to exclude RTT samples for this remote IP/CIDR (requires saving in Flow Tracking config)."; - button.dataset.remoteIp = rowData.remoteIp; + button.dataset.remoteIp = rowData.remote_ip; td.appendChild(button); row.appendChild(td); @@ -1555,52 +1774,6 @@ function renderTrafficTab() { updateTrafficPaginationControls(); } -function updateSpeedometer(devices) { - let totalDown = 0; - let totalUp = 0; - let planDown = 0; - let planUp = 0; - let retransmitsDown = 0; - let retransmitsUp = 0; - let tcpPacketsDown = 0; - let tcpPacketsUp = 0; - devices.forEach((device) => { - const deviceDown = toNumber(device.bytes_per_second.down, 0); - const deviceUp = toNumber(device.bytes_per_second.up, 0); - totalDown += deviceDown; - totalUp += deviceUp; - planDown = Math.max(planDown, toNumber(device.plan.down, 0)); - planUp = Math.max(planUp, toNumber(device.plan.up, 0)); - retransmitsDown += retransmitCountFromSample(device.tcp_retransmit_sample?.down); - retransmitsUp += retransmitCountFromSample(device.tcp_retransmit_sample?.up); - tcpPacketsDown += tcpPacketCountFromSample(device.tcp_retransmit_sample?.down); - tcpPacketsUp += tcpPacketCountFromSample(device.tcp_retransmit_sample?.up); - - let throughputGraph = deviceGraphs["throughputGraph_" + device.device_id]; - if (throughputGraph !== undefined) { - throughputGraph.update(deviceDown * 8, deviceUp * 8); - } - - let retransmitGraph = deviceGraphs["tcpRetransmitsGraph_" + device.device_id]; - if (retransmitGraph !== undefined) { - const deviceRetransmitDownPct = - retransmitFractionFromSample(device.tcp_retransmit_sample?.down) * 100.0; - const deviceRetransmitUpPct = - retransmitFractionFromSample(device.tcp_retransmit_sample?.up) * 100.0; - retransmitGraph.update( - deviceRetransmitDownPct, - deviceRetransmitUpPct - ); - } - }); - speedometer.update(totalDown * 8, totalUp * 8, planDown, planUp); - totalThroughput.update(totalDown * 8, totalUp * 8); - totalRetransmits.update( - tcpPacketsDown > 0 ? (retransmitsDown / tcpPacketsDown) * 100.0 : 0, - tcpPacketsUp > 0 ? (retransmitsUp / tcpPacketsUp) * 100.0 : 0 - ); -} - function fillLiveDevices(devices) { devices.forEach((device) => { let last_seen = document.getElementById("last_seen_" + device.device_id); @@ -1702,12 +1875,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 @@ -1910,18 +2087,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); @@ -2086,8 +2264,8 @@ function wireupAnalysis(circuits) { //console.log(url); // Restore the buttons - requestCircuitById((circuits) => { - wireupAnalysis(circuits); + requestCircuitById((payload) => { + wireupAnalysis(payload.devices || []); }); } return; @@ -2119,13 +2297,17 @@ function loadInitial() { initFlowFilters(); initQueuingActivityControls(); loadRttThresholds(); - requestCircuitById((circuits) => { + requestCircuitById((payload) => { + const circuits = payload.devices || []; + const advisory = payload.ethernet_advisory || null; let circuit = circuits[0]; + circuitConfigDevices = circuits; $("#circuitName").text(circuit.circuit_name); $("#circuitName").attr("title", circuit.circuit_name || ""); applyParentNodeLink(circuit.parent_node); - $("#bwMax").text(formatMbps(circuit.download_max_mbps) + " / " + formatMbps(circuit.upload_max_mbps)); - $("#bwMin").text(formatMbps(circuit.download_min_mbps) + " / " + formatMbps(circuit.upload_min_mbps)); + $("#bwMax").text(formatPlanSpeedPair(circuit.download_max_mbps, circuit.upload_max_mbps)); + $("#bwMin").text(formatPlanSpeedPair(circuit.download_min_mbps, circuit.upload_min_mbps)); + renderEthernetAdvisory(advisory); plan = { down: toNumber(circuit.download_max_mbps, 0), up: toNumber(circuit.upload_max_mbps, 0), @@ -2141,9 +2323,7 @@ function loadInitial() { initTabLifecycle(circuit.parent_node); updateQueuingActivityCards(); - connectPrivateChannel(); - connectPingers(circuits); - connectFlowChannel(); + connectCircuitSummaryChannel(); subscribeToCake(); wireupAnalysis(circuits); }, () => { @@ -2153,6 +2333,7 @@ function loadInitial() { function cleanupCircuitPage() { if (channelLink) { + wsClient.send({ Private: { StopCircuitWatcher: null } }); channelLink.close(); channelLink = null; } @@ -2161,13 +2342,12 @@ function cleanupCircuitPage() { cakeChannel = null; } if (pinger) { - pinger.close(); - pinger = null; - } - if (flowChannel) { - flowChannel.close(); - flowChannel = null; + stopPingMonitor(); } + devicePollTimer = clearPollingTimer(devicePollTimer); + sankeyPollTimer = clearPollingTimer(sankeyPollTimer); + topAsnPollTimer = clearPollingTimer(topAsnPollTimer); + trafficPollTimer = clearPollingTimer(trafficPollTimer); if (funnelSubscription) { funnelSubscription.dispose(); funnelSubscription = null; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 8d666554..04d8ef04 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -484,8 +484,8 @@ export function renderConfigMenu(currentPage) {
    ${menuItems.map(item => ` -
  • - +
  • + ${item.text}
  • diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_network.js b/src/rust/lqosd/src/node_manager/js_build/src/config_network.js index 24518684..a4da1ac8 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_network.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_network.js @@ -34,25 +34,25 @@ function applyEditorLockState() { } function renderNetworkNode(level, depth) { - let html = `
    `; + let html = `
    `; html += `
    `; for (const [key, value] of Object.entries(level)) { const isVirtual = value && value.virtual === true; const actionAttrs = editorButtonAttrs(); // Node header with actions - html += `
    `; - html += `
    ${key}${isVirtual ? ' Virtual' : ''}
    `; - html += `
    `; + html += `
    `; + html += `
    ${key}${isVirtual ? ' Virtual' : ''}
    `; + html += `
    `; if (depth > 0) { - html += ``; } - html += ``; - html += ``; html += `
    `; // Node details - html += `
    `; - html += `Download: ${value.downloadBandwidthMbps} Mbps`; - html += ``; - html += `Upload: ${value.uploadBandwidthMbps} Mbps`; - html += ``; html += `
    `; @@ -84,7 +84,7 @@ function renderNetworkNode(level, depth) { function renderNetwork() { if (!network_json || Object.keys(network_json).length === 0) { - $("#netjson").html(`
    No network nodes found. Add one to get started!
    `); + $("#netjson").html(`
    No network nodes found. Add one to get started.
    `); return; } $("#netjson").html(renderNetworkNode(network_json, 0)); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_treeguard.js b/src/rust/lqosd/src/node_manager/js_build/src/config_treeguard.js index 866ca66a..61a48e27 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_treeguard.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_treeguard.js @@ -12,12 +12,79 @@ let circuitRows = null; let selectedNodes = []; let selectedCircuits = []; +function setSummaryValue(id, value) { + const el = document.getElementById(id); + if (el) { + el.textContent = value; + } +} + +function updateOperationalSummary() { + const enabled = document.getElementById("enabled")?.checked ?? false; + const dryRun = document.getElementById("dryRun")?.checked ?? true; + const linksEnabled = document.getElementById("linksEnabled")?.checked ?? false; + const linksAllNodes = document.getElementById("linksAllNodes")?.checked ?? true; + const circuitsEnabled = document.getElementById("circuitsEnabled")?.checked ?? false; + const circuitsAllCircuits = document.getElementById("circuitsAllCircuits")?.checked ?? true; + const persistSqmOverrides = document.getElementById("persistSqmOverrides")?.checked ?? false; + + const mode = !enabled + ? "Disabled" + : dryRun + ? "Dry Run" + : "Live"; + const links = !linksEnabled + ? "Disabled" + : linksAllNodes + ? "All links" + : `Allowlist (${selectedNodes.length})`; + const circuits = !circuitsEnabled + ? "Disabled" + : circuitsAllCircuits + ? "All circuits" + : `Allowlist (${selectedCircuits.length})`; + const persistence = persistSqmOverrides ? "Persist overrides" : "In memory only"; + + setSummaryValue("treeguardSummaryMode", mode); + setSummaryValue("treeguardSummaryLinks", links); + setSummaryValue("treeguardSummaryCircuits", circuits); + setSummaryValue("treeguardSummaryPersistence", persistence); + + const note = document.getElementById("treeguardContextNote"); + if (!note) { + return; + } + + const warnings = []; + if (enabled && !dryRun) { + if (linksEnabled && linksAllNodes) { + warnings.push("live changes across all links"); + } + if (circuitsEnabled && circuitsAllCircuits) { + warnings.push("live changes across all circuits"); + } + if (persistSqmOverrides) { + warnings.push("persistent SQM overrides"); + } + } + + if (warnings.length === 0) { + note.classList.add("d-none"); + note.textContent = ""; + return; + } + + note.classList.remove("d-none"); + note.innerHTML = `Live scope: TreeGuard is currently set to apply ${warnings.join(", ")}.`; +} + function updateLinksEnrollmentUi() { const allNodes = document.getElementById("linksAllNodes")?.checked ?? true; const section = document.getElementById("nodesAllowlistSection"); if (section) { section.style.display = allNodes ? "none" : ""; } + updateOperationalSummary(); } function updateCircuitsEnrollmentUi() { @@ -26,6 +93,7 @@ function updateCircuitsEnrollmentUi() { if (section) { section.style.display = allCircuits ? "none" : ""; } + updateOperationalSummary(); } function loadNetworkData() { @@ -159,6 +227,7 @@ function updateNodesList() { if (selectedNodes.length === 0) { listContainer.innerHTML = '
    No nodes selected
    '; + updateOperationalSummary(); return; } @@ -178,6 +247,7 @@ function updateNodesList() { listItem.appendChild(removeBtn); listContainer.appendChild(listItem); }); + updateOperationalSummary(); } function addCircuitFromSelector() { @@ -226,6 +296,7 @@ function updateCircuitsList() { if (selectedCircuits.length === 0) { listContainer.innerHTML = '
    No circuits selected
    '; + updateOperationalSummary(); return; } @@ -245,6 +316,7 @@ function updateCircuitsList() { listItem.appendChild(removeBtn); listContainer.appendChild(listItem); }); + updateOperationalSummary(); } function validatePercent(name, value, min = 0, max = 100) { @@ -480,6 +552,18 @@ Promise.all([ document.getElementById("qooEnabled").checked = qoo.enabled; document.getElementById("minScore").value = qoo.min_score; + [ + "enabled", + "dryRun", + "linksEnabled", + "linksAllNodes", + "circuitsEnabled", + "circuitsAllCircuits", + "persistSqmOverrides", + ].forEach((id) => { + document.getElementById(id)?.addEventListener("change", updateOperationalSummary); + }); + document.getElementById("addNodeBtn").addEventListener("click", addNode); document.getElementById("linksAllNodes").addEventListener("change", updateLinksEnrollmentUi); document.getElementById("nodeSelector").addEventListener("keypress", (e) => { @@ -515,4 +599,6 @@ Promise.all([ alert("TreeGuard configuration saved successfully!"); }); }); + + updateOperationalSummary(); }); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js index ef358bdd..a3393ad4 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js @@ -1,7 +1,7 @@ import {DashletBaseInsight} from "./insight_dashlet_base"; import {formatUnixSecondsToLocalDateTime, mkBadge} from "./bakery_shared"; -const CIRCUIT_ACTIVITY_MAX_ITEMS = 6; +const CIRCUIT_ACTIVITY_MAX_ITEMS = 1; function truncateSummary(summary, maxChars = 180) { const normalized = (summary ?? "").toString().trim(); @@ -82,7 +82,7 @@ export class BakeryStatusDashlet extends DashletBaseInsight { } tooltip() { - return "
    Bakery Circuit Activity

    Shows live Bakery circuit-change progress while a live change is applying, then keeps a compact list of recent circuit-scoped Bakery operations underneath.

    "; + return "
    Bakery Circuit Activity

    Shows live Bakery circuit-change progress while a live change is applying, then keeps the single most recent circuit-scoped Bakery operation underneath.

    "; } subscribeTo() { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js index b1629a95..fe94faad 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js @@ -63,15 +63,9 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { } target.innerHTML = ` -
    -
    Network Snapshot
    -
    - ${this.inventoryCard(header)} - ${this.queuesCard(header)} ${this.throughputCard()} - ${this.simpleCard("Mapped IPs", "fa-map-marker-alt", "text-primary", formatCount(header.mapped_ip_count))} - ${this.simpleCard("Unknown IPs", "fa-question-circle", "text-warning", formatCount(header.unmapped_ip_count))} + ${this.inventoryCard(header)} ${this.insightCard(header)}
    `; @@ -86,25 +80,6 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { return this.groupCard("Inventory", "fa-layer-group", "text-primary", items); } - queuesCard(header) { - const items = [ - { label: "HTB", value: formatCount(header.htb_queue_count) }, - { label: "CAKE", value: formatCount(header.cake_queue_count) }, - { label: "fq-codel", value: formatCount(header.fq_codel_queue_count) }, - ]; - const allowQueueAlerts = !header?.bakery_reload_in_progress; - return this.groupCard( - "Queues", - "fa-stream", - "text-secondary", - items, - false, - allowQueueAlerts, - this.queueStatusBadge(header), - this.queueStatusMessage(header), - ); - } - throughputCard() { const bps = this.latestThroughput?.bps; const items = [ @@ -114,22 +89,6 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { return this.groupCard("Throughput", "fa-tachometer-alt", "text-info", items, true); } - simpleCard(label, icon, accent, value) { - return ` -
    -
    -
    - -
    -
    ${label}
    -
    ${value}
    -
    -
    -
    -
    - `; - } - insightCard(header) { const value = header.insight_connected; const badge = value === undefined @@ -152,30 +111,6 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { `; } - queueStatusBadge(header) { - if (!header?.queue_stats_stale) { - return ""; - } - const label = header?.bakery_reload_in_progress ? "Reloading" : "Stale"; - const title = header?.bakery_reload_in_progress - ? "Queue counts are showing the last known values while Bakery applies a full reload." - : "Queue counts are showing the last known values while live queue polling catches up."; - return `${label}`; - } - - queueStatusMessage(header) { - if (!header?.queue_stats_stale) { - return ""; - } - if (header?.bakery_reload_in_progress) { - return ""; - } - const text = header?.bakery_reload_in_progress - ? "Live queue polling is paused during the current Bakery reload." - : "Live queue polling is behind; values shown are the last known counts."; - return `
    ${text}
    `; - } - groupCard(title, icon, accent, items, allowHtmlLabel = false, allowAlerts = false, statusBadge = "", footer = "") { const hasZero = allowAlerts && items.some(item => this.isZero(item.value)); const rows = items.map((item, idx) => ` diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js index 73da5a2e..4f7b75d7 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js @@ -6,6 +6,7 @@ import {renderOperationCards} from "./operation_cards"; const TREEGUARD_OPERATION_MERGE_WINDOW_SECONDS = 180; const TREEGUARD_ACTIVITY_VIEW_STORAGE_KEY = "lqos_treeguard_activity_view"; +const TREEGUARD_ACTIVITY_PAGE_SIZE = 5; function formatUnixSecondsToLocalTime(unixSeconds) { const n = typeof unixSeconds === "number" ? unixSeconds : parseInt(unixSeconds, 10); @@ -490,6 +491,7 @@ export class TreeGuardActivityDashlet extends BaseDashlet { this.size = 12; this.nodeIdByName = new Map(); this.lastEntries = []; + this.currentPage = 0; this.viewMode = loadViewMode(); } @@ -591,6 +593,46 @@ export class TreeGuardActivityDashlet extends BaseDashlet { tableWrap.appendChild(table); this.eventsSection.appendChild(tableWrap); wrap.appendChild(this.eventsSection); + + const footer = document.createElement("div"); + footer.classList.add("d-flex", "justify-content-between", "align-items-center", "gap-2", "pt-2", "flex-wrap"); + + this.pageSummary = document.createElement("div"); + this.pageSummary.classList.add("small", "text-secondary"); + footer.appendChild(this.pageSummary); + + const pager = document.createElement("div"); + pager.classList.add("btn-group", "btn-group-sm"); + + this.prevButton = document.createElement("button"); + this.prevButton.type = "button"; + this.prevButton.classList.add("btn", "btn-outline-secondary"); + this.prevButton.innerHTML = " Newer"; + this.prevButton.setAttribute("aria-label", "Show newer TreeGuard activity"); + this.prevButton.addEventListener("click", () => { + if (this.currentPage > 0) { + this.currentPage -= 1; + this.renderEntries(this.lastEntries); + } + }); + + this.nextButton = document.createElement("button"); + this.nextButton.type = "button"; + this.nextButton.classList.add("btn", "btn-outline-secondary"); + this.nextButton.innerHTML = "Older "; + this.nextButton.setAttribute("aria-label", "Show older TreeGuard activity"); + this.nextButton.addEventListener("click", () => { + if (this.currentPage + 1 < this.totalPages()) { + this.currentPage += 1; + this.renderEntries(this.lastEntries); + } + }); + + pager.appendChild(this.prevButton); + pager.appendChild(this.nextButton); + footer.appendChild(pager); + wrap.appendChild(footer); + base.appendChild(wrap); this.renderViewMode(); return base; @@ -598,10 +640,12 @@ export class TreeGuardActivityDashlet extends BaseDashlet { setViewMode(mode) { this.viewMode = mode === "events" ? "events" : "operations"; + this.currentPage = 0; try { window?.localStorage?.setItem(TREEGUARD_ACTIVITY_VIEW_STORAGE_KEY, this.viewMode); } catch (_) {} this.renderViewMode(); + this.renderEntries(this.lastEntries); } renderViewMode() { @@ -619,6 +663,16 @@ export class TreeGuardActivityDashlet extends BaseDashlet { } } + totalPagesForCount(count) { + return Math.max(1, Math.ceil(count / TREEGUARD_ACTIVITY_PAGE_SIZE)); + } + + totalPages() { + const operationCount = buildTreeGuardOperationGroups(this.lastEntries).length; + const activeCount = this.viewMode === "events" ? this.lastEntries.length : operationCount; + return this.totalPagesForCount(activeCount); + } + onMessage(msg) { if (msg.event !== "TreeGuardActivity") { return; @@ -636,7 +690,14 @@ export class TreeGuardActivityDashlet extends BaseDashlet { } renderEntries(entries) { - const groups = buildTreeGuardOperationGroups(entries).map((group) => { + const operationGroups = buildTreeGuardOperationGroups(entries); + const totalRows = this.viewMode === "events" ? entries.length : operationGroups.length; + const totalPages = this.totalPagesForCount(totalRows); + this.currentPage = Math.min(this.currentPage, totalPages - 1); + const start = this.currentPage * TREEGUARD_ACTIVITY_PAGE_SIZE; + const end = Math.min(start + TREEGUARD_ACTIVITY_PAGE_SIZE, totalRows); + + const pagedGroups = operationGroups.slice(start, end).map((group) => { const fullReason = group.kind === "sqm_batch" ? summarizeSqmBatch(group) : (group.reason.label || ""); @@ -658,7 +719,7 @@ export class TreeGuardActivityDashlet extends BaseDashlet { progressBarClass: treeguardProgressClass(group), }; }); - renderOperationCards(this.operationsList, groups, { emptyText: "No recent operations" }); + renderOperationCards(this.operationsList, pagedGroups, { emptyText: "No recent operations" }); this.tbody.innerHTML = ""; @@ -670,11 +731,14 @@ export class TreeGuardActivityDashlet extends BaseDashlet { td.textContent = "No recent activity"; tr.appendChild(td); this.tbody.appendChild(tr); + this.pageSummary.textContent = this.viewMode === "events" ? "0 events" : "0 operations"; + this.prevButton.disabled = true; + this.nextButton.disabled = true; this.renderViewMode(); return; } - entries.slice(0, 50).forEach((entry) => { + entries.slice(start, end).forEach((entry) => { const tr = document.createElement("tr"); tr.classList.add("small"); @@ -760,6 +824,11 @@ export class TreeGuardActivityDashlet extends BaseDashlet { tr.appendChild(tdReason); this.tbody.appendChild(tr); }); + + const label = this.viewMode === "events" ? "events" : "operations"; + this.pageSummary.textContent = `${start + 1}-${end} of ${totalRows} ${label}`; + this.prevButton.disabled = this.currentPage === 0; + this.nextButton.disabled = this.currentPage + 1 >= totalPages; this.renderViewMode(); } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/ethernet_caps.js b/src/rust/lqosd/src/node_manager/js_build/src/ethernet_caps.js new file mode 100644 index 00000000..45999914 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/ethernet_caps.js @@ -0,0 +1,220 @@ +import {clearDiv, simpleRow, theading} from "./helpers/builders"; +import {formatMbps} from "./helpers/scaling"; +import {get_ws_client} from "./pubsub/ws"; + +const wsClient = get_ws_client(); +const params = new Proxy(new URLSearchParams(window.location.search), { + get: (searchParams, prop) => searchParams.get(prop), +}); + +let page = 0; +let pageSize = 100; +let search = ""; +let tier = ""; +let totalRows = 0; + +function sendWsRequest(responseEvent, request) { + return new Promise((resolve, reject) => { + let done = false; + const responseHandler = (msg) => { + if (done) return; + done = true; + wsClient.off(responseEvent, responseHandler); + wsClient.off("Error", errorHandler); + resolve(msg); + }; + const errorHandler = (msg) => { + if (done) return; + done = true; + wsClient.off(responseEvent, responseHandler); + wsClient.off("Error", errorHandler); + reject(msg); + }; + wsClient.on(responseEvent, responseHandler); + wsClient.on("Error", errorHandler); + wsClient.send(request); + }); +} + +function formatPlanPair(down, up) { + return `${formatMbps(down)} / ${formatMbps(up)} Mbps`; +} + +function tierBadgeClass(tierLabel) { + if (tierLabel === "10M") return "text-bg-danger"; + if (tierLabel === "100M") return "text-bg-warning"; + return "text-bg-info"; +} + +function buildTierBadge(row) { + const badge = document.createElement("span"); + badge.className = `badge rounded-pill ${tierBadgeClass(row?.badge?.tier_label || "")}`; + badge.textContent = row?.badge?.tier_label || "?"; + return badge; +} + +function tableRow(row) { + const tr = document.createElement("tr"); + tr.classList.add("small"); + + const circuitCell = document.createElement("td"); + const link = document.createElement("a"); + link.href = `/circuit.html?id=${encodeURIComponent(row.circuit_id)}`; + link.classList.add("redactable"); + link.textContent = row.circuit_name || row.circuit_id; + circuitCell.appendChild(link); + tr.appendChild(circuitCell); + + tr.appendChild(simpleRow(row.parent_node || "-", true)); + + const ethernetCell = document.createElement("td"); + ethernetCell.appendChild(buildTierBadge(row)); + tr.appendChild(ethernetCell); + + tr.appendChild(simpleRow(formatPlanPair(row.badge.requested_download_mbps, row.badge.requested_upload_mbps))); + tr.appendChild(simpleRow(formatPlanPair(row.badge.applied_download_mbps, row.badge.applied_upload_mbps))); + tr.appendChild(simpleRow(row.limiting_device_name || "-")); + tr.appendChild(simpleRow(row.limiting_interface_name || "-")); + + return tr; +} + +function renderTable(rows) { + const target = document.getElementById("ethernetCapsTableWrap"); + if (!target) return; + clearDiv(target); + + if (!Array.isArray(rows) || rows.length === 0) { + const empty = document.createElement("div"); + empty.className = "text-muted small"; + empty.textContent = "No Ethernet-limited circuits matched this filter."; + target.appendChild(empty); + return; + } + + const wrap = document.createElement("div"); + wrap.classList.add("lqos-table-wrap"); + const table = document.createElement("table"); + table.classList.add("lqos-table", "lqos-table-tight"); + + const thead = document.createElement("thead"); + thead.appendChild(theading("Circuit")); + thead.appendChild(theading("Parent")); + thead.appendChild(theading("Ethernet")); + thead.appendChild(theading("Requested")); + thead.appendChild(theading("Applied")); + thead.appendChild(theading("Limiting Device")); + thead.appendChild(theading("Interface")); + table.appendChild(thead); + + const tbody = document.createElement("tbody"); + rows.forEach((row) => tbody.appendChild(tableRow(row))); + table.appendChild(tbody); + wrap.appendChild(table); + target.appendChild(wrap); +} + +function updateSummary(query, rows) { + const summary = document.getElementById("ethernetCapsSummary"); + const pager = document.getElementById("ethernetCapsPager"); + const prev = document.getElementById("ethernetCapsPrev"); + const next = document.getElementById("ethernetCapsNext"); + if (!summary || !pager || !prev || !next) return; + + const totalPages = Math.max(1, Math.ceil(totalRows / pageSize)); + const currentPage = (query?.page ?? 0) + 1; + const start = totalRows === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const end = totalRows === 0 ? 0 : start + Math.max(0, rows.length - 1); + + summary.textContent = totalRows === 0 + ? "No active Ethernet caps" + : `${totalRows} Ethernet-limited circuits`; + pager.textContent = totalRows === 0 + ? "Page 1 / 1" + : `Showing ${start}–${end} of ${totalRows} • Page ${currentPage} / ${totalPages}`; + prev.disabled = currentPage <= 1; + next.disabled = currentPage >= totalPages; +} + +async function requestPage() { + try { + const query = { + page, + page_size: pageSize, + }; + if (search.trim()) { + query.search = search.trim(); + } + if (tier) { + query.tier = tier === "10M" ? "TenM" : tier === "100M" ? "HundredM" : "GigPlus"; + } + const msg = await sendWsRequest("EthernetCapsPage", { + EthernetCapsPage: { query }, + }); + const data = msg?.data || { rows: [], total_rows: 0, query }; + totalRows = Number.isFinite(Number(data.total_rows)) ? Number(data.total_rows) : 0; + renderTable(data.rows || []); + updateSummary(data.query, data.rows || []); + } catch (_error) { + totalRows = 0; + renderTable([]); + updateSummary({ page: 0 }, []); + } +} + +function bindControls() { + const searchInput = document.getElementById("ethernetCapsSearch"); + const tierSelect = document.getElementById("ethernetCapsTier"); + const pageSizeSelect = document.getElementById("ethernetCapsPageSize"); + const prev = document.getElementById("ethernetCapsPrev"); + const next = document.getElementById("ethernetCapsNext"); + + if (searchInput) { + searchInput.value = search; + searchInput.addEventListener("input", () => { + search = searchInput.value || ""; + page = 0; + requestPage(); + }); + } + if (tierSelect) { + tierSelect.value = tier; + tierSelect.addEventListener("change", () => { + tier = tierSelect.value || ""; + page = 0; + requestPage(); + }); + } + if (pageSizeSelect) { + pageSizeSelect.value = String(pageSize); + pageSizeSelect.addEventListener("change", () => { + const nextSize = parseInt(pageSizeSelect.value, 10); + pageSize = Number.isFinite(nextSize) && nextSize > 0 ? nextSize : 100; + page = 0; + requestPage(); + }); + } + prev?.addEventListener("click", () => { + page = Math.max(0, page - 1); + requestPage(); + }); + next?.addEventListener("click", () => { + const totalPages = Math.max(1, Math.ceil(totalRows / pageSize)); + page = Math.min(totalPages - 1, page + 1); + requestPage(); + }); +} + +function initFromQueryString() { + const rawTier = typeof params.tier === "string" ? params.tier.trim().toUpperCase() : ""; + tier = rawTier === "10M" || rawTier === "100M" || rawTier === "1G" ? rawTier : ""; + search = typeof params.search === "string" ? params.search : ""; +} + +wsClient.on("join", () => { + requestPage(); +}); + +initFromQueryString(); +bindControls(); +requestPage(); 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/flow_sankey.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/flow_sankey.js index 255317c5..ca7f4a07 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/flow_sankey.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/flow_sankey.js @@ -3,21 +3,16 @@ import {toNumber} from "../lq_js_common/helpers/scaling"; import {isRedacted} from "../helpers/redact"; const SANKEY_RECENT_FLOW_WINDOW_NANOS = 10_000_000_000; -const SANKEY_TOP_FLOW_LIMIT = 20; function totalFlowRate(flow) { - return ( - toNumber(flow?.[1]?.rate_estimate_bps?.down, 0) + - toNumber(flow?.[1]?.rate_estimate_bps?.up, 0) - ); + return toNumber(flow?.down_bps, 0) + toNumber(flow?.up_bps, 0); } function renderableSankeyFlows(flowMsg) { const flows = Array.isArray(flowMsg?.flows) ? flowMsg.flows : []; return flows - .filter((flow) => toNumber(flow?.[0]?.last_seen_nanos, 0) <= SANKEY_RECENT_FLOW_WINDOW_NANOS) - .sort((a, b) => totalFlowRate(b) - totalFlowRate(a)) - .slice(0, SANKEY_TOP_FLOW_LIMIT); + .filter((flow) => toNumber(flow?.last_seen_nanos, 0) <= SANKEY_RECENT_FLOW_WINDOW_NANOS) + .sort((a, b) => totalFlowRate(b) - totalFlowRate(a)); } export function getRenderableSankeyFlowCount(flowMsg) { @@ -53,11 +48,11 @@ export class FlowsSankey extends DashboardGraph { renderableSankeyFlows(flows).forEach((flow) => { flowCount++; - let localDevice = flow[0].device_name; - let proto = flow[0].protocol_name; - let asn = "ASN: " + flow[2].asn_id; - if (flow[0].asn_name !== "") asn += " " + flow[0].asn_name; - let remoteDevice = flow[0].remote_ip; + let localDevice = flow.device_name; + let proto = flow.protocol_name; + let asn = "ASN: " + flow.asn_id; + if (flow.asn_name !== "") asn += " " + flow.asn_name; + let remoteDevice = flow.remote_ip; // Ensure all members are present. The arrays hold links to subsequent // columns. @@ -75,9 +70,7 @@ export class FlowsSankey extends DashboardGraph { } // Accumulate traffic. - let currentRate = - toNumber(flow[1].rate_estimate_bps.down, 0) + - toNumber(flow[1].rate_estimate_bps.up, 0); + let currentRate = toNumber(flow.down_bps, 0) + toNumber(flow.up_bps, 0); if (localDevices[localDevice][asn] === undefined) { localDevices[localDevice][asn] = currentRate; } else { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js index db1c7c75..812927bc 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js @@ -24,8 +24,28 @@ function formatRttMs(value) { return `${n.toFixed(n >= 10 ? 0 : 1)} ms`; } -function getWaveformTheme() { +function hexToRgba(hex, alpha) { + if (typeof hex !== "string" || !hex.startsWith("#")) { + return hex; + } + const normalized = hex.length === 4 + ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` + : hex; + const red = parseInt(normalized.slice(1, 3), 16); + const green = parseInt(normalized.slice(3, 5), 16); + const blue = parseInt(normalized.slice(5, 7), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +function throughputPaletteColor(direction, fallback) { + const paletteIndex = direction === "up" ? 1 : 0; + return window.graphPalette?.[paletteIndex] || fallback; +} + +function getWaveformTheme(direction = "down") { const isDark = document.documentElement.getAttribute("data-bs-theme") !== "light"; + const throughputColor = throughputPaletteColor(direction, isDark ? "#4992ff" : "#d87c7c"); + if (isDark) { return { axisText: "rgba(216, 226, 244, 0.62)", @@ -33,14 +53,14 @@ function getWaveformTheme() { axisLine: "rgba(216, 226, 244, 0.28)", axisTick: "rgba(216, 226, 244, 0.2)", splitLine: "rgba(216, 226, 244, 0.045)", - throughputLine: "#32d3bd", - throughputGlow: "rgba(50, 211, 189, 0.34)", - throughputAreaTop: "rgba(50, 211, 189, 0.48)", - throughputAreaMid: "rgba(42, 156, 145, 0.3)", + throughputLine: throughputColor, + throughputGlow: hexToRgba(throughputColor, 0.34), + throughputAreaTop: hexToRgba(throughputColor, 0.48), + throughputAreaMid: hexToRgba(throughputColor, 0.28), throughputAreaBottom: "rgba(9, 20, 31, 0.03)", - rttLine: "#8fb0ff", - rttGlow: "rgba(143, 176, 255, 0.3)", - rttAreaTop: "rgba(143, 176, 255, 0.18)", + rttLine: "#b7a5ff", + rttGlow: "rgba(183, 165, 255, 0.32)", + rttAreaTop: "rgba(183, 165, 255, 0.18)", rttAreaBottom: "rgba(16, 22, 34, 0.01)", rttBandGood: "rgba(42, 168, 108, 0.08)", rttBandWarn: "rgba(228, 154, 44, 0.07)", @@ -58,14 +78,14 @@ function getWaveformTheme() { axisLine: "rgba(77, 94, 118, 0.28)", axisTick: "rgba(77, 94, 118, 0.18)", splitLine: "rgba(77, 94, 118, 0.08)", - throughputLine: "#129a87", - throughputGlow: "rgba(18, 154, 135, 0.18)", - throughputAreaTop: "rgba(18, 154, 135, 0.26)", - throughputAreaMid: "rgba(18, 154, 135, 0.14)", + throughputLine: throughputColor, + throughputGlow: hexToRgba(throughputColor, 0.2), + throughputAreaTop: hexToRgba(throughputColor, 0.24), + throughputAreaMid: hexToRgba(throughputColor, 0.13), throughputAreaBottom: "rgba(255, 255, 255, 0.02)", - rttLine: "#4f79d9", - rttGlow: "rgba(79, 121, 217, 0.18)", - rttAreaTop: "rgba(79, 121, 217, 0.1)", + rttLine: "#6f63cf", + rttGlow: "rgba(111, 99, 207, 0.2)", + rttAreaTop: "rgba(111, 99, 207, 0.1)", rttAreaBottom: "rgba(255, 255, 255, 0.01)", rttBandGood: "rgba(46, 163, 111, 0.06)", rttBandWarn: "rgba(214, 148, 40, 0.06)", @@ -191,15 +211,17 @@ function ceilingSeriesData(samples, direction, windowStart, windowEnd) { const ceilingBps = toNumber(ceilingData[i][1], 0); const throughputBps = toNumber(throughputData[i][1], 0); const atCeiling = ceilingBps > 0 && throughputBps >= (ceilingBps * 0.95); - const activeTimestamp = i > 0 ? timestamp : windowStart; + const activeValue = atCeiling ? ceilingBps : null; if (base.length === 0) { base.push([timestamp, ceilingBps]); - active.push([activeTimestamp, atCeiling ? ceilingBps : null]); + active.push([timestamp, activeValue]); + } else { + active.push([timestamp, activeValue]); } base.push([nextTimestamp, ceilingBps]); - active.push([nextTimestamp, atCeiling ? ceilingBps : null]); + active.push([nextTimestamp, activeValue]); } return { base, active }; } @@ -249,8 +271,8 @@ export class QueuingActivityWaveform extends DashboardGraph { if (this.dom && this.dom.classList) { this.dom.classList.remove("muted"); } - this.colors = getWaveformTheme(); this.direction = "down"; + this.colors = getWaveformTheme(this.direction); this.samples = []; this.sampleClockBase = null; this.lastSampleTimestamp = null; @@ -518,6 +540,46 @@ export class QueuingActivityWaveform extends DashboardGraph { this.scheduleRenderLoop(); } + applyThemeColors() { + this.option.xAxis[0].axisLine.lineStyle.color = this.colors.axisLine; + this.option.xAxis[1].axisLine.lineStyle.color = this.colors.axisLine; + this.option.xAxis[1].axisTick.lineStyle.color = this.colors.axisTick; + this.option.xAxis[1].axisLabel.color = this.colors.axisText; + this.option.yAxis[0].nameTextStyle.color = this.colors.axisName; + this.option.yAxis[0].axisLine.lineStyle.color = this.colors.axisLine; + this.option.yAxis[0].splitLine.lineStyle.color = this.colors.splitLine; + this.option.yAxis[0].axisLabel.color = this.colors.axisText; + this.option.yAxis[1].nameTextStyle.color = this.colors.axisName; + this.option.yAxis[1].axisLine.lineStyle.color = this.colors.axisLine; + this.option.yAxis[1].axisTick.lineStyle.color = this.colors.axisTick; + this.option.yAxis[1].splitLine.lineStyle.color = this.colors.splitLine; + this.option.yAxis[1].axisLabel.color = this.colors.axisText; + this.option.series[0].lineStyle.color = this.colors.throughputLine; + this.option.series[0].lineStyle.shadowColor = this.colors.throughputGlow; + this.option.series[0].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.colors.throughputAreaTop }, + { offset: 0.45, color: this.colors.throughputAreaMid }, + { offset: 1, color: this.colors.throughputAreaBottom }, + ]); + this.option.series[1].lineStyle.color = this.colors.ceilingInactive; + this.option.series[1].lineStyle.shadowColor = this.colors.ceilingInactiveGlow; + this.option.series[2].lineStyle.color = this.colors.ceilingActive; + this.option.series[2].lineStyle.shadowColor = this.colors.ceilingActiveGlow; + this.option.series[3].lineStyle.color = this.colors.ceilingActive; + this.option.series[3].lineStyle.shadowColor = this.colors.ceilingActiveGlow; + this.option.series[4].lineStyle.color = this.colors.rttLine; + this.option.series[4].lineStyle.shadowColor = this.colors.rttGlow; + this.option.series[4].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.colors.rttAreaTop }, + { offset: 1, color: this.colors.rttAreaBottom }, + ]); + this.option.series[4].markArea.data = rttMarkAreas( + this.colors, + this.rttThresholds, + this.option.yAxis[1].max || this.rttThresholds.red_ms, + ); + } + scheduleRenderLoop() { const step = (timestamp) => { if (!this.chart) { @@ -552,8 +614,11 @@ export class QueuingActivityWaveform extends DashboardGraph { setDirection(direction) { this.direction = direction === "up" ? "up" : "down"; + this.colors = getWaveformTheme(this.direction); this.cachedSeries.direction = this.direction; this.rebuildCachedSeries(); + this.applyThemeColors(); + this.chart.setOption(this.option); this.render(); } @@ -737,44 +802,8 @@ export class QueuingActivityWaveform extends DashboardGraph { } onThemeChange() { - this.colors = getWaveformTheme(); - this.option.xAxis[0].axisLine.lineStyle.color = this.colors.axisLine; - this.option.xAxis[1].axisLine.lineStyle.color = this.colors.axisLine; - this.option.xAxis[1].axisTick.lineStyle.color = this.colors.axisTick; - this.option.xAxis[1].axisLabel.color = this.colors.axisText; - this.option.yAxis[0].nameTextStyle.color = this.colors.axisName; - this.option.yAxis[0].axisLine.lineStyle.color = this.colors.axisLine; - this.option.yAxis[0].splitLine.lineStyle.color = this.colors.splitLine; - this.option.yAxis[0].axisLabel.color = this.colors.axisText; - this.option.yAxis[1].nameTextStyle.color = this.colors.axisName; - this.option.yAxis[1].axisLine.lineStyle.color = this.colors.axisLine; - this.option.yAxis[1].axisTick.lineStyle.color = this.colors.axisTick; - this.option.yAxis[1].splitLine.lineStyle.color = this.colors.splitLine; - this.option.yAxis[1].axisLabel.color = this.colors.axisText; - this.option.series[0].lineStyle.color = this.colors.throughputLine; - this.option.series[0].lineStyle.shadowColor = this.colors.throughputGlow; - this.option.series[0].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: this.colors.throughputAreaTop }, - { offset: 0.45, color: this.colors.throughputAreaMid }, - { offset: 1, color: this.colors.throughputAreaBottom }, - ]); - this.option.series[1].lineStyle.color = this.colors.ceilingInactive; - this.option.series[1].lineStyle.shadowColor = this.colors.ceilingInactiveGlow; - this.option.series[2].lineStyle.color = this.colors.ceilingActive; - this.option.series[2].lineStyle.shadowColor = this.colors.ceilingActiveGlow; - this.option.series[3].lineStyle.color = this.colors.ceilingActive; - this.option.series[3].lineStyle.shadowColor = this.colors.ceilingActiveGlow; - this.option.series[4].lineStyle.color = this.colors.rttLine; - this.option.series[4].lineStyle.shadowColor = this.colors.rttGlow; - this.option.series[4].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: this.colors.rttAreaTop }, - { offset: 1, color: this.colors.rttAreaBottom }, - ]); - this.option.series[4].markArea.data = rttMarkAreas( - this.colors, - this.rttThresholds, - this.option.yAxis[1].max || this.rttThresholds.red_ms, - ); + this.colors = getWaveformTheme(this.direction); + this.applyThemeColors(); this.chart.setOption(this.option); this.render(); } 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/js_build/src/helpers/builders.js b/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js index d3b0d87f..b73bb3cb 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js @@ -88,7 +88,9 @@ export function clientTableHeader() { let thead = document.createElement("thead"); thead.appendChild(theading("Circuit")); thead.appendChild(theading("Device")); - thead.appendChild(theading("Plan (Mbps)")); + const planHeading = theading("Plan (Mbps)"); + planHeading.classList.add("lqos-tree-plan-column"); + thead.appendChild(planHeading); thead.appendChild(theading("Parent")); thead.appendChild(theading("IP")); thead.appendChild(theading("Last Seen")); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/index.js b/src/rust/lqosd/src/node_manager/js_build/src/index.js index 6cea9f2a..8672d76c 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/index.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/index.js @@ -84,7 +84,6 @@ const defaultLayout = { { tag: "treeguardControlLoop", size: 4 }, { tag: "treeguardStateMix", size: 4 }, { tag: "treeguardDecisionImpact", size: 4 }, - { tag: "treeguardControls", size: 12 }, { tag: "treeguardActivity", size: 12 } ] }, diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree.js b/src/rust/lqosd/src/node_manager/js_build/src/tree.js index 4335b6ee..e9302410 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree.js @@ -174,12 +174,42 @@ function abbreviateIpForTable(ipText) { function summarizeIpListForTable(ipList) { const displayList = ipList.map((entry) => abbreviateIpForTable(entry)); - if (displayList.length > 2) { - return `${displayList[0]}, ${displayList[1]} +${displayList.length - 2}`; + if (displayList.length > 1) { + return `${displayList[0]} +${displayList.length - 1}`; } return displayList.join(", "); } +function formatEthernetPortLabel(mbps) { + const value = toNumber(mbps, 0); + if (value >= 1000 && value % 1000 === 0) { + return `${value / 1000}G`; + } + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}G`; + } + return `${Math.round(value)}M`; +} + +function ethernetCapsPageHref(badge) { + const tier = encodeURIComponent(formatEthernetPortLabel(badge?.negotiated_ethernet_mbps)); + return `/ethernet_caps.html?tier=${tier}`; +} + +function formatEthernetTooltip(badge) { + if (!badge) { + return ""; + } + return `Requested plan ${toNumber(badge.requested_download_mbps, 0)} / ${toNumber(badge.requested_upload_mbps, 0)} Mbps exceeded detected Ethernet speed. ` + + `Shaping auto-capped to ${toNumber(badge.applied_download_mbps, 0)} / ${toNumber(badge.applied_upload_mbps, 0)} Mbps.`; +} + +function ethernetBadgeClass(tierLabel) { + if (tierLabel === "10M") return "text-bg-danger"; + if (tierLabel === "100M") return "text-bg-warning"; + return "text-bg-info"; +} + function configuredMax(node) { return node.configured_max_throughput || node.max_throughput || [0, 0]; } @@ -1627,11 +1657,27 @@ function renderAttachedCircuitsRows(rows) { } tr.appendChild(deviceCell); - tr.appendChild(simpleRow(`${toNumber(circuit.plan_mbps?.down, 0)} / ${toNumber(circuit.plan_mbps?.up, 0)}`)); + const planCell = simpleRow( + `${toNumber(circuit.plan_mbps?.down, 0)} / ${toNumber(circuit.plan_mbps?.up, 0)}` + ); + planCell.classList.add("lqos-tree-plan-cell"); + if (circuit.ethernet_cap_badge) { + const badge = document.createElement("a"); + badge.className = `badge rounded-pill ms-2 text-decoration-none ${ethernetBadgeClass(circuit.ethernet_cap_badge.tier_label)}`; + badge.href = ethernetCapsPageHref(circuit.ethernet_cap_badge); + badge.setAttribute("aria-label", `Review ${circuit.ethernet_cap_badge.tier_label} Ethernet-limited circuits`); + badge.setAttribute("data-bs-toggle", "tooltip"); + badge.setAttribute("data-bs-placement", "top"); + badge.setAttribute("title", formatEthernetTooltip(circuit.ethernet_cap_badge)); + badge.textContent = circuit.ethernet_cap_badge.tier_label; + planCell.appendChild(badge); + } + tr.appendChild(planCell); tr.appendChild(simpleRow(circuit.parent_node, true)); const ipList = Array.isArray(circuit.ip_addrs) ? circuit.ip_addrs : []; const ipCell = simpleRow(summarizeIpListForTable(ipList), true); + ipCell.classList.add("lqos-tree-ip-cell"); if (ipList.length > 0) { ipCell.title = ipList.join(", "); } @@ -1666,6 +1712,7 @@ function renderAttachedCircuitsRows(rows) { tableWrap.appendChild(table); target.appendChild(sectionLabel); target.appendChild(tableWrap); + enableTooltipsWithin(target); } function attachedCircuitsUpdate(msg) { diff --git a/src/rust/lqosd/src/node_manager/local_api.rs b/src/rust/lqosd/src/node_manager/local_api.rs index e0f4cd28..88040112 100644 --- a/src/rust/lqosd/src/node_manager/local_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api.rs @@ -1,4 +1,5 @@ pub(crate) mod circuit; +pub(crate) mod circuit_activity; pub(crate) mod circuit_count; pub(crate) mod circuit_live; pub(crate) mod config; @@ -6,6 +7,7 @@ pub(crate) mod cpu_affinity; pub(crate) mod dashboard_themes; pub(crate) mod device_counts; pub(crate) mod directories; +pub(crate) mod ethernet_caps; pub(crate) mod executive; pub(crate) mod executive_cache; pub(crate) mod flow_explorer; diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit.rs b/src/rust/lqosd/src/node_manager/local_api/circuit.rs index 8991bfe8..0e191a87 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit.rs @@ -1,7 +1,29 @@ +use crate::node_manager::local_api::ethernet_caps::ethernet_advisory_for_circuit; use crate::shaped_devices_tracker::SHAPED_DEVICES; -use lqos_config::ShapedDevice; +use lqos_config::{CircuitEthernetMetadata, ShapedDevice}; +use serde::{Deserialize, Serialize}; -pub fn circuit_by_id_data(id: &str) -> Option> { +/// Circuit-page payload containing shaped devices plus optional Ethernet advisory metadata. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CircuitByIdData { + /// Shaped-device rows for the requested circuit. + pub devices: Vec, + /// Optional negotiated-Ethernet advisory derived from integration metadata. + pub ethernet_advisory: Option, +} + +fn load_ethernet_advisory( + circuit_id: &str, + devices: &[ShapedDevice], +) -> Option { + let device_ids: std::collections::HashSet<&str> = devices + .iter() + .map(|device| device.device_id.as_str()) + .collect(); + ethernet_advisory_for_circuit(circuit_id, &device_ids) +} + +pub fn circuit_by_id_data(id: &str) -> Option { let safe_id = id.to_lowercase().trim().to_string(); let reader = SHAPED_DEVICES.load(); let devices: Vec = reader @@ -14,6 +36,10 @@ pub fn circuit_by_id_data(id: &str) -> Option> { if devices.is_empty() { None } else { - Some(devices) + let ethernet_advisory = load_ethernet_advisory(&safe_id, &devices); + Some(CircuitByIdData { + devices, + ethernet_advisory, + }) } } diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs new file mode 100644 index 00000000..1a779524 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs @@ -0,0 +1,518 @@ +use crate::shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}; +use crate::throughput_tracker::flow_data::{ + ALL_FLOWS, FlowbeeLocalData, get_asn_name_and_country, +}; +use lqos_utils::hash_to_i64; +use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; +use lqos_utils::unix_time::time_since_boot; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::time::Duration; + +const RECENT_CIRCUIT_FLOWS_WINDOW_NANOS: u64 = 30 * 1_000_000_000; +const SANKEY_RECENT_FLOW_WINDOW_NANOS: u64 = 10 * 1_000_000_000; +const SANKEY_TOP_FLOW_LIMIT: usize = 20; +const TOP_ASN_LIMIT: usize = 10; +const FLOW_RATE_SANITY_MULTIPLIER: f64 = 2.0; +const FLOW_RATE_SANITY_FLOOR_BPS: u64 = 25_000_000; +const TRAFFIC_FLOW_HIDE_THRESHOLD_BPS: u32 = 1_048_576; + +/// Lightweight live summary for the circuit page header and Queue Dynamics. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitSummaryData { + pub circuit_id: String, + pub bytes_per_second: DownUpOrder, + pub rtt_current_p50_nanos: DownUpOrder>, + pub tcp_retransmit_sample: DownUpOrder, + pub qoo_score: Option, + pub rtt_excluded: bool, + pub active_flow_count: usize, + pub active_asn_count: usize, +} + +/// Server-side query for the live `Traffic Flows` table. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitTrafficFlowsQuery { + pub circuit: String, + pub page: usize, + pub page_size: usize, + pub hide_small: bool, + pub sort_column: String, + pub sort_direction: String, +} + +/// Compact server-side row for the live `Traffic Flows` table. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitTrafficFlowRow { + pub protocol_name: String, + pub down_bps: u32, + pub up_bps: u32, + pub bytes_sent_down: u64, + pub bytes_sent_up: u64, + pub packets_sent_down: u64, + pub packets_sent_up: u64, + pub tcp_retransmits_down: u16, + pub tcp_retransmits_up: u16, + pub retransmit_down_pct: f64, + pub retransmit_up_pct: f64, + pub rtt_down_nanos: u64, + pub rtt_up_nanos: u64, + pub qoo_down: Option, + pub qoo_up: Option, + pub asn_name: String, + pub asn_country: String, + pub remote_ip: String, + pub opacity: f64, + pub sort_rate_bps: f64, +} + +/// Page of server-side circuit traffic-flow rows. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitTrafficFlowsPage { + pub query: CircuitTrafficFlowsQuery, + pub total_rows: usize, + pub rows: Vec, +} + +/// Server-side query for the circuit `Top ASNs` table. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitTopAsnsQuery { + pub circuit: String, + pub hide_small: bool, +} + +/// Aggregated row for the circuit `Top ASNs` table. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitTopAsnRow { + pub asn_name: String, + pub asn_country: String, + pub down_bps: u64, + pub up_bps: u64, + pub bytes_sent_down: u64, + pub bytes_sent_up: u64, + pub packets_sent_down: u64, + pub packets_sent_up: u64, + pub retransmit_down_pct: f64, + pub retransmit_up_pct: f64, + pub flow_count: usize, +} + +/// Server-side payload for the circuit `Top ASNs` table. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitTopAsnsData { + pub total_asns: usize, + pub rows: Vec, +} + +/// Compact flow row for the circuit `Flow Sankey` tab. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitFlowSankeyRow { + pub device_name: String, + pub asn_id: u32, + pub asn_name: String, + pub protocol_name: String, + pub remote_ip: String, + pub down_bps: u32, + pub up_bps: u32, + pub last_seen_nanos: u64, +} + +#[derive(Clone, Debug)] +struct CircuitFlowSnapshotRow { + device_name: String, + asn_id: u32, + asn_name: String, + asn_country: String, + protocol_name: String, + remote_ip: String, + down_bps: u32, + up_bps: u32, + bytes_sent_down: u64, + bytes_sent_up: u64, + packets_sent_down: u64, + packets_sent_up: u64, + tcp_retransmits_down: u16, + tcp_retransmits_up: u16, + retransmit_down_pct: f64, + retransmit_up_pct: f64, + rtt_down_nanos: u64, + rtt_up_nanos: u64, + qoo_down: Option, + qoo_up: Option, + last_seen_nanos: u64, + opacity: f64, + sort_rate_bps: f64, +} + +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 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), + }) +} + +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 flow_rtt_nanos(local: &FlowbeeLocalData) -> DownUpOrder { + let rtt = local.get_rtt_array(); + DownUpOrder { + down: rtt[0].as_nanos(), + up: rtt[1].as_nanos(), + } +} + +fn flow_qoo(local: &FlowbeeLocalData) -> DownUpOrder> { + let qoq = local.get_qoq_scores(); + DownUpOrder { + down: qoq.download_total_f32(), + up: qoq.upload_total_f32(), + } +} + +fn flow_snapshot_rows(circuit_id: &str) -> Vec { + 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); + let Ok(now) = time_since_boot() else { + return Vec::new(); + }; + let now_as_nanos = Duration::from(now).as_nanos() as u64; + let recent_cutoff = now_as_nanos.saturating_sub(RECENT_CIRCUIT_FLOWS_WINDOW_NANOS); + + let all_flows = ALL_FLOWS.lock(); + all_flows + .flow_data + .iter() + .filter_map(|(key, (local, analysis))| { + if local.last_seen < recent_cutoff { + return None; + } + if local.circuit_hash != Some(circuit_hash) { + return None; + } + + let device_name = local + .device_hash + .and_then(|hash| cache.index_by_device_hash(&shaped, hash)) + .and_then(|idx| shaped.devices.get(idx)) + .map(|d| d.device_name.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + + let geo = get_asn_name_and_country(key.remote_ip.as_ip()); + let display_rate = display_rate_bps(local, display_rate_ceiling); + let current_rate = display_rate.down as u64 + display_rate.up as u64; + let packets_sent_down = local.packets_sent.down; + let packets_sent_up = local.packets_sent.up; + let tcp_retransmits_down = local.tcp_retransmits.down; + let tcp_retransmits_up = local.tcp_retransmits.up; + let retransmit_down_pct = if tcp_retransmits_down > 0 && packets_sent_down > 0 { + tcp_retransmits_down as f64 / packets_sent_down as f64 + } else { + 0.0 + }; + let retransmit_up_pct = if tcp_retransmits_up > 0 && packets_sent_up > 0 { + tcp_retransmits_up as f64 / packets_sent_up as f64 + } else { + 0.0 + }; + let rtt = flow_rtt_nanos(local); + let qoo = flow_qoo(local); + let last_seen_nanos = now_as_nanos.saturating_sub(local.last_seen); + + Some(CircuitFlowSnapshotRow { + device_name, + asn_id: analysis.asn_id.0, + asn_name: geo.name, + asn_country: geo.country, + protocol_name: analysis.protocol_analysis.to_string(), + remote_ip: key.remote_ip.to_string(), + down_bps: display_rate.down, + up_bps: display_rate.up, + bytes_sent_down: local.bytes_sent.down, + bytes_sent_up: local.bytes_sent.up, + packets_sent_down, + packets_sent_up, + tcp_retransmits_down, + tcp_retransmits_up, + retransmit_down_pct, + retransmit_up_pct, + rtt_down_nanos: rtt.down, + rtt_up_nanos: rtt.up, + qoo_down: qoo.down, + qoo_up: qoo.up, + last_seen_nanos, + opacity: 1.0 - f64::min(1.0, last_seen_nanos as f64 / RECENT_CIRCUIT_FLOWS_WINDOW_NANOS as f64), + sort_rate_bps: current_rate as f64, + }) + }) + .collect() +} + +fn sort_direction_is_asc(sort_direction: &str) -> bool { + sort_direction.eq_ignore_ascii_case("asc") +} + +fn compare_f64(left: f64, right: f64, asc: bool) -> Ordering { + let order = left.partial_cmp(&right).unwrap_or(Ordering::Equal); + if asc { order } else { order.reverse() } +} + +fn compare_u64(left: u64, right: u64, asc: bool) -> Ordering { + if asc { left.cmp(&right) } else { right.cmp(&left) } +} + +fn compare_strings(left: &str, right: &str, asc: bool) -> Ordering { + if asc { + left.cmp(right) + } else { + right.cmp(left) + } +} + +fn sort_traffic_rows(rows: &mut [CircuitFlowSnapshotRow], sort_column: &str, sort_direction: &str) { + let asc = sort_direction_is_asc(sort_direction); + rows.sort_by(|a, b| { + let primary = match sort_column { + "protocol" => compare_strings(&a.protocol_name, &b.protocol_name, asc), + "bytes" => compare_u64(a.bytes_sent_down + a.bytes_sent_up, b.bytes_sent_down + b.bytes_sent_up, asc), + "packets" => compare_u64(a.packets_sent_down + a.packets_sent_up, b.packets_sent_down + b.packets_sent_up, asc), + "retransmits" => compare_f64(a.retransmit_down_pct + a.retransmit_up_pct, b.retransmit_down_pct + b.retransmit_up_pct, asc), + "rtt" => compare_u64(a.rtt_down_nanos + a.rtt_up_nanos, b.rtt_down_nanos + b.rtt_up_nanos, asc), + "qoo" => compare_f64( + a.qoo_down.unwrap_or(0.0) as f64 + a.qoo_up.unwrap_or(0.0) as f64, + b.qoo_down.unwrap_or(0.0) as f64 + b.qoo_up.unwrap_or(0.0) as f64, + asc, + ), + "asn" => compare_strings(&a.asn_name, &b.asn_name, asc), + "country" => compare_strings(&a.asn_country, &b.asn_country, asc), + "ip" => compare_strings(&a.remote_ip, &b.remote_ip, asc), + _ => compare_f64(a.sort_rate_bps, b.sort_rate_bps, asc), + }; + if primary == Ordering::Equal { + compare_f64(a.sort_rate_bps, b.sort_rate_bps, false) + } else { + primary + } + }); +} + +pub fn circuit_flow_counts(circuit_id: &str) -> (usize, usize) { + let rows = flow_snapshot_rows(circuit_id); + let asn_count = rows + .iter() + .map(|row| (row.asn_name.clone(), row.asn_country.clone())) + .collect::>() + .len(); + (rows.len(), asn_count) +} + +pub fn circuit_traffic_flows_page(query: &CircuitTrafficFlowsQuery) -> CircuitTrafficFlowsPage { + let mut rows = flow_snapshot_rows(&query.circuit); + if query.hide_small { + rows.retain(|row| row.down_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS || row.up_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS); + } + sort_traffic_rows(&mut rows, &query.sort_column, &query.sort_direction); + + let total_rows = rows.len(); + let page_size = query.page_size.max(1); + let page = query.page.max(1); + let start = page_size.saturating_mul(page.saturating_sub(1)); + let paged = rows + .into_iter() + .skip(start) + .take(page_size) + .map(|row| CircuitTrafficFlowRow { + protocol_name: row.protocol_name, + down_bps: row.down_bps, + up_bps: row.up_bps, + bytes_sent_down: row.bytes_sent_down, + bytes_sent_up: row.bytes_sent_up, + packets_sent_down: row.packets_sent_down, + packets_sent_up: row.packets_sent_up, + tcp_retransmits_down: row.tcp_retransmits_down, + tcp_retransmits_up: row.tcp_retransmits_up, + retransmit_down_pct: row.retransmit_down_pct, + retransmit_up_pct: row.retransmit_up_pct, + rtt_down_nanos: row.rtt_down_nanos, + rtt_up_nanos: row.rtt_up_nanos, + qoo_down: row.qoo_down, + qoo_up: row.qoo_up, + asn_name: row.asn_name, + asn_country: row.asn_country, + remote_ip: row.remote_ip, + opacity: row.opacity, + sort_rate_bps: row.sort_rate_bps, + }) + .collect(); + + CircuitTrafficFlowsPage { + query: query.clone(), + total_rows, + rows: paged, + } +} + +pub fn circuit_top_asns_data(query: &CircuitTopAsnsQuery) -> CircuitTopAsnsData { + #[derive(Default)] + struct AsnBucket { + asn_name: String, + asn_country: String, + down_bps: u64, + up_bps: u64, + bytes_sent_down: u64, + bytes_sent_up: u64, + packets_sent_down: u64, + packets_sent_up: u64, + tcp_retransmits_down: u64, + tcp_retransmits_up: u64, + flow_count: usize, + } + + let mut rows = flow_snapshot_rows(&query.circuit); + if query.hide_small { + rows.retain(|row| row.down_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS || row.up_bps > TRAFFIC_FLOW_HIDE_THRESHOLD_BPS); + } + + let mut buckets: fxhash::FxHashMap<(String, String), AsnBucket> = fxhash::FxHashMap::default(); + for row in rows { + let key = ( + if row.asn_name.trim().is_empty() { + "Unknown ASN".to_string() + } else { + row.asn_name.clone() + }, + row.asn_country.clone(), + ); + let bucket = buckets.entry(key.clone()).or_insert_with(|| AsnBucket { + asn_name: key.0.clone(), + asn_country: key.1.clone(), + down_bps: 0, + up_bps: 0, + bytes_sent_down: 0, + bytes_sent_up: 0, + packets_sent_down: 0, + packets_sent_up: 0, + tcp_retransmits_down: 0, + tcp_retransmits_up: 0, + flow_count: 0, + }); + bucket.down_bps += row.down_bps as u64; + bucket.up_bps += row.up_bps as u64; + bucket.bytes_sent_down += row.bytes_sent_down; + bucket.bytes_sent_up += row.bytes_sent_up; + bucket.packets_sent_down += row.packets_sent_down; + bucket.packets_sent_up += row.packets_sent_up; + bucket.tcp_retransmits_down += row.tcp_retransmits_down as u64; + bucket.tcp_retransmits_up += row.tcp_retransmits_up as u64; + bucket.flow_count += 1; + } + + let total_asns = buckets.len(); + let mut bucket_rows: Vec = buckets + .into_values() + .map(|row| CircuitTopAsnRow { + asn_name: row.asn_name, + asn_country: row.asn_country, + down_bps: row.down_bps, + up_bps: row.up_bps, + bytes_sent_down: row.bytes_sent_down, + bytes_sent_up: row.bytes_sent_up, + packets_sent_down: row.packets_sent_down, + packets_sent_up: row.packets_sent_up, + retransmit_down_pct: if row.packets_sent_down > 0 { + row.tcp_retransmits_down as f64 / row.packets_sent_down as f64 + } else { + 0.0 + }, + retransmit_up_pct: if row.packets_sent_up > 0 { + row.tcp_retransmits_up as f64 / row.packets_sent_up as f64 + } else { + 0.0 + }, + flow_count: row.flow_count, + }) + .collect(); + + bucket_rows.sort_by(|a, b| { + let a_rate = a.down_bps + a.up_bps; + let b_rate = b.down_bps + b.up_bps; + b_rate + .cmp(&a_rate) + .then_with(|| b.flow_count.cmp(&a.flow_count)) + .then_with(|| a.asn_name.cmp(&b.asn_name)) + }); + bucket_rows.truncate(TOP_ASN_LIMIT); + + CircuitTopAsnsData { + total_asns, + rows: bucket_rows, + } +} + +pub fn circuit_flow_sankey_rows(circuit_id: &str) -> Vec { + let mut rows = flow_snapshot_rows(circuit_id); + rows.retain(|row| row.last_seen_nanos <= SANKEY_RECENT_FLOW_WINDOW_NANOS); + rows.sort_by(|a, b| compare_f64(a.sort_rate_bps, b.sort_rate_bps, false)); + rows.into_iter() + .take(SANKEY_TOP_FLOW_LIMIT) + .map(|row| CircuitFlowSankeyRow { + device_name: row.device_name, + asn_id: row.asn_id, + asn_name: row.asn_name, + protocol_name: row.protocol_name, + remote_ip: row.remote_ip, + down_bps: row.down_bps, + up_bps: row.up_bps, + last_seen_nanos: row.last_seen_nanos, + }) + .collect() +} diff --git a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs index 87137248..747cad17 100644 --- a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs @@ -734,24 +734,15 @@ fn resolve_snapshot_cpu_sets( }; if detection.has_hybrid_split { - let shaping = detection - .shaping - .into_iter() - .collect::>(); - let excluded = possible - .difference(&shaping) - .copied() - .collect::>(); + let shaping = detection.shaping.into_iter().collect::>(); + let excluded = possible.difference(&shaping).copied().collect::>(); return (shaping.into_iter().collect(), excluded, true); } if detection.exclude_efficiency_cores { let inferred = inferred_included_cpus(planned_core_metrics, placements); if !inferred.is_empty() && inferred.len() < possible.len() { - let excluded = possible - .difference(&inferred) - .copied() - .collect::>(); + let excluded = possible.difference(&inferred).copied().collect::>(); return (inferred.into_iter().collect(), excluded, false); } } @@ -1340,8 +1331,8 @@ pub fn cpu_affinity_preview_weights_data(q: CircuitsQuery) -> Vec(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum MaybeString { + String(String), + Null, + } + + Ok(match Option::::deserialize(deserializer)? { + Some(MaybeString::String(value)) => Some(value), + Some(MaybeString::Null) | None => None, + }) +} + +/// Normalized Ethernet-cap tier used by UI badges and filters. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum EthernetCapTier { + TenM, + HundredM, + GigPlus, +} + +impl EthernetCapTier { + /// Returns the short operator-facing label for this Ethernet tier. + pub const fn label(&self) -> &'static str { + match self { + Self::TenM => "10M", + Self::HundredM => "100M", + Self::GigPlus => "1G", + } + } +} + +/// Compact Ethernet-cap badge payload for circuit tables and detail views. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct EthernetCapBadge { + /// Normalized tier used for styling and filtering. + pub tier: EthernetCapTier, + /// Short display label such as `10M`, `100M`, or `1G`. + pub tier_label: String, + /// Negotiated Ethernet speed in Mbps reported by the limiting interface. + pub negotiated_ethernet_mbps: u64, + /// Requested download max before the cap was applied. + pub requested_download_mbps: f32, + /// Requested upload max before the cap was applied. + pub requested_upload_mbps: f32, + /// Applied download max after the cap. + pub applied_download_mbps: f32, + /// Applied upload max after the cap. + pub applied_upload_mbps: f32, +} + +/// Server-side paging/filter query for the Ethernet caps review page. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct EthernetCapsPageQuery { + pub page: Option, + pub page_size: Option, + #[serde(default, deserialize_with = "deserialize_optional_string")] + pub search: Option, + pub tier: Option, +} + +/// One Ethernet-limited circuit row for the review page. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct EthernetCapsPageRow { + /// Stable circuit identifier used for deep-linking to the circuit page. + pub circuit_id: String, + /// Human-facing circuit name. + pub circuit_name: String, + /// Parent node from shaped devices when available. + pub parent_node: String, + /// Compact warning badge metadata. + pub badge: EthernetCapBadge, + /// Device name that supplied the limiting Ethernet speed when known. + pub limiting_device_name: Option, + /// Interface name that supplied the limiting Ethernet speed when known. + pub limiting_interface_name: Option, +} + +/// A server-paged slice of Ethernet-capped circuits. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct EthernetCapsPage { + /// The normalized query that produced this page. + pub query: EthernetCapsPageQuery, + /// Total rows matching the query before paging. + pub total_rows: usize, + /// Page rows after filtering and paging. + pub rows: Vec, +} + +fn normalized_page_size(query: &EthernetCapsPageQuery) -> usize { + query + .page_size + .unwrap_or(DEFAULT_ETHERNET_CAPS_PAGE_SIZE) + .clamp(1, MAX_ETHERNET_CAPS_PAGE_SIZE) +} + +fn tier_for_speed(negotiated_ethernet_mbps: u64) -> EthernetCapTier { + match negotiated_ethernet_mbps { + 0..=10 => EthernetCapTier::TenM, + 11..=100 => EthernetCapTier::HundredM, + _ => EthernetCapTier::GigPlus, + } +} + +fn advisory_to_badge(advisory: &CircuitEthernetMetadata) -> Option { + if !advisory.auto_capped { + return None; + } + + let tier = tier_for_speed(advisory.negotiated_ethernet_mbps); + Some(EthernetCapBadge { + tier: tier.clone(), + tier_label: tier.label().to_string(), + negotiated_ethernet_mbps: advisory.negotiated_ethernet_mbps, + requested_download_mbps: advisory.requested_download_mbps, + requested_upload_mbps: advisory.requested_upload_mbps, + applied_download_mbps: advisory.applied_download_mbps, + applied_upload_mbps: advisory.applied_upload_mbps, + }) +} + +fn load_advisory_file() -> Option { + let cfg = load_config().ok()?; + let path = Path::new(&cfg.lqos_directory).join(CIRCUIT_ETHERNET_METADATA_FILENAME); + let payload = std::fs::read(path).ok()?; + serde_json::from_slice(&payload).ok() +} + +fn parent_node_by_circuit_id() -> HashMap { + let devices = SHAPED_DEVICES.load(); + let mut parent_nodes = HashMap::new(); + for device in &devices.devices { + let circuit_id = device.circuit_id.trim(); + if circuit_id.is_empty() { + continue; + } + parent_nodes + .entry(circuit_id.to_string()) + .or_insert_with(|| device.parent_node.trim().to_string()); + } + parent_nodes +} + +fn sort_rank(tier: &EthernetCapTier) -> u8 { + match tier { + EthernetCapTier::TenM => 0, + EthernetCapTier::HundredM => 1, + EthernetCapTier::GigPlus => 2, + } +} + +/// Returns a compact badge map keyed by circuit ID for Ethernet auto-capped circuits. +pub(crate) fn ethernet_cap_badge_map() -> HashMap { + let mut badges = HashMap::new(); + let Some(file) = load_advisory_file() else { + return badges; + }; + for advisory in file.circuits { + let Some(badge) = advisory_to_badge(&advisory) else { + continue; + }; + badges.insert(advisory.circuit_id.to_ascii_lowercase(), badge); + } + badges +} + +/// Finds one Ethernet advisory for the requested circuit and matching shaped-device IDs. +pub(crate) fn ethernet_advisory_for_circuit( + circuit_id: &str, + device_ids: &std::collections::HashSet<&str>, +) -> Option { + let file = load_advisory_file()?; + file.circuits.into_iter().find(|entry| { + entry.auto_capped + && entry.circuit_id.eq_ignore_ascii_case(circuit_id) + && entry + .device_ids + .iter() + .any(|device_id| device_ids.contains(device_id.as_str())) + }) +} + +/// Returns one filtered, sorted page of Ethernet-limited circuits for operator review. +pub fn ethernet_caps_page(query: EthernetCapsPageQuery) -> EthernetCapsPage { + let page = query.page.unwrap_or(0); + let page_size = normalized_page_size(&query); + let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); + let parent_nodes = parent_node_by_circuit_id(); + + let mut rows = Vec::new(); + if let Some(file) = load_advisory_file() { + for advisory in file.circuits { + let Some(badge) = advisory_to_badge(&advisory) else { + continue; + }; + if let Some(filter_tier) = query.tier.as_ref() + && &badge.tier != filter_tier + { + continue; + } + let parent_node = parent_nodes + .get(&advisory.circuit_id) + .cloned() + .unwrap_or_default(); + let row = EthernetCapsPageRow { + circuit_id: advisory.circuit_id, + circuit_name: advisory.circuit_name, + parent_node, + badge, + limiting_device_name: advisory.limiting_device_name, + limiting_interface_name: advisory.limiting_interface_name, + }; + if !search.is_empty() { + let limiting_device = row.limiting_device_name.as_deref().unwrap_or(""); + let limiting_interface = row.limiting_interface_name.as_deref().unwrap_or(""); + if !row.circuit_id.to_lowercase().contains(&search) + && !row.circuit_name.to_lowercase().contains(&search) + && !row.parent_node.to_lowercase().contains(&search) + && !row.badge.tier_label.to_lowercase().contains(&search) + && !limiting_device.to_lowercase().contains(&search) + && !limiting_interface.to_lowercase().contains(&search) + { + continue; + } + } + rows.push(row); + } + } + + rows.sort_by(|left, right| { + sort_rank(&left.badge.tier) + .cmp(&sort_rank(&right.badge.tier)) + .then_with(|| left.circuit_name.cmp(&right.circuit_name)) + .then_with(|| left.circuit_id.cmp(&right.circuit_id)) + }); + + let total_rows = rows.len(); + let start = page.saturating_mul(page_size); + let rows = if start >= total_rows { + Vec::new() + } else { + let end = (start + page_size).min(total_rows); + rows[start..end].to_vec() + }; + + EthernetCapsPage { + query: EthernetCapsPageQuery { + page: Some(page), + page_size: Some(page_size), + search: if search.is_empty() { + None + } else { + query.search + }, + tier: query.tier, + }, + total_rows, + rows, + } +} + +#[cfg(test)] +mod tests { + use super::{EthernetCapTier, sort_rank, tier_for_speed}; + + #[test] + fn ethernet_cap_tier_classifies_expected_speeds() { + assert_eq!(tier_for_speed(10), EthernetCapTier::TenM); + assert_eq!(tier_for_speed(100), EthernetCapTier::HundredM); + assert_eq!(tier_for_speed(1000), EthernetCapTier::GigPlus); + } + + #[test] + fn ethernet_cap_sort_rank_prioritizes_low_speed_tiers() { + assert!(sort_rank(&EthernetCapTier::TenM) < sort_rank(&EthernetCapTier::HundredM)); + assert!(sort_rank(&EthernetCapTier::HundredM) < sort_rank(&EthernetCapTier::GigPlus)); + } +} diff --git a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs index 3e6cb058..c0937af4 100644 --- a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs +++ b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs @@ -1,3 +1,4 @@ +use crate::node_manager::local_api::ethernet_caps::{EthernetCapBadge, ethernet_cap_badge_map}; use crate::shaped_devices_tracker::NETWORK_JSON; use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; @@ -51,6 +52,8 @@ pub struct TreeAttachedCircuitsQuery { pub struct TreeAttachedCircuitRow { pub circuit_id: String, pub circuit_name: String, + /// Optional Ethernet speed-cap advisory badge when this circuit was auto-capped. + pub ethernet_cap_badge: Option, pub parent_node: String, pub device_names: Vec, pub ip_addrs: Vec, @@ -133,6 +136,7 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC let node_name = resolve_node_name(&query); let snapshot = fresh_circuit_live_snapshot(); + let ethernet_badges = ethernet_cap_badge_map(); let mut rows: Vec = match node_name.as_deref() { Some(node_name) => snapshot .circuit_ids_by_parent_node @@ -143,6 +147,9 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC .map(|row| TreeAttachedCircuitRow { circuit_id: row.circuit_id.clone(), circuit_name: row.circuit_name.clone(), + ethernet_cap_badge: ethernet_badges + .get(&row.circuit_id.to_ascii_lowercase()) + .cloned(), parent_node: row.parent_node.clone(), device_names: row.device_names.clone(), ip_addrs: row.ip_addrs.clone(), diff --git a/src/rust/lqosd/src/node_manager/static2/circuit.html b/src/rust/lqosd/src/node_manager/static2/circuit.html index ea925a85..13abb7e0 100644 --- a/src/rust/lqosd/src/node_manager/static2/circuit.html +++ b/src/rust/lqosd/src/node_manager/static2/circuit.html @@ -3,21 +3,34 @@
    - +
    - - + + - - + + - + - +
    Parent NodeParent
    Maximum SpeedMax + + + + +
    Minimum SpeedMin
    RTT MetricsRTT
    @@ -90,6 +103,11 @@ Flow Sankey 0 +
    -
    -
    -
    - - Loading devices… +
    +
    +
    +
    Devices
    +

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

    +
    +
    +
    +
    +
    + + Loading devices… +
    @@ -188,6 +214,16 @@
    +
    +
    +
    +
    + + Loading ASN summary… +
    +
    +
    +
    diff --git a/src/rust/lqosd/src/node_manager/static2/config_devices.html b/src/rust/lqosd/src/node_manager/static2/config_devices.html index 9bf2bf03..b6963cee 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_devices.html +++ b/src/rust/lqosd/src/node_manager/static2/config_devices.html @@ -1,14 +1,10 @@ -
    -
    -
    - -
    -
    +
    +
    + -
    -
    +
    -
    +
    Shaped Devices
    Loading devices...
    @@ -40,7 +36,7 @@
    -
    +
    SQM overrides can be set per direction. Leave a side blank to use the global default; set to "none" to disable that side.
    @@ -61,7 +57,7 @@
    -
    +