mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 13:57:40 -05:00
Merge pull request #1043 from LibreQoE/bridge_mgmt
Add managed bridge/interface workflows and license capability gating
This commit is contained in:
79 files changed
+6143
-887
No files matched your search
@@ -48,11 +48,11 @@ Assuming your interfaces are ens19 and ens20, here is what your file would look
|
||||
network:
|
||||
ethernets:
|
||||
ens19:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
ens20:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
bridges:
|
||||
br0:
|
||||
interfaces:
|
||||
@@ -64,7 +64,7 @@ network:
|
||||
Please be sure to replace ens19 and ens20 in the example above with the correct shaping interfaces. The order of the interfaces does not matter for this section.
|
||||
```
|
||||
|
||||
By setting `dhcp4: no` and `dhcp6: no`, the interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned.
|
||||
By setting `dhcp4: false` and `dhcp6: false`, the interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned.
|
||||
|
||||
Then run
|
||||
|
||||
@@ -88,18 +88,18 @@ Assuming your interfaces are ens19 and ens20, here is what your file would look
|
||||
network:
|
||||
ethernets:
|
||||
ens19:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
ens20:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
version: 2
|
||||
```
|
||||
```{note}
|
||||
Please be sure to replace ens19 and ens20 in the example above with the correct shaping interfaces. The order of the interfaces does not matter for this section.
|
||||
```
|
||||
|
||||
By setting `dhcp4: no` and `dhcp6: no`, the interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned.
|
||||
By setting `dhcp4: false` and `dhcp6: false`, the interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned.
|
||||
|
||||
Then run
|
||||
|
||||
|
||||
+19
-11
@@ -11,6 +11,14 @@ El puente regular de Linux es recomendado para la mayoría de las instalaciones.
|
||||
|
||||
A continuación, se encuentran las instrucciones para configurar Netplan, ya sea usando el puente de Linux o el puente Bifrost con XDP:
|
||||
|
||||
```{note}
|
||||
La página Network Mode en la interfaz web de LibreQoS ahora inspecciona los archivos actuales de Netplan, ofrece en menús desplegables las interfaces elegibles que no forman parte de la ruta de gestión, prepara cambios administrados para `libreqos.yaml` en los modos puente de Linux y de interfaz única, los aplica con una ventana temporizada de reversión manejada por LibreQoS y le permite confirmar o revertir el cambio pendiente. También puede restaurar desde esa página la copia de seguridad administrada anterior. El modo puente XDP sigue siendo un flujo manual de Netplan.
|
||||
```
|
||||
|
||||
```{note}
|
||||
Si un cambio temporizado de Netplan interrumpe brevemente la sesión del navegador, vuelva a la página Network Mode cuando regrese la conectividad. LibreQoS retomará automáticamente desde esa página el flujo pendiente de confirmar o revertir.
|
||||
```
|
||||
|
||||
## Opción A: Configuración de Netplan para un puente regular de Linux (Recomendado)
|
||||
|
||||
Ubuntu Server utiliza Netplan, el cual se basa en archivos .yaml ubicados en /etc/netplan para determinar la configuración de interfaces.
|
||||
@@ -26,11 +34,11 @@ Asumiendo que sus interfaces de regulación son ens19 y ens20, su archivo se ver
|
||||
network:
|
||||
ethernets:
|
||||
ens19:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
ens20:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
bridges:
|
||||
br0:
|
||||
interfaces:
|
||||
@@ -42,7 +50,7 @@ network:
|
||||
Asegurese de reemplazar `ens19` y `ens20` en el ejemplo anterior con las interfaces reales que utilizará para regular el tráfico. El orden de las interfaces no importa en esta sección.
|
||||
```
|
||||
|
||||
Al establecer `dhcp4: no` y `dhcp6: no`, las interfaces de regulación se activarán como parte del ciclo normal de arranque, aunque no tengan direcciones IP asignadas.
|
||||
Al establecer `dhcp4: false` y `dhcp6: false`, las interfaces de regulación se activarán como parte del ciclo normal de arranque, aunque no tengan direcciones IP asignadas.
|
||||
|
||||
Después ejecute:
|
||||
|
||||
@@ -66,18 +74,18 @@ Asumiendo que sus interfaces de regulación son ens19 y ens20, su archivo se ver
|
||||
network:
|
||||
ethernets:
|
||||
ens19:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
ens20:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
version: 2
|
||||
```
|
||||
```{note}
|
||||
Asegurese de reemplazar `ens19` y `ens20` en el ejemplo anterior con las interfaces reales que utilizará para regular el tráfico. El orden de las interfaces no importa en esta sección.
|
||||
```
|
||||
|
||||
Al establecer `dhcp4: no` y `dhcp6: no`, las interfaces de regulación se activarán como parte del ciclo normal de arranque, aunque no tengan direcciones IP asignadas.
|
||||
Al establecer `dhcp4: false` y `dhcp6: false`, las interfaces de regulación se activarán como parte del ciclo normal de arranque, aunque no tengan direcciones IP asignadas.
|
||||
|
||||
Después ejecute:
|
||||
|
||||
@@ -86,4 +94,4 @@ sudo chmod 600 /etc/netplan/libreqos.yaml
|
||||
sudo netplan apply
|
||||
```
|
||||
|
||||
Para usar el puente XDP, asegurese de establecer `use_xdp_bridge` como `true` en el archivo lqos.conf dentro de la sección [Configuración](configuration-es.md).
|
||||
Para usar el puente XDP, asegurese de establecer `use_xdp_bridge` como `true` en el archivo lqos.conf dentro de la sección [Configuración](configuration-es.md).
|
||||
@@ -124,13 +124,13 @@ Notas operativas:
|
||||
|
||||
### Límites de circuitos mapeados y estado de licencia
|
||||
|
||||
`ShapedDevices.csv` puede contener entradas ilimitadas. En compilaciones v2.0 actuales, la admisión al estado de shaping activo depende del estado válido de la licencia Insight.
|
||||
`ShapedDevices.csv` puede contener entradas ilimitadas. En compilaciones v2.0 actuales, la admisión al estado de shaping activo depende de un estado válido de licencia/grant con derecho.
|
||||
|
||||
Sin una suscripción/licencia Insight válida, LibreQoS admite solo los primeros 1000 circuitos mapeados válidos al estado de shaping activo. Los circuitos mapeados válidos adicionales permanecen fuera del shaping activo hasta que se restaure una licencia Insight válida.
|
||||
Sin un estado válido de licencia/grant de Insight o Local, LibreQoS admite solo los primeros 1000 circuitos mapeados válidos al estado de shaping activo. Los circuitos mapeados válidos adicionales permanecen fuera del shaping activo hasta que se restaure una licencia válida con derecho.
|
||||
|
||||
Una suscripción/licencia Insight válida habilita conteos de circuitos mapeados por encima del límite predeterminado de 1000.
|
||||
Un estado válido de licencia/grant con derecho habilita conteos de circuitos mapeados por encima del límite predeterminado de 1000.
|
||||
|
||||
El comportamiento predeterminado del límite de 1000 aplica cuando Insight está:
|
||||
El comportamiento predeterminado del límite de 1000 aplica cuando el estado de licencia/grant está:
|
||||
- ausente
|
||||
- expirado
|
||||
- inválido por cualquier motivo
|
||||
@@ -145,6 +145,6 @@ Cuando se alcanza el límite, los operadores normalmente verán:
|
||||
|
||||
Cuando ocurra:
|
||||
|
||||
1. Revise estado de licencia Insight en la UI.
|
||||
1. Revise el estado de licencia en la página `License & Services`.
|
||||
2. Revise `journalctl -u lqosd` para conteos requested/allowed/dropped.
|
||||
3. Verifique si el nodo está operando con el límite predeterminado de 1000 circuitos mapeados porque el estado actual de Insight/licencia es inválido.
|
||||
3. Verifique si el nodo está operando con el límite predeterminado de 1000 circuitos mapeados porque el estado actual de licencia/grant es inválido.
|
||||
@@ -124,11 +124,11 @@ network:
|
||||
nameservers:
|
||||
addresses: [1.1.1.1, 8.8.8.8]
|
||||
ens19:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
ens20:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
```
|
||||
|
||||
Luego configure el comportamiento bridge segun [Configure Shaping Bridge](bridge-es.md).
|
||||
|
||||
@@ -197,9 +197,9 @@ Si ve mensajes como:
|
||||
|
||||
LibreQoS está aplicando un límite de circuitos mapeados.
|
||||
|
||||
`ShapedDevices.csv` puede contener entradas ilimitadas, pero sin una suscripción/licencia Insight válida LibreQoS admite solo los primeros 1000 circuitos mapeados válidos al estado de shaping activo.
|
||||
`ShapedDevices.csv` puede contener entradas ilimitadas, pero sin un estado válido de licencia/grant de Insight o Local LibreQoS admite solo los primeros 1000 circuitos mapeados válidos al estado de shaping activo.
|
||||
|
||||
El límite predeterminado de 1000 circuitos mapeados aplica cuando Insight está:
|
||||
El límite predeterminado de 1000 circuitos mapeados aplica cuando el estado de licencia/grant está:
|
||||
- ausente
|
||||
- expirado
|
||||
- inválido por cualquier motivo
|
||||
@@ -213,7 +213,7 @@ Síntomas típicos visibles para el operador:
|
||||
|
||||
Checks recomendados:
|
||||
|
||||
1. Confirmar estado de licencia Insight en UI.
|
||||
1. Confirmar el estado de licencia en la página `License & Services`.
|
||||
2. Revisar logs de `lqosd` para requested/allowed/dropped.
|
||||
3. Reducir circuitos mapeados (corto plazo) o ajustar licencia/límites (largo plazo).
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Si usas el puente XDP, el tráfico dejará de pasar por el puente mientras lqosd
|
||||
## Si instalaste el .deb
|
||||
|
||||
```{important}
|
||||
Desde v2.0, la ingesta de circuitos mapeados depende del estado válido de la licencia Insight. Sin una suscripción/licencia Insight válida, LibreQoS solo lee los primeros 1000 circuitos mapeados válidos al estado de shaping activo. Esto incluye estado local de grant/licencia expirado o inválido por cualquier motivo. Vea [Comportamiento de licenciamiento de Insight](insight-es.md#límites-de-circuitos-mapeados-y-estado-de-licencia) y [Solución de problemas](troubleshooting-es.md).
|
||||
Desde v2.0, la ingesta de circuitos mapeados depende de un estado válido de licencia/grant con derecho. Sin un estado válido de licencia/grant de Insight o Local, LibreQoS solo lee los primeros 1000 circuitos mapeados válidos al estado de shaping activo. Esto incluye estado local de grant/licencia expirado o inválido por cualquier motivo. Vea [Comportamiento de licenciamiento de Insight](insight-es.md#límites-de-circuitos-mapeados-y-estado-de-licencia) y [Solución de problemas](troubleshooting-es.md).
|
||||
```
|
||||
|
||||
Ejecuta:
|
||||
|
||||
+18
-10
@@ -11,6 +11,14 @@ The regular Linux bridge is recommended for most installations. The Linux Bridge
|
||||
|
||||
Below are the instructions to configure Netplan, whether using the Linux Bridge or Bifrost XDP bridge:
|
||||
|
||||
```{note}
|
||||
The Network Mode page in the LibreQoS web UI now inspects the current Netplan files, offers eligible non-management interfaces in dropdowns, stages managed `libreqos.yaml` changes for Linux bridge and single-interface modes, applies them with a timed LibreQoS rollback window, and lets you confirm or revert the pending change. You can also restore the previous managed backup from that page. XDP bridge mode remains a manual Netplan workflow.
|
||||
```
|
||||
|
||||
```{note}
|
||||
If a timed Netplan change briefly interrupts your browser session, return to the Network Mode page after connectivity comes back. LibreQoS will resume the pending confirm or revert flow from that page automatically.
|
||||
```
|
||||
|
||||
## Option A: Netplan config for a regular Linux bridge (Recommended)
|
||||
|
||||
Ubuntu Server uses NetPlan, which uses .yaml files in /etc/netplan to determine interface settings.
|
||||
@@ -26,11 +34,11 @@ Assuming your shaping interfaces are ens19 and ens20, here is what your file wou
|
||||
network:
|
||||
ethernets:
|
||||
ens19:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
ens20:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
bridges:
|
||||
br0:
|
||||
interfaces:
|
||||
@@ -42,7 +50,7 @@ network:
|
||||
Please be sure to replace ens19 and ens20 in the example above with the correct shaping interfaces. The order of the interfaces does not matter for this section.
|
||||
```
|
||||
|
||||
By setting `dhcp4: no` and `dhcp6: no`, the shaping interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned.
|
||||
By setting `dhcp4: false` and `dhcp6: false`, the shaping interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned.
|
||||
|
||||
Then run
|
||||
|
||||
@@ -66,18 +74,18 @@ Assuming your shaping interfaces are ens19 and ens20, here is what your file wou
|
||||
network:
|
||||
ethernets:
|
||||
ens19:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
ens20:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
version: 2
|
||||
```
|
||||
```{note}
|
||||
Please be sure to replace ens19 and ens20 in the example above with the correct shaping interfaces. The order of the interfaces does not matter for this section.
|
||||
```
|
||||
|
||||
By setting `dhcp4: no` and `dhcp6: no`, the shaping interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned.
|
||||
By setting `dhcp4: false` and `dhcp6: false`, the shaping interfaces will be brought up as part of the normal boot cycle, despite not having IP addresses assigned.
|
||||
|
||||
Then run
|
||||
|
||||
|
||||
@@ -119,13 +119,13 @@ Operational notes:
|
||||
|
||||
### Mapped circuit limits and license state
|
||||
|
||||
`ShapedDevices.csv` can contain unlimited entries. In current v2.0 builds, admission into active shaping state depends on valid Insight license state.
|
||||
`ShapedDevices.csv` can contain unlimited entries. In current v2.0 builds, admission into active shaping state depends on valid entitled license/grant state.
|
||||
|
||||
Without a valid Insight subscription/license, LibreQoS admits only the first 1000 valid mapped circuits into active shaping state. Additional valid mapped circuits remain outside active shaping until valid Insight licensing is restored.
|
||||
Without a valid Insight or Local license/grant state, LibreQoS admits only the first 1000 valid mapped circuits into active shaping state. Additional valid mapped circuits remain outside active shaping until valid entitled licensing is restored.
|
||||
|
||||
A valid Insight subscription/license enables mapped-circuit counts above the default 1000 limit.
|
||||
A valid entitled license/grant state enables mapped-circuit counts above the default 1000 limit.
|
||||
|
||||
The default 1000-limit behavior applies when Insight is:
|
||||
The default 1000-limit behavior applies when license/grant state is:
|
||||
- missing
|
||||
- expired
|
||||
- otherwise invalid
|
||||
@@ -139,6 +139,6 @@ When the limit is reached, operators will typically see:
|
||||
- `Bakery mapped circuit cap enforced`
|
||||
|
||||
When this occurs, check:
|
||||
1. Insight license status in the UI.
|
||||
1. License status in the `License & Services` page.
|
||||
2. `journalctl -u lqosd` for requested/allowed/dropped mapped counts.
|
||||
3. Whether your node is operating at the default 1000 mapped-circuit limit because the current Insight/license state is invalid.
|
||||
3. Whether your node is operating at the default 1000 mapped-circuit limit because the current license/grant state is invalid.
|
||||
@@ -138,11 +138,11 @@ network:
|
||||
nameservers:
|
||||
addresses: [1.1.1.1, 8.8.8.8]
|
||||
ens19:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
ens20:
|
||||
dhcp4: no
|
||||
dhcp6: no
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
```
|
||||
|
||||
Then configure bridge behavior per [Configure Shaping Bridge](bridge.md).
|
||||
|
||||
@@ -276,9 +276,9 @@ If logs mention messages like:
|
||||
- `Mapped circuit limit reached`
|
||||
- `Bakery mapped circuit cap enforced`
|
||||
|
||||
`ShapedDevices.csv` can contain unlimited entries, but without a valid Insight subscription/license LibreQoS admits only the first 1000 valid mapped circuits into active shaping state.
|
||||
`ShapedDevices.csv` can contain unlimited entries, but without a valid Insight or Local license/grant state LibreQoS admits only the first 1000 valid mapped circuits into active shaping state.
|
||||
|
||||
The default 1000 mapped-circuit limit applies when Insight is:
|
||||
The default 1000 mapped-circuit limit applies when license/grant state is:
|
||||
- missing
|
||||
- expired
|
||||
- otherwise invalid
|
||||
@@ -291,7 +291,7 @@ Typical operator-visible symptoms:
|
||||
- partial shaping, with circuits beyond the active limit left outside shaping state
|
||||
|
||||
Recommended checks:
|
||||
1. Confirm Insight/license status in the UI.
|
||||
1. Confirm license status in the `License & Services` page.
|
||||
2. Review `lqosd` logs for requested/allowed/dropped counts.
|
||||
3. Reduce mapped circuit count (short term) or update licensing/limits (long term).
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ If you use the XDP bridge, traffic will briefly stop passing through the bridge
|
||||
## If you installed the .deb
|
||||
|
||||
```{important}
|
||||
Starting in v2.0, mapped-circuit ingest depends on valid Insight license state. Without a valid Insight subscription/license, LibreQoS reads only the first 1000 valid mapped circuits into active shaping state. This includes expired or otherwise invalid local grant/license state. See [Insight Licensing Behavior](insight.md#mapped-circuit-limits-and-license-state) and [Troubleshooting](troubleshooting.md).
|
||||
Starting in v2.0, mapped-circuit ingest depends on valid entitled license/grant state. Without a valid Insight or Local license/grant state, LibreQoS reads only the first 1000 valid mapped circuits into active shaping state. This includes expired or otherwise invalid local grant/license state. See [Insight Licensing Behavior](insight.md#mapped-circuit-limits-and-license-state) and [Troubleshooting](troubleshooting.md).
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
+8
-3
@@ -38,7 +38,6 @@ LQOS_FILES=(
|
||||
network.example.json
|
||||
pythonCheck.py
|
||||
qoo_profiles.json
|
||||
README.md
|
||||
scheduler.py
|
||||
ShapedDevices.example.csv
|
||||
shaping_skip_report.py
|
||||
@@ -60,6 +59,7 @@ LQOS_BIN_FILES=(
|
||||
|
||||
RUSTPROGS=(
|
||||
lqosd
|
||||
lqos_netplan_helper
|
||||
lqtop
|
||||
xdp_iphash_to_cpu_cmdline
|
||||
xdp_pping
|
||||
@@ -107,6 +107,7 @@ pushd rust > /dev/null || exit
|
||||
# Build only required binaries and artifacts (exclude lqos_support_tool executable)
|
||||
cargo build --release \
|
||||
-p lqosd \
|
||||
-p lqos_netplan_helper \
|
||||
-p lqtop \
|
||||
-p xdp_iphash_to_cpu_cmdline \
|
||||
-p xdp_pping \
|
||||
@@ -160,9 +161,12 @@ sudo chown -R root:root /opt/libreqos
|
||||
install -m 0644 /opt/libreqos/src/bin/lqosd.service.example /etc/systemd/system/lqosd.service
|
||||
install -m 0644 /opt/libreqos/src/bin/lqos_scheduler.service.example /etc/systemd/system/lqos_scheduler.service
|
||||
install -m 0644 /opt/libreqos/src/bin/lqos_api.service.example /etc/systemd/system/lqos_api.service
|
||||
/bin/rm -f /etc/systemd/system/lqos_netplan_helper.service || true
|
||||
/bin/systemctl daemon-reload || true
|
||||
/bin/systemctl stop lqos_node_manager || true # In case it's running from a previous release
|
||||
/bin/systemctl disable lqos_node_manager || true # In case it's running from a previous release
|
||||
/bin/systemctl stop lqos_netplan_helper || true
|
||||
/bin/systemctl disable lqos_netplan_helper || true
|
||||
/bin/systemctl enable lqosd lqos_scheduler lqos_api || true
|
||||
/bin/systemctl restart lqosd lqos_scheduler lqos_api || true
|
||||
popd > /dev/null
|
||||
@@ -172,8 +176,9 @@ EOF
|
||||
cat <<EOF > postrm
|
||||
#!/bin/bash
|
||||
set +e
|
||||
/bin/systemctl stop lqosd lqos_scheduler lqos_api || true
|
||||
/bin/systemctl disable lqosd lqos_scheduler lqos_api || true
|
||||
/bin/systemctl stop lqos_netplan_helper lqosd lqos_scheduler lqos_api || true
|
||||
/bin/systemctl disable lqos_netplan_helper lqosd lqos_scheduler lqos_api || true
|
||||
/bin/rm -f /etc/systemd/system/lqos_netplan_helper.service || true
|
||||
/bin/systemctl daemon-reload || true
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
@@ -61,6 +61,7 @@ rustup update
|
||||
echo "Please wait while the system is compiled. Service will not be interrupted during this stage."
|
||||
PROGS=(
|
||||
lqosd
|
||||
lqos_netplan_helper
|
||||
lqtop
|
||||
xdp_iphash_to_cpu_cmdline
|
||||
xdp_pping
|
||||
@@ -73,6 +74,7 @@ PROGS=(
|
||||
)
|
||||
BUILD_PACKAGES=(
|
||||
lqosd
|
||||
lqos_netplan_helper
|
||||
lqtop
|
||||
xdp_iphash_to_cpu_cmdline
|
||||
xdp_pping
|
||||
@@ -193,6 +195,15 @@ if service_exists lqosd; then
|
||||
echo "lqosd is running as a service. Restarting it. You may need to enter your sudo password."
|
||||
sudo systemctl restart lqosd
|
||||
fi
|
||||
if service_exists lqos_netplan_helper; then
|
||||
echo "lqos_netplan_helper is no longer run as a service. Stopping and disabling it."
|
||||
sudo systemctl stop lqos_netplan_helper || true
|
||||
sudo systemctl disable lqos_netplan_helper || true
|
||||
if [ -f /etc/systemd/system/lqos_netplan_helper.service ]; then
|
||||
sudo rm -f /etc/systemd/system/lqos_netplan_helper.service
|
||||
sudo systemctl daemon-reload
|
||||
fi
|
||||
fi
|
||||
if service_exists lqos_scheduler; then
|
||||
echo "lqos_scheduler is running as a service. Restarting it. You may need to enter your sudo password."
|
||||
sudo systemctl restart lqos_scheduler
|
||||
|
||||
Generated
+39
@@ -2099,6 +2099,24 @@ dependencies = [
|
||||
"lqos_sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lqos_netplan_helper"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"default-net",
|
||||
"libc",
|
||||
"lqos_config",
|
||||
"lqos_utils",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"toml",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lqos_overrides"
|
||||
version = "0.1.0"
|
||||
@@ -2187,6 +2205,7 @@ dependencies = [
|
||||
"cursive",
|
||||
"ip_network",
|
||||
"lqos_config",
|
||||
"lqos_netplan_helper",
|
||||
"nix",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
@@ -2306,6 +2325,7 @@ dependencies = [
|
||||
"lqos_bus",
|
||||
"lqos_config",
|
||||
"lqos_heimdall",
|
||||
"lqos_netplan_helper",
|
||||
"lqos_overrides",
|
||||
"lqos_probe",
|
||||
"lqos_queue_tracker",
|
||||
@@ -3595,6 +3615,19 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -4389,6 +4422,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -43,6 +43,7 @@ members = [
|
||||
"lqos_overrides", # A CLI tool and library for unifying the override system and allowing API support for changing network.json and ShapedDevices.csv
|
||||
"lqos_topology", # Shared topology runtime domain logic and health/effective-state helper.
|
||||
"lqos_topology_compile", # Shared topology compilation and mode projection layer.
|
||||
"lqos_netplan_helper", # Managed netplan inspection/transaction helper.
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -588,8 +588,8 @@ pub enum BusRequest {
|
||||
/// Retrieve current global warning list
|
||||
GetGlobalWarnings,
|
||||
|
||||
/// Is Insight Enabled?
|
||||
CheckInsight,
|
||||
/// Retrieve the current local LTS/license capability summary.
|
||||
GetLtsCapabilities,
|
||||
|
||||
/// Retrieve current Insight license summary (licensed + optional max circuits).
|
||||
GetInsightLicenseSummary,
|
||||
|
||||
@@ -41,6 +41,43 @@ pub struct InsightLicenseSummary {
|
||||
/// `None` means the license did not specify a max.
|
||||
pub max_circuits: Option<u64>,
|
||||
}
|
||||
|
||||
/// Summary of the current effective LTS/license capabilities on this node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Allocative)]
|
||||
pub struct LtsCapabilitiesSummary {
|
||||
/// Effective numeric license state after resolving live session or cached grant.
|
||||
pub license_state: i32,
|
||||
/// Human-readable effective license state label.
|
||||
pub license_state_label: String,
|
||||
/// Which source currently authorizes the effective state.
|
||||
pub authority_label: String,
|
||||
/// Whether the live control service is currently reachable and permitted.
|
||||
pub control_service_reachable: bool,
|
||||
/// Whether local bootstrap intent exists.
|
||||
pub bootstrap_intent: bool,
|
||||
/// Whether automatic bootstrap retries are currently suppressed.
|
||||
pub bootstrap_suppressed: bool,
|
||||
/// Whether a valid cached grant is currently available.
|
||||
pub cached_grant_available: bool,
|
||||
/// Whether the node may open the control channel.
|
||||
pub can_open_control_channel: bool,
|
||||
/// Whether Insight UI/history pages are entitled.
|
||||
pub can_view_insight_ui: bool,
|
||||
/// Whether the API docs/API link should be enabled.
|
||||
pub can_use_api_link: bool,
|
||||
/// Whether support tickets are entitled.
|
||||
pub can_use_support_tickets: bool,
|
||||
/// Whether chatbot/Libby is entitled.
|
||||
pub can_use_chatbot: bool,
|
||||
/// Whether remote commands are entitled.
|
||||
pub can_receive_remote_commands: bool,
|
||||
/// Whether long-term stats collection is enabled.
|
||||
pub can_collect_long_term_stats: bool,
|
||||
/// Whether long-term stats submission is enabled.
|
||||
pub can_submit_long_term_stats: bool,
|
||||
/// Effective mapped-circuit limit. `None` means unlimited.
|
||||
pub mapped_circuit_limit: Option<u64>,
|
||||
}
|
||||
/// Serializable snapshot of BakeryStats for bus transmission
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Allocative)]
|
||||
pub struct BakeryStatsSnapshot {
|
||||
@@ -683,8 +720,8 @@ pub enum BusResponse {
|
||||
/// Search results
|
||||
SearchResults(Vec<SearchResultEntry>),
|
||||
|
||||
/// Is Insight Enabled?
|
||||
InsightStatus(bool),
|
||||
/// Current local LTS/license capability summary.
|
||||
LtsCapabilitiesSummary(LtsCapabilitiesSummary),
|
||||
|
||||
/// Insight license summary (licensed + optional max circuits).
|
||||
InsightLicenseSummary(InsightLicenseSummary),
|
||||
|
||||
@@ -27,10 +27,11 @@ mod tc_handle;
|
||||
pub use bus::response::{
|
||||
AsnHeatmapData, AsnListEntry, BakeryStatsSnapshot, CircuitCapacityRow, CircuitCount,
|
||||
CircuitHeatmapData, CountryListEntry, DeviceCounts, ExecutiveSummaryHeader, FlowMapPoint,
|
||||
FlowTimelineEntry, InsightLicenseSummary, NodeCapacity, ProtocolListEntry, QueueStatsTotal,
|
||||
RetransmitSummary, SchedulerDetails, SearchResultEntry, SiteHeatmapData,
|
||||
StormguardDebugDirection, StormguardDebugEntry, TreeGuardRuntimeNodeBranchSnapshot,
|
||||
TreeGuardRuntimeNodeOperationSnapshot, UrgentIssue, WarningLevel,
|
||||
FlowTimelineEntry, InsightLicenseSummary, LtsCapabilitiesSummary, NodeCapacity,
|
||||
ProtocolListEntry, QueueStatsTotal, RetransmitSummary, SchedulerDetails, SearchResultEntry,
|
||||
SiteHeatmapData, StormguardDebugDirection, StormguardDebugEntry,
|
||||
TreeGuardRuntimeNodeBranchSnapshot, TreeGuardRuntimeNodeOperationSnapshot, UrgentIssue,
|
||||
WarningLevel,
|
||||
};
|
||||
pub use bus::{
|
||||
BUS_SOCKET_PATH, BakeryCapacityReportInterface, BlackboardSystem, BusClientError, BusReply,
|
||||
|
||||
@@ -922,7 +922,7 @@ mod test {
|
||||
config.uisp_integration.enable_uisp = true;
|
||||
|
||||
let selected = NetworkJson::path_for_config(&config);
|
||||
assert_eq!(selected, temp.join("network.effective.json"));
|
||||
assert_eq!(selected, config.topology_state_file_path("network.effective.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1488,7 +1488,7 @@ mod tests {
|
||||
.exists()
|
||||
);
|
||||
|
||||
let quarantine_directory = lqos_directory.join(".topology_stale");
|
||||
let quarantine_directory = config.quarantine_state_directory_path();
|
||||
let quarantined = fs::read_dir(&quarantine_directory)
|
||||
.expect("directory should read")
|
||||
.filter_map(|entry| entry.ok())
|
||||
@@ -1628,7 +1628,7 @@ mod tests {
|
||||
.exists()
|
||||
);
|
||||
assert!(lqos_directory.join(TOPOLOGY_EDITOR_STATE_FILENAME).exists());
|
||||
let quarantine_directory = lqos_directory.join(".topology_stale");
|
||||
let quarantine_directory = config.quarantine_state_directory_path();
|
||||
let quarantined = if quarantine_directory.exists() {
|
||||
fs::read_dir(&quarantine_directory)
|
||||
.expect("directory should read")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "lqos_netplan_helper"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-2.0-only"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
default-net = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
lqos_config = { path = "../lqos_config" }
|
||||
lqos_utils = { path = "../lqos_utils" }
|
||||
serde_yaml = "0.9"
|
||||
libc = "0.2"
|
||||
tracing = { workspace = true }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
//! Managed netplan inspection and transaction helper for LibreQoS.
|
||||
|
||||
#![deny(clippy::unwrap_used)]
|
||||
|
||||
pub mod inspect;
|
||||
pub mod protocol;
|
||||
pub mod transaction;
|
||||
|
||||
pub use inspect::{DetectedNetplanFile, NetworkModeInspection, inspect_network_mode};
|
||||
pub use protocol::{
|
||||
ApplyMode, ApplyRequest, ApplyResponse, BackupSummary, HelperStatus, PendingOperationStatus,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use lqos_netplan_helper::inspect_network_mode;
|
||||
use lqos_netplan_helper::protocol::{ApplyMode, ApplyRequest};
|
||||
use lqos_netplan_helper::transaction::{
|
||||
HelperPaths, PendingChildren, apply_transaction, confirm_transaction, helper_status,
|
||||
retry_shaping_transaction, revert_transaction, rollback_transaction,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "lqos_netplan_helper")]
|
||||
#[command(about = "LibreQoS managed netplan helper")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
Status,
|
||||
Inspect,
|
||||
Preview,
|
||||
Apply {
|
||||
#[arg(long, default_value = "cli")]
|
||||
source: String,
|
||||
#[arg(long)]
|
||||
operator_username: Option<String>,
|
||||
},
|
||||
Confirm {
|
||||
operation_id: String,
|
||||
},
|
||||
Revert {
|
||||
operation_id: String,
|
||||
},
|
||||
Rollback {
|
||||
backup_id: String,
|
||||
},
|
||||
RetryShaping,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let paths = HelperPaths::default();
|
||||
let mut pending_children = PendingChildren::default();
|
||||
match cli.command {
|
||||
Commands::Status => {
|
||||
let response = helper_status(&paths, &mut pending_children)?;
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
Ok(())
|
||||
}
|
||||
Commands::Inspect => {
|
||||
let config = lqos_config::load_config().context("Unable to load current config")?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&inspect_network_mode(config.as_ref()))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Commands::Preview => {
|
||||
let config = lqos_config::load_config().context("Unable to load current config")?;
|
||||
let inspection = inspect_network_mode(config.as_ref());
|
||||
if let Some(preview) = inspection.managed_preview_yaml {
|
||||
println!("{preview}");
|
||||
} else if let Some(note) = inspection.preview_note {
|
||||
println!("{note}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Commands::Apply {
|
||||
source,
|
||||
operator_username,
|
||||
} => {
|
||||
let config = lqos_config::load_config().context("Unable to load current config")?;
|
||||
let response = apply_transaction(
|
||||
&paths,
|
||||
&mut pending_children,
|
||||
ApplyRequest {
|
||||
config: (*config).clone(),
|
||||
source,
|
||||
operator_username,
|
||||
mode: ApplyMode::Apply,
|
||||
confirm_dangerous_changes: true,
|
||||
},
|
||||
)?;
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
Ok(())
|
||||
}
|
||||
Commands::Confirm { operation_id } => {
|
||||
let response = confirm_transaction(&paths, &mut pending_children, &operation_id)?;
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
Ok(())
|
||||
}
|
||||
Commands::Revert { operation_id } => {
|
||||
let response = revert_transaction(&paths, &mut pending_children, &operation_id)?;
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
Ok(())
|
||||
}
|
||||
Commands::Rollback { backup_id } => {
|
||||
let response = rollback_transaction(&paths, &mut pending_children, &backup_id)?;
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
Ok(())
|
||||
}
|
||||
Commands::RetryShaping => {
|
||||
let response = retry_shaping_transaction(&paths, &mut pending_children)?;
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use lqos_config::Config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Input for a staged helper apply transaction.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub config: Config,
|
||||
pub source: String,
|
||||
pub operator_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mode: ApplyMode,
|
||||
#[serde(default)]
|
||||
pub confirm_dangerous_changes: bool,
|
||||
}
|
||||
|
||||
/// Apply-mode variants used by the transaction engine.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ApplyMode {
|
||||
#[default]
|
||||
Apply,
|
||||
Adopt,
|
||||
TakeOver,
|
||||
}
|
||||
|
||||
/// Generic helper action response used by apply/confirm/revert/rollback/retry-shaping.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ApplyResponse {
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
pub operation: Option<PendingOperationStatus>,
|
||||
pub last_backup_id: Option<String>,
|
||||
}
|
||||
|
||||
/// High-level helper status returned to callers.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct HelperStatus {
|
||||
#[serde(default)]
|
||||
pub pending_operation: Option<PendingOperationStatus>,
|
||||
#[serde(default)]
|
||||
pub last_backup_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub recent_backup_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub recent_backups: Vec<BackupSummary>,
|
||||
}
|
||||
|
||||
/// Serializable description of a pending network change awaiting confirmation.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct PendingOperationStatus {
|
||||
pub operation_id: String,
|
||||
pub backup_id: String,
|
||||
pub state: String,
|
||||
pub source: String,
|
||||
pub operator_username: Option<String>,
|
||||
pub summary: String,
|
||||
pub created_unix: u64,
|
||||
}
|
||||
|
||||
/// Summary of a stored rollback bundle.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct BackupSummary {
|
||||
pub backup_id: String,
|
||||
pub timestamp_unix: u64,
|
||||
pub source: String,
|
||||
pub operator_username: Option<String>,
|
||||
pub old_mode: String,
|
||||
pub new_mode: String,
|
||||
#[serde(default)]
|
||||
pub warnings_present: Vec<String>,
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -114,11 +114,11 @@ fn use_lts_weights() -> bool {
|
||||
if !(config.long_term_stats.gather_stats && config.long_term_stats.license_key.is_some()) {
|
||||
return false;
|
||||
}
|
||||
// Ask lqosd (via bus) whether Insight is actually enabled/licensed
|
||||
if let Ok(responses) = crate::blocking::run_query(vec![BusRequest::CheckInsight]) {
|
||||
// Ask lqosd (via bus) whether long-term stats submission is actually enabled/licensed
|
||||
if let Ok(responses) = crate::blocking::run_query(vec![BusRequest::GetLtsCapabilities]) {
|
||||
for resp in responses.into_iter() {
|
||||
if let BusResponse::InsightStatus(enabled) = resp {
|
||||
return enabled;
|
||||
if let BusResponse::LtsCapabilitiesSummary(summary) = resp {
|
||||
return summary.can_submit_long_term_stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3962,12 +3962,12 @@ fn treeguard_get_node_virtual_branch_state(
|
||||
#[pyfunction]
|
||||
/// Returns whether Insight features are currently enabled in `lqosd`.
|
||||
pub fn is_insight_enabled() -> PyResult<bool> {
|
||||
let Ok(responses) = run_query(vec![BusRequest::CheckInsight]) else {
|
||||
let Ok(responses) = run_query(vec![BusRequest::GetLtsCapabilities]) else {
|
||||
return Ok(false);
|
||||
};
|
||||
for resp in responses {
|
||||
if let BusResponse::InsightStatus(enabled) = resp {
|
||||
return Ok(enabled);
|
||||
if let BusResponse::LtsCapabilitiesSummary(summary) = resp {
|
||||
return Ok(summary.can_view_insight_ui);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
|
||||
@@ -7,8 +7,9 @@ license = "GPL-2.0-only"
|
||||
[dependencies]
|
||||
cursive = "0.21.1"
|
||||
lqos_config = { path = "../lqos_config" }
|
||||
lqos_netplan_helper = { path = "../lqos_netplan_helper" }
|
||||
nix.workspace = true
|
||||
anyhow.workspace = true
|
||||
once_cell = { workspace = true}
|
||||
ip_network = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
@@ -17,11 +17,11 @@ pub fn bridge_mode(s: &mut Cursive) {
|
||||
});
|
||||
let mut linux_btn = group.button(
|
||||
BridgeMode::Linux,
|
||||
"Linux Bridge (2 interfaces) - Please set up the bridge in your netplan",
|
||||
"Linux Bridge (2 interfaces) - LibreQoS will inspect and stage the managed Netplan change",
|
||||
);
|
||||
let mut xdp_btn = group.button(
|
||||
BridgeMode::XDP,
|
||||
"XDP Bridge (2 interfaces) - Please DO NOT have a bridge in your netplan",
|
||||
"XDP Bridge (2 interfaces) - Manual Netplan workflow; do not create a bridge in Netplan",
|
||||
);
|
||||
let mut single_btn = group.button(BridgeMode::Single, "Single Interface (1 interface)");
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ pub fn interface_menu(s: &mut Cursive) {
|
||||
.title("Select Interfaces")
|
||||
.button("OK", |s| {
|
||||
s.pop_layer();
|
||||
crate::preview_selected_network_mode(s);
|
||||
})
|
||||
.full_screen(),
|
||||
);
|
||||
|
||||
+272
-86
@@ -7,15 +7,22 @@ mod preflight;
|
||||
mod webusers;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::{env, fmt::Write as _};
|
||||
|
||||
use bandwidth::bandwidth_view;
|
||||
use config_builder::CURRENT_CONFIG;
|
||||
use cursive::{
|
||||
Rect, Vec2, View,
|
||||
view::{Margins, Nameable, Resizable},
|
||||
view::{Margins, Nameable, Resizable, Scrollable},
|
||||
views::{Checkbox, Dialog, EditView, FixedLayout, Layer, LinearLayout, OnLayoutView, TextView},
|
||||
};
|
||||
use lqos_netplan_helper::protocol::{ApplyMode, ApplyRequest};
|
||||
use lqos_netplan_helper::transaction::{
|
||||
HelperPaths, PendingChildren, apply_transaction, confirm_transaction, inspect_with_paths,
|
||||
revert_transaction,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
const VERSION: &str = include_str!("../../../VERSION_STRING");
|
||||
const DEFAULT_NETWORK_JSON: &str = include_str!("../../../network.example.json");
|
||||
@@ -253,6 +260,138 @@ fn main() {
|
||||
ui.run();
|
||||
}
|
||||
|
||||
fn build_candidate_config(existing: Option<lqos_config::Config>) -> lqos_config::Config {
|
||||
let mut config = existing.unwrap_or_default();
|
||||
let new_config = CURRENT_CONFIG.lock();
|
||||
config.node_name = new_config.node_name.clone();
|
||||
config.queues.downlink_bandwidth_mbps = new_config.mbps_to_internet;
|
||||
config.queues.uplink_bandwidth_mbps = new_config.mbps_to_network;
|
||||
config.queues.generated_pn_download_mbps = new_config.mbps_to_internet;
|
||||
config.queues.generated_pn_upload_mbps = new_config.mbps_to_network;
|
||||
match new_config.bridge_mode {
|
||||
config_builder::BridgeMode::Linux => {
|
||||
config.single_interface = None;
|
||||
config.bridge = Some(lqos_config::BridgeConfig {
|
||||
use_xdp_bridge: false,
|
||||
to_internet: new_config.to_internet.clone(),
|
||||
to_network: new_config.to_network.clone(),
|
||||
});
|
||||
}
|
||||
config_builder::BridgeMode::XDP => {
|
||||
config.single_interface = None;
|
||||
config.bridge = Some(lqos_config::BridgeConfig {
|
||||
use_xdp_bridge: true,
|
||||
to_internet: new_config.to_internet.clone(),
|
||||
to_network: new_config.to_network.clone(),
|
||||
});
|
||||
}
|
||||
config_builder::BridgeMode::Single => {
|
||||
config.single_interface = Some(lqos_config::SingleInterfaceConfig {
|
||||
interface: new_config.to_internet.clone(),
|
||||
internet_vlan: new_config.internet_vlan,
|
||||
network_vlan: new_config.network_vlan,
|
||||
});
|
||||
config.bridge = None;
|
||||
}
|
||||
}
|
||||
config.ip_ranges.allow_subnets = new_config.allow_subnets.clone();
|
||||
config
|
||||
}
|
||||
|
||||
fn inspection_report(inspection: &lqos_netplan_helper::NetworkModeInspection) -> String {
|
||||
let mut report = format!(
|
||||
"State: {}\n{}\n",
|
||||
inspection.inspector_state, inspection.summary
|
||||
);
|
||||
if !inspection.warnings.is_empty() {
|
||||
report.push_str("\nWarnings:\n");
|
||||
for warning in &inspection.warnings {
|
||||
let _ = writeln!(&mut report, "- {warning}");
|
||||
}
|
||||
}
|
||||
if !inspection.dangerous_changes.is_empty() {
|
||||
report.push_str("\nStrong confirmations required:\n");
|
||||
for warning in &inspection.dangerous_changes {
|
||||
let _ = writeln!(&mut report, "- {warning}");
|
||||
}
|
||||
}
|
||||
if !inspection.conflicts.is_empty() {
|
||||
report.push_str("\nConflicts:\n");
|
||||
for conflict in &inspection.conflicts {
|
||||
let _ = writeln!(&mut report, "- {conflict}");
|
||||
}
|
||||
}
|
||||
if let Some(preview) = &inspection.managed_preview_yaml {
|
||||
report.push_str("\nManaged Preview:\n");
|
||||
report.push_str(preview);
|
||||
} else if let Some(note) = &inspection.preview_note {
|
||||
report.push('\n');
|
||||
report.push_str(note);
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
pub(crate) fn preview_selected_network_mode(s: &mut cursive::Cursive) {
|
||||
let existing = lqos_config::load_config().ok().map(|cfg| (*cfg).clone());
|
||||
let candidate = build_candidate_config(existing);
|
||||
let inspection = inspect_with_paths(&HelperPaths::default(), &candidate);
|
||||
s.add_layer(
|
||||
Dialog::around(TextView::new(inspection_report(&inspection)).scrollable())
|
||||
.title("Detected Netplan State")
|
||||
.button("OK", |s| {
|
||||
s.pop_layer();
|
||||
})
|
||||
.full_screen(),
|
||||
);
|
||||
}
|
||||
|
||||
fn finish_setup(
|
||||
ui: &mut cursive::Cursive,
|
||||
config: &lqos_config::Config,
|
||||
mut event_log: Vec<String>,
|
||||
) {
|
||||
let state_root = config.resolved_state_directory();
|
||||
for category in [
|
||||
"topology",
|
||||
"shaping",
|
||||
"stats",
|
||||
"cache",
|
||||
"debug",
|
||||
"quarantine",
|
||||
] {
|
||||
std::fs::create_dir_all(state_root.join(category))
|
||||
.expect("Unable to create state directory");
|
||||
}
|
||||
|
||||
if !network_json_exists() {
|
||||
let path = Path::new(&config.lqos_directory).join("network.json");
|
||||
std::fs::write(path, DEFAULT_NETWORK_JSON).expect("Unable to write file");
|
||||
event_log.push("Network.json created.".to_string());
|
||||
} else {
|
||||
event_log.push("Network.json already exists - not updated.".to_string());
|
||||
}
|
||||
|
||||
if !shaped_devices_exists() {
|
||||
let path = Path::new(&config.lqos_directory).join("ShapedDevices.csv");
|
||||
std::fs::write(path, DEFAULT_SHAPED_DEVICES).expect("Unable to write file");
|
||||
event_log.push("ShapedDevices.csv created.".to_string());
|
||||
} else {
|
||||
event_log.push("ShapedDevices.csv already exists - not updated.".to_string());
|
||||
}
|
||||
|
||||
let report = cursive::With::with(LinearLayout::vertical(), |layout| {
|
||||
for line in &event_log {
|
||||
layout.add_child(TextView::new(line));
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_layer(
|
||||
Dialog::around(report)
|
||||
.title("Setup Complete")
|
||||
.button("OK", |ui| ui.quit()),
|
||||
);
|
||||
}
|
||||
|
||||
fn finalize(ui: &mut cursive::Cursive) {
|
||||
// If we cannot load the config but a file exists, warn the user and
|
||||
// take a backup before proceeding to create a new config.
|
||||
@@ -302,8 +441,7 @@ or Cancel to exit and investigate.",
|
||||
fn continue_finalize(ui: &mut cursive::Cursive) {
|
||||
let mut event_log = Vec::new();
|
||||
|
||||
// Update/Create the config file.
|
||||
let mut config = if let Ok(config) = lqos_config::load_config() {
|
||||
let existing_config = if let Ok(config) = lqos_config::load_config() {
|
||||
event_log.push("Loaded existing configuration".to_string());
|
||||
(*config).clone()
|
||||
} else {
|
||||
@@ -326,98 +464,146 @@ fn continue_finalize(ui: &mut cursive::Cursive) {
|
||||
event_log.push("Creating new configuration".to_string());
|
||||
lqos_config::Config::default()
|
||||
};
|
||||
let config = build_candidate_config(Some(existing_config));
|
||||
let using_helper = !matches!(
|
||||
CURRENT_CONFIG.lock().bridge_mode,
|
||||
config_builder::BridgeMode::XDP
|
||||
);
|
||||
|
||||
let new_config = CURRENT_CONFIG.lock();
|
||||
config.node_name = new_config.node_name.clone();
|
||||
config.queues.downlink_bandwidth_mbps = new_config.mbps_to_internet;
|
||||
config.queues.uplink_bandwidth_mbps = new_config.mbps_to_network;
|
||||
config.queues.generated_pn_download_mbps = new_config.mbps_to_internet;
|
||||
config.queues.generated_pn_upload_mbps = new_config.mbps_to_network;
|
||||
match new_config.bridge_mode {
|
||||
config_builder::BridgeMode::Linux => {
|
||||
config.single_interface = None;
|
||||
config.bridge = Some(lqos_config::BridgeConfig {
|
||||
use_xdp_bridge: false,
|
||||
to_internet: new_config.to_internet.clone(),
|
||||
to_network: new_config.to_network.clone(),
|
||||
});
|
||||
}
|
||||
config_builder::BridgeMode::XDP => {
|
||||
config.single_interface = None;
|
||||
config.bridge = Some(lqos_config::BridgeConfig {
|
||||
use_xdp_bridge: true,
|
||||
to_internet: new_config.to_internet.clone(),
|
||||
to_network: new_config.to_network.clone(),
|
||||
});
|
||||
}
|
||||
config_builder::BridgeMode::Single => {
|
||||
config.single_interface = Some(lqos_config::SingleInterfaceConfig {
|
||||
interface: new_config.to_internet.clone(),
|
||||
internet_vlan: new_config.internet_vlan,
|
||||
network_vlan: new_config.network_vlan,
|
||||
});
|
||||
config.bridge = None;
|
||||
if !using_helper {
|
||||
if let Err(e) = lqos_config::update_config(&config) {
|
||||
event_log.push(format!("ERROR: Unable to write configuration: {e:?}"));
|
||||
let msg = format!("ERROR: Unable to write configuration: {e:?}");
|
||||
ui.add_layer(
|
||||
Dialog::around(TextView::new(msg))
|
||||
.title("Error")
|
||||
.button("OK", |s| {
|
||||
s.pop_layer();
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
event_log.push("Configuration updated.".to_string());
|
||||
event_log.push("XDP bridge mode remains a manual Netplan workflow.".to_string());
|
||||
finish_setup(ui, &config, event_log);
|
||||
return;
|
||||
}
|
||||
config.ip_ranges.allow_subnets = new_config.allow_subnets.clone();
|
||||
if let Err(e) = lqos_config::update_config(&config) {
|
||||
event_log.push(format!("ERROR: Unable to write configuration: {e:?}"));
|
||||
let msg = format!("ERROR: Unable to write configuration: {e:?}");
|
||||
ui.add_layer(
|
||||
Dialog::around(TextView::new(msg))
|
||||
.title("Error")
|
||||
|
||||
let helper_paths = HelperPaths::default();
|
||||
let pending_children = Arc::new(Mutex::new(PendingChildren::default()));
|
||||
let inspection = inspect_with_paths(&helper_paths, &config);
|
||||
let requires_confirmation = !inspection.dangerous_changes.is_empty();
|
||||
let apply_mode = if inspection.can_take_over {
|
||||
ApplyMode::TakeOver
|
||||
} else if inspection.can_adopt {
|
||||
ApplyMode::Adopt
|
||||
} else {
|
||||
ApplyMode::Apply
|
||||
};
|
||||
let apply_request = ApplyRequest {
|
||||
config: config.clone(),
|
||||
source: "setup".to_string(),
|
||||
operator_username: None,
|
||||
mode: apply_mode,
|
||||
confirm_dangerous_changes: requires_confirmation,
|
||||
};
|
||||
|
||||
let response = {
|
||||
let mut pending = pending_children.lock();
|
||||
apply_transaction(&helper_paths, &mut pending, apply_request)
|
||||
};
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
ui.add_layer(
|
||||
Dialog::around(TextView::new(format!(
|
||||
"Unable to stage network-mode changes:\n{err:#}"
|
||||
)))
|
||||
.title("Helper Error")
|
||||
.button("OK", |s| {
|
||||
s.pop_layer();
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
event_log.push("Configuration updated".to_string());
|
||||
|
||||
let state_root = config.resolved_state_directory();
|
||||
for category in [
|
||||
"topology",
|
||||
"shaping",
|
||||
"stats",
|
||||
"cache",
|
||||
"debug",
|
||||
"quarantine",
|
||||
] {
|
||||
std::fs::create_dir_all(state_root.join(category))
|
||||
.expect("Unable to create state directory");
|
||||
}
|
||||
|
||||
// Does network.json exist?
|
||||
if !network_json_exists() {
|
||||
let path = Path::new(&config.lqos_directory).join("network.json");
|
||||
std::fs::write(path, DEFAULT_NETWORK_JSON).expect("Unable to write file");
|
||||
event_log.push("Network.json created.".to_string());
|
||||
} else {
|
||||
event_log.push("Network.json already exists - not updated.".to_string());
|
||||
}
|
||||
|
||||
// Does ShapedDevices.csv exist?
|
||||
if !shaped_devices_exists() {
|
||||
let path = Path::new(&config.lqos_directory).join("ShapedDevices.csv");
|
||||
std::fs::write(path, DEFAULT_SHAPED_DEVICES).expect("Unable to write file");
|
||||
event_log.push("ShapedDevices.csv created.".to_string());
|
||||
} else {
|
||||
event_log.push("ShapedDevices.csv already exists - not updated.".to_string());
|
||||
}
|
||||
|
||||
// Display final report
|
||||
use cursive::views::{Dialog, LinearLayout, TextView};
|
||||
|
||||
let report = cursive::With::with(LinearLayout::vertical(), |layout| {
|
||||
for line in &event_log {
|
||||
layout.add_child(TextView::new(line));
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let Some(operation) = response.operation.clone() else {
|
||||
finish_setup(ui, &config, event_log);
|
||||
return;
|
||||
};
|
||||
|
||||
let confirm_paths = helper_paths.clone();
|
||||
let confirm_pending = Arc::clone(&pending_children);
|
||||
let confirm_config = config.clone();
|
||||
let confirm_log = event_log.clone();
|
||||
let confirm_operation_id = operation.operation_id.clone();
|
||||
let revert_paths = helper_paths.clone();
|
||||
let revert_pending = Arc::clone(&pending_children);
|
||||
let revert_operation_id = operation.operation_id.clone();
|
||||
|
||||
ui.add_layer(
|
||||
Dialog::around(report)
|
||||
.title("Setup Complete")
|
||||
.button("OK", |ui| ui.quit()),
|
||||
Dialog::around(TextView::new(format!(
|
||||
"{}\n\n{}\n\nConfirm the change to keep the managed netplan update, or revert it now.",
|
||||
response.message,
|
||||
inspection_report(&inspection)
|
||||
)))
|
||||
.title("Confirm Netplan Change")
|
||||
.button("Confirm", move |s| {
|
||||
let result = {
|
||||
let mut pending = confirm_pending.lock();
|
||||
confirm_transaction(&confirm_paths, &mut pending, &confirm_operation_id)
|
||||
};
|
||||
match result {
|
||||
Ok(confirm) => {
|
||||
let mut log = confirm_log.clone();
|
||||
log.push(confirm.message);
|
||||
s.pop_layer();
|
||||
finish_setup(s, &confirm_config, log);
|
||||
}
|
||||
Err(err) => {
|
||||
s.add_layer(
|
||||
Dialog::around(TextView::new(format!(
|
||||
"Unable to confirm the pending network change:\n{err:#}"
|
||||
)))
|
||||
.title("Helper Error")
|
||||
.button("OK", |s| {
|
||||
s.pop_layer();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.button("Revert", move |s| {
|
||||
let result = {
|
||||
let mut pending = revert_pending.lock();
|
||||
revert_transaction(&revert_paths, &mut pending, &revert_operation_id)
|
||||
};
|
||||
match result {
|
||||
Ok(revert) => {
|
||||
s.add_layer(
|
||||
Dialog::around(TextView::new(revert.message))
|
||||
.title("Network Change Reverted")
|
||||
.button("OK", |s| {
|
||||
s.pop_layer();
|
||||
s.pop_layer();
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
s.add_layer(
|
||||
Dialog::around(TextView::new(format!(
|
||||
"Unable to revert the pending network change:\n{err:#}"
|
||||
)))
|
||||
.title("Helper Error")
|
||||
.button("OK", |s| {
|
||||
s.pop_layer();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.full_screen(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ lqos_topology_compile = { path = "../lqos_topology_compile" }
|
||||
lqos_heimdall = { path = "../lqos_heimdall" }
|
||||
lqos_stormguard = { path = "../lqos_stormguard" }
|
||||
lqos_probe = { path = "../lqos_probe" }
|
||||
lqos_netplan_helper = { path = "../lqos_netplan_helper" }
|
||||
tokio = { version = "1", features = [ "full" ] }
|
||||
once_cell = { workspace = true}
|
||||
lqos_bus = { path = "../lqos_bus" }
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
use crate::lts2_sys::license_grant::{self, LicenseGrant};
|
||||
use crate::lts2_sys::lts2_client::get_license_status;
|
||||
use crate::lts2_sys::shared_types::LtsStatus;
|
||||
use lqos_bus::LtsCapabilitiesSummary;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RuntimeLicenseState {
|
||||
control_service_reachable: bool,
|
||||
signup_bootstrap_active: bool,
|
||||
suppressed_license_key: Option<String>,
|
||||
}
|
||||
|
||||
static RUNTIME_LICENSE_STATE: Lazy<Mutex<RuntimeLicenseState>> =
|
||||
Lazy::new(|| Mutex::new(RuntimeLicenseState::default()));
|
||||
static CONTROL_CHANNEL_NOTIFY: Lazy<Notify> = Lazy::new(Notify::new);
|
||||
|
||||
pub fn set_control_service_reachable(reachable: bool) {
|
||||
RUNTIME_LICENSE_STATE.lock().control_service_reachable = reachable;
|
||||
if reachable {
|
||||
CONTROL_CHANNEL_NOTIFY.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_signup_bootstrap_active(active: bool) {
|
||||
RUNTIME_LICENSE_STATE.lock().signup_bootstrap_active = active;
|
||||
if active {
|
||||
CONTROL_CHANNEL_NOTIFY.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn suppress_bootstrap_for_license_key(license_key: &str) {
|
||||
let normalized = normalize_non_empty(Some(license_key));
|
||||
RUNTIME_LICENSE_STATE.lock().suppressed_license_key = normalized;
|
||||
}
|
||||
|
||||
pub fn clear_bootstrap_suppression() {
|
||||
RUNTIME_LICENSE_STATE.lock().suppressed_license_key = None;
|
||||
CONTROL_CHANNEL_NOTIFY.notify_waiters();
|
||||
}
|
||||
|
||||
pub fn wake_control_channel() {
|
||||
CONTROL_CHANNEL_NOTIFY.notify_waiters();
|
||||
}
|
||||
|
||||
pub async fn wait_for_control_channel_retry(delay: std::time::Duration) {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
_ = CONTROL_CHANNEL_NOTIFY.notified() => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_capabilities() -> LtsCapabilitiesSummary {
|
||||
let Ok(config) = lqos_config::load_config() else {
|
||||
return LtsCapabilitiesSummary {
|
||||
license_state: LtsStatus::Invalid as i32,
|
||||
license_state_label: LtsStatus::Invalid.label().to_string(),
|
||||
authority_label: "Unlicensed".to_string(),
|
||||
control_service_reachable: false,
|
||||
bootstrap_intent: false,
|
||||
bootstrap_suppressed: false,
|
||||
cached_grant_available: false,
|
||||
can_open_control_channel: false,
|
||||
can_view_insight_ui: false,
|
||||
can_use_api_link: false,
|
||||
can_use_support_tickets: false,
|
||||
can_use_chatbot: false,
|
||||
can_receive_remote_commands: false,
|
||||
can_collect_long_term_stats: false,
|
||||
can_submit_long_term_stats: false,
|
||||
mapped_circuit_limit: Some(1000),
|
||||
};
|
||||
};
|
||||
|
||||
current_capabilities_for_config(config.as_ref())
|
||||
}
|
||||
|
||||
pub fn can_submit_long_term_stats() -> bool {
|
||||
current_capabilities().can_submit_long_term_stats
|
||||
}
|
||||
|
||||
pub fn can_open_control_channel() -> bool {
|
||||
current_capabilities().can_open_control_channel
|
||||
}
|
||||
|
||||
pub fn control_service_reachable() -> bool {
|
||||
RUNTIME_LICENSE_STATE.lock().control_service_reachable
|
||||
}
|
||||
|
||||
fn current_capabilities_for_config(config: &lqos_config::Config) -> LtsCapabilitiesSummary {
|
||||
let runtime = RUNTIME_LICENSE_STATE.lock();
|
||||
let configured_license_key = normalize_non_empty(config.long_term_stats.license_key.as_deref());
|
||||
let valid_bootstrap_key =
|
||||
parse_license_uuid(config.long_term_stats.license_key.as_deref()).is_some();
|
||||
let bootstrap_intent = configured_license_key.is_some() || runtime.signup_bootstrap_active;
|
||||
let bootstrap_suppressed = configured_license_key.is_some()
|
||||
&& configured_license_key == runtime.suppressed_license_key;
|
||||
let control_service_reachable = runtime.control_service_reachable;
|
||||
drop(runtime);
|
||||
|
||||
let cached_grant = license_grant::current_valid_grant();
|
||||
let cached_grant_available = cached_grant.is_some();
|
||||
let live_status = LtsStatus::from_i32(get_license_status().license_type);
|
||||
let live_entitled = control_service_reachable && is_entitled_status(live_status);
|
||||
let effective_status = if live_entitled {
|
||||
live_status
|
||||
} else if let Some(grant) = cached_grant.as_ref() {
|
||||
LtsStatus::from_i32(grant.license_state)
|
||||
} else if bootstrap_intent {
|
||||
LtsStatus::NotChecked
|
||||
} else {
|
||||
LtsStatus::Invalid
|
||||
};
|
||||
|
||||
let authority_label = if live_entitled {
|
||||
"Live license session"
|
||||
} else if cached_grant_available {
|
||||
"Cached signed grant"
|
||||
} else if bootstrap_intent {
|
||||
"Bootstrap pending"
|
||||
} else {
|
||||
"Unlicensed"
|
||||
};
|
||||
|
||||
let can_open_control_channel =
|
||||
control_service_reachable || (valid_bootstrap_key && !bootstrap_suppressed);
|
||||
let mapped_circuit_limit = mapped_circuit_limit(effective_status, cached_grant.as_ref());
|
||||
let can_collect_or_submit =
|
||||
config.long_term_stats.gather_stats && supports_long_term_stats(effective_status);
|
||||
|
||||
LtsCapabilitiesSummary {
|
||||
license_state: effective_status as i32,
|
||||
license_state_label: effective_status.label().to_string(),
|
||||
authority_label: authority_label.to_string(),
|
||||
control_service_reachable,
|
||||
bootstrap_intent,
|
||||
bootstrap_suppressed,
|
||||
cached_grant_available,
|
||||
can_open_control_channel,
|
||||
can_view_insight_ui: can_view_insight_ui(effective_status),
|
||||
can_use_api_link: can_use_api_link(effective_status),
|
||||
can_use_support_tickets: can_use_support_tickets(effective_status),
|
||||
can_use_chatbot: can_use_chatbot(effective_status),
|
||||
can_receive_remote_commands: can_receive_remote_commands(effective_status),
|
||||
can_collect_long_term_stats: can_collect_or_submit,
|
||||
can_submit_long_term_stats: can_collect_or_submit,
|
||||
mapped_circuit_limit,
|
||||
}
|
||||
}
|
||||
|
||||
fn mapped_circuit_limit(status: LtsStatus, cached_grant: Option<&LicenseGrant>) -> Option<u64> {
|
||||
if !lifts_mapped_circuit_cap(status) {
|
||||
return Some(1000);
|
||||
}
|
||||
|
||||
cached_grant.and_then(|grant| grant.max_circuits)
|
||||
}
|
||||
|
||||
fn normalize_non_empty(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_license_uuid(value: Option<&str>) -> Option<Uuid> {
|
||||
let value = normalize_non_empty(value)?;
|
||||
Uuid::parse_str(&value.replace('-', "")).ok()
|
||||
}
|
||||
|
||||
fn is_entitled_status(status: LtsStatus) -> bool {
|
||||
!matches!(status, LtsStatus::Invalid | LtsStatus::NotChecked)
|
||||
}
|
||||
|
||||
fn lifts_mapped_circuit_cap(status: LtsStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
LtsStatus::AlwaysFree
|
||||
| LtsStatus::FreeTrial
|
||||
| LtsStatus::SelfHosted
|
||||
| LtsStatus::ApiOnly
|
||||
| LtsStatus::Full
|
||||
)
|
||||
}
|
||||
|
||||
fn supports_long_term_stats(status: LtsStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full
|
||||
)
|
||||
}
|
||||
|
||||
fn can_view_insight_ui(status: LtsStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full
|
||||
)
|
||||
}
|
||||
|
||||
fn can_use_api_link(status: LtsStatus) -> bool {
|
||||
matches!(status, LtsStatus::ApiOnly | LtsStatus::Full)
|
||||
}
|
||||
|
||||
fn can_use_support_tickets(status: LtsStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
LtsStatus::AlwaysFree
|
||||
| LtsStatus::FreeTrial
|
||||
| LtsStatus::SelfHosted
|
||||
| LtsStatus::ApiOnly
|
||||
| LtsStatus::Full
|
||||
)
|
||||
}
|
||||
|
||||
fn can_use_chatbot(status: LtsStatus) -> bool {
|
||||
can_use_support_tickets(status)
|
||||
}
|
||||
|
||||
fn can_receive_remote_commands(status: LtsStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tungstenite::Message;
|
||||
|
||||
use crate::lts2_sys::capabilities;
|
||||
use crate::lts2_sys::license_grant;
|
||||
use crate::lts2_sys::lts2_client::{LicenseStatus, set_license_status};
|
||||
use lqos_probe::ProbeClass;
|
||||
@@ -277,16 +278,24 @@ async fn persistent_connection(
|
||||
) -> std::result::Result<(), String> {
|
||||
let mut sleep_seconds = 60;
|
||||
'reconnect: loop {
|
||||
if !capabilities::can_open_control_channel() {
|
||||
capabilities::set_control_service_reachable(false);
|
||||
capabilities::wait_for_control_channel_retry(Duration::from_secs(sleep_seconds)).await;
|
||||
continue 'reconnect;
|
||||
}
|
||||
|
||||
if let Ok(mut socket) = connect().await {
|
||||
let mut permitted = false;
|
||||
// Preamble - get connected
|
||||
if let Err(e) = send_magic_number(&mut socket).await {
|
||||
warn!("Failed to send magic number: {}", e);
|
||||
capabilities::set_control_service_reachable(false);
|
||||
tokio::time::sleep(Duration::from_secs(sleep_seconds)).await;
|
||||
continue 'reconnect;
|
||||
}
|
||||
if let Err(e) = send_license(&mut socket).await {
|
||||
warn!("Failed to send license info: {}", e);
|
||||
capabilities::set_control_service_reachable(false);
|
||||
tokio::time::sleep(Duration::from_secs(sleep_seconds)).await;
|
||||
continue 'reconnect;
|
||||
}
|
||||
@@ -793,6 +802,8 @@ async fn persistent_connection(
|
||||
license_type: license_state,
|
||||
trial_expires: expiration_date as i32,
|
||||
});
|
||||
capabilities::clear_bootstrap_suppression();
|
||||
capabilities::set_control_service_reachable(true);
|
||||
permitted = true;
|
||||
sleep_seconds = 60;
|
||||
// Flush any pending chatbot messages now that we're permitted
|
||||
@@ -825,10 +836,19 @@ async fn persistent_connection(
|
||||
license_type: 0,
|
||||
trial_expires: -1,
|
||||
});
|
||||
permitted = false;
|
||||
capabilities::set_control_service_reachable(false);
|
||||
if let Ok(config) = lqos_config::load_config()
|
||||
&& let Some(license_key) =
|
||||
config.long_term_stats.license_key.as_deref()
|
||||
{
|
||||
capabilities::suppress_bootstrap_for_license_key(
|
||||
license_key,
|
||||
);
|
||||
}
|
||||
if let Err(e) = license_grant::invalidate_license_grant() {
|
||||
warn!("Failed to invalidate stored license grant: {e:?}");
|
||||
}
|
||||
break 'message_pump;
|
||||
}
|
||||
}
|
||||
messages::WsMessage::InsightPublicKey { public_key } => {
|
||||
@@ -869,7 +889,15 @@ async fn persistent_connection(
|
||||
}
|
||||
}
|
||||
messages::WsMessage::RemoteCommands { commands } => {
|
||||
crate::lts2_sys::lts2_client::enqueue(commands);
|
||||
if crate::lts2_sys::current_capabilities()
|
||||
.can_receive_remote_commands
|
||||
{
|
||||
crate::lts2_sys::lts2_client::enqueue(commands);
|
||||
} else {
|
||||
warn!(
|
||||
"Ignoring remote commands because the current license tier does not permit them"
|
||||
);
|
||||
}
|
||||
}
|
||||
messages::WsMessage::ChatbotChunk { request_id, data } => {
|
||||
if let Some(tx) = chatbot_streams.get(&request_id) {
|
||||
@@ -1113,8 +1141,9 @@ async fn persistent_connection(
|
||||
}
|
||||
}
|
||||
}
|
||||
capabilities::set_control_service_reachable(false);
|
||||
debug!("Sleeping before reconnecting the persistent channel");
|
||||
tokio::time::sleep(Duration::from_secs(sleep_seconds)).await;
|
||||
capabilities::wait_for_control_channel_retry(Duration::from_secs(sleep_seconds)).await;
|
||||
sleep_seconds = 60;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::lts2_sys::lts2_client::{LicenseStatus, set_license_status};
|
||||
use crate::lts2_sys::shared_types::LtsStatus;
|
||||
use lqos_utils::unix_time::unix_now;
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -212,31 +211,20 @@ pub fn current_license_summary() -> (Option<Uuid>, Option<u64>) {
|
||||
(grant.license_uuid, grant.max_circuits)
|
||||
}
|
||||
|
||||
pub fn current_license_limits() -> (bool, Option<u64>) {
|
||||
let Some(state) = GRANT_STATE.get() else {
|
||||
return current_license_limits_from_status();
|
||||
};
|
||||
pub fn current_valid_grant() -> Option<LicenseGrant> {
|
||||
let state = GRANT_STATE.get()?;
|
||||
let guard = state.lock();
|
||||
let Some(grant) = guard.as_ref() else {
|
||||
return current_license_limits_from_status();
|
||||
};
|
||||
let grant = guard.as_ref()?;
|
||||
let now = unix_now().unwrap_or(0) as i64;
|
||||
if grant.grant_expires <= now {
|
||||
return current_license_limits_from_status();
|
||||
}
|
||||
(true, grant.max_circuits)
|
||||
(grant.grant_expires > now).then(|| grant.clone())
|
||||
}
|
||||
|
||||
fn current_license_limits_from_status() -> (bool, Option<u64>) {
|
||||
let (status, _) = crate::lts2_sys::get_lts_license_status();
|
||||
let licensed = !matches!(
|
||||
status,
|
||||
LtsStatus::NotChecked | LtsStatus::ApiOnly | LtsStatus::Invalid
|
||||
);
|
||||
if licensed {
|
||||
(true, None)
|
||||
} else {
|
||||
pub fn current_license_limits() -> (bool, Option<u64>) {
|
||||
let caps = crate::lts2_sys::capabilities::current_capabilities();
|
||||
if caps.mapped_circuit_limit == Some(1000) {
|
||||
(false, None)
|
||||
} else {
|
||||
(true, caps.mapped_circuit_limit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ fn ingestor_loop(rx: std::sync::mpsc::Receiver<IngestorCommand>) {
|
||||
serial += 1;
|
||||
}
|
||||
} else {
|
||||
if !crate::lts2_sys::can_submit_long_term_stats() {
|
||||
continue;
|
||||
}
|
||||
let mut message_queue_lock = message_queue.lock();
|
||||
message_queue_lock.ingest(msg);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,11 @@ impl MessageQueue {
|
||||
}
|
||||
|
||||
pub(crate) fn build_chunks(&mut self) -> Result<Vec<Vec<u8>>> {
|
||||
if !crate::lts2_sys::can_submit_long_term_stats() {
|
||||
self.clear();
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Gather the license info for bundling
|
||||
let (license_key, node_id, node_name) = {
|
||||
let Ok(lock) = load_config() else {
|
||||
@@ -127,7 +132,6 @@ impl MessageQueue {
|
||||
)
|
||||
};
|
||||
let Ok(license_uuid) = Uuid::parse_str(&license_key.replace("-", "")) else {
|
||||
warn!("Failed to parse license key");
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
|
||||
@@ -450,6 +450,9 @@ pub async fn get_lts_license_trial_remaining_async() -> anyhow::Result<i32> {
|
||||
}
|
||||
|
||||
pub fn ingest_batch_complete() -> anyhow::Result<()> {
|
||||
if !crate::lts2_sys::can_submit_long_term_stats() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(tx) = client_commands::get_command_channel()
|
||||
&& tx.send(LtsClientCommand::IngestBatchComplete).is_err()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Provides an interface with Insight/LTS2
|
||||
|
||||
use parking_lot::Mutex;
|
||||
pub mod capabilities;
|
||||
pub mod license_grant;
|
||||
pub(crate) mod lts2_client;
|
||||
pub mod shared_types;
|
||||
@@ -12,6 +13,9 @@ use anyhow::Result;
|
||||
use once_cell::sync::Lazy;
|
||||
pub use shared_types::RemoteCommand;
|
||||
pub mod control_channel;
|
||||
pub use capabilities::{
|
||||
can_open_control_channel, can_submit_long_term_stats, current_capabilities,
|
||||
};
|
||||
|
||||
pub fn start_lts2(
|
||||
control_tx: tokio::sync::mpsc::Sender<control_channel::ControlChannelCommand>,
|
||||
|
||||
@@ -162,18 +162,19 @@ pub struct SiteRtt {
|
||||
#[repr(i32)]
|
||||
pub enum LtsStatus {
|
||||
NotChecked = -1,
|
||||
AlwaysFree = 0,
|
||||
FreeTrial = 1,
|
||||
SelfHosted = 2,
|
||||
ApiOnly = 3,
|
||||
Full = 4,
|
||||
Invalid = 5,
|
||||
Invalid = 0,
|
||||
AlwaysFree = 1,
|
||||
FreeTrial = 2,
|
||||
SelfHosted = 3,
|
||||
ApiOnly = 4,
|
||||
Full = 5,
|
||||
}
|
||||
|
||||
impl LtsStatus {
|
||||
pub fn from_i32(value: i32) -> Self {
|
||||
match value {
|
||||
-1 => LtsStatus::NotChecked,
|
||||
0 => LtsStatus::Invalid,
|
||||
1 => LtsStatus::AlwaysFree,
|
||||
2 => LtsStatus::FreeTrial,
|
||||
3 => LtsStatus::SelfHosted,
|
||||
@@ -182,6 +183,18 @@ impl LtsStatus {
|
||||
_ => LtsStatus::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
LtsStatus::NotChecked => "Not Checked",
|
||||
LtsStatus::Invalid => "Invalid",
|
||||
LtsStatus::AlwaysFree => "Always Free",
|
||||
LtsStatus::FreeTrial => "Free Trial",
|
||||
LtsStatus::SelfHosted => "Self Hosted",
|
||||
LtsStatus::ApiOnly => "Local API",
|
||||
LtsStatus::Full => "Insight",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
||||
+232
-150
@@ -17,6 +17,7 @@ mod remote_commands;
|
||||
mod rtt_exclusions;
|
||||
mod scheduler_control;
|
||||
mod shaped_devices_tracker;
|
||||
mod shaping_runtime;
|
||||
mod stats;
|
||||
mod stick;
|
||||
mod system_stats;
|
||||
@@ -65,8 +66,6 @@ use tracing::{error, info, warn};
|
||||
|
||||
use crate::blackboard::{BLACKBOARD_SENDER, BlackboardCommand};
|
||||
|
||||
use crate::lts2_sys::get_lts_license_status;
|
||||
use crate::lts2_sys::shared_types::LtsStatus;
|
||||
use crate::remote_commands::start_remote_commands;
|
||||
use lqos_stormguard::{STORMGUARD_DEBUG, STORMGUARD_STATS};
|
||||
use tracing::level_filters::LevelFilter;
|
||||
@@ -155,6 +154,18 @@ fn normalize_mapping_request(
|
||||
Ok((base_tc_handle, base_cpu, false))
|
||||
}
|
||||
|
||||
fn record_shaping_failure(
|
||||
state: shaping_runtime::ShapingRuntimeState,
|
||||
summary: impl Into<String>,
|
||||
detail: impl Into<String>,
|
||||
) {
|
||||
let summary = summary.into();
|
||||
let detail = detail.into();
|
||||
error!("{summary}: {detail}");
|
||||
node_manager::add_global_warning(node_manager::WarningLevel::Error, summary.clone());
|
||||
shaping_runtime::mark_error(state, summary, detail);
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Set up logging
|
||||
set_console_logging()?;
|
||||
@@ -178,12 +189,10 @@ fn main() -> Result<()> {
|
||||
// Announce startup
|
||||
info!("LibreQoS Daemon Starting");
|
||||
|
||||
// Run preflight checks
|
||||
preflight_checks::preflight_checks()?;
|
||||
|
||||
// Load config
|
||||
let config = lqos_config::load_config()?;
|
||||
let stick_offset = stick::recompute_stick_offset(&config)?;
|
||||
let web_config = config.clone();
|
||||
shaping_runtime::mark_starting("LibreQoS is starting the web UI and shaping services.");
|
||||
|
||||
ensure_rustls_crypto_provider()?;
|
||||
|
||||
@@ -194,33 +203,17 @@ fn main() -> Result<()> {
|
||||
// Apply Tunings
|
||||
tuning::tune_lqosd_from_config_file()?;
|
||||
|
||||
// Start the flow tracking actor. This has to happen
|
||||
// before the ringbuffer goes live.
|
||||
FlowActor::start()?;
|
||||
|
||||
// Start the XDP/TC kernels
|
||||
let kernels = if config.on_a_stick_mode() {
|
||||
LibreQoSKernels::on_a_stick_mode(
|
||||
config.internet_interface(),
|
||||
config.stick_vlans().1 as u16,
|
||||
config.stick_vlans().0 as u16,
|
||||
stick_offset,
|
||||
Some(heimdall_handle_events),
|
||||
Some(flowbee_handle_events),
|
||||
)?
|
||||
} else {
|
||||
LibreQoSKernels::new(
|
||||
&config.internet_interface(),
|
||||
&config.isp_interface(),
|
||||
Some(heimdall_handle_events),
|
||||
Some(flowbee_handle_events),
|
||||
)?
|
||||
};
|
||||
|
||||
// Start the Bakery for TC command execution
|
||||
let Ok(bakery_sender) = lqos_bakery::start_bakery() else {
|
||||
error!("Failed to start Bakery, exiting.");
|
||||
std::process::exit(1);
|
||||
let bakery_sender = match lqos_bakery::start_bakery() {
|
||||
Ok(sender) => Some(sender),
|
||||
Err(err) => {
|
||||
record_shaping_failure(
|
||||
shaping_runtime::ShapingRuntimeState::Degraded,
|
||||
"LibreQoS could not start the shaping worker.",
|
||||
format!("{err:#}"),
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn tracking sub-systems
|
||||
@@ -235,32 +228,215 @@ fn main() -> Result<()> {
|
||||
} else {
|
||||
info!("Insight client started successfully");
|
||||
}
|
||||
blackboard::start_blackboard();
|
||||
start_remote_commands();
|
||||
let flow_tx = setup_netflow_tracker()?;
|
||||
let _ = throughput_tracker::flow_data::setup_flow_analysis();
|
||||
start_heimdall()?;
|
||||
spawn_queue_structure_monitor()?;
|
||||
shaped_devices_tracker::shaped_devices_watcher()?;
|
||||
shaped_devices_tracker::network_json_watcher()?;
|
||||
shaped_devices_tracker::shaping_inputs_watcher()?;
|
||||
let system_usage_tx = system_stats::start_system_stats()?;
|
||||
throughput_tracker::spawn_throughput_monitor(
|
||||
flow_tx,
|
||||
system_usage_tx.clone(),
|
||||
bakery_sender.clone(),
|
||||
)?;
|
||||
spawn_queue_monitor()?;
|
||||
|
||||
if let Err(err) = treeguard::actor::start_treeguard_actor(system_usage_tx.clone()) {
|
||||
warn!("Failed to start TreeGuard actor: {err}");
|
||||
}
|
||||
|
||||
lqos_sys::bpf_garbage_collector();
|
||||
version_checks::start_version_check()?;
|
||||
|
||||
// Handle signals
|
||||
let mut signals = Signals::new([SIGINT, SIGHUP, SIGTERM])?;
|
||||
let (stormguard_ready_tx, stormguard_ready_rx) = tokio::sync::oneshot::channel::<
|
||||
Option<crossbeam_channel::Sender<lqos_bakery::BakeryCommands>>,
|
||||
>();
|
||||
|
||||
// Create the socket server
|
||||
let server = UnixSocketServer::new().expect("Unable to spawn server");
|
||||
|
||||
// Memory Debugging
|
||||
memory_debug();
|
||||
|
||||
let control_tx_for_webserver = control_tx_for_web.clone();
|
||||
let system_usage_tx_for_web = system_usage_tx.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("Async Bus/Web".to_string())
|
||||
.spawn(move || {
|
||||
let Ok(tokio_runtime) = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
else {
|
||||
error!("Unable to start Tokio runtime. Not much is going to work");
|
||||
return;
|
||||
};
|
||||
tokio_runtime.block_on(async {
|
||||
let probe_client =
|
||||
lqos_probe::ProbeManager::spawn(lqos_probe::ProbeManagerConfig::default());
|
||||
let probe_client_for_stormguard = probe_client.clone();
|
||||
probe_provider::install_probe_client(probe_client.clone());
|
||||
|
||||
tokio::spawn(async move {
|
||||
match lts2_sys::control_channel::start_control_channel(control_channel).await {
|
||||
Ok(_) => info!("Insight control channel started successfully"),
|
||||
Err(e) => error!("Insight control channel failed to start: {:#}", e),
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
match stormguard_ready_rx.await {
|
||||
Ok(Some(bakery_sender_for_async)) => {
|
||||
use tokio::time::{Duration, sleep};
|
||||
for _ in 0..100u32 {
|
||||
if tokio::fs::metadata(lqos_bus::BUS_SOCKET_PATH).await.is_ok() {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if let Some(sender) = lqos_bakery::BAKERY_SENDER.get() {
|
||||
let _ = sender.send(lqos_bakery::BakeryCommands::BusReady);
|
||||
}
|
||||
|
||||
match lqos_stormguard::start_stormguard(
|
||||
bakery_sender_for_async,
|
||||
shaped_devices_tracker::full_network_map_snapshot,
|
||||
probe_client_for_stormguard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => info!("StormGuard started successfully"),
|
||||
Err(e) => error!("StormGuard failed to start: {:#}", e),
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!("StormGuard not started because shaping is degraded.");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("StormGuard startup channel closed before shaping was ready.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (bus_tx, bus_rx) = tokio::sync::mpsc::channel::<(
|
||||
tokio::sync::oneshot::Sender<lqos_bus::BusReply>,
|
||||
BusRequest,
|
||||
)>(100);
|
||||
|
||||
let webserver_disabled = web_config.disable_webserver.unwrap_or(false);
|
||||
if !webserver_disabled {
|
||||
let control_tx_for_webserver = control_tx_for_webserver.clone();
|
||||
let probe_client_for_webserver = probe_client.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = node_manager::spawn_webserver(
|
||||
bus_tx,
|
||||
system_usage_tx_for_web,
|
||||
control_tx_for_webserver,
|
||||
probe_client_for_webserver,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Node Manager Failed: {e:?}");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
warn!("Webserver disabled by configuration");
|
||||
}
|
||||
|
||||
if let Err(e) = server.listen(handle_bus_requests, bus_rx).await {
|
||||
error!("Bus stopped: {e:?}");
|
||||
}
|
||||
});
|
||||
})?;
|
||||
|
||||
version_checks::start_version_check()?;
|
||||
|
||||
let mut kernels = None;
|
||||
let stormguard_ready = if bakery_sender.is_none() {
|
||||
false
|
||||
} else {
|
||||
match preflight_checks::preflight_checks() {
|
||||
Ok(()) => match stick::recompute_stick_offset(&config) {
|
||||
Ok(stick_offset) => match FlowActor::start() {
|
||||
Ok(()) => {
|
||||
let kernel_result = if config.on_a_stick_mode() {
|
||||
LibreQoSKernels::on_a_stick_mode(
|
||||
config.internet_interface(),
|
||||
config.stick_vlans().1 as u16,
|
||||
config.stick_vlans().0 as u16,
|
||||
stick_offset,
|
||||
Some(heimdall_handle_events),
|
||||
Some(flowbee_handle_events),
|
||||
)
|
||||
} else {
|
||||
LibreQoSKernels::new(
|
||||
&config.internet_interface(),
|
||||
&config.isp_interface(),
|
||||
Some(heimdall_handle_events),
|
||||
Some(flowbee_handle_events),
|
||||
)
|
||||
};
|
||||
|
||||
match kernel_result {
|
||||
Ok(started_kernels) => {
|
||||
kernels = Some(started_kernels);
|
||||
blackboard::start_blackboard();
|
||||
start_remote_commands();
|
||||
let flow_tx = setup_netflow_tracker()?;
|
||||
let _ = throughput_tracker::flow_data::setup_flow_analysis();
|
||||
start_heimdall()?;
|
||||
spawn_queue_structure_monitor()?;
|
||||
shaped_devices_tracker::shaped_devices_watcher()?;
|
||||
shaped_devices_tracker::network_json_watcher()?;
|
||||
shaped_devices_tracker::shaping_inputs_watcher()?;
|
||||
throughput_tracker::spawn_throughput_monitor(
|
||||
flow_tx,
|
||||
system_usage_tx.clone(),
|
||||
bakery_sender.clone().expect("checked is_some"),
|
||||
)?;
|
||||
spawn_queue_monitor()?;
|
||||
|
||||
if let Err(err) =
|
||||
treeguard::actor::start_treeguard_actor(system_usage_tx.clone())
|
||||
{
|
||||
warn!("Failed to start TreeGuard actor: {err}");
|
||||
}
|
||||
|
||||
lqos_sys::bpf_garbage_collector();
|
||||
shaping_runtime::mark_active(
|
||||
"LibreQoS shaping is active and the web UI is available.",
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
record_shaping_failure(
|
||||
shaping_runtime::ShapingRuntimeState::ErrorKernelAttach,
|
||||
"LibreQoS started the web UI, but kernel attach failed.",
|
||||
format!("{err:#}"),
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
record_shaping_failure(
|
||||
shaping_runtime::ShapingRuntimeState::Degraded,
|
||||
"LibreQoS started the web UI, but shaping runtime initialization failed.",
|
||||
format!("{err:#}"),
|
||||
);
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
record_shaping_failure(
|
||||
shaping_runtime::ShapingRuntimeState::ErrorConfig,
|
||||
"LibreQoS started the web UI, but the shaping configuration is invalid.",
|
||||
format!("{err:#}"),
|
||||
);
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
let detail = format!("{err:#}");
|
||||
record_shaping_failure(
|
||||
shaping_runtime::classify_preflight_error(&detail),
|
||||
"LibreQoS started the web UI, but shaping preflight failed.",
|
||||
detail,
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = stormguard_ready_tx.send(if stormguard_ready {
|
||||
bakery_sender.clone()
|
||||
} else {
|
||||
None
|
||||
});
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("Signal Handler".to_string())
|
||||
.spawn(move || {
|
||||
@@ -292,96 +468,6 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
// Create the socket server
|
||||
let server = UnixSocketServer::new().expect("Unable to spawn server");
|
||||
|
||||
// Memory Debugging
|
||||
memory_debug();
|
||||
|
||||
let bakery_sender_for_async = bakery_sender.clone();
|
||||
let control_tx_for_webserver = control_tx_for_web.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("Async Bus/Web".to_string())
|
||||
.spawn(move || {
|
||||
let Ok(tokio_runtime) = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
else {
|
||||
error!("Unable to start Tokio runtime. Not much is going to work");
|
||||
return;
|
||||
};
|
||||
tokio_runtime.block_on(async {
|
||||
// Notify bakery when the bus socket becomes available
|
||||
tokio::spawn(async move {
|
||||
use tokio::time::{Duration, sleep};
|
||||
// Wait up to ~5 seconds for the socket to appear
|
||||
for _ in 0..100u32 {
|
||||
if tokio::fs::metadata(lqos_bus::BUS_SOCKET_PATH).await.is_ok() {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if let Some(sender) = lqos_bakery::BAKERY_SENDER.get() {
|
||||
let _ = sender.send(lqos_bakery::BakeryCommands::BusReady);
|
||||
}
|
||||
});
|
||||
|
||||
let probe_client =
|
||||
lqos_probe::ProbeManager::spawn(lqos_probe::ProbeManagerConfig::default());
|
||||
let probe_client_for_stormguard = probe_client.clone();
|
||||
probe_provider::install_probe_client(probe_client.clone());
|
||||
|
||||
tokio::spawn(async move {
|
||||
match lts2_sys::control_channel::start_control_channel(control_channel).await {
|
||||
Ok(_) => info!("Insight control channel started successfully"),
|
||||
Err(e) => error!("Insight control channel failed to start: {:#}", e),
|
||||
}
|
||||
|
||||
match lqos_stormguard::start_stormguard(
|
||||
bakery_sender_for_async,
|
||||
shaped_devices_tracker::full_network_map_snapshot,
|
||||
probe_client_for_stormguard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => info!("StormGuard started successfully"),
|
||||
Err(e) => error!("StormGuard failed to start: {:#}", e),
|
||||
}
|
||||
});
|
||||
|
||||
let (bus_tx, bus_rx) = tokio::sync::mpsc::channel::<(
|
||||
tokio::sync::oneshot::Sender<lqos_bus::BusReply>,
|
||||
BusRequest,
|
||||
)>(100);
|
||||
|
||||
// Webserver starting point
|
||||
let webserver_disabled = config.disable_webserver.unwrap_or(false);
|
||||
if !webserver_disabled {
|
||||
let control_tx_for_webserver = control_tx_for_webserver.clone();
|
||||
let probe_client_for_webserver = probe_client.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = node_manager::spawn_webserver(
|
||||
bus_tx,
|
||||
system_usage_tx,
|
||||
control_tx_for_webserver,
|
||||
probe_client_for_webserver,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Node Manager Failed: {e:?}");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
warn!("Webserver disabled by configuration");
|
||||
}
|
||||
|
||||
// Main bus listen loop
|
||||
if let Err(e) = server.listen(handle_bus_requests, bus_rx).await {
|
||||
error!("Bus stopped: {e:?}");
|
||||
}
|
||||
});
|
||||
})?;
|
||||
let _ = handle.join();
|
||||
warn!("Main thread exiting");
|
||||
Ok(())
|
||||
@@ -1068,12 +1154,8 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
|
||||
.collect();
|
||||
BusResponse::SearchResults(results)
|
||||
}
|
||||
BusRequest::CheckInsight => {
|
||||
let (status, _) = get_lts_license_status();
|
||||
match status {
|
||||
LtsStatus::Invalid | LtsStatus::NotChecked => BusResponse::InsightStatus(false),
|
||||
_ => BusResponse::InsightStatus(true)
|
||||
}
|
||||
BusRequest::GetLtsCapabilities => {
|
||||
BusResponse::LtsCapabilitiesSummary(crate::lts2_sys::current_capabilities())
|
||||
}
|
||||
BusRequest::GetInsightLicenseSummary => {
|
||||
let (licensed, max_circuits) =
|
||||
|
||||
@@ -4,12 +4,15 @@ import { get_ws_client } from "./pubsub/ws";
|
||||
const log = document.getElementById('chatLog');
|
||||
const input = document.getElementById('chatInput');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const insightEnabled = typeof window.hasInsight !== 'undefined' ? !!window.hasInsight : false;
|
||||
const chatbotEnabled = typeof window.hasChatbot !== 'undefined' ? !!window.hasChatbot : false;
|
||||
const liveControlAvailable = typeof window.liveControlAvailable !== 'undefined'
|
||||
? !!window.liveControlAvailable
|
||||
: false;
|
||||
|
||||
function scrollToBottom() { log.scrollTop = log.scrollHeight; }
|
||||
|
||||
function ensureInsightNotice() {
|
||||
if (insightEnabled) return;
|
||||
if (chatbotEnabled && liveControlAvailable) return;
|
||||
const chatWrap = document.querySelector('.chat-wrap');
|
||||
const chatLog = document.getElementById('chatLog');
|
||||
if (!chatWrap || !chatLog) return;
|
||||
@@ -23,8 +26,12 @@ function ensureInsightNotice() {
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-2 text-info"><i class="fa fa-circle-info fa-lg"></i></div>
|
||||
<div>
|
||||
<div class="fw-semibold">Libby is an Insight feature.</div>
|
||||
<a class="alert-link" href="lts_trial.html">Start an Insight free trial</a> to enable Libby on this node.
|
||||
<div class="fw-semibold">${
|
||||
chatbotEnabled
|
||||
? "License valid, control service unavailable."
|
||||
: "Libby requires an entitled license."
|
||||
}</div>
|
||||
<a class="alert-link" href="config_lts.html">Open License & Services</a> to review availability for this node.
|
||||
</div>
|
||||
</div>`;
|
||||
chatWrap.insertBefore(notice, chatLog);
|
||||
@@ -37,14 +44,18 @@ function ensureInsightNotice() {
|
||||
ensureInsightNotice();
|
||||
|
||||
function disableChatWhenUnavailable() {
|
||||
if (insightEnabled) return;
|
||||
if (chatbotEnabled && liveControlAvailable) return;
|
||||
if (input) {
|
||||
input.disabled = true;
|
||||
input.placeholder = 'Insight required to chat with Libby';
|
||||
input.placeholder = chatbotEnabled
|
||||
? 'license valid, control service unavailable'
|
||||
: 'An entitled license is required to chat with Libby';
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.title = 'Enable Insight to chat with Libby';
|
||||
sendBtn.title = chatbotEnabled
|
||||
? 'license valid, control service unavailable'
|
||||
: 'Enable an entitled license to chat with Libby';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +238,7 @@ if (insightEnabled) {
|
||||
appendSys("Connected to Libby");
|
||||
client.send({ Private: { Chatbot: { browser_ts_ms: Date.now() } } });
|
||||
} else {
|
||||
appendSys("Libby requires an active Insight subscription. Start a free trial to enable chat.");
|
||||
appendSys("Libby requires an entitled license. Open License & Services to enable chat.");
|
||||
}
|
||||
|
||||
function sendText() {
|
||||
|
||||
@@ -20,7 +20,10 @@ class TimeControls {
|
||||
button.classList.add("btn", "btn-sm", "me-1");
|
||||
button.onclick = () => {
|
||||
if (period !== "Live" && !window.hasLts) {
|
||||
createAndShowModal('Extended Time Periods Require Insight', 'Displaying extended time periods requires an Insight subscription or free trial. Click the "Insight" button in the menu to learn more. Invest in your network --- sign up for Insight today!');
|
||||
createAndShowModal(
|
||||
"Extended History Requires an Entitled License",
|
||||
'Displaying extended time periods requires an entitled license with history access. Open "License & Services" in the menu to review availability for this node.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { get_ws_client } from "../pubsub/ws";
|
||||
const wsClient = get_ws_client();
|
||||
const secretBindings = [];
|
||||
|
||||
function sendWsRequest(responseEvent, request, onComplete, onError) {
|
||||
export function sendWsRequest(responseEvent, request, onComplete, onError) {
|
||||
let done = false;
|
||||
const responseHandler = (msg) => {
|
||||
if (done) return;
|
||||
@@ -632,11 +632,11 @@ export function renderConfigMenu(currentPage) {
|
||||
{ href: "config_general.html", icon: "fa-server", text: "General", id: "general" },
|
||||
{ href: "config_rtt.html", icon: "fa-stopwatch", text: "RTT Thresholds", id: "rtt" },
|
||||
{ href: "config_tuning.html", icon: "fa-warning", text: "Tuning", id: "tuning" },
|
||||
{ href: "config_interface.html", icon: "fa-chain", text: "Network Mode", id: "interface" },
|
||||
{ href: "config_interface.html", icon: "fa-chain", text: "Bridge & Interface Mode", id: "interface" },
|
||||
{ href: "config_queues.html", icon: "fa-car", text: "Queues", id: "queues" },
|
||||
{ href: "config_stormguard.html", icon: "fa-bolt", text: "StormGuard", id: "stormguard" },
|
||||
{ href: "config_treeguard.html", icon: "fa-shield-halved", text: "TreeGuard", id: "treeguard" },
|
||||
{ href: "config_lts.html", icon: "fa-line-chart", text: "LibreQoS Insight", id: "lts" },
|
||||
{ href: "config_lts.html", icon: "fa-line-chart", text: "License & Services", id: "lts" },
|
||||
{ href: "config_iprange.html", icon: "fa-address-card", text: "IP Ranges", id: "iprange" },
|
||||
{ href: "config_flows.html", icon: "fa-arrow-circle-down", text: "Flow Tracking", id: "flows" },
|
||||
{ href: "config_integration.html", icon: "fa-link", text: "Integration - Common", id: "integration" },
|
||||
|
||||
@@ -1,21 +1,92 @@
|
||||
import {saveConfig, loadConfig, renderConfigMenu} from "./config/config_helper";
|
||||
import {loadConfig, renderConfigMenu} from "./config/config_helper";
|
||||
|
||||
const DRAFT_KEY = "lqos-network-mode-draft";
|
||||
const PENDING_OPERATION_KEY = "lqos-network-mode-pending";
|
||||
const NETPLAN_TRY_TIMEOUT_MS = 30_000;
|
||||
const RECONNECT_POLL_INTERVAL_MS = 2_000;
|
||||
let currentHelperStatus = null;
|
||||
let currentInspection = null;
|
||||
let currentPendingOperation = null;
|
||||
let reconnectPollTimer = null;
|
||||
let reconnectProbeInFlight = false;
|
||||
let recoveryCountdownTimer = null;
|
||||
let recoveryReachable = true;
|
||||
|
||||
function selectedInterfaceValuesFromConfig(config) {
|
||||
return {
|
||||
toInternet: config?.bridge?.to_internet ?? "",
|
||||
toNetwork: config?.bridge?.to_network ?? "",
|
||||
singleInterface: config?.single_interface?.interface ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function cloneConfig(config) {
|
||||
return JSON.parse(JSON.stringify(config || {}));
|
||||
}
|
||||
|
||||
function configModeKind(config) {
|
||||
if (config?.bridge?.use_xdp_bridge) return "xdp";
|
||||
if (config?.bridge) return "bridge";
|
||||
if (config?.single_interface) return "single";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function getJson(url) {
|
||||
return fetch(url, {
|
||||
headers: { "Accept": "application/json" },
|
||||
credentials: "same-origin",
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(body || `Request failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
function postJson(url, body) {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(body),
|
||||
}).then(async (response) => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Request failed: ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
function validateConfig() {
|
||||
if (document.getElementById('bridgeMode').checked) {
|
||||
// Validate bridge mode fields
|
||||
const toInternet = document.getElementById('toInternet').value.trim();
|
||||
const toNetwork = document.getElementById('toNetwork').value.trim();
|
||||
|
||||
if (document.getElementById("bridgeMode").checked) {
|
||||
const toInternet = document.getElementById("toInternet").value.trim();
|
||||
const toNetwork = document.getElementById("toNetwork").value.trim();
|
||||
if (!toInternet || !toNetwork) {
|
||||
alert("Both interface names are required in bridge mode");
|
||||
return false;
|
||||
}
|
||||
} else if (document.getElementById('singleInterfaceMode').checked) {
|
||||
// Validate single interface mode fields
|
||||
const interfaceName = document.getElementById('interface').value.trim();
|
||||
const internetVlan = parseInt(document.getElementById('internetVlan').value);
|
||||
const networkVlan = parseInt(document.getElementById('networkVlan').value);
|
||||
|
||||
if (toInternet === toNetwork) {
|
||||
alert("Bridge mode requires two different interfaces");
|
||||
return false;
|
||||
}
|
||||
} else if (document.getElementById("singleInterfaceMode").checked) {
|
||||
const interfaceName = document.getElementById("interface").value.trim();
|
||||
const internetVlan = parseInt(document.getElementById("internetVlan").value, 10);
|
||||
const networkVlan = parseInt(document.getElementById("networkVlan").value, 10);
|
||||
if (!interfaceName) {
|
||||
alert("Interface name is required in single interface mode");
|
||||
return false;
|
||||
@@ -35,70 +106,773 @@ function validateConfig() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateConfig() {
|
||||
// Clear both sections first
|
||||
window.config.bridge = null;
|
||||
window.config.single_interface = null;
|
||||
function buildCandidateConfig() {
|
||||
const next = cloneConfig(window.config);
|
||||
next.bridge = null;
|
||||
next.single_interface = null;
|
||||
|
||||
if (document.getElementById('bridgeMode').checked) {
|
||||
// Update bridge configuration
|
||||
window.config.bridge = {
|
||||
use_xdp_bridge: document.getElementById('useXdpBridge').checked,
|
||||
to_internet: document.getElementById('toInternet').value.trim(),
|
||||
to_network: document.getElementById('toNetwork').value.trim()
|
||||
if (document.getElementById("bridgeMode").checked) {
|
||||
next.bridge = {
|
||||
use_xdp_bridge: document.getElementById("useXdpBridge").checked,
|
||||
to_internet: document.getElementById("toInternet").value.trim(),
|
||||
to_network: document.getElementById("toNetwork").value.trim(),
|
||||
};
|
||||
} else {
|
||||
// Update single interface configuration
|
||||
window.config.single_interface = {
|
||||
interface: document.getElementById('interface').value.trim(),
|
||||
internet_vlan: parseInt(document.getElementById('internetVlan').value),
|
||||
network_vlan: parseInt(document.getElementById('networkVlan').value)
|
||||
next.single_interface = {
|
||||
interface: document.getElementById("interface").value.trim(),
|
||||
internet_vlan: parseInt(document.getElementById("internetVlan").value, 10),
|
||||
network_vlan: parseInt(document.getElementById("networkVlan").value, 10),
|
||||
};
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function interfaceCandidates() {
|
||||
return Array.isArray(currentInspection?.interface_candidates) ? currentInspection.interface_candidates : [];
|
||||
}
|
||||
|
||||
function optionLabel(candidate, selectedValue) {
|
||||
if (!candidate) return selectedValue;
|
||||
if (candidate.bridge_eligible || candidate.single_interface_eligible) {
|
||||
return candidate.name;
|
||||
}
|
||||
if (candidate.current_selection || candidate.name === selectedValue) {
|
||||
return `${candidate.name} (current selection; unavailable)`;
|
||||
}
|
||||
return `${candidate.name} (unavailable)`;
|
||||
}
|
||||
|
||||
function buildSelectOptions(selectElement, modeKey, selectedValue, excludedValue = null) {
|
||||
if (!selectElement) return;
|
||||
const eligibilityField = modeKey === "single" ? "single_interface_eligible" : "bridge_eligible";
|
||||
const candidates = interfaceCandidates();
|
||||
const options = [`<option value="">Select an eligible interface</option>`];
|
||||
const seen = new Set();
|
||||
|
||||
candidates.forEach((candidate) => {
|
||||
const selected = candidate.name === selectedValue;
|
||||
const eligible = Boolean(candidate[eligibilityField]);
|
||||
if (!eligible && !selected) return;
|
||||
if (excludedValue && candidate.name === excludedValue && !selected) return;
|
||||
options.push(
|
||||
`<option value="${escapeHtml(candidate.name)}">${escapeHtml(optionLabel(candidate, selectedValue))}</option>`
|
||||
);
|
||||
seen.add(candidate.name);
|
||||
});
|
||||
|
||||
if (selectedValue && !seen.has(selectedValue)) {
|
||||
options.push(
|
||||
`<option value="${escapeHtml(selectedValue)}">${escapeHtml(`${selectedValue} (current selection; unavailable)`)}</option>`
|
||||
);
|
||||
}
|
||||
|
||||
selectElement.innerHTML = options.join("");
|
||||
selectElement.value = selectedValue || "";
|
||||
}
|
||||
|
||||
function renderInterfaceHelp(helpElementId, modeKey, selectedValues = {}) {
|
||||
const element = document.getElementById(helpElementId);
|
||||
if (!element) return;
|
||||
|
||||
const eligibilityField = modeKey === "single" ? "single_interface_eligible" : "bridge_eligible";
|
||||
const selectedSet = new Set(
|
||||
Object.values(selectedValues)
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
const unavailable = interfaceCandidates().filter((candidate) => {
|
||||
if (candidate[eligibilityField]) return false;
|
||||
return !selectedSet.has(candidate.name);
|
||||
});
|
||||
|
||||
if (unavailable.length === 0) {
|
||||
element.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
element.innerHTML = `
|
||||
<details class="lqos-config-disclosure mt-2">
|
||||
<summary>Why some interfaces are unavailable</summary>
|
||||
<ul class="mb-0 mt-2">
|
||||
${unavailable.map((candidate) => {
|
||||
const reason = Array.isArray(candidate.details) && candidate.details.length > 0
|
||||
? candidate.details.join(" ")
|
||||
: "Unavailable for managed LibreQoS setup.";
|
||||
return `<li><code>${escapeHtml(candidate.name)}</code>: ${escapeHtml(reason)}</li>`;
|
||||
}).join("")}
|
||||
</ul>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function setReviewTab(tabName) {
|
||||
document.querySelectorAll("[data-review-tab]").forEach((button) => {
|
||||
const active = button.dataset.reviewTab === tabName;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", active ? "true" : "false");
|
||||
});
|
||||
document.querySelectorAll("[data-review-pane]").forEach((pane) => {
|
||||
pane.classList.toggle("d-none", pane.dataset.reviewPane !== tabName);
|
||||
});
|
||||
}
|
||||
|
||||
function wireReviewTabs() {
|
||||
document.querySelectorAll("[data-review-tab]").forEach((button) => {
|
||||
if (button.dataset.wired === "true") return;
|
||||
button.dataset.wired = "true";
|
||||
button.addEventListener("click", () => {
|
||||
setReviewTab(button.dataset.reviewTab || "files");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInterfaceSelectors(config = null) {
|
||||
const selected = selectedInterfaceValuesFromConfig(config || buildCandidateConfig());
|
||||
buildSelectOptions(document.getElementById("toInternet"), "bridge", selected.toInternet, selected.toNetwork);
|
||||
buildSelectOptions(document.getElementById("toNetwork"), "bridge", selected.toNetwork, selected.toInternet);
|
||||
buildSelectOptions(document.getElementById("interface"), "single", selected.singleInterface);
|
||||
renderInterfaceHelp("bridgeInterfaceHelp", "bridge", {
|
||||
toInternet: selected.toInternet,
|
||||
toNetwork: selected.toNetwork,
|
||||
});
|
||||
renderInterfaceHelp("singleInterfaceHelp", "single", {
|
||||
interface: selected.singleInterface,
|
||||
});
|
||||
}
|
||||
|
||||
function populateFormFromConfig(config) {
|
||||
renderInterfaceSelectors(config);
|
||||
if (config?.bridge) {
|
||||
document.getElementById("bridgeMode").checked = true;
|
||||
document.getElementById("useXdpBridge").checked = config.bridge.use_xdp_bridge ?? true;
|
||||
document.getElementById("toInternet").value = config.bridge.to_internet ?? "";
|
||||
document.getElementById("toNetwork").value = config.bridge.to_network ?? "";
|
||||
} else if (config?.single_interface) {
|
||||
document.getElementById("singleInterfaceMode").checked = true;
|
||||
document.getElementById("interface").value = config.single_interface.interface ?? "";
|
||||
document.getElementById("internetVlan").value = config.single_interface.internet_vlan ?? 2;
|
||||
document.getElementById("networkVlan").value = config.single_interface.network_vlan ?? 3;
|
||||
}
|
||||
|
||||
const event = new Event("change");
|
||||
document.querySelector('input[name="networkMode"]:checked')?.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the configuration menu
|
||||
renderConfigMenu('interface');
|
||||
function saveDraft() {
|
||||
const draft = buildCandidateConfig();
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
|
||||
return draft;
|
||||
}
|
||||
|
||||
loadConfig(() => {
|
||||
// window.config now contains the configuration.
|
||||
// Populate form fields with config values
|
||||
if (window.config) {
|
||||
// Determine which mode is active
|
||||
if (window.config.bridge) {
|
||||
// Bridge mode
|
||||
document.getElementById('bridgeMode').checked = true;
|
||||
document.getElementById('useXdpBridge').checked =
|
||||
window.config.bridge.use_xdp_bridge ?? true;
|
||||
document.getElementById('toInternet').value =
|
||||
window.config.bridge.to_internet ?? "eth0";
|
||||
document.getElementById('toNetwork').value =
|
||||
window.config.bridge.to_network ?? "eth1";
|
||||
} else if (window.config.single_interface) {
|
||||
// Single interface mode
|
||||
document.getElementById('singleInterfaceMode').checked = true;
|
||||
document.getElementById('interface').value =
|
||||
window.config.single_interface.interface ?? "eth0";
|
||||
document.getElementById('internetVlan').value =
|
||||
window.config.single_interface.internet_vlan ?? 2;
|
||||
document.getElementById('networkVlan').value =
|
||||
window.config.single_interface.network_vlan ?? 3;
|
||||
function loadPendingOperation() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_OPERATION_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pendingDeadlineMs(operation) {
|
||||
if (!operation) return null;
|
||||
if (Number.isFinite(operation.created_unix)) {
|
||||
return (operation.created_unix * 1000) + NETPLAN_TRY_TIMEOUT_MS;
|
||||
}
|
||||
if (Number.isFinite(operation.saved_at_ms)) {
|
||||
return operation.saved_at_ms + NETPLAN_TRY_TIMEOUT_MS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCountdown(remainingMs) {
|
||||
if (!Number.isFinite(remainingMs)) {
|
||||
return "Waiting for reconnect status...";
|
||||
}
|
||||
if (remainingMs <= 0) {
|
||||
return "Rollback deadline reached. Waiting for LibreQoS to report the final state.";
|
||||
}
|
||||
const totalSeconds = Math.ceil(remainingMs / 1000);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
return `Confirm or revert within ${minutes}:${String(seconds).padStart(2, "0")}.`;
|
||||
}
|
||||
|
||||
function setPendingOperation(operation, extra = {}) {
|
||||
if (!operation?.operation_id) return;
|
||||
currentPendingOperation = {
|
||||
...currentPendingOperation,
|
||||
...operation,
|
||||
...extra,
|
||||
saved_at_ms: Date.now(),
|
||||
};
|
||||
localStorage.setItem(PENDING_OPERATION_KEY, JSON.stringify(currentPendingOperation));
|
||||
renderRecoveryPanel();
|
||||
ensureRecoveryPolling();
|
||||
}
|
||||
|
||||
function clearPendingOperation() {
|
||||
currentPendingOperation = null;
|
||||
localStorage.removeItem(PENDING_OPERATION_KEY);
|
||||
if (reconnectPollTimer) {
|
||||
clearInterval(reconnectPollTimer);
|
||||
reconnectPollTimer = null;
|
||||
}
|
||||
if (recoveryCountdownTimer) {
|
||||
clearInterval(recoveryCountdownTimer);
|
||||
recoveryCountdownTimer = null;
|
||||
}
|
||||
renderRecoveryPanel();
|
||||
}
|
||||
|
||||
function setRecoveryReachable(isReachable) {
|
||||
recoveryReachable = Boolean(isReachable);
|
||||
renderRecoveryPanel();
|
||||
}
|
||||
|
||||
function activePendingOperationId() {
|
||||
return currentHelperStatus?.pending_operation?.operation_id || currentPendingOperation?.operation_id || null;
|
||||
}
|
||||
|
||||
function renderRecoveryPanel() {
|
||||
const panel = document.getElementById("networkRecoveryPanel");
|
||||
const summary = document.getElementById("networkRecoverySummary");
|
||||
const detail = document.getElementById("networkRecoveryDetail");
|
||||
const countdown = document.getElementById("networkRecoveryCountdown");
|
||||
const confirmButton = document.getElementById("recoveryConfirmButton");
|
||||
const revertButton = document.getElementById("recoveryRevertButton");
|
||||
if (!panel || !summary || !detail || !countdown || !confirmButton || !revertButton) return;
|
||||
|
||||
const pending = currentPendingOperation;
|
||||
if (!pending?.operation_id) {
|
||||
panel.classList.add("d-none");
|
||||
confirmButton.disabled = true;
|
||||
revertButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
panel.classList.remove("d-none");
|
||||
panel.classList.toggle("alert-warning", !recoveryReachable);
|
||||
panel.classList.toggle("alert-success", recoveryReachable);
|
||||
|
||||
const deadline = pendingDeadlineMs(pending);
|
||||
const remainingMs = deadline == null ? null : deadline - Date.now();
|
||||
const summaryText = pending.summary || "LibreQoS staged a network change and is waiting for confirmation.";
|
||||
const reconnectText = recoveryReachable
|
||||
? "LibreQoS is reachable again. Confirm the network change if access looks correct, or revert it if not."
|
||||
: "Connection is temporarily unavailable while LibreQoS tests the network change. This page will keep polling and show the confirm buttons again as soon as the box comes back.";
|
||||
|
||||
summary.textContent = summaryText;
|
||||
detail.textContent = `${reconnectText} Pending operation: ${pending.operation_id}`;
|
||||
countdown.textContent = formatCountdown(remainingMs);
|
||||
|
||||
confirmButton.disabled = !recoveryReachable;
|
||||
revertButton.disabled = !recoveryReachable;
|
||||
}
|
||||
|
||||
function ensureRecoveryPolling() {
|
||||
if (!currentPendingOperation?.operation_id) return;
|
||||
if (!recoveryCountdownTimer) {
|
||||
recoveryCountdownTimer = setInterval(() => {
|
||||
renderRecoveryPanel();
|
||||
}, 1000);
|
||||
}
|
||||
if (!reconnectPollTimer) {
|
||||
reconnectPollTimer = setInterval(() => {
|
||||
pollRecoveryStatus();
|
||||
}, RECONNECT_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function probeHealth() {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
return fetch("/health", {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((response) => response.ok)
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
}
|
||||
|
||||
function pollRecoveryStatus() {
|
||||
if (!currentPendingOperation?.operation_id || reconnectProbeInFlight) return;
|
||||
reconnectProbeInFlight = true;
|
||||
probeHealth()
|
||||
.then((reachable) => {
|
||||
setRecoveryReachable(reachable);
|
||||
if (!reachable) return null;
|
||||
return refreshHelperStatus();
|
||||
})
|
||||
.catch(() => {
|
||||
setRecoveryReachable(false);
|
||||
})
|
||||
.finally(() => {
|
||||
reconnectProbeInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
function badgeClassForShapingState(state) {
|
||||
switch (state) {
|
||||
case "Active":
|
||||
return "text-bg-success";
|
||||
case "Starting":
|
||||
return "text-bg-info";
|
||||
case "Inactive":
|
||||
return "text-bg-secondary";
|
||||
case "ErrorPreflight":
|
||||
case "ErrorKernelAttach":
|
||||
case "ErrorInterfaceMissing":
|
||||
case "ErrorConfig":
|
||||
return "text-bg-danger";
|
||||
case "Degraded":
|
||||
return "text-bg-warning";
|
||||
default:
|
||||
return "text-bg-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function badgeClassForInspectionState(state) {
|
||||
switch (state) {
|
||||
case "ManagedByLibreQoS":
|
||||
return "text-bg-success";
|
||||
case "ExternalCompatible":
|
||||
return "text-bg-info";
|
||||
case "Ready":
|
||||
return "text-bg-primary";
|
||||
case "PendingTry":
|
||||
return "text-bg-warning";
|
||||
case "Missing":
|
||||
case "Conflict":
|
||||
case "ComplexUnsupported":
|
||||
return "text-bg-danger";
|
||||
default:
|
||||
return "text-bg-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlertList(containerId, tone, messages) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
container.innerHTML = messages
|
||||
.map((message) => `<div class="alert alert-${tone} py-2 mb-2">${escapeHtml(message)}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderShapingStatus(status) {
|
||||
const badge = document.getElementById("shapingStatusBadge");
|
||||
const summary = document.getElementById("shapingStatusSummary");
|
||||
const detail = document.getElementById("shapingStatusDetail");
|
||||
const banner = document.getElementById("shapingBanner");
|
||||
const retryButton = document.getElementById("retryShapingButton");
|
||||
if (!badge || !summary || !detail || !banner || !retryButton) return;
|
||||
|
||||
const state = status?.state || "Unknown";
|
||||
badge.className = `badge ${badgeClassForShapingState(state)}`;
|
||||
badge.textContent = state;
|
||||
summary.textContent = status?.summary || "No shaping status available.";
|
||||
|
||||
if (status?.detail) {
|
||||
detail.classList.remove("d-none");
|
||||
detail.textContent = status.detail;
|
||||
} else {
|
||||
detail.classList.add("d-none");
|
||||
detail.textContent = "";
|
||||
}
|
||||
|
||||
banner.innerHTML = status?.degraded
|
||||
? `<div class="alert alert-danger" role="alert"><strong>Shaping is degraded.</strong> ${escapeHtml(status.summary || "")}</div>`
|
||||
: "";
|
||||
|
||||
const canRetry = Boolean(status?.can_retry);
|
||||
retryButton.disabled = !canRetry;
|
||||
retryButton.classList.toggle("d-none", !canRetry);
|
||||
}
|
||||
|
||||
function renderDetectedFiles(files) {
|
||||
const container = document.getElementById("netplanFiles");
|
||||
if (!container) return;
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
container.innerHTML = `<div class="text-secondary">No relevant netplan files were detected for the current selection.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = files.map((file) => {
|
||||
const details = Array.isArray(file.details) && file.details.length > 0
|
||||
? `<ul class="mb-0 mt-2">${file.details.map((detail) => `<li>${escapeHtml(detail)}</li>`).join("")}</ul>`
|
||||
: `<div class="text-secondary mt-2">No extra details.</div>`;
|
||||
const interfaces = Array.isArray(file.relevant_interfaces) && file.relevant_interfaces.length > 0
|
||||
? file.relevant_interfaces.map((iface) => `<code>${escapeHtml(iface)}</code>`).join(", ")
|
||||
: "None";
|
||||
const badgeClass = file.compatible ? "text-bg-success" : badgeClassForInspectionState(file.classification);
|
||||
return `
|
||||
<div class="border rounded p-3 mb-3">
|
||||
<div class="d-flex justify-content-between flex-wrap gap-2 align-items-center">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(file.path)}</div>
|
||||
<div class="text-secondary">Relevant interfaces: ${interfaces}</div>
|
||||
</div>
|
||||
<span class="badge ${badgeClass}">${escapeHtml(file.classification)}</span>
|
||||
</div>
|
||||
${details}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderInspection(inspection) {
|
||||
currentInspection = inspection || null;
|
||||
renderInterfaceSelectors();
|
||||
const badge = document.getElementById("netplanStateBadge");
|
||||
const summary = document.getElementById("netplanSummary");
|
||||
const preview = document.getElementById("managedPreview");
|
||||
const previewMeta = document.getElementById("managedPreviewMeta");
|
||||
const previewNote = document.getElementById("managedPreviewNote");
|
||||
const diffPreview = document.getElementById("diffPreview");
|
||||
const diffPreviewMeta = document.getElementById("diffPreviewMeta");
|
||||
const applyButton = document.getElementById("applyButton");
|
||||
const adoptButton = document.getElementById("adoptButton");
|
||||
const takeoverButton = document.getElementById("takeoverButton");
|
||||
if (!badge || !summary || !preview || !previewMeta || !previewNote || !diffPreview || !diffPreviewMeta || !applyButton || !adoptButton || !takeoverButton) return;
|
||||
|
||||
const state = inspection?.inspector_state || "Unknown";
|
||||
badge.className = `badge ${badgeClassForInspectionState(state)}`;
|
||||
badge.textContent = state;
|
||||
summary.textContent = inspection?.summary || "No netplan inspection data available.";
|
||||
|
||||
const warnings = Array.isArray(inspection?.warnings) ? [...inspection.warnings] : [];
|
||||
const dangerousChanges = Array.isArray(inspection?.dangerous_changes) ? inspection.dangerous_changes : [];
|
||||
if (dangerousChanges.length > 0) {
|
||||
warnings.unshift(...dangerousChanges);
|
||||
}
|
||||
renderAlertList("netplanWarnings", dangerousChanges.length > 0 ? "warning" : "warning", warnings);
|
||||
renderAlertList("netplanConflicts", "danger", inspection?.conflicts || []);
|
||||
renderDetectedFiles(inspection?.detected_files || []);
|
||||
|
||||
previewMeta.innerHTML = `
|
||||
<div><strong>Mode:</strong> ${escapeHtml(inspection?.mode_label || "Unknown")}</div>
|
||||
<div><strong>Managed file:</strong> <code>${escapeHtml(inspection?.managed_file_path || "/etc/netplan/libreqos.yaml")}</code></div>`;
|
||||
previewNote.innerHTML = [
|
||||
inspection?.strong_confirmation_text
|
||||
? `<div class="alert alert-danger py-2">${escapeHtml(inspection.strong_confirmation_text)}</div>`
|
||||
: "",
|
||||
inspection?.preview_note
|
||||
? `<div class="alert alert-info py-2">${escapeHtml(inspection.preview_note)}</div>`
|
||||
: "",
|
||||
].join("");
|
||||
preview.textContent = inspection?.managed_preview_yaml || "";
|
||||
diffPreviewMeta.innerHTML = inspection?.diff_preview_label
|
||||
? `<div><strong>Diff:</strong> ${escapeHtml(inspection.diff_preview_label)}</div>`
|
||||
: `<div class="text-secondary">No diff preview available.</div>`;
|
||||
diffPreview.textContent = inspection?.diff_preview || "No diff preview available.";
|
||||
|
||||
[
|
||||
"bridgeMode",
|
||||
"singleInterfaceMode",
|
||||
"useXdpBridge",
|
||||
"toInternet",
|
||||
"toNetwork",
|
||||
"interface",
|
||||
"internetVlan",
|
||||
"networkVlan",
|
||||
"saveButton",
|
||||
].forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.disabled = Boolean(inspection?.editing_locked);
|
||||
}
|
||||
|
||||
// Trigger form visibility update
|
||||
const event = new Event('change');
|
||||
document.querySelector('input[name="networkMode"]:checked')?.dispatchEvent(event);
|
||||
});
|
||||
applyButton.disabled = !inspection?.can_apply;
|
||||
adoptButton.disabled = !inspection?.can_adopt;
|
||||
takeoverButton.disabled = !inspection?.can_take_over;
|
||||
}
|
||||
|
||||
// Add save button click handler
|
||||
document.getElementById('saveButton').addEventListener('click', () => {
|
||||
if (validateConfig()) {
|
||||
updateConfig();
|
||||
saveConfig(() => {
|
||||
alert("Configuration saved successfully!");
|
||||
});
|
||||
function renderHelperStatus(status) {
|
||||
currentHelperStatus = status || null;
|
||||
const pendingEl = document.getElementById("pendingChange");
|
||||
const lastBackupEl = document.getElementById("lastBackup");
|
||||
const confirmButton = document.getElementById("confirmPendingButton");
|
||||
const revertButton = document.getElementById("revertPendingButton");
|
||||
const restoreButton = document.getElementById("restoreLatestBackupButton");
|
||||
if (!pendingEl || !lastBackupEl || !confirmButton || !revertButton || !restoreButton) return;
|
||||
|
||||
const pending = status?.pending_operation || null;
|
||||
if (pending) {
|
||||
setPendingOperation(pending);
|
||||
pendingEl.innerHTML = `
|
||||
<div><strong>${escapeHtml(pending.state)}</strong></div>
|
||||
<div class="text-secondary mt-1">${escapeHtml(pending.summary)}</div>
|
||||
<div class="small mt-2"><code>${escapeHtml(pending.operation_id)}</code></div>`;
|
||||
confirmButton.disabled = false;
|
||||
revertButton.disabled = false;
|
||||
} else {
|
||||
clearPendingOperation();
|
||||
pendingEl.innerHTML = `<div class="text-secondary">No pending network change.</div>`;
|
||||
confirmButton.disabled = true;
|
||||
revertButton.disabled = true;
|
||||
}
|
||||
|
||||
const recentBackups = Array.isArray(status?.recent_backups) ? status.recent_backups : [];
|
||||
if (recentBackups.length > 0) {
|
||||
const recent = recentBackups.map((backup) => {
|
||||
const warnings = Array.isArray(backup.warnings_present) && backup.warnings_present.length > 0
|
||||
? `<div class="small text-warning mt-1">${backup.warnings_present.map(escapeHtml).join(" | ")}</div>`
|
||||
: "";
|
||||
return `
|
||||
<div class="border rounded p-2 mt-2 lqos-backup-entry">
|
||||
<div><code class="lqos-break-anywhere">${escapeHtml(backup.backup_id)}</code></div>
|
||||
<div class="small text-secondary mt-1">${escapeHtml(backup.old_mode)} -> ${escapeHtml(backup.new_mode)}</div>
|
||||
${warnings}
|
||||
</div>`;
|
||||
}).join("");
|
||||
lastBackupEl.innerHTML = recent;
|
||||
restoreButton.disabled = Boolean(pending);
|
||||
} else {
|
||||
lastBackupEl.innerHTML = `<div class="text-secondary">No helper backup recorded yet.</div>`;
|
||||
restoreButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshHelperStatus() {
|
||||
return getJson("/local-api/network-mode/status")
|
||||
.then((data) => {
|
||||
renderHelperStatus(data?.helper_status || {});
|
||||
setRecoveryReachable(true);
|
||||
return data?.helper_status || {};
|
||||
})
|
||||
.catch(() => {
|
||||
if (!currentPendingOperation?.operation_id) {
|
||||
renderHelperStatus(null);
|
||||
}
|
||||
setRecoveryReachable(false);
|
||||
throw new Error("Unable to refresh helper status");
|
||||
});
|
||||
}
|
||||
|
||||
function inspectCandidate() {
|
||||
if (!validateConfig()) return Promise.resolve();
|
||||
const candidate = buildCandidateConfig();
|
||||
const button = document.getElementById("inspectButton");
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = "Inspecting...";
|
||||
}
|
||||
|
||||
return postJson("/local-api/network-mode/inspect", { config: candidate })
|
||||
.then((inspection) => {
|
||||
renderInspection(inspection);
|
||||
return refreshHelperStatus();
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message || "Unable to inspect current netplan");
|
||||
})
|
||||
.finally(() => {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = "Inspect Current Netplan";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("Configuration not found in window.config");
|
||||
}
|
||||
|
||||
function confirmDangerousChange(actionLabel, candidate) {
|
||||
const warnings = Array.isArray(currentInspection?.dangerous_changes) ? currentInspection.dangerous_changes.slice() : [];
|
||||
if (configModeKind(window.config) !== configModeKind(candidate)) {
|
||||
warnings.push("Switching between Linux bridge and single-interface modes requires strong confirmation.");
|
||||
}
|
||||
if (warnings.length === 0 && actionLabel === "Apply") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const intro = currentInspection?.strong_confirmation_text
|
||||
|| "This change may interrupt access to this system. LibreQoS will automatically roll back if you do not confirm within 30 seconds. If your browser disconnects, stay on this page and it will keep trying to reconnect.";
|
||||
return window.confirm(`${actionLabel} requires confirmation:\n\n- ${warnings.join("\n- ")}\n\n${intro}`);
|
||||
}
|
||||
|
||||
function applyNetworkChanges(mode = "Apply") {
|
||||
if (!validateConfig()) return;
|
||||
const candidate = buildCandidateConfig();
|
||||
const actionLabel = mode === "Adopt" ? "Adopt into libreqos.yaml" : mode === "TakeOver" ? "Take Over libreqos.yaml" : "Apply Network Changes";
|
||||
if (!confirmDangerousChange(actionLabel, candidate)) {
|
||||
return;
|
||||
}
|
||||
const button = document.getElementById("applyButton");
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = "Applying...";
|
||||
}
|
||||
|
||||
postJson("/local-api/network-mode/apply", {
|
||||
config: candidate,
|
||||
mode,
|
||||
confirm_dangerous_changes: true,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.operation) {
|
||||
setPendingOperation(response.operation, {
|
||||
action_label: actionLabel,
|
||||
});
|
||||
}
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
window.config = candidate;
|
||||
renderRecoveryPanel();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
return Promise.allSettled([inspectCandidate(), refreshHelperStatus()]);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message || "Unable to apply network changes");
|
||||
})
|
||||
.finally(() => {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = "Apply Network Changes";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function actOnPending(endpoint) {
|
||||
const operationId = activePendingOperationId();
|
||||
if (!operationId) {
|
||||
alert("No pending network operation is available");
|
||||
return;
|
||||
}
|
||||
|
||||
postJson(endpoint, { operation_id: operationId })
|
||||
.then((response) => {
|
||||
clearPendingOperation();
|
||||
alert(response?.message || "Operation completed");
|
||||
return Promise.allSettled([refreshHelperStatus(), inspectCandidate()]);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message || "Operation failed");
|
||||
});
|
||||
}
|
||||
|
||||
function rollbackLatestBackup() {
|
||||
const pending = currentHelperStatus?.pending_operation;
|
||||
if (pending?.operation_id) {
|
||||
alert("Confirm or revert the pending network change before restoring an older backup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const backupId = currentHelperStatus?.recent_backups?.[0]?.backup_id;
|
||||
if (!backupId) {
|
||||
alert("No rollback bundle is available.");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
"Restore the previous managed LibreQoS network configuration?\n\n"
|
||||
+ "This will overwrite the current managed netplan state with the most recent backup and may interrupt access to this system."
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
postJson("/local-api/network-mode/rollback", { backup_id: backupId })
|
||||
.then((response) => {
|
||||
alert(response?.message || "Rollback completed");
|
||||
return Promise.all([refreshHelperStatus(), inspectCandidate()]);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message || "Unable to restore the previous managed config");
|
||||
});
|
||||
}
|
||||
|
||||
function retryShaping() {
|
||||
const button = document.getElementById("retryShapingButton");
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = "Retrying...";
|
||||
}
|
||||
|
||||
postJson("/local-api/network-mode/retry-shaping", {})
|
||||
.then((response) => {
|
||||
alert(response?.message || "Shaping retry requested");
|
||||
return Promise.all([refreshHelperStatus(), inspectCandidate()]);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message || "Unable to retry shaping");
|
||||
})
|
||||
.finally(() => {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = "Retry Shaping";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireActions() {
|
||||
wireReviewTabs();
|
||||
[
|
||||
"bridgeMode",
|
||||
"singleInterfaceMode",
|
||||
"toInternet",
|
||||
"toNetwork",
|
||||
"interface",
|
||||
].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", () => {
|
||||
renderInterfaceSelectors();
|
||||
});
|
||||
});
|
||||
document.getElementById("saveButton").addEventListener("click", () => {
|
||||
if (!validateConfig()) return;
|
||||
saveDraft();
|
||||
inspectCandidate();
|
||||
alert("Network mode draft saved in this browser. Use Apply Network Changes to commit both lqos.conf and netplan together.");
|
||||
});
|
||||
document.getElementById("inspectButton").addEventListener("click", inspectCandidate);
|
||||
document.getElementById("applyButton").addEventListener("click", () => applyNetworkChanges("Apply"));
|
||||
document.getElementById("adoptButton").addEventListener("click", () => applyNetworkChanges("Adopt"));
|
||||
document.getElementById("takeoverButton").addEventListener("click", () => applyNetworkChanges("TakeOver"));
|
||||
document.getElementById("retryShapingButton").addEventListener("click", retryShaping);
|
||||
document.getElementById("confirmPendingButton").addEventListener("click", () => {
|
||||
actOnPending("/local-api/network-mode/confirm");
|
||||
});
|
||||
document.getElementById("revertPendingButton").addEventListener("click", () => {
|
||||
actOnPending("/local-api/network-mode/revert");
|
||||
});
|
||||
document.getElementById("recoveryConfirmButton").addEventListener("click", () => {
|
||||
actOnPending("/local-api/network-mode/confirm");
|
||||
});
|
||||
document.getElementById("recoveryRevertButton").addEventListener("click", () => {
|
||||
actOnPending("/local-api/network-mode/revert");
|
||||
});
|
||||
document.getElementById("restoreLatestBackupButton").addEventListener("click", rollbackLatestBackup);
|
||||
}
|
||||
|
||||
renderConfigMenu("interface");
|
||||
|
||||
loadConfig((msg) => {
|
||||
const payload = msg?.data || {};
|
||||
window.config = payload.config || window.config || {};
|
||||
currentPendingOperation = loadPendingOperation();
|
||||
|
||||
renderShapingStatus(payload.shaping_status || {});
|
||||
renderInspection(payload.network_mode_inspection || {});
|
||||
const draft = loadDraft();
|
||||
populateFormFromConfig(draft || window.config);
|
||||
renderRecoveryPanel();
|
||||
wireActions();
|
||||
refreshHelperStatus().catch(() => {});
|
||||
ensureRecoveryPolling();
|
||||
}, () => {
|
||||
alert("Unable to load LibreQoS configuration");
|
||||
});
|
||||
@@ -1,20 +1,29 @@
|
||||
import {saveConfig, loadConfig, renderConfigMenu} from "./config/config_helper";
|
||||
import {
|
||||
loadConfig,
|
||||
renderConfigMenu,
|
||||
saveConfig,
|
||||
sendWsRequest,
|
||||
} from "./config/config_helper";
|
||||
|
||||
const licenseKeyInput = document.getElementById("licenseKey");
|
||||
const toggleLicenseKeyButton = document.getElementById("toggleLicenseKey");
|
||||
const clearLicenseKeyButton = document.getElementById("clearLicenseKey");
|
||||
const saveButton = document.getElementById("saveButton");
|
||||
const retryButton = document.getElementById("retryLicenseCheck");
|
||||
|
||||
function validateConfig() {
|
||||
// Validate numeric fields
|
||||
const collationPeriod = parseInt(document.getElementById("collationPeriod").value);
|
||||
if (isNaN(collationPeriod) || collationPeriod < 1) {
|
||||
const collationPeriod = parseInt(document.getElementById("collationPeriod").value, 10);
|
||||
if (Number.isNaN(collationPeriod) || collationPeriod < 1) {
|
||||
alert("Collation Period must be a number greater than 0");
|
||||
return false;
|
||||
}
|
||||
|
||||
const uispInterval = parseInt(document.getElementById("uispInterval").value);
|
||||
if (isNaN(uispInterval) || uispInterval < 0) {
|
||||
const uispInterval = parseInt(document.getElementById("uispInterval").value, 10);
|
||||
if (Number.isNaN(uispInterval) || uispInterval < 0) {
|
||||
alert("UISP Reporting Interval must be a number of at least 0");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate URL format if provided
|
||||
const ltsUrl = document.getElementById("ltsUrl").value.trim();
|
||||
if (ltsUrl) {
|
||||
try {
|
||||
@@ -29,46 +38,199 @@ function validateConfig() {
|
||||
}
|
||||
|
||||
function updateConfig() {
|
||||
// Update only the long-term stats section
|
||||
window.config.long_term_stats = {
|
||||
gather_stats: document.getElementById("gatherStats").checked,
|
||||
collation_period_seconds: parseInt(document.getElementById("collationPeriod").value),
|
||||
license_key: document.getElementById("licenseKey").value.trim() || null,
|
||||
uisp_reporting_interval_seconds: parseInt(document.getElementById("uispInterval").value) || null,
|
||||
lts_url: document.getElementById("ltsUrl").value.trim() || null
|
||||
collation_period_seconds: parseInt(document.getElementById("collationPeriod").value, 10),
|
||||
license_key: licenseKeyInput.value.trim() || null,
|
||||
uisp_reporting_interval_seconds: parseInt(document.getElementById("uispInterval").value, 10) || null,
|
||||
lts_url: document.getElementById("ltsUrl").value.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Render the configuration menu
|
||||
renderConfigMenu('lts');
|
||||
function setLicenseKeyVisibility(revealed) {
|
||||
licenseKeyInput.type = revealed ? "text" : "password";
|
||||
toggleLicenseKeyButton.innerHTML = revealed
|
||||
? '<i class="fa fa-eye-slash"></i>'
|
||||
: '<i class="fa fa-eye"></i>';
|
||||
toggleLicenseKeyButton.setAttribute(
|
||||
"aria-label",
|
||||
revealed ? "Hide license key" : "Reveal license key",
|
||||
);
|
||||
}
|
||||
|
||||
function formatMappedCircuitLimit(limit) {
|
||||
if (limit === null || limit === undefined) {
|
||||
return "Unlimited";
|
||||
}
|
||||
return Number(limit).toLocaleString();
|
||||
}
|
||||
|
||||
function renderCapabilityBadges(capabilities) {
|
||||
const container = document.getElementById("licenseCapabilityBadges");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeSpec = [
|
||||
{
|
||||
enabled: capabilities.can_view_insight_ui,
|
||||
label: "Insight UI",
|
||||
},
|
||||
{
|
||||
enabled: capabilities.can_use_api_link,
|
||||
label: "API Docs",
|
||||
},
|
||||
{
|
||||
enabled: capabilities.can_use_support_tickets,
|
||||
label: "Support",
|
||||
},
|
||||
{
|
||||
enabled: capabilities.can_use_chatbot,
|
||||
label: "Libby",
|
||||
},
|
||||
{
|
||||
enabled: capabilities.can_receive_remote_commands,
|
||||
label: "Remote Control",
|
||||
},
|
||||
{
|
||||
enabled: capabilities.can_submit_long_term_stats,
|
||||
label: "Stats Submit",
|
||||
},
|
||||
];
|
||||
|
||||
container.innerHTML = badgeSpec
|
||||
.map((badge) => {
|
||||
const css = badge.enabled
|
||||
? "badge rounded-pill text-bg-success-subtle text-success-emphasis border border-success-subtle"
|
||||
: "badge rounded-pill text-bg-light text-secondary border";
|
||||
return `<span class="${css}">${badge.label}</span>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderAvailability(capabilities) {
|
||||
document.getElementById("licenseStateLabel").textContent = capabilities.license_state_label || "Unknown";
|
||||
document.getElementById("licenseAuthorityBadge").textContent = capabilities.authority_label || "Unknown";
|
||||
document.getElementById("controlServiceStatus").textContent = capabilities.control_service_reachable
|
||||
? "Reachable"
|
||||
: "Unavailable";
|
||||
document.getElementById("mappedCircuitLimit").textContent = formatMappedCircuitLimit(
|
||||
capabilities.mapped_circuit_limit,
|
||||
);
|
||||
|
||||
renderCapabilityBadges(capabilities);
|
||||
|
||||
const alert = document.getElementById("licenseAvailabilityAlert");
|
||||
if (!alert) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = "";
|
||||
if (
|
||||
!capabilities.control_service_reachable
|
||||
&& (capabilities.can_use_support_tickets || capabilities.can_use_chatbot)
|
||||
) {
|
||||
message = "license valid, control service unavailable";
|
||||
} else if (capabilities.bootstrap_suppressed) {
|
||||
message = "Automatic bootstrap retries are currently suppressed for the configured key.";
|
||||
} else if (
|
||||
capabilities.bootstrap_intent
|
||||
&& !capabilities.control_service_reachable
|
||||
&& capabilities.authority_label === "Bootstrap pending"
|
||||
) {
|
||||
message = "Bootstrap is pending. Save a corrected key or use Retry License Check to try again.";
|
||||
}
|
||||
|
||||
if (message) {
|
||||
alert.textContent = message;
|
||||
alert.classList.remove("d-none");
|
||||
} else {
|
||||
alert.textContent = "";
|
||||
alert.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
function fetchCapabilities(request = { LtsCapabilities: {} }) {
|
||||
sendWsRequest(
|
||||
"LtsCapabilitiesResult",
|
||||
request,
|
||||
(msg) => {
|
||||
renderAvailability(msg.data || {});
|
||||
},
|
||||
(msg) => {
|
||||
const alert = document.getElementById("licenseAvailabilityAlert");
|
||||
if (!alert) {
|
||||
return;
|
||||
}
|
||||
alert.textContent = msg?.message || "Unable to load license status.";
|
||||
alert.classList.remove("d-none");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function wireActions() {
|
||||
setLicenseKeyVisibility(false);
|
||||
|
||||
toggleLicenseKeyButton.addEventListener("click", () => {
|
||||
setLicenseKeyVisibility(licenseKeyInput.type === "password");
|
||||
});
|
||||
|
||||
clearLicenseKeyButton.addEventListener("click", () => {
|
||||
licenseKeyInput.value = "";
|
||||
});
|
||||
|
||||
retryButton.addEventListener("click", () => {
|
||||
retryButton.disabled = true;
|
||||
sendWsRequest(
|
||||
"LtsCapabilitiesResult",
|
||||
{ LtsRetryLicenseCheck: {} },
|
||||
(msg) => {
|
||||
retryButton.disabled = false;
|
||||
renderAvailability(msg.data || {});
|
||||
},
|
||||
(msg) => {
|
||||
retryButton.disabled = false;
|
||||
alert(msg?.message || "Unable to retry license check.");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", () => {
|
||||
if (!validateConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConfig();
|
||||
saveButton.disabled = true;
|
||||
saveConfig(
|
||||
() => {
|
||||
saveButton.disabled = false;
|
||||
fetchCapabilities();
|
||||
alert("Configuration saved successfully!");
|
||||
},
|
||||
(msg) => {
|
||||
saveButton.disabled = false;
|
||||
alert(msg?.message || "That didn't work");
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
renderConfigMenu("lts");
|
||||
|
||||
loadConfig(() => {
|
||||
// window.config now contains the configuration.
|
||||
// Populate form fields with config values
|
||||
if (window.config && window.config.long_term_stats) {
|
||||
const lts = window.config.long_term_stats;
|
||||
|
||||
// Boolean fields
|
||||
document.getElementById("gatherStats").checked = lts.gather_stats ?? true;
|
||||
|
||||
// Numeric fields
|
||||
document.getElementById("collationPeriod").value = lts.collation_period_seconds ?? 60;
|
||||
document.getElementById("uispInterval").value = lts.uisp_reporting_interval_seconds ?? 300;
|
||||
|
||||
// Optional string fields
|
||||
document.getElementById("licenseKey").value = lts.license_key ?? "";
|
||||
document.getElementById("ltsUrl").value = lts.lts_url ?? "";
|
||||
|
||||
// Add save button click handler
|
||||
document.getElementById('saveButton').addEventListener('click', () => {
|
||||
if (validateConfig()) {
|
||||
updateConfig();
|
||||
saveConfig(() => {
|
||||
alert("Configuration saved successfully!");
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!window.config || !window.config.long_term_stats) {
|
||||
console.error("Long-term stats configuration not found in window.config");
|
||||
return;
|
||||
}
|
||||
|
||||
const lts = window.config.long_term_stats;
|
||||
document.getElementById("gatherStats").checked = lts.gather_stats ?? true;
|
||||
document.getElementById("collationPeriod").value = lts.collation_period_seconds ?? 60;
|
||||
document.getElementById("uispInterval").value = lts.uisp_reporting_interval_seconds ?? 300;
|
||||
document.getElementById("ltsUrl").value = lts.lts_url ?? "";
|
||||
licenseKeyInput.value = lts.license_key ?? "";
|
||||
|
||||
wireActions();
|
||||
fetchCapabilities();
|
||||
});
|
||||
@@ -323,7 +323,7 @@ function renderOverview() {
|
||||
if (!window.hasInsight) {
|
||||
const promo = document.createElement("div");
|
||||
promo.className = "cpu-affinity-inline-note mb-3";
|
||||
promo.innerHTML = 'Enable <a href="lts_trial.html">Insight</a> to improve CPU planning with historical data.';
|
||||
promo.innerHTML = 'Enable historical services in <a href="config_lts.html">License & Services</a> to improve CPU planning.';
|
||||
body.appendChild(promo);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet {
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="exec-icon ${value ? "text-success" : "text-danger"}"><i class="fas fa-satellite-dish"></i></span>
|
||||
<div>
|
||||
<div class="text-secondary small">Insight</div>
|
||||
<div class="text-secondary small">Control Service</div>
|
||||
<div class="exec-metric-value text-secondary">${badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export class QueueStatsTotalDash extends DashletBaseInsight {
|
||||
}
|
||||
|
||||
if (!window.hasLts) {
|
||||
historyGraph.innerHTML = "<p class='text-secondary small'>You need an active LibreQoS Insight account to view this data.</p>";
|
||||
historyGraph.innerHTML = "<p class='text-secondary small'>You need an entitled license with history access to view this data.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export class ThroughputBpsDash extends DashletBaseInsight{
|
||||
|
||||
const yestValue = document.createElement("span");
|
||||
yestValue.classList.add("fw-bold", "text-secondary");
|
||||
yestValue.innerHTML = "<i class=\"fa fa-fw fa-centerline fa-line-chart nav-icon small\"></i> History<br />Requires<br />Insight";
|
||||
yestValue.innerHTML = "<i class=\"fa fa-fw fa-centerline fa-line-chart nav-icon small\"></i> History<br />Requires<br />Entitlement";
|
||||
|
||||
yestWrapper.appendChild(yestHeader);
|
||||
yestWrapper.appendChild(yestValue);
|
||||
|
||||
@@ -41,7 +41,7 @@ export class TcpRetransmitsDash extends DashletBaseInsight{
|
||||
graph.id = this.graphDivId() + "_" + t;
|
||||
graph.classList.add("dashgraph");
|
||||
graph.style.display = "none";
|
||||
graph.innerHTML = window.hasLts ? "Loading..." : "<p class='text-secondary small'>You need an active LibreQoS Insight account to view this data.</p>";
|
||||
graph.innerHTML = window.hasLts ? "Loading..." : "<p class='text-secondary small'>You need an entitled license with history access to view this data.</p>";
|
||||
this.graphDivs.push(graph);
|
||||
controls.appendChild(this.makePeriodBtn(t));
|
||||
});
|
||||
|
||||
@@ -222,11 +222,14 @@ function submitComment() {
|
||||
}
|
||||
|
||||
function initSupportTickets() {
|
||||
const hasInsight = typeof window.hasInsight !== "undefined" ? !!window.hasInsight : false;
|
||||
const hasSupportTickets =
|
||||
typeof window.hasSupportTickets !== "undefined"
|
||||
? !!window.hasSupportTickets
|
||||
: hasInsight;
|
||||
: false;
|
||||
const liveControlAvailable =
|
||||
typeof window.liveControlAvailable !== "undefined"
|
||||
? !!window.liveControlAvailable
|
||||
: false;
|
||||
const noInsight = document.getElementById("ticketGateNoInsight");
|
||||
const yesInsight = document.getElementById("ticketGateInsight");
|
||||
const btnNew = document.getElementById("btnNewTicket");
|
||||
@@ -243,8 +246,12 @@ function initSupportTickets() {
|
||||
|
||||
if (noInsight) noInsight.classList.add("d-none");
|
||||
if (yesInsight) yesInsight.classList.remove("d-none");
|
||||
if (btnNew) btnNew.disabled = false;
|
||||
if (btnRefresh) btnRefresh.disabled = false;
|
||||
if (btnNew) btnNew.disabled = !liveControlAvailable;
|
||||
if (btnRefresh) btnRefresh.disabled = !liveControlAvailable;
|
||||
|
||||
if (!liveControlAvailable) {
|
||||
setAlert("ticketListAlert", "license valid, control service unavailable");
|
||||
}
|
||||
|
||||
wsClient = get_ws_client();
|
||||
wsClient.on("SupportTicketListResult", (msg) => {
|
||||
@@ -278,8 +285,8 @@ function initSupportTickets() {
|
||||
wsClient.on("Error", (msg) => {
|
||||
const message = msg && msg.message ? String(msg.message) : "Unknown error";
|
||||
if (
|
||||
message.includes("Support tickets require an Insight subscription") ||
|
||||
message.includes("Insight not enabled")
|
||||
message.includes("Support tickets require an entitled license.") ||
|
||||
message.includes("license valid, control service unavailable")
|
||||
) {
|
||||
if (noInsight) noInsight.classList.remove("d-none");
|
||||
if (yesInsight) yesInsight.classList.add("d-none");
|
||||
|
||||
@@ -792,7 +792,7 @@ function saveQueueModeAndReload(nextMode) {
|
||||
|
||||
const confirmed = window.confirm(
|
||||
nextMode === "observe"
|
||||
? "Switch LibreQoS to Observe mode? This reload will remove the active LibreQoS shaping tree so you can gather a true baseline. It may briefly interrupt traffic."
|
||||
? "Switch LibreQoS to Observe mode? LibreQoS will reload without the active shaping tree so you can gather a true baseline. Traffic may briefly pause during the reload, typically for about 5 seconds."
|
||||
: "Switch LibreQoS to Shape mode? This reload will apply the LibreQoS shaping tree and begin active shaping. It may briefly interrupt traffic."
|
||||
);
|
||||
if (!confirmed) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {createBootstrapToast} from "../lq_js_common/helpers/toasts";
|
||||
import {PLACEHOLDER_TEASERS} from "../lts_teasers_shared";
|
||||
import {get_ws_client} from "../pubsub/ws";
|
||||
|
||||
let insightModalShown = false;
|
||||
|
||||
let modalTeasers = [];
|
||||
let modalTeasersLoaded = false;
|
||||
let modalLtsBaseUrl = "https://insight.libreqos.com/";
|
||||
const wsClient = get_ws_client();
|
||||
|
||||
const listenOnce = (eventName, handler) => {
|
||||
@@ -17,188 +11,7 @@ const listenOnce = (eventName, handler) => {
|
||||
wsClient.on(eventName, wrapped);
|
||||
};
|
||||
|
||||
function sendWsRequest(responseEvent, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
const onResponse = (msg) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
wsClient.off(responseEvent, onResponse);
|
||||
wsClient.off("Error", onError);
|
||||
resolve(msg);
|
||||
};
|
||||
const onError = (msg) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
wsClient.off(responseEvent, onResponse);
|
||||
wsClient.off("Error", onError);
|
||||
reject(msg);
|
||||
};
|
||||
wsClient.on(responseEvent, onResponse);
|
||||
wsClient.on("Error", onError);
|
||||
wsClient.send(request);
|
||||
});
|
||||
}
|
||||
|
||||
function getModalLtsUrl(endpoint) {
|
||||
let baseUrl = modalLtsBaseUrl;
|
||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
let base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
||||
base += base.endsWith("signup-api/") ? "" : "signup-api/";
|
||||
endpoint = endpoint.replace(/^\/+/, "");
|
||||
return base + endpoint;
|
||||
}
|
||||
|
||||
async function loadModalTeasers() {
|
||||
if (modalTeasersLoaded) {
|
||||
renderModalTeasers();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendWsRequest("LtsTrialConfigResult", { LtsTrialConfig: {} });
|
||||
const data = response && response.data ? response.data : {};
|
||||
if (data.lts_url && typeof data.lts_url === "string") {
|
||||
modalLtsBaseUrl = data.lts_url;
|
||||
}
|
||||
} catch (e) {
|
||||
// If this fails, we just fall back to the default base URL.
|
||||
console.error("Failed to fetch LTS base URL for modal teasers:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $.get(getModalLtsUrl("teasers"));
|
||||
if (response && Array.isArray(response.teasers) && response.teasers.length > 0) {
|
||||
modalTeasers = response.teasers.map((teaser) => {
|
||||
const copy = { ...teaser };
|
||||
if (copy.image) {
|
||||
const img = typeof copy.image === "string" ? copy.image : "";
|
||||
const cleaned = img.replace(/^\/?signup-api\//, "");
|
||||
copy.image = getModalLtsUrl(cleaned);
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
} else {
|
||||
modalTeasers = PLACEHOLDER_TEASERS.slice();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load Insight teasers for modal:", e);
|
||||
modalTeasers = PLACEHOLDER_TEASERS.slice();
|
||||
}
|
||||
|
||||
modalTeasersLoaded = true;
|
||||
renderModalTeasers();
|
||||
}
|
||||
|
||||
function renderModalTeasers() {
|
||||
const container = document.getElementById("insightTeaserRow");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teasers = (modalTeasers.length ? modalTeasers : PLACEHOLDER_TEASERS).slice();
|
||||
teasers.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
const cardsHtml = teasers
|
||||
.map((teaser) => {
|
||||
const imageSrc = teaser.image || teaser.imageUrl || "";
|
||||
const title = teaser.title || "";
|
||||
const description = teaser.description || "";
|
||||
return `
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card h-100 bg-dark border-secondary text-secondary">
|
||||
${
|
||||
imageSrc
|
||||
? `<img src="${imageSrc}" class="card-img-top" alt="${title}" style="height: 180px; object-fit: contain; background-color: #1b1e21;">`
|
||||
: ""
|
||||
}
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title text-secondary">${title}</h5>
|
||||
<p class="card-text text-secondary">${description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
container.innerHTML = cardsHtml;
|
||||
}
|
||||
|
||||
function showInsightTrialModal() {
|
||||
if (insightModalShown) {
|
||||
return;
|
||||
}
|
||||
insightModalShown = true;
|
||||
|
||||
if (!window.bootstrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.getElementById("insightTrialModal")) {
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="insightTrialModal" tabindex="-1" aria-labelledby="insightTrialModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen">
|
||||
<div class="modal-content bg-dark text-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h2 class="modal-title text-secondary" id="insightTrialModalLabel">
|
||||
<i class="fa fa-line-chart nav-icon"></i>
|
||||
Get The Most Out of LibreQoS with <strong>LibreQoS Insight</strong>
|
||||
</h2>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-secondary">
|
||||
<div class="alert alert-primary text-secondary" role="alert">
|
||||
<p class="mb-0">
|
||||
You have <strong>{{window.smn}}</strong> shaped devices.
|
||||
LibreQoS is helping your network; Insight can make it amazing.
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-3">
|
||||
With Insight, you can:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Ask Libby about your network in natural language.</li>
|
||||
<li>Use CPU load balancing and mitigation features like binpacking and reload reduction.</li>
|
||||
<li>Explore accurate statistics, analytics, and long-term network insights.</li>
|
||||
</ul>
|
||||
<!-- From the Insight Trial -->
|
||||
<div class="mt-4">
|
||||
<div class="row g-3" id="insightTeaserRow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Maybe Later</button>
|
||||
<a href="lts_trial.html" class="btn btn-primary">Start Insight Trial</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
modalHtml.replace("{{window.smn}}", window.smn || "?"),
|
||||
);
|
||||
}
|
||||
|
||||
const modalEl = document.getElementById("insightTrialModal");
|
||||
if (!modalEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load and render teaser cards inside the modal
|
||||
loadModalTeasers();
|
||||
|
||||
const modal = new bootstrap.Modal(modalEl, { focus: true });
|
||||
modal.show();
|
||||
}
|
||||
|
||||
export function globalWarningToasts() {
|
||||
if (window.sm) {
|
||||
showInsightTrialModal();
|
||||
}
|
||||
const handler = (msg) => {
|
||||
wsClient.off("GlobalWarnings", handler);
|
||||
const warnings = msg && msg.data ? msg.data : [];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { get_ws_client } from "../pubsub/ws";
|
||||
|
||||
const sponsorBtn = "<a href=\"https://github.com/sponsors/LibreQoE/\" target='_blank' class='text-primary-emphasis'><i class=\"fa fa-heart\"></i> Sponsor Us on GitHub</a>";
|
||||
const sponsorMessages = [
|
||||
"LibreQoS handles shaping. Insight adds history, dashboards, and alerts so you catch issues before tickets arrive.",
|
||||
"Need proof before or after changes? Insight keeps latency, retransmit, and flow history in one place.",
|
||||
@@ -12,21 +11,54 @@ const sponsorMessages = [
|
||||
"See circuit and site behavior over time, not just right now. Insight gives you historical context.",
|
||||
];
|
||||
|
||||
export function sponsorTag(parentId) {
|
||||
if (!window.hasLts) {
|
||||
const client = get_ws_client();
|
||||
const handler = () => {
|
||||
if (!window.hasLts) {
|
||||
let div = document.createElement("div");
|
||||
let random = Math.floor(Math.random() * sponsorMessages.length);
|
||||
div.innerHTML = sponsorMessages[random];
|
||||
div.classList.add("alert", "alert-warning", "toasty");
|
||||
let parent = document.getElementById(parentId);
|
||||
parent.appendChild(div);
|
||||
}
|
||||
client.off("DeviceCount", handler);
|
||||
};
|
||||
client.on("DeviceCount", handler);
|
||||
client.send({ DeviceCount: {} });
|
||||
function getSponsorState(mappedCircuits) {
|
||||
if (window.mappedCircuitLimit !== 1000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mappedCircuits > 1000) {
|
||||
return {
|
||||
message: "This deployment is above the unlicensed 1,000 mapped circuit limit; add a license to remove the cap.",
|
||||
alertClass: "alert-danger",
|
||||
};
|
||||
}
|
||||
|
||||
if (mappedCircuits >= 800) {
|
||||
return {
|
||||
message: "Unlicensed deployments are limited to 1,000 mapped circuits, and this system is approaching that limit.",
|
||||
alertClass: "alert-warning",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: sponsorMessages[Math.floor(Math.random() * sponsorMessages.length)],
|
||||
alertClass: "alert-success",
|
||||
};
|
||||
}
|
||||
|
||||
export function sponsorTag(parentId) {
|
||||
const client = get_ws_client();
|
||||
const handler = (msg) => {
|
||||
client.off("DeviceCount", handler);
|
||||
|
||||
const parent = document.getElementById(parentId);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedCircuits = Number(msg?.data?.mapped_circuits ?? 0);
|
||||
const sponsorState = getSponsorState(mappedCircuits);
|
||||
if (!sponsorState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.textContent = sponsorState.message;
|
||||
div.classList.add("alert", sponsorState.alertClass, "toasty");
|
||||
div.setAttribute("role", "alert");
|
||||
parent.appendChild(div);
|
||||
};
|
||||
|
||||
client.on("DeviceCount", handler);
|
||||
client.send({ DeviceCount: {} });
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub(crate) mod executive_cache;
|
||||
pub(crate) mod flow_explorer;
|
||||
pub(crate) mod flow_map;
|
||||
pub mod lts;
|
||||
pub(crate) mod network_mode;
|
||||
pub(crate) mod network_tree;
|
||||
pub(crate) mod network_tree_lite;
|
||||
pub(crate) mod node_rate_overrides;
|
||||
@@ -33,7 +34,7 @@ pub(crate) mod warnings;
|
||||
|
||||
use crate::node_manager::auth::auth_layer;
|
||||
use crate::node_manager::shaper_queries_actor::ShaperQueryCommand;
|
||||
use axum::routing::get;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Router};
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
@@ -44,6 +45,17 @@ pub fn local_api(shaper_query: tokio::sync::mpsc::Sender<ShaperQueryCommand>) ->
|
||||
"/throughputAttributionDebug",
|
||||
get(throughput_attribution_debug::throughput_attribution_debug),
|
||||
)
|
||||
.route("/network-mode/status", get(network_mode::status))
|
||||
.route("/network-mode/inspect", post(network_mode::inspect))
|
||||
.route("/network-mode/apply", post(network_mode::apply))
|
||||
.route("/network-mode/confirm", post(network_mode::confirm))
|
||||
.route("/network-mode/revert", post(network_mode::revert))
|
||||
.route("/network-mode/rollback", post(network_mode::rollback))
|
||||
.route(
|
||||
"/network-mode/retry-shaping",
|
||||
post(network_mode::retry_shaping),
|
||||
)
|
||||
.with_state(network_mode::NetworkModeApiState::default())
|
||||
.layer(Extension(shaper_query))
|
||||
.layer(CorsLayer::very_permissive())
|
||||
.route_layer(axum::middleware::from_fn(auth_layer))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::node_manager::auth::LoginResult;
|
||||
use crate::node_manager::local_api::network_mode::NetworkModeInspection;
|
||||
use crate::shaped_devices_tracker::SHAPED_DEVICES;
|
||||
use crate::shaping_runtime::ShapingRuntimeStatus;
|
||||
use axum::http::StatusCode;
|
||||
use default_net::get_interfaces;
|
||||
use lqos_bus::{BusRequest, bus_request};
|
||||
@@ -156,6 +158,10 @@ pub struct ConfigView {
|
||||
pub config: Config,
|
||||
#[serde(default)]
|
||||
pub secret_state: ConfigSecretState,
|
||||
#[serde(default)]
|
||||
pub shaping_status: ShapingRuntimeStatus,
|
||||
#[serde(default)]
|
||||
pub network_mode_inspection: NetworkModeInspection,
|
||||
}
|
||||
|
||||
pub fn admin_check_data(login: LoginResult) -> bool {
|
||||
@@ -317,6 +323,9 @@ pub fn get_config_data(login: LoginResult) -> Result<ConfigView, StatusCode> {
|
||||
let mut config = (*config).clone();
|
||||
let secret_state = redact_config_secrets(&mut config);
|
||||
ConfigView {
|
||||
shaping_status: crate::shaping_runtime::get_status(),
|
||||
network_mode_inspection:
|
||||
crate::node_manager::local_api::network_mode::inspect_network_mode(&config),
|
||||
config,
|
||||
secret_state,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
use crate::node_manager::local_api::unknown_ips::get_unknown_ips;
|
||||
use crate::shaped_devices_tracker::SHAPED_DEVICES;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct DeviceCount {
|
||||
pub shaped_devices: usize,
|
||||
pub unknown_ips: usize,
|
||||
pub mapped_circuits: usize,
|
||||
}
|
||||
|
||||
pub fn device_count() -> DeviceCount {
|
||||
let shaped_devices = SHAPED_DEVICES.load();
|
||||
let mapped_circuits = shaped_devices
|
||||
.devices
|
||||
.iter()
|
||||
.map(|device| device.circuit_hash)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len();
|
||||
|
||||
DeviceCount {
|
||||
shaped_devices: SHAPED_DEVICES.load().devices.len(),
|
||||
shaped_devices: shaped_devices.devices.len(),
|
||||
unknown_ips: get_unknown_ips().len(),
|
||||
mapped_circuits,
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ mod last_24_hours;
|
||||
mod shaper_status;
|
||||
|
||||
use crate::lts2_sys::lts2_client::{LicenseStatus, set_license_status};
|
||||
use crate::lts2_sys::shared_types::LtsStatus;
|
||||
use crate::node_manager::auth::LoginResult;
|
||||
use crate::node_manager::local_api::circuit_count;
|
||||
use axum::http::StatusCode;
|
||||
pub use last_24_hours::*;
|
||||
use lqos_bus::LtsCapabilitiesSummary;
|
||||
use lqos_bus::{BusRequest, bus_request};
|
||||
use lqos_config::load_config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -135,6 +135,9 @@ async fn apply_insight_license(
|
||||
bus_request(vec![BusRequest::UpdateLqosdConfig(Box::new(cfg))])
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
crate::lts2_sys::capabilities::set_signup_bootstrap_active(false);
|
||||
crate::lts2_sys::capabilities::clear_bootstrap_suppression();
|
||||
crate::lts2_sys::capabilities::wake_control_channel();
|
||||
|
||||
set_license_status(LicenseStatus {
|
||||
license_type: INSIGHT_FREE_TRIAL_STATUS_CODE,
|
||||
@@ -153,6 +156,7 @@ async fn apply_insight_license(
|
||||
}
|
||||
|
||||
pub async fn lts_trial_start_signup_data() -> Result<String, StatusCode> {
|
||||
crate::lts2_sys::capabilities::set_signup_bootstrap_active(true);
|
||||
let config = load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let start_url = build_insight_url(config.long_term_stats.lts_url.as_deref(), "/su/start");
|
||||
let request = SignupStartRequest {
|
||||
@@ -253,6 +257,7 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode>
|
||||
Ok(SignupCheckState::Pending) => {}
|
||||
Ok(SignupCheckState::Expired) => {
|
||||
debug!(claim_id = %claim_id, "Insight signup claim expired");
|
||||
crate::lts2_sys::capabilities::set_signup_bootstrap_active(false);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(SignupCheckState::Provisioned { account_key }) => {
|
||||
@@ -263,6 +268,7 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode>
|
||||
account_key = %account_key,
|
||||
"Applied Insight license from signup flow"
|
||||
);
|
||||
crate::lts2_sys::capabilities::set_signup_bootstrap_active(false);
|
||||
return Ok(());
|
||||
}
|
||||
Err(status) => {
|
||||
@@ -272,6 +278,7 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode>
|
||||
status = %status,
|
||||
"Failed to apply Insight license; stopping signup poll loop"
|
||||
);
|
||||
crate::lts2_sys::capabilities::set_signup_bootstrap_active(false);
|
||||
return Err(status);
|
||||
}
|
||||
}
|
||||
@@ -281,6 +288,7 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode>
|
||||
claim_id = %claim_id,
|
||||
"Insight signup status poll returned an unexpected payload"
|
||||
);
|
||||
crate::lts2_sys::capabilities::set_signup_bootstrap_active(false);
|
||||
return Err(StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
}
|
||||
@@ -288,33 +296,51 @@ async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode>
|
||||
}
|
||||
|
||||
pub(crate) async fn insight_gate() -> Result<(), StatusCode> {
|
||||
let (status, _) = crate::lts2_sys::get_lts_license_status_async().await;
|
||||
match status {
|
||||
LtsStatus::Invalid | LtsStatus::NotChecked => Err(StatusCode::FORBIDDEN),
|
||||
_ => Ok(()),
|
||||
let capabilities = crate::lts2_sys::current_capabilities();
|
||||
if !capabilities.can_view_insight_ui {
|
||||
Err(StatusCode::FORBIDDEN)
|
||||
} else if !capabilities.control_service_reachable {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn support_ticket_gate() -> Result<(), StatusCode> {
|
||||
let (status, _) = crate::lts2_sys::get_lts_license_status_async().await;
|
||||
match status {
|
||||
LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full => {
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(StatusCode::FORBIDDEN),
|
||||
let capabilities = crate::lts2_sys::current_capabilities();
|
||||
if !capabilities.can_use_support_tickets {
|
||||
Err(StatusCode::FORBIDDEN)
|
||||
} else if !capabilities.control_service_reachable {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lts_trial_signup_data(license_key: String) -> Result<(), StatusCode> {
|
||||
info!("Received license key, enabling free trial: {}", license_key);
|
||||
if license_key == "FAIL" {
|
||||
warn!("Free trial request failed");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
info!("Free trial request succeeded, license key: {}", license_key);
|
||||
apply_insight_license(license_key, true).await
|
||||
}
|
||||
|
||||
pub fn lts_capabilities_data(login: LoginResult) -> Result<LtsCapabilitiesSummary, StatusCode> {
|
||||
if login != LoginResult::Admin {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
Ok(crate::lts2_sys::current_capabilities())
|
||||
}
|
||||
|
||||
pub fn retry_license_check_data(login: LoginResult) -> Result<LtsCapabilitiesSummary, StatusCode> {
|
||||
if login != LoginResult::Admin {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
crate::lts2_sys::capabilities::clear_bootstrap_suppression();
|
||||
crate::lts2_sys::capabilities::wake_control_channel();
|
||||
Ok(crate::lts2_sys::current_capabilities())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
use crate::node_manager::auth::{LoginResult, get_username};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use lqos_config::Config;
|
||||
use lqos_netplan_helper::protocol::{ApplyMode, ApplyRequest, ApplyResponse, HelperStatus};
|
||||
use lqos_netplan_helper::transaction::{
|
||||
HelperPaths, PendingChildren, apply_transaction, confirm_transaction, helper_status,
|
||||
inspect_with_paths, retry_shaping_transaction, revert_transaction, rollback_transaction,
|
||||
};
|
||||
pub use lqos_netplan_helper::{NetworkModeInspection, inspect_network_mode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NetworkModeApiState {
|
||||
paths: Arc<HelperPaths>,
|
||||
pending_children: Arc<Mutex<PendingChildren>>,
|
||||
}
|
||||
|
||||
impl Default for NetworkModeApiState {
|
||||
fn default() -> Self {
|
||||
let paths = Arc::new(HelperPaths::default());
|
||||
let mut pending_children = PendingChildren::default();
|
||||
if let Err(err) = helper_status(paths.as_ref(), &mut pending_children) {
|
||||
warn!("Unable to initialize network-mode helper state: {err}");
|
||||
}
|
||||
Self {
|
||||
paths,
|
||||
pending_children: Arc::new(Mutex::new(pending_children)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NetworkModeInspectRequest {
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NetworkModeApplyRequest {
|
||||
pub config: Config,
|
||||
#[serde(default)]
|
||||
pub mode: ApplyMode,
|
||||
#[serde(default)]
|
||||
pub confirm_dangerous_changes: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NetworkModeConfirmRequest {
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NetworkModeRollbackRequest {
|
||||
pub backup_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct NetworkModeStateResponse {
|
||||
pub helper_status: HelperStatus,
|
||||
}
|
||||
|
||||
fn unauthorized() -> (StatusCode, Json<ApplyResponse>) {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApplyResponse {
|
||||
ok: false,
|
||||
message: "Unauthorized".to_string(),
|
||||
operation: None,
|
||||
last_backup_id: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn helper_validation_error_response(message: String) -> (StatusCode, Json<ApplyResponse>) {
|
||||
warn!("Bridge helper request failed: {message}");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApplyResponse {
|
||||
ok: false,
|
||||
message,
|
||||
operation: None,
|
||||
last_backup_id: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn helper_internal_error_response(message: String) -> (StatusCode, Json<ApplyResponse>) {
|
||||
error!("Bridge helper internal error: {message}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApplyResponse {
|
||||
ok: false,
|
||||
message,
|
||||
operation: None,
|
||||
last_backup_id: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_network_mode(candidate: Config) -> Result<Config, StatusCode> {
|
||||
let live = lqos_config::load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut merged = (*live).clone();
|
||||
merged.bridge = candidate.bridge;
|
||||
merged.single_interface = candidate.single_interface;
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
async fn run_network_mode_inspection(
|
||||
state: &NetworkModeApiState,
|
||||
config: Config,
|
||||
) -> Result<NetworkModeInspection, StatusCode> {
|
||||
let paths = state.paths.clone();
|
||||
tokio::task::spawn_blocking(move || inspect_with_paths(paths.as_ref(), &config))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
async fn run_pending_operation<T, F>(
|
||||
state: &NetworkModeApiState,
|
||||
operation: F,
|
||||
) -> Result<T, StatusCode>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&HelperPaths, &mut PendingChildren) -> anyhow::Result<T> + Send + 'static,
|
||||
{
|
||||
let paths = state.paths.clone();
|
||||
let pending_children = state.pending_children.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut pending = pending_children.blocking_lock();
|
||||
operation(paths.as_ref(), &mut pending)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
pub async fn status(
|
||||
State(state): State<NetworkModeApiState>,
|
||||
Extension(login): Extension<LoginResult>,
|
||||
) -> Result<Json<NetworkModeStateResponse>, StatusCode> {
|
||||
if login != LoginResult::Admin {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let helper_status = run_pending_operation(&state, helper_status).await?;
|
||||
|
||||
Ok(Json(NetworkModeStateResponse { helper_status }))
|
||||
}
|
||||
|
||||
pub async fn inspect(
|
||||
State(state): State<NetworkModeApiState>,
|
||||
Extension(login): Extension<LoginResult>,
|
||||
Json(body): Json<NetworkModeInspectRequest>,
|
||||
) -> Result<Json<NetworkModeInspection>, StatusCode> {
|
||||
if login != LoginResult::Admin {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let merged = merge_network_mode(body.config)?;
|
||||
Ok(Json(run_network_mode_inspection(&state, merged).await?))
|
||||
}
|
||||
|
||||
pub async fn apply(
|
||||
State(state): State<NetworkModeApiState>,
|
||||
jar: CookieJar,
|
||||
Extension(login): Extension<LoginResult>,
|
||||
Json(body): Json<NetworkModeApplyRequest>,
|
||||
) -> (StatusCode, Json<ApplyResponse>) {
|
||||
if login != LoginResult::Admin {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let username = get_username(&jar).await;
|
||||
let merged = match merge_network_mode(body.config) {
|
||||
Ok(config) => config,
|
||||
Err(status) => {
|
||||
return (
|
||||
status,
|
||||
Json(ApplyResponse {
|
||||
ok: false,
|
||||
message: "Unable to load the live LibreQoS configuration".to_string(),
|
||||
operation: None,
|
||||
last_backup_id: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let paths = state.paths.clone();
|
||||
let pending_children = state.pending_children.clone();
|
||||
move || {
|
||||
let mut pending = pending_children.blocking_lock();
|
||||
apply_transaction(
|
||||
paths.as_ref(),
|
||||
&mut pending,
|
||||
ApplyRequest {
|
||||
config: merged,
|
||||
source: "ui".to_string(),
|
||||
operator_username: Some(username),
|
||||
mode: body.mode,
|
||||
confirm_dangerous_changes: body.confirm_dangerous_changes,
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn confirm(
|
||||
State(state): State<NetworkModeApiState>,
|
||||
Extension(login): Extension<LoginResult>,
|
||||
Json(body): Json<NetworkModeConfirmRequest>,
|
||||
) -> (StatusCode, Json<ApplyResponse>) {
|
||||
if login != LoginResult::Admin {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let paths = state.paths.clone();
|
||||
let pending_children = state.pending_children.clone();
|
||||
let operation_id = body.operation_id.clone();
|
||||
move || {
|
||||
let mut pending = pending_children.blocking_lock();
|
||||
confirm_transaction(paths.as_ref(), &mut pending, &operation_id)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn revert(
|
||||
State(state): State<NetworkModeApiState>,
|
||||
Extension(login): Extension<LoginResult>,
|
||||
Json(body): Json<NetworkModeConfirmRequest>,
|
||||
) -> (StatusCode, Json<ApplyResponse>) {
|
||||
if login != LoginResult::Admin {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let paths = state.paths.clone();
|
||||
let pending_children = state.pending_children.clone();
|
||||
let operation_id = body.operation_id.clone();
|
||||
move || {
|
||||
let mut pending = pending_children.blocking_lock();
|
||||
revert_transaction(paths.as_ref(), &mut pending, &operation_id)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rollback(
|
||||
State(state): State<NetworkModeApiState>,
|
||||
Extension(login): Extension<LoginResult>,
|
||||
Json(body): Json<NetworkModeRollbackRequest>,
|
||||
) -> (StatusCode, Json<ApplyResponse>) {
|
||||
if login != LoginResult::Admin {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let paths = state.paths.clone();
|
||||
let pending_children = state.pending_children.clone();
|
||||
let backup_id = body.backup_id.clone();
|
||||
move || {
|
||||
let mut pending = pending_children.blocking_lock();
|
||||
rollback_transaction(paths.as_ref(), &mut pending, &backup_id)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_validation_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn retry_shaping(
|
||||
State(state): State<NetworkModeApiState>,
|
||||
Extension(login): Extension<LoginResult>,
|
||||
) -> (StatusCode, Json<ApplyResponse>) {
|
||||
if login != LoginResult::Admin {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let paths = state.paths.clone();
|
||||
let pending_children = state.pending_children.clone();
|
||||
move || {
|
||||
let mut pending = pending_children.blocking_lock();
|
||||
retry_shaping_transaction(paths.as_ref(), &mut pending)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(response)) => (StatusCode::OK, Json(response)),
|
||||
Ok(Err(err)) => helper_internal_error_response(err.to_string()),
|
||||
Err(err) => helper_internal_error_response(format!("Bridge helper task failed: {err}")),
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-2 text-info"><i class="fa fa-circle-info fa-lg"></i></div>
|
||||
<div>
|
||||
<div class="fw-semibold">The LibreQoS API is an Insight feature.</div>
|
||||
<a class="alert-link" href="lts_trial.html">Start an Insight free trial</a> to enable API access and Libby on this node.
|
||||
<div class="fw-semibold">The LibreQoS API requires an entitled license.</div>
|
||||
<a class="alert-link" href="config_lts.html">Review License & Services</a> to enable API access on this node.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,8 +42,8 @@
|
||||
<div class="card-body">
|
||||
<h4 class="h6 card-title mb-2"><i class="fa-solid fa-plug me-2"></i>Getting started</h4>
|
||||
<p class="mb-2 text-secondary">
|
||||
Click the <strong>Insight - Free Trial</strong> link in the top menu to start a trial and enable the Local API and Libby.
|
||||
If you already have a key, use the same link to enter it. Once enabled, the Swagger UI is available at <code>/api-docs</code>
|
||||
Open <strong>License & Services</strong> from the top menu to enter or review your key.
|
||||
Once enabled, the Swagger UI is available at <code>/api-docs</code>
|
||||
and Libby at <code>http://<node-ip>:9121/</code>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -55,8 +55,8 @@
|
||||
(function(){
|
||||
const notice = document.getElementById('apiInsightNotice');
|
||||
if (!notice) return;
|
||||
const hasInsight = typeof window.hasInsight !== 'undefined' ? !!window.hasInsight : false;
|
||||
if (!hasInsight) {
|
||||
const hasApiDocs = typeof window.hasApiDocs !== 'undefined' ? !!window.hasApiDocs : false;
|
||||
if (!hasApiDocs) {
|
||||
notice.classList.remove('d-none');
|
||||
} else {
|
||||
notice.classList.add('d-none');
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-2 text-info"><i class="fa fa-circle-info fa-lg"></i></div>
|
||||
<div>
|
||||
<div class="fw-semibold">Libby is an Insight feature.</div>
|
||||
<a class="alert-link" href="lts_trial.html">Start an Insight free trial</a> to enable Libby on this node.
|
||||
<div class="fw-semibold">Libby requires an entitled license.</div>
|
||||
<a class="alert-link" href="config_lts.html">Open License & Services</a> to review availability for this node.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
<div class="lqos-config-page">
|
||||
<div id="configMenuContainer"></div>
|
||||
|
||||
<div id="shapingBanner"></div>
|
||||
<div id="networkRecoveryPanel" class="alert alert-warning d-none" role="alert">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold mb-1">Network Change In Progress</div>
|
||||
<div id="networkRecoverySummary" class="mb-1">
|
||||
LibreQoS is waiting for you to confirm or revert the pending network change.
|
||||
</div>
|
||||
<div id="networkRecoveryDetail" class="small mb-0">
|
||||
Your browser may disconnect while the network change is being tested. This page will keep waiting for the box to come back.
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-column align-items-lg-end gap-2">
|
||||
<div id="networkRecoveryCountdown" class="small text-secondary">Waiting for pending change details...</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="recoveryConfirmButton" class="btn btn-success btn-sm" disabled>Confirm Network Change</button>
|
||||
<button type="button" id="recoveryRevertButton" class="btn btn-outline-danger btn-sm" disabled>Revert Network Change</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<h5 class="lqos-config-panel-title">Bridge & Interface Mode</h5>
|
||||
<div class="lqos-config-panel-subtitle">Choose how LibreQoS connects to the network, then inspect the detected netplan state before applying a managed bridge workflow.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="lqos-config-section-subtitle">Select either a two-interface bridge or a single trunk interface using VLANs. XDP bridge mode remains manual.</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 class="form-text">Two physical interfaces: one facing the Internet and one facing the LAN. Linux bridge mode is the managed netplan path.</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="form-text">Single physical interface using VLANs (on-a-stick). Managed netplan preview disables DHCP on the selected interface.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,62 +53,184 @@
|
||||
|
||||
<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>
|
||||
<div class="lqos-config-section">
|
||||
<h6 class="lqos-config-section-title">Bridge Mode Configuration</h6>
|
||||
<div class="lqos-config-section-subtitle">Choose the two shaping interfaces for the managed Linux bridge flow, or leave XDP enabled if you are intentionally handling Netplan outside this page.</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 (for example, <code>eth0</code>).</div>
|
||||
</div>
|
||||
|
||||
<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 (for example, <code>eth1</code>).</div>
|
||||
</div>
|
||||
<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. This remains a manual/power-user netplan workflow.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="toInternet" class="form-label">Internet-Facing Interface</label>
|
||||
<select class="form-select" id="toInternet">
|
||||
<option value="">Select an eligible interface</option>
|
||||
</select>
|
||||
<div class="form-text">Eligible non-management interfaces with LibreQoS-friendly queue support.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="toNetwork" class="form-label">LAN-Facing Interface</label>
|
||||
<select class="form-select" id="toNetwork">
|
||||
<option value="">Select an eligible interface</option>
|
||||
</select>
|
||||
<div class="form-text">Pick a different eligible interface for the LAN side.</div>
|
||||
</div>
|
||||
<div id="bridgeInterfaceHelp" class="small text-secondary"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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 (for example, <code>eth0</code>).</div>
|
||||
</div>
|
||||
<div class="lqos-config-section">
|
||||
<h6 class="lqos-config-section-title">Single Interface Mode Configuration</h6>
|
||||
<div class="lqos-config-section-subtitle">Pick the trunk interface and define the WAN and LAN VLAN IDs that LibreQoS should prepare in managed Netplan.</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>
|
||||
|
||||
<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>
|
||||
<div class="mb-3">
|
||||
<label for="interface" class="form-label">Interface Name</label>
|
||||
<select class="form-select" id="interface">
|
||||
<option value="">Select an eligible interface</option>
|
||||
</select>
|
||||
<div class="form-text">Eligible non-management physical interface for the trunk.</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>
|
||||
|
||||
<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>
|
||||
<div id="singleInterfaceHelp" class="small text-secondary mt-2"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="lqos-config-actions">
|
||||
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
|
||||
<div class="lqos-config-actions lqos-config-actions-network-mode">
|
||||
<div class="lqos-config-action-group">
|
||||
<div class="lqos-config-action-label">Draft</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" id="saveButton" class="btn btn-outline-primary btn-sm">Save Bridge & Interface Mode</button>
|
||||
<button type="button" id="inspectButton" class="btn btn-outline-secondary btn-sm">Inspect Current Netplan</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lqos-config-action-group lqos-config-action-group-primary">
|
||||
<div class="lqos-config-action-label">Commit Candidate</div>
|
||||
<button type="button" id="applyButton" class="btn btn-primary">Apply Network Changes</button>
|
||||
</div>
|
||||
<div class="lqos-config-action-group">
|
||||
<div class="lqos-config-action-label">Import Existing Netplan</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" id="adoptButton" class="btn btn-outline-primary btn-sm" disabled>Adopt into libreqos.yaml</button>
|
||||
<button type="button" id="takeoverButton" class="btn btn-outline-primary btn-sm" disabled>Take Over libreqos.yaml</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="lqos-config-grid lqos-config-grid-compact mt-1">
|
||||
<section class="lqos-config-panel">
|
||||
<div class="lqos-config-panel-header d-flex justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<h5 class="lqos-config-panel-title">Shaping Status</h5>
|
||||
<div class="lqos-config-panel-subtitle">Current shaping runtime state, degraded-mode detail, and whether manual intervention is still needed.</div>
|
||||
</div>
|
||||
<span id="shapingStatusBadge" class="badge text-bg-secondary">Loading</span>
|
||||
</div>
|
||||
<div class="lqos-config-section">
|
||||
<p id="shapingStatusSummary" class="mb-2 text-secondary">Loading shaping status...</p>
|
||||
<pre id="shapingStatusDetail" class="small mb-0 d-none"></pre>
|
||||
<div class="mt-3 d-flex justify-content-start">
|
||||
<button type="button" id="retryShapingButton" class="btn btn-outline-warning btn-sm d-none" disabled>Retry Shaping</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lqos-config-panel">
|
||||
<div class="lqos-config-panel-header d-flex justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<h5 class="lqos-config-panel-title">Bridge Status</h5>
|
||||
<div class="lqos-config-panel-subtitle">Detected Netplan ownership, compatibility warnings, and whether the current mode can be managed safely by LibreQoS.</div>
|
||||
</div>
|
||||
<span id="netplanStateBadge" class="badge text-bg-secondary">Loading</span>
|
||||
</div>
|
||||
<div class="lqos-config-section">
|
||||
<p id="netplanSummary" class="mb-2 text-secondary">Loading netplan inspection...</p>
|
||||
<div id="netplanWarnings"></div>
|
||||
<div id="netplanConflicts"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lqos-config-panel lqos-config-panel-wide">
|
||||
<div class="lqos-config-panel-header">
|
||||
<div>
|
||||
<h5 class="lqos-config-panel-title">Review Candidate</h5>
|
||||
<div class="lqos-config-panel-subtitle">Use the tabs below to review detected files, the managed candidate LibreQoS will write, and the exact diff before you commit a network change.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lqos-config-section">
|
||||
<div class="nav nav-pills lqos-config-tabbar mb-3" role="tablist" aria-label="Network mode review sections">
|
||||
<button type="button" class="nav-link active" data-review-tab="files" aria-selected="true">Detected Files</button>
|
||||
<button type="button" class="nav-link" data-review-tab="managed" aria-selected="false">Managed Preview</button>
|
||||
<button type="button" class="nav-link" data-review-tab="diff" aria-selected="false">Diff Preview</button>
|
||||
</div>
|
||||
|
||||
<div class="lqos-config-tabpanel" data-review-pane="files">
|
||||
<div class="small text-secondary mb-2">Each relevant file is classified so you can see whether LibreQoS can adopt it, must leave it alone, or treats it as conflicting.</div>
|
||||
<div id="netplanFiles" class="small text-secondary">Loading detected netplan files...</div>
|
||||
</div>
|
||||
|
||||
<div class="lqos-config-tabpanel d-none" data-review-pane="managed">
|
||||
<div id="managedPreviewMeta" class="small text-secondary mb-2"></div>
|
||||
<div id="managedPreviewNote"></div>
|
||||
<pre id="managedPreview" class="mb-0"></pre>
|
||||
</div>
|
||||
|
||||
<div class="lqos-config-tabpanel d-none" data-review-pane="diff">
|
||||
<div id="diffPreviewMeta" class="small text-secondary mb-2"></div>
|
||||
<pre id="diffPreview" class="mb-0 text-secondary">No diff preview available.</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lqos-config-panel">
|
||||
<div class="lqos-config-panel-header">
|
||||
<div>
|
||||
<h5 class="lqos-config-panel-title">Pending Change</h5>
|
||||
<div class="lqos-config-panel-subtitle">Use these controls to explicitly confirm or revert a staged network change before the rollback window expires.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lqos-config-section">
|
||||
<div id="pendingChange" class="text-secondary">No pending network change.</div>
|
||||
<div class="mt-3 lqos-config-button-row">
|
||||
<button type="button" id="confirmPendingButton" class="btn btn-success btn-sm" disabled>Confirm Pending Change</button>
|
||||
<button type="button" id="revertPendingButton" class="btn btn-outline-danger btn-sm" disabled>Revert Pending Change</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lqos-config-panel">
|
||||
<div class="lqos-config-panel-header">
|
||||
<div>
|
||||
<h5 class="lqos-config-panel-title">Last Backup</h5>
|
||||
<div class="lqos-config-panel-subtitle">Restore the most recent LibreQoS-managed backup if you need to return to the previous committed network configuration.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lqos-config-section">
|
||||
<div id="lastBackup" class="text-secondary">No helper backup recorded yet.</div>
|
||||
<div class="mt-3 lqos-config-button-row">
|
||||
<button type="button" id="restoreLatestBackupButton" class="btn btn-outline-secondary btn-sm" disabled>Restore Previous Managed Config</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="config_interface.js%CACHEBUSTERS%"></script>
|
||||
|
||||
@@ -5,41 +5,71 @@
|
||||
<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>
|
||||
<h5 class="lqos-config-panel-title">License & Services</h5>
|
||||
<div class="lqos-config-panel-subtitle">Review effective license state, control bootstrap behavior, and manage Insight or Local service settings from one place.</div>
|
||||
</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>
|
||||
<h6 class="lqos-config-section-title">Effective Status</h6>
|
||||
<div class="lqos-config-section-subtitle">This reflects the current license authority in use, not just what is configured locally.</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 class="card border-0 bg-body-tertiary mb-3">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
|
||||
<div>
|
||||
<div class="text-secondary small text-uppercase">License State</div>
|
||||
<div class="fs-5 fw-semibold" id="licenseStateLabel">Loading...</div>
|
||||
</div>
|
||||
<span class="badge text-bg-secondary" id="licenseAuthorityBadge">Loading</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="small text-secondary">Control Service</div>
|
||||
<div class="fw-semibold" id="controlServiceStatus">Checking...</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="small text-secondary">Mapped Circuits</div>
|
||||
<div class="fw-semibold" id="mappedCircuitLimit">Checking...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="small text-secondary">Capabilities</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-2" id="licenseCapabilityBadges"></div>
|
||||
</div>
|
||||
<div class="alert alert-warning d-none mt-3 mb-0" id="licenseAvailabilityAlert" role="alert"></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="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 class="d-flex flex-wrap gap-2">
|
||||
<button type="button" id="retryLicenseCheck" class="btn btn-outline-secondary">
|
||||
<i class="fa fa-rotate-right me-1"></i> Retry License Check
|
||||
</button>
|
||||
<span class="text-secondary small align-self-center" id="retryLicenseHint">
|
||||
Use this after correcting a key or when you want to force a fresh control-channel attempt.
|
||||
</span>
|
||||
</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>
|
||||
<h6 class="lqos-config-section-title">Collection and Endpoint Settings</h6>
|
||||
<div class="lqos-config-section-subtitle">Enable long-term stats for entitled tiers and define optional self-hosted service settings.</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="gatherStats">
|
||||
<label class="form-check-label" for="gatherStats">Gather Long-Term Statistics</label>
|
||||
<div class="form-text">Enable collection and submission of long-term performance data when the active license tier permits it.</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 long-term records.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="uispInterval" class="form-label">UISP Reporting Interval (seconds)</label>
|
||||
@@ -54,6 +84,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="lqos-config-section">
|
||||
<h6 class="lqos-config-section-title">License Key</h6>
|
||||
<div class="lqos-config-section-subtitle">Configured locally for control-channel bootstrap and entitlement refresh.</div>
|
||||
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-8">
|
||||
<label for="licenseKey" class="form-label">License Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="licenseKey" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn btn-outline-secondary" id="toggleLicenseKey" aria-label="Reveal license key">
|
||||
<i class="fa fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text" id="licenseKeyHelp">Configured license key for Insight or Local service entitlement, when applicable.</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<button type="button" id="clearLicenseKey" class="btn btn-outline-danger w-100">
|
||||
<i class="fa fa-trash me-1"></i> Clear License Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<i class="fa fa-life-ring"></i> Support Tickets
|
||||
</h5>
|
||||
<div class="text-body-secondary small">
|
||||
Priority support is available to Insight subscribers.
|
||||
Priority support is available to entitled license tiers.
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
@@ -23,8 +23,8 @@
|
||||
|
||||
<div id="ticketGateNoInsight" class="alert alert-info mt-3 mb-0 d-none">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
To submit a support ticket you need an <strong>Insight</strong> subscription.
|
||||
<a class="alert-link" href="lts_trial.html">Start an Insight free trial</a>.
|
||||
To submit a support ticket you need an entitled license.
|
||||
<a class="alert-link" href="config_lts.html">Open License & Services</a>.
|
||||
</div>
|
||||
|
||||
<div id="ticketGateInsight" class="mt-3 d-none">
|
||||
@@ -37,19 +37,18 @@
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa fa-question-circle"></i> Help & Support</h5>
|
||||
<h5 class="card-title"><i class="fa fa-circle-info"></i> About LibreQoS</h5>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
Priority support is given to Insight subscribers and donors. Other support is
|
||||
best effort, with volunteers trying to help as best we can.
|
||||
</div>
|
||||
<p class="text-body-secondary">
|
||||
Learn more about the project and the people who helped shape it.
|
||||
</p>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="https://github.com/sponsors/LibreQoE/" class="btn btn-primary d-inline-flex align-items-center gap-2">
|
||||
<i class="fa fa-dollar-sign"></i> Support LibreQoS Today
|
||||
<a href="https://libreqos.io/" class="btn btn-primary d-inline-flex align-items-center gap-2">
|
||||
<i class="fa fa-globe"></i> Visit LibreQoS.io
|
||||
</a>
|
||||
<button class="btn btn-outline-primary d-inline-flex align-items-center" type="button" id="btnDave">
|
||||
<button class="btn btn-lqos-memorial d-inline-flex align-items-center gap-2" type="button" id="btnDave">
|
||||
<i class="fa fa-heart"></i>
|
||||
In Memory of Dave Täht
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,6 @@ body.dark-mode .card-header.bg-light {
|
||||
</div>
|
||||
<div id="circuitCount" class="mt-3 mb-4" style="display: none;">
|
||||
<h3>Your Network: <span id="circuitNumber">0</span> Active Circuits</h3>
|
||||
<p class="fs-5">Only $<span id="monthlyPrice">0.00</span>/month for comprehensive network insights!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1997,6 +1997,7 @@ table tr td a {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
padding: 1.1rem 1.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lqos-config-panel-header {
|
||||
@@ -2030,6 +2031,8 @@ table tr td a {
|
||||
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;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lqos-config-section-title {
|
||||
@@ -2075,6 +2078,127 @@ table tr td a {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.lqos-config-actions-network-mode {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lqos-config-action-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: min(100%, 16rem);
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid var(--lqos-border);
|
||||
border-radius: var(--lqos-radius-md);
|
||||
background: color-mix(in srgb, var(--lqos-surface-2) 82%, transparent);
|
||||
}
|
||||
|
||||
.lqos-config-action-group-primary {
|
||||
border-color: color-mix(in srgb, var(--bs-info) 32%, var(--lqos-border));
|
||||
background: color-mix(in srgb, var(--bs-info-bg-subtle) 42%, var(--lqos-surface-2) 58%);
|
||||
}
|
||||
|
||||
.lqos-config-action-label {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lqos-config-grid-compact {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.lqos-config-panel-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.lqos-config-tabbar {
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.lqos-config-tabbar .nav-link {
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--lqos-border);
|
||||
background: color-mix(in srgb, var(--lqos-surface-2) 78%, transparent);
|
||||
}
|
||||
|
||||
.lqos-config-tabpanel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lqos-config-button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lqos-config-button-row .btn {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.lqos-break-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#pendingChange,
|
||||
#lastBackup {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#pendingChange code,
|
||||
#lastBackup code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lqos-backup-entry {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lqos-config-disclosure {
|
||||
border: 1px dashed color-mix(in srgb, var(--lqos-border) 85%, transparent);
|
||||
border-radius: var(--lqos-radius-md);
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: color-mix(in srgb, var(--lqos-surface-2) 72%, transparent);
|
||||
}
|
||||
|
||||
.lqos-config-disclosure summary {
|
||||
cursor: pointer;
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lqos-config-disclosure[open] summary {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
#lastBackup {
|
||||
max-height: 13rem;
|
||||
overflow: auto;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.lqos-config-grid-compact {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lqos-config-panel-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.lqos-config-panel .card {
|
||||
border: 1px solid var(--lqos-border);
|
||||
border-radius: var(--lqos-radius-md);
|
||||
@@ -2447,3 +2571,41 @@ table tr td a {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.btn-lqos-memorial {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #58667d 0%, #39475a 100%);
|
||||
border-color: #39475a;
|
||||
box-shadow: 0 0.35rem 0.9rem rgba(57, 71, 90, 0.18);
|
||||
}
|
||||
|
||||
.btn-lqos-memorial:hover,
|
||||
.btn-lqos-memorial:focus,
|
||||
.btn-lqos-memorial:active,
|
||||
.btn-check:checked + .btn-lqos-memorial,
|
||||
.btn-check:active + .btn-lqos-memorial,
|
||||
.btn-lqos-memorial.show {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4a576c 0%, #2f3c4d 100%);
|
||||
border-color: #2f3c4d;
|
||||
}
|
||||
|
||||
.btn-lqos-memorial:focus-visible {
|
||||
box-shadow: 0 0 0 0.25rem rgba(88, 102, 125, 0.28);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .btn-lqos-memorial {
|
||||
background: linear-gradient(135deg, #6b7d9a 0%, #4d5f79 100%);
|
||||
border-color: #7d90ae;
|
||||
box-shadow: 0 0.35rem 0.9rem rgba(5, 8, 15, 0.35);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .btn-lqos-memorial:hover,
|
||||
html[data-bs-theme="dark"] .btn-lqos-memorial:focus,
|
||||
html[data-bs-theme="dark"] .btn-lqos-memorial:active,
|
||||
html[data-bs-theme="dark"] .btn-check:checked + .btn-lqos-memorial,
|
||||
html[data-bs-theme="dark"] .btn-check:active + .btn-lqos-memorial,
|
||||
html[data-bs-theme="dark"] .btn-lqos-memorial.show {
|
||||
background: linear-gradient(135deg, #7a8fb3 0%, #5d7290 100%);
|
||||
border-color: #8ea3c4;
|
||||
}
|
||||
@@ -32,7 +32,6 @@
|
||||
<body>
|
||||
%%%LTS_SCRIPT%%%
|
||||
<script>window.LQOS_UI_VERSION = "%%VERSION%%";</script>
|
||||
<script>window.sm = %%MODAL%%; window.smn = "%%MODAL_NUM%%"</script>
|
||||
<div id="wrapper" class="lqos-shell"> <!-- Whole Page Wrapper -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastHolder"></div>
|
||||
|
||||
@@ -180,7 +179,7 @@
|
||||
<button type="button" class="btn btn-outline-secondary" id="btnQueueModeObserve">Observe</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="btnQueueModeShape">Shape</button>
|
||||
</div>
|
||||
<div class="small text-secondary mt-2">Reload required. Switching modes may briefly interrupt traffic.</div>
|
||||
<div class="small text-secondary mt-2">Reload required. Switching modes typically cause a brief traffic pause of about 5 seconds.</div>
|
||||
<hr />
|
||||
</li>
|
||||
<!-- User Info -->
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
//! Provides an Axum layer that applies templates to static HTML
|
||||
//! files.
|
||||
|
||||
use crate::lts2_sys::shared_types::LtsStatus;
|
||||
use crate::node_manager::auth::{FIRST_LOAD, get_username};
|
||||
use crate::shaped_devices_tracker::SHAPED_DEVICES;
|
||||
use crate::node_manager::auth::get_username;
|
||||
use crate::tool_status::is_api_available;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::header;
|
||||
@@ -11,11 +9,8 @@ use axum::http::{HeaderValue, Request, Response, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::IntoResponse;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use itertools::Itertools;
|
||||
use lqos_config::{RttThresholds, load_config};
|
||||
use lqos_utils::unix_time::unix_now;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
|
||||
const VERSION_STRING: &str = include_str!("../../../../VERSION_STRING");
|
||||
|
||||
@@ -103,9 +98,6 @@ pub async fn apply_templates(
|
||||
let template_text = template_text.replace("%%USERNAME%%", &username);
|
||||
|
||||
let res = next.run(req).await;
|
||||
//let mut lts_script = "<script>window.hasLts = false;</script>";
|
||||
let mut script_has_lts = false;
|
||||
let mut script_has_insight = false;
|
||||
let new_version = crate::version_checks::new_version_available();
|
||||
|
||||
if apply_template {
|
||||
@@ -113,20 +105,10 @@ pub async fn apply_templates(
|
||||
let mut trial_link;
|
||||
|
||||
// Change the LTS part of the template
|
||||
let (lts_status, _) = crate::lts2_sys::get_lts_license_status_async().await;
|
||||
let capabilities = crate::lts2_sys::current_capabilities();
|
||||
trial_link = INSIGHT_LINK_OFFER_TRIAL.to_string();
|
||||
let script_has_support_tickets = matches!(
|
||||
lts_status,
|
||||
LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full
|
||||
);
|
||||
match lts_status {
|
||||
LtsStatus::Invalid | LtsStatus::NotChecked => {}
|
||||
_ => {
|
||||
// Link to it
|
||||
trial_link = INSIGHT_LINK_ACTIVE.to_string();
|
||||
script_has_insight = true;
|
||||
script_has_lts = true;
|
||||
}
|
||||
if capabilities.can_view_insight_ui {
|
||||
trial_link = INSIGHT_LINK_ACTIVE.to_string();
|
||||
}
|
||||
|
||||
// Title and node_id
|
||||
@@ -136,37 +118,25 @@ pub async fn apply_templates(
|
||||
|
||||
// "LTS script" - which is increasingly becoming a misnomer
|
||||
let lts_script = format!(
|
||||
"<script>window.hasLts = {}; window.hasInsight = {}; window.hasSupportTickets = {}; window.nodeId = '{}'; window.rttThresholds = {{greenMs: {}, yellowMs: {}, redMs: {}}};</script>",
|
||||
js_tf(script_has_lts),
|
||||
js_tf(script_has_insight),
|
||||
js_tf(script_has_support_tickets),
|
||||
"<script>window.hasLts = {}; window.hasInsight = {}; window.hasSupportTickets = {}; window.hasChatbot = {}; window.hasApiDocs = {}; window.liveControlAvailable = {}; window.licenseStateLabel = {}; window.licenseAuthorityLabel = {}; window.mappedCircuitLimit = {}; window.nodeId = '{}'; window.rttThresholds = {{greenMs: {}, yellowMs: {}, redMs: {}}};</script>",
|
||||
js_tf(capabilities.can_view_insight_ui),
|
||||
js_tf(capabilities.can_view_insight_ui),
|
||||
js_tf(capabilities.can_use_support_tickets),
|
||||
js_tf(capabilities.can_use_chatbot),
|
||||
js_tf(capabilities.can_use_api_link),
|
||||
js_tf(capabilities.control_service_reachable),
|
||||
serde_json::to_string(&capabilities.license_state_label)
|
||||
.unwrap_or_else(|_| "\"Unknown\"".to_string()),
|
||||
serde_json::to_string(&capabilities.authority_label)
|
||||
.unwrap_or_else(|_| "\"Unknown\"".to_string()),
|
||||
serde_json::to_string(&capabilities.mapped_circuit_limit)
|
||||
.unwrap_or_else(|_| "null".to_string()),
|
||||
node_id_js,
|
||||
rtt_thresholds.green_ms,
|
||||
rtt_thresholds.yellow_ms,
|
||||
rtt_thresholds.red_ms,
|
||||
);
|
||||
|
||||
// First Login
|
||||
let mut show_modal = "false";
|
||||
let mut show_modal_number = "0".to_string();
|
||||
if let Ok(now) = unix_now() {
|
||||
let week_ago = now - (7 * 24 * 60 * 60);
|
||||
let fl = FIRST_LOAD.load(Relaxed);
|
||||
if fl != 0 && fl < week_ago {
|
||||
let sd = SHAPED_DEVICES.load();
|
||||
let num_circuits = sd
|
||||
.devices
|
||||
.iter()
|
||||
.sorted_by(|a, b| a.circuit_hash.cmp(&b.circuit_hash))
|
||||
.dedup()
|
||||
.count();
|
||||
if num_circuits > 1_000 && !script_has_insight {
|
||||
show_modal = "true";
|
||||
show_modal_number = num_circuits.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (mut res_parts, res_body) = res.into_parts();
|
||||
let bytes = to_bytes(res_body, 1_000_000)
|
||||
.await
|
||||
@@ -178,11 +148,9 @@ pub async fn apply_templates(
|
||||
.replace("%%VERSION%%", version_string)
|
||||
.replace("%%TITLE%%", &title)
|
||||
.replace("%%LTS_LINK%%", &trial_link)
|
||||
.replace("%%%LTS_SCRIPT%%%", <s_script)
|
||||
.replace("%%MODAL%%", show_modal)
|
||||
.replace("%%MODAL_NUM%%", &show_modal_number);
|
||||
.replace("%%%LTS_SCRIPT%%%", <s_script);
|
||||
// Handle API_LINK placeholder (require service + valid Insight)
|
||||
let api_link = if is_api_available() && script_has_insight {
|
||||
let api_link = if is_api_available() && capabilities.can_use_api_link {
|
||||
API_LINK_ACTIVE
|
||||
} else {
|
||||
API_LINK_INACTIVE
|
||||
|
||||
@@ -714,19 +714,32 @@ async fn receive_channel_message(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
WsRequest::SupportTicketList => {
|
||||
if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await {
|
||||
WsRequest::SupportTicketList => match lts::support_ticket_gate().await {
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "Support tickets require an Insight subscription".to_string(),
|
||||
message: "Support tickets require an entitled license.".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE) => {
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "license valid, control service unavailable".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
let control_tx = request_state.private_state.control_tx();
|
||||
if control_tx
|
||||
@@ -741,7 +754,7 @@ async fn receive_channel_message(
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "Insight control channel unavailable".to_string(),
|
||||
message: "license valid, control service unavailable".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -749,8 +762,8 @@ async fn receive_channel_message(
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(30), reply_rx)
|
||||
.await;
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), reply_rx).await;
|
||||
match result {
|
||||
Ok(Ok(Ok(tickets))) => {
|
||||
if send_ws_response(&tx, WsResponse::SupportTicketListResult { tickets })
|
||||
@@ -774,20 +787,33 @@ async fn receive_channel_message(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WsRequest::SupportTicketGet { ticket_id } => {
|
||||
if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await {
|
||||
},
|
||||
WsRequest::SupportTicketGet { ticket_id } => match lts::support_ticket_gate().await {
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "Support tickets require an Insight subscription".to_string(),
|
||||
message: "Support tickets require an entitled license.".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE) => {
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "license valid, control service unavailable".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
let control_tx = request_state.private_state.control_tx();
|
||||
if control_tx
|
||||
@@ -803,7 +829,7 @@ async fn receive_channel_message(
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "Insight control channel unavailable".to_string(),
|
||||
message: "license valid, control service unavailable".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -836,7 +862,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
WsRequest::SupportTicketCreate {
|
||||
subject,
|
||||
priority,
|
||||
@@ -854,11 +880,16 @@ async fn receive_channel_message(
|
||||
{
|
||||
return true;
|
||||
}
|
||||
} else if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await {
|
||||
} else if let Err(status) = lts::support_ticket_gate().await {
|
||||
let message = match status {
|
||||
StatusCode::FORBIDDEN => "Support tickets require an entitled license.",
|
||||
StatusCode::SERVICE_UNAVAILABLE => "license valid, control service unavailable",
|
||||
_ => "Support tickets unavailable",
|
||||
};
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "Support tickets require an Insight subscription".to_string(),
|
||||
message: message.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -884,7 +915,7 @@ async fn receive_channel_message(
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "Insight control channel unavailable".to_string(),
|
||||
message: "license valid, control service unavailable".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -934,11 +965,16 @@ async fn receive_channel_message(
|
||||
{
|
||||
return true;
|
||||
}
|
||||
} else if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await {
|
||||
} else if let Err(status) = lts::support_ticket_gate().await {
|
||||
let message = match status {
|
||||
StatusCode::FORBIDDEN => "Support tickets require an entitled license.",
|
||||
StatusCode::SERVICE_UNAVAILABLE => "license valid, control service unavailable",
|
||||
_ => "Support tickets unavailable",
|
||||
};
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "Support tickets require an Insight subscription".to_string(),
|
||||
message: message.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -968,7 +1004,7 @@ async fn receive_channel_message(
|
||||
if send_ws_response(
|
||||
&tx,
|
||||
WsResponse::Error {
|
||||
message: "Insight control channel unavailable".to_string(),
|
||||
message: "license valid, control service unavailable".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1049,6 +1085,56 @@ async fn receive_channel_message(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
WsRequest::LtsCapabilities => match lts::lts_capabilities_data(*request_state.login) {
|
||||
Ok(data) => {
|
||||
let response = WsResponse::LtsCapabilitiesResult { data };
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Unauthorized".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Unable to load license status".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
WsRequest::LtsRetryLicenseCheck => {
|
||||
match lts::retry_license_check_data(*request_state.login) {
|
||||
Ok(data) => {
|
||||
let response = WsResponse::LtsCapabilitiesResult { data };
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Unauthorized".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Unable to retry license check".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WsRequest::LtsStartSignup => {
|
||||
let result = lts::lts_trial_start_signup_data().await;
|
||||
match result {
|
||||
@@ -1107,7 +1193,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1132,7 +1218,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1158,7 +1244,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1185,7 +1271,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1212,7 +1298,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1238,7 +1324,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1264,7 +1350,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1292,7 +1378,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1318,7 +1404,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1345,7 +1431,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1371,7 +1457,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
@@ -1397,7 +1483,7 @@ async fn receive_channel_message(
|
||||
}
|
||||
Err(StatusCode::FORBIDDEN) => {
|
||||
let response = WsResponse::Error {
|
||||
message: "Insight not enabled".to_string(),
|
||||
message: "This page requires an entitled license.".to_string(),
|
||||
};
|
||||
if send_ws_response(&tx, response).await {
|
||||
return true;
|
||||
|
||||
@@ -61,7 +61,9 @@ use crate::throughput_tracker::TcpRetransmitTotal;
|
||||
use crate::throughput_tracker::flow_data::{
|
||||
AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry,
|
||||
};
|
||||
use lqos_bus::{Circuit, FlowbeeSummaryData, QueueStoreTransit, StormguardDebugEntry};
|
||||
use lqos_bus::{
|
||||
Circuit, FlowbeeSummaryData, LtsCapabilitiesSummary, QueueStoreTransit, StormguardDebugEntry,
|
||||
};
|
||||
use lqos_config::QooProfileInfo;
|
||||
use lqos_config::{Config, NetworkJsonTransport, ShapedDevice, WebUser};
|
||||
use lqos_utils::units::DownUpOrder;
|
||||
@@ -144,6 +146,8 @@ pub enum WsRequest {
|
||||
LtsTrialConfig,
|
||||
CircuitCount,
|
||||
LtsStartSignup,
|
||||
LtsCapabilities,
|
||||
LtsRetryLicenseCheck,
|
||||
LtsSignUp {
|
||||
license_key: String,
|
||||
},
|
||||
@@ -778,6 +782,9 @@ pub enum WsResponse {
|
||||
CircuitCountResult {
|
||||
data: CircuitCount,
|
||||
},
|
||||
LtsCapabilitiesResult {
|
||||
data: LtsCapabilitiesSummary,
|
||||
},
|
||||
LtsStartSignupResult {
|
||||
claim_id: String,
|
||||
},
|
||||
|
||||
@@ -178,6 +178,22 @@ impl PrivateState {
|
||||
return;
|
||||
}
|
||||
|
||||
let capabilities = crate::lts2_sys::current_capabilities();
|
||||
if !capabilities.can_use_chatbot || !capabilities.control_service_reachable {
|
||||
let message = if capabilities.can_use_chatbot {
|
||||
"license valid, control service unavailable"
|
||||
} else {
|
||||
"Libby requires an entitled license."
|
||||
};
|
||||
let response = WsResponse::Error {
|
||||
message: message.to_string(),
|
||||
};
|
||||
if let Ok(payload) = encode_ws_message(&response) {
|
||||
let _ = self.tx.send(payload).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let request_id = rand::random::<u64>();
|
||||
self.chatbot_request = Some(request_id);
|
||||
let (stream_tx, mut stream_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
||||
|
||||
@@ -20,6 +20,10 @@ pub fn start_remote_commands() {
|
||||
}
|
||||
|
||||
fn run_command(command: RemoteCommand) {
|
||||
if !crate::lts2_sys::current_capabilities().can_receive_remote_commands {
|
||||
warn!("Ignoring remote command because current license tier does not permit it");
|
||||
return;
|
||||
}
|
||||
match command {
|
||||
RemoteCommand::Log(msg) => {
|
||||
warn!("Message from Insight: {}", msg);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use lqos_utils::unix_time::unix_now;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ShapingRuntimeState {
|
||||
Active,
|
||||
Starting,
|
||||
Inactive,
|
||||
ErrorPreflight,
|
||||
ErrorKernelAttach,
|
||||
ErrorInterfaceMissing,
|
||||
ErrorConfig,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ShapingRuntimeStatus {
|
||||
pub state: ShapingRuntimeState,
|
||||
pub degraded: bool,
|
||||
pub can_retry: bool,
|
||||
pub summary: String,
|
||||
pub detail: Option<String>,
|
||||
pub updated_unix: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ShapingRuntimeStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: ShapingRuntimeState::Starting,
|
||||
degraded: false,
|
||||
can_retry: false,
|
||||
summary: "LibreQoS is starting shaping.".to_string(),
|
||||
detail: None,
|
||||
updated_unix: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static SHAPING_RUNTIME_STATUS: Lazy<RwLock<ShapingRuntimeStatus>> =
|
||||
Lazy::new(|| RwLock::new(ShapingRuntimeStatus::default()));
|
||||
|
||||
fn now_unix() -> Option<u64> {
|
||||
unix_now().ok()
|
||||
}
|
||||
|
||||
fn set_status(status: ShapingRuntimeStatus) {
|
||||
*SHAPING_RUNTIME_STATUS.write() = status;
|
||||
}
|
||||
|
||||
pub fn mark_starting(summary: impl Into<String>) {
|
||||
set_status(ShapingRuntimeStatus {
|
||||
state: ShapingRuntimeState::Starting,
|
||||
degraded: false,
|
||||
can_retry: false,
|
||||
summary: summary.into(),
|
||||
detail: None,
|
||||
updated_unix: now_unix(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn mark_active(summary: impl Into<String>) {
|
||||
set_status(ShapingRuntimeStatus {
|
||||
state: ShapingRuntimeState::Active,
|
||||
degraded: false,
|
||||
can_retry: false,
|
||||
summary: summary.into(),
|
||||
detail: None,
|
||||
updated_unix: now_unix(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn mark_error(
|
||||
state: ShapingRuntimeState,
|
||||
summary: impl Into<String>,
|
||||
detail: impl Into<String>,
|
||||
) {
|
||||
set_status(ShapingRuntimeStatus {
|
||||
state,
|
||||
degraded: true,
|
||||
can_retry: true,
|
||||
summary: summary.into(),
|
||||
detail: Some(detail.into()),
|
||||
updated_unix: now_unix(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get_status() -> ShapingRuntimeStatus {
|
||||
SHAPING_RUNTIME_STATUS.read().clone()
|
||||
}
|
||||
|
||||
pub fn classify_preflight_error(detail: &str) -> ShapingRuntimeState {
|
||||
let lower = detail.to_lowercase();
|
||||
if lower.contains("does not exist") || lower.contains("missing") {
|
||||
ShapingRuntimeState::ErrorInterfaceMissing
|
||||
} else if lower.contains("configuration")
|
||||
|| lower.contains("/etc/lqos.conf")
|
||||
|| lower.contains("load config")
|
||||
{
|
||||
ShapingRuntimeState::ErrorConfig
|
||||
} else {
|
||||
ShapingRuntimeState::ErrorPreflight
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ use self::throughput_entry::ThroughputEntry;
|
||||
use crate::system_stats::SystemStats;
|
||||
use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection;
|
||||
use crate::{
|
||||
lts2_sys::{get_lts_license_status, shared_types::LtsStatus},
|
||||
shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES},
|
||||
stats::TIME_TO_POLL_HOSTS,
|
||||
throughput_tracker::tracking_data::{FlowApplyContext, ThroughputTracker},
|
||||
@@ -1015,10 +1014,7 @@ pub fn executive_summary_header() -> BusResponse {
|
||||
let queue_counts = ALL_QUEUE_SUMMARY.queue_counts();
|
||||
let bakery_reload_in_progress = full_reload_in_progress();
|
||||
let queue_stats_stale = queue_stats_stale() || bakery_reload_in_progress;
|
||||
let insight_connected = !matches!(
|
||||
get_lts_license_status().0,
|
||||
LtsStatus::Invalid | LtsStatus::NotChecked
|
||||
);
|
||||
let insight_connected = crate::lts2_sys::current_capabilities().control_service_reachable;
|
||||
|
||||
BusResponse::ExecutiveSummaryHeader(ExecutiveSummaryHeader {
|
||||
circuit_count,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::lts2_sys::get_lts_license_status;
|
||||
use crate::lts2_sys::shared_types::{CircuitCakeDrops, CircuitCakeMarks, LtsStatus};
|
||||
use crate::lts2_sys::shared_types::{CircuitCakeDrops, CircuitCakeMarks};
|
||||
use crate::shaped_devices_tracker::{
|
||||
NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, shaped_device_from_hashes_or_ip,
|
||||
};
|
||||
@@ -118,13 +117,7 @@ pub(crate) fn submit_throughput_stats(
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail out if the license doesn't indicate that we're allowed to submit stats
|
||||
let (license_status, _days_remaining) = get_lts_license_status();
|
||||
let can_submit = !matches!(
|
||||
license_status,
|
||||
LtsStatus::NotChecked | LtsStatus::ApiOnly | LtsStatus::Invalid
|
||||
);
|
||||
if !can_submit {
|
||||
if !crate::lts2_sys::can_submit_long_term_stats() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -406,11 +406,12 @@ mod tests {
|
||||
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();
|
||||
config.state_directory = Some(temp_dir.join("state").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))
|
||||
let payload = fs::read(config.topology_state_file_path(CIRCUIT_ETHERNET_METADATA_FILENAME))
|
||||
.expect("metadata file should exist");
|
||||
let parsed: CircuitEthernetMetadataFile =
|
||||
serde_json::from_slice(&payload).expect("metadata file should parse");
|
||||
|
||||
+5
-2
@@ -56,8 +56,11 @@ def configure_scheduler_stdio():
|
||||
continue
|
||||
try:
|
||||
reconfigure(line_buffering=True, write_through=True)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: unable to enable line-buffered scheduler {stream_name}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def get_state_directory():
|
||||
|
||||
Reference in new issue
Block a user