Merge pull request #1016 from LibreQoE/uisp_fix_33

TreeGuard/Bakery runtime hardening and operator UX improvements with UISP Ethernet auto-cap advisories
This commit is contained in:
Robert Chacón authored and GitHub committed 2026-03-30 12:10:07 -06:00
commit 9f06bc272b
83 files changed
+5548 -1965

No files matched your search

+2 -2
View File
@@ -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
+28 -13
View File
@@ -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
@@ -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`.
+2
View File
@@ -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`
+30 -20
View File
@@ -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`.
+2
View File
@@ -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`
+2
View File
@@ -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`.
+27 -17
View File
@@ -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`.
+6 -3
View File
@@ -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:
+1
View File
@@ -2029,6 +2029,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"toml",
"tracing",
]
+3
View File
@@ -15,3 +15,6 @@ parking_lot.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
toml.workspace = true
+136 -46
View File
@@ -1246,26 +1246,18 @@ struct GroupedBakeryEventLimiter {
}
impl GroupedBakeryEventLimiter {
fn emit(
fn emit_with_site_name(
&mut self,
group_key: impl Into<String>,
event: &str,
status: &str,
site: Option<(i64, Option<String>)>,
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<String>,
event: &str,
status: &str,
site_hash: Option<i64>,
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<String> {
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<BakeryCommands>],
config: &Arc<Config>,
@@ -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::path::PathBuf> =
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<BakeryCommands> {
@@ -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());
@@ -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<CircuitEthernetMetadata>,
}
/// 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<String>,
/// 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<String>,
/// Device name of the limiting device when known.
pub limiting_device_name: Option<String>,
/// Interface name that reported the limiting Ethernet speed when known.
pub limiting_interface_name: Option<String>,
}
+4
View File
@@ -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,
};
+33 -1
View File
@@ -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>) -> 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
@@ -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
File diff suppressed because it is too large. Load diff
@@ -484,8 +484,8 @@ export function renderConfigMenu(currentPage) {
<div class="col-12">
<ul class="config-menu">
${menuItems.map(item => `
<li class="config-menu-item${item.id === currentPage ? ' active' : ''}">
<a href="${item.href}" class="text-decoration-none">
<li class="config-menu-item">
<a href="${item.href}" class="config-menu-link text-decoration-none${item.id === currentPage ? ' active' : ''}">
<i class="fa ${item.icon}"></i> ${item.text}
</a>
</li>
@@ -34,25 +34,25 @@ function applyEditorLockState() {
}
function renderNetworkNode(level, depth) {
let html = `<div class="card mb-3" style="margin-left: ${depth * 30}px;">`;
let html = `<div class="card lqos-network-node-card mb-3" style="margin-left: ${depth * 30}px;">`;
html += `<div class="card-body">`;
for (const [key, value] of Object.entries(level)) {
const isVirtual = value && value.virtual === true;
const actionAttrs = editorButtonAttrs();
// Node header with actions
html += `<div class="d-flex justify-content-between align-items-center mb-2">`;
html += `<h5 class="card-title mb-0">${key}${isVirtual ? ' <span class="badge bg-secondary ms-2"><i class="fa fa-ghost"></i> Virtual</span>' : ''}</h5>`;
html += `<div>`;
html += `<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">`;
html += `<h5 class="card-title mb-0">${key}${isVirtual ? ' <span class="badge bg-info-subtle text-info border border-info-subtle ms-2"><i class="fa fa-ghost"></i> Virtual</span>' : ''}</h5>`;
html += `<div class="d-flex flex-wrap gap-2">`;
if (depth > 0) {
html += `<button class="btn btn-sm btn-outline-secondary me-1" onclick="promoteNode('${key}')" title="Promote ${key}" aria-label="Promote ${key}"${actionAttrs}>
html += `<button class="btn btn-sm btn-outline-primary" onclick="promoteNode('${key}')" title="Promote ${key}" aria-label="Promote ${key}"${actionAttrs}>
<i class="fas fa-arrow-up"></i> Promote
</button>`;
}
html += `<button class="btn btn-sm btn-outline-secondary me-1" onclick="toggleVirtualNode('${key}')" title="Toggle virtual mode for ${key}" aria-label="Toggle virtual mode for ${key}"${actionAttrs}>
html += `<button class="btn btn-sm btn-outline-primary" onclick="toggleVirtualNode('${key}')" title="Toggle virtual mode for ${key}" aria-label="Toggle virtual mode for ${key}"${actionAttrs}>
<i class="fas ${isVirtual ? 'fa-toggle-on' : 'fa-toggle-off'}"></i> Virtual
</button>`;
html += `<button class="btn btn-sm btn-outline-secondary me-1" onclick="renameNode('${key}')" title="Rename ${key}" aria-label="Rename ${key}"${actionAttrs}>
html += `<button class="btn btn-sm btn-outline-primary" onclick="renameNode('${key}')" title="Rename ${key}" aria-label="Rename ${key}"${actionAttrs}>
<i class="fas fa-pencil-alt"></i> Rename
</button>`;
html += `<button class="btn btn-sm btn-outline-danger" onclick="deleteNode('${key}')" title="Delete ${key}" aria-label="Delete ${key}"${actionAttrs}>
@@ -61,13 +61,13 @@ function renderNetworkNode(level, depth) {
html += `</div></div>`;
// Node details
html += `<div class="mb-3">`;
html += `<span class="badge bg-primary me-2">Download: ${value.downloadBandwidthMbps} Mbps</span>`;
html += `<button class="btn btn-sm btn-outline-secondary me-2" onclick="nodeSpeedChange('${key}', 'd')" title="Edit download speed for ${key}" aria-label="Edit download speed for ${key}"${actionAttrs}>
html += `<div class="mb-3 d-flex flex-wrap align-items-center gap-2">`;
html += `<span class="badge bg-primary-subtle text-primary border border-primary-subtle">Download: ${value.downloadBandwidthMbps} Mbps</span>`;
html += `<button class="btn btn-sm btn-outline-primary" onclick="nodeSpeedChange('${key}', 'd')" title="Edit download speed for ${key}" aria-label="Edit download speed for ${key}"${actionAttrs}>
<i class="fas fa-pencil-alt" aria-hidden="true"></i>
</button>`;
html += `<span class="badge bg-success me-2">Upload: ${value.uploadBandwidthMbps} Mbps</span>`;
html += `<button class="btn btn-sm btn-outline-secondary" onclick="nodeSpeedChange('${key}', 'u')" title="Edit upload speed for ${key}" aria-label="Edit upload speed for ${key}"${actionAttrs}>
html += `<span class="badge bg-success-subtle text-success border border-success-subtle">Upload: ${value.uploadBandwidthMbps} Mbps</span>`;
html += `<button class="btn btn-sm btn-outline-primary" onclick="nodeSpeedChange('${key}', 'u')" title="Edit upload speed for ${key}" aria-label="Edit upload speed for ${key}"${actionAttrs}>
<i class="fas fa-pencil-alt" aria-hidden="true"></i>
</button>`;
html += `</div>`;
@@ -84,7 +84,7 @@ function renderNetworkNode(level, depth) {
function renderNetwork() {
if (!network_json || Object.keys(network_json).length === 0) {
$("#netjson").html(`<div class="alert alert-info">No network nodes found. Add one to get started!</div>`);
$("#netjson").html(`<div class="lqos-config-note">No network nodes found. Add one to get started.</div>`);
return;
}
$("#netjson").html(renderNetworkNode(network_json, 0));
@@ -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 = `<strong>Live scope:</strong> 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 = '<div class="text-muted">No nodes selected</div>';
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 = '<div class="text-muted">No circuits selected</div>';
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();
});
@@ -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 "<h5>Bakery Circuit Activity</h5><p>Shows live Bakery circuit-change progress while a live change is applying, then keeps a compact list of recent circuit-scoped Bakery operations underneath.</p>";
return "<h5>Bakery Circuit Activity</h5><p>Shows live Bakery circuit-change progress while a live change is applying, then keeps the single most recent circuit-scoped Bakery operation underneath.</p>";
}
subscribeTo() {
@@ -63,15 +63,9 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet {
}
target.innerHTML = `
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<div class="exec-section-title mb-0 text-secondary"><i class="fas fa-chart-pie me-2 text-primary"></i>Network Snapshot</div>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-3 g-3">
${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)}
</div>
`;
@@ -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 `
<div class="col">
<div class="executive-card h-100">
<div class="d-flex align-items-center gap-3">
<span class="exec-icon ${accent}"><i class="fas ${icon}"></i></span>
<div>
<div class="text-secondary small">${label}</div>
<div class="exec-metric-value text-secondary">${value}</div>
</div>
</div>
</div>
</div>
`;
}
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 `<span class="badge bg-warning-subtle text-warning exec-badge" title="${title}" aria-label="${title}">${label}</span>`;
}
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 `<div class="text-warning small mt-2"><i class="fas fa-rotate me-1"></i>${text}</div>`;
}
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) => `
@@ -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 = "<i class='fa fa-chevron-left'></i> 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 <i class='fa fa-chevron-right'></i>";
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();
}
}
@@ -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();
@@ -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;
}
@@ -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];
}
}
}
@@ -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];
}
}
}
@@ -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;
@@ -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 {
@@ -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();
}
@@ -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);
@@ -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"));
@@ -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 }
]
},
@@ -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) {
@@ -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;
@@ -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<Vec<ShapedDevice>> {
/// 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<ShapedDevice>,
/// Optional negotiated-Ethernet advisory derived from integration metadata.
pub ethernet_advisory: Option<CircuitEthernetMetadata>,
}
fn load_ethernet_advisory(
circuit_id: &str,
devices: &[ShapedDevice],
) -> Option<CircuitEthernetMetadata> {
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<CircuitByIdData> {
let safe_id = id.to_lowercase().trim().to_string();
let reader = SHAPED_DEVICES.load();
let devices: Vec<ShapedDevice> = reader
@@ -14,6 +36,10 @@ pub fn circuit_by_id_data(id: &str) -> Option<Vec<ShapedDevice>> {
if devices.is_empty() {
None
} else {
Some(devices)
let ethernet_advisory = load_ethernet_advisory(&safe_id, &devices);
Some(CircuitByIdData {
devices,
ethernet_advisory,
})
}
}
@@ -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<u64>,
pub rtt_current_p50_nanos: DownUpOrder<Option<u64>>,
pub tcp_retransmit_sample: DownUpOrder<TcpRetransmitSample>,
pub qoo_score: Option<f32>,
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<f32>,
pub qoo_up: Option<f32>,
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<CircuitTrafficFlowRow>,
}
/// 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<CircuitTopAsnRow>,
}
/// 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<f32>,
qoo_up: Option<f32>,
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<DownUpOrder<u32>> {
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<u32>>,
) -> DownUpOrder<u32> {
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<u64> {
let rtt = local.get_rtt_array();
DownUpOrder {
down: rtt[0].as_nanos(),
up: rtt[1].as_nanos(),
}
}
fn flow_qoo(local: &FlowbeeLocalData) -> DownUpOrder<Option<f32>> {
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<CircuitFlowSnapshotRow> {
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::<std::collections::HashSet<_>>()
.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<CircuitTopAsnRow> = 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<CircuitFlowSankeyRow> {
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()
}
@@ -734,24 +734,15 @@ fn resolve_snapshot_cpu_sets(
};
if detection.has_hybrid_split {
let shaping = detection
.shaping
.into_iter()
.collect::<BTreeSet<_>>();
let excluded = possible
.difference(&shaping)
.copied()
.collect::<Vec<_>>();
let shaping = detection.shaping.into_iter().collect::<BTreeSet<_>>();
let excluded = possible.difference(&shaping).copied().collect::<Vec<_>>();
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::<Vec<_>>();
let excluded = possible.difference(&inferred).copied().collect::<Vec<_>>();
return (inferred.into_iter().collect(), excluded, false);
}
}
@@ -1340,8 +1331,8 @@ pub fn cpu_affinity_preview_weights_data(q: CircuitsQuery) -> Vec<PreviewWeightI
#[cfg(test)]
mod tests {
use super::{
PlannedCoreMetrics, RuntimeNodePlacement, derive_runtime_node_placements,
is_cpu_root_node, resolve_snapshot_cpu_sets,
PlannedCoreMetrics, RuntimeNodePlacement, derive_runtime_node_placements, is_cpu_root_node,
resolve_snapshot_cpu_sets,
};
use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode;
use lqos_config::{ShapingCpuDetection, ShapingCpuSource};
@@ -0,0 +1,294 @@
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use lqos_config::{
CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadata, CircuitEthernetMetadataFile,
load_config,
};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::path::Path;
const DEFAULT_ETHERNET_CAPS_PAGE_SIZE: usize = 100;
const MAX_ETHERNET_CAPS_PAGE_SIZE: usize = 250;
fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum MaybeString {
String(String),
Null,
}
Ok(match Option::<MaybeString>::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<usize>,
pub page_size: Option<usize>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
pub search: Option<String>,
pub tier: Option<EthernetCapTier>,
}
/// 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<String>,
/// Interface name that supplied the limiting Ethernet speed when known.
pub limiting_interface_name: Option<String>,
}
/// 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<EthernetCapsPageRow>,
}
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<EthernetCapBadge> {
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<CircuitEthernetMetadataFile> {
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<String, String> {
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<String, EthernetCapBadge> {
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<CircuitEthernetMetadata> {
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));
}
}
@@ -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<EthernetCapBadge>,
pub parent_node: String,
pub device_names: Vec<String>,
pub ip_addrs: Vec<String>,
@@ -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<TreeAttachedCircuitRow> = 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(),
@@ -3,21 +3,34 @@
<div class="lqos-circuit-panel">
<h5><i class="fa fa-user"></i> <span id="circuitName" class="redactable lqos-ellipsis-inline"></span></h5>
<div class="lqos-table-wrap">
<table class="lqos-table lqos-table-compact">
<table class="lqos-table lqos-table-compact lqos-circuit-header-table">
<tr>
<td class="table-label-cell">Parent Node</td>
<td class="table-value-cell"><a id="parentNode" class="redactable" href="#"></a></td>
<td class="table-label-cell">Parent</td>
<td class="table-value-cell"><a id="parentNode" class="redactable lqos-circuit-parent-link lqos-ellipsis-inline" href="#"></a></td>
</tr>
<tr>
<td class="table-label-cell">Maximum Speed</td>
<td class="table-value-cell"><span id="bwMax"></span></td>
<td class="table-label-cell">Max</td>
<td class="table-value-cell">
<span id="bwMax"></span>
<a
id="ethernetAdvisoryBadge"
class="badge rounded-pill text-bg-warning ms-2 d-none"
href="#"
tabindex="0"
aria-label="Ethernet speed warning"
data-bs-toggle="tooltip"
data-bs-placement="top"
>
<i class="fa fa-ethernet me-1"></i><span id="ethernetAdvisoryBadgeText"></span>
</a>
</td>
</tr>
<tr>
<td class="table-label-cell">Minimum Speed</td>
<td class="table-label-cell">Min</td>
<td class="table-value-cell"><span id="bwMin"></span></td>
</tr>
<tr>
<td class="table-label-cell">RTT Metrics</td>
<td class="table-label-cell">RTT</td>
<td class="table-value-cell">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="excludeRttToggle">
@@ -90,6 +103,11 @@
<i class="fa fa-map"></i> Flow Sankey <span id="activeFlowCount" class="badge">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="top-asns-tab" data-bs-toggle="tab" data-bs-target="#top-asns" type="button" role="tab" aria-controls="top-asns" aria-selected="false">
<i class="fa fa-building-columns"></i> Top ASNs <span id="topAsnCount" class="badge">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="traffic-tab" data-bs-toggle="tab" data-bs-target="#traffic" type="button" role="tab" aria-controls="contact" aria-selected="false">
<i class="fa fa-table"></i> Traffic Flows <span id="trafficFlowCount" class="badge">0</span>
@@ -172,11 +190,19 @@
</div>
</div>
<div class="tab-pane fade" id="devs" role="tabpanel" aria-labelledby="devs-tab">
<div class="row" id="devices">
<div class="col-12">
<div class="lqos-loading-block lqos-loading-block-lg">
<i class="fa fa-spinner fa-spin"></i>
<span>Loading devices…</span>
<div class="lqos-circuit-devices-panel">
<div class="lqos-circuit-devices-header">
<div class="lqos-circuit-devices-header-copy">
<h5><i class="fa fa-server"></i> Devices</h5>
<p class="text-muted small mb-0">Per-device live throughput, RTT, retransmits, and address inventory for this circuit.</p>
</div>
</div>
<div class="row" id="devices">
<div class="col-12">
<div class="lqos-loading-block lqos-loading-block-lg">
<i class="fa fa-spinner fa-spin"></i>
<span>Loading devices…</span>
</div>
</div>
</div>
</div>
@@ -188,6 +214,16 @@
</div>
</div>
</div>
<div class="tab-pane fade" id="top-asns" role="tabpanel" aria-labelledby="top-asns-tab">
<div class="row">
<div class="col-12" id="topAsnsTable">
<div class="lqos-loading-block lqos-loading-block-lg">
<i class="fa fa-spinner fa-spin"></i>
<span>Loading ASN summary…</span>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="traffic" role="tabpanel" aria-labelledby="traffic-tab">
<div class="row">
<div class="col-12 d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
@@ -1,14 +1,10 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<div id="devicesEditorLock" class="alert alert-secondary d-none" role="alert"></div>
</div>
</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<div id="devicesEditorLock" class="lqos-config-note d-none" role="alert"></div>
<div class="row">
<div class="col-12">
<section class="lqos-config-panel">
<div class="card">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<h5 class="mb-0">Shaped Devices</h5>
<div class="small text-body-secondary" id="sdSummary">Loading devices...</div>
@@ -40,7 +36,7 @@
</div>
</div>
<div class="alert alert-info py-2 small mb-3">
<div class="lqos-config-note py-2 small mb-3">
SQM overrides can be set per direction. Leave a side blank to use the global default; set to "none" to disable that side.
</div>
@@ -61,7 +57,7 @@
</nav>
</div>
</div>
</div>
</section>
</div>
<div class="modal fade" id="sdEditModal" tabindex="-1" aria-labelledby="sdEditModalLabel" aria-hidden="true">
@@ -1,62 +1,82 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="mb-3">
<label for="flowTimeout" class="form-label">Flow Timeout (seconds)</label>
<input type="number" class="form-control" id="flowTimeout" min="1">
<div class="form-text">How long to keep flow records before expiring them</div>
</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableNetflow">
<label class="form-check-label" for="enableNetflow">Enable Netflow</label>
<div class="form-text">Enable Netflow export of flow data</div>
</div>
<div class="mb-3">
<label for="netflowPort" class="form-label">Netflow Port</label>
<input type="number" class="form-control" id="netflowPort" min="1" max="65535">
<div class="form-text">Port to send Netflow data to (optional)</div>
</div>
<div class="mb-3">
<label for="netflowIP" class="form-label">Netflow IP Address</label>
<input type="text" class="form-control" id="netflowIP">
<div class="form-text">IP address to send Netflow data to (optional)</div>
</div>
<div class="mb-3">
<label for="netflowVersion" class="form-label">Netflow Version</label>
<select class="form-select" id="netflowVersion">
<option value="5">Version 5</option>
<option value="9">Version 9</option>
</select>
<div class="form-text">Netflow protocol version to use</div>
</div>
<div class="card mb-3">
<div class="card-header">Do Not Track Subnets</div>
<div class="card-body">
<div class="form-text mb-2">
Flow tracking will ignore RTT events where the <code>remote WAN IP</code> matches a subnet in this list.
</div>
<div class="mb-3">
<select class="form-select" id="doNotTrackSubnets" size="5">
</select>
</div>
<div class="input-group mb-3">
<input type="text" class="form-control" id="newDoNotTrackSubnet" placeholder="Enter IP or CIDR (e.g. 8.8.8.8, 192.168.1.0/24)">
<button class="btn btn-outline-primary" type="button" id="addDoNotTrackSubnet">Add</button>
</div>
<button class="btn btn-outline-danger" type="button" id="removeDoNotTrackSubnet">Remove Selected</button>
<div id="doNotTrackValidation" class="small mt-3"></div>
<div id="doNotTrackLoadStatus" class="small mt-2"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Flow Tracking</h5>
<div class="lqos-config-panel-subtitle">Control flow-record retention, Netflow export, and subnets that should be ignored by flow-based RTT tracking.</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-5">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Retention and Export</h6>
<div class="lqos-config-section-subtitle">Tune how long flows are retained and where Netflow exports are sent.</div>
<div class="mb-3">
<label for="flowTimeout" class="form-label">Flow Timeout (seconds)</label>
<input type="number" class="form-control" id="flowTimeout" min="1">
<div class="form-text">How long to keep flow records before expiring them.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableNetflow">
<label class="form-check-label" for="enableNetflow">Enable Netflow</label>
<div class="form-text">Enable Netflow export of flow data.</div>
</div>
<div class="mb-3">
<label for="netflowPort" class="form-label">Netflow Port</label>
<input type="number" class="form-control" id="netflowPort" min="1" max="65535">
<div class="form-text">Port to send Netflow data to (optional).</div>
</div>
<div class="mb-3">
<label for="netflowIP" class="form-label">Netflow IP Address</label>
<input type="text" class="form-control" id="netflowIP">
<div class="form-text">IP address to send Netflow data to (optional).</div>
</div>
<div class="mb-0">
<label for="netflowVersion" class="form-label">Netflow Version</label>
<select class="form-select" id="netflowVersion">
<option value="5">Version 5</option>
<option value="9">Version 9</option>
</select>
<div class="form-text">Netflow protocol version to use.</div>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card h-100">
<div class="card-header">Do Not Track Subnets</div>
<div class="card-body">
<div class="form-text mb-2">
Flow tracking will ignore RTT events where the <code>remote WAN IP</code> matches a subnet in this list.
</div>
<div class="mb-3">
<select class="form-select" id="doNotTrackSubnets" size="5"></select>
</div>
<div class="input-group mb-3">
<input type="text" class="form-control" id="newDoNotTrackSubnet" placeholder="Enter IP or CIDR (e.g. 8.8.8.8, 192.168.1.0/24)">
<button class="btn btn-outline-primary" type="button" id="addDoNotTrackSubnet">Add</button>
</div>
<button class="btn btn-outline-danger" type="button" id="removeDoNotTrackSubnet">Remove Selected</button>
<div id="doNotTrackValidation" class="small mt-3"></div>
<div id="doNotTrackLoadStatus" class="small mt-2"></div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_flows.js%CACHEBUSTERS%"></script>
@@ -1,103 +1,149 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="mb-3">
<label for="nodeId" class="form-label">Node ID</label>
<input type="text" class="form-control" id="nodeId" aria-describedby="nodeIdHelp" disabled>
<div id="nodeIdHelp" class="form-text">Unique identifier for this node</div>
</div>
<div class="mb-3">
<label for="nodeName" class="form-label">Node Name</label>
<input type="text" class="form-control" id="nodeName">
</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<div class="mb-3">
<label for="qooProfileId" class="form-label">QoO Profile</label>
<select class="form-select" id="qooProfileId"></select>
<div class="form-text">Used for QoO/QoQ scoring. Choose (default) to use the default profile.</div>
</div>
<div class="alert alert-info" role="alert">
QoO profiles are defined in <code id="qooProfilesPath">qoo_profiles.json</code>. Edit that file to add/change profiles, then reload this page.
</div>
<div class="card mb-3">
<div class="card-header">Available QoO Profiles</div>
<div class="card-body">
<div id="qooProfilesLoadAlert"></div>
<div class="table-responsive lqos-table-wrap">
<table class="lqos-table lqos-table-compact align-middle mb-0">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th>Description</th>
<th>Flags</th>
</tr>
</thead>
<tbody id="qooProfilesTableBody"></tbody>
</table>
</div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">General Settings</h5>
<div class="lqos-config-panel-subtitle">Node identity, packet capture timing, WebUI behavior, and heatmap retention.</div>
</div>
</div>
<div class="mb-3">
<label for="packetCaptureTime" class="form-label">Packet Capture Time (seconds)</label>
<input type="number" class="form-control" id="packetCaptureTime" min="1">
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Node and WebUI</h6>
<div class="lqos-config-section-subtitle">Core identity and listener settings for this LibreQoS node.</div>
<div class="mb-3">
<label for="nodeId" class="form-label">Node ID</label>
<input type="text" class="form-control" id="nodeId" aria-describedby="nodeIdHelp" disabled>
<div id="nodeIdHelp" class="form-text">Unique identifier for this node</div>
</div>
<div class="mb-3">
<label for="nodeName" class="form-label">Node Name</label>
<input type="text" class="form-control" id="nodeName">
</div>
<div class="mb-3">
<label for="packetCaptureTime" class="form-label">Packet Capture Time (seconds)</label>
<input type="number" class="form-control" id="packetCaptureTime" min="1">
</div>
<div class="mb-3">
<label for="queueCheckPeriod" class="form-label">Queue Check Period (milliseconds)</label>
<input type="number" class="form-control" id="queueCheckPeriod" min="100">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="disableWebserver">
<label class="form-check-label" for="disableWebserver">Disable Web Server</label>
<div class="form-text">Check to disable the web interface (headless mode)</div>
</div>
<div class="mb-3">
<label for="webserverListen" class="form-label">Web Server Listen Address</label>
<input type="text" class="form-control" id="webserverListen" placeholder="e.g. 0.0.0.0:80">
<div class="form-text">Leave blank for default (0.0.0.0:9123)</div>
</div>
<div class="mb-0 form-check">
<input type="checkbox" class="form-check-input" id="disableIcmpPing">
<label class="form-check-label" for="disableIcmpPing">Disable ICMP Ping</label>
<div class="form-text">Disable ICMP ping monitoring for devices in the hosts view</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Runtime Behavior</h6>
<div class="lqos-config-section-subtitle">Hybrid-core shaping preference and optional telemetry retention.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="excludeEfficiencyCores">
<label class="form-check-label" for="excludeEfficiencyCores">Exclude Efficiency Cores</label>
<div class="form-text">Use cached hybrid-core detection to keep shaping on P-cores and leave E-cores for background work when available</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableCircuitHeatmaps">
<label class="form-check-label" for="enableCircuitHeatmaps">Enable Circuit Heatmaps</label>
<div class="form-text">Track per-circuit TemporalHeatmap data (disable to save memory)</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableSiteHeatmaps">
<label class="form-check-label" for="enableSiteHeatmaps">Enable Site Heatmaps</label>
<div class="form-text">Track per-site TemporalHeatmap data (disable to save memory)</div>
</div>
<div class="mb-0 form-check">
<input type="checkbox" class="form-check-input" id="enableAsnHeatmaps">
<label class="form-check-label" for="enableAsnHeatmaps">Enable ASN Heatmaps</label>
<div class="form-text">Track per-ASN TemporalHeatmap data (disable to save memory)</div>
</div>
</div>
</div>
</div>
</section>
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">QoO Profiles</h5>
<div class="lqos-config-panel-subtitle">Choose the default Quality of Outcome profile and review the profile catalog currently available on disk.</div>
</div>
</div>
<div class="mb-3">
<label for="queueCheckPeriod" class="form-label">Queue Check Period (milliseconds)</label>
<input type="number" class="form-control" id="queueCheckPeriod" min="100">
</div>
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Profile Selection</h6>
<div class="lqos-config-section-subtitle">Set the profile used for QoO and QoQ scoring throughout the WebUI.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableCircuitHeatmaps">
<label class="form-check-label" for="enableCircuitHeatmaps">Enable Circuit Heatmaps</label>
<div class="form-text">Track per-circuit TemporalHeatmap data (disable to save memory)</div>
</div>
<div class="mb-3">
<label for="qooProfileId" class="form-label">QoO Profile</label>
<select class="form-select" id="qooProfileId"></select>
<div class="form-text">Choose (default) to use the configured default profile.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableSiteHeatmaps">
<label class="form-check-label" for="enableSiteHeatmaps">Enable Site Heatmaps</label>
<div class="form-text">Track per-site TemporalHeatmap data (disable to save memory)</div>
</div>
<div class="lqos-config-note" role="note">
QoO profiles are defined in <code id="qooProfilesPath">qoo_profiles.json</code>. Edit that file to add or change profiles, then reload this page.
</div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableAsnHeatmaps">
<label class="form-check-label" for="enableAsnHeatmaps">Enable ASN Heatmaps</label>
<div class="form-text">Track per-ASN TemporalHeatmap data (disable to save memory)</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="disableWebserver">
<label class="form-check-label" for="disableWebserver">Disable Web Server</label>
<div class="form-text">Check to disable the web interface (headless mode)</div>
</div>
<div class="mb-3">
<label for="webserverListen" class="form-label">Web Server Listen Address</label>
<input type="text" class="form-control" id="webserverListen" placeholder="e.g. 0.0.0.0:80">
<div class="form-text">Leave blank for default (0.0.0.0:9123)</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="disableIcmpPing">
<label class="form-check-label" for="disableIcmpPing">Disable ICMP Ping</label>
<div class="form-text">Disable ICMP ping monitoring for devices in the hosts view</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="excludeEfficiencyCores">
<label class="form-check-label" for="excludeEfficiencyCores">Exclude Efficiency Cores</label>
<div class="form-text">Use cached hybrid-core detection to keep shaping on P-cores and leave E-cores for background work when available</div>
<div class="col-12 col-xl-8">
<div class="card h-100">
<div class="card-header">Available QoO Profiles</div>
<div class="card-body">
<div id="qooProfilesLoadAlert"></div>
<div class="table-responsive lqos-table-wrap">
<table class="lqos-table lqos-table-compact align-middle mb-0">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th>Description</th>
<th>Flags</th>
</tr>
</thead>
<tbody id="qooProfilesTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_general.js%CACHEBUSTERS%"></script>
@@ -1,45 +1,71 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="circuitNameAsAddress">
<label class="form-check-label" for="circuitNameAsAddress">Use Circuit Name as Address</label>
<div class="form-text">Replace IP addresses with circuit names in network.json</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Integration Defaults</h5>
<div class="lqos-config-panel-subtitle">Set import behavior shared across integrations, including naming, overwrite policy, queue-refresh timing, and bandwidth adjustments.</div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="alwaysOverwriteNetworkJson">
<label class="form-check-label" for="alwaysOverwriteNetworkJson">Always Overwrite network.json</label>
<div class="form-text">Force overwrite of network.json on every update</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Import Behavior</h6>
<div class="lqos-config-section-subtitle">Control how imported topology and subscriber metadata are written into LibreQoS state files.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="useMikrotikIpv6">
<label class="form-check-label" for="useMikrotikIpv6">Enrich IPv6 via Mikrotik</label>
<div class="form-text">Use `mikrotikDHCPRouterList.csv` to map IPv4 subscribers to Mikrotik-advertised IPv6 prefixes</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="circuitNameAsAddress">
<label class="form-check-label" for="circuitNameAsAddress">Use Circuit Name as Address</label>
<div class="form-text">Replace IP addresses with circuit names in <code>network.json</code>.</div>
</div>
<div class="mb-3">
<label for="queueRefreshInterval" class="form-label">Queue Refresh Interval (minutes)</label>
<input type="number" class="form-control" id="queueRefreshInterval" min="1">
<div class="form-text">How frequently to refresh queue data from the integration</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="alwaysOverwriteNetworkJson">
<label class="form-check-label" for="alwaysOverwriteNetworkJson">Always Overwrite network.json</label>
<div class="form-text">Force overwrite of <code>network.json</code> on every update.</div>
</div>
<div class="mb-3">
<label for="promoteToRoot" class="form-label">Promote to Root Nodes</label>
<textarea class="form-control" id="promoteToRoot" rows="3"></textarea>
<div class="form-text">Enter one circuit ID per line to promote to root nodes</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="useMikrotikIpv6">
<label class="form-check-label" for="useMikrotikIpv6">Enrich IPv6 via Mikrotik</label>
<div class="form-text">Use <code>mikrotikDHCPRouterList.csv</code> to map IPv4 subscribers to Mikrotik-advertised IPv6 prefixes.</div>
</div>
<div class="mb-3">
<label for="clientBandwidthMultiplier" class="form-label">Client Bandwidth Multiplier</label>
<input type="number" class="form-control" id="clientBandwidthMultiplier" step="0.1" min="0.1" value="1.0">
<div class="form-text">Multiplier applied to client bandwidth values (1.0 = 100%)</div>
</div>
<div class="mb-0">
<label for="queueRefreshInterval" class="form-label">Queue Refresh Interval (minutes)</label>
<input type="number" class="form-control" id="queueRefreshInterval" min="1">
<div class="form-text">How frequently to refresh queue data from the integration.</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Promotion and Scaling</h6>
<div class="lqos-config-section-subtitle">Promote selected circuits to the root and adjust imported subscriber bandwidth values before shaping.</div>
<div class="mb-3">
<label for="promoteToRoot" class="form-label">Promote to Root Nodes</label>
<textarea class="form-control" id="promoteToRoot" rows="5"></textarea>
<div class="form-text">Enter one circuit ID per line to promote to root nodes.</div>
</div>
<div class="mb-0">
<label for="clientBandwidthMultiplier" class="form-label">Client Bandwidth Multiplier</label>
<input type="number" class="form-control" id="clientBandwidthMultiplier" step="0.1" min="0.1" value="1.0">
<div class="form-text">Multiplier applied to client bandwidth values. <code>1.0</code> means no adjustment.</div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_integration.js%CACHEBUSTERS%"></script>
@@ -1,79 +1,92 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="networkMode" id="bridgeMode" value="bridge">
<label class="form-check-label" for="bridgeMode">
Bridge Mode
</label>
<div class="form-text">Two physical interfaces - one facing the Internet, one facing the LAN</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="networkMode" id="singleInterfaceMode" value="single">
<label class="form-check-label" for="singleInterfaceMode">
Single Interface Mode
</label>
<div class="form-text">Single physical interface using VLANs (on-a-stick)</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Network Mode</h5>
<div class="lqos-config-panel-subtitle">Choose how LibreQoS connects to the network, then configure the required interfaces or VLANs.</div>
</div>
</div>
<!-- Bridge Mode Configuration -->
<div id="bridgeConfig" class="mb-3" style="display: none;">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Operating Mode</h6>
<div class="lqos-config-section-subtitle">Select either a two-interface bridge or a single trunk interface using VLANs.</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="networkMode" id="bridgeMode" value="bridge">
<label class="form-check-label" for="bridgeMode">Bridge Mode</label>
<div class="form-text">Two physical interfaces: one facing the Internet and one facing the LAN.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="networkMode" id="singleInterfaceMode" value="single">
<label class="form-check-label" for="singleInterfaceMode">Single Interface Mode</label>
<div class="form-text">Single physical interface using VLANs (on-a-stick).</div>
</div>
</div>
</div>
</section>
<div id="bridgeConfig" style="display: none;">
<section class="lqos-config-panel">
<div class="card">
<div class="card-header">Bridge Mode Configuration</div>
<div class="card-body">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="useXdpBridge">
<label class="form-check-label" for="useXdpBridge">Use XDP Bridge</label>
<div class="form-text">Use the XDP bridge instead of the Linux Bridge (not compatible with the default configuration)</div>
<div class="form-text">Use the XDP bridge instead of the Linux Bridge (not compatible with the default configuration).</div>
</div>
<div class="mb-3">
<label for="toInternet" class="form-label">Internet-Facing Interface</label>
<input type="text" class="form-control" id="toInternet">
<div class="form-text">Interface name facing the Internet (e.g. eth0)</div>
<div class="form-text">Interface name facing the Internet (for example, <code>eth0</code>).</div>
</div>
<div class="mb-3">
<div class="mb-0">
<label for="toNetwork" class="form-label">LAN-Facing Interface</label>
<input type="text" class="form-control" id="toNetwork">
<div class="form-text">Interface name facing the LAN (e.g. eth1)</div>
<div class="form-text">Interface name facing the LAN (for example, <code>eth1</code>).</div>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- Single Interface Mode Configuration -->
<div id="singleInterfaceConfig" class="mb-3" style="display: none;">
<div id="singleInterfaceConfig" style="display: none;">
<section class="lqos-config-panel">
<div class="card">
<div class="card-header">Single Interface Mode Configuration</div>
<div class="card-body">
<div class="mb-3">
<label for="interface" class="form-label">Interface Name</label>
<input type="text" class="form-control" id="interface">
<div class="form-text">Physical interface name (e.g. eth0)</div>
<div class="form-text">Physical interface name (for example, <code>eth0</code>).</div>
</div>
<div class="mb-3">
<label for="internetVlan" class="form-label">Internet VLAN ID</label>
<input type="number" class="form-control" id="internetVlan" min="1" max="4094">
<div class="form-text">VLAN ID for Internet traffic</div>
<div class="form-text">VLAN ID for Internet traffic.</div>
</div>
<div class="mb-3">
<div class="mb-0">
<label for="networkVlan" class="form-label">LAN VLAN ID</label>
<input type="number" class="form-control" id="networkVlan" min="1" max="4094">
<div class="form-text">VLAN ID for LAN traffic</div>
<div class="form-text">VLAN ID for LAN traffic.</div>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_interface.js%CACHEBUSTERS%"></script>
@@ -1,67 +1,78 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">IP Ranges</h5>
<div class="lqos-config-panel-subtitle">Define allow and ignore subnet lists used during discovery and shaping decisions.</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-header">Ignored Subnets</div>
<div class="card-body">
<div class="mb-3">
<select class="form-select" id="ignoredSubnets" size="5">
</select>
<select class="form-select" id="ignoredSubnets" size="5"></select>
</div>
<div class="input-group mb-3">
<input type="text" class="form-control" id="newIgnoredSubnet" placeholder="Enter IP/CIDR (e.g. 192.168.1.0/24 or 2001:db8::/32)">
<button class="btn btn-outline-secondary" type="button" id="addIgnoredSubnet">Add</button>
<button class="btn btn-outline-primary" type="button" id="addIgnoredSubnet">Add</button>
</div>
<button class="btn btn-outline-danger" type="button" id="removeIgnoredSubnet">Remove Selected</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-header">Allowed Subnets</div>
<div class="card-body">
<div class="mb-3">
<select class="form-select" id="allowedSubnets" size="5">
</select>
<select class="form-select" id="allowedSubnets" size="5"></select>
</div>
<div class="input-group mb-3">
<input type="text" class="form-control" id="newAllowedSubnet" placeholder="Enter IP/CIDR (e.g. 192.168.1.0/24 or 2001:db8::/32)">
<button class="btn btn-outline-secondary" type="button" id="addAllowedSubnet">Add</button>
<button class="btn btn-outline-primary" type="button" id="addAllowedSubnet">Add</button>
</div>
<button class="btn btn-outline-danger" type="button" id="removeAllowedSubnet">Remove Selected</button>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="unknownHonorsIgnore">
<label class="form-check-label" for="unknownHonorsIgnore">
Unknown IPs Honor Ignore List
</label>
<div class="form-text">Should IPs not in any list be treated as ignored?</div>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="unknownHonorsAllow">
<label class="form-check-label" for="unknownHonorsAllow">
Unknown IPs Honor Allow List
</label>
<div class="form-text">Should IPs not in any list be treated as allowed?</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Unknown IP Handling</h6>
<div class="lqos-config-section-subtitle">Decide how IPs not explicitly listed in either set should be treated.</div>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="unknownHonorsIgnore">
<label class="form-check-label" for="unknownHonorsIgnore">Unknown IPs Honor Ignore List</label>
<div class="form-text">Should IPs not in any list be treated as ignored?</div>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="unknownHonorsAllow">
<label class="form-check-label" for="unknownHonorsAllow">Unknown IPs Honor Allow List</label>
<div class="form-text">Should IPs not in any list be treated as allowed?</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_iprange.js%CACHEBUSTERS%"></script>
@@ -1,40 +1,66 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="gatherStats">
<label class="form-check-label" for="gatherStats">Gather Insight Statistics</label>
<div class="form-text">Enable collection of Insight performance data</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Insight</h5>
<div class="lqos-config-panel-subtitle">Control Insight statistics collection, licensing, and optional self-hosted endpoint settings.</div>
</div>
</div>
<div class="mb-3">
<label for="collationPeriod" class="form-label">Collation Period (seconds)</label>
<input type="number" class="form-control" id="collationPeriod" min="1">
<div class="form-text">How frequently to aggregate statistics into Insight records</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Collection and License</h6>
<div class="lqos-config-section-subtitle">Enable Insight data gathering and define the cadence used for aggregation and reporting.</div>
<div class="mb-3">
<label for="licenseKey" class="form-label">License Key</label>
<input type="text" class="form-control" id="licenseKey">
<div class="form-text">License key for LibreQoS Insight (if applicable)</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="gatherStats">
<label class="form-check-label" for="gatherStats">Gather Insight Statistics</label>
<div class="form-text">Enable collection of Insight performance data.</div>
</div>
<div class="mb-3">
<label for="uispInterval" class="form-label">UISP Reporting Interval (seconds)</label>
<input type="number" class="form-control" id="uispInterval" min="1">
<div class="form-text">How frequently to query UISP for updates (set 0 to disable)</div>
</div>
<div class="mb-3">
<label for="collationPeriod" class="form-label">Collation Period (seconds)</label>
<input type="number" class="form-control" id="collationPeriod" min="1">
<div class="form-text">How frequently to aggregate statistics into Insight records.</div>
</div>
<div class="mb-3">
<label for="ltsUrl" class="form-label">Custom Insight Server URL</label>
<input type="text" class="form-control" id="ltsUrl">
<div class="form-text">URL for a self-hosted Insight server (leave blank for default)</div>
</div>
<div class="mb-0">
<label for="licenseKey" class="form-label">License Key</label>
<input type="text" class="form-control" id="licenseKey">
<div class="form-text">License key for LibreQoS Insight, when applicable.</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Endpoints and Reporting</h6>
<div class="lqos-config-section-subtitle">Set reporting cadence for UISP synchronization and optionally point LibreQoS at a self-hosted Insight service.</div>
<div class="mb-3">
<label for="uispInterval" class="form-label">UISP Reporting Interval (seconds)</label>
<input type="number" class="form-control" id="uispInterval" min="1">
<div class="form-text">How frequently to query UISP for updates. Set <code>0</code> to disable.</div>
</div>
<div class="mb-0">
<label for="ltsUrl" class="form-label">Custom Insight Server URL</label>
<input type="text" class="form-control" id="ltsUrl">
<div class="form-text">URL for a self-hosted Insight server. Leave blank to use the default service.</div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_lts.js%CACHEBUSTERS%"></script>
@@ -1,16 +1,28 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<div id="networkEditorLock" class="alert alert-secondary d-none" role="alert"></div>
</div>
</div>
<div class="row">
<div class="col-12">
<button id="btnSaveNetwork" class="btn btn-outline-primary mb-3">
<i class="fa fa-save"></i> Save Changes
</button>
<div id="netjson"></div>
</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Network Layout</h5>
<div class="lqos-config-panel-subtitle">Review the shaping hierarchy, rename or promote nodes, and adjust per-node rates before saving <code>network.json</code>.</div>
</div>
</div>
<div id="networkEditorLock" class="lqos-config-note lqos-config-note-warning d-none mb-3" role="alert"></div>
<div class="lqos-config-actions mb-3">
<button id="btnSaveNetwork" class="btn btn-outline-primary">
<i class="fa fa-save"></i> Save Changes
</button>
</div>
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Topology Editor</h6>
<div class="lqos-config-section-subtitle">Node cards show hierarchy, configured rates, virtualization state, and the actions available for each branch.</div>
<div id="netjson" class="lqos-config-network-editor"></div>
</div>
</section>
</div>
<script src="config_network.js%CACHEBUSTERS%"></script>
@@ -1,7 +1,16 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Netzur Integration</h5>
<div class="lqos-config-panel-subtitle">Configure API access and timeout behavior for automated imports from Netzur CRM.</div>
</div>
</div>
<div class="lqos-config-section">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableNetzur">
<label class="form-check-label" for="enableNetzur">Enable Netzur Integration</label>
@@ -26,8 +35,12 @@
<div class="form-text">Adjust only if the API routinely needs more than 60 seconds</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveNetzur" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_netzur.js%CACHEBUSTERS%"></script>
@@ -1,7 +1,16 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Powercode Integration</h5>
<div class="lqos-config-panel-subtitle">Configure authentication and endpoint details for automated imports from Powercode.</div>
</div>
</div>
<div class="lqos-config-section">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enablePowercode">
<label class="form-check-label" for="enablePowercode">Enable Powercode Integration</label>
@@ -20,9 +29,13 @@
<div class="form-text">Base URL for Powercode API (e.g. https://your-powercode.com)</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_powercode.js%CACHEBUSTERS%"></script>
@@ -1,99 +1,134 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="mb-3">
<label for="defaultSqm" class="form-label">Default SQM Algorithm</label>
<select class="form-select" id="defaultSqm">
<option value="cake diffserv4">cake diffserv4</option>
<option value="cake diffserv4 ack-filter">cake diffserv4 ack-filter</option>
<option value="fq_codel">fq_codel</option>
</select>
<div class="form-text">Queue management algorithm to use by default</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Queues and Shaping</h5>
<div class="lqos-config-panel-subtitle">Default SQM behavior, shaping mode, interface capacity, and lazy-queue behavior.</div>
</div>
</div>
<div class="mb-3">
<label for="queueMode" class="form-label">Queue Mode</label>
<select class="form-select" id="queueMode">
<option value="shape">Shape</option>
<option value="observe">Observe</option>
</select>
<div class="form-text">Observe keeps root MQ active for the transparent bridge, but does not apply the LibreQoS shaping tree.</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Shaping Defaults</h6>
<div class="lqos-config-section-subtitle">Core shaping mode and default SQM selection for new circuits.</div>
<div class="mb-3">
<label for="uplinkBandwidth" class="form-label">Uplink Bandwidth (Mbps)</label>
<input type="number" class="form-control" id="uplinkBandwidth" min="1">
<div class="form-text">Total upstream bandwidth capacity</div>
</div>
<div class="mb-3">
<label for="defaultSqm" class="form-label">Default SQM Algorithm</label>
<select class="form-select" id="defaultSqm">
<option value="cake diffserv4">cake diffserv4</option>
<option value="cake diffserv4 ack-filter">cake diffserv4 ack-filter</option>
<option value="fq_codel">fq_codel</option>
</select>
<div class="form-text">Queue management algorithm to use by default.</div>
</div>
<div class="mb-3">
<label for="downlinkBandwidth" class="form-label">Downlink Bandwidth (Mbps)</label>
<input type="number" class="form-control" id="downlinkBandwidth" min="1">
<div class="form-text">Total downstream bandwidth capacity</div>
</div>
<div class="mb-3">
<label for="queueMode" class="form-label">Queue Mode</label>
<select class="form-select" id="queueMode">
<option value="shape">Shape</option>
<option value="observe">Observe</option>
</select>
<div class="form-text">Observe keeps root MQ active for the transparent bridge, but does not apply the LibreQoS shaping tree.</div>
</div>
<div class="mb-3">
<label for="fastQueuesFqCodel" class="form-label">Prefer fq_codel at or above (Mbps)</label>
<input type="number" class="form-control" id="fastQueuesFqCodel" min="1" step="0.1">
<div class="form-text">If unset, defaults to 1000. For circuits with download >= this rate, fq_codel is preferred when no per-circuit override is set.</div>
</div>
<div class="mb-3">
<label for="fastQueuesFqCodel" class="form-label">Prefer fq_codel at or above (Mbps)</label>
<input type="number" class="form-control" id="fastQueuesFqCodel" min="1" step="0.1">
<div class="form-text">If unset, defaults to 1000. For circuits with download at or above this rate, fq_codel is preferred when no per-circuit override is set.</div>
</div>
<div class="mb-3">
<label for="generatedPnDownload" class="form-label">Generated Per-Node Download (Mbps)</label>
<input type="number" class="form-control" id="generatedPnDownload" min="1">
<div class="form-text">Download bandwidth per interface queue</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="dryRun">
<label class="form-check-label" for="dryRun">Dry Run</label>
<div class="form-text">Print commands without executing them.</div>
</div>
<div class="mb-3">
<label for="generatedPnUpload" class="form-label">Generated Per-Node Upload (Mbps)</label>
<input type="number" class="form-control" id="generatedPnUpload" min="1">
<div class="form-text">Upload bandwidth per interface queue</div>
</div>
<div class="mb-0 form-check">
<input type="checkbox" class="form-check-input" id="sudo">
<label class="form-check-label" for="sudo">Use Sudo</label>
<div class="form-text">Prefix commands with sudo.</div>
</div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="dryRun">
<label class="form-check-label" for="dryRun">Dry Run</label>
<div class="form-text">Print commands without executing them</div>
</div>
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Capacity and Queue Count</h6>
<div class="lqos-config-section-subtitle">Bandwidth ceilings and queue-count controls used during tree generation.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="sudo">
<label class="form-check-label" for="sudo">Use Sudo</label>
<div class="form-text">Prefix commands with sudo</div>
</div>
<div class="mb-3">
<label for="uplinkBandwidth" class="form-label">Uplink Bandwidth (Mbps)</label>
<input type="number" class="form-control" id="uplinkBandwidth" min="1">
<div class="form-text">Total upstream bandwidth capacity.</div>
</div>
<div class="mb-3">
<label for="overrideQueues" class="form-label">Override Available Queues</label>
<input type="number" class="form-control" id="overrideQueues" min="1">
<div class="form-text">Override the number of available queues (leave blank for auto)</div>
</div>
<div class="mb-3">
<label for="downlinkBandwidth" class="form-label">Downlink Bandwidth (Mbps)</label>
<input type="number" class="form-control" id="downlinkBandwidth" min="1">
<div class="form-text">Total downstream bandwidth capacity.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="useBinpacking">
<label class="form-check-label" for="useBinpacking">Use Binpacking</label>
<div class="form-text">Use binpacking algorithm to optimize flat networks</div>
</div>
<div class="mb-3">
<label for="generatedPnDownload" class="form-label">Generated Per-Node Download (Mbps)</label>
<input type="number" class="form-control" id="generatedPnDownload" min="1">
<div class="form-text">Download bandwidth per interface queue.</div>
</div>
<div class="mb-3">
<label for="lazyQueues" class="form-label">Lazy Queue Mode</label>
<select class="form-select" id="lazyQueues">
<option value="No">No - Disabled</option>
<option value="Htb">Htb - HTB queues on build, CAKE classes on demand</option>
<option value="Full">Full - Both HTB and CAKE queues on demand</option>
</select>
<div class="form-text">Control how circuit queues are created for fractional rate plans</div>
</div>
<div class="mb-3">
<label for="generatedPnUpload" class="form-label">Generated Per-Node Upload (Mbps)</label>
<input type="number" class="form-control" id="generatedPnUpload" min="1">
<div class="form-text">Upload bandwidth per interface queue.</div>
</div>
<div class="mb-3">
<label for="lazyExpireSeconds" class="form-label">Lazy Queue Expiration (seconds)</label>
<input type="number" class="form-control" id="lazyExpireSeconds" min="30">
<div class="form-text">Time before unused lazy queues are removed (leave blank for default: 600 seconds)</div>
</div>
<div class="mb-3">
<label for="overrideQueues" class="form-label">Override Available Queues</label>
<input type="number" class="form-control" id="overrideQueues" min="1">
<div class="form-text">Override the number of available queues (leave blank for auto).</div>
</div>
<div class="mb-0 form-check">
<input type="checkbox" class="form-check-input" id="useBinpacking">
<label class="form-check-label" for="useBinpacking">Use Binpacking</label>
<div class="form-text">Use the binpacking algorithm to optimize flat networks.</div>
</div>
</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Lazy Queue Mode</h6>
<div class="lqos-config-section-subtitle">Control when HTB and CAKE structures are created for fractional plans.</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<label for="lazyQueues" class="form-label">Lazy Queue Mode</label>
<select class="form-select" id="lazyQueues">
<option value="No">No - Disabled</option>
<option value="Htb">Htb - HTB queues on build, CAKE classes on demand</option>
<option value="Full">Full - Both HTB and CAKE queues on demand</option>
</select>
<div class="form-text">Control how circuit queues are created for fractional rate plans.</div>
</div>
<div class="col-12 col-lg-6">
<label for="lazyExpireSeconds" class="form-label">Lazy Queue Expiration (seconds)</label>
<input type="number" class="form-control" id="lazyExpireSeconds" min="30">
<div class="form-text">Time before unused lazy queues are removed (leave blank for default: 600 seconds).</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_queues.js%CACHEBUSTERS%"></script>
@@ -1,51 +1,68 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="card mb-3">
<div class="card-header">RTT Color Thresholds</div>
<div class="card-body">
<div class="alert alert-info mb-3" role="alert">
These values control how RTT (latency) is colored in the web UI (heatmaps, markers, and RTT blocks).
They do <strong>not</strong> change QoO/QoQ scoring.
</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="useDefaultRttThresholds">
<label class="form-check-label" for="useDefaultRttThresholds">
Use default thresholds (0ms green, 100ms yellow, 200ms red)
</label>
</div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">RTT Color Thresholds</h5>
<div class="lqos-config-panel-subtitle">Control how latency is colored across the WebUI without changing QoO or QoQ scoring behavior.</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<label for="rttGreenMs" class="form-label">Green point (ms)</label>
<input type="number" class="form-control" id="rttGreenMs" min="0" step="1">
<div class="form-text">RTT at/below this value will be colored green.</div>
<div class="lqos-config-note mb-3" role="note">
These values control how RTT (latency) is colored in heatmaps, map markers, and RTT blocks. They do <strong>not</strong> change QoO/QoQ scoring.
</div>
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Threshold Values</h6>
<div class="lqos-config-section-subtitle">Set the transition points for green, yellow, and red RTT ranges.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="useDefaultRttThresholds">
<label class="form-check-label" for="useDefaultRttThresholds">
Use default thresholds (0ms green, 100ms yellow, 200ms red)
</label>
</div>
<div class="col-md-4">
<label for="rttYellowMs" class="form-label">Yellow point (ms)</label>
<input type="number" class="form-control" id="rttYellowMs" min="0" step="1">
<div class="form-text">RTT near this value will be colored yellow.</div>
</div>
<div class="col-md-4">
<label for="rttRedMs" class="form-label">Red point (ms)</label>
<input type="number" class="form-control" id="rttRedMs" min="1" step="1">
<div class="form-text">RTT at/above this value will be colored red.</div>
<div class="row g-3">
<div class="col-md-4">
<label for="rttGreenMs" class="form-label">Green point (ms)</label>
<input type="number" class="form-control" id="rttGreenMs" min="0" step="1">
<div class="form-text">RTT at or below this value will be colored green.</div>
</div>
<div class="col-md-4">
<label for="rttYellowMs" class="form-label">Yellow point (ms)</label>
<input type="number" class="form-control" id="rttYellowMs" min="0" step="1">
<div class="form-text">RTT near this value will be colored yellow.</div>
</div>
<div class="col-md-4">
<label for="rttRedMs" class="form-label">Red point (ms)</label>
<input type="number" class="form-control" id="rttRedMs" min="1" step="1">
<div class="form-text">RTT at or above this value will be colored red.</div>
</div>
</div>
</div>
</div>
<div class="mt-3">
<div class="small text-muted mb-1">Preview</div>
<div id="rttPreview" class="border rounded p-2 bg-body-tertiary"></div>
<div class="col-12 col-xl-4">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Preview</h6>
<div class="lqos-config-section-subtitle">Review the current threshold mapping before saving.</div>
<div id="rttPreview" class="lqos-config-preview"></div>
<div id="rttValidationMessage" class="small mt-2"></div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_rtt.js%CACHEBUSTERS%"></script>
@@ -1,7 +1,16 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Sonar Integration</h5>
<div class="lqos-config-panel-subtitle">Configure Sonar API access plus the SNMP and model metadata LibreQoS uses for wireless device enrichment.</div>
</div>
</div>
<div class="lqos-config-section">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableSonar">
<label class="form-check-label" for="enableSonar">Enable Sonar Integration</label>
@@ -44,9 +53,13 @@
<div class="form-text">Comma-separated list of active status IDs</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_sonar.js%CACHEBUSTERS%"></script>
@@ -1,7 +1,16 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Splynx Integration</h5>
<div class="lqos-config-panel-subtitle">Connect LibreQoS to Splynx and choose how much hierarchy should be imported into the shaping tree.</div>
</div>
</div>
<div class="lqos-config-section">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableSplynx">
<label class="form-check-label" for="enableSplynx">Enable Splynx Integration</label>
@@ -37,8 +46,12 @@
<div class="form-text">Select the topology strategy for network hierarchy. AP Only provides the best balance of performance and structure.</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_splynx.js%CACHEBUSTERS%"></script>
@@ -1,271 +1,346 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<!-- Warning Message -->
<div class="alert alert-warning d-flex align-items-center mb-4" role="alert">
<i class="fa fa-exclamation-triangle me-2"></i>
<div>
<strong>Advanced Feature:</strong> TreeGuard can write persistent changes to <code>lqos_overrides.treeguard.json</code>
and may change link virtualization and per-circuit SQM settings. Defaults now manage <strong>all links</strong> and
<strong>all circuits</strong>; uncheck those boxes or re-enable <strong>Dry Run</strong> to reduce scope.
</div>
</div>
<form>
<!-- Enable/Disable TreeGuard -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enabled">
<label class="form-check-label" for="enabled">Enable TreeGuard</label>
<div class="form-text">Enable intelligent node management (virtualization + SQM switching)</div>
</div>
<!-- Dry Run Mode -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="dryRun">
<label class="form-check-label" for="dryRun">Dry Run Mode</label>
<div class="form-text">Test mode - record actions without applying persistent or live changes</div>
</div>
<!-- Tick Seconds -->
<div class="mb-3">
<label for="tickSeconds" class="form-label">Tick Interval (seconds)</label>
<input type="number" class="form-control" id="tickSeconds" min="1" step="1" value="1">
<div class="form-text">How often TreeGuard evaluates telemetry and makes decisions</div>
</div>
<hr class="my-4" />
<h5>CPU</h5>
<div class="mb-3">
<label for="cpuMode" class="form-label">Mode</label>
<select class="form-select" id="cpuMode">
<option value="cpu_aware">CPU-aware</option>
<option value="traffic_rtt_only">Traffic/RTT only</option>
</select>
<div class="form-text">Controls how CPU usage influences TreeGuard decisions</div>
</div>
<div class="mb-3">
<label for="cpuHighPct" class="form-label">CPU High Threshold (%)</label>
<input type="number" class="form-control" id="cpuHighPct" min="0" max="100" step="1" value="75">
<div class="form-text">At/above this CPU%, TreeGuard may take CPU-saving actions</div>
</div>
<div class="mb-3">
<label for="cpuLowPct" class="form-label">CPU Low Threshold (%)</label>
<input type="number" class="form-control" id="cpuLowPct" min="0" max="100" step="1" value="55">
<div class="form-text">At/below this CPU%, TreeGuard should revert CPU-saving actions</div>
</div>
<hr class="my-4" />
<h5>Links (Node Virtualization)</h5>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="linksEnabled">
<label class="form-check-label" for="linksEnabled">Enable Link Virtualization</label>
<div class="form-text">Allow TreeGuard to mark enrolled nodes as virtual/unvirtual</div>
</div>
<div class="mb-3">
<label class="form-label">Managed Nodes</label>
<div class="mb-2 form-check">
<input type="checkbox" class="form-check-input" id="linksAllNodes">
<label class="form-check-label" for="linksAllNodes">All links</label>
<div class="form-text">If enabled, TreeGuard manages all non-root nodes in network.json (allowlist ignored)</div>
</div>
<div id="nodesAllowlistSection">
<div class="form-text">TreeGuard will only manage nodes explicitly listed here</div>
<div class="input-group mb-2">
<select class="form-select" id="nodeSelector">
<option value="">Select a node...</option>
</select>
<button class="btn btn-outline-primary" type="button" id="addNodeBtn">
<i class="fa fa-plus"></i> Add Node
</button>
</div>
<div id="selectedNodesList" class="list-group">
<!-- Dynamically populated list with remove buttons -->
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Top-Level Nodes</label>
<div class="mb-2 form-check">
<input type="checkbox" class="form-check-input" id="topLevelAutoVirtualize">
<label class="form-check-label" for="topLevelAutoVirtualize">Auto-virtualize top-level nodes</label>
<div class="form-text">
Top-level nodes are keys of the root object in <code>network.json</code> (e.g. <code>Site_1</code>). If enabled,
TreeGuard may virtualize them even if not allowlisted.
</div>
</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<label for="topLevelSafeUtilPct" class="form-label">Top-Level Safe Utilization (%)</label>
<input type="number" class="form-control" id="topLevelSafeUtilPct" min="0" max="100" step="0.1" value="85.0">
<div class="form-text">If sustained below this threshold for 15 minutes, TreeGuard may virtualize top-level nodes.</div>
<h5 class="lqos-config-panel-title">TreeGuard Control Loop</h5>
<div class="lqos-config-panel-subtitle">Global enablement, evaluation cadence, and CPU-response behavior for TreeGuard.</div>
</div>
</div>
<div class="mb-3">
<label for="idleUtilPct" class="form-label">Idle Utilization (%)</label>
<input type="number" class="form-control" id="idleUtilPct" min="0" max="100" step="0.1" value="2.0">
<div class="form-text">Utilization below which a link is considered idle (after smoothing)</div>
</div>
<div class="mb-3">
<label for="idleMinMinutes" class="form-label">Idle Minimum Duration (minutes)</label>
<input type="number" class="form-control" id="idleMinMinutes" min="0" step="1" value="15">
<div class="form-text">How long a link must remain idle before virtualizing</div>
</div>
<div class="mb-3">
<label for="linksRttMissingSeconds" class="form-label">RTT Missing Timeout (seconds)</label>
<input type="number" class="form-control" id="linksRttMissingSeconds" min="0" step="1" value="120">
<div class="form-text">RTT sample age after which RTT is treated as missing/unsafe</div>
</div>
<div class="mb-3">
<label for="unvirtualizeUtilPct" class="form-label">Unvirtualize Utilization (%)</label>
<input type="number" class="form-control" id="unvirtualizeUtilPct" min="0" max="100" step="0.1" value="5.0">
<div class="form-text">If utilization rises above this threshold, TreeGuard should unvirtualize</div>
</div>
<div class="mb-3">
<label for="minStateDwellMinutes" class="form-label">Minimum State Dwell (minutes)</label>
<input type="number" class="form-control" id="minStateDwellMinutes" min="0" step="1" value="30">
<div class="form-text">Minimum time before a node may change state again</div>
</div>
<div class="mb-3">
<label for="maxLinkChangesPerHour" class="form-label">Max Link Changes Per Hour</label>
<input type="number" class="form-control" id="maxLinkChangesPerHour" min="0" step="1" value="4">
<div class="form-text">Rate limit to prevent flapping</div>
</div>
<div class="mb-3">
<label for="reloadCooldownMinutes" class="form-label">Reload Cooldown (minutes)</label>
<input type="number" class="form-control" id="reloadCooldownMinutes" min="0" step="1" value="10">
<div class="form-text">Minimum time between topology reload attempts</div>
</div>
<hr class="my-4" />
<h5>Circuits (SQM Switching)</h5>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="circuitsEnabled">
<label class="form-check-label" for="circuitsEnabled">Enable Circuit Management</label>
<div class="form-text">Allow TreeGuard to manage per-circuit SQM for enrolled circuits</div>
</div>
<div class="mb-3">
<label class="form-label">Managed Circuits</label>
<div class="mb-2 form-check">
<input type="checkbox" class="form-check-input" id="circuitsAllCircuits">
<label class="form-check-label" for="circuitsAllCircuits">All circuits</label>
<div class="form-text">If enabled, TreeGuard manages all circuits found in ShapedDevices (allowlist ignored)</div>
</div>
<div id="circuitsAllowlistSection">
<div class="form-text">Circuit IDs as in ShapedDevices.csv</div>
<div class="input-group mb-2">
<select class="form-select" id="circuitSelector">
<option value="">Select a circuit...</option>
</select>
<button class="btn btn-outline-primary" type="button" id="addCircuitBtn">
<i class="fa fa-plus"></i> Add Circuit
</button>
<div class="lqos-config-section mb-3">
<h6 class="lqos-config-section-title">Current Behavior</h6>
<div class="lqos-config-section-subtitle">This summary reflects how TreeGuard will behave with the settings currently shown on this page.</div>
<div class="row g-3">
<div class="col-12 col-md-6 col-xl-3">
<div class="small text-body-secondary mb-1">Mode</div>
<div id="treeguardSummaryMode" class="fw-semibold">-</div>
</div>
<div class="input-group mb-2">
<input type="text" class="form-control" id="circuitManual" placeholder="Or type a circuit ID...">
<button class="btn btn-outline-primary" type="button" id="addCircuitManualBtn">
<i class="fa fa-plus"></i> Add
</button>
<div class="col-12 col-md-6 col-xl-3">
<div class="small text-body-secondary mb-1">Links</div>
<div id="treeguardSummaryLinks" class="fw-semibold">-</div>
</div>
<div id="selectedCircuitsList" class="list-group">
<!-- Dynamically populated list with remove buttons -->
<div class="col-12 col-md-6 col-xl-3">
<div class="small text-body-secondary mb-1">Circuits</div>
<div id="treeguardSummaryCircuits" class="fw-semibold">-</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="small text-body-secondary mb-1">Persistence</div>
<div id="treeguardSummaryPersistence" class="fw-semibold">-</div>
</div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="switchingEnabled">
<label class="form-check-label" for="switchingEnabled">Enable SQM Switching</label>
<div class="form-text">Allow TreeGuard to switch CAKE ↔ fq_codel</div>
<div id="treeguardContextNote" class="lqos-config-note lqos-config-note-warning d-none" role="alert"></div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Global Operation</h6>
<div class="lqos-config-section-subtitle">Enable TreeGuard and decide whether actions are applied live or recorded only.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enabled">
<label class="form-check-label" for="enabled">Enable TreeGuard</label>
<div class="form-text">Enable intelligent node management (virtualization plus SQM switching).</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="dryRun">
<label class="form-check-label" for="dryRun">Dry Run Mode</label>
<div class="form-text">Test mode: record actions without applying persistent or live changes.</div>
</div>
<div class="mb-0">
<label for="tickSeconds" class="form-label">Tick Interval (seconds)</label>
<input type="number" class="form-control" id="tickSeconds" min="1" step="1" value="1">
<div class="form-text">How often TreeGuard evaluates telemetry and makes decisions.</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">CPU Policy</h6>
<div class="lqos-config-section-subtitle">Define how CPU pressure influences virtualization and SQM decisions.</div>
<div class="mb-3">
<label for="cpuMode" class="form-label">Mode</label>
<select class="form-select" id="cpuMode">
<option value="cpu_aware">CPU-aware</option>
<option value="traffic_rtt_only">Traffic/RTT only</option>
</select>
<div class="form-text">Controls how CPU usage influences TreeGuard decisions.</div>
</div>
<div class="mb-3">
<label for="cpuHighPct" class="form-label">CPU High Threshold (%)</label>
<input type="number" class="form-control" id="cpuHighPct" min="0" max="100" step="1" value="75">
<div class="form-text">At or above this CPU%, TreeGuard may take CPU-saving actions.</div>
</div>
<div class="mb-0">
<label for="cpuLowPct" class="form-label">CPU Low Threshold (%)</label>
<input type="number" class="form-control" id="cpuLowPct" min="0" max="100" step="1" value="55">
<div class="form-text">At or below this CPU%, TreeGuard should revert CPU-saving actions.</div>
</div>
</div>
</div>
</div>
</section>
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Links and Virtualization</h5>
<div class="lqos-config-panel-subtitle">Choose which nodes TreeGuard may virtualize and the thresholds used to do it safely.</div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="independentDirections">
<label class="form-check-label" for="independentDirections">Independent Down/Up Decisions</label>
<div class="form-text">Allow different SQM tokens for down vs up</div>
<div class="row g-3">
<div class="col-12 col-xl-5">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Managed Links</h6>
<div class="lqos-config-section-subtitle">Define TreeGuard scope for node virtualization.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="linksEnabled">
<label class="form-check-label" for="linksEnabled">Enable Link Virtualization</label>
<div class="form-text">Allow TreeGuard to mark enrolled nodes as virtual or physical.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="linksAllNodes">
<label class="form-check-label" for="linksAllNodes">All links</label>
<div class="form-text">If enabled, TreeGuard manages all non-root nodes in <code>network.json</code> and ignores the allowlist.</div>
</div>
<div id="nodesAllowlistSection">
<div class="form-text mb-2">TreeGuard will only manage nodes explicitly listed here.</div>
<div class="input-group mb-2">
<select class="form-select" id="nodeSelector">
<option value="">Select a node...</option>
</select>
<button class="btn btn-outline-primary" type="button" id="addNodeBtn">
<i class="fa fa-plus"></i> Add Node
</button>
</div>
<div id="selectedNodesList" class="list-group">
<!-- Dynamically populated list with remove buttons -->
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Virtualization Thresholds</h6>
<div class="lqos-config-section-subtitle">Top-level handling, idle detection, and rate limiting for state changes.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="topLevelAutoVirtualize">
<label class="form-check-label" for="topLevelAutoVirtualize">Auto-virtualize top-level nodes</label>
<div class="form-text">Top-level nodes are keys of the root object in <code>network.json</code>. If enabled, TreeGuard may virtualize them even if not allowlisted.</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="topLevelSafeUtilPct" class="form-label">Top-Level Safe Utilization (%)</label>
<input type="number" class="form-control" id="topLevelSafeUtilPct" min="0" max="100" step="0.1" value="85.0">
<div class="form-text">If sustained below this threshold for 15 minutes, TreeGuard may virtualize top-level nodes.</div>
</div>
<div class="col-md-6">
<label for="idleUtilPct" class="form-label">Idle Utilization (%)</label>
<input type="number" class="form-control" id="idleUtilPct" min="0" max="100" step="0.1" value="2.0">
<div class="form-text">Utilization below which a link is considered idle (after smoothing).</div>
</div>
<div class="col-md-6">
<label for="idleMinMinutes" class="form-label">Idle Minimum Duration (minutes)</label>
<input type="number" class="form-control" id="idleMinMinutes" min="0" step="1" value="15">
<div class="form-text">How long a link must remain idle before virtualizing.</div>
</div>
<div class="col-md-6">
<label for="linksRttMissingSeconds" class="form-label">RTT Missing Timeout (seconds)</label>
<input type="number" class="form-control" id="linksRttMissingSeconds" min="0" step="1" value="120">
<div class="form-text">RTT sample age after which RTT is treated as missing or unsafe.</div>
</div>
<div class="col-md-6">
<label for="unvirtualizeUtilPct" class="form-label">Unvirtualize Utilization (%)</label>
<input type="number" class="form-control" id="unvirtualizeUtilPct" min="0" max="100" step="0.1" value="5.0">
<div class="form-text">If utilization rises above this threshold, TreeGuard should unvirtualize.</div>
</div>
<div class="col-md-6">
<label for="minStateDwellMinutes" class="form-label">Minimum State Dwell (minutes)</label>
<input type="number" class="form-control" id="minStateDwellMinutes" min="0" step="1" value="30">
<div class="form-text">Minimum time before a node may change state again.</div>
</div>
<div class="col-md-6">
<label for="maxLinkChangesPerHour" class="form-label">Max Link Changes Per Hour</label>
<input type="number" class="form-control" id="maxLinkChangesPerHour" min="0" step="1" value="4">
<div class="form-text">Rate limit to prevent flapping.</div>
</div>
<div class="col-md-6">
<label for="reloadCooldownMinutes" class="form-label">Reload Cooldown (minutes)</label>
<input type="number" class="form-control" id="reloadCooldownMinutes" min="0" step="1" value="10">
<div class="form-text">Minimum time between topology reload attempts.</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">Circuits and SQM Switching</h5>
<div class="lqos-config-panel-subtitle">Define circuit enrollment, CAKE to fq_codel switching, and dwell thresholds for circuit-level changes.</div>
</div>
</div>
<div class="mb-3">
<label for="circuitsIdleUtilPct" class="form-label">Idle Utilization (%)</label>
<input type="number" class="form-control" id="circuitsIdleUtilPct" min="0" max="100" step="0.1" value="2.0">
<div class="form-text">Utilization below which a circuit direction is considered idle (after smoothing)</div>
<div class="row g-3">
<div class="col-12 col-xl-5">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Managed Circuits</h6>
<div class="lqos-config-section-subtitle">Choose which circuits TreeGuard is allowed to manage.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="circuitsEnabled">
<label class="form-check-label" for="circuitsEnabled">Enable Circuit Management</label>
<div class="form-text">Allow TreeGuard to manage per-circuit SQM for enrolled circuits.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="circuitsAllCircuits">
<label class="form-check-label" for="circuitsAllCircuits">All circuits</label>
<div class="form-text">If enabled, TreeGuard manages all circuits found in ShapedDevices and ignores the allowlist.</div>
</div>
<div id="circuitsAllowlistSection">
<div class="form-text mb-2">Circuit IDs as in ShapedDevices.csv.</div>
<div class="input-group mb-2">
<select class="form-select" id="circuitSelector">
<option value="">Select a circuit...</option>
</select>
<button class="btn btn-outline-primary" type="button" id="addCircuitBtn">
<i class="fa fa-plus"></i> Add Circuit
</button>
</div>
<div class="input-group mb-2">
<input type="text" class="form-control" id="circuitManual" placeholder="Or type a circuit ID...">
<button class="btn btn-outline-primary" type="button" id="addCircuitManualBtn">
<i class="fa fa-plus"></i> Add
</button>
</div>
<div id="selectedCircuitsList" class="list-group">
<!-- Dynamically populated list with remove buttons -->
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">SQM Switching Thresholds</h6>
<div class="lqos-config-section-subtitle">Control when TreeGuard downgrades or restores circuit SQM.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="switchingEnabled">
<label class="form-check-label" for="switchingEnabled">Enable SQM Switching</label>
<div class="form-text">Allow TreeGuard to switch CAKE and fq_codel.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="independentDirections">
<label class="form-check-label" for="independentDirections">Independent Down/Up Decisions</label>
<div class="form-text">Allow different SQM tokens for down versus up.</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="circuitsIdleUtilPct" class="form-label">Idle Utilization (%)</label>
<input type="number" class="form-control" id="circuitsIdleUtilPct" min="0" max="100" step="0.1" value="2.0">
<div class="form-text">Utilization below which a circuit direction is considered idle (after smoothing).</div>
</div>
<div class="col-md-6">
<label for="circuitsIdleMinMinutes" class="form-label">Idle Minimum Duration (minutes)</label>
<input type="number" class="form-control" id="circuitsIdleMinMinutes" min="0" step="1" value="15">
<div class="form-text">How long a circuit direction must remain idle before downgrading SQM.</div>
</div>
<div class="col-md-6">
<label for="circuitsRttMissingSeconds" class="form-label">RTT Missing Timeout (seconds)</label>
<input type="number" class="form-control" id="circuitsRttMissingSeconds" min="0" step="1" value="120">
</div>
<div class="col-md-6">
<label for="circuitsUpgradeUtilPct" class="form-label">Upgrade Utilization (%)</label>
<input type="number" class="form-control" id="circuitsUpgradeUtilPct" min="0" max="100" step="0.1" value="5.0">
<div class="form-text">Utilization above which a downgraded direction upgrades back to CAKE.</div>
</div>
<div class="col-md-6">
<label for="minSwitchDwellMinutes" class="form-label">Minimum Switch Dwell (minutes)</label>
<input type="number" class="form-control" id="minSwitchDwellMinutes" min="0" step="1" value="30">
</div>
<div class="col-md-6">
<label for="maxSwitchesPerHour" class="form-label">Max Switches Per Hour</label>
<input type="number" class="form-control" id="maxSwitchesPerHour" min="0" step="1" value="4">
</div>
</div>
<div class="mt-3 form-check">
<input type="checkbox" class="form-check-input" id="persistSqmOverrides">
<label class="form-check-label" for="persistSqmOverrides">Persist SQM Overrides</label>
<div class="form-text">Write SQM overrides to <code>lqos_overrides.treeguard.json</code> to avoid scheduler fights.</div>
</div>
<div class="lqos-config-note mt-3" role="note">
TreeGuard can run entirely in memory, or it can also persist SQM overrides so the scheduler does not revert those decisions on the next refresh.
</div>
</div>
</div>
</div>
</section>
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">QoO Guardrail</h5>
<div class="lqos-config-panel-subtitle">Prevent CPU-saving actions when quality is already degraded.</div>
</div>
</div>
<div class="mb-3">
<label for="circuitsIdleMinMinutes" class="form-label">Idle Minimum Duration (minutes)</label>
<input type="number" class="form-control" id="circuitsIdleMinMinutes" min="0" step="1" value="15">
<div class="form-text">How long a circuit direction must remain idle before downgrading SQM</div>
</div>
<div class="mb-3">
<label for="circuitsRttMissingSeconds" class="form-label">RTT Missing Timeout (seconds)</label>
<input type="number" class="form-control" id="circuitsRttMissingSeconds" min="0" step="1" value="120">
</div>
<div class="mb-3">
<label for="circuitsUpgradeUtilPct" class="form-label">Upgrade Utilization (%)</label>
<input type="number" class="form-control" id="circuitsUpgradeUtilPct" min="0" max="100" step="0.1" value="5.0">
<div class="form-text">Utilization above which a downgraded direction upgrades back to CAKE</div>
</div>
<div class="mb-3">
<label for="minSwitchDwellMinutes" class="form-label">Minimum Switch Dwell (minutes)</label>
<input type="number" class="form-control" id="minSwitchDwellMinutes" min="0" step="1" value="30">
</div>
<div class="mb-3">
<label for="maxSwitchesPerHour" class="form-label">Max Switches Per Hour</label>
<input type="number" class="form-control" id="maxSwitchesPerHour" min="0" step="1" value="4">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="persistSqmOverrides">
<label class="form-check-label" for="persistSqmOverrides">Persist SQM Overrides</label>
<div class="form-text">Write SQM overrides to lqos_overrides.treeguard.json to avoid scheduler fights</div>
</div>
<hr class="my-4" />
<h5>QoO Guardrail</h5>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="qooEnabled">
<label class="form-check-label" for="qooEnabled">Enable QoO Guardrail</label>
<div class="form-text">If QoO is available and below the threshold, do not take CPU-saving actions</div>
</div>
<div class="mb-3">
<label for="minScore" class="form-label">Minimum QoO Score</label>
<input type="number" class="form-control" id="minScore" min="0" max="100" step="0.1" value="70.0">
<div class="form-text">0..100; enforced only when QoO is available</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Guardrail Policy</h6>
<div class="lqos-config-section-subtitle">Use QoO as a veto when service quality is already below the desired threshold.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="qooEnabled">
<label class="form-check-label" for="qooEnabled">Enable QoO Guardrail</label>
<div class="form-text">If QoO is available and below the threshold, do not take CPU-saving actions.</div>
</div>
<div class="mb-0">
<label for="minScore" class="form-label">Minimum QoO Score</label>
<input type="number" class="form-control" id="minScore" min="0" max="100" step="0.1" value="70.0">
<div class="form-text">0 to 100; enforced only when QoO is available.</div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_treeguard.js%CACHEBUSTERS%"></script>
@@ -1,59 +1,86 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<div class="alert alert-warning" role="alert">
<i class="fa fa-exclamation-triangle"></i> Warning: These are advanced settings. Adjust them at your own risk.
</div>
<form>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="stopIrqBalance">
<label class="form-check-label" for="stopIrqBalance">Stop IRQ Balance Service</label>
<div class="form-text">Disables the irq_balance system service</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">System Tuning</h5>
<div class="lqos-config-panel-subtitle">Adjust IRQ handling, kernel packet budgets, and NIC offload behavior on systems that need manual tuning.</div>
</div>
</div>
<div class="mb-3">
<label for="netdevBudgetUsecs" class="form-label">Netdev Budget (μs)</label>
<input type="number" class="form-control" id="netdevBudgetUsecs">
<div class="form-text">Time budget for network device processing in microseconds</div>
<div class="lqos-config-note lqos-config-note-warning mb-3" role="alert">
<i class="fa fa-exclamation-triangle me-2"></i>These are advanced settings. Change them only when you have a measured reason to do so.
</div>
<div class="mb-3">
<label for="netdevBudgetPackets" class="form-label">Netdev Budget (Packets)</label>
<input type="number" class="form-control" id="netdevBudgetPackets">
<div class="form-text">Packet budget for network device processing</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Interrupts and Budgets</h6>
<div class="lqos-config-section-subtitle">Control how aggressively the host services NIC interrupts and packet processing.</div>
<div class="mb-3">
<label for="rxUsecs" class="form-label">RX Polling Frequency (μs)</label>
<input type="number" class="form-control" id="rxUsecs">
<div class="form-text">Receive side polling frequency in microseconds</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="stopIrqBalance">
<label class="form-check-label" for="stopIrqBalance">Stop IRQ Balance Service</label>
<div class="form-text">Disables the <code>irq_balance</code> system service.</div>
</div>
<div class="mb-3">
<label for="txUsecs" class="form-label">TX Polling Frequency (μs)</label>
<input type="number" class="form-control" id="txUsecs">
<div class="form-text">Transmit side polling frequency in microseconds</div>
</div>
<div class="mb-3">
<label for="netdevBudgetUsecs" class="form-label">Netdev Budget (μs)</label>
<input type="number" class="form-control" id="netdevBudgetUsecs">
<div class="form-text">Time budget for network device processing in microseconds.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="disableRxVlan">
<label class="form-check-label" for="disableRxVlan">Disable RX VLAN Offloading</label>
</div>
<div class="mb-3">
<label for="netdevBudgetPackets" class="form-label">Netdev Budget (Packets)</label>
<input type="number" class="form-control" id="netdevBudgetPackets">
<div class="form-text">Packet budget for network device processing.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="disableTxVlan">
<label class="form-check-label" for="disableTxVlan">Disable TX VLAN Offloading</label>
</div>
<div class="mb-3">
<label for="rxUsecs" class="form-label">RX Polling Frequency (μs)</label>
<input type="number" class="form-control" id="rxUsecs">
<div class="form-text">Receive-side polling frequency in microseconds.</div>
</div>
<div class="mb-3">
<label for="disableOffload" class="form-label">Disable Offload Features</label>
<input type="text" class="form-control" id="disableOffload" aria-describedby="offloadHelp">
<div id="offloadHelp" class="form-text">Comma-separated list of offload features to disable (e.g. gso,tso,lro)</div>
</div>
<div class="mb-0">
<label for="txUsecs" class="form-label">TX Polling Frequency (μs)</label>
<input type="number" class="form-control" id="txUsecs">
<div class="form-text">Transmit-side polling frequency in microseconds.</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Offload Controls</h6>
<div class="lqos-config-section-subtitle">Disable hardware features that interfere with troubleshooting or expected shaping behavior.</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="disableRxVlan">
<label class="form-check-label" for="disableRxVlan">Disable RX VLAN Offloading</label>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="disableTxVlan">
<label class="form-check-label" for="disableTxVlan">Disable TX VLAN Offloading</label>
</div>
<div class="mb-0">
<label for="disableOffload" class="form-label">Disable Offload Features</label>
<input type="text" class="form-control" id="disableOffload" aria-describedby="offloadHelp">
<div id="offloadHelp" class="form-text">Comma-separated list of offload features to disable, for example <code>gso,tso,lro</code>.</div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_tuning.js%CACHEBUSTERS%"></script>
@@ -1,133 +1,170 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableUisp">
<label class="form-check-label" for="enableUisp">Enable UISP Integration</label>
<div class="form-text">Enable integration with UISP billing system</div>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">UISP Integration</h5>
<div class="lqos-config-panel-subtitle">Connect to UISP, choose import strategy, and control how topology, suspended accounts, capacity, and site overrides are handled.</div>
</div>
</div>
<div class="mb-3">
<label for="uispToken" class="form-label">API Token</label>
<input type="text" class="form-control" id="uispToken">
<div class="form-text">UISP API token for authentication</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Connection</h6>
<div class="lqos-config-section-subtitle">Define API access, default site scope, and transport/security behavior.</div>
<div class="mb-3">
<label for="uispUrl" class="form-label">UISP URL</label>
<input type="text" class="form-control" id="uispUrl">
<div class="form-text">Base URL for UISP API (e.g. https://your-uisp.com)</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableUisp">
<label class="form-check-label" for="enableUisp">Enable UISP Integration</label>
<div class="form-text">Enable integration with UISP.</div>
</div>
<div class="mb-3">
<label for="uispSite" class="form-label">UISP Site</label>
<input type="text" class="form-control" id="uispSite">
<div class="form-text">Site identifier in UISP</div>
</div>
<div class="mb-3">
<label for="uispToken" class="form-label">API Token</label>
<input type="text" class="form-control" id="uispToken">
<div class="form-text">UISP API token for authentication.</div>
</div>
<div class="mb-3">
<label for="uispStrategy" class="form-label">Strategy</label>
<select class="form-select" id="uispStrategy">
<option value="flat">Flat - Only shapes subscribers by service plan speed</option>
<option value="ap_only">AP Only - Shapes by service plan and Access Point</option>
<option value="ap_site">AP Site - Shapes by service plan, Access Point, and Site</option>
<option value="full">Full - Shapes entire network including backhauls, Sites, APs, and clients</option>
</select>
<div class="form-text">Select the topology strategy for network hierarchy</div>
</div>
<div class="mb-3">
<label for="uispUrl" class="form-label">UISP URL</label>
<input type="text" class="form-control" id="uispUrl">
<div class="form-text">Base URL for the UISP API, for example <code>https://your-uisp.com</code>.</div>
</div>
<div class="mb-3">
<label for="uispSuspendedStrategy" class="form-label">Suspended Strategy</label>
<select class="form-select" id="uispSuspendedStrategy">
<option value="none">None - Do not handle suspensions</option>
<option value="ignore">Ignore - Do not add suspended customers to network map</option>
<option value="slow">Slow - Limit suspended customers to 1mbps</option>
</select>
<div class="form-text">Select how to handle suspended customer accounts</div>
</div>
<div class="mb-3">
<label for="uispSite" class="form-label">UISP Site</label>
<input type="text" class="form-control" id="uispSite">
<div class="form-text">Optional site identifier in UISP.</div>
</div>
<div class="mb-3">
<label for="uispAirmaxCapacity" class="form-label">Airmax Capacity</label>
<input type="number" class="form-control" id="uispAirmaxCapacity" step="0.1">
<div class="form-text">Capacity factor for Airmax devices</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="uispIpv6WithMikrotik">
<label class="form-check-label" for="uispIpv6WithMikrotik">IPv6 with Mikrotik</label>
<div class="form-text">Enable IPv6 enrichment with Mikrotik devices.</div>
</div>
<div class="mb-3">
<label for="uispLtuCapacity" class="form-label">LTU Capacity</label>
<input type="number" class="form-control" id="uispLtuCapacity" step="0.1">
<div class="form-text">Capacity factor for LTU devices</div>
</div>
<div class="mb-0 form-check">
<input type="checkbox" class="form-check-input" id="uispInsecureSsl">
<label class="form-check-label" for="uispInsecureSsl">Insecure SSL</label>
<div class="form-text">Disable SSL certificate validation. Use only for testing or known self-signed environments.</div>
</div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="uispIpv6WithMikrotik">
<label class="form-check-label" for="uispIpv6WithMikrotik">IPv6 with Mikrotik</label>
<div class="form-text">Enable IPv6 support with Mikrotik devices</div>
</div>
<div class="col-12 col-xl-6">
<div class="lqos-config-section h-100">
<h6 class="lqos-config-section-title">Strategy and Capacity</h6>
<div class="lqos-config-section-subtitle">Choose the imported hierarchy depth and how LibreQoS should interpret suspended accounts and device capacity.</div>
<div class="mb-3">
<label for="uispBandwidthOverhead" class="form-label">Bandwidth Overhead Factor</label>
<input type="number" class="form-control" id="uispBandwidthOverhead" step="0.1" value="1.0">
<div class="form-text">Multiplier for bandwidth overhead calculations</div>
</div>
<div class="mb-3">
<label for="uispStrategy" class="form-label">Strategy</label>
<select class="form-select" id="uispStrategy">
<option value="flat">Flat - Only shapes subscribers by service plan speed</option>
<option value="ap_only">AP Only - Shapes by service plan and Access Point</option>
<option value="ap_site">AP Site - Shapes by service plan, Access Point, and Site</option>
<option value="full">Full - Shapes entire network including backhauls, Sites, APs, and clients</option>
</select>
<div class="form-text">Select the topology strategy for network hierarchy.</div>
</div>
<div class="mb-3">
<label for="uispCommitMultiplier" class="form-label">Commit Bandwidth Multiplier</label>
<input type="number" class="form-control" id="uispCommitMultiplier" step="0.1" value="1.0">
<div class="form-text">Multiplier for commit bandwidth calculations</div>
</div>
<div class="mb-3">
<label for="uispSuspendedStrategy" class="form-label">Suspended Strategy</label>
<select class="form-select" id="uispSuspendedStrategy">
<option value="none">None - Do not handle suspensions</option>
<option value="ignore">Ignore - Do not add suspended customers to network map</option>
<option value="slow">Slow - Limit suspended customers to 1mbps</option>
</select>
<div class="form-text">Select how to handle suspended customer accounts.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="uispUsePtmpAsParent">
<label class="form-check-label" for="uispUsePtmpAsParent">Use PTMP as Parent</label>
<div class="form-text">Treat PTMP devices as parent nodes</div>
</div>
<div class="mb-3">
<label for="uispAirmaxCapacity" class="form-label">Airmax Capacity</label>
<input type="number" class="form-control" id="uispAirmaxCapacity" step="0.1">
<div class="form-text">Capacity factor for Airmax devices.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="uispIgnoreCalculatedCapacity">
<label class="form-check-label" for="uispIgnoreCalculatedCapacity">Ignore Calculated Capacity</label>
<div class="form-text">Ignore capacity calculations from UISP</div>
</div>
<div class="mb-3">
<label for="uispLtuCapacity" class="form-label">LTU Capacity</label>
<input type="number" class="form-control" id="uispLtuCapacity" step="0.1">
<div class="form-text">Capacity factor for LTU devices.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="uispInsecureSsl">
<label class="form-check-label" for="uispInsecureSsl">Insecure SSL</label>
<div class="form-text">Disable SSL certificate validation (not recommended)</div>
</div>
<div class="mb-3">
<label for="uispBandwidthOverhead" class="form-label">Bandwidth Overhead Factor</label>
<input type="number" class="form-control" id="uispBandwidthOverhead" step="0.1" value="1.0">
<div class="form-text">Multiplier for bandwidth overhead calculations.</div>
</div>
<div class="mb-3">
<label for="uispExcludeSites" class="form-label">Exclude Sites</label>
<input type="text" class="form-control" id="uispExcludeSites">
<div class="form-text">Comma-separated list of site IDs to exclude from synchronization</div>
</div>
<div class="mb-3">
<label for="uispCommitMultiplier" class="form-label">Commit Bandwidth Multiplier</label>
<input type="number" class="form-control" id="uispCommitMultiplier" step="0.1" value="1.0">
<div class="form-text">Multiplier for commit bandwidth calculations.</div>
</div>
<div class="mb-3">
<label for="uispSquashSites" class="form-label">Squash Sites</label>
<input type="text" class="form-control" id="uispSquashSites">
<div class="form-text">Comma-separated list of site IDs to consolidate under a single parent</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="uispUsePtmpAsParent">
<label class="form-check-label" for="uispUsePtmpAsParent">Use PTMP as Parent</label>
<div class="form-text">Treat PTMP devices as parent nodes.</div>
</div>
<div class="mb-3">
<label for="uispExceptionCpes" class="form-label">Exception CPEs</label>
<input type="text" class="form-control" id="uispExceptionCpes">
<div class="form-text">Comma-separated list of CPE exceptions in "cpe:parent" format</div>
</div>
<div class="mb-0 form-check">
<input type="checkbox" class="form-check-input" id="uispIgnoreCalculatedCapacity">
<label class="form-check-label" for="uispIgnoreCalculatedCapacity">Ignore Calculated Capacity</label>
<div class="form-text">Ignore capacity calculations from UISP.</div>
</div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="uispEnableSquashing">
<label class="form-check-label" for="uispEnableSquashing">Enable Squashing</label>
<div class="form-text">Enable site squashing functionality</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Site and CPE Overrides</h6>
<div class="lqos-config-section-subtitle">Fine-tune how specific sites and CPEs are excluded, squashed, or reassigned during synchronization.</div>
<div class="mb-3">
<label for="uispDoNotSquashSites" class="form-label">Do Not Squash Sites</label>
<input type="text" class="form-control" id="uispDoNotSquashSites">
<div class="form-text">Comma-separated list of site IDs that should not be squashed</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<label for="uispExcludeSites" class="form-label">Exclude Sites</label>
<input type="text" class="form-control" id="uispExcludeSites">
<div class="form-text">Comma-separated list of site IDs to exclude from synchronization.</div>
</div>
<div class="col-12 col-lg-6">
<label for="uispSquashSites" class="form-label">Squash Sites</label>
<input type="text" class="form-control" id="uispSquashSites">
<div class="form-text">Comma-separated list of site IDs to consolidate under a single parent.</div>
</div>
<div class="col-12 col-lg-6">
<label for="uispExceptionCpes" class="form-label">Exception CPEs</label>
<input type="text" class="form-control" id="uispExceptionCpes">
<div class="form-text">Comma-separated list of CPE exceptions in <code>cpe:parent</code> format.</div>
</div>
<div class="col-12 col-lg-6">
<label for="uispDoNotSquashSites" class="form-label">Do Not Squash Sites</label>
<input type="text" class="form-control" id="uispDoNotSquashSites">
<div class="form-text">Comma-separated list of site IDs that should not be squashed.</div>
</div>
<div class="col-12">
<div class="mb-0 form-check">
<input type="checkbox" class="form-check-input" id="uispEnableSquashing">
<label class="form-check-label" for="uispEnableSquashing">Enable Squashing</label>
<div class="form-text">Enable site squashing functionality.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_uisp.js%CACHEBUSTERS%"></script>
@@ -1,7 +1,16 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">VISP Integration</h5>
<div class="lqos-config-panel-subtitle">Configure GraphQL UBO API access, tenant selection, and optional online-user enrichment from VISP.</div>
</div>
</div>
<div class="lqos-config-section">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableVisp">
<label class="form-check-label" for="enableVisp">Enable VISP Integration</label>
@@ -46,8 +55,12 @@
<div class="form-text">Used to enrich IPs via online RADIUS sessions if VISP is missing static IPs</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveVisp" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_visp.js%CACHEBUSTERS%"></script>
@@ -1,7 +1,16 @@
<div id="configMenuContainer"></div>
<div class="row">
<div class="col-12">
<form>
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">WispGate Integration</h5>
<div class="lqos-config-panel-subtitle">Configure API credentials and endpoint details for automated imports from WispGate.</div>
</div>
</div>
<div class="lqos-config-section">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enableWispgate">
<label class="form-check-label" for="enableWispgate">Enable WispGate Integration</label>
@@ -20,10 +29,13 @@
<div class="form-text">WispGate API URL</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</form>
</div>
</div>
</form>
</div>
<script src="config_wispgate.js%CACHEBUSTERS%"></script>
@@ -0,0 +1,51 @@
<div class="lqos-config-page">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title"><i class="fa fa-ethernet me-2"></i>Ethernet Caps</h5>
<div class="lqos-config-panel-subtitle">Circuits automatically down-rated because detected Ethernet speed was below the requested plan.</div>
</div>
<div id="ethernetCapsSummary" class="small text-body-secondary"></div>
</div>
<div class="lqos-config-section">
<div class="row g-2 align-items-end mb-3">
<div class="col-12 col-lg-5">
<label class="form-label small text-body-secondary" for="ethernetCapsSearch">Search</label>
<input id="ethernetCapsSearch" class="form-control" type="text" placeholder="Search circuits, parents, device names…">
</div>
<div class="col-6 col-lg-2">
<label class="form-label small text-body-secondary" for="ethernetCapsTier">Ethernet</label>
<select id="ethernetCapsTier" class="form-select">
<option value="">All</option>
<option value="10M">10M</option>
<option value="100M">100M</option>
<option value="1G">1G+</option>
</select>
</div>
<div class="col-6 col-lg-2">
<label class="form-label small text-body-secondary" for="ethernetCapsPageSize">Per page</label>
<select id="ethernetCapsPageSize" class="form-select">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="250">250</option>
</select>
</div>
<div class="col-12 col-lg-3 d-flex justify-content-lg-end gap-2">
<button id="ethernetCapsPrev" type="button" class="btn btn-outline-primary">
<i class="fa fa-arrow-left"></i>
</button>
<button id="ethernetCapsNext" type="button" class="btn btn-outline-primary">
<i class="fa fa-arrow-right"></i>
</button>
</div>
</div>
<div id="ethernetCapsTableWrap">
<div class="text-muted small"><i class="fa fa-spinner fa-spin me-1"></i>Loading Ethernet cap data…</div>
</div>
<div id="ethernetCapsPager" class="small text-body-secondary mt-3"></div>
</div>
</section>
</div>
<script src="ethernet_caps.js%CACHEBUSTERS%"></script>
@@ -405,6 +405,27 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
.lqos-circuit-panel .lqos-table {
height: 100%;
}
.lqos-circuit-header-table.lqos-table-compact tbody td {
padding: 0.5rem 0.55rem;
}
.lqos-table.lqos-circuit-header-table .table-label-cell {
width: 6.75rem;
white-space: nowrap;
padding-right: 0.25rem;
}
.lqos-table.lqos-circuit-header-table .table-value-cell {
width: auto;
padding-left: 0.25rem;
}
.lqos-circuit-parent-link {
display: block;
max-width: 100%;
}
.flow-rtt-exclude-btn.btn.btn-sm {
padding-top: 0.175rem;
padding-bottom: 0.175rem;
line-height: 1.1;
}
.lqos-circuit-gauge-panel {
display: flex;
align-items: center;
@@ -699,6 +720,73 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
font-weight: 600;
line-height: 1.35;
}
.lqos-circuit-devices-panel {
margin-top: 1rem;
border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent);
border-radius: var(--lqos-radius-md);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--lqos-surface-2) 88%, transparent) 0%,
var(--lqos-surface-solid) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 12px 24px rgba(6, 12, 20, 0.12);
padding: 0.95rem;
}
.lqos-circuit-devices-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.9rem;
}
.lqos-circuit-devices-header-copy {
min-width: 0;
}
.lqos-circuit-devices-header-copy h5 {
margin-bottom: 0.25rem;
}
.lqos-circuit-devices-meta {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.4rem;
}
.lqos-circuit-devices-meta-pill {
display: inline-flex;
align-items: center;
padding: 0.34rem 0.72rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--lqos-border) 84%, transparent);
background: color-mix(in srgb, var(--lqos-surface-2) 72%, transparent);
color: var(--bs-secondary-color);
font-size: 0.76rem;
font-weight: 600;
white-space: nowrap;
}
.lqos-circuit-device-card {
border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent);
border-radius: var(--lqos-radius-sm);
background: color-mix(in srgb, var(--lqos-surface-2) 66%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
padding: 0.8rem;
}
.lqos-circuit-device-summary,
.lqos-circuit-device-graphs {
min-width: 0;
}
.lqos-circuit-device-summary h5 {
margin-bottom: 0.5rem;
}
.lqos-circuit-device-graph {
border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent);
border-radius: var(--lqos-radius-sm);
background: color-mix(in srgb, var(--lqos-surface-2) 76%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
padding: 0.35rem;
}
.lqos-funnel-panel {
margin-top: 1rem;
border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent);
@@ -873,9 +961,11 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
.lqos-circuit-activity-chart {
height: 296px;
}
.lqos-circuit-devices-header,
.lqos-cake-header {
flex-direction: column;
}
.lqos-circuit-devices-meta,
.lqos-cake-meta {
justify-content: flex-start;
}
@@ -907,9 +997,11 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
.lqos-circuit-activity-kpi-value {
font-size: 1.05rem;
}
.lqos-circuit-devices-panel,
.lqos-cake-panel {
padding: 0.8rem;
}
.lqos-circuit-device-card,
.lqos-funnel-panel,
.lqos-funnel-node-card,
.lqos-funnel-path-card {
@@ -1178,6 +1270,37 @@ h5 {
width: 7.5rem;
max-width: 7.5rem;
}
.lqos-circuit-traffic-table {
width: 100%;
table-layout: fixed;
}
.lqos-circuit-traffic-table th,
.lqos-circuit-traffic-table td {
white-space: nowrap;
}
.lqos-circuit-traffic-table .lqos-circuit-traffic-protocol-cell {
width: 10rem;
max-width: 10rem;
}
.lqos-circuit-traffic-table .lqos-circuit-traffic-asn-cell {
width: 12rem;
max-width: 12rem;
}
.lqos-circuit-traffic-table .lqos-circuit-traffic-country-cell {
width: 8rem;
max-width: 8rem;
}
.lqos-circuit-traffic-table .lqos-circuit-traffic-protocol-cell .lqos-table-cell-ellipsis,
.lqos-circuit-traffic-table .lqos-circuit-traffic-asn-cell .lqos-table-cell-ellipsis,
.lqos-circuit-traffic-table .lqos-circuit-traffic-country-cell .lqos-table-cell-ellipsis {
max-width: 100%;
}
.lqos-tree-stack {
display: flex;
flex-direction: column;
@@ -1221,6 +1344,14 @@ h5 {
text-overflow: ellipsis;
white-space: nowrap;
}
.lqos-tree-plan-column,
.lqos-tree-plan-cell {
min-width: 8.25rem;
white-space: nowrap;
}
.lqos-tree-ip-cell {
white-space: nowrap;
}
.lqos-tree-row-statuses {
display: inline-flex;
align-items: center;
@@ -1598,6 +1729,142 @@ table tr td a {
/* For the config panel */
.lqos-config-page {
display: grid;
gap: 1rem;
}
.lqos-config-panel {
border: 1px solid var(--lqos-border);
border-radius: var(--lqos-radius-lg);
background: var(--lqos-surface);
box-shadow: var(--lqos-shadow-sm);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 1.1rem 1.15rem;
}
.lqos-config-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.lqos-config-panel-title {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
}
.lqos-config-panel-subtitle {
margin-top: 0.2rem;
color: var(--bs-secondary-color);
font-size: 0.92rem;
}
.lqos-config-grid {
display: grid;
gap: 1rem;
}
.lqos-config-section {
border: 1px solid var(--lqos-border);
border-radius: var(--lqos-radius-md);
background: color-mix(in srgb, var(--lqos-surface-2) 82%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
padding: 1rem 1rem 0.9rem;
}
.lqos-config-section-title {
font-size: 0.95rem;
font-weight: 700;
margin: 0 0 0.25rem;
}
.lqos-config-section-subtitle {
color: var(--bs-secondary-color);
font-size: 0.87rem;
margin-bottom: 1rem;
}
.lqos-config-note {
border: 1px solid color-mix(in srgb, var(--bs-info) 24%, var(--lqos-border));
border-radius: var(--lqos-radius-md);
background: color-mix(in srgb, var(--bs-info-bg-subtle) 70%, var(--lqos-surface) 30%);
color: var(--bs-body-color);
padding: 0.85rem 1rem;
}
.lqos-config-note-warning {
border-color: color-mix(in srgb, var(--bs-warning) 30%, var(--lqos-border));
background: color-mix(in srgb, var(--bs-warning-bg-subtle) 76%, var(--lqos-surface) 24%);
}
.lqos-config-note code {
color: inherit;
}
.lqos-config-preview {
border: 1px solid var(--lqos-border);
border-radius: var(--lqos-radius-md);
background: color-mix(in srgb, var(--lqos-surface-2) 86%, transparent);
padding: 0.75rem 0.85rem;
}
.lqos-config-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.lqos-config-panel .card {
border: 1px solid var(--lqos-border);
border-radius: var(--lqos-radius-md);
background: color-mix(in srgb, var(--lqos-surface-2) 86%, transparent);
box-shadow: none;
}
.lqos-config-panel .card-header {
background: transparent;
border-bottom-color: var(--lqos-border);
font-weight: 700;
}
.lqos-config-panel .form-text,
.lqos-config-panel .text-muted,
.lqos-config-panel .small.text-muted {
color: var(--bs-secondary-color) !important;
}
.lqos-config-network-editor {
display: grid;
gap: 0.9rem;
}
.lqos-config-network-editor .lqos-network-node-card {
background: color-mix(in srgb, var(--lqos-surface-2) 88%, transparent);
}
.lqos-config-network-editor .card-title {
font-size: 1rem;
font-weight: 700;
}
.lqos-config-network-editor .card-body {
padding: 1rem 1rem 0.95rem;
}
.lqos-config-network-editor .btn {
white-space: nowrap;
}
.lqos-config-network-editor .badge {
font-weight: 600;
}
.nav-pills .nav-link {
color: var(--bs-secondary-color);
}
@@ -1632,19 +1899,35 @@ table tr td a {
.config-menu-item {
margin: 0;
padding: 5px 10px;
background-color: var(--bs-tertiary-bg);
list-style: none;
}
.config-menu-link {
display: inline-flex;
align-items: center;
gap: 0.45rem;
width: 100%;
padding: 6px 11px;
background: color-mix(in srgb, var(--lqos-surface-2) 80%, transparent);
border: 1px solid var(--lqos-border);
border-radius: 999px;
color: inherit;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
}
.config-menu-item:hover {
background-color: var(--bs-info-bg-subtle);
.config-menu-link:hover {
background: color-mix(in srgb, var(--bs-info-bg-subtle) 72%, var(--lqos-surface) 28%);
border-color: color-mix(in srgb, var(--bs-info) 26%, var(--lqos-border));
transform: translateY(-1px);
}
.config-menu-item.active {
background-color: var(--bs-info);
color: var(--bs-info-text);
.config-menu-link.active {
background: color-mix(in srgb, var(--bs-info) 20%, var(--lqos-surface) 80%);
color: var(--bs-info-text-emphasis, var(--bs-info));
border-color: color-mix(in srgb, var(--bs-info) 44%, var(--lqos-border));
font-weight: bold;
}
@@ -36,6 +36,7 @@ pub(super) fn static_routes() -> Result<Router> {
"unknown_ips.html",
"configuration.html",
"circuit.html",
"ethernet_caps.html",
"site_map.html",
"flow_map.html",
"all_tree_sankey.html",
+55 -7
View File
@@ -11,9 +11,9 @@ use std::sync::Arc;
use crate::node_manager::auth::{LoginResult, login_from_token};
use crate::node_manager::local_api::{
circuit, circuit_count, config, cpu_affinity, dashboard_themes, device_counts, directories,
executive, flow_explorer, flow_map, lts, network_tree, network_tree_lite, node_rate_overrides,
packet_analysis, reload_libreqos, scheduler, search, shaped_device_api, shaped_devices_page,
unknown_ips, urgent, warnings,
ethernet_caps, executive, flow_explorer, flow_map, lts, network_tree, network_tree_lite,
node_rate_overrides, packet_analysis, reload_libreqos, scheduler, search, shaped_device_api,
shaped_devices_page, unknown_ips, urgent, warnings,
};
use crate::node_manager::shaper_queries_actor::ShaperQueryCommand;
use crate::node_manager::ws::messages::{
@@ -21,6 +21,12 @@ use crate::node_manager::ws::messages::{
};
use crate::node_manager::ws::publish_subscribe::PubSub;
use crate::node_manager::ws::published_channels::PublishedChannels;
use crate::node_manager::ws::single_user_channels::circuit::{
circuit_devices_result, circuit_devices_snapshot,
};
use crate::node_manager::ws::single_user_channels::flows_by_circuit::{
circuit_flow_sankey_result, circuit_top_asns_result, circuit_traffic_flows_result,
};
use crate::node_manager::ws::ticker::channel_ticker;
use crate::system_stats::SystemStats;
use axum::{
@@ -380,6 +386,14 @@ async fn receive_channel_message(
return true;
}
}
WsRequest::EthernetCapsPage { query } => {
let response = WsResponse::EthernetCapsPage {
data: ethernet_caps::ethernet_caps_page(query),
};
if send_ws_response(&tx, response).await {
return true;
}
}
WsRequest::ExecutiveHeatmapPage { query } => {
let response = WsResponse::ExecutiveHeatmapPage {
data: executive::executive_heatmap_page(query),
@@ -421,11 +435,45 @@ async fn receive_channel_message(
}
}
WsRequest::CircuitById { id } => {
let (ok, devices) = match circuit::circuit_by_id_data(&id) {
Some(devices) => (true, devices),
None => (false, Vec::new()),
let (ok, data) = match circuit::circuit_by_id_data(&id) {
Some(data) => (true, Some(data)),
None => (false, None),
};
let response = WsResponse::CircuitByIdResult { id, data, ok };
if send_ws_response(&tx, response).await {
return true;
}
}
WsRequest::CircuitDevices { circuit } => {
let devices = circuit_devices_snapshot(&circuit, request_state.private_state.bus_tx()).await;
let response = circuit_devices_result(circuit, devices);
if send_ws_response(&tx, response).await {
return true;
}
}
WsRequest::CircuitFlowSankey { circuit } => {
let response = WsResponse::CircuitFlowSankeyResult {
circuit_id: circuit.clone(),
flows: circuit_flow_sankey_result(&circuit),
};
if send_ws_response(&tx, response).await {
return true;
}
}
WsRequest::CircuitTopAsns { query } => {
let response = WsResponse::CircuitTopAsnsResult {
circuit_id: query.circuit.clone(),
data: circuit_top_asns_result(&query),
};
if send_ws_response(&tx, response).await {
return true;
}
}
WsRequest::CircuitTrafficFlowsPage { query } => {
let response = WsResponse::CircuitTrafficFlowsPageResult {
circuit_id: query.circuit.clone(),
data: circuit_traffic_flows_result(&query),
};
let response = WsResponse::CircuitByIdResult { id, devices, ok };
if send_ws_response(&tx, response).await {
return true;
}
+46 -21
View File
@@ -1,10 +1,16 @@
use crate::lts2_sys::control_channel::{SupportTicket, SupportTicketSummary};
use crate::node_manager::WarningLevel;
use crate::node_manager::local_api::circuit::CircuitByIdData;
use crate::node_manager::local_api::circuit_activity::{
CircuitFlowSankeyRow, CircuitSummaryData, CircuitTopAsnsData, CircuitTopAsnsQuery,
CircuitTrafficFlowsPage, CircuitTrafficFlowsQuery,
};
use crate::node_manager::local_api::dashboard_themes::{DashletIdentity, ThemeEntry};
use crate::node_manager::local_api::device_counts::DeviceCount;
use crate::node_manager::local_api::directories::{
CircuitDirectoryPage, CircuitDirectoryQuery, NodeDirectoryEntry, TreeGuardMetadataSummary,
};
use crate::node_manager::local_api::ethernet_caps::{EthernetCapsPage, EthernetCapsPageQuery};
use crate::node_manager::local_api::executive::{
ExecutiveDashboardSummary, ExecutiveHeatmapPage, ExecutiveHeatmapPageQuery,
ExecutiveLeaderboardPage, ExecutiveLeaderboardPageQuery,
@@ -44,7 +50,6 @@ use crate::throughput_tracker::TcpRetransmitTotal;
use crate::throughput_tracker::flow_data::{
AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry,
};
use crate::throughput_tracker::flow_data::{FlowAnalysis, FlowbeeLocalData};
use lqos_bus::{Circuit, FlowbeeSummaryData, QueueStoreTransit, StormguardDebugEntry};
use lqos_config::QooProfileInfo;
use lqos_config::{Config, NetworkJsonTransport, ShapedDevice, WebUser};
@@ -71,7 +76,8 @@ pub struct WsHelloReply {
pub enum PrivateRequest {
CircuitWatcher { circuit: String },
PingMonitor { ips: Vec<(String, String)> },
FlowsByCircuit { circuit: String },
StopCircuitWatcher,
StopPingMonitorWatch,
CakeWatcher { circuit: String },
Chatbot { browser_ts_ms: Option<f64> },
ChatbotUserInput { text: String },
@@ -202,6 +208,9 @@ pub enum WsRequest {
CircuitDirectoryPage {
query: CircuitDirectoryQuery,
},
EthernetCapsPage {
query: EthernetCapsPageQuery,
},
NodeDirectory,
TreeGuardMetadataSummary,
GetUsers,
@@ -221,6 +230,18 @@ pub enum WsRequest {
CircuitById {
id: String,
},
CircuitDevices {
circuit: String,
},
CircuitFlowSankey {
circuit: String,
},
CircuitTopAsns {
query: CircuitTopAsnsQuery,
},
CircuitTrafficFlowsPage {
query: CircuitTrafficFlowsQuery,
},
SetCircuitRttExcluded {
circuit_id: String,
excluded: bool,
@@ -499,18 +520,11 @@ pub struct NodeCapacity {
pub median_rtt: f32,
}
#[derive(Debug, Serialize)]
pub struct FlowbeeKeyTransit {
pub remote_ip: String,
pub local_ip: String,
pub src_port: u16,
pub dst_port: u16,
pub ip_protocol: u8,
pub device_name: String,
pub asn_name: String,
pub asn_country: String,
pub protocol_name: String,
pub last_seen_nanos: u64,
#[derive(Debug, Serialize, Deserialize)]
pub struct CircuitDevicesResult {
pub circuit_id: String,
pub devices: Vec<Circuit>,
pub ok: bool,
}
#[derive(Debug, Serialize)]
@@ -636,6 +650,9 @@ pub enum WsResponse {
CircuitDirectoryPage {
data: CircuitDirectoryPage,
},
EthernetCapsPage {
data: EthernetCapsPage,
},
NodeDirectory {
data: Vec<NodeDirectoryEntry>,
},
@@ -746,7 +763,7 @@ pub enum WsResponse {
},
CircuitByIdResult {
id: String,
devices: Vec<ShapedDevice>,
data: Option<CircuitByIdData>,
ok: bool,
},
RequestAnalysisResult {
@@ -913,10 +930,10 @@ pub enum WsResponse {
data: Vec<TreeguardActivityEntry>,
},
CircuitWatcher {
circuit_id: String,
devices: Vec<Circuit>,
qoo_score: Option<f32>,
rtt_excluded: bool,
data: CircuitSummaryData,
},
CircuitDevicesResult {
data: CircuitDevicesResult,
},
SetCircuitRttExcludedResult {
ok: bool,
@@ -928,9 +945,17 @@ pub enum WsResponse {
ip: String,
result: PingState,
},
FlowsByCircuit {
CircuitFlowSankeyResult {
circuit_id: String,
flows: Vec<(FlowbeeKeyTransit, FlowbeeLocalData, FlowAnalysis)>,
flows: Vec<CircuitFlowSankeyRow>,
},
CircuitTopAsnsResult {
circuit_id: String,
data: CircuitTopAsnsData,
},
CircuitTrafficFlowsPageResult {
circuit_id: String,
data: CircuitTrafficFlowsPage,
},
CakeWatcher {
#[serde(flatten)]
@@ -1,8 +1,8 @@
mod cake_watcher;
mod chatbot;
mod circuit;
pub(crate) mod circuit;
mod circuit_metrics;
mod flows_by_circuit;
pub(crate) mod flows_by_circuit;
mod ping_monitor;
mod tree_attached_circuits;
@@ -10,7 +10,6 @@ use crate::node_manager::ws::messages::{PrivateRequest, WsResponse, encode_ws_me
use crate::node_manager::ws::single_user_channels::cake_watcher::cake_watcher;
use crate::node_manager::ws::single_user_channels::circuit::circuit_watcher;
use crate::node_manager::ws::single_user_channels::circuit_metrics::watch_circuit_metrics;
use crate::node_manager::ws::single_user_channels::flows_by_circuit::flows_by_circuit;
use crate::node_manager::ws::single_user_channels::ping_monitor::ping_monitor;
use crate::node_manager::ws::single_user_channels::tree_attached_circuits::watch_tree_attached_circuits;
use tokio::spawn;
@@ -26,6 +25,8 @@ pub struct PrivateState {
control_tx: tokio::sync::mpsc::Sender<crate::lts2_sys::control_channel::ControlChannelCommand>,
browser_language: Option<String>,
chatbot_request: Option<u64>,
circuit_watch: Option<tokio::task::JoinHandle<()>>,
ping_monitor_watch: Option<tokio::task::JoinHandle<()>>,
tree_attached_circuits_watch: Option<tokio::task::JoinHandle<()>>,
circuit_metrics_watch: Option<tokio::task::JoinHandle<()>>,
}
@@ -48,6 +49,8 @@ impl PrivateState {
control_tx,
browser_language,
chatbot_request: None,
circuit_watch: None,
ping_monitor_watch: None,
tree_attached_circuits_watch: None,
circuit_metrics_watch: None,
}
@@ -59,20 +62,28 @@ impl PrivateState {
self.control_tx.clone()
}
pub fn bus_tx(
&self,
) -> Sender<(
tokio::sync::oneshot::Sender<lqos_bus::BusReply>,
lqos_bus::BusRequest,
)> {
self.bus_tx.clone()
}
pub async fn handle_request(&mut self, request: PrivateRequest) {
match request {
PrivateRequest::CircuitWatcher { circuit } => {
spawn(circuit_watcher(
circuit,
self.tx.clone(),
self.bus_tx.clone(),
));
self.replace_circuit_watch(circuit);
}
PrivateRequest::PingMonitor { ips } => {
spawn(ping_monitor(ips, self.tx.clone()));
self.replace_ping_monitor_watch(ips);
}
PrivateRequest::FlowsByCircuit { circuit } => {
spawn(flows_by_circuit(circuit, self.tx.clone()));
PrivateRequest::StopCircuitWatcher => {
self.abort_circuit_watch();
}
PrivateRequest::StopPingMonitorWatch => {
self.abort_ping_monitor_watch();
}
PrivateRequest::CakeWatcher { circuit } => {
spawn(cake_watcher(circuit, self.tx.clone()));
@@ -99,6 +110,32 @@ impl PrivateState {
}
}
fn replace_circuit_watch(&mut self, circuit: String) {
self.abort_circuit_watch();
self.circuit_watch = Some(spawn(circuit_watcher(
circuit,
self.tx.clone(),
self.bus_tx.clone(),
)));
}
fn abort_circuit_watch(&mut self) {
if let Some(handle) = self.circuit_watch.take() {
handle.abort();
}
}
fn replace_ping_monitor_watch(&mut self, ips: Vec<(String, String)>) {
self.abort_ping_monitor_watch();
self.ping_monitor_watch = Some(spawn(ping_monitor(ips, self.tx.clone())));
}
fn abort_ping_monitor_watch(&mut self) {
if let Some(handle) = self.ping_monitor_watch.take() {
handle.abort();
}
}
fn replace_tree_attached_circuits_watch(
&mut self,
query: crate::node_manager::local_api::tree_attached_circuits::TreeAttachedCircuitsQuery,
@@ -187,6 +224,8 @@ impl PrivateState {
impl Drop for PrivateState {
fn drop(&mut self) {
self.abort_circuit_watch();
self.abort_ping_monitor_watch();
self.abort_tree_attached_circuits_watch();
self.abort_circuit_metrics_watch();
}
@@ -1,13 +1,153 @@
use crate::node_manager::ws::messages::{WsResponse, encode_ws_message};
use crate::node_manager::local_api::circuit_activity::{CircuitSummaryData, circuit_flow_counts};
use crate::node_manager::ws::messages::{CircuitDevicesResult, WsResponse, encode_ws_message};
use crate::node_manager::ws::ticker::all_circuits;
use crate::rtt_exclusions;
use crate::shaped_devices_tracker::SHAPED_DEVICES;
use crate::throughput_tracker::THROUGHPUT_TRACKER;
use lqos_bus::BusRequest;
use lqos_bus::{BusRequest, Circuit};
use lqos_utils::units::{DownUpOrder, down_up_retransmit_sample};
use std::time::Duration;
use tokio::time::MissedTickBehavior;
use tracing::info;
const QUEUING_ACTIVITY_RTT_FLOOR_BPS: u64 = 200_000;
fn qoo_score_for_circuit(circuit: &str) -> Option<f32> {
let shaped = SHAPED_DEVICES.load();
let circuit_hash = shaped
.devices
.iter()
.find(|d| d.circuit_id == circuit)
.map(|d| d.circuit_hash);
circuit_hash.and_then(|hash| {
let qoq_heatmaps = THROUGHPUT_TRACKER.circuit_qoq_heatmaps.lock();
qoq_heatmaps.get(&hash).and_then(|heatmap| {
let blocks = heatmap.blocks();
let dl = blocks.download_total.last().copied().flatten();
let ul = blocks.upload_total.last().copied().flatten();
match (dl, ul) {
(Some(d), Some(u)) => Some(d.min(u)),
(Some(d), None) => Some(d),
(None, Some(u)) => Some(u),
(None, None) => None,
}
})
})
}
fn weighted_directional_rtt_p50_nanos(devices: &[Circuit], direction: fn(&Circuit) -> (u64, Option<u64>)) -> Option<u64> {
let mut weighted_entries = Vec::new();
let mut fallback_values = Vec::new();
for device in devices {
let (throughput_bps, rtt_nanos_opt) = direction(device);
let Some(rtt_nanos) = rtt_nanos_opt else {
continue;
};
fallback_values.push(rtt_nanos);
if throughput_bps > QUEUING_ACTIVITY_RTT_FLOOR_BPS {
weighted_entries.push((rtt_nanos, throughput_bps));
}
}
if !weighted_entries.is_empty() {
weighted_entries.sort_by_key(|(rtt, _)| *rtt);
let total_weight: u128 = weighted_entries.iter().map(|(_, weight)| *weight as u128).sum();
let threshold = total_weight / 2;
let mut running = 0_u128;
for (rtt, weight) in weighted_entries {
running += weight as u128;
if running >= threshold {
return Some(rtt);
}
}
}
if fallback_values.is_empty() {
return None;
}
fallback_values.sort_unstable();
let middle = fallback_values.len() / 2;
if fallback_values.len() % 2 == 1 {
Some(fallback_values[middle])
} else {
Some((fallback_values[middle - 1] + fallback_values[middle]) / 2)
}
}
fn summarize_circuit_devices(circuit: &str, devices: &[Circuit]) -> CircuitSummaryData {
let bytes_per_second = devices.iter().fold(DownUpOrder::default(), |mut acc, device| {
acc.down += device.bytes_per_second.down;
acc.up += device.bytes_per_second.up;
acc
});
let tcp_retransmit_sample = down_up_retransmit_sample(
DownUpOrder {
down: devices
.iter()
.map(|device| device.tcp_retransmit_sample.down.retransmits.get())
.sum(),
up: devices
.iter()
.map(|device| device.tcp_retransmit_sample.up.retransmits.get())
.sum(),
},
DownUpOrder {
down: devices
.iter()
.map(|device| device.tcp_retransmit_sample.down.packets.get())
.sum(),
up: devices
.iter()
.map(|device| device.tcp_retransmit_sample.up.packets.get())
.sum(),
},
);
let rtt_current_p50_nanos = DownUpOrder {
down: weighted_directional_rtt_p50_nanos(devices, |device| {
(
device.bytes_per_second.down.saturating_mul(8),
device.rtt_current_p50_nanos.down,
)
}),
up: weighted_directional_rtt_p50_nanos(devices, |device| {
(
device.bytes_per_second.up.saturating_mul(8),
device.rtt_current_p50_nanos.up,
)
}),
};
let (active_flow_count, active_asn_count) = circuit_flow_counts(circuit);
CircuitSummaryData {
circuit_id: circuit.to_string(),
bytes_per_second,
rtt_current_p50_nanos,
tcp_retransmit_sample,
qoo_score: qoo_score_for_circuit(circuit),
rtt_excluded: rtt_exclusions::is_excluded_circuit_id(circuit),
active_flow_count,
active_asn_count,
}
}
pub async fn circuit_devices_snapshot(
circuit: &str,
bus_tx: tokio::sync::mpsc::Sender<(
tokio::sync::oneshot::Sender<lqos_bus::BusReply>,
BusRequest,
)>,
) -> Vec<Circuit> {
all_circuits(bus_tx)
.await
.into_iter()
.filter(|device| device.circuit_id.as_deref() == Some(circuit))
.collect()
}
pub(super) async fn circuit_watcher(
circuit: String,
tx: tokio::sync::mpsc::Sender<std::sync::Arc<Vec<u8>>>,
@@ -20,53 +160,14 @@ pub(super) async fn circuit_watcher(
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
ticker.tick().await;
// Get all circuits and filter them
let devices_for_circuit: Vec<_> = all_circuits(bus_tx.clone())
.await
.into_iter()
.filter(|c| {
if let Some(c) = c.circuit_id.as_ref() {
*c == circuit
} else {
false
}
})
.collect();
let qoo_score = {
let shaped = SHAPED_DEVICES.load();
let circuit_hash = shaped
.devices
.iter()
.find(|d| d.circuit_id == circuit)
.map(|d| d.circuit_hash);
circuit_hash.and_then(|hash| {
let qoq_heatmaps = THROUGHPUT_TRACKER.circuit_qoq_heatmaps.lock();
qoq_heatmaps.get(&hash).and_then(|heatmap| {
let blocks = heatmap.blocks();
let dl = blocks.download_total.last().copied().flatten();
let ul = blocks.upload_total.last().copied().flatten();
match (dl, ul) {
(Some(d), Some(u)) => Some(d.min(u)),
(Some(d), None) => Some(d),
(None, Some(u)) => Some(u),
(None, None) => None,
}
})
})
let devices_for_circuit = circuit_devices_snapshot(&circuit, bus_tx.clone()).await;
let response = WsResponse::CircuitWatcher {
data: summarize_circuit_devices(&circuit, &devices_for_circuit),
};
let result = WsResponse::CircuitWatcher {
circuit_id: circuit.clone(),
devices: devices_for_circuit,
qoo_score,
rtt_excluded: rtt_exclusions::is_excluded_circuit_id(&circuit),
};
if let Ok(payload) = encode_ws_message(&result) {
if let Ok(payload) = encode_ws_message(&response) {
if tx.send(payload).await.is_err() {
info!("Channel is gone");
info!("CircuitWatcher channel is gone");
break;
}
} else {
@@ -75,3 +176,13 @@ pub(super) async fn circuit_watcher(
}
}
}
pub fn circuit_devices_result(circuit: String, devices: Vec<Circuit>) -> WsResponse {
WsResponse::CircuitDevicesResult {
data: CircuitDevicesResult {
circuit_id: circuit,
devices,
ok: true,
},
}
}
@@ -1,103 +1,17 @@
use crate::node_manager::ws::messages::{FlowbeeKeyTransit, WsResponse, encode_ws_message};
use crate::shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES};
use crate::throughput_tracker::flow_data::{
ALL_FLOWS, FlowAnalysis, FlowbeeLocalData, get_asn_name_and_country,
use crate::node_manager::local_api::circuit_activity::{
CircuitFlowSankeyRow, CircuitTopAsnsData, CircuitTopAsnsQuery, CircuitTrafficFlowsPage,
CircuitTrafficFlowsQuery, circuit_flow_sankey_rows, circuit_top_asns_data,
circuit_traffic_flows_page,
};
use lqos_utils::hash_to_i64;
use lqos_utils::unix_time::time_since_boot;
use std::time::Duration;
use tokio::time::MissedTickBehavior;
use tracing::debug;
const RECENT_CIRCUIT_FLOWS_WINDOW_NANOS: u64 = 30 * 1_000_000_000;
fn recent_flows_by_circuit(
circuit_id: &str,
) -> Vec<(FlowbeeKeyTransit, FlowbeeLocalData, FlowAnalysis)> {
let circuit_hash = hash_to_i64(circuit_id);
let shaped = SHAPED_DEVICES.load();
let cache = SHAPED_DEVICE_HASH_CACHE.load();
if let Ok(now) = time_since_boot() {
let now_as_nanos = Duration::from(now).as_nanos() as u64;
let recent_cutoff = now_as_nanos.saturating_sub(RECENT_CIRCUIT_FLOWS_WINDOW_NANOS);
{
let all_flows = ALL_FLOWS.lock();
let result: Vec<(FlowbeeKeyTransit, FlowbeeLocalData, FlowAnalysis)> = all_flows
.flow_data
.iter()
.filter_map(|(key, (local, analysis))| {
// Don't show older flows
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 (local_ip_str, remote_ip_str, asn_name, asn_country) = (
key.local_ip.to_string(),
key.remote_ip.to_string(),
geo.name,
geo.country,
);
Some((
FlowbeeKeyTransit {
remote_ip: remote_ip_str,
local_ip: local_ip_str,
src_port: key.src_port,
dst_port: key.dst_port,
ip_protocol: key.ip_protocol,
device_name,
asn_name,
asn_country,
protocol_name: analysis.protocol_analysis.to_string(),
last_seen_nanos: now_as_nanos.saturating_sub(local.last_seen),
},
local.clone(),
*analysis,
))
})
.collect();
return result;
}
}
Vec::new()
pub fn circuit_flow_sankey_result(circuit: &str) -> Vec<CircuitFlowSankeyRow> {
circuit_flow_sankey_rows(circuit)
}
pub(super) async fn flows_by_circuit(
circuit: String,
tx: tokio::sync::mpsc::Sender<std::sync::Arc<Vec<u8>>>,
) {
let mut ticker = tokio::time::interval(Duration::from_secs(1));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
let flows: Vec<(FlowbeeKeyTransit, FlowbeeLocalData, FlowAnalysis)> =
recent_flows_by_circuit(&circuit).into_iter().collect();
let result = WsResponse::FlowsByCircuit {
circuit_id: circuit.clone(),
flows,
};
if let Ok(payload) = encode_ws_message(&result) {
if tx.send(payload).await.is_err() {
debug!("Channel is gone");
break;
}
} else {
break;
}
ticker.tick().await;
}
pub fn circuit_top_asns_result(query: &CircuitTopAsnsQuery) -> CircuitTopAsnsData {
circuit_top_asns_data(query)
}
pub fn circuit_traffic_flows_result(query: &CircuitTrafficFlowsQuery) -> CircuitTrafficFlowsPage {
circuit_traffic_flows_page(query)
}
@@ -9,7 +9,7 @@ use crate::shaped_devices_tracker::NETWORK_JSON;
use lqos_bakery::{
BakeryActivityEntry as BakeryActivitySnapshot, BakeryApplyType, BakeryMode,
BakeryPreflightSnapshot, BakeryRuntimeNodeOperationAction, BakeryRuntimeNodeOperationStatus,
BakeryStatusSnapshot,
BakeryStatusSnapshot, bakery_runtime_node_branch_snapshot,
};
use lqos_utils::hash_to_i64;
use std::sync::Arc;
@@ -86,6 +86,10 @@ fn resolve_site_name(site_hash: i64) -> Option<String> {
.find_map(|node| (hash_to_i64(&node.name) == site_hash).then(|| node.name.clone()))
}
fn resolve_runtime_site_name(site_hash: i64) -> Option<String> {
bakery_runtime_node_branch_snapshot(site_hash).map(|snapshot| snapshot.site_name)
}
fn extract_site_hash_from_summary(summary: &str) -> Option<i64> {
let lower = summary.to_ascii_lowercase();
let site_index = lower.find("site ")?;
@@ -134,6 +138,7 @@ fn map_status(snapshot: BakeryStatusSnapshot) -> BakeryStatusData {
site_hash: entry.site_hash,
site_name: entry
.site_name
.or_else(|| resolve_runtime_site_name(entry.site_hash))
.or_else(|| resolve_site_name(entry.site_hash)),
action: runtime_action_to_string(entry.action),
status: runtime_status_to_string(entry.status),
@@ -180,6 +185,7 @@ fn map_activity(entry: BakeryActivitySnapshot) -> BakeryActivityEntry {
.or_else(|| extract_site_hash_from_summary(&entry.summary));
let site_name = entry
.site_name
.or_else(|| site_hash.and_then(resolve_runtime_site_name))
.or_else(|| site_hash.and_then(resolve_site_name));
BakeryActivityEntry {
ts: entry.ts,
@@ -77,6 +77,9 @@ pub struct FlowbeeLocalData {
pub packets_sent: DownUpOrder<u64>,
/// Rate estimate
pub rate_estimate_bps: DownUpOrder<u32>,
/// Optional UI-oriented display rate. This is populated by specific
/// websocket/API surfaces that need plan-aware presentation guards.
pub display_rate_bps: Option<DownUpOrder<u32>>,
/// TCP Retransmission count (also counts duplicates)
pub tcp_retransmits: DownUpOrder<u16>,
/// Has the connection ended?
@@ -102,12 +105,15 @@ impl Serialize for FlowbeeLocalData {
S: Serializer,
{
// Note: Keep this wire format stable (UI compatibility) while we refactor internal storage.
let mut state = serializer.serialize_struct("FlowbeeLocalData", 13)?;
let mut state = serializer.serialize_struct("FlowbeeLocalData", 14)?;
state.serialize_field("start_time", &self.start_time)?;
state.serialize_field("last_seen", &self.last_seen)?;
state.serialize_field("bytes_sent", &self.bytes_sent)?;
state.serialize_field("packets_sent", &self.packets_sent)?;
state.serialize_field("rate_estimate_bps", &self.rate_estimate_bps)?;
if let Some(display_rate_bps) = &self.display_rate_bps {
state.serialize_field("display_rate_bps", display_rate_bps)?;
}
state.serialize_field("tcp_retransmits", &self.tcp_retransmits)?;
state.serialize_field("end_status", &self.end_status)?;
state.serialize_field("tos", &self.tos)?;
@@ -133,6 +139,7 @@ impl FlowbeeLocalData {
bytes_sent: data.bytes_sent,
packets_sent: data.packets_sent,
rate_estimate_bps: data.rate_estimate_bps,
display_rate_bps: None,
tcp_retransmits: data.tcp_retransmits,
end_status: data.end_status,
tos: data.tos,
+68 -32
View File
@@ -1850,12 +1850,12 @@ fn run_tick(
cpu_max_pct,
dry_run: tg.dry_run,
circuit_id,
circuit_entity_id: &entry.circuit_entity_id,
circuit_label: &entry.circuit_label,
devices: &entry.devices,
sqm_batch_id: &sqm_batch_id,
allowlisted: tg.circuits.all_circuits
|| allowlisted_circuits.contains(circuit_id),
circuit_entity_id: &entry.circuit_entity_id,
circuit_label: &entry.circuit_label,
devices: &entry.devices,
sqm_batch_id: &sqm_batch_id,
allowlisted: tg.circuits.all_circuits
|| allowlisted_circuits.contains(circuit_id),
cap_down: entry.cap_down,
cap_up: entry.cap_up,
bps: live_rollup
@@ -3164,14 +3164,14 @@ where
activity,
now_unix,
dry_run,
persist_sqm_overrides: circuits_cfg.persist_sqm_overrides,
circuit_id,
circuit_entity_id,
circuit_label,
devices,
base_sqm,
batch_id: sqm_batch_id,
},
persist_sqm_overrides: circuits_cfg.persist_sqm_overrides,
circuit_id,
circuit_entity_id,
circuit_label,
devices,
base_sqm,
batch_id: sqm_batch_id,
},
state,
transition,
persist_override,
@@ -3279,6 +3279,7 @@ mod tests {
use lqos_utils::rtt::RttBuffer;
use lqos_utils::units::DownUpOrder;
use std::collections::VecDeque;
use std::ffi::OsString;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::Arc;
@@ -4096,6 +4097,57 @@ mod tests {
config_path
}
fn treeguard_test_lock() -> &'static std::sync::Mutex<()> {
static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
struct LiveBakeryTestContext {
_guard: std::sync::MutexGuard<'static, ()>,
old_lqos_config: Option<OsString>,
old_lqos_directory: Option<OsString>,
old_shaping_tree_active: bool,
}
impl LiveBakeryTestContext {
fn new(name: &str) -> Self {
let guard = treeguard_test_lock()
.lock()
.expect("treeguard test lock should not be poisoned");
let runtime_dir = test_runtime_dir(name);
let config_path = write_test_config(&runtime_dir);
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY");
unsafe {
std::env::set_var("LQOS_CONFIG", &config_path);
std::env::set_var("LQOS_DIRECTORY", &runtime_dir);
}
lqos_config::clear_cached_config();
let old_shaping_tree_active = lqos_bakery::set_shaping_tree_active_for_tests(true);
Self {
_guard: guard,
old_lqos_config,
old_lqos_directory,
old_shaping_tree_active,
}
}
}
impl Drop for LiveBakeryTestContext {
fn drop(&mut self) {
match &self.old_lqos_config {
Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) },
None => unsafe { std::env::remove_var("LQOS_CONFIG") },
}
match &self.old_lqos_directory {
Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) },
None => unsafe { std::env::remove_var("LQOS_DIRECTORY") },
}
lqos_config::clear_cached_config();
lqos_bakery::set_shaping_tree_active_for_tests(self.old_shaping_tree_active);
}
}
fn install_test_bakery_sender() -> crossbeam_channel::Receiver<BakeryCommands> {
static INIT: Once = Once::new();
static RECEIVER: OnceLock<crossbeam_channel::Receiver<BakeryCommands>> = OnceLock::new();
@@ -4112,15 +4164,7 @@ mod tests {
#[test]
fn run_tick_end_to_end_switches_circuit_and_emits_bakery_update() {
let runtime_dir = test_runtime_dir("run-tick");
let config_path = write_test_config(&runtime_dir);
let old_lqos_config = std::env::var_os("LQOS_CONFIG");
let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY");
unsafe {
std::env::set_var("LQOS_CONFIG", &config_path);
std::env::set_var("LQOS_DIRECTORY", &runtime_dir);
}
lqos_config::clear_cached_config();
let _live_bakery = LiveBakeryTestContext::new("run-tick");
let devices = vec![ShapedDevice {
circuit_id: "circuit-3".to_string(),
@@ -4257,19 +4301,11 @@ mod tests {
NETWORK_JSON.write().nodes = old_network;
QUEUE_STRUCTURE.store(old_queue_structure);
CIRCUIT_RTT_BUFFERS.store(old_rtt);
match old_lqos_config {
Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) },
None => unsafe { std::env::remove_var("LQOS_CONFIG") },
}
match old_lqos_directory {
Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) },
None => unsafe { std::env::remove_var("LQOS_DIRECTORY") },
}
lqos_config::clear_cached_config();
}
#[test]
fn apply_link_virtualization_decision_emits_requested_activity_when_bakery_accepts_submit() {
let _live_bakery = LiveBakeryTestContext::new("link-virtualize");
let rx = install_test_bakery_sender();
while rx.try_recv().is_ok() {}
+2
View File
@@ -185,6 +185,7 @@ pub struct DeviceInterface {
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug, Allocative)]
pub struct InterfaceIdentification {
pub r#type: Option<String>,
pub name: Option<String>,
pub mac: Option<String>,
}
@@ -200,6 +201,7 @@ pub struct DeviceAddress {
pub struct InterfaceStatus {
pub status: Option<String>,
pub speed: Option<String>,
pub currentSpeed: Option<String>,
}
#[allow(non_snake_case)]
@@ -0,0 +1,405 @@
use crate::errors::UispIntegrationError;
use crate::uisp_types::UispDevice;
use lqos_config::{CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadata, Config};
use std::path::Path;
use tracing::error;
const ETH_PORT_HEADROOM_FACTOR_SUB_GIG: f32 = 0.95;
/// Requested and applied circuit rates after negotiated-Ethernet evaluation.
#[derive(Clone, Debug, PartialEq)]
pub struct EthernetRateDecision {
/// Minimum download after any cap was applied.
pub download_min: f32,
/// Minimum upload after any cap was applied.
pub upload_min: f32,
/// Maximum download after any cap was applied.
pub download_max: f32,
/// Maximum upload after any cap was applied.
pub upload_max: f32,
/// Optional UI advisory for the affected circuit.
pub advisory: Option<CircuitEthernetMetadata>,
}
/// Applies a negotiated-Ethernet cap to a circuit and returns the adjusted rates plus advisory.
pub fn apply_ethernet_rate_cap<'a>(
circuit_id: &str,
circuit_name: &str,
devices: impl IntoIterator<Item = &'a UispDevice>,
download_min: f32,
upload_min: f32,
download_max: f32,
upload_max: f32,
) -> EthernetRateDecision {
let devices: Vec<&UispDevice> = devices.into_iter().collect();
let related_device_ids = devices.iter().map(|device| device.id.clone()).collect();
let candidate_devices: Vec<&UispDevice> = {
let station_devices: Vec<&UispDevice> = devices
.iter()
.copied()
.filter(|device| device.is_wireless_station_cpe())
.collect();
if !station_devices.is_empty() {
station_devices
} else {
devices
.iter()
.copied()
.filter(|device| !device.is_router_like())
.collect()
}
};
let mut limiting_device: Option<&UispDevice> = None;
for device in &candidate_devices {
let Some(speed_mbps) = device.negotiated_ethernet_mbps else {
continue;
};
if limiting_device
.as_ref()
.is_none_or(|current| speed_mbps < current.negotiated_ethernet_mbps.unwrap_or(u64::MAX))
{
limiting_device = Some(device);
}
}
let Some(limiting_device) = limiting_device else {
return EthernetRateDecision {
download_min,
upload_min,
download_max,
upload_max,
advisory: None,
};
};
let usable_cap = ethernet_usable_cap_mbps(
limiting_device
.negotiated_ethernet_mbps
.expect("limiting device must have ethernet speed"),
);
let applied_download_max = download_max.min(usable_cap);
let applied_upload_max = upload_max.min(usable_cap);
let applied_download_min = download_min.min(applied_download_max);
let applied_upload_min = upload_min.min(applied_upload_max);
let auto_capped = applied_download_max < download_max || applied_upload_max < upload_max;
EthernetRateDecision {
download_min: applied_download_min,
upload_min: applied_upload_min,
download_max: applied_download_max,
upload_max: applied_upload_max,
advisory: Some(CircuitEthernetMetadata {
circuit_id: circuit_id.to_string(),
circuit_name: circuit_name.to_string(),
device_ids: related_device_ids,
source: "uisp".to_string(),
negotiated_ethernet_mbps: limiting_device.negotiated_ethernet_mbps.unwrap_or_default(),
requested_download_mbps: download_max,
requested_upload_mbps: upload_max,
applied_download_mbps: applied_download_max,
applied_upload_mbps: applied_upload_max,
auto_capped,
limiting_device_id: Some(limiting_device.id.clone()),
limiting_device_name: Some(limiting_device.name.clone()),
limiting_interface_name: limiting_device.negotiated_ethernet_interface.clone(),
}),
}
}
/// Persists circuit Ethernet advisories alongside other generated runtime files.
pub fn write_ethernet_advisories(
config: &Config,
advisories: &[CircuitEthernetMetadata],
) -> Result<(), UispIntegrationError> {
let path = Path::new(&config.lqos_directory).join(CIRCUIT_ETHERNET_METADATA_FILENAME);
let payload = serde_json::to_vec_pretty(&lqos_config::CircuitEthernetMetadataFile {
circuits: advisories
.iter()
.filter(|advisory| advisory.auto_capped)
.cloned()
.collect(),
})
.map_err(|e| {
error!("Unable to serialize circuit Ethernet metadata: {e:?}");
UispIntegrationError::CsvError
})?;
std::fs::write(path, payload).map_err(|e| {
error!("Unable to write circuit Ethernet metadata: {e:?}");
UispIntegrationError::CsvError
})?;
Ok(())
}
fn ethernet_usable_cap_mbps(negotiated_mbps: u64) -> f32 {
match negotiated_mbps {
0..=999 => negotiated_mbps as f32 * ETH_PORT_HEADROOM_FACTOR_SUB_GIG,
1000..=2499 => negotiated_mbps.saturating_sub(5) as f32,
2500..=9999 => negotiated_mbps.saturating_sub(10) as f32,
_ => negotiated_mbps.saturating_sub(50) as f32,
}
}
#[cfg(test)]
mod tests {
use super::{apply_ethernet_rate_cap, write_ethernet_advisories};
use crate::uisp_types::UispDevice;
use lqos_config::{CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadataFile, Config};
use std::collections::HashSet;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn sample_device(
id: &str,
name: &str,
speed: Option<u64>,
iface: Option<&str>,
role: Option<&str>,
wireless_mode: Option<&str>,
) -> UispDevice {
UispDevice {
id: id.to_string(),
name: name.to_string(),
mac: String::new(),
role: role.map(str::to_string),
wireless_mode: wireless_mode.map(str::to_string),
site_id: "site-1".to_string(),
download: 1000,
upload: 1000,
ipv4: HashSet::new(),
ipv6: HashSet::new(),
negotiated_ethernet_mbps: speed,
negotiated_ethernet_interface: iface.map(str::to_string),
}
}
#[test]
fn ethernet_cap_uses_lowest_detected_port_speed() {
let fast = sample_device(
"dev-fast",
"Fast",
Some(1000),
Some("eth0"),
Some("station"),
Some("sta-ptmp"),
);
let slow = sample_device(
"dev-slow",
"Slow",
Some(100),
Some("eth1"),
Some("station"),
Some("sta-ptmp"),
);
let decision = apply_ethernet_rate_cap(
"circuit-1",
"Circuit 1",
[&fast, &slow],
25.0,
25.0,
300.0,
300.0,
);
assert_eq!(decision.download_max, 95.0);
assert_eq!(decision.upload_max, 95.0);
assert_eq!(decision.download_min, 25.0);
assert_eq!(decision.upload_min, 25.0);
let advisory = decision.advisory.expect("advisory should exist");
assert!(advisory.auto_capped);
assert_eq!(advisory.negotiated_ethernet_mbps, 100);
assert_eq!(advisory.limiting_device_id.as_deref(), Some("dev-slow"));
}
#[test]
fn ethernet_cap_preserves_lower_upload_plan() {
let slow = sample_device(
"dev-slow",
"Slow",
Some(100),
Some("eth1"),
Some("station"),
Some("sta-ptmp"),
);
let decision =
apply_ethernet_rate_cap("circuit-1", "Circuit 1", [&slow], 10.0, 10.0, 300.0, 50.0);
assert_eq!(decision.download_max, 95.0);
assert_eq!(decision.upload_max, 50.0);
let advisory = decision.advisory.expect("advisory should exist");
assert!(advisory.auto_capped);
assert_eq!(advisory.applied_download_mbps, 95.0);
assert_eq!(advisory.applied_upload_mbps, 50.0);
}
#[test]
fn gigabit_ports_use_small_fixed_margin() {
let gig = sample_device(
"dev-gig",
"Gig",
Some(1000),
Some("eth0"),
Some("station"),
Some("sta-ptmp"),
);
let decision =
apply_ethernet_rate_cap("circuit-1", "Circuit 1", [&gig], 100.0, 100.0, 995.0, 995.0);
assert_eq!(decision.download_max, 995.0);
assert_eq!(decision.upload_max, 995.0);
let advisory = decision.advisory.expect("advisory should exist");
assert!(!advisory.auto_capped);
assert_eq!(advisory.applied_download_mbps, 995.0);
assert_eq!(advisory.applied_upload_mbps, 995.0);
}
#[test]
fn gigabit_ports_cap_above_line_rate_to_995() {
let gig = sample_device(
"dev-gig",
"Gig",
Some(1000),
Some("eth0"),
Some("station"),
Some("sta-ptmp"),
);
let decision = apply_ethernet_rate_cap(
"circuit-1",
"Circuit 1",
[&gig],
100.0,
100.0,
5000.0,
5000.0,
);
assert_eq!(decision.download_max, 995.0);
assert_eq!(decision.upload_max, 995.0);
let advisory = decision.advisory.expect("advisory should exist");
assert!(advisory.auto_capped);
assert_eq!(advisory.applied_download_mbps, 995.0);
assert_eq!(advisory.applied_upload_mbps, 995.0);
}
#[test]
fn station_devices_win_over_router_devices() {
let station = sample_device(
"dev-station",
"Station",
Some(1000),
Some("data"),
Some("station"),
Some("sta-ptmp"),
);
let router = sample_device(
"dev-router",
"Router",
Some(10),
Some("eth3"),
Some("homeWiFi"),
Some("ap"),
);
let decision = apply_ethernet_rate_cap(
"circuit-1",
"Circuit 1",
[&station, &router],
10.0,
10.0,
115.0,
22.0,
);
assert_eq!(decision.download_max, 115.0);
assert_eq!(decision.upload_max, 22.0);
let advisory = decision.advisory.expect("advisory should exist");
assert!(!advisory.auto_capped);
assert_eq!(advisory.negotiated_ethernet_mbps, 1000);
assert_eq!(advisory.limiting_device_id.as_deref(), Some("dev-station"));
}
#[test]
fn router_only_devices_do_not_drive_ethernet_cap() {
let router = sample_device(
"dev-router",
"Router",
Some(100),
Some("eth0"),
Some("router"),
None,
);
let decision =
apply_ethernet_rate_cap("circuit-1", "Circuit 1", [&router], 10.0, 10.0, 115.0, 22.0);
assert_eq!(decision.download_max, 115.0);
assert_eq!(decision.upload_max, 22.0);
assert!(decision.advisory.is_none());
}
#[test]
fn writer_persists_only_auto_capped_advisories() {
let auto_capped = sample_device(
"dev-100m",
"Hundred",
Some(100),
Some("eth0"),
Some("station"),
Some("sta-ptmp"),
);
let observed_only = sample_device(
"dev-1g",
"Gig",
Some(1000),
Some("eth0"),
Some("station"),
Some("sta-ptmp"),
);
let capped = apply_ethernet_rate_cap(
"circuit-capped",
"Capped Circuit",
[&auto_capped],
10.0,
10.0,
115.0,
22.0,
)
.advisory
.expect("auto-capped advisory should exist");
let observed = apply_ethernet_rate_cap(
"circuit-observed",
"Observed Circuit",
[&observed_only],
100.0,
100.0,
995.0,
995.0,
)
.advisory
.expect("observed advisory should exist");
let mut config = Config::default();
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should move forward")
.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("lqos-ethernet-writer-{unique}"));
fs::create_dir_all(&temp_dir).expect("temp dir should be created");
config.lqos_directory = temp_dir.to_string_lossy().to_string();
write_ethernet_advisories(&config, &[capped.clone(), observed])
.expect("writer should succeed");
let payload = fs::read(temp_dir.join(CIRCUIT_ETHERNET_METADATA_FILENAME))
.expect("metadata file should exist");
let parsed: CircuitEthernetMetadataFile =
serde_json::from_slice(&payload).expect("metadata file should parse");
assert_eq!(parsed.circuits, vec![capped]);
let _ = fs::remove_dir_all(temp_dir);
}
}
+1
View File
@@ -5,6 +5,7 @@
mod blackboard;
#[warn(missing_docs)]
mod errors;
mod ethernet_advisory;
pub mod ip_ranges;
mod strategies;
pub mod uisp_types;
@@ -1,8 +1,9 @@
use crate::errors::UispIntegrationError;
use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories};
use crate::ip_ranges::IpRanges;
use crate::strategies::common::UispData;
use crate::strategies::full::shaped_devices_writer::ShapedDevice;
use lqos_config::Config;
use lqos_config::{CircuitEthernetMetadata, Config};
use std::collections::HashSet;
use std::fs::write;
use std::path::Path;
@@ -68,6 +69,7 @@ pub async fn build_ap_only_network(
// Write ShapedDevices.csv
let file_path = Path::new(&config.lqos_directory).join("ShapedDevices.csv");
let mut shaped_devices = Vec::new();
let mut ethernet_advisories: Vec<CircuitEthernetMetadata> = Vec::new();
let mut seen_pairs = HashSet::new();
for (parent, client_ids) in mappings.iter() {
for client_id in client_ids {
@@ -77,50 +79,53 @@ pub async fn build_ap_only_network(
.iter()
.filter(|d| d.site_id == *client_id)
.collect::<Vec<_>>();
let requested = if let Some((dl_min, dl_max, ul_min, ul_max)) =
site.burst_rates(&config)
{
(
f32::max(0.1, dl_min),
f32::max(0.1, dl_max),
f32::max(0.1, ul_min),
f32::max(0.1, ul_max),
)
} else if site.suspended && config.uisp_integration.suspended_strategy == "slow" {
(0.1, 0.1, 0.1, 0.1)
} else {
(
f32::max(
0.1,
site.max_down_mbps as f32
* config.uisp_integration.commit_bandwidth_multiplier,
),
f32::max(
0.1,
site.max_down_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor,
),
f32::max(
0.1,
site.max_up_mbps as f32
* config.uisp_integration.commit_bandwidth_multiplier,
),
f32::max(
0.1,
site.max_up_mbps as f32 * config.uisp_integration.bandwidth_overhead_factor,
),
)
};
let ethernet_decision = apply_ethernet_rate_cap(
&site.id,
&site.name,
devices.iter().copied(),
requested.0,
requested.2,
requested.1,
requested.3,
);
if let Some(advisory) = ethernet_decision.advisory.clone() {
ethernet_advisories.push(advisory);
}
for device in devices.iter().filter(|d| d.has_address()) {
// Compute subscriber rates: prefer UISP QoS + burst
let (download_min, mut download_max, upload_min, mut upload_max) =
if let Some((dl_min, dl_max, ul_min, ul_max)) = site.burst_rates(&config) {
(
f32::max(0.1, dl_min),
f32::max(0.1, dl_max),
f32::max(0.1, ul_min),
f32::max(0.1, ul_max),
)
} else if site.suspended && config.uisp_integration.suspended_strategy == "slow"
{
(0.1, 0.1, 0.1, 0.1)
} else {
(
f32::max(
0.1,
site.max_down_mbps as f32
* config.uisp_integration.commit_bandwidth_multiplier,
),
f32::max(
0.1,
site.max_down_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor,
),
f32::max(
0.1,
site.max_up_mbps as f32
* config.uisp_integration.commit_bandwidth_multiplier,
),
f32::max(
0.1,
site.max_up_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor,
),
)
};
if download_max < download_min {
download_max = download_min;
}
if upload_max < upload_min {
upload_max = upload_min;
}
let key = (site.id.clone(), device.id.clone());
if !seen_pairs.insert(key) {
continue;
@@ -135,10 +140,10 @@ pub async fn build_ap_only_network(
mac: device.mac.clone(),
ipv4: device.ipv4_list(),
ipv6: device.ipv6_list(),
download_min,
upload_min,
download_max,
upload_max,
download_min: ethernet_decision.download_min,
upload_min: ethernet_decision.upload_min,
download_max: ethernet_decision.download_max,
upload_max: ethernet_decision.upload_max,
comment: "".to_string(),
};
shaped_devices.push(sd);
@@ -158,6 +163,7 @@ pub async fn build_ap_only_network(
error!("{e:?}");
UispIntegrationError::CsvError
})?;
write_ethernet_advisories(&config, &ethernet_advisories)?;
info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len());
Ok(())
@@ -1,9 +1,10 @@
use crate::blackboard_blob;
use crate::errors::UispIntegrationError;
use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories};
use crate::ip_ranges::IpRanges;
use crate::strategies::common::UispData;
use crate::strategies::full::shaped_devices_writer::ShapedDevice;
use lqos_config::Config;
use lqos_config::{CircuitEthernetMetadata, Config};
use std::collections::{HashMap, HashSet};
use std::fs::write;
use std::path::Path;
@@ -52,7 +53,14 @@ pub async fn build_ap_site_network(
//println!("{:#?}", root);
let mut shaped_devices = Vec::new();
let net_json = root.walk_children(None, &uisp_data, &mut shaped_devices, &config);
let mut ethernet_advisories = Vec::new();
let net_json = root.walk_children(
None,
&uisp_data,
&mut shaped_devices,
&mut ethernet_advisories,
&config,
);
let network_path = Path::new(&config.lqos_directory).join("network.json");
if network_path.exists() && !config.integration_common.always_overwrite_network_json {
@@ -69,7 +77,7 @@ pub async fn build_ap_site_network(
info!("Written network.json");
}
let _ = write_shaped_devices(&config, &mut shaped_devices);
let _ = write_shaped_devices(&config, &mut shaped_devices, &ethernet_advisories);
info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len());
Ok(())
@@ -78,6 +86,7 @@ pub async fn build_ap_site_network(
pub(crate) fn write_shaped_devices(
config: &Arc<Config>,
shaped_devices: &mut Vec<ShapedDevice>,
ethernet_advisories: &[CircuitEthernetMetadata],
) -> Result<(), UispIntegrationError> {
let file_path = Path::new(&config.lqos_directory).join("ShapedDevices.csv");
let mut seen_pairs = HashSet::new();
@@ -95,6 +104,7 @@ pub(crate) fn write_shaped_devices(
error!("{e:?}");
UispIntegrationError::CsvError
})?;
write_ethernet_advisories(config, ethernet_advisories)?;
info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len());
Ok(())
}
@@ -166,6 +176,7 @@ impl Layer {
parent: Option<&str>,
uisp_data: &UispData,
shaped_devices: &mut Vec<ShapedDevice>,
ethernet_advisories: &mut Vec<CircuitEthernetMetadata>,
config: &Config,
) -> serde_json::Map<String, serde_json::Value> {
let mut children = serde_json::Map::new();
@@ -181,13 +192,24 @@ impl Layer {
children.insert(
name.clone(),
child
.walk_children(Some(&parent_name), uisp_data, shaped_devices, config)
.walk_children(
Some(&parent_name),
uisp_data,
shaped_devices,
ethernet_advisories,
config,
)
.into(),
);
}
GraphMapping::ClientById(_client_id) => {
let _ =
child.walk_children(Some(&parent_name), uisp_data, shaped_devices, config);
let _ = child.walk_children(
Some(&parent_name),
uisp_data,
shaped_devices,
ethernet_advisories,
config,
);
}
_ => {}
}
@@ -244,67 +266,61 @@ impl Layer {
.iter()
.filter(|d| d.site_id == *client_id)
.collect::<Vec<_>>();
let requested = if let Some(site) =
uisp_data.sites.iter().find(|s| s.id == *client_id)
{
if let Some((dl_min, dl_max, ul_min, ul_max)) = site.burst_rates(config)
{
(
f32::max(0.1, dl_min),
f32::max(0.1, dl_max),
f32::max(0.1, ul_min),
f32::max(0.1, ul_max),
)
} else if site.suspended
&& config.uisp_integration.suspended_strategy == "slow"
{
(0.1, 0.1, 0.1, 0.1)
} else {
(
f32::max(
0.1,
site.max_down_mbps as f32
* config.uisp_integration.commit_bandwidth_multiplier,
),
f32::max(
0.1,
site.max_down_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor,
),
f32::max(
0.1,
site.max_up_mbps as f32
* config.uisp_integration.commit_bandwidth_multiplier,
),
f32::max(
0.1,
site.max_up_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor,
),
)
}
} else {
(0.1, 0.1, 0.1, 0.1)
};
let ethernet_decision = apply_ethernet_rate_cap(
&site.id,
&site.name,
devices.iter().copied(),
requested.0,
requested.2,
requested.1,
requested.3,
);
if let Some(advisory) = ethernet_decision.advisory.clone() {
ethernet_advisories.push(advisory);
}
for device in devices.iter().filter(|d| d.has_address()) {
// Compute subscriber rates: prefer UISP QoS + burst
let (download_min, mut download_max, upload_min, mut upload_max) =
if let Some(site) =
uisp_data.sites.iter().find(|s| s.id == *client_id)
{
if let Some((dl_min, dl_max, ul_min, ul_max)) =
site.burst_rates(config)
{
(
f32::max(0.1, dl_min),
f32::max(0.1, dl_max),
f32::max(0.1, ul_min),
f32::max(0.1, ul_max),
)
} else if site.suspended
&& config.uisp_integration.suspended_strategy == "slow"
{
(0.1, 0.1, 0.1, 0.1)
} else {
(
f32::max(
0.1,
site.max_down_mbps as f32
* config
.uisp_integration
.commit_bandwidth_multiplier,
),
f32::max(
0.1,
site.max_down_mbps as f32
* config
.uisp_integration
.bandwidth_overhead_factor,
),
f32::max(
0.1,
site.max_up_mbps as f32
* config
.uisp_integration
.commit_bandwidth_multiplier,
),
f32::max(
0.1,
site.max_up_mbps as f32
* config
.uisp_integration
.bandwidth_overhead_factor,
),
)
}
} else {
(0.1, 0.1, 0.1, 0.1)
};
if download_max < download_min {
download_max = download_min;
}
if upload_max < upload_min {
upload_max = upload_min;
}
let sd = ShapedDevice {
circuit_id: site.id.clone(),
circuit_name: site.name.clone(),
@@ -314,10 +330,10 @@ impl Layer {
mac: device.mac.clone(),
ipv4: device.ipv4_list(),
ipv6: device.ipv6_list(),
download_min,
upload_min,
download_max,
upload_max,
download_min: ethernet_decision.download_min,
upload_min: ethernet_decision.upload_min,
download_max: ethernet_decision.download_max,
upload_max: ethernet_decision.upload_max,
comment: "".to_string(),
};
shaped_devices.push(sd);
@@ -19,7 +19,54 @@ pub(crate) struct UispData {
//pub data_links: Vec<UispDataLink>,
}
/// Ensure site names are unique by appending the site ID to duplicates.
fn short_address_segment(site: &Site) -> Option<String> {
let address = site.address()?;
let first_segment = address.split(',').next()?.trim();
if first_segment.is_empty() {
None
} else {
Some(first_segment.to_string())
}
}
fn service_name(site: &Site) -> Option<String> {
let name = site.ucrm.as_ref()?.service.as_ref()?.name.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn short_site_id(site: &Site) -> String {
site.id.chars().take(8).collect()
}
fn disambiguation_candidates(site: &Site, base_name: &str) -> Vec<String> {
let mut candidates = Vec::new();
if let Some(address_segment) = short_address_segment(site)
&& address_segment != base_name
{
candidates.push(format!("{base_name} ({address_segment})"));
}
if let Some(service_name) = service_name(site)
&& service_name != base_name
{
candidates.push(format!("{base_name} ({service_name})"));
}
let short_id = short_site_id(site);
if !short_id.is_empty() {
candidates.push(format!("{base_name} ({short_id})"));
}
candidates.push(format!("{base_name} ({})", site.id));
candidates
}
/// Ensure site names are unique by appending a human-friendly disambiguator to duplicates.
pub(crate) fn dedup_site_names(sites: &mut [Site]) {
let mut name_counts: HashMap<String, usize> = HashMap::new();
for site in sites.iter() {
@@ -30,6 +77,18 @@ pub(crate) fn dedup_site_names(sites: &mut [Site]) {
*name_counts.entry(base_name).or_insert(0) += 1;
}
let mut used_names: HashSet<String> = sites
.iter()
.filter_map(|site| {
let base_name = site.name_or_blank();
if base_name.is_empty() || name_counts.get(&base_name).copied().unwrap_or(0) > 1 {
None
} else {
Some(base_name)
}
})
.collect();
for site in sites.iter_mut() {
let base_name = site.name_or_blank();
if base_name.is_empty() {
@@ -37,7 +96,11 @@ pub(crate) fn dedup_site_names(sites: &mut [Site]) {
}
if name_counts.get(&base_name).copied().unwrap_or(0) > 1 {
let candidate = format!("{base_name} {}", site.id);
let candidate = disambiguation_candidates(site, &base_name)
.into_iter()
.find(|candidate| !used_names.contains(candidate))
.unwrap_or_else(|| format!("{base_name} ({})", site.id));
used_names.insert(candidate.clone());
if let Some(ident) = site.identification.as_mut() {
ident.name = Some(candidate.clone());
}
@@ -45,7 +108,7 @@ pub(crate) fn dedup_site_names(sites: &mut [Site]) {
site_id = %site.id,
original = %base_name,
renamed = %candidate,
"Duplicate UISP site name detected; renaming with site ID for LibreQoS uniqueness"
"Duplicate UISP site name detected; renaming with human-friendly disambiguator for LibreQoS uniqueness"
);
}
}
@@ -225,3 +288,94 @@ impl UispData {
.collect()
}
}
#[cfg(test)]
mod tests {
use super::dedup_site_names;
use serde_json::json;
use uisp::Site;
fn mk_site(id: &str, name: &str, address: Option<&str>, service_name: Option<&str>) -> Site {
let mut value = json!({
"id": id,
"identification": {
"id": id,
"name": name,
"type": "endpoint",
"suspended": false
}
});
if let Some(address) = address {
value["description"] = json!({ "address": address });
}
if let Some(service_name) = service_name {
value["ucrm"] = json!({
"service": {
"id": format!("svc-{id}"),
"name": service_name,
"status": 1,
"tariffId": "1",
"trafficShapingOverrideEnabled": false
}
});
}
serde_json::from_value(value).expect("site JSON must deserialize")
}
#[test]
fn duplicate_site_names_prefer_address_segment() {
let mut sites = vec![
mk_site(
"213f4b53-bddf-41dd-af65-2dbaa5bf0927",
"Pathway Communications",
Some("7100 Binational Way, Santa Teresa, 88008, New Mexico, United States"),
None,
),
mk_site(
"96bb13cd-10cc-43a1-ae56-a389161178d7",
"Pathway Communications",
Some("175 Lindburgh Dr, El Paso, 79932, Texas, United States"),
None,
),
];
dedup_site_names(&mut sites);
assert_eq!(
sites[0].name_or_blank(),
"Pathway Communications (7100 Binational Way)"
);
assert_eq!(
sites[1].name_or_blank(),
"Pathway Communications (175 Lindburgh Dr)"
);
}
#[test]
fn duplicate_site_names_fall_back_to_service_name() {
let mut sites = vec![
mk_site("site-1", "Acme", None, Some("1000/1000 Mbps - Primary")),
mk_site("site-2", "Acme", None, Some("1000/1000 Mbps - Backup")),
];
dedup_site_names(&mut sites);
assert_eq!(sites[0].name_or_blank(), "Acme (1000/1000 Mbps - Primary)");
assert_eq!(sites[1].name_or_blank(), "Acme (1000/1000 Mbps - Backup)");
}
#[test]
fn duplicate_site_names_fall_back_to_short_id() {
let mut sites = vec![
mk_site("abcd1234-0000-0000-0000-000000000000", "Acme", None, None),
mk_site("efgh5678-0000-0000-0000-000000000000", "Acme", None, None),
];
dedup_site_names(&mut sites);
assert_eq!(sites[0].name_or_blank(), "Acme (abcd1234)");
assert_eq!(sites[1].name_or_blank(), "Acme (efgh5678)");
}
}
@@ -1,9 +1,10 @@
use crate::blackboard_blob;
use crate::errors::UispIntegrationError;
use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories};
use crate::ip_ranges::IpRanges;
use crate::strategies::common::dedup_site_names;
use crate::uisp_types::UispDevice;
use lqos_config::Config;
use lqos_config::{CircuitEthernetMetadata, Config};
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
@@ -82,6 +83,7 @@ pub async fn build_flat_network(
// Simple Shaped Devices File
let mut shaped_devices = Vec::new();
let mut ethernet_advisories: Vec<CircuitEthernetMetadata> = Vec::new();
let mut seen_pairs = HashSet::new();
let ipv4_to_v6 = Vec::new();
for site in sites.iter() {
@@ -96,7 +98,7 @@ pub async fn build_flat_network(
.map(|id| id.suspended)
.unwrap_or(false)
&& config.uisp_integration.suspended_strategy == "slow";
let (download_min, upload_min, mut download_max, mut upload_max) = if suspended_slow {
let requested = if suspended_slow {
(0.1, 0.1, 0.1, 0.1)
} else if let Some(qos) = &site.qos {
let base_down = qos
@@ -172,16 +174,25 @@ pub async fn build_flat_network(
f32::max(0.1, ul_max_f),
)
};
// Ensure max >= min
if download_max < download_min {
download_max = download_min;
}
if upload_max < upload_min {
upload_max = upload_min;
let site_devices: Vec<UispDevice> = devices
.iter()
.map(|device| UispDevice::from_uisp(device, &config, &ip_ranges, &ipv4_to_v6))
.filter(|device| device.site_id == site.id && device.has_address())
.collect();
let ethernet_decision = apply_ethernet_rate_cap(
&site.id,
&site.name_or_blank(),
site_devices.iter(),
requested.0,
requested.1,
requested.2,
requested.3,
);
if let Some(advisory) = ethernet_decision.advisory.clone() {
ethernet_advisories.push(advisory);
}
for device in devices.iter() {
let dev = UispDevice::from_uisp(device, &config, &ip_ranges, &ipv4_to_v6);
if dev.site_id == site.id {
if let Some(dev) = site_devices.iter().find(|dev| dev.id == device.get_id()) {
// We're an endpoint in the right sight. We're getting there
let key = (site.id.clone(), device.get_id());
if !seen_pairs.insert(key) {
@@ -197,10 +208,10 @@ pub async fn build_flat_network(
mac: device.identification.mac.clone().unwrap_or("".to_string()),
ipv4: dev.ipv4_list(),
ipv6: dev.ipv6_list(),
download_min,
download_max,
upload_min,
upload_max,
download_min: ethernet_decision.download_min,
download_max: ethernet_decision.download_max,
upload_min: ethernet_decision.upload_min,
upload_max: ethernet_decision.upload_max,
comment: "".to_string(),
};
shaped_devices.push(sd);
@@ -224,6 +235,7 @@ pub async fn build_flat_network(
error!("{e:?}");
UispIntegrationError::CsvError
})?;
write_ethernet_advisories(&config, &ethernet_advisories)?;
info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len());
Ok(())
@@ -1,5 +1,7 @@
use crate::errors::UispIntegrationError;
use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories};
use crate::uisp_types::{UispDevice, UispSite, UispSiteType};
use lqos_config::CircuitEthernetMetadata;
use lqos_config::Config;
use serde::Serialize;
use std::path::Path;
@@ -24,6 +26,11 @@ pub struct ShapedDevice {
pub comment: String,
}
struct ShapedDeviceOutputs<'a> {
shaped_devices: &'a mut Vec<ShapedDevice>,
ethernet_advisories: &'a mut Vec<CircuitEthernetMetadata>,
}
/// Writes the ShapedDevices.csv file for UISP
///
/// # Arguments
@@ -39,17 +46,14 @@ pub fn write_shaped_devices(
) -> Result<(), UispIntegrationError> {
let file_path = Path::new(&config.lqos_directory).join("ShapedDevices.csv");
let mut shaped_devices = Vec::new();
let mut ethernet_advisories: Vec<CircuitEthernetMetadata> = Vec::new();
// Traverse
traverse(
sites,
root_idx,
0,
devices,
&mut shaped_devices,
config,
root_idx,
);
let mut outputs = ShapedDeviceOutputs {
shaped_devices: &mut shaped_devices,
ethernet_advisories: &mut ethernet_advisories,
};
traverse(sites, root_idx, 0, devices, &mut outputs, config, root_idx);
// Write the CSV
let mut writer = csv::WriterBuilder::new()
@@ -65,6 +69,7 @@ pub fn write_shaped_devices(
error!("{e:?}");
UispIntegrationError::CsvError
})?;
write_ethernet_advisories(config, &ethernet_advisories)?;
info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len());
Ok(())
@@ -75,52 +80,60 @@ fn traverse(
idx: usize,
depth: u32,
devices: &[UispDevice],
shaped_devices: &mut Vec<ShapedDevice>,
outputs: &mut ShapedDeviceOutputs<'_>,
config: &Config,
root_idx: usize,
) {
if !sites[idx].device_indices.is_empty() {
// We have devices!
if sites[idx].site_type == UispSiteType::Client {
let site_devices: Vec<&UispDevice> = sites[idx]
.device_indices
.iter()
.filter_map(|device_idx| devices.get(*device_idx))
.collect();
let requested =
if let Some((dl_min, dl_max, ul_min, ul_max)) = sites[idx].burst_rates(config) {
(
f32::max(0.1, dl_min),
f32::max(0.1, dl_max),
f32::max(0.1, ul_min),
f32::max(0.1, ul_max),
)
} else {
let download_max_f32 = sites[idx].max_down_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor;
let upload_max_f32 = sites[idx].max_up_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor;
let download_min_f32 =
download_max_f32 * config.uisp_integration.commit_bandwidth_multiplier;
let upload_min_f32 =
upload_max_f32 * config.uisp_integration.commit_bandwidth_multiplier;
(
f32::max(0.1, download_min_f32),
f32::max(0.1, download_max_f32),
f32::max(0.1, upload_min_f32),
f32::max(0.1, upload_max_f32),
)
};
let ethernet_decision = apply_ethernet_rate_cap(
&sites[idx].id,
&sites[idx].name,
site_devices.iter().copied(),
requested.0,
requested.2,
requested.1,
requested.3,
);
if let Some(advisory) = ethernet_decision.advisory.clone() {
outputs.ethernet_advisories.push(advisory);
}
// Add as normal clients
for device in sites[idx].device_indices.iter() {
let device = &devices[*device];
if device.has_address() {
// Prefer UISP QoS + burst if available; else fallback to capacity-based
let (download_min, mut download_max, upload_min, mut upload_max) =
if let Some((dl_min, dl_max, ul_min, ul_max)) =
sites[idx].burst_rates(config)
{
(
f32::max(0.1, dl_min),
f32::max(0.1, dl_max),
f32::max(0.1, ul_min),
f32::max(0.1, ul_max),
)
} else {
let download_max_f32 = sites[idx].max_down_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor;
let upload_max_f32 = sites[idx].max_up_mbps as f32
* config.uisp_integration.bandwidth_overhead_factor;
let download_min_f32 = download_max_f32
* config.uisp_integration.commit_bandwidth_multiplier;
let upload_min_f32 = upload_max_f32
* config.uisp_integration.commit_bandwidth_multiplier;
(
f32::max(0.1, download_min_f32),
f32::max(0.1, download_max_f32),
f32::max(0.1, upload_min_f32),
f32::max(0.1, upload_max_f32),
)
};
// Ensure max >= min per LibreQoS requirements
if download_max < download_min {
download_max = download_min;
}
if upload_max < upload_min {
upload_max = upload_min;
}
let sd = ShapedDevice {
circuit_id: sites[idx].id.clone(),
circuit_name: sites[idx].name.clone(),
@@ -130,13 +143,13 @@ fn traverse(
mac: device.mac.clone(),
ipv4: device.ipv4_list(),
ipv6: device.ipv6_list(),
download_min,
download_max,
upload_min,
upload_max,
download_min: ethernet_decision.download_min,
download_max: ethernet_decision.download_max,
upload_min: ethernet_decision.upload_min,
upload_max: ethernet_decision.upload_max,
comment: "".to_string(),
};
shaped_devices.push(sd);
outputs.shaped_devices.push(sd);
}
}
} else {
@@ -178,7 +191,7 @@ fn traverse(
upload_max,
comment: "Infrastructure Entry".to_string(),
};
shaped_devices.push(sd);
outputs.shaped_devices.push(sd);
}
}
}
@@ -194,7 +207,7 @@ fn traverse(
child_idx,
depth + 1,
devices,
shaped_devices,
outputs,
config,
root_idx,
);
@@ -6,6 +6,7 @@ mod net_json_parent;
use crate::blackboard_blob;
use crate::errors::UispIntegrationError;
use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories};
use crate::ip_ranges::IpRanges;
use crate::strategies::common::UispData;
use crate::strategies::full::routes_override::RouteOverride;
@@ -18,7 +19,7 @@ use crate::strategies::full2::graph_mapping::GraphMapping;
use crate::strategies::full2::link_mapping::LinkMapping;
use crate::strategies::full2::net_json_parent::{NetJsonParent, walk_parents};
use crate::uisp_types::UispDevice;
use lqos_config::Config;
use lqos_config::{CircuitEthernetMetadata, Config};
use petgraph::Directed;
use petgraph::graph::NodeIndex;
use petgraph::visit::{EdgeRef, NodeRef};
@@ -375,6 +376,7 @@ pub async fn build_full_network_v2(
let mut seen_pairs = HashSet::new();
let mut processed_site_pairs = 0usize;
let mut shaped_device_count = 0usize;
let mut ethernet_advisories: Vec<CircuitEthernetMetadata> = Vec::new();
for (ap_id, client_sites) in client_mappings.iter() {
for site_id in client_sites.iter() {
@@ -389,47 +391,55 @@ pub async fn build_full_network_v2(
"Processing site: {} (ID: {}) with AP: {} (ID: {})",
site.name, site.id, ap_device.name, ap_device.id
);
let site_devices: Vec<&UispDevice> = uisp_data
.devices
.iter()
.filter(|d| d.site_id == *site_id && d.has_address())
.collect();
let requested =
if let Some((dl_min, dl_max, ul_min, ul_max)) = site.burst_rates(&config) {
(
f32::max(0.1, dl_min),
f32::max(0.1, dl_max),
f32::max(0.1, ul_min),
f32::max(0.1, ul_max),
)
} else if site.suspended && config.uisp_integration.suspended_strategy == "slow" {
(0.1, 0.1, 0.1, 0.1)
} else {
let download_f32 = (site.max_down_mbps as f32)
* config.uisp_integration.bandwidth_overhead_factor;
let upload_f32 = (site.max_up_mbps as f32)
* config.uisp_integration.bandwidth_overhead_factor;
let download_min_f32 =
download_f32 * config.uisp_integration.commit_bandwidth_multiplier;
let upload_min_f32 =
upload_f32 * config.uisp_integration.commit_bandwidth_multiplier;
(
f32::max(0.1, download_min_f32),
f32::max(0.1, download_f32),
f32::max(0.1, upload_min_f32),
f32::max(0.1, upload_f32),
)
};
let ethernet_decision = apply_ethernet_rate_cap(
&site.id,
&site.name,
site_devices.iter().copied(),
requested.0,
requested.2,
requested.1,
requested.3,
);
if let Some(advisory) = ethernet_decision.advisory.clone() {
ethernet_advisories.push(advisory);
}
for device in uisp_data.devices.iter().filter(|d| d.site_id == *site_id) {
if !device.has_address() {
continue;
}
// Compute subscriber rates: prefer UISP QoS + burst; fallback to capacity-based
let (download_min, mut download_max, upload_min, mut upload_max) =
if let Some((dl_min, dl_max, ul_min, ul_max)) = site.burst_rates(&config) {
(
f32::max(0.1, dl_min),
f32::max(0.1, dl_max),
f32::max(0.1, ul_min),
f32::max(0.1, ul_max),
)
} else if site.suspended && config.uisp_integration.suspended_strategy == "slow"
{
(0.1, 0.1, 0.1, 0.1)
} else {
let download_f32 = (site.max_down_mbps as f32)
* config.uisp_integration.bandwidth_overhead_factor;
let upload_f32 = (site.max_up_mbps as f32)
* config.uisp_integration.bandwidth_overhead_factor;
let download_min_f32 =
download_f32 * config.uisp_integration.commit_bandwidth_multiplier;
let upload_min_f32 =
upload_f32 * config.uisp_integration.commit_bandwidth_multiplier;
(
f32::max(0.1, download_min_f32),
f32::max(0.1, download_f32),
f32::max(0.1, upload_min_f32),
f32::max(0.1, upload_f32),
)
};
// Ensure max >= min
if download_max < download_min {
download_max = download_min;
}
if upload_max < upload_min {
upload_max = upload_min;
}
let parent_node = {
if parents.contains_key(&ap_device.name) {
ap_device.name.clone()
@@ -456,10 +466,10 @@ pub async fn build_full_network_v2(
mac: device.mac.to_owned(),
ipv4: device.ipv4_list(),
ipv6: device.ipv6_list(),
download_min,
upload_min,
download_max,
upload_max,
download_min: ethernet_decision.download_min,
upload_min: ethernet_decision.upload_min,
download_max: ethernet_decision.download_max,
upload_max: ethernet_decision.upload_max,
comment: "".to_string(),
};
debug!(
@@ -489,6 +499,7 @@ pub async fn build_full_network_v2(
error!("{e:?}");
UispIntegrationError::CsvError
})?;
write_ethernet_advisories(&config, &ethernet_advisories)?;
info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len());
Ok(())
@@ -15,14 +15,137 @@ pub struct UispDevice {
pub id: String,
pub name: String,
pub mac: String,
pub role: Option<String>,
pub wireless_mode: Option<String>,
pub site_id: String,
pub download: u64,
pub upload: u64,
pub ipv4: HashSet<String>,
pub ipv6: HashSet<String>,
pub negotiated_ethernet_mbps: Option<u64>,
pub negotiated_ethernet_interface: Option<String>,
}
impl UispDevice {
fn parse_speed_mbps(raw: &str) -> Option<u64> {
let lower = raw.trim().to_lowercase();
if lower.is_empty() {
return None;
}
let digits: String = lower
.chars()
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
.collect();
if digits.is_empty() {
return None;
}
let value = digits.parse::<f64>().ok()?;
if !(value.is_finite() && value > 0.0) {
return None;
}
let multiplier = if lower.contains("gbps") || lower.ends_with('g') {
1000.0
} else {
1.0
};
Some((value * multiplier).round() as u64)
}
fn negotiated_ethernet_from_device(device: &Device) -> (Option<u64>, Option<String>) {
let mut best: Option<(u64, Option<String>)> = None;
let Some(interfaces) = &device.interfaces else {
return (None, None);
};
for interface in interfaces {
let interface_type = interface
.identification
.as_ref()
.and_then(|id| id.r#type.clone())
.unwrap_or_default()
.to_lowercase();
if matches!(interface_type.as_str(), "wlan" | "wifi" | "wireless") {
continue;
}
let Some(status) = &interface.status else {
continue;
};
let speed_raw = if let Some(current_speed) = status.currentSpeed.as_deref() {
Some(current_speed)
} else if Self::allows_plain_speed(status.status.as_deref()) {
status.speed.as_deref()
} else {
None
};
let Some(speed_raw) = speed_raw else {
continue;
};
let Some(speed_mbps) = Self::parse_speed_mbps(speed_raw) else {
continue;
};
let iface_name = interface
.identification
.as_ref()
.and_then(|id| id.name.clone());
if best
.as_ref()
.is_none_or(|(current_speed, _)| speed_mbps < *current_speed)
{
best = Some((speed_mbps, iface_name));
}
}
best.map_or((None, None), |(speed, iface)| (Some(speed), iface))
}
fn allows_plain_speed(status: Option<&str>) -> bool {
let Some(status) = status else {
return false;
};
matches!(
status.trim().to_ascii_lowercase().as_str(),
"connected" | "up" | "active"
)
}
pub(crate) fn is_wireless_station_cpe(&self) -> bool {
let role = self
.role
.as_deref()
.map(str::trim)
.map(str::to_ascii_lowercase);
if matches!(role.as_deref(), Some("station" | "sta" | "cpe")) {
return true;
}
let wireless_mode = self
.wireless_mode
.as_deref()
.map(str::trim)
.map(str::to_ascii_lowercase);
matches!(
wireless_mode.as_deref(),
Some(mode) if mode.starts_with("sta") || mode.starts_with("station")
)
}
pub(crate) fn is_router_like(&self) -> bool {
let role = self
.role
.as_deref()
.map(str::trim)
.map(str::to_ascii_lowercase);
matches!(
role.as_deref(),
Some("router" | "homewifi" | "home-wifi" | "home wifi")
)
}
/// Check if an IP/CIDR represents a network address (all host bits are zero)
/// Returns true if it's a network address, false if it's a host address
#[cfg_attr(test, allow(dead_code))]
@@ -209,16 +332,25 @@ impl UispDevice {
site_id = exception.parent.clone();
}
}
let (negotiated_ethernet_mbps, negotiated_ethernet_interface) =
Self::negotiated_ethernet_from_device(device);
Self {
id: device.get_id(),
name: device.get_name().unwrap_or_default(),
mac,
role: device.identification.role.clone(),
wireless_mode: device
.overview
.as_ref()
.and_then(|o| o.wirelessMode.clone()),
site_id,
upload,
download,
ipv4,
ipv6,
negotiated_ethernet_mbps,
negotiated_ethernet_interface,
}
}
@@ -269,6 +401,8 @@ impl UispDevice {
#[cfg(test)]
mod tests {
use super::UispDevice;
use serde_json::json;
use uisp::Device;
#[test]
fn test_is_network_address() {
@@ -302,4 +436,74 @@ mod tests {
assert!(!UispDevice::is_network_address("192.168.1.0/invalid")); // Invalid prefix
assert!(!UispDevice::is_network_address("192.168.1.0/33")); // Invalid IPv4 prefix length
}
#[test]
fn test_parse_speed_mbps() {
assert_eq!(UispDevice::parse_speed_mbps("100Mbps-Full"), Some(100));
assert_eq!(UispDevice::parse_speed_mbps("1000"), Some(1000));
assert_eq!(UispDevice::parse_speed_mbps("1Gbps"), Some(1000));
assert_eq!(UispDevice::parse_speed_mbps("2.5Gbps"), Some(2500));
assert_eq!(UispDevice::parse_speed_mbps(""), None);
assert_eq!(UispDevice::parse_speed_mbps("unknown"), None);
}
fn mk_device_with_interfaces(interfaces: serde_json::Value) -> Device {
serde_json::from_value(json!({
"identification": {
"id": "dev-1",
"hostname": "dev-1",
"role": null,
},
"overview": {
"wirelessMode": null
},
"interfaces": interfaces,
}))
.expect("device JSON must deserialize")
}
#[test]
fn disconnected_plain_speed_is_ignored_for_negotiated_ethernet() {
let device = mk_device_with_interfaces(json!([
{
"identification": { "name": "eth0@1", "type": "eth" },
"status": { "status": "disconnected", "speed": "10-half", "currentSpeed": null },
"wireless": {}
}
]));
let (speed, iface) = UispDevice::negotiated_ethernet_from_device(&device);
assert_eq!(speed, None);
assert_eq!(iface, None);
}
#[test]
fn disconnected_currentspeed_is_still_accepted() {
let device = mk_device_with_interfaces(json!([
{
"identification": { "name": "data", "type": "eth" },
"status": { "status": "disconnected", "speed": "auto", "currentSpeed": "100-full" },
"wireless": {}
}
]));
let (speed, iface) = UispDevice::negotiated_ethernet_from_device(&device);
assert_eq!(speed, Some(100));
assert_eq!(iface.as_deref(), Some("data"));
}
#[test]
fn connected_plain_speed_is_accepted() {
let device = mk_device_with_interfaces(json!([
{
"identification": { "name": "eth0", "type": "eth" },
"status": { "status": "connected", "speed": "1000-full", "currentSpeed": null },
"wireless": {}
}
]));
let (speed, iface) = UispDevice::negotiated_ethernet_from_device(&device);
assert_eq!(speed, Some(1000));
assert_eq!(iface.as_deref(), Some("eth0"));
}
}
+5 -1
View File
@@ -7,6 +7,7 @@ import chardet
from LibreQoS import refreshShapers, refreshShapersUpdateOnly
import subprocess
import sys
import tempfile
from io import StringIO
from liblqos_python import automatic_import_uisp, automatic_import_splynx, queue_refresh_interval_mins, \
automatic_import_powercode, automatic_import_sonar, influx_db_enabled, get_libreqos_directory, \
@@ -71,7 +72,10 @@ def _write_integration_output_artifact(label, output):
if not normalized:
return None
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
artifact = f"/tmp/lqos_scheduler_{_sanitize_label_for_filename(label)}_{timestamp}.log"
artifact = os.path.join(
tempfile.gettempdir(),
f"lqos_scheduler_{_sanitize_label_for_filename(label)}_{timestamp}.log",
)
try:
with open(artifact, "w", encoding="utf-8") as handle:
handle.write(normalized)
+12 -7
View File
@@ -18,7 +18,8 @@ def install_scheduler_stubs():
lqlib.automatic_import_powercode = lambda: False
lqlib.automatic_import_sonar = lambda: False
lqlib.influx_db_enabled = lambda: False
lqlib.get_libreqos_directory = lambda: "/tmp/libreqos"
# Test-only fake install root.
lqlib.get_libreqos_directory = lambda: "/tmp/libreqos" # nosec B108
lqlib.blackboard_finish = Mock()
lqlib.blackboard_submit = Mock()
lqlib.automatic_import_wispgate = lambda: False
@@ -142,7 +143,8 @@ class TestSchedulerAffinity(unittest.TestCase):
result = types.SimpleNamespace(returncode=0, stdout="", stderr="")
with patch.object(scheduler, "automatic_import_uisp", return_value=True):
with patch.object(scheduler, "get_libreqos_directory", return_value="/tmp/libreqos"):
# Test-only fake install root.
with patch.object(scheduler, "get_libreqos_directory", return_value="/tmp/libreqos"): # nosec B108
with patch.object(scheduler, "run_integration_subprocess", return_value=result) as mock_run:
with patch.object(scheduler, "apply_lqos_overrides"):
with patch.object(scheduler.os.path, "isfile", return_value=True):
@@ -151,8 +153,8 @@ class TestSchedulerAffinity(unittest.TestCase):
mock_run.assert_called_once()
mock_popen.assert_called_once_with(
"/tmp/libreqos/bin/post_integration_hook.sh",
cwd="/tmp/libreqos/bin",
"/tmp/libreqos/bin/post_integration_hook.sh", # nosec B108
cwd="/tmp/libreqos/bin", # nosec B108
)
@@ -195,7 +197,8 @@ class TestSchedulerErrorReporting(unittest.TestCase):
result = types.SimpleNamespace(returncode=0, stdout="uisp info\n", stderr="")
with patch.object(scheduler, "automatic_import_uisp", return_value=True):
with patch.object(scheduler, "get_libreqos_directory", return_value="/tmp/libreqos"):
# Test-only fake install root.
with patch.object(scheduler, "get_libreqos_directory", return_value="/tmp/libreqos"): # nosec B108
with patch.object(scheduler, "run_integration_subprocess", return_value=result):
with patch.object(scheduler, "apply_lqos_overrides"):
with patch.object(scheduler.os.path, "isfile", return_value=False):
@@ -214,7 +217,8 @@ class TestSchedulerErrorReporting(unittest.TestCase):
result = types.SimpleNamespace(returncode=1, stdout="uisp info\n", stderr="")
with patch.object(scheduler, "automatic_import_uisp", return_value=True):
with patch.object(scheduler, "get_libreqos_directory", return_value="/tmp/libreqos"):
# Test-only fake install root.
with patch.object(scheduler, "get_libreqos_directory", return_value="/tmp/libreqos"): # nosec B108
with patch.object(scheduler, "run_integration_subprocess", return_value=result):
with patch.object(scheduler, "apply_lqos_overrides"):
with patch.object(scheduler.os.path, "isfile", return_value=False):
@@ -267,7 +271,8 @@ class TestSchedulerOverrideMerge(unittest.TestCase):
"1", "1", "330", "330", "", ""
]]
with patch.object(scheduler, "shaped_devices_csv_path", return_value="/tmp/ShapedDevices.csv"):
# Test-only fake csv path.
with patch.object(scheduler, "shaped_devices_csv_path", return_value="/tmp/ShapedDevices.csv"): # nosec B108
with patch.object(scheduler, "read_shaped_devices_csv", return_value=(header, rows)):
with patch.object(scheduler, "overrides_persistent_devices_materialized", return_value=[]):
with patch.object(