From 87a108c12da07c7cb2e523e3a3c93b356d2df339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Chac=C3=B3n?= Date: Wed, 18 Mar 2026 16:35:40 -0600 Subject: [PATCH 001/102] Update conf.py --- conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf.py b/conf.py index dbfb41d5..9f4f6139 100644 --- a/conf.py +++ b/conf.py @@ -38,7 +38,7 @@ def ultimateReplace(app, docname, source): source[0] = result ultimate_replacements = { - "{deb_url_v1_5}" : "libreqos_2.0-beta-1.202603181919-1_amd64.deb" + "{deb_url_v1_5}" : "libreqos_2.0.202603182012-1_amd64.deb" } def setup(app): From d27aaf40716198e04df3e09a0fbd8b25e637b845 Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Wed, 18 Mar 2026 17:22:43 -0600 Subject: [PATCH 002/102] changes --- docs-es/v2.0/quickstart-es.md | 1 + docs-es/v2.0/update-es.md | 1 + docs/v2.0/quickstart.md | 1 + docs/v2.0/update.md | 1 + 4 files changed, 4 insertions(+) diff --git a/docs-es/v2.0/quickstart-es.md b/docs-es/v2.0/quickstart-es.md index 42341086..fe193776 100644 --- a/docs-es/v2.0/quickstart-es.md +++ b/docs-es/v2.0/quickstart-es.md @@ -46,6 +46,7 @@ Después del reinicio, reanude la instalación: ```bash cd /tmp +wget https://download.libreqos.com/{deb_url_v1_5} sudo apt install ./{deb_url_v1_5} ``` diff --git a/docs-es/v2.0/update-es.md b/docs-es/v2.0/update-es.md index 5609b5e1..da34a9de 100644 --- a/docs-es/v2.0/update-es.md +++ b/docs-es/v2.0/update-es.md @@ -43,6 +43,7 @@ Después del reinicio, reanuda la actualización y reinicia los servicios: ```bash cd /tmp +wget https://download.libreqos.com/{deb_url_v1_5} sudo apt install ./{deb_url_v1_5} sudo systemctl restart lqosd lqos_scheduler ``` diff --git a/docs/v2.0/quickstart.md b/docs/v2.0/quickstart.md index 5b83d0cc..5091b52e 100644 --- a/docs/v2.0/quickstart.md +++ b/docs/v2.0/quickstart.md @@ -46,6 +46,7 @@ After the reboot, resume the install: ```bash cd /tmp +wget https://download.libreqos.com/{deb_url_v1_5} sudo apt install ./{deb_url_v1_5} ``` diff --git a/docs/v2.0/update.md b/docs/v2.0/update.md index 5bb995e2..a3bf6b29 100644 --- a/docs/v2.0/update.md +++ b/docs/v2.0/update.md @@ -43,6 +43,7 @@ After the reboot, resume the upgrade and restart services: ```bash cd /tmp +wget https://download.libreqos.com/{deb_url_v1_5} sudo apt install ./{deb_url_v1_5} sudo systemctl restart lqosd lqos_scheduler ``` From 0b5f0e83546b182d2ff19727bfe8f5cdd5b4c695 Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Wed, 18 Mar 2026 19:35:07 -0600 Subject: [PATCH 003/102] changes --- docs-es/v2.0/stormguard-es.md | 3 ++ docs/v2.0/stormguard.md | 3 ++ src/rust/lqos_python/src/lib.rs | 64 ++++++++++++------------ src/scheduler.py | 9 +++- src/test_scheduler.py | 89 +++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 35 deletions(-) diff --git a/docs-es/v2.0/stormguard-es.md b/docs-es/v2.0/stormguard-es.md index d3189d1f..498b50f9 100644 --- a/docs-es/v2.0/stormguard-es.md +++ b/docs-es/v2.0/stormguard-es.md @@ -12,6 +12,8 @@ StormGuard es el subsistema de ajuste adaptativo de colas de LibreQoS para event - Aplica ajustes acotados a límites de nodos configurados para proteger calidad bajo estrés. - Expone estado y depuración en WebUI. +Los cambios adaptativos de velocidad por sitio de StormGuard se guardan en la capa de overrides de StormGuard. No se escriben de vuelta en `network.json`. + ## Configuración StormGuard se configura en `/etc/lqos.conf` bajo `[stormguard]`. @@ -69,6 +71,7 @@ También verifique: - que los nombres en `targets` aún coincidan con `network.json` - que cambios de integración no hayan renombrado nodos clave - que los pisos mínimos sean razonables para su perfil de tráfico +- que `network.json` siga reflejando sus velocidades planificadas/de origen si está investigando una reducción inesperada de StormGuard - que `log_file` (si se usa) sea escribible por el servicio ## Páginas relacionadas diff --git a/docs/v2.0/stormguard.md b/docs/v2.0/stormguard.md index be1739ef..e7019962 100644 --- a/docs/v2.0/stormguard.md +++ b/docs/v2.0/stormguard.md @@ -12,6 +12,8 @@ StormGuard is LibreQoS' adaptive queue-adjustment subsystem for congestion and q - Applies bounded adjustments to configured node limits to protect quality under stress. - Exposes status/debug data in the WebUI (Node Manager). +StormGuard adaptive site-speed changes are stored in the StormGuard override layer. They are not written back into `network.json`. + ## Configuration StormGuard is configured in `/etc/lqos.conf` under `[stormguard]`. @@ -71,6 +73,7 @@ Also verify: - target node names still match your current `network.json` hierarchy - integration updates have not renamed key nodes/APs - your minimum percentage floors are reasonable for expected traffic profiles +- `network.json` still reflects your planned/source-of-truth site rates if you are investigating an unexpected StormGuard reduction - `log_file` path (if configured) is writable by the service user ## Related Pages diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index d7bdb8c4..2edb31a7 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -677,6 +677,10 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> { overrides_network_adjustments_effective, m )?)?; + m.add_function(wrap_pyfunction!( + overrides_network_adjustments_materialized, + m + )?)?; m.add_function(wrap_pyfunction!(is_network_flat, m)?)?; m.add_function(wrap_pyfunction!(blackboard_finish, m)?)?; m.add_function(wrap_pyfunction!(blackboard_submit, m)?)?; @@ -1229,38 +1233,7 @@ fn overrides_network_adjustments(py: Python<'_>) -> PyResult> { Err(e) => return Err(PyOSError::new_err(e.to_string())), }; - let mut out: Vec = Vec::new(); - for adj in overrides.network_adjustments().iter() { - let d = PyDict::new(py); - match adj { - lqos_overrides::NetworkAdjustment::AdjustSiteSpeed { - site_name, - download_bandwidth_mbps, - upload_bandwidth_mbps, - } => { - d.set_item("type", "adjust_site_speed")?; - d.set_item("site_name", site_name.clone())?; - if let Some(v) = download_bandwidth_mbps { - d.set_item("download_bandwidth_mbps", *v)?; - } - if let Some(v) = upload_bandwidth_mbps { - d.set_item("upload_bandwidth_mbps", *v)?; - } - } - lqos_overrides::NetworkAdjustment::SetNodeVirtual { - node_name, - virtual_node, - } => { - d.set_item("type", "set_node_virtual")?; - d.set_item("node_name", node_name.clone())?; - d.set_item("virtual", *virtual_node)?; - } - } - let obj: PyObject = d.unbind().into(); - out.push(obj); - } - - Ok(out) + network_adjustments_to_py(py, overrides.network_adjustments()) } /// Returns the list of network adjustments as Python dicts, using the effective overrides view @@ -1280,8 +1253,33 @@ fn overrides_network_adjustments_effective(py: Python<'_>) -> PyResult return Err(PyOSError::new_err(e.to_string())), }; + network_adjustments_to_py(py, overrides.network_adjustments()) +} + +/// Returns the list of network adjustments that should be materialized into `network.json`. +/// +/// This includes operator-owned network adjustments and TreeGuard virtual-node changes, but +/// intentionally excludes StormGuard site-speed adjustments so adaptive runtime rates do not +/// overwrite the operator-authored topology/source-of-truth file. +#[pyfunction] +fn overrides_network_adjustments_materialized(py: Python<'_>) -> PyResult> { + let config = lqos_config::load_config().map_err(|e| PyOSError::new_err(e.to_string()))?; + let apply_treeguard = config.treeguard.enabled; + + let overrides = match lqos_overrides::OverrideStore::load_effective(false, apply_treeguard) { + Ok(o) => o, + Err(e) => return Err(PyOSError::new_err(e.to_string())), + }; + + network_adjustments_to_py(py, overrides.network_adjustments()) +} + +fn network_adjustments_to_py( + py: Python<'_>, + adjustments: &[lqos_overrides::NetworkAdjustment], +) -> PyResult> { let mut out: Vec = Vec::new(); - for adj in overrides.network_adjustments().iter() { + for adj in adjustments.iter() { let d = PyDict::new(py); match adj { lqos_overrides::NetworkAdjustment::AdjustSiteSpeed { diff --git a/src/scheduler.py b/src/scheduler.py index 7308d5e0..9f55db14 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -12,7 +12,8 @@ from liblqos_python import automatic_import_uisp, automatic_import_splynx, queue automatic_import_powercode, automatic_import_sonar, influx_db_enabled, get_libreqos_directory, \ blackboard_finish, blackboard_submit, automatic_import_wispgate, enable_insight_topology, insight_topology_role, \ automatic_import_netzur, automatic_import_visp, calculate_hash, efficiency_core_ids, scheduler_alive, scheduler_error, \ - overrides_persistent_devices_effective, overrides_circuit_adjustments_effective, overrides_network_adjustments_effective, \ + overrides_persistent_devices_effective, overrides_circuit_adjustments_effective, \ + overrides_network_adjustments_materialized, \ scheduler_output from apscheduler.schedulers.background import BlockingScheduler @@ -455,10 +456,14 @@ def apply_network_adjustments(network: dict) -> bool: Currently supports: adjust_site_speed (by site_name) updating downloadBandwidthMbps and uploadBandwidthMbps at the matching node, and set_node_virtual (by node_name) updating the boolean 'virtual' flag. + + This path intentionally excludes StormGuard adaptive site-speed overrides so + runtime StormGuard decisions do not overwrite the operator-authored + `network.json` source of truth. Returns True if any changes were applied. """ try: - adjustments = overrides_network_adjustments_effective() + adjustments = overrides_network_adjustments_materialized() except Exception as e: print(f"Failed to read network adjustments: {e}") return False diff --git a/src/test_scheduler.py b/src/test_scheduler.py index 491fd085..01f4444e 100644 --- a/src/test_scheduler.py +++ b/src/test_scheduler.py @@ -34,6 +34,7 @@ def install_scheduler_stubs(): lqlib.overrides_persistent_devices_effective = lambda: [] lqlib.overrides_circuit_adjustments_effective = lambda: [] lqlib.overrides_network_adjustments_effective = lambda: [] + lqlib.overrides_network_adjustments_materialized = lambda: [] sys.modules["liblqos_python"] = lqlib apscheduler_pkg = types.ModuleType("apscheduler") @@ -282,6 +283,94 @@ class TestSchedulerOverrideMerge(unittest.TestCase): self.assertEqual(written_rows[0][11], "330") self.assertEqual(written_rows[0][13], "fq_codel/fq_codel") + def test_apply_network_adjustments_uses_materialized_adjustments(self): + network = { + "Root": { + "downloadBandwidthMbps": 1000, + "uploadBandwidthMbps": 1000, + "children": { + "SiteA": { + "downloadBandwidthMbps": 100, + "uploadBandwidthMbps": 50, + "children": {}, + }, + "NodeB": { + "downloadBandwidthMbps": 200, + "uploadBandwidthMbps": 100, + "virtual": False, + "children": {}, + }, + }, + } + } + + with patch.object( + scheduler, + "overrides_network_adjustments_materialized", + return_value=[ + { + "type": "adjust_site_speed", + "site_name": "SiteA", + "download_bandwidth_mbps": 80, + "upload_bandwidth_mbps": 40, + }, + { + "type": "set_node_virtual", + "node_name": "NodeB", + "virtual": True, + }, + ], + ): + changed = scheduler.apply_network_adjustments(network) + + self.assertTrue(changed) + site = network["Root"]["children"]["SiteA"] + self.assertEqual(site["downloadBandwidthMbps"], 80) + self.assertEqual(site["uploadBandwidthMbps"], 40) + self.assertTrue(network["Root"]["children"]["NodeB"]["virtual"]) + + def test_apply_network_adjustments_does_not_use_effective_stormguard_speeds(self): + network = { + "Root": { + "downloadBandwidthMbps": 1000, + "uploadBandwidthMbps": 1000, + "children": { + "Pine Hills": { + "downloadBandwidthMbps": 940, + "uploadBandwidthMbps": 500, + "children": {}, + } + }, + } + } + + effective_adjustments = [ + { + "type": "adjust_site_speed", + "site_name": "Pine Hills", + "download_bandwidth_mbps": 4, + "upload_bandwidth_mbps": 4, + } + ] + + with patch.dict( + scheduler.apply_network_adjustments.__globals__, + {"overrides_network_adjustments_effective": lambda: effective_adjustments}, + clear=False, + ): + with patch.object( + scheduler, + "overrides_network_adjustments_materialized", + return_value=[], + ) as mock_materialized: + changed = scheduler.apply_network_adjustments(network) + + mock_materialized.assert_called_once_with() + self.assertFalse(changed) + site = network["Root"]["children"]["Pine Hills"] + self.assertEqual(site["downloadBandwidthMbps"], 940) + self.assertEqual(site["uploadBandwidthMbps"], 500) + if __name__ == "__main__": unittest.main() From d5836d5f1b0ef8851d4870106ff67b0f5d5944be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Chac=C3=B3n?= Date: Wed, 18 Mar 2026 19:49:12 -0600 Subject: [PATCH 004/102] Update conf.py --- conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf.py b/conf.py index 9f4f6139..4a20b728 100644 --- a/conf.py +++ b/conf.py @@ -38,7 +38,7 @@ def ultimateReplace(app, docname, source): source[0] = result ultimate_replacements = { - "{deb_url_v1_5}" : "libreqos_2.0.202603182012-1_amd64.deb" + "{deb_url_v1_5}" : "libreqos_2.0.202603190142-1_amd64.deb" } def setup(app): From ca9659bf2259359444cd3b406b9013b9a3a3c326 Mon Sep 17 00:00:00 2001 From: rchac Date: Fri, 20 Mar 2026 02:54:09 +0000 Subject: [PATCH 005/102] Harden TreeGuard node and circuit identity handling --- docs-es/v2.0/treeguard-es.md | 9 + docs/v2.0/treeguard.md | 9 + .../src/node_manager/local_api/scheduler.rs | 4 +- .../lqosd/src/shaped_devices_tracker/mod.rs | 5 +- src/rust/lqosd/src/treeguard/actor.rs | 279 ++++++++++++++++-- 5 files changed, 272 insertions(+), 34 deletions(-) diff --git a/docs-es/v2.0/treeguard-es.md b/docs-es/v2.0/treeguard-es.md index a3438936..1f7e23c2 100644 --- a/docs-es/v2.0/treeguard-es.md +++ b/docs-es/v2.0/treeguard-es.md @@ -86,6 +86,15 @@ Cuando está habilitado y no está en dry-run, TreeGuard puede persistir decisio TreeGuard está diseñado para no pelear con overrides del operador. Si existen overrides del operador para entidades enroladas, TreeGuard omite esas entidades y reporta advertencias. +TreeGuard también se niega a gestionar nodos que ya estén marcados con `"virtual": true` en el `network.json` base. Si existen overrides viejos de TreeGuard para esos nodos, TreeGuard limpia su propia capa de overrides y vuelve a respetar la definición base de la topología. + +Para la gestión SQM por circuito, TreeGuard trata los valores duplicados de `device_id` como colisiones de identidad inseguras. Si el mismo `device_id` aparece en más de un circuito dentro de `ShapedDevices.csv`, TreeGuard omite esos circuitos afectados y limpia cualquier override SQM de TreeGuard asociado a esos `device_id` duplicados. + +La actividad reciente de TreeGuard está disponible en dos lugares: + +- Las vistas de estado/actividad de TreeGuard en la WebUI. +- El journal de `lqosd`, donde TreeGuard ahora registra cada evento de actividad para que recargas, limpieza de overrides, cambios de SQM y fallos se puedan diagnosticar sin inspeccionar websockets. + ## Páginas Relacionadas - [HTB + fq_codel + CAKE: Comportamiento Detallado de Colas](htb_fq_codel_cake-es.md) diff --git a/docs/v2.0/treeguard.md b/docs/v2.0/treeguard.md index 505b08fc..c4e03ab5 100644 --- a/docs/v2.0/treeguard.md +++ b/docs/v2.0/treeguard.md @@ -86,6 +86,15 @@ When enabled and not dry-run, TreeGuard may persist decisions to: TreeGuard is designed to avoid fighting operator-owned overrides. If operator overrides exist for enrolled entities, TreeGuard skips those entities and reports warnings. +TreeGuard also refuses to manage nodes that are already marked `"virtual": true` in the base `network.json`. If stale TreeGuard-owned node-virtualization overrides exist for those nodes, TreeGuard clears its own override layer and falls back to the base topology definition. + +For circuit SQM management, TreeGuard treats duplicate `device_id` values as unsafe identity collisions. If the same `device_id` appears in more than one circuit in `ShapedDevices.csv`, TreeGuard skips those affected circuits and clears any TreeGuard-owned SQM overrides for those duplicate device IDs. + +Recent TreeGuard activity is available in two places: + +- The WebUI TreeGuard status/activity views. +- The `lqosd` journal, where TreeGuard now logs each recorded activity event so reloads, override cleanup, SQM changes, and failures are diagnosable without websocket inspection. + ## Related Pages - [HTB + fq_codel + CAKE: Detailed Queueing Behavior](htb_fq_codel_cake.md) diff --git a/src/rust/lqosd/src/node_manager/local_api/scheduler.rs b/src/rust/lqosd/src/node_manager/local_api/scheduler.rs index 4448d6e9..d290d409 100644 --- a/src/rust/lqosd/src/node_manager/local_api/scheduler.rs +++ b/src/rust/lqosd/src/node_manager/local_api/scheduler.rs @@ -1,6 +1,8 @@ use serde::Serialize; -use crate::tool_status::{is_scheduler_available, scheduler_error_message, scheduler_output_message}; +use crate::tool_status::{ + is_scheduler_available, scheduler_error_message, scheduler_output_message, +}; // Remove ANSI escape sequences (basic CSI/OSC handling) for browser display fn strip_ansi(input: &str) -> String { diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index 9c6ce813..9df172e1 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -277,7 +277,10 @@ pub fn get_funnel(circuit_id: &str) -> BusResponse { .rev() .skip(1) { - result.push((*idx, node_to_transport(&reader.get_nodes_when_ready()[*idx]))); + result.push(( + *idx, + node_to_transport(&reader.get_nodes_when_ready()[*idx]), + )); } return BusResponse::NetworkMap(result); } diff --git a/src/rust/lqosd/src/treeguard/actor.rs b/src/rust/lqosd/src/treeguard/actor.rs index d06d3ae0..29ea37d1 100644 --- a/src/rust/lqosd/src/treeguard/actor.rs +++ b/src/rust/lqosd/src/treeguard/actor.rs @@ -27,7 +27,7 @@ use parking_lot::RwLock; use std::collections::VecDeque; use std::sync::OnceLock; use std::time::{Duration, Instant}; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; static TREEGUARD_SENDER: OnceLock> = OnceLock::new(); static TREEGUARD_STATUS_CACHE: OnceLock> = OnceLock::new(); @@ -209,6 +209,7 @@ struct TreeguardRuntimeState { circuit_states: FxHashMap, managed_nodes: FxHashSet, managed_device_ids: FxHashSet, + duplicate_device_conflict_circuits: FxHashSet, last_dry_run: Option, reload_controller: ReloadController, } @@ -228,6 +229,7 @@ fn run_tick( let circuit_states = &mut runtime_state.circuit_states; let managed_nodes = &mut runtime_state.managed_nodes; let managed_device_ids = &mut runtime_state.managed_device_ids; + let duplicate_device_conflict_circuits = &mut runtime_state.duplicate_device_conflict_circuits; let last_dry_run = &mut runtime_state.last_dry_run; let reload_controller = &mut runtime_state.reload_controller; @@ -600,10 +602,60 @@ fn run_tick( .as_ref() .and_then(|overrides| overrides_node_virtual(overrides, node_name)); - if node.virtual_node && treeguard_virtual_override.is_none() { + if node.virtual_node { status.warnings.push(format!( "TreeGuard links: node '{node_name}' is marked virtual in base network.json; TreeGuard will not manage it." )); + if treeguard_virtual_override.is_some() { + let needs_reload = treeguard_virtual_override == Some(false); + match overrides::clear_node_virtual(node_name) { + Ok(changed) => { + if changed { + if needs_reload { + reload_controller.request_reload( + ReloadPriority::Normal, + format!( + "Cleared stale TreeGuard override for base-virtual node '{node_name}'" + ), + ); + } + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "node".to_string(), + entity_id: node_name.to_string(), + action: "clear_virtual_override_base_virtual" + .to_string(), + persisted: true, + reason: "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.".to_string(), + }, + ); + } + } + Err(e) => { + status.warnings.push(format!( + "TreeGuard links: failed to clear stale TreeGuard override for base-virtual node '{node_name}': {e}" + )); + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "node".to_string(), + entity_id: node_name.to_string(), + action: "clear_virtual_override_base_virtual_failed" + .to_string(), + persisted: false, + reason: format!( + "Failed to clear stale TreeGuard override for base-virtual node: {e}" + ), + }, + ); + } + } + } + managed_nodes.remove(node_name); + link_states.remove(node_name); continue; } @@ -935,10 +987,60 @@ fn run_tick( .as_ref() .and_then(|overrides| overrides_node_virtual(overrides, node_name)); - if node.virtual_node && treeguard_virtual_override.is_none() { + if node.virtual_node { status.warnings.push(format!( - "TreeGuard links: node '{node_name}' is marked virtual in base network.json; TreeGuard will not manage it." - )); + "TreeGuard links: node '{node_name}' is marked virtual in base network.json; TreeGuard will not manage it." + )); + if treeguard_virtual_override.is_some() { + let needs_reload = treeguard_virtual_override == Some(false); + match overrides::clear_node_virtual(node_name) { + Ok(changed) => { + if changed { + if needs_reload { + reload_controller.request_reload( + ReloadPriority::Normal, + format!( + "Cleared stale TreeGuard override for base-virtual node '{node_name}'" + ), + ); + } + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "node".to_string(), + entity_id: node_name.clone(), + action: "clear_virtual_override_base_virtual" + .to_string(), + persisted: true, + reason: "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.".to_string(), + }, + ); + } + } + Err(e) => { + status.warnings.push(format!( + "TreeGuard links: failed to clear stale TreeGuard override for base-virtual node '{node_name}': {e}" + )); + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "node".to_string(), + entity_id: node_name.clone(), + action: "clear_virtual_override_base_virtual_failed" + .to_string(), + persisted: false, + reason: format!( + "Failed to clear stale TreeGuard override for base-virtual node: {e}" + ), + }, + ); + } + } + } + managed_nodes.remove(node_name); + link_states.remove(node_name); continue; } @@ -1365,8 +1467,36 @@ fn run_tick( } } managed_device_ids.clear(); + duplicate_device_conflict_circuits.clear(); circuit_states.clear(); } else { + let mut circuits_by_device_id: FxHashMap> = FxHashMap::default(); + circuits_by_device_id.reserve(shaped.devices.len()); + for device in shaped.devices.iter() { + let device_id = device.device_id.trim(); + let circuit_id = device.circuit_id.trim(); + if device_id.is_empty() || circuit_id.is_empty() { + continue; + } + circuits_by_device_id + .entry(device_id.to_string()) + .or_default() + .insert(circuit_id.to_string()); + } + let duplicate_device_ids: FxHashMap> = circuits_by_device_id + .into_iter() + .filter_map(|(device_id, circuits)| { + if circuits.len() <= 1 { + return None; + } + let mut circuits: Vec = circuits.into_iter().collect(); + circuits.sort(); + Some((device_id, circuits)) + }) + .collect(); + let mut current_duplicate_device_conflict_circuits: FxHashSet = + FxHashSet::default(); + // Reconcile device IDs removed from allowlisted circuits. let treeguard_device_ids_with_overrides: FxHashSet = treeguard_overrides_snapshot .as_ref() @@ -1460,6 +1590,102 @@ fn run_tick( } for circuit_id in enrolled_circuits.iter() { + let devices: Vec = shaped + .devices + .iter() + .filter(|d| d.circuit_id == circuit_id.as_str()) + .cloned() + .collect(); + + let circuit_name: Option = devices.iter().find_map(|d| { + let name = d.circuit_name.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } + }); + let circuit_entity_id: String = match circuit_name.as_deref() { + Some(name) => format!("{name} ({circuit_id})"), + None => circuit_id.clone(), + }; + let circuit_label: String = circuit_name.unwrap_or_else(|| circuit_id.clone()); + + let mut circuit_device_ids: Vec = + devices.iter().map(|d| d.device_id.clone()).collect(); + circuit_device_ids.sort(); + circuit_device_ids.dedup(); + let duplicate_details: Vec<(String, Vec)> = circuit_device_ids + .iter() + .filter_map(|device_id| { + duplicate_device_ids + .get(device_id) + .map(|circuits| (device_id.clone(), circuits.clone())) + }) + .collect(); + if !duplicate_details.is_empty() { + current_duplicate_device_conflict_circuits.insert(circuit_id.clone()); + let duplicate_reason = duplicate_details + .iter() + .map(|(device_id, circuits)| { + format!( + "device_id '{}' is shared by circuits [{}]", + device_id, + circuits.join(", ") + ) + }) + .collect::>() + .join("; "); + status.warnings.push(format!( + "TreeGuard circuits: circuit '{circuit_id}' has duplicate device IDs; TreeGuard will not manage it. {duplicate_reason}" + )); + if !duplicate_device_conflict_circuits.contains(circuit_id) { + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "circuit".to_string(), + entity_id: circuit_entity_id.clone(), + action: "skip_duplicate_device_id".to_string(), + persisted: false, + reason: format!( + "TreeGuard refuses circuits with duplicate device IDs. {duplicate_reason}" + ), + }, + ); + } + if !circuit_device_ids.is_empty() { + match overrides::clear_device_overrides(&circuit_device_ids) { + Ok(changed) => { + if changed { + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "circuit".to_string(), + entity_id: circuit_entity_id.clone(), + action: "clear_sqm_overrides_duplicate_device_id" + .to_string(), + persisted: true, + reason: "Duplicate device IDs detected; cleared TreeGuard SQM overlays and skipped management.".to_string(), + }, + ); + } + } + Err(e) => { + status.warnings.push(format!( + "TreeGuard circuits: failed to clear TreeGuard SQM overlays for duplicate device IDs on circuit '{circuit_id}': {e}" + )); + } + } + for did in circuit_device_ids.iter() { + managed_device_ids.remove(did); + } + } + circuit_states.remove(circuit_id); + continue; + } + let state = circuit_states.entry(circuit_id.clone()).or_insert_with(|| { let mut state = CircuitState::default(); if let Some(token) = infer_circuit_sqm_override_token( @@ -1547,31 +1773,6 @@ fn run_tick( up: None, }); - let devices: Vec = shaped - .devices - .iter() - .filter(|d| d.circuit_id == circuit_id.as_str()) - .cloned() - .collect(); - - let circuit_name: Option = devices.iter().find_map(|d| { - let name = d.circuit_name.trim(); - if name.is_empty() { - None - } else { - Some(name.to_string()) - } - }); - let circuit_entity_id: String = match circuit_name.as_deref() { - Some(name) => format!("{name} ({circuit_id})"), - None => circuit_id.clone(), - }; - let circuit_label: String = circuit_name.unwrap_or_else(|| circuit_id.clone()); - - let mut circuit_device_ids: Vec = - devices.iter().map(|d| d.device_id.clone()).collect(); - circuit_device_ids.sort(); - circuit_device_ids.dedup(); let operator_conflict = circuit_device_ids .iter() .any(|did| operator_sqm_device_overrides.contains(did)); @@ -1680,8 +1881,10 @@ fn run_tick( let mut persisted_ok = false; let mut can_apply_live = true; if tg.circuits.persist_sqm_overrides { - let device_ids: Vec = - devices.iter().map(|device| device.device_id.clone()).collect(); + let device_ids: Vec = devices + .iter() + .map(|device| device.device_id.clone()) + .collect(); match overrides::set_devices_sqm_override(&device_ids, &token) { Ok(_) => { persisted_ok = true; @@ -1788,6 +1991,7 @@ fn run_tick( fq_codel_circuits += 1; } } + *duplicate_device_conflict_circuits = current_duplicate_device_conflict_circuits; } status.virtualized_nodes = virtualized_nodes; @@ -1914,6 +2118,17 @@ fn infer_circuit_sqm_override_token( /// /// This function is not pure: it mutates `activity`. fn push_activity(activity: &mut VecDeque, entry: TreeguardActivityEntry) { + if entry.action.contains("failed") { + warn!( + "TreeGuard activity: entity_type={} entity_id={} action={} persisted={} reason={}", + entry.entity_type, entry.entity_id, entry.action, entry.persisted, entry.reason + ); + } else { + info!( + "TreeGuard activity: entity_type={} entity_id={} action={} persisted={} reason={}", + entry.entity_type, entry.entity_id, entry.action, entry.persisted, entry.reason + ); + } if activity.len() >= ACTIVITY_RING_CAPACITY { activity.pop_front(); } From a14d4bc99e7cfa5c20775544f4e55adf19aa5493 Mon Sep 17 00:00:00 2001 From: rchac Date: Fri, 20 Mar 2026 03:27:35 +0000 Subject: [PATCH 006/102] changes --- src/rust/lqos_config/src/cpu_topology.rs | 6 +- src/rust/lqosd/src/treeguard/decisions.rs | 76 +++++++++++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/rust/lqos_config/src/cpu_topology.rs b/src/rust/lqos_config/src/cpu_topology.rs index 6032d37e..917c8ab5 100644 --- a/src/rust/lqos_config/src/cpu_topology.rs +++ b/src/rust/lqos_config/src/cpu_topology.rs @@ -432,6 +432,10 @@ fn detect_from_cpuid(possible: &[u32]) -> Option { #[cfg(target_arch = "x86_64")] use std::arch::x86_64::__cpuid_count; + if __cpuid_count(0, 0).eax < 0x1a { + return None; + } + let original_affinity = sched_getaffinity(Pid::from_raw(0)).ok()?; let restore_affinity = original_affinity; let mut performance = Vec::new(); @@ -448,7 +452,7 @@ fn detect_from_cpuid(possible: &[u32]) -> Option { return None; } - let leaf = unsafe { __cpuid_count(0x1a, 0) }; + let leaf = __cpuid_count(0x1a, 0); let core_type = (leaf.eax >> 24) & 0xff; match core_type { CPUID_CORE_TYPE_CORE => performance.push(*cpu), diff --git a/src/rust/lqosd/src/treeguard/decisions.rs b/src/rust/lqosd/src/treeguard/decisions.rs index b9ea9833..7ec466a7 100644 --- a/src/rust/lqosd/src/treeguard/decisions.rs +++ b/src/rust/lqosd/src/treeguard/decisions.rs @@ -436,8 +436,11 @@ mod tests { } #[test] - fn link_does_not_virtualize_when_cpu_low() { - let cpu = TreeguardCpuConfig::default(); + fn link_does_not_virtualize_when_cpu_low_in_cpu_aware_mode() { + let cpu = TreeguardCpuConfig { + mode: TreeguardCpuMode::CpuAware, + ..TreeguardCpuConfig::default() + }; let links = TreeguardLinksConfig::default(); let qoo_cfg = TreeguardQooConfig::default(); let state = LinkState::default(); @@ -460,6 +463,34 @@ mod tests { assert_eq!(decision, LinkVirtualDecision::NoChange); } + #[test] + fn link_virtualizes_when_cpu_low_in_traffic_rtt_only_mode() { + let cpu = TreeguardCpuConfig::default(); + let links = TreeguardLinksConfig::default(); + let qoo_cfg = TreeguardQooConfig::default(); + let state = LinkState::default(); + let decision = decide_link_virtualization(LinkVirtualizationInput { + now_unix: 1000, + allowlisted: true, + cpu_max_pct: Some(10), + cpu_cfg: &cpu, + links_cfg: &links, + qoo_cfg: &qoo_cfg, + rtt_missing: false, + qoo: DownUpOrder { + down: Some(100.0), + up: Some(100.0), + }, + util_ewma_pct: DownUpOrder { down: 1.0, up: 1.0 }, + sustained_idle: true, + state: &state, + }); + assert_eq!( + decision, + LinkVirtualDecision::Set(LinkVirtualState::Virtual) + ); + } + #[test] fn link_unvirtualizes_on_util_spike() { let cpu = TreeguardCpuConfig::default(); @@ -694,8 +725,11 @@ mod tests { } #[test] - fn circuit_reverts_when_cpu_low() { - let cpu = TreeguardCpuConfig::default(); + fn circuit_reverts_when_cpu_low_in_cpu_aware_mode() { + let cpu = TreeguardCpuConfig { + mode: TreeguardCpuMode::CpuAware, + ..TreeguardCpuConfig::default() + }; let circuits = TreeguardCircuitsConfig::default(); let qoo_cfg = TreeguardQooConfig::default(); let state = CircuitState { @@ -727,6 +761,40 @@ mod tests { assert_eq!(decision.up, Some(CircuitSqmState::Cake)); } + #[test] + fn circuit_does_not_revert_when_cpu_low_in_traffic_rtt_only_mode() { + let cpu = TreeguardCpuConfig::default(); + let circuits = TreeguardCircuitsConfig::default(); + let qoo_cfg = TreeguardQooConfig::default(); + let state = CircuitState { + down: CircuitDirectionState { + desired: CircuitSqmState::FqCodel, + ..Default::default() + }, + up: CircuitDirectionState { + desired: CircuitSqmState::FqCodel, + ..Default::default() + }, + }; + + let decision = decide_circuit_sqm(CircuitSqmInput { + now_unix: 1000, + allowlisted: true, + cpu_max_pct: Some(10), + cpu_cfg: &cpu, + circuits_cfg: &circuits, + qoo_cfg: &qoo_cfg, + rtt_missing: false, + qoo: DownUpOrder { + down: Some(90.0), + up: Some(90.0), + }, + state: &state, + }); + assert_eq!(decision.down, None); + assert_eq!(decision.up, None); + } + #[test] fn circuit_missing_rtt_does_not_block_downgrade_when_idle() { let cpu = TreeguardCpuConfig::default(); From 765eb446119691bea5c263ed345b3e8d53cfebbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Chac=C3=B3n?= Date: Thu, 19 Mar 2026 21:39:49 -0600 Subject: [PATCH 007/102] Update conf.py --- conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf.py b/conf.py index 4a20b728..4e72fddb 100644 --- a/conf.py +++ b/conf.py @@ -38,7 +38,7 @@ def ultimateReplace(app, docname, source): source[0] = result ultimate_replacements = { - "{deb_url_v1_5}" : "libreqos_2.0.202603190142-1_amd64.deb" + "{deb_url_v1_5}" : "libreqos_2.0.202603200330-1_amd64.deb" } def setup(app): From 37271219e627ee0c857a066572b228b438ebe69a Mon Sep 17 00:00:00 2001 From: rchac Date: Sat, 21 Mar 2026 03:21:46 +0000 Subject: [PATCH 008/102] Improve tree UI and node override handling --- docs-es/v2.0/node-manager-ui-es.md | 10 + docs/v2.0/node-manager-ui.md | 10 + src/rust/lqos_config/src/network_json.rs | 18 +- .../src/network_json/network_json_node.rs | 3 + .../network_json/network_json_transport.rs | 12 + src/rust/lqos_overrides/src/main.rs | 7 +- src/rust/lqos_overrides/src/overrides_file.rs | 243 +++++-- src/rust/lqos_python/src/lib.rs | 4 + .../lqos_stormguard/src/adaptive_actions.rs | 51 +- src/rust/lqos_stormguard/src/config.rs | 9 +- src/rust/lqos_stormguard/src/site_state.rs | 4 +- .../js_build/src/all_tree_sankey.js | 336 +++++----- .../src/dashlets/executive_heatmap_panels.js | 20 +- .../js_build/src/helpers/builders.js | 10 +- .../src/node_manager/js_build/src/index.js | 2 - .../lq_js_common/helpers/content_builders.js | 26 +- .../src/lq_js_common/helpers/tooltips.js | 53 ++ .../js_build/src/toasts/version_check.js | 3 +- .../src/node_manager/js_build/src/tree.js | 614 ++++++++++++++++-- src/rust/lqosd/src/node_manager/local_api.rs | 1 + .../node_manager/local_api/network_tree.rs | 10 +- .../local_api/node_rate_overrides.rs | 289 +++++++++ .../node_manager/static2/all_tree_sankey.html | 9 +- .../src/node_manager/static2/node_manager.css | 61 ++ .../src/node_manager/static2/template.html | 1 + .../lqosd/src/node_manager/static2/tree.html | 59 +- src/rust/lqosd/src/node_manager/template.rs | 16 +- src/rust/lqosd/src/node_manager/ws.rs | 144 +++- .../lqosd/src/node_manager/ws/messages.rs | 25 + .../lqosd/src/shaped_devices_tracker/mod.rs | 125 +++- src/rust/lqosd/src/version_checks.rs | 67 ++ .../src/strategies/ap_site.rs | 15 +- .../src/strategies/full2/graph_mapping.rs | 5 +- .../src/uisp_types/uisp_site.rs | 7 +- src/scheduler.py | 37 +- src/test_scheduler.py | 48 +- 36 files changed, 1964 insertions(+), 390 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/helpers/tooltips.js create mode 100644 src/rust/lqosd/src/node_manager/local_api/node_rate_overrides.rs diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index e117e8ac..49742a8b 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -11,6 +11,16 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta ### Vista de árbol de red - Vista jerárquica de nodos/circuitos desde la perspectiva del shaper. - Útil para identificar cuellos de botella y patrones de utilización padre/hijo. +- Las páginas de detalle del árbol ahora muestran una ruta tipo breadcrumb hasta el nodo seleccionado, para navegar hacia abajo sin depender de volver siempre a la raíz. +- Las filas del árbol ahora incluyen resúmenes del subárbol para `Sites` descendientes y `Circuits` adjuntos/descendientes, lo que ayuda a estimar el tamaño de cada rama antes de expandirla. +- Las filas del árbol y el encabezado del nodo seleccionado pueden mostrar pequeños íconos de estado para manejo especial, incluyendo nodos virtuales y nodos administrados actualmente por StormGuard. +- Las páginas de detalle del árbol incluyen un inspector de `Node Settings` que muestra: + - la velocidad configurada base desde el `network.json` generado + - cualquier override de velocidad del operador almacenado en `lqos_overrides.json` + - la velocidad configurada efectiva actual +- Los administradores pueden guardar o limpiar overrides de velocidad del nodo desde ese inspector. Los usuarios de solo lectura siguen pudiendo inspeccionar los valores, pero los controles permanecen deshabilitados. +- Los overrides de velocidad desde el árbol requieren un ID estable del nodo y rechazan intencionalmente nodos generados. Cuando un override ya fue guardado pero todavía no fue materializado en el `network.json` generado, el inspector muestra un indicador `⟳ Pending` explicando que el cambio se aplicará en la siguiente ejecución del scheduler. +- El control de pausa del Sankey de árbol completo ahora solo detiene el polling; el drill-down local, el reset y los cambios de profundidad máxima siguen renderizando desde el snapshot en caché mientras está en pausa. ### Site Map - Mapa operativo plano de sitios y APs usando geodatos importados de nodos. diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index 837a37f7..eb731832 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -11,6 +11,16 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### Network Tree Overview - Hierarchical topology view of nodes/circuits from the shaper perspective. - Useful for spotting bottlenecks and parent/child utilization patterns. +- Tree detail pages now show a breadcrumb path to the selected node, so drilling down does not require repeated resets back to root. +- Tree rows include subtree summaries for descendant `Sites` plus attached/descendant `Circuits`, which helps estimate branch size before expanding further. +- Tree rows and the selected-node header can show small status icons for special handling, including virtual nodes and nodes currently managed by StormGuard. +- Tree detail pages include a `Node Settings` inspector that shows: + - base configured rate from generated `network.json` + - any operator-owned rate override from `lqos_overrides.json` + - current effective configured rate +- Administrators can save or clear node rate overrides from that inspector. Read-only users can still inspect the values, but the controls remain disabled. +- Tree node rate overrides require a stable node ID and intentionally refuse generated nodes. When an override has been saved but not yet materialized into generated `network.json`, the inspector shows a `⟳ Pending` indicator explaining that the change will apply on the next scheduler run. +- The full-tree Sankey pause control now freezes polling only; local drill-down, reset, and max-depth changes continue to rerender from the cached snapshot while paused. ### Site Map - Flat operational map of Sites and APs using imported node geodata. diff --git a/src/rust/lqos_config/src/network_json.rs b/src/rust/lqos_config/src/network_json.rs index 079c00fd..f395ad2d 100644 --- a/src/rust/lqos_config/src/network_json.rs +++ b/src/rust/lqos_config/src/network_json.rs @@ -606,14 +606,8 @@ mod test { assert_eq!(site.id.as_deref(), Some("uisp:site:123")); assert_eq!(ap.id.as_deref(), Some("uisp:device:456")); - assert_eq!( - site.clone_to_transit().id.as_deref(), - Some("uisp:site:123") - ); - assert_eq!( - ap.clone_to_transit().id.as_deref(), - Some("uisp:device:456") - ); + assert_eq!(site.clone_to_transit().id.as_deref(), Some("uisp:site:123")); + assert_eq!(ap.clone_to_transit().id.as_deref(), Some("uisp:device:456")); } #[test] @@ -653,8 +647,12 @@ mod test { assert_eq!(bad.longitude, None); let encoded = serde_json::to_value(good.clone_to_transit()).expect("transport serializes"); - let encoded_lat = encoded["latitude"].as_f64().expect("latitude encodes as number"); - let encoded_lon = encoded["longitude"].as_f64().expect("longitude encodes as number"); + let encoded_lat = encoded["latitude"] + .as_f64() + .expect("latitude encodes as number"); + let encoded_lon = encoded["longitude"] + .as_f64() + .expect("longitude encodes as number"); assert!((encoded_lat - 45.123).abs() < 0.001); assert!((encoded_lon + 111.75).abs() < 0.001); } diff --git a/src/rust/lqos_config/src/network_json/network_json_node.rs b/src/rust/lqos_config/src/network_json/network_json_node.rs index 1fc075f2..1ac1762e 100644 --- a/src/rust/lqos_config/src/network_json/network_json_node.rs +++ b/src/rust/lqos_config/src/network_json/network_json_node.rs @@ -143,6 +143,9 @@ impl NetworkJsonNode { node_type: self.node_type.clone(), latitude: self.latitude, longitude: self.longitude, + subtree_site_count: 0, + subtree_circuit_count: 0, + subtree_device_count: 0, } } } diff --git a/src/rust/lqos_config/src/network_json/network_json_transport.rs b/src/rust/lqos_config/src/network_json/network_json_transport.rs index 16c56738..1017f99a 100644 --- a/src/rust/lqos_config/src/network_json/network_json_transport.rs +++ b/src/rust/lqos_config/src/network_json/network_json_transport.rs @@ -58,4 +58,16 @@ pub struct NetworkJsonTransport { /// Optional node longitude from network.json metadata. #[serde(default)] pub longitude: Option, + /// Total number of descendant site-tree nodes below this node. + /// + /// This excludes the node itself. For the synthetic root node, this is the + /// total number of nodes in the loaded site tree. + #[serde(default)] + pub subtree_site_count: u32, + /// Total number of unique circuits attached to this node or any descendant node. + #[serde(default)] + pub subtree_circuit_count: u32, + /// Total number of shaped devices attached to this node or any descendant node. + #[serde(default)] + pub subtree_device_count: u32, } diff --git a/src/rust/lqos_overrides/src/main.rs b/src/rust/lqos_overrides/src/main.rs index b0c18309..d0cd3e2c 100644 --- a/src/rust/lqos_overrides/src/main.rs +++ b/src/rust/lqos_overrides/src/main.rs @@ -303,12 +303,14 @@ struct AddDeviceSpeedArgs { #[derive(Args, Debug, Default)] struct AddSiteSpeedArgs { + #[arg(long)] + node_id: Option, #[arg(long)] site_name: String, #[arg(long)] - download_bandwidth_mbps: Option, + download_bandwidth_mbps: Option, #[arg(long)] - upload_bandwidth_mbps: Option, + upload_bandwidth_mbps: Option, } fn main() -> Result<()> { @@ -419,6 +421,7 @@ fn main() -> Result<()> { Commands::NetworkAdjustments { command: cmd } => match cmd { NetworkAdjustmentsCommand::AddSiteSpeed(args) => { let adj = NetworkAdjustment::AdjustSiteSpeed { + node_id: args.node_id, site_name: args.site_name, download_bandwidth_mbps: args.download_bandwidth_mbps, upload_bandwidth_mbps: args.upload_bandwidth_mbps, diff --git a/src/rust/lqos_overrides/src/overrides_file.rs b/src/rust/lqos_overrides/src/overrides_file.rs index 0cc448b5..4459ce5e 100644 --- a/src/rust/lqos_overrides/src/overrides_file.rs +++ b/src/rust/lqos_overrides/src/overrides_file.rs @@ -87,17 +87,20 @@ pub enum CircuitAdjustment { } /// A network-level override applied while generating `network.json`. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum NetworkAdjustment { /// Replaces site bandwidth values for a named site. AdjustSiteSpeed { + /// Optional stable node identifier to update. + #[serde(default, skip_serializing_if = "Option::is_none")] + node_id: Option, /// Site name to update. site_name: String, /// Replacement download bandwidth in Mbps. - download_bandwidth_mbps: Option, + download_bandwidth_mbps: Option, /// Replacement upload bandwidth in Mbps. - upload_bandwidth_mbps: Option, + upload_bandwidth_mbps: Option, }, /// Marks a named node as virtual or non-virtual. SetNodeVirtual { @@ -372,21 +375,30 @@ fn merge_network_adjustments_owned( ) -> Vec { use std::collections::{HashMap, HashSet}; - let mut stormguard_site_speeds: HashMap<&str, (Option, Option)> = HashMap::new(); - let mut stormguard_site_order: Vec<&str> = Vec::new(); - let mut stormguard_site_seen: HashSet<&str> = HashSet::new(); + let mut stormguard_site_speeds: HashMap = HashMap::new(); + let mut stormguard_site_order: Vec = Vec::new(); + let mut stormguard_site_seen: HashSet = HashSet::new(); for adj in stormguard_adjustments { if let NetworkAdjustment::AdjustSiteSpeed { + node_id, site_name, download_bandwidth_mbps, upload_bandwidth_mbps, } = adj { - let name = site_name.as_str(); - stormguard_site_speeds.insert(name, (*download_bandwidth_mbps, *upload_bandwidth_mbps)); - if !stormguard_site_seen.contains(name) { - stormguard_site_order.push(name); - stormguard_site_seen.insert(name); + let key = site_speed_key(node_id.as_deref(), site_name); + stormguard_site_speeds.insert( + key.clone(), + NetworkAdjustment::AdjustSiteSpeed { + node_id: node_id.clone(), + site_name: site_name.clone(), + download_bandwidth_mbps: *download_bandwidth_mbps, + upload_bandwidth_mbps: *upload_bandwidth_mbps, + }, + ); + if !stormguard_site_seen.contains(&key) { + stormguard_site_order.push(key.clone()); + stormguard_site_seen.insert(key); } } } @@ -413,7 +425,8 @@ fn merge_network_adjustments_owned( let mut out = Vec::new(); let mut used_treeguard_virtual: HashSet<&str> = HashSet::new(); let mut operator_virtual_seen: HashSet<&str> = HashSet::new(); - let mut operator_site_speed_seen: HashSet<&str> = HashSet::new(); + let mut operator_site_speed_seen: HashSet = HashSet::new(); + let mut operator_site_name_seen: HashSet = HashSet::new(); for adj in operator_adjustments { match adj { @@ -441,16 +454,19 @@ fn merge_network_adjustments_owned( } } NetworkAdjustment::AdjustSiteSpeed { + node_id, site_name, download_bandwidth_mbps, upload_bandwidth_mbps, } => { - let name = site_name.as_str(); - if operator_site_speed_seen.contains(name) { + let key = site_speed_key(node_id.as_deref(), site_name); + if operator_site_speed_seen.contains(&key) { continue; } - operator_site_speed_seen.insert(name); + operator_site_speed_seen.insert(key); + operator_site_name_seen.insert(site_name.clone()); out.push(NetworkAdjustment::AdjustSiteSpeed { + node_id: node_id.clone(), site_name: site_name.clone(), download_bandwidth_mbps: *download_bandwidth_mbps, upload_bandwidth_mbps: *upload_bandwidth_mbps, @@ -459,17 +475,25 @@ fn merge_network_adjustments_owned( } } - for name in stormguard_site_order { - if operator_site_speed_seen.contains(name) { + for key in stormguard_site_order { + if operator_site_speed_seen.contains(&key) { continue; } - let Some((download_bandwidth_mbps, upload_bandwidth_mbps)) = - stormguard_site_speeds.get(name) + let Some(NetworkAdjustment::AdjustSiteSpeed { + node_id, + site_name, + download_bandwidth_mbps, + upload_bandwidth_mbps, + }) = stormguard_site_speeds.get(&key) else { continue; }; + if operator_site_name_seen.contains(site_name) { + continue; + } out.push(NetworkAdjustment::AdjustSiteSpeed { - site_name: name.to_string(), + node_id: node_id.clone(), + site_name: site_name.clone(), download_bandwidth_mbps: *download_bandwidth_mbps, upload_bandwidth_mbps: *upload_bandwidth_mbps, }); @@ -679,10 +703,11 @@ impl OverrideFile { } if current == &device_id ) }); - self.circuit_adjustments.push(CircuitAdjustment::DeviceAdjustSqm { - device_id, - sqm_override: normalized, - }); + self.circuit_adjustments + .push(CircuitAdjustment::DeviceAdjustSqm { + device_id, + sqm_override: normalized, + }); true } @@ -714,34 +739,95 @@ impl OverrideFile { self.network_adjustments.push(adj); } + /// Returns the stored site bandwidth override that best matches this node. + /// + /// Preference order: + /// 1. Exact `node_id` match when one is supplied. + /// 2. Legacy name-only match for the same `site_name`. + pub fn find_site_bandwidth_override( + &self, + node_id: Option<&str>, + site_name: &str, + ) -> Option<&NetworkAdjustment> { + if let Some(node_id) = node_id + && let Some(found) = self.network_adjustments.iter().find(|adj| { + matches!( + adj, + NetworkAdjustment::AdjustSiteSpeed { + node_id: Some(current_node_id), + .. + } if current_node_id == node_id + ) + }) + { + return Some(found); + } + + self.network_adjustments.iter().find(|adj| { + matches!( + adj, + NetworkAdjustment::AdjustSiteSpeed { + node_id: None, + site_name: current_site_name, + .. + } if current_site_name == site_name + ) + }) + } + /// Add or replace a site bandwidth override for `site_name`. pub fn set_site_bandwidth_override( &mut self, + node_id: Option, site_name: String, - download_bandwidth_mbps: Option, - upload_bandwidth_mbps: Option, - ) { + download_bandwidth_mbps: Option, + upload_bandwidth_mbps: Option, + ) -> bool { + let desired = NetworkAdjustment::AdjustSiteSpeed { + node_id: node_id.clone(), + site_name: site_name.clone(), + download_bandwidth_mbps, + upload_bandwidth_mbps, + }; + if self.find_site_bandwidth_override(node_id.as_deref(), &site_name) == Some(&desired) { + return false; + } + self.network_adjustments.retain(|adj| match adj { NetworkAdjustment::AdjustSiteSpeed { - site_name: current, .. - } => current != &site_name, + node_id: current_node_id, + site_name: current_site_name, + .. + } => !site_speed_override_matches( + current_node_id.as_deref(), + current_site_name, + node_id.as_deref(), + &site_name, + ), _ => true, }); - self.network_adjustments - .push(NetworkAdjustment::AdjustSiteSpeed { - site_name, - download_bandwidth_mbps, - upload_bandwidth_mbps, - }); + self.network_adjustments.push(desired); + true } - /// Remove any site bandwidth overrides for `site_name`. Returns number removed. - pub fn remove_site_bandwidth_override_by_name_count(&mut self, site_name: &str) -> usize { + /// Remove any site bandwidth overrides for `site_name` or `node_id`. Returns number removed. + pub fn remove_site_bandwidth_override_count( + &mut self, + node_id: Option<&str>, + site_name: &str, + ) -> usize { let before = self.network_adjustments.len(); self.network_adjustments.retain(|adj| match adj { NetworkAdjustment::AdjustSiteSpeed { - site_name: current, .. - } => current != site_name, + node_id: current_node_id, + site_name: current_site_name, + .. + } => !site_speed_override_matches( + current_node_id.as_deref(), + current_site_name, + node_id, + site_name, + ), _ => true, }); before.saturating_sub(self.network_adjustments.len()) @@ -819,6 +905,29 @@ impl OverrideFile { } } +fn site_speed_key(node_id: Option<&str>, site_name: &str) -> String { + match node_id { + Some(node_id) if !node_id.trim().is_empty() => format!("id:{node_id}"), + _ => format!("name:{site_name}"), + } +} + +fn site_speed_override_matches( + current_node_id: Option<&str>, + current_site_name: &str, + requested_node_id: Option<&str>, + requested_site_name: &str, +) -> bool { + if let Some(requested_node_id) = requested_node_id { + if current_node_id == Some(requested_node_id) { + return true; + } + return current_node_id.is_none() && current_site_name == requested_site_name; + } + + current_site_name == requested_site_name +} + impl OverrideStore { /// Loads a single overrides layer. /// @@ -930,18 +1039,22 @@ mod tests { let mut stormguard = OverrideFile::default(); stormguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment( - "dev1", "stormguard", + "dev1", + "stormguard", )); stormguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment( - "dev3", "stormguard", + "dev3", + "stormguard", )); let mut treeguard = OverrideFile::default(); treeguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment( - "dev1", "treeguard", + "dev1", + "treeguard", )); treeguard.add_persistent_shaped_device_return_changed(shaped_device_with_comment( - "dev4", "treeguard", + "dev4", + "treeguard", )); let merged = merge_owned_sections(operator, stormguard, treeguard); @@ -1009,10 +1122,9 @@ mod tests { "dev1".to_string(), Some("fq_codel".to_string()) )); - assert!(of.set_device_sqm_override_return_changed( - "dev1".to_string(), - Some("cake".to_string()) - )); + assert!( + of.set_device_sqm_override_return_changed("dev1".to_string(), Some("cake".to_string())) + ); assert_eq!(of.remove_device_sqm_override_by_device_count("dev1"), 1); assert_eq!(of.remove_device_sqm_override_by_device_count("dev1"), 0); } @@ -1022,21 +1134,23 @@ mod tests { let mut operator = OverrideFile::default(); operator.set_network_node_virtual("NodeA".to_string(), false); operator.add_network_adjustment(NetworkAdjustment::AdjustSiteSpeed { + node_id: Some("node-site-1".to_string()), site_name: "Site1".to_string(), - download_bandwidth_mbps: Some(100), - upload_bandwidth_mbps: Some(50), + download_bandwidth_mbps: Some(100.0), + upload_bandwidth_mbps: Some(50.0), }); let mut stormguard = OverrideFile::default(); - stormguard.set_site_bandwidth_override("Site1".to_string(), Some(80), Some(40)); - stormguard.set_site_bandwidth_override("Site2".to_string(), Some(150), Some(75)); + stormguard.set_site_bandwidth_override(None, "Site1".to_string(), Some(80.0), Some(40.0)); + stormguard.set_site_bandwidth_override(None, "Site2".to_string(), Some(150.0), Some(75.0)); let mut treeguard = OverrideFile::default(); treeguard.set_network_node_virtual("NodeA".to_string(), true); treeguard.add_network_adjustment(NetworkAdjustment::AdjustSiteSpeed { + node_id: Some("node-site-3".to_string()), site_name: "Site3".to_string(), - download_bandwidth_mbps: Some(200), - upload_bandwidth_mbps: Some(100), + download_bandwidth_mbps: Some(200.0), + upload_bandwidth_mbps: Some(100.0), }); let merged = merge_owned_sections(operator, stormguard, treeguard); @@ -1065,6 +1179,29 @@ mod tests { assert_eq!(site_speed_names, vec!["Site1", "Site2"]); } + #[test] + fn find_site_bandwidth_override_prefers_node_id_match() { + let mut of = OverrideFile::default(); + of.set_site_bandwidth_override(None, "AP27".to_string(), Some(80.0), Some(40.0)); + of.set_site_bandwidth_override( + Some("node-ap27".to_string()), + "AP27".to_string(), + Some(120.5), + Some(60.25), + ); + + let found = of.find_site_bandwidth_override(Some("node-ap27"), "AP27"); + assert!(matches!( + found, + Some(NetworkAdjustment::AdjustSiteSpeed { + node_id: Some(node_id), + download_bandwidth_mbps: Some(download), + upload_bandwidth_mbps: Some(upload), + .. + }) if node_id == "node-ap27" && *download == 120.5 && *upload == 60.25 + )); + } + #[test] fn override_file_defaults_accept_empty_json() { let of: OverrideFile = serde_json::from_str("{}").expect("empty JSON should deserialize"); diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index 2edb31a7..48beb564 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -1283,11 +1283,15 @@ fn network_adjustments_to_py( let d = PyDict::new(py); match adj { lqos_overrides::NetworkAdjustment::AdjustSiteSpeed { + node_id, site_name, download_bandwidth_mbps, upload_bandwidth_mbps, } => { d.set_item("type", "adjust_site_speed")?; + if let Some(node_id) = node_id { + d.set_item("node_id", node_id.clone())?; + } d.set_item("site_name", site_name.clone())?; if let Some(v) = download_bandwidth_mbps { d.set_item("download_bandwidth_mbps", *v)?; diff --git a/src/rust/lqos_stormguard/src/adaptive_actions.rs b/src/rust/lqos_stormguard/src/adaptive_actions.rs index 62ef6369..367adb7d 100644 --- a/src/rust/lqos_stormguard/src/adaptive_actions.rs +++ b/src/rust/lqos_stormguard/src/adaptive_actions.rs @@ -15,8 +15,8 @@ pub enum CircuitFallbackOutcome { pub struct SiteOverrideUpdate { pub site_name: String, - pub download_bandwidth_mbps: Option, - pub upload_bandwidth_mbps: Option, + pub download_bandwidth_mbps: Option, + pub upload_bandwidth_mbps: Option, } #[derive(Clone)] @@ -36,7 +36,7 @@ pub fn apply_site_override_updates(updates: &[SiteOverrideUpdate]) -> Result 0 { changed = true; } @@ -48,6 +48,7 @@ pub fn apply_site_override_updates(updates: &[SiteOverrideUpdate]) -> Result Result Option<(Option, Option)> { +) -> Option<(Option, Option)> { overrides .network_adjustments() .iter() .find_map(|adj| match adj { lqos_overrides::NetworkAdjustment::AdjustSiteSpeed { + node_id: _, site_name: current, download_bandwidth_mbps, upload_bandwidth_mbps, @@ -357,12 +359,13 @@ fn group_circuit_fallbacks( continue; } - let entry = grouped - .entry(device.circuit_id.clone()) - .or_insert_with(|| PersistedCircuitFallback { - sqm_override: token.to_string(), - devices: Vec::new(), - }); + let entry = + grouped + .entry(device.circuit_id.clone()) + .or_insert_with(|| PersistedCircuitFallback { + sqm_override: token.to_string(), + devices: Vec::new(), + }); if entry.sqm_override != token { continue; } @@ -434,10 +437,12 @@ mod tests { "dev2".to_string(), Some("fq_codel".to_string()) )); - assert!(overrides.set_device_sqm_override_return_changed( - "dev3".to_string(), - Some("cake".to_string()) - )); + assert!( + overrides.set_device_sqm_override_return_changed( + "dev3".to_string(), + Some("cake".to_string()) + ) + ); let grouped = group_circuit_fallbacks(&overrides, &[d1, d2, d3]); assert_eq!(grouped.len(), 2); @@ -479,14 +484,16 @@ mod tests { "dev1".to_string(), Some("fq_codel".to_string()) )); - assert!(overrides.set_device_sqm_override_return_changed( - "dev2".to_string(), - Some("cake".to_string()) - )); - assert!(overrides.set_device_sqm_override_return_changed( - "dev3".to_string(), - Some(" ".to_string()) - )); + assert!( + overrides.set_device_sqm_override_return_changed( + "dev2".to_string(), + Some("cake".to_string()) + ) + ); + assert!( + overrides + .set_device_sqm_override_return_changed("dev3".to_string(), Some(" ".to_string())) + ); let grouped = group_circuit_fallbacks(&overrides, &[d1, d2, d3]); assert_eq!( diff --git a/src/rust/lqos_stormguard/src/config.rs b/src/rust/lqos_stormguard/src/config.rs index c12b70d4..6e897728 100644 --- a/src/rust/lqos_stormguard/src/config.rs +++ b/src/rust/lqos_stormguard/src/config.rs @@ -130,7 +130,7 @@ pub fn configure() -> anyhow::Result { Ok(result) } -fn load_stormguard_site_overrides() -> HashMap, Option)> { +fn load_stormguard_site_overrides() -> HashMap, Option)> { let Ok(overrides) = OverrideStore::load_layer(OverrideLayer::Stormguard) else { warn!("Unable to load StormGuard override layer; starting from planned rates."); return HashMap::new(); @@ -141,6 +141,7 @@ fn load_stormguard_site_overrides() -> HashMap, Option .iter() .filter_map(|adj| match adj { NetworkAdjustment::AdjustSiteSpeed { + node_id: _, site_name, download_bandwidth_mbps, upload_bandwidth_mbps, @@ -155,7 +156,7 @@ fn load_stormguard_site_overrides() -> HashMap, Option fn get_sites_from_queueing_structure( sg_config: &lqos_config::StormguardConfig, - persisted_site_overrides: &HashMap, Option)>, + persisted_site_overrides: &HashMap, Option)>, ) -> HashMap { let mut selected: Vec = if sg_config.all_sites { all_candidate_site_names() @@ -196,12 +197,12 @@ fn get_sites_from_queueing_structure( .unwrap_or((None, None)); let current_download_mbps = persisted .0 - .map(u64::from) + .map(|mbps| mbps.max(0.0) as u64) .unwrap_or(max_down) .clamp(min_down, max_down); let current_upload_mbps = persisted .1 - .map(u64::from) + .map(|mbps| mbps.max(0.0) as u64) .unwrap_or(max_up) .clamp(min_up, max_up); diff --git a/src/rust/lqos_stormguard/src/site_state.rs b/src/rust/lqos_stormguard/src/site_state.rs index 7ffd9664..b8af7da3 100644 --- a/src/rust/lqos_stormguard/src/site_state.rs +++ b/src/rust/lqos_stormguard/src/site_state.rs @@ -836,9 +836,9 @@ impl SiteStateTracker { SiteOverrideUpdate { site_name: site.config.name.clone(), download_bandwidth_mbps: (site.queue_download_mbps != site.config.max_download_mbps) - .then_some(site.queue_download_mbps as u32), + .then_some(site.queue_download_mbps as f32), upload_bandwidth_mbps: (site.queue_upload_mbps != site.config.max_upload_mbps) - .then_some(site.queue_upload_mbps as u32), + .then_some(site.queue_upload_mbps as f32), } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/all_tree_sankey.js b/src/rust/lqosd/src/node_manager/js_build/src/all_tree_sankey.js index 3834221a..ec162996 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/all_tree_sankey.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/all_tree_sankey.js @@ -38,6 +38,15 @@ const MIN_LINK_BITS_PER_SEC = 1_000_000; let sankeyOverlay = null; +function setRootNodeLabel(name) { + const target = document.getElementById("rootNode"); + if (!target) { + return; + } + target.textContent = name || "Root"; + target.classList.add("redactable"); +} + function listenOnceWithTimeout(eventName, timeoutMs, handler, onTimeout) { let done = false; const wrapped = (msg) => { @@ -126,6 +135,157 @@ class AllTreeSankeyGraph extends GenericRingBuffer { this.pending = false; } + render(graph) { + const head = this.getHead(); + if (!Array.isArray(head) || head.length === 0) { + setRootNodeLabel("Root"); + if (sankeyOverlay) { + sankeyOverlay.show("Limited throughput", "Nothing to render yet."); + } + graph.update([], []); + return; + } + + if (!head[rootId] || !head[rootId][1]) { + rootId = 0; + } + + const rootEntry = head[rootId]; + if (!rootEntry || !rootEntry[1]) { + setRootNodeLabel("Root"); + if (sankeyOverlay) { + sankeyOverlay.show("Limited throughput", "Nothing to render yet."); + } + graph.update([], []); + return; + } + + allNodes = head; + setRootNodeLabel(rootEntry[1].name); + + let redact = isRedacted(); + let nodes = []; + let links = []; + const linkMap = new Map(); + const rootParents = rootEntry[1].parents || []; + let startDepth = Math.max(0, rootParents.length - 1); + + for (let i=0; i maxDepth) { + continue; + } + if (rootId !== 0 && i !== rootId && !parents.includes(rootId)) { + continue; + } + + const downBytesPerSec = toNumber(head[i][1].current_throughput?.[0], 0); + const downBitsPerSec = downBytesPerSec * 8; + const maxBitsPerSec = toNumber(head[i][1].max_throughput?.[0], 0) * 1_000_000; + const percent = Math.min(100, maxBitsPerSec > 0 ? (downBitsPerSec / maxBitsPerSec) * 100 : 0); + + let capacityColor = isColorBlindMode() + ? lerpViridis(percent / 100) + : lerpGreenToRedViaOrange(100 - percent, 100); + + let label = { + fontSize: 10, + color: "#999", + formatter: (params) => { + return trimStringWithElipsis(params.name.replace("(Generated Site) ", ""), 14); + } + }; + if (redact) { + label.fontFamily = "Illegible"; + } + + nodes.push({ + name: head[i][1].name, + label: label, + itemStyle: { + color: capacityColor + }, + n: 1, + }); + + if (i > 0) { + let immediateParent = head[i][1].immediate_parent; + if (immediateParent === null || immediateParent === undefined) continue; + if (!head[immediateParent] || !head[immediateParent][1]) continue; + links.push({ + source: head[immediateParent][1].name, + target: head[i][1].name, + value: downBitsPerSec, + lineStyle: { + color: capacityColor, + }, + maxBitsPerSec: maxBitsPerSec, + n: 1, + }); + linkMap.set( + `${head[immediateParent][1].name}\u0000${head[i][1].name}`, + links[links.length - 1], + ); + } + } + + this.iterate((data) => { + for (let i=0; i 0) { + let immediateParent = data[i][1].immediate_parent; + if (immediateParent === null || immediateParent === undefined) continue; + if (!data[immediateParent] || !data[immediateParent][1]) continue; + + const link = linkMap.get( + `${data[immediateParent][1].name}\u0000${data[i][1].name}`, + ); + if (link !== undefined) { + link.value += toNumber(data[i][1].current_throughput?.[0], 0) * 8; + link.n++; + } + } + } + }); + + for (let i=0; i 0 ? (links[i].value / maxBits) * 100 : 0); + let capacityColor = isColorBlindMode() + ? lerpViridis(percent / 100) + : lerpGreenToRedViaOrange(100 - percent, 100); + links[i].lineStyle.color = capacityColor; + } + + links = links.filter(link => link.value >= MIN_LINK_BITS_PER_SEC); + + if (links.length === 0) { + if (sankeyOverlay) { + sankeyOverlay.show("Limited throughput", "Nothing to render yet."); + } + graph.update([], []); + return; + } + + if (sankeyOverlay) { + sankeyOverlay.hide(); + } + + const referenced = new Set(); + links.forEach(link => { + referenced.add(link.source); + referenced.add(link.target); + }); + + referenced.add(rootEntry[1].name); + nodes = nodes.filter(node => referenced.has(node.name)); + graph.update(nodes, links); + } + onTick(graph) { if (this.pending) { return; @@ -142,162 +302,8 @@ class AllTreeSankeyGraph extends GenericRingBuffer { } const data = msg && msg.data ? msg.data : []; - // Maintain a 10-second ringbuffer of recent data self.push(data); - - let redact = isRedacted(); - let nodes = []; - let links = []; - const linkMap = new Map(); - - // Build the basic tree from the current head, to ensure - // that we're displaying the most recent nodes. - let head = self.getHead(); - if (!Array.isArray(head) || head.length === 0 || !head[rootId] || !head[rootId][1]) { - if (sankeyOverlay) { - sankeyOverlay.show("Limited throughput", "Nothing to render yet."); - } - graph.update([], []); - return; - } - - allNodes = head; - - const rootParents = head[rootId][1].parents || []; - let startDepth = rootParents.length - 1; - - for (let i=0; i maxDepth) { - continue; - } - // If head[i][1].parents does not contain rootId, skip - if (rootId !== 0 && !parents.includes(rootId)) { - continue; - } - - const downBytesPerSec = toNumber(head[i][1].current_throughput?.[0], 0); - const downBitsPerSec = downBytesPerSec * 8; - const maxBitsPerSec = toNumber(head[i][1].max_throughput?.[0], 0) * 1_000_000; - const percent = Math.min(100, maxBitsPerSec > 0 ? (downBitsPerSec / maxBitsPerSec) * 100 : 0); - - // Use appropriate color scale based on color blind mode - let capacityColor = isColorBlindMode() - ? lerpViridis(percent / 100) - : lerpGreenToRedViaOrange(100 - percent, 100); - - // Use appropriate color scale for node - let color = capacityColor; - - let label = { - fontSize: 10, - color: "#999", - formatter: (params) => { - // Trim to 10 chars with elipsis - return trimStringWithElipsis(params.name.replace("(Generated Site) ", ""), 14); - } - }; - if (redact) { - label.fontFamily = "Illegible"; - } - - nodes.push({ - name: head[i][1].name, - label: label, - itemStyle: { - color: color - }, - n: 1, - }); - - if (i > 0) { - let immediateParent = head[i][1].immediate_parent; - if (immediateParent === null || immediateParent === undefined) continue; - if (!head[immediateParent] || !head[immediateParent][1]) continue; - links.push({ - source: head[immediateParent][1].name, - target: head[i][1].name, - value: downBitsPerSec, - lineStyle: { - color: capacityColor, - }, - maxBitsPerSec: maxBitsPerSec, - n: 1, - }); - linkMap.set( - `${head[immediateParent][1].name}\u0000${head[i][1].name}`, - links[links.length - 1], - ); - } - } - - // Now we iterate over the entire ringbuffer to accumulate data over the period - // of the ringbuffer. - self.iterate((data) => { - for (let i=0; i 0) { - let immediateParent = data[i][1].immediate_parent; - if (immediateParent === null || immediateParent === undefined) continue; - if (!data[immediateParent] || !data[immediateParent][1]) continue; - - const link = linkMap.get( - `${data[immediateParent][1].name}\u0000${data[i][1].name}`, - ); - if (link !== undefined) { - link.value += toNumber(data[i][1].current_throughput?.[0], 0) * 8; - link.n++; - } - } - } - }); - - // Now go through the links and average the values, recalculating the color - for (let i=0; i 0 ? (links[i].value / maxBits) * 100 : 0); - let capacityColor = isColorBlindMode() - ? lerpViridis(percent / 100) - : lerpGreenToRedViaOrange(100 - percent, 100); - links[i].lineStyle.color = capacityColor; - } - - // Filter links with <1 Mbps average throughput - links = links.filter(link => link.value >= MIN_LINK_BITS_PER_SEC); - - if (links.length === 0) { - if (sankeyOverlay) { - sankeyOverlay.show("Limited throughput", "Nothing to render yet."); - } - graph.update([], []); - return; - } - - if (sankeyOverlay) { - sankeyOverlay.hide(); - } - - // Collect node names that are still referenced by links - const referenced = new Set(); - links.forEach(link => { - referenced.add(link.source); - referenced.add(link.target); - }); - - // Always keep the root node - let rootName = nodes.length > 0 ? nodes[0].name : null; - if (rootName) referenced.add(rootName); - - // Filter nodes to only those referenced - nodes = nodes.filter(node => referenced.has(node.name)); - - // Update the graph - graph.update(nodes, links); + self.render(graph); }, () => { self.pending = false; self.pendingCancel = null; @@ -313,6 +319,10 @@ class AllTreeSankeyGraph extends GenericRingBuffer { wsClient.send({ NetworkTree: {} }); } + + rerender(graph) { + this.render(graph); + } } var allNodes = []; @@ -349,17 +359,18 @@ class AllTreeSankey extends DashboardGraph { this.option && this.chart.setOption(this.option); this.chart.showLoading(); this.chart.on('click', (params) => { - console.log(params.name); - console.log(this.nodeMap); let name = params.name; - // If it contains a >, it's a link if (name.indexOf(" > ") === -1) { rootId = idOfNode(name); } else { rootId = idOfNode(params.data.source); } + this.model.rerender(this); + }); + $("#btnRoot").click(() => { + rootId = 0; + this.model.rerender(this); }); - $("#btnRoot").click(() => { rootId = 0; }); } update(data, links) { @@ -394,15 +405,16 @@ function getMaxDepth() { function bindMaxDepth() { let d = document.getElementById("maxDepth"); d.value = maxDepth; - d.onclick = () => { + d.addEventListener("change", () => { maxDepth = parseInt(d.value); localStorage.setItem("atsDepth", maxDepth.toString()); - }; + graph.model.rerender(graph); + }); } let maxDepth = getMaxDepth(); -bindMaxDepth(); let graph = new AllTreeSankey("sankey"); +bindMaxDepth(); sankeyOverlay = makeOverlay(graph.dom, "allTreeSankeyOverlay"); sankeyOverlay.show("Waiting for data", "Requesting network tree..."); let loopTimer = null; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js index d62a527c..c60df37f 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js @@ -1,4 +1,5 @@ import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import {disposeTooltipsWithin, enableTooltipsWithin} from "../lq_js_common/helpers/tooltips"; import {colorByQoqScore, colorByRetransmitPct, colorByRttMs} from "../helpers/color_scales"; import {getSiteIdMap, linkToCircuit, linkToSite} from "../executive_utils"; import { @@ -41,19 +42,6 @@ function qooInfoIconHtml() { return ``; } -function initTooltipsWithin(rootEl) { - if (!rootEl) return; - if (typeof bootstrap === "undefined" || !bootstrap.Tooltip) return; - const elements = rootEl.querySelectorAll('[data-bs-toggle="tooltip"]'); - elements.forEach((element) => { - if (bootstrap.Tooltip.getOrCreateInstance) { - bootstrap.Tooltip.getOrCreateInstance(element); - } else { - new bootstrap.Tooltip(element); - } - }); -} - function qoqHeatmapRow(blocks, colorFn) { const length = Array.isArray(blocks?.upload_total) && blocks.upload_total.length @@ -260,6 +248,7 @@ export class ExecutiveGlobalHeatmapDashlet extends ExecutiveHeatmapBase { return heatRow(row.label, row.badge, row.values, row.color, row.format); }) .join(""); + disposeTooltipsWithin(target); target.innerHTML = `
@@ -271,7 +260,7 @@ export class ExecutiveGlobalHeatmapDashlet extends ExecutiveHeatmapBase {
`; - initTooltipsWithin(target); + enableTooltipsWithin(target); } } @@ -367,6 +356,7 @@ class ExecutiveMetricHeatmapBase extends ExecutiveHeatmapBase { const titleHtml = this.config.link ? `${titleLabel}${linkIcon}` : `${titleLabel}`; + disposeTooltipsWithin(activeTarget); activeTarget.innerHTML = `
@@ -377,7 +367,7 @@ class ExecutiveMetricHeatmapBase extends ExecutiveHeatmapBase {
`; - initTooltipsWithin(activeTarget); + enableTooltipsWithin(activeTarget); }); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js b/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js index 602c1366..f687a643 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js @@ -1,6 +1,7 @@ import {formatRetransmit, formatRtt, formatThroughput} from "./scaling"; import {scaleNanos} from "../lq_js_common/helpers/scaling"; import {redactCell} from "./redact"; +import {disposeTooltipsWithin, enableTooltipsWithin} from "../lq_js_common/helpers/tooltips"; export function heading5Icon(icon, text) { let h5 = document.createElement("h5"); @@ -51,17 +52,14 @@ export function clearDashDiv(id, target) { } export function clearDiv(target, targetLength=0) { + disposeTooltipsWithin(target); while (target.children.length > targetLength) { target.removeChild(target.lastChild); } } export function enableTooltips() { - // Tooltips everywhere! - let tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) - let tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { - return new bootstrap.Tooltip(tooltipTriggerEl) - }) + enableTooltipsWithin(document); } let pendingTooltips = []; @@ -73,7 +71,7 @@ export function tooltipsNextFrame(id) { pendingTooltips.forEach((id) => { let tooltipTriggerEl = document.getElementById(id); if (tooltipTriggerEl !== null) { - new bootstrap.Tooltip(tooltipTriggerEl); + enableTooltipsWithin(tooltipTriggerEl); } }); pendingTooltips = []; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/index.js b/src/rust/lqosd/src/node_manager/js_build/src/index.js index 03e58ea6..43d91f35 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/index.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/index.js @@ -1,5 +1,4 @@ import {Dashboard} from "./lq_js_common/dashboard/dashboard"; -import {checkForUpgrades} from "./toasts/version_check"; import {sponsorTag} from "./toasts/sponsor_us"; import {globalWarningToasts} from "./toasts/global_warnings"; import {showTimeControls} from "./components/timescale"; @@ -85,7 +84,6 @@ const defaultLayout = { window.timeGraphs = []; showTimeControls("timescale"); -checkForUpgrades(); sponsorTag("toasts"); globalWarningToasts(); const dashboard = new Dashboard("dashboard", "mainDashboard", defaultLayout, widgetFactory, DashletMenu, false, ""); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/helpers/content_builders.js b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/helpers/content_builders.js index f2da7a95..380ce926 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/helpers/content_builders.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/helpers/content_builders.js @@ -1,3 +1,5 @@ +import {enableTooltipsWithin} from "./tooltips"; + export function heading5Icon(icon, text) { let h5 = document.createElement("h5"); h5.innerHTML = " " + text; @@ -5,27 +7,5 @@ export function heading5Icon(icon, text) { } export function enableTooltips() { - // Tooltips everywhere! Make this idempotent to avoid leaking tooltip instances - // on websocket reconnects or repeated dashboard setup cycles. - if (typeof bootstrap === "undefined" || !bootstrap.Tooltip) { - return; - } - const Tooltip = bootstrap.Tooltip; - const tooltipTriggerList = Array.prototype.slice.call( - document.querySelectorAll('[data-bs-toggle="tooltip"]'), - ); - tooltipTriggerList.forEach((el) => { - if (!el) return; - if (Tooltip.getOrCreateInstance) { - Tooltip.getOrCreateInstance(el); - return; - } - if (Tooltip.getInstance) { - const existing = Tooltip.getInstance(el); - if (existing && existing.dispose) { - existing.dispose(); - } - } - new Tooltip(el); - }); + enableTooltipsWithin(document); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/helpers/tooltips.js b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/helpers/tooltips.js new file mode 100644 index 00000000..89da0a7c --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/helpers/tooltips.js @@ -0,0 +1,53 @@ +function tooltipElementsWithin(rootEl = document) { + if (!rootEl) { + return []; + } + + const elements = []; + if (rootEl.matches && rootEl.matches('[data-bs-toggle="tooltip"]')) { + elements.push(rootEl); + } + + if (rootEl.querySelectorAll) { + elements.push(...rootEl.querySelectorAll('[data-bs-toggle="tooltip"]')); + } + + return elements; +} + +export function enableTooltipsWithin(rootEl = document) { + if (typeof bootstrap === "undefined" || !bootstrap.Tooltip) { + return; + } + + const Tooltip = bootstrap.Tooltip; + tooltipElementsWithin(rootEl).forEach((el) => { + if (!el) { + return; + } + if (Tooltip.getOrCreateInstance) { + Tooltip.getOrCreateInstance(el); + return; + } + if (Tooltip.getInstance) { + const existing = Tooltip.getInstance(el); + if (existing && existing.dispose) { + existing.dispose(); + } + } + new Tooltip(el); + }); +} + +export function disposeTooltipsWithin(rootEl = document) { + if (typeof bootstrap === "undefined" || !bootstrap.Tooltip || !bootstrap.Tooltip.getInstance) { + return; + } + + tooltipElementsWithin(rootEl).forEach((el) => { + const existing = bootstrap.Tooltip.getInstance(el); + if (existing && existing.dispose) { + existing.dispose(); + } + }); +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/toasts/version_check.js b/src/rust/lqosd/src/node_manager/js_build/src/toasts/version_check.js index 56c670e8..f346c288 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/toasts/version_check.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/toasts/version_check.js @@ -1,7 +1,6 @@ import {createBootstrapToast} from "../lq_js_common/helpers/toasts"; export function checkForUpgrades() { - // Wait 1 second to ensure the newVersion variable has been set setTimeout(() => { if (window.newVersion) { let headerSpan = document.createElement("span"); @@ -13,4 +12,4 @@ export function checkForUpgrades() { createBootstrapToast("versionToast", headerSpan, bodyDiv); } }, 100); -} \ No newline at end of file +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree.js b/src/rust/lqosd/src/node_manager/js_build/src/tree.js index 71443eec..5bd3cb57 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree.js @@ -6,6 +6,7 @@ import { formatThroughput, } from "./helpers/scaling"; import {colorByQoqScore} from "./helpers/color_scales"; +import {enableTooltipsWithin} from "./lq_js_common/helpers/tooltips"; import {scaleNumber, toNumber} from "./lq_js_common/helpers/scaling"; import {get_ws_client, subscribeWS} from "./pubsub/ws"; @@ -15,12 +16,23 @@ var upParent = 0; var subscribed = false; var expandedNodes = new Set(); var childrenByParentId = new Map(); +var stormguardNodes = new Set(); +var nodeRateOverrideState = { + loading: false, + saving: false, + data: null, + error: null, + flash: null, +}; +var nodeOverrideInputsDirty = false; +var nodeOverrideLastSeedSignature = null; const wsClient = get_ws_client(); const QOO_TOOLTIP_HTML = "
Quality of Outcome (QoO)
" + "

Quality of Outcome (QoO) is IETF IPPM “Internet Quality” (draft-ietf-ippm-qoo).
" + "https://datatracker.ietf.org/doc/draft-ietf-ippm-qoo/
" + "LibreQoS implements a latency and loss-based model to estimate quality of outcome.

"; const THROUGHPUT_COMPARE_EPSILON_MBPS = 0.01; +const NODE_OVERRIDE_PENDING_TOOLTIP = "Stored as an operator override. Will be applied to generated network.json on the next scheduler run."; const listenOnce = (eventName, handler) => { const wrapped = (msg) => { @@ -30,6 +42,29 @@ const listenOnce = (eventName, handler) => { wsClient.on(eventName, wrapped); }; +function sendWsRequest(responseEvent, request) { + return new Promise((resolve, reject) => { + let done = false; + const responseHandler = (msg) => { + if (done) return; + done = true; + wsClient.off(responseEvent, responseHandler); + wsClient.off("Error", errorHandler); + resolve(msg); + }; + const errorHandler = (msg) => { + if (done) return; + done = true; + wsClient.off(responseEvent, responseHandler); + wsClient.off("Error", errorHandler); + reject(msg); + }; + wsClient.on(responseEvent, responseHandler); + wsClient.on("Error", errorHandler); + wsClient.send(request); + }); +} + function formatDeviceIp(ip) { if (typeof ip === "string") { return ip; @@ -99,6 +134,40 @@ function formatLimitValue(mbps) { return scaleNumber(toNumber(mbps, 0) * 1000 * 1000, 1); } +function formatMbpsInputValue(mbps) { + if (mbps === null || mbps === undefined) { + return ""; + } + const numeric = Number(mbps); + if (!Number.isFinite(numeric)) { + return ""; + } + return numeric.toFixed(2).replace(/\.?0+$/, ""); +} + +function ratesApproximatelyEqual(left, right) { + return Math.abs(toNumber(left, 0) - toNumber(right, 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS; +} + +function formatRatePair(downMbps, upMbps) { + return `${formatLimitValue(downMbps)} / ${formatLimitValue(upMbps)}`; +} + +function currentNode() { + return tree && tree[parent] ? tree[parent][1] : null; +} + +function currentNodeRateQuery() { + const node = currentNode(); + if (!node) { + return null; + } + return { + node_id: node.id ?? null, + node_name: node.name, + }; +} + function buildEffectiveLimitCellHtml(node) { const effective = effectiveMax(node); const effectiveValue = `${formatLimitValue(effective[0])} / ${formatLimitValue(effective[1])}`; @@ -127,17 +196,453 @@ function buildDirectionalLimitHtml(primaryMbps, configuredMbps, showConfigured) return `
${primary}
Cfg ${formatLimitValue(configuredMbps)}
`; } -function initTooltipsWithin(rootEl) { - if (!rootEl) return; - if (typeof bootstrap === "undefined" || !bootstrap.Tooltip) return; - const elements = rootEl.querySelectorAll('[data-bs-toggle="tooltip"]'); - elements.forEach((element) => { - if (bootstrap.Tooltip.getOrCreateInstance) { - bootstrap.Tooltip.getOrCreateInstance(element); +function subtreeSiteCount(node) { + return toNumber(node.subtree_site_count, 0); +} + +function subtreeCircuitCount(node) { + return toNumber(node.subtree_circuit_count, 0); +} + +function nodeDetailParts(node) { + const parts = []; + if (node.type !== null && node.type !== undefined && node.type !== "") { + parts.push(node.type); + } + const circuitCount = subtreeCircuitCount(node); + if (circuitCount > 0) { + parts.push(`Circuits ${circuitCount}`); + } + const siteCount = subtreeSiteCount(node); + if (siteCount > 0) { + parts.push(`Sites ${siteCount}`); + } + return parts; +} + +function appendNodeDetailText(target, node) { + const parts = nodeDetailParts(node); + if (parts.length === 0) { + return; + } + const detail = document.createElement("span"); + detail.classList.add("text-body-secondary"); + detail.textContent = ` (${parts.join(", ")})`; + target.appendChild(detail); +} + +function buildStatusIcon(iconName, textClass, title) { + let icon = document.createElement("i"); + icon.classList.add("fa", "fa-fw", iconName, textClass); + icon.setAttribute("data-bs-toggle", "tooltip"); + icon.setAttribute("data-bs-placement", "top"); + icon.setAttribute("title", title); + return icon; +} + +function isStormguardNode(node) { + return node && typeof node.name === "string" && stormguardNodes.has(node.name); +} + +function renderHeaderStatusIcons(node) { + const target = document.getElementById("nodeNameIcons"); + if (!target) { + return; + } + clearDiv(target); + if (!node) { + return; + } + if (node.virtual === true) { + target.appendChild(buildStatusIcon( + "fa-ghost", + "text-secondary", + "Virtual node (logical only; not shaped in HTB)." + )); + } + if (isStormguardNode(node)) { + target.appendChild(buildStatusIcon( + "fa-cloud-bolt", + "text-primary", + "StormGuard-managed node (dynamic queue limits active)." + )); + } + enableTooltipsWithin(target); +} + +function sameStringSet(a, b) { + if (a.size !== b.size) { + return false; + } + for (const entry of a) { + if (!b.has(entry)) { + return false; + } + } + return true; +} + +function treeHref(nodeId) { + return `/tree.html?parent=${nodeId}`; +} + +function renderBreadcrumb() { + const target = document.getElementById("treeBreadcrumb"); + if (!target) { + return; + } + clearDiv(target); + if (!tree || tree[parent] === undefined) { + return; + } + + const current = tree[parent][1]; + const path = Array.isArray(current.parents) && current.parents.length > 0 + ? current.parents + : [parent]; + const navWrap = document.createElement("div"); + navWrap.classList.add("lqos-tree-nav"); + const trail = document.createElement("div"); + trail.classList.add("lqos-tree-breadcrumb"); + + path.forEach((nodeId, index) => { + const entry = tree[nodeId]; + if (!entry || !entry[1]) { + return; + } + if (index > 0) { + const separator = document.createElement("span"); + separator.classList.add("lqos-tree-breadcrumb-separator"); + separator.innerHTML = ""; + trail.appendChild(separator); + } + + if (index === path.length - 1) { + const currentNode = document.createElement("span"); + currentNode.classList.add("lqos-tree-breadcrumb-current", "redactable"); + currentNode.textContent = entry[1].name; + trail.appendChild(currentNode); } else { - new bootstrap.Tooltip(element); + const link = document.createElement("a"); + link.href = treeHref(nodeId); + link.classList.add("lqos-tree-breadcrumb-link", "redactable"); + link.textContent = entry[1].name; + trail.appendChild(link); } }); + + navWrap.appendChild(trail); + target.appendChild(navWrap); +} + +function setNodeOverrideFlash(message, variant = "success") { + nodeRateOverrideState.flash = message ? {message, variant} : null; + renderNodeSettings(); +} + +function renderAlertMessages(targetId, messages, variant) { + const target = document.getElementById(targetId); + if (!target) { + return; + } + clearDiv(target); + messages.forEach((message) => { + if (!message) { + return; + } + const alert = document.createElement("div"); + alert.classList.add("alert", `alert-${variant}`, "py-2", "px-3", "small"); + alert.textContent = message; + target.appendChild(alert); + }); +} + +function currentOverridePair(node, overrideData) { + const base = configuredMax(node); + return [ + overrideData && overrideData.has_override && overrideData.override_download_bandwidth_mbps !== null && overrideData.override_download_bandwidth_mbps !== undefined + ? overrideData.override_download_bandwidth_mbps + : base[0], + overrideData && overrideData.has_override && overrideData.override_upload_bandwidth_mbps !== null && overrideData.override_upload_bandwidth_mbps !== undefined + ? overrideData.override_upload_bandwidth_mbps + : base[1], + ]; +} + +function isPendingApply(node, overrideData) { + if (!node || !overrideData || !overrideData.has_override) { + return false; + } + const configured = configuredMax(node); + if (overrideData.override_download_bandwidth_mbps !== null + && overrideData.override_download_bandwidth_mbps !== undefined + && !ratesApproximatelyEqual(overrideData.override_download_bandwidth_mbps, configured[0])) { + return true; + } + if (overrideData.override_upload_bandwidth_mbps !== null + && overrideData.override_upload_bandwidth_mbps !== undefined + && !ratesApproximatelyEqual(overrideData.override_upload_bandwidth_mbps, configured[1])) { + return true; + } + return false; +} + +function setNodeOverrideInputsDisabled(disabled) { + ["nodeOverrideDownload", "nodeOverrideUpload", "nodeOverrideSave", "nodeOverrideClear"].forEach((id) => { + const el = document.getElementById(id); + if (el) { + el.disabled = disabled; + } + }); +} + +function maybeSeedOverrideInputs(node, overrideData, force = false) { + const downloadInput = document.getElementById("nodeOverrideDownload"); + const uploadInput = document.getElementById("nodeOverrideUpload"); + if (!downloadInput || !uploadInput || !node) { + return; + } + + const [down, up] = currentOverridePair(node, overrideData); + const signature = JSON.stringify({ + nodeId: node.id ?? null, + nodeName: node.name, + download: down, + upload: up, + }); + if (!force && nodeOverrideInputsDirty) { + return; + } + if (!force && nodeOverrideLastSeedSignature === signature) { + return; + } + + downloadInput.value = formatMbpsInputValue(down); + uploadInput.value = formatMbpsInputValue(up); + nodeOverrideLastSeedSignature = signature; + nodeOverrideInputsDirty = false; +} + +function renderOverrideValue(node, overrideData) { + const target = document.getElementById("nodeSettingsOverride"); + if (!target) { + return; + } + clearDiv(target); + const wrap = document.createElement("span"); + wrap.classList.add("lqos-tree-settings-value"); + + const value = document.createElement("span"); + if (!overrideData || !overrideData.has_override) { + value.textContent = "None"; + wrap.appendChild(value); + target.appendChild(wrap); + return; + } + + const [overrideDown, overrideUp] = currentOverridePair(node, overrideData); + value.textContent = formatRatePair(overrideDown, overrideUp); + wrap.appendChild(value); + + if (isPendingApply(node, overrideData)) { + const pending = document.createElement("span"); + pending.classList.add("lqos-tree-pending"); + pending.setAttribute("data-bs-toggle", "tooltip"); + pending.setAttribute("data-bs-placement", "top"); + pending.setAttribute("title", NODE_OVERRIDE_PENDING_TOOLTIP); + + const symbol = document.createElement("span"); + symbol.classList.add("lqos-tree-pending-symbol"); + symbol.textContent = "⟳"; + pending.appendChild(symbol); + + const label = document.createElement("span"); + label.textContent = "Pending"; + pending.appendChild(label); + wrap.appendChild(pending); + } + + target.appendChild(wrap); +} + +function renderNodeSettings() { + const node = currentNode(); + if (!node) { + return; + } + + const statusTarget = document.getElementById("nodeOverrideStatus"); + if (statusTarget) { + if (nodeRateOverrideState.loading) { + statusTarget.textContent = "Loading operator override..."; + } else if (nodeRateOverrideState.saving) { + statusTarget.textContent = "Saving operator override..."; + } else if (nodeRateOverrideState.error) { + statusTarget.textContent = nodeRateOverrideState.error; + } else { + statusTarget.textContent = ""; + } + } + + const typeTarget = document.getElementById("nodeSettingsType"); + if (typeTarget) { + typeTarget.textContent = node.type ?? "-"; + } + const nodeIdTarget = document.getElementById("nodeSettingsNodeId"); + if (nodeIdTarget) { + nodeIdTarget.textContent = node.id ?? "Unavailable"; + } + const baseConfiguredTarget = document.getElementById("nodeSettingsBaseConfigured"); + if (baseConfiguredTarget) { + const configured = configuredMax(node); + baseConfiguredTarget.textContent = formatRatePair(configured[0], configured[1]); + } + const effectiveTarget = document.getElementById("nodeSettingsEffectiveNow"); + if (effectiveTarget) { + const effective = effectiveMax(node); + effectiveTarget.textContent = formatRatePair(effective[0], effective[1]); + } + + renderOverrideValue(node, nodeRateOverrideState.data); + + renderAlertMessages( + "nodeOverrideFlash", + nodeRateOverrideState.flash ? [nodeRateOverrideState.flash.message] : [], + nodeRateOverrideState.flash ? nodeRateOverrideState.flash.variant : "success", + ); + renderAlertMessages( + "nodeOverrideLegacyWarnings", + nodeRateOverrideState.data?.legacy_warnings || [], + "warning", + ); + renderAlertMessages( + "nodeOverrideDisabledReason", + nodeRateOverrideState.data?.disabled_reason ? [nodeRateOverrideState.data.disabled_reason] : [], + "secondary", + ); + + maybeSeedOverrideInputs(node, nodeRateOverrideState.data); + + const canEdit = !!nodeRateOverrideState.data?.can_edit && !nodeRateOverrideState.loading && !nodeRateOverrideState.saving; + setNodeOverrideInputsDisabled(!canEdit); + const clearButton = document.getElementById("nodeOverrideClear"); + if (clearButton) { + clearButton.disabled = !canEdit || !nodeRateOverrideState.data?.has_override; + } + + const settingsCard = document.querySelector(".lqos-tree-settings-card"); + if (settingsCard) { + enableTooltipsWithin(settingsCard); + } +} + +async function loadNodeRateOverrideState() { + const query = currentNodeRateQuery(); + if (!query) { + return; + } + nodeRateOverrideState.loading = true; + nodeRateOverrideState.error = null; + nodeRateOverrideState.data = null; + renderNodeSettings(); + try { + const response = await sendWsRequest("GetNodeRateOverride", { + GetNodeRateOverride: {query}, + }); + nodeRateOverrideState.data = response.data || null; + nodeRateOverrideState.error = null; + } catch (errorMsg) { + nodeRateOverrideState.data = null; + nodeRateOverrideState.error = errorMsg?.message || "Unable to load override state"; + } finally { + nodeRateOverrideState.loading = false; + renderNodeSettings(); + } +} + +async function saveNodeRateOverride() { + const node = currentNode(); + if (!node || !nodeRateOverrideState.data?.can_edit || nodeRateOverrideState.saving) { + return; + } + + const downloadRaw = document.getElementById("nodeOverrideDownload")?.value ?? ""; + const uploadRaw = document.getElementById("nodeOverrideUpload")?.value ?? ""; + const download = downloadRaw === "" ? null : Number.parseFloat(downloadRaw); + const upload = uploadRaw === "" ? null : Number.parseFloat(uploadRaw); + if ((download !== null && (!Number.isFinite(download) || download < 0)) + || (upload !== null && (!Number.isFinite(upload) || upload < 0)) + || (download === null && upload === null)) { + setNodeOverrideFlash("Enter valid non-negative download and upload rates before saving.", "danger"); + return; + } + + nodeRateOverrideState.saving = true; + nodeRateOverrideState.error = null; + renderNodeSettings(); + try { + const response = await sendWsRequest("SetNodeRateOverrideResult", { + SetNodeRateOverride: { + update: { + node_id: node.id, + node_name: node.name, + download_bandwidth_mbps: download, + upload_bandwidth_mbps: upload, + }, + }, + }); + if (!response.ok) { + setNodeOverrideFlash(response.message || "Unable to save override.", "danger"); + nodeRateOverrideState.data = response.data || nodeRateOverrideState.data; + } else { + nodeRateOverrideState.data = response.data || nodeRateOverrideState.data; + setNodeOverrideFlash(response.message || "Override saved.", "success"); + nodeOverrideInputsDirty = false; + nodeOverrideLastSeedSignature = null; + maybeSeedOverrideInputs(node, nodeRateOverrideState.data, true); + } + } catch (errorMsg) { + setNodeOverrideFlash(errorMsg?.message || "Unable to save override.", "danger"); + } finally { + nodeRateOverrideState.saving = false; + renderNodeSettings(); + } +} + +async function clearNodeRateOverride() { + const node = currentNode(); + if (!node || !nodeRateOverrideState.data?.can_edit || nodeRateOverrideState.saving) { + return; + } + + nodeRateOverrideState.saving = true; + nodeRateOverrideState.error = null; + renderNodeSettings(); + try { + const response = await sendWsRequest("ClearNodeRateOverrideResult", { + ClearNodeRateOverride: { + query: { + node_id: node.id ?? null, + node_name: node.name, + }, + }, + }); + if (!response.ok) { + setNodeOverrideFlash(response.message || "Unable to clear override.", "danger"); + nodeRateOverrideState.data = response.data || nodeRateOverrideState.data; + } else { + nodeRateOverrideState.data = response.data || nodeRateOverrideState.data; + nodeOverrideInputsDirty = false; + nodeOverrideLastSeedSignature = null; + maybeSeedOverrideInputs(node, nodeRateOverrideState.data, true); + setNodeOverrideFlash(response.message || "Override cleared.", "success"); + } + } catch (errorMsg) { + setNodeOverrideFlash(errorMsg?.message || "Unable to clear override.", "danger"); + } finally { + nodeRateOverrideState.saving = false; + renderNodeSettings(); + } } function formatQooScore(score0to100, fallback = "-") { @@ -215,19 +720,6 @@ function renderTree() { } }); - if (parent !== 0) { - if (upParent === 0) { - upParent = tree[parent][1].immediate_parent; - } - const navWrap = document.createElement("div"); - navWrap.classList.add("lqos-tree-nav"); - navWrap.innerHTML = "" + - " Up One Level" + - "" + tree[upParent][1].name + "" + - ""; - treeStack.appendChild(navWrap); - } - treeTable.appendChild(tbody); // Clear and apply @@ -236,7 +728,7 @@ function renderTree() { tableWrap.appendChild(treeTable); treeStack.appendChild(tableWrap); target.appendChild(treeStack); - initTooltipsWithin(treeTable); + enableTooltipsWithin(treeTable); } // This runs first and builds the initial structure on the page @@ -247,11 +739,12 @@ function getInitialTree() { buildChildrenMap(); if (tree[parent] !== undefined) { fillHeader(tree[parent][1]); + loadNodeRateOverrideState(); } renderTree(); if (!subscribed) { - subscribeWS(["NetworkTree", "NetworkTreeClients"], onMessage); + subscribeWS(["NetworkTree", "NetworkTreeClients", "StormguardStatus"], onMessage); subscribed = true; } }); @@ -259,8 +752,13 @@ function getInitialTree() { } function fillHeader(node) { - //console.log("Header"); $("#nodeName").text(node.name); + renderHeaderStatusIcons(node); + const summaryTarget = document.getElementById("treeHeaderSummary"); + if (summaryTarget) { + summaryTarget.textContent = nodeDetailParts(node).join(" / "); + } + renderBreadcrumb(); const configured = configuredMax(node); const effective = effectiveMax(node); const configuredDown = formatLimitValue(configured[0]); @@ -279,7 +777,6 @@ function fillHeader(node) { $("#parentConfiguredU").text(configuredUp).removeClass("lqos-limit-secondary is-match").addClass(matchClassUp); $("#parentTpD").html(formatThroughput(toNumber(node.current_throughput[0], 0) * 8, effective[0])); $("#parentTpU").html(formatThroughput(toNumber(node.current_throughput[1], 0) * 8, effective[1])); - //console.log(node); $("#parentRttD").html(formatRtt(node.rtts[0])); $("#parentRttU").html(formatRtt(node.rtts[1])); $("#parentQooD").html(formatQooScore(node.qoo ? node.qoo[0] : null)); @@ -296,6 +793,7 @@ function fillHeader(node) { retr = toNumber(node.current_retransmits[1], 0) / packetsUp; } $("#parentRxmitU").html(formatRetransmit(retr)); + renderNodeSettings(); } function iterateChildren(idx, tBody, depth) { @@ -347,23 +845,25 @@ function buildRow(i, depth=0) { nameWrap.appendChild(spacer); } if (node.virtual === true) { - let virtualIcon = document.createElement("i"); - virtualIcon.classList.add("fa", "fa-fw", "fa-ghost", "text-secondary"); - virtualIcon.setAttribute("data-bs-toggle", "tooltip"); - virtualIcon.setAttribute("data-bs-placement", "top"); - virtualIcon.setAttribute("title", "Virtual node (logical only; not shaped in HTB)."); - nameWrap.appendChild(virtualIcon); + nameWrap.appendChild(buildStatusIcon( + "fa-ghost", + "text-secondary", + "Virtual node (logical only; not shaped in HTB)." + )); + } + if (isStormguardNode(node)) { + nameWrap.appendChild(buildStatusIcon( + "fa-cloud-bolt", + "text-primary", + "StormGuard-managed node (dynamic queue limits active)." + )); } let link = document.createElement("a"); - link.href = "/tree.html?parent=" + nodeId + "&upParent=" + parent; + link.href = treeHref(nodeId); link.classList.add("redactable"); link.textContent = node.name; nameWrap.appendChild(link); - if (node.type !== null) { - let typeText = document.createElement("span"); - typeText.textContent = " (" + node.type + ")"; - nameWrap.appendChild(typeText); - } + appendNodeDetailText(nameWrap, node); col.appendChild(nameWrap); row.appendChild(col); @@ -493,7 +993,12 @@ function treeUpdate(msg) { tree[nodeId] = [nodeId, node]; needsRebuild = true; } else { - if (tree[nodeId][1].immediate_parent !== node.immediate_parent) { + if (tree[nodeId][1].immediate_parent !== node.immediate_parent + || tree[nodeId][1].subtree_site_count !== node.subtree_site_count + || tree[nodeId][1].subtree_circuit_count !== node.subtree_circuit_count + || tree[nodeId][1].type !== node.type + || tree[nodeId][1].virtual !== node.virtual + || tree[nodeId][1].name !== node.name) { needsRebuild = true; } tree[nodeId][1] = node; @@ -741,11 +1246,31 @@ function clientsUpdate(msg) { target.appendChild(tableWrap); } +function stormguardUpdate(msg) { + const nextNodes = new Set(); + msg.data.forEach((entry) => { + if (!Array.isArray(entry) || typeof entry[0] !== "string" || entry[0].length === 0) { + return; + } + nextNodes.add(entry[0]); + }); + if (sameStringSet(stormguardNodes, nextNodes)) { + return; + } + stormguardNodes = nextNodes; + if (tree && tree[parent] !== undefined) { + fillHeader(tree[parent][1]); + renderTree(); + } +} + function onMessage(msg) { if (msg.event === "NetworkTree") { treeUpdate(msg); } else if (msg.event === "NetworkTreeClients") { clientsUpdate(msg); + } else if (msg.event === "StormguardStatus") { + stormguardUpdate(msg); } } @@ -763,4 +1288,17 @@ if (params.upParent !== null) { upParent = parseInt(params.upParent); } +document.getElementById("nodeOverrideDownload")?.addEventListener("input", () => { + nodeOverrideInputsDirty = true; +}); +document.getElementById("nodeOverrideUpload")?.addEventListener("input", () => { + nodeOverrideInputsDirty = true; +}); +document.getElementById("nodeOverrideSave")?.addEventListener("click", () => { + saveNodeRateOverride(); +}); +document.getElementById("nodeOverrideClear")?.addEventListener("click", () => { + clearNodeRateOverride(); +}); + getInitialTree(); diff --git a/src/rust/lqosd/src/node_manager/local_api.rs b/src/rust/lqosd/src/node_manager/local_api.rs index d5f971d2..ece0b74a 100644 --- a/src/rust/lqosd/src/node_manager/local_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api.rs @@ -8,6 +8,7 @@ pub(crate) mod flow_explorer; pub(crate) mod flow_map; pub mod lts; pub(crate) mod network_tree; +pub(crate) mod node_rate_overrides; pub(crate) mod packet_analysis; pub(crate) mod reload_libreqos; pub(crate) mod scheduler; diff --git a/src/rust/lqosd/src/node_manager/local_api/network_tree.rs b/src/rust/lqosd/src/node_manager/local_api/network_tree.rs index 38d8a4ea..9c137687 100644 --- a/src/rust/lqosd/src/node_manager/local_api/network_tree.rs +++ b/src/rust/lqosd/src/node_manager/local_api/network_tree.rs @@ -1,12 +1,6 @@ -use crate::shaped_devices_tracker::{NETWORK_JSON, node_to_transport}; +use crate::shaped_devices_tracker::full_network_map_snapshot; use lqos_config::NetworkJsonTransport; pub fn network_tree_data() -> Vec<(usize, NetworkJsonTransport)> { - let net_json = NETWORK_JSON.read(); - net_json - .get_nodes_when_ready() - .iter() - .enumerate() - .map(|(i, n)| (i, node_to_transport(n))) - .collect() + full_network_map_snapshot() } diff --git a/src/rust/lqosd/src/node_manager/local_api/node_rate_overrides.rs b/src/rust/lqosd/src/node_manager/local_api/node_rate_overrides.rs new file mode 100644 index 00000000..935d3a3a --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/node_rate_overrides.rs @@ -0,0 +1,289 @@ +use crate::node_manager::auth::LoginResult; +use axum::http::StatusCode; +use lqos_config::load_config; +use lqos_overrides::{NetworkAdjustment, OverrideLayer, OverrideStore}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +const GENERATED_NODE_ID_PREFIX: &str = "libreqos:generated:"; +const GENERATED_NODE_NAME_PREFIX: &str = "(Generated Site) "; +const LEGACY_UISP_FILE: &str = "integrationUISPbandwidths.csv"; +const LEGACY_SPLYNX_FILE: &str = "integrationSplynxBandwidths.csv"; + +/// Query payload for inspecting the operator rate override state of a tree node. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NodeRateOverrideQuery { + /// Stable node identifier from `network.json`, when available. + pub node_id: Option, + /// Display name of the selected node. + pub node_name: String, +} + +/// Update payload for persisting an operator-owned tree node rate override. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NodeRateOverrideUpdate { + /// Stable node identifier from `network.json`. + pub node_id: String, + /// Display name of the selected node. + pub node_name: String, + /// Override download bandwidth in Mbps. + pub download_bandwidth_mbps: Option, + /// Override upload bandwidth in Mbps. + pub upload_bandwidth_mbps: Option, +} + +/// Inspector/view-model data for the tree node rate override panel. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NodeRateOverrideData { + /// Whether the current session is allowed to persist overrides. + pub writable: bool, + /// Whether editing is allowed for this specific node and session. + pub can_edit: bool, + /// Human-readable reason editing is disabled, when applicable. + pub disabled_reason: Option, + /// Whether an operator-owned override currently exists. + pub has_override: bool, + /// Stable node identifier stored on the matching override, when present. + pub override_node_id: Option, + /// Stored operator override download bandwidth in Mbps. + pub override_download_bandwidth_mbps: Option, + /// Stored operator override upload bandwidth in Mbps. + pub override_upload_bandwidth_mbps: Option, + /// Warnings about active legacy integration bandwidth override files. + pub legacy_warnings: Vec, +} + +/// Load the current tree node rate override inspector data. +pub fn get_node_rate_override_data( + login: LoginResult, + query: NodeRateOverrideQuery, +) -> Result { + build_node_rate_override_data(login, query) +} + +/// Save or replace the operator-owned rate override for a tree node. +pub fn set_node_rate_override_data( + login: LoginResult, + update: NodeRateOverrideUpdate, +) -> Result { + if login != LoginResult::Admin { + return Err(StatusCode::FORBIDDEN); + } + validate_update_payload(&update)?; + + let query = NodeRateOverrideQuery { + node_id: Some(update.node_id.clone()), + node_name: update.node_name.clone(), + }; + if let Some(reason) = edit_disabled_reason(login, &query) { + tracing::warn!( + node_name = %update.node_name, + node_id = %update.node_id, + "Rejected tree node override save: {reason}" + ); + return Err(StatusCode::BAD_REQUEST); + } + + let mut overrides = OverrideStore::load_layer(OverrideLayer::Operator) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let changed = overrides.set_site_bandwidth_override( + Some(update.node_id), + update.node_name, + update.download_bandwidth_mbps, + update.upload_bandwidth_mbps, + ); + if changed { + OverrideStore::save_layer(OverrideLayer::Operator, &overrides) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + build_node_rate_override_data(login, query) +} + +/// Remove the operator-owned rate override for a tree node. +pub fn clear_node_rate_override_data( + login: LoginResult, + query: NodeRateOverrideQuery, +) -> Result { + if login != LoginResult::Admin { + return Err(StatusCode::FORBIDDEN); + } + if let Some(reason) = edit_disabled_reason(login, &query) { + tracing::warn!( + node_name = %query.node_name, + node_id = %query.node_id.clone().unwrap_or_default(), + "Rejected tree node override clear: {reason}" + ); + return Err(StatusCode::BAD_REQUEST); + } + + let Some(node_id) = query.node_id.as_deref() else { + return Err(StatusCode::BAD_REQUEST); + }; + + let mut overrides = OverrideStore::load_layer(OverrideLayer::Operator) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let removed = overrides.remove_site_bandwidth_override_count(Some(node_id), &query.node_name); + if removed > 0 { + OverrideStore::save_layer(OverrideLayer::Operator, &overrides) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + build_node_rate_override_data(login, query) +} + +fn build_node_rate_override_data( + login: LoginResult, + query: NodeRateOverrideQuery, +) -> Result { + let overrides = OverrideStore::load_layer(OverrideLayer::Operator) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let matched_override = + overrides.find_site_bandwidth_override(query.node_id.as_deref(), &query.node_name); + let (has_override, override_node_id, override_down, override_up) = match matched_override { + Some(NetworkAdjustment::AdjustSiteSpeed { + node_id, + download_bandwidth_mbps, + upload_bandwidth_mbps, + .. + }) => ( + true, + node_id.clone(), + *download_bandwidth_mbps, + *upload_bandwidth_mbps, + ), + _ => (false, None, None, None), + }; + + let disabled_reason = edit_disabled_reason(login, &query); + Ok(NodeRateOverrideData { + writable: login == LoginResult::Admin, + can_edit: disabled_reason.is_none(), + disabled_reason, + has_override, + override_node_id, + override_download_bandwidth_mbps: override_down, + override_upload_bandwidth_mbps: override_up, + legacy_warnings: legacy_warning_messages()?, + }) +} + +fn edit_disabled_reason(login: LoginResult, query: &NodeRateOverrideQuery) -> Option { + if login != LoginResult::Admin { + return Some("Only administrators can edit node rate overrides.".to_string()); + } + let trimmed_name = query.node_name.trim(); + if trimmed_name.is_empty() { + return Some("This node cannot be edited because it does not expose a stable name.".into()); + } + let Some(node_id) = query.node_id.as_deref() else { + return Some( + "This node cannot be edited from the tree because it does not expose a stable node ID." + .to_string(), + ); + }; + if node_id.starts_with(GENERATED_NODE_ID_PREFIX) + || trimmed_name.starts_with(GENERATED_NODE_NAME_PREFIX) + { + return Some("Generated nodes cannot be edited from the tree.".to_string()); + } + None +} + +fn validate_update_payload(update: &NodeRateOverrideUpdate) -> Result<(), StatusCode> { + if update.node_id.trim().is_empty() || update.node_name.trim().is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + if update.download_bandwidth_mbps.is_none() && update.upload_bandwidth_mbps.is_none() { + return Err(StatusCode::BAD_REQUEST); + } + + for value in [update.download_bandwidth_mbps, update.upload_bandwidth_mbps] + .into_iter() + .flatten() + { + if !value.is_finite() || value < 0.0 { + return Err(StatusCode::BAD_REQUEST); + } + } + Ok(()) +} + +fn legacy_warning_messages() -> Result, StatusCode> { + let config = load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let base = Path::new(&config.lqos_directory); + let mut warnings = Vec::new(); + + if config.uisp_integration.enable_uisp && base.join(LEGACY_UISP_FILE).exists() { + warnings.push(format!( + "Legacy UISP bandwidth overrides are present in `{LEGACY_UISP_FILE}`. Tree edits write operator overrides to `lqos_overrides.json` and do not modify that file." + )); + } + if config.splynx_integration.enable_splynx && base.join(LEGACY_SPLYNX_FILE).exists() { + warnings.push(format!( + "Legacy Splynx bandwidth overrides are present in `{LEGACY_SPLYNX_FILE}`. Tree edits write operator overrides to `lqos_overrides.json` and do not modify that file." + )); + } + + Ok(warnings) +} + +#[cfg(test)] +mod tests { + use super::{ + GENERATED_NODE_ID_PREFIX, GENERATED_NODE_NAME_PREFIX, NodeRateOverrideQuery, + NodeRateOverrideUpdate, edit_disabled_reason, validate_update_payload, + }; + use crate::node_manager::auth::LoginResult; + use axum::http::StatusCode; + + #[test] + fn read_only_sessions_cannot_edit_even_for_real_nodes() { + let query = NodeRateOverrideQuery { + node_id: Some("node-ap27".to_string()), + node_name: "AP27".to_string(), + }; + assert_eq!( + edit_disabled_reason(LoginResult::ReadOnly, &query), + Some("Only administrators can edit node rate overrides.".to_string()) + ); + } + + #[test] + fn generated_nodes_are_blocked_for_admins() { + let query = NodeRateOverrideQuery { + node_id: Some(format!("{GENERATED_NODE_ID_PREFIX}site:ap27")), + node_name: format!("{GENERATED_NODE_NAME_PREFIX}AP27"), + }; + assert_eq!( + edit_disabled_reason(LoginResult::Admin, &query), + Some("Generated nodes cannot be edited from the tree.".to_string()) + ); + } + + #[test] + fn missing_node_id_is_rejected_for_writes() { + let query = NodeRateOverrideQuery { + node_id: None, + node_name: "AP27".to_string(), + }; + assert_eq!( + edit_disabled_reason(LoginResult::Admin, &query), + Some( + "This node cannot be edited from the tree because it does not expose a stable node ID." + .to_string() + ) + ); + } + + #[test] + fn update_payload_requires_non_negative_finite_rates() { + let update = NodeRateOverrideUpdate { + node_id: "node-ap27".to_string(), + node_name: "AP27".to_string(), + download_bandwidth_mbps: Some(-1.0), + upload_bandwidth_mbps: Some(10.0), + }; + assert_eq!(validate_update_payload(&update), Err(StatusCode::BAD_REQUEST)); + } +} diff --git a/src/rust/lqosd/src/node_manager/static2/all_tree_sankey.html b/src/rust/lqosd/src/node_manager/static2/all_tree_sankey.html index b38ff086..bee1aed2 100644 --- a/src/rust/lqosd/src/node_manager/static2/all_tree_sankey.html +++ b/src/rust/lqosd/src/node_manager/static2/all_tree_sankey.html @@ -1,11 +1,12 @@ -
-
+
+
Top node::
-
+
- + +
diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 19f58160..a8573013 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -864,6 +864,45 @@ h5 { gap: 0.8rem; margin-top: 0.8rem; } +.lqos-tree-settings-card { + padding: 1rem 1.15rem; + border-radius: var(--lqos-radius-md); + border: 1px solid var(--lqos-border); + background: color-mix(in srgb, var(--lqos-surface-2) 92%, transparent); + box-shadow: var(--lqos-shadow-sm); +} +.lqos-tree-settings-grid dt { + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.lqos-tree-settings-grid dd { + margin-bottom: 0.85rem; + font-size: 0.95rem; +} +.lqos-tree-settings-value { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; +} +.lqos-tree-pending { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--bs-warning-text-emphasis, var(--bs-warning)); + font-size: 0.88rem; + font-weight: 600; +} +.lqos-tree-pending-symbol { + font-size: 1rem; + line-height: 1; +} +.lqos-tree-settings-card .alert { + margin-bottom: 0.75rem; +} .lqos-tree-nav { display: flex; align-items: center; @@ -889,6 +928,28 @@ h5 { color: var(--bs-secondary-color); font-size: 0.82rem; } +.lqos-tree-breadcrumb { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; +} +.lqos-tree-breadcrumb-link { + color: var(--bs-body-color); + text-decoration: none; +} +.lqos-tree-breadcrumb-link:hover { + color: var(--bs-body-color); + text-decoration: underline; +} +.lqos-tree-breadcrumb-current { + font-weight: 600; + color: var(--bs-body-color); +} +.lqos-tree-breadcrumb-separator { + color: var(--bs-secondary-color); + font-size: 0.72rem; +} .lqos-tree-section-label { display: inline-flex; align-items: center; diff --git a/src/rust/lqosd/src/node_manager/static2/template.html b/src/rust/lqosd/src/node_manager/static2/template.html index 5eaae9df..4b799f3d 100644 --- a/src/rust/lqosd/src/node_manager/static2/template.html +++ b/src/rust/lqosd/src/node_manager/static2/template.html @@ -139,6 +139,7 @@ %%URGENT_STATUS%% %%SCHEDULER_STATUS%% + %%UPDATE_STATUS%%
+"## + } else { + "" + }; + let byte_string = byte_string.replace("%%UPDATE_STATUS%%", update_placeholder); + let byte_string = byte_string.replace("%CACHEBUSTERS%", &format!("?gh={}", GIT_HASH)); if let Some(length) = res_parts.headers.get_mut("content-length") { *length = HeaderValue::from(byte_string.len()); diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index aa53d983..1c7f48a3 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -11,8 +11,8 @@ use std::sync::Arc; use crate::node_manager::auth::{LoginResult, login_from_token}; use crate::node_manager::local_api::{ circuit, circuit_count, config, cpu_affinity, dashboard_themes, device_counts, flow_explorer, - flow_map, lts, network_tree, packet_analysis, reload_libreqos, scheduler, search, - shaped_device_api, unknown_ips, urgent, warnings, + flow_map, lts, network_tree, node_rate_overrides, packet_analysis, reload_libreqos, scheduler, + search, shaped_device_api, unknown_ips, urgent, warnings, }; use crate::node_manager::shaper_queries_actor::ShaperQueryCommand; use crate::node_manager::ws::messages::{ @@ -1370,6 +1370,146 @@ async fn receive_channel_message( return true; } } + WsRequest::GetNodeRateOverride { query } => { + match node_rate_overrides::get_node_rate_override_data(*request_state.login, query) { + Ok(data) => { + let response = WsResponse::GetNodeRateOverride { 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(StatusCode::BAD_REQUEST) => { + let response = WsResponse::Error { + message: "Invalid tree node override request".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(_) => { + let response = WsResponse::Error { + message: "Unable to load tree node override state".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + } + } + WsRequest::SetNodeRateOverride { update } => { + let result = + node_rate_overrides::set_node_rate_override_data(*request_state.login, update); + match result { + Ok(data) => { + let response = WsResponse::SetNodeRateOverrideResult { + ok: true, + message: "Override saved".to_string(), + data, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(StatusCode::FORBIDDEN) => { + let response = WsResponse::SetNodeRateOverrideResult { + ok: false, + message: "Unauthorized".to_string(), + data: node_rate_overrides::NodeRateOverrideData { + writable: false, + can_edit: false, + disabled_reason: Some( + "Only administrators can edit node rate overrides.".to_string(), + ), + has_override: false, + override_node_id: None, + override_download_bandwidth_mbps: None, + override_upload_bandwidth_mbps: None, + legacy_warnings: Vec::new(), + }, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(StatusCode::BAD_REQUEST) => { + let response = WsResponse::Error { + message: "Invalid tree node override payload".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(_) => { + let response = WsResponse::SetNodeRateOverrideResult { + ok: false, + message: "Error saving override".to_string(), + data: node_rate_overrides::NodeRateOverrideData { + writable: true, + can_edit: false, + disabled_reason: Some( + "Unable to reload tree node override state.".to_string(), + ), + has_override: false, + override_node_id: None, + override_download_bandwidth_mbps: None, + override_upload_bandwidth_mbps: None, + legacy_warnings: Vec::new(), + }, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + } + } + WsRequest::ClearNodeRateOverride { query } => { + let result = + node_rate_overrides::clear_node_rate_override_data(*request_state.login, query); + match result { + Ok(data) => { + let response = WsResponse::ClearNodeRateOverrideResult { + ok: true, + message: "Override cleared".to_string(), + 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(StatusCode::BAD_REQUEST) => { + let response = WsResponse::Error { + message: "Invalid tree node override payload".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(_) => { + let response = WsResponse::Error { + message: "Unable to clear tree node override".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + } + } WsRequest::ListNics => match config::list_nics_data(*request_state.login) { Ok(data) => { let response = WsResponse::ListNics { data }; diff --git a/src/rust/lqosd/src/node_manager/ws/messages.rs b/src/rust/lqosd/src/node_manager/ws/messages.rs index 79d6fd37..f96571ff 100644 --- a/src/rust/lqosd/src/node_manager/ws/messages.rs +++ b/src/rust/lqosd/src/node_manager/ws/messages.rs @@ -2,6 +2,9 @@ use crate::lts2_sys::control_channel::{SupportTicket, SupportTicketSummary}; use crate::node_manager::WarningLevel; use crate::node_manager::local_api::dashboard_themes::{DashletIdentity, ThemeEntry}; use crate::node_manager::local_api::device_counts::DeviceCount; +use crate::node_manager::local_api::node_rate_overrides::{ + NodeRateOverrideData, NodeRateOverrideQuery, NodeRateOverrideUpdate, +}; use crate::node_manager::local_api::packet_analysis::RequestAnalysisResult; use crate::node_manager::local_api::scheduler::{SchedulerDetails, SchedulerStatus}; use crate::node_manager::local_api::search::SearchResult; @@ -144,6 +147,15 @@ pub enum WsRequest { network_json: Value, shaped_devices: Vec, }, + GetNodeRateOverride { + query: NodeRateOverrideQuery, + }, + SetNodeRateOverride { + update: NodeRateOverrideUpdate, + }, + ClearNodeRateOverride { + query: NodeRateOverrideQuery, + }, ListNics, NetworkJson, AllShapedDevices, @@ -455,6 +467,19 @@ pub enum WsResponse { ok: bool, message: String, }, + GetNodeRateOverride { + data: NodeRateOverrideData, + }, + SetNodeRateOverrideResult { + ok: bool, + message: String, + data: NodeRateOverrideData, + }, + ClearNodeRateOverrideResult { + ok: bool, + message: String, + data: NodeRateOverrideData, + }, GetUsers { data: Vec, }, diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index 9df172e1..407013af 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -1,6 +1,6 @@ use anyhow::Result; use arc_swap::ArcSwap; -use fxhash::FxHashMap; +use fxhash::{FxHashMap, FxHashSet}; use lqos_bus::{BusResponse, Circuit}; use lqos_config::{ConfigShapedDevices, NetworkJsonNode, NetworkJsonTransport, ShapedDevice}; use lqos_queue_tracker::EFFECTIVE_NODE_RATES; @@ -87,15 +87,77 @@ impl ShapedDeviceHashCache { pub static SHAPED_DEVICE_HASH_CACHE: Lazy> = Lazy::new(|| ArcSwap::new(Arc::new(ShapedDeviceHashCache::default()))); +#[derive(Clone, Copy, Debug, Default)] +struct NetworkTreeSummary { + subtree_site_count: u32, + subtree_circuit_count: u32, + subtree_device_count: u32, +} + /// Clones a network node into its transport form and overlays effective inherited limits when /// the active queue structure contains a matching node entry. pub fn node_to_transport(node: &NetworkJsonNode) -> NetworkJsonTransport { + node_to_transport_with_summary(node, NetworkTreeSummary::default()) +} + +fn node_to_transport_with_summary( + node: &NetworkJsonNode, + summary: NetworkTreeSummary, +) -> NetworkJsonTransport { let mut transport = node.clone_to_transit(); transport.configured_max_throughput = node.max_throughput; transport.effective_max_throughput = EFFECTIVE_NODE_RATES.load().get(&node.name).copied(); + transport.subtree_site_count = summary.subtree_site_count; + transport.subtree_circuit_count = summary.subtree_circuit_count; + transport.subtree_device_count = summary.subtree_device_count; transport } +fn build_network_tree_summaries( + nodes: &[NetworkJsonNode], + shaped_devices: &ConfigShapedDevices, +) -> Vec { + let mut summaries = vec![NetworkTreeSummary::default(); nodes.len()]; + let mut direct_circuits = vec![FxHashSet::default(); nodes.len()]; + let mut node_index_by_name = FxHashMap::default(); + node_index_by_name.reserve(nodes.len()); + + for (idx, node) in nodes.iter().enumerate() { + node_index_by_name.entry(node.name.as_str()).or_insert(idx); + } + + for device in &shaped_devices.devices { + let Some(node_idx) = node_index_by_name.get(device.parent_node.as_str()).copied() else { + continue; + }; + summaries[node_idx].subtree_device_count = + summaries[node_idx].subtree_device_count.saturating_add(1); + direct_circuits[node_idx].insert(device.circuit_hash); + } + + for (idx, circuits) in direct_circuits.iter().enumerate() { + summaries[idx].subtree_circuit_count = circuits.len() as u32; + } + + for idx in (1..nodes.len()).rev() { + let Some(parent_idx) = nodes[idx].immediate_parent else { + continue; + }; + summaries[parent_idx].subtree_site_count = summaries[parent_idx] + .subtree_site_count + .saturating_add(1) + .saturating_add(summaries[idx].subtree_site_count); + summaries[parent_idx].subtree_circuit_count = summaries[parent_idx] + .subtree_circuit_count + .saturating_add(summaries[idx].subtree_circuit_count); + summaries[parent_idx].subtree_device_count = summaries[parent_idx] + .subtree_device_count + .saturating_add(summaries[idx].subtree_device_count); + } + + summaries +} + fn load_shaped_devices() { debug!("ShapedDevices.csv has changed. Attempting to load it."); let shaped_devices = ConfigShapedDevices::load(); @@ -152,14 +214,30 @@ fn watch_for_shaped_devices_changing() -> Result<()> { pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse { let net_json = NETWORK_JSON.read(); let nodes_ref = net_json.get_nodes_when_ready(); + let shaped_devices = SHAPED_DEVICES.load(); + let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); if let Some(parent) = nodes_ref.get(parent_idx) { - let mut nodes = vec![(parent_idx, node_to_transport(parent))]; + let mut nodes = vec![( + parent_idx, + node_to_transport_with_summary( + parent, + summaries.get(parent_idx).copied().unwrap_or_default(), + ), + )]; nodes.extend( nodes_ref .iter() .enumerate() .filter(|(_, node)| node.immediate_parent == Some(parent_idx)) - .map(|(i, node)| (i, node_to_transport(node))), + .map(|(i, node)| { + ( + i, + node_to_transport_with_summary( + node, + summaries.get(i).copied().unwrap_or_default(), + ), + ) + }), ); BusResponse::NetworkMap(nodes) } else { @@ -169,10 +247,18 @@ pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse { pub fn full_network_map_snapshot() -> Vec<(usize, NetworkJsonTransport)> { let nj = NETWORK_JSON.read(); - nj.get_nodes_when_ready() + let nodes = nj.get_nodes_when_ready(); + let shaped_devices = SHAPED_DEVICES.load(); + let summaries = build_network_tree_summaries(nodes, shaped_devices.as_ref()); + nodes .iter() .enumerate() - .map(|(i, n)| (i, node_to_transport(n))) + .map(|(i, n)| { + ( + i, + node_to_transport_with_summary(n, summaries.get(i).copied().unwrap_or_default()), + ) + }) .collect() } @@ -182,9 +268,29 @@ pub fn get_full_network_map() -> BusResponse { pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse { let net_json = NETWORK_JSON.read(); - if let Some(parent) = net_json.get_cloned_entry_by_index(0) { - let mut nodes = vec![(0, parent)]; - nodes.extend_from_slice(&net_json.get_cloned_children(0)); + let nodes_ref = net_json.get_nodes_when_ready(); + let shaped_devices = SHAPED_DEVICES.load(); + let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); + if let Some(parent) = nodes_ref.first() { + let mut nodes = vec![( + 0, + node_to_transport_with_summary(parent, summaries.first().copied().unwrap_or_default()), + )]; + nodes.extend( + nodes_ref + .iter() + .enumerate() + .filter(|(idx, node)| *idx != 0 && node.immediate_parent == Some(0)) + .map(|(idx, node)| { + ( + idx, + node_to_transport_with_summary( + node, + summaries.get(idx).copied().unwrap_or_default(), + ), + ) + }), + ); // Remove the top-level entry for root nodes.remove(0); // Sort by total bandwidth (up + down) descending @@ -246,6 +352,9 @@ pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse { node_type: None, latitude: None, longitude: None, + subtree_site_count: 0, + subtree_circuit_count: 0, + subtree_device_count: 0, }, )); } diff --git a/src/rust/lqosd/src/version_checks.rs b/src/rust/lqosd/src/version_checks.rs index 41ae310a..03beac9e 100644 --- a/src/rust/lqosd/src/version_checks.rs +++ b/src/rust/lqosd/src/version_checks.rs @@ -8,6 +8,7 @@ use std::sync::atomic::AtomicBool; use std::thread; const VERSION_STRING: &str = include_str!("../../../VERSION_STRING"); +const FORCE_UPDATE_ENV: &str = "LQOS_FORCE_UPDATE_AVAILABLE"; #[derive(Serialize, Debug)] struct VersionCheckRequest { @@ -23,6 +24,42 @@ pub struct VersionCheckResponse { static NEW_VERSION_AVAILABLE: AtomicBool = AtomicBool::new(false); +fn parse_force_update_override(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if matches!( + trimmed.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) { + return Some(true); + } + + if matches!( + trimmed.to_ascii_lowercase().as_str(), + "0" | "false" | "no" | "off" + ) { + return Some(false); + } + + None +} + +fn force_update_available() -> Option { + let raw = std::env::var(FORCE_UPDATE_ENV).ok()?; + let parsed = parse_force_update_override(&raw); + if parsed.is_none() { + tracing::warn!( + "{} has invalid value {:?}; expected one of: 1/0 true/false yes/no on/off", + FORCE_UPDATE_ENV, + raw + ); + } + parsed +} + /// Initializes the version checking system. pub fn start_version_check() -> Result<()> { thread::Builder::new() @@ -64,6 +101,9 @@ pub fn start_version_check() -> Result<()> { /// Returns true if a new version is available. pub fn new_version_available() -> bool { + if let Some(forced) = force_update_available() { + return forced; + } NEW_VERSION_AVAILABLE.load(std::sync::atomic::Ordering::Relaxed) } @@ -74,3 +114,30 @@ fn check_version(request: VersionCheckRequest) -> Result { .into_json()?; Ok(response.update_available) } + +#[cfg(test)] +mod tests { + use super::parse_force_update_override; + + #[test] + fn parses_truthy_force_update_values() { + assert_eq!(parse_force_update_override("1"), Some(true)); + assert_eq!(parse_force_update_override("true"), Some(true)); + assert_eq!(parse_force_update_override("YES"), Some(true)); + assert_eq!(parse_force_update_override(" on "), Some(true)); + } + + #[test] + fn parses_falsey_force_update_values() { + assert_eq!(parse_force_update_override("0"), Some(false)); + assert_eq!(parse_force_update_override("false"), Some(false)); + assert_eq!(parse_force_update_override("NO"), Some(false)); + assert_eq!(parse_force_update_override(" off "), Some(false)); + } + + #[test] + fn rejects_invalid_force_update_values() { + assert_eq!(parse_force_update_override(""), None); + assert_eq!(parse_force_update_override("maybe"), None); + } +} diff --git a/src/rust/uisp_integration/src/strategies/ap_site.rs b/src/rust/uisp_integration/src/strategies/ap_site.rs index 98664bca..19d59bc1 100644 --- a/src/rust/uisp_integration/src/strategies/ap_site.rs +++ b/src/rust/uisp_integration/src/strategies/ap_site.rs @@ -209,10 +209,16 @@ impl Layer { root.insert("parent_site".to_string(), name.to_string().into()); if let (Some(latitude), Some(longitude)) = (site.latitude, site.longitude) { if let Some(number) = serde_json::Number::from_f64(latitude as f64) { - root.insert("latitude".to_string(), serde_json::Value::Number(number)); + root.insert( + "latitude".to_string(), + serde_json::Value::Number(number), + ); } if let Some(number) = serde_json::Number::from_f64(longitude as f64) { - root.insert("longitude".to_string(), serde_json::Value::Number(number)); + root.insert( + "longitude".to_string(), + serde_json::Value::Number(number), + ); } } } @@ -224,7 +230,10 @@ impl Layer { if let Some(device) = uisp_data.devices.iter().find(|d| d.name == *name) { root.insert("downloadBandwidthMbps".to_owned(), device.download.into()); root.insert("uploadBandwidthMbps".to_owned(), device.upload.into()); - root.insert("id".to_string(), format!("uisp:device:{}", device.id).into()); + root.insert( + "id".to_string(), + format!("uisp:device:{}", device.id).into(), + ); root.insert("uisp_device".to_string(), device.id.clone().into()); } } diff --git a/src/rust/uisp_integration/src/strategies/full2/graph_mapping.rs b/src/rust/uisp_integration/src/strategies/full2/graph_mapping.rs index b6ae24cd..76f0d7cc 100644 --- a/src/rust/uisp_integration/src/strategies/full2/graph_mapping.rs +++ b/src/rust/uisp_integration/src/strategies/full2/graph_mapping.rs @@ -42,7 +42,10 @@ impl GraphMapping { format!("uisp:site:{id}") } GraphMapping::GeneratedSite { name } => { - format!("libreqos:generated:uisp:site:{}", slugify_generated_name(name)) + format!( + "libreqos:generated:uisp:site:{}", + slugify_generated_name(name) + ) } GraphMapping::AccessPoint { id, .. } => format!("uisp:device:{id}"), } diff --git a/src/rust/uisp_integration/src/uisp_types/uisp_site.rs b/src/rust/uisp_integration/src/uisp_types/uisp_site.rs index 85458f50..2e3643e2 100644 --- a/src/rust/uisp_integration/src/uisp_types/uisp_site.rs +++ b/src/rust/uisp_integration/src/uisp_types/uisp_site.rs @@ -78,7 +78,12 @@ impl UispSite { .description .as_ref() .and_then(|description| description.location.as_ref()) - .map(|location| (Some(location.latitude as f32), Some(location.longitude as f32))) + .map(|location| { + ( + Some(location.latitude as f32), + Some(location.longitude as f32), + ) + }) .unwrap_or((None, None)); // Extract UISP QoS base speeds (bps -> Mbps) and burst sizes (kB/s -> Mbps) let mut base_down_mbps: f32 = 0.0; diff --git a/src/scheduler.py b/src/scheduler.py index 9f55db14..05ea8a22 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -453,9 +453,10 @@ def load_network_json(path: str): def apply_network_adjustments(network: dict) -> bool: """Apply network adjustments from overrides to the network JSON structure. - Currently supports: adjust_site_speed (by site_name) updating - downloadBandwidthMbps and uploadBandwidthMbps at the matching node, and - set_node_virtual (by node_name) updating the boolean 'virtual' flag. + Currently supports: adjust_site_speed (preferring node_id, with legacy + site_name fallback) updating downloadBandwidthMbps and + uploadBandwidthMbps at the matching node, and set_node_virtual (by + node_name) updating the boolean 'virtual' flag. This path intentionally excludes StormGuard adaptive site-speed overrides so runtime StormGuard decisions do not overwrite the operator-authored @@ -471,27 +472,40 @@ def apply_network_adjustments(network: dict) -> bool: if not adjustments: return False - def adjust_node(tree: dict, site: str, dl_opt, ul_opt) -> bool: + def normalize_bandwidth_value(value): + numeric = float(value) + if numeric.is_integer(): + return int(numeric) + return numeric + + def adjust_node(tree: dict, site: str, node_id, dl_opt, ul_opt) -> bool: changed_local = False for key in list(tree.keys()): if key == 'children': child = tree.get('children') if isinstance(child, dict): - if adjust_node(child, site, dl_opt, ul_opt): + if adjust_node(child, site, node_id, dl_opt, ul_opt): changed_local = True continue node = tree.get(key) if isinstance(node, dict): - if key == site: + current_node_id = node.get('id') + matches_target = False + if node_id: + matches_target = current_node_id == node_id + elif key == site: + matches_target = True + + if matches_target: if dl_opt is not None: - node['downloadBandwidthMbps'] = int(dl_opt) + node['downloadBandwidthMbps'] = normalize_bandwidth_value(dl_opt) changed_local = True if ul_opt is not None: - node['uploadBandwidthMbps'] = int(ul_opt) + node['uploadBandwidthMbps'] = normalize_bandwidth_value(ul_opt) changed_local = True # Recurse into children if 'children' in node and isinstance(node['children'], dict): - if adjust_node(node['children'], site, dl_opt, ul_opt): + if adjust_node(node['children'], site, node_id, dl_opt, ul_opt): changed_local = True return changed_local @@ -521,10 +535,11 @@ def apply_network_adjustments(network: dict) -> bool: for adj in adjustments: if adj.get('type') == 'adjust_site_speed': site = adj.get('site_name', '') + node_id = adj.get('node_id', None) dl = adj.get('download_bandwidth_mbps', None) ul = adj.get('upload_bandwidth_mbps', None) - if site: - if adjust_node(network, site, dl, ul): + if site or node_id: + if adjust_node(network, site, node_id, dl, ul): net_changed = True elif adj.get('type') == 'set_node_virtual': node_name = adj.get('node_name', '') diff --git a/src/test_scheduler.py b/src/test_scheduler.py index 01f4444e..0967ce7f 100644 --- a/src/test_scheduler.py +++ b/src/test_scheduler.py @@ -295,6 +295,7 @@ class TestSchedulerOverrideMerge(unittest.TestCase): "children": {}, }, "NodeB": { + "id": "node-b", "downloadBandwidthMbps": 200, "uploadBandwidthMbps": 100, "virtual": False, @@ -304,6 +305,47 @@ class TestSchedulerOverrideMerge(unittest.TestCase): } } + with patch.object( + scheduler, + "overrides_network_adjustments_materialized", + return_value=[ + { + "type": "adjust_site_speed", + "node_id": "node-b", + "site_name": "NodeB", + "download_bandwidth_mbps": 80.5, + "upload_bandwidth_mbps": 40.25, + }, + { + "type": "set_node_virtual", + "node_name": "NodeB", + "virtual": True, + }, + ], + ): + changed = scheduler.apply_network_adjustments(network) + + self.assertTrue(changed) + node = network["Root"]["children"]["NodeB"] + self.assertEqual(node["downloadBandwidthMbps"], 80.5) + self.assertEqual(node["uploadBandwidthMbps"], 40.25) + self.assertTrue(network["Root"]["children"]["NodeB"]["virtual"]) + + def test_apply_network_adjustments_keeps_legacy_name_based_matching(self): + network = { + "Root": { + "downloadBandwidthMbps": 1000, + "uploadBandwidthMbps": 1000, + "children": { + "SiteA": { + "downloadBandwidthMbps": 100, + "uploadBandwidthMbps": 50, + "children": {}, + }, + }, + } + } + with patch.object( scheduler, "overrides_network_adjustments_materialized", @@ -314,11 +356,6 @@ class TestSchedulerOverrideMerge(unittest.TestCase): "download_bandwidth_mbps": 80, "upload_bandwidth_mbps": 40, }, - { - "type": "set_node_virtual", - "node_name": "NodeB", - "virtual": True, - }, ], ): changed = scheduler.apply_network_adjustments(network) @@ -327,7 +364,6 @@ class TestSchedulerOverrideMerge(unittest.TestCase): site = network["Root"]["children"]["SiteA"] self.assertEqual(site["downloadBandwidthMbps"], 80) self.assertEqual(site["uploadBandwidthMbps"], 40) - self.assertTrue(network["Root"]["children"]["NodeB"]["virtual"]) def test_apply_network_adjustments_does_not_use_effective_stormguard_speeds(self): network = { From f19dc44b9f38cd2d2a96c53dadb75013a800149d Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Sat, 21 Mar 2026 03:30:32 -0600 Subject: [PATCH 009/102] changes --- docs-es/v2.0/node-manager-ui-es.md | 10 +- docs/v2.0/node-manager-ui.md | 10 +- .../src/node_manager/js_build/src/tree.js | 572 +++++++++++++++--- .../src/node_manager/static2/node_manager.css | 271 ++++++++- .../lqosd/src/node_manager/static2/tree.html | 212 ++++--- 5 files changed, 870 insertions(+), 205 deletions(-) diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 49742a8b..687995c3 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -14,12 +14,16 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - Las páginas de detalle del árbol ahora muestran una ruta tipo breadcrumb hasta el nodo seleccionado, para navegar hacia abajo sin depender de volver siempre a la raíz. - Las filas del árbol ahora incluyen resúmenes del subárbol para `Sites` descendientes y `Circuits` adjuntos/descendientes, lo que ayuda a estimar el tamaño de cada rama antes de expandirla. - Las filas del árbol y el encabezado del nodo seleccionado pueden mostrar pequeños íconos de estado para manejo especial, incluyendo nodos virtuales y nodos administrados actualmente por StormGuard. -- Las páginas de detalle del árbol incluyen un inspector de `Node Settings` que muestra: +- Las páginas de detalle del árbol incluyen una tarjeta de `Node Details` que muestra: + - el tipo de nodo y el tamaño de la rama del nodo seleccionado - la velocidad configurada base desde el `network.json` generado - cualquier override de velocidad del operador almacenado en `lqos_overrides.json` - la velocidad configurada efectiva actual -- Los administradores pueden guardar o limpiar overrides de velocidad del nodo desde ese inspector. Los usuarios de solo lectura siguen pudiendo inspeccionar los valores, pero los controles permanecen deshabilitados. -- Los overrides de velocidad desde el árbol requieren un ID estable del nodo y rechazan intencionalmente nodos generados. Cuando un override ya fue guardado pero todavía no fue materializado en el `network.json` generado, el inspector muestra un indicador `⟳ Pending` explicando que el cambio se aplicará en la siguiente ejecución del scheduler. +- Las páginas de detalle del árbol también incluyen una barra de contexto compacta, un panel de medidores `Node Snapshot` y un indicador de advertencia de compatibilidad junto a `Operator Override` cuando existen avisos de compatibilidad relacionados con overrides de integraciones. +- Las páginas de detalle del árbol ahora incluyen un localizador estable del nodo en la URL cuando está disponible y vuelven a resolver el nodo seleccionado ante reindexaciones en vivo del árbol, para que permanecer en una página siga el mismo nodo lógico después de regenerar `network.json`. +- Los administradores pueden guardar o limpiar overrides de velocidad del nodo desde el editor `Operator Override` cuando el nodo seleccionado es editable. Los usuarios de solo lectura y los nodos no editables siguen viendo los valores actuales en la tarjeta de detalles. +- El nodo sintético `Root` se muestra como un agregado de solo lectura y no expone el editor `Operator Override`. +- Los overrides de velocidad desde el árbol requieren un ID estable del nodo y rechazan intencionalmente nodos generados. Cuando un override ya fue guardado pero todavía no fue materializado en el `network.json` generado, la tarjeta de detalles muestra un indicador `⟳ Pending` explicando que el cambio se aplicará en la siguiente ejecución del scheduler. - El control de pausa del Sankey de árbol completo ahora solo detiene el polling; el drill-down local, el reset y los cambios de profundidad máxima siguen renderizando desde el snapshot en caché mientras está en pausa. ### Site Map diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index eb731832..102cf010 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -14,12 +14,16 @@ This page documents key WebUI (Node Manager) views and operational behavior in t - Tree detail pages now show a breadcrumb path to the selected node, so drilling down does not require repeated resets back to root. - Tree rows include subtree summaries for descendant `Sites` plus attached/descendant `Circuits`, which helps estimate branch size before expanding further. - Tree rows and the selected-node header can show small status icons for special handling, including virtual nodes and nodes currently managed by StormGuard. -- Tree detail pages include a `Node Settings` inspector that shows: +- Tree detail pages include a `Node Details` card that shows: + - node type and branch size for the selected tree node - base configured rate from generated `network.json` - any operator-owned rate override from `lqos_overrides.json` - current effective configured rate -- Administrators can save or clear node rate overrides from that inspector. Read-only users can still inspect the values, but the controls remain disabled. -- Tree node rate overrides require a stable node ID and intentionally refuse generated nodes. When an override has been saved but not yet materialized into generated `network.json`, the inspector shows a `⟳ Pending` indicator explaining that the change will apply on the next scheduler run. +- Tree detail pages also include a compact context bar, a `Node Snapshot` gauge panel, and a compatibility warning indicator beside `Operator Override` when integration-related override compatibility notices are present. +- Tree detail pages now carry a stable node locator in the URL when available and re-resolve the selected node across live tree reindexing, so staying on a node page continues to follow the same logical node after `network.json` regenerates. +- Administrators can save or clear node rate overrides from the `Operator Override` editor when the selected node is editable. Read-only users and non-editable nodes still see the current values in the details card. +- The synthetic `Root` node is shown as a read-only aggregate and does not expose the `Operator Override` editor. +- Tree node rate overrides require a stable node ID and intentionally refuse generated nodes. When an override has been saved but not yet materialized into generated `network.json`, the details card shows a `⟳ Pending` indicator explaining that the change will apply on the next scheduler run. - The full-tree Sankey pause control now freezes polling only; local drill-down, reset, and max-depth changes continue to rerender from the cached snapshot while paused. ### Site Map diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree.js b/src/rust/lqosd/src/node_manager/js_build/src/tree.js index 5bd3cb57..068126fe 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree.js @@ -6,6 +6,8 @@ import { formatThroughput, } from "./helpers/scaling"; import {colorByQoqScore} from "./helpers/color_scales"; +import {BitsPerSecondGauge} from "./graphs/bits_gauge"; +import {QooScoreGauge} from "./graphs/qoo_score_gauge"; import {enableTooltipsWithin} from "./lq_js_common/helpers/tooltips"; import {scaleNumber, toNumber} from "./lq_js_common/helpers/scaling"; import {get_ws_client, subscribeWS} from "./pubsub/ws"; @@ -17,6 +19,15 @@ var subscribed = false; var expandedNodes = new Set(); var childrenByParentId = new Map(); var stormguardNodes = new Set(); +var treeBitsGauge = null; +var treeQooGauge = null; +var lastClientsMessage = null; +var currentSelectionIdentity = null; +var selectionLocator = { + nodeId: null, + nodePath: null, + lastKnownIndex: 0, +}; var nodeRateOverrideState = { loading: false, saving: false, @@ -114,6 +125,28 @@ function formatIpBytes(bytes) { return list.join("."); } +function abbreviateIpForTable(ipText) { + if (typeof ipText !== "string") { + return String(ipText ?? ""); + } + if (!ipText.includes(":")) { + return ipText; + } + const groups = ipText.split(":").filter((group) => group.length > 0); + if (groups.length <= 3) { + return ipText; + } + return `${groups.slice(0, 3).join(":")}:...`; +} + +function summarizeIpListForTable(ipList) { + const displayList = ipList.map((entry) => abbreviateIpForTable(entry)); + if (displayList.length > 2) { + return `${displayList[0]}, ${displayList[1]} +${displayList.length - 2}`; + } + return displayList.join(", "); +} + function configuredMax(node) { return node.configured_max_throughput || node.max_throughput || [0, 0]; } @@ -153,6 +186,13 @@ function formatRatePair(downMbps, upMbps) { return `${formatLimitValue(downMbps)} / ${formatLimitValue(upMbps)}`; } +function isSyntheticRootNode(node) { + return !!node + && (node.id === null || node.id === undefined) + && node.immediate_parent === null + && node.name === "Root"; +} + function currentNode() { return tree && tree[parent] ? tree[parent][1] : null; } @@ -204,31 +244,25 @@ function subtreeCircuitCount(node) { return toNumber(node.subtree_circuit_count, 0); } -function nodeDetailParts(node) { +function branchSizeParts(node) { const parts = []; - if (node.type !== null && node.type !== undefined && node.type !== "") { - parts.push(node.type); - } const circuitCount = subtreeCircuitCount(node); if (circuitCount > 0) { - parts.push(`Circuits ${circuitCount}`); + parts.push(`${circuitCount} ${circuitCount === 1 ? "circuit" : "circuits"}`); } const siteCount = subtreeSiteCount(node); if (siteCount > 0) { - parts.push(`Sites ${siteCount}`); + parts.push(`${siteCount} ${siteCount === 1 ? "site" : "sites"}`); } return parts; } -function appendNodeDetailText(target, node) { - const parts = nodeDetailParts(node); +function formatBranchSize(node) { + const parts = branchSizeParts(node); if (parts.length === 0) { - return; + return "No descendant sites or circuits"; } - const detail = document.createElement("span"); - detail.classList.add("text-body-secondary"); - detail.textContent = ` (${parts.join(", ")})`; - target.appendChild(detail); + return parts.join(" / "); } function buildStatusIcon(iconName, textClass, title) { @@ -245,7 +279,7 @@ function isStormguardNode(node) { } function renderHeaderStatusIcons(node) { - const target = document.getElementById("nodeNameIcons"); + const target = document.getElementById("nodeStateBadges"); if (!target) { return; } @@ -282,8 +316,206 @@ function sameStringSet(a, b) { return true; } -function treeHref(nodeId) { - return `/tree.html?parent=${nodeId}`; +function normalizeNodePath(path) { + if (!Array.isArray(path)) { + return null; + } + const normalized = path + .map((entry) => typeof entry === "string" ? entry : String(entry ?? "")) + .filter((entry) => entry.length > 0); + return normalized.length > 0 ? normalized : null; +} + +function parseNodePathParam(rawPath) { + if (typeof rawPath !== "string" || rawPath.length === 0) { + return null; + } + try { + return normalizeNodePath(JSON.parse(rawPath)); + } catch (_error) { + return null; + } +} + +function pathEquals(left, right) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) { + return false; + } + } + return true; +} + +function getNodePathNames(nodeId, node = tree?.[nodeId]?.[1]) { + if (!node) { + return null; + } + if (isSyntheticRootNode(node)) { + return ["Root"]; + } + const parentIndexes = Array.isArray(node.parents) && node.parents.length > 0 + ? node.parents + : [nodeId]; + const names = []; + for (const idx of parentIndexes) { + const entry = tree?.[idx]; + if (!entry || !entry[1] || typeof entry[1].name !== "string" || entry[1].name.length === 0) { + return normalizeNodePath([node.name]); + } + names.push(entry[1].name); + } + if (names.length === 0 || names[names.length - 1] !== node.name) { + names.push(node.name); + } + return normalizeNodePath(names); +} + +function selectionIdentityForNode(nodeId, node = tree?.[nodeId]?.[1]) { + if (!node) { + return null; + } + if (typeof node.id === "string" && node.id.length > 0) { + return `id:${node.id}`; + } + const nodePath = getNodePathNames(nodeId, node); + if (nodePath) { + return `path:${JSON.stringify(nodePath)}`; + } + return `index:${nodeId}`; +} + +function syncBrowserUrlToSelection() { + if (typeof window === "undefined" || typeof history === "undefined") { + return; + } + const url = new URL(window.location.href); + url.searchParams.set("parent", String(parent)); + if (selectionLocator.nodeId) { + url.searchParams.set("nodeId", selectionLocator.nodeId); + } else { + url.searchParams.delete("nodeId"); + } + if (selectionLocator.nodePath) { + url.searchParams.set("nodePath", JSON.stringify(selectionLocator.nodePath)); + } else { + url.searchParams.delete("nodePath"); + } + const nextUrl = `${url.pathname}?${url.searchParams.toString()}${url.hash}`; + const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (nextUrl !== currentUrl) { + history.replaceState(null, "", nextUrl); + } +} + +function rememberSelection(nodeId, node = tree?.[nodeId]?.[1]) { + if (!node) { + return false; + } + const nextIdentity = selectionIdentityForNode(nodeId, node); + const identityChanged = currentSelectionIdentity !== nextIdentity; + currentSelectionIdentity = nextIdentity; + selectionLocator.nodeId = typeof node.id === "string" && node.id.length > 0 ? node.id : null; + selectionLocator.nodePath = getNodePathNames(nodeId, node); + selectionLocator.lastKnownIndex = nodeId; + syncBrowserUrlToSelection(); + return identityChanged; +} + +function findNodeIndexByStableId(nodeId) { + if (!tree || typeof nodeId !== "string" || nodeId.length === 0) { + return null; + } + for (let i = 0; i < tree.length; i++) { + const node = tree[i]?.[1]; + if (node?.id === nodeId) { + return i; + } + } + return null; +} + +function findNodeIndexByPath(nodePath) { + if (!tree || !Array.isArray(nodePath) || nodePath.length === 0) { + return null; + } + for (let i = 0; i < tree.length; i++) { + const node = tree[i]?.[1]; + if (!node) { + continue; + } + if (pathEquals(getNodePathNames(i, node), nodePath)) { + return i; + } + } + return null; +} + +function resolveSelectedParentIndex() { + const byStableId = findNodeIndexByStableId(selectionLocator.nodeId); + if (byStableId !== null) { + return byStableId; + } + const byPath = findNodeIndexByPath(selectionLocator.nodePath); + if (byPath !== null) { + return byPath; + } + if (Number.isInteger(selectionLocator.lastKnownIndex) && tree?.[selectionLocator.lastKnownIndex] !== undefined) { + return selectionLocator.lastKnownIndex; + } + if (tree?.[parent] !== undefined) { + return parent; + } + if (tree?.[0] !== undefined) { + return 0; + } + return null; +} + +function reconcileSelection() { + const resolvedParent = resolveSelectedParentIndex(); + if (resolvedParent === null) { + return {parentChanged: false, identityChanged: false}; + } + const previousParent = parent; + parent = resolvedParent; + const identityChanged = rememberSelection(parent); + return { + parentChanged: previousParent !== parent, + identityChanged, + }; +} + +function treeHref(nodeId, node = tree?.[nodeId]?.[1]) { + const params = new URLSearchParams(); + params.set("parent", String(nodeId)); + if (node?.id) { + params.set("nodeId", node.id); + } + const nodePath = getNodePathNames(nodeId, node); + if (nodePath) { + params.set("nodePath", JSON.stringify(nodePath)); + } + return `/tree.html?${params.toString()}`; +} + +function formatTreeCount(value) { + const numeric = toNumber(value, 0); + return numeric > 0 ? String(numeric) : "-"; +} + +function buildTreeRowStatusIcon(iconName, isActive, activeTitle, inactiveTitle) { + const icon = document.createElement("i"); + icon.classList.add("fa", "fa-fw", iconName, "lqos-tree-row-status-icon"); + if (!isActive) { + icon.classList.add("is-inactive"); + } + icon.setAttribute("data-bs-toggle", "tooltip"); + icon.setAttribute("data-bs-placement", "top"); + icon.setAttribute("title", isActive ? activeTitle : inactiveTitle); + return icon; } function renderBreadcrumb() { @@ -300,12 +532,13 @@ function renderBreadcrumb() { const path = Array.isArray(current.parents) && current.parents.length > 0 ? current.parents : [parent]; + const ancestors = path.slice(0, -1); const navWrap = document.createElement("div"); navWrap.classList.add("lqos-tree-nav"); const trail = document.createElement("div"); trail.classList.add("lqos-tree-breadcrumb"); - path.forEach((nodeId, index) => { + ancestors.forEach((nodeId, index) => { const entry = tree[nodeId]; if (!entry || !entry[1]) { return; @@ -316,25 +549,57 @@ function renderBreadcrumb() { separator.innerHTML = ""; trail.appendChild(separator); } - - if (index === path.length - 1) { - const currentNode = document.createElement("span"); - currentNode.classList.add("lqos-tree-breadcrumb-current", "redactable"); - currentNode.textContent = entry[1].name; - trail.appendChild(currentNode); - } else { - const link = document.createElement("a"); - link.href = treeHref(nodeId); - link.classList.add("lqos-tree-breadcrumb-link", "redactable"); - link.textContent = entry[1].name; - trail.appendChild(link); - } + const link = document.createElement("a"); + link.href = treeHref(nodeId, entry[1]); + link.classList.add("lqos-tree-breadcrumb-link", "redactable"); + link.textContent = entry[1].name; + trail.appendChild(link); }); + if (ancestors.length > 0) { + const separator = document.createElement("span"); + separator.classList.add("lqos-tree-breadcrumb-separator"); + separator.innerHTML = ""; + trail.appendChild(separator); + } + + const currentNode = document.createElement("span"); + currentNode.classList.add("lqos-tree-breadcrumb-current", "redactable"); + currentNode.textContent = current.name; + trail.appendChild(currentNode); + navWrap.appendChild(trail); target.appendChild(navWrap); } +function renderContextMeta(node) { + const target = document.getElementById("treeContextMeta"); + if (!target) { + return; + } + clearDiv(target); + if (!node) { + return; + } + + const metaItems = []; + if (node.type) { + metaItems.push(node.type); + } + const branchSize = formatBranchSize(node); + if (branchSize) { + metaItems.push(branchSize); + } + metaItems.push(`Max ${formatRatePair(...effectiveMax(node))}`); + + metaItems.forEach((text) => { + const pill = document.createElement("span"); + pill.classList.add("lqos-tree-context-pill"); + pill.textContent = text; + target.appendChild(pill); + }); +} + function setNodeOverrideFlash(message, variant = "success") { nodeRateOverrideState.flash = message ? {message, variant} : null; renderNodeSettings(); @@ -357,6 +622,44 @@ function renderAlertMessages(targetId, messages, variant) { }); } +function escapeHtml(text) { + return String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderCompatibilityWarningsIndicator(messages) { + const target = document.getElementById("nodeOverrideCompatibilityIndicator"); + if (!target) { + return; + } + const visibleMessages = Array.isArray(messages) ? messages.filter(Boolean) : []; + const nextSignature = JSON.stringify(visibleMessages); + if (target.dataset.warningSignature === nextSignature) { + return; + } + target.dataset.warningSignature = nextSignature; + clearDiv(target); + if (visibleMessages.length === 0) { + return; + } + const tooltipHtml = visibleMessages + .map((message) => `
${escapeHtml(message)}
`) + .join(""); + const icon = document.createElement("span"); + icon.classList.add("lqos-tree-compat-indicator"); + icon.setAttribute("data-bs-toggle", "tooltip"); + icon.setAttribute("data-bs-placement", "top"); + icon.setAttribute("data-bs-html", "true"); + icon.setAttribute("title", tooltipHtml); + icon.setAttribute("aria-label", `Compatibility warnings: ${visibleMessages.length}`); + icon.innerHTML = `${visibleMessages.length}`; + target.appendChild(icon); +} + function currentOverridePair(node, overrideData) { const base = configuredMax(node); return [ @@ -428,13 +731,14 @@ function renderOverrideValue(node, overrideData) { if (!target) { return; } + target.textContent = ""; clearDiv(target); const wrap = document.createElement("span"); - wrap.classList.add("lqos-tree-settings-value"); + wrap.classList.add("lqos-tree-detail-value"); const value = document.createElement("span"); if (!overrideData || !overrideData.has_override) { - value.textContent = "None"; + value.textContent = "- / -"; wrap.appendChild(value); target.appendChild(wrap); return; @@ -470,6 +774,7 @@ function renderNodeSettings() { if (!node) { return; } + const syntheticRoot = isSyntheticRootNode(node); const statusTarget = document.getElementById("nodeOverrideStatus"); if (statusTarget) { @@ -488,9 +793,9 @@ function renderNodeSettings() { if (typeTarget) { typeTarget.textContent = node.type ?? "-"; } - const nodeIdTarget = document.getElementById("nodeSettingsNodeId"); - if (nodeIdTarget) { - nodeIdTarget.textContent = node.id ?? "Unavailable"; + const branchSizeTarget = document.getElementById("nodeSettingsBranchSize"); + if (branchSizeTarget) { + branchSizeTarget.textContent = formatBranchSize(node); } const baseConfiguredTarget = document.getElementById("nodeSettingsBaseConfigured"); if (baseConfiguredTarget) { @@ -510,14 +815,11 @@ function renderNodeSettings() { nodeRateOverrideState.flash ? [nodeRateOverrideState.flash.message] : [], nodeRateOverrideState.flash ? nodeRateOverrideState.flash.variant : "success", ); - renderAlertMessages( - "nodeOverrideLegacyWarnings", - nodeRateOverrideState.data?.legacy_warnings || [], - "warning", - ); + renderCompatibilityWarningsIndicator(nodeRateOverrideState.data?.legacy_warnings || []); + const disabledReason = syntheticRoot ? null : nodeRateOverrideState.data?.disabled_reason; renderAlertMessages( "nodeOverrideDisabledReason", - nodeRateOverrideState.data?.disabled_reason ? [nodeRateOverrideState.data.disabled_reason] : [], + disabledReason ? [disabledReason] : [], "secondary", ); @@ -525,14 +827,18 @@ function renderNodeSettings() { const canEdit = !!nodeRateOverrideState.data?.can_edit && !nodeRateOverrideState.loading && !nodeRateOverrideState.saving; setNodeOverrideInputsDisabled(!canEdit); + const editorSection = document.getElementById("nodeOverrideEditorSection"); + if (editorSection) { + editorSection.hidden = !canEdit; + } const clearButton = document.getElementById("nodeOverrideClear"); if (clearButton) { clearButton.disabled = !canEdit || !nodeRateOverrideState.data?.has_override; } - const settingsCard = document.querySelector(".lqos-tree-settings-card"); - if (settingsCard) { - enableTooltipsWithin(settingsCard); + const detailsPanel = document.querySelector(".lqos-tree-details-panel"); + if (detailsPanel) { + enableTooltipsWithin(detailsPanel); } } @@ -658,9 +964,49 @@ function formatQooScore(score0to100, fallback = "-") { return "■" + clamped; } +function representativeNodeQoo(node) { + if (!node || !node.qoo) { + return null; + } + const values = [node.qoo[0], node.qoo[1]] + .map((entry) => Number(entry)) + .filter((entry) => Number.isFinite(entry) && entry !== 255); + if (values.length === 0) { + return null; + } + return values.reduce((sum, entry) => sum + entry, 0) / values.length; +} + +function ensureTreeGauges() { + if (!treeBitsGauge) { + treeBitsGauge = new BitsPerSecondGauge("treeBitsGauge", "Max"); + } + if (!treeQooGauge) { + treeQooGauge = new QooScoreGauge("treeQooGauge"); + } +} + +function updateTreeGauges(node) { + if (!node) { + return; + } + ensureTreeGauges(); + const effective = effectiveMax(node); + treeBitsGauge.update( + toNumber(node.current_throughput?.[0], 0) * 8, + toNumber(node.current_throughput?.[1], 0) * 8, + effective[0], + effective[1], + ); + treeQooGauge.update(representativeNodeQoo(node)); +} + function buildChildrenMap() { childrenByParentId = new Map(); for (let i=0; i 0) { - col.style.paddingLeft = (depth * 1.25) + "rem"; - } + col.classList.add("small", "lqos-tree-node-cell"); let nameWrap = document.createElement("div"); - nameWrap.classList.add("d-flex", "align-items-center", "gap-1"); + nameWrap.classList.add("lqos-tree-node-wrap"); + if (depth > 0) { + nameWrap.style.paddingLeft = (depth * 1.25) + "rem"; + } + const lead = document.createElement("div"); + lead.classList.add("lqos-tree-node-lead"); + const toggleSlot = document.createElement("div"); + toggleSlot.classList.add("lqos-tree-toggle-slot"); if (hasChildren(nodeId)) { let toggle = document.createElement("button"); toggle.type = "button"; - toggle.classList.add("btn", "btn-link", "btn-sm", "p-0", "text-decoration-none"); - toggle.style.lineHeight = "1"; + toggle.classList.add("btn", "btn-link", "btn-sm", "p-0", "text-decoration-none", "lqos-tree-toggle"); let icon = document.createElement("i"); icon.classList.add("fa", "fa-fw", expandedNodes.has(nodeId) ? "fa-minus" : "fa-plus"); toggle.appendChild(icon); @@ -837,36 +1188,43 @@ function buildRow(i, depth=0) { event.stopPropagation(); toggleNode(nodeId); }); - nameWrap.appendChild(toggle); - } else { - let spacer = document.createElement("i"); - spacer.classList.add("fa", "fa-fw", "fa-plus"); - spacer.style.visibility = "hidden"; - nameWrap.appendChild(spacer); - } - if (node.virtual === true) { - nameWrap.appendChild(buildStatusIcon( - "fa-ghost", - "text-secondary", - "Virtual node (logical only; not shaped in HTB)." - )); - } - if (isStormguardNode(node)) { - nameWrap.appendChild(buildStatusIcon( - "fa-cloud-bolt", - "text-primary", - "StormGuard-managed node (dynamic queue limits active)." - )); + toggleSlot.appendChild(toggle); } + lead.appendChild(toggleSlot); let link = document.createElement("a"); - link.href = treeHref(nodeId); - link.classList.add("redactable"); + link.href = treeHref(nodeId, node); + link.classList.add("redactable", "lqos-tree-node-link"); link.textContent = node.name; - nameWrap.appendChild(link); - appendNodeDetailText(nameWrap, node); + lead.appendChild(link); + nameWrap.appendChild(lead); + const statusWrap = document.createElement("div"); + statusWrap.classList.add("lqos-tree-row-statuses"); + statusWrap.appendChild(buildTreeRowStatusIcon( + "fa-ghost", + node.virtual === true, + "Virtual node: Active", + "Virtual node: Inactive", + )); + statusWrap.appendChild(buildTreeRowStatusIcon( + "fa-cloud-bolt", + isStormguardNode(node), + "StormGuard: Active", + "StormGuard: Inactive", + )); + nameWrap.appendChild(statusWrap); col.appendChild(nameWrap); row.appendChild(col); + col = document.createElement("td"); + col.classList.add("small"); + col.textContent = formatTreeCount(subtreeCircuitCount(node)); + row.appendChild(col); + + col = document.createElement("td"); + col.classList.add("small"); + col.textContent = formatTreeCount(subtreeSiteCount(node)); + row.appendChild(col); + col = document.createElement("td"); col.id = "limit-" + nodeId; col.classList.add("small"); @@ -985,9 +1343,11 @@ function buildRow(i, depth=0) { function treeUpdate(msg) { //console.log(msg); let needsRebuild = false; + const seenNodeIds = new Set(); msg.data.forEach((n) => { let nodeId = n[0]; let node = n[1]; + seenNodeIds.add(nodeId); if (tree[nodeId] === undefined) { tree[nodeId] = [nodeId, node]; @@ -1004,10 +1364,6 @@ function treeUpdate(msg) { tree[nodeId][1] = node; } - if (nodeId === parent) { - fillHeader(node); - } - let col = document.getElementById("limit-" + nodeId); if (col !== null) { col.innerHTML = buildEffectiveLimitCellHtml(node); @@ -1100,13 +1456,36 @@ function treeUpdate(msg) { } } }); + for (let i = 0; i < tree.length; i++) { + if (tree[i] === undefined || seenNodeIds.has(i)) { + continue; + } + delete tree[i]; + needsRebuild = true; + } if (needsRebuild) { buildChildrenMap(); + } + const selectionState = reconcileSelection(); + if (selectionState.identityChanged) { + loadNodeRateOverrideState(); + } + if (tree[parent] !== undefined) { + fillHeader(tree[parent][1]); + } + if (needsRebuild || selectionState.parentChanged) { renderTree(); } + if (selectionState.parentChanged && lastClientsMessage) { + clientsUpdate(lastClientsMessage); + } } function clientsUpdate(msg) { + lastClientsMessage = msg; + if (!tree || tree[parent] === undefined) { + return; + } let myName = tree[parent][1].name; let target = document.getElementById("clients"); @@ -1180,6 +1559,8 @@ function clientsUpdate(msg) { circuitLink.href = "/circuit.html?id=" + circuit.circuit_id; circuitLink.innerText = circuit.circuit_name; circuitLink.classList.add("redactable"); + circuitLink.classList.add("lqos-tree-circuit-name"); + circuitLink.title = circuit.circuit_name; linkTd.appendChild(circuitLink); tr.appendChild(linkTd); @@ -1197,10 +1578,7 @@ function clientsUpdate(msg) { tr.appendChild(simpleRow(circuit.parent_node, true)); const ipList = Array.from(circuit.ips); - const ipCell = simpleRow( - ipList.length > 2 ? `${ipList[0]}, ${ipList[1]} +${ipList.length - 2}` : ipList.join(", "), - true - ); + const ipCell = simpleRow(summarizeIpListForTable(ipList), true); if (ipList.length > 0) { ipCell.title = ipList.join(", "); } @@ -1258,6 +1636,10 @@ function stormguardUpdate(msg) { return; } stormguardNodes = nextNodes; + const selectionState = reconcileSelection(); + if ((selectionState.parentChanged || selectionState.identityChanged) && lastClientsMessage) { + clientsUpdate(lastClientsMessage); + } if (tree && tree[parent] !== undefined) { fillHeader(tree[parent][1]); renderTree(); @@ -1279,15 +1661,21 @@ const params = new Proxy(new URLSearchParams(window.location.search), { }); if (params.parent !== null) { - parent = parseInt(params.parent); + const parsedParent = parseInt(params.parent, 10); + parent = Number.isFinite(parsedParent) ? parsedParent : 0; } else { parent = 0; } if (params.upParent !== null) { - upParent = parseInt(params.upParent); + const parsedUpParent = parseInt(params.upParent, 10); + upParent = Number.isFinite(parsedUpParent) ? parsedUpParent : 0; } +selectionLocator.nodeId = typeof params.nodeId === "string" && params.nodeId.length > 0 ? params.nodeId : null; +selectionLocator.nodePath = parseNodePathParam(params.nodePath); +selectionLocator.lastKnownIndex = parent; + document.getElementById("nodeOverrideDownload")?.addEventListener("input", () => { nodeOverrideInputsDirty = true; }); diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index a8573013..8b8c2280 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -864,30 +864,248 @@ h5 { gap: 0.8rem; margin-top: 0.8rem; } -.lqos-tree-settings-card { - padding: 1rem 1.15rem; - border-radius: var(--lqos-radius-md); +.lqos-tree-node-cell { + min-width: 17rem; +} +.lqos-tree-node-wrap { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-width: 0; +} +.lqos-tree-node-lead { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + flex: 1 1 auto; +} +.lqos-tree-toggle-slot { + width: 1.2rem; + flex: 0 0 1.2rem; + display: flex; + align-items: center; + justify-content: center; +} +.lqos-tree-toggle { + line-height: 1; +} +.lqos-tree-node-link { + min-width: 0; +} +.lqos-tree-circuit-name { + display: block; + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lqos-tree-row-statuses { + display: inline-flex; + align-items: center; + gap: 0.2rem; + flex: 0 0 auto; +} +.lqos-tree-row-status-icon { + color: var(--bs-secondary-color); + opacity: 0.95; + transition: opacity 120ms ease, color 120ms ease; +} +.lqos-tree-row-status-icon.is-inactive { + opacity: 0.35; +} +.lqos-tree-hero { + padding: 0.75rem 1rem; border: 1px solid var(--lqos-border); - background: color-mix(in srgb, var(--lqos-surface-2) 92%, transparent); + border-radius: var(--lqos-radius-md); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--lqos-surface-2) 82%, transparent) 0%, + color-mix(in srgb, var(--lqos-surface-solid) 98%, transparent) 100% + ); box-shadow: var(--lqos-shadow-sm); } -.lqos-tree-settings-grid dt { - color: var(--bs-secondary-color); - font-size: 0.78rem; +.lqos-tree-kicker { + display: inline-flex; + align-items: center; + gap: 0.45rem; + color: var(--bs-secondary-text-emphasis); + font-size: 1.55rem; font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + letter-spacing: 0; + line-height: 1.2; + text-transform: none; } -.lqos-tree-settings-grid dd { - margin-bottom: 0.85rem; - font-size: 0.95rem; +.lqos-tree-hero-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.85rem; + margin-top: 0.65rem; } -.lqos-tree-settings-value { +.lqos-tree-hero-breadcrumb { + min-width: 0; +} +.lqos-tree-hero-status { + display: flex; + align-items: center; + justify-content: flex-end; +} +.lqos-tree-context-meta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.45rem; +} +.lqos-tree-context-pill { + display: inline-flex; + align-items: center; + padding: 0.32rem 0.68rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 82%, transparent); + background: color-mix(in srgb, var(--lqos-surface-2) 90%, transparent); + color: var(--bs-secondary-color); + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; +} +.lqos-tree-compat-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-width: 1.75rem; + height: 1.75rem; + padding: 0 0.5rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--bs-warning) 48%, var(--lqos-border)); + background: color-mix(in srgb, var(--bs-warning-bg-subtle, #3a2d00) 72%, transparent); + color: var(--bs-warning-text-emphasis, var(--bs-warning)); + font-size: 0.82rem; + font-weight: 700; + cursor: help; +} +.lqos-tree-compat-count { + font-size: 0.76rem; + line-height: 1; +} +.lqos-tree-panel { + height: 100%; + display: flex; + flex-direction: column; + gap: 0.85rem; + min-height: 230px; + border: 1px solid var(--lqos-border); + border-radius: var(--lqos-radius-md); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--lqos-surface-2) 88%, transparent) 0%, + var(--lqos-surface-solid) 100% + ); + box-shadow: var(--lqos-shadow-sm); + padding: 0.85rem; +} +.lqos-tree-panel-heading { + min-height: 1.75rem; +} +.lqos-tree-panel h5, +.lqos-tree-snapshot-panel h5 { + font-size: 1.15rem; + font-weight: 650; +} +.lqos-tree-snapshot-panel { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 0.85rem; + padding: 0.85rem 0.85rem 0.6rem; +} +.lqos-tree-snapshot-panel .lqos-tree-panel-heading { + margin-bottom: 0; +} +.lqos-tree-snapshot-panel .lqos-circuit-gauge-grid { + flex: 1 1 auto; + align-content: center; +} +.lqos-tree-state-panel .lqos-table-wrap, +.lqos-tree-details-panel .lqos-table-wrap { + height: 100%; +} +.lqos-tree-detail-value { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 0.4rem; } +.lqos-tree-editor { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding-top: 0.85rem; + border-top: 1px solid color-mix(in srgb, var(--lqos-border) 72%, transparent); +} +.lqos-tree-editor-heading-end { + display: inline-flex; + align-items: center; + justify-content: flex-end; + margin-left: auto; + min-height: 1.75rem; +} +.lqos-tree-editor[hidden] { + display: none !important; +} +.lqos-tree-editor #nodeOverrideFlash .alert:last-child, +.lqos-tree-details-panel #nodeOverrideDisabledReason .alert:last-child { + margin-bottom: 0; +} +.lqos-tree-editor-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: nowrap; + padding: 0.65rem 0.75rem; + border: 1px solid color-mix(in srgb, var(--lqos-border) 78%, transparent); + border-radius: var(--lqos-radius-md); + background: color-mix(in srgb, var(--lqos-surface-2) 76%, transparent); +} +.lqos-tree-editor-field { + flex: 1 1 0; + min-width: 0; +} +.lqos-tree-editor-field .input-group-text { + min-width: 5.9rem; + justify-content: center; + background: color-mix(in srgb, var(--lqos-surface-2) 88%, transparent); + border-color: color-mix(in srgb, var(--lqos-border) 78%, transparent); + color: var(--bs-secondary-color); + font-size: 0.82rem; + font-weight: 600; +} +.lqos-tree-editor-actions { + justify-content: flex-start; + flex: 0 0 auto; + white-space: nowrap; +} +.lqos-tree-state-badges { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem; +} +.lqos-tree-state-badges .fa { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 82%, transparent); + background: color-mix(in srgb, var(--lqos-surface-2) 88%, transparent); + box-shadow: var(--lqos-shadow-sm); +} .lqos-tree-pending { display: inline-flex; align-items: center; @@ -900,19 +1118,15 @@ h5 { font-size: 1rem; line-height: 1; } -.lqos-tree-settings-card .alert { - margin-bottom: 0.75rem; -} .lqos-tree-nav { display: flex; align-items: center; - margin-top: 0.15rem; } .lqos-tree-nav a { display: inline-flex; align-items: center; gap: 0.55rem; - padding: 0.55rem 0.85rem; + padding: 0.4rem 0.7rem; border-radius: 999px; border: 1px solid var(--lqos-border); background: color-mix(in srgb, var(--lqos-surface-2) 90%, transparent); @@ -961,6 +1175,27 @@ h5 { letter-spacing: 0.08em; text-transform: uppercase; } +@media (max-width: 991.98px) { + .lqos-tree-hero-status { + justify-content: flex-start; + } + .lqos-tree-hero-bar { + flex-direction: column; + align-items: flex-start; + } + .lqos-tree-context-meta { + justify-content: flex-start; + } +} +@media (max-width: 767.98px) { + .lqos-tree-editor-row { + flex-wrap: wrap; + } + .lqos-tree-editor-field, + .lqos-tree-editor-actions { + width: 100%; + } +} /* Funky tricks for the tree view */ .overlayThroughputWrapper { diff --git a/src/rust/lqosd/src/node_manager/static2/tree.html b/src/rust/lqosd/src/node_manager/static2/tree.html index fcc0fd08..6ef593ee 100644 --- a/src/rust/lqosd/src/node_manager/static2/tree.html +++ b/src/rust/lqosd/src/node_manager/static2/tree.html @@ -1,99 +1,133 @@ -
-
-
Network Tree ()
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DownloadUpload
Effective Limit
Configured Limit
Throughput
RTT
QoO
TCP Retransmits
+
+
+
+
+
Network Tree
+
+
+
+
+
+
-
-
-
-
Node Settings
-
+
+
+
+
Live Queue State
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DownloadUpload
Effective Limit
Configured Limit
Throughput
RTT
QoO
TCP Retransmits
+
+
+
+
+
+
+
Node Snapshot
+
+
+
+
+
+
+
+
+
+
+
Node Details
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Node Type-
Branch Size-
Base Configured Rate-
Operator OverrideNone
Effective Now-
-
-
-
-
Node Type
-
-
- -
Stable Node ID
-
-
- -
Base Source
-
Integration/network.json
- -
Override Source
-
Operator
- -
Base Configured Rate
-
-
- -
Operator Override
-
None
- -
Effective Now
-
-
-
-
-
- - +
+
+
Operator Override
+
-
- - +
+
+
+ Download + +
+
+ Upload + +
+
+ + +
-
- - -
From abdc417466b50ea9807a923696af92d6f53ba3f1 Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Sat, 21 Mar 2026 03:41:11 -0600 Subject: [PATCH 010/102] changes --- src/rust/lqosd/src/node_manager/static2/node_manager.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 8b8c2280..29662e5d 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -1061,6 +1061,10 @@ h5 { .lqos-tree-details-panel #nodeOverrideDisabledReason .alert:last-child { margin-bottom: 0; } +.lqos-tree-editor #nodeOverrideFlash:empty, +.lqos-tree-details-panel #nodeOverrideDisabledReason:empty { + display: none; +} .lqos-tree-editor-row { display: flex; align-items: center; From 06e83a1b7c27d465acb35626c247ed365254ba85 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 21 Mar 2026 11:04:13 -0500 Subject: [PATCH 011/102] Feat: OSM site map via Insight tiles Adds bbox priming, labeled markers, and site hierarchy lines; fixes CPUID unsafe call. --- src/rust/lqos_config/src/cpu_topology.rs | 7 +- .../src/node_manager/js_build/src/site_map.js | 862 ++++++------------ .../src/node_manager/static2/site_map.html | 36 + 3 files changed, 321 insertions(+), 584 deletions(-) diff --git a/src/rust/lqos_config/src/cpu_topology.rs b/src/rust/lqos_config/src/cpu_topology.rs index 917c8ab5..5e159b72 100644 --- a/src/rust/lqos_config/src/cpu_topology.rs +++ b/src/rust/lqos_config/src/cpu_topology.rs @@ -432,7 +432,9 @@ fn detect_from_cpuid(possible: &[u32]) -> Option { #[cfg(target_arch = "x86_64")] use std::arch::x86_64::__cpuid_count; - if __cpuid_count(0, 0).eax < 0x1a { + // Safety: CPUID is available on all supported x86/x86_64 platforms, and we query leaf 0 first + // to determine the maximum supported leaf before using leaf 0x1A. + if unsafe { __cpuid_count(0, 0) }.eax < 0x1a { return None; } @@ -452,7 +454,8 @@ fn detect_from_cpuid(possible: &[u32]) -> Option { return None; } - let leaf = __cpuid_count(0x1a, 0); + // Safety: Leaf support is validated above using CPUID leaf 0. + let leaf = unsafe { __cpuid_count(0x1a, 0) }; let core_type = (leaf.eax >> 24) & 0xff; match core_type { CPUID_CORE_TYPE_CORE => performance.push(*cpu), diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 535676b5..476f3569 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -8,44 +8,25 @@ const wsClient = get_ws_client(); const INITIAL_REQUEST_TIMEOUT_MS = 2500; const HISTORY_WINDOW_MS = 30_000; -const COUNTRIES_GEOJSON_PATH = "vendor/countries.geojson"; -const ADMIN1_BOUNDARIES_GEOJSON_PATH = "vendor/site_map_admin1_boundaries.geojson"; -const COASTLINES_GEOJSON_PATH = "vendor/site_map_coastlines.geojson"; -const LAKES_GEOJSON_PATH = "vendor/site_map_lakes.geojson"; -const RIVERS_GEOJSON_PATH = "vendor/site_map_rivers.geojson"; -const PHYSICAL_REGIONS_GEOJSON_PATH = "vendor/site_map_physical_regions.geojson"; -const PHYSICAL_REGIONS_10M_GEOJSON_PATH = "vendor/site_map_physical_regions_10m.geojson"; -const MARINE_AREAS_GEOJSON_PATH = "vendor/site_map_marine_areas.geojson"; -const MAJOR_ROADS_10M_GEOJSON_PATH = "vendor/site_map_major_roads_10m.geojson"; -const INITIAL_CENTER = [-101.5, 39.8]; -const INITIAL_ZOOM = 3.15; + +const TILE_BBOX_URL = "https://insight.librqos.com/tiles/api/bbox"; +const TILE_BBOX_URL_FALLBACK = "https://insight.libreqos.com/tiles/api/bbox"; +const TILE_BBOX_BEARER_VALUE = "LibreQoSRocks"; +const TILE_URL_TEMPLATE = "https://insight.libreqos.com/tiles/{z}/{y}/{x}.png?key=LibreQosRocks"; +const TILE_ATTRIBUTION = "© OpenStreetMap contributors"; +const TILE_MAX_ZOOM = 19; + +const FALLBACK_CENTER = [-101.5, 39.8]; +const FALLBACK_ZOOM = 3.15; + +const OSM_RASTER_SOURCE_ID = "site-map-osm"; +const OSM_RASTER_LAYER_ID = "site-map-osm-tiles"; const SITE_SOURCE_ID = "site-map-sites"; const AP_SOURCE_ID = "site-map-aps"; -const COUNTRIES_SOURCE_ID = "site-map-countries"; -const ADMIN1_SOURCE_ID = "site-map-admin1"; -const COASTLINE_SOURCE_ID = "site-map-coastlines"; -const LAKES_SOURCE_ID = "site-map-lakes"; -const RIVERS_SOURCE_ID = "site-map-rivers"; -const PHYSICAL_REGIONS_SOURCE_ID = "site-map-physical-regions"; -const PHYSICAL_REGIONS_10M_SOURCE_ID = "site-map-physical-regions-10m"; -const MARINE_AREAS_SOURCE_ID = "site-map-marine-areas"; -const MAJOR_ROADS_10M_SOURCE_ID = "site-map-major-roads-10m"; -const SITE_CLUSTER_LAYER_ID = "site-map-site-clusters"; +const SITE_LINK_SOURCE_ID = "site-map-site-links"; const SITE_POINTS_LAYER_ID = "site-map-site-points"; -const AP_CLUSTER_LAYER_ID = "site-map-ap-clusters"; const AP_POINTS_LAYER_ID = "site-map-ap-points"; -const COUNTRY_FILL_LAYER_ID = "site-map-country-fill"; -const COUNTRY_LINE_LAYER_ID = "site-map-country-line"; -const ADMIN1_LINE_LAYER_ID = "site-map-admin1-line"; -const COASTLINE_LAYER_ID = "site-map-coastline-line"; -const LAKES_FILL_LAYER_ID = "site-map-lakes-fill"; -const RIVERS_LINE_LAYER_ID = "site-map-rivers-line"; -const PHYSICAL_REGIONS_FILL_LAYER_ID = "site-map-physical-regions-fill"; -const PHYSICAL_REGIONS_LINE_LAYER_ID = "site-map-physical-regions-line"; -const PHYSICAL_REGIONS_10M_FILL_LAYER_ID = "site-map-physical-regions-10m-fill"; -const PHYSICAL_REGIONS_10M_LINE_LAYER_ID = "site-map-physical-regions-10m-line"; -const MARINE_AREAS_FILL_LAYER_ID = "site-map-marine-areas-fill"; -const MAJOR_ROADS_10M_LAYER_ID = "site-map-major-roads-10m-line"; +const SITE_LINK_LAYER_ID = "site-map-site-links-line"; function listenOnceWithTimeout(eventName, timeoutMs, handler, onTimeout) { let done = false; @@ -133,103 +114,115 @@ function asNodeType(node) { return String(node?.type || node?.node_type || "").toLowerCase(); } -function countryPalette() { +function markerPalette() { if (isDarkMode()) { return { - background: "#09111d", - land: "#132339", - water: "#102945", - marine: "rgba(16, 34, 56, 0.42)", - terrain: "rgba(94, 118, 148, 0.12)", - roads: "rgba(147, 180, 226, 0.34)", - coast: "rgba(170, 203, 255, 0.56)", - admin1: "rgba(125, 155, 198, 0.22)", - rivers: "rgba(122, 174, 240, 0.36)", - borders: "rgba(160, 194, 255, 0.82)", siteStroke: "rgba(244, 248, 255, 0.68)", apStroke: "rgba(244, 248, 255, 0.54)", - popupBg: "rgba(11, 18, 30, 0.96)", - popupBorder: "rgba(148, 163, 184, 0.3)", + link: "rgba(15, 23, 42, 0.35)", }; } return { - background: "#d8e5f1", - land: "#eef4fb", - water: "#ccdeee", - marine: "rgba(194, 214, 234, 0.46)", - terrain: "rgba(118, 136, 158, 0.08)", - roads: "rgba(109, 135, 171, 0.28)", - coast: "rgba(71, 101, 141, 0.44)", - admin1: "rgba(77, 102, 135, 0.18)", - rivers: "rgba(83, 128, 185, 0.30)", - borders: "rgba(61, 90, 130, 0.72)", siteStroke: "rgba(15, 23, 42, 0.36)", apStroke: "rgba(15, 23, 42, 0.28)", - popupBg: "rgba(255, 255, 255, 0.96)", - popupBorder: "rgba(15, 23, 42, 0.12)", + link: "rgba(15, 23, 42, 0.35)", }; } -function buildMapStyle() { - const palette = countryPalette(); +function buildOsmRasterStyle() { return { version: 8, - sources: {}, + sources: { + [OSM_RASTER_SOURCE_ID]: { + type: "raster", + tiles: [TILE_URL_TEMPLATE], + tileSize: 256, + attribution: TILE_ATTRIBUTION, + maxzoom: TILE_MAX_ZOOM, + }, + }, layers: [ { id: "site-map-background", type: "background", paint: { - "background-color": palette.background, + "background-color": isDarkMode() ? "#0b1220" : "#ffffff", }, }, + { + id: OSM_RASTER_LAYER_ID, + type: "raster", + source: OSM_RASTER_SOURCE_ID, + minzoom: 0, + maxzoom: TILE_MAX_ZOOM, + }, ], }; } -function qooClusterColorExpression() { - return [ - "case", - [">", ["get", "qooCount"], 0], - [ - "step", - ["/", ["get", "qooSum"], ["get", "qooCount"]], - "#d94b5b", - 45, "#f5b84f", - 75, "#6ecc84", - 90, "#4ca4ff", - ], - "#8893a5", - ]; +function normalizeBboxResponse(data) { + if (Array.isArray(data) && data.length >= 3) { + const [lat, lon, zoom] = data; + return { lat: Number(lat), lon: Number(lon), zoom: Number(zoom) }; + } + if (data && typeof data === "object") { + const lat = Number(data.lat ?? data.latitude); + const lon = Number(data.lon ?? data.lng ?? data.longitude); + const zoom = Number(data.zoom); + return { lat, lon, zoom }; + } + return null; } -function rttClusterColorExpression() { - return [ - "case", - [">", ["get", "rttCount"], 0], - [ - "step", - ["/", ["get", "rttSum"], ["get", "rttCount"]], - "#61d26f", - 40, "#d7d86b", - 90, "#f0a44d", - 160, "#d94b5b", - ], - "#8893a5", - ]; -} +async function requestOsmCenterFromBbox(siteLatLonPairs, timeoutMs = 2500) { + if (!Array.isArray(siteLatLonPairs) || siteLatLonPairs.length === 0) { + return null; + } -function clusterRadiusExpression() { - return [ - "step", - ["get", "throughputSum"], - 18, - 5_000_000, 22, - 25_000_000, 28, - 100_000_000, 36, - 500_000_000, 46, - 1_000_000_000, 56, - ]; + const startedAt = Date.now(); + const urls = [TILE_BBOX_URL, TILE_BBOX_URL_FALLBACK] + .filter((url) => typeof url === "string" && url.length > 0); + + let lastError = null; + // Try the documented URL first, but fall back to the tile domain if DNS/method mismatches occur. + for (const url of urls) { + const elapsed = Date.now() - startedAt; + const remainingMs = Math.max(250, timeoutMs - elapsed); + + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), remainingMs); + + try { + const resp = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Bearer: TILE_BBOX_BEARER_VALUE, + }, + body: JSON.stringify(siteLatLonPairs), + signal: controller.signal, + }); + if (!resp.ok) { + throw new Error(`bbox request failed: ${resp.status}`); + } + const json = await resp.json(); + const normalized = normalizeBboxResponse(json); + if (!normalized + || !Number.isFinite(normalized.lat) + || !Number.isFinite(normalized.lon) + || !Number.isFinite(normalized.zoom)) { + throw new Error("bbox returned invalid center"); + } + return normalized; + } catch (err) { + lastError = err; + } finally { + window.clearTimeout(timeoutId); + } + } + + console.warn("Site map bbox request failed; falling back to local fit.", lastError); + return null; } class SiteMapPage { @@ -245,9 +238,8 @@ class SiteMapPage { this.lastUpdateAt = 0; this.latestRender = null; this.unmappedOpen = false; - this.heavyLayersInstalled = false; - this.heavyLayersInstallQueued = false; - this.heavyLayersFallbackTimer = null; + this.mapInitPromise = null; + this.siteLabelMarkers = new Map(); this.canvas = document.getElementById("siteMapCanvas"); this.statusChip = document.getElementById("siteMapStatusChip"); @@ -268,7 +260,6 @@ class SiteMapPage { init() { this.bindControls(); this.refreshLegend(); - this.initMap(); this.requestInitialTree(); this.startUpdatedClock(); this.observeThemeChanges(); @@ -304,14 +295,15 @@ class SiteMapPage { }); } - initMap() { + initMap(center = FALLBACK_CENTER, zoom = FALLBACK_ZOOM) { this.map = new window.maplibregl.Map({ container: this.canvas, - style: buildMapStyle(), - center: INITIAL_CENTER, - zoom: INITIAL_ZOOM, + style: buildOsmRasterStyle(), + center, + zoom, + maxZoom: TILE_MAX_ZOOM, attributionControl: false, - customAttribution: "Natural Earth", + customAttribution: "LibreQoS Insight tile cache", }); this.map.addControl(new window.maplibregl.NavigationControl({ visualizePitch: false }), "bottom-left"); this.map.addControl(new window.maplibregl.AttributionControl({ compact: true }), "bottom-left"); @@ -325,11 +317,10 @@ class SiteMapPage { }); this.map.on("load", () => { - this.installPointSourcesAndLayers(); + this.installSourcesAndLayers(); this.installInteractions(); this.applyTheme(); this.renderFromHistory(); - this.queueHeavyLayersInstall(); }); } @@ -348,43 +339,10 @@ class SiteMapPage { if (!this.map || !this.map.isStyleLoaded()) { return; } - const palette = countryPalette(); - this.map.setPaintProperty("site-map-background", "background-color", palette.background); - if (this.map.getLayer(COUNTRY_FILL_LAYER_ID)) { - this.map.setPaintProperty(COUNTRY_FILL_LAYER_ID, "fill-color", palette.land); - } - if (this.map.getLayer(MARINE_AREAS_FILL_LAYER_ID)) { - this.map.setPaintProperty(MARINE_AREAS_FILL_LAYER_ID, "fill-color", palette.marine); - } - if (this.map.getLayer(PHYSICAL_REGIONS_FILL_LAYER_ID)) { - this.map.setPaintProperty(PHYSICAL_REGIONS_FILL_LAYER_ID, "fill-color", palette.terrain); - } - if (this.map.getLayer(PHYSICAL_REGIONS_LINE_LAYER_ID)) { - this.map.setPaintProperty(PHYSICAL_REGIONS_LINE_LAYER_ID, "line-color", palette.terrain); - } - if (this.map.getLayer(PHYSICAL_REGIONS_10M_FILL_LAYER_ID)) { - this.map.setPaintProperty(PHYSICAL_REGIONS_10M_FILL_LAYER_ID, "fill-color", palette.terrain); - } - if (this.map.getLayer(PHYSICAL_REGIONS_10M_LINE_LAYER_ID)) { - this.map.setPaintProperty(PHYSICAL_REGIONS_10M_LINE_LAYER_ID, "line-color", palette.terrain); - } - if (this.map.getLayer(MAJOR_ROADS_10M_LAYER_ID)) { - this.map.setPaintProperty(MAJOR_ROADS_10M_LAYER_ID, "line-color", palette.roads); - } - if (this.map.getLayer(LAKES_FILL_LAYER_ID)) { - this.map.setPaintProperty(LAKES_FILL_LAYER_ID, "fill-color", palette.water); - } - if (this.map.getLayer(RIVERS_LINE_LAYER_ID)) { - this.map.setPaintProperty(RIVERS_LINE_LAYER_ID, "line-color", palette.rivers); - } - if (this.map.getLayer(ADMIN1_LINE_LAYER_ID)) { - this.map.setPaintProperty(ADMIN1_LINE_LAYER_ID, "line-color", palette.admin1); - } - if (this.map.getLayer(COUNTRY_LINE_LAYER_ID)) { - this.map.setPaintProperty(COUNTRY_LINE_LAYER_ID, "line-color", palette.borders); - } - if (this.map.getLayer(COASTLINE_LAYER_ID)) { - this.map.setPaintProperty(COASTLINE_LAYER_ID, "line-color", palette.coast); + const palette = markerPalette(); + this.map.setPaintProperty("site-map-background", "background-color", isDarkMode() ? "#0b1220" : "#ffffff"); + if (this.map.getLayer(SITE_LINK_LAYER_ID)) { + this.map.setPaintProperty(SITE_LINK_LAYER_ID, "line-color", palette.link); } if (this.map.getLayer(SITE_POINTS_LAYER_ID)) { this.map.setPaintProperty(SITE_POINTS_LAYER_ID, "circle-stroke-color", palette.siteStroke); @@ -394,430 +352,87 @@ class SiteMapPage { } } - installPointSourcesAndLayers() { + installSourcesAndLayers() { + this.map.addSource(SITE_LINK_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); this.map.addSource(SITE_SOURCE_ID, { type: "geojson", data: { type: "FeatureCollection", features: [] }, - cluster: true, - clusterRadius: 42, - clusterMaxZoom: 7, - clusterProperties: { - throughputSum: ["+", ["get", "throughputCombined"]], - qooSum: ["+", ["coalesce", ["get", "qooWorst"], 0]], - qooCount: ["+", ["case", ["has", "qooWorst"], 1, 0]], - rttSum: ["+", ["coalesce", ["get", "rttWorst"], 0]], - rttCount: ["+", ["case", ["has", "rttWorst"], 1, 0]], - }, }); this.map.addSource(AP_SOURCE_ID, { type: "geojson", data: { type: "FeatureCollection", features: [] }, - cluster: true, - clusterRadius: 40, - clusterMaxZoom: 11, - clusterProperties: { - throughputSum: ["+", ["get", "throughputCombined"]], - qooSum: ["+", ["coalesce", ["get", "qooWorst"], 0]], - qooCount: ["+", ["case", ["has", "qooWorst"], 1, 0]], - rttSum: ["+", ["coalesce", ["get", "rttWorst"], 0]], - rttCount: ["+", ["case", ["has", "rttWorst"], 1, 0]], - }, }); this.map.addLayer({ - id: SITE_CLUSTER_LAYER_ID, - type: "circle", - source: SITE_SOURCE_ID, - filter: ["has", "point_count"], + id: SITE_LINK_LAYER_ID, + type: "line", + source: SITE_LINK_SOURCE_ID, + layout: { + "line-join": "round", + "line-cap": "round", + }, paint: { - "circle-color": qooClusterColorExpression(), - "circle-radius": clusterRadiusExpression(), - "circle-opacity": 0.88, - "circle-stroke-color": countryPalette().siteStroke, - "circle-stroke-width": 1.1, + "line-color": markerPalette().link, + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 2, 0.6, + 6, 1.2, + 10, 2.0, + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 2, 0.25, + 6, 0.45, + 10, 0.6, + ], }, }); + this.map.addLayer({ id: SITE_POINTS_LAYER_ID, type: "circle", source: SITE_SOURCE_ID, - filter: ["!", ["has", "point_count"]], paint: { "circle-color": ["get", "metricColor"], "circle-radius": ["get", "markerRadius"], "circle-opacity": [ "interpolate", ["linear"], ["zoom"], - 0, 0.92, - 5, 0.62, - 8, 0.18, + 0, 0.86, + 6, 0.76, + 10, 0.62, ], - "circle-stroke-color": countryPalette().siteStroke, - "circle-stroke-width": 1.0, - "circle-blur": 0.08, + "circle-stroke-color": markerPalette().siteStroke, + "circle-stroke-width": 1.15, + "circle-blur": 0.06, }, }); - this.map.addLayer({ - id: AP_CLUSTER_LAYER_ID, - type: "circle", - source: AP_SOURCE_ID, - filter: ["has", "point_count"], - paint: { - "circle-color": qooClusterColorExpression(), - "circle-radius": clusterRadiusExpression(), - "circle-opacity": [ - "interpolate", ["linear"], ["zoom"], - 0, 0.18, - 4, 0.44, - 6, 0.82, - 8, 0.9, - ], - "circle-stroke-color": countryPalette().apStroke, - "circle-stroke-width": 1.0, - }, - }); this.map.addLayer({ id: AP_POINTS_LAYER_ID, type: "circle", source: AP_SOURCE_ID, - filter: ["!", ["has", "point_count"]], paint: { "circle-color": ["get", "metricColor"], "circle-radius": ["get", "markerRadius"], "circle-opacity": [ "interpolate", ["linear"], ["zoom"], - 0, 0.06, - 4, 0.18, - 6, 0.76, - 8, 0.96, + 0, 0.0, + 5, 0.08, + 7, 0.55, + 9, 0.86, ], - "circle-stroke-color": countryPalette().apStroke, + "circle-stroke-color": markerPalette().apStroke, "circle-stroke-width": 1.0, "circle-blur": 0.05, }, }); } - installHeavyGeographyLayers() { - if (this.heavyLayersInstalled || !this.map?.isStyleLoaded()) { - return; - } - - const addLayerBelowPoints = (layer) => this.map.addLayer(layer, SITE_CLUSTER_LAYER_ID); - - this.map.addSource(COUNTRIES_SOURCE_ID, { - type: "geojson", - data: COUNTRIES_GEOJSON_PATH, - }); - this.map.addSource(MARINE_AREAS_SOURCE_ID, { - type: "geojson", - data: MARINE_AREAS_GEOJSON_PATH, - }); - this.map.addSource(PHYSICAL_REGIONS_SOURCE_ID, { - type: "geojson", - data: PHYSICAL_REGIONS_GEOJSON_PATH, - }); - this.map.addSource(PHYSICAL_REGIONS_10M_SOURCE_ID, { - type: "geojson", - data: PHYSICAL_REGIONS_10M_GEOJSON_PATH, - }); - this.map.addSource(MAJOR_ROADS_10M_SOURCE_ID, { - type: "geojson", - data: MAJOR_ROADS_10M_GEOJSON_PATH, - }); - this.map.addSource(LAKES_SOURCE_ID, { - type: "geojson", - data: LAKES_GEOJSON_PATH, - }); - this.map.addSource(RIVERS_SOURCE_ID, { - type: "geojson", - data: RIVERS_GEOJSON_PATH, - }); - this.map.addSource(ADMIN1_SOURCE_ID, { - type: "geojson", - data: ADMIN1_BOUNDARIES_GEOJSON_PATH, - }); - this.map.addSource(COASTLINE_SOURCE_ID, { - type: "geojson", - data: COASTLINES_GEOJSON_PATH, - }); - - addLayerBelowPoints({ - id: MARINE_AREAS_FILL_LAYER_ID, - type: "fill", - source: MARINE_AREAS_SOURCE_ID, - paint: { - "fill-color": countryPalette().marine, - "fill-opacity": 0.9, - }, - }); - addLayerBelowPoints({ - id: COUNTRY_FILL_LAYER_ID, - type: "fill", - source: COUNTRIES_SOURCE_ID, - paint: { - "fill-color": countryPalette().land, - "fill-opacity": isDarkMode() ? 0.8 : 0.76, - }, - }); - addLayerBelowPoints({ - id: PHYSICAL_REGIONS_FILL_LAYER_ID, - type: "fill", - source: PHYSICAL_REGIONS_SOURCE_ID, - paint: { - "fill-color": countryPalette().terrain, - "fill-opacity": [ - "interpolate", ["linear"], ["zoom"], - 0, - [ - "match", ["get", "featurecla"], - "mountain range", isDarkMode() ? 0.22 : 0.15, - "mountain", isDarkMode() ? 0.18 : 0.12, - "plateau", isDarkMode() ? 0.12 : 0.08, - "basin", isDarkMode() ? 0.09 : 0.06, - "plain", isDarkMode() ? 0.06 : 0.04, - "desert", isDarkMode() ? 0.08 : 0.05, - isDarkMode() ? 0.08 : 0.05, - ], - 4.5, - [ - "match", ["get", "featurecla"], - "mountain range", isDarkMode() ? 0.18 : 0.11, - "mountain", isDarkMode() ? 0.15 : 0.09, - "plateau", isDarkMode() ? 0.08 : 0.05, - "basin", isDarkMode() ? 0.06 : 0.04, - "plain", isDarkMode() ? 0.04 : 0.02, - "desert", isDarkMode() ? 0.05 : 0.03, - isDarkMode() ? 0.05 : 0.03, - ], - 6, - 0, - ], - }, - }); - addLayerBelowPoints({ - id: PHYSICAL_REGIONS_LINE_LAYER_ID, - type: "line", - source: PHYSICAL_REGIONS_SOURCE_ID, - paint: { - "line-color": countryPalette().terrain, - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 2, 0.18, - 5, 0.3, - 8, 0.46, - ], - "line-opacity": [ - "interpolate", ["linear"], ["zoom"], - 0, 0.05, - 4.5, 0.08, - 6, 0.03, - 7, 0, - ], - }, - }); - addLayerBelowPoints({ - id: PHYSICAL_REGIONS_10M_FILL_LAYER_ID, - type: "fill", - source: PHYSICAL_REGIONS_10M_SOURCE_ID, - minzoom: 4.5, - paint: { - "fill-color": countryPalette().terrain, - "fill-opacity": [ - "interpolate", ["linear"], ["zoom"], - 4.5, - 0, - 5.5, - [ - "match", ["get", "featurecla"], - "mountain range", isDarkMode() ? 0.24 : 0.16, - "mountain", isDarkMode() ? 0.2 : 0.13, - "plateau", isDarkMode() ? 0.12 : 0.08, - "basin", isDarkMode() ? 0.09 : 0.06, - "plain", isDarkMode() ? 0.05 : 0.03, - "desert", isDarkMode() ? 0.07 : 0.05, - isDarkMode() ? 0.07 : 0.05, - ], - 8, - [ - "match", ["get", "featurecla"], - "mountain range", isDarkMode() ? 0.28 : 0.19, - "mountain", isDarkMode() ? 0.24 : 0.16, - "plateau", isDarkMode() ? 0.15 : 0.1, - "basin", isDarkMode() ? 0.11 : 0.08, - "plain", isDarkMode() ? 0.07 : 0.05, - "desert", isDarkMode() ? 0.09 : 0.06, - isDarkMode() ? 0.09 : 0.06, - ], - ], - }, - }); - addLayerBelowPoints({ - id: PHYSICAL_REGIONS_10M_LINE_LAYER_ID, - type: "line", - source: PHYSICAL_REGIONS_10M_SOURCE_ID, - minzoom: 4.5, - paint: { - "line-color": countryPalette().terrain, - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 4.5, 0.18, - 6, 0.34, - 8, 0.5, - ], - "line-opacity": [ - "interpolate", ["linear"], ["zoom"], - 4.5, 0, - 5.5, 0.05, - 8, 0.1, - ], - }, - }); - addLayerBelowPoints({ - id: MAJOR_ROADS_10M_LAYER_ID, - type: "line", - source: MAJOR_ROADS_10M_SOURCE_ID, - minzoom: 5.5, - paint: { - "line-color": countryPalette().roads, - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 5.5, 0.3, - 7, 0.55, - 9, 0.95, - ], - "line-opacity": [ - "interpolate", ["linear"], ["zoom"], - 5.5, 0, - 6.5, isDarkMode() ? 0.18 : 0.14, - 9, isDarkMode() ? 0.28 : 0.22, - ], - }, - }); - this.map.setPaintProperty(MAJOR_ROADS_10M_LAYER_ID, "line-opacity", [ - "interpolate", ["linear"], ["zoom"], - 0, 0, - 4.5, isDarkMode() ? 0.1 : 0.08, - 5.5, isDarkMode() ? 0.14 : 0.1, - 6.25, 0, - ]); - addLayerBelowPoints({ - id: LAKES_FILL_LAYER_ID, - type: "fill", - source: LAKES_SOURCE_ID, - paint: { - "fill-color": countryPalette().water, - "fill-opacity": 0.92, - }, - }); - addLayerBelowPoints({ - id: RIVERS_LINE_LAYER_ID, - type: "line", - source: RIVERS_SOURCE_ID, - paint: { - "line-color": countryPalette().rivers, - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 2, 0.22, - 5, 0.4, - 8, 0.62, - ], - "line-opacity": [ - "interpolate", ["linear"], ["zoom"], - 2, 0.42, - 5, 0.34, - 8, 0.28, - ], - }, - }); - addLayerBelowPoints({ - id: ADMIN1_LINE_LAYER_ID, - type: "line", - source: ADMIN1_SOURCE_ID, - paint: { - "line-color": countryPalette().admin1, - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 2, 0.34, - 5, 0.58, - 8, 0.95, - ], - "line-opacity": [ - "interpolate", ["linear"], ["zoom"], - 0, 0.3, - 3, 0.46, - 5.5, 0.6, - 7, 0.52, - 9, 0.42, - ], - }, - }); - addLayerBelowPoints({ - id: COUNTRY_LINE_LAYER_ID, - type: "line", - source: COUNTRIES_SOURCE_ID, - paint: { - "line-color": countryPalette().borders, - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 2, 0.5, - 5, 0.8, - 8, 1.2, - ], - "line-opacity": 0.9, - }, - }); - addLayerBelowPoints({ - id: COASTLINE_LAYER_ID, - type: "line", - source: COASTLINE_SOURCE_ID, - paint: { - "line-color": countryPalette().coast, - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 2, 0.45, - 5, 0.8, - 8, 1.25, - ], - "line-opacity": 0.82, - }, - }); - - this.heavyLayersInstalled = true; - this.applyTheme(); - } - - queueHeavyLayersInstall() { - if (this.heavyLayersInstalled || this.heavyLayersInstallQueued) { - return; - } - this.heavyLayersInstallQueued = true; - const install = () => { - if (this.heavyLayersFallbackTimer !== null) { - window.clearTimeout(this.heavyLayersFallbackTimer); - this.heavyLayersFallbackTimer = null; - } - this.heavyLayersInstallQueued = false; - this.installHeavyGeographyLayers(); - }; - const installWhenReady = () => { - if (this.heavyLayersInstalled) { - return; - } - if (this.map?.isStyleLoaded()) { - install(); - return; - } - this.map?.once("idle", install); - }; - - this.map?.once("idle", installWhenReady); - this.heavyLayersFallbackTimer = window.setTimeout(installWhenReady, 1200); - } - installInteractions() { const pointLayers = [SITE_POINTS_LAYER_ID, AP_POINTS_LAYER_ID]; - const clusterLayers = [SITE_CLUSTER_LAYER_ID, AP_CLUSTER_LAYER_ID]; pointLayers.forEach((layerId) => { this.map.on("mouseenter", layerId, () => { @@ -842,47 +457,6 @@ class SiteMapPage { this.renderDetails(feature.properties); }); }); - - clusterLayers.forEach((layerId) => { - this.map.on("mouseenter", layerId, () => { - this.map.getCanvas().style.cursor = "pointer"; - }); - this.map.on("mouseleave", layerId, () => { - this.map.getCanvas().style.cursor = ""; - this.popup.remove(); - }); - this.map.on("mousemove", layerId, (event) => { - const feature = event.features?.[0]; - if (!feature) return; - const props = feature.properties || {}; - const count = toNumber(props.point_count, 0); - const typeLabel = layerId === SITE_CLUSTER_LAYER_ID ? "Site" : "AP"; - this.popup - .setLngLat(event.lngLat) - .setHTML(` -
-
${typeLabel} cluster
-
${count} grouped nodes at this zoom level.
-
`) - .addTo(this.map); - }); - this.map.on("click", layerId, async (event) => { - const feature = event.features?.[0]; - if (!feature) return; - const sourceId = layerId === SITE_CLUSTER_LAYER_ID ? SITE_SOURCE_ID : AP_SOURCE_ID; - const clusterId = feature.properties?.cluster_id; - const source = this.map.getSource(sourceId); - if (!source || clusterId === undefined || clusterId === null) return; - source.getClusterExpansionZoom(clusterId, (err, zoom) => { - if (err) return; - this.map.easeTo({ - center: feature.geometry.coordinates, - zoom, - duration: 400, - }); - }); - }); - }); } requestInitialTree() { @@ -910,9 +484,36 @@ class SiteMapPage { this.latestSnapshot = data; this.lastUpdateAt = Date.now(); this.setStatus("Live", "success"); + this.ensureMapInitialized(); this.renderFromHistory(); } + ensureMapInitialized() { + if (this.map || this.mapInitPromise) { + return; + } + const siteLatLonPairs = this.latestSnapshot + .filter((entry) => Array.isArray(entry) && entry.length >= 2) + .map(([, node]) => node) + .filter((node) => asNodeType(node) === "site") + .map((node) => { + const lat = Number(node.latitude); + const lon = Number(node.longitude); + return Number.isFinite(lat) && Number.isFinite(lon) ? [lat, lon] : null; + }) + .filter((pair) => Array.isArray(pair)); + + this.mapInitPromise = (async () => { + const center = await requestOsmCenterFromBbox(siteLatLonPairs, 2500); + if (center) { + this.hasFitOnce = true; + this.initMap([center.lon, center.lat], center.zoom); + return; + } + this.initMap(FALLBACK_CENTER, FALLBACK_ZOOM); + })(); + } + buildAggregates() { if (!this.history.length) { return null; @@ -1087,11 +688,44 @@ class SiteMapPage { features.push(feature); }); + const siteLinkFeatures = []; + byIndex.forEach((node) => { + if (node.type !== "site") { + return; + } + if (node.latitude === null || node.longitude === null) { + return; + } + if (node.immediateParent === null || node.immediateParent === undefined) { + return; + } + const parent = byIndex.get(node.immediateParent); + if (!parent || parent.type !== "site" || parent.latitude === null || parent.longitude === null) { + return; + } + siteLinkFeatures.push({ + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + [node.longitude, node.latitude], + [parent.longitude, parent.latitude], + ], + }, + properties: { + key: `${node.key}:${parent.key}`, + fromName: node.name, + toName: parent.name, + }, + }); + }); + const siteFeatures = features.filter((feature) => feature.properties.nodeType === "site"); const apFeatures = features.filter((feature) => feature.properties.nodeType === "ap"); return { siteFeatures, apFeatures, + siteLinkFeatures, unmappedSites, unmappedAps, maxBitsPerSecond, @@ -1118,7 +752,8 @@ class SiteMapPage { updateSources(aggregate) { const siteSource = this.map.getSource(SITE_SOURCE_ID); const apSource = this.map.getSource(AP_SOURCE_ID); - if (!siteSource || !apSource) { + const linkSource = this.map.getSource(SITE_LINK_SOURCE_ID); + if (!siteSource || !apSource || !linkSource) { return; } siteSource.setData({ @@ -1129,21 +764,84 @@ class SiteMapPage { type: "FeatureCollection", features: aggregate.apFeatures, }); + linkSource.setData({ + type: "FeatureCollection", + features: aggregate.siteLinkFeatures ?? [], + }); const sparseApCoverage = aggregate.apFeatures.length < 24; const siteOpacity = sparseApCoverage - ? ["interpolate", ["linear"], ["zoom"], 0, 0.92, 5, 0.7, 8, 0.42] - : ["interpolate", ["linear"], ["zoom"], 0, 0.92, 5, 0.54, 7, 0.12, 8, 0.02]; - const siteClusterOpacity = sparseApCoverage - ? ["interpolate", ["linear"], ["zoom"], 0, 0.9, 5, 0.76, 8, 0.38] - : ["interpolate", ["linear"], ["zoom"], 0, 0.9, 5, 0.6, 7, 0.2, 8, 0.06]; + ? ["interpolate", ["linear"], ["zoom"], 0, 0.86, 6, 0.76, 10, 0.62] + : ["interpolate", ["linear"], ["zoom"], 0, 0.86, 6, 0.72, 8, 0.28, 10, 0.12]; + const apOpacity = sparseApCoverage + ? ["interpolate", ["linear"], ["zoom"], 0, 0.02, 6, 0.22, 8, 0.78, 10, 0.92] + : ["interpolate", ["linear"], ["zoom"], 0, 0.0, 6, 0.18, 8, 0.82, 10, 0.96]; this.map.setPaintProperty(SITE_POINTS_LAYER_ID, "circle-opacity", siteOpacity); - this.map.setPaintProperty(SITE_CLUSTER_LAYER_ID, "circle-opacity", siteClusterOpacity); + this.map.setPaintProperty(AP_POINTS_LAYER_ID, "circle-opacity", apOpacity); - const clusterColor = this.mode === "qoo" ? qooClusterColorExpression() : rttClusterColorExpression(); - this.map.setPaintProperty(SITE_CLUSTER_LAYER_ID, "circle-color", clusterColor); - this.map.setPaintProperty(AP_CLUSTER_LAYER_ID, "circle-color", clusterColor); + this.syncSiteLabels(aggregate.siteFeatures); + } + + syncSiteLabels(siteFeatures) { + if (!this.map) { + return; + } + + const wanted = new Set(); + siteFeatures.forEach((feature) => { + const key = feature?.properties?.key; + const name = feature?.properties?.name; + const metricColor = feature?.properties?.metricColor; + const coordinates = feature?.geometry?.coordinates; + if (!key || !name || !Array.isArray(coordinates) || coordinates.length < 2) { + return; + } + wanted.add(key); + + const existing = this.siteLabelMarkers.get(key); + if (!existing) { + const el = document.createElement("div"); + el.className = "site-map-site-label"; + el.innerHTML = ``; + const dot = el.querySelector(".site-map-site-label-dot"); + const text = el.querySelector(".site-map-site-label-text"); + if (text) { + text.textContent = name; + } + if (dot && metricColor) { + dot.style.backgroundColor = metricColor; + dot.style.opacity = "0.82"; + } + + const marker = new window.maplibregl.Marker({ + element: el, + anchor: "bottom", + offset: [0, -14], + }) + .setLngLat(coordinates) + .addTo(this.map); + + this.siteLabelMarkers.set(key, { marker, dot, text }); + return; + } + + existing.marker.setLngLat(coordinates); + if (existing.text) { + existing.text.textContent = name; + } + if (existing.dot && metricColor) { + existing.dot.style.backgroundColor = metricColor; + } + }); + + Array.from(this.siteLabelMarkers.entries()).forEach(([key, value]) => { + if (wanted.has(key)) { + return; + } + value.marker.remove(); + this.siteLabelMarkers.delete(key); + }); } fitToData(siteFeatures, apFeatures) { diff --git a/src/rust/lqosd/src/node_manager/static2/site_map.html b/src/rust/lqosd/src/node_manager/static2/site_map.html index e677b001..f1743a23 100644 --- a/src/rust/lqosd/src/node_manager/static2/site_map.html +++ b/src/rust/lqosd/src/node_manager/static2/site_map.html @@ -247,6 +247,42 @@ min-width: 220px; } +.site-map-site-label { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px 3px 7px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 72%, transparent); + background: color-mix(in srgb, var(--lqos-surface-solid) 86%, transparent); + box-shadow: var(--lqos-shadow-sm); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + font-size: 0.8rem; + font-weight: 700; + line-height: 1.1; + white-space: nowrap; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + user-select: none; +} + +.site-map-site-label-dot { + width: 10px; + height: 10px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.55); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.22); + flex: 0 0 auto; +} + +.site-map-site-label-text { + overflow: hidden; + text-overflow: ellipsis; +} + .maplibregl-popup-content { border-radius: 14px; border: 1px solid var(--lqos-border); From 87004f5e0620d51b8a9e89e61e6617f26e243988 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 21 Mar 2026 11:38:18 -0500 Subject: [PATCH 012/102] Fix: strict Insight bbox + site links --- .../src/node_manager/js_build/src/site_map.js | 147 +++++++++++------- 1 file changed, 95 insertions(+), 52 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 476f3569..0b5915d8 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -9,12 +9,11 @@ const wsClient = get_ws_client(); const INITIAL_REQUEST_TIMEOUT_MS = 2500; const HISTORY_WINDOW_MS = 30_000; -const TILE_BBOX_URL = "https://insight.librqos.com/tiles/api/bbox"; -const TILE_BBOX_URL_FALLBACK = "https://insight.libreqos.com/tiles/api/bbox"; +const TILE_BBOX_URL = "https://insight.libreqos.com/tiles/api/bbox"; const TILE_BBOX_BEARER_VALUE = "LibreQoSRocks"; const TILE_URL_TEMPLATE = "https://insight.libreqos.com/tiles/{z}/{y}/{x}.png?key=LibreQosRocks"; const TILE_ATTRIBUTION = "© OpenStreetMap contributors"; -const TILE_MAX_ZOOM = 19; +const TILE_MAX_ZOOM = 17; const FALLBACK_CENTER = [-101.5, 39.8]; const FALLBACK_ZOOM = 3.15; @@ -114,6 +113,39 @@ function asNodeType(node) { return String(node?.type || node?.node_type || "").toLowerCase(); } +function immediateParentIndex(node) { + const raw = node?.immediate_parent ?? node?.immediateParent; + if (raw === null || raw === undefined) { + return null; + } + const numeric = Number(raw); + return Number.isFinite(numeric) ? numeric : null; +} + +function findNearestAncestorSiteIndex(indexMap, startIndex) { + let currentIndex = startIndex; + const visited = new Set(); + while (currentIndex !== null && currentIndex !== undefined) { + const numeric = Number(currentIndex); + if (!Number.isFinite(numeric)) { + return null; + } + if (visited.has(numeric)) { + return null; + } + visited.add(numeric); + const node = indexMap.get(numeric); + if (!node) { + return null; + } + if (asNodeType(node) === "site") { + return numeric; + } + currentIndex = immediateParentIndex(node); + } + return null; +} + function markerPalette() { if (isDarkMode()) { return { @@ -161,6 +193,13 @@ function buildOsmRasterStyle() { } function normalizeBboxResponse(data) { + // Newer/expected shape (see osm_cache): { center: { lat, lon }, zoom } + if (data && typeof data === "object" && data.center && typeof data.center === "object") { + const lat = Number(data.center.lat ?? data.center.latitude); + const lon = Number(data.center.lon ?? data.center.lng ?? data.center.longitude); + const zoom = Number(data.zoom); + return { lat, lon, zoom }; + } if (Array.isArray(data) && data.length >= 3) { const [lat, lon, zoom] = data; return { lat: Number(lat), lon: Number(lon), zoom: Number(zoom) }; @@ -176,53 +215,37 @@ function normalizeBboxResponse(data) { async function requestOsmCenterFromBbox(siteLatLonPairs, timeoutMs = 2500) { if (!Array.isArray(siteLatLonPairs) || siteLatLonPairs.length === 0) { - return null; + throw new Error("No site coordinates supplied"); } - const startedAt = Date.now(); - const urls = [TILE_BBOX_URL, TILE_BBOX_URL_FALLBACK] - .filter((url) => typeof url === "string" && url.length > 0); + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); - let lastError = null; - // Try the documented URL first, but fall back to the tile domain if DNS/method mismatches occur. - for (const url of urls) { - const elapsed = Date.now() - startedAt; - const remainingMs = Math.max(250, timeoutMs - elapsed); - - const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), remainingMs); - - try { - const resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Bearer: TILE_BBOX_BEARER_VALUE, - }, - body: JSON.stringify(siteLatLonPairs), - signal: controller.signal, - }); - if (!resp.ok) { - throw new Error(`bbox request failed: ${resp.status}`); - } - const json = await resp.json(); - const normalized = normalizeBboxResponse(json); - if (!normalized - || !Number.isFinite(normalized.lat) - || !Number.isFinite(normalized.lon) - || !Number.isFinite(normalized.zoom)) { - throw new Error("bbox returned invalid center"); - } - return normalized; - } catch (err) { - lastError = err; - } finally { - window.clearTimeout(timeoutId); + try { + const resp = await fetch(TILE_BBOX_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Bearer: TILE_BBOX_BEARER_VALUE, + }, + body: JSON.stringify(siteLatLonPairs), + signal: controller.signal, + }); + if (!resp.ok) { + throw new Error(`bbox request failed: ${resp.status}`); } + const json = await resp.json(); + const normalized = normalizeBboxResponse(json); + if (!normalized + || !Number.isFinite(normalized.lat) + || !Number.isFinite(normalized.lon) + || !Number.isFinite(normalized.zoom)) { + throw new Error("bbox returned invalid center"); + } + return normalized; + } finally { + window.clearTimeout(timeoutId); } - - console.warn("Site map bbox request failed; falling back to local fit.", lastError); - return null; } class SiteMapPage { @@ -240,6 +263,7 @@ class SiteMapPage { this.unmappedOpen = false; this.mapInitPromise = null; this.siteLabelMarkers = new Map(); + this.lastBboxAttemptAt = 0; this.canvas = document.getElementById("siteMapCanvas"); this.statusChip = document.getElementById("siteMapStatusChip"); @@ -492,6 +516,11 @@ class SiteMapPage { if (this.map || this.mapInitPromise) { return; } + const now = Date.now(); + if (this.lastBboxAttemptAt && (now - this.lastBboxAttemptAt) < 15_000) { + return; + } + this.lastBboxAttemptAt = now; const siteLatLonPairs = this.latestSnapshot .filter((entry) => Array.isArray(entry) && entry.length >= 2) .map(([, node]) => node) @@ -504,13 +533,20 @@ class SiteMapPage { .filter((pair) => Array.isArray(pair)); this.mapInitPromise = (async () => { - const center = await requestOsmCenterFromBbox(siteLatLonPairs, 2500); - if (center) { + try { + if (siteLatLonPairs.length === 1) { + const [lat, lon] = siteLatLonPairs[0]; + siteLatLonPairs.push([lat + 0.0001, lon + 0.0001]); + } + const center = await requestOsmCenterFromBbox(siteLatLonPairs, 4000); this.hasFitOnce = true; this.initMap([center.lon, center.lat], center.zoom); - return; + } catch (err) { + console.error("Site map bbox request failed; map not initialized.", err); + this.setStatus("Waiting for Insight map location", "warning"); + } finally { + this.mapInitPromise = null; } - this.initMap(FALLBACK_CENTER, FALLBACK_ZOOM); })(); } @@ -689,6 +725,7 @@ class SiteMapPage { }); const siteLinkFeatures = []; + const emittedLinks = new Set(); byIndex.forEach((node) => { if (node.type !== "site") { return; @@ -696,13 +733,19 @@ class SiteMapPage { if (node.latitude === null || node.longitude === null) { return; } - if (node.immediateParent === null || node.immediateParent === undefined) { + const parentSiteIndex = findNearestAncestorSiteIndex(latestIndexMap, node.immediateParent); + if (parentSiteIndex === null) { return; } - const parent = byIndex.get(node.immediateParent); + const parent = byIndex.get(parentSiteIndex); if (!parent || parent.type !== "site" || parent.latitude === null || parent.longitude === null) { return; } + const key = `${node.key}->${parent.key}`; + if (emittedLinks.has(key)) { + return; + } + emittedLinks.add(key); siteLinkFeatures.push({ type: "Feature", geometry: { @@ -713,7 +756,7 @@ class SiteMapPage { ], }, properties: { - key: `${node.key}:${parent.key}`, + key, fromName: node.name, toName: parent.name, }, From f6eea785cfdff273943402634fed249900e7f68e Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 21 Mar 2026 13:40:06 -0500 Subject: [PATCH 013/102] Fix: Render Insight OSM tiles on site map --- .../src/node_manager/js_build/src/site_map.js | 403 ++++++++++++++---- 1 file changed, 317 insertions(+), 86 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 0b5915d8..5802798a 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -10,8 +10,14 @@ const INITIAL_REQUEST_TIMEOUT_MS = 2500; const HISTORY_WINDOW_MS = 30_000; const TILE_BBOX_URL = "https://insight.libreqos.com/tiles/api/bbox"; -const TILE_BBOX_BEARER_VALUE = "LibreQoSRocks"; -const TILE_URL_TEMPLATE = "https://insight.libreqos.com/tiles/{z}/{y}/{x}.png?key=LibreQosRocks"; +// NOTE: This key is intentionally non-secret. The remote OSM cache uses it as a lightweight gate. +// It must match both the bbox Authorization token and the tile `key=` query param. +const OSM_CACHE_KEY = "LibreQoSRocks"; +const INSIGHT_TILE_PROTOCOL = "insight"; +// Insight OSM cache tiles: `z/y/x` (no `.png` suffix). See `src/lts2/rust/osm_cache/src/http.rs`. +// The Insight tile server returns `503 + Retry-After` while it fetches missing tiles, so we route +// requests through a custom MapLibre protocol handler that retries before surfacing errors. +const TILE_URL_TEMPLATE = `${INSIGHT_TILE_PROTOCOL}://insight.libreqos.com/tiles/{z}/{y}/{x}?key=${encodeURIComponent(OSM_CACHE_KEY)}`; const TILE_ATTRIBUTION = "© OpenStreetMap contributors"; const TILE_MAX_ZOOM = 17; @@ -27,6 +33,26 @@ const SITE_POINTS_LAYER_ID = "site-map-site-points"; const AP_POINTS_LAYER_ID = "site-map-ap-points"; const SITE_LINK_LAYER_ID = "site-map-site-links-line"; +const INSIGHT_TILE_MAX_PARALLEL_FETCHES = 6; +let insightTileFetchActive = 0; +const insightTileFetchWaiters = []; + +async function withInsightTileFetchSlot(fn) { + if (insightTileFetchActive >= INSIGHT_TILE_MAX_PARALLEL_FETCHES) { + await new Promise((resolve) => insightTileFetchWaiters.push(resolve)); + } + insightTileFetchActive += 1; + try { + return await fn(); + } finally { + insightTileFetchActive = Math.max(0, insightTileFetchActive - 1); + const next = insightTileFetchWaiters.shift(); + if (next) { + next(); + } + } +} + function listenOnceWithTimeout(eventName, timeoutMs, handler, onTimeout) { let done = false; const wrapped = (msg) => { @@ -105,6 +131,14 @@ function averageOrNull(sum, count) { return count > 0 ? (sum / count) : null; } +function hasMeaningfulLatLon(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + return false; + } + // Many integrations default to (0,0) when coordinates are unknown. + return !(lat === 0 && lon === 0); +} + function stableNodeKey(index, node) { return node.id || `${index}:${node.name}`; } @@ -161,6 +195,26 @@ function markerPalette() { }; } +function linkColorExpression() { + const stops = isDarkMode() + ? [ + 0.0, "rgba(76, 164, 255, 0.40)", + 0.55, "rgba(245, 184, 79, 0.52)", + 1.0, "rgba(217, 75, 91, 0.62)", + ] + : [ + 0.0, "rgba(76, 164, 255, 0.32)", + 0.55, "rgba(245, 184, 79, 0.44)", + 1.0, "rgba(217, 75, 91, 0.54)", + ]; + return [ + "interpolate", + ["linear"], + ["coalesce", ["get", "utilizationRatio"], 0], + ...stops, + ]; +} + function buildOsmRasterStyle() { return { version: 8, @@ -192,6 +246,109 @@ function buildOsmRasterStyle() { }; } +function sleepMs(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function installInsightTileProtocolOnce() { + if (!window.maplibregl?.addProtocol) { + return; + } + if (window.__lqosInsightTileProtocolInstalled) { + return; + } + window.__lqosInsightTileProtocolInstalled = true; + + window.maplibregl.addProtocol(INSIGHT_TILE_PROTOCOL, (params, abortControllerOrCallback) => { + const url = String(params?.url ?? "").replace(`${INSIGHT_TILE_PROTOCOL}://`, "https://"); + const responseType = String(params?.type || "arrayBuffer"); + const method = params?.method || "GET"; + const body = params?.body; + + const makeHeaders = () => { + try { + return new Headers(params?.headers ?? {}); + } catch (_) { + return new Headers(); + } + }; + + const fetchWithRetries = async (signal) => { + const deadlineMs = Date.now() + 90_000; + while (Date.now() < deadlineMs) { + if (signal?.aborted) { + throw new Error("AbortError"); + } + const headers = makeHeaders(); + if (responseType === "json" && !headers.has("Accept")) { + headers.set("Accept", "application/json"); + } + const resp = await withInsightTileFetchSlot(() => fetch(url, { + method, + body, + headers, + credentials: "omit", + cache: params?.cache, + signal, + })); + + if (resp.status === 503 || resp.status === 429) { + const retryAfter = Number(resp.headers.get("retry-after")); + const baseDelayMs = Number.isFinite(retryAfter) ? (retryAfter * 1000) : 1000; + const delayMs = Math.min(Math.max(baseDelayMs, 250), 15_000) + Math.round(Math.random() * 250); + await sleepMs(delayMs); + if (signal?.aborted) { + throw new Error("AbortError"); + } + continue; + } + + if (!resp.ok) { + throw new Error(`tile request failed: ${resp.status}`); + } + + let data; + if (responseType === "json") { + data = await resp.json(); + } else if (responseType === "text") { + data = await resp.text(); + } else { + data = await resp.arrayBuffer(); + } + + if (signal?.aborted) { + throw new Error("AbortError"); + } + + return { + data, + cacheControl: resp.headers.get("cache-control") ?? undefined, + expires: resp.headers.get("expires") ?? undefined, + }; + } + throw new Error("tile request retries exhausted"); + }; + + // MapLibre's protocol handler signature differs by version: + // - newer: (params, abortController) => Promise<{data, cacheControl, expires}> + // - older: (params, callback) => { cancel() } + if (typeof abortControllerOrCallback === "function") { + const callback = abortControllerOrCallback; + const controller = new AbortController(); + fetchWithRetries(controller.signal) + .then((result) => callback(null, result.data, result.cacheControl, result.expires)) + .catch((err) => { + if (controller.signal.aborted) return; + callback(err); + }); + return { cancel: () => controller.abort() }; + } + + const signal = abortControllerOrCallback?.signal; + return fetchWithRetries(signal); + }); +} + function normalizeBboxResponse(data) { // Newer/expected shape (see osm_cache): { center: { lat, lon }, zoom } if (data && typeof data === "object" && data.center && typeof data.center === "object") { @@ -218,6 +375,25 @@ async function requestOsmCenterFromBbox(siteLatLonPairs, timeoutMs = 2500) { throw new Error("No site coordinates supplied"); } + const points = siteLatLonPairs + .map((pair) => { + if (!Array.isArray(pair) || pair.length < 2) { + return null; + } + const [lat, lon] = pair; + const latN = Number(lat); + const lonN = Number(lon); + if (!Number.isFinite(latN) || !Number.isFinite(lonN)) { + return null; + } + return { lat: latN, lon: lonN }; + }) + .filter((item) => item !== null); + + if (points.length === 0) { + throw new Error("No valid site coordinates supplied"); + } + const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); @@ -226,9 +402,9 @@ async function requestOsmCenterFromBbox(siteLatLonPairs, timeoutMs = 2500) { method: "POST", headers: { "Content-Type": "application/json", - Bearer: TILE_BBOX_BEARER_VALUE, + Authorization: `Bearer ${OSM_CACHE_KEY}`, }, - body: JSON.stringify(siteLatLonPairs), + body: JSON.stringify(points), signal: controller.signal, }); if (!resp.ok) { @@ -262,6 +438,7 @@ class SiteMapPage { this.latestRender = null; this.unmappedOpen = false; this.mapInitPromise = null; + this.mapBootstrapped = false; this.siteLabelMarkers = new Map(); this.lastBboxAttemptAt = 0; @@ -320,6 +497,10 @@ class SiteMapPage { } initMap(center = FALLBACK_CENTER, zoom = FALLBACK_ZOOM) { + installInsightTileProtocolOnce(); + if (typeof window.maplibregl?.setMaxParallelImageRequests === "function") { + window.maplibregl.setMaxParallelImageRequests(6); + } this.map = new window.maplibregl.Map({ container: this.canvas, style: buildOsmRasterStyle(), @@ -340,12 +521,30 @@ class SiteMapPage { className: "site-map-popup", }); - this.map.on("load", () => { - this.installSourcesAndLayers(); - this.installInteractions(); + const bootstrapOverlays = () => { + if (!this.map) { + return; + } + try { + this.installSourcesAndLayers(); + } catch (err) { + return; + } + if (!this.map.getSource(SITE_SOURCE_ID) || !this.map.getLayer(SITE_POINTS_LAYER_ID)) { + return; + } + if (!this.mapBootstrapped) { + this.installInteractions(); + this.mapBootstrapped = true; + } this.applyTheme(); this.renderFromHistory(); - }); + }; + + // `load` can be delayed while raster tiles are still coming in; `styledata` fires earlier. + this.map.on("load", bootstrapOverlays); + this.map.on("styledata", bootstrapOverlays); + bootstrapOverlays(); } observeThemeChanges() { @@ -366,7 +565,7 @@ class SiteMapPage { const palette = markerPalette(); this.map.setPaintProperty("site-map-background", "background-color", isDarkMode() ? "#0b1220" : "#ffffff"); if (this.map.getLayer(SITE_LINK_LAYER_ID)) { - this.map.setPaintProperty(SITE_LINK_LAYER_ID, "line-color", palette.link); + this.map.setPaintProperty(SITE_LINK_LAYER_ID, "line-color", linkColorExpression()); } if (this.map.getLayer(SITE_POINTS_LAYER_ID)) { this.map.setPaintProperty(SITE_POINTS_LAYER_ID, "circle-stroke-color", palette.siteStroke); @@ -377,82 +576,94 @@ class SiteMapPage { } installSourcesAndLayers() { - this.map.addSource(SITE_LINK_SOURCE_ID, { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }); - this.map.addSource(SITE_SOURCE_ID, { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }); - this.map.addSource(AP_SOURCE_ID, { - type: "geojson", - data: { type: "FeatureCollection", features: [] }, - }); + if (!this.map.getSource(SITE_LINK_SOURCE_ID)) { + this.map.addSource(SITE_LINK_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } + if (!this.map.getSource(SITE_SOURCE_ID)) { + this.map.addSource(SITE_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } + if (!this.map.getSource(AP_SOURCE_ID)) { + this.map.addSource(AP_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } - this.map.addLayer({ - id: SITE_LINK_LAYER_ID, - type: "line", - source: SITE_LINK_SOURCE_ID, - layout: { - "line-join": "round", - "line-cap": "round", - }, - paint: { - "line-color": markerPalette().link, - "line-width": [ - "interpolate", ["linear"], ["zoom"], - 2, 0.6, - 6, 1.2, - 10, 2.0, - ], - "line-opacity": [ - "interpolate", ["linear"], ["zoom"], - 2, 0.25, - 6, 0.45, - 10, 0.6, - ], - }, - }); + if (!this.map.getLayer(SITE_LINK_LAYER_ID)) { + this.map.addLayer({ + id: SITE_LINK_LAYER_ID, + type: "line", + source: SITE_LINK_SOURCE_ID, + layout: { + "line-join": "round", + "line-cap": "round", + }, + paint: { + "line-color": linkColorExpression(), + "line-width": [ + "interpolate", ["linear"], ["zoom"], + 2, 0.6, + 6, 1.2, + 10, 2.0, + ], + "line-opacity": [ + "interpolate", ["linear"], ["zoom"], + 2, 0.25, + 6, 0.45, + 10, 0.6, + ], + }, + }); + } - this.map.addLayer({ - id: SITE_POINTS_LAYER_ID, - type: "circle", - source: SITE_SOURCE_ID, - paint: { - "circle-color": ["get", "metricColor"], - "circle-radius": ["get", "markerRadius"], - "circle-opacity": [ - "interpolate", ["linear"], ["zoom"], - 0, 0.86, - 6, 0.76, - 10, 0.62, - ], - "circle-stroke-color": markerPalette().siteStroke, - "circle-stroke-width": 1.15, - "circle-blur": 0.06, - }, - }); + if (!this.map.getLayer(SITE_POINTS_LAYER_ID)) { + this.map.addLayer({ + id: SITE_POINTS_LAYER_ID, + type: "circle", + source: SITE_SOURCE_ID, + paint: { + "circle-color": ["get", "metricColor"], + "circle-radius": ["get", "markerRadius"], + "circle-opacity": [ + "interpolate", ["linear"], ["zoom"], + 0, 0.86, + 6, 0.76, + 10, 0.62, + ], + "circle-stroke-color": markerPalette().siteStroke, + "circle-stroke-width": 1.15, + "circle-blur": 0.06, + }, + }); + } - this.map.addLayer({ - id: AP_POINTS_LAYER_ID, - type: "circle", - source: AP_SOURCE_ID, - paint: { - "circle-color": ["get", "metricColor"], - "circle-radius": ["get", "markerRadius"], - "circle-opacity": [ - "interpolate", ["linear"], ["zoom"], - 0, 0.0, - 5, 0.08, - 7, 0.55, - 9, 0.86, - ], - "circle-stroke-color": markerPalette().apStroke, - "circle-stroke-width": 1.0, - "circle-blur": 0.05, - }, - }); + if (!this.map.getLayer(AP_POINTS_LAYER_ID)) { + this.map.addLayer({ + id: AP_POINTS_LAYER_ID, + type: "circle", + source: AP_SOURCE_ID, + paint: { + "circle-color": ["get", "metricColor"], + "circle-radius": ["get", "markerRadius"], + "circle-opacity": [ + "interpolate", ["linear"], ["zoom"], + 0, 0.0, + 5, 0.08, + 7, 0.55, + 9, 0.86, + ], + "circle-stroke-color": markerPalette().apStroke, + "circle-stroke-width": 1.0, + "circle-blur": 0.05, + }, + }); + } } installInteractions() { @@ -528,10 +739,15 @@ class SiteMapPage { .map((node) => { const lat = Number(node.latitude); const lon = Number(node.longitude); - return Number.isFinite(lat) && Number.isFinite(lon) ? [lat, lon] : null; + return hasMeaningfulLatLon(lat, lon) ? [lat, lon] : null; }) .filter((pair) => Array.isArray(pair)); + if (siteLatLonPairs.length === 0) { + this.setStatus("No mapped sites", "warning"); + return; + } + this.mapInitPromise = (async () => { try { if (siteLatLonPairs.length === 1) { @@ -646,8 +862,8 @@ class SiteMapPage { id: node.id || null, type: nodeType, immediateParent: node.immediate_parent, - latitude: Number.isFinite(node.latitude) ? node.latitude : null, - longitude: Number.isFinite(node.longitude) ? node.longitude : null, + latitude: null, + longitude: null, throughputDown: avgDown, throughputUp: avgUp, throughputCombined, @@ -660,6 +876,12 @@ class SiteMapPage { parentName: null, inheritedCoords: false, }; + const lat = Number(node.latitude); + const lon = Number(node.longitude); + if (hasMeaningfulLatLon(lat, lon)) { + normalized.latitude = lat; + normalized.longitude = lon; + } byIndex.set(value.latestIndex, normalized); }); @@ -746,6 +968,7 @@ class SiteMapPage { return; } emittedLinks.add(key); + const utilizationRatio = maxBitsPerSecond > 0 ? Math.max(0, Math.min(1, node.throughputCombined / maxBitsPerSecond)) : 0; siteLinkFeatures.push({ type: "Feature", geometry: { @@ -759,6 +982,7 @@ class SiteMapPage { key, fromName: node.name, toName: parent.name, + utilizationRatio, }, }); }); @@ -776,7 +1000,14 @@ class SiteMapPage { } renderFromHistory() { - if (!this.map || !this.map.isStyleLoaded()) { + if (!this.map) { + return; + } + // MapLibre may report the style as not-yet-loaded while raster tiles are still fetching + // (503/429). The overlays only require the GeoJSON sources/layers to exist. + if (!this.map.getSource(SITE_SOURCE_ID) + || !this.map.getSource(AP_SOURCE_ID) + || !this.map.getSource(SITE_LINK_SOURCE_ID)) { return; } const aggregate = this.buildAggregates(); From 1277b6389bfe44793b7946ee8ca499f9e9d7c86e Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Sat, 21 Mar 2026 13:02:04 -0600 Subject: [PATCH 014/102] Scale WebUI transport and add 100k test handoff --- docs-es/v2.0/configuration-es.md | 3 + docs-es/v2.0/node-manager-ui-es.md | 34 +- docs/v2.0/configuration.md | 3 + docs/v2.0/node-manager-ui.md | 27 +- src/rust/lqos_bus/src/bus/response.rs | 2 + .../src/tracking/all_queue_data.rs | 2 + .../lqos_queue_tracker/src/tracking/mod.rs | 2 + src/rust/lqosd/src/node_manager.rs | 3 +- .../js_build/src/all_tree_sankey.js | 6 +- .../node_manager/js_build/src/asn_analysis.js | 125 ++- .../src/node_manager/js_build/src/circuit.js | 295 +++++-- .../js_build/src/config/config_helper.js | 187 +++- .../js_build/src/config_devices.js | 523 ++++++----- .../js_build/src/config_network.js | 112 ++- .../js_build/src/config_treeguard.js | 45 +- .../js_build/src/configuration.js | 235 ++++- .../node_manager/js_build/src/cpu_weights.js | 17 +- .../src/dashlets/children_sankey_down.js | 27 +- .../src/dashlets/children_sankey_up.js | 27 +- .../js_build/src/dashlets/cpu_dash.js | 25 +- .../src/dashlets/executive_heatmap_panels.js | 311 ++++--- .../src/dashlets/executive_snapshot.js | 11 +- .../src/dashlets/queue_stats_total.js | 43 +- .../js_build/src/dashlets/ram_dash.js | 27 +- .../js_build/src/dashlets/rtt_histo_dash.js | 25 +- .../src/dashlets/throughput_pps_dash.js | 57 +- .../src/dashlets/throughput_ring_dash.js | 50 +- .../js_build/src/dashlets/top_asn_download.js | 25 +- .../js_build/src/dashlets/top_asn_upload.js | 25 +- .../src/dashlets/tracked_flow_count_dash.js | 54 +- .../src/dashlets/treeguard_activity.js | 50 +- .../src/dashlets/treeguard_controls.js | 100 ++- .../js_build/src/dashlets/treeguard_status.js | 105 +-- .../js_build/src/dashlets/world_map_down.js | 24 +- .../js_build/src/dashlets/world_map_up.js | 24 +- .../src/executive_circuits_due_upgrade.js | 37 +- .../src/executive_heatmap_page_template.js | 242 ++--- .../executive_leaderboard_page_template.js | 225 +++++ .../src/executive_oversubscribed_sites.js | 95 +- .../src/executive_sites_due_upgrade.js | 38 +- .../js_build/src/executive_top_asns.js | 59 +- .../js_build/src/executive_utils.js | 176 +++- .../js_build/src/executive_worst_sites.js | 55 +- .../js_build/src/graphs/cake_backlog.js | 126 +-- .../js_build/src/graphs/cake_delays.js | 124 +-- .../js_build/src/graphs/cake_drops.js | 126 +-- .../js_build/src/graphs/cake_history.js | 190 ++++ .../js_build/src/graphs/cake_marks.js | 126 +-- .../js_build/src/graphs/cake_queue_length.js | 78 +- .../js_build/src/graphs/cake_traffic.js | 153 +--- .../js_build/src/graphs/dashboard_graph.js | 144 +++ .../src/node_manager/js_build/src/index.js | 4 +- .../lq_js_common/dashboard/base_dashlet.js | 42 + .../src/lq_js_common/dashboard/dashboard.js | 575 +++++++++++- .../node_manager/js_build/src/pubsub/ws.js | 186 +++- .../js_build/src/shaped-devices.js | 199 +++-- .../src/node_manager/js_build/src/site_map.js | 16 +- .../src/node_manager/js_build/src/tree.js | 182 ++-- src/rust/lqosd/src/node_manager/local_api.rs | 7 + .../node_manager/local_api/circuit_live.rs | 50 ++ .../src/node_manager/local_api/config.rs | 360 +++++++- .../src/node_manager/local_api/directories.rs | 233 +++++ .../src/node_manager/local_api/executive.rs | 467 ++++++++++ .../node_manager/local_api/executive_cache.rs | 835 ++++++++++++++++++ .../node_manager/local_api/network_tree.rs | 1 + .../local_api/network_tree_lite.rs | 46 + .../local_api/shaped_devices_page.rs | 124 +++ .../local_api/tree_attached_circuits.rs | 213 +++++ .../src/node_manager/static2/circuit.html | 94 +- .../node_manager/static2/config_devices.html | 9 +- .../node_manager/static2/config_network.html | 4 +- .../node_manager/static2/configuration.html | 8 +- .../src/node_manager/static2/node_manager.css | 267 ++++++ src/rust/lqosd/src/node_manager/ws.rs | 192 +++- .../lqosd/src/node_manager/ws/messages.rs | 137 ++- .../src/node_manager/ws/published_channels.rs | 3 +- .../node_manager/ws/single_user_channels.rs | 58 ++ .../single_user_channels/circuit_metrics.rs | 47 + .../tree_attached_circuits.rs | 43 + src/rust/lqosd/src/node_manager/ws/ticker.rs | 11 +- .../ws/ticker/executive_dashboard_summary.rs | 22 + .../ws/ticker/executive_heatmaps.rs | 271 ------ .../ws/ticker/network_tree_lite.rs | 19 + .../shaped_devices_tracker/circuit_live.rs | 222 +++++ .../lqosd/src/shaped_devices_tracker/mod.rs | 85 ++ .../src/shaped_devices_tracker/netjson.rs | 2 + src/rust/lqosd/src/throughput_tracker/mod.rs | 1 + 87 files changed, 7342 insertions(+), 2318 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/executive_leaderboard_page_template.js create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_history.js create mode 100644 src/rust/lqosd/src/node_manager/local_api/circuit_live.rs create mode 100644 src/rust/lqosd/src/node_manager/local_api/directories.rs create mode 100644 src/rust/lqosd/src/node_manager/local_api/executive.rs create mode 100644 src/rust/lqosd/src/node_manager/local_api/executive_cache.rs create mode 100644 src/rust/lqosd/src/node_manager/local_api/network_tree_lite.rs create mode 100644 src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs create mode 100644 src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs create mode 100644 src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit_metrics.rs create mode 100644 src/rust/lqosd/src/node_manager/ws/single_user_channels/tree_attached_circuits.rs create mode 100644 src/rust/lqosd/src/node_manager/ws/ticker/executive_dashboard_summary.rs delete mode 100644 src/rust/lqosd/src/node_manager/ws/ticker/executive_heatmaps.rs create mode 100644 src/rust/lqosd/src/node_manager/ws/ticker/network_tree_lite.rs create mode 100644 src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs diff --git a/docs-es/v2.0/configuration-es.md b/docs-es/v2.0/configuration-es.md index 20f2a325..6f9e1da8 100644 --- a/docs-es/v2.0/configuration-es.md +++ b/docs-es/v2.0/configuration-es.md @@ -29,9 +29,12 @@ La mayoría de cambios operativos diarios se realizan en la WebUI (`http://tu_ip - Ajustes generales: `Configuration -> General` - Ajustes de integración: `Configuration -> Integrations` +- Editor de layout de red: `Configuration -> Network Layout` - Editor de dispositivos regulados: `Configuration -> Shaped Devices` - Validación operativa: páginas de `WebUI (Node Manager)` (dashboard/tree/flow/scheduler) +Cuando una integración está habilitada y actúa como fuente de verdad, los editores `Network Layout` y `Shaped Devices` siguen siendo visibles pero pasan a modo de solo lectura en la WebUI. + ## Modos de operación y fuente de verdad Lea esto primero antes de cambios en producción: diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 687995c3..e338f5f7 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -7,30 +7,24 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta ### Dashboard - Resumen por widgets de throughput, retransmisiones, RTT, flujos y actividad de colas. - El contenido puede variar según versión y funciones habilitadas. +- Executive Summary ofrece una vista operativa compacta para redes grandes, con páginas de detalle para heatmaps y rankings ejecutivos. +- Algunos gráficos pueden tardar un poco en poblarse al abrir una pestaña por primera vez, especialmente en sistemas ocupados o inmediatamente después de reiniciar servicios. ### Vista de árbol de red - Vista jerárquica de nodos/circuitos desde la perspectiva del shaper. - Útil para identificar cuellos de botella y patrones de utilización padre/hijo. -- Las páginas de detalle del árbol ahora muestran una ruta tipo breadcrumb hasta el nodo seleccionado, para navegar hacia abajo sin depender de volver siempre a la raíz. -- Las filas del árbol ahora incluyen resúmenes del subárbol para `Sites` descendientes y `Circuits` adjuntos/descendientes, lo que ayuda a estimar el tamaño de cada rama antes de expandirla. -- Las filas del árbol y el encabezado del nodo seleccionado pueden mostrar pequeños íconos de estado para manejo especial, incluyendo nodos virtuales y nodos administrados actualmente por StormGuard. -- Las páginas de detalle del árbol incluyen una tarjeta de `Node Details` que muestra: - - el tipo de nodo y el tamaño de la rama del nodo seleccionado - - la velocidad configurada base desde el `network.json` generado - - cualquier override de velocidad del operador almacenado en `lqos_overrides.json` - - la velocidad configurada efectiva actual -- Las páginas de detalle del árbol también incluyen una barra de contexto compacta, un panel de medidores `Node Snapshot` y un indicador de advertencia de compatibilidad junto a `Operator Override` cuando existen avisos de compatibilidad relacionados con overrides de integraciones. -- Las páginas de detalle del árbol ahora incluyen un localizador estable del nodo en la URL cuando está disponible y vuelven a resolver el nodo seleccionado ante reindexaciones en vivo del árbol, para que permanecer en una página siga el mismo nodo lógico después de regenerar `network.json`. -- Los administradores pueden guardar o limpiar overrides de velocidad del nodo desde el editor `Operator Override` cuando el nodo seleccionado es editable. Los usuarios de solo lectura y los nodos no editables siguen viendo los valores actuales en la tarjeta de detalles. -- El nodo sintético `Root` se muestra como un agregado de solo lectura y no expone el editor `Operator Override`. -- Los overrides de velocidad desde el árbol requieren un ID estable del nodo y rechazan intencionalmente nodos generados. Cuando un override ya fue guardado pero todavía no fue materializado en el `network.json` generado, la tarjeta de detalles muestra un indicador `⟳ Pending` explicando que el cambio se aplicará en la siguiente ejecución del scheduler. -- El control de pausa del Sankey de árbol completo ahora solo detiene el polling; el drill-down local, el reset y los cambios de profundidad máxima siguen renderizando desde el snapshot en caché mientras está en pausa. +- Las páginas de detalle del árbol muestran una ruta tipo breadcrumb, conteos de rama e indicadores de estado para el nodo seleccionado. +- `Node Details` resume el tipo de nodo seleccionado, el tamaño de la rama, las velocidades configuradas y la velocidad efectiva actual. +- `Node Snapshot` ofrece un resumen visual rápido del throughput actual y el QoO del nodo seleccionado. +- Los circuitos adjuntos se muestran en una tabla dedicada para el nodo seleccionado. +- Los administradores pueden guardar o limpiar valores de `Operator Override` cuando el nodo admite overrides a nivel de nodo. Los usuarios de solo lectura y los nodos no compatibles siguen mostrando los valores actuales sin controles de edición. ### Site Map - Mapa operativo plano de sitios y APs usando geodatos importados de nodos. - Usa QoO por defecto con un selector alternativo para RTT, mientras el tamaño del marcador refleja el throughput combinado reciente. - Usa un promedio del lado cliente de 30 segundos a partir de `NetworkTree`, sin agregar trabajo de rollup en el backend. - Los APs pueden heredar coordenadas del sitio padre solo para visualización cuando faltan coordenadas explícitas. +- El encuadre inicial del mapa ahora prioriza las coordenadas de los sitios para una vista inicial más cercana, usando coordenadas de AP solo cuando todavía no hay sitios mapeados. - Usa un mapa base local con estilo LibreQoS con bordes de país/estado, costas, lagos principales, ríos principales, áreas marinas, superposiciones sutiles de regiones físicas y contexto de autopistas principales a mayor zoom para orientación geográfica. - Site Map utiliza una capa local de carreteras derivada de Natural Earth para ayudar con la orientación, manteniendo el resto del mapa base discreto y operativo. @@ -45,9 +39,20 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta ### ASN Analysis - Página operativa ASN en vivo que combina un ranking top-20 de ASN, gráfico de burbujas latencia-vs-tráfico, franja mínima de KPIs del ASN seleccionado, gráfico de tendencia ASN de 15 minutos y la sección integrada de Flow Evidence. - Soporta modos de ranking `Impact` y `Throughput`, manteniendo la evidencia de flujos ASN en la misma página. +- Las versiones actuales obtienen el contexto ejecutivo ASN mediante requests paginados y acotados solo a ASN, en lugar de suscribirse a un feed completo de heatmaps ejecutivos. - La ruta heredada `ASN Explorer` ahora redirige aquí para conservar compatibilidad con marcadores antiguos. - Resultados vacíos suelen indicar poco dato reciente, no necesariamente falla. +### Página de circuito +- Las páginas de circuito combinan comportamiento de colas, throughput en vivo, RTT, retransmisiones y troubleshooting por flujo para un suscriptor/circuito individual. +- `Queue Dynamics` muestra el comportamiento del throughput y RTT del circuito a lo largo del tiempo, incluyendo un KPI de `Active Flows` basado en la misma ventana reciente usada por la tabla `Traffic Flows`. +- `Queue Stats` muestra los 3 minutos más recientes del historial en vivo de la cola del circuito como muestras scatter crudas de 1 segundo, incluyendo backlog, delay, longitud de cola, tráfico, marcas ECN y drops. +- Los gráficos de Queue Stats ahora usan hover sincronizado para inspeccionar el mismo segundo en todos los gráficos de cola al mismo tiempo. +- `Queue Tree` ahora presenta la ruta ascendente de colas del circuito en tarjetas de ancestros con estilo, incluyendo un resumen de ruta y paneles de throughput, retransmisiones y latencia de igual ancho para cada nodo aguas arriba. +- `Traffic Flows` es una tabla operativa de flujos recientes, no una vista de historial a largo plazo. +- `Traffic Flows` incluye paginación y un filtro `Hide Small Flows` para que los circuitos grandes y ocupados sigan siendo utilizables sin intentar renderizar cada fila. +- `Flow Sankey` enfatiza los flujos recientes más activos en lugar de todos los flujos retenidos más antiguos. + ### Árbol/ponderación de CPU - Muestra distribución de colas/circuitos por núcleo de CPU. - Ayuda a validar comportamiento de binpacking y balance de carga. @@ -55,6 +60,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta ### Editor de Shaped Devices - Editor CRUD para `ShapedDevices.csv`. - Incluye paginación y filtros en versiones actuales. +- En el editor dedicado, las acciones de agregar, editar y eliminar se guardan de inmediato. ### Problemas urgentes - WebUI puede mostrar problemas operativos urgentes emitidos por servicios backend. diff --git a/docs/v2.0/configuration.md b/docs/v2.0/configuration.md index a0a0149b..88fcedcb 100644 --- a/docs/v2.0/configuration.md +++ b/docs/v2.0/configuration.md @@ -29,9 +29,12 @@ Most day-to-day LibreQoS configuration is handled in WebUI (`http://your_shaper_ - General settings: `Configuration -> General` - Integration settings: `Configuration -> Integrations` +- Network layout editor: `Configuration -> Network Layout` - Shaped devices editor: `Configuration -> Shaped Devices` - Runtime operational validation: `WebUI (Node Manager)` pages (dashboard/tree/flow/scheduler) +When an integration is enabled and acting as the source of truth, the `Network Layout` and `Shaped Devices` editors remain viewable but become read-only in WebUI. + ## Operating Modes and Source of Truth Read this first before production changes: diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index 102cf010..c605be80 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -7,30 +7,24 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### Dashboard - Widget-based overview of throughput, retransmits, RTT, flow counts, and queue activity. - Dashboard content can vary by version and enabled features. +- Executive Summary provides a compact operational view for large networks, with drilldown pages for executive heatmaps and leaderboards. +- Some charts may take a short time to populate after first opening a tab, especially on busy systems or immediately after a service restart. ### Network Tree Overview - Hierarchical topology view of nodes/circuits from the shaper perspective. - Useful for spotting bottlenecks and parent/child utilization patterns. -- Tree detail pages now show a breadcrumb path to the selected node, so drilling down does not require repeated resets back to root. -- Tree rows include subtree summaries for descendant `Sites` plus attached/descendant `Circuits`, which helps estimate branch size before expanding further. -- Tree rows and the selected-node header can show small status icons for special handling, including virtual nodes and nodes currently managed by StormGuard. -- Tree detail pages include a `Node Details` card that shows: - - node type and branch size for the selected tree node - - base configured rate from generated `network.json` - - any operator-owned rate override from `lqos_overrides.json` - - current effective configured rate -- Tree detail pages also include a compact context bar, a `Node Snapshot` gauge panel, and a compatibility warning indicator beside `Operator Override` when integration-related override compatibility notices are present. -- Tree detail pages now carry a stable node locator in the URL when available and re-resolve the selected node across live tree reindexing, so staying on a node page continues to follow the same logical node after `network.json` regenerates. -- Administrators can save or clear node rate overrides from the `Operator Override` editor when the selected node is editable. Read-only users and non-editable nodes still see the current values in the details card. -- The synthetic `Root` node is shown as a read-only aggregate and does not expose the `Operator Override` editor. -- Tree node rate overrides require a stable node ID and intentionally refuse generated nodes. When an override has been saved but not yet materialized into generated `network.json`, the details card shows a `⟳ Pending` indicator explaining that the change will apply on the next scheduler run. -- The full-tree Sankey pause control now freezes polling only; local drill-down, reset, and max-depth changes continue to rerender from the cached snapshot while paused. +- Tree detail pages show a breadcrumb path, branch counts, and status indicators for the selected node. +- `Node Details` summarizes the selected node’s type, branch size, configured rates, and current effective rate. +- `Node Snapshot` provides a quick visual summary of current throughput and QoO for the selected node. +- Attached circuits are shown in a dedicated table for the selected node. +- Administrators can save or clear `Operator Override` values where node-level overrides are supported. Read-only users and unsupported nodes continue to display current values without edit controls. ### Site Map - Flat operational map of Sites and APs using imported node geodata. - Defaults to QoO coloring with an RTT toggle, while marker size reflects recent combined throughput. - Uses a 30-second client-side average from `NetworkTree` data rather than adding backend rollup work. - APs can inherit parent site coordinates for display when explicit AP coordinates are missing. +- Initial map framing now prefers site coordinates for a closer first view, falling back to AP coordinates when no sites are mapped yet. - Uses a local LibreQoS-themed basemap with country/state borders, coastlines, major lakes, major rivers, marine areas, subtle physical-region overlays, and higher-zoom major-highway context for geographic orientation. - Site Map uses a local Natural Earth-derived roads layer for orientation while keeping the rest of the basemap quiet and operational. @@ -45,12 +39,16 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### ASN Analysis - Live ASN operations page combining a top-20 ASN leaderboard, latency-vs-traffic bubble chart, selected ASN KPI strip, 15-minute ASN trend chart, and embedded Flow Evidence. - Supports `Impact` and `Throughput` ranking modes while keeping ASN flow evidence on the same page. +- Current builds populate ASN executive context from bounded ASN-only executive page requests instead of subscribing to a full executive heatmap feed. - The legacy `ASN Explorer` route now redirects here so older bookmarks still work. - Empty results usually indicate low recent data volume rather than failure. ### Circuit page - Circuit pages combine queue behavior, live throughput, RTT, retransmits, and per-flow troubleshooting for an individual subscriber/circuit. - `Queue Dynamics` shows circuit throughput and RTT behavior over time, including an `Active Flows` KPI based on the same recent flow window used by the `Traffic Flows` table. +- `Queue Stats` shows the most recent 3 minutes of live queue history for the circuit as raw 1-second scatter samples, including backlog, delay, queue length, traffic, ECN marks, and drops. +- Queue Stats charts now use synchronized hover so operators can inspect the same second across all queue charts together. +- `Queue Tree` now presents the circuit's live upstream queue path in styled ancestor cards, with a path summary plus equal-width throughput, retransmit, and latency panels for each upstream node. - `Traffic Flows` is a recent-flow operational table rather than a long-term history view. - `Traffic Flows` includes paging and a `Hide Small Flows` filter so large busy circuits remain usable without trying to render every row at once. - `Flow Sankey` emphasizes the hottest recent flows rather than every older retained flow. @@ -62,6 +60,7 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### Shaped Devices Editor - CRUD editor for `ShapedDevices.csv`. - Supports paging and filtering in current builds. +- Add, edit, and delete actions save immediately in the dedicated editor. ### Urgent Issues - WebUI can display urgent operational issues generated by backend services. diff --git a/src/rust/lqos_bus/src/bus/response.rs b/src/rust/lqos_bus/src/bus/response.rs index 192024ec..13abe3db 100644 --- a/src/rust/lqos_bus/src/bus/response.rs +++ b/src/rust/lqos_bus/src/bus/response.rs @@ -111,6 +111,8 @@ pub struct ExecutiveSummaryHeader { pub htb_queue_count: u64, /// Number of CAKE queues being tracked. pub cake_queue_count: u64, + /// Number of fq_codel queues being tracked. + pub fq_codel_queue_count: u64, /// Whether Insight is connected. pub insight_connected: bool, } diff --git a/src/rust/lqos_queue_tracker/src/tracking/all_queue_data.rs b/src/rust/lqos_queue_tracker/src/tracking/all_queue_data.rs index da612304..585be9b5 100644 --- a/src/rust/lqos_queue_tracker/src/tracking/all_queue_data.rs +++ b/src/rust/lqos_queue_tracker/src/tracking/all_queue_data.rs @@ -9,6 +9,8 @@ use std::collections::HashMap; pub struct QueueCounts { /// Number of CAKE queues. pub cake: usize, + /// Number of fq_codel queues. + pub fq_codel: usize, /// Number of HTB queues. pub htb: usize, } diff --git a/src/rust/lqos_queue_tracker/src/tracking/mod.rs b/src/rust/lqos_queue_tracker/src/tracking/mod.rs index 2915ca1e..8bf79af1 100644 --- a/src/rust/lqos_queue_tracker/src/tracking/mod.rs +++ b/src/rust/lqos_queue_tracker/src/tracking/mod.rs @@ -83,6 +83,7 @@ fn count_queue_types(queues: &[QueueType]) -> QueueCounts { for queue in queues.iter() { match queue { QueueType::Cake(_) => counts.cake += 1, + QueueType::FqCodel(_) => counts.fq_codel += 1, QueueType::Htb(_) => counts.htb += 1, _ => {} } @@ -191,6 +192,7 @@ fn all_queue_reader() { .unwrap_or_default(); let counts = QueueCounts { cake: counts_down.cake + counts_up.cake, + fq_codel: counts_down.fq_codel + counts_up.fq_codel, htb: counts_down.htb + counts_up.htb, }; (download, upload, counts) diff --git a/src/rust/lqosd/src/node_manager.rs b/src/rust/lqosd/src/node_manager.rs index 72077653..4e59893d 100644 --- a/src/rust/lqosd/src/node_manager.rs +++ b/src/rust/lqosd/src/node_manager.rs @@ -1,5 +1,5 @@ mod auth; -mod local_api; +pub(crate) mod local_api; mod run; mod shaper_queries_actor; mod static_pages; @@ -9,6 +9,7 @@ pub(crate) mod ws; pub use local_api::circuit_count::circuit_count_data; pub use local_api::device_counts::device_count; +pub(crate) use local_api::executive_cache::invalidate_executive_cache_snapshot; pub use local_api::flow_explorer::{ FlowTimeline, asn_list_data, country_list_data, country_timeline_data, flow_timeline_data, protocol_list_data, protocol_timeline_data, diff --git a/src/rust/lqosd/src/node_manager/js_build/src/all_tree_sankey.js b/src/rust/lqosd/src/node_manager/js_build/src/all_tree_sankey.js index ec162996..fe53910f 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/all_tree_sankey.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/all_tree_sankey.js @@ -293,7 +293,7 @@ class AllTreeSankeyGraph extends GenericRingBuffer { this.pending = true; const self = this; - this.pendingCancel = listenOnceWithTimeout("NetworkTree", REQUEST_TIMEOUT_MS, (msg) => { + this.pendingCancel = listenOnceWithTimeout("NetworkTreeLite", REQUEST_TIMEOUT_MS, (msg) => { self.pending = false; self.pendingCancel = null; @@ -312,12 +312,12 @@ class AllTreeSankeyGraph extends GenericRingBuffer { return; } if (sankeyOverlay) { - sankeyOverlay.show("Waiting for data", "No NetworkTree websocket response received yet."); + sankeyOverlay.show("Waiting for data", "No NetworkTreeLite websocket response received yet."); } graph.update([], []); }); - wsClient.send({ NetworkTree: {} }); + wsClient.send({ NetworkTreeLite: {} }); } rerender(graph) { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/asn_analysis.js b/src/rust/lqosd/src/node_manager/js_build/src/asn_analysis.js index 24de678b..4a83d42e 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/asn_analysis.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/asn_analysis.js @@ -3,7 +3,12 @@ import {colorByQoqScore, colorByRetransmitPct, colorByRttMs} from "./helpers/col import {isDarkMode} from "./helpers/dark_mode"; import {openFlowRttExcludeWizard} from "./lq_js_common/helpers/flow_rtt_exclude_wizard"; import {scaleNanos, scaleNumber, toNumber} from "./lq_js_common/helpers/scaling"; -import {listenExecutiveHeatmaps, medianFromBlocks, sumBlocks} from "./executive_utils"; +import { + medianFromBlocks, + pollExecutiveHeatmapPage, + pollExecutiveLeaderboardPage, + sumBlocks, +} from "./executive_utils"; import {get_ws_client} from "./pubsub/ws"; const wsClient = get_ws_client(); @@ -11,7 +16,8 @@ const EVIDENCE_ITEMS_PER_PAGE = 10; const TOP_LABEL_COUNT = 4; const TOP_ROW_LIMIT = 20; const HEATMAP_BLOCK_SECONDS = 60; -const LIVE_INTERVAL_TEXT = "Heatmaps auto-refresh about every second"; +const EXECUTIVE_PAGE_SIZE = 250; +const LIVE_INTERVAL_TEXT = "Executive data auto-refreshes about every 5 seconds"; const NEUTRAL_CHART_COLOR = "#94a3b8"; const RTT_THRESHOLDS = (() => { const configured = window.rttThresholds || window.rtt_thresholds || window.config?.rtt_thresholds || {}; @@ -22,7 +28,10 @@ const RTT_THRESHOLDS = (() => { })(); const state = { - executiveHeatmaps: null, + executiveTopAsns: null, + executiveDownloadHeatmap: null, + executiveRttHeatmap: null, + executiveRetransmitHeatmap: null, executiveRows: [], selectedAsn: null, asnList: [], @@ -43,11 +52,12 @@ const state = { retransmitChartTheme: null, themeObserver: null, activeEvidenceRequestToken: 0, + executivePollHandles: [], }; const SORT_OPTIONS = { - start: (a, b) => b.start - a.start, - duration: (a, b) => b.duration_nanos - a.duration_nanos, + start: (a, b) => toNumber(b?.start, 0) - toNumber(a?.start, 0), + duration: (a, b) => toNumber(b?.duration_nanos, 0) - toNumber(a?.duration_nanos, 0), bytes: (a, b) => totalFlowBytes(b) - totalFlowBytes(a), }; @@ -183,26 +193,60 @@ function arrayMedian(values) { } function buildExecutiveRows() { - const heatmaps = state.executiveHeatmaps?.asns || []; + const topAsns = (state.executiveTopAsns?.rows || []) + .filter((row) => row?.kind === "TopAsnByTraffic"); + const downloadRows = (state.executiveDownloadHeatmap?.rows || []); + const rttRows = (state.executiveRttHeatmap?.rows || []); + const retransmitRows = (state.executiveRetransmitHeatmap?.rows || []); + const downloadByAsn = new Map(downloadRows + .map((row) => [toNumber(row.asn, null), row])); + const rttByAsn = new Map(rttRows + .map((row) => [toNumber(row.asn, null), row])); + const retransmitByAsn = new Map(retransmitRows + .map((row) => [toNumber(row.asn, null), row])); const countMap = new Map( (state.asnList || []).map((entry) => [entry.asn, toNumber(entry.count, 0)]), ); + const allAsns = new Set(); + downloadRows.forEach((row) => allAsns.add(toNumber(row?.asn, null))); + rttRows.forEach((row) => allAsns.add(toNumber(row?.asn, null))); + retransmitRows.forEach((row) => allAsns.add(toNumber(row?.asn, null))); + topAsns.forEach((row) => allAsns.add(toNumber(row?.asn, null))); - const baseRows = heatmaps.map((asn) => { - const downloadBlocks = asn?.blocks?.download || []; - const uploadBlocks = asn?.blocks?.upload || []; - const rttBlocks = asn?.blocks?.rtt || []; - const retransmitBlocks = asn?.blocks?.retransmit || []; + const baseRows = [...allAsns] + .filter((asnNumber) => Number.isFinite(asnNumber) && asnNumber > 0) + .map((asnNumber) => { + const topAsn = topAsns.find((row) => toNumber(row?.asn, 0) === asnNumber) || null; + const downloadRow = downloadByAsn.get(asnNumber); + const rttRow = rttByAsn.get(asnNumber); + const retransmitRow = retransmitByAsn.get(asnNumber); + const downloadBlocks = downloadRow?.split_blocks?.download || []; + const uploadBlocks = downloadRow?.split_blocks?.upload || []; + const rttBlocks = rttRow?.rtt_blocks?.rtt || []; + const retransmitBlocks = retransmitRow?.scalar_blocks?.values || []; const totalMbpsMinutes = sumBlocks(downloadBlocks) + sumBlocks(uploadBlocks); - const avgMbps = totalMbpsMinutes / Math.max(1, Math.max(downloadBlocks.length, uploadBlocks.length, 15)); - const totalBytes = ((totalMbpsMinutes * 1_000_000) / 8) * HEATMAP_BLOCK_SECONDS; + const totalBytes = toNumber(topAsn?.total_bytes_15m, 0) + || ((totalMbpsMinutes * 1_000_000) / 8) * HEATMAP_BLOCK_SECONDS; + const avgMbps = totalMbpsMinutes > 0 + ? totalMbpsMinutes / Math.max(1, Math.max(downloadBlocks.length, uploadBlocks.length, 15)) + : (totalBytes * 8) / (HEATMAP_BLOCK_SECONDS * 15 * 1_000_000); const medianRtt = medianFromBlocks(rttBlocks); const medianRetrans = medianFromBlocks(retransmitBlocks); - const flowCount = countMap.get(asn.asn) || 0; + const flowCount = countMap.get(asnNumber) || 0; + const asnName = topAsn?.asn_name + || downloadRow?.label + || rttRow?.label + || retransmitRow?.label + || `ASN ${asnNumber}`; return { - asn: asn.asn, - asnName: asn.asn_name || `ASN ${asn.asn}`, - heatmap: asn, + asn: asnNumber, + asnName, + heatmap: { + download: downloadBlocks, + upload: uploadBlocks, + rtt: rttBlocks, + retransmit: retransmitBlocks, + }, totalBytes, avgMbps, flowCount, @@ -655,7 +699,7 @@ function requestLists() { function requestAsnList() { listenOnce("AsnList", (msg) => { - state.asnList = (msg?.data || []).slice().sort((a, b) => b.count - a.count); + state.asnList = (msg?.data || []).slice().sort((a, b) => toNumber(b?.count, 0) - toNumber(a?.count, 0)); state.listCountsByAsn = new Map(state.asnList.map((row) => [row.asn, toNumber(row.count, 0)])); buildExecutiveRows(); renderAll(); @@ -700,7 +744,7 @@ function renderDropdown({ targetId, buttonText, items, emptyText, itemRenderer } function requestCountryList() { listenOnce("CountryList", (msg) => { - state.countryList = (msg?.data || []).slice().sort((a, b) => b.count - a.count); + state.countryList = (msg?.data || []).slice().sort((a, b) => toNumber(b?.count, 0) - toNumber(a?.count, 0)); renderDropdown({ targetId: "asnAnalysisCountryControl", buttonText: "Country", @@ -723,7 +767,7 @@ function requestCountryList() { function requestProtocolList() { listenOnce("ProtocolList", (msg) => { - state.protocolList = (msg?.data || []).slice().sort((a, b) => b.count - a.count); + state.protocolList = (msg?.data || []).slice().sort((a, b) => toNumber(b?.count, 0) - toNumber(a?.count, 0)); renderDropdown({ targetId: "asnAnalysisProtocolControl", buttonText: "Protocol", @@ -1034,6 +1078,7 @@ function wireControls() { refreshButton?.addEventListener("click", () => { requestLists(); + state.executivePollHandles.forEach((handle) => handle?.refresh?.()); const top = sortedLeaderboardRows()[0]; if (top) { setSelectedAsn(top.asn); @@ -1083,14 +1128,46 @@ function observeThemeAndResize() { } function initExecutiveFeed() { - listenExecutiveHeatmaps((data) => { - state.executiveHeatmaps = data || {}; - state.lastExecutiveUpdate = new Date(); + const syncExecutiveState = (key, data) => { + state[key] = data || null; + state.lastExecutiveUpdate = new Date(toNumber(data?.generated_at_unix_ms, Date.now())); buildExecutiveRows(); chooseDefaultSelection(); renderAll(); updateLiveClock(); - }); + }; + + state.executivePollHandles = [ + pollExecutiveLeaderboardPage({ + kind: "TopAsnsByTraffic", + page: 0, + page_size: EXECUTIVE_PAGE_SIZE, + }, (data) => syncExecutiveState("executiveTopAsns", data)), + pollExecutiveHeatmapPage({ + metric: "Download", + entity_kinds: ["Asn"], + page: 0, + page_size: EXECUTIVE_PAGE_SIZE, + sort: "Label", + descending: false, + }, (data) => syncExecutiveState("executiveDownloadHeatmap", data)), + pollExecutiveHeatmapPage({ + metric: "Rtt", + entity_kinds: ["Asn"], + page: 0, + page_size: EXECUTIVE_PAGE_SIZE, + sort: "Label", + descending: false, + }, (data) => syncExecutiveState("executiveRttHeatmap", data)), + pollExecutiveHeatmapPage({ + metric: "Retransmit", + entity_kinds: ["Asn"], + page: 0, + page_size: EXECUTIVE_PAGE_SIZE, + sort: "Label", + descending: false, + }, (data) => syncExecutiveState("executiveRetransmitHeatmap", data)), + ]; } function init() { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js index 3cbbf1bf..8f8d1c0d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js @@ -494,6 +494,82 @@ function resolveFunnelState(msg, parentNode) { }; } +function buildFunnelPathCard(state, displayParents) { + const pathCard = document.createElement("div"); + pathCard.classList.add("lqos-funnel-path-card"); + + const topRow = document.createElement("div"); + topRow.classList.add("lqos-funnel-path-top"); + + const title = document.createElement("div"); + title.classList.add("lqos-funnel-path-title"); + title.textContent = "Queue Path"; + topRow.appendChild(title); + + const meta = document.createElement("div"); + meta.classList.add("lqos-funnel-path-meta"); + + const parentPill = document.createElement("span"); + parentPill.classList.add("lqos-funnel-path-pill"); + parentPill.textContent = "Circuit parent"; + meta.appendChild(parentPill); + + const countPill = document.createElement("span"); + countPill.classList.add("lqos-funnel-path-pill"); + countPill.textContent = `${displayParents.length} upstream node${displayParents.length === 1 ? "" : "s"}`; + meta.appendChild(countPill); + + topRow.appendChild(meta); + pathCard.appendChild(topRow); + + const chain = document.createElement("div"); + chain.classList.add("lqos-funnel-path-chain"); + + const origin = document.createElement("span"); + origin.classList.add("lqos-funnel-path-node", "is-origin", "redactable"); + origin.textContent = state.immediateParent.name || "Unknown"; + chain.appendChild(origin); + + displayParents.forEach(({ node }) => { + const separator = document.createElement("span"); + separator.classList.add("lqos-funnel-path-separator"); + separator.textContent = "→"; + chain.appendChild(separator); + + const ancestor = document.createElement("span"); + ancestor.classList.add("lqos-funnel-path-node", "redactable"); + ancestor.textContent = node.name || "Unknown"; + chain.appendChild(ancestor); + }); + + pathCard.appendChild(chain); + + const note = document.createElement("p"); + note.classList.add("text-muted", "small", "mb-0"); + note.textContent = + displayParents.length > 0 + ? "Live upstream queue ancestors for this circuit, shown with the same order used below." + : "This circuit parent does not currently have additional upstream queue ancestors."; + pathCard.appendChild(note); + + return pathCard; +} + +function buildFunnelEmptyState(message) { + const empty = document.createElement("div"); + empty.classList.add("lqos-funnel-empty-state"); + + const icon = document.createElement("i"); + icon.classList.add("fa", "fa-circle-info"); + empty.appendChild(icon); + + const text = document.createElement("span"); + text.textContent = message; + empty.appendChild(text); + + return empty; +} + function renderFunnel(state) { const target = document.getElementById("theFunnel"); if (!target) { @@ -505,51 +581,106 @@ function renderFunnel(state) { funnelParents = []; funnelParentSignature = []; clearDiv(target); - target.appendChild(document.createTextNode("No parent node found")); + target.appendChild(buildFunnelEmptyState("No parent node found for this circuit.")); return; } - const parentIndexes = [...state.parentIndexes].reverse(); - let parentDiv = document.createElement("div"); - parentIndexes.forEach((parent) => { - const node = state.data[parent] && state.data[parent][1] ? state.data[parent][1] : null; - if (!node) { - return; + const displayParents = [...state.parentIndexes] + .reverse() + .map((parent, index) => { + const node = state.data[parent] && state.data[parent][1] ? state.data[parent][1] : null; + if (!node) { + return null; + } + return { + parent, + node, + position: index + 1, + }; + }) + .filter(Boolean); + + const parentDiv = document.createElement("div"); + parentDiv.classList.add("lqos-funnel-stack"); + parentDiv.appendChild(buildFunnelPathCard(state, displayParents)); + + if (displayParents.length === 0) { + parentDiv.appendChild(buildFunnelEmptyState("No upstream queue ancestors are currently available beyond the circuit parent.")); + } + + displayParents.forEach(({ parent, node, position }) => { + const card = document.createElement("section"); + card.classList.add("lqos-funnel-node-card"); + + const header = document.createElement("div"); + header.classList.add("lqos-funnel-node-header"); + + const titleWrap = document.createElement("div"); + titleWrap.classList.add("lqos-funnel-node-title"); + + const heading = document.createElement("h5"); + const icon = document.createElement("i"); + icon.classList.add("fa", "fa-sitemap"); + heading.appendChild(icon); + + const name = document.createElement("span"); + name.classList.add("redactable"); + name.textContent = node.name || "Unknown"; + heading.appendChild(name); + titleWrap.appendChild(heading); + + const subtitle = document.createElement("p"); + subtitle.classList.add("text-muted", "small", "mb-0"); + subtitle.textContent = "Live queue telemetry for this upstream node."; + titleWrap.appendChild(subtitle); + + header.appendChild(titleWrap); + + const badges = document.createElement("div"); + badges.classList.add("lqos-funnel-node-badges"); + + const typeBadge = document.createElement("span"); + typeBadge.classList.add("lqos-funnel-node-type"); + if (node.is_virtual === true) { + typeBadge.classList.add("is-virtual"); } + typeBadge.textContent = node.is_virtual === true ? "Virtual" : "Physical"; + badges.appendChild(typeBadge); - let row = document.createElement("div"); - row.classList.add("row"); + const stepBadge = document.createElement("span"); + stepBadge.classList.add("lqos-funnel-node-step"); + stepBadge.textContent = `Ancestor ${position} / ${displayParents.length}`; + badges.appendChild(stepBadge); - let col = document.createElement("div"); - col.classList.add("col-12"); - let heading = document.createElement("h5"); - heading.classList.add("redactable"); - const virtualLabel = node.is_virtual === true ? " Virtual" : ""; - heading.innerHTML = " " + node.name + virtualLabel; - col.appendChild(heading); - row.appendChild(col); - parentDiv.appendChild(row); + header.appendChild(badges); + card.appendChild(header); - row = document.createElement("div"); - row.classList.add("row"); + const row = document.createElement("div"); + row.classList.add("row", "g-3"); - let col_tp = document.createElement("div"); - col_tp.classList.add("col-4"); - col_tp.id = "funnel_tp_" + parent; - col_tp.style.height = "250px"; - row.appendChild(col_tp); + const chartSpecs = [ + { id: "funnel_tp_" + parent, height: "250px" }, + { id: "funnel_rxmit_" + parent, height: "250px" }, + { id: "funnel_rtt_" + parent, height: "250px" }, + ]; - let col_rxmit = document.createElement("div"); - col_rxmit.classList.add("col-4"); - col_rxmit.id = "funnel_rxmit_" + parent; - row.appendChild(col_rxmit); + chartSpecs.forEach((chart) => { + const col = document.createElement("div"); + col.classList.add("col-12", "col-xl-4"); - let col_rtt = document.createElement("div"); - col_rtt.classList.add("col-4"); - col_rtt.id = "funnel_rtt_" + parent; - row.appendChild(col_rtt); + const panel = document.createElement("div"); + panel.classList.add("lqos-funnel-chart-panel"); - parentDiv.appendChild(row); + const graph = document.createElement("div"); + graph.id = chart.id; + graph.style.height = chart.height; + panel.appendChild(graph); + col.appendChild(panel); + row.appendChild(col); + }); + + card.appendChild(row); + parentDiv.appendChild(card); }); funnelGraphs = {}; @@ -558,7 +689,7 @@ function renderFunnel(state) { requestAnimationFrame(() => { setTimeout(() => { - parentIndexes.forEach((parent) => { + displayParents.forEach(({ parent }) => { if (!document.getElementById("funnel_tp_" + parent)) { return; } @@ -615,28 +746,65 @@ function renderCakeGraphShell() { return; } cakeTab.innerHTML = ` -
-
-
+
+
+
+
Queue Stats
+

Raw 1s scatter samples over the last 3 minutes. Hover any queue chart to inspect the same timestamp across all six charts.

+
+
+ Last 3 minutes + 1s samples + Synchronized hover +
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Queue Memory: ? -
Queue Type: ?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Queue Memory
+
?
+
+
+
Queue Type
+
?
+
+
+
Interpretation
+
Download plots above zero, upload plots below zero. Tooltips always show absolute values with direction labels.
+
+
+
`; @@ -680,6 +848,13 @@ function ensureCakeGraphs() { marks: new CakeMarks("cakeMarks"), drops: new CakeDrops("cakeDrops"), }; + const cakeChartInstances = Object.values(cakeGraphs) + .map((graph) => graph?.chart) + .filter(Boolean); + cakeChartInstances.forEach((chart) => { + chart.group = "circuitCakeQueueStats"; + }); + echarts.connect("circuitCakeQueueStats"); applyCakeMessage(latestCakeMsg); }); } @@ -1709,18 +1884,18 @@ function initialDevices(circuits) { function initialFunnel(parentNode) { funnelParentNodeName = parentNode; - listenOnce("NetworkTree", (msg) => { + listenOnce("NetworkTreeLite", (msg) => { renderFunnel(resolveFunnelState(msg, parentNode)); if (funnelSubscription) { funnelSubscription.dispose(); } - funnelSubscription = subscribeWS(["NetworkTree"], onTreeEvent); + funnelSubscription = subscribeWS(["NetworkTreeLite"], onTreeEvent); }); - wsClient.send({ NetworkTree: {} }); + wsClient.send({ NetworkTreeLite: {} }); } function onTreeEvent(msg) { - if (msg.event !== "NetworkTree" || !funnelParentNodeName) { + if (msg.event !== "NetworkTreeLite" || !funnelParentNodeName) { return; } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 30e15573..8d666554 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -46,6 +46,34 @@ function ensureOptionalConfigSections(config) { return config; } +const TOPOLOGY_SOURCE_INTEGRATIONS = [ + { name: "UISP", enabled: (config) => !!config?.uisp_integration?.enable_uisp }, + { name: "Splynx", enabled: (config) => !!config?.splynx_integration?.enable_splynx }, + { name: "Powercode", enabled: (config) => !!config?.powercode_integration?.enable_powercode }, + { name: "Sonar", enabled: (config) => !!config?.sonar_integration?.enable_sonar }, + { name: "Netzur", enabled: (config) => !!config?.netzur_integration?.enable_netzur }, + { name: "VISP", enabled: (config) => !!config?.visp_integration?.enable_visp }, + { name: "WispGate", enabled: (config) => !!config?.wispgate_integration?.enable_wispgate }, +]; + +export function activeTopologySourceIntegrations(config) { + return TOPOLOGY_SOURCE_INTEGRATIONS.filter((entry) => entry.enabled(config)).map( + (entry) => entry.name, + ); +} + +export function topologyEditorsLocked(config) { + return activeTopologySourceIntegrations(config).length > 0; +} + +export function topologyEditorsLockMessage(config) { + const active = activeTopologySourceIntegrations(config); + if (active.length === 0) { + return ""; + } + return `Editing is disabled because these integrations are the source of truth: ${active.join(", ")}.`; +} + export function loadConfig(onComplete, onError) { sendWsRequest( "GetConfig", @@ -156,6 +184,30 @@ export function saveNetworkAndDevices(network_json, shaped_devices, onComplete, ); } +export function saveNetworkJsonOnly(network_json, onComplete, onError) { + if (!network_json || typeof network_json !== "object") { + alert("Invalid network configuration"); + return; + } + + sendWsRequest( + "UpdateNetworkJsonOnlyResult", + { UpdateNetworkJsonOnly: { network_json } }, + (msg) => { + if (onComplete) onComplete(!!msg.ok, msg.message); + }, + (msg) => { + const errorMsg = (msg && msg.message) ? msg.message : "Request failed"; + if (onComplete) onComplete(false, errorMsg); + if (onError) { + onError(msg); + } else { + alert("Error saving network configuration: " + errorMsg); + } + }, + ); +} + export function adminCheck(onComplete, onError) { sendWsRequest( "AdminCheck", @@ -201,9 +253,129 @@ export function loadNetworkJson(onComplete, onError) { } export function loadAllShapedDevices(onComplete, onError) { + const pageSize = 250; + const rows = []; + + const loadPage = (page) => { + sendWsRequest( + "ShapedDevicesPage", + { ShapedDevicesPage: { query: { page, page_size: pageSize } } }, + (msg) => { + const data = msg && msg.data ? msg.data : {}; + const pageRows = Array.isArray(data.rows) ? data.rows : []; + rows.push(...pageRows); + const totalRows = Number.isFinite(Number(data.total_rows)) + ? Math.max(0, Math.trunc(Number(data.total_rows))) + : rows.length; + if (rows.length >= totalRows || pageRows.length < pageSize) { + if (onComplete) onComplete(rows); + return; + } + loadPage(page + 1); + }, + onError, + ); + }; + + loadPage(0); +} + +export function loadShapedDevicesPage(query, onComplete, onError) { sendWsRequest( - "AllShapedDevices", - { AllShapedDevices: {} }, + "ShapedDevicesPage", + { ShapedDevicesPage: { query } }, + (msg) => { + if (onComplete) onComplete(msg.data || null); + }, + onError, + ); +} + +export function getShapedDevice(deviceId, onComplete, onError) { + sendWsRequest( + "GetShapedDeviceResult", + { GetShapedDevice: { device_id: deviceId } }, + (msg) => { + if (onComplete) onComplete(msg); + }, + onError, + ); +} + +export function createShapedDevice(device, onComplete, onError) { + sendWsRequest( + "CreateShapedDeviceResult", + { CreateShapedDevice: { device } }, + (msg) => { + if (onComplete) onComplete(msg); + }, + onError, + ); +} + +export function updateShapedDevice(originalDeviceId, device, onComplete, onError) { + sendWsRequest( + "UpdateShapedDeviceResult", + { UpdateShapedDevice: { original_device_id: originalDeviceId, device } }, + (msg) => { + if (onComplete) onComplete(msg); + }, + onError, + ); +} + +export function deleteShapedDevice(deviceId, onComplete, onError) { + sendWsRequest( + "DeleteShapedDeviceResult", + { DeleteShapedDevice: { device_id: deviceId } }, + (msg) => { + if (onComplete) onComplete(msg); + }, + onError, + ); +} + +export function loadCircuitDirectoryPage(query, onComplete, onError) { + sendWsRequest( + "CircuitDirectoryPage", + { CircuitDirectoryPage: { query } }, + (msg) => { + if (onComplete) onComplete(msg.data || null); + }, + onError, + ); +} + +export function loadAllCircuitDirectoryRows(onComplete, onError) { + const pageSize = 250; + const rows = []; + + const loadPage = (page) => { + loadCircuitDirectoryPage( + { page, page_size: pageSize }, + (data) => { + const pageRows = Array.isArray(data?.rows) ? data.rows : []; + rows.push(...pageRows); + const totalRows = Number.isFinite(Number(data?.total_rows)) + ? Math.max(0, Math.trunc(Number(data.total_rows))) + : rows.length; + if (rows.length >= totalRows || pageRows.length < pageSize) { + if (onComplete) onComplete(rows); + return; + } + loadPage(page + 1); + }, + onError, + ); + }; + + loadPage(0); +} + +export function loadNodeDirectory(onComplete, onError) { + sendWsRequest( + "NodeDirectory", + { NodeDirectory: {} }, (msg) => { if (onComplete) onComplete(msg.data || []); }, @@ -211,6 +383,17 @@ export function loadAllShapedDevices(onComplete, onError) { ); } +export function loadTreeGuardMetadataSummary(onComplete, onError) { + sendWsRequest( + "TreeGuardMetadataSummary", + { TreeGuardMetadataSummary: {} }, + (msg) => { + if (onComplete) onComplete(msg.data || null); + }, + onError, + ); +} + export function getUsers(onComplete, onError) { sendWsRequest( "GetUsers", diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js b/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js index c9c22a3a..38f740bf 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js @@ -1,21 +1,52 @@ import { - loadAllShapedDevices, + loadConfig, + createShapedDevice, + deleteShapedDevice, loadNetworkJson, + loadShapedDevicesPage, renderConfigMenu, - saveNetworkAndDevices, + topologyEditorsLockMessage, + topologyEditorsLocked, + updateShapedDevice, validNodeList, } from "./config/config_helper"; -let shaped_devices = []; +let current_rows = []; let network_json = null; -let filtered_indices = []; +let total_rows = 0; +let total_circuits = 0; let page = 0; let page_size = 25; let search_term = ""; -let invalid_indices = new Set(); -let edit_index = null; +let edit_device_id = null; let creating_new = false; let search_timer = null; +let modal_busy = false; +let topology_editor_locked = false; +let topology_editor_lock_message = ""; + +function setModalReadOnly(readOnly) { + $("#sdEditForm input, #sdEditForm textarea, #sdEditForm select").prop("disabled", readOnly); + $("#sdModalSave").prop("disabled", readOnly || modal_busy); +} + +function applyEditorLockState() { + $("#btnAddDevice").prop("disabled", topology_editor_locked); + $("#sdTableContainer").toggleClass("opacity-75", topology_editor_locked); + + const banner = $("#devicesEditorLock"); + if (topology_editor_locked && topology_editor_lock_message) { + banner.removeClass("d-none").text(topology_editor_lock_message); + } else { + banner.addClass("d-none").text(""); + } + + setModalReadOnly(topology_editor_locked); +} + +function actionButtonAttrs() { + return topology_editor_locked ? " disabled aria-disabled='true'" : ""; +} function defaultDevice() { return { @@ -231,94 +262,93 @@ function buildParentNodeOptions(selectedNode) { } function totalPages() { - return Math.max(1, Math.ceil(filtered_indices.length / page_size)); + return Math.max(1, Math.ceil(total_rows / page_size)); } -function applySearch(resetPage = true) { - const term = (search_term || "").toLowerCase().trim(); - filtered_indices = []; +function currentQuery() { + return { + page, + page_size, + search: search_term.trim().length > 0 ? search_term.trim() : undefined, + }; +} - if (!term) { - for (let i = 0; i < shaped_devices.length; i++) { - filtered_indices.push(i); - } - } else { - for (let i = 0; i < shaped_devices.length; i++) { - const device = shaped_devices[i] || {}; - const parts = [ - device.circuit_id, - device.circuit_name, - device.device_id, - device.device_name, - device.parent_node, - device.mac, - ]; +function renderLoading() { + $("#sdTableContainer").html( + "
Loading...
", + ); +} - if (Array.isArray(device.ipv4)) { - device.ipv4.forEach((tuple) => parts.push(formatIpTuple(tuple, 32))); - } - if (Array.isArray(device.ipv6)) { - device.ipv6.forEach((tuple) => parts.push(formatIpTuple(tuple, 128))); +function requestPage() { + renderLoading(); + $("#btnAddDevice").prop("disabled", true); + $("#btnSaveDevices").prop("disabled", true); + loadShapedDevicesPage( + currentQuery(), + (data) => { + current_rows = Array.isArray(data?.rows) ? data.rows : []; + total_rows = Number.isFinite(Number(data?.total_rows)) + ? Math.max(0, Math.trunc(Number(data.total_rows))) + : current_rows.length; + total_circuits = Number.isFinite(Number(data?.total_circuits)) + ? Math.max(0, Math.trunc(Number(data.total_circuits))) + : 0; + + const totalPagesForResult = Math.max(1, Math.ceil(total_rows / page_size)); + if (page >= totalPagesForResult && total_rows > 0) { + page = totalPagesForResult - 1; + requestPage(); + return; } - const haystack = parts - .filter((val) => val !== undefined && val !== null) - .join(" ") - .toLowerCase(); - - if (haystack.includes(term)) { - filtered_indices.push(i); - } - } - } - - if (resetPage) { - page = 0; - } else { - page = Math.min(page, totalPages() - 1); - } - - render(); + render(); + $("#btnAddDevice").prop("disabled", topology_editor_locked); + $("#btnSaveDevices").prop("disabled", false); + applyEditorLockState(); + }, + () => { + $("#sdTableContainer").html( + "
Failed to load shaped devices.
", + ); + $("#btnAddDevice").prop("disabled", topology_editor_locked); + $("#btnSaveDevices").prop("disabled", false); + }, + ); } function setPage(newPage) { const total = totalPages(); page = Math.min(Math.max(newPage, 0), total - 1); - render(); + requestPage(); } function setPageSize(newSize) { page_size = newSize; page = 0; - render(); + requestPage(); } function renderSummary() { - const total = shaped_devices.length; - const filtered = filtered_indices.length; - const start = filtered === 0 ? 0 : page * page_size + 1; - const end = filtered === 0 ? 0 : Math.min(page * page_size + page_size, filtered); + const start = total_rows === 0 ? 0 : page * page_size + 1; + const end = total_rows === 0 ? 0 : page * page_size + current_rows.length; let text = ""; - if (filtered === 0) { + if (total_rows === 0) { text = "No devices to display"; - } else if (filtered === total) { - text = `Showing ${start}-${end} of ${total} devices`; + } else if (search_term.trim().length > 0) { + text = `Showing ${start}-${end} of ${total_rows} matching devices`; } else { - text = `Showing ${start}-${end} of ${filtered} devices (filtered from ${total})`; + text = `Showing ${start}-${end} of ${total_rows} devices across ${total_circuits} circuits`; } $("#sdSummary").text(text); } function renderTable() { const container = $("#sdTableContainer"); - if (filtered_indices.length === 0) { + if (current_rows.length === 0) { container.html("
No devices match the current search.
"); return; } - const start = page * page_size; - const end = Math.min(start + page_size, filtered_indices.length); - let html = ""; html += ""; @@ -338,21 +368,20 @@ function renderTable() { html += ""; html += ""; - for (let i = start; i < end; i++) { - const idx = filtered_indices[i]; - const row = shaped_devices[idx] || {}; - const invalidClass = invalid_indices.has(idx) ? " table-danger" : ""; + current_rows.forEach((row) => { const comment = row.comment ? String(row.comment) : ""; + const deviceId = escapeHtml(row.device_id || ""); + const actionAttrs = actionButtonAttrs(); - html += ""; + html += ""; html += ""; html += ""; html += @@ -386,14 +415,14 @@ function renderTable() { } html += ""; html += ""; - } + }); html += "
" + - "" + - "" + + "" + + "" + "" + formatIdCell(row.circuit_id, 5) + "
"; container.html(html); } function buildPaginationModel(total, current) { - let pages = []; + const pages = []; const add = (value) => { if (value < 0 || value >= total) return; if (!pages.includes(value)) pages.push(value); @@ -407,7 +436,7 @@ function buildPaginationModel(total, current) { add(current + 2); pages.sort((a, b) => a - b); - let result = []; + const result = []; let last = null; pages.forEach((p) => { if (last !== null && p - last > 1) { @@ -423,7 +452,7 @@ function renderPagination() { const total = totalPages(); const listBottom = $("#sdPagination"); const listTop = $("#sdPaginationTop"); - if (filtered_indices.length === 0) { + if (total_rows === 0) { listBottom.html(""); listTop.html(""); return; @@ -531,19 +560,22 @@ function collectModalDevice() { const sqmDown = $("#sdModalSqmDown").val().trim().toLowerCase(); const sqmUp = $("#sdModalSqmUp").val().trim().toLowerCase(); - if (!sqmDown && !sqmUp) { - delete device.sqm_override; - } else { + if (sqmDown || sqmUp) { device.sqm_override = `${sqmDown}/${sqmUp}`; } return device; } -function openEditModal(index) { - const device = shaped_devices[index]; +function findCurrentRow(deviceId) { + return current_rows.find((row) => row.device_id === deviceId); +} + +function openEditModal(deviceId) { + if (topology_editor_locked) return; + const device = findCurrentRow(deviceId); if (!device) return; - edit_index = index; + edit_device_id = device.device_id; creating_new = false; $("#sdEditModalLabel").text("Edit Device"); populateModal(device); @@ -551,39 +583,18 @@ function openEditModal(index) { } function openNewModal() { - edit_index = null; + if (topology_editor_locked) return; + edit_device_id = null; creating_new = true; $("#sdEditModalLabel").text("Add Device"); populateModal(defaultDevice()); showModal(); } -function saveModalChanges() { - const device = collectModalDevice(); - if (creating_new) { - shaped_devices.unshift(device); - } else if (edit_index !== null) { - shaped_devices[edit_index] = device; - } - invalid_indices = new Set(); - hideModal(); - applySearch(creating_new); -} - -function deleteDevice(index) { - const device = shaped_devices[index]; - if (!device) return; - const label = device.device_name || device.device_id || "this device"; - if (!confirm(`Delete ${label}?`)) return; - shaped_devices.splice(index, 1); - invalid_indices = new Set(); - applySearch(false); -} - function checkIpv4(ip) { - const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; + const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; + if (!ipv4Pattern.test(ip)) return false; const parts = ip.split("/"); - if (!ipv4Pattern.test(parts[0])) return false; if (parts.length > 1 && parts[1].length > 0) { const prefix = parseInt(parts[1], 10); if (Number.isNaN(prefix) || prefix < 0 || prefix > 32) return false; @@ -596,148 +607,134 @@ function checkIpv6(ip) { return regex.test(ip); } -function normalizeIp(tuple, family) { - const defaultPrefix = family === 6 ? 128 : 32; - if (!tuple || tuple.length === 0) return ""; - const addr = tuple[0] ? formatIpAddr(tuple[0], family).trim() : ""; - if (!addr) return ""; - const prefix = Number.isFinite(tuple[1]) ? tuple[1] : defaultPrefix; - const normalizedAddr = family === 6 ? addr.toLowerCase() : addr; - return normalizedAddr + "/" + prefix; -} - -function validateDevices() { - let valid = true; - let errors = []; - invalid_indices = new Set(); - +function validateModalDevice(device) { + const errors = []; const nodes = network_json ? validNodeList(network_json) : []; - const deviceIds = new Map(); - const ipv4s = new Map(); - const ipv6s = new Map(); - const markInvalid = (index, message) => { - valid = false; - errors.push(message); - invalid_indices.add(index); - }; + if (!device.circuit_id) errors.push("Circuit ID is required"); + if (!device.circuit_name) errors.push("Circuit Name is required"); + if (!device.device_id) errors.push("Device ID is required"); + if (!device.device_name) errors.push("Device Name is required"); - shaped_devices.forEach((device, index) => { - if (!device.circuit_id || device.circuit_id.trim().length === 0) { - markInvalid(index, "Circuits must have a Circuit ID"); - } - if (!device.circuit_name || device.circuit_name.trim().length === 0) { - markInvalid(index, "Circuits must have a Circuit Name"); - } - if (!device.device_id || device.device_id.trim().length === 0) { - markInvalid(index, "Circuits must have a Device ID"); - } - if (!device.device_name || device.device_name.trim().length === 0) { - markInvalid(index, "Circuits must have a Device Name"); + const parentNode = device.parent_node || ""; + if (nodes.length === 0) { + if (parentNode.length > 0) { + errors.push("You have a flat network, so you can't specify a parent node."); } + } else if (parentNode.length > 0 && nodes.indexOf(parentNode) === -1) { + errors.push("Parent node does not exist."); + } - if (device.device_id) { - const existing = deviceIds.get(device.device_id); - if (existing !== undefined && existing !== index) { - markInvalid(index, `Devices with duplicate ID [${device.device_id}] detected`); - invalid_indices.add(existing); - } else { - deviceIds.set(device.device_id, index); - } - } + const ipv4List = Array.isArray(device.ipv4) ? device.ipv4 : []; + const ipv6List = Array.isArray(device.ipv6) ? device.ipv6 : []; + if (ipv4List.length === 0 && ipv6List.length === 0) { + errors.push("You must specify either an IPv4 or IPv6 (or both) address."); + } - const parentNode = device.parent_node || ""; - if (nodes.length === 0) { - if (parentNode.length > 0) { - markInvalid(index, "You have a flat network, so you can't specify a parent node."); - } - } else if (parentNode.length > 0 && nodes.indexOf(parentNode) === -1) { - markInvalid(index, "Parent node: " + parentNode + " does not exist"); - } - - const ipv4List = Array.isArray(device.ipv4) ? device.ipv4 : []; - const ipv6List = Array.isArray(device.ipv6) ? device.ipv6 : []; - if (ipv4List.length === 0 && ipv6List.length === 0) { - markInvalid(index, "You must specify either an IPv4 or IPv6 (or both) address"); - } - - ipv4List.forEach((tuple) => { - const formatted = normalizeIp(tuple, 4); - if (!formatted) return; - if (!checkIpv4(formatted)) { - markInvalid(index, formatted + " is not a valid IPv4 address"); - } - const dupe = ipv4s.get(formatted); - if (dupe !== undefined && dupe !== index) { - markInvalid(index, formatted + " is a duplicate IP"); - invalid_indices.add(dupe); - } else { - ipv4s.set(formatted, index); - } - }); - - ipv6List.forEach((tuple) => { - const formatted = normalizeIp(tuple, 6); - if (!formatted) return; - if (!checkIpv6(formatted)) { - markInvalid(index, formatted + " is not a valid IPv6 address"); - } - const dupe = ipv6s.get(formatted); - if (dupe !== undefined && dupe !== index) { - markInvalid(index, formatted + " is a duplicate IP"); - invalid_indices.add(dupe); - } else { - ipv6s.set(formatted, index); - } - }); - - const downloadMin = parseFloat(device.download_min_mbps); - const uploadMin = parseFloat(device.upload_min_mbps); - const downloadMax = parseFloat(device.download_max_mbps); - const uploadMax = parseFloat(device.upload_max_mbps); - - if (Number.isNaN(downloadMin)) { - markInvalid(index, "Download min is not a valid number"); - } else if (downloadMin < 0.1) { - markInvalid(index, "Download min must be 0.1 or more"); - } - - if (Number.isNaN(uploadMin)) { - markInvalid(index, "Upload min is not a valid number"); - } else if (uploadMin < 0.1) { - markInvalid(index, "Upload min must be 0.1 or more"); - } - - if (Number.isNaN(downloadMax)) { - markInvalid(index, "Download max is not a valid number"); - } else if (downloadMax < 0.2) { - markInvalid(index, "Download max must be 0.2 or more"); - } - - if (Number.isNaN(uploadMax)) { - markInvalid(index, "Upload max is not a valid number"); - } else if (uploadMax < 0.2) { - markInvalid(index, "Upload max must be 0.2 or more"); + ipv4List.forEach((tuple) => { + const formatted = formatIpTuple(tuple, 32); + if (!formatted || !checkIpv4(formatted)) { + errors.push(`${formatted || "IPv4 entry"} is not a valid IPv4 address.`); } }); - if (!valid) { - let errorMessage = "Invalid ShapedDevices Entries:\n"; - errors.forEach((message) => { - errorMessage += message + "\n"; - }); - alert(errorMessage); + ipv6List.forEach((tuple) => { + const formatted = formatIpTuple(tuple, 128); + if (!formatted || !checkIpv6(formatted)) { + errors.push(`${formatted || "IPv6 entry"} is not a valid IPv6 address.`); + } + }); + + if (!Number.isFinite(device.download_min_mbps) || device.download_min_mbps < 0.1) { + errors.push("Download min must be 0.1 or more."); + } + if (!Number.isFinite(device.upload_min_mbps) || device.upload_min_mbps < 0.1) { + errors.push("Upload min must be 0.1 or more."); + } + if (!Number.isFinite(device.download_max_mbps) || device.download_max_mbps < 0.2) { + errors.push("Download max must be 0.2 or more."); + } + if (!Number.isFinite(device.upload_max_mbps) || device.upload_max_mbps < 0.2) { + errors.push("Upload max must be 0.2 or more."); } - render(); - return { valid, errors }; + const sqm = device.sqm_override ? parseSqmOverride(device.sqm_override) : { down: "", up: "" }; + const validSqm = (token) => token === "" || token === "cake" || token === "fq_codel" || token === "none"; + if (!validSqm(sqm.down) || !validSqm(sqm.up)) { + errors.push("SQM overrides must be blank, cake, fq_codel, or none."); + } + + return { valid: errors.length === 0, errors }; +} + +function setModalBusy(busy) { + modal_busy = busy; + $("#sdModalSave").prop("disabled", busy || topology_editor_locked); +} + +function saveModalChanges() { + if (topology_editor_locked) return; + if (modal_busy) return; + const device = collectModalDevice(); + const validation = validateModalDevice(device); + if (!validation.valid) { + alert(validation.errors.join("\n")); + return; + } + + setModalBusy(true); + const onComplete = (msg) => { + setModalBusy(false); + if (msg && msg.ok) { + hideModal(); + if (creating_new) { + page = 0; + } + requestPage(); + return; + } + alert((msg && msg.message) ? msg.message : "Unable to save shaped device."); + }; + + if (creating_new) { + createShapedDevice(device, onComplete, () => { + setModalBusy(false); + alert("Unable to create shaped device."); + }); + return; + } + + updateShapedDevice(edit_device_id, device, onComplete, () => { + setModalBusy(false); + alert("Unable to update shaped device."); + }); +} + +function deleteDevice(deviceId) { + if (topology_editor_locked) return; + const device = findCurrentRow(deviceId); + const label = device?.device_name || deviceId || "this device"; + if (!confirm(`Delete ${label}?`)) return; + deleteShapedDevice( + deviceId, + (msg) => { + if (msg && msg.ok) { + requestPage(); + return; + } + alert((msg && msg.message) ? msg.message : "Unable to delete shaped device."); + }, + () => { + alert("Unable to delete shaped device."); + }, + ); } function start() { renderConfigMenu("devices"); - $("#btnSaveDevices").prop("disabled", true); $("#btnAddDevice").prop("disabled", true); + $("#btnSaveDevices").prop("disabled", true); const initialPageSize = parseInt($("#sdPageSize").val(), 10); if (!Number.isNaN(initialPageSize)) { page_size = initialPageSize; @@ -748,7 +745,10 @@ function start() { if (search_timer) { clearTimeout(search_timer); } - search_timer = setTimeout(() => applySearch(true), 200); + search_timer = setTimeout(() => { + page = 0; + requestPage(); + }, 200); }); $("#sdPageSize").on("change", (event) => { @@ -756,7 +756,7 @@ function start() { if (!Number.isNaN(value)) setPageSize(value); }); - $("#sdPagination").on("click", "a.page-link", (event) => { + $("#sdPagination, #sdPaginationTop").on("click", "a.page-link", (event) => { event.preventDefault(); const target = parseInt($(event.currentTarget).data("page"), 10); if (!Number.isNaN(target)) setPage(target); @@ -764,14 +764,18 @@ function start() { $("#sdTableContainer").on("click", ".sd-edit", (event) => { event.preventDefault(); - const idx = parseInt($(event.currentTarget).data("index"), 10); - if (!Number.isNaN(idx)) openEditModal(idx); + const deviceId = $(event.currentTarget).data("device-id"); + if (typeof deviceId === "string" && deviceId.length > 0) { + openEditModal(deviceId); + } }); $("#sdTableContainer").on("click", ".sd-delete", (event) => { event.preventDefault(); - const idx = parseInt($(event.currentTarget).data("index"), 10); - if (!Number.isNaN(idx)) deleteDevice(idx); + const deviceId = $(event.currentTarget).data("device-id"); + if (typeof deviceId === "string" && deviceId.length > 0) { + deleteDevice(deviceId); + } }); $("#btnAddDevice").on("click", (event) => { @@ -784,41 +788,34 @@ function start() { saveModalChanges(); }); - $("#btnSaveDevices").on("click", () => { - const validation = validateDevices(); - if (!validation.valid) { - return; - } - saveNetworkAndDevices(network_json, shaped_devices, (success, message) => { - if (success) { - alert("Configuration saved successfully!"); - } else { - alert("Failed to save configuration: " + message); - } - }); + $("#btnSaveDevices").on("click", (event) => { + event.preventDefault(); + requestPage(); }); - loadNetworkJson( - (njs) => { - network_json = njs; - loadAllShapedDevices( - (data) => { - shaped_devices = Array.isArray(data) ? data : []; - invalid_indices = new Set(); - applySearch(true); - $("#btnSaveDevices").prop("disabled", false); - $("#btnAddDevice").prop("disabled", false); + loadConfig( + (msg) => { + const config = msg?.data || window.config || {}; + topology_editor_locked = topologyEditorsLocked(config); + topology_editor_lock_message = topologyEditorsLockMessage(config); + applyEditorLockState(); + + loadNetworkJson( + (njs) => { + network_json = njs; + requestPage(); + applyEditorLockState(); }, () => { $("#sdTableContainer").html( - "
Failed to load shaped devices.
", + "
Failed to load network configuration.
", ); }, ); }, () => { $("#sdTableContainer").html( - "
Failed to load network configuration.
", + "
Failed to load configuration.
", ); }, ); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_network.js b/src/rust/lqosd/src/node_manager/js_build/src/config_network.js index 38a54565..24518684 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_network.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_network.js @@ -1,13 +1,37 @@ import { + activeTopologySourceIntegrations, loadAllShapedDevices, + loadConfig, loadNetworkJson, renderConfigMenu, saveNetworkAndDevices, + saveNetworkJsonOnly, + topologyEditorsLockMessage, + topologyEditorsLocked, } from "./config/config_helper"; let network_json = null; let shaped_devices = null; +let shaped_devices_dirty = false; const MIN_NODE_MBPS = 0.1; +let topology_editor_locked = false; +let topology_editor_lock_message = ""; + +function editorButtonAttrs() { + return topology_editor_locked ? " disabled aria-disabled='true'" : ""; +} + +function applyEditorLockState() { + $("#btnSaveNetwork").prop("disabled", topology_editor_locked); + $("#netjson").toggleClass("opacity-75", topology_editor_locked); + + const banner = $("#networkEditorLock"); + if (topology_editor_locked && topology_editor_lock_message) { + banner.removeClass("d-none").text(topology_editor_lock_message); + } else { + banner.addClass("d-none").text(""); + } +} function renderNetworkNode(level, depth) { let html = `
`; @@ -15,22 +39,23 @@ function renderNetworkNode(level, depth) { for (const [key, value] of Object.entries(level)) { const isVirtual = value && value.virtual === true; + const actionAttrs = editorButtonAttrs(); // Node header with actions html += `
`; html += `
${key}${isVirtual ? ' Virtual' : ''}
`; html += `
`; if (depth > 0) { - html += ``; } - html += ``; - html += ``; - html += ``; html += `
`; @@ -38,11 +63,11 @@ function renderNetworkNode(level, depth) { // Node details html += `
`; html += `Download: ${value.downloadBandwidthMbps} Mbps`; - html += ``; html += `Upload: ${value.uploadBandwidthMbps} Mbps`; - html += ``; html += `
`; @@ -66,6 +91,7 @@ function renderNetwork() { } function promoteNode(nodeId) { + if (topology_editor_locked) return; console.log("Promoting ", nodeId); let previousParent = null; @@ -89,6 +115,7 @@ function promoteNode(nodeId) { } function nodeSpeedChange(nodeId, direction) { + if (topology_editor_locked) return; let newVal = prompt(`New ${direction === 'd' ? 'download' : 'upload'} value in Mbps`); newVal = parseFloat(newVal); if (isNaN(newVal)) { @@ -121,6 +148,7 @@ function nodeSpeedChange(nodeId, direction) { } function toggleVirtualNode(nodeId) { + if (topology_editor_locked) return; function iterate(tree) { for (const [key, value] of Object.entries(tree)) { if (key === nodeId) { @@ -138,6 +166,7 @@ function toggleVirtualNode(nodeId) { } function deleteNode(nodeId) { + if (topology_editor_locked) return; if (!confirm(`Are you sure you want to delete ${nodeId} and all its children?`)) { return; } @@ -188,6 +217,7 @@ function deleteNode(nodeId) { let sd = shaped_devices[i]; if (deleteList.indexOf(sd.parent_node) > -1) { sd.parent_node = ""; + shaped_devices_dirty = true; } } alert("Because there was no obvious parent, you may have to fix some parenting in your Shaped Devices list."); @@ -197,6 +227,7 @@ function deleteNode(nodeId) { let sd = shaped_devices[i]; if (deleteList.indexOf(sd.parent_node) > -1) { sd.parent_node = deleteParent; + shaped_devices_dirty = true; } } } @@ -207,6 +238,7 @@ function deleteNode(nodeId) { } function renameNode(nodeId) { + if (topology_editor_locked) return; let newName = prompt("New node name?"); if (!newName || newName.trim() === "") { alert("Please enter a valid name"); @@ -254,6 +286,7 @@ function renameNode(nodeId) { let sd = shaped_devices[i]; if (sd.parent_node === nodeId) { sd.parent_node = newName; + shaped_devices_dirty = true; } } @@ -275,34 +308,71 @@ function start() { // Add save button handler // Add network save button handler $("#btnSaveNetwork").on('click', () => { + if (topology_editor_locked) { + alert(topology_editor_lock_message); + return; + } // Validate network structure if (!network_json || Object.keys(network_json).length === 0) { alert("Network configuration is empty"); return; } - // Save with empty shaped_devices since we're only saving network - saveNetworkAndDevices(network_json, shaped_devices, (success, message) => { + const onComplete = (success, message) => { if (success) { + shaped_devices_dirty = false; alert(message); } else { alert("Failed to save network configuration: " + message); } - }); + }; + + if (shaped_devices_dirty) { + saveNetworkAndDevices(network_json, shaped_devices, onComplete); + return; + } + + saveNetworkJsonOnly(network_json, onComplete); }); - // Load network data - loadAllShapedDevices((data) => { - shaped_devices = data; - loadNetworkJson((njs) => { - network_json = njs; - renderNetwork(); - }, () => { - alert("Failed to load network configuration"); - }); - }, () => { - alert("Failed to load shaped devices"); - }); + loadConfig( + (msg) => { + const config = msg?.data || window.config || {}; + topology_editor_locked = topologyEditorsLocked(config); + topology_editor_lock_message = topologyEditorsLockMessage(config); + const activeIntegrations = activeTopologySourceIntegrations(config); + if (!topology_editor_lock_message && activeIntegrations.length > 0) { + topology_editor_lock_message = `Editing is disabled because these integrations are the source of truth: ${activeIntegrations.join(", ")}.`; + } + applyEditorLockState(); + + const finishLoad = () => { + loadNetworkJson((njs) => { + network_json = njs; + renderNetwork(); + applyEditorLockState(); + }, () => { + alert("Failed to load network configuration"); + }); + }; + + if (topology_editor_locked) { + shaped_devices = []; + finishLoad(); + return; + } + + loadAllShapedDevices((data) => { + shaped_devices = data; + finishLoad(); + }, () => { + alert("Failed to load shaped devices"); + }); + }, + () => { + alert("Failed to load configuration"); + }, + ); } $(document).ready(start); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_treeguard.js b/src/rust/lqosd/src/node_manager/js_build/src/config_treeguard.js index 25e0fe28..866ca66a 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_treeguard.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_treeguard.js @@ -1,5 +1,5 @@ import { - loadAllShapedDevices, + loadAllCircuitDirectoryRows, loadConfig, loadNetworkJson, renderConfigMenu, @@ -8,7 +8,7 @@ import { import {defaultTreeguardConfig, ensureTreeguardConfig} from "./config/treeguard_defaults"; let networkData = null; -let shapedDevices = null; +let circuitRows = null; let selectedNodes = []; let selectedCircuits = []; @@ -53,19 +53,19 @@ function loadNetworkData() { function loadCircuitsData() { return new Promise((resolve, reject) => { - loadAllShapedDevices( + loadAllCircuitDirectoryRows( (data) => { if (!Array.isArray(data)) { - console.warn("Shaped devices response was not an array:", data); - shapedDevices = []; + console.warn("Circuit directory response was not an array:", data); + circuitRows = []; } else { - shapedDevices = data; + circuitRows = data; } populateCircuitSelector(); resolve(); }, (err) => { - console.error("Error loading shaped devices:", err); + console.error("Error loading circuit directory:", err); alert("Failed to load circuit list. You can still add circuits manually."); reject(err); }, @@ -108,23 +108,24 @@ function populateCircuitSelector() { const selector = document.getElementById("circuitSelector"); selector.innerHTML = ''; - const circuits = new Set(); - if (Array.isArray(shapedDevices)) { - shapedDevices.forEach((device) => { - if (device && typeof device.circuit_id === "string" && device.circuit_id.trim() !== "") { - circuits.add(device.circuit_id.trim()); - } - }); + if (!Array.isArray(circuitRows)) { + return; } - Array.from(circuits) - .sort((a, b) => a.localeCompare(b)) - .forEach((circuitId) => { - const option = document.createElement("option"); - option.value = circuitId; - option.textContent = circuitId; - selector.appendChild(option); - }); + circuitRows.forEach((row) => { + const circuitId = (row?.circuit_id ?? "").trim(); + if (!circuitId) { + return; + } + const circuitName = (row?.circuit_name ?? "").trim(); + const display = circuitName && circuitName !== circuitId + ? `${circuitName} (${circuitId})` + : circuitId; + const option = document.createElement("option"); + option.value = circuitId; + option.textContent = display; + selector.appendChild(option); + }); } function addNode() { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/configuration.js b/src/rust/lqosd/src/node_manager/js_build/src/configuration.js index 8cb95635..c724f3e7 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/configuration.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/configuration.js @@ -1,12 +1,23 @@ // Mostly ported from the original node manager import { get_ws_client } from "./pubsub/ws"; +import { + activeTopologySourceIntegrations, + loadAllShapedDevices, + topologyEditorsLockMessage, + topologyEditorsLocked, +} from "./config/config_helper"; let nics = null; let lqosd_config = null; let shaped_devices = null; let network_json = null; let qoo_profiles = null; +let shaped_devices_loaded = false; +let shaped_devices_loading = false; +let shaped_devices_dirty = false; const MIN_NODE_MBPS = 0.1; +let topology_editor_locked = false; +let topology_editor_lock_message = ""; const wsClient = get_ws_client(); @@ -193,6 +204,33 @@ function ensureSplynxSection(config) { return config; } +function topologyEditDisabledAttr() { + return topology_editor_locked ? " disabled aria-disabled='true'" : ""; +} + +function applyTopologyEditorLockState() { + $("#btnSaveNetDevices").prop("disabled", topology_editor_locked); + $("#btnFlattenNetwork").prop("disabled", topology_editor_locked); + $("#btnAddNetworkNode").prop("disabled", topology_editor_locked); + $("#btnAddShapedDeviceRow").prop("disabled", topology_editor_locked || !shaped_devices_loaded); + $("#njsNewNodeName, #njsNewNodeDown, #njsNewNodeUp").prop("disabled", topology_editor_locked); + $("#netjson, #shapedDeviceTable").toggleClass("opacity-75", topology_editor_locked); + + const targets = [ + "#legacyTopologyEditorLockSummary", + "#legacyNetworkEditorLock", + "#legacyDevicesEditorLock", + ]; + for (const selector of targets) { + const element = $(selector); + if (topology_editor_locked && topology_editor_lock_message) { + element.removeClass("d-none").text(topology_editor_lock_message); + } else { + element.addClass("d-none").text(""); + } + } +} + function doBindings() { let active = true; @@ -485,23 +523,25 @@ function saveConfig() { function iterateNetJson(level, depth) { let html = "
"; + const devices = Array.isArray(shaped_devices) ? shaped_devices : []; + const disabledAttr = topologyEditDisabledAttr(); for (const [key, value] of Object.entries(level)) { const isVirtual = value && value.virtual === true; html += "
"; html += "" + key + ""; if (depth > 0) { - html += " "; + html += " "; } - html += " "; - html += " "; + html += " "; + html += " "; html += "
"; - html += "Download: " + value.downloadBandwidthMbps + " Mbps
"; - html += "Upload: " + value.uploadBandwidthMbps + " Mbps
"; + html += "Download: " + value.downloadBandwidthMbps + " Mbps
"; + html += "Upload: " + value.uploadBandwidthMbps + " Mbps
"; html += "Virtual: " + (isVirtual ? " Yes" : "No") + - "
"; + "
"; let num_children = 0; - for (let i=0; i" + html = "" } else { - html = "" + html = "" } return html; } function makeSheetNumberBox(rowId, boxId, value) { - let html = "" + let html = "" return html; } @@ -753,14 +818,14 @@ function separatedIpArray(rowId, boxId, value) { if (val.length > 0) { val = val.substring(0, val.length-2); } - html += ""; + html += ""; html += ""; return html; } function nodeDropDown(rowId, boxId, selectedNode) { let html = ""; - html += ""; function iterate(data, level) { let html = ""; @@ -800,7 +865,70 @@ function validNodeList() { return nodes; } +function renderShapedDevicesPlaceholder(message, tone="secondary") { + $("#shapedDeviceTable").html( + "
" + message + "
", + ); +} + +function setShapedDevicesControlsEnabled(enabled) { + $("#btnAddShapedDeviceRow").prop("disabled", !enabled || topology_editor_locked); +} + +function ensureShapedDevicesLoaded(onReady) { + if (shaped_devices_loaded) { + if (onReady) onReady(); + return; + } + if (shaped_devices_loading) { + if (onReady) { + const waitForLoad = () => { + if (shaped_devices_loaded) { + onReady(); + } else if (shaped_devices_loading) { + window.setTimeout(waitForLoad, 100); + } + }; + waitForLoad(); + } + return; + } + + shaped_devices_loading = true; + setShapedDevicesControlsEnabled(false); + renderShapedDevicesPlaceholder( + "
Loading shaped devices...
", + "info", + ); + loadAllShapedDevices( + (data) => { + shaped_devices = data; + shaped_devices_loaded = true; + shaped_devices_loading = false; + shaped_devices_dirty = false; + setShapedDevicesControlsEnabled(true); + shapedDevices(); + RenderNetworkJson(); + applyTopologyEditorLockState(); + if (onReady) onReady(); + }, + () => { + shaped_devices_loading = false; + setShapedDevicesControlsEnabled(false); + renderShapedDevicesPlaceholder("Unable to load shaped devices.", "danger"); + applyTopologyEditorLockState(); + }, + ); +} + function newSdRow() { + if (topology_editor_locked) return; + if (!shaped_devices_loaded) { + ensureShapedDevicesLoaded(() => { + newSdRow(); + }); + return; + } shaped_devices.unshift({ circuit_id: "new_circuit", circuit_name: "new circuit", @@ -815,11 +943,17 @@ function newSdRow() { upload_max_mbps: 100, comment: "", }); + shaped_devices_dirty = true; shapedDevices(); } function deleteSdRow(id) { + if (topology_editor_locked) return; + if (!shaped_devices_loaded) { + return; + } shaped_devices.splice(id, 1); + shaped_devices_dirty = true; shapedDevices(); } @@ -1127,6 +1261,29 @@ function ipAddressesToTuple(ip) { } function saveNetAndDevices() { + if (topology_editor_locked) { + alert(topology_editor_lock_message); + return; + } + if (!shaped_devices_loaded || !shaped_devices_dirty) { + sendWsRequest( + "UpdateNetworkJsonOnlyResult", + { UpdateNetworkJsonOnly: { network_json: network_json } }, + (msg) => { + if (msg && msg.ok) { + alert("Configuration saved"); + } else { + const message = msg && msg.message ? msg.message : "Error"; + alert("Configuration not saved: " + message); + } + }, + () => { + alert("Configuration not saved: Error"); + }, + ); + return; + } + let isValid = validateSd().valid; if (!isValid) { alert("Validation errors in ShapedDevices. Not Saving"); @@ -1162,6 +1319,7 @@ function saveNetAndDevices() { { UpdateNetworkAndDevices: submission }, (msg) => { if (msg && msg.ok) { + shaped_devices_dirty = false; alert("Configuration saved"); } else { const message = msg && msg.message ? msg.message : "Error"; @@ -1175,6 +1333,10 @@ function saveNetAndDevices() { } function shapedDevices() { + if (!shaped_devices_loaded) { + renderShapedDevicesPlaceholder("Open the Shaped Devices tab to load and edit device rows."); + return; + } let html = ""; html += ""; html += ""; @@ -1195,7 +1357,7 @@ function shapedDevices() { html += makeSheetNumberBox(i, "download_max_mbps", row.download_max_mbps); html += makeSheetNumberBox(i, "upload_max_mbps", row.upload_max_mbps); html += makeSheetBox(i, "comment", row.comment, true); - html += ""; + html += ""; html += ""; } @@ -1301,6 +1463,15 @@ function start() { window.nodeSpeedChange = nodeSpeedChange; window.toggleVirtualNode = toggleVirtualNode; window.deleteSdRow = deleteSdRow; + renderShapedDevicesPlaceholder("Open the Shaped Devices tab to load and edit device rows."); + $("#v-pills-shapeddevs-tab").on("shown.bs.tab", () => { + ensureShapedDevicesLoaded(); + }); + $("#shapedDeviceTable").on("input change", "input, select, textarea", () => { + if (shaped_devices_loaded) { + shaped_devices_dirty = true; + } + }); // Old display(); @@ -1327,6 +1498,13 @@ function start() { { GetConfig: {} }, (cfgMsg) => { lqosd_config = ensureSplynxSection(cfgMsg.data); + topology_editor_locked = topologyEditorsLocked(lqosd_config); + topology_editor_lock_message = topologyEditorsLockMessage(lqosd_config); + const activeIntegrations = activeTopologySourceIntegrations(lqosd_config); + if (!topology_editor_lock_message && activeIntegrations.length > 0) { + topology_editor_lock_message = `Editing is disabled because these integrations are the source of truth: ${activeIntegrations.join(", ")}.`; + } + applyTopologyEditorLockState(); console.log("Bindings Done"); sendWsRequest( "ListNics", @@ -1352,18 +1530,9 @@ function start() { { NetworkJson: {} }, (netMsg) => { network_json = netMsg.data; - sendWsRequest( - "AllShapedDevices", - { AllShapedDevices: {} }, - (sdMsg) => { - shaped_devices = sdMsg.data; - shapedDevices(); - RenderNetworkJson(); - }, - () => { - alert("Unable to load shaped devices"); - }, - ); + shapedDevices(); + RenderNetworkJson(); + applyTopologyEditorLockState(); }, () => { alert("Unable to load network.json"); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js b/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js index 10763d34..264a960d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/cpu_weights.js @@ -306,14 +306,17 @@ function fetchCircuits() { } renderCircuits(msg.data); }); + const request = { + cpu: cpu, + direction: dir, + page: state.page, + page_size: state.pageSize, + }; + if (state.searchTerm && state.searchTerm.trim() !== "") { + request.search = state.searchTerm.trim(); + } wsClient.send({ - CpuAffinityCircuits: { - cpu: cpu, - direction: dir, - page: state.page, - page_size: state.pageSize, - search: null, - } + CpuAffinityCircuits: request }); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/children_sankey_down.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/children_sankey_down.js index 8a8f6ee0..8c2d1135 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/children_sankey_down.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/children_sankey_down.js @@ -61,7 +61,12 @@ export class ShaperChildrenDown extends BaseDashlet { tooltip(){ return "
Child Throughput

Top child nodes by download throughput."; } subscribeTo(){ return ["TreeSummary", "TreeSummaryL2"]; } buildContainer(){ let b = super.buildContainer(); b.appendChild(this.graphDiv()); return b; } - setup(){ this.graph = new ChildrenSankeyGraph(this.graphDivId(), 'down'); if (this.last) this.graph.update(this.last); } + setup(){ + this.traceRender("setup-start"); + this.graph = new ChildrenSankeyGraph(this.graphDivId(), 'down'); + this.traceRender("setup-complete", { graphId: this.graphDivId() }); + if (this.last) this.graph.update(this.last); + } _showEmpty(show, msg = "No recent data"){ const card = document.getElementById(this.id); if (!card) return; @@ -144,8 +149,15 @@ export class ShaperChildrenDown extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { - this.graph.update({ nodes, links }); - return true; + this.traceRender("onMessage", { eventName: "TreeSummaryL2", nodeCount: nodes.length, linkCount: links.length }); + try { + this.graph.update({ nodes, links }); + this.traceRender("update-ok", { eventName: "TreeSummaryL2", nodeCount: nodes.length, linkCount: links.length }); + return true; + } catch (err) { + this.traceRender("update-error", { eventName: "TreeSummaryL2", error: err && err.message ? err.message : String(err) }); + throw err; + } } return false; } @@ -177,7 +189,14 @@ export class ShaperChildrenDown extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { this.last = rows; - this.graph.update(rows); + this.traceRender("onMessage", { eventName: msg.event, rowCount: rows.length }); + try { + this.graph.update(rows); + this.traceRender("update-ok", { eventName: msg.event, rowCount: rows.length }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) }); + throw err; + } } } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/children_sankey_up.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/children_sankey_up.js index 9b909704..904a7adc 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/children_sankey_up.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/children_sankey_up.js @@ -52,7 +52,12 @@ export class ShaperChildrenUp extends BaseDashlet { tooltip(){ return "

Child Throughput

Top child nodes by upload throughput."; } subscribeTo(){ return ["TreeSummary", "TreeSummaryL2"]; } buildContainer(){ let b=super.buildContainer(); b.appendChild(this.graphDiv()); return b; } - setup(){ this.graph = new ChildrenSankeyGraphUp(this.graphDivId()); if (this.last) this.graph.update(this.last); } + setup(){ + this.traceRender("setup-start"); + this.graph = new ChildrenSankeyGraphUp(this.graphDivId()); + this.traceRender("setup-complete", { graphId: this.graphDivId() }); + if (this.last) this.graph.update(this.last); + } _showEmpty(show, msg = "No recent data"){ const card = document.getElementById(this.id); if (!card) return; @@ -134,8 +139,15 @@ export class ShaperChildrenUp extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { - this.graph.update({ nodes, links }); - return true; + this.traceRender("onMessage", { eventName: "TreeSummaryL2", nodeCount: nodes.length, linkCount: links.length }); + try { + this.graph.update({ nodes, links }); + this.traceRender("update-ok", { eventName: "TreeSummaryL2", nodeCount: nodes.length, linkCount: links.length }); + return true; + } catch (err) { + this.traceRender("update-error", { eventName: "TreeSummaryL2", error: err && err.message ? err.message : String(err) }); + throw err; + } } return false; } @@ -164,7 +176,14 @@ export class ShaperChildrenUp extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { this.last = rows; - this.graph.update(rows); + this.traceRender("onMessage", { eventName: msg.event, rowCount: rows.length }); + try { + this.graph.update(rows); + this.traceRender("update-ok", { eventName: msg.event, rowCount: rows.length }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) }); + throw err; + } } } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/cpu_dash.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/cpu_dash.js index 4dccfab8..76a90ace 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/cpu_dash.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/cpu_dash.js @@ -30,7 +30,11 @@ export class CpuDash extends BaseDashlet{ setup() { super.setup(); + this.traceRender("setup-start"); this.graph = new CpuHistogram(this.graphDivId()); + this.traceRender("setup-complete", { + graphId: this.graphDivId(), + }); } setupZoomed() { @@ -44,9 +48,24 @@ export class CpuDash extends BaseDashlet{ onMessage(msg) { if (msg.event === "Cpu") { - this.graph.update(msg.data); - if (this.zoomGraph) { - this.zoomGraph.update(msg.data); + this.traceRender("onMessage", { + eventName: msg.event, + cpuRows: Array.isArray(msg.data) ? msg.data.length : 0, + }); + try { + this.graph.update(msg.data); + if (this.zoomGraph) { + this.zoomGraph.update(msg.data); + } + this.traceRender("update-ok", { + eventName: msg.event, + }); + } catch (err) { + this.traceRender("update-error", { + eventName: msg.event, + error: err && err.message ? err.message : String(err), + }); + throw err; } } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js index c60df37f..49e6ba1a 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_heatmap_panels.js @@ -1,13 +1,11 @@ import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; import {disposeTooltipsWithin, enableTooltipsWithin} from "../lq_js_common/helpers/tooltips"; import {colorByQoqScore, colorByRetransmitPct, colorByRttMs} from "../helpers/color_scales"; -import {getSiteIdMap, linkToCircuit, linkToSite} from "../executive_utils"; +import {badgeForEntityKind, linkToExecutiveMetricRow} from "../executive_utils"; import { - buildHeatmapRows, colorByCapacity, formatLatest, latestValue, - nonNullCount, heatRow, rttHeatRow, retransmitHeatRow, @@ -141,6 +139,69 @@ function qoqHeatRow(label, badge, blocks, link = null) { `; } +function executiveBadge(row) { + return badgeForEntityKind(row?.entity_kind); +} + +function executiveSplitBlocks(row) { + return { + download: row?.split_blocks?.download || [], + upload: row?.split_blocks?.upload || [], + }; +} + +function executiveQoqBlocks(row) { + return { + download_total: row?.split_blocks?.download || [], + upload_total: row?.split_blocks?.upload || [], + }; +} + +function executiveRttBlocks(row) { + return { + rtt: row?.rtt_blocks?.rtt || [], + rtt_p50_down: row?.rtt_blocks?.dl_p50 || [], + rtt_p90_down: row?.rtt_blocks?.dl_p90 || [], + rtt_p50_up: row?.rtt_blocks?.ul_p50 || [], + rtt_p90_up: row?.rtt_blocks?.ul_p90 || [], + }; +} + +function executiveRetransmitBlocks(row) { + return { + retransmit: row?.scalar_blocks?.values || [], + retransmit_down: row?.split_blocks?.download || [], + retransmit_up: row?.split_blocks?.upload || [], + }; +} + +function latestUtilization(row) { + const split = executiveSplitBlocks(row); + return Math.max( + latestValue(split.download) ?? Number.NEGATIVE_INFINITY, + latestValue(split.upload) ?? Number.NEGATIVE_INFINITY, + ); +} + +function mergedUtilizationRows(summary) { + const byKey = new Map(); + [...(summary?.top_download || []), ...(summary?.top_upload || [])].forEach((row) => { + if (!row?.row_key || byKey.has(row.row_key)) { + return; + } + byKey.set(row.row_key, row); + }); + return [...byKey.values()] + .sort((left, right) => { + const latestDiff = latestUtilization(right) - latestUtilization(left); + if (Number.isFinite(latestDiff) && latestDiff !== 0) { + return latestDiff; + } + return String(left?.label || "").localeCompare(String(right?.label || "")); + }) + .slice(0, MAX_HEATMAP_ROWS); +} + class ExecutiveHeatmapBase extends BaseDashlet { constructor(slot) { super(slot); @@ -155,12 +216,12 @@ class ExecutiveHeatmapBase extends BaseDashlet { } canBeSlowedDown() { return true; } - subscribeTo() { return ["ExecutiveHeatmaps"]; } + subscribeTo() { return ["ExecutiveDashboardSummary"]; } onMessage(msg) { - if (msg.event === "ExecutiveHeatmaps") { + if (msg.event === "ExecutiveDashboardSummary") { this.lastData = msg.data; - window.executiveHeatmapData = msg.data; + window.executiveDashboardSummary = msg.data; this.render(); } } @@ -170,8 +231,8 @@ class ExecutiveHeatmapBase extends BaseDashlet { window.addEventListener("colorBlindModeChanged", this._colorBlindListener); this._colorBlindBound = true; } - if (window.executiveHeatmapData) { - this.lastData = window.executiveHeatmapData; + if (window.executiveDashboardSummary) { + this.lastData = window.executiveDashboardSummary; this.render(); } } @@ -282,167 +343,101 @@ class ExecutiveMetricHeatmapBase extends ExecutiveHeatmapBase { render() { const target = document.getElementById(this._contentId); if (!target) return; - const rows = buildHeatmapRows(this.lastData || {}); - const filteredRows = this.config.hideAsns ? rows.filter(row => row.badge !== "ASN") : rows; - if (!filteredRows.length) { + const summary = this.lastData || {}; + const rows = this.summaryRows(summary); + if (!rows.length) { target.innerHTML = this.emptyCard(); return; } - const sorted = filteredRows.slice().sort((a, b) => this.metricSort(a, b)); - const limited = sorted.slice(0, MAX_HEATMAP_ROWS); - getSiteIdMap().then((siteIdMap) => { - const activeTarget = document.getElementById(this._contentId); - if (!activeTarget) return; - const metricRows = limited.map(row => { - const link = row.badge === "Circuit" - ? linkToCircuit(row.circuit_id) - : row.badge === "Site" - ? linkToSite(row.site_name || row.label, siteIdMap) - : null; - if (this.config.metricKey === "rtt") { - return rttHeatRow( - row.label, - row.badge, - row.blocks, - this.config.colorFn, - this.config.formatFn, - link - ); - } - if (this.config.metricKey === "retransmit") { - return retransmitHeatRow( - row.label, - row.badge, - row.blocks, - this.config.colorFn, - this.config.formatFn, - link - ); - } - if (this.config.metricKey === "utilization") { - return utilizationHeatRow( - row.label, - row.badge, - row.blocks, - this.config.colorFn, - this.config.formatFn, - link - ); - } - if (this.config.metricKey === "qoo") { - return qoqHeatRow( - row.label, - row.badge, - row.qoq_blocks, - link, - ); - } - return heatRow( + const activeTarget = document.getElementById(this._contentId); + if (!activeTarget) return; + const metricRows = rows.map((row) => { + const badge = executiveBadge(row); + const link = linkToExecutiveMetricRow(row); + if (this.config.metricKey === "rtt") { + return rttHeatRow( row.label, - row.badge, - row.blocks[this.config.metricKey] || [], + badge, + executiveRttBlocks(row), this.config.colorFn, this.config.formatFn, - link + link, ); - }).join(""); - const linkIcon = this.config.link - ? `` - : ""; - const isQooMetric = this.config.metricKey === "qoo"; - const titleLabel = isQooMetric - ? `QoO${qooInfoIconHtml()} Heatmap` - : this.config.title; - const titleHtml = this.config.link - ? `${titleLabel}${linkIcon}` - : `${titleLabel}`; - disposeTooltipsWithin(activeTarget); - activeTarget.innerHTML = ` -

-
-
-
${titleHtml}
-
-
${metricRows}
+ } + if (this.config.metricKey === "retransmit") { + return retransmitHeatRow( + row.label, + badge, + executiveRetransmitBlocks(row), + this.config.colorFn, + this.config.formatFn, + link, + ); + } + if (this.config.metricKey === "utilization") { + return utilizationHeatRow( + row.label, + badge, + executiveSplitBlocks(row), + this.config.colorFn, + this.config.formatFn, + link, + ); + } + if (this.config.metricKey === "qoo") { + return qoqHeatRow( + row.label, + badge, + executiveQoqBlocks(row), + link, + ); + } + return heatRow( + row.label, + badge, + row?.scalar_blocks?.values || [], + this.config.colorFn, + this.config.formatFn, + link, + ); + }).join(""); + const linkIcon = this.config.link + ? `` + : ""; + const isQooMetric = this.config.metricKey === "qoo"; + const titleLabel = isQooMetric + ? `QoO${qooInfoIconHtml()} Heatmap` + : this.config.title; + const titleHtml = this.config.link + ? `${titleLabel}${linkIcon}` + : `${titleLabel}`; + disposeTooltipsWithin(activeTarget); + activeTarget.innerHTML = ` +
+
+
+
${titleHtml}
+
${metricRows}
- `; - enableTooltipsWithin(activeTarget); - }); +
+ `; + enableTooltipsWithin(activeTarget); } - metricSort(a, b) { - const metricKey = this.config.metricKey; - const isUtilization = metricKey === "utilization"; - const isQoo = metricKey === "qoo"; - const aDownVals = a.blocks.download || []; - const aUpVals = a.blocks.upload || []; - const bDownVals = b.blocks.download || []; - const bUpVals = b.blocks.upload || []; - - const aVals = isUtilization ? [] : (a.blocks[metricKey] || []); - const bVals = isUtilization ? [] : (b.blocks[metricKey] || []); - - const latestQoo = (blocks) => { - if (!blocks) return null; - const vals = [ - latestValue(blocks.download_total), - latestValue(blocks.upload_total), - ].filter((v) => v !== null && v !== undefined); - if (!vals.length) return null; - const sum = vals.reduce((x, y) => x + y, 0); - return sum / vals.length; - }; - const countQoo = (blocks) => { - if (!blocks) return 0; - return Math.max( - nonNullCount(blocks.download_total), - nonNullCount(blocks.upload_total), - ); - }; - - const aLatest = isQoo - ? latestQoo(a.qoq_blocks) - : isUtilization - ? Math.max(latestValue(aDownVals) ?? -Infinity, latestValue(aUpVals) ?? -Infinity) - : latestValue(aVals); - const bLatest = isQoo - ? latestQoo(b.qoq_blocks) - : isUtilization - ? Math.max(latestValue(bDownVals) ?? -Infinity, latestValue(bUpVals) ?? -Infinity) - : latestValue(bVals); - const aCount = isQoo - ? countQoo(a.qoq_blocks) - : isUtilization - ? Math.max(nonNullCount(aDownVals), nonNullCount(aUpVals)) - : nonNullCount(aVals); - const bCount = isQoo - ? countQoo(b.qoq_blocks) - : isUtilization - ? Math.max(nonNullCount(bDownVals), nonNullCount(bUpVals)) - : nonNullCount(bVals); - const countWeight = this.config.countWeight || 0; - const minSamples = this.config.minSamples || 0; - - const score = (latest, count) => { - if (latest === null || latest === undefined) return -Infinity; - let base = latest; - if (isQoo) { - // QoO: higher is better; show worst first by inverting for sort score. - base = -latest; - } - let s = base + countWeight * (count / 15); - if (count < minSamples) { - s -= 1000; // Heavily de-prioritize sparse data - } - return s; - }; - - const aScore = score(aLatest, aCount); - const bScore = score(bLatest, bCount); - if (bScore !== aScore) return bScore - aScore; - if (bCount !== aCount) return bCount - aCount; - return (a.label || "").localeCompare(b.label || ""); + summaryRows(summary) { + switch (this.config.metricKey) { + case "rtt": + return (summary?.top_rtt || []).slice(0, MAX_HEATMAP_ROWS); + case "retransmit": + return (summary?.top_retransmit || []).slice(0, MAX_HEATMAP_ROWS); + case "utilization": + return mergedUtilizationRows(summary); + case "qoo": + return (summary?.top_qoo || []).slice(0, MAX_HEATMAP_ROWS); + default: + return []; + } } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js index e41474f1..7db2c186 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js @@ -18,7 +18,7 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { canBeSlowedDown() { return true; } title() { return "Network Snapshot"; } tooltip() { return "Headline health metrics for the executive view."; } - subscribeTo() { return ["ExecutiveHeatmaps", "Throughput"]; } + subscribeTo() { return ["ExecutiveDashboardSummary", "Throughput"]; } buildContainer() { const container = super.buildContainer(); @@ -36,14 +36,14 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { } setup() { - if (window.executiveHeatmapData) { + if (window.executiveDashboardSummary) { this.render(); } } onMessage(msg) { - if (msg.event === "ExecutiveHeatmaps") { - window.executiveHeatmapData = msg.data; + if (msg.event === "ExecutiveDashboardSummary") { + window.executiveDashboardSummary = msg.data; this.render(); return; } @@ -54,7 +54,7 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { } render() { - const header = window.executiveHeatmapData?.header; + const header = window.executiveDashboardSummary?.header; const target = document.getElementById(this._contentId); if (!target) return; if (!header) { @@ -90,6 +90,7 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { const items = [ { label: "HTB", value: formatCount(header.htb_queue_count) }, { label: "CAKE", value: formatCount(header.cake_queue_count) }, + { label: "fq-codel", value: formatCount(header.fq_codel_queue_count) }, ]; return this.groupCard("Queues", "fa-stream", "text-secondary", items, false, true); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/queue_stats_total.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/queue_stats_total.js index 1b1d56d5..ab4a4877 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/queue_stats_total.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/queue_stats_total.js @@ -5,6 +5,7 @@ import {periodNameToSeconds} from "../helpers/time_periods"; import {get_ws_client} from "../pubsub/ws"; const wsClient = get_ws_client(); +const LIVE_SAMPLE_LIMIT = 300; const listenOnceForSeconds = (eventName, seconds, onSuccess, onError) => { const wrapped = (msg) => { @@ -29,6 +30,7 @@ export class QueueStatsTotalDash extends DashletBaseInsight { super(slot); this.historyGraph = null; this.historyPeriod = null; + this.backgroundSamples = []; } title() { @@ -43,6 +45,14 @@ export class QueueStatsTotalDash extends DashletBaseInsight { return [ "QueueStatsTotal" ]; } + keepAliveWhenHidden() { + return true; + } + + keepSubscribedWhenHidden() { + return true; + } + historyGraphDivId() { return this.id + "_history_graph"; } @@ -72,12 +82,39 @@ export class QueueStatsTotalDash extends DashletBaseInsight { onMessage(msg) { if (msg.event === "QueueStatsTotal" && window.timePeriods.activePeriod === "Live") { - this.graph.update(msg.marks, msg.drops); + this.#applyLiveSample(msg.marks, msg.drops); } } + onBackgroundMessage(msg) { + if (msg.event !== "QueueStatsTotal" || window.timePeriods.activePeriod !== "Live") { + return; + } + this.backgroundSamples.push({ + marks: msg.marks, + drops: msg.drops, + }); + if (this.backgroundSamples.length > LIVE_SAMPLE_LIMIT) { + this.backgroundSamples.shift(); + } + } + + flushBackgroundMessages() { + if (window.timePeriods.activePeriod !== "Live" || this.backgroundSamples.length === 0) { + this.backgroundSamples = []; + return false; + } + for (let i = 0; i < this.backgroundSamples.length; i++) { + const sample = this.backgroundSamples[i]; + this.#applyLiveSample(sample.marks, sample.drops); + } + this.backgroundSamples = []; + return true; + } + onTimeChange() { super.onTimeChange(); + this.backgroundSamples = []; const liveGraph = document.getElementById(this.graphDivId()); const historyGraph = document.getElementById(this.historyGraphDivId()); @@ -141,4 +178,8 @@ export class QueueStatsTotalDash extends DashletBaseInsight { ); wsClient.send({ LtsCake: { seconds } }); } + + #applyLiveSample(marks, drops) { + this.graph.update(marks, drops); + } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/ram_dash.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/ram_dash.js index 2891330c..eb6337a4 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/ram_dash.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/ram_dash.js @@ -31,7 +31,11 @@ export class RamDash extends BaseDashlet{ setup() { super.setup(); + this.traceRender("setup-start"); this.graph = new RamPie(this.graphDivId()); + this.traceRender("setup-complete", { + graphId: this.graphDivId(), + }); } setupZoomed() { @@ -48,9 +52,26 @@ export class RamDash extends BaseDashlet{ let total = toNumber(msg.data.total, 0); let used = toNumber(msg.data.used, 0); let free = Math.max(0, total - used); - this.graph.update(free, used); - if (this.zoomGraph) { - this.zoomGraph.update(free, used); + this.traceRender("onMessage", { + eventName: msg.event, + total, + used, + free, + }); + try { + this.graph.update(free, used); + if (this.zoomGraph) { + this.zoomGraph.update(free, used); + } + this.traceRender("update-ok", { + eventName: msg.event, + }); + } catch (err) { + this.traceRender("update-error", { + eventName: msg.event, + error: err && err.message ? err.message : String(err), + }); + throw err; } } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/rtt_histo_dash.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/rtt_histo_dash.js index 09826c13..a4313a08 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/rtt_histo_dash.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/rtt_histo_dash.js @@ -31,8 +31,12 @@ export class RttHistoDash extends DashletBaseInsight { setup() { super.setup(); + this.traceRender("setup-start"); this.graph = new RttHistogram(this.graphDivId()); window.timeGraphs.push(this); + this.traceRender("setup-complete", { + graphId: this.graphDivId(), + }); } setupZoomed() { @@ -51,9 +55,24 @@ export class RttHistoDash extends DashletBaseInsight { onMessage(msg) { if (msg.event === "RttHistogram" && window.timePeriods.activePeriod === "Live") { - this.graph.update(msg.data); - if (this.zoomGraph) { - this.zoomGraph.update(msg.data); + this.traceRender("onMessage", { + eventName: msg.event, + bins: Array.isArray(msg.data) ? msg.data.length : 0, + }); + try { + this.graph.update(msg.data); + if (this.zoomGraph) { + this.zoomGraph.update(msg.data); + } + this.traceRender("update-ok", { + eventName: msg.event, + }); + } catch (err) { + this.traceRender("update-error", { + eventName: msg.event, + error: err && err.message ? err.message : String(err), + }); + throw err; } } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_pps_dash.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_pps_dash.js index 57a90ac8..00939d5c 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_pps_dash.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_pps_dash.js @@ -2,7 +2,14 @@ import {PacketsPerSecondBar} from "../graphs/packets_bar"; import {PacketsPerSecondTimescale} from "../graphs/packets_bar_insight"; import {DashletBaseInsight} from "./insight_dashlet_base"; +const LIVE_SAMPLE_LIMIT = 300; + export class ThroughputPpsDash extends DashletBaseInsight{ + constructor(slot) { + super(slot); + this.backgroundSamples = []; + } + title() { return "Throughput PPS"; } @@ -19,6 +26,14 @@ export class ThroughputPpsDash extends DashletBaseInsight{ return [ "Throughput" ]; } + keepAliveWhenHidden() { + return true; + } + + keepSubscribedWhenHidden() { + return true; + } + buildContainer() { let base = super.buildContainer(); base.appendChild(this.graphDiv()); @@ -47,15 +62,42 @@ export class ThroughputPpsDash extends DashletBaseInsight{ onMessage(msg) { if (msg.event === "Throughput" && window.timePeriods.activePeriod === "Live") { - this.graph.update(msg.data.pps.down, msg.data.pps.up, msg.data.tcp_pps, msg.data.udp_pps, msg.data.icmp_pps); - if (this.zoomGraph) { - this.zoomGraph.update(msg.data.pps.down, msg.data.pps.up, msg.data.tcp_pps, msg.data.udp_pps, msg.data.icmp_pps); - } + this.#applyLiveSample(msg.data.pps.down, msg.data.pps.up, msg.data.tcp_pps, msg.data.udp_pps, msg.data.icmp_pps); } } + onBackgroundMessage(msg) { + if (msg.event !== "Throughput" || window.timePeriods.activePeriod !== "Live") { + return; + } + this.backgroundSamples.push({ + down: msg.data.pps.down, + up: msg.data.pps.up, + tcp: msg.data.tcp_pps, + udp: msg.data.udp_pps, + icmp: msg.data.icmp_pps, + }); + if (this.backgroundSamples.length > LIVE_SAMPLE_LIMIT) { + this.backgroundSamples.shift(); + } + } + + flushBackgroundMessages() { + if (window.timePeriods.activePeriod !== "Live" || this.backgroundSamples.length === 0) { + this.backgroundSamples = []; + return false; + } + for (let i = 0; i < this.backgroundSamples.length; i++) { + const sample = this.backgroundSamples[i]; + this.#applyLiveSample(sample.down, sample.up, sample.tcp, sample.udp, sample.icmp); + } + this.backgroundSamples = []; + return true; + } + onTimeChange() { super.onTimeChange(); + this.backgroundSamples = []; this.graph.chart.clear(); this.graph.chart.showLoading(); if (window.timePeriods.activePeriod === "Live") { @@ -68,4 +110,11 @@ export class ThroughputPpsDash extends DashletBaseInsight{ this.setupZoomed(); } } + + #applyLiveSample(down, up, tcp, udp, icmp) { + this.graph.update(down, up, tcp, udp, icmp); + if (this.zoomGraph) { + this.zoomGraph.update(down, up, tcp, udp, icmp); + } + } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_ring_dash.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_ring_dash.js index f1ad35ac..d9bb6bb7 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_ring_dash.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/throughput_ring_dash.js @@ -2,10 +2,13 @@ import {ThroughputRingBufferGraph} from "../graphs/throughput_ring_graph"; import {ThroughputRingBufferGraphTimescale} from "../graphs/throughput_ring_graph_timescale"; import {DashletBaseInsight} from "./insight_dashlet_base"; +const LIVE_SAMPLE_LIMIT = 60 * 5; + export class ThroughputRingDash extends DashletBaseInsight{ constructor(slot) { super(slot); this.counter = 0; + this.backgroundSamples = []; } currentPeriod() { @@ -49,6 +52,14 @@ export class ThroughputRingDash extends DashletBaseInsight{ return [ "Throughput" ]; } + keepAliveWhenHidden() { + return true; + } + + keepSubscribedWhenHidden() { + return true; + } + buildContainer() { let base = super.buildContainer(); let graphs = this.graphDiv(); @@ -88,10 +99,7 @@ export class ThroughputRingDash extends DashletBaseInsight{ onMessage(msg) { if (msg.event === "Throughput" && window.timePeriods.activePeriod === "Live") { - this.graph.update(msg.data.shaped_bps, msg.data.bps); - if (this.zoomGraph) { - this.zoomGraph.update(msg.data.shaped_bps, msg.data.bps); - } + this.#applyLiveSample(msg.data.shaped_bps, msg.data.bps); this.counter++; if (this.counter > 120) { @@ -102,6 +110,32 @@ export class ThroughputRingDash extends DashletBaseInsight{ } } + onBackgroundMessage(msg) { + if (msg.event !== "Throughput" || window.timePeriods.activePeriod !== "Live") { + return; + } + this.backgroundSamples.push({ + shaped: msg.data.shaped_bps, + unshaped: msg.data.bps, + }); + if (this.backgroundSamples.length > LIVE_SAMPLE_LIMIT) { + this.backgroundSamples.shift(); + } + } + + flushBackgroundMessages() { + if (window.timePeriods.activePeriod !== "Live" || this.backgroundSamples.length === 0) { + this.backgroundSamples = []; + return false; + } + for (let i = 0; i < this.backgroundSamples.length; i++) { + const sample = this.backgroundSamples[i]; + this.#applyLiveSample(sample.shaped, sample.unshaped); + } + this.backgroundSamples = []; + return true; + } + supportsZoom() { return true; } @@ -155,6 +189,7 @@ export class ThroughputRingDash extends DashletBaseInsight{ onTimeChange() { super.onTimeChange(); + this.backgroundSamples = []; this.updateTitlesAndTooltip(); this.graph.chart.clear(); this.graph.chart.showLoading(); @@ -168,4 +203,11 @@ export class ThroughputRingDash extends DashletBaseInsight{ this.setupZoomed(); } } + + #applyLiveSample(shaped, unshaped) { + this.graph.update(shaped, unshaped); + if (this.zoomGraph) { + this.zoomGraph.update(shaped, unshaped); + } + } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_asn_download.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_asn_download.js index f2f00ab9..3840e303 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_asn_download.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_asn_download.js @@ -61,7 +61,12 @@ export class ShaperTopAsnDownload extends BaseDashlet { tooltip() { return "
Top Download ASNs

Aggregated from live top flows by rate (bps)."; } subscribeTo() { return ["AsnTopDownload", "TopFlowsRate"]; } buildContainer() { let b = super.buildContainer(); b.appendChild(this.graphDiv()); return b; } - setup() { this.graph = new TopAsnSankeyGraph(this.graphDivId(), 'down'); if (this.last) this.graph.update(this.last); } + setup() { + this.traceRender("setup-start"); + this.graph = new TopAsnSankeyGraph(this.graphDivId(), 'down'); + this.traceRender("setup-complete", { graphId: this.graphDivId() }); + if (this.last) this.graph.update(this.last); + } _showEmpty(show, msg = "No recent data") { const card = document.getElementById(this.id); if (!card) return; @@ -118,7 +123,14 @@ export class ShaperTopAsnDownload extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { this.last = items; - this.graph.update(items); + this.traceRender("onMessage", { eventName: msg.event, rowCount: items.length }); + try { + this.graph.update(items); + this.traceRender("update-ok", { eventName: msg.event, rowCount: items.length }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) }); + throw err; + } } return; } @@ -142,7 +154,14 @@ export class ShaperTopAsnDownload extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { this.last = items; - this.graph.update(items); + this.traceRender("onMessage", { eventName: msg.event, rowCount: items.length }); + try { + this.graph.update(items); + this.traceRender("update-ok", { eventName: msg.event, rowCount: items.length }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) }); + throw err; + } } } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_asn_upload.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_asn_upload.js index 5ba0abfc..67e1b207 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_asn_upload.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_asn_upload.js @@ -51,7 +51,12 @@ export class ShaperTopAsnUpload extends BaseDashlet { tooltip() { return "

Top Upload ASNs

Aggregated from live top flows by rate (bps)."; } subscribeTo() { return ["AsnTopUpload", "TopFlowsRate"]; } buildContainer() { let b = super.buildContainer(); b.appendChild(this.graphDiv()); return b; } - setup() { this.graph = new TopAsnSankeyGraphUp(this.graphDivId()); if (this.last) this.graph.update(this.last); } + setup() { + this.traceRender("setup-start"); + this.graph = new TopAsnSankeyGraphUp(this.graphDivId()); + this.traceRender("setup-complete", { graphId: this.graphDivId() }); + if (this.last) this.graph.update(this.last); + } _showEmpty(show, msg = "No recent data") { const card = document.getElementById(this.id); if (!card) return; @@ -105,7 +110,14 @@ export class ShaperTopAsnUpload extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { this.last = items; - this.graph.update(items); + this.traceRender("onMessage", { eventName: msg.event, rowCount: items.length }); + try { + this.graph.update(items); + this.traceRender("update-ok", { eventName: msg.event, rowCount: items.length }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) }); + throw err; + } } return; } @@ -129,7 +141,14 @@ export class ShaperTopAsnUpload extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { this.last = items; - this.graph.update(items); + this.traceRender("onMessage", { eventName: msg.event, rowCount: items.length }); + try { + this.graph.update(items); + this.traceRender("update-ok", { eventName: msg.event, rowCount: items.length }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) }); + throw err; + } } } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/tracked_flow_count_dash.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/tracked_flow_count_dash.js index e1c76bb1..2f27fac2 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/tracked_flow_count_dash.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/tracked_flow_count_dash.js @@ -2,7 +2,14 @@ import {FlowCountGraph} from "../graphs/flows_graph"; import {FlowCountGraphTimescale} from "../graphs/flows_graph_timeseries"; import {DashletBaseInsight} from "./insight_dashlet_base"; +const LIVE_SAMPLE_LIMIT = 300; + export class TrackedFlowsCount extends DashletBaseInsight{ + constructor(slot) { + super(slot); + this.backgroundSamples = []; + } + title() { return "Tracked Flows"; } @@ -19,6 +26,14 @@ export class TrackedFlowsCount extends DashletBaseInsight{ return [ "FlowCount" ]; } + keepAliveWhenHidden() { + return true; + } + + keepSubscribedWhenHidden() { + return true; + } + buildContainer() { let base = super.buildContainer(); base.appendChild(this.graphDiv()); @@ -47,15 +62,39 @@ export class TrackedFlowsCount extends DashletBaseInsight{ onMessage(msg) { if (msg.event === "FlowCount" && window.timePeriods.activePeriod === "Live") { - this.graph.update(msg.active, msg.recent); - if (this.zoomGraph) { - this.zoomGraph.update(msg.active, msg.recent); - } + this.#applyLiveSample(msg.active, msg.recent); } } + onBackgroundMessage(msg) { + if (msg.event !== "FlowCount" || window.timePeriods.activePeriod !== "Live") { + return; + } + this.backgroundSamples.push({ + active: msg.active, + recent: msg.recent, + }); + if (this.backgroundSamples.length > LIVE_SAMPLE_LIMIT) { + this.backgroundSamples.shift(); + } + } + + flushBackgroundMessages() { + if (window.timePeriods.activePeriod !== "Live" || this.backgroundSamples.length === 0) { + this.backgroundSamples = []; + return false; + } + for (let i = 0; i < this.backgroundSamples.length; i++) { + const sample = this.backgroundSamples[i]; + this.#applyLiveSample(sample.active, sample.recent); + } + this.backgroundSamples = []; + return true; + } + onTimeChange() { super.onTimeChange(); + this.backgroundSamples = []; this.graph.chart.clear(); this.graph.chart.showLoading(); if (window.timePeriods.activePeriod === "Live") { @@ -68,4 +107,11 @@ export class TrackedFlowsCount extends DashletBaseInsight{ this.setupZoomed(); } } + + #applyLiveSample(active, recent) { + this.graph.update(active, recent); + if (this.zoomGraph) { + this.zoomGraph.update(active, recent); + } + } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js index 856e9f84..0744c87b 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_activity.js @@ -171,8 +171,8 @@ export class TreeGuardActivityDashlet extends BaseDashlet { constructor(slot) { super(slot); this.size = 12; - this.circuitNameById = new Map(); this.nodeIdByName = new Map(); + this.lastEntries = []; } title() { @@ -189,37 +189,21 @@ export class TreeGuardActivityDashlet extends BaseDashlet { setup() { const wsClient = get_ws_client(); - const shapedWrapped = (msg) => { - wsClient.off("AllShapedDevices", shapedWrapped); - const devices = msg && Array.isArray(msg.data) ? msg.data : []; - devices.forEach((d) => { - const id = (d && d.circuit_id ? String(d.circuit_id) : "").trim(); - const name = (d && d.circuit_name ? String(d.circuit_name) : "").trim(); - if (!id || !name) return; - if (!this.circuitNameById.has(id)) { - this.circuitNameById.set(id, name); - } - }); - }; - wsClient.on("AllShapedDevices", shapedWrapped); - wsClient.send({ AllShapedDevices: {} }); - - const treeWrapped = (msg) => { - wsClient.off("NetworkTree", treeWrapped); + const nodeWrapped = (msg) => { + wsClient.off("NodeDirectory", nodeWrapped); const data = msg && Array.isArray(msg.data) ? msg.data : []; data.forEach((entry) => { - if (!Array.isArray(entry) || entry.length < 2) return; - const id = entry[0]; - const node = entry[1]; - const name = (node && node.name ? String(node.name) : "").trim(); + const id = entry?.tree_index; + const name = (entry?.node_name ?? "").trim(); if (!name) return; if (!this.nodeIdByName.has(name)) { this.nodeIdByName.set(name, id); } }); + this.rerenderWithMetadata(); }; - wsClient.on("NetworkTree", treeWrapped); - wsClient.send({ NetworkTree: {} }); + wsClient.on("NodeDirectory", nodeWrapped); + wsClient.send({ NodeDirectory: {} }); } buildContainer() { @@ -258,6 +242,17 @@ export class TreeGuardActivityDashlet extends BaseDashlet { } const entries = Array.isArray(msg.data) ? msg.data : []; + this.lastEntries = entries; + this.renderEntries(entries); + } + + rerenderWithMetadata() { + if (this.lastEntries.length > 0) { + this.renderEntries(this.lastEntries); + } + } + + renderEntries(entries) { this.tbody.innerHTML = ""; if (entries.length === 0) { @@ -303,13 +298,6 @@ export class TreeGuardActivityDashlet extends BaseDashlet { const circuitId = parsed.circuitId; let display = parsed.display; let title = parsed.hasName ? circuitId : ""; - if (!parsed.hasName && circuitId) { - const name = this.circuitNameById.get(circuitId); - if (name) { - display = name; - title = circuitId; - } - } tdEntity.appendChild( mkLink(`circuit.html?id=${encodeURIComponent(circuitId)}`, display, title), ); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_controls.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_controls.js index c4d672d1..437eeb15 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_controls.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_controls.js @@ -8,6 +8,8 @@ export class TreeguardControlsDashlet extends BaseDashlet { this.size = 6; this.treeguard = defaultTreeguardConfig(); this.isSaving = false; + this.liveStatus = null; + this.configLoaded = false; } title() { @@ -19,7 +21,7 @@ export class TreeguardControlsDashlet extends BaseDashlet { } subscribeTo() { - return []; + return ["TreeGuardStatus"]; } statusLineId() { @@ -76,7 +78,8 @@ export class TreeguardControlsDashlet extends BaseDashlet { setup() { this.bindControls(); - this.refreshConfig(); + this.syncControls(); + this.refreshConfig(false); } bindControls() { @@ -86,20 +89,33 @@ export class TreeguardControlsDashlet extends BaseDashlet { dryRun.addEventListener("change", () => this.persistToggle("dry_run", dryRun.checked)); } - refreshConfig() { - this.setSaveState("Loading config...", false); + refreshConfig(showLoading = true, onLoaded = null, onFailed = null) { + if (showLoading) { + this.setSaveState("Loading config...", false); + } loadConfig( () => { this.treeguard = ensureTreeguardConfig(window.config); + this.configLoaded = true; this.syncControls(); this.renderStatusLine(); - this.setSaveState("", false); + if (showLoading) { + this.setSaveState("", false); + } + if (onLoaded) { + onLoaded(); + } }, () => { this.treeguard = ensureTreeguardConfig(window.config || {}); this.syncControls(); this.renderStatusLine(); - this.setSaveState("Unable to refresh TreeGuard config.", true); + if (showLoading) { + this.setSaveState("Unable to refresh TreeGuard config.", true); + } + if (onFailed) { + onFailed(); + } }, ); } @@ -107,26 +123,82 @@ export class TreeguardControlsDashlet extends BaseDashlet { syncControls() { const enabled = document.getElementById(this.enabledId()); const dryRun = document.getElementById(this.dryRunId()); - enabled.checked = !!this.treeguard.enabled; - dryRun.checked = !!this.treeguard.dry_run; + const statusEnabled = this.liveStatus?.enabled; + const statusDryRun = this.liveStatus?.dry_run; + enabled.checked = this.configLoaded ? !!this.treeguard.enabled : !!(statusEnabled ?? this.treeguard.enabled); + dryRun.checked = this.configLoaded ? !!this.treeguard.dry_run : !!(statusDryRun ?? this.treeguard.dry_run); enabled.disabled = this.isSaving; dryRun.disabled = this.isSaving; } renderStatusLine() { const statusLine = document.getElementById(this.statusLineId()); - const liveState = this.treeguard.dry_run ? "dry run" : "live"; + const liveStatus = this.liveStatus || {}; + const enabled = (liveStatus.enabled ?? this.treeguard.enabled) ? "Enabled" : "Disabled"; + const dryRun = !!(liveStatus.dry_run ?? this.treeguard.dry_run); + const liveState = dryRun ? "dry run" : "live"; const mode = this.treeguard.cpu?.mode === "cpu_aware" ? "CPU-aware" : "Traffic/RTT only"; const linkScope = this.treeguard.links?.all_nodes ? "all links" : "allowlisted links"; const circuitScope = this.treeguard.circuits?.all_circuits ? "all circuits" : "allowlisted circuits"; - const enabled = this.treeguard.enabled ? "Enabled" : "Disabled"; - statusLine.innerHTML = ` - Status: ${enabled}, ${liveState}, ${mode}, ${linkScope}, ${circuitScope}. - Open configuration - `; + const managedNodes = Number.isFinite(Number(liveStatus.managed_nodes)) + ? Math.max(0, Math.trunc(Number(liveStatus.managed_nodes))) + : null; + const managedCircuits = Number.isFinite(Number(liveStatus.managed_circuits)) + ? Math.max(0, Math.trunc(Number(liveStatus.managed_circuits))) + : null; + const warningCount = Array.isArray(liveStatus.warnings) ? liveStatus.warnings.length : 0; + const summaryBits = [ + `Status: ${enabled}`, + liveState, + mode, + linkScope, + circuitScope, + ]; + if (managedNodes !== null || managedCircuits !== null) { + const counts = []; + if (managedNodes !== null) counts.push(`${managedNodes} node${managedNodes === 1 ? "" : "s"}`); + if (managedCircuits !== null) counts.push(`${managedCircuits} circuit${managedCircuits === 1 ? "" : "s"}`); + if (counts.length > 0) { + summaryBits.push(`managing ${counts.join(", ")}`); + } + } + if (warningCount > 0) { + summaryBits.push(`${warningCount} warning${warningCount === 1 ? "" : "s"}`); + } + statusLine.innerHTML = `${summaryBits.join(", ")}. Open configuration`; + } + + onMessage(msg) { + if (msg.event !== "TreeGuardStatus") { + return; + } + this.liveStatus = msg.data || null; + if (!this.configLoaded) { + this.syncControls(); + if (!this.isSaving) { + this.setSaveState("", false); + } + } + this.renderStatusLine(); } persistToggle(field, nextValue) { + if (!this.configLoaded) { + this.isSaving = true; + this.syncControls(); + this.refreshConfig( + true, + () => { + this.isSaving = false; + this.persistToggle(field, nextValue); + }, + () => { + this.isSaving = false; + this.syncControls(); + }, + ); + return; + } const previousValue = this.treeguard[field]; this.treeguard[field] = nextValue; window.config = window.config || {}; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_status.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_status.js index 534ec798..aa8287fa 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_status.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_status.js @@ -162,10 +162,7 @@ export class TreeGuardStatusDashlet extends BaseDashlet { constructor(slot) { super(slot); this.size = 6; - this.totalNodes = null; - this.totalCircuits = null; - this.estimatedVirtualizedNodes = null; - this.estimatedFqCodelCircuits = null; + this.metadataSummary = null; this.lastStatusData = null; } @@ -183,59 +180,15 @@ export class TreeGuardStatusDashlet extends BaseDashlet { setup() { const wsClient = get_ws_client(); - - const treeWrapped = (msg) => { - wsClient.off("NetworkTree", treeWrapped); - const data = msg && Array.isArray(msg.data) ? msg.data : []; - let count = 0; - let virtualCount = 0; - data.forEach((entry) => { - if (!Array.isArray(entry) || entry.length < 2) return; - const node = entry[1]; - const name = (node && node.name ? String(node.name) : "").trim(); - if (!name || name === "Root") return; - count += 1; - if (node && (node.virtual === true || node.is_virtual === true)) { - virtualCount += 1; - } - }); - this.totalNodes = count; - this.estimatedVirtualizedNodes = virtualCount; - this.rerenderWithEstimates(); + const metadataWrapped = (msg) => { + wsClient.off("TreeGuardMetadataSummary", metadataWrapped); + this.metadataSummary = msg?.data || null; + if (this.lastStatusData) { + this.renderStatus(this.lastStatusData); + } }; - wsClient.on("NetworkTree", treeWrapped); - wsClient.send({ NetworkTree: {} }); - - const shapedWrapped = (msg) => { - wsClient.off("AllShapedDevices", shapedWrapped); - const devices = msg && Array.isArray(msg.data) ? msg.data : []; - const circuits = new Set(); - const fqCodelCircuits = new Set(); - devices.forEach((d) => { - const id = (d && d.circuit_id ? String(d.circuit_id) : "").trim(); - if (id) circuits.add(id); - - const override = (d && d.sqm_override !== undefined ? String(d.sqm_override) : "").trim(); - if (!override || !id) return; - const { down, up } = parseDirectionalSqmToken(override); - const downLower = (down ?? "").toString().trim().toLowerCase(); - const upLower = (up ?? "").toString().trim().toLowerCase(); - if (downLower === "fq_codel" || upLower === "fq_codel") { - fqCodelCircuits.add(id); - } - }); - this.totalCircuits = circuits.size; - this.estimatedFqCodelCircuits = fqCodelCircuits.size; - this.rerenderWithEstimates(); - }; - wsClient.on("AllShapedDevices", shapedWrapped); - wsClient.send({ AllShapedDevices: {} }); - } - - rerenderWithEstimates() { - if (this.lastStatusData) { - this.renderStatus(this.lastStatusData); - } + wsClient.on("TreeGuardMetadataSummary", metadataWrapped); + wsClient.send({ TreeGuardMetadataSummary: {} }); } buildContainer() { @@ -339,17 +292,7 @@ export class TreeGuardStatusDashlet extends BaseDashlet { const managedCircuits = Math.max(0, Math.trunc(Number(data.managed_circuits ?? 0) || 0)); const virtualizedNodesExact = Math.max(0, Math.trunc(Number(data.virtualized_nodes ?? 0) || 0)); const fqCodelCircuitsExact = Math.max(0, Math.trunc(Number(data.fq_codel_circuits ?? 0) || 0)); - - const virtualizedEstimate = Math.max(0, Math.trunc(Number(this.estimatedVirtualizedNodes ?? 0) || 0)); - const fqCodelEstimate = Math.max(0, Math.trunc(Number(this.estimatedFqCodelCircuits ?? 0) || 0)); - - const usingVirtualEstimate = virtualizedNodesExact === 0 && virtualizedEstimate > 0; - const usingFqCodelEstimate = fqCodelCircuitsExact === 0 && fqCodelEstimate > 0; - - const virtualizedNodes = usingVirtualEstimate ? virtualizedEstimate : virtualizedNodesExact; - const fqCodelCircuits = usingFqCodelEstimate ? fqCodelEstimate : fqCodelCircuitsExact; - - const totalNodes = Number(this.totalNodes ?? 0); + const totalNodes = Number(this.metadataSummary?.total_nodes ?? 0); const nodesMax = totalNodes > 0 ? totalNodes : Math.max(1, managedNodes); const nodesPct = nodesMax ? clamp((managedNodes / nodesMax) * 100, 0, 100) : 0; updateProgressMetric(this.nodesMetric, { @@ -357,10 +300,10 @@ export class TreeGuardStatusDashlet extends BaseDashlet { max: nodesMax, text: managedNodes.toString(), bgClass: "bg-info", - title: totalNodes > 0 ? `${managedNodes} / ${totalNodes} (${nodesPct.toFixed(0)}%)` : `${managedNodes}`, + title: totalNodes > 0 ? `${managedNodes} / ${totalNodes} (${nodesPct.toFixed(0)}%)` : `${managedNodes}`, }); - const totalCircuits = Number(this.totalCircuits ?? 0); + const totalCircuits = Number(this.metadataSummary?.total_circuits ?? 0); const circuitsMax = totalCircuits > 0 ? totalCircuits : Math.max(1, managedCircuits); const circuitsPct = circuitsMax ? clamp((managedCircuits / circuitsMax) * 100, 0, 100) : 0; updateProgressMetric(this.circuitsMetric, { @@ -373,34 +316,30 @@ export class TreeGuardStatusDashlet extends BaseDashlet { : `${managedCircuits}`, }); + const virtualizedNodes = virtualizedNodesExact; const virtMax = Math.max(1, managedNodes); const virtPct = managedNodes > 0 ? clamp((virtualizedNodes / virtMax) * 100, 0, 100) : 0; updateProgressMetric(this.virtualizedMetric, { value: virtualizedNodes, max: virtMax, - text: usingVirtualEstimate ? `≈${virtualizedNodes}` : virtualizedNodes.toString(), + text: virtualizedNodes.toString(), bgClass: "bg-primary", - title: (() => { - const core = managedNodes > 0 - ? `${virtualizedNodes} / ${managedNodes} (${virtPct.toFixed(0)}%)` - : `${virtualizedNodes}`; - return usingVirtualEstimate ? `Estimated from NetworkTree snapshot: ${core}` : core; - })(), + title: managedNodes > 0 + ? `${virtualizedNodes} / ${managedNodes} (${virtPct.toFixed(0)}%)` + : `${virtualizedNodes}`, }); + const fqCodelCircuits = fqCodelCircuitsExact; const fqMax = Math.max(1, managedCircuits); const fqPct = managedCircuits > 0 ? clamp((fqCodelCircuits / fqMax) * 100, 0, 100) : 0; updateProgressMetric(this.fqCodelMetric, { value: fqCodelCircuits, max: fqMax, - text: usingFqCodelEstimate ? `≈${fqCodelCircuits}` : fqCodelCircuits.toString(), + text: fqCodelCircuits.toString(), bgClass: "bg-warning", - title: (() => { - const core = managedCircuits > 0 - ? `${fqCodelCircuits} / ${managedCircuits} (${fqPct.toFixed(0)}%)` - : `${fqCodelCircuits}`; - return usingFqCodelEstimate ? `Estimated from ShapedDevices SQM overrides: ${core}` : core; - })(), + title: managedCircuits > 0 + ? `${fqCodelCircuits} / ${managedCircuits} (${fqPct.toFixed(0)}%)` + : `${fqCodelCircuits}`, }); this.lastActionEl.innerHTML = ""; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/world_map_down.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/world_map_down.js index d8796620..fd9aeb54 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/world_map_down.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/world_map_down.js @@ -108,7 +108,12 @@ export class ShaperWorldMapDown extends BaseDashlet { tooltip(){ return "

World Map

Endpoint locations sized by traffic and colored by RTT."; } subscribeTo(){ return ["EndpointLatLon"]; } buildContainer(){ let b=super.buildContainer(); b.appendChild(this.graphDiv()); return b; } - setup(){ this.graph = new WorldMap3DGraph(this.graphDivId()); if (this.last) this.graph.update(this.last); } + setup(){ + this.traceRender("setup-start"); + this.graph = new WorldMap3DGraph(this.graphDivId()); + this.traceRender("setup-complete", { graphId: this.graphDivId() }); + if (this.last) this.graph.update(this.last); + } _showEmpty(show, msg = "No recent data"){ const card = document.getElementById(this.id); if (!card) return; @@ -158,11 +163,24 @@ export class ShaperWorldMapDown extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { this.last = out; + this.traceRender("onMessage", { eventName: msg.event, pointCount: out.length }); ensureWorldMap().then(() => { - this.graph.update(out); + try { + this.graph.update(out); + this.traceRender("update-ok", { eventName: msg.event, pointCount: out.length }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) }); + throw err; + } }).catch(() => { // If the world map fails to load, still attempt to render points - this.graph.update(out); + try { + this.graph.update(out); + this.traceRender("update-ok", { eventName: msg.event, pointCount: out.length, fallback: true }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err), fallback: true }); + throw err; + } }); } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/world_map_up.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/world_map_up.js index 3de83a6f..e49aa713 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/world_map_up.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/world_map_up.js @@ -35,7 +35,12 @@ export class ShaperWorldMapUp extends BaseDashlet { tooltip(){ return "

World Map

Endpoint locations sized by traffic and colored by RTT."; } subscribeTo(){ return ["EndpointLatLon"]; } buildContainer(){ let b=super.buildContainer(); b.appendChild(this.graphDiv()); return b; } - setup(){ this.graph = new WorldMap3DGraph(this.graphDivId()); if (this.last) this.graph.update(this.last); } + setup(){ + this.traceRender("setup-start"); + this.graph = new WorldMap3DGraph(this.graphDivId()); + this.traceRender("setup-complete", { graphId: this.graphDivId() }); + if (this.last) this.graph.update(this.last); + } _showEmpty(show, msg = "No recent data"){ const card = document.getElementById(this.id); if (!card) return; @@ -85,10 +90,23 @@ export class ShaperWorldMapUp extends BaseDashlet { this._showEmpty(!hasData); if (hasData) { this.last = out; + this.traceRender("onMessage", { eventName: msg.event, pointCount: out.length }); ensureWorldMap().then(() => { - this.graph.update(out); + try { + this.graph.update(out); + this.traceRender("update-ok", { eventName: msg.event, pointCount: out.length }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) }); + throw err; + } }).catch(() => { - this.graph.update(out); + try { + this.graph.update(out); + this.traceRender("update-ok", { eventName: msg.event, pointCount: out.length, fallback: true }); + } catch (err) { + this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err), fallback: true }); + throw err; + } }); } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_circuits_due_upgrade.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_circuits_due_upgrade.js index 7d97a4ee..142df326 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_circuits_due_upgrade.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_circuits_due_upgrade.js @@ -1,36 +1,3 @@ -import {listenExecutiveHeatmaps, averageWithCount, renderTable, renderCircuitLink} from "./executive_utils"; +import {renderExecutiveLeaderboardPage} from "./executive_leaderboard_page_template"; -const THRESHOLD = 80; -const MIN_SAMPLES = 10; - -function buildRows(data) { - const rows = (data.circuits || []).map(circuit => { - const down = averageWithCount(circuit.blocks?.download || []); - const up = averageWithCount(circuit.blocks?.upload || []); - return { - name: circuit.circuit_name || circuit.circuit_id || `Circuit ${circuit.circuit_hash}`, - circuit_id: circuit.circuit_id, - down, - up, - }; - }).filter(row => - row.down.count >= MIN_SAMPLES && - row.up.count >= MIN_SAMPLES && - (row.down.avg || 0) >= THRESHOLD && - (row.up.avg || 0) >= THRESHOLD - ); - - rows.sort((a, b) => ((b.down.avg || 0) + (b.up.avg || 0)) - ((a.down.avg || 0) + (a.up.avg || 0))); - return rows; -} - -function render(data) { - const rows = buildRows(data); - renderTable("executiveCircuitsDueTable", [ - { header: "Circuit", render: (r) => renderCircuitLink(r.name, r.circuit_id) }, - { header: "Avg Down Util (%)", render: (r) => r.down.avg !== null ? r.down.avg.toFixed(1) : "—" }, - { header: "Avg Up Util (%)", render: (r) => r.up.avg !== null ? r.up.avg.toFixed(1) : "—" }, - ], rows, "No circuits meet the 80%+ utilization threshold yet."); -} - -listenExecutiveHeatmaps(render); +renderExecutiveLeaderboardPage("executiveCircuitsDueTable", "circuits_due_upgrade"); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_heatmap_page_template.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_heatmap_page_template.js index fa909067..837d7098 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_heatmap_page_template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_heatmap_page_template.js @@ -1,30 +1,29 @@ -import {listenExecutiveHeatmaps, renderTable, getSiteIdMap, linkToCircuit, linkToSite} from "./executive_utils"; import { - buildHeatmapRows, + badgeForEntityKind, + linkToExecutiveMetricRow, + pollExecutiveHeatmapPage, +} from "./executive_utils"; +import { heatRow, rttHeatRow, - latestValue, - nonNullCount, + retransmitHeatRow, + utilizationHeatRow, formatLatest, colorByCapacity, } from "./dashlets/executive_heatmap_shared"; import {colorByRttMs, colorByRetransmitPct} from "./helpers/color_scales"; +const DEFAULT_PAGE_SIZE = 50; +const PAGE_REFRESH_MS = 5000; + const metricConfigs = { rtt: { title: "RTT (p50/p90) Heatmap", - metricKey: "rtt", + metric: "Rtt", colorFn: (v) => colorByRttMs(v), formatFn: (v) => formatLatest(v, "ms"), - describeFn: (v) => { - if (v === null || v === undefined || Number.isNaN(v)) return ""; - if (v < 20) return "Good"; - if (v < 60) return "Moderate"; - if (v < 120) return "Elevated"; - return "Severe"; - }, icon: "fa-stopwatch", - description: "Full list of sites, circuits, and ASNs sorted by latest RTT (p50/p90 quadrants).", + description: "Paged list of sites, circuits, and ASNs ranked server-side by latest RTT (p50/p90 quadrants).", legend: [ { label: "Good", sample: 10 }, { label: "Moderate", sample: 40 }, @@ -34,18 +33,11 @@ const metricConfigs = { }, retransmit: { title: "TCP Retransmits Heatmap", - metricKey: "retransmit", + metric: "Retransmit", colorFn: (v) => colorByRetransmitPct(Math.min(10, Math.max(0, v || 0))), formatFn: (v) => formatLatest(v, "%", 1), - describeFn: (v) => { - if (v === null || v === undefined || Number.isNaN(v)) return ""; - if (v < 1) return "Low"; - if (v < 2.5) return "Watch"; - if (v < 5) return "Elevated"; - return "Severe"; - }, icon: "fa-undo-alt", - description: "Full list sorted by latest retransmit percentage.", + description: "Paged list ranked server-side by latest retransmit percentage.", legend: [ { label: "Low", sample: 0.5 }, { label: "Watch", sample: 1.5 }, @@ -55,18 +47,11 @@ const metricConfigs = { }, download: { title: "Download Utilization Heatmap", - metricKey: "download", + metric: "Download", colorFn: colorByCapacity, formatFn: (v) => formatLatest(v, "%"), - describeFn: (v) => { - if (v === null || v === undefined || Number.isNaN(v)) return ""; - if (v < 60) return "Comfortable"; - if (v < 80) return "Busy"; - if (v < 95) return "Near capacity"; - return "Saturated"; - }, icon: "fa-arrow-down", - description: "Full list sorted by latest download utilization.", + description: "Paged list ranked server-side by latest download utilization.", legend: [ { label: "Comfortable", sample: 30 }, { label: "Busy", sample: 70 }, @@ -76,18 +61,11 @@ const metricConfigs = { }, upload: { title: "Upload Utilization Heatmap", - metricKey: "upload", + metric: "Upload", colorFn: colorByCapacity, formatFn: (v) => formatLatest(v, "%"), - describeFn: (v) => { - if (v === null || v === undefined || Number.isNaN(v)) return ""; - if (v < 60) return "Comfortable"; - if (v < 80) return "Busy"; - if (v < 95) return "Near capacity"; - return "Saturated"; - }, icon: "fa-arrow-up", - description: "Full list sorted by latest upload utilization.", + description: "Paged list ranked server-side by latest upload utilization.", legend: [ { label: "Comfortable", sample: 30 }, { label: "Busy", sample: 70 }, @@ -108,89 +86,139 @@ function renderLegend(cfg) {

Legend: ${items} - Latest value column includes text labels so the page is readable without relying on color alone. + Detail pages now page server-ranked rows instead of streaming the full executive universe every second.
`; } -function metricSort(metricKey) { - return (a, b) => { - const aVals = a.blocks[metricKey] || []; - const bVals = b.blocks[metricKey] || []; - const aLatest = latestValue(aVals); - const bLatest = latestValue(bVals); - if (bLatest !== aLatest) { - if (aLatest === null) return 1; - if (bLatest === null) return -1; - return bLatest - aLatest; - } - const aCount = nonNullCount(aVals); - const bCount = nonNullCount(bVals); - if (bCount !== aCount) return bCount - aCount; - return (a.label || "").localeCompare(b.label || ""); +function rowSplitBlocks(row) { + return { + download: row?.split_blocks?.download || [], + upload: row?.split_blocks?.upload || [], }; } +function rowRetransmitBlocks(row) { + return { + retransmit: row?.scalar_blocks?.values || [], + retransmit_down: row?.split_blocks?.download || [], + retransmit_up: row?.split_blocks?.upload || [], + }; +} + +function rowRttBlocks(row) { + return { + rtt: row?.rtt_blocks?.rtt || [], + rtt_p50_down: row?.rtt_blocks?.dl_p50 || [], + rtt_p90_down: row?.rtt_blocks?.dl_p90 || [], + rtt_p50_up: row?.rtt_blocks?.ul_p50 || [], + rtt_p90_up: row?.rtt_blocks?.ul_p90 || [], + }; +} + +function renderMetricRow(cfg, metricKey, row) { + const badge = badgeForEntityKind(row?.entity_kind); + const link = linkToExecutiveMetricRow(row); + if (metricKey === "rtt") { + return rttHeatRow( + row.label, + badge, + rowRttBlocks(row), + cfg.colorFn, + cfg.formatFn, + link, + ); + } + if (metricKey === "retransmit") { + return retransmitHeatRow( + row.label, + badge, + rowRetransmitBlocks(row), + cfg.colorFn, + cfg.formatFn, + link, + ); + } + return utilizationHeatRow( + row.label, + badge, + rowSplitBlocks(row), + cfg.colorFn, + cfg.formatFn, + link, + ); +} + +function attachPaginationHandlers(target, state) { + target.querySelectorAll("[data-exec-page]").forEach((button) => { + button.addEventListener("click", () => { + const nextPage = Number(button.dataset.execPage); + if (!Number.isFinite(nextPage) || nextPage < 0 || nextPage === Number(state.query.page || 0)) { + return; + } + state.query.page = nextPage; + state.pollHandle.refresh(); + }); + }); +} + function renderHeatmapTable(targetId, metricKey) { const cfg = metricConfigs[metricKey]; if (!cfg) return; const target = document.getElementById(targetId); if (!target) return; - let lastData = null; - const renderRows = (data) => { - lastData = data; - getSiteIdMap().then((siteIdMap) => { - const activeTarget = document.getElementById(targetId); - if (!activeTarget) return; - const rows = buildHeatmapRows(data).sort(metricSort(metricKey)); - const body = rows.map(row => { - const link = row.badge === "Circuit" - ? linkToCircuit(row.circuit_id) - : row.badge === "Site" - ? linkToSite(row.site_name || row.label, siteIdMap) - : null; - if (metricKey === "rtt") { - return rttHeatRow( - row.label, - row.badge, - row.blocks, - cfg.colorFn, - cfg.formatFn, - link, - cfg.describeFn, - ); - } - return heatRow( - row.label, - row.badge, - row.blocks[metricKey] || [], - cfg.colorFn, - cfg.formatFn, - link, - cfg.describeFn, - ); - }).join(""); - activeTarget.innerHTML = ` -
-
-
-
${cfg.title}
-
-

${cfg.description}

- ${renderLegend(cfg)} -
${body}
-
-
- `; - }); + + const state = { + query: { + metric: cfg.metric, + entity_kinds: ["Site", "Circuit", "Asn"], + page: 0, + page_size: DEFAULT_PAGE_SIZE, + sort: "LatestValue", + descending: true, + }, + lastData: null, + pollHandle: null, }; + + const renderRows = (data) => { + state.lastData = data; + const pageSize = Number(data?.query?.page_size ?? state.query.page_size ?? DEFAULT_PAGE_SIZE); + const page = Number(data?.query?.page ?? state.query.page ?? 0); + const totalRows = Number(data?.total_rows ?? 0); + const totalPages = Math.max(1, Math.ceil(totalRows / Math.max(1, pageSize))); + const hasPrev = page > 0; + const hasNext = page + 1 < totalPages; + const body = (data?.rows || []).map((row) => renderMetricRow(cfg, metricKey, row)).join(""); + + target.innerHTML = ` +
+
+
+
${cfg.title}
+
+ Updated ${new Date(data?.generated_at_unix_ms || Date.now()).toLocaleTimeString()} + + Page ${page + 1} / ${totalPages} + +
+
+

${cfg.description}

+ ${renderLegend(cfg)} +
${body || `
No heatmap rows available.
`}
+
+
+ `; + attachPaginationHandlers(target, state); + }; + window.addEventListener("colorBlindModeChanged", () => { - if (lastData) { - renderRows(lastData); + if (state.lastData) { + renderRows(state.lastData); } }); - listenExecutiveHeatmaps(renderRows); - // initial placeholder + + state.pollHandle = pollExecutiveHeatmapPage(state.query, renderRows, PAGE_REFRESH_MS); target.innerHTML = `
Waiting for heatmap data…
`; } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_leaderboard_page_template.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_leaderboard_page_template.js new file mode 100644 index 00000000..2f1c75c4 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_leaderboard_page_template.js @@ -0,0 +1,225 @@ +import { + colorSwatch, + linkToExecutiveLeaderboardRow, + pollExecutiveLeaderboardPage, + renderCircuitLink, +} from "./executive_utils"; +import {colorByCapacity, formatLatest} from "./dashlets/executive_heatmap_shared"; +import {colorByRetransmitPct, colorByRttMs} from "./helpers/color_scales"; + +const DEFAULT_PAGE_SIZE = 50; +const PAGE_REFRESH_MS = 5000; +const SECONDS_PER_BLOCK = 60; +const BITS_PER_MEGABIT = 1_000_000; + +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return "—"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let idx = 0; + while (value >= 1024 && idx < units.length - 1) { + value /= 1024; + idx += 1; + } + return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[idx]}`; +} + +function renderLinkedLabel(label, link) { + if (!link) return `${label}`; + return `${label}`; +} + +const configs = { + worst_sites: { + kind: "WorstSitesByRtt", + empty: "No site RTT heatmap data yet.", + columns: [ + { + header: "Site", + render: (row) => renderLinkedLabel(row.site_name, linkToExecutiveLeaderboardRow(row)), + }, + { + header: "Median RTT (ms)", + render: (row) => `${colorSwatch(colorByRttMs(row.median_rtt_ms))}${row.median_rtt_ms.toFixed(1)}`, + }, + { + header: "Avg Down Util (%)", + render: (row) => row.avg_down_util === null || row.avg_down_util === undefined + ? "—" + : `${colorSwatch(colorByCapacity(row.avg_down_util))}${row.avg_down_util.toFixed(1)}`, + }, + { + header: "Avg Up Util (%)", + render: (row) => row.avg_up_util === null || row.avg_up_util === undefined + ? "—" + : `${colorSwatch(colorByCapacity(row.avg_up_util))}${row.avg_up_util.toFixed(1)}`, + }, + ], + }, + oversubscribed_sites: { + kind: "OversubscribedSites", + empty: "No oversubscription data available yet.", + columns: [ + { + header: "Site", + render: (row) => renderLinkedLabel(row.site_name, linkToExecutiveLeaderboardRow(row)), + }, + { header: "Oversub Down", render: (row) => row.ratio_down === null || row.ratio_down === undefined ? "—" : `${row.ratio_down.toFixed(2)}x` }, + { header: "Oversub Up", render: (row) => row.ratio_up === null || row.ratio_up === undefined ? "—" : `${row.ratio_up.toFixed(2)}x` }, + { + header: "Cap (D/U)", + render: (row) => `${formatLatest(row.cap_down, "Mbps", row.cap_down >= 10 ? 1 : 2)} / ${formatLatest(row.cap_up, "Mbps", row.cap_up >= 10 ? 1 : 2)}`, + }, + { + header: "Subscribed (D/U)", + render: (row) => `${formatLatest(row.sub_down, "Mbps", row.sub_down >= 10 ? 1 : 2)} / ${formatLatest(row.sub_up, "Mbps", row.sub_up >= 10 ? 1 : 2)}`, + }, + { + header: "Median RTT (ms)", + render: (row) => row.median_rtt_ms === null || row.median_rtt_ms === undefined + ? "—" + : `${colorSwatch(colorByRttMs(row.median_rtt_ms))}${formatLatest(row.median_rtt_ms, "ms", 1)}`, + }, + { + header: "Avg Down Util (%)", + render: (row) => row.avg_down_util === null || row.avg_down_util === undefined + ? "—" + : `${colorSwatch(colorByCapacity(row.avg_down_util))}${row.avg_down_util.toFixed(1)}`, + }, + { + header: "Avg Up Util (%)", + render: (row) => row.avg_up_util === null || row.avg_up_util === undefined + ? "—" + : `${colorSwatch(colorByCapacity(row.avg_up_util))}${row.avg_up_util.toFixed(1)}`, + }, + ], + }, + sites_due_upgrade: { + kind: "SitesDueUpgrade", + empty: "No sites meet the 80%+ utilization threshold yet.", + columns: [ + { + header: "Site", + render: (row) => renderLinkedLabel(row.site_name, linkToExecutiveLeaderboardRow(row)), + }, + { + header: "Avg Down Util (%)", + render: (row) => row.avg_down_util.toFixed(1), + }, + { + header: "Avg Up Util (%)", + render: (row) => row.avg_up_util.toFixed(1), + }, + ], + }, + circuits_due_upgrade: { + kind: "CircuitsDueUpgrade", + empty: "No circuits meet the 80%+ utilization threshold yet.", + columns: [ + { + header: "Circuit", + render: (row) => renderCircuitLink(row.circuit_name, row.circuit_id), + }, + { + header: "Avg Down Util (%)", + render: (row) => row.avg_down_util.toFixed(1), + }, + { + header: "Avg Up Util (%)", + render: (row) => row.avg_up_util.toFixed(1), + }, + ], + }, + top_asns: { + kind: "TopAsnsByTraffic", + empty: "No ASN heatmap data yet.", + columns: [ + { + header: "ASN", + render: (row) => row.asn_name ? `${row.asn_name} (ASN ${row.asn})` : `ASN ${row.asn}`, + }, + { + header: "Total Traffic (15m)", + render: (row) => formatBytes(row.total_bytes_15m || 0), + }, + { + header: "Median RTT (ms)", + render: (row) => row.median_rtt_ms === null || row.median_rtt_ms === undefined + ? "—" + : `${colorSwatch(colorByRttMs(row.median_rtt_ms))}${row.median_rtt_ms.toFixed(1)}`, + }, + { + header: "Median Retrans (%)", + render: (row) => row.median_retransmit_pct === null || row.median_retransmit_pct === undefined + ? "—" + : `${colorSwatch(colorByRetransmitPct(Math.min(10, Math.max(0, row.median_retransmit_pct))))}${row.median_retransmit_pct.toFixed(2)}`, + }, + ], + }, +}; + +function attachPaginationHandlers(target, state) { + target.querySelectorAll("[data-exec-page]").forEach((button) => { + button.addEventListener("click", () => { + const nextPage = Number(button.dataset.execPage); + if (!Number.isFinite(nextPage) || nextPage < 0 || nextPage === Number(state.query.page || 0)) { + return; + } + state.query.page = nextPage; + state.pollHandle.refresh(); + }); + }); +} + +function tableMarkup(columns, rows, emptyMessage) { + if (!rows.length) { + return `
${emptyMessage}
`; + } + const thead = `
${columns.map((column) => ``).join("")}`; + const tbody = rows.map((row) => `${columns.map((column) => ``).join("")}`).join(""); + return ` +
+
Circuit IDCircuit NameDevice IDDevice NameParent NodeMACIPv4IPv6Download MinUpload MinDownload MaxUpload MaxComment
${column.header}
${column.render(row)}
+ ${thead} + ${tbody} +
+
+ `; +} + +export function renderExecutiveLeaderboardPage(targetId, configKey) { + const cfg = configs[configKey]; + const target = document.getElementById(targetId); + if (!cfg || !target) return; + + const state = { + query: { + kind: cfg.kind, + page: 0, + page_size: DEFAULT_PAGE_SIZE, + }, + pollHandle: null, + }; + + const renderRows = (data) => { + const pageSize = Number(data?.query?.page_size ?? state.query.page_size ?? DEFAULT_PAGE_SIZE); + const page = Number(data?.query?.page ?? state.query.page ?? 0); + const totalRows = Number(data?.total_rows ?? 0); + const totalPages = Math.max(1, Math.ceil(totalRows / Math.max(1, pageSize))); + const hasPrev = page > 0; + const hasNext = page + 1 < totalPages; + target.innerHTML = ` +
+ Updated ${new Date(data?.generated_at_unix_ms || Date.now()).toLocaleTimeString()} + + Page ${page + 1} / ${totalPages} + +
+ ${tableMarkup(cfg.columns, data?.rows || [], cfg.empty)} + `; + attachPaginationHandlers(target, state); + }; + + state.pollHandle = pollExecutiveLeaderboardPage(state.query, renderRows, PAGE_REFRESH_MS); + target.innerHTML = `
Waiting for executive data…
`; +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_oversubscribed_sites.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_oversubscribed_sites.js index 0e64c199..271ca7b2 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_oversubscribed_sites.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_oversubscribed_sites.js @@ -1,94 +1,3 @@ -import { - listenExecutiveHeatmaps, - averageWithCount, - renderTable, - medianFromBlocks, - getSiteIdMap, - renderSiteLink -} from "./executive_utils"; -import {colorSwatch} from "./executive_utils"; -import {colorByCapacity, formatLatest} from "./dashlets/executive_heatmap_shared"; -import {colorByRttMs} from "./helpers/color_scales"; +import {renderExecutiveLeaderboardPage} from "./executive_leaderboard_page_template"; -const MIN_SAMPLES = 6; -const MAX_ROWS = 20; - -function buildRows(data) { - const oversub = data.oversubscribed_sites || []; - if (!oversub.length) return []; - - // Build a lookup for heatmap stats by site - const siteStats = new Map(); - (data.sites || []).forEach(site => { - const down = averageWithCount(site.blocks?.download || []); - const up = averageWithCount(site.blocks?.upload || []); - const rtt = medianFromBlocks(site.blocks?.rtt || []); - siteStats.set(site.site_name || site.name || "", { - down, - up, - rtt, - }); - }); - - const rows = oversub.map(item => { - const name = item.site_name || "Site"; - const stats = siteStats.get(name) || {}; - const ratio = item.ratio_max ?? Math.max(item.ratio_down || 0, item.ratio_up || 0); - return { - name, - ratio_down: item.ratio_down, - ratio_up: item.ratio_up, - ratio, - cap_down: item.cap_down, - cap_up: item.cap_up, - sub_down: item.sub_down, - sub_up: item.sub_up, - perf_down: stats.down || { avg: null, count: 0 }, - perf_up: stats.up || { avg: null, count: 0 }, - rtt: stats.rtt ?? null, - }; - }); - - rows.sort((a, b) => (b.ratio || 0) - (a.ratio || 0)); - return rows.slice(0, MAX_ROWS); -} - -function fmtRatio(r) { - if (r === undefined || r === null || Number.isNaN(r)) return "—"; - return `${r.toFixed(2)}x`; -} - -function fmtMbps(val) { - if (val === undefined || val === null || Number.isNaN(val)) return "—"; - return `${val.toFixed(val >= 10 ? 1 : 2)} Mbps`; -} - -function render(data) { - const rows = buildRows(data); - getSiteIdMap().then((siteIdMap) => { - renderTable("executiveOversubscribedTable", [ - { header: "Site", render: (r) => renderSiteLink(r.name, siteIdMap) }, - { header: "Oversub Down", render: (r) => fmtRatio(r.ratio_down) }, - { header: "Oversub Up", render: (r) => fmtRatio(r.ratio_up) }, - { header: "Cap (D/U)", render: (r) => `${fmtMbps(r.cap_down)} / ${fmtMbps(r.cap_up)}` }, - { header: "Subscribed (D/U)", render: (r) => `${fmtMbps(r.sub_down)} / ${fmtMbps(r.sub_up)}` }, - { header: "Median RTT (ms)", render: (r) => { - if (r.rtt === null || r.rtt === undefined) return "—"; - const color = colorByRttMs(r.rtt); - return `${colorSwatch(color)}${formatLatest(r.rtt, "ms", 1)}`; - }}, - { header: "Avg Down Util (%)", render: (r) => { - if (r.perf_down.avg === null || r.perf_down.avg === undefined) return "—"; - const color = colorByCapacity(r.perf_down.avg); - return `${colorSwatch(color)}${r.perf_down.avg.toFixed(1)}`; - }}, - { header: "Avg Up Util (%)", render: (r) => { - if (r.perf_up.avg === null || r.perf_up.avg === undefined) return "—"; - const color = colorByCapacity(r.perf_up.avg); - return `${colorSwatch(color)}${r.perf_up.avg.toFixed(1)}`; - }}, - ], rows, "No oversubscription data available yet."); - }); -} - -listenExecutiveHeatmaps(render); +renderExecutiveLeaderboardPage("executiveOversubscribedTable", "oversubscribed_sites"); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_sites_due_upgrade.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_sites_due_upgrade.js index 5fdd0fa0..d779ecea 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_sites_due_upgrade.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_sites_due_upgrade.js @@ -1,37 +1,3 @@ -import {listenExecutiveHeatmaps, averageWithCount, renderTable, getSiteIdMap, renderSiteLink} from "./executive_utils"; +import {renderExecutiveLeaderboardPage} from "./executive_leaderboard_page_template"; -const THRESHOLD = 80; -const MIN_SAMPLES = 10; - -function buildRows(data) { - const rows = (data.sites || []).map(site => { - const down = averageWithCount(site.blocks?.download || []); - const up = averageWithCount(site.blocks?.upload || []); - return { - name: site.site_name || "Site", - down, - up, - }; - }).filter(row => - row.down.count >= MIN_SAMPLES && - row.up.count >= MIN_SAMPLES && - (row.down.avg || 0) >= THRESHOLD && - (row.up.avg || 0) >= THRESHOLD - ); - - rows.sort((a, b) => ((b.down.avg || 0) + (b.up.avg || 0)) - ((a.down.avg || 0) + (a.up.avg || 0))); - return rows; -} - -function render(data) { - const rows = buildRows(data); - getSiteIdMap().then((siteIdMap) => { - renderTable("executiveSitesDueTable", [ - { header: "Site", render: (r) => renderSiteLink(r.name, siteIdMap) }, - { header: "Avg Down Util (%)", render: (r) => r.down.avg !== null ? r.down.avg.toFixed(1) : "—" }, - { header: "Avg Up Util (%)", render: (r) => r.up.avg !== null ? r.up.avg.toFixed(1) : "—" }, - ], rows, "No sites meet the 80%+ utilization threshold yet."); - }); -} - -listenExecutiveHeatmaps(render); +renderExecutiveLeaderboardPage("executiveSitesDueTable", "sites_due_upgrade"); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_top_asns.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_top_asns.js index 399098ba..094cf747 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_top_asns.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_top_asns.js @@ -1,58 +1,3 @@ -import {listenExecutiveHeatmaps, medianFromBlocks, sumBlocks, renderTable, colorSwatch} from "./executive_utils"; -import {colorByRttMs, colorByRetransmitPct} from "./helpers/color_scales"; +import {renderExecutiveLeaderboardPage} from "./executive_leaderboard_page_template"; -const SECONDS_PER_BLOCK = 60; -const BITS_PER_MEGABIT = 1_000_000; - -function buildRows(data) { - const rows = (data.asns || []).map(asn => { - const downSum = sumBlocks(asn.blocks?.download || []); - const upSum = sumBlocks(asn.blocks?.upload || []); - const totalMbps = downSum + upSum; - const totalBytes = (totalMbps * BITS_PER_MEGABIT / 8) * SECONDS_PER_BLOCK; - const medianRtt = medianFromBlocks(asn.blocks?.rtt || []); - const medianRetrans = medianFromBlocks(asn.blocks?.retransmit || []); - return { - asn: asn.asn, - asn_name: asn.asn_name, - totalBytes, - medianRtt, - medianRetrans, - }; - }); - rows.sort((a, b) => b.totalBytes - a.totalBytes); - return rows.slice(0, 10); -} - -function formatBytes(bytes) { - if (!Number.isFinite(bytes) || bytes <= 0) return "—"; - const units = ["B", "KB", "MB", "GB", "TB"]; - let i = 0; - let val = bytes; - while (val >= 1024 && i < units.length - 1) { - val /= 1024; - i++; - } - return `${val.toFixed(val >= 10 ? 1 : 2)} ${units[i]}`; -} - -function render(data) { - const rows = buildRows(data); - renderTable("executiveTopAsnsTable", [ - { header: "ASN", render: (r) => r.asn_name ? `${r.asn_name} (ASN ${r.asn})` : r.asn }, - { header: "Total Traffic (15m)", render: (r) => formatBytes(r.totalBytes) }, - { header: "Median RTT (ms)", render: (r) => { - if (r.medianRtt === null) return "—"; - const color = colorByRttMs(r.medianRtt); - return `${colorSwatch(color)}${r.medianRtt.toFixed(1)}`; - }}, - { header: "Median Retrans (%)", render: (r) => { - if (r.medianRetrans === null) return "—"; - const capped = Math.min(10, Math.max(0, r.medianRetrans)); - const color = colorByRetransmitPct(capped); - return `${colorSwatch(color)}${r.medianRetrans.toFixed(2)}`; - }}, - ], rows, "No ASN heatmap data yet."); -} - -listenExecutiveHeatmaps(render); +renderExecutiveLeaderboardPage("executiveTopAsnsTable", "top_asns"); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_utils.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_utils.js index 13537567..102e651d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_utils.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_utils.js @@ -1,6 +1,8 @@ import {get_ws_client, subscribeWS} from "./pubsub/ws"; const wsClient = get_ws_client(); +const DEFAULT_EXECUTIVE_REFRESH_MS = 5000; + const listenOnce = (eventName, handler) => { const wrapped = (msg) => { wsClient.off(eventName, wrapped); @@ -9,14 +11,127 @@ const listenOnce = (eventName, handler) => { wsClient.on(eventName, wrapped); }; -export function listenExecutiveHeatmaps(onData) { - subscribeWS(["ExecutiveHeatmaps"], (msg) => { - if (msg.event === "ExecutiveHeatmaps") { +function normalizeEntityKinds(entityKinds) { + return [...new Set((entityKinds || []).map((kind) => String(kind)))].sort(); +} + +function matchesExecutiveHeatmapQuery(responseQuery, requestQuery) { + if (!responseQuery || !requestQuery) return false; + return String(responseQuery.metric || "") === String(requestQuery.metric || "") + && Number(responseQuery.page ?? 0) === Number(requestQuery.page ?? 0) + && Number(responseQuery.page_size ?? 0) === Number(requestQuery.page_size ?? 0) + && String(responseQuery.sort || "LatestValue") === String(requestQuery.sort || "LatestValue") + && Boolean(responseQuery.descending ?? true) === Boolean(requestQuery.descending ?? true) + && String(responseQuery.search || "") === String(requestQuery.search || "") + && JSON.stringify(normalizeEntityKinds(responseQuery.entity_kinds)) === JSON.stringify(normalizeEntityKinds(requestQuery.entity_kinds)); +} + +function matchesExecutiveLeaderboardQuery(responseQuery, requestQuery) { + if (!responseQuery || !requestQuery) return false; + return String(responseQuery.kind || "") === String(requestQuery.kind || "") + && Number(responseQuery.page ?? 0) === Number(requestQuery.page ?? 0) + && Number(responseQuery.page_size ?? 0) === Number(requestQuery.page_size ?? 0) + && String(responseQuery.search || "") === String(requestQuery.search || ""); +} + +function requestMatchingResponse(eventName, request, matcher, onData, onError = null) { + let disposed = false; + const cleanup = wsClient.on(eventName, (msg) => { + if (disposed) return; + const data = msg?.data || {}; + if (!matcher(data)) return; + disposed = true; + cleanup(); + onData(data); + }); + try { + wsClient.send(request); + } catch (err) { + disposed = true; + cleanup(); + if (onError) { + onError(err); + } else { + console.error(err); + } + } + return () => { + if (disposed) return; + disposed = true; + cleanup(); + }; +} + +function pollMatchingResponse(eventName, buildRequest, matcher, onData, intervalMs = DEFAULT_EXECUTIVE_REFRESH_MS) { + const handlerDisposer = wsClient.on(eventName, (msg) => { + const data = msg?.data || {}; + if (!matcher(data)) return; + onData(data); + }); + const sendRequest = () => { + wsClient.send(buildRequest()); + }; + sendRequest(); + const timer = window.setInterval(sendRequest, intervalMs); + return { + dispose() { + window.clearInterval(timer); + handlerDisposer(); + }, + refresh() { + sendRequest(); + }, + }; +} + +export function listenExecutiveDashboardSummary(onData) { + return subscribeWS(["ExecutiveDashboardSummary"], (msg) => { + if (msg.event === "ExecutiveDashboardSummary") { onData(msg.data || {}); } }); } +export function requestExecutiveHeatmapPage(query, onData, onError = null) { + return requestMatchingResponse( + "ExecutiveHeatmapPage", + { ExecutiveHeatmapPage: { query } }, + (data) => matchesExecutiveHeatmapQuery(data.query, query), + onData, + onError, + ); +} + +export function pollExecutiveHeatmapPage(query, onData, intervalMs = DEFAULT_EXECUTIVE_REFRESH_MS) { + return pollMatchingResponse( + "ExecutiveHeatmapPage", + () => ({ ExecutiveHeatmapPage: { query } }), + (data) => matchesExecutiveHeatmapQuery(data.query, query), + onData, + intervalMs, + ); +} + +export function requestExecutiveLeaderboardPage(query, onData, onError = null) { + return requestMatchingResponse( + "ExecutiveLeaderboardPage", + { ExecutiveLeaderboardPage: { query } }, + (data) => matchesExecutiveLeaderboardQuery(data.query, query), + onData, + onError, + ); +} + +export function pollExecutiveLeaderboardPage(query, onData, intervalMs = DEFAULT_EXECUTIVE_REFRESH_MS) { + return pollMatchingResponse( + "ExecutiveLeaderboardPage", + () => ({ ExecutiveLeaderboardPage: { query } }), + (data) => matchesExecutiveLeaderboardQuery(data.query, query), + onData, + intervalMs, + ); +} + let siteIdMap = null; let siteIdMapPromise = null; @@ -31,7 +146,7 @@ export function getNodeIdMap() { siteIdMap = map; resolve(map); }; - listenOnce("NetworkTree", (msg) => { + listenOnce("NetworkTreeLite", (msg) => { const data = msg && msg.data ? msg.data : []; const map = new Map(); (data || []).forEach((entry) => { @@ -44,7 +159,7 @@ export function getNodeIdMap() { }); resolveOnce(map); }); - wsClient.send({ NetworkTree: {} }); + wsClient.send({ NetworkTreeLite: {} }); setTimeout(() => { resolveOnce(new Map()); }, 5000); @@ -61,6 +176,22 @@ export function linkToCircuit(circuitId) { return `circuit.html?id=${encodeURIComponent(circuitId)}`; } +export function linkToTreeLocator(locator) { + if (!locator) return null; + const params = new URLSearchParams(); + if (locator.parent_index !== undefined && locator.parent_index !== null) { + params.set("parent", String(locator.parent_index)); + } + if (locator.node_id) { + params.set("nodeId", locator.node_id); + } + if (Array.isArray(locator.node_path) && locator.node_path.length > 0) { + params.set("nodePath", JSON.stringify(locator.node_path)); + } + const query = params.toString(); + return query ? `tree.html?${query}` : null; +} + export function linkToSite(siteName, siteIdLookup) { if (!siteName || !siteIdLookup) return null; const siteId = siteIdLookup.get(siteName); @@ -75,6 +206,41 @@ export function linkToTreeNode(nodeName, nodeIdLookup) { return `tree.html?parent=${encodeURIComponent(nodeId)}`; } +export function badgeForEntityKind(entityKind) { + switch (String(entityKind || "")) { + case "Site": + return "Site"; + case "Circuit": + return "Circuit"; + case "Asn": + return "ASN"; + default: + return ""; + } +} + +export function linkToExecutiveMetricRow(row) { + if (!row) return null; + if (row.circuit_id) { + return linkToCircuit(row.circuit_id); + } + return linkToTreeLocator(row.tree); +} + +export function linkToExecutiveLeaderboardRow(row) { + if (!row || !row.kind) return null; + switch (row.kind) { + case "WorstSiteByRtt": + case "OversubscribedSite": + case "SiteDueUpgrade": + return linkToTreeLocator(row.tree); + case "CircuitDueUpgrade": + return linkToCircuit(row.circuit_id); + default: + return null; + } +} + export function renderCircuitLink(name, circuitId) { const link = linkToCircuit(circuitId); if (!link) return `${name}`; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_worst_sites.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_worst_sites.js index 572f46a2..74ced0b5 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_worst_sites.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_worst_sites.js @@ -1,54 +1,3 @@ -import { - listenExecutiveHeatmaps, - medianFromBlocks, - averageFromBlocks, - renderTable, - colorSwatch, - getSiteIdMap, - renderSiteLink -} from "./executive_utils"; -import {colorByRttMs} from "./helpers/color_scales"; -import {colorByCapacity} from "./dashlets/executive_heatmap_shared"; +import {renderExecutiveLeaderboardPage} from "./executive_leaderboard_page_template"; -function buildRows(data) { - const sites = (data.sites || []).map(site => { - const rtt = medianFromBlocks(site.blocks?.rtt || []); - const avgDown = averageFromBlocks(site.blocks?.download || []); - const avgUp = averageFromBlocks(site.blocks?.upload || []); - return { - name: site.site_name || "Site", - rtt, - avgDown, - avgUp, - }; - }).filter(row => row.rtt !== null); - - sites.sort((a, b) => (b.rtt || 0) - (a.rtt || 0)); - return sites.slice(0, 20); -} - -function render(data) { - const rows = buildRows(data); - getSiteIdMap().then((siteIdMap) => { - renderTable("executiveWorstSitesTable", [ - { header: "Site", render: (r) => renderSiteLink(r.name, siteIdMap) }, - { header: "Median RTT (ms)", render: (r) => { - if (r.rtt === null) return "—"; - const color = colorByRttMs(r.rtt); - return `${colorSwatch(color)}${r.rtt.toFixed(1)}`; - }}, - { header: "Avg Down Util (%)", render: (r) => { - if (r.avgDown === null) return "—"; - const color = colorByCapacity(r.avgDown); - return `${colorSwatch(color)}${r.avgDown.toFixed(1)}`; - }}, - { header: "Avg Up Util (%)", render: (r) => { - if (r.avgUp === null) return "—"; - const color = colorByCapacity(r.avgUp); - return `${colorSwatch(color)}${r.avgUp.toFixed(1)}`; - }}, - ], rows, "No site heatmap data yet."); - }); -} - -listenExecutiveHeatmaps(render); +renderExecutiveLeaderboardPage("executiveWorstSitesTable", "worst_sites"); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_backlog.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_backlog.js index a6e0ee8e..1bd64112 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_backlog.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_backlog.js @@ -1,19 +1,22 @@ import {DashboardGraph} from "./dashboard_graph"; -import {scaleNumber} from "../lq_js_common/helpers/scaling"; +import { + CAKE_CHART_WINDOW_SECONDS, + cakeChartTitle, + cakeCommonGrid, + cakeCommonXAxis, + cakeHistoryWindow, + cakeScatterSeries, + cakeTooltip, + formatCakeBytes, +} from "./cake_history"; export class CakeBacklog extends DashboardGraph { constructor(id) { super(id); - let xaxis = []; - for (let i=0; i<600; i++) { - xaxis.push(i); - } - this.option = { - title: { - text: "Backlog", - }, + title: cakeChartTitle("Backlog", "Bytes"), + grid: cakeCommonGrid(), legend: { orient: "horizontal", right: 10, @@ -50,87 +53,24 @@ export class CakeBacklog extends DashboardGraph { } ] }, - xAxis: { - type: 'category', - data: xaxis, - }, + xAxis: cakeCommonXAxis(CAKE_CHART_WINDOW_SECONDS), yAxis: { type: 'value', axisLabel: { - formatter: (val) => { - return scaleNumber(Math.abs(val), 0); - }, + formatter: (val) => formatCakeBytes(val), } }, series: [ - { - name: 'Bulk', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] } - }, - { - name: 'Best Effort', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'Voice', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, - { - name: 'Bulk Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] }, - }, - { - name: 'Best Effort Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'RT Voice Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, + cakeScatterSeries("Bulk", window.graphPalette[0]), + cakeScatterSeries("Best Effort", window.graphPalette[1]), + cakeScatterSeries("RT Video", window.graphPalette[2]), + cakeScatterSeries("Voice", window.graphPalette[3]), + cakeScatterSeries("Bulk Up", window.graphPalette[0]), + cakeScatterSeries("Best Effort Up", window.graphPalette[1]), + cakeScatterSeries("RT Video Up", window.graphPalette[2]), + cakeScatterSeries("RT Voice Up", window.graphPalette[3]), ], - tooltip: { - trigger: 'item', - }, + tooltip: cakeTooltip(formatCakeBytes), animation: false, } this.option && this.chart.setOption(this.option); @@ -160,19 +100,15 @@ export class CakeBacklog extends DashboardGraph { for (let i=0; i<8; i++) { this.option.series[i].data = []; } - //console.log(msg); - for (let i=msg.history_head; i<600; i++) { + for (const sample of cakeHistoryWindow(msg, CAKE_CHART_WINDOW_SECONDS)) { for (let j=0; j<4; j++) { - if (msg.history[i][0].tins[0] === undefined) continue; - this.option.series[j].data.push(msg.history[i][0].tins[j].backlog_bytes); - this.option.series[j+4].data.push(0 - msg.history[i][1].tins[j].backlog_bytes); - } - } - for (let i=0; i formatCakeMillisecondsFromUs(val), + }, }, series: [ - { - name: 'Bulk', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] } - }, - { - name: 'Best Effort', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'Voice', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, - { - name: 'Bulk Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] }, - }, - { - name: 'Best Effort Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'RT Voice Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, + cakeScatterSeries("Bulk", window.graphPalette[0]), + cakeScatterSeries("Best Effort", window.graphPalette[1]), + cakeScatterSeries("RT Video", window.graphPalette[2]), + cakeScatterSeries("Voice", window.graphPalette[3]), + cakeScatterSeries("Bulk Up", window.graphPalette[0]), + cakeScatterSeries("Best Effort Up", window.graphPalette[1]), + cakeScatterSeries("RT Video Up", window.graphPalette[2]), + cakeScatterSeries("RT Voice Up", window.graphPalette[3]), ], - tooltip: { - trigger: 'item', - }, + tooltip: cakeTooltip(formatCakeMillisecondsFromUs), animation: false, } this.option && this.chart.setOption(this.option); @@ -153,19 +99,15 @@ export class CakeDelays extends DashboardGraph { for (let i=0; i<8; i++) { this.option.series[i].data = []; } - //console.log(msg); - for (let i=msg.history_head; i<600; i++) { + for (const sample of cakeHistoryWindow(msg, CAKE_CHART_WINDOW_SECONDS)) { for (let j=0; j<4; j++) { - if (msg.history[i][0].tins[0] === undefined) continue; - this.option.series[j].data.push(msg.history[i][0].tins[j].base_delay_us); - this.option.series[j+4].data.push(0 - msg.history[i][1].tins[j].base_delay_us); - } - } - for (let i=0; i { - return scaleNumber(Math.abs(val), 0); - }, + formatter: (val) => formatCakePackets(val), } }, series: [ - { - name: 'Bulk', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] } - }, - { - name: 'Best Effort', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'Voice', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, - { - name: 'Bulk Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] } - }, - { - name: 'Best Effort Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'RT Voice Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, + cakeScatterSeries("Bulk", window.graphPalette[0]), + cakeScatterSeries("Best Effort", window.graphPalette[1]), + cakeScatterSeries("RT Video", window.graphPalette[2]), + cakeScatterSeries("Voice", window.graphPalette[3]), + cakeScatterSeries("Bulk Up", window.graphPalette[0]), + cakeScatterSeries("Best Effort Up", window.graphPalette[1]), + cakeScatterSeries("RT Video Up", window.graphPalette[2]), + cakeScatterSeries("RT Voice Up", window.graphPalette[3]), ], - tooltip: { - trigger: 'item', - }, + tooltip: cakeTooltip(formatCakePackets), animation: false, } this.option && this.chart.setOption(this.option); @@ -159,19 +99,15 @@ export class CakeDrops extends DashboardGraph { for (let i=0; i<8; i++) { this.option.series[i].data = []; } - //console.log(msg); - for (let i=msg.history_head; i<600; i++) { + for (const sample of cakeHistoryWindow(msg, CAKE_CHART_WINDOW_SECONDS)) { for (let j=0; j<4; j++) { - if (msg.history[i][0].tins[0] === undefined) continue; - this.option.series[j].data.push(msg.history[i][0].tins[j].drops); - this.option.series[j+4].data.push(0 - msg.history[i][1].tins[j].drops); - } - } - for (let i=0; i= 1; minute--) { + const index = windowSize - (minute * 60); + if (index >= 0 && index < windowSize) { + labels[index] = `-${minute}m`; + } + } + + if (windowSize > 0) { + if (labels[0] === "") { + labels[0] = `-${Math.ceil(windowSize / 60)}m`; + } + labels[windowSize - 1] = "Now"; + } + + return labels; +} + +export function cakeChartTitle(text, unitsLabel) { + return { + text, + subtext: `${CAKE_CHART_WINDOW_LABEL} · ${unitsLabel}`, + left: 10, + top: 10, + textStyle: { + fontSize: 14, + fontWeight: 700, + }, + subtextStyle: { + fontSize: 11, + color: "#8c939d", + }, + }; +} + +export function cakeCommonGrid() { + return { + left: 58, + right: 16, + top: 62, + bottom: 46, + containLabel: true, + }; +} + +export function cakeCommonXAxis(windowSize = CAKE_CHART_WINDOW_SECONDS) { + return { + type: "category", + data: cakeChartXAxis(windowSize), + boundaryGap: false, + axisTick: { + show: false, + }, + axisLabel: { + color: "#8c939d", + fontSize: 10, + interval: 0, + }, + axisLine: { + lineStyle: { + color: "rgba(148, 163, 184, 0.28)", + }, + }, + splitLine: { + show: false, + }, + axisPointer: { + show: true, + label: { + show: false, + }, + }, + }; +} + +export function cakeScatterSeries(name, color) { + return { + name, + data: [], + type: "scatter", + symbol: "circle", + symbolSize: 4, + itemStyle: { + color, + opacity: 0.72, + }, + emphasis: { + scale: 1.8, + itemStyle: { + color, + opacity: 0.98, + borderColor: "rgba(255, 255, 255, 0.92)", + borderWidth: 1, + }, + }, + }; +} + +export function cakeTooltip(unitFormatter) { + return { + trigger: "axis", + axisPointer: { + type: "line", + snap: false, + }, + confine: true, + formatter: (params) => { + const rows = Array.isArray(params) ? params : [params]; + if (rows.length === 0) { + return ""; + } + const heading = rows[0]?.axisValueLabel || "Selected sample"; + const lines = [ + `
${heading}
`, + ]; + + rows.forEach((entry) => { + const raw = Array.isArray(entry.value) ? entry.value[1] : entry.value; + if (raw === null || raw === undefined || Number.isNaN(Number(raw))) { + return; + } + const isUpload = entry.seriesName.endsWith(" Up"); + const label = isUpload + ? entry.seriesName.slice(0, -3) + : entry.seriesName; + const direction = isUpload ? "Upload" : "Download"; + lines.push( + `${entry.marker}${label} (${direction}): ${unitFormatter(Math.abs(Number(raw)))}`, + ); + }); + + return lines.join("
"); + }, + }; +} + +export function formatCakeBytes(value) { + return `${scaleNumber(Math.abs(toNumber(value, 0)), 1)}B`; +} + +export function formatCakeBitsPerSecond(value) { + return `${scaleNumber(Math.abs(toNumber(value, 0)), 1)}bps`; +} + +export function formatCakePackets(value) { + return `${scaleNumber(Math.abs(toNumber(value, 0)), 0)} pkts`; +} + +export function formatCakeMillisecondsFromUs(value) { + const ms = Math.abs(toNumber(value, 0)) / 1000; + if (ms >= 100) { + return `${Math.round(ms)}ms`; + } + if (ms >= 10) { + return `${trimTrailingZeros(ms.toFixed(1))}ms`; + } + return `${trimTrailingZeros(ms.toFixed(2))}ms`; +} + +export function cakeHistoryWindow(msg, windowSize = CAKE_CHART_WINDOW_SECONDS) { + if (!msg || !Array.isArray(msg.history) || msg.history.length === 0) { + return new Array(windowSize).fill(null); + } + + const historySize = msg.history.length; + const historyHead = Number.isFinite(msg.history_head) ? msg.history_head : 0; + const ordered = []; + + for (let i = historyHead; i < historySize; i++) { + ordered.push(msg.history[i]); + } + for (let i = 0; i < historyHead; i++) { + ordered.push(msg.history[i]); + } + + const available = ordered.slice(-Math.min(windowSize, ordered.length)); + if (available.length >= windowSize) { + return available; + } + + return new Array(windowSize - available.length).fill(null).concat(available); +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_marks.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_marks.js index 9926c925..773ba18f 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_marks.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/cake_marks.js @@ -1,19 +1,22 @@ import {DashboardGraph} from "./dashboard_graph"; -import {scaleNumber} from "../lq_js_common/helpers/scaling"; +import { + CAKE_CHART_WINDOW_SECONDS, + cakeChartTitle, + cakeCommonGrid, + cakeCommonXAxis, + cakeHistoryWindow, + cakeScatterSeries, + cakeTooltip, + formatCakePackets, +} from "./cake_history"; export class CakeMarks extends DashboardGraph { constructor(id) { super(id); - let xaxis = []; - for (let i=0; i<600; i++) { - xaxis.push(i); - } - this.option = { - title: { - text: "ECN Marks", - }, + title: cakeChartTitle("ECN Marks", "Packets per sample"), + grid: cakeCommonGrid(), legend: { orient: "horizontal", right: 10, @@ -50,87 +53,24 @@ export class CakeMarks extends DashboardGraph { } ] }, - xAxis: { - type: 'category', - data: xaxis, - }, + xAxis: cakeCommonXAxis(CAKE_CHART_WINDOW_SECONDS), yAxis: { type: 'value', axisLabel: { - formatter: (val) => { - return scaleNumber(Math.abs(val), 0); - }, + formatter: (val) => formatCakePackets(val), } }, series: [ - { - name: 'Bulk', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] } - }, - { - name: 'Best Effort', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'Voice', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, - { - name: 'Bulk Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] }, - }, - { - name: 'Best Effort Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'RT Voice Up', - data: [], - type: 'scatter', - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, + cakeScatterSeries("Bulk", window.graphPalette[0]), + cakeScatterSeries("Best Effort", window.graphPalette[1]), + cakeScatterSeries("RT Video", window.graphPalette[2]), + cakeScatterSeries("Voice", window.graphPalette[3]), + cakeScatterSeries("Bulk Up", window.graphPalette[0]), + cakeScatterSeries("Best Effort Up", window.graphPalette[1]), + cakeScatterSeries("RT Video Up", window.graphPalette[2]), + cakeScatterSeries("RT Voice Up", window.graphPalette[3]), ], - tooltip: { - trigger: 'item', - }, + tooltip: cakeTooltip(formatCakePackets), animation: false, } this.option && this.chart.setOption(this.option); @@ -159,19 +99,15 @@ export class CakeMarks extends DashboardGraph { for (let i=0; i<8; i++) { this.option.series[i].data = []; } - //console.log(msg); - for (let i=msg.history_head; i<600; i++) { + for (const sample of cakeHistoryWindow(msg, CAKE_CHART_WINDOW_SECONDS)) { for (let j=0; j<4; j++) { - if (msg.history[i][0].tins[0] === undefined) continue; - this.option.series[j].data.push(msg.history[i][0].tins[j].marks); - this.option.series[j+4].data.push(0 - msg.history[i][1].tins[j].marks); - } - } - for (let i=0; i formatCakePackets(val), + }, }, series: [ - { - name: 'Queue Length', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[0] - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: window.graphPalette[0] - }, - { - name: 'Queue Length Up', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[0] - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: window.graphPalette[0] - }, + cakeScatterSeries("Queue Length", window.graphPalette[0]), + cakeScatterSeries("Queue Length Up", window.graphPalette[4] || window.graphPalette[0]), ], - tooltip: { - trigger: 'item', - }, + tooltip: cakeTooltip(formatCakePackets), animation: false, } this.option && this.chart.setOption(this.option); @@ -73,10 +55,8 @@ export class CakeQueueLength extends DashboardGraph { onThemeChange() { super.onThemeChange(); this.option.legend.data[0].itemStyle.color = window.graphPalette[0]; - this.option.series[0].lineStyle.color = window.graphPalette[0]; - this.option.series[0].itemStyle = window.graphPalette[0]; - this.option.series[1].lineStyle.color = window.graphPalette[0]; - this.option.series[1].itemStyle = window.graphPalette + this.option.series[0].itemStyle.color = window.graphPalette[0]; + this.option.series[1].itemStyle.color = window.graphPalette[4] || window.graphPalette[0]; this.chart.setOption(this.option); } @@ -86,14 +66,14 @@ export class CakeQueueLength extends DashboardGraph { this.option.series[0].data = []; this.option.series[1].data = []; - //console.log(msg); - for (let i=msg.history_head; i<600; i++) { - this.option.series[0].data.push(msg.history[i][0].qlen); - this.option.series[1].data.push(0 - msg.history[i][1].qlen); - } - for (let i=0; i { - return scaleNumber(Math.abs(val), 0); - }, + formatter: (val) => formatCakeBitsPerSecond(val), } }, series: [ - { - name: 'Bulk', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[0], - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] } - }, - { - name: 'Best Effort', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[1] - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[2] - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'Voice', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[3] - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, - { - name: 'Bulk Up', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[0], - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[0] } - }, - { - name: 'Best Effort Up', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[1] - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[1] } - }, - { - name: 'RT Video Up', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[2] - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[2] } - }, - { - name: 'RT Voice Up', - data: [], - type: 'scatter', - lineStyle: { - color: window.graphPalette[3] - }, - symbol: 'circle', - symbolSize: 2, - itemStyle: { color: window.graphPalette[3] } - }, + cakeScatterSeries("Bulk", window.graphPalette[0]), + cakeScatterSeries("Best Effort", window.graphPalette[1]), + cakeScatterSeries("RT Video", window.graphPalette[2]), + cakeScatterSeries("Voice", window.graphPalette[3]), + cakeScatterSeries("Bulk Up", window.graphPalette[0]), + cakeScatterSeries("Best Effort Up", window.graphPalette[1]), + cakeScatterSeries("RT Video Up", window.graphPalette[2]), + cakeScatterSeries("RT Voice Up", window.graphPalette[3]), ], - tooltip: { - trigger: 'item', - }, + tooltip: cakeTooltip(formatCakeBitsPerSecond), animation: false, } this.option && this.chart.setOption(this.option); @@ -183,21 +100,15 @@ export class CakeTraffic extends DashboardGraph { for (let i=0; i<8; i++) { this.option.series[i].data = []; } - //console.log(msg); - for (let i=msg.history_head; i<600; i++) { + for (const sample of cakeHistoryWindow(msg, CAKE_CHART_WINDOW_SECONDS)) { for (let j=0; j<4; j++) { - if (msg.history[i][0].tins[0] === undefined) continue; - const downBits = toNumber(msg.history[i][0].tins[j].sent_bytes, 0) * 8; - const upBits = toNumber(msg.history[i][1].tins[j].sent_bytes, 0) * 8; - this.option.series[j].data.push(downBits); - this.option.series[j+4].data.push(0 - upBits); - } - } - for (let i=0; i 0 && state.clientHeight > 0 && state.display !== "none"; +} + function parsePixels(value) { if (!value || typeof value !== "string" || !value.endsWith("px")) { return 0; @@ -55,9 +91,17 @@ export class DashboardGraph { constructor(id) { this.id = id; this.dom = document.getElementById(id); + this.pendingSetOptionArgs = null; + this.pendingSetOptionRaf = null; + this.pendingSetOptionAttempts = 0; if (!this.dom) { throw new Error(`DashboardGraph: missing DOM element '${id}'`); } + emitDashboardRenderHook({ + graphId: this.id, + stage: "graph-constructor-start", + ...domStateSnapshot(this.dom), + }); this.dom.classList.add("muted"); // Some charts are created while Bootstrap/tab layout is still settling. // Ensure ECharts never initializes against a 0x0 container. @@ -100,6 +144,10 @@ export class DashboardGraph { ]; this.chart = echarts.init(this.dom, 'vintage'); } + this.#wrapChartMethod("setOption"); + this.#wrapChartMethod("resize"); + this.#wrapChartMethod("showLoading"); + this.#wrapChartMethod("hideLoading"); this.chart.showLoading(); this.option = {}; window.requestAnimationFrame(() => { @@ -108,6 +156,11 @@ export class DashboardGraph { this.chart.resize(); } }); + emitDashboardRenderHook({ + graphId: this.id, + stage: "graph-constructor-complete", + ...domStateSnapshot(this.dom), + }); // Apply to the global list of graphs if (window.graphList === undefined) { @@ -126,6 +179,97 @@ export class DashboardGraph { window.graphList.push(this); } + #scheduleDeferredSetOptionRetry() { + if (this.pendingSetOptionRaf !== null || this.pendingSetOptionArgs === null) { + return; + } + this.pendingSetOptionRaf = window.requestAnimationFrame(() => { + this.pendingSetOptionRaf = null; + if (this.pendingSetOptionArgs === null) { + return; + } + if (domCanPaint(this.dom)) { + this.#flushPendingSetOption("raf"); + return; + } + this.pendingSetOptionAttempts += 1; + if (this.pendingSetOptionAttempts >= 10) { + emitDashboardRenderHook({ + graphId: this.id, + stage: "chart-setOption-drop-deferred", + attempts: this.pendingSetOptionAttempts, + ...domStateSnapshot(this.dom), + }); + this.pendingSetOptionArgs = null; + this.pendingSetOptionAttempts = 0; + return; + } + this.#scheduleDeferredSetOptionRetry(); + }); + } + + #flushPendingSetOption(reason) { + if (this.pendingSetOptionArgs === null) { + return; + } + const args = this.pendingSetOptionArgs; + this.pendingSetOptionArgs = null; + this.pendingSetOptionAttempts = 0; + emitDashboardRenderHook({ + graphId: this.id, + stage: "chart-setOption-flush-deferred", + reason, + ...domStateSnapshot(this.dom), + }); + this.chart.setOption(...args); + } + + #wrapChartMethod(methodName) { + if (!this.chart || typeof this.chart[methodName] !== "function") { + return; + } + const original = this.chart[methodName].bind(this.chart); + this.chart[methodName] = (...args) => { + emitDashboardRenderHook({ + graphId: this.id, + stage: `chart-${methodName}-start`, + argsCount: args.length, + ...domStateSnapshot(this.dom), + }); + try { + if (methodName === "setOption" && !domCanPaint(this.dom)) { + this.pendingSetOptionArgs = args; + this.pendingSetOptionAttempts = 0; + emitDashboardRenderHook({ + graphId: this.id, + stage: "chart-setOption-deferred", + ...domStateSnapshot(this.dom), + }); + this.#scheduleDeferredSetOptionRetry(); + return undefined; + } + const result = original(...args); + emitDashboardRenderHook({ + graphId: this.id, + stage: `chart-${methodName}-ok`, + ...domStateSnapshot(this.dom), + }); + if (methodName === "resize" && domCanPaint(this.dom)) { + this.#flushPendingSetOption("resize"); + } + return result; + } catch (err) { + emitDashboardRenderHook({ + graphId: this.id, + stage: `chart-${methodName}-error`, + error: err && err.message ? err.message : String(err), + ...domStateSnapshot(this.dom), + }); + throw err; + } + }; + } + onThemeChange() { // Override this if you have to } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/index.js b/src/rust/lqosd/src/node_manager/js_build/src/index.js index 43d91f35..21003811 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/index.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/index.js @@ -12,12 +12,12 @@ const defaultLayout = { name: "Executive Summary", dashlets: [ { tag: "executiveSnapshot", size: 12 }, - { tag: "executiveHelpers", size: 12 }, { tag: "executiveGlobalHeatmap", size: 12 }, { tag: "executiveHeatmapDownload", size: 6 }, { tag: "executiveHeatmapUpload", size: 6 }, { tag: "executiveHeatmapRtt", size: 6 }, - { tag: "executiveHeatmapRetrans", size: 6 } + { tag: "executiveHeatmapRetrans", size: 6 }, + { tag: "executiveHelpers", size: 12 } ] }, { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/base_dashlet.js b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/base_dashlet.js index 5fe7e127..950e623d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/base_dashlet.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/base_dashlet.js @@ -48,9 +48,39 @@ export class BaseDashlet { return []; } + keepAliveWhenHidden() { + return false; + } + + keepSubscribedWhenHidden() { + return this.keepAliveWhenHidden(); + } + + renderWhenHidden() { + return false; + } + onMessage(msg) { } + onBackgroundMessage(_msg) { + } + + flushBackgroundMessages() { + return false; + } + + traceRender(stage, details = {}) { + if (typeof window !== "undefined" && typeof window.__lqosDashboardRenderHook === "function") { + window.__lqosDashboardRenderHook({ + dashletId: this.id, + title: this.title(), + stage, + ...details, + }); + } + } + setupOnce(msg) { if (this.disposed) { return; @@ -59,11 +89,23 @@ export class BaseDashlet { this.setup(msg); } this.setupDone = true; + if (typeof window !== "undefined" && typeof window.__lqosDashboardDashletHook === "function") { + window.__lqosDashboardDashletHook({ + type: "setup", + dashletId: this.id, + title: this.title(), + setupDone: this.setupDone, + }); + } enableTooltips(); } setup() {} + onTabActivated() {} + + onTabDeactivated() {} + graphDivId() { return this.id + "_graph"; } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/dashboard.js b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/dashboard.js index 3b151c73..a13ba6c4 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/dashboard.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/dashboard.js @@ -1,10 +1,11 @@ // Provides a generic dashboard system for use in LibreQoS and Insight import {DashboardLayout} from "./layout"; -import {subscribeWS} from "./ws"; import {get_ws_client} from "../../pubsub/ws"; import {heading5Icon} from "../helpers/content_builders"; import {openDashboardEditor} from "./dashboard_editor"; +const DIAGNOSTIC_CHANNELS = new Set(["Cpu", "Ram", "RttHistogram"]); + export class Dashboard { // Takes the target DIV in which to build the dashboard, // and the name of the dashboard for cookie and builder @@ -16,6 +17,7 @@ export class Dashboard { this.widgetFactory = widgetFactory; this.dashletMenu = dashletMenu; this.savedDashUrl = savedDashUrl; + this.hasCadence = hasCadence; // Set up the div this.parentDiv = document.getElementById(this.divName); @@ -43,34 +45,230 @@ export class Dashboard { // Data Wrangling this.channels = []; - this.subscription = null; + this.tabChannels = {}; + this.pendingImmediateDashlets = new Set(); + this.activatedTabs = new Set(); + this.wsClient = get_ws_client(); + this.channelDisposers = new Map(); + this.subscribedChannels = new Set(); + this.lastMessages = new Map(); // Auto Refresh Handling this.paused = false; - let cadence = localStorage.getItem("dashCadence"); - if (cadence === null) { + if (!this.hasCadence) { this.cadence = 1; - localStorage.setItem("dashCadence", this.cadence.toString()); } else { - this.cadence = parseInt(cadence); + let cadence = localStorage.getItem("dashCadence"); + if (cadence === null) { + this.cadence = 1; + localStorage.setItem("dashCadence", this.cadence.toString()); + } else { + const parsedCadence = parseInt(cadence); + this.cadence = Number.isFinite(parsedCadence) && parsedCadence > 0 ? parsedCadence : 1; + } } this.tickCounter = 0; } + #urlDebugEnabled() { + try { + const params = new URLSearchParams(window.location.search || ""); + if (!params.has("debug")) { + return false; + } + const value = (params.get("debug") || "").trim().toLowerCase(); + return value === "" || value === "1" || value === "true" || value === "dashboard"; + } catch (_) { + return false; + } + } + + #debugEnabled() { + return localStorage.getItem("debugDashboard") === "1" || this.#urlDebugEnabled(); + } + + #debugDumpEnabled() { + return this.#urlDebugEnabled(); + } + + #debug(event, details = {}) { + if (!this.#debugEnabled()) { + return; + } + if (!window.__lqosDashboardTrace) { + window.__lqosDashboardTrace = []; + } + const entry = { + ts: new Date().toISOString(), + event, + ...details, + }; + window.__lqosDashboardTrace.push(entry); + if (window.__lqosDashboardTrace.length > 1000) { + window.__lqosDashboardTrace.shift(); + } + console.debug("[dashboard]", entry); + } + + #markDashletState(dashlet, patch = {}) { + if (!dashlet) { + return; + } + if (!dashlet.__dashboardDebugState) { + dashlet.__dashboardDebugState = {}; + } + Object.assign(dashlet.__dashboardDebugState, patch); + const el = document.getElementById(dashlet.id); + if (!el) { + return; + } + if (dashlet.__dashboardDebugState.lastSetupAt) { + el.dataset.debugLastSetupAt = dashlet.__dashboardDebugState.lastSetupAt; + } + if (dashlet.__dashboardDebugState.lastMessageAt) { + el.dataset.debugLastMessageAt = dashlet.__dashboardDebugState.lastMessageAt; + } + if (dashlet.__dashboardDebugState.lastEvent) { + el.dataset.debugLastEvent = dashlet.__dashboardDebugState.lastEvent; + } + if (dashlet.__dashboardDebugState.lastReplayAt) { + el.dataset.debugLastReplayAt = dashlet.__dashboardDebugState.lastReplayAt; + } + if (dashlet.__dashboardDebugState.lastReplayEvent) { + el.dataset.debugLastReplayEvent = dashlet.__dashboardDebugState.lastReplayEvent; + } + } + + #paneStateSnapshot(pane) { + if (!pane) { + return { + hasPane: false, + }; + } + const rect = typeof pane.getBoundingClientRect === "function" + ? pane.getBoundingClientRect() + : { width: 0, height: 0 }; + const computed = window.getComputedStyle ? window.getComputedStyle(pane) : null; + return { + hasPane: true, + paneId: pane.id, + paneClientWidth: pane.clientWidth, + paneClientHeight: pane.clientHeight, + paneRectWidth: rect.width, + paneRectHeight: rect.height, + paneDisplay: computed ? computed.display : "", + paneVisibility: computed ? computed.visibility : "", + }; + } + + #dashletDebugSnapshot(dashlet) { + let channels = []; + if (dashlet && typeof dashlet.subscribeTo === "function") { + const subscribed = dashlet.subscribeTo(); + channels = Array.isArray(subscribed) ? [...subscribed] : [subscribed]; + } + return { + id: dashlet?.id || "", + title: dashlet?.title || "", + tag: dashlet?.tag || "", + tabIndex: dashlet?.tabIndex, + keepSubscribedWhenHidden: !!dashlet?.keepSubscribedWhenHidden?.(), + renderWhenHidden: !!dashlet?.renderWhenHidden?.(), + subscribed: this.#dashletShouldStayActive(dashlet), + renderable: dashlet?.tabIndex === this.layout.activeTab || !!dashlet?.renderWhenHidden?.(), + channels, + debugState: dashlet?.__dashboardDebugState || {}, + }; + } + + #downloadDebugDump() { + const tabs = Array.from(document.querySelectorAll(`#${this.divName}_tabs .nav-link`)).map((tab, index) => ({ + index, + title: tab.textContent ? tab.textContent.trim() : "", + active: tab.classList.contains("active"), + })); + const panes = Array.from(document.querySelectorAll(`#${this.divName}_tab_content .tab-pane`)).map((pane, index) => ({ + index, + classes: Array.from(pane.classList), + ...this.#paneStateSnapshot(pane), + })); + const desiredChannels = this.wsClient?.desiredChannels instanceof Map + ? Array.from(this.wsClient.desiredChannels.keys()) + : []; + const subscribedChannels = Array.from(this.subscribedChannels); + const dump = { + generatedAt: new Date().toISOString(), + href: window.location.href, + userAgent: navigator.userAgent, + activeTab: this.layout.activeTab, + cadence: this.cadence, + paused: this.paused, + activatedTabs: Array.from(this.activatedTabs), + desiredChannels, + subscribedChannels, + lastMessageChannels: Array.from(this.lastMessages.keys()), + tabs, + panes, + dashlets: this.dashlets.map((dashlet) => this.#dashletDebugSnapshot(dashlet)), + dashboardTrace: window.__lqosDashboardTrace || [], + wsTrace: window.__lqosDashboardWsTrace || [], + }; + const stamp = dump.generatedAt.replace(/[:.]/g, "-"); + const fileName = `${this.cookieName || "dashboard"}-debug-${stamp}.json`; + const blob = new Blob([JSON.stringify(dump, null, 2)], {type: "application/json"}); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), 1000); + this.#debug("debug-dump-download", { + fileName, + subscribedChannels, + desiredChannels, + }); + } + build() { this.destroy(); + window.__lqosDashboardDashletHook = (entry) => { + this.#debug("dashlet-hook", entry); + }; + window.__lqosDashboardRenderHook = (entry) => { + this.#debug("render-hook", entry); + }; this.#filterWidgetList(); this.#buildTabUI(); this.#buildTabContents(); + this.activatedTabs.add(this.layout.activeTab); this.#buildChannelList(this.dashlets); - this.#webSocketSubscription(); + const renderableDashlets = this.#renderableDashlets(); + this.#debug("build", { + activeTab: this.layout.activeTab, + channels: this.channels, + renderableDashlets: renderableDashlets.map((d) => d.id), + }); + for (let i = 0; i < renderableDashlets.length; i++) { + renderableDashlets[i].setupOnce({}); + this.#markDashletState(renderableDashlets[i], { + lastSetupAt: new Date().toISOString(), + }); + } + this.#queueImmediateDashlets(renderableDashlets); + this.#syncWebSocketChannels(); + this.#replayCachedMessages(renderableDashlets); } destroy() { - if (this.subscription) { - this.subscription.dispose(); - this.subscription = null; + this.#disposeWebSocketChannels(); + if (window.__lqosDashboardDashletHook) { + delete window.__lqosDashboardDashletHook; + } + if (window.__lqosDashboardRenderHook) { + delete window.__lqosDashboardRenderHook; } for (let i = 0; i < this.dashlets.length; i++) { if (this.dashlets[i] && this.dashlets[i].dispose) { @@ -80,7 +278,11 @@ export class Dashboard { this.dashlets = []; this.tabDashlets = {}; this.channels = []; + this.tabChannels = {}; this.childIds = []; + this.pendingImmediateDashlets.clear(); + this.activatedTabs = new Set(); + this.lastMessages.clear(); this.#clearRenderedDashboard(); } @@ -149,9 +351,15 @@ export class Dashboard { switchTab(tabIndex) { if (tabIndex === this.layout.activeTab) return; - + const previousTabIndex = this.layout.activeTab; + this.#debug("switch-tab", { + from: previousTabIndex, + to: tabIndex, + }); + // Update active tab in layout this.layout.activeTab = tabIndex; + this.activatedTabs.add(tabIndex); this.layout.save(this.layout); // Update tab navigation @@ -170,8 +378,26 @@ export class Dashboard { if (index === tabIndex) { pane.classList.add('active'); pane.style.display = 'flex'; - // Notify dashlets in this tab that they're now visible + this.#debug("tab-pane-visible", { + tabIndex, + ...this.#paneStateSnapshot(pane), + }); let tabDashlets = this.tabDashlets[index] || []; + const skipReplayDashletIds = new Set(); + tabDashlets.forEach((dashlet) => { + dashlet.setupOnce({}); + this.#markDashletState(dashlet, { + lastSetupAt: new Date().toISOString(), + }); + }); + tabDashlets.forEach((dashlet) => { + if (dashlet.flushBackgroundMessages && dashlet.flushBackgroundMessages()) { + skipReplayDashletIds.add(dashlet.id); + } + }); + this.#queueImmediateDashlets(tabDashlets); + this.#replayCachedMessages(tabDashlets, skipReplayDashletIds); + // Notify dashlets in this tab that they're now visible tabDashlets.forEach(dashlet => { if (dashlet.onTabActivated) { dashlet.onTabActivated(); @@ -180,6 +406,10 @@ export class Dashboard { // Resize all ECharts instances in the newly visible tab setTimeout(() => { + this.#debug("tab-pane-resize-pass", { + tabIndex, + ...this.#paneStateSnapshot(pane), + }); const charts = pane.querySelectorAll('.dashgraph, .dashgraphZoomed'); charts.forEach(chartDiv => { if (typeof echarts !== 'undefined') { @@ -195,6 +425,16 @@ export class Dashboard { pane.style.display = 'none'; } }); + + const previousDashlets = this.tabDashlets[previousTabIndex] || []; + previousDashlets.forEach((dashlet) => { + if (dashlet.onTabDeactivated) { + dashlet.onTabDeactivated(); + } + }); + + this.#buildChannelList(); + this.#syncWebSocketChannels(); } #filterWidgetList() { @@ -229,11 +469,133 @@ export class Dashboard { } } + #dashletShouldStayActive(dashlet) { + return dashlet.keepSubscribedWhenHidden() || this.activatedTabs.has(dashlet.tabIndex); + } + + #subscribedDashlets() { + return this.dashlets.filter((dashlet) => this.#dashletShouldStayActive(dashlet)); + } + + #renderableDashlets() { + return this.dashlets.filter( + (dashlet) => dashlet.tabIndex === this.layout.activeTab || dashlet.renderWhenHidden(), + ); + } + + #backgroundDashlets() { + return this.#subscribedDashlets().filter( + (dashlet) => + dashlet.tabIndex !== this.layout.activeTab + && dashlet.keepSubscribedWhenHidden() + && !dashlet.renderWhenHidden(), + ); + } + + #queueImmediateDashlets(dashlets) { + if (!Array.isArray(dashlets) || this.cadence <= 1) { + return; + } + for (let i = 0; i < dashlets.length; i++) { + const dashlet = dashlets[i]; + if (dashlet && dashlet.canBeSlowedDown()) { + this.pendingImmediateDashlets.add(dashlet.id); + this.#debug("queue-immediate", { + dashletId: dashlet.id, + }); + } + } + } + + #debugInterestingChannels(context, channels) { + if (!Array.isArray(channels)) { + return; + } + const interesting = channels.filter((channel) => DIAGNOSTIC_CHANNELS.has(channel)); + if (interesting.length === 0) { + return; + } + this.#debug("interesting-channels", { + context, + channels: interesting, + activeTab: this.layout.activeTab, + renderableDashlets: this.#renderableDashlets().map((d) => d.id), + subscribedDashlets: this.#subscribedDashlets().map((d) => d.id), + }); + } + + #dashletSubscribedToMessage(dashlet, eventName) { + const channels = dashlet.subscribeTo(); + if (Array.isArray(channels)) { + return channels.includes(eventName); + } + return channels === eventName; + } + + #replayCachedMessages(dashlets, skipDashletIds = new Set()) { + if (!Array.isArray(dashlets)) { + return; + } + for (let i = 0; i < dashlets.length; i++) { + const dashlet = dashlets[i]; + if (skipDashletIds.has(dashlet.id)) { + continue; + } + const channels = dashlet.subscribeTo(); + if (!Array.isArray(channels)) { + if (channels && this.lastMessages.has(channels)) { + dashlet.onMessage(this.lastMessages.get(channels)); + this.#markDashletState(dashlet, { + lastReplayAt: new Date().toISOString(), + lastReplayEvent: channels, + }); + this.#debug("replay-cached", { + dashletId: dashlet.id, + eventName: channels, + }); + this.pendingImmediateDashlets.delete(dashlet.id); + } + continue; + } + for (let j = 0; j < channels.length; j++) { + const channel = channels[j]; + if (this.lastMessages.has(channel)) { + dashlet.onMessage(this.lastMessages.get(channel)); + this.#markDashletState(dashlet, { + lastReplayAt: new Date().toISOString(), + lastReplayEvent: channel, + }); + this.#debug("replay-cached", { + dashletId: dashlet.id, + eventName: channel, + }); + this.pendingImmediateDashlets.delete(dashlet.id); + } + } + } + } + #buildChannelList() { this.channels = []; - for (let i=0; i { + this.tabChannels[tabIndex] = []; + }); + + for (let i = 0; i < this.dashlets.length; i++) { + const dashlet = this.dashlets[i]; + const channels = dashlet.subscribeTo(); + const tabChannels = this.tabChannels[dashlet.tabIndex] || []; + for (let j = 0; j < channels.length; j++) { + if (!tabChannels.includes(channels[j])) { + tabChannels.push(channels[j]); + } + } + this.tabChannels[dashlet.tabIndex] = tabChannels; + if (!this.#dashletShouldStayActive(dashlet)) { + continue; + } + for (let j = 0; j < channels.length; j++) { if (!this.#alreadySubscribed(channels[j])) { this.channels.push(channels[j]); } @@ -250,37 +612,154 @@ export class Dashboard { return false; } - #webSocketSubscription() { - if (this.channels.length === 0) { - // We're not in WS mode - for (let i=0; i 0) { + this.wsClient.unsubscribe(Array.from(this.subscribedChannels)); + } + for (const disposer of this.channelDisposers.values()) { + disposer(); + } + this.channelDisposers.clear(); + this.subscribedChannels.clear(); + } + + #syncWebSocketChannels() { + const hadEstablishedSubscription = + !!this.wsClient.ws && this.wsClient.handshake_done && this.subscribedChannels.size > 0; + const nextChannels = new Set(this.channels); + const addedChannels = []; + const removedChannels = []; + + for (const channel of nextChannels) { + if (!this.subscribedChannels.has(channel)) { + addedChannels.push(channel); } + } + for (const channel of this.subscribedChannels) { + if (!nextChannels.has(channel)) { + removedChannels.push(channel); + } + } + + for (let i = 0; i < removedChannels.length; i++) { + const channel = removedChannels[i]; + const disposer = this.channelDisposers.get(channel); + if (disposer) { + disposer(); + this.channelDisposers.delete(channel); + } + this.subscribedChannels.delete(channel); + } + if (removedChannels.length > 0) { + this.wsClient.unsubscribe(removedChannels); + } + if (removedChannels.length > 0) { + this.#debug("channels-removed", { + channels: removedChannels, + }); + } + this.#debugInterestingChannels("removed", removedChannels); + + for (let i = 0; i < addedChannels.length; i++) { + const channel = addedChannels[i]; + const disposer = this.wsClient.on(channel, (msg) => { + this.#handleWebSocketMessage(msg); + }); + this.channelDisposers.set(channel, disposer); + this.subscribedChannels.add(channel); + } + if (addedChannels.length > 0) { + this.wsClient.subscribe(addedChannels); + } + if (hadEstablishedSubscription && addedChannels.length > 0) { + this.wsClient.refreshSubscriptions("dashboard-channel-add"); + } + this.#debugInterestingChannels("added", addedChannels); + this.#debug("channels-sync", { + added: addedChannels, + removed: removedChannels, + subscribed: Array.from(this.subscribedChannels), + }); + } + + #handleWebSocketMessage(msg) { + if (!msg || !msg.event || this.paused) { return; } - this.subscription = subscribeWS(this.channels, (msg) => { - if (msg.event === "join") { - // The DOM will be present now, setup events - for (let i=0; i d.id), + backgroundDashlets: backgroundDashlets.map((d) => d.id), + subscribedDashlets: this.#subscribedDashlets().map((d) => d.id), + }); + if (DIAGNOSTIC_CHANNELS.has(msg.event)) { + this.#debug("interesting-message", { + eventName: msg.event, + activeTab: this.layout.activeTab, + renderableDashlets: renderableDashlets.map((d) => d.id), + backgroundDashlets: backgroundDashlets.map((d) => d.id), + subscribedDashlets: this.#subscribedDashlets().map((d) => d.id), + }); + } + + for (let i = 0; i < renderableDashlets.length; i++) { + const dashlet = renderableDashlets[i]; + if (!this.#dashletSubscribedToMessage(dashlet, msg.event)) { + continue; + } + const shouldDeliverImmediately = + this.pendingImmediateDashlets.has(dashlet.id); + if (dashlet.canBeSlowedDown()) { + if (shouldDeliverImmediately || this.tickCounter === 0) { + dashlet.onMessage(msg); + this.#markDashletState(dashlet, { + lastMessageAt: new Date().toISOString(), + lastEvent: msg.event, + }); + this.#debug("deliver", { + dashletId: dashlet.id, + eventName: msg.event, + immediate: shouldDeliverImmediately, + slowed: true, + }); + this.pendingImmediateDashlets.delete(dashlet.id); } } else { - // Propagate the message - if (!this.paused) { - this.tickCounter++; - this.tickCounter %= this.cadence; - for (let i = 0; i < this.dashlets.length; i++) { - if (this.dashlets[i].canBeSlowedDown()) { - if (this.tickCounter === 0) { - this.dashlets[i].onMessage(msg); - } - } else { - this.dashlets[i].onMessage(msg); - } - } - } + dashlet.onMessage(msg); + this.#markDashletState(dashlet, { + lastMessageAt: new Date().toISOString(), + lastEvent: msg.event, + }); + this.#debug("deliver", { + dashletId: dashlet.id, + eventName: msg.event, + immediate: false, + slowed: false, + }); } - }); + } + + for (let i = 0; i < backgroundDashlets.length; i++) { + const dashlet = backgroundDashlets[i]; + if (!this.#dashletSubscribedToMessage(dashlet, msg.event)) { + continue; + } + dashlet.onBackgroundMessage(msg); + this.#debug("background-deliver", { + dashletId: dashlet.id, + eventName: msg.event, + }); + } } #editButton(hasCadence) { @@ -323,7 +802,8 @@ export class Dashboard { cadencePicker.max = "60"; cadencePicker.value = this.cadence; cadencePicker.onchange = () => { - this.cadence = parseInt(cadencePicker.value); + const parsedCadence = parseInt(cadencePicker.value); + this.cadence = Number.isFinite(parsedCadence) && parsedCadence > 0 ? parsedCadence : 1; localStorage.setItem("dashCadence", this.cadence.toString()); } cadenceDiv.appendChild(cadenceLabel); @@ -343,7 +823,20 @@ export class Dashboard { } }; + let debugDumpDiv = null; + if (this.#debugDumpEnabled()) { + debugDumpDiv = document.createElement("span"); + debugDumpDiv.id = this.divName + "_debug_dump"; + debugDumpDiv.innerHTML = ""; + debugDumpDiv.onclick = () => { + this.#downloadDebugDump(); + }; + } + parent.appendChild(editDiv); + if (debugDumpDiv !== null) { + parent.appendChild(debugDumpDiv); + } if (hasCadence) { parent.appendChild(pauseDiv); parent.appendChild(cadenceDiv); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/pubsub/ws.js b/src/rust/lqosd/src/node_manager/js_build/src/pubsub/ws.js index 004b019a..b528b77c 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/pubsub/ws.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/pubsub/ws.js @@ -4,9 +4,64 @@ const ACK_TEXT = "I accept that this is an unstable, internal API and is unsuppo const EXPECTED_UI_VERSION = (window.LQOS_UI_VERSION || "").trim() || null; const USER_TOKEN_COOKIE = "User-Token"; const encoder = new Encoder({ useRecords: false, variableMapSize: true }); +const DIAGNOSTIC_CHANNELS = new Set(["Cpu", "Ram", "RttHistogram"]); let shared_client = null; +function dashboardDebugEnabled() { + if (typeof window === "undefined") { + return false; + } + try { + const params = new URLSearchParams(window.location.search || ""); + if (params.has("debug")) { + const value = (params.get("debug") || "").trim().toLowerCase(); + if (value === "" || value === "1" || value === "true" || value === "dashboard") { + return true; + } + } + } catch (_) { + // Ignore malformed URL search state and fall back to localStorage. + } + return !!window.localStorage && window.localStorage.getItem("debugDashboard") === "1"; +} + +function pushDashboardWsTrace(entry) { + if (typeof window === "undefined") { + return; + } + if (!window.__lqosDashboardWsTrace) { + window.__lqosDashboardWsTrace = []; + } + window.__lqosDashboardWsTrace.push({ + ts: new Date().toISOString(), + ...entry, + }); + if (window.__lqosDashboardWsTrace.length > 500) { + window.__lqosDashboardWsTrace.shift(); + } +} + +function dashboardWsDebug(event, details = {}) { + if (!dashboardDebugEnabled()) { + return; + } + const entry = { event, ...details }; + pushDashboardWsTrace(entry); + console.debug("[dashboard-ws]", entry); +} + +function dashboardWsInteresting(event, channels = [], details = {}) { + const interesting = channels.filter((channel) => DIAGNOSTIC_CHANNELS.has(channel)); + if (interesting.length === 0) { + return; + } + dashboardWsDebug(event, { + channels: interesting, + ...details, + }); +} + function get_cookie_value(name) { const cookies = document.cookie ? document.cookie.split(";") : []; const prefix = `${name}=`; @@ -51,10 +106,18 @@ export class WsClient { this.reconnectTimer = null; } this.manualClose = false; - this.ws = new WebSocket(ws_proto() + window.location.host + "/websocket/ws"); - this.ws.binaryType = "arraybuffer"; + const socket = new WebSocket(ws_proto() + window.location.host + "/websocket/ws"); + this.ws = socket; + socket.binaryType = "arraybuffer"; + dashboardWsDebug("connect", { + url: ws_proto() + window.location.host + "/websocket/ws", + desiredChannels: Array.from(this.desiredChannels.keys()), + }); - this.ws.onmessage = async (event) => { + socket.onmessage = async (event) => { + if (this.ws !== socket) { + return; + } let msg = null; try { if (event.data instanceof ArrayBuffer) { @@ -69,19 +132,43 @@ export class WsClient { } if (msg && msg.event === "Hello") { + dashboardWsDebug("hello", { + version: msg.version, + }); this._acknowledge_handshake(msg); return; } + dashboardWsDebug("message", { + eventName: msg && msg.event ? msg.event : "unknown", + }); + if (msg && msg.event && DIAGNOSTIC_CHANNELS.has(msg.event)) { + dashboardWsDebug("message-interesting", { + eventName: msg.event, + desiredChannels: Array.from(this.desiredChannels.keys()), + }); + } this._dispatch(msg); }; - this.ws.onclose = () => { + socket.onclose = () => { + if (this.ws !== socket) { + return; + } + dashboardWsDebug("close", { + desiredChannels: Array.from(this.desiredChannels.keys()), + }); this.ws = null; this.handshake_done = false; this._scheduleReconnect(); }; - this.ws.onerror = () => { + socket.onerror = () => { + if (this.ws !== socket) { + return; + } + dashboardWsDebug("error", { + desiredChannels: Array.from(this.desiredChannels.keys()), + }); this.ws = null; this.handshake_done = false; this._scheduleReconnect(); @@ -89,6 +176,9 @@ export class WsClient { } close() { + dashboardWsDebug("manual-close", { + desiredChannels: Array.from(this.desiredChannels.keys()), + }); this.manualClose = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); @@ -104,12 +194,48 @@ export class WsClient { this.handlers.clear(); } + refreshSubscriptions(reason = "unspecified") { + dashboardWsDebug("refresh-subscriptions", { + reason, + desiredChannels: Array.from(this.desiredChannels.keys()), + }); + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + const oldSocket = this.ws; + this.ws = null; + this.handshake_done = false; + if (oldSocket) { + try { + oldSocket.close(); + } catch (_) { + // Ignore close errors; a new socket is opened below if needed. + } + } + if (this._shouldMaintainConnection()) { + this.connect(); + } + } + subscribe(channels) { + dashboardWsDebug("subscribe-request", { + channels, + }); + dashboardWsInteresting("subscribe-request-interesting", channels, { + desiredChannels: Array.from(this.desiredChannels.keys()), + }); for (let i = 0; i < channels.length; i++) { const channel = channels[i]; const current = this.desiredChannels.get(channel) || 0; this.desiredChannels.set(channel, current + 1); if (current === 0 && this.handshake_done && this.ws) { + dashboardWsDebug("subscribe-wire", { + channel, + }); + dashboardWsInteresting("subscribe-wire-interesting", [channel], { + desiredChannels: Array.from(this.desiredChannels.keys()), + }); this.ws.send(encoder.encode({ Subscribe: { channel } })); } } @@ -119,12 +245,24 @@ export class WsClient { } unsubscribe(channels) { + dashboardWsDebug("unsubscribe-request", { + channels, + }); + dashboardWsInteresting("unsubscribe-request-interesting", channels, { + desiredChannels: Array.from(this.desiredChannels.keys()), + }); for (let i = 0; i < channels.length; i++) { const channel = channels[i]; const current = this.desiredChannels.get(channel) || 0; if (current <= 1) { this.desiredChannels.delete(channel); if (this.handshake_done && this.ws) { + dashboardWsDebug("unsubscribe-wire", { + channel, + }); + dashboardWsInteresting("unsubscribe-wire-interesting", [channel], { + desiredChannels: Array.from(this.desiredChannels.keys()), + }); this.ws.send(encoder.encode({ Unsubscribe: { channel } })); } } else { @@ -135,6 +273,9 @@ export class WsClient { send(request_obj) { const normalized = normalizeRequest(request_obj); + dashboardWsDebug("send", { + request: normalized && typeof normalized === "object" ? Object.keys(normalized)[0] : "unknown", + }); if (!this.ws) { this.connect(); } @@ -181,6 +322,9 @@ export class WsClient { return; } this.handshake_done = true; + dashboardWsDebug("handshake-complete", { + desiredChannels: Array.from(this.desiredChannels.keys()), + }); this.ws.send( encoder.encode({ HelloReply: { @@ -194,6 +338,10 @@ export class WsClient { } this.pending = []; for (const channel of this.desiredChannels.keys()) { + dashboardWsInteresting("subscribe-wire-interesting", [channel], { + desiredChannels: Array.from(this.desiredChannels.keys()), + fromHandshake: true, + }); this.ws.send(encoder.encode({ Subscribe: { channel } })); } this.reconnectDelayMs = 1000; @@ -220,6 +368,10 @@ export class WsClient { return; } const delay = this.reconnectDelayMs; + dashboardWsDebug("schedule-reconnect", { + delayMs: delay, + desiredChannels: Array.from(this.desiredChannels.keys()), + }); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); @@ -240,12 +392,32 @@ function normalizeRequest(request_obj) { if (!request_obj || typeof request_obj !== "object") { return request_obj; } + const stripUndefined = (value) => { + if (Array.isArray(value)) { + return value.map((entry) => + entry === undefined ? null : stripUndefined(entry), + ); + } + if (!value || typeof value !== "object") { + return value; + } + const result = {}; + Object.keys(value).forEach((key) => { + const entry = value[key]; + if (entry === undefined) { + return; + } + result[key] = stripUndefined(entry); + }); + return result; + }; + const keys = Object.keys(request_obj); if (keys.length !== 1) { - return request_obj; + return stripUndefined(request_obj); } const key = keys[0]; - const value = request_obj[key]; + const value = stripUndefined(request_obj[key]); if ( value && typeof value === "object" && diff --git a/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js b/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js index 590c5651..5f80cac4 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js @@ -1,16 +1,19 @@ import {clearDiv, formatLastSeen} from "./helpers/builders"; -import {get_ws_client, subscribeWS} from "./pubsub/ws"; +import {get_ws_client} from "./pubsub/ws"; import {formatRetransmit, formatRtt, formatThroughput} from "./helpers/scaling"; import {colorByQoqScore} from "./helpers/color_scales"; import {toNumber} from "./lq_js_common/helpers/scaling"; let shapedDevices = []; -let displayDevices = []; let devicesPerPage = 24; let page = 0; let searchTerm = ""; let metricElsByCircuitId = new Map(); const latestByCircuitId = new Map(); +const planByCircuitId = new Map(); +let totalRows = 0; +let totalCircuits = 0; +let circuitMetricsWatchSignature = null; const wsClient = get_ws_client(); const QOO_TOOLTIP_HTML = @@ -19,13 +22,32 @@ const QOO_TOOLTIP_HTML = "https://datatracker.ietf.org/doc/draft-ietf-ippm-qoo/
" + "LibreQoS implements a latency and loss-based model to estimate quality of outcome.

"; -const listenOnce = (eventName, handler) => { - const wrapped = (msg) => { - wsClient.off(eventName, wrapped); - handler(msg); - }; - wsClient.on(eventName, wrapped); -}; +function sendWsRequest(responseEvent, request) { + return new Promise((resolve, reject) => { + let done = false; + const responseHandler = (msg) => { + if (done) return; + done = true; + wsClient.off(responseEvent, responseHandler); + wsClient.off("Error", errorHandler); + resolve(msg); + }; + const errorHandler = (msg) => { + if (done) return; + done = true; + wsClient.off(responseEvent, responseHandler); + wsClient.off("Error", errorHandler); + reject(msg); + }; + wsClient.on(responseEvent, responseHandler); + wsClient.on("Error", errorHandler); + wsClient.send(request); + }); +} + +function sendPrivateRequest(command) { + wsClient.send({Private: command}); +} function initTooltipsWithin(rootEl) { if (!rootEl) return; @@ -48,37 +70,12 @@ function formatPlanValue(value) { } function countCircuits() { - let entries = {}; - shapedDevices.forEach((d) => { - if (!entries.hasOwnProperty(d.circuit_id)) { - entries[d.circuit_id] = 1; - } - }); - let count = 0; - for (const _ in entries) { - count++; - } - return count; + return totalCircuits; } function filterDevices() { - const term = (searchTerm || "").toLowerCase().trim(); - if (term === "") { - displayDevices = shapedDevices; - } else { - displayDevices = shapedDevices.filter((d) => { - const deviceName = (d.device_name || "").toLowerCase(); - const circuitName = (d.circuit_name || "").toLowerCase(); - const parentNode = (d.parent_node || "").toLowerCase(); - return ( - deviceName.indexOf(term) > -1 || - circuitName.indexOf(term) > -1 || - parentNode.indexOf(term) > -1 - ); - }); - } page = 0; - renderCards(); + requestShapedDevicesPage(); } function formatQooScore(score0to100, fallback = "-") { @@ -138,17 +135,18 @@ function applyCircuitUpdate(device) { if (!device || !device.circuit_id) return; const circuitId = device.circuit_id; latestByCircuitId.set(circuitId, device); + const plan = planByCircuitId.get(circuitId) || {down: 0, up: 0}; updateMetricText(circuitId, "lastSeen", formatLastSeen(device.last_seen_nanos)); updateMetricHtml( circuitId, "tpDown", - formatThroughput(toNumber(device.bytes_per_second.down, 0) * 8, device.plan.down), + formatThroughput(toNumber(device.bytes_per_second.down, 0) * 8, toNumber(plan.down, 0)), ); updateMetricHtml( circuitId, "tpUp", - formatThroughput(toNumber(device.bytes_per_second.up, 0) * 8, device.plan.up), + formatThroughput(toNumber(device.bytes_per_second.up, 0) * 8, toNumber(plan.up, 0)), ); updateMetricHtml( circuitId, @@ -439,7 +437,7 @@ function ensureLayout() { devicesPerPage = 24; } page = 0; - renderCards(); + requestShapedDevicesPage(); }; perPageWrap.appendChild(perPageLabel); perPageWrap.appendChild(perPageSelect); @@ -459,7 +457,7 @@ function ensureLayout() { prev.title = "Previous page"; prev.onclick = () => { page = Math.max(0, page - 1); - renderCards(); + requestShapedDevicesPage(); }; const next = document.createElement("button"); next.id = "sdNextPage"; @@ -469,9 +467,9 @@ function ensureLayout() { next.setAttribute("aria-label", "Next devices page"); next.title = "Next page"; next.onclick = () => { - const totalPages = Math.max(1, Math.ceil(displayDevices.length / devicesPerPage)); + const totalPages = Math.max(1, Math.ceil(totalRows / devicesPerPage)); page = Math.min(totalPages - 1, page + 1); - renderCards(); + requestShapedDevicesPage(); }; pager.appendChild(prev); pager.appendChild(next); @@ -509,11 +507,85 @@ function ensureLayout() { }; } +function currentShapedDevicesPageQuery() { + const query = { + page, + page_size: devicesPerPage, + }; + if (searchTerm && searchTerm.trim() !== "") { + query.search = searchTerm; + } + return query; +} + +function visibleCircuitIds() { + const seen = new Set(); + const ids = []; + shapedDevices.forEach((device) => { + const id = device && device.circuit_id ? String(device.circuit_id).trim() : ""; + if (!id || seen.has(id)) return; + seen.add(id); + ids.push(id); + }); + return ids; +} + +function requestCircuitMetricsWatch(force = false) { + const circuitIds = visibleCircuitIds(); + const signature = JSON.stringify(circuitIds); + if (!force && circuitMetricsWatchSignature === signature) { + return; + } + circuitMetricsWatchSignature = signature; + if (circuitIds.length === 0) { + sendPrivateRequest({StopCircuitMetricsWatch: null}); + return; + } + sendPrivateRequest({ + WatchCircuitMetrics: { + query: { + circuit_ids: circuitIds, + }, + }, + }); +} + +async function requestShapedDevicesPage() { + try { + const msg = await sendWsRequest("ShapedDevicesPage", { + ShapedDevicesPage: { + query: currentShapedDevicesPageQuery(), + }, + }); + const data = msg && msg.data ? msg.data : {}; + shapedDevices = Array.isArray(data.rows) ? data.rows : []; + totalRows = Number.isFinite(Number(data.total_rows)) ? Number(data.total_rows) : shapedDevices.length; + totalCircuits = Number.isFinite(Number(data.total_circuits)) ? Number(data.total_circuits) : countCircuits(); + planByCircuitId.clear(); + shapedDevices.forEach((device) => { + if (!device || !device.circuit_id) return; + const current = planByCircuitId.get(device.circuit_id) || {down: 0, up: 0}; + current.down = Math.max(toNumber(current.down, 0), toNumber(device.download_max_mbps, 0)); + current.up = Math.max(toNumber(current.up, 0), toNumber(device.upload_max_mbps, 0)); + planByCircuitId.set(device.circuit_id, current); + }); + renderCards(); + requestCircuitMetricsWatch(true); + $("#count").text(totalRows + " devices"); + $("#countCircuit").text(totalCircuits + " circuits"); + } catch (_error) { + shapedDevices = []; + totalRows = 0; + totalCircuits = 0; + renderCards(); + } +} + function renderCards() { const layout = ensureLayout(); if (!layout) return; - const totalPages = Math.max(1, Math.ceil(displayDevices.length / devicesPerPage)); + const totalPages = Math.max(1, Math.ceil(totalRows / devicesPerPage)); if (page >= totalPages) page = totalPages - 1; if (page < 0) page = 0; @@ -524,27 +596,24 @@ function renderCards() { if (layout.nextButton) layout.nextButton.disabled = page >= totalPages - 1; if (layout.pageCounter) layout.pageCounter.innerText = "Page " + (page + 1) + " / " + totalPages; if (layout.summary) { - if (displayDevices.length === 0) { + if (totalRows === 0) { layout.summary.innerText = "No matches"; } else { const start = page * devicesPerPage + 1; - const end = Math.min((page + 1) * devicesPerPage, displayDevices.length); - layout.summary.innerText = "Showing " + start + "–" + end + " of " + displayDevices.length; + const end = Math.min((page + 1) * devicesPerPage, totalRows); + layout.summary.innerText = "Showing " + start + "–" + end + " of " + totalRows; } } metricElsByCircuitId = new Map(); clearDiv(layout.grid); - const start = page * devicesPerPage; - const end = Math.min(start + devicesPerPage, displayDevices.length); - for (let i = start; i < end; i++) { - const device = displayDevices[i]; + shapedDevices.forEach((device) => { const col = document.createElement("div"); col.classList.add("col"); col.appendChild(buildDeviceCard(device)); layout.grid.appendChild(col); - } + }); // Fill visible cards from cached live data immediately. metricElsByCircuitId.forEach((_metrics, circuitId) => { @@ -557,24 +626,18 @@ function renderCards() { initTooltipsWithin(layout.target); } -function loadDevices() { - listenOnce("DevicesAll", (msg) => { - const data = msg && msg.data ? msg.data : []; - shapedDevices = data; - displayDevices = data; - renderCards(); - $("#count").text(shapedDevices.length + " devices"); - $("#countCircuit").text(countCircuits() + " circuits"); +function handleCircuitMetrics(msg) { + const metrics = msg && Array.isArray(msg.data) ? msg.data : []; + metrics.forEach((metric) => { + if (!metric || !metric.circuit_id) return; + applyCircuitUpdate(metric); }); - wsClient.send({ DevicesAll: {} }); } -loadDevices(); -subscribeWS(["NetworkTreeClients"], (msg) => { - if (msg.event !== "NetworkTreeClients") return; - const data = msg && msg.data ? msg.data : []; - data.forEach((d) => { - if (!d || !d.circuit_id) return; - applyCircuitUpdate(d); - }); +wsClient.on("CircuitMetricsSnapshot", handleCircuitMetrics); +wsClient.on("CircuitMetricsUpdate", handleCircuitMetrics); +wsClient.on("join", () => { + requestShapedDevicesPage(); }); + +requestShapedDevicesPage(); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 535676b5..92b339b9 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -19,6 +19,8 @@ const MARINE_AREAS_GEOJSON_PATH = "vendor/site_map_marine_areas.geojson"; const MAJOR_ROADS_10M_GEOJSON_PATH = "vendor/site_map_major_roads_10m.geojson"; const INITIAL_CENTER = [-101.5, 39.8]; const INITIAL_ZOOM = 3.15; +const INITIAL_FIT_PADDING = 48; +const INITIAL_FIT_MAX_ZOOM = 8.35; const SITE_SOURCE_ID = "site-map-sites"; const AP_SOURCE_ID = "site-map-aps"; const COUNTRIES_SOURCE_ID = "site-map-countries"; @@ -888,18 +890,18 @@ class SiteMapPage { requestInitialTree() { this.setStatus("Waiting for data", "spinner"); if (!this.subscription) { - this.subscription = subscribeWS(["NetworkTree"], (liveMsg) => { - if (liveMsg.event === "NetworkTree") { + this.subscription = subscribeWS(["NetworkTreeLite"], (liveMsg) => { + if (liveMsg.event === "NetworkTreeLite") { this.processTreeMessage(liveMsg); } }); } - listenOnceWithTimeout("NetworkTree", INITIAL_REQUEST_TIMEOUT_MS, (msg) => { + listenOnceWithTimeout("NetworkTreeLite", INITIAL_REQUEST_TIMEOUT_MS, (msg) => { this.processTreeMessage(msg); }, () => { this.setStatus("No data received yet", "warning"); }); - wsClient.send({ NetworkTree: {} }); + wsClient.send({ NetworkTreeLite: {} }); } processTreeMessage(msg) { @@ -1147,15 +1149,15 @@ class SiteMapPage { } fitToData(siteFeatures, apFeatures) { - const features = [...siteFeatures, ...apFeatures]; + const features = siteFeatures.length > 0 ? [...siteFeatures] : [...apFeatures]; if (!features.length) { return; } const bounds = new window.maplibregl.LngLatBounds(); features.forEach((feature) => bounds.extend(feature.geometry.coordinates)); this.map.fitBounds(bounds, { - padding: 70, - maxZoom: 7.5, + padding: INITIAL_FIT_PADDING, + maxZoom: INITIAL_FIT_MAX_ZOOM, duration: 0, }); this.hasFitOnce = true; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree.js b/src/rust/lqosd/src/node_manager/js_build/src/tree.js index 068126fe..be727e31 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree.js @@ -21,7 +21,8 @@ var childrenByParentId = new Map(); var stormguardNodes = new Set(); var treeBitsGauge = null; var treeQooGauge = null; -var lastClientsMessage = null; +var lastAttachedCircuitsPage = null; +var attachedCircuitsWatchSignature = null; var currentSelectionIdentity = null; var selectionLocator = { nodeId: null, @@ -76,6 +77,10 @@ function sendWsRequest(responseEvent, request) { }); } +function sendPrivateRequest(command) { + wsClient.send({Private: command}); +} + function formatDeviceIp(ip) { if (typeof ip === "string") { return ip; @@ -202,10 +207,13 @@ function currentNodeRateQuery() { if (!node) { return null; } - return { - node_id: node.id ?? null, + const query = { node_name: node.name, }; + if (node.id) { + query.node_id = node.id; + } + return query; } function buildEffectiveLimitCellHtml(node) { @@ -842,6 +850,45 @@ function renderNodeSettings() { } } +function currentTreeAttachedCircuitsQuery() { + const node = currentNode(); + if (!node) { + return null; + } + const query = { + page: 0, + page_size: 100, + sort: "CircuitName", + descending: false, + }; + const nodeId = selectionLocator.nodeId || node.id; + const nodePath = selectionLocator.nodePath || getNodePathNames(parent, node); + if (nodeId) { + query.node_id = nodeId; + } + if (nodePath) { + query.node_path = nodePath; + } + return query; +} + +function requestTreeAttachedCircuitsWatch(force = false) { + const query = currentTreeAttachedCircuitsQuery(); + if (!query) { + return; + } + const signature = JSON.stringify(query); + if (!force && attachedCircuitsWatchSignature === signature) { + return; + } + attachedCircuitsWatchSignature = signature; + sendPrivateRequest({ + WatchTreeAttachedCircuits: { + query, + }, + }); +} + async function loadNodeRateOverrideState() { const query = currentNodeRateQuery(); if (!query) { @@ -927,10 +974,7 @@ async function clearNodeRateOverride() { try { const response = await sendWsRequest("ClearNodeRateOverrideResult", { ClearNodeRateOverride: { - query: { - node_id: node.id ?? null, - node_name: node.name, - }, + query: currentNodeRateQuery(), }, }); if (!response.ok) { @@ -1091,12 +1135,10 @@ function getInitialTree() { loadNodeRateOverrideState(); } renderTree(); - if (selectionState.parentChanged && lastClientsMessage) { - clientsUpdate(lastClientsMessage); - } + requestTreeAttachedCircuitsWatch(true); if (!subscribed) { - subscribeWS(["NetworkTree", "NetworkTreeClients", "StormguardStatus"], onMessage); + subscribeWS(["NetworkTree", "StormguardStatus"], onMessage); subscribed = true; } }); @@ -1476,17 +1518,15 @@ function treeUpdate(msg) { if (needsRebuild || selectionState.parentChanged) { renderTree(); } - if (selectionState.parentChanged && lastClientsMessage) { - clientsUpdate(lastClientsMessage); + if (selectionState.parentChanged || selectionState.identityChanged) { + requestTreeAttachedCircuitsWatch(true); } } -function clientsUpdate(msg) { - lastClientsMessage = msg; - if (!tree || tree[parent] === undefined) { +function renderAttachedCircuitsRows(rows) { + if (!Array.isArray(rows)) { return; } - let myName = tree[parent][1].name; let target = document.getElementById("clients"); let table = document.createElement("table"); @@ -1494,63 +1534,7 @@ function clientsUpdate(msg) { table.appendChild(clientTableHeader()); let tbody = document.createElement("tbody"); clearDiv(target); - - const circuits = new Map(); - msg.data.forEach((device) => { - if (device.parent_node !== myName) { - return; - } - - const circuitId = device.circuit_id || `${device.parent_node || ""}:${device.circuit_name || ""}`; - if (!circuits.has(circuitId)) { - circuits.set(circuitId, { - circuit_id: circuitId, - circuit_name: device.circuit_name || "(Unknown circuit)", - parent_node: device.parent_node || "", - plan: { - down: toNumber(device.plan?.down, 0), - up: toNumber(device.plan?.up, 0), - }, - device_names: new Set(), - ips: new Set(), - last_seen_nanos: toNumber(device.last_seen_nanos, 0), - bytes_per_second: {down: 0, up: 0}, - median_latency: {down: null, up: null}, - tcp_packets: {down: 0, up: 0}, - tcp_retransmits: {down: 0, up: 0}, - }); - } - - const circuit = circuits.get(circuitId); - if (device.device_name) { - circuit.device_names.add(device.device_name); - } - const ipText = formatDeviceIp(device.ip); - if (ipText && ipText !== "-") { - circuit.ips.add(ipText); - } - - circuit.last_seen_nanos = Math.min(circuit.last_seen_nanos, toNumber(device.last_seen_nanos, 0)); - circuit.bytes_per_second.down += toNumber(device.bytes_per_second?.down, 0); - circuit.bytes_per_second.up += toNumber(device.bytes_per_second?.up, 0); - circuit.tcp_packets.down += toNumber(device.tcp_packets?.down, 0); - circuit.tcp_packets.up += toNumber(device.tcp_packets?.up, 0); - circuit.tcp_retransmits.down += toNumber(device.tcp_retransmits?.down, 0); - circuit.tcp_retransmits.up += toNumber(device.tcp_retransmits?.up, 0); - - const downLatency = toNumber(device.median_latency?.down, 0); - if (downLatency > 0 && (circuit.median_latency.down === null || downLatency > circuit.median_latency.down)) { - circuit.median_latency.down = downLatency; - } - const upLatency = toNumber(device.median_latency?.up, 0); - if (upLatency > 0 && (circuit.median_latency.up === null || upLatency > circuit.median_latency.up)) { - circuit.median_latency.up = upLatency; - } - }); - - Array.from(circuits.values()) - .sort((a, b) => a.circuit_name.localeCompare(b.circuit_name)) - .forEach((circuit) => { + rows.forEach((circuit) => { let tr = document.createElement("tr"); tr.classList.add("small"); @@ -1564,7 +1548,7 @@ function clientsUpdate(msg) { linkTd.appendChild(circuitLink); tr.appendChild(linkTd); - const deviceNames = Array.from(circuit.device_names); + const deviceNames = Array.isArray(circuit.device_names) ? circuit.device_names : []; const deviceCell = simpleRow( deviceNames.length > 2 ? `${deviceNames[0]}, ${deviceNames[1]} +${deviceNames.length - 2}` : deviceNames.join(", "), true @@ -1574,45 +1558,45 @@ function clientsUpdate(msg) { } tr.appendChild(deviceCell); - tr.appendChild(simpleRow(circuit.plan.down + " / " + circuit.plan.up)); + tr.appendChild(simpleRow(`${toNumber(circuit.plan_mbps?.down, 0)} / ${toNumber(circuit.plan_mbps?.up, 0)}`)); tr.appendChild(simpleRow(circuit.parent_node, true)); - const ipList = Array.from(circuit.ips); + const ipList = Array.isArray(circuit.ip_addrs) ? circuit.ip_addrs : []; const ipCell = simpleRow(summarizeIpListForTable(ipList), true); if (ipList.length > 0) { ipCell.title = ipList.join(", "); } tr.appendChild(ipCell); - tr.appendChild(simpleRow(formatLastSeen(circuit.last_seen_nanos))); - tr.appendChild(simpleRowHtml(formatThroughput(circuit.bytes_per_second.down * 8, circuit.plan.down))); - tr.appendChild(simpleRowHtml(formatThroughput(circuit.bytes_per_second.up * 8, circuit.plan.up))); + tr.appendChild(simpleRow(formatLastSeen(toNumber(circuit.last_seen_nanos, 0)))); + tr.appendChild(simpleRowHtml(formatThroughput(toNumber(circuit.bytes_per_second?.down, 0) * 8, toNumber(circuit.plan_mbps?.down, 0)))); + tr.appendChild(simpleRowHtml(formatThroughput(toNumber(circuit.bytes_per_second?.up, 0) * 8, toNumber(circuit.plan_mbps?.up, 0)))); - if (circuit.median_latency.down !== null) { - tr.appendChild(simpleRowHtml(formatRtt(circuit.median_latency.down))); + if (toNumber(circuit.rtt_current_p50_nanos?.down, 0) > 0) { + tr.appendChild(simpleRowHtml(formatRtt(toNumber(circuit.rtt_current_p50_nanos?.down, 0) / 1_000_000))); } else { tr.appendChild(simpleRow("-")); } - if (circuit.median_latency.up !== null) { - tr.appendChild(simpleRowHtml(formatRtt(circuit.median_latency.up))); + if (toNumber(circuit.rtt_current_p50_nanos?.up, 0) > 0) { + tr.appendChild(simpleRowHtml(formatRtt(toNumber(circuit.rtt_current_p50_nanos?.up, 0) / 1_000_000))); } else { tr.appendChild(simpleRow("-")); } let retr = 0; - if (circuit.tcp_packets.down > 0) { - retr = circuit.tcp_retransmits.down / circuit.tcp_packets.down; + if (toNumber(circuit.tcp_packets?.down, 0) > 0) { + retr = toNumber(circuit.tcp_retransmits?.down, 0) / toNumber(circuit.tcp_packets?.down, 0); } tr.appendChild(simpleRowHtml(formatRetransmit(retr))); retr = 0; - if (circuit.tcp_packets.up > 0) { - retr = circuit.tcp_retransmits.up / circuit.tcp_packets.up; + if (toNumber(circuit.tcp_packets?.up, 0) > 0) { + retr = toNumber(circuit.tcp_retransmits?.up, 0) / toNumber(circuit.tcp_packets?.up, 0); } tr.appendChild(simpleRowHtml(formatRetransmit(retr))); tbody.appendChild(tr); - }); + }); table.appendChild(tbody); const sectionLabel = document.createElement("div"); sectionLabel.classList.add("lqos-tree-section-label"); @@ -1624,6 +1608,11 @@ function clientsUpdate(msg) { target.appendChild(tableWrap); } +function attachedCircuitsUpdate(msg) { + lastAttachedCircuitsPage = msg?.data || null; + renderAttachedCircuitsRows(lastAttachedCircuitsPage?.rows || []); +} + function stormguardUpdate(msg) { const nextNodes = new Set(); msg.data.forEach((entry) => { @@ -1637,8 +1626,8 @@ function stormguardUpdate(msg) { } stormguardNodes = nextNodes; const selectionState = reconcileSelection(); - if ((selectionState.parentChanged || selectionState.identityChanged) && lastClientsMessage) { - clientsUpdate(lastClientsMessage); + if (selectionState.parentChanged || selectionState.identityChanged) { + requestTreeAttachedCircuitsWatch(true); } if (tree && tree[parent] !== undefined) { fillHeader(tree[parent][1]); @@ -1649,8 +1638,6 @@ function stormguardUpdate(msg) { function onMessage(msg) { if (msg.event === "NetworkTree") { treeUpdate(msg); - } else if (msg.event === "NetworkTreeClients") { - clientsUpdate(msg); } else if (msg.event === "StormguardStatus") { stormguardUpdate(msg); } @@ -1688,5 +1675,14 @@ document.getElementById("nodeOverrideSave")?.addEventListener("click", () => { document.getElementById("nodeOverrideClear")?.addEventListener("click", () => { clearNodeRateOverride(); }); +wsClient.on("TreeAttachedCircuitsSnapshot", attachedCircuitsUpdate); +wsClient.on("TreeAttachedCircuitsUpdate", attachedCircuitsUpdate); +wsClient.on("join", () => { + if (!tree) { + return; + } + requestTreeAttachedCircuitsWatch(true); + loadNodeRateOverrideState(); +}); getInitialTree(); diff --git a/src/rust/lqosd/src/node_manager/local_api.rs b/src/rust/lqosd/src/node_manager/local_api.rs index ece0b74a..a2fab021 100644 --- a/src/rust/lqosd/src/node_manager/local_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api.rs @@ -1,19 +1,26 @@ pub(crate) mod circuit; +pub(crate) mod circuit_live; pub(crate) mod circuit_count; pub(crate) mod config; pub(crate) mod cpu_affinity; pub(crate) mod dashboard_themes; pub(crate) mod device_counts; +pub(crate) mod directories; +pub(crate) mod executive; +pub(crate) mod executive_cache; pub(crate) mod flow_explorer; pub(crate) mod flow_map; pub mod lts; pub(crate) mod network_tree; +pub(crate) mod network_tree_lite; pub(crate) mod node_rate_overrides; pub(crate) mod packet_analysis; pub(crate) mod reload_libreqos; pub(crate) mod scheduler; pub(crate) mod search; pub(crate) mod shaped_device_api; +pub(crate) mod shaped_devices_page; +pub(crate) mod tree_attached_circuits; pub(crate) mod unknown_ips; pub(crate) mod urgent; pub(crate) mod warnings; diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_live.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_live.rs new file mode 100644 index 00000000..7ea3b94f --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_live.rs @@ -0,0 +1,50 @@ +use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot; +use lqos_utils::units::DownUpOrder; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +const MAX_CIRCUIT_METRICS_IDS: usize = 250; + +/// Query for the compact live metrics needed by the shaped-devices page. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitMetricsQuery { + pub circuit_ids: Vec, +} + +/// Live metrics for a single visible circuit card. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitLiveMetrics { + pub circuit_id: String, + pub bytes_per_second: DownUpOrder, + pub rtt_current_p50_nanos: DownUpOrder>, + pub qoo: DownUpOrder>, + pub tcp_packets: DownUpOrder, + pub tcp_retransmits: DownUpOrder, + pub last_seen_nanos: u64, +} + +/// Resolves the current live metrics for a bounded set of circuit ids. +pub fn circuit_live_metrics(query: &CircuitMetricsQuery) -> Vec { + let snapshot = fresh_circuit_live_snapshot(); + let mut seen = HashSet::new(); + query + .circuit_ids + .iter() + .filter_map(|id| { + let trimmed = id.trim(); + if trimmed.is_empty() || !seen.insert(trimmed.to_string()) { + return None; + } + snapshot.by_circuit_id.get(trimmed).map(|row| CircuitLiveMetrics { + circuit_id: row.circuit_id.clone(), + bytes_per_second: row.bytes_per_second, + rtt_current_p50_nanos: row.rtt_current_p50_nanos, + qoo: row.qoo, + tcp_packets: row.tcp_packets, + tcp_retransmits: row.tcp_retransmits, + last_seen_nanos: row.last_seen_nanos, + }) + }) + .take(MAX_CIRCUIT_METRICS_IDS) + .collect() +} diff --git a/src/rust/lqosd/src/node_manager/local_api/config.rs b/src/rust/lqosd/src/node_manager/local_api/config.rs index 87634d9b..dda44285 100644 --- a/src/rust/lqosd/src/node_manager/local_api/config.rs +++ b/src/rust/lqosd/src/node_manager/local_api/config.rs @@ -4,11 +4,39 @@ use axum::http::StatusCode; use default_net::get_interfaces; use lqos_bus::{BusRequest, bus_request}; use lqos_config::{Config, ConfigShapedDevices, ShapedDevice, UserRole, WebUser, WebUsers}; +use lqos_utils::hash_to_i64; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; +type TopologySourceIntegration = (&'static str, fn(&Config) -> bool); + +const TOPOLOGY_SOURCE_INTEGRATIONS: [TopologySourceIntegration; 7] = [ + ("UISP", |config| config.uisp_integration.enable_uisp), + ("Splynx", |config| config.splynx_integration.enable_splynx), + ("Powercode", |config| config.powercode_integration.enable_powercode), + ("Sonar", |config| config.sonar_integration.enable_sonar), + ("Netzur", |config| { + config + .netzur_integration + .as_ref() + .is_some_and(|integration| integration.enable_netzur) + }), + ("VISP", |config| { + config + .visp_integration + .as_ref() + .is_some_and(|integration| integration.enable_visp) + }), + ("WispGate", |config| { + config + .wispgate_integration + .as_ref() + .is_some_and(|integration| integration.enable_wispgate) + }), +]; + pub fn admin_check_data(login: LoginResult) -> bool { matches!(login, LoginResult::Admin) } @@ -74,6 +102,208 @@ pub fn all_shaped_devices_data() -> Vec { SHAPED_DEVICES.load().devices.clone() } +/// Returns the enabled integration names that act as the source of truth for +/// `network.json` and `ShapedDevices.csv`. +pub fn active_topology_source_integrations(config: &Config) -> Vec<&'static str> { + TOPOLOGY_SOURCE_INTEGRATIONS + .iter() + .filter_map(|(name, enabled)| enabled(config).then_some(*name)) + .collect() +} + +fn topology_editor_lock_message() -> Result, String> { + let config = lqos_config::load_config() + .map_err(|e| format!("Unable to load LibreQoS config: {e}"))?; + let integrations = active_topology_source_integrations(config.as_ref()); + if integrations.is_empty() { + return Ok(None); + } + Ok(Some(format!( + "Editing is disabled because these integrations are the source of truth: {}.", + integrations.join(", ") + ))) +} + +fn ensure_topology_editor_unlocked() -> Result<(), String> { + if let Some(message) = topology_editor_lock_message()? { + return Err(message); + } + Ok(()) +} + +fn persist_network_json(network_json: &Value) { + let config = lqos_config::load_config().expect("Unable to load LibreQoS config"); + let serialized_string = serde_json::to_string_pretty(network_json) + .expect("Unable to serialize network.json payload"); + let net_json_path = std::path::Path::new(&config.lqos_directory).join("network.json"); + let net_json_backup_path = + std::path::Path::new(&config.lqos_directory).join("network.json.backup"); + if net_json_path.exists() { + std::fs::copy(&net_json_path, net_json_backup_path) + .expect("Unable to create network.json backup"); + } + std::fs::write(net_json_path, serialized_string).expect("Unable to write network.json"); +} + +fn normalize_sqm_override(raw: &Option) -> Option { + let token = raw.as_deref().unwrap_or("").trim().to_lowercase(); + if token.is_empty() { + None + } else { + Some(token) + } +} + +fn normalize_shaped_device(device: &mut ShapedDevice) { + device.circuit_id = device.circuit_id.trim().to_string(); + device.circuit_name = device.circuit_name.trim().to_string(); + device.device_id = device.device_id.trim().to_string(); + device.device_name = device.device_name.trim().to_string(); + device.parent_node = device.parent_node.trim().to_string(); + device.mac = device.mac.trim().to_string(); + device.comment = device.comment.trim().to_string(); + device.sqm_override = normalize_sqm_override(&device.sqm_override); +} + +fn populate_shaped_device_hashes(devices: &mut [ShapedDevice]) { + for device in devices { + device.circuit_hash = hash_to_i64(&device.circuit_id); + device.device_hash = hash_to_i64(&device.device_id); + device.parent_hash = hash_to_i64(&device.parent_node); + } +} + +fn collect_network_nodes(value: &Value, out: &mut HashSet) { + let Some(map) = value.as_object() else { + return; + }; + for (name, node) in map { + out.insert(name.to_string()); + if let Some(children) = node.get("children") { + collect_network_nodes(children, out); + } + } +} + +fn validate_sqm_override(raw: &Option) -> Result<(), String> { + let Some(token) = normalize_sqm_override(raw) else { + return Ok(()); + }; + let valid = |value: &str| matches!(value, "" | "cake" | "fq_codel" | "none"); + if token.contains('/') { + let mut parts = token.splitn(2, '/'); + let down = parts.next().unwrap_or("").trim(); + let up = parts.next().unwrap_or("").trim(); + if valid(down) && valid(up) { + return Ok(()); + } + } else if valid(&token) { + return Ok(()); + } + Err(format!( + "Invalid SQM override '{token}'. Allowed values: cake, fq_codel, none, or directional down/up tokens." + )) +} + +fn validate_shaped_devices(devices: &[ShapedDevice]) -> Result<(), String> { + let network_json = network_json_data(); + let mut nodes = HashSet::new(); + collect_network_nodes(&network_json, &mut nodes); + + let mut device_ids = HashSet::new(); + let mut ipv4s = HashSet::new(); + let mut ipv6s = HashSet::new(); + + for (index, device) in devices.iter().enumerate() { + let label = if device.device_id.is_empty() { + format!("row {}", index + 1) + } else { + format!("device '{}'", device.device_id) + }; + + if device.circuit_id.is_empty() { + return Err(format!("{label}: Circuit ID is required")); + } + if device.circuit_name.is_empty() { + return Err(format!("{label}: Circuit Name is required")); + } + if device.device_id.is_empty() { + return Err(format!("{label}: Device ID is required")); + } + if device.device_name.is_empty() { + return Err(format!("{label}: Device Name is required")); + } + if !device_ids.insert(device.device_id.clone()) { + return Err(format!("Duplicate device ID '{}'", device.device_id)); + } + if nodes.is_empty() { + if !device.parent_node.is_empty() { + return Err(format!( + "{label}: Parent node '{}' is invalid for a flat network", + device.parent_node + )); + } + } else if !device.parent_node.is_empty() && !nodes.contains(&device.parent_node) { + return Err(format!("{label}: Parent node '{}' does not exist", device.parent_node)); + } + if device.ipv4.is_empty() && device.ipv6.is_empty() { + return Err(format!("{label}: At least one IPv4 or IPv6 address is required")); + } + for (addr, prefix) in &device.ipv4 { + let key = format!("{addr}/{prefix}"); + if !ipv4s.insert(key.clone()) { + return Err(format!("Duplicate IPv4 entry '{key}'")); + } + } + for (addr, prefix) in &device.ipv6 { + let key = format!("{addr}/{prefix}"); + if !ipv6s.insert(key.clone()) { + return Err(format!("Duplicate IPv6 entry '{key}'")); + } + } + if !device.download_min_mbps.is_finite() || device.download_min_mbps < 0.1 { + return Err(format!("{label}: Download min must be >= 0.1 Mbps")); + } + if !device.upload_min_mbps.is_finite() || device.upload_min_mbps < 0.1 { + return Err(format!("{label}: Upload min must be >= 0.1 Mbps")); + } + if !device.download_max_mbps.is_finite() || device.download_max_mbps < 0.2 { + return Err(format!("{label}: Download max must be >= 0.2 Mbps")); + } + if !device.upload_max_mbps.is_finite() || device.upload_max_mbps < 0.2 { + return Err(format!("{label}: Upload max must be >= 0.2 Mbps")); + } + validate_sqm_override(&device.sqm_override)?; + } + + Ok(()) +} + +fn persist_shaped_devices(mut devices: Vec) -> Result<(), String> { + for device in &mut devices { + normalize_shaped_device(device); + } + validate_shaped_devices(&devices)?; + populate_shaped_device_hashes(&mut devices); + + let config = lqos_config::load_config().expect("Unable to load LibreQoS config"); + let sd_path = std::path::Path::new(&config.lqos_directory).join("ShapedDevices.csv"); + let sd_backup_path = + std::path::Path::new(&config.lqos_directory).join("ShapedDevices.csv.backup"); + if sd_path.exists() { + std::fs::copy(&sd_path, sd_backup_path) + .map_err(|e| format!("Unable to create ShapedDevices.csv backup: {e}"))?; + } + let mut copied = ConfigShapedDevices::default(); + copied.replace_with_new_data(devices); + copied + .write_csv("ShapedDevices.csv") + .map_err(|e| format!("Unable to write ShapedDevices.csv: {e}"))?; + SHAPED_DEVICES.store(Arc::new(copied)); + + Ok(()) +} + pub async fn update_lqosd_config_data( login: LoginResult, config: Config, @@ -87,44 +317,120 @@ pub async fn update_lqosd_config_data( Ok(()) } +/// Persists both `network.json` and `ShapedDevices.csv` for administrative +/// edits. +/// +/// Returns an error string when the caller is unauthorized, when integration- +/// managed topology editing is locked, or when validation/persistence fails. pub fn update_network_and_devices_data( login: LoginResult, network_json: Value, shaped_devices: Vec, -) -> Result<(), StatusCode> { +) -> Result<(), String> { + if login != LoginResult::Admin { + return Err("Unauthorized".to_string()); + } + ensure_topology_editor_unlocked()?; + persist_network_json(&network_json); + persist_shaped_devices(shaped_devices)?; + + Ok(()) +} + +/// Persists `network.json` for administrative edits without modifying shaped +/// devices. +/// +/// Returns an error string when the caller is unauthorized, when integration- +/// managed topology editing is locked, or when persistence fails. +pub fn update_network_json_only_data(login: LoginResult, network_json: Value) -> Result<(), String> { + if login != LoginResult::Admin { + return Err("Unauthorized".to_string()); + } + ensure_topology_editor_unlocked()?; + + persist_network_json(&network_json); + + Ok(()) +} + +/// Returns one shaped device row by device identifier for administrative +/// callers. +pub fn get_shaped_device_data(login: LoginResult, device_id: String) -> Result, StatusCode> { if login != LoginResult::Admin { return Err(StatusCode::FORBIDDEN); } + let wanted = device_id.trim(); + Ok(SHAPED_DEVICES + .load() + .devices + .iter() + .find(|device| device.device_id == wanted) + .cloned()) +} - let config = lqos_config::load_config().expect("Unable to load LibreQoS config"); - - // Save network.json - let serialized_string = serde_json::to_string_pretty(&network_json) - .expect("Unable to serialize network.json payload"); - let net_json_path = std::path::Path::new(&config.lqos_directory).join("network.json"); - let net_json_backup_path = - std::path::Path::new(&config.lqos_directory).join("network.json.backup"); - if net_json_path.exists() { - // Make a backup - std::fs::copy(&net_json_path, net_json_backup_path) - .expect("Unable to create network.json backup"); +/// Creates one shaped device row for administrative callers. +/// +/// Returns an error string when the caller is unauthorized, when integration- +/// managed topology editing is locked, or when validation/persistence fails. +pub fn create_shaped_device_data(login: LoginResult, device: ShapedDevice) -> Result { + if login != LoginResult::Admin { + return Err("Unauthorized".to_string()); } - std::fs::write(net_json_path, serialized_string).expect("Unable to write network.json"); + ensure_topology_editor_unlocked()?; + let mut devices = SHAPED_DEVICES.load().devices.clone(); + devices.push(device.clone()); + persist_shaped_devices(devices)?; + let created = get_shaped_device_data(login, device.device_id.clone()) + .map_err(|_| "Unable to reload shaped device".to_string())? + .ok_or_else(|| "Unable to reload shaped device".to_string())?; + Ok(created) +} - // Save the Shaped Devices - let sd_path = std::path::Path::new(&config.lqos_directory).join("ShapedDevices.csv"); - let sd_backup_path = - std::path::Path::new(&config.lqos_directory).join("ShapedDevices.csv.backup"); - if sd_path.exists() { - std::fs::copy(&sd_path, sd_backup_path).expect("Unable to create ShapedDevices.csv backup"); +/// Updates one shaped device row for administrative callers. +/// +/// Returns an error string when the caller is unauthorized, when integration- +/// managed topology editing is locked, when the row is not found, or when +/// validation/persistence fails. +pub fn update_shaped_device_data( + login: LoginResult, + original_device_id: String, + device: ShapedDevice, +) -> Result { + if login != LoginResult::Admin { + return Err("Unauthorized".to_string()); } - let mut copied = ConfigShapedDevices::default(); - copied.replace_with_new_data(shaped_devices); - copied - .write_csv(&format!("{}/ShapedDevices.csv", config.lqos_directory)) - .expect("Unable to write ShapedDevices.csv"); - SHAPED_DEVICES.store(Arc::new(copied)); + ensure_topology_editor_unlocked()?; + let mut devices = SHAPED_DEVICES.load().devices.clone(); + let wanted = original_device_id.trim(); + let Some(index) = devices.iter().position(|row| row.device_id == wanted) else { + return Err("Not found".to_string()); + }; + devices[index] = device.clone(); + persist_shaped_devices(devices)?; + let updated = get_shaped_device_data(login, device.device_id.clone()) + .map_err(|_| "Unable to reload shaped device".to_string())? + .ok_or_else(|| "Unable to reload shaped device".to_string())?; + Ok(updated) +} +/// Deletes one shaped device row for administrative callers. +/// +/// Returns an error string when the caller is unauthorized, when integration- +/// managed topology editing is locked, when the row is not found, or when +/// persistence fails. +pub fn delete_shaped_device_data(login: LoginResult, device_id: String) -> Result<(), String> { + if login != LoginResult::Admin { + return Err("Unauthorized".to_string()); + } + ensure_topology_editor_unlocked()?; + let wanted = device_id.trim(); + let mut devices = SHAPED_DEVICES.load().devices.clone(); + let before = devices.len(); + devices.retain(|device| device.device_id != wanted); + if devices.len() == before { + return Err("Not found".to_string()); + } + persist_shaped_devices(devices)?; Ok(()) } diff --git a/src/rust/lqosd/src/node_manager/local_api/directories.rs b/src/rust/lqosd/src/node_manager/local_api/directories.rs new file mode 100644 index 00000000..e3b77469 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/directories.rs @@ -0,0 +1,233 @@ +use crate::node_manager::local_api::network_tree; +use crate::shaped_devices_tracker::SHAPED_DEVICES; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +const DEFAULT_CIRCUIT_DIRECTORY_PAGE_SIZE: usize = 100; +const MAX_CIRCUIT_DIRECTORY_PAGE_SIZE: usize = 250; + +fn deserialize_optional_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum MaybeString { + String(String), + Null, + } + + Ok(match Option::::deserialize(deserializer)? { + Some(MaybeString::String(value)) => Some(value), + Some(MaybeString::Null) | None => None, + }) +} + +/// Query parameters for the paged circuit directory used by configuration UIs. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitDirectoryQuery { + pub page: Option, + pub page_size: Option, + #[serde(default, deserialize_with = "deserialize_optional_string")] + pub search: Option, +} + +/// Compact circuit metadata row for selectors and lookups. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitDirectoryRow { + pub circuit_id: String, + pub circuit_name: String, + pub parent_node: String, + pub sqm_override: Option, +} + +/// A server-paged slice of the circuit directory. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitDirectoryPage { + pub query: CircuitDirectoryQuery, + pub total_rows: usize, + pub rows: Vec, +} + +/// Lightweight node directory entry for UI link resolution and selectors. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct NodeDirectoryEntry { + pub tree_index: usize, + pub node_id: Option, + pub node_name: String, + pub node_type: Option, +} + +/// Small TreeGuard summary used by dashboard/status panels. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TreeGuardMetadataSummary { + pub total_nodes: usize, + pub total_circuits: usize, + pub virtualized_nodes: usize, + pub fq_codel_circuits: usize, +} + +fn normalized_page_size(query: &CircuitDirectoryQuery) -> usize { + query + .page_size + .unwrap_or(DEFAULT_CIRCUIT_DIRECTORY_PAGE_SIZE) + .clamp(1, MAX_CIRCUIT_DIRECTORY_PAGE_SIZE) +} + +fn has_fq_codel_override(raw: &str) -> bool { + raw.split('/') + .map(|part| part.trim().to_ascii_lowercase()) + .any(|part| part == "fq_codel") +} + +/// Returns one filtered, sorted page of circuit directory rows. +pub fn circuit_directory_page(query: CircuitDirectoryQuery) -> CircuitDirectoryPage { + let page = query.page.unwrap_or(0); + let page_size = normalized_page_size(&query); + let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); + let devices = SHAPED_DEVICES.load(); + + let mut circuits: BTreeMap = BTreeMap::new(); + for device in &devices.devices { + let circuit_id = device.circuit_id.trim().to_string(); + if circuit_id.is_empty() { + continue; + } + let row = circuits + .entry(circuit_id.clone()) + .or_insert_with(|| CircuitDirectoryRow { + circuit_id: circuit_id.clone(), + circuit_name: device.circuit_name.trim().to_string(), + parent_node: device.parent_node.trim().to_string(), + sqm_override: { + let override_value = device.sqm_override.as_deref().unwrap_or("").trim(); + if override_value.is_empty() { + None + } else { + Some(override_value.to_string()) + } + }, + }); + if row.circuit_name.is_empty() && !device.circuit_name.trim().is_empty() { + row.circuit_name = device.circuit_name.trim().to_string(); + } + if row.parent_node.is_empty() && !device.parent_node.trim().is_empty() { + row.parent_node = device.parent_node.trim().to_string(); + } + let sqm_override = device.sqm_override.as_deref().unwrap_or("").trim(); + if row.sqm_override.is_none() && !sqm_override.is_empty() { + row.sqm_override = Some(sqm_override.to_string()); + } + } + + let mut filtered: Vec = circuits + .into_values() + .filter(|row| { + if search.is_empty() { + return true; + } + row.circuit_id.to_lowercase().contains(&search) + || row.circuit_name.to_lowercase().contains(&search) + || row.parent_node.to_lowercase().contains(&search) + || row + .sqm_override + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&search) + }) + .collect(); + filtered.sort_by(|left, right| { + left.circuit_name + .cmp(&right.circuit_name) + .then_with(|| left.circuit_id.cmp(&right.circuit_id)) + }); + + let total_rows = filtered.len(); + let start = page.saturating_mul(page_size); + let rows = if start >= total_rows { + Vec::new() + } else { + let end = (start + page_size).min(total_rows); + filtered[start..end].to_vec() + }; + + CircuitDirectoryPage { + query: CircuitDirectoryQuery { + page: Some(page), + page_size: Some(page_size), + search: if search.is_empty() { + None + } else { + query.search + }, + }, + total_rows, + rows, + } +} + +/// Returns a compact flattened node directory for UI link resolution. +pub fn node_directory_data() -> Vec { + let mut nodes = network_tree::network_tree_data() + .into_iter() + .filter_map(|(tree_index, node)| { + let node_name = node.name.trim().to_string(); + if node_name.is_empty() || node_name == "Root" { + return None; + } + Some(NodeDirectoryEntry { + tree_index, + node_id: node.id.clone(), + node_name, + node_type: node.node_type.clone(), + }) + }) + .collect::>(); + nodes.sort_by(|left, right| { + left.node_name + .cmp(&right.node_name) + .then_with(|| left.tree_index.cmp(&right.tree_index)) + }); + nodes +} + +/// Returns a compact TreeGuard metadata summary without loading full device/node payloads into the browser. +pub fn treeguard_metadata_summary() -> TreeGuardMetadataSummary { + let network = network_tree::network_tree_data(); + let total_nodes = network + .iter() + .filter(|(_, node)| { + let name = node.name.trim(); + !name.is_empty() && name != "Root" + }) + .count(); + let virtualized_nodes = network + .iter() + .filter(|(_, node)| { + let name = node.name.trim(); + !name.is_empty() && name != "Root" && node.is_virtual + }) + .count(); + + let devices = SHAPED_DEVICES.load(); + let mut circuit_ids = BTreeSet::new(); + let mut fq_codel_circuit_ids = BTreeSet::new(); + for device in &devices.devices { + let circuit_id = device.circuit_id.trim().to_string(); + if circuit_id.is_empty() { + continue; + } + circuit_ids.insert(circuit_id.clone()); + if has_fq_codel_override(device.sqm_override.as_deref().unwrap_or("").trim()) { + fq_codel_circuit_ids.insert(circuit_id); + } + } + + TreeGuardMetadataSummary { + total_nodes, + total_circuits: circuit_ids.len(), + virtualized_nodes, + fq_codel_circuits: fq_codel_circuit_ids.len(), + } +} diff --git a/src/rust/lqosd/src/node_manager/local_api/executive.rs b/src/rust/lqosd/src/node_manager/local_api/executive.rs new file mode 100644 index 00000000..dd5042b2 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/executive.rs @@ -0,0 +1,467 @@ +use crate::node_manager::local_api::executive_cache::fresh_executive_cache_snapshot; +use lqos_bus::ExecutiveSummaryHeader; +use lqos_utils::{HeatmapBlocks, qoq_heatmap::QoqHeatmapBlocks}; +use serde::{Deserialize, Serialize}; + +const DEFAULT_EXECUTIVE_HEATMAP_PAGE_SIZE: usize = 50; +const DEFAULT_EXECUTIVE_LEADERBOARD_PAGE_SIZE: usize = 50; +const MAX_EXECUTIVE_PAGE_SIZE: usize = 250; + +/// Entity kinds shown in executive dashboard and detail views. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum ExecutiveEntityKind { + Site, + Circuit, + Asn, +} + +/// Executive metrics supported by summary and detail heatmap views. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum ExecutiveMetric { + Download, + Upload, + Retransmit, + Rtt, + Qoo, +} + +/// Server-side sort modes for executive heatmap detail pages. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ExecutiveHeatmapSort { + LatestValue, + Label, + SampleCount, +} + +/// Named executive leaderboard views. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum ExecutiveLeaderboardKind { + WorstSitesByRtt, + OversubscribedSites, + SitesDueUpgrade, + CircuitsDueUpgrade, + TopAsnsByTraffic, +} + +/// Stable locator for linking executive rows back to the tree view. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct ExecutiveTreeLocator { + pub node_id: Option, + pub node_path: Option>, + pub parent_index: Option, +} + +/// One scalar historical series, usually used for a single metric. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct ExecutiveScalarBlocks { + pub values: Vec>, +} + +/// One split directional historical series. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct ExecutiveSplitBlocks { + pub download: Vec>, + pub upload: Vec>, +} + +/// RTT history including combined and directional percentile data. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct ExecutiveRttBlocks { + pub rtt: Vec>, + pub dl_p50: Vec>, + pub dl_p90: Vec>, + pub ul_p50: Vec>, + pub ul_p90: Vec>, +} + +/// One server-ranked metric row for the executive dashboard. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveDashboardMetricRow { + pub row_key: String, + pub entity_kind: ExecutiveEntityKind, + pub metric: ExecutiveMetric, + pub label: String, + pub circuit_id: Option, + pub asn: Option, + pub tree: Option, + pub scalar_blocks: Option, + pub split_blocks: Option, + pub rtt_blocks: Option, +} + +/// Oversubscription leaderboard row for the executive dashboard. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveOversubscribedSiteRow { + pub row_key: String, + pub site_name: String, + pub tree: Option, + pub ratio_down: Option, + pub ratio_up: Option, + pub ratio_max: Option, + pub cap_down: f32, + pub cap_up: f32, + pub sub_down: f32, + pub sub_up: f32, + pub avg_down_util: Option, + pub avg_up_util: Option, + pub median_rtt_ms: Option, +} + +/// Top-ASN row for executive dashboard summary. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveTopAsnRow { + pub row_key: String, + pub asn: u32, + pub asn_name: Option, + pub total_bytes_15m: u64, + pub median_rtt_ms: Option, + pub median_retransmit_pct: Option, +} + +/// Compact executive summary payload for the dashboard. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveDashboardSummary { + pub generated_at_unix_ms: u64, + pub header: ExecutiveSummaryHeader, + pub global: HeatmapBlocks, + pub global_qoq: QoqHeatmapBlocks, + pub top_download: Vec, + pub top_upload: Vec, + pub top_retransmit: Vec, + pub top_rtt: Vec, + pub top_qoo: Vec, + pub oversubscribed_sites: Vec, + pub top_asns: Vec, +} + +/// Query for a paged executive heatmap detail page. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveHeatmapPageQuery { + pub metric: ExecutiveMetric, + pub entity_kinds: Vec, + pub page: Option, + pub page_size: Option, + pub search: Option, + pub sort: Option, + pub descending: Option, +} + +/// One paged executive heatmap detail row. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveHeatmapPageRow { + pub row_key: String, + pub entity_kind: ExecutiveEntityKind, + pub label: String, + pub circuit_id: Option, + pub asn: Option, + pub tree: Option, + pub scalar_blocks: Option, + pub split_blocks: Option, + pub rtt_blocks: Option, +} + +/// One paged executive heatmap detail response. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveHeatmapPage { + pub generated_at_unix_ms: u64, + pub query: ExecutiveHeatmapPageQuery, + pub total_rows: usize, + pub rows: Vec, +} + +/// Query for a paged executive leaderboard detail view. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveLeaderboardPageQuery { + pub kind: ExecutiveLeaderboardKind, + pub page: Option, + pub page_size: Option, + pub search: Option, +} + +/// One paged executive leaderboard response. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ExecutiveLeaderboardPage { + pub generated_at_unix_ms: u64, + pub query: ExecutiveLeaderboardPageQuery, + pub total_rows: usize, + pub rows: Vec, +} + +/// Rows for the standalone executive leaderboard pages. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind")] +pub enum ExecutiveLeaderboardRow { + WorstSiteByRtt { + row_key: String, + site_name: String, + tree: Option, + median_rtt_ms: f32, + avg_down_util: Option, + avg_up_util: Option, + }, + OversubscribedSite { + row_key: String, + site_name: String, + tree: Option, + ratio_down: Option, + ratio_up: Option, + ratio_max: Option, + cap_down: f32, + cap_up: f32, + sub_down: f32, + sub_up: f32, + avg_down_util: Option, + avg_up_util: Option, + median_rtt_ms: Option, + }, + SiteDueUpgrade { + row_key: String, + site_name: String, + tree: Option, + avg_down_util: f32, + avg_up_util: f32, + }, + CircuitDueUpgrade { + row_key: String, + circuit_id: String, + circuit_name: String, + avg_down_util: f32, + avg_up_util: f32, + }, + TopAsnByTraffic { + row_key: String, + asn: u32, + asn_name: Option, + total_bytes_15m: u64, + median_rtt_ms: Option, + median_retransmit_pct: Option, + }, +} + +fn normalize_page_size(requested: Option, default_size: usize) -> usize { + requested + .unwrap_or(default_size) + .clamp(1, MAX_EXECUTIVE_PAGE_SIZE) +} + +fn normalized_search(search: &Option) -> Option { + let value = search.as_deref().unwrap_or("").trim().to_lowercase(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +fn normalized_entity_kinds(mut kinds: Vec) -> Vec { + if kinds.is_empty() { + return vec![ + ExecutiveEntityKind::Site, + ExecutiveEntityKind::Circuit, + ExecutiveEntityKind::Asn, + ]; + } + kinds.sort_by_key(|kind| match kind { + ExecutiveEntityKind::Site => 0_u8, + ExecutiveEntityKind::Circuit => 1_u8, + ExecutiveEntityKind::Asn => 2_u8, + }); + kinds.dedup(); + kinds +} + +fn latest_value(row: &ExecutiveHeatmapPageRow, metric: &ExecutiveMetric) -> Option { + match metric { + ExecutiveMetric::Download | ExecutiveMetric::Upload | ExecutiveMetric::Retransmit => row + .scalar_blocks + .as_ref() + .and_then(|blocks| blocks.values.iter().rev().flatten().copied().next()), + ExecutiveMetric::Qoo => row.split_blocks.as_ref().and_then(|blocks| { + let values = [ + blocks.download.iter().rev().flatten().copied().next(), + blocks.upload.iter().rev().flatten().copied().next(), + ]; + let present: Vec = values.into_iter().flatten().collect(); + if present.is_empty() { + None + } else { + Some(present.iter().sum::() / present.len() as f32) + } + }), + ExecutiveMetric::Rtt => row + .rtt_blocks + .as_ref() + .and_then(|blocks| blocks.rtt.iter().rev().flatten().copied().next()), + } +} + +fn sample_count(row: &ExecutiveHeatmapPageRow, metric: &ExecutiveMetric) -> usize { + match metric { + ExecutiveMetric::Download | ExecutiveMetric::Upload | ExecutiveMetric::Retransmit => row + .scalar_blocks + .as_ref() + .map(|blocks| blocks.values.iter().filter(|value| value.is_some()).count()) + .unwrap_or(0), + ExecutiveMetric::Qoo => row + .split_blocks + .as_ref() + .map(|blocks| { + blocks + .download + .iter() + .chain(blocks.upload.iter()) + .filter(|value| value.is_some()) + .count() + }) + .unwrap_or(0), + ExecutiveMetric::Rtt => row + .rtt_blocks + .as_ref() + .map(|blocks| blocks.rtt.iter().filter(|value| value.is_some()).count()) + .unwrap_or(0), + } +} + +fn leaderboard_matches_search(row: &ExecutiveLeaderboardRow, search: &Option) -> bool { + let Some(search) = search else { + return true; + }; + match row { + ExecutiveLeaderboardRow::WorstSiteByRtt { site_name, .. } + | ExecutiveLeaderboardRow::OversubscribedSite { site_name, .. } + | ExecutiveLeaderboardRow::SiteDueUpgrade { site_name, .. } => { + site_name.to_lowercase().contains(search) + } + ExecutiveLeaderboardRow::CircuitDueUpgrade { + circuit_id, + circuit_name, + .. + } => { + circuit_id.to_lowercase().contains(search) + || circuit_name.to_lowercase().contains(search) + } + ExecutiveLeaderboardRow::TopAsnByTraffic { + asn, + asn_name, + .. + } => { + asn.to_string().contains(search) + || asn_name + .as_ref() + .map(|name| name.to_lowercase().contains(search)) + .unwrap_or(false) + } + } +} + +/// Returns the compact executive dashboard summary for published websocket updates. +pub fn executive_dashboard_summary() -> ExecutiveDashboardSummary { + fresh_executive_cache_snapshot().dashboard.clone() +} + +/// Returns one filtered, sorted executive heatmap detail page. +pub fn executive_heatmap_page(query: ExecutiveHeatmapPageQuery) -> ExecutiveHeatmapPage { + let snapshot = fresh_executive_cache_snapshot(); + let page = query.page.unwrap_or(0); + let page_size = normalize_page_size(query.page_size, DEFAULT_EXECUTIVE_HEATMAP_PAGE_SIZE); + let search = normalized_search(&query.search); + let sort = query + .sort + .clone() + .unwrap_or(ExecutiveHeatmapSort::LatestValue); + let descending = query.descending.unwrap_or(true); + let entity_kinds = normalized_entity_kinds(query.entity_kinds.clone()); + + let mut rows = snapshot + .heatmap_rows_for_metric(&query.metric) + .into_iter() + .filter(|row| entity_kinds.contains(&row.entity_kind)) + .filter(|row| { + let Some(search) = &search else { + return true; + }; + row.label.to_lowercase().contains(search) + || row + .circuit_id + .as_ref() + .map(|id| id.to_lowercase().contains(search)) + .unwrap_or(false) + || row.asn.map(|asn| asn.to_string().contains(search)).unwrap_or(false) + }) + .collect::>(); + + rows.sort_by(|left, right| match sort { + ExecutiveHeatmapSort::Label => left.label.cmp(&right.label), + ExecutiveHeatmapSort::SampleCount => sample_count(left, &query.metric) + .cmp(&sample_count(right, &query.metric)) + .then_with(|| left.label.cmp(&right.label)), + ExecutiveHeatmapSort::LatestValue => { + let left_latest = latest_value(left, &query.metric); + let right_latest = latest_value(right, &query.metric); + right_latest + .partial_cmp(&left_latest) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| right.label.cmp(&left.label)) + } + }); + + if !descending { + rows.reverse(); + } + + let total_rows = rows.len(); + let start = page.saturating_mul(page_size); + let rows = if start >= total_rows { + Vec::new() + } else { + rows.into_iter().skip(start).take(page_size).collect() + }; + + ExecutiveHeatmapPage { + generated_at_unix_ms: snapshot.generated_at_unix_ms, + query: ExecutiveHeatmapPageQuery { + metric: query.metric, + entity_kinds, + page: Some(page), + page_size: Some(page_size), + search, + sort: Some(sort), + descending: Some(descending), + }, + total_rows, + rows, + } +} + +/// Returns one filtered executive leaderboard page. +pub fn executive_leaderboard_page(query: ExecutiveLeaderboardPageQuery) -> ExecutiveLeaderboardPage { + let snapshot = fresh_executive_cache_snapshot(); + let page = query.page.unwrap_or(0); + let page_size = normalize_page_size(query.page_size, DEFAULT_EXECUTIVE_LEADERBOARD_PAGE_SIZE); + let search = normalized_search(&query.search); + let mut rows = snapshot + .leaderboard_rows + .get(&query.kind) + .cloned() + .unwrap_or_default(); + rows.retain(|row| leaderboard_matches_search(row, &search)); + let total_rows = rows.len(); + let start = page.saturating_mul(page_size); + let rows = if start >= total_rows { + Vec::new() + } else { + rows.into_iter().skip(start).take(page_size).collect() + }; + + ExecutiveLeaderboardPage { + generated_at_unix_ms: snapshot.generated_at_unix_ms, + query: ExecutiveLeaderboardPageQuery { + kind: query.kind, + page: Some(page), + page_size: Some(page_size), + search, + }, + total_rows, + rows, + } +} diff --git a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs new file mode 100644 index 00000000..db013a2a --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs @@ -0,0 +1,835 @@ +use crate::node_manager::local_api::executive::{ + ExecutiveDashboardMetricRow, ExecutiveDashboardSummary, ExecutiveEntityKind, + ExecutiveHeatmapPageRow, ExecutiveLeaderboardKind, ExecutiveLeaderboardRow, ExecutiveMetric, + ExecutiveOversubscribedSiteRow, ExecutiveRttBlocks, ExecutiveScalarBlocks, + ExecutiveSplitBlocks, ExecutiveTopAsnRow, ExecutiveTreeLocator, +}; +use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; +use crate::throughput_tracker::{ + THROUGHPUT_TRACKER, asn_heatmaps, circuit_heatmaps, executive_summary_header, global_heatmap, + site_heatmaps, +}; +use arc_swap::ArcSwap; +use fxhash::{FxHashMap, FxHashSet}; +use lqos_bus::{ + AsnHeatmapData, BusResponse, CircuitHeatmapData, ExecutiveSummaryHeader, SiteHeatmapData, +}; +use lqos_utils::{ + HeatmapBlocks, + qoq_heatmap::{QoqHeatmapBlocks, TemporalQoqHeatmap}, + temporal_heatmap::TemporalHeatmap, +}; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const EXECUTIVE_TOP_LIMIT: usize = 10; +const EXECUTIVE_OVERSUBSCRIBED_LIMIT: usize = 20; +const MIN_SAMPLES_FOR_ALERTS: usize = 10; +const DUE_UPGRADE_THRESHOLD: f32 = 80.0; +const COUNT_WEIGHT: f32 = 100.0; +const MIN_HEATMAP_SAMPLES: usize = 3; +const HEATMAP_BLOCK_SECONDS: u64 = 60; +const BITS_PER_MEGABIT: f32 = 1_000_000.0; + +#[derive(Clone, Debug)] +pub(crate) struct ExecutiveEntitySnapshot { + pub row_key: String, + pub entity_kind: ExecutiveEntityKind, + pub label: String, + pub circuit_id: Option, + pub asn: Option, + pub tree: Option, + pub heatmap: HeatmapBlocks, + pub qoq_blocks: Option, +} + +/// Shared once-per-second executive cache used by dashboard summary and detail pages. +#[derive(Clone, Debug)] +pub(crate) struct ExecutiveCacheSnapshot { + pub generated_at_unix_ms: u64, + pub dashboard: ExecutiveDashboardSummary, + pub entities: Vec, + pub leaderboard_rows: FxHashMap>, +} + +impl Default for ExecutiveCacheSnapshot { + fn default() -> Self { + Self { + generated_at_unix_ms: 0, + dashboard: ExecutiveDashboardSummary { + generated_at_unix_ms: 0, + header: ExecutiveSummaryHeader::default(), + global: TemporalHeatmap::new().blocks(), + global_qoq: TemporalQoqHeatmap::new().blocks(), + top_download: Vec::new(), + top_upload: Vec::new(), + top_retransmit: Vec::new(), + top_rtt: Vec::new(), + top_qoo: Vec::new(), + oversubscribed_sites: Vec::new(), + top_asns: Vec::new(), + }, + entities: Vec::new(), + leaderboard_rows: FxHashMap::default(), + } + } +} + +impl ExecutiveCacheSnapshot { + /// Builds transport rows for one requested metric from cached raw executive entities. + pub(crate) fn heatmap_rows_for_metric( + &self, + metric: &ExecutiveMetric, + ) -> Vec { + self.entities + .iter() + .map(|entity| ExecutiveHeatmapPageRow { + row_key: entity.row_key.clone(), + entity_kind: entity.entity_kind.clone(), + label: entity.label.clone(), + circuit_id: entity.circuit_id.clone(), + asn: entity.asn, + tree: entity.tree.clone(), + scalar_blocks: heatmap_scalar_blocks(metric, &entity.heatmap), + split_blocks: match metric { + ExecutiveMetric::Retransmit => Some(ExecutiveSplitBlocks { + download: array_to_vec(&entity.heatmap.retransmit_down), + upload: array_to_vec(&entity.heatmap.retransmit_up), + }), + ExecutiveMetric::Qoo => entity.qoq_blocks.as_ref().map(qoq_to_split_blocks), + ExecutiveMetric::Download | ExecutiveMetric::Upload => Some(ExecutiveSplitBlocks { + download: array_to_vec(&entity.heatmap.download), + upload: array_to_vec(&entity.heatmap.upload), + }), + ExecutiveMetric::Rtt => None, + }, + rtt_blocks: match metric { + ExecutiveMetric::Rtt => Some(heatmap_to_rtt_blocks(&entity.heatmap)), + _ => None, + }, + }) + .collect() + } +} + +static EXECUTIVE_CACHE_SNAPSHOT: Lazy> = + Lazy::new(|| ArcSwap::new(Arc::new(ExecutiveCacheSnapshot::default()))); +static EXECUTIVE_CACHE_LAST_REFRESH_SECS: AtomicU64 = AtomicU64::new(0); +static EXECUTIVE_CACHE_REFRESH_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +fn current_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn current_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn array_to_vec(values: &[Option; N]) -> Vec> { + values.to_vec() +} + +fn heatmap_scalar_blocks( + metric: &ExecutiveMetric, + heatmap: &HeatmapBlocks, +) -> Option { + let values = match metric { + ExecutiveMetric::Download => Some(array_to_vec(&heatmap.download)), + ExecutiveMetric::Upload => Some(array_to_vec(&heatmap.upload)), + ExecutiveMetric::Retransmit => Some(array_to_vec(&heatmap.retransmit)), + ExecutiveMetric::Rtt => Some(array_to_vec(&heatmap.rtt)), + ExecutiveMetric::Qoo => None, + }?; + Some(ExecutiveScalarBlocks { values }) +} + +fn qoq_to_split_blocks(blocks: &QoqHeatmapBlocks) -> ExecutiveSplitBlocks { + ExecutiveSplitBlocks { + download: array_to_vec(&blocks.download_total), + upload: array_to_vec(&blocks.upload_total), + } +} + +fn heatmap_to_rtt_blocks(blocks: &HeatmapBlocks) -> ExecutiveRttBlocks { + ExecutiveRttBlocks { + rtt: array_to_vec(&blocks.rtt), + dl_p50: array_to_vec(&blocks.rtt_p50_down), + dl_p90: array_to_vec(&blocks.rtt_p90_down), + ul_p50: array_to_vec(&blocks.rtt_p50_up), + ul_p90: array_to_vec(&blocks.rtt_p90_up), + } +} + +fn latest_value(values: &[Option]) -> Option { + values.iter().rev().flatten().copied().next() +} + +fn non_null_count(values: &[Option]) -> usize { + values.iter().filter(|value| value.is_some()).count() +} + +fn latest_qoo(blocks: Option<&QoqHeatmapBlocks>) -> Option { + let blocks = blocks?; + let mut present = Vec::new(); + if let Some(download) = latest_value(&blocks.download_total) { + present.push(download); + } + if let Some(upload) = latest_value(&blocks.upload_total) { + present.push(upload); + } + if present.is_empty() { + None + } else { + Some(present.iter().sum::() / present.len() as f32) + } +} + +fn qoo_sample_count(blocks: Option<&QoqHeatmapBlocks>) -> usize { + let Some(blocks) = blocks else { + return 0; + }; + non_null_count(&blocks.download_total).max(non_null_count(&blocks.upload_total)) +} + +fn sort_score_for_metric(entity: &ExecutiveEntitySnapshot, metric: &ExecutiveMetric) -> f32 { + match metric { + ExecutiveMetric::Download => latest_value(&entity.heatmap.download).unwrap_or(f32::NEG_INFINITY), + ExecutiveMetric::Upload => latest_value(&entity.heatmap.upload).unwrap_or(f32::NEG_INFINITY), + ExecutiveMetric::Retransmit => { + let latest = latest_value(&entity.heatmap.retransmit).unwrap_or(f32::NEG_INFINITY); + let count = non_null_count(&entity.heatmap.retransmit); + let penalty = if count < MIN_HEATMAP_SAMPLES { 1000.0 } else { 0.0 }; + latest + COUNT_WEIGHT * (count as f32 / 15.0) - penalty + } + ExecutiveMetric::Rtt => { + let latest = latest_value(&entity.heatmap.rtt).unwrap_or(f32::NEG_INFINITY); + let count = non_null_count(&entity.heatmap.rtt); + let penalty = if count < MIN_HEATMAP_SAMPLES { 1000.0 } else { 0.0 }; + latest + COUNT_WEIGHT * (count as f32 / 15.0) - penalty + } + ExecutiveMetric::Qoo => { + let latest = latest_qoo(entity.qoq_blocks.as_ref()).unwrap_or(f32::INFINITY); + let count = qoo_sample_count(entity.qoq_blocks.as_ref()); + let penalty = if count < MIN_HEATMAP_SAMPLES { 1000.0 } else { 0.0 }; + -latest + COUNT_WEIGHT * (count as f32 / 15.0) - penalty + } + } +} + +fn metric_row_from_entity( + entity: &ExecutiveEntitySnapshot, + metric: ExecutiveMetric, +) -> ExecutiveDashboardMetricRow { + ExecutiveDashboardMetricRow { + row_key: entity.row_key.clone(), + entity_kind: entity.entity_kind.clone(), + metric: metric.clone(), + label: entity.label.clone(), + circuit_id: entity.circuit_id.clone(), + asn: entity.asn, + tree: entity.tree.clone(), + scalar_blocks: heatmap_scalar_blocks(&metric, &entity.heatmap), + split_blocks: match metric { + ExecutiveMetric::Download | ExecutiveMetric::Upload => Some(ExecutiveSplitBlocks { + download: array_to_vec(&entity.heatmap.download), + upload: array_to_vec(&entity.heatmap.upload), + }), + ExecutiveMetric::Retransmit => Some(ExecutiveSplitBlocks { + download: array_to_vec(&entity.heatmap.retransmit_down), + upload: array_to_vec(&entity.heatmap.retransmit_up), + }), + ExecutiveMetric::Qoo => entity.qoq_blocks.as_ref().map(qoq_to_split_blocks), + ExecutiveMetric::Rtt => None, + }, + rtt_blocks: match metric { + ExecutiveMetric::Rtt => Some(heatmap_to_rtt_blocks(&entity.heatmap)), + _ => None, + }, + } +} + +fn top_metric_rows( + entities: &[ExecutiveEntitySnapshot], + metric: ExecutiveMetric, + limit: usize, +) -> Vec { + let mut ranked = entities + .iter() + .filter(|entity| matches!(entity.entity_kind, ExecutiveEntityKind::Site | ExecutiveEntityKind::Circuit)) + .cloned() + .collect::>(); + ranked.sort_by(|left, right| { + sort_score_for_metric(right, &metric) + .partial_cmp(&sort_score_for_metric(left, &metric)) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.label.cmp(&right.label)) + }); + ranked + .into_iter() + .take(limit) + .map(|entity| metric_row_from_entity(&entity, metric.clone())) + .collect() +} + +fn total_bytes_from_heatmap(blocks: &HeatmapBlocks) -> u64 { + let total_mbps_minutes: f32 = blocks + .download + .iter() + .chain(blocks.upload.iter()) + .flatten() + .copied() + .sum(); + ((total_mbps_minutes * BITS_PER_MEGABIT / 8.0) * HEATMAP_BLOCK_SECONDS as f32) as u64 +} + +fn median_value(values: &[Option]) -> Option { + let mut numeric = values.iter().flatten().copied().collect::>(); + if numeric.is_empty() { + return None; + } + numeric.sort_by(|left, right| left.total_cmp(right)); + let midpoint = numeric.len() / 2; + if numeric.len() % 2 == 1 { + Some(numeric[midpoint]) + } else { + Some((numeric[midpoint - 1] + numeric[midpoint]) / 2.0) + } +} + +fn average_with_count(values: &[Option]) -> (Option, usize) { + let numeric = values.iter().flatten().copied().collect::>(); + if numeric.is_empty() { + return (None, 0); + } + let sum = numeric.iter().sum::(); + (Some(sum / numeric.len() as f32), numeric.len()) +} + +fn build_top_asn_rows(entities: &[ExecutiveEntitySnapshot]) -> Vec { + let mut rows = entities + .iter() + .filter(|entity| entity.entity_kind == ExecutiveEntityKind::Asn) + .map(|entity| ExecutiveTopAsnRow { + row_key: entity.row_key.clone(), + asn: entity.asn.unwrap_or_default(), + asn_name: match entity.label.as_str() { + label if label.starts_with("ASN ") => None, + label => Some(label.to_string()), + }, + total_bytes_15m: total_bytes_from_heatmap(&entity.heatmap), + median_rtt_ms: median_value(&entity.heatmap.rtt), + median_retransmit_pct: median_value(&entity.heatmap.retransmit), + }) + .collect::>(); + rows.sort_by(|left, right| right.total_bytes_15m.cmp(&left.total_bytes_15m)); + rows.truncate(EXECUTIVE_TOP_LIMIT); + rows +} + +fn build_leaderboard_rows( + entities: &[ExecutiveEntitySnapshot], + oversubscribed_sites: &[ExecutiveOversubscribedSiteRow], +) -> FxHashMap> { + let mut rows = FxHashMap::default(); + + let mut worst_sites = entities + .iter() + .filter(|entity| entity.entity_kind == ExecutiveEntityKind::Site) + .filter_map(|entity| { + let median_rtt_ms = median_value(&entity.heatmap.rtt)?; + let (avg_down_util, _) = average_with_count(&entity.heatmap.download); + let (avg_up_util, _) = average_with_count(&entity.heatmap.upload); + Some(ExecutiveLeaderboardRow::WorstSiteByRtt { + row_key: entity.row_key.clone(), + site_name: entity.label.clone(), + tree: entity.tree.clone(), + median_rtt_ms, + avg_down_util, + avg_up_util, + }) + }) + .collect::>(); + worst_sites.sort_by(|left, right| match (left, right) { + ( + ExecutiveLeaderboardRow::WorstSiteByRtt { median_rtt_ms: left, .. }, + ExecutiveLeaderboardRow::WorstSiteByRtt { + median_rtt_ms: right, + .. + }, + ) => right.total_cmp(left), + _ => std::cmp::Ordering::Equal, + }); + rows.insert(ExecutiveLeaderboardKind::WorstSitesByRtt, worst_sites); + + rows.insert( + ExecutiveLeaderboardKind::OversubscribedSites, + oversubscribed_sites + .iter() + .cloned() + .map(|row| ExecutiveLeaderboardRow::OversubscribedSite { + row_key: row.row_key, + site_name: row.site_name, + tree: row.tree, + ratio_down: row.ratio_down, + ratio_up: row.ratio_up, + ratio_max: row.ratio_max, + cap_down: row.cap_down, + cap_up: row.cap_up, + sub_down: row.sub_down, + sub_up: row.sub_up, + avg_down_util: row.avg_down_util, + avg_up_util: row.avg_up_util, + median_rtt_ms: row.median_rtt_ms, + }) + .collect(), + ); + + let mut sites_due_upgrade = entities + .iter() + .filter(|entity| entity.entity_kind == ExecutiveEntityKind::Site) + .filter_map(|entity| { + let (avg_down_util, down_count) = average_with_count(&entity.heatmap.download); + let (avg_up_util, up_count) = average_with_count(&entity.heatmap.upload); + let (Some(avg_down_util), Some(avg_up_util)) = (avg_down_util, avg_up_util) else { + return None; + }; + if down_count < MIN_SAMPLES_FOR_ALERTS + || up_count < MIN_SAMPLES_FOR_ALERTS + || avg_down_util < DUE_UPGRADE_THRESHOLD + || avg_up_util < DUE_UPGRADE_THRESHOLD + { + return None; + } + Some(ExecutiveLeaderboardRow::SiteDueUpgrade { + row_key: entity.row_key.clone(), + site_name: entity.label.clone(), + tree: entity.tree.clone(), + avg_down_util, + avg_up_util, + }) + }) + .collect::>(); + sites_due_upgrade.sort_by(|left, right| match (left, right) { + ( + ExecutiveLeaderboardRow::SiteDueUpgrade { + avg_down_util: left_down, + avg_up_util: left_up, + .. + }, + ExecutiveLeaderboardRow::SiteDueUpgrade { + avg_down_util: right_down, + avg_up_util: right_up, + .. + }, + ) => (right_down + right_up) + .partial_cmp(&(left_down + left_up)) + .unwrap_or(std::cmp::Ordering::Equal), + _ => std::cmp::Ordering::Equal, + }); + rows.insert(ExecutiveLeaderboardKind::SitesDueUpgrade, sites_due_upgrade); + + let mut circuits_due_upgrade = entities + .iter() + .filter(|entity| entity.entity_kind == ExecutiveEntityKind::Circuit) + .filter_map(|entity| { + let (avg_down_util, down_count) = average_with_count(&entity.heatmap.download); + let (avg_up_util, up_count) = average_with_count(&entity.heatmap.upload); + let (Some(avg_down_util), Some(avg_up_util)) = (avg_down_util, avg_up_util) else { + return None; + }; + if down_count < MIN_SAMPLES_FOR_ALERTS + || up_count < MIN_SAMPLES_FOR_ALERTS + || avg_down_util < DUE_UPGRADE_THRESHOLD + || avg_up_util < DUE_UPGRADE_THRESHOLD + { + return None; + } + Some(ExecutiveLeaderboardRow::CircuitDueUpgrade { + row_key: entity.row_key.clone(), + circuit_id: entity.circuit_id.clone().unwrap_or_default(), + circuit_name: entity.label.clone(), + avg_down_util, + avg_up_util, + }) + }) + .collect::>(); + circuits_due_upgrade.sort_by(|left, right| match (left, right) { + ( + ExecutiveLeaderboardRow::CircuitDueUpgrade { + avg_down_util: left_down, + avg_up_util: left_up, + .. + }, + ExecutiveLeaderboardRow::CircuitDueUpgrade { + avg_down_util: right_down, + avg_up_util: right_up, + .. + }, + ) => (right_down + right_up) + .partial_cmp(&(left_down + left_up)) + .unwrap_or(std::cmp::Ordering::Equal), + _ => std::cmp::Ordering::Equal, + }); + rows.insert( + ExecutiveLeaderboardKind::CircuitsDueUpgrade, + circuits_due_upgrade, + ); + + let mut top_asns = build_top_asn_rows(entities) + .into_iter() + .map(|row| ExecutiveLeaderboardRow::TopAsnByTraffic { + row_key: row.row_key, + asn: row.asn, + asn_name: row.asn_name, + total_bytes_15m: row.total_bytes_15m, + median_rtt_ms: row.median_rtt_ms, + median_retransmit_pct: row.median_retransmit_pct, + }) + .collect::>(); + top_asns.sort_by(|left, right| match (left, right) { + ( + ExecutiveLeaderboardRow::TopAsnByTraffic { + total_bytes_15m: left, + .. + }, + ExecutiveLeaderboardRow::TopAsnByTraffic { + total_bytes_15m: right, + .. + }, + ) => right.cmp(left), + _ => std::cmp::Ordering::Equal, + }); + rows.insert(ExecutiveLeaderboardKind::TopAsnsByTraffic, top_asns); + + rows +} + +fn heatmap_blocks_from_response(response: BusResponse) -> HeatmapBlocks { + match response { + BusResponse::GlobalHeatmap(blocks) => blocks, + _ => TemporalHeatmap::new().blocks(), + } +} + +fn header_from_response(response: BusResponse) -> ExecutiveSummaryHeader { + match response { + BusResponse::ExecutiveSummaryHeader(header) => header, + _ => ExecutiveSummaryHeader::default(), + } +} + +fn circuit_heatmap_rows() -> Vec { + match circuit_heatmaps() { + BusResponse::CircuitHeatmaps(rows) => rows, + _ => Vec::new(), + } +} + +fn site_heatmap_rows() -> Vec { + match site_heatmaps() { + BusResponse::SiteHeatmaps(rows) => rows, + _ => Vec::new(), + } +} + +fn asn_heatmap_rows() -> Vec { + match asn_heatmaps() { + BusResponse::AsnHeatmaps(rows) => rows, + _ => Vec::new(), + } +} + +fn node_path_names( + idx: usize, + nodes: &[lqos_config::NetworkJsonNode], +) -> Option> { + let node = nodes.get(idx)?; + if node.name == "Root" { + return Some(vec!["Root".to_string()]); + } + let parent_indexes = if node.parents.is_empty() { + vec![node.immediate_parent.unwrap_or(0)] + } else { + node.parents.clone() + }; + let mut names = Vec::new(); + for parent_idx in parent_indexes { + names.push(nodes.get(parent_idx)?.name.clone()); + } + if names.last() != Some(&node.name) { + names.push(node.name.clone()); + } + Some(names) +} + +fn node_locator_map() -> FxHashMap { + let reader = NETWORK_JSON.read(); + let nodes = reader.get_nodes_when_ready(); + let mut locators = FxHashMap::default(); + for (idx, node) in nodes.iter().enumerate() { + if node.name == "Root" || locators.contains_key(&node.name) { + continue; + } + locators.insert( + node.name.clone(), + ExecutiveTreeLocator { + node_id: node.id.clone(), + node_path: node_path_names(idx, nodes), + parent_index: Some(idx), + }, + ); + } + locators +} + +fn entity_snapshots( + circuits: Vec, + sites: Vec, + asns: Vec, +) -> Vec { + let locators = node_locator_map(); + let mut rows = Vec::new(); + for site in sites { + let row_key = locators + .get(&site.site_name) + .and_then(|locator| { + locator + .node_id + .as_ref() + .map(|node_id| format!("site:{node_id}")) + .or_else(|| { + locator + .node_path + .as_ref() + .map(|path| format!("site:{:?}", path)) + }) + }) + .unwrap_or_else(|| format!("site:{}", site.site_name)); + rows.push(ExecutiveEntitySnapshot { + row_key, + entity_kind: ExecutiveEntityKind::Site, + label: site.site_name.clone(), + circuit_id: None, + asn: None, + tree: locators.get(&site.site_name).cloned(), + heatmap: site.blocks, + qoq_blocks: site.qoq_blocks, + }); + } + for circuit in circuits { + let label = if circuit.circuit_name.trim().is_empty() { + circuit.circuit_id.clone() + } else { + circuit.circuit_name.clone() + }; + rows.push(ExecutiveEntitySnapshot { + row_key: format!("circuit:{}", circuit.circuit_id), + entity_kind: ExecutiveEntityKind::Circuit, + label, + circuit_id: Some(circuit.circuit_id), + asn: None, + tree: None, + heatmap: circuit.blocks, + qoq_blocks: circuit.qoq_blocks, + }); + } + for asn in asns { + rows.push(ExecutiveEntitySnapshot { + row_key: format!("asn:{}", asn.asn), + entity_kind: ExecutiveEntityKind::Asn, + label: asn + .asn_name + .clone() + .unwrap_or_else(|| format!("ASN {}", asn.asn)), + circuit_id: None, + asn: Some(asn.asn), + tree: None, + heatmap: asn.blocks, + qoq_blocks: None, + }); + } + rows +} + +fn build_oversubscribed_sites( + entities: &[ExecutiveEntitySnapshot], +) -> Vec { + let devices = SHAPED_DEVICES.load(); + let mut circuit_map: std::collections::HashMap = + std::collections::HashMap::new(); + for device in &devices.devices { + let entry = circuit_map + .entry(device.circuit_id.clone()) + .or_insert_with(|| (device.parent_node.clone(), 0.0_f32, 0.0_f32)); + entry.1 = entry.1.max(device.download_max_mbps); + entry.2 = entry.2.max(device.upload_max_mbps); + } + + let reader = NETWORK_JSON.read(); + let nodes = reader.get_nodes_when_ready(); + if nodes.is_empty() { + return Vec::new(); + } + + let mut children: Vec> = vec![Vec::new(); nodes.len()]; + for (idx, node) in nodes.iter().enumerate() { + if let Some(parent) = node.immediate_parent + && parent < children.len() + { + children[parent].push(idx); + } + } + + let entity_map = entities + .iter() + .filter(|entity| entity.entity_kind == ExecutiveEntityKind::Site) + .map(|entity| (entity.label.clone(), entity)) + .collect::>(); + + let mut results = Vec::new(); + for (idx, node) in nodes.iter().enumerate() { + if node.name == "Root" { + continue; + } + let node_type = node.node_type.as_deref().unwrap_or("").to_ascii_lowercase(); + if node_type != "site" && !node_type.is_empty() && node_type != "ap" { + continue; + } + + let mut stack = vec![idx]; + let mut visited = FxHashSet::default(); + let mut descendant_names = FxHashSet::default(); + while let Some(next_idx) = stack.pop() { + if !visited.insert(next_idx) { + continue; + } + if let Some(name) = nodes.get(next_idx).map(|entry| entry.name.clone()) { + descendant_names.insert(name); + } + if let Some(child_nodes) = children.get(next_idx) { + for child in child_nodes { + stack.push(*child); + } + } + } + + let cap_down = node.max_throughput.0 as f32; + let cap_up = node.max_throughput.1 as f32; + + let mut sub_down = 0.0_f32; + let mut sub_up = 0.0_f32; + for (parent_name, circuit_down, circuit_up) in circuit_map.values() { + if descendant_names.contains(parent_name) { + sub_down += *circuit_down; + sub_up += *circuit_up; + } + } + + let Some(stats) = entity_map.get(&node.name) else { + continue; + }; + let (avg_down_util, _) = average_with_count(&stats.heatmap.download); + let (avg_up_util, _) = average_with_count(&stats.heatmap.upload); + let median_rtt_ms = median_value(&stats.heatmap.rtt); + let ratio_down = (cap_down > 0.0).then_some(sub_down / cap_down); + let ratio_up = (cap_up > 0.0).then_some(sub_up / cap_up); + let ratio_max = match (ratio_down, ratio_up) { + (Some(down), Some(up)) => Some(down.max(up)), + (Some(down), None) => Some(down), + (None, Some(up)) => Some(up), + (None, None) => None, + }; + + results.push(ExecutiveOversubscribedSiteRow { + row_key: stats.row_key.clone(), + site_name: node.name.clone(), + tree: stats.tree.clone(), + ratio_down, + ratio_up, + ratio_max, + cap_down, + cap_up, + sub_down, + sub_up, + avg_down_util, + avg_up_util, + median_rtt_ms, + }); + } + + results.sort_by(|left, right| { + right + .ratio_max + .unwrap_or(0.0) + .total_cmp(&left.ratio_max.unwrap_or(0.0)) + }); + results.truncate(EXECUTIVE_OVERSUBSCRIBED_LIMIT); + results +} + +/// Invalidates the shared executive cache so the next read rebuilds immediately. +pub(crate) fn invalidate_executive_cache_snapshot() { + EXECUTIVE_CACHE_LAST_REFRESH_SECS.store(0, Ordering::Release); +} + +/// Rebuilds the once-per-second executive cache snapshot. +pub(crate) fn rebuild_executive_cache_snapshot() -> Arc { + let generated_at_unix_ms = current_unix_ms(); + let header = header_from_response(executive_summary_header()); + let global = heatmap_blocks_from_response(global_heatmap()); + let global_qoq = THROUGHPUT_TRACKER.global_qoq_heatmap.lock().blocks(); + let entities = entity_snapshots(circuit_heatmap_rows(), site_heatmap_rows(), asn_heatmap_rows()); + let oversubscribed_sites = build_oversubscribed_sites(&entities); + let top_download = top_metric_rows(&entities, ExecutiveMetric::Download, EXECUTIVE_TOP_LIMIT); + let top_upload = top_metric_rows(&entities, ExecutiveMetric::Upload, EXECUTIVE_TOP_LIMIT); + let top_retransmit = + top_metric_rows(&entities, ExecutiveMetric::Retransmit, EXECUTIVE_TOP_LIMIT); + let top_rtt = top_metric_rows(&entities, ExecutiveMetric::Rtt, EXECUTIVE_TOP_LIMIT); + let top_qoo = top_metric_rows(&entities, ExecutiveMetric::Qoo, EXECUTIVE_TOP_LIMIT); + let top_asns = build_top_asn_rows(&entities); + let leaderboard_rows = build_leaderboard_rows(&entities, &oversubscribed_sites); + + let snapshot = Arc::new(ExecutiveCacheSnapshot { + generated_at_unix_ms, + dashboard: ExecutiveDashboardSummary { + generated_at_unix_ms, + header, + global, + global_qoq, + top_download, + top_upload, + top_retransmit, + top_rtt, + top_qoo, + oversubscribed_sites, + top_asns, + }, + entities, + leaderboard_rows, + }); + EXECUTIVE_CACHE_SNAPSHOT.store(snapshot.clone()); + EXECUTIVE_CACHE_LAST_REFRESH_SECS.store(current_epoch_secs(), Ordering::Release); + snapshot +} + +/// Returns the current executive cache snapshot, rebuilding it if stale. +pub(crate) fn fresh_executive_cache_snapshot() -> Arc { + let now_secs = current_epoch_secs(); + if EXECUTIVE_CACHE_LAST_REFRESH_SECS.load(Ordering::Acquire) == now_secs { + return EXECUTIVE_CACHE_SNAPSHOT.load_full(); + } + let _guard = EXECUTIVE_CACHE_REFRESH_LOCK.lock(); + if EXECUTIVE_CACHE_LAST_REFRESH_SECS.load(Ordering::Acquire) == now_secs { + return EXECUTIVE_CACHE_SNAPSHOT.load_full(); + } + rebuild_executive_cache_snapshot() +} diff --git a/src/rust/lqosd/src/node_manager/local_api/network_tree.rs b/src/rust/lqosd/src/node_manager/local_api/network_tree.rs index 9c137687..bab10c27 100644 --- a/src/rust/lqosd/src/node_manager/local_api/network_tree.rs +++ b/src/rust/lqosd/src/node_manager/local_api/network_tree.rs @@ -1,6 +1,7 @@ use crate::shaped_devices_tracker::full_network_map_snapshot; use lqos_config::NetworkJsonTransport; +/// Returns the current full network tree snapshot for websocket/API consumers. pub fn network_tree_data() -> Vec<(usize, NetworkJsonTransport)> { full_network_map_snapshot() } diff --git a/src/rust/lqosd/src/node_manager/local_api/network_tree_lite.rs b/src/rust/lqosd/src/node_manager/local_api/network_tree_lite.rs new file mode 100644 index 00000000..ecaa9b50 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/network_tree_lite.rs @@ -0,0 +1,46 @@ +use crate::shaped_devices_tracker::full_network_map_lite_snapshot; +use serde::{Deserialize, Serialize}; + +/// Minimal live tree payload for pages that do not need the full `NetworkJsonTransport`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NetworkTreeLiteNode { + /// Display name for the node. + pub name: String, + /// Optional stable node identifier from `network.json` metadata. + #[serde(default)] + pub id: Option, + /// True if the node is virtual/logical-only. + #[serde(rename = "virtual", default)] + pub is_virtual: bool, + /// Configured maximum throughput in Mbps. + pub max_throughput: (f64, f64), + /// Current throughput in bytes per second. + pub current_throughput: (u64, u64), + /// Current TCP packets. + pub current_tcp_packets: (u64, u64), + /// Current TCP retransmits. + pub current_retransmits: (u64, u64), + /// Approximate current RTT medians for down/up, in milliseconds. + pub rtts: Vec, + /// QoO score for download/upload directions. + #[serde(default)] + pub qoo: (Option, Option), + /// Parent node indexes. + pub parents: Vec, + /// Immediate parent node index. + pub immediate_parent: Option, + /// Optional node type metadata from `network.json`. + #[serde(rename = "type")] + pub node_type: Option, + /// Optional geographic latitude. + #[serde(default)] + pub latitude: Option, + /// Optional geographic longitude. + #[serde(default)] + pub longitude: Option, +} + +/// Returns the current lightweight network tree snapshot for websocket/API consumers. +pub fn network_tree_lite_data() -> Vec<(usize, NetworkTreeLiteNode)> { + full_network_map_lite_snapshot() +} diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs new file mode 100644 index 00000000..1284e680 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs @@ -0,0 +1,124 @@ +use crate::shaped_devices_tracker::SHAPED_DEVICES; +use lqos_config::ShapedDevice; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::HashSet; + +const DEFAULT_SHAPED_DEVICES_PAGE_SIZE: usize = 24; +const MAX_SHAPED_DEVICES_PAGE_SIZE: usize = 250; + +fn deserialize_optional_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum MaybeString { + String(String), + Null, + } + + Ok(match Option::::deserialize(deserializer)? { + Some(MaybeString::String(value)) => Some(value), + Some(MaybeString::Null) | None => None, + }) +} + +/// Server-side paging and search query for the shaped-devices inventory page. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ShapedDevicesPageQuery { + pub page: Option, + pub page_size: Option, + #[serde(default, deserialize_with = "deserialize_optional_string")] + pub search: Option, +} + +/// A server-paged slice of shaped devices plus total result counts. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ShapedDevicesPage { + pub query: ShapedDevicesPageQuery, + pub total_rows: usize, + pub total_circuits: usize, + pub rows: Vec, +} + +fn normalized_page_size(query: &ShapedDevicesPageQuery) -> usize { + query + .page_size + .unwrap_or(DEFAULT_SHAPED_DEVICES_PAGE_SIZE) + .clamp(1, MAX_SHAPED_DEVICES_PAGE_SIZE) +} + +/// Returns one filtered, sorted page of shaped-device rows. +pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { + let page = query.page.unwrap_or(0); + let page_size = normalized_page_size(&query); + let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); + let devices = SHAPED_DEVICES.load(); + + let mut filtered: Vec = devices + .devices + .iter() + .filter(|device| { + if search.is_empty() { + return true; + } + device.device_name.to_lowercase().contains(&search) + || device.circuit_name.to_lowercase().contains(&search) + || device.parent_node.to_lowercase().contains(&search) + || device.circuit_id.to_lowercase().contains(&search) + || device.device_id.to_lowercase().contains(&search) + || device.mac.to_lowercase().contains(&search) + || device.comment.to_lowercase().contains(&search) + || device + .sqm_override + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&search) + || device + .ipv4 + .iter() + .any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search)) + || device + .ipv6 + .iter() + .any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search)) + }) + .cloned() + .collect(); + filtered.sort_by(|left, right| { + left.circuit_name + .cmp(&right.circuit_name) + .then_with(|| left.device_name.cmp(&right.device_name)) + .then_with(|| left.device_id.cmp(&right.device_id)) + }); + + let total_rows = filtered.len(); + let total_circuits = filtered + .iter() + .map(|device| device.circuit_id.clone()) + .collect::>() + .len(); + let start = page.saturating_mul(page_size); + let rows = if start >= total_rows { + Vec::new() + } else { + let end = (start + page_size).min(total_rows); + filtered[start..end].to_vec() + }; + + ShapedDevicesPage { + query: ShapedDevicesPageQuery { + page: Some(page), + page_size: Some(page_size), + search: if search.is_empty() { + None + } else { + query.search + }, + }, + total_rows, + total_circuits, + rows, + } +} diff --git a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs new file mode 100644 index 00000000..9cf33274 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs @@ -0,0 +1,213 @@ +use crate::shaped_devices_tracker::NETWORK_JSON; +use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot; +use lqos_utils::units::DownUpOrder; +use serde::{Deserialize, Deserializer, Serialize}; + +const DEFAULT_ATTACHED_CIRCUITS_PAGE_SIZE: usize = 100; +const MAX_ATTACHED_CIRCUITS_PAGE_SIZE: usize = 250; + +fn deserialize_optional_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum MaybeString { + String(String), + Null, + } + + Ok(match Option::::deserialize(deserializer)? { + Some(MaybeString::String(value)) => Some(value), + Some(MaybeString::Null) | None => None, + }) +} + +/// Sort options for the tree page's attached-circuits table. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum TreeAttachedCircuitsSort { + CircuitName, + LastSeen, + ThroughputDown, + ThroughputUp, +} + +/// Query for the selected node's attached-circuits table. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TreeAttachedCircuitsQuery { + #[serde(default, deserialize_with = "deserialize_optional_string")] + pub node_id: Option, + pub node_path: Option>, + pub page: Option, + pub page_size: Option, + #[serde(default, deserialize_with = "deserialize_optional_string")] + pub search: Option, + pub sort: Option, + pub descending: Option, +} + +/// One aggregated circuit row for the tree page's attached-circuits table. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TreeAttachedCircuitRow { + pub circuit_id: String, + pub circuit_name: String, + pub parent_node: String, + pub device_names: Vec, + pub ip_addrs: Vec, + pub plan_mbps: DownUpOrder, + pub bytes_per_second: DownUpOrder, + pub rtt_current_p50_nanos: DownUpOrder>, + pub qoo: DownUpOrder>, + pub tcp_packets: DownUpOrder, + pub tcp_retransmits: DownUpOrder, + pub last_seen_nanos: u64, +} + +/// One server-paged response for the selected node's attached circuits. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TreeAttachedCircuitsPage { + pub query: TreeAttachedCircuitsQuery, + pub total_rows: usize, + pub rows: Vec, +} + +fn normalized_page_size(query: &TreeAttachedCircuitsQuery) -> usize { + query + .page_size + .unwrap_or(DEFAULT_ATTACHED_CIRCUITS_PAGE_SIZE) + .clamp(1, MAX_ATTACHED_CIRCUITS_PAGE_SIZE) +} + +fn resolve_node_name(query: &TreeAttachedCircuitsQuery) -> Option { + let reader = NETWORK_JSON.read(); + let nodes = reader.get_nodes_when_ready(); + + if let Some(node_id) = query.node_id.as_deref() + && let Some(node) = nodes.iter().find(|node| node.id.as_deref() == Some(node_id)) + { + return Some(node.name.clone()); + } + + let node_path = query.node_path.as_ref()?; + if node_path.is_empty() { + return None; + } + nodes.iter().find_map(|node| { + let path = if node.name == "Root" { + vec!["Root".to_string()] + } else { + let parent_indexes = if node.parents.is_empty() { + vec![node.immediate_parent.unwrap_or(0)] + } else { + node.parents.clone() + }; + let mut names = Vec::new(); + for idx in parent_indexes { + let parent = nodes.get(idx)?; + names.push(parent.name.clone()); + } + if names.last() != Some(&node.name) { + names.push(node.name.clone()); + } + names + }; + if &path == node_path { + Some(node.name.clone()) + } else { + None + } + }) +} + +/// Returns one filtered, sorted page of circuits attached to the selected tree node. +pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedCircuitsPage { + let page = query.page.unwrap_or(0); + let page_size = normalized_page_size(&query); + let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); + let sort = query.sort.clone().unwrap_or(TreeAttachedCircuitsSort::CircuitName); + let descending = query.descending.unwrap_or(false); + let node_name = resolve_node_name(&query); + + let snapshot = fresh_circuit_live_snapshot(); + let mut rows: Vec = match node_name.as_deref() { + Some(node_name) => snapshot + .circuit_ids_by_parent_node + .get(node_name) + .into_iter() + .flat_map(|ids| ids.iter()) + .filter_map(|id| snapshot.by_circuit_id.get(id)) + .map(|row| TreeAttachedCircuitRow { + circuit_id: row.circuit_id.clone(), + circuit_name: row.circuit_name.clone(), + parent_node: row.parent_node.clone(), + device_names: row.device_names.clone(), + ip_addrs: row.ip_addrs.clone(), + plan_mbps: row.plan_mbps, + bytes_per_second: row.bytes_per_second, + rtt_current_p50_nanos: row.rtt_current_p50_nanos, + qoo: row.qoo, + tcp_packets: row.tcp_packets, + tcp_retransmits: row.tcp_retransmits, + last_seen_nanos: row.last_seen_nanos, + }) + .collect(), + None => Vec::new(), + }; + + if !search.is_empty() { + rows.retain(|row| { + row.circuit_name.to_lowercase().contains(&search) + || row.circuit_id.to_lowercase().contains(&search) + || row.parent_node.to_lowercase().contains(&search) + || row.device_names.iter().any(|name| name.to_lowercase().contains(&search)) + || row.ip_addrs.iter().any(|ip| ip.to_lowercase().contains(&search)) + }); + } + + rows.sort_by(|left, right| match sort { + TreeAttachedCircuitsSort::CircuitName => left + .circuit_name + .cmp(&right.circuit_name) + .then_with(|| left.circuit_id.cmp(&right.circuit_id)), + TreeAttachedCircuitsSort::LastSeen => left + .last_seen_nanos + .cmp(&right.last_seen_nanos) + .then_with(|| left.circuit_name.cmp(&right.circuit_name)), + TreeAttachedCircuitsSort::ThroughputDown => left + .bytes_per_second + .down + .cmp(&right.bytes_per_second.down) + .then_with(|| left.circuit_name.cmp(&right.circuit_name)), + TreeAttachedCircuitsSort::ThroughputUp => left + .bytes_per_second + .up + .cmp(&right.bytes_per_second.up) + .then_with(|| left.circuit_name.cmp(&right.circuit_name)), + }); + if descending { + rows.reverse(); + } + + let total_rows = rows.len(); + let start = page.saturating_mul(page_size); + let rows = if start >= total_rows { + Vec::new() + } else { + let end = (start + page_size).min(total_rows); + rows[start..end].to_vec() + }; + + TreeAttachedCircuitsPage { + query: TreeAttachedCircuitsQuery { + node_id: query.node_id, + node_path: query.node_path, + page: Some(page), + page_size: Some(page_size), + search: if search.is_empty() { None } else { query.search }, + sort: Some(sort), + descending: Some(descending), + }, + total_rows, + rows, + } +} diff --git a/src/rust/lqosd/src/node_manager/static2/circuit.html b/src/rust/lqosd/src/node_manager/static2/circuit.html index b049b49d..4571ca5d 100644 --- a/src/rust/lqosd/src/node_manager/static2/circuit.html +++ b/src/rust/lqosd/src/node_manager/static2/circuit.html @@ -221,8 +221,19 @@
-
-
+
+
+
+
Queue Tree
+

Ancestor queue path from this circuit toward root, with live queue telemetry for each upstream node.

+
+
+ Ancestor path + Live updates + Throughput / Retransmits / Latency +
+
+
Loading queue tree… @@ -231,28 +242,65 @@
-
-
-
+
+
+
+
Queue Stats
+

Raw 1s scatter samples over the last 3 minutes. Hover any queue chart to inspect the same timestamp across all six charts.

+
+
+ Last 3 minutes + 1s samples + Synchronized hover +
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Queue Memory: ? -
Queue Type: ?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Queue Memory
+
?
+
+
+
Queue Type
+
?
+
+
+
Interpretation
+
Download plots above zero, upload plots below zero. Tooltips always show absolute values with direction labels.
+
+
+
diff --git a/src/rust/lqosd/src/node_manager/static2/config_devices.html b/src/rust/lqosd/src/node_manager/static2/config_devices.html index 75b70c58..9bf2bf03 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_devices.html +++ b/src/rust/lqosd/src/node_manager/static2/config_devices.html @@ -1,26 +1,25 @@
- +
-
+
Shaped Devices
Loading devices...
+
Adds, edits, and deletes save immediately.
diff --git a/src/rust/lqosd/src/node_manager/static2/config_network.html b/src/rust/lqosd/src/node_manager/static2/config_network.html index 9ade3ff3..09f32421 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_network.html +++ b/src/rust/lqosd/src/node_manager/static2/config_network.html @@ -1,9 +1,7 @@
- +
diff --git a/src/rust/lqosd/src/node_manager/static2/configuration.html b/src/rust/lqosd/src/node_manager/static2/configuration.html index 27aa7b7c..7481d7f2 100644 --- a/src/rust/lqosd/src/node_manager/static2/configuration.html +++ b/src/rust/lqosd/src/node_manager/static2/configuration.html @@ -12,6 +12,7 @@
+ @@ -587,6 +588,7 @@

Network.Json - Network Layout

+

Network Adjustment Options

@@ -613,9 +615,9 @@

Shaped Devices

-

All Shaped Devices. Note that if you have an integration enabled, this will be overwritten - periodically.

- diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 29662e5d..e6f4ebbf 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -619,6 +619,246 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { .lqos-circuit-activity-kpi-value.is-active { color: #3fd279; } +.lqos-cake-panel { + margin-top: 1rem; + border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent); + border-radius: var(--lqos-radius-md); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--lqos-surface-2) 88%, transparent) 0%, + var(--lqos-surface-solid) 100% + ); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 12px 24px rgba(6, 12, 20, 0.12); + padding: 0.95rem; +} +.lqos-cake-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.9rem; +} +.lqos-cake-header-copy { + min-width: 0; +} +.lqos-cake-header-copy h5 { + margin-bottom: 0.25rem; +} +.lqos-cake-meta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.4rem; +} +.lqos-cake-meta-pill { + display: inline-flex; + align-items: center; + padding: 0.34rem 0.72rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 84%, transparent); + background: color-mix(in srgb, var(--lqos-surface-2) 72%, transparent); + color: var(--bs-secondary-color); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} +.lqos-cake-chart-panel { + height: 100%; + border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent); + border-radius: var(--lqos-radius-sm); + background: color-mix(in srgb, var(--lqos-surface-2) 66%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 0.35rem; +} +.lqos-cake-chart-panel.is-primary { + background: color-mix(in srgb, var(--lqos-surface-2) 78%, transparent); +} +.lqos-cake-info-card { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent); + border-radius: var(--lqos-radius-sm); + background: color-mix(in srgb, var(--lqos-surface-2) 62%, transparent); + padding: 0.8rem 0.9rem; +} +.lqos-cake-info-label { + color: var(--bs-secondary-color); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + margin-bottom: 0.28rem; +} +.lqos-cake-info-value { + color: var(--bs-emphasis-color); + font-size: 0.96rem; + font-weight: 600; + line-height: 1.35; +} +.lqos-funnel-panel { + margin-top: 1rem; + border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent); + border-radius: var(--lqos-radius-md); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--lqos-surface-2) 88%, transparent) 0%, + var(--lqos-surface-solid) 100% + ); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 12px 24px rgba(6, 12, 20, 0.12); + padding: 0.95rem; +} +.lqos-funnel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.9rem; +} +.lqos-funnel-header-copy { + min-width: 0; +} +.lqos-funnel-header-copy h5 { + margin-bottom: 0.25rem; +} +.lqos-funnel-meta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.4rem; +} +.lqos-funnel-meta-pill, +.lqos-funnel-path-pill, +.lqos-funnel-node-step { + display: inline-flex; + align-items: center; + padding: 0.34rem 0.72rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 84%, transparent); + background: color-mix(in srgb, var(--lqos-surface-2) 72%, transparent); + color: var(--bs-secondary-color); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} +.lqos-funnel-stack { + display: grid; + gap: 0.95rem; +} +.lqos-funnel-path-card, +.lqos-funnel-node-card, +.lqos-funnel-empty-state { + border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent); + border-radius: var(--lqos-radius-sm); + background: color-mix(in srgb, var(--lqos-surface-2) 64%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} +.lqos-funnel-path-card { + padding: 0.9rem; +} +.lqos-funnel-path-top, +.lqos-funnel-node-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.85rem; +} +.lqos-funnel-path-title { + color: var(--bs-emphasis-color); + font-size: 0.95rem; + font-weight: 700; +} +.lqos-funnel-path-meta, +.lqos-funnel-node-badges { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.45rem; +} +.lqos-funnel-path-chain { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.45rem; + margin: 0.72rem 0 0.55rem; +} +.lqos-funnel-path-node { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.34rem 0.78rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 82%, transparent); + background: color-mix(in srgb, var(--lqos-surface) 86%, transparent); + color: var(--bs-emphasis-color); + font-size: 0.84rem; + font-weight: 600; +} +.lqos-funnel-path-node.is-origin { + background: color-mix(in srgb, #198754 18%, var(--lqos-surface) 82%); + border-color: color-mix(in srgb, #198754 34%, var(--lqos-border)); +} +.lqos-funnel-path-separator { + color: var(--bs-secondary-color); + font-size: 0.92rem; +} +.lqos-funnel-empty-state { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 1rem 1.05rem; + color: var(--bs-secondary-color); +} +.lqos-funnel-empty-state i { + color: color-mix(in srgb, var(--bs-warning) 70%, var(--bs-secondary-color)); +} +.lqos-funnel-node-card { + padding: 0.9rem; +} +.lqos-funnel-node-title h5 { + display: flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.25rem; +} +.lqos-funnel-node-title h5 .fa { + flex: 0 0 auto; +} +.lqos-funnel-node-badges .badge { + padding: 0.42rem 0.7rem; + font-weight: 600; +} +.lqos-funnel-node-type { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.72rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 82%, transparent); + background: color-mix(in srgb, var(--lqos-surface) 84%, transparent); + color: var(--bs-emphasis-color); + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} +.lqos-funnel-node-type.is-virtual { + background: color-mix(in srgb, #6c757d 22%, var(--lqos-surface) 80%); + border-color: color-mix(in srgb, #6c757d 40%, var(--lqos-border)); +} +.lqos-funnel-chart-panel { + height: 100%; + border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent); + border-radius: var(--lqos-radius-sm); + background: color-mix(in srgb, var(--lqos-surface-2) 68%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 0.35rem; +} @media (max-width: 991.98px) { .lqos-circuit-activity-header { flex-direction: column; @@ -633,6 +873,25 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { .lqos-circuit-activity-chart { height: 296px; } + .lqos-cake-header { + flex-direction: column; + } + .lqos-cake-meta { + justify-content: flex-start; + } + .lqos-cake-info-card { + grid-template-columns: 1fr; + } + .lqos-funnel-header, + .lqos-funnel-path-top, + .lqos-funnel-node-header { + flex-direction: column; + } + .lqos-funnel-meta, + .lqos-funnel-path-meta, + .lqos-funnel-node-badges { + justify-content: flex-start; + } } @media (max-width: 575.98px) { .lqos-circuit-activity-panel { @@ -648,6 +907,14 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { .lqos-circuit-activity-kpi-value { font-size: 1.05rem; } + .lqos-cake-panel { + padding: 0.8rem; + } + .lqos-funnel-panel, + .lqos-funnel-node-card, + .lqos-funnel-path-card { + padding: 0.8rem; + } } .tiny { font-size: 0.72rem; diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 1c7f48a3..7ef4ca56 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -10,9 +10,10 @@ use std::sync::Arc; use crate::node_manager::auth::{LoginResult, login_from_token}; use crate::node_manager::local_api::{ - circuit, circuit_count, config, cpu_affinity, dashboard_themes, device_counts, flow_explorer, - flow_map, lts, network_tree, node_rate_overrides, packet_analysis, reload_libreqos, scheduler, - search, shaped_device_api, unknown_ips, urgent, warnings, + circuit, circuit_count, config, cpu_affinity, dashboard_themes, device_counts, directories, + executive, flow_explorer, flow_map, lts, network_tree, node_rate_overrides, packet_analysis, + network_tree_lite, reload_libreqos, scheduler, search, shaped_device_api, + shaped_devices_page, unknown_ips, urgent, warnings, }; use crate::node_manager::shaper_queries_actor::ShaperQueryCommand; use crate::node_manager::ws::messages::{ @@ -355,6 +356,30 @@ async fn receive_channel_message( return true; } } + WsRequest::ShapedDevicesPage { query } => { + let response = WsResponse::ShapedDevicesPage { + data: shaped_devices_page::shaped_devices_page(query), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + WsRequest::ExecutiveHeatmapPage { query } => { + let response = WsResponse::ExecutiveHeatmapPage { + data: executive::executive_heatmap_page(query), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + WsRequest::ExecutiveLeaderboardPage { query } => { + let response = WsResponse::ExecutiveLeaderboardPage { + data: executive::executive_leaderboard_page(query), + }; + if send_ws_response(&tx, response).await { + return true; + } + } WsRequest::NetworkTree => { let response = WsResponse::NetworkTree { data: network_tree::network_tree_data(), @@ -363,6 +388,14 @@ async fn receive_channel_message( return true; } } + WsRequest::NetworkTreeLite => { + let response = WsResponse::NetworkTreeLite { + data: network_tree_lite::network_tree_lite_data(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } WsRequest::FlowMap => { let response = WsResponse::FlowMap { data: flow_map::flow_map_data(), @@ -1351,6 +1384,17 @@ async fn receive_channel_message( return true; } } + WsRequest::UpdateNetworkJsonOnly { network_json } => { + let result = config::update_network_json_only_data(*request_state.login, network_json); + let (ok, message) = match result { + Ok(()) => (true, "Ok".to_string()), + Err(message) => (false, message), + }; + let response = WsResponse::UpdateNetworkJsonOnlyResult { ok, message }; + if send_ws_response(&tx, response).await { + return true; + } + } WsRequest::UpdateNetworkAndDevices { network_json, shaped_devices, @@ -1362,8 +1406,7 @@ async fn receive_channel_message( ); let (ok, message) = match result { Ok(()) => (true, "Ok".to_string()), - Err(StatusCode::FORBIDDEN) => (false, "Unauthorized".to_string()), - Err(_) => (false, "Error".to_string()), + Err(message) => (false, message), }; let response = WsResponse::UpdateNetworkAndDevicesResult { ok, message }; if send_ws_response(&tx, response).await { @@ -1550,6 +1593,145 @@ async fn receive_channel_message( return true; } } + WsRequest::GetShapedDevice { device_id } => match config::get_shaped_device_data( + *request_state.login, + device_id, + ) { + Ok(device) => { + let response = WsResponse::GetShapedDeviceResult { + ok: device.is_some(), + message: if device.is_some() { + "Ok".to_string() + } else { + "Not found".to_string() + }, + device, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(StatusCode::FORBIDDEN) => { + let response = WsResponse::GetShapedDeviceResult { + ok: false, + message: "Unauthorized".to_string(), + device: None, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(_) => { + let response = WsResponse::GetShapedDeviceResult { + ok: false, + message: "Error".to_string(), + device: None, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + }, + WsRequest::CreateShapedDevice { device } => match config::create_shaped_device_data( + *request_state.login, + device, + ) { + Ok(device) => { + let response = WsResponse::CreateShapedDeviceResult { + ok: true, + message: "Ok".to_string(), + device: Some(device), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(message) => { + let response = WsResponse::CreateShapedDeviceResult { + ok: false, + message, + device: None, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + }, + WsRequest::UpdateShapedDevice { + original_device_id, + device, + } => match config::update_shaped_device_data(*request_state.login, original_device_id, device) + { + Ok(device) => { + let response = WsResponse::UpdateShapedDeviceResult { + ok: true, + message: "Ok".to_string(), + device: Some(device), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(message) => { + let response = WsResponse::UpdateShapedDeviceResult { + ok: false, + message, + device: None, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + }, + WsRequest::DeleteShapedDevice { device_id } => { + let device_id_clone = device_id.clone(); + match config::delete_shaped_device_data(*request_state.login, device_id) { + Ok(()) => { + let response = WsResponse::DeleteShapedDeviceResult { + ok: true, + message: "Ok".to_string(), + device_id: device_id_clone, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(message) => { + let response = WsResponse::DeleteShapedDeviceResult { + ok: false, + message, + device_id: device_id_clone, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + } + } + WsRequest::CircuitDirectoryPage { query } => { + let response = WsResponse::CircuitDirectoryPage { + data: directories::circuit_directory_page(query), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + WsRequest::NodeDirectory => { + let response = WsResponse::NodeDirectory { + data: directories::node_directory_data(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + WsRequest::TreeGuardMetadataSummary => { + let response = WsResponse::TreeGuardMetadataSummary { + data: directories::treeguard_metadata_summary(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } WsRequest::GetUsers => match config::get_users_data(*request_state.login) { Ok(data) => { let response = WsResponse::GetUsers { data }; diff --git a/src/rust/lqosd/src/node_manager/ws/messages.rs b/src/rust/lqosd/src/node_manager/ws/messages.rs index f96571ff..951e5674 100644 --- a/src/rust/lqosd/src/node_manager/ws/messages.rs +++ b/src/rust/lqosd/src/node_manager/ws/messages.rs @@ -2,15 +2,26 @@ use crate::lts2_sys::control_channel::{SupportTicket, SupportTicketSummary}; use crate::node_manager::WarningLevel; use crate::node_manager::local_api::dashboard_themes::{DashletIdentity, ThemeEntry}; use crate::node_manager::local_api::device_counts::DeviceCount; +use crate::node_manager::local_api::directories::{ + CircuitDirectoryPage, CircuitDirectoryQuery, NodeDirectoryEntry, TreeGuardMetadataSummary, +}; +use crate::node_manager::local_api::executive::{ + ExecutiveDashboardSummary, ExecutiveHeatmapPage, ExecutiveHeatmapPageQuery, + ExecutiveLeaderboardPage, ExecutiveLeaderboardPageQuery, +}; use crate::node_manager::local_api::node_rate_overrides::{ NodeRateOverrideData, NodeRateOverrideQuery, NodeRateOverrideUpdate, }; +use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode; use crate::node_manager::local_api::packet_analysis::RequestAnalysisResult; use crate::node_manager::local_api::scheduler::{SchedulerDetails, SchedulerStatus}; use crate::node_manager::local_api::search::SearchResult; +use crate::node_manager::local_api::shaped_devices_page::{ShapedDevicesPage, ShapedDevicesPageQuery}; +use crate::node_manager::local_api::tree_attached_circuits::{TreeAttachedCircuitsPage, TreeAttachedCircuitsQuery}; use crate::node_manager::local_api::unknown_ips::{ClearUnknownIpsResponse, UnknownIp}; use crate::node_manager::local_api::urgent::{UrgentList, UrgentStatus}; use crate::node_manager::local_api::{ + circuit_live::{CircuitLiveMetrics, CircuitMetricsQuery}, circuit_count::CircuitCount, cpu_affinity::{ CircuitBrief, CpuAffinityCircuitsPage, CpuAffinitySiteTreeNode, CpuAffinitySummaryEntry, @@ -31,13 +42,10 @@ use crate::throughput_tracker::flow_data::{ }; use crate::throughput_tracker::flow_data::{FlowAnalysis, FlowbeeLocalData}; use lqos_bus::{ - AsnHeatmapData, Circuit, CircuitHeatmapData, ExecutiveSummaryHeader, FlowbeeSummaryData, - QueueStoreTransit, SiteHeatmapData, StormguardDebugEntry, + Circuit, FlowbeeSummaryData, QueueStoreTransit, StormguardDebugEntry, }; use lqos_config::QooProfileInfo; use lqos_config::{Config, NetworkJsonTransport, ShapedDevice, WebUser}; -use lqos_utils::qoq_heatmap::QoqHeatmapBlocks; -use lqos_utils::temporal_heatmap::HeatmapBlocks; use lqos_utils::units::DownUpOrder; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -65,6 +73,10 @@ pub enum PrivateRequest { CakeWatcher { circuit: String }, Chatbot { browser_ts_ms: Option }, ChatbotUserInput { text: String }, + WatchTreeAttachedCircuits { query: TreeAttachedCircuitsQuery }, + StopTreeAttachedCircuitsWatch, + WatchCircuitMetrics { query: CircuitMetricsQuery }, + StopCircuitMetricsWatch, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -93,7 +105,17 @@ pub enum WsRequest { SchedulerDetails, DeviceCount, DevicesAll, + ShapedDevicesPage { + query: ShapedDevicesPageQuery, + }, + ExecutiveHeatmapPage { + query: ExecutiveHeatmapPageQuery, + }, + ExecutiveLeaderboardPage { + query: ExecutiveLeaderboardPageQuery, + }, NetworkTree, + NetworkTreeLite, FlowMap, GlobalWarnings, Search { @@ -143,6 +165,9 @@ pub enum WsRequest { UpdateConfig { config: Config, }, + UpdateNetworkJsonOnly { + network_json: Value, + }, UpdateNetworkAndDevices { network_json: Value, shaped_devices: Vec, @@ -159,6 +184,24 @@ pub enum WsRequest { ListNics, NetworkJson, AllShapedDevices, + GetShapedDevice { + device_id: String, + }, + CreateShapedDevice { + device: ShapedDevice, + }, + UpdateShapedDevice { + original_device_id: String, + device: ShapedDevice, + }, + DeleteShapedDevice { + device_id: String, + }, + CircuitDirectoryPage { + query: CircuitDirectoryQuery, + }, + NodeDirectory, + TreeGuardMetadataSummary, GetUsers, AddUser { username: String, @@ -334,29 +377,6 @@ pub struct NodeCapacity { pub median_rtt: f32, } -#[derive(Debug, Serialize)] -pub struct OversubscribedSite { - pub site_name: String, - pub cap_down: f32, - pub cap_up: f32, - pub sub_down: f32, - pub sub_up: f32, - pub ratio_down: Option, - pub ratio_up: Option, - pub ratio_max: Option, -} - -#[derive(Debug, Serialize)] -pub struct ExecutiveHeatmapsData { - pub header: ExecutiveSummaryHeader, - pub global: HeatmapBlocks, - pub global_qoq: QoqHeatmapBlocks, - pub circuits: Vec, - pub sites: Vec, - pub asns: Vec, - pub oversubscribed_sites: Vec, -} - #[derive(Debug, Serialize)] pub struct FlowbeeKeyTransit { pub remote_ip: String, @@ -463,10 +483,43 @@ pub enum WsResponse { ok: bool, message: String, }, + UpdateNetworkJsonOnlyResult { + ok: bool, + message: String, + }, UpdateNetworkAndDevicesResult { ok: bool, message: String, }, + GetShapedDeviceResult { + ok: bool, + message: String, + device: Option, + }, + CreateShapedDeviceResult { + ok: bool, + message: String, + device: Option, + }, + UpdateShapedDeviceResult { + ok: bool, + message: String, + device: Option, + }, + DeleteShapedDeviceResult { + ok: bool, + message: String, + device_id: String, + }, + CircuitDirectoryPage { + data: CircuitDirectoryPage, + }, + NodeDirectory { + data: Vec, + }, + TreeGuardMetadataSummary { + data: TreeGuardMetadataSummary, + }, GetNodeRateOverride { data: NodeRateOverrideData, }, @@ -554,6 +607,18 @@ pub enum WsResponse { DevicesAll { data: Vec, }, + ShapedDevicesPage { + data: ShapedDevicesPage, + }, + ExecutiveDashboardSummary { + data: ExecutiveDashboardSummary, + }, + ExecutiveHeatmapPage { + data: ExecutiveHeatmapPage, + }, + ExecutiveLeaderboardPage { + data: ExecutiveLeaderboardPage, + }, FlowMap { data: Vec<(f64, f64, String, u64, f32)>, }, @@ -667,9 +732,24 @@ pub enum WsResponse { NetworkTree { data: Vec<(usize, NetworkJsonTransport)>, }, + NetworkTreeLite { + data: Vec<(usize, NetworkTreeLiteNode)>, + }, NetworkTreeClients { data: Vec, }, + TreeAttachedCircuitsSnapshot { + data: TreeAttachedCircuitsPage, + }, + TreeAttachedCircuitsUpdate { + data: TreeAttachedCircuitsPage, + }, + CircuitMetricsSnapshot { + data: Vec, + }, + CircuitMetricsUpdate { + data: Vec, + }, QueueStatsTotal { marks: DownUpOrder, drops: DownUpOrder, @@ -704,9 +784,6 @@ pub enum WsResponse { TreeGuardActivity { data: Vec, }, - ExecutiveHeatmaps { - data: ExecutiveHeatmapsData, - }, CircuitWatcher { circuit_id: String, devices: Vec, diff --git a/src/rust/lqosd/src/node_manager/ws/published_channels.rs b/src/rust/lqosd/src/node_manager/ws/published_channels.rs index 648d983e..b84fd9a9 100644 --- a/src/rust/lqosd/src/node_manager/ws/published_channels.rs +++ b/src/rust/lqosd/src/node_manager/ws/published_channels.rs @@ -39,6 +39,7 @@ pub enum PublishedChannels { TreeSummaryL2, QueueStatsTotal, NetworkTree, + NetworkTreeLite, NetworkTreeClients, CircuitCapacity, TreeCapacity, @@ -50,5 +51,5 @@ pub enum PublishedChannels { EndpointLatLon, AsnTopDownload, AsnTopUpload, - ExecutiveHeatmaps, + ExecutiveDashboardSummary, } diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs index 2401477a..c744cc6e 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs @@ -1,14 +1,18 @@ mod cake_watcher; mod chatbot; +mod circuit_metrics; mod circuit; mod flows_by_circuit; mod ping_monitor; +mod tree_attached_circuits; use crate::node_manager::ws::messages::{PrivateRequest, WsResponse, encode_ws_message}; +use crate::node_manager::ws::single_user_channels::circuit_metrics::watch_circuit_metrics; use crate::node_manager::ws::single_user_channels::cake_watcher::cake_watcher; use crate::node_manager::ws::single_user_channels::circuit::circuit_watcher; use crate::node_manager::ws::single_user_channels::flows_by_circuit::flows_by_circuit; use crate::node_manager::ws::single_user_channels::ping_monitor::ping_monitor; +use crate::node_manager::ws::single_user_channels::tree_attached_circuits::watch_tree_attached_circuits; use tokio::spawn; use tokio::sync::mpsc::Sender; use tracing::info; @@ -22,6 +26,8 @@ pub struct PrivateState { control_tx: tokio::sync::mpsc::Sender, browser_language: Option, chatbot_request: Option, + tree_attached_circuits_watch: Option>, + circuit_metrics_watch: Option>, } impl PrivateState { @@ -42,6 +48,8 @@ impl PrivateState { control_tx, browser_language, chatbot_request: None, + tree_attached_circuits_watch: None, + circuit_metrics_watch: None, } } @@ -76,6 +84,49 @@ impl PrivateState { PrivateRequest::ChatbotUserInput { text } => { self.forward_chatbot_input(text).await; } + PrivateRequest::WatchTreeAttachedCircuits { query } => { + self.replace_tree_attached_circuits_watch(query); + } + PrivateRequest::StopTreeAttachedCircuitsWatch => { + self.abort_tree_attached_circuits_watch(); + } + PrivateRequest::WatchCircuitMetrics { query } => { + self.replace_circuit_metrics_watch(query); + } + PrivateRequest::StopCircuitMetricsWatch => { + self.abort_circuit_metrics_watch(); + } + } + } + + fn replace_tree_attached_circuits_watch( + &mut self, + query: crate::node_manager::local_api::tree_attached_circuits::TreeAttachedCircuitsQuery, + ) { + self.abort_tree_attached_circuits_watch(); + self.tree_attached_circuits_watch = Some(spawn(watch_tree_attached_circuits( + query, + self.tx.clone(), + ))); + } + + fn abort_tree_attached_circuits_watch(&mut self) { + if let Some(handle) = self.tree_attached_circuits_watch.take() { + handle.abort(); + } + } + + fn replace_circuit_metrics_watch( + &mut self, + query: crate::node_manager::local_api::circuit_live::CircuitMetricsQuery, + ) { + self.abort_circuit_metrics_watch(); + self.circuit_metrics_watch = Some(spawn(watch_circuit_metrics(query, self.tx.clone()))); + } + + fn abort_circuit_metrics_watch(&mut self) { + if let Some(handle) = self.circuit_metrics_watch.take() { + handle.abort(); } } @@ -136,6 +187,13 @@ impl PrivateState { } } +impl Drop for PrivateState { + fn drop(&mut self) { + self.abort_tree_attached_circuits_watch(); + self.abort_circuit_metrics_watch(); + } +} + // JS CBOR encoder emits float64 for timestamps beyond 32-bit ranges; normalize to i64. fn normalize_browser_ts_ms(browser_ts_ms: Option) -> Option { let ts_ms = browser_ts_ms?; diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit_metrics.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit_metrics.rs new file mode 100644 index 00000000..d03dcbd2 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit_metrics.rs @@ -0,0 +1,47 @@ +use crate::node_manager::local_api::circuit_live::{ + CircuitLiveMetrics, CircuitMetricsQuery, circuit_live_metrics, +}; +use crate::node_manager::ws::messages::{WsResponse, encode_ws_message}; +use std::time::Duration; +use tokio::time::MissedTickBehavior; +use tracing::debug; + +pub(super) async fn watch_circuit_metrics( + query: CircuitMetricsQuery, + tx: tokio::sync::mpsc::Sender>>, +) { + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut last_metrics: Option> = None; + + loop { + let metrics = circuit_live_metrics(&query); + let changed = last_metrics.as_ref() != Some(&metrics); + let response = if last_metrics.is_none() { + Some(WsResponse::CircuitMetricsSnapshot { + data: metrics.clone(), + }) + } else if changed { + Some(WsResponse::CircuitMetricsUpdate { + data: metrics.clone(), + }) + } else { + None + }; + last_metrics = Some(metrics); + + if let Some(response) = response { + match encode_ws_message(&response) { + Ok(payload) => { + if tx.send(payload).await.is_err() { + debug!("CircuitMetrics watcher channel closed"); + break; + } + } + Err(_) => break, + } + } + + ticker.tick().await; + } +} diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels/tree_attached_circuits.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels/tree_attached_circuits.rs new file mode 100644 index 00000000..ee7e791f --- /dev/null +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels/tree_attached_circuits.rs @@ -0,0 +1,43 @@ +use crate::node_manager::local_api::tree_attached_circuits::{ + TreeAttachedCircuitsPage, TreeAttachedCircuitsQuery, tree_attached_circuits, +}; +use crate::node_manager::ws::messages::{WsResponse, encode_ws_message}; +use std::time::Duration; +use tokio::time::MissedTickBehavior; +use tracing::debug; + +pub(super) async fn watch_tree_attached_circuits( + query: TreeAttachedCircuitsQuery, + tx: tokio::sync::mpsc::Sender>>, +) { + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut last_page: Option = None; + + loop { + let page = tree_attached_circuits(query.clone()); + let changed = last_page.as_ref() != Some(&page); + let response = if last_page.is_none() { + Some(WsResponse::TreeAttachedCircuitsSnapshot { data: page.clone() }) + } else if changed { + Some(WsResponse::TreeAttachedCircuitsUpdate { data: page.clone() }) + } else { + None + }; + last_page = Some(page); + + if let Some(response) = response { + match encode_ws_message(&response) { + Ok(payload) => { + if tx.send(payload).await.is_err() { + debug!("TreeAttachedCircuits watcher channel closed"); + break; + } + } + Err(_) => break, + } + } + + ticker.tick().await; + } +} diff --git a/src/rust/lqosd/src/node_manager/ws/ticker.rs b/src/rust/lqosd/src/node_manager/ws/ticker.rs index 1bedb10e..d7f6323d 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker.rs @@ -13,11 +13,12 @@ mod bakery; mod cadence; mod circuit_capacity; mod endpoint_latlon; -mod executive_heatmaps; +mod executive_dashboard_summary; mod flow_counter; mod flow_endpoints; pub(crate) mod ipstats_conversion; mod network_tree; +mod network_tree_lite; mod queue_stats_total; mod retransmits; mod rtt_histogram; @@ -165,6 +166,10 @@ async fn one_second_cadence( "network_tree", network_tree::network_tree(channels.clone(), bus_tx.clone()) ), + ticker_with_timeout( + "network_tree_lite", + network_tree_lite::network_tree_lite(channels.clone()) + ), ticker_with_timeout( "circuit_capacity", circuit_capacity::circuit_capacity(channels.clone()) @@ -202,8 +207,8 @@ async fn one_second_cadence( treeguard::treeguard_activity(channels.clone()) ), ticker_with_timeout( - "executive_heatmaps", - executive_heatmaps::executive_heatmaps(channels.clone(), bus_tx.clone()) + "executive_dashboard_summary", + executive_dashboard_summary::executive_dashboard_summary(channels.clone()) ), ); diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/executive_dashboard_summary.rs b/src/rust/lqosd/src/node_manager/ws/ticker/executive_dashboard_summary.rs new file mode 100644 index 00000000..6a4b04e4 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/ws/ticker/executive_dashboard_summary.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; + +use crate::node_manager::local_api::executive; +use crate::node_manager::ws::messages::WsResponse; +use crate::node_manager::ws::publish_subscribe::PubSub; +use crate::node_manager::ws::published_channels::PublishedChannels; + +pub async fn executive_dashboard_summary(channels: Arc) { + if !channels + .is_channel_alive(PublishedChannels::ExecutiveDashboardSummary) + .await + { + return; + } + + let payload = WsResponse::ExecutiveDashboardSummary { + data: executive::executive_dashboard_summary(), + }; + channels + .send(PublishedChannels::ExecutiveDashboardSummary, payload) + .await; +} diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/executive_heatmaps.rs b/src/rust/lqosd/src/node_manager/ws/ticker/executive_heatmaps.rs deleted file mode 100644 index 38e29edb..00000000 --- a/src/rust/lqosd/src/node_manager/ws/ticker/executive_heatmaps.rs +++ /dev/null @@ -1,271 +0,0 @@ -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use crate::node_manager::ws::messages::{ExecutiveHeatmapsData, OversubscribedSite, WsResponse}; -use crate::node_manager::ws::publish_subscribe::PubSub; -use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; -use crate::throughput_tracker::THROUGHPUT_TRACKER; -use lqos_bus::{BusReply, BusRequest, BusResponse, ExecutiveSummaryHeader}; -use lqos_utils::temporal_heatmap::{HeatmapBlocks, TemporalHeatmap}; -use once_cell::sync::Lazy; -use parking_lot::Mutex; -use std::collections::{HashMap, HashSet}; -use tokio::sync::mpsc::Sender; - -static LAST_PUBLISH: Lazy>> = Lazy::new(|| Mutex::new(None)); -const MIN_INTERVAL: Duration = Duration::from_secs(1); - -pub async fn executive_heatmaps( - channels: Arc, - bus_tx: Sender<(tokio::sync::oneshot::Sender, BusRequest)>, -) { - if !channels - .is_channel_alive(PublishedChannels::ExecutiveHeatmaps) - .await - { - return; - } - - if !should_publish() { - return; - } - - let circuits = fetch_circuit_heatmaps(bus_tx.clone()) - .await - .unwrap_or_default(); - let sites = fetch_site_heatmaps(bus_tx.clone()) - .await - .unwrap_or_default(); - let asns = fetch_asn_heatmaps(bus_tx.clone()).await.unwrap_or_default(); - let header = fetch_executive_header(bus_tx.clone()) - .await - .unwrap_or_else(empty_header); - let global = fetch_global_heatmap(bus_tx) - .await - .unwrap_or_else(empty_blocks); - let global_qoq = THROUGHPUT_TRACKER.global_qoq_heatmap.lock().blocks(); - let oversubscribed_sites = compute_oversubscribed_sites(); - - let payload = WsResponse::ExecutiveHeatmaps { - data: ExecutiveHeatmapsData { - header, - global, - global_qoq, - circuits, - sites, - asns, - oversubscribed_sites, - }, - }; - - channels - .send(PublishedChannels::ExecutiveHeatmaps, payload) - .await; -} - -fn should_publish() -> bool { - let now = Instant::now(); - let mut lock = LAST_PUBLISH.lock(); - if let Some(last) = *lock - && now.duration_since(last) < MIN_INTERVAL - { - return false; - } - *lock = Some(now); - true -} - -async fn fetch_circuit_heatmaps( - bus_tx: Sender<(tokio::sync::oneshot::Sender, BusRequest)>, -) -> Option> { - if let Some(BusResponse::CircuitHeatmaps(data)) = - fetch_single_response(bus_tx, BusRequest::GetCircuitHeatmaps).await - { - return Some(data); - } - None -} - -async fn fetch_site_heatmaps( - bus_tx: Sender<(tokio::sync::oneshot::Sender, BusRequest)>, -) -> Option> { - if let Some(BusResponse::SiteHeatmaps(data)) = - fetch_single_response(bus_tx, BusRequest::GetSiteHeatmaps).await - { - return Some(data); - } - None -} - -async fn fetch_asn_heatmaps( - bus_tx: Sender<(tokio::sync::oneshot::Sender, BusRequest)>, -) -> Option> { - if let Some(BusResponse::AsnHeatmaps(data)) = - fetch_single_response(bus_tx, BusRequest::GetAsnHeatmaps).await - { - return Some(data); - } - None -} - -async fn fetch_executive_header( - bus_tx: Sender<(tokio::sync::oneshot::Sender, BusRequest)>, -) -> Option { - if let Some(BusResponse::ExecutiveSummaryHeader(data)) = - fetch_single_response(bus_tx, BusRequest::GetExecutiveSummaryHeader).await - { - return Some(data); - } - None -} - -async fn fetch_global_heatmap( - bus_tx: Sender<(tokio::sync::oneshot::Sender, BusRequest)>, -) -> Option { - if let Some(BusResponse::GlobalHeatmap(data)) = - fetch_single_response(bus_tx, BusRequest::GetGlobalHeatmap).await - { - return Some(data); - } - None -} - -async fn fetch_single_response( - bus_tx: Sender<(tokio::sync::oneshot::Sender, BusRequest)>, - request: BusRequest, -) -> Option { - let (tx, rx) = tokio::sync::oneshot::channel::(); - bus_tx.send((tx, request)).await.ok()?; - let replies = rx.await.ok()?; - replies.responses.into_iter().next() -} - -fn empty_blocks() -> HeatmapBlocks { - TemporalHeatmap::new().blocks() -} - -fn empty_header() -> ExecutiveSummaryHeader { - ExecutiveSummaryHeader::default() -} - -#[derive(Default)] -struct OversubTally { - cap_down: f32, - cap_up: f32, - sub_down: f32, - sub_up: f32, -} - -fn compute_oversubscribed_sites() -> Vec { - let devices = SHAPED_DEVICES.load(); - let mut circuit_map: HashMap = HashMap::new(); - for device in devices.devices.iter() { - let entry = circuit_map - .entry(device.circuit_id.clone()) - .or_insert_with(|| (device.parent_node.clone(), 0.0_f32, 0.0_f32)); - // Keep the largest per-circuit subscription values. - entry.1 = entry.1.max(device.download_max_mbps); - entry.2 = entry.2.max(device.upload_max_mbps); - } - - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - if nodes.is_empty() { - return Vec::new(); - } - - // Build children adjacency from immediate_parent. - let mut children: Vec> = vec![Vec::new(); nodes.len()]; - for (idx, node) in nodes.iter().enumerate() { - if let Some(parent) = node.immediate_parent - && parent < children.len() - { - children[parent].push(idx); - } - } - - // Helper to collect descendant names including the node itself. - let collect_descendants = - |start: usize, children: &Vec>, nodes: &Vec| { - let mut stack = vec![start]; - let mut visited = HashSet::new(); - let mut names = HashSet::new(); - while let Some(idx) = stack.pop() { - if !visited.insert(idx) { - continue; - } - if let Some(name) = nodes.get(idx).map(|n| n.name.clone()) { - names.insert(name); - } - if let Some(kids) = children.get(idx) { - for &child in kids { - stack.push(child); - } - } - } - names - }; - - let mut results: Vec<(String, OversubTally)> = Vec::new(); - for (idx, node) in nodes.iter().enumerate() { - if node.name == "Root" { - continue; - } - let node_type = node.node_type.as_deref().unwrap_or("").to_ascii_lowercase(); - if node_type != "site" && !node_type.is_empty() && node_type != "ap" { - continue; - } - - let mut tally = OversubTally { - cap_down: node.max_throughput.0 as f32, - cap_up: node.max_throughput.1 as f32, - ..Default::default() - }; - - let descendants = collect_descendants(idx, &children, nodes); - for (_circuit_id, (parent_name, down, up)) in circuit_map.iter() { - if descendants.contains(parent_name) { - tally.sub_down += *down; - tally.sub_up += *up; - } - } - - results.push((node.name.clone(), tally)); - } - - results - .into_iter() - .filter_map(|(site_name, t)| { - let ratio_down = if t.cap_down > 0.0 { - Some(t.sub_down / t.cap_down) - } else { - None - }; - let ratio_up = if t.cap_up > 0.0 { - Some(t.sub_up / t.cap_up) - } else { - None - }; - if ratio_down.is_none() && ratio_up.is_none() { - return None; - } - let ratio_max = match (ratio_down, ratio_up) { - (Some(d), Some(u)) => Some(d.max(u)), - (Some(d), None) => Some(d), - (None, Some(u)) => Some(u), - (None, None) => None, - }; - Some(OversubscribedSite { - site_name, - cap_down: t.cap_down, - cap_up: t.cap_up, - sub_down: t.sub_down, - sub_up: t.sub_up, - ratio_down, - ratio_up, - ratio_max, - }) - }) - .collect() -} diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/network_tree_lite.rs b/src/rust/lqosd/src/node_manager/ws/ticker/network_tree_lite.rs new file mode 100644 index 00000000..0d92a3e3 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/ws/ticker/network_tree_lite.rs @@ -0,0 +1,19 @@ +use crate::node_manager::local_api::network_tree_lite::network_tree_lite_data; +use crate::node_manager::ws::messages::WsResponse; +use crate::node_manager::ws::publish_subscribe::PubSub; +use crate::node_manager::ws::published_channels::PublishedChannels; +use std::sync::Arc; + +pub async fn network_tree_lite(channels: Arc) { + if !channels + .is_channel_alive(PublishedChannels::NetworkTreeLite) + .await + { + return; + } + + let message = WsResponse::NetworkTreeLite { + data: network_tree_lite_data(), + }; + channels.send(PublishedChannels::NetworkTreeLite, message).await; +} diff --git a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs new file mode 100644 index 00000000..a1c61e5d --- /dev/null +++ b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs @@ -0,0 +1,222 @@ +use crate::throughput_tracker::THROUGHPUT_TRACKER; +use fxhash::{FxHashMap, FxHashSet}; +use lqos_utils::rtt::{FlowbeeEffectiveDirection, RttBucket}; +use lqos_utils::units::DownUpOrder; +use lqos_utils::unix_time::time_since_boot; +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{CIRCUIT_LIVE_LAST_REFRESH_SECS, CIRCUIT_LIVE_REFRESH_LOCK, CIRCUIT_LIVE_SNAPSHOT}; +use super::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}; + +/// Per-circuit live metrics aggregated from the device-level throughput tracker. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitLiveRollup { + pub circuit_id: String, + pub circuit_name: String, + pub parent_node: String, + pub device_names: Vec, + pub ip_addrs: Vec, + pub plan_mbps: DownUpOrder, + pub bytes_per_second: DownUpOrder, + pub rtt_current_p50_nanos: DownUpOrder>, + pub qoo: DownUpOrder>, + pub tcp_packets: DownUpOrder, + pub tcp_retransmits: DownUpOrder, + pub last_seen_nanos: u64, +} + +/// Shared once-per-second snapshot of circuit rollups and parent-node indexes. +#[derive(Clone, Debug, Default)] +pub struct CircuitLiveSnapshot { + pub by_circuit_id: FxHashMap, + pub circuit_ids_by_parent_node: FxHashMap>, +} + +#[derive(Default)] +struct CircuitAccumulator { + circuit_name: String, + parent_node: String, + device_names: FxHashSet, + ip_addrs: FxHashSet, + plan_mbps: DownUpOrder, + bytes_per_second: DownUpOrder, + rtt_current_p50_nanos: DownUpOrder>, + qoo: DownUpOrder>, + tcp_packets: DownUpOrder, + tcp_retransmits: DownUpOrder, + last_seen_nanos: Option, +} + +fn current_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn max_opt_u64(left: Option, right: Option) -> Option { + match (left, right) { + (Some(a), Some(b)) => Some(a.max(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } +} + +fn min_opt_f32(left: Option, right: Option) -> Option { + match (left, right) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } +} + +fn sort_string_set(values: FxHashSet) -> Vec { + let mut out: Vec = values.into_iter().collect(); + out.sort_unstable(); + out +} + +fn ip_to_string(ip: IpAddr) -> String { + ip.to_string() +} + +fn kernel_age_from_last_seen(kernel_now: std::time::Duration, last_seen: u64) -> u64 { + if last_seen == 0 { + return u64::MAX; + } + let since_boot = kernel_now.as_nanos(); + since_boot.saturating_sub(last_seen as u128) as u64 +} + +/// Rebuilds the shared circuit-live snapshot from current tracker state. +/// +/// Side effects: stores the rebuilt snapshot in the global `CIRCUIT_LIVE_SNAPSHOT` +/// cache and updates the refresh timestamp used by `fresh_circuit_live_snapshot`. +pub fn rebuild_circuit_live_snapshot() -> Arc { + let Ok(kernel_now) = time_since_boot() else { + let empty = Arc::new(CircuitLiveSnapshot::default()); + CIRCUIT_LIVE_SNAPSHOT.store(empty.clone()); + return empty; + }; + let kernel_now: std::time::Duration = kernel_now.into(); + + let shaped_devices = SHAPED_DEVICES.load(); + let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let mut by_circuit_id: FxHashMap = FxHashMap::default(); + + for (ip_key, data) in THROUGHPUT_TRACKER.raw_data.lock().iter() { + let device = data + .device_hash + .and_then(|device_hash| cache.index_by_device_hash(&shaped_devices, device_hash)) + .or_else(|| { + data.circuit_hash + .and_then(|circuit_hash| cache.index_by_circuit_hash(&shaped_devices, circuit_hash)) + }) + .and_then(|idx| shaped_devices.devices.get(idx)); + let Some(device) = device else { + continue; + }; + if device.circuit_id.trim().is_empty() { + continue; + } + + let entry = by_circuit_id + .entry(device.circuit_id.clone()) + .or_default(); + if entry.circuit_name.is_empty() { + entry.circuit_name = device.circuit_name.clone(); + } + if entry.parent_node.is_empty() { + entry.parent_node = device.parent_node.clone(); + } + if !device.device_name.trim().is_empty() { + entry.device_names.insert(device.device_name.clone()); + } + entry.ip_addrs.insert(ip_to_string(ip_key.as_ip())); + entry.plan_mbps.down = entry.plan_mbps.down.max(device.download_max_mbps.round()); + entry.plan_mbps.up = entry.plan_mbps.up.max(device.upload_max_mbps.round()); + entry.bytes_per_second.down += data.bytes_per_second.down; + entry.bytes_per_second.up += data.bytes_per_second.up; + entry.tcp_packets.down += data.tcp_packets.down.saturating_sub(data.prev_tcp_packets.down); + entry.tcp_packets.up += data.tcp_packets.up.saturating_sub(data.prev_tcp_packets.up); + entry.tcp_retransmits.down += data.tcp_retransmits.down; + entry.tcp_retransmits.up += data.tcp_retransmits.up; + entry.last_seen_nanos = Some(match entry.last_seen_nanos { + Some(current) => current.min(kernel_age_from_last_seen(kernel_now, data.last_seen)), + None => kernel_age_from_last_seen(kernel_now, data.last_seen), + }); + entry.rtt_current_p50_nanos.down = max_opt_u64( + entry.rtt_current_p50_nanos.down, + data.rtt_buffer + .percentile(RttBucket::Current, FlowbeeEffectiveDirection::Download, 50) + .map(|rtt| rtt.as_nanos()), + ); + entry.rtt_current_p50_nanos.up = max_opt_u64( + entry.rtt_current_p50_nanos.up, + data.rtt_buffer + .percentile(RttBucket::Current, FlowbeeEffectiveDirection::Upload, 50) + .map(|rtt| rtt.as_nanos()), + ); + entry.qoo.down = min_opt_f32(entry.qoo.down, data.qoq.download_total_f32()); + entry.qoo.up = min_opt_f32(entry.qoo.up, data.qoq.upload_total_f32()); + } + + let mut finalized: FxHashMap = FxHashMap::default(); + let mut circuit_ids_by_parent_node: FxHashMap> = FxHashMap::default(); + for (circuit_id, value) in by_circuit_id { + if !value.parent_node.trim().is_empty() { + circuit_ids_by_parent_node + .entry(value.parent_node.clone()) + .or_default() + .push(circuit_id.clone()); + } + finalized.insert( + circuit_id.clone(), + CircuitLiveRollup { + circuit_id, + circuit_name: value.circuit_name, + parent_node: value.parent_node, + device_names: sort_string_set(value.device_names), + ip_addrs: sort_string_set(value.ip_addrs), + plan_mbps: value.plan_mbps, + bytes_per_second: value.bytes_per_second, + rtt_current_p50_nanos: value.rtt_current_p50_nanos, + qoo: value.qoo, + tcp_packets: value.tcp_packets, + tcp_retransmits: value.tcp_retransmits, + last_seen_nanos: value.last_seen_nanos.unwrap_or(u64::MAX), + }, + ); + } + for ids in circuit_ids_by_parent_node.values_mut() { + ids.sort_unstable(); + } + + let snapshot = Arc::new(CircuitLiveSnapshot { + by_circuit_id: finalized, + circuit_ids_by_parent_node, + }); + CIRCUIT_LIVE_SNAPSHOT.store(snapshot.clone()); + CIRCUIT_LIVE_LAST_REFRESH_SECS.store(current_epoch_secs(), std::sync::atomic::Ordering::Release); + snapshot +} + +/// Returns the current once-per-second circuit-live snapshot, rebuilding it if stale. +/// +/// Side effects: may rebuild and replace the global snapshot cache. +pub fn fresh_circuit_live_snapshot() -> Arc { + let now_secs = current_epoch_secs(); + if CIRCUIT_LIVE_LAST_REFRESH_SECS.load(std::sync::atomic::Ordering::Acquire) == now_secs { + return CIRCUIT_LIVE_SNAPSHOT.load_full(); + } + let _guard = CIRCUIT_LIVE_REFRESH_LOCK.lock(); + if CIRCUIT_LIVE_LAST_REFRESH_SECS.load(std::sync::atomic::Ordering::Acquire) == now_secs { + return CIRCUIT_LIVE_SNAPSHOT.load_full(); + } + rebuild_circuit_live_snapshot() +} diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index 407013af..8d28c246 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode; use arc_swap::ArcSwap; use fxhash::{FxHashMap, FxHashSet}; use lqos_bus::{BusResponse, Circuit}; @@ -10,14 +11,18 @@ use lqos_utils::rtt::{FlowbeeEffectiveDirection, RttBucket}; use lqos_utils::units::DownUpOrder; use lqos_utils::unix_time::time_since_boot; use once_cell::sync::Lazy; +use parking_lot::Mutex; use std::sync::Arc; +use std::sync::atomic::AtomicU64; use std::time::Duration; use tracing::{debug, error, info, warn}; // Removed rate_for_plan() function - no longer needed with f32 plan structures +pub mod circuit_live; mod netjson; use crate::throughput_tracker::THROUGHPUT_TRACKER; +pub use circuit_live::CircuitLiveSnapshot; pub use netjson::*; pub static SHAPED_DEVICES: Lazy> = @@ -86,6 +91,18 @@ impl ShapedDeviceHashCache { pub static SHAPED_DEVICE_HASH_CACHE: Lazy> = Lazy::new(|| ArcSwap::new(Arc::new(ShapedDeviceHashCache::default()))); +pub static CIRCUIT_LIVE_SNAPSHOT: Lazy> = + Lazy::new(|| ArcSwap::new(Arc::new(CircuitLiveSnapshot::default()))); +pub static CIRCUIT_LIVE_LAST_REFRESH_SECS: AtomicU64 = AtomicU64::new(0); +pub static CIRCUIT_LIVE_REFRESH_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +pub(crate) fn invalidate_circuit_live_snapshot() { + CIRCUIT_LIVE_LAST_REFRESH_SECS.store(0, std::sync::atomic::Ordering::Release); +} + +pub(crate) fn invalidate_executive_cache_snapshot() { + crate::node_manager::invalidate_executive_cache_snapshot(); +} #[derive(Clone, Copy, Debug, Default)] struct NetworkTreeSummary { @@ -166,6 +183,8 @@ fn load_shaped_devices() { let cache = ShapedDeviceHashCache::from_devices(&new_file.devices); SHAPED_DEVICES.store(Arc::new(new_file)); SHAPED_DEVICE_HASH_CACHE.store(Arc::new(cache)); + invalidate_circuit_live_snapshot(); + invalidate_executive_cache_snapshot(); let nj = NETWORK_JSON.read(); crate::throughput_tracker::THROUGHPUT_TRACKER.refresh_circuit_ids(&nj); } else { @@ -174,6 +193,8 @@ fn load_shaped_devices() { ); SHAPED_DEVICES.store(Arc::new(ConfigShapedDevices::default())); SHAPED_DEVICE_HASH_CACHE.store(Arc::new(ShapedDeviceHashCache::default())); + invalidate_circuit_live_snapshot(); + invalidate_executive_cache_snapshot(); } } @@ -262,6 +283,70 @@ pub fn full_network_map_snapshot() -> Vec<(usize, NetworkJsonTransport)> { .collect() } +fn node_to_transport_lite(node: &NetworkJsonNode) -> NetworkTreeLiteNode { + let download = + node.rtt_buffer + .percentile(RttBucket::Current, FlowbeeEffectiveDirection::Download, 50); + let upload = + node.rtt_buffer + .percentile(RttBucket::Current, FlowbeeEffectiveDirection::Upload, 50); + + let rtts = match (download, upload) { + (None, None) => Vec::new(), + (Some(d), None) => vec![d.as_millis() as f32; 2], + (None, Some(u)) => vec![u.as_millis() as f32; 2], + (Some(d), Some(u)) => vec![d.as_millis() as f32, u.as_millis() as f32], + }; + + let qoo = node + .qoq_heatmap + .as_ref() + .map(|heatmap| { + let blocks = heatmap.blocks(); + let latest = |values: &[Option]| values.iter().rev().find_map(|v| *v); + (latest(&blocks.download_total), latest(&blocks.upload_total)) + }) + .unwrap_or((None, None)); + + NetworkTreeLiteNode { + name: node.name.clone(), + id: node.id.clone(), + is_virtual: node.virtual_node, + max_throughput: node.max_throughput, + current_throughput: ( + node.current_throughput.get_down(), + node.current_throughput.get_up(), + ), + current_tcp_packets: ( + node.current_tcp_packets.get_down(), + node.current_tcp_packets.get_up(), + ), + current_retransmits: ( + node.current_tcp_retransmits.get_down(), + node.current_tcp_retransmits.get_up(), + ), + rtts, + qoo, + parents: node.parents.clone(), + immediate_parent: node.immediate_parent, + node_type: node.node_type.clone(), + latitude: node.latitude, + longitude: node.longitude, + } +} + +/// Returns a lightweight live snapshot of the network tree for pages that do not need the full +/// `NetworkJsonTransport` payload. +pub fn full_network_map_lite_snapshot() -> Vec<(usize, NetworkTreeLiteNode)> { + let nj = NETWORK_JSON.read(); + let nodes = nj.get_nodes_when_ready(); + nodes + .iter() + .enumerate() + .map(|(i, n)| (i, node_to_transport_lite(n))) + .collect() +} + pub fn get_full_network_map() -> BusResponse { BusResponse::NetworkMap(full_network_map_snapshot()) } diff --git a/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs b/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs index b7e52e3d..5b263925 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs @@ -44,6 +44,8 @@ fn load_network_json() { if let Ok(njs) = njs { let mut nj = NETWORK_JSON.write(); *nj = njs; + super::invalidate_circuit_live_snapshot(); + super::invalidate_executive_cache_snapshot(); crate::throughput_tracker::THROUGHPUT_TRACKER.refresh_circuit_ids(&nj); } else { warn!("Unable to load network.json"); diff --git a/src/rust/lqosd/src/throughput_tracker/mod.rs b/src/rust/lqosd/src/throughput_tracker/mod.rs index 7c67171e..6a7cba77 100644 --- a/src/rust/lqosd/src/throughput_tracker/mod.rs +++ b/src/rust/lqosd/src/throughput_tracker/mod.rs @@ -886,6 +886,7 @@ pub fn executive_summary_header() -> BusResponse { unmapped_ip_count, htb_queue_count: queue_counts.htb as u64, cake_queue_count: queue_counts.cake as u64, + fq_codel_queue_count: queue_counts.fq_codel as u64, insight_connected, }) } From a870a3e667cb450adfaecc24d5ce5e96b8206aff Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 21 Mar 2026 14:35:52 -0500 Subject: [PATCH 015/102] Fix: Utilization-colored site links --- .../src/node_manager/js_build/src/site_map.js | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 5802798a..f4ea36e6 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -13,6 +13,7 @@ const TILE_BBOX_URL = "https://insight.libreqos.com/tiles/api/bbox"; // NOTE: This key is intentionally non-secret. The remote OSM cache uses it as a lightweight gate. // It must match both the bbox Authorization token and the tile `key=` query param. const OSM_CACHE_KEY = "LibreQoSRocks"; +const WEB_MERCATOR_MAX_LAT = 85.05112878; const INSIGHT_TILE_PROTOCOL = "insight"; // Insight OSM cache tiles: `z/y/x` (no `.png` suffix). See `src/lts2/rust/osm_cache/src/http.rs`. // The Insight tile server returns `503 + Retry-After` while it fetches missing tiles, so we route @@ -21,9 +22,6 @@ const TILE_URL_TEMPLATE = `${INSIGHT_TILE_PROTOCOL}://insight.libreqos.com/tiles const TILE_ATTRIBUTION = "© OpenStreetMap contributors"; const TILE_MAX_ZOOM = 17; -const FALLBACK_CENTER = [-101.5, 39.8]; -const FALLBACK_ZOOM = 3.15; - const OSM_RASTER_SOURCE_ID = "site-map-osm"; const OSM_RASTER_LAYER_ID = "site-map-osm-tiles"; const SITE_SOURCE_ID = "site-map-sites"; @@ -127,6 +125,14 @@ function worstRtt(rttDownMs, rttUpMs) { return Math.max(...values); } +function configuredMaxThroughput(node) { + return node?.configured_max_throughput || node?.max_throughput || [0, 0]; +} + +function effectiveMaxThroughput(node) { + return node?.effective_max_throughput || configuredMaxThroughput(node); +} + function averageOrNull(sum, count) { return count > 0 ? (sum / count) : null; } @@ -136,7 +142,16 @@ function hasMeaningfulLatLon(lat, lon) { return false; } // Many integrations default to (0,0) when coordinates are unknown. - return !(lat === 0 && lon === 0); + if (lat === 0 && lon === 0) { + return false; + } + if (lat < -WEB_MERCATOR_MAX_LAT || lat > WEB_MERCATOR_MAX_LAT) { + return false; + } + if (lon < -180 || lon > 180) { + return false; + } + return true; } function stableNodeKey(index, node) { @@ -383,7 +398,7 @@ async function requestOsmCenterFromBbox(siteLatLonPairs, timeoutMs = 2500) { const [lat, lon] = pair; const latN = Number(lat); const lonN = Number(lon); - if (!Number.isFinite(latN) || !Number.isFinite(lonN)) { + if (!hasMeaningfulLatLon(latN, lonN)) { return null; } return { lat: latN, lon: lonN }; @@ -496,7 +511,15 @@ class SiteMapPage { }); } - initMap(center = FALLBACK_CENTER, zoom = FALLBACK_ZOOM) { + initMap(center, zoom) { + if (!Array.isArray(center) + || center.length < 2 + || !Number.isFinite(center[0]) + || !Number.isFinite(center[1]) + || !Number.isFinite(zoom)) { + throw new Error("Invalid map center/zoom"); + } + installInsightTileProtocolOnce(); if (typeof window.maplibregl?.setMaxParallelImageRequests === "function") { window.maplibregl.setMaxParallelImageRequests(6); @@ -608,15 +631,15 @@ class SiteMapPage { "line-color": linkColorExpression(), "line-width": [ "interpolate", ["linear"], ["zoom"], - 2, 0.6, - 6, 1.2, - 10, 2.0, + 2, 0.9, + 6, 1.6, + 10, 2.6, ], "line-opacity": [ "interpolate", ["linear"], ["zoom"], - 2, 0.25, - 6, 0.45, - 10, 0.6, + 2, 0.35, + 6, 0.55, + 10, 0.72, ], }, }); @@ -854,6 +877,9 @@ class SiteMapPage { const qooUp = averageOrNull(value.qooUpSum, value.qooUpCount); const rttDownMs = averageOrNull(value.rttDownSum, value.rttDownCount); const rttUpMs = averageOrNull(value.rttUpSum, value.rttUpCount); + const maxMbps = effectiveMaxThroughput(node); + const limitDownMbps = toNumber(maxMbps?.[0], 0); + const limitUpMbps = toNumber(maxMbps?.[1], 0); const normalized = { key: value.key, @@ -861,7 +887,7 @@ class SiteMapPage { name: node.name, id: node.id || null, type: nodeType, - immediateParent: node.immediate_parent, + immediateParent: immediateParentIndex(node), latitude: null, longitude: null, throughputDown: avgDown, @@ -875,6 +901,8 @@ class SiteMapPage { rttWorst: worstRtt(rttDownMs, rttUpMs), parentName: null, inheritedCoords: false, + limitDownMbps, + limitUpMbps, }; const lat = Number(node.latitude); const lon = Number(node.longitude); @@ -968,7 +996,13 @@ class SiteMapPage { return; } emittedLinks.add(key); - const utilizationRatio = maxBitsPerSecond > 0 ? Math.max(0, Math.min(1, node.throughputCombined / maxBitsPerSecond)) : 0; + const downLimitBits = node.limitDownMbps > 0 ? node.limitDownMbps * 1000 * 1000 : 0; + const upLimitBits = node.limitUpMbps > 0 ? node.limitUpMbps * 1000 * 1000 : 0; + const downRatio = downLimitBits > 0 ? (node.throughputDown / downLimitBits) : null; + const upRatio = upLimitBits > 0 ? (node.throughputUp / upLimitBits) : null; + const utilizationRatio = (downRatio === null && upRatio === null) + ? null + : Math.max(0, Math.min(1, Math.max(downRatio ?? 0, upRatio ?? 0))); siteLinkFeatures.push({ type: "Feature", geometry: { From 66d9b12e7f085544b1dfc548bcbeb5002b5e58d4 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 21 Mar 2026 15:11:19 -0500 Subject: [PATCH 016/102] Fix: Marker size legend matches map --- .../src/node_manager/js_build/src/site_map.js | 26 +++++++++++++++++++ .../src/node_manager/static2/site_map.html | 8 +++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index f4ea36e6..23dec8dc 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -471,6 +471,9 @@ class SiteMapPage { this.legendGradient = document.getElementById("siteMapLegendGradient"); this.legendLow = document.getElementById("siteMapLegendLow"); this.legendHigh = document.getElementById("siteMapLegendHigh"); + this.sizeDotSmall = document.getElementById("siteMapSizeDotSmall"); + this.sizeDotMedium = document.getElementById("siteMapSizeDotMedium"); + this.sizeDotLarge = document.getElementById("siteMapSizeDotLarge"); } init() { @@ -1089,6 +1092,29 @@ class SiteMapPage { this.map.setPaintProperty(AP_POINTS_LAYER_ID, "circle-opacity", apOpacity); this.syncSiteLabels(aggregate.siteFeatures); + this.syncMarkerSizeLegend(aggregate.maxBitsPerSecond); + } + + syncMarkerSizeLegend(maxBitsPerSecond) { + const dots = [this.sizeDotSmall, this.sizeDotMedium, this.sizeDotLarge]; + if (dots.some((dot) => !dot)) { + return; + } + + const max = Math.max(1, toNumber(maxBitsPerSecond, 1)); + // Match the actual marker sizing function by sampling at a few ratios. + const ratios = [0, 0.1, 1.0]; + const diameters = ratios.map((ratio) => throughputRadiusPx(max * ratio, max) * 2); + + const apply = (el, diameter) => { + const sizePx = Math.max(10, Math.min(64, Math.round(toNumber(diameter, 12)))); + el.style.width = `${sizePx}px`; + el.style.height = `${sizePx}px`; + }; + + apply(this.sizeDotSmall, diameters[0]); + apply(this.sizeDotMedium, diameters[1]); + apply(this.sizeDotLarge, diameters[2]); } syncSiteLabels(siteFeatures) { diff --git a/src/rust/lqosd/src/node_manager/static2/site_map.html b/src/rust/lqosd/src/node_manager/static2/site_map.html index f1743a23..aae78e97 100644 --- a/src/rust/lqosd/src/node_manager/static2/site_map.html +++ b/src/rust/lqosd/src/node_manager/static2/site_map.html @@ -176,6 +176,8 @@ } .site-map-size-dot { + width: 12px; + height: 12px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.45); background: radial-gradient(circle at 35% 35%, rgba(255,255,255,0.9) 0%, rgba(100, 160, 255, 0.95) 42%, rgba(60, 108, 196, 0.92) 100%); @@ -383,9 +385,9 @@
Marker Size
- - - + + +
Combined recent throughput over the last 30 seconds.
From 3dcee3759fc676a12c01029fd638bcc85273f842 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sat, 21 Mar 2026 15:28:28 -0500 Subject: [PATCH 017/102] Fix: Auto-fit site map viewport --- .../src/node_manager/js_build/src/site_map.js | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 23dec8dc..0b0ccdf0 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -781,7 +781,6 @@ class SiteMapPage { siteLatLonPairs.push([lat + 0.0001, lon + 0.0001]); } const center = await requestOsmCenterFromBbox(siteLatLonPairs, 4000); - this.hasFitOnce = true; this.initMap([center.lon, center.lat], center.zoom); } catch (err) { console.error("Site map bbox request failed; map not initialized.", err); @@ -1056,7 +1055,7 @@ class SiteMapPage { this.updateSelection(); this.renderUnmapped(aggregate.unmappedSites, aggregate.unmappedAps); if (!this.hasFitOnce) { - this.fitToData(aggregate.siteFeatures, aggregate.apFeatures); + this.fitToDataIfNeeded(aggregate.siteFeatures, aggregate.apFeatures); } } @@ -1178,16 +1177,40 @@ class SiteMapPage { }); } - fitToData(siteFeatures, apFeatures) { - const features = [...siteFeatures, ...apFeatures]; - if (!features.length) { + fitToDataIfNeeded(siteFeatures, apFeatures) { + if (!this.map) { return; } + + const features = [ + ...siteFeatures, + // Include only APs with their own coordinates so a bad parent/duplicate doesn't pull the view out. + ...apFeatures.filter((feature) => feature?.properties?.inheritedCoords !== 1), + ]; + if (!features.length) { + this.hasFitOnce = true; + return; + } + + let viewBounds; + try { + viewBounds = this.map.getBounds(); + } catch (_) { + viewBounds = null; + } + + if (viewBounds) { + const allInside = features.every((feature) => viewBounds.contains(feature.geometry.coordinates)); + if (allInside) { + this.hasFitOnce = true; + return; + } + } + const bounds = new window.maplibregl.LngLatBounds(); features.forEach((feature) => bounds.extend(feature.geometry.coordinates)); this.map.fitBounds(bounds, { padding: 70, - maxZoom: 7.5, duration: 0, }); this.hasFitOnce = true; From 804209a5a4c95577ca87f36eb1e19facbe8d47c0 Mon Sep 17 00:00:00 2001 From: rchac Date: Sun, 22 Mar 2026 00:00:13 +0000 Subject: [PATCH 018/102] changes --- docs-es/v2.0/node-manager-ui-es.md | 1 + docs-es/v2.0/troubleshooting-es.md | 5 + docs/v2.0/node-manager-ui.md | 1 + docs/v2.0/troubleshooting.md | 5 + src/LibreQoS.py | 245 +++++++++++++----- src/rust/Cargo.lock | 3 + src/rust/lqos_bakery/src/lib.rs | 173 ++++++++++++- src/rust/lqos_bus/src/bus/mod.rs | 8 + src/rust/lqos_bus/src/bus/response.rs | 6 + src/rust/lqos_bus/src/lib.rs | 6 +- src/rust/lqos_python/Cargo.toml | 2 + src/rust/lqos_python/src/blocking.rs | 40 ++- src/rust/lqos_python/src/lib.rs | 199 +++++++++++++- src/rust/lqos_queue_tracker/Cargo.toml | 1 + src/rust/lqos_queue_tracker/src/lib.rs | 2 +- .../queing_structure_json_monitor.rs | 3 - .../lqos_queue_tracker/src/tracking/mod.rs | 21 ++ src/rust/lqos_sys/src/lib.rs | 1 + src/rust/lqos_sys/src/lqos_kernel.rs | 43 +++ .../src/dashlets/executive_snapshot.js | 40 ++- src/rust/lqosd/src/node_manager/local_api.rs | 2 +- .../node_manager/local_api/circuit_live.rs | 21 +- .../src/node_manager/local_api/config.rs | 38 ++- .../src/node_manager/local_api/executive.rs | 21 +- .../node_manager/local_api/executive_cache.rs | 59 +++-- .../local_api/node_rate_overrides.rs | 5 +- .../local_api/shaped_devices_page.rs | 14 +- .../local_api/tree_attached_circuits.rs | 25 +- src/rust/lqosd/src/node_manager/ws.rs | 131 +++++----- .../lqosd/src/node_manager/ws/messages.rs | 16 +- .../node_manager/ws/single_user_channels.rs | 10 +- .../ws/ticker/network_tree_lite.rs | 4 +- .../shaped_devices_tracker/circuit_live.rs | 17 +- .../lqosd/src/shaped_devices_tracker/mod.rs | 2 +- src/rust/lqosd/src/throughput_tracker/mod.rs | 8 +- src/scheduler.py | 8 +- src/test_scheduler.py | 1 + 37 files changed, 942 insertions(+), 245 deletions(-) diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index e338f5f7..5af185c2 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -9,6 +9,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - El contenido puede variar según versión y funciones habilitadas. - Executive Summary ofrece una vista operativa compacta para redes grandes, con páginas de detalle para heatmaps y rankings ejecutivos. - Algunos gráficos pueden tardar un poco en poblarse al abrir una pestaña por primera vez, especialmente en sistemas ocupados o inmediatamente después de reiniciar servicios. +- Durante un full reload de Bakery, las tarjetas de conteo de colas mantienen los últimos valores conocidos de HTB/CAKE/fq-codel y los marcan como `Reloading` en lugar de mostrar ceros momentáneamente. ### Vista de árbol de red - Vista jerárquica de nodos/circuitos desde la perspectiva del shaper. diff --git a/docs-es/v2.0/troubleshooting-es.md b/docs-es/v2.0/troubleshooting-es.md index d2053f76..80112b0b 100644 --- a/docs-es/v2.0/troubleshooting-es.md +++ b/docs-es/v2.0/troubleshooting-es.md @@ -103,6 +103,8 @@ sudo RUST_LOG=info /opt/libreqos/src/bin/lqosd sudo journalctl -u lqos_scheduler --since "1 day ago" --no-pager > lqos_sched_log.txt ``` +Si el scheduler falla inmediatamente después de un reinicio con un mensaje como `Socket (typically /run/lqos/bus) not found`, eso indica que `lqosd` todavía no había terminado de enlazar el bus local. Los builds actuales esperan brevemente la disponibilidad del bus al arrancar el scheduler en lugar de abortar de inmediato, por lo que ya no deberían aparecer panics repetidos de arranque tras un reinicio. + ### El estado del scheduler en WebUI aparece no saludable Versiones recientes muestran estado/readiness del scheduler en WebUI. @@ -214,6 +216,9 @@ WebUI muestra códigos legibles por máquina para triage rápido. |---|---|---|---| | `MAPPED_CIRCUIT_LIMIT` | Bakery está forzando límite de circuitos mapeados. | Estado de licencia Insight y `journalctl -u lqosd` con requested/allowed/dropped. | Reducir circuitos mapeados o actualizar licencia/límites. | | `TC_U16_OVERFLOW` | IDs minor de clases/colas excedieron rango u16 de tc en una cola CPU. | `journalctl -u lqos_scheduler -u lqosd`, profundidad topológica y distribución por colas. | Aumentar paralelismo de colas y/o simplificar/rebalancear jerarquía. | +| `TC_QDISC_CAPACITY` | Los qdisc autoasignados planificados exceden el presupuesto seguro por interfaz antes de aplicar. | Conteos estimados de qdisc por interfaz en el contexto del urgent issue, `journalctl -u lqos_scheduler -u lqosd`, configuración `on_a_stick` y `monitor_only`. | Reducir la carga planificada de qdisc para esta ejecución (por ejemplo menos circuitos/dispositivos en la forma de prueba) antes de reintentar; no confiar en una aplicación parcial. | +| `XDP_IP_MAPPING_CAPACITY` | Los mapeos IP requeridos exceden la capacidad actual del mapa XDP en el kernel. | Forma de `ShapedDevices.csv`, mezcla IPv4/IPv6, supuesto de un dispositivo frente a varios dispositivos, `journalctl -u lqos_scheduler -u lqosd`. | Reducir mapeos requeridos de inmediato (por ejemplo menos dispositivos o prueba IPv4-only), o aumentar la capacidad del mapa del kernel en un cambio coordinado. | +| `XDP_IP_MAPPING_APPLY_FAILED` | Uno o más inserts de mapeo IP fallaron durante la aplicación. | `journalctl -u lqos_scheduler -u lqosd` para ejemplos resumidos y conteos de fallo. | Corregir la causa del fallo y volver a ejecutar; no confiar en shaping parcial. | Patrón operativo: diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index c605be80..49e919b1 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -9,6 +9,7 @@ This page documents key WebUI (Node Manager) views and operational behavior in t - Dashboard content can vary by version and enabled features. - Executive Summary provides a compact operational view for large networks, with drilldown pages for executive heatmaps and leaderboards. - Some charts may take a short time to populate after first opening a tab, especially on busy systems or immediately after a service restart. +- During a Bakery full reload, queue-count cards keep showing the last known HTB/CAKE/fq-codel values and mark them as `Reloading` instead of briefly zeroing them. ### Network Tree Overview - Hierarchical topology view of nodes/circuits from the shaper perspective. diff --git a/docs/v2.0/troubleshooting.md b/docs/v2.0/troubleshooting.md index 634af4ed..af4b142e 100644 --- a/docs/v2.0/troubleshooting.md +++ b/docs/v2.0/troubleshooting.md @@ -129,6 +129,8 @@ sudo journalctl -u lqos_scheduler --since "1 day ago" --no-pager > lqos_sched_lo ``` This exports a log file to lqos_sched_log.txt. You can review this file to see what caused the scheduler to error out. +If the scheduler fails immediately after a restart with a message like `Socket (typically /run/lqos/bus) not found`, that indicates `lqosd` had not finished binding the local bus yet. Current builds wait briefly for bus readiness at scheduler startup instead of crashing immediately, so repeated startup panics after restart should no longer be expected. + ### Scheduler status in WebUI looks unhealthy Recent builds expose scheduler readiness/state in the WebUI (Node Manager). @@ -234,6 +236,9 @@ WebUI urgent issues include machine-readable codes. Use them to triage quickly. |---|---|---|---| | `MAPPED_CIRCUIT_LIMIT` | Bakery is enforcing a mapped-circuit limit. | Insight license status, `journalctl -u lqosd` for requested/allowed/dropped counts. | Reduce mapped circuits immediately or update license/limits. | | `TC_U16_OVERFLOW` | Queue/class minor IDs exceeded the Linux tc u16 range on a CPU queue. | `journalctl -u lqos_scheduler -u lqosd`, topology depth/queue distribution. | Increase queue count and/or simplify/rebalance hierarchy (for example with integration strategy or root promotion changes). | +| `TC_QDISC_CAPACITY` | Planned auto-allocated qdiscs exceed the per-interface safe budget before apply. | Estimated per-interface qdisc counts in the urgent issue context, `journalctl -u lqos_scheduler -u lqosd`, `on_a_stick` and `monitor_only` config. | Reduce the planned qdisc load for this run (for example fewer circuits/devices in the test shape) before retrying; do not trust partial apply. | +| `XDP_IP_MAPPING_CAPACITY` | Required IP mappings exceed the current XDP kernel map capacity. | `ShapedDevices.csv` row shape, IPv4/IPv6 mix, one-device-vs-many-device assumptions, `journalctl -u lqos_scheduler -u lqosd`. | Reduce required mappings immediately (for example fewer devices or IPv4-only test shape), or raise kernel map capacity in a coordinated change. | +| `XDP_IP_MAPPING_APPLY_FAILED` | One or more IP mapping inserts failed during apply. | `journalctl -u lqos_scheduler -u lqosd` for summarized failure examples and counts. | Fix the underlying mapping failure, then rerun; do not trust partial shaping. | Operational pattern: 1. Open urgent issue details in WebUI (code/message/context). diff --git a/src/LibreQoS.py b/src/LibreQoS.py index cfff8e53..63b9d471 100755 --- a/src/LibreQoS.py +++ b/src/LibreQoS.py @@ -39,7 +39,7 @@ from liblqos_python import is_lqosd_alive, clear_ip_mappings, delete_ip_mapping, interface_a, interface_b, enable_actual_shell_commands, use_bin_packing_to_balance_cpu, monitor_mode_only, \ run_shell_commands_as_sudo, generated_pn_download_mbps, generated_pn_upload_mbps, queues_available_override, \ on_a_stick, get_tree_weights, get_weights, is_network_flat, get_libreqos_directory, enable_insight_topology, \ - is_insight_enabled, \ + is_insight_enabled, scheduler_error, xdp_ip_mapping_capacity, \ fast_queues_fq_codel, \ shaping_cpu_count, \ Bakery @@ -51,6 +51,31 @@ except Exception: def submit_urgent_issue(*_args, **_kwargs): return False + +class RefreshFailure(Exception): + pass + + +def report_refresh_failure(code, message, context=None, dedupe_key=None): + logging.error(message) + print("ERROR: " + message) + try: + scheduler_error(message) + except Exception: + pass + try: + submit_urgent_issue( + "LibreQoS", + "Error", + code, + message, + json.dumps(context) if context is not None else None, + dedupe_key, + ) + except Exception: + pass + raise RefreshFailure(message) + R2Q = 10 #MAX_R2Q = 200_000 MAX_R2Q = 60_000 # See https://lartc.vger.kernel.narkive.com/NKaH1ZNG/htb-quantum-of-class-100001-is-small-consider-r2q-change @@ -282,15 +307,16 @@ def validateNetworkAndDevices(): # And read from the sanitized byte stream with io.StringIO(text_content) as csv_file: csv_reader = csv.reader(csv_file, delimiter=',') - #Remove comments if any - commentsRemoved = [] + header_consumed = False + seenTheseIPsAlready = set() for row in csv_reader: - if not row[0].startswith('#'): - commentsRemoved.append(row) - #Remove header - commentsRemoved.pop(0) - seenTheseIPsAlready = [] - for row in commentsRemoved: + if not row: + continue + if row[0].startswith('#'): + continue + if not header_consumed: + header_consumed = True + continue # Accept optional 14th column 'sqm' but ignore here (validation focuses on core fields) circuitID, circuitName, deviceID, deviceName, ParentNode, mac, ipv4_input, ipv6_input, downloadMin, uploadMin, downloadMax, uploadMax, comment = row[0:13] # Must have circuitID, it's a unique identifier required for stateful changes to queue structure @@ -311,14 +337,14 @@ def validateNetworkAndDevices(): if ipEntry in seenTheseIPsAlready: warnings.warn("Provided IPv4 '" + ipEntry + "' in ShapedDevices.csv at row " + str(rowNum) + " is duplicate.", stacklevel=2) #devicesValidatedOrNot = False - seenTheseIPsAlready.append(ipEntry) + seenTheseIPsAlready.add(ipEntry) else: if (type(ipaddress.ip_network(ipEntry)) is ipaddress.IPv4Network) or (type(ipaddress.ip_address(ipEntry)) is ipaddress.IPv4Address): ipv4_subnets_and_hosts.extend(ipEntry) else: warnings.warn("Provided IPv4 '" + ipEntry + "' in ShapedDevices.csv at row " + str(rowNum) + " is not valid.", stacklevel=2) devicesValidatedOrNot = False - seenTheseIPsAlready.append(ipEntry) + seenTheseIPsAlready.add(ipEntry) except: warnings.warn("Provided IPv4 '" + ipv4_input + "' in ShapedDevices.csv at row " + str(rowNum) + " is not valid.", stacklevel=2) devicesValidatedOrNot = False @@ -333,14 +359,14 @@ def validateNetworkAndDevices(): if ipEntry in seenTheseIPsAlready: warnings.warn("Provided IPv6 '" + ipEntry + "' in ShapedDevices.csv at row " + str(rowNum) + " is duplicate.", stacklevel=2) devicesValidatedOrNot = False - seenTheseIPsAlready.append(ipEntry) + seenTheseIPsAlready.add(ipEntry) else: if (type(ipaddress.ip_network(ipEntry)) is ipaddress.IPv6Network) or (type(ipaddress.ip_address(ipEntry)) is ipaddress.IPv6Address): ipv6_subnets_and_hosts.extend(ipEntry) else: warnings.warn("Provided IPv6 '" + ipEntry + "' in ShapedDevices.csv at row " + str(rowNum) + " is not valid.", stacklevel=2) devicesValidatedOrNot = False - seenTheseIPsAlready.append(ipEntry) + seenTheseIPsAlready.add(ipEntry) except: warnings.warn("Provided IPv6 '" + ipv6_input + "' in ShapedDevices.csv at row " + str(rowNum) + " is not valid.", stacklevel=2) devicesValidatedOrNot = False @@ -404,19 +430,20 @@ def validateNetworkAndDevices(): def loadSubscriberCircuits(shapedDevicesFile): # Load Subscriber Circuits & Devices subscriberCircuits = [] - knownCircuitIDs = [] + circuitsById = {} counterForCircuitsWithoutParentNodes = 0 dictForCircuitsWithoutParentNodes = {} with open(shapedDevicesFile) as csv_file: csv_reader = csv.reader(csv_file, delimiter=',') - # Remove comments if any - commentsRemoved = [] + header_consumed = False for row in csv_reader: - if not row[0].startswith('#'): - commentsRemoved.append(row) - # Remove header - commentsRemoved.pop(0) - for row in commentsRemoved: + if not row: + continue + if row[0].startswith('#'): + continue + if not header_consumed: + header_consumed = True + continue # Optional per-circuit SQM override in last column sqm_override_token = '' if len(row) > 13: @@ -458,41 +485,39 @@ def loadSubscriberCircuits(shapedDevicesFile): # If there is something in the circuit ID field if circuitID != "": # Seen circuit before - if circuitID in knownCircuitIDs: - for circuit in subscriberCircuits: - if circuit['circuitID'] == circuitID: - if circuit['ParentNode'] != "none": - if circuit['ParentNode'] != ParentNode: - errorMessageString = "Device " + deviceName + " with deviceID " + deviceID + " had different Parent Node from other devices of circuit ID #" + circuitID - raise ValueError(errorMessageString) - # Check if bandwidth parameters match other cdevices of this same circuit ID, but only check if monitorOnlyMode is Off - if monitor_mode_only() == False: - if ((circuit['minDownload'] != float(downloadMin)) - or (circuit['minUpload'] != float(uploadMin)) - or (circuit['maxDownload'] != float(downloadMax)) - or (circuit['maxUpload'] != float(uploadMax))): - warnings.warn("Device " + deviceName + " with ID " + deviceID + " had different bandwidth parameters than other devices on this circuit. Will instead use the bandwidth parameters defined by the first device added to its circuit.", stacklevel=2) - # If this row specifies an SQM override, but the circuit already has a different one, warn and keep the first. - if sqm_override_token != '': - if 'sqm' in circuit: - if circuit['sqm'] != sqm_override_token: - warnings.warn("Device " + deviceName + " with ID " + deviceID + " had different SQM override than other devices on this circuit. Will instead use the SQM defined by the first device added to its circuit.", stacklevel=2) - else: - circuit['sqm'] = sqm_override_token - devicesListForCircuit = circuit['devices'] - thisDevice = { - "deviceID": deviceID, - "deviceName": deviceName, - "mac": mac, - "ipv4s": ipv4_subnets_and_hosts, - "ipv6s": ipv6_subnets_and_hosts, - "comment": comment - } - devicesListForCircuit.append(thisDevice) - circuit['devices'] = devicesListForCircuit + circuit = circuitsById.get(circuitID) + if circuit is not None: + if circuit['ParentNode'] != "none": + if circuit['ParentNode'] != ParentNode: + errorMessageString = "Device " + deviceName + " with deviceID " + deviceID + " had different Parent Node from other devices of circuit ID #" + circuitID + raise ValueError(errorMessageString) + # Check if bandwidth parameters match other cdevices of this same circuit ID, but only check if monitorOnlyMode is Off + if monitor_mode_only() == False: + if ((circuit['minDownload'] != float(downloadMin)) + or (circuit['minUpload'] != float(uploadMin)) + or (circuit['maxDownload'] != float(downloadMax)) + or (circuit['maxUpload'] != float(uploadMax))): + warnings.warn("Device " + deviceName + " with ID " + deviceID + " had different bandwidth parameters than other devices on this circuit. Will instead use the bandwidth parameters defined by the first device added to its circuit.", stacklevel=2) + # If this row specifies an SQM override, but the circuit already has a different one, warn and keep the first. + if sqm_override_token != '': + if 'sqm' in circuit: + if circuit['sqm'] != sqm_override_token: + warnings.warn("Device " + deviceName + " with ID " + deviceID + " had different SQM override than other devices on this circuit. Will instead use the SQM defined by the first device added to its circuit.", stacklevel=2) + else: + circuit['sqm'] = sqm_override_token + devicesListForCircuit = circuit['devices'] + thisDevice = { + "deviceID": deviceID, + "deviceName": deviceName, + "mac": mac, + "ipv4s": ipv4_subnets_and_hosts, + "ipv6s": ipv6_subnets_and_hosts, + "comment": comment + } + devicesListForCircuit.append(thisDevice) + circuit['devices'] = devicesListForCircuit # Have not seen circuit before else: - knownCircuitIDs.append(circuitID) if ParentNode == "": ParentNode = "none" #ParentNode = ParentNode.strip() @@ -525,6 +550,7 @@ def loadSubscriberCircuits(shapedDevicesFile): dictForCircuitsWithoutParentNodes[counterForCircuitsWithoutParentNodes] = ((float(downloadMax))+(float(uploadMax))) counterForCircuitsWithoutParentNodes += 1 subscriberCircuits.append(thisCircuit) + circuitsById[circuitID] = thisCircuit # If there is nothing in the circuit ID field else: # Copy deviceName to circuitName if none defined already @@ -569,6 +595,7 @@ def refreshShapers(): print("refreshShapers starting at " + datetime.now().strftime("%d/%m/%Y %H:%M:%S")) # Create a single batch of xdp update commands to execute together ipMapBatch = BatchedCommands() + requiredIpMappings = 0 # Warn user if enableActualShellCommands is False, because that would mean no actual commands are executing if enable_actual_shell_commands() == False: @@ -1611,6 +1638,7 @@ def refreshShapers(): # Define lists for hash filters print("Preparing TC commands") def traverseNetwork(data): + nonlocal requiredIpMappings # Cake needs help handling rates lower than 5 Mbps def sqmFixupRate(rate:int, sqm:str) -> str: @@ -1783,6 +1811,7 @@ def refreshShapers(): circuit.get('circuitID', ''), device.get('deviceID', ''), ) + requiredIpMappings += 1 #xdpCPUmapCommands.append('./bin/xdp_iphash_to_cpu_cmdline add --ip ' + str(ipv4) + ' --cpu ' + data[node]['cpuNum'] + ' --classid ' + circuit['classid']) if on_a_stick(): ipMapBatch.add_ip_mapping( @@ -1804,6 +1833,7 @@ def refreshShapers(): circuit.get('circuitID', ''), device.get('deviceID', ''), ) + requiredIpMappings += 1 #xdpCPUmapCommands.append('./bin/xdp_iphash_to_cpu_cmdline add --ip ' + str(ipv6) + ' --cpu ' + data[node]['cpuNum'] + ' --classid ' + circuit['classid']) if on_a_stick(): ipMapBatch.add_ip_mapping( @@ -1824,6 +1854,62 @@ def refreshShapers(): # Here is the actual call to the recursive traverseNetwork() function. traverseNetwork(network) + if enable_actual_shell_commands(): + ipMappingCapacity = xdp_ip_mapping_capacity() + print( + "Prepared " + + str(requiredIpMappings) + + " unique XDP IP mappings against capacity " + + str(ipMappingCapacity) + ) + if requiredIpMappings > ipMappingCapacity: + report_refresh_failure( + "XDP_IP_MAPPING_CAPACITY", + "Required XDP IP mappings (" + + str(requiredIpMappings) + + ") exceed current kernel map capacity (" + + str(ipMappingCapacity) + + "). Aborting refresh before apply.", + { + "required_ip_mappings": requiredIpMappings, + "kernel_map_capacity": ipMappingCapacity, + "queued_requests": ipMapBatch.length(), + "on_a_stick": on_a_stick(), + "shaped_devices_file": shapedDevicesFile, + "network_json_file": networkJSONfile, + }, + "XDP_IP_MAPPING_CAPACITY", + ) + + qdiscBudgetEstimate = bakery.estimate_qdisc_budget() + if not qdiscBudgetEstimate["ok"]: + interfaceCounts = qdiscBudgetEstimate["interfaces"] + sortedInterfaces = sorted(interfaceCounts.items()) + interfaceSummary = ", ".join( + f"{interface} estimated {count} qdiscs" + for interface, count in sortedInterfaces + ) + report_refresh_failure( + "TC_QDISC_CAPACITY", + "Planned queue model exceeds qdisc budget. " + + interfaceSummary + + "; safe budget " + + str(qdiscBudgetEstimate["safe_budget"]) + + ", kernel limit " + + str(qdiscBudgetEstimate["hard_limit"]) + + ". Aborting refresh before apply.", + { + "interfaces": dict(sortedInterfaces), + "safe_budget": qdiscBudgetEstimate["safe_budget"], + "hard_limit": qdiscBudgetEstimate["hard_limit"], + "on_a_stick": on_a_stick(), + "monitor_only": monitor_mode_only(), + "shaped_devices_file": shapedDevicesFile, + "network_json_file": networkJSONfile, + }, + "TC_QDISC_CAPACITY", + ) + # Save queuingStructure queuingStructure = {} queuingStructure['Network'] = network @@ -1882,7 +1968,19 @@ def refreshShapers(): numXdpCommands = ipMapBatch.length() if enable_actual_shell_commands(): ipMapBatch.finish_ip_mappings() - ipMapBatch.submit() + try: + ipMapBatch.submit() + except Exception as e: + report_refresh_failure( + "XDP_IP_MAPPING_APPLY_FAILED", + "Failed to apply XDP IP mappings: " + str(e), + { + "required_ip_mappings": requiredIpMappings, + "queued_requests": numXdpCommands, + "on_a_stick": on_a_stick(), + }, + "XDP_IP_MAPPING_APPLY_FAILED", + ) #for command in xdpCPUmapCommands: # logging.info(command) # commands = command.split(' ') @@ -2093,19 +2191,26 @@ if __name__ == '__main__': except Exception as e: print(f"Warning: could not remove planner state: {e}") - if args.validate: - status = validateNetworkAndDevices() - elif args.clearrules: - tearDown(interface_a(), interface_b()) - elif args.updateonly: - # Single-interface updates don't work at all right now. - if on_a_stick(): - print("--updateonly is not supported for single-interface configurations") - os._exit(-1) - refreshShapersUpdateOnly() - else: - # Refresh and/or set up queues - refreshShapers() + exit_code = 0 + try: + if args.validate: + status = validateNetworkAndDevices() + elif args.clearrules: + tearDown(interface_a(), interface_b()) + elif args.updateonly: + # Single-interface updates don't work at all right now. + if on_a_stick(): + print("--updateonly is not supported for single-interface configurations") + exit_code = -1 + else: + refreshShapersUpdateOnly() + else: + # Refresh and/or set up queues + refreshShapers() + except RefreshFailure: + exit_code = 1 + finally: + free_lock_file() - # Free the lock file - free_lock_file() + if exit_code != 0: + os._exit(exit_code) diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 315210ef..317da84c 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2075,9 +2075,11 @@ dependencies = [ "anyhow", "base64", "chrono", + "lqos_bakery", "lqos_bus", "lqos_config", "lqos_overrides", + "lqos_sys", "lqos_utils", "miniz_oxide", "nix", @@ -2099,6 +2101,7 @@ dependencies = [ "arc-swap", "criterion", "dashmap", + "lqos_bakery", "lqos_bus", "lqos_config", "lqos_sys", diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index 1cd1d287..191396f3 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -25,7 +25,7 @@ mod queue_math; mod utils; use crossbeam_channel::{Receiver, Sender}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; @@ -194,6 +194,12 @@ fn build_temp_add_cmd( /// Count of Bakery-Managed circuits that are currently active. pub static ACTIVE_CIRCUITS: AtomicUsize = AtomicUsize::new(0); +/// True while Bakery is applying a full reload batch to `tc`. +static FULL_RELOAD_IN_PROGRESS: AtomicBool = AtomicBool::new(false); +/// Hard kernel limit for auto-allocated qdisc handles on a single network interface. +pub const HARD_QDISC_HANDLE_LIMIT_PER_INTERFACE: usize = 32_768; +/// Conservative operational limit used to fail a full reload before the kernel limit is reached. +pub const SAFE_AUTO_QDISC_BUDGET_PER_INTERFACE: usize = 32_000; /// Maximum number of mapped circuits allowed without Insight. const DEFAULT_MAPPED_CIRCUITS_LIMIT: usize = 1000; /// Minimum interval between repeated mapped-circuit-limit urgent issues. @@ -208,6 +214,95 @@ static MQ_CREATED: AtomicBool = AtomicBool::new(false); /// Used to avoid racing live activation against initial class creation. static FIRST_COMMIT_APPLIED: AtomicBool = AtomicBool::new(false); +struct FullReloadScope; + +impl Drop for FullReloadScope { + fn drop(&mut self) { + FULL_RELOAD_IN_PROGRESS.store(false, Ordering::Relaxed); + } +} + +/// Estimated auto-allocated qdisc usage for a planned full reload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QdiscBudgetEstimate { + /// Estimated auto-allocated qdisc count grouped by network interface name. + pub interfaces: BTreeMap, + /// Conservative operational limit enforced before a full reload is committed. + pub safe_budget: usize, + /// Kernel hard limit for the auto-allocated qdisc handle pool on one interface. + pub hard_limit: usize, +} + +impl QdiscBudgetEstimate { + /// Returns `true` when all planned per-interface counts fit within the safe budget. + pub fn ok(&self) -> bool { + self.interfaces + .values() + .all(|count| *count <= self.safe_budget) + } +} + +fn find_arg_value<'a>(argv: &'a [String], key: &str) -> Option<&'a str> { + argv.windows(2) + .find_map(|pair| (pair[0] == key).then_some(pair[1].as_str())) +} + +fn auto_allocated_qdisc_identity(argv: &[String]) -> Option<(String, String)> { + if argv.len() < 2 || argv[0] != "qdisc" { + return None; + } + if !matches!(argv[1].as_str(), "add" | "replace") { + return None; + } + if argv.iter().any(|token| token == "handle") { + return None; + } + + let dev = find_arg_value(argv, "dev")?.to_string(); + let parent = find_arg_value(argv, "parent")?.to_string(); + Some((dev, parent)) +} + +/// Estimates auto-allocated qdisc usage for the current full-reload builder queue. +/// +/// This expands the queued Bakery commands through the same builder-path `tc` +/// argv generation used by a structural full reload, then counts only the +/// resulting qdiscs that do not specify an explicit `handle`. The estimate is +/// tracked per interface because Linux enforces qdisc auto-handle exhaustion on +/// a per-device basis. +pub fn estimate_full_reload_auto_qdisc_budget( + config: &Arc, + queue: &[BakeryCommands], +) -> QdiscBudgetEstimate { + let mut interfaces = BTreeMap::new(); + let mut seen_qdiscs = HashSet::new(); + + for command in queue { + let Some(builder_commands) = command.to_commands(config, ExecutionMode::Builder) else { + continue; + }; + for argv in builder_commands { + let Some((dev, parent)) = auto_allocated_qdisc_identity(&argv) else { + continue; + }; + if seen_qdiscs.insert((dev.clone(), parent)) { + *interfaces.entry(dev).or_insert(0) += 1; + } + } + } + + QdiscBudgetEstimate { + interfaces, + safe_budget: SAFE_AUTO_QDISC_BUDGET_PER_INTERFACE, + hard_limit: HARD_QDISC_HANDLE_LIMIT_PER_INTERFACE, + } +} + +/// Returns `true` while Bakery is applying a full reload batch to Linux `tc`. +pub fn full_reload_in_progress() -> bool { + FULL_RELOAD_IN_PROGRESS.load(Ordering::Relaxed) +} + #[derive(Debug, Default, Clone, Copy)] struct MappedLimitStats { enforced_limit: Option, @@ -1788,6 +1883,8 @@ fn full_reload( stormguard_overrides: &HashMap, ) { warn!("Bakery: Full reload triggered due to site or circuit changes."); + FULL_RELOAD_IN_PROGRESS.store(true, Ordering::Relaxed); + let _reload_scope = FullReloadScope; sites.clear(); circuits.clear(); live_circuits.clear(); @@ -1861,6 +1958,7 @@ fn apply_stormguard_overrides( #[cfg(test)] mod tests { use super::*; + use lqos_config::Config; fn mk_add_circuit(hash: i64, ip_addresses: &str) -> Arc { Arc::new(BakeryCommands::AddCircuit { @@ -1984,4 +2082,77 @@ mod tests { assert_eq!(stats.dropped_mapped, 0); assert_eq!(filtered.len(), 3); } + + #[test] + fn qdisc_budget_estimate_counts_auto_allocated_qdiscs_per_interface() { + let config = Arc::new(Config::default()); + let queue = vec![ + BakeryCommands::StartBatch, + BakeryCommands::MqSetup { + queues_available: 2, + stick_offset: 0, + }, + BakeryCommands::AddSite { + site_hash: 1, + parent_class_id: TcHandle::from_u32(0x1), + up_parent_class_id: TcHandle::from_u32(0x2), + class_minor: 0x10, + download_bandwidth_min: 50.0, + upload_bandwidth_min: 50.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + }, + BakeryCommands::AddCircuit { + circuit_hash: 2, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + ip_addresses: "192.0.2.1/32".to_string(), + sqm_override: None, + }, + BakeryCommands::CommitBatch, + ]; + + let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); + assert_eq!(estimate.interfaces.get("eth1"), Some(&5)); + assert_eq!(estimate.interfaces.get("eth0"), Some(&5)); + assert!(estimate.ok()); + } + + #[test] + fn qdisc_budget_estimate_skips_circuit_leaf_qdiscs_in_monitor_only_mode() { + let mut cfg = Config::default(); + cfg.queues.monitor_only = true; + let config = Arc::new(cfg); + let queue = vec![ + BakeryCommands::MqSetup { + queues_available: 1, + stick_offset: 0, + }, + BakeryCommands::AddCircuit { + circuit_hash: 2, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + ip_addresses: "192.0.2.1/32".to_string(), + sqm_override: None, + }, + ]; + + let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); + assert_eq!(estimate.interfaces.get("eth1"), Some(&2)); + assert_eq!(estimate.interfaces.get("eth0"), Some(&2)); + } } diff --git a/src/rust/lqos_bus/src/bus/mod.rs b/src/rust/lqos_bus/src/bus/mod.rs index fb042258..c85b53b7 100644 --- a/src/rust/lqos_bus/src/bus/mod.rs +++ b/src/rust/lqos_bus/src/bus/mod.rs @@ -30,20 +30,28 @@ pub const BUS_SOCKET_PATH: &str = "/run/lqos/bus"; /// that the directory exists. pub(crate) const BUS_SOCKET_DIRECTORY: &str = "/run/lqos"; +/// Errors returned by local bus clients while connecting to or communicating +/// with the `lqosd` Unix socket. #[derive(Error, Debug)] pub enum BusClientError { + /// The local bus socket does not yet exist or is not reachable. #[error( "Socket (typically /run/lqos/bus) not found. Check that lqosd is running, and you have permission to access the socket path." )] SocketNotFound, + /// The request could not be encoded for transport. #[error("Unable to encode request")] EncodingError, + /// The reply could not be decoded from the socket stream. #[error("Unable to decode request")] DecodingError, + /// Writing the request to the socket failed. #[error("Cannot write to socket")] StreamWriteError, + /// Reading the reply from the socket failed. #[error("Cannot read from socket")] StreamReadError, + /// The socket connection is no longer usable. #[error("Stream is no longer connected")] StreamNotConnected, } diff --git a/src/rust/lqos_bus/src/bus/response.rs b/src/rust/lqos_bus/src/bus/response.rs index 13abe3db..d60b3158 100644 --- a/src/rust/lqos_bus/src/bus/response.rs +++ b/src/rust/lqos_bus/src/bus/response.rs @@ -113,6 +113,12 @@ pub struct ExecutiveSummaryHeader { pub cake_queue_count: u64, /// Number of fq_codel queues being tracked. pub fq_codel_queue_count: u64, + /// Whether the displayed queue counts are intentionally held at their last-known values. + #[serde(default)] + pub queue_stats_stale: bool, + /// Whether Bakery is currently applying a full reload batch. + #[serde(default)] + pub bakery_reload_in_progress: bool, /// Whether Insight is connected. pub insight_connected: bool, } diff --git a/src/rust/lqos_bus/src/lib.rs b/src/rust/lqos_bus/src/lib.rs index 7d076a29..0ce5ab05 100644 --- a/src/rust/lqos_bus/src/lib.rs +++ b/src/rust/lqos_bus/src/lib.rs @@ -32,9 +32,9 @@ pub use bus::response::{ StormguardDebugDirection, StormguardDebugEntry, UrgentIssue, WarningLevel, }; pub use bus::{ - BUS_SOCKET_PATH, BlackboardSystem, BusReply, BusRequest, BusResponse, BusSession, - CakeDiffTinTransit, CakeDiffTransit, CakeTransit, LibreqosBusClient, QueueStoreTransit, - TopFlowType, UnixSocketServer, UrgentSeverity, UrgentSource, bus_request, + BUS_SOCKET_PATH, BlackboardSystem, BusClientError, BusReply, BusRequest, BusResponse, + BusSession, CakeDiffTinTransit, CakeDiffTransit, CakeTransit, LibreqosBusClient, + QueueStoreTransit, TopFlowType, UnixSocketServer, UrgentSeverity, UrgentSource, bus_request, }; pub use tc_handle::TcHandle; diff --git a/src/rust/lqos_python/Cargo.toml b/src/rust/lqos_python/Cargo.toml index 928cec88..aab57e9d 100644 --- a/src/rust/lqos_python/Cargo.toml +++ b/src/rust/lqos_python/Cargo.toml @@ -10,7 +10,9 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { workspace = true, features = ["extension-module"] } +lqos_bakery = { path = "../lqos_bakery" } lqos_bus = { path = "../lqos_bus" } +lqos_sys = { path = "../lqos_sys" } lqos_utils = { path = "../lqos_utils" } lqos_config = { path = "../lqos_config" } lqos_overrides = { path = "../lqos_overrides" } diff --git a/src/rust/lqos_python/src/blocking.rs b/src/rust/lqos_python/src/blocking.rs index 58361351..0b9c99c6 100644 --- a/src/rust/lqos_python/src/blocking.rs +++ b/src/rust/lqos_python/src/blocking.rs @@ -1,5 +1,9 @@ -use anyhow::Result; -use lqos_bus::{BusRequest, BusResponse, bus_request}; +use anyhow::{Error, Result}; +use lqos_bus::{BusClientError, BusRequest, BusResponse, bus_request}; +use std::{ + thread::sleep, + time::{Duration, Instant}, +}; pub fn run_query(requests: Vec) -> Result> { let mut replies = Vec::with_capacity(8); @@ -11,3 +15,35 @@ pub fn run_query(requests: Vec) -> Result> { Ok(replies) }) } + +fn should_retry_bus_error(error: &Error) -> bool { + let Some(bus_error) = error.downcast_ref::() else { + return false; + }; + matches!( + bus_error, + BusClientError::SocketNotFound + | BusClientError::StreamReadError + | BusClientError::StreamWriteError + ) +} + +/// Runs a bus query, retrying briefly while the local bus socket is still starting up. +pub fn run_query_wait_for_bus( + requests: Vec, + timeout: Duration, + retry_interval: Duration, +) -> Result> { + let deadline = Instant::now() + timeout; + loop { + match run_query(requests.clone()) { + Ok(replies) => return Ok(replies), + Err(error) => { + if !should_retry_bus_error(&error) || Instant::now() >= deadline { + return Err(error); + } + sleep(retry_interval); + } + } + } +} diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index 48beb564..5e64136b 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -17,7 +17,8 @@ use std::{ }; mod blocking; use anyhow::{Error, Result}; -use blocking::run_query; +use blocking::{run_query, run_query_wait_for_bus}; +use lqos_bakery::estimate_full_reload_auto_qdisc_budget; use sysinfo::System; mod device_weights; use base64::Engine as _; @@ -583,6 +584,7 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(delete_ip_mapping, m)?)?; m.add_function(wrap_pyfunction!(add_ip_mapping, m)?)?; m.add_function(wrap_pyfunction!(validate_shaped_devices, m)?)?; + m.add_function(wrap_pyfunction!(wait_for_bus_ready, m)?)?; m.add_function(wrap_pyfunction!(is_libre_already_running, m)?)?; m.add_function(wrap_pyfunction!(create_lock_file, m)?)?; m.add_function(wrap_pyfunction!(free_lock_file, m)?)?; @@ -696,6 +698,7 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(scheduler_error, m)?)?; m.add_function(wrap_pyfunction!(scheduler_output, m)?)?; m.add_function(wrap_pyfunction!(submit_urgent_issue, m)?)?; + m.add_function(wrap_pyfunction!(xdp_ip_mapping_capacity, m)?)?; m.add_function(wrap_pyfunction!(is_insight_enabled, m)?)?; m.add_function(wrap_pyfunction!(log_info, m)?)?; m.add_function(wrap_pyfunction!(hash_to_i64, m)?)?; @@ -837,13 +840,34 @@ fn add_ip_mapping( ) -> PyResult<()> { let request = parse_add_ip(&ip, &classid, &cpu, upload, &circuit_id, &device_id); if let Ok(request) = request { - run_query(vec![request]).unwrap(); + let responses = run_query(vec![request]).map_err(|e| PyOSError::new_err(e.to_string()))?; + for response in responses { + if let BusResponse::Fail(message) = response { + return Err(PyOSError::new_err(message)); + } + } Ok(()) } else { Err(PyOSError::new_err(request.err().unwrap().to_string())) } } +fn summarize_failure_examples(failures: &BTreeMap) -> String { + const MAX_EXAMPLES: usize = 3; + failures + .iter() + .take(MAX_EXAMPLES) + .map(|(message, count)| { + if *count > 1 { + format!("{message} (x{count})") + } else { + message.clone() + } + }) + .collect::>() + .join("; ") +} + #[pyclass] /// Collects IP mapping commands so they can be submitted to `lqosd` in batches. pub struct BatchedCommands { @@ -899,22 +923,50 @@ impl BatchedCommands { /// Sends queued requests to `lqosd` in chunks and returns how many were submitted. pub fn submit(&mut self) -> PyResult { const MAX_BATH_SIZE: usize = 512; - // We're draining the request list out, which is a move that - // *should* be elided by the optimizing compiler. let len = self.batch.len(); + let mut failed_requests = 0usize; + let mut failure_examples: BTreeMap = BTreeMap::new(); while !self.batch.is_empty() { let batch_size = usize::min(MAX_BATH_SIZE, self.batch.len()); let batch: Vec = self.batch.drain(0..batch_size).collect(); - run_query(batch).unwrap(); + let responses = run_query(batch).map_err(|e| { + PyOSError::new_err(format!("IP mapping batch transport failed: {e}")) + })?; + responses.into_iter().for_each(|response| { + if let BusResponse::Fail(message) = response { + failed_requests += 1; + *failure_examples.entry(message).or_insert(0) += 1; + } + }); + } + if failed_requests > 0 { + let example_text = summarize_failure_examples(&failure_examples); + return Err(PyOSError::new_err(format!( + "IP mapping apply failed for {failed_requests} of {len} queued requests. Examples: {example_text}" + ))); } Ok(len) } } +/// Returns the current XDP IP-mapping capacity. +/// +/// If the live pinned map exists, this reflects its `max_entries`; otherwise it +/// falls back to the compiled default capacity. +#[pyfunction] +fn xdp_ip_mapping_capacity() -> PyResult { + Ok(lqos_sys::ip_mapping_capacity()) +} + /// Requests Rust-side validation of `ShapedDevices.csv` #[pyfunction] fn validate_shaped_devices() -> PyResult { - let result = run_query(vec![BusRequest::ValidateShapedDevicesCsv]).unwrap(); + let result = run_query_wait_for_bus( + vec![BusRequest::ValidateShapedDevicesCsv], + Duration::from_secs(5), + Duration::from_millis(100), + ) + .map_err(|e| PyOSError::new_err(format!("Unable to validate shaped devices: {e}")))?; for response in result.iter() { match response { BusResponse::Ack => return Ok("OK".to_string()), @@ -925,6 +977,29 @@ fn validate_shaped_devices() -> PyResult { Ok("".to_string()) } +/// Waits until the local `lqosd` bus is ready to answer requests. +/// +/// This is intended for scheduler startup sequencing. It retries only for +/// short-lived socket or handshake errors while `lqosd` is still binding the +/// bus socket. +#[pyfunction(signature = (timeout_ms = 5000))] +fn wait_for_bus_ready(timeout_ms: u64) -> PyResult { + let replies = run_query_wait_for_bus( + vec![BusRequest::Ping], + Duration::from_millis(timeout_ms), + Duration::from_millis(100), + ) + .map_err(|e| PyOSError::new_err(format!("Timed out waiting for lqosd bus readiness: {e}")))?; + for response in replies { + if let BusResponse::Ack = response { + return Ok(true); + } + } + Err(PyOSError::new_err( + "Timed out waiting for lqosd bus readiness: bus did not acknowledge ping", + )) +} + /// Returns a Python list of dictionaries representing persistent devices for ShapedDevices.csv /// The dictionary keys mirror the normalized loader used in LibreQoS.py: /// circuitID, circuitName, deviceID, deviceName, ParentNode, mac, @@ -2073,6 +2148,68 @@ enum BakeryCommands { }, } +impl BakeryCommands { + fn as_runtime_command(&self) -> lqos_bakery::BakeryCommands { + match self { + BakeryCommands::StartBatch => lqos_bakery::BakeryCommands::StartBatch, + BakeryCommands::Commit => lqos_bakery::BakeryCommands::CommitBatch, + BakeryCommands::MqSetup { + queues_available, + stick_offset, + } => lqos_bakery::BakeryCommands::MqSetup { + queues_available: *queues_available, + stick_offset: *stick_offset, + }, + BakeryCommands::AddSite { + site_hash, + parent_class_id, + up_parent_class_id, + class_minor, + download_bandwidth_min, + upload_bandwidth_min, + download_bandwidth_max, + upload_bandwidth_max, + } => lqos_bakery::BakeryCommands::AddSite { + site_hash: *site_hash, + parent_class_id: *parent_class_id, + up_parent_class_id: *up_parent_class_id, + class_minor: *class_minor, + download_bandwidth_min: *download_bandwidth_min, + upload_bandwidth_min: *upload_bandwidth_min, + download_bandwidth_max: *download_bandwidth_max, + upload_bandwidth_max: *upload_bandwidth_max, + }, + BakeryCommands::AddCircuit { + circuit_hash, + parent_class_id, + up_parent_class_id, + class_minor, + download_bandwidth_min, + upload_bandwidth_min, + download_bandwidth_max, + upload_bandwidth_max, + class_major, + up_class_major, + ip_addresses, + sqm_override, + } => lqos_bakery::BakeryCommands::AddCircuit { + circuit_hash: *circuit_hash, + parent_class_id: *parent_class_id, + up_parent_class_id: *up_parent_class_id, + class_minor: *class_minor, + download_bandwidth_min: *download_bandwidth_min, + upload_bandwidth_min: *upload_bandwidth_min, + download_bandwidth_max: *download_bandwidth_max, + upload_bandwidth_max: *upload_bandwidth_max, + class_major: *class_major, + up_class_major: *up_class_major, + ip_addresses: ip_addresses.clone(), + sqm_override: sqm_override.clone(), + }, + } + } +} + #[pyclass] /// Queues Bakery operations for batched submission to the LibreQoS daemon. pub struct Bakery { @@ -2105,7 +2242,10 @@ impl Bakery { .build() .unwrap() .block_on(async { - let mut bus = lqos_bus::LibreqosBusClient::new().await.unwrap(); + let Ok(mut bus) = lqos_bus::LibreqosBusClient::new().await else { + eprintln!("Failed to connect to lqosd bus for Bakery commit"); + return; + }; let chunks = queue.chunks(1024); for chunk in chunks { let mut requests = Vec::new(); @@ -2191,6 +2331,31 @@ impl Bakery { Ok(()) } + /// Estimates whether the queued full-reload batch fits within the per-interface qdisc budget. + pub fn estimate_qdisc_budget(&self, py: Python) -> PyResult { + let config = lqos_config::load_config().map_err(|e| PyOSError::new_err(e.to_string()))?; + let queue: Vec = self + .queue + .iter() + .map(BakeryCommands::as_runtime_command) + .collect(); + let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); + + let result = PyDict::new(py); + let interfaces = PyDict::new(py); + let is_ok = estimate.ok(); + + for (interface, count) in estimate.interfaces { + interfaces.set_item(interface, count)?; + } + + result.set_item("interfaces", interfaces)?; + result.set_item("safe_budget", estimate.safe_budget)?; + result.set_item("hard_limit", estimate.hard_limit)?; + result.set_item("ok", is_ok)?; + Ok(result.into()) + } + /// Queues multi-queue setup for the target shaper. pub fn setup_mq(&mut self, queues_available: usize, stick_offset: usize) -> PyResult<()> { self.queue.push(BakeryCommands::MqSetup { @@ -2383,3 +2548,23 @@ pub fn is_insight_enabled() -> PyResult { pub fn hash_to_i64(text: String) -> PyResult { Ok(lqos_utils::hash_to_i64(&text)) } + +#[cfg(test)] +mod tests { + use super::summarize_failure_examples; + use std::collections::BTreeMap; + + #[test] + fn summarize_failure_examples_limits_output() { + let mut failures = BTreeMap::new(); + failures.insert("alpha".to_string(), 2); + failures.insert("beta".to_string(), 1); + failures.insert("delta".to_string(), 1); + failures.insert("gamma".to_string(), 1); + + assert_eq!( + summarize_failure_examples(&failures), + "alpha (x2); beta; delta" + ); + } +} diff --git a/src/rust/lqos_queue_tracker/Cargo.toml b/src/rust/lqos_queue_tracker/Cargo.toml index d6dcd712..64333321 100644 --- a/src/rust/lqos_queue_tracker/Cargo.toml +++ b/src/rust/lqos_queue_tracker/Cargo.toml @@ -9,6 +9,7 @@ thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } lqos_bus = { path = "../lqos_bus" } +lqos_bakery = { path = "../lqos_bakery" } lqos_config = { path = "../lqos_config" } lqos_sys = { path = "../lqos_sys" } lqos_utils = { path = "../lqos_utils" } diff --git a/src/rust/lqos_queue_tracker/src/lib.rs b/src/rust/lqos_queue_tracker/src/lib.rs index 64291d51..ef3de6db 100644 --- a/src/rust/lqos_queue_tracker/src/lib.rs +++ b/src/rust/lqos_queue_tracker/src/lib.rs @@ -26,5 +26,5 @@ pub use queue_structure::{ }; pub use queue_types::deserialize_tc_tree; // Exported for the benchmarker pub use tracking::spawn_queue_monitor; -pub use tracking::{ALL_QUEUE_SUMMARY, QueueCounts, TOTAL_QUEUE_STATS}; +pub use tracking::{ALL_QUEUE_SUMMARY, QueueCounts, TOTAL_QUEUE_STATS, queue_stats_stale}; pub use tracking::{add_watched_queue, still_watching}; diff --git a/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs b/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs index 58e89c3f..7a3b8407 100644 --- a/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs +++ b/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs @@ -1,7 +1,6 @@ use crate::queue_structure::{ queue_network::QueueNetwork, queue_node::QueueNode, read_queueing_structure, }; -use crate::tracking::ALL_QUEUE_SUMMARY; use arc_swap::ArcSwap; use lqos_utils::file_watcher::FileWatcher; use once_cell::sync::Lazy; @@ -94,7 +93,6 @@ fn update_queue_structure() { maybe_queues: Some(queues.clone()), }; let effective_node_rates = build_effective_node_rates(&queues); - ALL_QUEUE_SUMMARY.clear(); QUEUE_STRUCTURE.store(Arc::new(new_queue_structure)); EFFECTIVE_NODE_RATES.store(Arc::new(effective_node_rates)); QUEUE_STRUCTURE_CHANGED_STORMGUARD.store(true, std::sync::atomic::Ordering::Relaxed); @@ -108,7 +106,6 @@ fn update_queue_structure() { warn!( "Failed to load queuingStructure.json ({err:?}); leaving queue structure unavailable" ); - ALL_QUEUE_SUMMARY.clear(); QUEUE_STRUCTURE.store(Arc::new(QueueStructure { maybe_queues: None })); EFFECTIVE_NODE_RATES.store(Arc::new(HashMap::new())); } diff --git a/src/rust/lqos_queue_tracker/src/tracking/mod.rs b/src/rust/lqos_queue_tracker/src/tracking/mod.rs index 8bf79af1..3c3fdb3e 100644 --- a/src/rust/lqos_queue_tracker/src/tracking/mod.rs +++ b/src/rust/lqos_queue_tracker/src/tracking/mod.rs @@ -2,7 +2,9 @@ use crate::{ circuit_to_queue::CIRCUIT_TO_QUEUE, interval::QUEUE_MONITOR_INTERVAL, queue_store::QueueStore, tracking::reader::read_named_queue_from_interface, }; +use lqos_bakery::full_reload_in_progress; use lqos_utils::fdtimer::periodic; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use timerfd::{SetTimeFlags, TimerFd, TimerState}; use tracing::{debug, error, warn}; @@ -18,6 +20,16 @@ use crate::tracking::reader::read_all_queues_from_interface; use watched_queues::WATCHED_QUEUES; pub use watched_queues::{add_watched_queue, still_watching}; +static QUEUE_STATS_STALE: AtomicBool = AtomicBool::new(false); + +/// Returns `true` when queue counts are intentionally held at their last-known values. +/// +/// This is currently used during Bakery full reloads, where shelling out to `tc` +/// for full-queue snapshots is too expensive to provide reliable live counts. +pub fn queue_stats_stale() -> bool { + QUEUE_STATS_STALE.load(Ordering::Relaxed) +} + fn track_queues() { if WATCHED_QUEUES.is_empty() { //info!("No queues marked for read."); @@ -154,6 +166,12 @@ fn connect_queues_to_circuit_up( fn all_queue_reader() { let start = Instant::now(); + if full_reload_in_progress() { + QUEUE_STATS_STALE.store(true, Ordering::Relaxed); + debug!("(TC monitor) Skipping full queue read during Bakery full reload"); + return; + } + let structure = QUEUE_STRUCTURE.load(); if let Some(structure) = &structure.maybe_queues { if let Ok(config) = lqos_config::load_config() { @@ -200,10 +218,13 @@ fn all_queue_reader() { //println!("{}", download.len() + upload.len()); ALL_QUEUE_SUMMARY.ingest_batch(download, upload, queue_counts); + QUEUE_STATS_STALE.store(false, Ordering::Relaxed); } else { + QUEUE_STATS_STALE.store(true, Ordering::Relaxed); warn!("(TC monitor) Unable to read configuration"); } } else { + QUEUE_STATS_STALE.store(true, Ordering::Relaxed); warn!("(TC monitor) Not reading queues due to structure not yet ready"); } let elapsed = start.elapsed(); diff --git a/src/rust/lqos_sys/src/lib.rs b/src/rust/lqos_sys/src/lib.rs index 94ffb597..d7f7a462 100644 --- a/src/rust/lqos_sys/src/lib.rs +++ b/src/rust/lqos_sys/src/lib.rs @@ -32,6 +32,7 @@ pub use ip_mapping::{ pub use kernel_wrapper::LibreQoSKernels; pub use linux::num_possible_cpus; pub use lqos_kernel::interface_name_to_index; +pub use lqos_kernel::ip_mapping_capacity; pub use lqos_kernel::max_tracked_ips; pub use lqos_kernel::unload_xdp_from_interface; pub use throughput::{HostCounter, throughput_for_each}; diff --git a/src/rust/lqos_sys/src/lqos_kernel.rs b/src/rust/lqos_sys/src/lqos_kernel.rs index 5d57b3ac..7094d44c 100644 --- a/src/rust/lqos_sys/src/lqos_kernel.rs +++ b/src/rust/lqos_sys/src/lqos_kernel.rs @@ -37,6 +37,49 @@ pub fn max_tracked_ips() -> usize { (unsafe { bpf::max_tracker_ips() }) as usize } +fn pinned_map_max_entries(path: &str) -> Result> { + if !Path::new(path).exists() { + return Ok(None); + } + + let path_c = CString::new(path)?; + let fd = unsafe { bpf_obj_get(path_c.as_ptr()) }; + if fd < 0 { + warn!("Unable to open pinned BPF map '{path}' for capacity check (fd={fd})"); + return Ok(None); + } + + let mut info = MaybeUninit::::zeroed(); + let mut len: u32 = std::mem::size_of::() as u32; + let err = unsafe { bpf_obj_get_info_by_fd(fd, info.as_mut_ptr() as *mut c_void, &mut len) }; + unsafe { + close(fd); + } + if err != 0 { + return Err(Error::msg(format!( + "Unable to query pinned BPF map '{path}' capacity (err={err})." + ))); + } + + let info = unsafe { info.assume_init() }; + Ok(Some(info.max_entries)) +} + +/// Returns the currently available IP-mapping capacity. +/// +/// If the live pinned map exists, this returns its `max_entries` value. +/// Otherwise, it falls back to the compiled `MAX_TRACKED_IPS` constant. +pub fn ip_mapping_capacity() -> usize { + match pinned_map_max_entries("/sys/fs/bpf/map_ip_to_cpu_and_tc") { + Ok(Some(entries)) => entries as usize, + Ok(None) => max_tracked_ips(), + Err(e) => { + warn!("Unable to determine live IP-mapping capacity: {e:?}"); + max_tracked_ips() + } + } +} + pub fn check_root() -> Result<()> { unsafe { if geteuid() == 0 { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js index 7db2c186..6ce8a04e 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js @@ -92,7 +92,16 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { { label: "CAKE", value: formatCount(header.cake_queue_count) }, { label: "fq-codel", value: formatCount(header.fq_codel_queue_count) }, ]; - return this.groupCard("Queues", "fa-stream", "text-secondary", items, false, true); + return this.groupCard( + "Queues", + "fa-stream", + "text-secondary", + items, + false, + true, + this.queueStatusBadge(header), + this.queueStatusMessage(header), + ); } throughputCard() { @@ -142,7 +151,28 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { `; } - groupCard(title, icon, accent, items, allowHtmlLabel = false, allowAlerts = false) { + queueStatusBadge(header) { + if (!header?.queue_stats_stale) { + return ""; + } + const label = header?.bakery_reload_in_progress ? "Reloading" : "Stale"; + const title = header?.bakery_reload_in_progress + ? "Queue counts are showing the last known values while Bakery applies a full reload." + : "Queue counts are showing the last known values while live queue polling catches up."; + return `${label}`; + } + + queueStatusMessage(header) { + if (!header?.queue_stats_stale) { + return ""; + } + const text = header?.bakery_reload_in_progress + ? "Live queue polling is paused during the current Bakery reload." + : "Live queue polling is behind; values shown are the last known counts."; + return `
${text}
`; + } + + groupCard(title, icon, accent, items, allowHtmlLabel = false, allowAlerts = false, statusBadge = "", footer = "") { const hasZero = allowAlerts && items.some(item => this.isZero(item.value)); const rows = items.map((item, idx) => `
@@ -156,8 +186,12 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet {
-
${title}
+
+
${title}
+ ${statusBadge} +
${rows}
+ ${footer}
diff --git a/src/rust/lqosd/src/node_manager/local_api.rs b/src/rust/lqosd/src/node_manager/local_api.rs index a2fab021..e0f4cd28 100644 --- a/src/rust/lqosd/src/node_manager/local_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api.rs @@ -1,6 +1,6 @@ pub(crate) mod circuit; -pub(crate) mod circuit_live; pub(crate) mod circuit_count; +pub(crate) mod circuit_live; pub(crate) mod config; pub(crate) mod cpu_affinity; pub(crate) mod dashboard_themes; diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_live.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_live.rs index 7ea3b94f..6f631f89 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_live.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_live.rs @@ -35,15 +35,18 @@ pub fn circuit_live_metrics(query: &CircuitMetricsQuery) -> Vec bool); const TOPOLOGY_SOURCE_INTEGRATIONS: [TopologySourceIntegration; 7] = [ ("UISP", |config| config.uisp_integration.enable_uisp), ("Splynx", |config| config.splynx_integration.enable_splynx), - ("Powercode", |config| config.powercode_integration.enable_powercode), + ("Powercode", |config| { + config.powercode_integration.enable_powercode + }), ("Sonar", |config| config.sonar_integration.enable_sonar), ("Netzur", |config| { config @@ -112,8 +114,8 @@ pub fn active_topology_source_integrations(config: &Config) -> Vec<&'static str> } fn topology_editor_lock_message() -> Result, String> { - let config = lqos_config::load_config() - .map_err(|e| format!("Unable to load LibreQoS config: {e}"))?; + let config = + lqos_config::load_config().map_err(|e| format!("Unable to load LibreQoS config: {e}"))?; let integrations = active_topology_source_integrations(config.as_ref()); if integrations.is_empty() { return Ok(None); @@ -147,11 +149,7 @@ fn persist_network_json(network_json: &Value) { fn normalize_sqm_override(raw: &Option) -> Option { let token = raw.as_deref().unwrap_or("").trim().to_lowercase(); - if token.is_empty() { - None - } else { - Some(token) - } + if token.is_empty() { None } else { Some(token) } } fn normalize_shaped_device(device: &mut ShapedDevice) { @@ -244,10 +242,15 @@ fn validate_shaped_devices(devices: &[ShapedDevice]) -> Result<(), String> { )); } } else if !device.parent_node.is_empty() && !nodes.contains(&device.parent_node) { - return Err(format!("{label}: Parent node '{}' does not exist", device.parent_node)); + return Err(format!( + "{label}: Parent node '{}' does not exist", + device.parent_node + )); } if device.ipv4.is_empty() && device.ipv6.is_empty() { - return Err(format!("{label}: At least one IPv4 or IPv6 address is required")); + return Err(format!( + "{label}: At least one IPv4 or IPv6 address is required" + )); } for (addr, prefix) in &device.ipv4 { let key = format!("{addr}/{prefix}"); @@ -342,7 +345,10 @@ pub fn update_network_and_devices_data( /// /// Returns an error string when the caller is unauthorized, when integration- /// managed topology editing is locked, or when persistence fails. -pub fn update_network_json_only_data(login: LoginResult, network_json: Value) -> Result<(), String> { +pub fn update_network_json_only_data( + login: LoginResult, + network_json: Value, +) -> Result<(), String> { if login != LoginResult::Admin { return Err("Unauthorized".to_string()); } @@ -355,7 +361,10 @@ pub fn update_network_json_only_data(login: LoginResult, network_json: Value) -> /// Returns one shaped device row by device identifier for administrative /// callers. -pub fn get_shaped_device_data(login: LoginResult, device_id: String) -> Result, StatusCode> { +pub fn get_shaped_device_data( + login: LoginResult, + device_id: String, +) -> Result, StatusCode> { if login != LoginResult::Admin { return Err(StatusCode::FORBIDDEN); } @@ -372,7 +381,10 @@ pub fn get_shaped_device_data(login: LoginResult, device_id: String) -> Result Result { +pub fn create_shaped_device_data( + login: LoginResult, + device: ShapedDevice, +) -> Result { if login != LoginResult::Admin { return Err("Unauthorized".to_string()); } diff --git a/src/rust/lqosd/src/node_manager/local_api/executive.rs b/src/rust/lqosd/src/node_manager/local_api/executive.rs index dd5042b2..da282be4 100644 --- a/src/rust/lqosd/src/node_manager/local_api/executive.rs +++ b/src/rust/lqosd/src/node_manager/local_api/executive.rs @@ -246,11 +246,7 @@ fn normalize_page_size(requested: Option, default_size: usize) -> usize { fn normalized_search(search: &Option) -> Option { let value = search.as_deref().unwrap_or("").trim().to_lowercase(); - if value.is_empty() { - None - } else { - Some(value) - } + if value.is_empty() { None } else { Some(value) } } fn normalized_entity_kinds(mut kinds: Vec) -> Vec { @@ -340,11 +336,7 @@ fn leaderboard_matches_search(row: &ExecutiveLeaderboardRow, search: &Option { + ExecutiveLeaderboardRow::TopAsnByTraffic { asn, asn_name, .. } => { asn.to_string().contains(search) || asn_name .as_ref() @@ -386,7 +378,10 @@ pub fn executive_heatmap_page(query: ExecutiveHeatmapPageQuery) -> ExecutiveHeat .as_ref() .map(|id| id.to_lowercase().contains(search)) .unwrap_or(false) - || row.asn.map(|asn| asn.to_string().contains(search)).unwrap_or(false) + || row + .asn + .map(|asn| asn.to_string().contains(search)) + .unwrap_or(false) }) .collect::>(); @@ -434,7 +429,9 @@ pub fn executive_heatmap_page(query: ExecutiveHeatmapPageQuery) -> ExecutiveHeat } /// Returns one filtered executive leaderboard page. -pub fn executive_leaderboard_page(query: ExecutiveLeaderboardPageQuery) -> ExecutiveLeaderboardPage { +pub fn executive_leaderboard_page( + query: ExecutiveLeaderboardPageQuery, +) -> ExecutiveLeaderboardPage { let snapshot = fresh_executive_cache_snapshot(); let page = query.page.unwrap_or(0); let page_size = normalize_page_size(query.page_size, DEFAULT_EXECUTIVE_LEADERBOARD_PAGE_SIZE); diff --git a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs index db013a2a..7ccca272 100644 --- a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs +++ b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs @@ -100,10 +100,12 @@ impl ExecutiveCacheSnapshot { upload: array_to_vec(&entity.heatmap.retransmit_up), }), ExecutiveMetric::Qoo => entity.qoq_blocks.as_ref().map(qoq_to_split_blocks), - ExecutiveMetric::Download | ExecutiveMetric::Upload => Some(ExecutiveSplitBlocks { - download: array_to_vec(&entity.heatmap.download), - upload: array_to_vec(&entity.heatmap.upload), - }), + ExecutiveMetric::Download | ExecutiveMetric::Upload => { + Some(ExecutiveSplitBlocks { + download: array_to_vec(&entity.heatmap.download), + upload: array_to_vec(&entity.heatmap.upload), + }) + } ExecutiveMetric::Rtt => None, }, rtt_blocks: match metric { @@ -202,24 +204,40 @@ fn qoo_sample_count(blocks: Option<&QoqHeatmapBlocks>) -> usize { fn sort_score_for_metric(entity: &ExecutiveEntitySnapshot, metric: &ExecutiveMetric) -> f32 { match metric { - ExecutiveMetric::Download => latest_value(&entity.heatmap.download).unwrap_or(f32::NEG_INFINITY), - ExecutiveMetric::Upload => latest_value(&entity.heatmap.upload).unwrap_or(f32::NEG_INFINITY), + ExecutiveMetric::Download => { + latest_value(&entity.heatmap.download).unwrap_or(f32::NEG_INFINITY) + } + ExecutiveMetric::Upload => { + latest_value(&entity.heatmap.upload).unwrap_or(f32::NEG_INFINITY) + } ExecutiveMetric::Retransmit => { let latest = latest_value(&entity.heatmap.retransmit).unwrap_or(f32::NEG_INFINITY); let count = non_null_count(&entity.heatmap.retransmit); - let penalty = if count < MIN_HEATMAP_SAMPLES { 1000.0 } else { 0.0 }; + let penalty = if count < MIN_HEATMAP_SAMPLES { + 1000.0 + } else { + 0.0 + }; latest + COUNT_WEIGHT * (count as f32 / 15.0) - penalty } ExecutiveMetric::Rtt => { let latest = latest_value(&entity.heatmap.rtt).unwrap_or(f32::NEG_INFINITY); let count = non_null_count(&entity.heatmap.rtt); - let penalty = if count < MIN_HEATMAP_SAMPLES { 1000.0 } else { 0.0 }; + let penalty = if count < MIN_HEATMAP_SAMPLES { + 1000.0 + } else { + 0.0 + }; latest + COUNT_WEIGHT * (count as f32 / 15.0) - penalty } ExecutiveMetric::Qoo => { let latest = latest_qoo(entity.qoq_blocks.as_ref()).unwrap_or(f32::INFINITY); let count = qoo_sample_count(entity.qoq_blocks.as_ref()); - let penalty = if count < MIN_HEATMAP_SAMPLES { 1000.0 } else { 0.0 }; + let penalty = if count < MIN_HEATMAP_SAMPLES { + 1000.0 + } else { + 0.0 + }; -latest + COUNT_WEIGHT * (count as f32 / 15.0) - penalty } } @@ -264,7 +282,12 @@ fn top_metric_rows( ) -> Vec { let mut ranked = entities .iter() - .filter(|entity| matches!(entity.entity_kind, ExecutiveEntityKind::Site | ExecutiveEntityKind::Circuit)) + .filter(|entity| { + matches!( + entity.entity_kind, + ExecutiveEntityKind::Site | ExecutiveEntityKind::Circuit + ) + }) .cloned() .collect::>(); ranked.sort_by(|left, right| { @@ -360,7 +383,10 @@ fn build_leaderboard_rows( .collect::>(); worst_sites.sort_by(|left, right| match (left, right) { ( - ExecutiveLeaderboardRow::WorstSiteByRtt { median_rtt_ms: left, .. }, + ExecutiveLeaderboardRow::WorstSiteByRtt { + median_rtt_ms: left, + .. + }, ExecutiveLeaderboardRow::WorstSiteByRtt { median_rtt_ms: right, .. @@ -548,10 +574,7 @@ fn asn_heatmap_rows() -> Vec { } } -fn node_path_names( - idx: usize, - nodes: &[lqos_config::NetworkJsonNode], -) -> Option> { +fn node_path_names(idx: usize, nodes: &[lqos_config::NetworkJsonNode]) -> Option> { let node = nodes.get(idx)?; if node.name == "Root" { return Some(vec!["Root".to_string()]); @@ -787,7 +810,11 @@ pub(crate) fn rebuild_executive_cache_snapshot() -> Arc let header = header_from_response(executive_summary_header()); let global = heatmap_blocks_from_response(global_heatmap()); let global_qoq = THROUGHPUT_TRACKER.global_qoq_heatmap.lock().blocks(); - let entities = entity_snapshots(circuit_heatmap_rows(), site_heatmap_rows(), asn_heatmap_rows()); + let entities = entity_snapshots( + circuit_heatmap_rows(), + site_heatmap_rows(), + asn_heatmap_rows(), + ); let oversubscribed_sites = build_oversubscribed_sites(&entities); let top_download = top_metric_rows(&entities, ExecutiveMetric::Download, EXECUTIVE_TOP_LIMIT); let top_upload = top_metric_rows(&entities, ExecutiveMetric::Upload, EXECUTIVE_TOP_LIMIT); diff --git a/src/rust/lqosd/src/node_manager/local_api/node_rate_overrides.rs b/src/rust/lqosd/src/node_manager/local_api/node_rate_overrides.rs index 935d3a3a..370f0b13 100644 --- a/src/rust/lqosd/src/node_manager/local_api/node_rate_overrides.rs +++ b/src/rust/lqosd/src/node_manager/local_api/node_rate_overrides.rs @@ -284,6 +284,9 @@ mod tests { download_bandwidth_mbps: Some(-1.0), upload_bandwidth_mbps: Some(10.0), }; - assert_eq!(validate_update_payload(&update), Err(StatusCode::BAD_REQUEST)); + assert_eq!( + validate_update_payload(&update), + Err(StatusCode::BAD_REQUEST) + ); } } diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs index 1284e680..684159b8 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs @@ -75,14 +75,12 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { .unwrap_or("") .to_lowercase() .contains(&search) - || device - .ipv4 - .iter() - .any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search)) - || device - .ipv6 - .iter() - .any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search)) + || device.ipv4.iter().any(|(addr, prefix)| { + format!("{addr}/{prefix}").to_lowercase().contains(&search) + }) + || device.ipv6.iter().any(|(addr, prefix)| { + format!("{addr}/{prefix}").to_lowercase().contains(&search) + }) }) .cloned() .collect(); diff --git a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs index 9cf33274..9bd67032 100644 --- a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs +++ b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs @@ -83,7 +83,9 @@ fn resolve_node_name(query: &TreeAttachedCircuitsQuery) -> Option { let nodes = reader.get_nodes_when_ready(); if let Some(node_id) = query.node_id.as_deref() - && let Some(node) = nodes.iter().find(|node| node.id.as_deref() == Some(node_id)) + && let Some(node) = nodes + .iter() + .find(|node| node.id.as_deref() == Some(node_id)) { return Some(node.name.clone()); } @@ -124,7 +126,10 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let sort = query.sort.clone().unwrap_or(TreeAttachedCircuitsSort::CircuitName); + let sort = query + .sort + .clone() + .unwrap_or(TreeAttachedCircuitsSort::CircuitName); let descending = query.descending.unwrap_or(false); let node_name = resolve_node_name(&query); @@ -159,8 +164,14 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC row.circuit_name.to_lowercase().contains(&search) || row.circuit_id.to_lowercase().contains(&search) || row.parent_node.to_lowercase().contains(&search) - || row.device_names.iter().any(|name| name.to_lowercase().contains(&search)) - || row.ip_addrs.iter().any(|ip| ip.to_lowercase().contains(&search)) + || row + .device_names + .iter() + .any(|name| name.to_lowercase().contains(&search)) + || row + .ip_addrs + .iter() + .any(|ip| ip.to_lowercase().contains(&search)) }); } @@ -203,7 +214,11 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC node_path: query.node_path, page: Some(page), page_size: Some(page_size), - search: if search.is_empty() { None } else { query.search }, + search: if search.is_empty() { + None + } else { + query.search + }, sort: Some(sort), descending: Some(descending), }, diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 7ef4ca56..efa2d882 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -11,9 +11,9 @@ use std::sync::Arc; use crate::node_manager::auth::{LoginResult, login_from_token}; use crate::node_manager::local_api::{ circuit, circuit_count, config, cpu_affinity, dashboard_themes, device_counts, directories, - executive, flow_explorer, flow_map, lts, network_tree, node_rate_overrides, packet_analysis, - network_tree_lite, reload_libreqos, scheduler, search, shaped_device_api, - shaped_devices_page, unknown_ips, urgent, warnings, + executive, flow_explorer, flow_map, lts, network_tree, network_tree_lite, node_rate_overrides, + packet_analysis, reload_libreqos, scheduler, search, shaped_device_api, shaped_devices_page, + unknown_ips, urgent, warnings, }; use crate::node_manager::shaper_queries_actor::ShaperQueryCommand; use crate::node_manager::ws::messages::{ @@ -1593,75 +1593,76 @@ async fn receive_channel_message( return true; } } - WsRequest::GetShapedDevice { device_id } => match config::get_shaped_device_data( - *request_state.login, - device_id, - ) { - Ok(device) => { - let response = WsResponse::GetShapedDeviceResult { - ok: device.is_some(), - message: if device.is_some() { - "Ok".to_string() - } else { - "Not found".to_string() - }, - device, - }; - if send_ws_response(&tx, response).await { - return true; + WsRequest::GetShapedDevice { device_id } => { + match config::get_shaped_device_data(*request_state.login, device_id) { + Ok(device) => { + let response = WsResponse::GetShapedDeviceResult { + ok: device.is_some(), + message: if device.is_some() { + "Ok".to_string() + } else { + "Not found".to_string() + }, + device, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(StatusCode::FORBIDDEN) => { + let response = WsResponse::GetShapedDeviceResult { + ok: false, + message: "Unauthorized".to_string(), + device: None, + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(_) => { + let response = WsResponse::GetShapedDeviceResult { + ok: false, + message: "Error".to_string(), + device: None, + }; + if send_ws_response(&tx, response).await { + return true; + } } } - Err(StatusCode::FORBIDDEN) => { - let response = WsResponse::GetShapedDeviceResult { - ok: false, - message: "Unauthorized".to_string(), - device: None, - }; - if send_ws_response(&tx, response).await { - return true; + } + WsRequest::CreateShapedDevice { device } => { + match config::create_shaped_device_data(*request_state.login, device) { + Ok(device) => { + let response = WsResponse::CreateShapedDeviceResult { + ok: true, + message: "Ok".to_string(), + device: Some(device), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + Err(message) => { + let response = WsResponse::CreateShapedDeviceResult { + ok: false, + message, + device: None, + }; + if send_ws_response(&tx, response).await { + return true; + } } } - Err(_) => { - let response = WsResponse::GetShapedDeviceResult { - ok: false, - message: "Error".to_string(), - device: None, - }; - if send_ws_response(&tx, response).await { - return true; - } - } - }, - WsRequest::CreateShapedDevice { device } => match config::create_shaped_device_data( - *request_state.login, - device, - ) { - Ok(device) => { - let response = WsResponse::CreateShapedDeviceResult { - ok: true, - message: "Ok".to_string(), - device: Some(device), - }; - if send_ws_response(&tx, response).await { - return true; - } - } - Err(message) => { - let response = WsResponse::CreateShapedDeviceResult { - ok: false, - message, - device: None, - }; - if send_ws_response(&tx, response).await { - return true; - } - } - }, + } WsRequest::UpdateShapedDevice { original_device_id, device, - } => match config::update_shaped_device_data(*request_state.login, original_device_id, device) - { + } => match config::update_shaped_device_data( + *request_state.login, + original_device_id, + device, + ) { Ok(device) => { let response = WsResponse::UpdateShapedDeviceResult { ok: true, diff --git a/src/rust/lqosd/src/node_manager/ws/messages.rs b/src/rust/lqosd/src/node_manager/ws/messages.rs index 951e5674..0ee5bd2a 100644 --- a/src/rust/lqosd/src/node_manager/ws/messages.rs +++ b/src/rust/lqosd/src/node_manager/ws/messages.rs @@ -9,20 +9,24 @@ use crate::node_manager::local_api::executive::{ ExecutiveDashboardSummary, ExecutiveHeatmapPage, ExecutiveHeatmapPageQuery, ExecutiveLeaderboardPage, ExecutiveLeaderboardPageQuery, }; +use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode; use crate::node_manager::local_api::node_rate_overrides::{ NodeRateOverrideData, NodeRateOverrideQuery, NodeRateOverrideUpdate, }; -use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode; use crate::node_manager::local_api::packet_analysis::RequestAnalysisResult; use crate::node_manager::local_api::scheduler::{SchedulerDetails, SchedulerStatus}; use crate::node_manager::local_api::search::SearchResult; -use crate::node_manager::local_api::shaped_devices_page::{ShapedDevicesPage, ShapedDevicesPageQuery}; -use crate::node_manager::local_api::tree_attached_circuits::{TreeAttachedCircuitsPage, TreeAttachedCircuitsQuery}; +use crate::node_manager::local_api::shaped_devices_page::{ + ShapedDevicesPage, ShapedDevicesPageQuery, +}; +use crate::node_manager::local_api::tree_attached_circuits::{ + TreeAttachedCircuitsPage, TreeAttachedCircuitsQuery, +}; use crate::node_manager::local_api::unknown_ips::{ClearUnknownIpsResponse, UnknownIp}; use crate::node_manager::local_api::urgent::{UrgentList, UrgentStatus}; use crate::node_manager::local_api::{ - circuit_live::{CircuitLiveMetrics, CircuitMetricsQuery}, circuit_count::CircuitCount, + circuit_live::{CircuitLiveMetrics, CircuitMetricsQuery}, cpu_affinity::{ CircuitBrief, CpuAffinityCircuitsPage, CpuAffinitySiteTreeNode, CpuAffinitySummaryEntry, PreviewWeightItem, @@ -41,9 +45,7 @@ use crate::throughput_tracker::flow_data::{ AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry, }; use crate::throughput_tracker::flow_data::{FlowAnalysis, FlowbeeLocalData}; -use lqos_bus::{ - Circuit, FlowbeeSummaryData, QueueStoreTransit, StormguardDebugEntry, -}; +use lqos_bus::{Circuit, FlowbeeSummaryData, QueueStoreTransit, StormguardDebugEntry}; use lqos_config::QooProfileInfo; use lqos_config::{Config, NetworkJsonTransport, ShapedDevice, WebUser}; use lqos_utils::units::DownUpOrder; diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs index c744cc6e..7c31887f 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs @@ -1,15 +1,15 @@ mod cake_watcher; mod chatbot; -mod circuit_metrics; mod circuit; +mod circuit_metrics; mod flows_by_circuit; mod ping_monitor; mod tree_attached_circuits; use crate::node_manager::ws::messages::{PrivateRequest, WsResponse, encode_ws_message}; -use crate::node_manager::ws::single_user_channels::circuit_metrics::watch_circuit_metrics; use crate::node_manager::ws::single_user_channels::cake_watcher::cake_watcher; use crate::node_manager::ws::single_user_channels::circuit::circuit_watcher; +use crate::node_manager::ws::single_user_channels::circuit_metrics::watch_circuit_metrics; use crate::node_manager::ws::single_user_channels::flows_by_circuit::flows_by_circuit; use crate::node_manager::ws::single_user_channels::ping_monitor::ping_monitor; use crate::node_manager::ws::single_user_channels::tree_attached_circuits::watch_tree_attached_circuits; @@ -104,10 +104,8 @@ impl PrivateState { query: crate::node_manager::local_api::tree_attached_circuits::TreeAttachedCircuitsQuery, ) { self.abort_tree_attached_circuits_watch(); - self.tree_attached_circuits_watch = Some(spawn(watch_tree_attached_circuits( - query, - self.tx.clone(), - ))); + self.tree_attached_circuits_watch = + Some(spawn(watch_tree_attached_circuits(query, self.tx.clone()))); } fn abort_tree_attached_circuits_watch(&mut self) { diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/network_tree_lite.rs b/src/rust/lqosd/src/node_manager/ws/ticker/network_tree_lite.rs index 0d92a3e3..d9cc4af2 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/network_tree_lite.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/network_tree_lite.rs @@ -15,5 +15,7 @@ pub async fn network_tree_lite(channels: Arc) { let message = WsResponse::NetworkTreeLite { data: network_tree_lite_data(), }; - channels.send(PublishedChannels::NetworkTreeLite, message).await; + channels + .send(PublishedChannels::NetworkTreeLite, message) + .await; } diff --git a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs index a1c61e5d..bb49a4dd 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs @@ -114,8 +114,9 @@ pub fn rebuild_circuit_live_snapshot() -> Arc { .device_hash .and_then(|device_hash| cache.index_by_device_hash(&shaped_devices, device_hash)) .or_else(|| { - data.circuit_hash - .and_then(|circuit_hash| cache.index_by_circuit_hash(&shaped_devices, circuit_hash)) + data.circuit_hash.and_then(|circuit_hash| { + cache.index_by_circuit_hash(&shaped_devices, circuit_hash) + }) }) .and_then(|idx| shaped_devices.devices.get(idx)); let Some(device) = device else { @@ -125,9 +126,7 @@ pub fn rebuild_circuit_live_snapshot() -> Arc { continue; } - let entry = by_circuit_id - .entry(device.circuit_id.clone()) - .or_default(); + let entry = by_circuit_id.entry(device.circuit_id.clone()).or_default(); if entry.circuit_name.is_empty() { entry.circuit_name = device.circuit_name.clone(); } @@ -142,7 +141,10 @@ pub fn rebuild_circuit_live_snapshot() -> Arc { entry.plan_mbps.up = entry.plan_mbps.up.max(device.upload_max_mbps.round()); entry.bytes_per_second.down += data.bytes_per_second.down; entry.bytes_per_second.up += data.bytes_per_second.up; - entry.tcp_packets.down += data.tcp_packets.down.saturating_sub(data.prev_tcp_packets.down); + entry.tcp_packets.down += data + .tcp_packets + .down + .saturating_sub(data.prev_tcp_packets.down); entry.tcp_packets.up += data.tcp_packets.up.saturating_sub(data.prev_tcp_packets.up); entry.tcp_retransmits.down += data.tcp_retransmits.down; entry.tcp_retransmits.up += data.tcp_retransmits.up; @@ -202,7 +204,8 @@ pub fn rebuild_circuit_live_snapshot() -> Arc { circuit_ids_by_parent_node, }); CIRCUIT_LIVE_SNAPSHOT.store(snapshot.clone()); - CIRCUIT_LIVE_LAST_REFRESH_SECS.store(current_epoch_secs(), std::sync::atomic::Ordering::Release); + CIRCUIT_LIVE_LAST_REFRESH_SECS + .store(current_epoch_secs(), std::sync::atomic::Ordering::Release); snapshot } diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index 8d28c246..a344a729 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -1,5 +1,5 @@ -use anyhow::Result; use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode; +use anyhow::Result; use arc_swap::ArcSwap; use fxhash::{FxHashMap, FxHashSet}; use lqos_bus::{BusResponse, Circuit}; diff --git a/src/rust/lqosd/src/throughput_tracker/mod.rs b/src/rust/lqosd/src/throughput_tracker/mod.rs index 6a7cba77..274a73e6 100644 --- a/src/rust/lqosd/src/throughput_tracker/mod.rs +++ b/src/rust/lqosd/src/throughput_tracker/mod.rs @@ -18,12 +18,12 @@ use crate::{ use arc_swap::ArcSwap; pub(crate) use flow_data::RttBuffer; use fxhash::{FxHashMap, FxHashSet}; -use lqos_bakery::BakeryCommands; +use lqos_bakery::{BakeryCommands, full_reload_in_progress}; use lqos_bus::{ AsnHeatmapData, BusResponse, CircuitHeatmapData, ExecutiveSummaryHeader, FlowbeeProtocol, IpStats, SiteHeatmapData, TcHandle, TopFlowType, XdpPpingResult, }; -use lqos_queue_tracker::ALL_QUEUE_SUMMARY; +use lqos_queue_tracker::{ALL_QUEUE_SUMMARY, queue_stats_stale}; use lqos_sys::flowbee_data::FlowbeeKey; use lqos_utils::units::{DownUpOrder, down_up_divide}; use lqos_utils::{XdpIpAddress, hash_to_i64, unix_time::time_since_boot}; @@ -873,6 +873,8 @@ pub fn executive_summary_header() -> BusResponse { let unmapped_ip_count = total_hosts.saturating_sub(shaped_hosts) as u64; 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 @@ -887,6 +889,8 @@ pub fn executive_summary_header() -> BusResponse { htb_queue_count: queue_counts.htb as u64, cake_queue_count: queue_counts.cake as u64, fq_codel_queue_count: queue_counts.fq_codel as u64, + queue_stats_stale, + bakery_reload_in_progress, insight_connected, }) } diff --git a/src/scheduler.py b/src/scheduler.py index 05ea8a22..581548d8 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -14,7 +14,7 @@ from liblqos_python import automatic_import_uisp, automatic_import_splynx, queue automatic_import_netzur, automatic_import_visp, calculate_hash, efficiency_core_ids, scheduler_alive, scheduler_error, \ overrides_persistent_devices_effective, overrides_circuit_adjustments_effective, \ overrides_network_adjustments_materialized, \ - scheduler_output + scheduler_output, wait_for_bus_ready from apscheduler.schedulers.background import BlockingScheduler from apscheduler.executors.pool import ThreadPoolExecutor @@ -579,8 +579,14 @@ def not_dead_yet(): #print(f"Scheduler alive at {datetime.datetime.now()}") scheduler_alive() + +def ensure_bus_ready(): + """Wait briefly for lqosd to finish binding the local bus socket.""" + wait_for_bus_ready(5000) + if __name__ == '__main__': try: + ensure_bus_ready() importAndShapeFullReload() network_hash = calculate_hash() diff --git a/src/test_scheduler.py b/src/test_scheduler.py index 0967ce7f..1690b30f 100644 --- a/src/test_scheduler.py +++ b/src/test_scheduler.py @@ -31,6 +31,7 @@ def install_scheduler_stubs(): lqlib.scheduler_alive = Mock() lqlib.scheduler_error = Mock() lqlib.scheduler_output = Mock() + lqlib.wait_for_bus_ready = Mock(return_value=True) lqlib.overrides_persistent_devices_effective = lambda: [] lqlib.overrides_circuit_adjustments_effective = lambda: [] lqlib.overrides_network_adjustments_effective = lambda: [] From 048028ee878afff3bfd0e133e000bd0a34447e56 Mon Sep 17 00:00:00 2001 From: rchac Date: Sun, 22 Mar 2026 00:03:28 +0000 Subject: [PATCH 019/102] changes --- .../node_manager/js_build/src/dashlets/executive_snapshot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js index 6ce8a04e..1644f7e8 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js @@ -201,7 +201,7 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { renderValueWithAlert(value, showAlert) { const isZero = this.isZero(value); - const valueClass = isZero ? "text-danger" : "text-secondary"; + const valueClass = isZero && showAlert ? "text-danger" : "text-secondary"; if (!showAlert || !isZero) { return `${value}`; } From 3963384930ae7cd36a720439c29b6e9fac1bae27 Mon Sep 17 00:00:00 2001 From: rchac Date: Sun, 22 Mar 2026 05:58:56 +0000 Subject: [PATCH 020/102] changes --- docs-es/v2.0/node-manager-ui-es.md | 1 + docs-es/v2.0/treeguard-es.md | 4 + docs/v2.0/node-manager-ui.md | 1 + docs/v2.0/treeguard.md | 4 + src/rust/Cargo.lock | 2 + src/rust/lqos_bakery/Cargo.toml | 2 + src/rust/lqos_bakery/src/commands.rs | 39 + src/rust/lqos_bakery/src/lib.rs | 1060 +++++++++++++- src/rust/lqos_bakery/src/qdisc_handles.rs | 515 +++++++ src/rust/lqos_bakery/src/queue_math.rs | 20 + src/rust/lqos_bakery/src/utils.rs | 35 +- src/rust/lqos_bus/src/bus/mod.rs | 5 +- src/rust/lqos_bus/src/bus/request.rs | 23 + src/rust/lqos_bus/src/lib.rs | 7 +- src/rust/lqos_config/src/etc/mod.rs | 8 + src/rust/lqos_config/src/lib.rs | 4 +- src/rust/lqos_python/src/lib.rs | 55 +- src/rust/lqos_queue_tracker/src/lib.rs | 5 +- .../src/queue_structure/mod.rs | 6 +- .../queing_structure_json_monitor.rs | 2 + .../src/queue_structure/queue_node.rs | 1 + .../lqos_queue_tracker/src/tracking/mod.rs | 60 + .../lqos_stormguard/src/adaptive_actions.rs | 2 + src/rust/lqosd/src/main.rs | 26 + .../js_build/src/dashlets/bakery_activity.js | 98 ++ .../src/dashlets/bakery_apply_summary.js | 100 ++ .../js_build/src/dashlets/bakery_capacity.js | 115 ++ .../js_build/src/dashlets/bakery_shared.js | 78 ++ .../src/dashlets/bakery_status_summary.js | 101 ++ .../js_build/src/dashlets/dashlet_index.js | 12 + .../src/dashlets/executive_snapshot.js | 6 +- .../src/node_manager/js_build/src/index.js | 10 + .../src/lq_js_common/dashboard/layout.js | 27 +- .../lqosd/src/node_manager/ws/messages.rs | 53 +- .../src/node_manager/ws/published_channels.rs | 1 + src/rust/lqosd/src/node_manager/ws/ticker.rs | 6 +- .../src/node_manager/ws/ticker/bakery.rs | 120 +- src/rust/lqosd/src/treeguard/actor.rs | 1248 +++++++++++++---- src/rust/lqosd/src/treeguard/bakery.rs | 139 +- src/scheduler.py | 10 +- src/test_scheduler.py | 36 + 41 files changed, 3675 insertions(+), 372 deletions(-) create mode 100644 src/rust/lqos_bakery/src/qdisc_handles.rs create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_activity.js create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_apply_summary.js create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_capacity.js create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_shared.js create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status_summary.js diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 5af185c2..850cf65e 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -8,6 +8,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - Resumen por widgets de throughput, retransmisiones, RTT, flujos y actividad de colas. - El contenido puede variar según versión y funciones habilitadas. - Executive Summary ofrece una vista operativa compacta para redes grandes, con páginas de detalle para heatmaps y rankings ejecutivos. +- Bakery ofrece una pestaña dedicada para el estado de aplicación de colas, eventos recientes de Bakery, resultados de seguridad/preflight de qdisc y el gráfico existente de actividad de circuitos. - Algunos gráficos pueden tardar un poco en poblarse al abrir una pestaña por primera vez, especialmente en sistemas ocupados o inmediatamente después de reiniciar servicios. - Durante un full reload de Bakery, las tarjetas de conteo de colas mantienen los últimos valores conocidos de HTB/CAKE/fq-codel y los marcan como `Reloading` en lugar de mostrar ceros momentáneamente. diff --git a/docs-es/v2.0/treeguard-es.md b/docs-es/v2.0/treeguard-es.md index 1f7e23c2..606ce238 100644 --- a/docs-es/v2.0/treeguard-es.md +++ b/docs-es/v2.0/treeguard-es.md @@ -86,6 +86,10 @@ Cuando está habilitado y no está en dry-run, TreeGuard puede persistir decisio TreeGuard está diseñado para no pelear con overrides del operador. Si existen overrides del operador para entidades enroladas, TreeGuard omite esas entidades y reporta advertencias. +Las decisiones de virtualización de nodos de TreeGuard son overrides de tiempo de ejecución. El +scheduler no las materializa de vuelta en el `network.json` base, por lo que deshabilitar o limpiar +TreeGuard no reescribe permanentemente la topología definida por el operador. + TreeGuard también se niega a gestionar nodos que ya estén marcados con `"virtual": true` en el `network.json` base. Si existen overrides viejos de TreeGuard para esos nodos, TreeGuard limpia su propia capa de overrides y vuelve a respetar la definición base de la topología. Para la gestión SQM por circuito, TreeGuard trata los valores duplicados de `device_id` como colisiones de identidad inseguras. Si el mismo `device_id` aparece en más de un circuito dentro de `ShapedDevices.csv`, TreeGuard omite esos circuitos afectados y limpia cualquier override SQM de TreeGuard asociado a esos `device_id` duplicados. diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index 49e919b1..e36e9510 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -8,6 +8,7 @@ This page documents key WebUI (Node Manager) views and operational behavior in t - Widget-based overview of throughput, retransmits, RTT, flow counts, and queue activity. - Dashboard content can vary by version and enabled features. - Executive Summary provides a compact operational view for large networks, with drilldown pages for executive heatmaps and leaderboards. +- Bakery provides dedicated status for queue apply state, recent Bakery events, qdisc safety/preflight results, and the existing circuit-activity graph. - Some charts may take a short time to populate after first opening a tab, especially on busy systems or immediately after a service restart. - During a Bakery full reload, queue-count cards keep showing the last known HTB/CAKE/fq-codel values and mark them as `Reloading` instead of briefly zeroing them. diff --git a/docs/v2.0/treeguard.md b/docs/v2.0/treeguard.md index c4e03ab5..d5b48c71 100644 --- a/docs/v2.0/treeguard.md +++ b/docs/v2.0/treeguard.md @@ -86,6 +86,10 @@ When enabled and not dry-run, TreeGuard may persist decisions to: TreeGuard is designed to avoid fighting operator-owned overrides. If operator overrides exist for enrolled entities, TreeGuard skips those entities and reports warnings. +TreeGuard virtual-node decisions are runtime overrides. They are not materialized back into the +base `network.json` by the scheduler, so disabling or clearing TreeGuard does not permanently +rewrite operator-authored topology. + TreeGuard also refuses to manage nodes that are already marked `"virtual": true` in the base `network.json`. If stale TreeGuard-owned node-virtualization overrides exist for those nodes, TreeGuard clears its own override layer and falls back to the base topology definition. For circuit SQM management, TreeGuard treats duplicate `device_id` values as unsafe identity collisions. If the same `device_id` appears in more than one circuit in `ShapedDevices.csv`, TreeGuard skips those affected circuits and clears any TreeGuard-owned SQM overrides for those duplicate device IDs. diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 317da84c..398bc331 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -1989,6 +1989,8 @@ dependencies = [ "lqos_config", "lqos_sys", "parking_lot", + "serde", + "serde_json", "tokio", "tracing", ] diff --git a/src/rust/lqos_bakery/Cargo.toml b/src/rust/lqos_bakery/Cargo.toml index b229c4dc..da7fe67e 100644 --- a/src/rust/lqos_bakery/Cargo.toml +++ b/src/rust/lqos_bakery/Cargo.toml @@ -13,3 +13,5 @@ crossbeam-channel.workspace = true allocative.workspace = true parking_lot.workspace = true tokio.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/src/rust/lqos_bakery/src/commands.rs b/src/rust/lqos_bakery/src/commands.rs index 93b73ab4..3783aa5c 100644 --- a/src/rust/lqos_bakery/src/commands.rs +++ b/src/rust/lqos_bakery/src/commands.rs @@ -1,4 +1,5 @@ use crate::MQ_CREATED; +use crate::qdisc_handles::{InfraQdiscSlot, infra_qdisc_handle}; use crate::queue_math::{ format_rate_for_tc, format_rate_for_tc_f32, quantum, r2q, sqm_as_vec, sqm_tokens_for, }; @@ -33,6 +34,8 @@ struct AddCircuitParams { upload_bandwidth_max: f32, class_major: u16, up_class_major: u16, + down_qdisc_handle: Option, + up_qdisc_handle: Option, // Optional per-circuit SQM override: "cake" or "fq_codel" sqm_override: Option, } @@ -149,6 +152,10 @@ pub enum BakeryCommands { class_major: u16, /// Major class ID (uplink) used when attaching SQM/HTB. up_class_major: u16, + /// Explicit qdisc handle major for the downlink leaf qdisc, if assigned. + down_qdisc_handle: Option, + /// Explicit qdisc handle major for the uplink leaf qdisc, if assigned. + up_qdisc_handle: Option, /// Concatenated list of all IPs for this circuit. ip_addresses: String, // Concatenated list of all IPs for this circuit /// Optional per-circuit SQM override: "cake" or "fq_codel" @@ -226,6 +233,8 @@ impl BakeryCommands { upload_bandwidth_max, class_major, up_class_major, + down_qdisc_handle, + up_qdisc_handle, ip_addresses: _, sqm_override, } => Self::add_circuit( @@ -242,6 +251,8 @@ impl BakeryCommands { upload_bandwidth_max: *upload_bandwidth_max, class_major: *class_major, up_class_major: *up_class_major, + down_qdisc_handle: *down_qdisc_handle, + up_qdisc_handle: *up_qdisc_handle, sqm_override: sqm_override.clone(), }, ), @@ -364,6 +375,11 @@ impl BakeryCommands { config.isp_interface(), "parent".to_string(), format!("0x{:x}:1", queue + 1), + "handle".to_string(), + format!( + "0x{:x}:", + infra_qdisc_handle((queue + 1) as u16, InfraQdiscSlot::Primary) + ), ]; class.extend(sqm_strings.clone()); result.push(class); @@ -401,6 +417,11 @@ impl BakeryCommands { config.isp_interface(), "parent".to_string(), format!("0x{:x}:2", queue + 1), + "handle".to_string(), + format!( + "0x{:x}:", + infra_qdisc_handle((queue + 1) as u16, InfraQdiscSlot::Default) + ), ]; default_class.extend(sqm_strings.clone()); result.push(default_class); @@ -478,6 +499,11 @@ impl BakeryCommands { config.internet_interface(), "parent".to_string(), format!("0x{:x}:1", queue + stick_offset + 1), + "handle".to_string(), + format!( + "0x{:x}:", + infra_qdisc_handle((queue + stick_offset + 1) as u16, InfraQdiscSlot::Primary) + ), ]; class.extend(sqm_strings.clone()); result.push(class); @@ -514,6 +540,11 @@ impl BakeryCommands { config.internet_interface(), "parent".to_string(), format!("0x{:x}:2", queue + stick_offset + 1), + "handle".to_string(), + format!( + "0x{:x}:", + infra_qdisc_handle((queue + stick_offset + 1) as u16, InfraQdiscSlot::Default) + ), ]; default_class.extend(sqm_strings.clone()); result.push(default_class); @@ -718,6 +749,10 @@ impl BakeryCommands { "parent".to_string(), format!("0x{:x}:0x{:x}", params.class_major, params.class_minor), ]; + if let Some(handle) = params.down_qdisc_handle { + sqm_command.push("handle".to_string()); + sqm_command.push(format!("0x{:x}:", handle)); + } sqm_command.extend(sqm_tokens_for( params.download_bandwidth_max, config, @@ -766,6 +801,10 @@ impl BakeryCommands { "parent".to_string(), format!("0x{:x}:0x{:x}", params.up_class_major, params.class_minor), ]; + if let Some(handle) = params.up_qdisc_handle { + sqm_command.push("handle".to_string()); + sqm_command.push(format!("0x{:x}:", handle)); + } sqm_command.extend(sqm_tokens_for( params.upload_bandwidth_max, config, diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index 191396f3..198dc0bd 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -21,10 +21,13 @@ mod commands; mod diff; +mod qdisc_handles; mod queue_math; mod utils; use crossbeam_channel::{Receiver, Sender}; +use parking_lot::RwLock; +use std::collections::VecDeque; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::sync::atomic::Ordering::Relaxed; @@ -35,14 +38,16 @@ use utils::current_timestamp; pub(crate) const CHANNEL_CAPACITY: usize = 65536; // 64k capacity for Bakery commands use crate::commands::ExecutionMode; use crate::diff::{CircuitDiffResult, SiteDiffResult, diff_circuits, diff_sites}; -use crate::queue_math::format_rate_for_tc_f32; -use crate::utils::{execute_in_memory, write_command_file}; +use crate::qdisc_handles::QdiscHandleState; +use crate::queue_math::{SqmKind, effective_sqm_kind, format_rate_for_tc_f32}; +use crate::utils::{ExecuteResult, execute_in_memory, write_command_file}; pub use commands::BakeryCommands; use lqos_bus::{ BusRequest, BusResponse, InsightLicenseSummary, LibreqosBusClient, TcHandle, UrgentSeverity, UrgentSource, }; use lqos_config::{Config, LazyQueueMode}; +use qdisc_handles::MqDeviceLayout; // ---------------------- Live-Move Types and Helpers (module scope) ---------------------- #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -63,6 +68,8 @@ struct Migration { up_parent_class_id: TcHandle, class_major: u16, up_class_major: u16, + down_qdisc_handle: Option, + up_qdisc_handle: Option, // Old and new rates old_down_min: f32, old_down_max: f32, @@ -161,6 +168,7 @@ fn build_temp_add_cmd( down_max: f32, up_min: f32, up_max: f32, + preserve_qdisc_handles: bool, ) -> Option { if let BakeryCommands::AddCircuit { circuit_hash, @@ -168,6 +176,8 @@ fn build_temp_add_cmd( up_parent_class_id, class_major, up_class_major, + down_qdisc_handle, + up_qdisc_handle, ip_addresses, sqm_override, .. @@ -184,6 +194,10 @@ fn build_temp_add_cmd( upload_bandwidth_max: up_max, class_major: *class_major, up_class_major: *up_class_major, + down_qdisc_handle: preserve_qdisc_handles + .then_some(*down_qdisc_handle) + .flatten(), + up_qdisc_handle: preserve_qdisc_handles.then_some(*up_qdisc_handle).flatten(), ip_addresses: ip_addresses.clone(), sqm_override: sqm_override.clone(), }) @@ -197,15 +211,157 @@ pub static ACTIVE_CIRCUITS: AtomicUsize = AtomicUsize::new(0); /// True while Bakery is applying a full reload batch to `tc`. static FULL_RELOAD_IN_PROGRESS: AtomicBool = AtomicBool::new(false); /// Hard kernel limit for auto-allocated qdisc handles on a single network interface. -pub const HARD_QDISC_HANDLE_LIMIT_PER_INTERFACE: usize = 32_768; -/// Conservative operational limit used to fail a full reload before the kernel limit is reached. -pub const SAFE_AUTO_QDISC_BUDGET_PER_INTERFACE: usize = 32_000; +pub const HARD_QDISC_HANDLE_LIMIT_PER_INTERFACE: usize = 65_534; +/// Conservative operational limit used to fail a full reload before qdisc handle exhaustion. +pub const SAFE_QDISC_BUDGET_PER_INTERFACE: usize = 65_000; /// Maximum number of mapped circuits allowed without Insight. const DEFAULT_MAPPED_CIRCUITS_LIMIT: usize = 1000; /// Minimum interval between repeated mapped-circuit-limit urgent issues. const CIRCUIT_LIMIT_URGENT_INTERVAL_SECONDS: u64 = 30 * 60; /// Last timestamp at which we emitted a mapped-circuit-limit urgent issue. static LAST_CIRCUIT_LIMIT_URGENT_TS: AtomicU64 = AtomicU64::new(0); +const BAKERY_EVENT_LIMIT: usize = 50; + +/// High-level Bakery execution mode for operator-facing status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BakeryMode { + /// Bakery is not currently applying queue changes. + Idle, + /// Bakery is applying a structural full reload. + ApplyingFullReload, + /// Bakery is applying an incremental or live change. + ApplyingLiveChange, +} + +/// Type of the most recent apply action recorded by Bakery. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BakeryApplyType { + /// No apply has been recorded yet. + None, + /// A full reload apply. + FullReload, + /// A live/incremental apply. + LiveChange, +} + +/// Per-interface qdisc budget usage snapshot for Bakery UI/status. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BakeryCapacityInterfaceSnapshot { + /// Interface name, e.g. `ens19`. + pub name: String, + /// Planned qdisc count for that interface. + pub planned_qdiscs: usize, +} + +/// Last qdisc preflight result retained for Bakery UI/status. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BakeryPreflightSnapshot { + /// Whether the last preflight was within budget. + pub ok: bool, + /// Short operator-facing summary. + pub message: String, + /// Safe operational budget used during the preflight. + pub safe_budget: usize, + /// Hard kernel limit used for reference. + pub hard_limit: usize, + /// Per-interface planned qdisc counts. + pub interfaces: Vec, +} + +/// Operator-facing Bakery status snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BakeryStatusSnapshot { + /// Current Bakery mode. + pub mode: BakeryMode, + /// Unix timestamp when the current action started, if any. + pub current_action_started_unix: Option, + /// Currently active circuit count. + pub active_circuits: usize, + /// Unix timestamp of the last successful apply, if any. + pub last_success_unix: Option, + /// Unix timestamp of the last failed apply, if any. + pub last_failure_unix: Option, + /// Summary of the last failure, if any. + pub last_failure_summary: Option, + /// Type of the last apply recorded by Bakery. + pub last_apply_type: BakeryApplyType, + /// Number of `tc` commands in the last apply. + pub last_total_tc_commands: usize, + /// Number of `class` commands in the last apply. + pub last_class_commands: usize, + /// Number of `qdisc` commands in the last apply. + pub last_qdisc_commands: usize, + /// Time spent expanding/building the last apply command list. + pub last_build_duration_ms: u64, + /// Time spent running the last apply through `tc`. + pub last_apply_duration_ms: u64, + /// Last qdisc preflight summary known to Bakery. + pub preflight: Option, +} + +/// Recent operator-facing Bakery activity event. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BakeryActivityEntry { + /// Unix timestamp in seconds. + pub ts: u64, + /// Stable short event code. + pub event: String, + /// `info`, `warning`, or `error`. + pub status: String, + /// Human-readable summary. + pub summary: String, +} + +#[derive(Clone, Debug)] +struct BakeryTelemetryState { + mode: BakeryMode, + current_action_started_unix: Option, + last_success_unix: Option, + last_failure_unix: Option, + last_failure_summary: Option, + last_apply_type: BakeryApplyType, + last_total_tc_commands: usize, + last_class_commands: usize, + last_qdisc_commands: usize, + last_build_duration_ms: u64, + last_apply_duration_ms: u64, + preflight: Option, + activity: VecDeque, +} + +#[derive(Clone, Copy, Debug)] +struct BakeryApplyMetrics<'a> { + apply_type: BakeryApplyType, + summary: &'a str, + build_duration_ms: u64, + apply_duration_ms: u64, + total_tc_commands: usize, + class_commands: usize, + qdisc_commands: usize, + ok: bool, +} + +impl Default for BakeryTelemetryState { + fn default() -> Self { + Self { + mode: BakeryMode::Idle, + current_action_started_unix: None, + last_success_unix: None, + last_failure_unix: None, + last_failure_summary: None, + last_apply_type: BakeryApplyType::None, + last_total_tc_commands: 0, + last_class_commands: 0, + last_qdisc_commands: 0, + last_build_duration_ms: 0, + last_apply_duration_ms: 0, + preflight: None, + activity: VecDeque::with_capacity(BAKERY_EVENT_LIMIT), + } + } +} + +static BAKERY_TELEMETRY: OnceLock> = OnceLock::new(); /// Message Queue sender for the bakery pub static BAKERY_SENDER: OnceLock> = OnceLock::new(); @@ -219,17 +375,177 @@ struct FullReloadScope; impl Drop for FullReloadScope { fn drop(&mut self) { FULL_RELOAD_IN_PROGRESS.store(false, Ordering::Relaxed); + let mut state = telemetry_state().write(); + if state.mode == BakeryMode::ApplyingFullReload { + state.mode = BakeryMode::Idle; + state.current_action_started_unix = None; + } } } -/// Estimated auto-allocated qdisc usage for a planned full reload. +fn telemetry_state() -> &'static RwLock { + BAKERY_TELEMETRY.get_or_init(|| RwLock::new(BakeryTelemetryState::default())) +} + +fn count_tc_command_types(commands: &[Vec]) -> (usize, usize, usize) { + let total = commands.len(); + let mut class_count = 0usize; + let mut qdisc_count = 0usize; + for argv in commands { + if let Some(kind) = argv.first() { + match kind.as_str() { + "class" => class_count += 1, + "qdisc" => qdisc_count += 1, + _ => {} + } + } + } + (total, class_count, qdisc_count) +} + +fn push_bakery_event(event: &str, status: &str, summary: String) { + let entry = BakeryActivityEntry { + ts: current_timestamp(), + event: event.to_string(), + status: status.to_string(), + summary, + }; + let mut state = telemetry_state().write(); + state.activity.push_front(entry); + while state.activity.len() > BAKERY_EVENT_LIMIT { + state.activity.pop_back(); + } +} + +fn mark_bakery_action_started(mode: BakeryMode, event: &str, summary: String) { + let ts = current_timestamp(); + { + let mut state = telemetry_state().write(); + state.mode = mode; + state.current_action_started_unix = Some(ts); + } + push_bakery_event(event, "info", summary); +} + +fn mark_bakery_action_finished(metrics: BakeryApplyMetrics<'_>) { + let ts = current_timestamp(); + { + let mut state = telemetry_state().write(); + state.last_apply_type = metrics.apply_type; + state.last_total_tc_commands = metrics.total_tc_commands; + state.last_class_commands = metrics.class_commands; + state.last_qdisc_commands = metrics.qdisc_commands; + state.last_build_duration_ms = metrics.build_duration_ms; + state.last_apply_duration_ms = metrics.apply_duration_ms; + if metrics.ok { + state.last_success_unix = Some(ts); + } else { + state.last_failure_unix = Some(ts); + state.last_failure_summary = Some(metrics.summary.to_string()); + } + if state.mode != BakeryMode::ApplyingFullReload { + state.mode = BakeryMode::Idle; + state.current_action_started_unix = None; + } + } + push_bakery_event( + if metrics.ok { + "apply_finished" + } else { + "apply_failed" + }, + if metrics.ok { "info" } else { "error" }, + metrics.summary.to_string(), + ); +} + +/// Returns the latest Bakery status snapshot for UI/status consumers. +pub fn bakery_status_snapshot() -> BakeryStatusSnapshot { + let state = telemetry_state().read().clone(); + BakeryStatusSnapshot { + mode: state.mode, + current_action_started_unix: state.current_action_started_unix, + active_circuits: ACTIVE_CIRCUITS.load(Ordering::Relaxed), + last_success_unix: state.last_success_unix, + last_failure_unix: state.last_failure_unix, + last_failure_summary: state.last_failure_summary, + last_apply_type: state.last_apply_type, + last_total_tc_commands: state.last_total_tc_commands, + last_class_commands: state.last_class_commands, + last_qdisc_commands: state.last_qdisc_commands, + last_build_duration_ms: state.last_build_duration_ms, + last_apply_duration_ms: state.last_apply_duration_ms, + preflight: state.preflight, + } +} + +/// Returns recent Bakery activity entries for UI/status consumers. +pub fn bakery_activity_snapshot() -> Vec { + telemetry_state().read().activity.iter().cloned().collect() +} + +/// Stores the latest qdisc-budget preflight result for UI/status consumers. +/// +/// This function is not pure: it updates retained in-memory Bakery telemetry state. +pub fn record_qdisc_preflight_snapshot(snapshot: BakeryPreflightSnapshot) { + let ok = snapshot.ok; + let summary = snapshot.message.clone(); + { + let mut state = telemetry_state().write(); + state.preflight = Some(snapshot); + } + push_bakery_event( + if ok { + "preflight_ok" + } else { + "preflight_blocked" + }, + if ok { "info" } else { "warning" }, + summary, + ); +} + +fn summarize_apply_result(purpose: &str, result: &ExecuteResult) -> String { + match &result.failure_summary { + Some(summary) if !summary.is_empty() => format!("{purpose}: {summary}"), + _ if result.ok => format!("{purpose}: completed"), + _ => format!("{purpose}: failed"), + } +} + +fn execute_and_record_live_change( + command_buffer: &Vec>, + purpose: &str, +) -> ExecuteResult { + let (total, class_commands, qdisc_commands) = count_tc_command_types(command_buffer); + mark_bakery_action_started( + BakeryMode::ApplyingLiveChange, + "live_change_started", + format!("{purpose}: started"), + ); + let result = execute_in_memory(command_buffer, purpose); + let summary = summarize_apply_result(purpose, &result); + mark_bakery_action_finished(BakeryApplyMetrics { + apply_type: BakeryApplyType::LiveChange, + summary: &summary, + build_duration_ms: 0, + apply_duration_ms: result.duration_ms, + total_tc_commands: total, + class_commands, + qdisc_commands, + ok: result.ok, + }); + result +} + +/// Estimated qdisc-handle usage for a planned full reload. #[derive(Debug, Clone, PartialEq, Eq)] pub struct QdiscBudgetEstimate { - /// Estimated auto-allocated qdisc count grouped by network interface name. + /// Estimated total qdisc count grouped by network interface name. pub interfaces: BTreeMap, /// Conservative operational limit enforced before a full reload is committed. pub safe_budget: usize, - /// Kernel hard limit for the auto-allocated qdisc handle pool on one interface. + /// Kernel hard limit for the per-device qdisc-handle namespace. pub hard_limit: usize, } @@ -247,29 +563,29 @@ fn find_arg_value<'a>(argv: &'a [String], key: &str) -> Option<&'a str> { .find_map(|pair| (pair[0] == key).then_some(pair[1].as_str())) } -fn auto_allocated_qdisc_identity(argv: &[String]) -> Option<(String, String)> { +fn planned_qdisc_identity(argv: &[String]) -> Option<(String, String)> { if argv.len() < 2 || argv[0] != "qdisc" { return None; } if !matches!(argv[1].as_str(), "add" | "replace") { return None; } - if argv.iter().any(|token| token == "handle") { - return None; - } let dev = find_arg_value(argv, "dev")?.to_string(); + if let Some(handle) = find_arg_value(argv, "handle") { + return Some((dev, format!("handle:{handle}"))); + } let parent = find_arg_value(argv, "parent")?.to_string(); - Some((dev, parent)) + Some((dev, format!("parent:{parent}"))) } -/// Estimates auto-allocated qdisc usage for the current full-reload builder queue. +/// Estimates total qdisc usage for the current full-reload builder queue. /// /// This expands the queued Bakery commands through the same builder-path `tc` -/// argv generation used by a structural full reload, then counts only the -/// resulting qdiscs that do not specify an explicit `handle`. The estimate is -/// tracked per interface because Linux enforces qdisc auto-handle exhaustion on -/// a per-device basis. +/// argv generation used by a structural full reload, then counts the resulting +/// qdiscs per device. Explicit qdisc handles are deduplicated by `handle`, +/// while any remaining auto-handled paths are conservatively deduplicated by +/// `parent`. pub fn estimate_full_reload_auto_qdisc_budget( config: &Arc, queue: &[BakeryCommands], @@ -282,10 +598,10 @@ pub fn estimate_full_reload_auto_qdisc_budget( continue; }; for argv in builder_commands { - let Some((dev, parent)) = auto_allocated_qdisc_identity(&argv) else { + let Some((dev, identity)) = planned_qdisc_identity(&argv) else { continue; }; - if seen_qdiscs.insert((dev.clone(), parent)) { + if seen_qdiscs.insert((dev.clone(), identity)) { *interfaces.entry(dev).or_insert(0) += 1; } } @@ -293,11 +609,212 @@ pub fn estimate_full_reload_auto_qdisc_budget( QdiscBudgetEstimate { interfaces, - safe_budget: SAFE_AUTO_QDISC_BUDGET_PER_INTERFACE, + safe_budget: SAFE_QDISC_BUDGET_PER_INTERFACE, hard_limit: HARD_QDISC_HANDLE_LIMIT_PER_INTERFACE, } } +fn current_mq_layout( + batch: &[Arc], + config: &Arc, + existing: &Option, +) -> Option { + let mut latest = existing.clone(); + for command in batch { + if let BakeryCommands::MqSetup { + queues_available, + stick_offset, + } = command.as_ref() + { + latest = Some(MqDeviceLayout::from_setup( + config, + *queues_available, + *stick_offset, + )); + } + } + latest +} + +fn with_assigned_qdisc_handles( + command: &Arc, + config: &Arc, + mq_layout: &MqDeviceLayout, + qdisc_handles: &mut QdiscHandleState, +) -> Arc { + let BakeryCommands::AddCircuit { circuit_hash, .. } = command.as_ref() else { + return Arc::clone(command); + }; + + if config.queues.monitor_only { + return Arc::clone(command); + } + + let mut enriched = command.as_ref().clone(); + let isp_interface = config.isp_interface(); + let internet_interface = config.internet_interface(); + let isp_reserved = mq_layout.reserved_handles(&isp_interface); + let up_reserved = mq_layout.reserved_handles(&internet_interface); + + if let BakeryCommands::AddCircuit { + down_qdisc_handle, + up_qdisc_handle, + .. + } = &mut enriched + { + if down_qdisc_handle.is_none() { + *down_qdisc_handle = + qdisc_handles.assign_circuit_handle(&isp_interface, *circuit_hash, &isp_reserved); + } + if !config.on_a_stick_mode() && up_qdisc_handle.is_none() { + *up_qdisc_handle = qdisc_handles.assign_circuit_handle( + &internet_interface, + *circuit_hash, + &up_reserved, + ); + } + } + + Arc::new(enriched) +} + +fn parse_directional_sqm_override( + sqm_override: &Option, +) -> (Option, Option) { + match sqm_override { + None => (None, None), + Some(s) => { + if s.contains('/') { + let mut it = s.splitn(2, '/'); + let down = it.next().unwrap_or("").trim(); + let up = it.next().unwrap_or("").trim(); + let map = |t: &str| -> Option { + if t.is_empty() { + None + } else { + Some(t.to_string()) + } + }; + (map(down), map(up)) + } else { + (Some(s.clone()), Some(s.clone())) + } + } + } +} + +fn effective_directional_sqm_kinds( + command: &BakeryCommands, + config: &Arc, +) -> (Option, Option) { + let BakeryCommands::AddCircuit { + download_bandwidth_max, + upload_bandwidth_max, + sqm_override, + .. + } = command + else { + return (None, None); + }; + + if config.queues.monitor_only { + return (None, None); + } + + let (down_override_opt, up_override_opt) = parse_directional_sqm_override(sqm_override); + + let down_kind = + (!matches!(down_override_opt.as_deref(), Some(s) if s.eq_ignore_ascii_case("none"))) + .then(|| effective_sqm_kind(*download_bandwidth_max, config, &down_override_opt)); + let up_kind = (!config.on_a_stick_mode() + && !matches!(up_override_opt.as_deref(), Some(s) if s.eq_ignore_ascii_case("none"))) + .then(|| effective_sqm_kind(*upload_bandwidth_max, config, &up_override_opt)); + + (down_kind, up_kind) +} + +fn effective_directional_qdisc_parents( + command: &BakeryCommands, + config: &Arc, +) -> (Option, Option) { + let BakeryCommands::AddCircuit { + class_minor, + class_major, + up_class_major, + sqm_override, + .. + } = command + else { + return (None, None); + }; + + if config.queues.monitor_only { + return (None, None); + } + + let (down_override_opt, up_override_opt) = parse_directional_sqm_override(sqm_override); + + let down_parent = + (!matches!(down_override_opt.as_deref(), Some(s) if s.eq_ignore_ascii_case("none"))) + .then(|| TcHandle::from_u32(((*class_major as u32) << 16) | (*class_minor as u32))); + let up_parent = (!config.on_a_stick_mode() + && !matches!(up_override_opt.as_deref(), Some(s) if s.eq_ignore_ascii_case("none"))) + .then(|| TcHandle::from_u32(((*up_class_major as u32) << 16) | (*class_minor as u32))); + + (down_parent, up_parent) +} + +fn rotate_changed_qdisc_handles( + previous: &BakeryCommands, + command: &Arc, + config: &Arc, + mq_layout: &MqDeviceLayout, + qdisc_handles: &mut QdiscHandleState, +) -> Arc { + let (old_down_kind, old_up_kind) = effective_directional_sqm_kinds(previous, config); + let (new_down_kind, new_up_kind) = effective_directional_sqm_kinds(command.as_ref(), config); + let (old_down_parent, old_up_parent) = effective_directional_qdisc_parents(previous, config); + let (new_down_parent, new_up_parent) = + effective_directional_qdisc_parents(command.as_ref(), config); + + let mut rotated = command.as_ref().clone(); + let isp_interface = config.isp_interface(); + let internet_interface = config.internet_interface(); + let isp_reserved = mq_layout.reserved_handles(&isp_interface); + let up_reserved = mq_layout.reserved_handles(&internet_interface); + + if let BakeryCommands::AddCircuit { + circuit_hash, + down_qdisc_handle, + up_qdisc_handle, + .. + } = &mut rotated + { + let down_kind_changed = + old_down_kind.is_some() && new_down_kind.is_some() && old_down_kind != new_down_kind; + let down_parent_changed = old_down_parent.is_some() + && new_down_parent.is_some() + && old_down_parent != new_down_parent; + if down_kind_changed || down_parent_changed { + *down_qdisc_handle = + qdisc_handles.rotate_circuit_handle(&isp_interface, *circuit_hash, &isp_reserved); + } + let up_kind_changed = + old_up_kind.is_some() && new_up_kind.is_some() && old_up_kind != new_up_kind; + let up_parent_changed = + old_up_parent.is_some() && new_up_parent.is_some() && old_up_parent != new_up_parent; + if up_kind_changed || up_parent_changed { + *up_qdisc_handle = qdisc_handles.rotate_circuit_handle( + &internet_interface, + *circuit_hash, + &up_reserved, + ); + } + } + + Arc::new(rotated) +} + /// Returns `true` while Bakery is applying a full reload batch to Linux `tc`. pub fn full_reload_in_progress() -> bool { FULL_RELOAD_IN_PROGRESS.load(Ordering::Relaxed) @@ -545,6 +1062,23 @@ fn maybe_emit_mapped_circuit_limit_urgent(stats: &MappedLimitStats) { }); } +fn log_mapped_limit_decision( + context: &str, + mapped_limit: ResolvedMappedLimit, + stats: MappedLimitStats, +) { + warn!( + "Bakery mapped circuit decision ({}): requested={}, allowed={}, dropped={}, effective_limit={}, licensed={}, max_circuits={:?}", + context, + stats.requested_mapped, + stats.allowed_mapped, + stats.dropped_mapped, + format_mapped_limit(stats.enforced_limit), + mapped_limit.licensed, + mapped_limit.max_circuits + ); +} + /// Starts the Bakery system, returning a channel sender for sending commands to the Bakery. pub fn start_bakery() -> anyhow::Result> { let (tx, rx) = crossbeam_channel::bounded(CHANNEL_CAPACITY); @@ -567,6 +1101,11 @@ fn bakery_main(rx: Receiver, tx: Sender) { let mut sites: HashMap> = HashMap::new(); let mut circuits: HashMap> = HashMap::new(); let mut live_circuits: HashMap = HashMap::new(); + let mut mq_layout: Option = None; + let mut qdisc_handles = lqos_config::load_config() + .ok() + .map(|config| QdiscHandleState::load(&config)) + .unwrap_or_default(); // Persist latest StormGuard ceilings keyed by interface + class so we can replay after rebuilds. let mut stormguard_overrides: HashMap = HashMap::new(); @@ -837,11 +1376,18 @@ fn bakery_main(rx: Receiver, tx: Sender) { batch = Some(Vec::new()); } BakeryCommands::CommitBatch => { + push_bakery_event( + "commit_received", + "info", + "Bakery commit received.".to_string(), + ); handle_commit_batch( &mut batch, &mut sites, &mut circuits, &mut live_circuits, + &mut mq_layout, + &mut qdisc_handles, &tx, &mut migrations, &stormguard_overrides, @@ -895,6 +1441,8 @@ fn bakery_main(rx: Receiver, tx: Sender) { upload_bandwidth_max: mig.old_up_max, class_major: mig.class_major, up_class_major: mig.up_class_major, + down_qdisc_handle: mig.down_qdisc_handle, + up_qdisc_handle: mig.up_qdisc_handle, ip_addresses: "".to_string(), sqm_override: mig.sqm_override.clone(), }, @@ -903,6 +1451,7 @@ fn bakery_main(rx: Receiver, tx: Sender) { mig.old_down_max, mig.old_up_min, mig.old_up_max, + false, ) { let mut cmds = Vec::new(); match config.queues.lazy_queues.as_ref() { @@ -942,7 +1491,10 @@ fn bakery_main(rx: Receiver, tx: Sender) { } } if !cmds.is_empty() { - execute_in_memory(&cmds, "live-move: create shadow"); + execute_and_record_live_change( + &cmds, + "live-move: create shadow", + ); } mig.stage = MigrationStage::SwapToShadow; advanced += 1; @@ -990,6 +1542,8 @@ fn bakery_main(rx: Receiver, tx: Sender) { upload_bandwidth_max: mig.old_up_max, class_major: mig.class_major, up_class_major: mig.up_class_major, + down_qdisc_handle: None, + up_qdisc_handle: None, ip_addresses: "".to_string(), sqm_override: mig.sqm_override.clone(), }, @@ -998,6 +1552,7 @@ fn bakery_main(rx: Receiver, tx: Sender) { mig.old_down_max, mig.old_up_min, mig.old_up_max, + true, ) { let mut cmds = Vec::new(); if let Some(prune) = old_cmd.to_prune(&config, true) { @@ -1011,6 +1566,7 @@ fn bakery_main(rx: Receiver, tx: Sender) { mig.new_down_max, mig.new_up_min, mig.new_up_max, + true, ) { match config.queues.lazy_queues.as_ref() { None | Some(LazyQueueMode::No) => { @@ -1050,7 +1606,7 @@ fn bakery_main(rx: Receiver, tx: Sender) { } } if !cmds.is_empty() { - execute_in_memory(&cmds, "live-move: build final"); + execute_and_record_live_change(&cmds, "live-move: build final"); } mig.stage = MigrationStage::SwapToFinal; advanced += 1; @@ -1094,6 +1650,8 @@ fn bakery_main(rx: Receiver, tx: Sender) { upload_bandwidth_max: mig.old_up_max, class_major: mig.class_major, up_class_major: mig.up_class_major, + down_qdisc_handle: None, + up_qdisc_handle: None, ip_addresses: "".to_string(), sqm_override: mig.sqm_override.clone(), }, @@ -1102,9 +1660,10 @@ fn bakery_main(rx: Receiver, tx: Sender) { mig.old_down_max, mig.old_up_min, mig.old_up_max, + false, ) && let Some(prune) = shadow_cmd.to_prune(&config, true) { - execute_in_memory(&prune, "live-move: prune shadow"); + execute_and_record_live_change(&prune, "live-move: prune shadow"); } mig.stage = MigrationStage::Done; advanced += 1; @@ -1203,11 +1762,14 @@ fn bakery_main(rx: Receiver, tx: Sender) { error!("Bakery thread exited unexpectedly."); } +#[allow(clippy::too_many_arguments)] fn handle_commit_batch( batch: &mut Option>>, sites: &mut HashMap>, circuits: &mut HashMap>, live_circuits: &mut HashMap, + mq_layout: &mut Option, + qdisc_handles: &mut QdiscHandleState, tx: &Sender, migrations: &mut HashMap, stormguard_overrides: &HashMap, @@ -1216,11 +1778,13 @@ fn handle_commit_batch( error!("Failed to load configuration, exiting Bakery thread."); return; }; + qdisc_handles.clear_retired_handles(); let Some(new_batch) = batch.take() else { debug!("CommitBatch received without a batch to commit."); return; }; + let resolved_mq_layout = current_mq_layout(&new_batch, &config, mq_layout); let mapped_limit = resolve_mapped_circuit_limit(); let effective_limit = mapped_limit.effective_limit; @@ -1230,6 +1794,7 @@ fn handle_commit_batch( if !has_mq_been_setup { let (new_batch, mapped_limit_stats) = filter_batch_by_mapped_circuit_limit(new_batch, circuits, effective_limit); + log_mapped_limit_decision("full reload", mapped_limit, mapped_limit_stats); if mapped_limit_stats.dropped_mapped > 0 { warn!( "Bakery mapped circuit cap enforced (full reload): requested={}, allowed={}, dropped={}, limit={} (licensed={}, max_circuits={:?})", @@ -1249,8 +1814,11 @@ fn handle_commit_batch( sites, circuits, live_circuits, + mq_layout, + qdisc_handles, &config, new_batch, + resolved_mq_layout, stormguard_overrides, ); MQ_CREATED.store(true, std::sync::atomic::Ordering::Relaxed); @@ -1261,6 +1829,7 @@ fn handle_commit_batch( if matches!(site_change_mode, SiteDiffResult::RebuildRequired) { let (new_batch, mapped_limit_stats) = filter_batch_by_mapped_circuit_limit(new_batch, circuits, effective_limit); + log_mapped_limit_decision("site-structure rebuild", mapped_limit, mapped_limit_stats); if mapped_limit_stats.dropped_mapped > 0 { warn!( "Bakery mapped circuit cap enforced (site-structure rebuild): requested={}, allowed={}, dropped={}, limit={} (licensed={}, max_circuits={:?})", @@ -1280,8 +1849,11 @@ fn handle_commit_batch( sites, circuits, live_circuits, + mq_layout, + qdisc_handles, &config, new_batch, + resolved_mq_layout, stormguard_overrides, ); MQ_CREATED.store(true, std::sync::atomic::Ordering::Relaxed); @@ -1305,6 +1877,11 @@ fn handle_commit_batch( { let (new_batch, mapped_limit_stats) = filter_batch_by_mapped_circuit_limit(new_batch.clone(), circuits, effective_limit); + log_mapped_limit_decision( + "circuit-structure rebuild", + mapped_limit, + mapped_limit_stats, + ); if mapped_limit_stats.dropped_mapped > 0 { warn!( "Bakery mapped circuit cap enforced (circuit-structure rebuild): requested={}, allowed={}, dropped={}, limit={} (licensed={}, max_circuits={:?})", @@ -1326,8 +1903,11 @@ fn handle_commit_batch( sites, circuits, live_circuits, + mq_layout, + qdisc_handles, &config, new_batch, + resolved_mq_layout, stormguard_overrides, ); MQ_CREATED.store(true, std::sync::atomic::Ordering::Relaxed); @@ -1369,8 +1949,11 @@ fn handle_commit_batch( sites, circuits, live_circuits, + mq_layout, + qdisc_handles, &config, new_batch.clone(), + resolved_mq_layout.clone(), stormguard_overrides, ); return; // Skip the rest of this CommitBatch processing @@ -1413,9 +1996,13 @@ fn handle_commit_batch( } }; if let Some(cmd) = commands { - execute_in_memory(&cmd, "removing circuit"); + execute_and_record_live_change(&cmd, "removing circuit"); } live_circuits.remove(&circuit_hash); + qdisc_handles.release_circuit(&config.isp_interface(), circuit_hash); + if !config.on_a_stick_mode() { + qdisc_handles.release_circuit(&config.internet_interface(), circuit_hash); + } } else { debug!( "RemoveCircuit received for unknown circuit: {}", @@ -1427,8 +2014,25 @@ fn handle_commit_batch( // 2) Speed changes (avoid linux TC deadlock by removing qdisc first) if !categories.speed_changed.is_empty() { + let Some(layout) = resolved_mq_layout.as_ref() else { + warn!("Bakery: missing MQ layout during circuit speed updates"); + return; + }; let mut immediate_commands = Vec::new(); for cmd in &categories.speed_changed { + let mut enriched_cmd = + with_assigned_qdisc_handles(cmd, &config, layout, qdisc_handles); + if let BakeryCommands::AddCircuit { circuit_hash, .. } = enriched_cmd.as_ref() + && let Some(old_cmd) = circuits.get(circuit_hash) + { + enriched_cmd = rotate_changed_qdisc_handles( + old_cmd.as_ref(), + &enriched_cmd, + &config, + layout, + qdisc_handles, + ); + } if let BakeryCommands::AddCircuit { circuit_hash, parent_class_id, @@ -1442,7 +2046,8 @@ fn handle_commit_batch( up_class_major, ip_addresses, sqm_override, - } = cmd.as_ref() + .. + } = enriched_cmd.as_ref() { let was_activated = live_circuits.contains_key(circuit_hash); if was_activated { @@ -1457,6 +2062,8 @@ fn handle_commit_batch( upload_bandwidth_min: old_up_min, download_bandwidth_max: old_down_max, upload_bandwidth_max: old_up_max, + down_qdisc_handle, + up_qdisc_handle, .. } = old_cmd.as_ref() { @@ -1466,6 +2073,8 @@ fn handle_commit_batch( up_parent_class_id: *up_parent_class_id, class_major: *class_major, up_class_major: *up_class_major, + down_qdisc_handle: *down_qdisc_handle, + up_qdisc_handle: *up_qdisc_handle, old_down_min: *old_down_min, old_down_max: *old_down_max, old_up_min: *old_up_min, @@ -1483,7 +2092,7 @@ fn handle_commit_batch( }; migrations.insert(*circuit_hash, mig); // Update desired circuit definition now - circuits.insert(*circuit_hash, Arc::clone(cmd)); + circuits.insert(*circuit_hash, Arc::clone(&enriched_cmd)); continue; // skip immediate path } } @@ -1491,41 +2100,43 @@ fn handle_commit_batch( // Fallback: immediate safe update match config.queues.lazy_queues.as_ref() { None | Some(LazyQueueMode::No) => { - if let Some(prune) = cmd.to_prune(&config, true) { + if let Some(prune) = enriched_cmd.to_prune(&config, true) { immediate_commands.extend(prune); } - if let Some(add) = cmd.to_commands(&config, ExecutionMode::Builder) { + if let Some(add) = + enriched_cmd.to_commands(&config, ExecutionMode::Builder) + { immediate_commands.extend(add); } } Some(LazyQueueMode::Htb) => { if was_activated { - if let Some(prune) = cmd.to_prune(&config, false) { + if let Some(prune) = enriched_cmd.to_prune(&config, false) { immediate_commands.extend(prune); } if let Some(add_htb) = - cmd.to_commands(&config, ExecutionMode::Builder) + enriched_cmd.to_commands(&config, ExecutionMode::Builder) { immediate_commands.extend(add_htb); } if let Some(add_qdisc) = - cmd.to_commands(&config, ExecutionMode::LiveUpdate) + enriched_cmd.to_commands(&config, ExecutionMode::LiveUpdate) { immediate_commands.extend(add_qdisc); } } else if let Some(add_htb) = - cmd.to_commands(&config, ExecutionMode::Builder) + enriched_cmd.to_commands(&config, ExecutionMode::Builder) { immediate_commands.extend(add_htb); } } Some(LazyQueueMode::Full) => { if was_activated { - if let Some(prune) = cmd.to_prune(&config, true) { + if let Some(prune) = enriched_cmd.to_prune(&config, true) { immediate_commands.extend(prune); } if let Some(add_all) = - cmd.to_commands(&config, ExecutionMode::LiveUpdate) + enriched_cmd.to_commands(&config, ExecutionMode::LiveUpdate) { immediate_commands.extend(add_all); } @@ -1534,11 +2145,14 @@ fn handle_commit_batch( } } } - circuits.insert(*circuit_hash, Arc::clone(cmd)); + circuits.insert(*circuit_hash, enriched_cmd); } } if !immediate_commands.is_empty() { - execute_in_memory(&immediate_commands, "updating circuit speeds (fallback)"); + execute_and_record_live_change( + &immediate_commands, + "updating circuit speeds (fallback)", + ); } } @@ -1586,18 +2200,39 @@ fn handle_commit_batch( ); maybe_emit_mapped_circuit_limit_urgent(&stats); } + log_mapped_limit_decision( + "incremental additions", + mapped_limit, + MappedLimitStats { + enforced_limit: effective_limit, + requested_mapped: requested_mapped_additions, + allowed_mapped: requested_mapped_additions + .saturating_sub(dropped_mapped_additions), + dropped_mapped: dropped_mapped_additions, + }, + ); - let commands: Vec> = accepted_additions + let Some(layout) = resolved_mq_layout.as_ref() else { + warn!( + "Bakery: missing MQ layout during incremental additions; skipping TC changes" + ); + return; + }; + let enriched_additions: Vec> = accepted_additions + .iter() + .map(|command| with_assigned_qdisc_handles(command, &config, layout, qdisc_handles)) + .collect(); + let commands: Vec> = enriched_additions .iter() .filter_map(|c| c.to_commands(&config, ExecutionMode::Builder)) .flatten() .collect(); if !commands.is_empty() { - execute_in_memory(&commands, "adding new circuits"); + execute_and_record_live_change(&commands, "adding new circuits"); } - for command in accepted_additions { + for command in enriched_additions { if let BakeryCommands::AddCircuit { circuit_hash, .. } = command.as_ref() { - circuits.insert(*circuit_hash, Arc::clone(command)); + circuits.insert(*circuit_hash, command); } } } @@ -1605,13 +2240,21 @@ fn handle_commit_batch( // 4) IP-only changes require no TC commands; mappings already handled by mapping engine // We still refresh the stored circuit snapshot for those entries if !categories.ip_changed.is_empty() { + let Some(layout) = resolved_mq_layout.as_ref() else { + warn!("Bakery: missing MQ layout during IP-only circuit updates"); + return; + }; for command in categories.ip_changed { - if let BakeryCommands::AddCircuit { circuit_hash, .. } = command.as_ref() { - circuits.insert(*circuit_hash, Arc::clone(command)); + let enriched = with_assigned_qdisc_handles(command, &config, layout, qdisc_handles); + if let BakeryCommands::AddCircuit { circuit_hash, .. } = enriched.as_ref() { + circuits.insert(*circuit_hash, enriched); } } } } + + *mq_layout = resolved_mq_layout; + qdisc_handles.save(&config); } fn handle_circuit_activity( @@ -1680,7 +2323,7 @@ fn handle_circuit_activity( if commands.is_empty() { return; // No commands to write } - execute_in_memory(&commands, "enabling live circuits"); + execute_and_record_live_change(&commands, "enabling live circuits"); } fn handle_tick( @@ -1772,7 +2415,7 @@ fn handle_tick( if commands.is_empty() { return; // No commands to write } - execute_in_memory(&commands, "pruning lazy queues"); + execute_and_record_live_change(&commands, "pruning lazy queues"); } fn handle_change_site_speed_live( @@ -1852,7 +2495,7 @@ fn handle_change_site_speed_live( format_rate_for_tc_f32(download_bandwidth_max), ], ]; - execute_in_memory(&commands, "changing site speed live"); + execute_and_record_live_change(&commands, "changing site speed live"); // Update the site speeds in the site map - create a new Arc with updated values let new_site = Arc::new(BakeryCommands::AddSite { site_hash, @@ -1873,22 +2516,59 @@ fn handle_change_site_speed_live( } } +#[allow(clippy::too_many_arguments)] fn full_reload( batch: &mut Option>>, sites: &mut HashMap>, circuits: &mut HashMap>, live_circuits: &mut HashMap, + mq_layout: &mut Option, + qdisc_handles: &mut QdiscHandleState, config: &Arc, new_batch: Vec>, + resolved_mq_layout: Option, stormguard_overrides: &HashMap, ) { warn!("Bakery: Full reload triggered due to site or circuit changes."); FULL_RELOAD_IN_PROGRESS.store(true, Ordering::Relaxed); + mark_bakery_action_started( + BakeryMode::ApplyingFullReload, + "full_reload_started", + "Full reload triggered due to site or circuit changes.".to_string(), + ); let _reload_scope = FullReloadScope; sites.clear(); - circuits.clear(); + let previous_circuits = std::mem::take(circuits); live_circuits.clear(); - process_batch(new_batch, config, sites, circuits); + if let Some(layout) = resolved_mq_layout.as_ref() { + process_batch( + new_batch, + config, + sites, + circuits, + &previous_circuits, + layout, + qdisc_handles, + ); + let active_hashes = circuits.keys().copied().collect::>(); + qdisc_handles.retain_circuits(&config.isp_interface(), &active_hashes); + if !config.on_a_stick_mode() { + qdisc_handles.retain_circuits(&config.internet_interface(), &active_hashes); + } + qdisc_handles.save(config); + *mq_layout = resolved_mq_layout; + } else { + warn!("Bakery: full reload skipped qdisc-handle assignment because MQ layout is unknown"); + process_batch( + new_batch, + config, + sites, + circuits, + &previous_circuits, + &MqDeviceLayout::default(), + qdisc_handles, + ); + } *batch = None; apply_stormguard_overrides(stormguard_overrides, config); } @@ -1898,11 +2578,31 @@ fn process_batch( config: &Arc, sites: &mut HashMap>, circuits: &mut HashMap>, + previous_circuits: &HashMap>, + mq_layout: &MqDeviceLayout, + qdisc_handles: &mut QdiscHandleState, ) { info!("Bakery: Processing batch of {} commands", batch.len()); + let build_started = std::time::Instant::now(); let mut circuit_count = 0u64; let commands = batch .into_iter() + .map(|b| { + let enriched = with_assigned_qdisc_handles(&b, config, mq_layout, qdisc_handles); + let BakeryCommands::AddCircuit { circuit_hash, .. } = enriched.as_ref() else { + return enriched; + }; + let Some(previous) = previous_circuits.get(circuit_hash) else { + return enriched; + }; + rotate_changed_qdisc_handles( + previous.as_ref(), + &enriched, + config, + mq_layout, + qdisc_handles, + ) + }) .filter_map(|b| { // Ensure that our state map is up to date with the latest commands match b.as_ref() { @@ -1922,7 +2622,20 @@ fn process_batch( let path = Path::new(&config.lqos_directory).join("linux_tc_rust.txt"); write_command_file(&path, &commands); - execute_in_memory(&commands, "processing batch"); + let build_duration_ms = build_started.elapsed().as_millis() as u64; + let (total_tc_commands, class_commands, qdisc_commands) = count_tc_command_types(&commands); + let result = execute_in_memory(&commands, "processing batch"); + let summary = summarize_apply_result("processing batch", &result); + mark_bakery_action_finished(BakeryApplyMetrics { + apply_type: BakeryApplyType::FullReload, + summary: &summary, + build_duration_ms, + apply_duration_ms: result.duration_ms, + total_tc_commands, + class_commands, + qdisc_commands, + ok: result.ok, + }); // Mark that at least one batch has been applied, unblocking live activation. FIRST_COMMIT_APPLIED.store(true, Ordering::Relaxed); @@ -1952,13 +2665,21 @@ fn apply_stormguard_overrides( format!("{}mbit", rate), ]); } - execute_in_memory(&commands, "replaying StormGuard overrides"); + let result = execute_in_memory(&commands, "replaying StormGuard overrides"); + if !result.ok { + push_bakery_event( + "stormguard_override_replay_failed", + "error", + summarize_apply_result("replaying StormGuard overrides", &result), + ); + } } #[cfg(test)] mod tests { use super::*; use lqos_config::Config; + use std::time::{SystemTime, UNIX_EPOCH}; fn mk_add_circuit(hash: i64, ip_addresses: &str) -> Arc { Arc::new(BakeryCommands::AddCircuit { @@ -1972,11 +2693,25 @@ mod tests { upload_bandwidth_max: 100.0, class_major: 0x100, up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, ip_addresses: ip_addresses.to_string(), sqm_override: None, }) } + fn test_config_with_runtime_dir(name: &str) -> Arc { + let mut cfg = Config::default(); + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("lqos-bakery-{name}-{ts}")); + std::fs::create_dir_all(&dir).expect("temp runtime dir"); + cfg.lqos_directory = dir.display().to_string(); + Arc::new(cfg) + } + fn has_hash(batch: &[Arc], hash: i64) -> bool { batch.iter().any(|cmd| { matches!( @@ -2084,7 +2819,7 @@ mod tests { } #[test] - fn qdisc_budget_estimate_counts_auto_allocated_qdiscs_per_interface() { + fn qdisc_budget_estimate_counts_total_qdiscs_per_interface() { let config = Arc::new(Config::default()); let queue = vec![ BakeryCommands::StartBatch, @@ -2113,6 +2848,8 @@ mod tests { upload_bandwidth_max: 100.0, class_major: 0x100, up_class_major: 0x200, + down_qdisc_handle: Some(0x9000), + up_qdisc_handle: Some(0x9001), ip_addresses: "192.0.2.1/32".to_string(), sqm_override: None, }, @@ -2120,8 +2857,8 @@ mod tests { ]; let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); - assert_eq!(estimate.interfaces.get("eth1"), Some(&5)); - assert_eq!(estimate.interfaces.get("eth0"), Some(&5)); + assert_eq!(estimate.interfaces.get("eth1"), Some(&8)); + assert_eq!(estimate.interfaces.get("eth0"), Some(&8)); assert!(estimate.ok()); } @@ -2146,13 +2883,226 @@ mod tests { upload_bandwidth_max: 100.0, class_major: 0x100, up_class_major: 0x200, + down_qdisc_handle: Some(0x9000), + up_qdisc_handle: Some(0x9001), ip_addresses: "192.0.2.1/32".to_string(), sqm_override: None, }, ]; let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); - assert_eq!(estimate.interfaces.get("eth1"), Some(&2)); - assert_eq!(estimate.interfaces.get("eth0"), Some(&2)); + assert_eq!(estimate.interfaces.get("eth1"), Some(&4)); + assert_eq!(estimate.interfaces.get("eth0"), Some(&4)); + } + + #[test] + fn kind_switch_rotates_only_changed_direction_and_reuses_old_handle() { + let config = test_config_with_runtime_dir("kind-switch"); + let layout = MqDeviceLayout::from_setup(&config, 2, 0); + let mut handles = QdiscHandleState::default(); + + let original = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 101, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.1/32".to_string(), + sqm_override: None, + }); + + let original = with_assigned_qdisc_handles(&original, &config, &layout, &mut handles); + let BakeryCommands::AddCircuit { + down_qdisc_handle: Some(original_down), + up_qdisc_handle: Some(original_up), + .. + } = original.as_ref() + else { + panic!("expected assigned handles"); + }; + + handles.save(&config); + let mut reloaded = QdiscHandleState::load(&config); + + let switched = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 101, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.1/32".to_string(), + sqm_override: Some("fq_codel/cake".to_string()), + }); + + let switched = with_assigned_qdisc_handles(&switched, &config, &layout, &mut reloaded); + let rotated = rotate_changed_qdisc_handles( + original.as_ref(), + &switched, + &config, + &layout, + &mut reloaded, + ); + + let BakeryCommands::AddCircuit { + down_qdisc_handle: Some(rotated_down), + up_qdisc_handle: Some(rotated_up), + .. + } = rotated.as_ref() + else { + panic!("expected rotated handles"); + }; + + assert_ne!(*rotated_down, *original_down); + assert_eq!(*rotated_up, *original_up); + + reloaded.save(&config); + let mut persisted = QdiscHandleState::load(&config); + let new_circuit = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 202, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x21, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.2/32".to_string(), + sqm_override: None, + }); + let new_circuit = + with_assigned_qdisc_handles(&new_circuit, &config, &layout, &mut persisted); + + let BakeryCommands::AddCircuit { + down_qdisc_handle: Some(new_down), + .. + } = new_circuit.as_ref() + else { + panic!("expected new circuit handle"); + }; + + assert_eq!(*new_down, *original_down); + } + + #[test] + fn parent_change_rotates_only_changed_direction_and_reuses_old_handle() { + let config = test_config_with_runtime_dir("parent-change"); + let layout = MqDeviceLayout::from_setup(&config, 2, 0); + let mut handles = QdiscHandleState::default(); + + let original = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 303, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.3/32".to_string(), + sqm_override: None, + }); + + let original = with_assigned_qdisc_handles(&original, &config, &layout, &mut handles); + let BakeryCommands::AddCircuit { + down_qdisc_handle: Some(original_down), + up_qdisc_handle: Some(original_up), + .. + } = original.as_ref() + else { + panic!("expected assigned handles"); + }; + + handles.save(&config); + let mut reloaded = QdiscHandleState::load(&config); + + let moved = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 303, + parent_class_id: TcHandle::from_u32(0x10002), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x101, + up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.3/32".to_string(), + sqm_override: None, + }); + + let moved = with_assigned_qdisc_handles(&moved, &config, &layout, &mut reloaded); + let rotated = rotate_changed_qdisc_handles( + original.as_ref(), + &moved, + &config, + &layout, + &mut reloaded, + ); + + let BakeryCommands::AddCircuit { + down_qdisc_handle: Some(rotated_down), + up_qdisc_handle: Some(rotated_up), + .. + } = rotated.as_ref() + else { + panic!("expected rotated handles"); + }; + + assert_ne!(*rotated_down, *original_down); + assert_eq!(*rotated_up, *original_up); + + reloaded.save(&config); + let mut persisted = QdiscHandleState::load(&config); + let new_circuit = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 404, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x21, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.4/32".to_string(), + sqm_override: None, + }); + let new_circuit = + with_assigned_qdisc_handles(&new_circuit, &config, &layout, &mut persisted); + + let BakeryCommands::AddCircuit { + down_qdisc_handle: Some(new_down), + .. + } = new_circuit.as_ref() + else { + panic!("expected new circuit handle"); + }; + assert_eq!(*new_down, *original_down); } } diff --git a/src/rust/lqos_bakery/src/qdisc_handles.rs b/src/rust/lqos_bakery/src/qdisc_handles.rs new file mode 100644 index 00000000..42ebec07 --- /dev/null +++ b/src/rust/lqos_bakery/src/qdisc_handles.rs @@ -0,0 +1,515 @@ +use lqos_config::Config; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use tracing::{debug, warn}; + +/// Root MQ handle major reserved by LibreQoS. +pub(crate) const MQ_ROOT_HANDLE_MAJOR: u16 = 0x7FFF; +/// Prefer allocating dynamic circuit qdiscs from the upper handle space first. +const PREFERRED_CIRCUIT_START: u16 = 0x9000; +/// Highest qdisc handle major we will allocate dynamically. +const MAX_DYNAMIC_HANDLE_MAJOR: u16 = 0xFFFE; +/// Base range used for deterministic per-queue infrastructure qdisc handles. +const INFRA_HANDLE_BASE: u16 = 0x2000; +/// JSON version for persisted Bakery qdisc-handle state. +const QDISC_HANDLE_FILE_VERSION: u32 = 1; +const QDISC_HANDLE_FILE: &str = "bakery_qdisc_handles.json"; + +/// Per-device MQ queue layout used to reserve non-circuit qdisc handles. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct MqDeviceLayout { + devices: BTreeMap>, +} + +impl MqDeviceLayout { + /// Builds the reserved queue-major layout from the current MQ setup command. + pub(crate) fn from_setup( + config: &Config, + queues_available: usize, + stick_offset: usize, + ) -> Self { + let mut devices = BTreeMap::>::new(); + + let isp = config.isp_interface().to_string(); + let isp_entry = devices.entry(isp).or_default(); + for queue in 0..queues_available { + let major = (queue + 1) as u16; + isp_entry.insert(major); + } + + let internet = config.internet_interface().to_string(); + let internet_entry = devices.entry(internet).or_default(); + for queue in 0..queues_available { + let major = (queue + stick_offset + 1) as u16; + internet_entry.insert(major); + } + + Self { devices } + } + + /// Returns the qdisc-handle majors reserved on a single interface. + pub(crate) fn reserved_handles(&self, interface: &str) -> HashSet { + let mut reserved = HashSet::new(); + reserved.insert(MQ_ROOT_HANDLE_MAJOR); + reserved.insert(0xFFFF); + + if let Some(majors) = self.devices.get(interface) { + for major in majors { + reserved.insert(*major); + reserved.insert(infra_qdisc_handle(*major, InfraQdiscSlot::Primary)); + reserved.insert(infra_qdisc_handle(*major, InfraQdiscSlot::Default)); + } + } + + reserved + } +} + +/// Slot identifier for fixed per-queue infrastructure qdiscs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InfraQdiscSlot { + Primary, + Default, +} + +/// Computes the deterministic explicit qdisc handle for a fixed infrastructure leaf. +pub(crate) fn infra_qdisc_handle(queue_major: u16, slot: InfraQdiscSlot) -> u16 { + let base = INFRA_HANDLE_BASE.saturating_add(queue_major.saturating_mul(2)); + match slot { + InfraQdiscSlot::Primary => base, + InfraQdiscSlot::Default => base.saturating_add(1), + } +} + +/// Persisted circuit-to-qdisc handle assignments, grouped by interface. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct QdiscHandleState { + interfaces: BTreeMap, + retired_handles: BTreeMap>, +} + +impl QdiscHandleState { + /// Loads qdisc-handle state from the LibreQoS runtime directory. + pub(crate) fn load(config: &Config) -> Self { + let path = state_path(config); + let Ok(raw) = std::fs::read_to_string(&path) else { + return Self::default(); + }; + let Ok(file) = serde_json::from_str::(&raw) else { + warn!( + "Bakery: ignoring unreadable qdisc-handle state at {:?}", + path + ); + return Self::default(); + }; + if file.version != QDISC_HANDLE_FILE_VERSION { + debug!( + "Bakery: ignoring qdisc-handle state version {} at {:?}", + file.version, path + ); + return Self::default(); + } + + let interfaces = file + .interfaces + .into_iter() + .map(|(iface, entry)| { + let circuits = entry + .circuits + .into_iter() + .filter_map(|(hash, handle)| hash.parse::().ok().map(|h| (h, handle))) + .collect(); + (iface, InterfaceHandleState::from_circuits(circuits)) + }) + .collect(); + + Self { + interfaces, + retired_handles: BTreeMap::new(), + } + } + + /// Clears transient retired-handle reservations from the previous apply cycle. + pub(crate) fn clear_retired_handles(&mut self) { + for (interface, retired) in self.retired_handles.iter() { + let Some(state) = self.interfaces.get_mut(interface) else { + continue; + }; + for handle in retired { + state.note_reusable_handle(*handle); + } + } + self.retired_handles.clear(); + } + + /// Saves qdisc-handle state to the LibreQoS runtime directory. + pub(crate) fn save(&self, config: &Config) { + let path = state_path(config); + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + warn!( + "Bakery: unable to create qdisc-handle state directory {:?}: {}", + parent, e + ); + return; + } + + let persisted = PersistedQdiscHandleState { + version: QDISC_HANDLE_FILE_VERSION, + interfaces: self + .interfaces + .iter() + .map(|(iface, circuits)| { + ( + iface.clone(), + PersistedInterfaceHandleState { + circuits: circuits + .circuits + .iter() + .map(|(hash, handle)| (hash.to_string(), *handle)) + .collect(), + }, + ) + }) + .collect(), + }; + + let Ok(serialized) = serde_json::to_string_pretty(&persisted) else { + warn!("Bakery: unable to serialize qdisc-handle state"); + return; + }; + + let temp_path = path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&temp_path, serialized.as_bytes()) { + warn!( + "Bakery: unable to write temporary qdisc-handle state {:?}: {}", + temp_path, e + ); + return; + } + + if let Err(e) = std::fs::rename(&temp_path, &path) { + warn!( + "Bakery: unable to atomically replace qdisc-handle state {:?}: {}", + path, e + ); + let _ = std::fs::remove_file(&temp_path); + } + } + + /// Returns a stable explicit qdisc handle for the circuit on the given interface. + pub(crate) fn assign_circuit_handle( + &mut self, + interface: &str, + circuit_hash: i64, + reserved: &HashSet, + ) -> Option { + if let Some(existing) = self + .interfaces + .get(interface) + .and_then(|state| state.circuits.get(&circuit_hash)) + .copied() + && !reserved.contains(&existing) + && self + .interfaces + .get(interface) + .and_then(|state| state.owner_of(existing)) + == Some(circuit_hash) + { + return Some(existing); + } + + let retired = self.retired_handles.get(interface); + let state = self.interfaces.entry(interface.to_string()).or_default(); + let next = state.next_free_handle(reserved, retired)?; + state.insert(circuit_hash, next); + Some(next) + } + + /// Forces a circuit to rotate to a different explicit qdisc handle on an interface. + pub(crate) fn rotate_circuit_handle( + &mut self, + interface: &str, + circuit_hash: i64, + reserved: &HashSet, + ) -> Option { + let retired_handle = self + .interfaces + .get(interface) + .and_then(|state| state.circuits.get(&circuit_hash)) + .copied(); + + let retired = self.retired_handles.get(interface); + let state = self.interfaces.entry(interface.to_string()).or_default(); + let next = state.next_free_handle(reserved, retired)?; + if let Some(handle) = retired_handle { + self.retired_handles + .entry(interface.to_string()) + .or_default() + .insert(handle); + } + state.insert(circuit_hash, next); + Some(next) + } + + /// Releases a circuit handle on an interface so it can be reused deterministically. + pub(crate) fn release_circuit(&mut self, interface: &str, circuit_hash: i64) -> bool { + self.interfaces + .get_mut(interface) + .and_then(|state| state.remove(circuit_hash)) + .is_some() + } + + /// Drops persisted handle assignments for circuits no longer present on an interface. + pub(crate) fn retain_circuits(&mut self, interface: &str, active: &HashSet) { + if let Some(state) = self.interfaces.get_mut(interface) { + state.retain_circuits(active); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InterfaceHandleState { + circuits: BTreeMap, + owners: HashMap, + next_candidate: u16, +} + +impl Default for InterfaceHandleState { + fn default() -> Self { + Self { + circuits: BTreeMap::new(), + owners: HashMap::new(), + next_candidate: PREFERRED_CIRCUIT_START, + } + } +} + +impl InterfaceHandleState { + fn from_circuits(circuits: BTreeMap) -> Self { + let mut state = Self { + circuits, + owners: HashMap::new(), + next_candidate: PREFERRED_CIRCUIT_START, + }; + for (circuit_hash, handle) in state.circuits.iter() { + state.owners.insert(*handle, *circuit_hash); + } + state + } + + fn owner_of(&self, handle: u16) -> Option { + self.owners.get(&handle).copied() + } + + fn insert(&mut self, circuit_hash: i64, handle: u16) { + if let Some(previous) = self.circuits.insert(circuit_hash, handle) { + self.owners.remove(&previous); + self.note_reusable_handle(previous); + } + self.owners.insert(handle, circuit_hash); + if self.next_candidate == handle { + self.next_candidate = advance_handle_candidate(handle); + } + } + + fn remove(&mut self, circuit_hash: i64) -> Option { + let removed = self.circuits.remove(&circuit_hash)?; + self.owners.remove(&removed); + self.note_reusable_handle(removed); + Some(removed) + } + + fn retain_circuits(&mut self, active: &HashSet) { + let removed = self + .circuits + .iter() + .filter_map(|(hash, handle)| (!active.contains(hash)).then_some((*hash, *handle))) + .collect::>(); + + for (hash, handle) in removed { + self.circuits.remove(&hash); + self.owners.remove(&handle); + self.note_reusable_handle(handle); + } + } + + fn note_reusable_handle(&mut self, handle: u16) { + if handle >= PREFERRED_CIRCUIT_START || self.next_candidate < PREFERRED_CIRCUIT_START { + self.next_candidate = self.next_candidate.min(handle); + } else if self.circuits.is_empty() { + self.next_candidate = PREFERRED_CIRCUIT_START; + } + } + + fn next_free_handle( + &mut self, + reserved: &HashSet, + retired: Option<&BTreeSet>, + ) -> Option { + if let Some(handle) = self.scan_available( + self.next_candidate, + MAX_DYNAMIC_HANDLE_MAJOR, + reserved, + retired, + ) { + self.next_candidate = advance_handle_candidate(handle); + return Some(handle); + } + + if self.next_candidate > PREFERRED_CIRCUIT_START + && let Some(handle) = self.scan_available( + PREFERRED_CIRCUIT_START, + self.next_candidate - 1, + reserved, + retired, + ) + { + self.next_candidate = advance_handle_candidate(handle); + return Some(handle); + } + + let low_end = PREFERRED_CIRCUIT_START.saturating_sub(1); + let low_start = if self.next_candidate < PREFERRED_CIRCUIT_START { + self.next_candidate.max(1) + } else { + 1 + }; + + if let Some(handle) = self.scan_available(low_start, low_end, reserved, retired) { + self.next_candidate = advance_handle_candidate(handle); + return Some(handle); + } + + if low_start > 1 + && let Some(handle) = self.scan_available(1, low_start - 1, reserved, retired) + { + self.next_candidate = advance_handle_candidate(handle); + return Some(handle); + } + + None + } + + fn scan_available( + &self, + start: u16, + end: u16, + reserved: &HashSet, + retired: Option<&BTreeSet>, + ) -> Option { + if start > end { + return None; + } + + for handle in start..=end { + if reserved.contains(&handle) { + continue; + } + if self.owners.contains_key(&handle) { + continue; + } + if retired.is_some_and(|set| set.contains(&handle)) { + continue; + } + return Some(handle); + } + None + } +} + +fn advance_handle_candidate(handle: u16) -> u16 { + if handle >= MAX_DYNAMIC_HANDLE_MAJOR { + 1 + } else { + handle + 1 + } +} + +fn state_path(config: &Config) -> PathBuf { + Path::new(&config.lqos_directory).join(QDISC_HANDLE_FILE) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedQdiscHandleState { + version: u32, + #[serde(default)] + interfaces: BTreeMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct PersistedInterfaceHandleState { + #[serde(default)] + circuits: BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_primary_default_handles_are_unique() { + let primary = infra_qdisc_handle(1, InfraQdiscSlot::Primary); + let default = infra_qdisc_handle(1, InfraQdiscSlot::Default); + assert_ne!(primary, default); + assert_eq!(primary, 0x2002); + assert_eq!(default, 0x2003); + } + + #[test] + fn allocator_prefers_high_range_and_reuses_existing() { + let mut state = QdiscHandleState::default(); + let reserved = HashSet::from([0x7FFF, 0xFFFF]); + let a = state + .assign_circuit_handle("eth0", 1, &reserved) + .expect("first handle"); + let b = state + .assign_circuit_handle("eth0", 2, &reserved) + .expect("second handle"); + assert_eq!(a, PREFERRED_CIRCUIT_START); + assert_eq!(b, PREFERRED_CIRCUIT_START + 1); + let a_again = state + .assign_circuit_handle("eth0", 1, &reserved) + .expect("reused handle"); + assert_eq!(a, a_again); + } + + #[test] + fn allocator_can_rotate_to_a_new_handle() { + let mut state = QdiscHandleState::default(); + let reserved = HashSet::from([0x7FFF, 0xFFFF]); + let first = state + .assign_circuit_handle("eth0", 1, &reserved) + .expect("first handle"); + let rotated = state + .rotate_circuit_handle("eth0", 1, &reserved) + .expect("rotated handle"); + assert_ne!(first, rotated); + assert_eq!(rotated, first + 1); + } + + #[test] + fn rotated_handles_are_not_reused_until_cleared() { + let mut state = QdiscHandleState::default(); + let reserved = HashSet::from([0x7FFF, 0xFFFF]); + let first = state + .assign_circuit_handle("eth0", 1, &reserved) + .expect("first handle"); + let rotated = state + .rotate_circuit_handle("eth0", 1, &reserved) + .expect("rotated handle"); + let second_circuit = state + .assign_circuit_handle("eth0", 2, &reserved) + .expect("second circuit handle"); + assert_ne!(second_circuit, first); + assert_eq!(second_circuit, rotated + 1); + + state.clear_retired_handles(); + let third_circuit = state + .assign_circuit_handle("eth0", 3, &reserved) + .expect("third circuit handle"); + assert_eq!(third_circuit, first); + } +} diff --git a/src/rust/lqos_bakery/src/queue_math.rs b/src/rust/lqos_bakery/src/queue_math.rs index 7c20cbb2..c58cea57 100644 --- a/src/rust/lqos_bakery/src/queue_math.rs +++ b/src/rust/lqos_bakery/src/queue_math.rs @@ -1,6 +1,12 @@ use std::sync::Arc; use tracing::debug; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SqmKind { + Cake, + FqCodel, +} + pub(crate) fn sqm_as_vec(config: &Arc) -> Vec { config .queues @@ -192,3 +198,17 @@ pub(crate) fn sqm_tokens_for( Some(_) => sqm_rate_fixup(rate, config), // defensive fallback } } + +pub(crate) fn effective_sqm_kind( + rate: f32, + config: &Arc, + override_opt: &Option, +) -> SqmKind { + match sqm_tokens_for(rate, config, override_opt) + .first() + .map(String::as_str) + { + Some("fq_codel") => SqmKind::FqCodel, + _ => SqmKind::Cake, + } +} diff --git a/src/rust/lqos_bakery/src/utils.rs b/src/rust/lqos_bakery/src/utils.rs index 41e9f68e..3fc09a7b 100644 --- a/src/rust/lqos_bakery/src/utils.rs +++ b/src/rust/lqos_bakery/src/utils.rs @@ -14,7 +14,15 @@ pub(crate) fn current_timestamp() -> u64 { static FILE_LOCK: Mutex<()> = Mutex::new(()); -pub(crate) fn execute_in_memory(command_buffer: &Vec>, purpose: &str) { +#[derive(Clone, Debug)] +pub(crate) struct ExecuteResult { + pub(crate) ok: bool, + pub(crate) duration_ms: u64, + pub(crate) failure_summary: Option, +} + +pub(crate) fn execute_in_memory(command_buffer: &Vec>, purpose: &str) -> ExecuteResult { + let started = std::time::Instant::now(); let lock = FILE_LOCK.lock(); info!( "Bakery: Executing in-memory commands: {} lines, for {purpose}", @@ -24,7 +32,12 @@ pub(crate) fn execute_in_memory(command_buffer: &Vec>, purpose: &str let path = Path::new("/tmp/lqos_bakery_commands.txt"); let Some(lines) = write_command_file(path, command_buffer) else { error!("Failed to write commands to file for {purpose}"); - return; + drop(lock); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(format!("Failed to write commands to file for {purpose}")), + }; }; let Ok(output) = std::process::Command::new("/sbin/tc") @@ -33,7 +46,12 @@ pub(crate) fn execute_in_memory(command_buffer: &Vec>, purpose: &str else { let message = format!("Failed to execute tc batch command for {purpose}."); error!(message); - return; + drop(lock); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(message), + }; }; let output_str = String::from_utf8_lossy(&output.stdout) @@ -80,9 +98,20 @@ pub(crate) fn execute_in_memory(command_buffer: &Vec>, purpose: &str let _ = f.write_all(detailed.as_bytes()); let _ = f.flush(); } + drop(lock); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(error_str.trim().to_string()), + }; } drop(lock); // Explicitly drop the lock to release it. This happens automatically at the end of the scope, but it's good to be explicit. + ExecuteResult { + ok: true, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: None, + } } pub(crate) fn write_command_file(path: &Path, commands: &Vec>) -> Option { diff --git a/src/rust/lqos_bus/src/bus/mod.rs b/src/rust/lqos_bus/src/bus/mod.rs index c85b53b7..c6f7fade 100644 --- a/src/rust/lqos_bus/src/bus/mod.rs +++ b/src/rust/lqos_bus/src/bus/mod.rs @@ -12,7 +12,10 @@ mod unix_socket_server; pub use client::{LibreqosBusClient, bus_request}; pub use queue_data::*; pub use reply::BusReply; -pub use request::{BlackboardSystem, BusRequest, TopFlowType, UrgentSeverity, UrgentSource}; +pub use request::{ + BakeryCapacityReportInterface, BlackboardSystem, BusRequest, TopFlowType, UrgentSeverity, + UrgentSource, +}; #[allow(unused_imports)] pub use response::{ AsnHeatmapData, BakeryStatsSnapshot, BusResponse, CircuitHeatmapData, SiteHeatmapData, diff --git a/src/rust/lqos_bus/src/bus/request.rs b/src/rust/lqos_bus/src/bus/request.rs index af02e55d..86071f0f 100644 --- a/src/rust/lqos_bus/src/bus/request.rs +++ b/src/rust/lqos_bus/src/bus/request.rs @@ -6,6 +6,15 @@ use allocative::Allocative; use lqos_config::Tunables; use serde::{Deserialize, Serialize}; +/// Per-interface Bakery qdisc-budget report entry. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct BakeryCapacityReportInterface { + /// Interface name, e.g. `ens19`. + pub name: String, + /// Planned qdisc count for the interface. + pub planned_qdiscs: usize, +} + /// Source system for an urgent issue #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Copy, Allocative)] pub enum UrgentSource { @@ -360,6 +369,20 @@ pub enum BusRequest { /// Get current Bakery statistics GetBakeryStats, + /// Report the most recent Bakery qdisc-budget preflight result. + BakeryReportPreflight { + /// Whether the planned full reload fits within the current safe budget. + ok: bool, + /// Operator-facing summary of the preflight result. + message: String, + /// Safe operational budget used for the preflight. + safe_budget: usize, + /// Hard kernel limit for reference. + hard_limit: usize, + /// Per-interface planned qdisc counts. + interfaces: Vec, + }, + /// Announce that the API is ready ApiReady, diff --git a/src/rust/lqos_bus/src/lib.rs b/src/rust/lqos_bus/src/lib.rs index 0ce5ab05..7cb639db 100644 --- a/src/rust/lqos_bus/src/lib.rs +++ b/src/rust/lqos_bus/src/lib.rs @@ -32,9 +32,10 @@ pub use bus::response::{ StormguardDebugDirection, StormguardDebugEntry, UrgentIssue, WarningLevel, }; pub use bus::{ - BUS_SOCKET_PATH, BlackboardSystem, BusClientError, BusReply, BusRequest, BusResponse, - BusSession, CakeDiffTinTransit, CakeDiffTransit, CakeTransit, LibreqosBusClient, - QueueStoreTransit, TopFlowType, UnixSocketServer, UrgentSeverity, UrgentSource, bus_request, + BUS_SOCKET_PATH, BakeryCapacityReportInterface, BlackboardSystem, BusClientError, BusReply, + BusRequest, BusResponse, BusSession, CakeDiffTinTransit, CakeDiffTransit, CakeTransit, + LibreqosBusClient, QueueStoreTransit, TopFlowType, UnixSocketServer, UrgentSeverity, + UrgentSource, bus_request, }; pub use tc_handle::TcHandle; diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index 1e538123..b9d3963a 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -39,6 +39,14 @@ pub fn load_config() -> Result, LibreQoSConfigError> { Ok(config) } +/// Clears the in-process cached configuration so the next `load_config()` reads from disk again. +/// +/// This function has side effects: it mutates the process-global config cache. +#[doc(hidden)] +pub fn clear_cached_config() { + CONFIG.store(None.into()); +} + fn actually_load_from_disk() -> Result, LibreQoSConfigError> { let config_location = if let Ok(lqos_config) = std::env::var("LQOS_CONFIG") { info!("Overriding lqos.conf location from environment variable."); diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index 2233cd59..3ec74504 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -22,8 +22,8 @@ pub use cpu_topology::{ pub use etc::{ BridgeConfig, Config, LazyQueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig, StormguardStrategy, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, - TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, disable_xdp_bridge, - enable_long_term_stats, load_config, update_config, + TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, clear_cached_config, + disable_xdp_bridge, enable_long_term_stats, load_config, update_config, }; pub use network_json::{NetworkJson, NetworkJsonNode, NetworkJsonTransport}; pub use program_control::load_libreqos; diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index 5e64136b..a9f2ce58 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -3,7 +3,10 @@ #![allow(non_local_definitions)] // Temporary: rewrite required for much of this, for newer PyO3. #![allow(unsafe_op_in_unsafe_fn)] #![warn(missing_docs)] -use lqos_bus::{BlackboardSystem, BusRequest, BusResponse, TcHandle, UrgentSeverity, UrgentSource}; +use lqos_bus::{ + BakeryCapacityReportInterface, BlackboardSystem, BusRequest, BusResponse, TcHandle, + UrgentSeverity, UrgentSource, +}; use lqos_utils::hex_string::read_hex_string; use lqos_utils::rustls::ensure_rustls_crypto_provider; use nix::libc::getpid; @@ -1333,15 +1336,14 @@ fn overrides_network_adjustments_effective(py: Python<'_>) -> PyResult) -> PyResult> { - let config = lqos_config::load_config().map_err(|e| PyOSError::new_err(e.to_string()))?; - let apply_treeguard = config.treeguard.enabled; - - let overrides = match lqos_overrides::OverrideStore::load_effective(false, apply_treeguard) { + let overrides = match lqos_overrides::OverrideStore::load_effective(false, false) { Ok(o) => o, Err(e) => return Err(PyOSError::new_err(e.to_string())), }; @@ -2203,6 +2205,8 @@ impl BakeryCommands { upload_bandwidth_max: *upload_bandwidth_max, class_major: *class_major, up_class_major: *up_class_major, + down_qdisc_handle: None, + up_qdisc_handle: None, ip_addresses: ip_addresses.clone(), sqm_override: sqm_override.clone(), }, @@ -2340,10 +2344,43 @@ impl Bakery { .map(BakeryCommands::as_runtime_command) .collect(); let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); + let is_ok = estimate.ok(); + let interface_reports = estimate + .interfaces + .iter() + .map(|(name, planned_qdiscs)| BakeryCapacityReportInterface { + name: name.clone(), + planned_qdiscs: *planned_qdiscs, + }) + .collect::>(); + let summary = if interface_reports.is_empty() { + format!( + "Planned queue model {} qdisc budget. No shaping interfaces were queued.", + if is_ok { "fits" } else { "exceeds" } + ) + } else { + let interface_summary = interface_reports + .iter() + .map(|entry| format!("{} estimated {} qdiscs", entry.name, entry.planned_qdiscs)) + .collect::>() + .join(", "); + format!( + "Planned queue model {} qdisc budget. {interface_summary}; safe budget {}, kernel limit {}.", + if is_ok { "fits" } else { "exceeds" }, + estimate.safe_budget, + estimate.hard_limit, + ) + }; + let _ = run_query(vec![BusRequest::BakeryReportPreflight { + ok: is_ok, + message: summary, + safe_budget: estimate.safe_budget, + hard_limit: estimate.hard_limit, + interfaces: interface_reports, + }]); let result = PyDict::new(py); let interfaces = PyDict::new(py); - let is_ok = estimate.ok(); for (interface, count) in estimate.interfaces { interfaces.set_item(interface, count)?; diff --git a/src/rust/lqos_queue_tracker/src/lib.rs b/src/rust/lqos_queue_tracker/src/lib.rs index ef3de6db..cebcae6a 100644 --- a/src/rust/lqos_queue_tracker/src/lib.rs +++ b/src/rust/lqos_queue_tracker/src/lib.rs @@ -20,9 +20,10 @@ const NUM_QUEUE_HISTORY: usize = 600; pub use bus::get_raw_circuit_data; pub use interval::set_queue_refresh_interval; +#[doc(hidden)] pub use queue_structure::{ - EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD, - spawn_queue_structure_monitor, + EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD, QueueNode, + QueueStructure, spawn_queue_structure_monitor, }; pub use queue_types::deserialize_tc_tree; // Exported for the benchmarker pub use tracking::spawn_queue_monitor; diff --git a/src/rust/lqos_queue_tracker/src/queue_structure/mod.rs b/src/rust/lqos_queue_tracker/src/queue_structure/mod.rs index 409ce614..6d33280c 100644 --- a/src/rust/lqos_queue_tracker/src/queue_structure/mod.rs +++ b/src/rust/lqos_queue_tracker/src/queue_structure/mod.rs @@ -2,11 +2,13 @@ mod queing_structure_json_monitor; mod queue_network; mod queue_node; pub use queing_structure_json_monitor::spawn_queue_structure_monitor; +#[doc(hidden)] pub use queing_structure_json_monitor::{ - EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD, + EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD, QueueStructure, }; use queue_network::QueueNetwork; -pub(crate) use queue_node::QueueNode; +#[doc(hidden)] +pub use queue_node::QueueNode; use thiserror::Error; pub(crate) fn read_queueing_structure() -> Result, QueueStructureError> { diff --git a/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs b/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs index 7a3b8407..7d1783b4 100644 --- a/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs +++ b/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs @@ -30,7 +30,9 @@ pub static EFFECTIVE_NODE_RATES: Lazy>> = La /// to avoid circular dependencies. pub static QUEUE_STRUCTURE_CHANGED_STORMGUARD: AtomicBool = AtomicBool::new(false); +#[allow(missing_docs)] #[derive(Clone)] +/// Snapshot of the current flattened queue tree loaded from `queuingStructure.json`. pub struct QueueStructure { pub maybe_queues: Option>, } diff --git a/src/rust/lqos_queue_tracker/src/queue_structure/queue_node.rs b/src/rust/lqos_queue_tracker/src/queue_structure/queue_node.rs index fcbf8e89..d255aefc 100644 --- a/src/rust/lqos_queue_tracker/src/queue_structure/queue_node.rs +++ b/src/rust/lqos_queue_tracker/src/queue_structure/queue_node.rs @@ -5,6 +5,7 @@ use lqos_utils::hex_string::read_hex_string; use serde_json::Value; use tracing::{debug, error, warn}; +#[allow(missing_docs)] #[derive(Default, Clone, Debug)] pub struct QueueNode { pub name: Option, diff --git a/src/rust/lqos_queue_tracker/src/tracking/mod.rs b/src/rust/lqos_queue_tracker/src/tracking/mod.rs index 3c3fdb3e..9c38ae79 100644 --- a/src/rust/lqos_queue_tracker/src/tracking/mod.rs +++ b/src/rust/lqos_queue_tracker/src/tracking/mod.rs @@ -297,3 +297,63 @@ pub fn spawn_queue_monitor() -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::connect_queues_to_circuit; + use crate::{QueueNode, deserialize_tc_tree}; + use lqos_bus::TcHandle; + use lqos_utils::hash_to_i64; + + fn test_structure() -> Vec { + vec![QueueNode { + class_id: TcHandle::from_string("3:14af").expect("valid class id"), + up_class_id: TcHandle::from_string("43:14af").expect("valid up class id"), + parent_class_id: TcHandle::from_string("3:20").expect("valid parent"), + up_parent_class_id: TcHandle::from_string("43:20").expect("valid up parent"), + class_major: 0x0003, + up_class_major: 0x0043, + class_minor: 0x14af, + circuit_id: Some("circuit-1".to_string()), + circuit_hash: Some(hash_to_i64("circuit-1")), + ..QueueNode::default() + }] + } + + #[test] + fn cake_queue_matching_uses_parent_not_leaf_handle() { + let structure = test_structure(); + let old_handle_queues = deserialize_tc_tree( + r#"[{ + "kind":"cake", + "handle":"9000:", + "parent":"3:14af", + "options":{}, + "tins":[], + "drops":7 + }]"#, + ) + .expect("old-handle cake should parse"); + let rotated_handle_queues = deserialize_tc_tree( + r#"[{ + "kind":"cake", + "handle":"9002:", + "parent":"3:14af", + "options":{}, + "tins":[], + "drops":11 + }]"#, + ) + .expect("rotated-handle cake should parse"); + + let old_matches = connect_queues_to_circuit(&structure, &old_handle_queues); + let rotated_matches = connect_queues_to_circuit(&structure, &rotated_handle_queues); + + assert_eq!(old_matches.len(), 1); + assert_eq!(rotated_matches.len(), 1); + assert_eq!(old_matches[0].circuit_hash, hash_to_i64("circuit-1")); + assert_eq!(rotated_matches[0].circuit_hash, hash_to_i64("circuit-1")); + assert_eq!(old_matches[0].drops, 7); + assert_eq!(rotated_matches[0].drops, 11); + } +} diff --git a/src/rust/lqos_stormguard/src/adaptive_actions.rs b/src/rust/lqos_stormguard/src/adaptive_actions.rs index 367adb7d..8860705e 100644 --- a/src/rust/lqos_stormguard/src/adaptive_actions.rs +++ b/src/rust/lqos_stormguard/src/adaptive_actions.rs @@ -309,6 +309,8 @@ fn apply_circuit_sqm_override_live( upload_bandwidth_max: node.upload_bandwidth_mbps as f32, class_major, up_class_major, + down_qdisc_handle: None, + up_qdisc_handle: None, ip_addresses: ip_list(devices), sqm_override: sqm_override.map(|value| value.to_string()), })?; diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index 7630d238..280b515a 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -711,6 +711,8 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec upload_bandwidth_max: *upload_bandwidth_max, class_major: *class_major, up_class_major: *up_class_major, + down_qdisc_handle: None, + up_qdisc_handle: None, ip_addresses: ip_addresses.clone(), sqm_override: sqm_override.clone(), }); @@ -736,6 +738,30 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec BusRequest::GetBakeryStats => BusResponse::BakeryActiveCircuits( lqos_bakery::ACTIVE_CIRCUITS.load(std::sync::atomic::Ordering::Relaxed), ), + BusRequest::BakeryReportPreflight { + ok, + message, + safe_budget, + hard_limit, + interfaces, + } => { + lqos_bakery::record_qdisc_preflight_snapshot( + lqos_bakery::BakeryPreflightSnapshot { + ok: *ok, + message: message.clone(), + safe_budget: *safe_budget, + hard_limit: *hard_limit, + interfaces: interfaces + .iter() + .map(|entry| lqos_bakery::BakeryCapacityInterfaceSnapshot { + name: entry.name.clone(), + planned_qdiscs: entry.planned_qdiscs, + }) + .collect(), + }, + ); + BusResponse::Ack + }, BusRequest::ApiReady => { tool_status::api_seen(); BusResponse::Ack diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_activity.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_activity.js new file mode 100644 index 00000000..b7f86b63 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_activity.js @@ -0,0 +1,98 @@ +import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import {formatUnixSecondsToLocalDateTime, mkBadge} from "./bakery_shared"; + +function eventBadge(entry) { + const status = (entry?.status ?? "").toString().toLowerCase(); + if (status === "error") { + return mkBadge("Error", "bg-danger-subtle text-danger border border-danger-subtle", entry?.event || ""); + } + if (status === "warning") { + return mkBadge("Warning", "bg-warning-subtle text-warning border border-warning-subtle", entry?.event || ""); + } + return mkBadge("Info", "bg-light text-secondary border", entry?.event || ""); +} + +export class BakeryActivityDashlet extends BaseDashlet { + constructor(slot) { + super(slot); + this.size = 12; + } + + title() { + return "Recent Bakery Events"; + } + + tooltip() { + return "
Recent Bakery Events

Recent Bakery lifecycle events, including commit receipt, apply starts/finishes, preflight blocks, and failures.

"; + } + + subscribeTo() { + return ["BakeryActivity"]; + } + + buildContainer() { + const base = super.buildContainer(); + base.classList.add("dashbox-body-scroll"); + + const wrap = document.createElement("div"); + wrap.classList.add("p-2"); + + const tableWrap = document.createElement("div"); + tableWrap.classList.add("lqos-table-wrap"); + + const table = document.createElement("table"); + table.classList.add("lqos-table", "lqos-table-compact", "mb-0", "small"); + + const thead = document.createElement("thead"); + const hr = document.createElement("tr"); + ["Local Time", "Status", "Summary"].forEach((label) => { + const th = document.createElement("th"); + th.textContent = label; + hr.appendChild(th); + }); + thead.appendChild(hr); + + this.tbody = document.createElement("tbody"); + table.appendChild(thead); + table.appendChild(this.tbody); + tableWrap.appendChild(table); + wrap.appendChild(tableWrap); + base.appendChild(wrap); + return base; + } + + onMessage(msg) { + if (msg.event !== "BakeryActivity") { + return; + } + + const entries = Array.isArray(msg.data) ? msg.data : []; + this.tbody.innerHTML = ""; + if (entries.length === 0) { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 3; + td.textContent = "No recent activity"; + tr.appendChild(td); + this.tbody.appendChild(tr); + return; + } + + entries.forEach((entry) => { + const tr = document.createElement("tr"); + const tdTime = document.createElement("td"); + const tdStatus = document.createElement("td"); + const tdSummary = document.createElement("td"); + + tdTime.textContent = formatUnixSecondsToLocalDateTime(entry?.ts); + tdStatus.appendChild(eventBadge(entry)); + tdSummary.textContent = entry?.summary || "—"; + tdSummary.title = entry?.event || ""; + + tr.appendChild(tdTime); + tr.appendChild(tdStatus); + tr.appendChild(tdSummary); + this.tbody.appendChild(tr); + }); + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_apply_summary.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_apply_summary.js new file mode 100644 index 00000000..e52ea14d --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_apply_summary.js @@ -0,0 +1,100 @@ +import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import {formatDurationMs, mkBadge} from "./bakery_shared"; + +function formatApplyType(kindRaw) { + switch ((kindRaw ?? "").toString()) { + case "FullReload": + return mkBadge("Full Reload", "bg-warning-subtle text-warning border border-warning-subtle"); + case "LiveChange": + return mkBadge("Live Change", "bg-info-subtle text-info border border-info-subtle"); + case "None": + default: + return mkBadge("None", "bg-light text-secondary border"); + } +} + +export class BakeryApplySummaryDashlet extends BaseDashlet { + constructor(slot) { + super(slot); + this.size = 4; + } + + title() { + return "Apply Summary"; + } + + tooltip() { + return "
Apply Summary

Shows the most recent Bakery apply type, command counts, and recorded build/apply durations.

"; + } + + subscribeTo() { + return ["BakeryStatus"]; + } + + buildContainer() { + const base = super.buildContainer(); + const wrap = document.createElement("div"); + wrap.classList.add("p-2"); + + const tableWrap = document.createElement("div"); + tableWrap.classList.add("lqos-table-wrap"); + + const table = document.createElement("table"); + table.classList.add("lqos-table", "lqos-table-compact", "mb-0", "small"); + const tbody = document.createElement("tbody"); + + const mkRow = (label, valueEl) => { + const tr = document.createElement("tr"); + const tdL = document.createElement("td"); + tdL.classList.add("table-label-cell"); + tdL.style.width = "42%"; + tdL.textContent = label; + const tdV = document.createElement("td"); + tdV.classList.add("table-value-cell"); + tdV.appendChild(valueEl); + tr.appendChild(tdL); + tr.appendChild(tdV); + return tr; + }; + + this.applyTypeEl = document.createElement("div"); + this.totalCommandsEl = document.createElement("span"); + this.classCommandsEl = document.createElement("span"); + this.qdiscCommandsEl = document.createElement("span"); + this.buildDurationEl = document.createElement("span"); + this.applyDurationEl = document.createElement("span"); + + tbody.appendChild(mkRow("Last Apply Type", this.applyTypeEl)); + tbody.appendChild(mkRow("Total tc Commands", this.totalCommandsEl)); + tbody.appendChild(mkRow("Class Commands", this.classCommandsEl)); + tbody.appendChild(mkRow("Qdisc Commands", this.qdiscCommandsEl)); + tbody.appendChild(mkRow("Build Time", this.buildDurationEl)); + tbody.appendChild(mkRow("Apply Time", this.applyDurationEl)); + + table.appendChild(tbody); + tableWrap.appendChild(table); + wrap.appendChild(tableWrap); + base.appendChild(wrap); + return base; + } + + onMessage(msg) { + if (msg.event !== "BakeryStatus") { + return; + } + const status = msg?.data?.currentState || {}; + this.applyTypeEl.innerHTML = ""; + this.applyTypeEl.appendChild(formatApplyType(status.lastApplyType)); + this.totalCommandsEl.textContent = Number.isFinite(status.lastTotalTcCommands) + ? status.lastTotalTcCommands.toLocaleString() + : "—"; + this.classCommandsEl.textContent = Number.isFinite(status.lastClassCommands) + ? status.lastClassCommands.toLocaleString() + : "—"; + this.qdiscCommandsEl.textContent = Number.isFinite(status.lastQdiscCommands) + ? status.lastQdiscCommands.toLocaleString() + : "—"; + this.buildDurationEl.textContent = formatDurationMs(status.lastBuildDurationMs); + this.applyDurationEl.textContent = formatDurationMs(status.lastApplyDurationMs); + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_capacity.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_capacity.js new file mode 100644 index 00000000..87f70281 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_capacity.js @@ -0,0 +1,115 @@ +import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import {bakeryPreflightBadge} from "./bakery_shared"; + +export class BakeryCapacityDashlet extends BaseDashlet { + constructor(slot) { + super(slot); + this.size = 4; + this.lastPreflight = null; + } + + title() { + return "Capacity / Safety"; + } + + tooltip() { + return "
Capacity / Safety

Shows the last recorded Bakery qdisc-budget preflight, including per-interface planned qdisc counts and whether the full reload was within budget.

"; + } + + subscribeTo() { + return ["BakeryStatus"]; + } + + buildContainer() { + const base = super.buildContainer(); + const wrap = document.createElement("div"); + wrap.classList.add("p-2"); + + this.badgeWrap = document.createElement("div"); + this.badgeWrap.classList.add("mb-2"); + wrap.appendChild(this.badgeWrap); + + this.summaryEl = document.createElement("div"); + this.summaryEl.classList.add("small", "text-muted", "mb-2"); + this.summaryEl.style.whiteSpace = "normal"; + this.summaryEl.style.wordBreak = "break-word"; + wrap.appendChild(this.summaryEl); + + const tableWrap = document.createElement("div"); + tableWrap.classList.add("lqos-table-wrap"); + + const table = document.createElement("table"); + table.classList.add("lqos-table", "lqos-table-compact", "mb-0", "small"); + + const thead = document.createElement("thead"); + const hr = document.createElement("tr"); + ["Interface", "Planned", "Budget"].forEach((label) => { + const th = document.createElement("th"); + th.textContent = label; + hr.appendChild(th); + }); + thead.appendChild(hr); + + this.tbody = document.createElement("tbody"); + + table.appendChild(thead); + table.appendChild(this.tbody); + tableWrap.appendChild(table); + wrap.appendChild(tableWrap); + base.appendChild(wrap); + return base; + } + + onMessage(msg) { + if (msg.event !== "BakeryStatus") { + return; + } + this.lastPreflight = msg?.data?.currentState?.preflight || null; + this.renderPreflight(); + } + + renderPreflight() { + this.badgeWrap.innerHTML = ""; + this.badgeWrap.appendChild(bakeryPreflightBadge(this.lastPreflight)); + + this.summaryEl.textContent = this.lastPreflight?.message || "No preflight has been recorded yet."; + this.tbody.innerHTML = ""; + + const interfaces = Array.isArray(this.lastPreflight?.interfaces) ? this.lastPreflight.interfaces : []; + if (interfaces.length === 0) { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 3; + td.textContent = "No interface budget data"; + tr.appendChild(td); + this.tbody.appendChild(tr); + return; + } + + interfaces.forEach((entry) => { + const tr = document.createElement("tr"); + const tdName = document.createElement("td"); + const tdPlanned = document.createElement("td"); + const tdBudget = document.createElement("td"); + + tdName.textContent = entry?.name || "—"; + tdPlanned.textContent = Number.isFinite(entry?.plannedQdiscs) + ? entry.plannedQdiscs.toLocaleString() + : "—"; + + const hardLimit = Number.isFinite(this.lastPreflight?.hardLimit) ? this.lastPreflight.hardLimit : null; + const safeBudget = Number.isFinite(this.lastPreflight?.safeBudget) ? this.lastPreflight.safeBudget : null; + tdBudget.textContent = safeBudget !== null && hardLimit !== null + ? `${safeBudget.toLocaleString()} / ${hardLimit.toLocaleString()}` + : "—"; + if (Number.isFinite(entry?.plannedQdiscs) && safeBudget !== null && entry.plannedQdiscs > safeBudget) { + tdPlanned.classList.add("text-danger"); + } + + tr.appendChild(tdName); + tr.appendChild(tdPlanned); + tr.appendChild(tdBudget); + this.tbody.appendChild(tr); + }); + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_shared.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_shared.js new file mode 100644 index 00000000..5425d49b --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_shared.js @@ -0,0 +1,78 @@ +export function formatUnixSecondsToLocalDateTime(unixSeconds) { + const n = typeof unixSeconds === "number" ? unixSeconds : parseInt(unixSeconds, 10); + if (!Number.isFinite(n) || n <= 0) { + return "—"; + } + return new Date(n * 1000).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +export function formatDurationMs(durationMs) { + const n = typeof durationMs === "number" ? durationMs : parseInt(durationMs, 10); + if (!Number.isFinite(n) || n < 0) { + return "—"; + } + if (n < 1000) { + return `${n} ms`; + } + if (n < 60_000) { + return `${(n / 1000).toFixed(1)} s`; + } + return `${(n / 60_000).toFixed(1)} min`; +} + +export function formatElapsedSince(unixSeconds) { + const n = typeof unixSeconds === "number" ? unixSeconds : parseInt(unixSeconds, 10); + if (!Number.isFinite(n) || n <= 0) { + return "—"; + } + const delta = Math.max(0, Math.floor(Date.now() / 1000) - n); + if (delta < 60) { + return `${delta}s`; + } + if (delta < 3600) { + return `${Math.floor(delta / 60)}m ${delta % 60}s`; + } + const hours = Math.floor(delta / 3600); + const mins = Math.floor((delta % 3600) / 60); + return `${hours}h ${mins}m`; +} + +export function mkBadge(text, className, title = "") { + const span = document.createElement("span"); + span.className = `badge ${className}`; + span.textContent = text; + if (title) { + span.title = title; + } + return span; +} + +export function bakeryModeBadge(modeRaw) { + const mode = (modeRaw ?? "").toString(); + switch (mode) { + case "ApplyingFullReload": + return mkBadge("Applying Full Reload", "bg-warning-subtle text-warning border border-warning-subtle"); + case "ApplyingLiveChange": + return mkBadge("Applying Live Change", "bg-info-subtle text-info border border-info-subtle"); + case "Idle": + default: + return mkBadge("Idle", "bg-light text-secondary border"); + } +} + +export function bakeryPreflightBadge(preflight) { + if (!preflight) { + return mkBadge("Unknown", "bg-light text-secondary border"); + } + if (preflight.ok) { + return mkBadge("Within Budget", "bg-success-subtle text-success border border-success-subtle", preflight.message || ""); + } + return mkBadge("Over Budget", "bg-danger-subtle text-danger border border-danger-subtle", preflight.message || ""); +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status_summary.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status_summary.js new file mode 100644 index 00000000..244104fe --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status_summary.js @@ -0,0 +1,101 @@ +import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; +import { + bakeryModeBadge, + formatElapsedSince, + formatUnixSecondsToLocalDateTime +} from "./bakery_shared"; + +export class BakeryStatusSummaryDashlet extends BaseDashlet { + constructor(slot) { + super(slot); + this.size = 4; + this.lastStatus = null; + } + + title() { + return "Bakery Status"; + } + + tooltip() { + return "
Bakery Status

Current Bakery mode, how long the current action has been running, and the last recorded success/failure timestamps.

"; + } + + subscribeTo() { + return ["BakeryStatus"]; + } + + buildContainer() { + const base = super.buildContainer(); + const wrap = document.createElement("div"); + wrap.classList.add("p-2"); + + const tableWrap = document.createElement("div"); + tableWrap.classList.add("lqos-table-wrap"); + + const table = document.createElement("table"); + table.classList.add("lqos-table", "lqos-table-compact", "mb-0", "small"); + const tbody = document.createElement("tbody"); + + const mkRow = (label, valueEl) => { + const tr = document.createElement("tr"); + const tdL = document.createElement("td"); + tdL.classList.add("table-label-cell"); + tdL.style.width = "42%"; + tdL.textContent = label; + const tdV = document.createElement("td"); + tdV.classList.add("table-value-cell"); + tdV.appendChild(valueEl); + tr.appendChild(tdL); + tr.appendChild(tdV); + return tr; + }; + + this.modeEl = document.createElement("div"); + this.durationEl = document.createElement("span"); + this.lastSuccessEl = document.createElement("span"); + this.lastFailureEl = document.createElement("span"); + this.lastFailureSummaryEl = document.createElement("span"); + this.lastFailureSummaryEl.style.whiteSpace = "normal"; + this.lastFailureSummaryEl.style.wordBreak = "break-word"; + + tbody.appendChild(mkRow("Current Mode", this.modeEl)); + tbody.appendChild(mkRow("Current Duration", this.durationEl)); + tbody.appendChild(mkRow("Last Success", this.lastSuccessEl)); + tbody.appendChild(mkRow("Last Failure", this.lastFailureEl)); + tbody.appendChild(mkRow("Failure Summary", this.lastFailureSummaryEl)); + + table.appendChild(tbody); + tableWrap.appendChild(table); + wrap.appendChild(tableWrap); + base.appendChild(wrap); + return base; + } + + onMessage(msg) { + if (msg.event !== "BakeryStatus") { + return; + } + this.lastStatus = msg?.data?.currentState || null; + this.renderStatus(); + } + + onCadence() { + if (this.lastStatus?.mode === "ApplyingFullReload" || this.lastStatus?.mode === "ApplyingLiveChange") { + this.renderStatus(); + } + } + + renderStatus() { + const status = this.lastStatus || {}; + + this.modeEl.innerHTML = ""; + this.modeEl.appendChild(bakeryModeBadge(status.mode)); + + this.durationEl.textContent = status.currentActionStartedUnix + ? formatElapsedSince(status.currentActionStartedUnix) + : "—"; + this.lastSuccessEl.textContent = formatUnixSecondsToLocalDateTime(status.lastSuccessUnix); + this.lastFailureEl.textContent = formatUnixSecondsToLocalDateTime(status.lastFailureUnix); + this.lastFailureSummaryEl.textContent = status.lastFailureSummary || "—"; + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/dashlet_index.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/dashlet_index.js index 68211401..7538b44d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/dashlet_index.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/dashlet_index.js @@ -28,6 +28,10 @@ import {LtsLast24Hours} from "./ltsLast24Hours"; import {TcpRetransmitsDash} from "./total_retransmits"; import {StormguardStatusDashlet} from "./stormguard_status"; import {BakeryStatusDashlet} from "./bakery_status"; +import {BakeryStatusSummaryDashlet} from "./bakery_status_summary"; +import {BakeryApplySummaryDashlet} from "./bakery_apply_summary"; +import {BakeryCapacityDashlet} from "./bakery_capacity"; +import {BakeryActivityDashlet} from "./bakery_activity"; import {TreeGuardStatusDashlet} from "./treeguard_status"; import {TreeGuardActivityDashlet} from "./treeguard_activity"; import {TreeguardControlsDashlet} from "./treeguard_controls"; @@ -83,6 +87,10 @@ export const DashletMenu = [ { name: "(Insight) Last 24 Hours", tag: "ltsLast24", size: 3, category: "Insight" }, { name: "Stormguard Bandwidth Adjustments", tag: "stormguardStatus", size: 6, category: "Queue Management" }, { name: "Bakery Circuit Activity", tag: "bakeryStatus", size: 6, category: "Queue Management" }, + { name: "Bakery Status", tag: "bakeryStatusSummary", size: 4, category: "Bakery" }, + { name: "Bakery Apply Summary", tag: "bakeryApplySummary", size: 4, category: "Bakery" }, + { name: "Bakery Capacity / Safety", tag: "bakeryCapacity", size: 4, category: "Bakery" }, + { name: "Bakery Recent Activity", tag: "bakeryActivity", size: 12, category: "Bakery" }, { name: "TreeGuard Controls", tag: "treeguardControls", size: 6, category: "TreeGuard" }, { name: "TreeGuard Status", tag: "treeguardStatus", size: 6, category: "TreeGuard" }, { name: "TreeGuard Activity", tag: "treeguardActivity", size: 12, category: "TreeGuard" }, @@ -137,6 +145,10 @@ export function widgetFactory(widgetName, count) { case "ltsLast24" : widget = new LtsLast24Hours(count); break; case "stormguardStatus" : widget = new StormguardStatusDashlet(count); break; case "bakeryStatus" : widget = new BakeryStatusDashlet(count); break; + case "bakeryStatusSummary": widget = new BakeryStatusSummaryDashlet(count); break; + case "bakeryApplySummary": widget = new BakeryApplySummaryDashlet(count); break; + case "bakeryCapacity": widget = new BakeryCapacityDashlet(count); break; + case "bakeryActivity": widget = new BakeryActivityDashlet(count); break; case "treeguardControls": widget = new TreeguardControlsDashlet(count); break; case "treeguardStatus" : widget = new TreeGuardStatusDashlet(count); break; case "treeguardActivity": widget = new TreeGuardActivityDashlet(count); break; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js index 1644f7e8..b1629a95 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/executive_snapshot.js @@ -92,13 +92,14 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { { label: "CAKE", value: formatCount(header.cake_queue_count) }, { label: "fq-codel", value: formatCount(header.fq_codel_queue_count) }, ]; + const allowQueueAlerts = !header?.bakery_reload_in_progress; return this.groupCard( "Queues", "fa-stream", "text-secondary", items, false, - true, + allowQueueAlerts, this.queueStatusBadge(header), this.queueStatusMessage(header), ); @@ -166,6 +167,9 @@ export class ExecutiveSnapshotDashlet extends BaseDashlet { if (!header?.queue_stats_stale) { return ""; } + if (header?.bakery_reload_in_progress) { + return ""; + } const text = header?.bakery_reload_in_progress ? "Live queue polling is paused during the current Bakery reload." : "Live queue polling is behind; values shown are the last known counts."; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/index.js b/src/rust/lqosd/src/node_manager/js_build/src/index.js index 21003811..92bd093b 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/index.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/index.js @@ -78,6 +78,16 @@ const defaultLayout = { { tag: "treeguardStatus", size: 6 }, { tag: "treeguardActivity", size: 12 } ] + }, + { + name: "Bakery", + dashlets: [ + { tag: "bakeryStatusSummary", size: 4 }, + { tag: "bakeryApplySummary", size: 4 }, + { tag: "bakeryCapacity", size: 4 }, + { tag: "bakeryStatus", size: 12 }, + { tag: "bakeryActivity", size: 12 } + ] } ] }; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/layout.js b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/layout.js index 157dd9c3..97efc038 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/layout.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/layout.js @@ -11,7 +11,10 @@ export class DashboardLayout { // Check if it's the new format with version if (parsed.version >= 2) { this.version = parsed.version; - this.tabs = Array.isArray(parsed.tabs) ? parsed.tabs : []; + this.tabs = DashboardLayout.mergeMissingDefaultTabs( + Array.isArray(parsed.tabs) ? parsed.tabs : [], + this._defaultTabs + ); this.activeTab = parsed.activeTab || 0; } else { // Old format saved in localStorage (pre-tabs) @@ -126,3 +129,25 @@ DashboardLayout.tabsEqual = function(tabsA, tabsB) { } return true; }; + +DashboardLayout.mergeMissingDefaultTabs = function(existingTabs, defaultTabs) { + if (!Array.isArray(existingTabs) || !Array.isArray(defaultTabs) || defaultTabs.length === 0) { + return Array.isArray(existingTabs) ? existingTabs : []; + } + const knownNames = new Set(existingTabs.map((tab) => tab?.name || "")); + const merged = existingTabs.slice(); + defaultTabs.forEach((tab) => { + const name = tab?.name || ""; + if (!name || knownNames.has(name)) { + return; + } + knownNames.add(name); + merged.push({ + name, + dashlets: Array.isArray(tab?.dashlets) + ? tab.dashlets.map((dashlet) => ({ tag: dashlet.tag, size: dashlet.size })) + : [] + }); + }); + return merged; +}; diff --git a/src/rust/lqosd/src/node_manager/ws/messages.rs b/src/rust/lqosd/src/node_manager/ws/messages.rs index 0ee5bd2a..0b6759d8 100644 --- a/src/rust/lqosd/src/node_manager/ws/messages.rs +++ b/src/rust/lqosd/src/node_manager/ws/messages.rs @@ -317,18 +317,56 @@ pub struct RamData { pub used: u64, } -#[derive(Debug, Serialize)] -pub struct BakeryStatusState { - #[serde(rename = "activeCircuits")] - pub active_circuits: usize, +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BakeryCapacityInterfaceData { + pub name: String, + pub planned_qdiscs: usize, } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BakeryPreflightData { + pub ok: bool, + pub message: String, + pub safe_budget: usize, + pub hard_limit: usize, + pub interfaces: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BakeryStatusState { + pub active_circuits: usize, + pub mode: String, + pub current_action_started_unix: Option, + pub last_success_unix: Option, + pub last_failure_unix: Option, + pub last_failure_summary: Option, + pub last_apply_type: String, + pub last_total_tc_commands: usize, + pub last_class_commands: usize, + pub last_qdisc_commands: usize, + pub last_build_duration_ms: u64, + pub last_apply_duration_ms: u64, + pub preflight: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct BakeryStatusData { - #[serde(rename = "currentState")] pub current_state: BakeryStatusState, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BakeryActivityEntry { + pub ts: u64, + pub event: String, + pub status: String, + pub summary: String, +} + #[derive(Clone, Debug, Serialize)] pub struct TreeguardStatusData { pub enabled: bool, @@ -780,6 +818,9 @@ pub enum WsResponse { BakeryStatus { data: BakeryStatusData, }, + BakeryActivity { + data: Vec, + }, TreeGuardStatus { data: TreeguardStatusData, }, diff --git a/src/rust/lqosd/src/node_manager/ws/published_channels.rs b/src/rust/lqosd/src/node_manager/ws/published_channels.rs index b84fd9a9..e736dcd6 100644 --- a/src/rust/lqosd/src/node_manager/ws/published_channels.rs +++ b/src/rust/lqosd/src/node_manager/ws/published_channels.rs @@ -46,6 +46,7 @@ pub enum PublishedChannels { StormguardStatus, StormguardDebug, BakeryStatus, + BakeryActivity, TreeGuardStatus, TreeGuardActivity, EndpointLatLon, diff --git a/src/rust/lqosd/src/node_manager/ws/ticker.rs b/src/rust/lqosd/src/node_manager/ws/ticker.rs index d7f6323d..d12d44c1 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker.rs @@ -194,10 +194,8 @@ async fn one_second_cadence( "stormguard", stormguard::stormguard_ticker(channels.clone(), bus_tx.clone()) ), - ticker_with_timeout( - "bakery", - bakery::bakery_ticker(channels.clone(), bus_tx.clone()) - ), + ticker_with_timeout("bakery_status", bakery::bakery_status(channels.clone())), + ticker_with_timeout("bakery_activity", bakery::bakery_activity(channels.clone())), ticker_with_timeout( "treeguard_status", treeguard::treeguard_status(channels.clone()) diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs b/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs index afa69ff7..24c40c51 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs @@ -1,14 +1,80 @@ -use crate::node_manager::ws::messages::{BakeryStatusData, BakeryStatusState, WsResponse}; +use crate::node_manager::ws::messages::{ + BakeryActivityEntry, BakeryCapacityInterfaceData, BakeryPreflightData, BakeryStatusData, + BakeryStatusState, WsResponse, +}; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use lqos_bus::{BusReply, BusRequest, BusResponse}; +use lqos_bakery::{ + BakeryActivityEntry as BakeryActivitySnapshot, BakeryApplyType, BakeryMode, + BakeryPreflightSnapshot, BakeryStatusSnapshot, +}; use std::sync::Arc; -use tokio::sync::mpsc::Sender; -pub async fn bakery_ticker( - pubsub: Arc, - bus_tx: Sender<(tokio::sync::oneshot::Sender, BusRequest)>, -) { +fn mode_to_string(mode: BakeryMode) -> String { + match mode { + BakeryMode::Idle => "Idle", + BakeryMode::ApplyingFullReload => "ApplyingFullReload", + BakeryMode::ApplyingLiveChange => "ApplyingLiveChange", + } + .to_string() +} + +fn apply_type_to_string(apply_type: BakeryApplyType) -> String { + match apply_type { + BakeryApplyType::None => "None", + BakeryApplyType::FullReload => "FullReload", + BakeryApplyType::LiveChange => "LiveChange", + } + .to_string() +} + +fn map_preflight(snapshot: BakeryPreflightSnapshot) -> BakeryPreflightData { + BakeryPreflightData { + ok: snapshot.ok, + message: snapshot.message, + safe_budget: snapshot.safe_budget, + hard_limit: snapshot.hard_limit, + interfaces: snapshot + .interfaces + .into_iter() + .map(|entry| BakeryCapacityInterfaceData { + name: entry.name, + planned_qdiscs: entry.planned_qdiscs, + }) + .collect(), + } +} + +fn map_status(snapshot: BakeryStatusSnapshot) -> BakeryStatusData { + BakeryStatusData { + current_state: BakeryStatusState { + active_circuits: snapshot.active_circuits, + mode: mode_to_string(snapshot.mode), + current_action_started_unix: snapshot.current_action_started_unix, + last_success_unix: snapshot.last_success_unix, + last_failure_unix: snapshot.last_failure_unix, + last_failure_summary: snapshot.last_failure_summary, + last_apply_type: apply_type_to_string(snapshot.last_apply_type), + last_total_tc_commands: snapshot.last_total_tc_commands, + last_class_commands: snapshot.last_class_commands, + last_qdisc_commands: snapshot.last_qdisc_commands, + last_build_duration_ms: snapshot.last_build_duration_ms, + last_apply_duration_ms: snapshot.last_apply_duration_ms, + preflight: snapshot.preflight.map(map_preflight), + }, + } +} + +fn map_activity(entry: BakeryActivitySnapshot) -> BakeryActivityEntry { + BakeryActivityEntry { + ts: entry.ts, + event: entry.event, + status: entry.status, + summary: entry.summary, + } +} + +pub async fn bakery_status(pubsub: Arc) { if !pubsub .is_channel_alive(PublishedChannels::BakeryStatus) .await @@ -16,25 +82,23 @@ pub async fn bakery_ticker( return; } - // Request stats from bus - let (tx, rx) = tokio::sync::oneshot::channel::(); - let request = BusRequest::GetBakeryStats; - if let Ok(_) = bus_tx.send((tx, request)).await - && let Ok(replies) = rx.await - { - for response in replies.responses { - if let BusResponse::BakeryActiveCircuits(stats) = response { - // Format as JSON - let msg = WsResponse::BakeryStatus { - data: BakeryStatusData { - current_state: BakeryStatusState { - active_circuits: stats, - }, - }, - }; - - pubsub.send(PublishedChannels::BakeryStatus, msg).await; - } - } - } + let data = map_status(lqos_bakery::bakery_status_snapshot()); + let msg = WsResponse::BakeryStatus { data }; + pubsub.send(PublishedChannels::BakeryStatus, msg).await; +} + +pub async fn bakery_activity(pubsub: Arc) { + if !pubsub + .is_channel_alive(PublishedChannels::BakeryActivity) + .await + { + return; + } + + let data = lqos_bakery::bakery_activity_snapshot() + .into_iter() + .map(map_activity) + .collect(); + let msg = WsResponse::BakeryActivity { data }; + pubsub.send(PublishedChannels::BakeryActivity, msg).await; } diff --git a/src/rust/lqosd/src/treeguard/actor.rs b/src/rust/lqosd/src/treeguard/actor.rs index 29ea37d1..982fd183 100644 --- a/src/rust/lqosd/src/treeguard/actor.rs +++ b/src/rust/lqosd/src/treeguard/actor.rs @@ -211,6 +211,7 @@ struct TreeguardRuntimeState { managed_device_ids: FxHashSet, duplicate_device_conflict_circuits: FxHashSet, last_dry_run: Option, + paused_for_bakery_reload: bool, reload_controller: ReloadController, } @@ -225,14 +226,6 @@ fn run_tick( tick_seconds: &mut u64, runtime_state: &mut TreeguardRuntimeState, ) { - let link_states = &mut runtime_state.link_states; - let circuit_states = &mut runtime_state.circuit_states; - let managed_nodes = &mut runtime_state.managed_nodes; - let managed_device_ids = &mut runtime_state.managed_device_ids; - let duplicate_device_conflict_circuits = &mut runtime_state.duplicate_device_conflict_circuits; - let last_dry_run = &mut runtime_state.last_dry_run; - let reload_controller = &mut runtime_state.reload_controller; - let now_unix = unix_now().unwrap_or(0); let now_nanos_since_boot = time_since_boot() .ok() @@ -257,9 +250,12 @@ fn run_tick( let tg = &config.treeguard; *tick_seconds = tg.tick_seconds.max(1); - if last_dry_run.is_some_and(|prev| prev != tg.dry_run) { - link_states.clear(); - circuit_states.clear(); + if runtime_state + .last_dry_run + .is_some_and(|prev| prev != tg.dry_run) + { + runtime_state.link_states.clear(); + runtime_state.circuit_states.clear(); push_activity( activity, TreeguardActivityEntry { @@ -272,7 +268,18 @@ fn run_tick( }, ); } - *last_dry_run = Some(tg.dry_run); + runtime_state.last_dry_run = Some(tg.dry_run); + + if pause_for_bakery_reload(status, tick_seconds, runtime_state, tg.enabled, tg.dry_run) { + return; + } + + let link_states = &mut runtime_state.link_states; + let circuit_states = &mut runtime_state.circuit_states; + let managed_nodes = &mut runtime_state.managed_nodes; + let managed_device_ids = &mut runtime_state.managed_device_ids; + let duplicate_device_conflict_circuits = &mut runtime_state.duplicate_device_conflict_circuits; + let reload_controller = &mut runtime_state.reload_controller; let mut virtualized_nodes: usize = 0; let mut fq_codel_circuits: usize = 0; @@ -1703,68 +1710,11 @@ fn run_tick( } state }); - prune_recent_changes(&mut state.down.recent_changes_unix, now_unix); - prune_recent_changes(&mut state.up.recent_changes_unix, now_unix); - let circuit_hash = hash_to_i64(circuit_id); let (cap_down, cap_up) = capacity_by_circuit .get(&circuit_hash) .copied() .unwrap_or((0.0, 0.0)); - let capacity_known = cap_down > 0.0 && cap_up > 0.0; - if !capacity_known { - status.warnings.push(format!( - "TreeGuard circuits: circuit '{circuit_id}' has unknown capacity; no changes will be made." - )); - state.down.idle_since_unix = None; - state.up.idle_since_unix = None; - } else { - let bps = bps_by_circuit - .get(&circuit_hash) - .copied() - .unwrap_or(DownUpOrder { down: 0, up: 0 }); - let mbps_down = (bps.down as f64 * 8.0) / 1_000_000.0; - let mbps_up = (bps.up as f64 * 8.0) / 1_000_000.0; - let util_down_pct = (mbps_down / cap_down as f64) * 100.0; - let util_up_pct = (mbps_up / cap_up as f64) * 100.0; - - let ewma_down = state - .down - .util_ewma_pct - .update(util_down_pct, UTIL_EWMA_ALPHA); - let ewma_up = state.up.util_ewma_pct.update(util_up_pct, UTIL_EWMA_ALPHA); - - update_idle_since( - &mut state.down.idle_since_unix, - now_unix, - ewma_down, - tg.circuits.idle_util_pct as f64, - ); - update_idle_since( - &mut state.up.idle_since_unix, - now_unix, - ewma_up, - tg.circuits.idle_util_pct as f64, - ); - } - - let rtt_missing = match now_nanos_since_boot { - None => true, - Some(now_nanos) => match rtt_snapshot.get(&circuit_hash) { - None => true, - Some(buf) => { - if buf.last_seen == 0 { - true - } else { - let age_nanos = now_nanos.saturating_sub(buf.last_seen); - age_nanos - >= u64::from(tg.circuits.rtt_missing_seconds) - .saturating_mul(1_000_000_000) - } - } - }, - }; - let qoo = qoo_by_circuit .get(&circuit_hash) .cloned() @@ -1810,184 +1760,161 @@ fn run_tick( continue; } - let decision = decisions::decide_circuit_sqm(decisions::CircuitSqmInput { - now_unix, - allowlisted: allowlisted_circuits.contains(circuit_id) && capacity_known, - cpu_max_pct, - cpu_cfg: &tg.cpu, - circuits_cfg: &tg.circuits, - qoo_cfg: &tg.qoo, - rtt_missing, - qoo, - state, - }); + let fq_codel = if tg.dry_run { + let capacity_known = cap_down > 0.0 && cap_up > 0.0; + if !capacity_known { + status.warnings.push(format!( + "TreeGuard circuits: circuit '{circuit_id}' has unknown capacity; no changes will be made." + )); + state.down.idle_since_unix = None; + state.up.idle_since_unix = None; + } else { + let bps = bps_by_circuit + .get(&circuit_hash) + .copied() + .unwrap_or(DownUpOrder { down: 0, up: 0 }); + let mbps_down = (bps.down as f64 * 8.0) / 1_000_000.0; + let mbps_up = (bps.up as f64 * 8.0) / 1_000_000.0; + let util_down_pct = (mbps_down / cap_down as f64) * 100.0; + let util_up_pct = (mbps_up / cap_up as f64) * 100.0; - let mut proposed_down = state.down.desired; - let mut proposed_up = state.up.desired; - if let Some(down) = decision.down { - proposed_down = down; - } - if let Some(up) = decision.up { - proposed_up = up; - } + let ewma_down = state + .down + .util_ewma_pct + .update(util_down_pct, UTIL_EWMA_ALPHA); + let ewma_up = state.up.util_ewma_pct.update(util_up_pct, UTIL_EWMA_ALPHA); - let changed_down = proposed_down != state.down.desired; - let changed_up = proposed_up != state.up.desired; - let changed = changed_down || changed_up; - - if devices.is_empty() { - status.warnings.push(format!( - "TreeGuard circuits: circuit '{circuit_id}' has no devices in ShapedDevices.csv." - )); - } else { - for dev in devices.iter() { - managed_device_ids.insert(dev.device_id.clone()); - } - } - - if changed && !devices.is_empty() { - let token = decisions::format_directional_sqm_override(proposed_down, proposed_up); - - if tg.dry_run { - if changed_down { - state.down.desired = proposed_down; - state.down.last_change_unix = Some(now_unix); - state.down.recent_changes_unix.push_back(now_unix); - prune_recent_changes(&mut state.down.recent_changes_unix, now_unix); - } - if changed_up { - state.up.desired = proposed_up; - state.up.last_change_unix = Some(now_unix); - state.up.recent_changes_unix.push_back(now_unix); - prune_recent_changes(&mut state.up.recent_changes_unix, now_unix); - } - - push_activity( - activity, - TreeguardActivityEntry { - time: now_unix.to_string(), - entity_type: "circuit".to_string(), - entity_id: circuit_entity_id.clone(), - action: format!("would_set_sqm_override:{token}"), - persisted: false, - reason: "Dry-run".to_string(), - }, + update_idle_since( + &mut state.down.idle_since_unix, + now_unix, + ewma_down, + tg.circuits.idle_util_pct as f64, ); - status.last_action_summary = Some(format!( - "Would set SQM override for circuit '{}' -> {}", - circuit_label, token + update_idle_since( + &mut state.up.idle_since_unix, + now_unix, + ewma_up, + tg.circuits.idle_util_pct as f64, + ); + } + + let rtt_missing = match now_nanos_since_boot { + None => true, + Some(now_nanos) => match rtt_snapshot.get(&circuit_hash) { + None => true, + Some(buf) => { + if buf.last_seen == 0 { + true + } else { + let age_nanos = now_nanos.saturating_sub(buf.last_seen); + age_nanos + >= u64::from(tg.circuits.rtt_missing_seconds) + .saturating_mul(1_000_000_000) + } + } + }, + }; + + let decision = decisions::decide_circuit_sqm(decisions::CircuitSqmInput { + now_unix, + allowlisted: allowlisted_circuits.contains(circuit_id) && capacity_known, + cpu_max_pct, + cpu_cfg: &tg.cpu, + circuits_cfg: &tg.circuits, + qoo_cfg: &tg.qoo, + rtt_missing, + qoo, + state, + }); + + let mut proposed_down = state.down.desired; + let mut proposed_up = state.up.desired; + if let Some(down) = decision.down { + proposed_down = down; + } + if let Some(up) = decision.up { + proposed_up = up; + } + + let changed_down = proposed_down != state.down.desired; + let changed_up = proposed_up != state.up.desired; + + if devices.is_empty() { + status.warnings.push(format!( + "TreeGuard circuits: circuit '{circuit_id}' has no devices in ShapedDevices.csv." )); } else { - let mut persisted_ok = false; - let mut can_apply_live = true; - if tg.circuits.persist_sqm_overrides { - let device_ids: Vec = devices - .iter() - .map(|device| device.device_id.clone()) - .collect(); - match overrides::set_devices_sqm_override(&device_ids, &token) { - Ok(_) => { - persisted_ok = true; - } - Err(e) => { - can_apply_live = false; - status.warnings.push(format!( - "TreeGuard circuits: failed to persist SQM overrides for circuit '{circuit_id}': {e}" - )); - push_activity( - activity, - TreeguardActivityEntry { - time: now_unix.to_string(), - entity_type: "circuit".to_string(), - entity_id: circuit_entity_id.clone(), - action: "set_sqm_override_failed".to_string(), - persisted: false, - reason: format!("Overrides write failed: {e}"), - }, - ); - } - } - } - - let live_ok = if can_apply_live { - match bakery::apply_circuit_sqm_override_live(circuit_id, &devices, &token) - { - Ok(()) => true, - Err(e) => { - status.warnings.push(format!( - "TreeGuard circuits: live SQM apply failed for circuit '{circuit_id}': {e}" - )); - push_activity( - activity, - TreeguardActivityEntry { - time: now_unix.to_string(), - entity_type: "circuit".to_string(), - entity_id: circuit_entity_id.clone(), - action: format!("apply_sqm_live_failed:{token}"), - persisted: persisted_ok, - reason: format!("Bakery live apply failed: {e}"), - }, - ); - false - } - } - } else { - false - }; - - if live_ok || persisted_ok { - if changed_down { - state.down.desired = proposed_down; - state.down.last_change_unix = Some(now_unix); - state.down.recent_changes_unix.push_back(now_unix); - prune_recent_changes(&mut state.down.recent_changes_unix, now_unix); - } - if changed_up { - state.up.desired = proposed_up; - state.up.last_change_unix = Some(now_unix); - state.up.recent_changes_unix.push_back(now_unix); - prune_recent_changes(&mut state.up.recent_changes_unix, now_unix); - } - - let (action, reason) = match (persisted_ok, live_ok) { - (true, true) => ( - "set_sqm_override".to_string(), - "Applied live + persisted".to_string(), - ), - (true, false) => ( - "set_sqm_override".to_string(), - "Persisted (live apply failed)".to_string(), - ), - (false, true) => { - ("set_sqm_live".to_string(), "Applied live".to_string()) - } - (false, false) => { - ("set_sqm_live".to_string(), "Not applied".to_string()) - } - }; - push_activity( - activity, - TreeguardActivityEntry { - time: now_unix.to_string(), - entity_type: "circuit".to_string(), - entity_id: circuit_entity_id.clone(), - action: format!("{action}:{token}"), - persisted: persisted_ok, - reason, - }, - ); - - status.last_action_summary = Some(format!( - "SQM override for circuit '{}' -> {}", - circuit_label, token - )); + for dev in devices.iter() { + managed_device_ids.insert(dev.device_id.clone()); } } - } - if state.down.desired == CircuitSqmState::FqCodel - || state.up.desired == CircuitSqmState::FqCodel - { + if changed_down || changed_up { + apply_circuit_sqm_change( + CircuitSqmApplyContext { + status, + activity, + now_unix, + dry_run: true, + persist_sqm_overrides: tg.circuits.persist_sqm_overrides, + circuit_id, + circuit_entity_id: &circuit_entity_id, + circuit_label: &circuit_label, + devices: &devices, + }, + state, + CircuitSqmTransition { + proposed_down, + proposed_up, + changed_down, + changed_up, + }, + overrides::set_devices_sqm_override, + |circuit_id, devices, token| { + bakery::apply_circuit_sqm_override_live(circuit_id, devices, token) + }, + ); + } + + state.down.desired == CircuitSqmState::FqCodel + || state.up.desired == CircuitSqmState::FqCodel + } else { + process_circuit_tick( + CircuitTickContext { + status, + activity, + managed_device_ids, + now_unix, + now_nanos_since_boot, + cpu_max_pct, + circuit_id, + circuit_entity_id: &circuit_entity_id, + circuit_label: &circuit_label, + devices: &devices, + allowlisted: allowlisted_circuits.contains(circuit_id), + cap_down, + cap_up, + bps: bps_by_circuit + .get(&circuit_hash) + .copied() + .unwrap_or(DownUpOrder { down: 0, up: 0 }), + last_rtt_seen_nanos: rtt_snapshot + .get(&circuit_hash) + .map(|buf| buf.last_seen), + qoo, + cpu_cfg: &tg.cpu, + circuits_cfg: &tg.circuits, + qoo_cfg: &tg.qoo, + }, + state, + overrides::set_devices_sqm_override, + |circuit_id, devices, token| { + bakery::apply_circuit_sqm_override_live(circuit_id, devices, token) + }, + ) + }; + + if fq_codel { fq_codel_circuits += 1; } } @@ -1998,6 +1925,60 @@ fn run_tick( status.fq_codel_circuits = fq_codel_circuits; } +/// Applies TreeGuard backoff while Bakery is performing a structural full reload. +/// +/// This function is not pure: it mutates TreeGuard status/runtime state and may emit logs. +fn pause_for_bakery_reload( + status: &mut TreeguardStatusData, + tick_seconds: &mut u64, + runtime_state: &mut TreeguardRuntimeState, + enabled: bool, + dry_run: bool, +) -> bool { + pause_for_bakery_reload_with_flag( + status, + tick_seconds, + runtime_state, + enabled, + dry_run, + lqos_bakery::full_reload_in_progress(), + ) +} + +fn pause_for_bakery_reload_with_flag( + status: &mut TreeguardStatusData, + tick_seconds: &mut u64, + runtime_state: &mut TreeguardRuntimeState, + enabled: bool, + dry_run: bool, + bakery_reload_in_progress: bool, +) -> bool { + let paused = enabled && bakery_reload_in_progress; + if paused { + if !runtime_state.paused_for_bakery_reload { + info!("TreeGuard: pausing while Bakery full reload is in progress"); + runtime_state.paused_for_bakery_reload = true; + } + *tick_seconds = (*tick_seconds).max(5); + status.enabled = enabled; + status.dry_run = dry_run; + status.cpu_max_pct = None; + status.last_action_summary = + Some("Paused while Bakery full reload is in progress".to_string()); + status + .warnings + .push("TreeGuard paused while Bakery full reload is in progress.".to_string()); + return true; + } + + if runtime_state.paused_for_bakery_reload { + info!("TreeGuard: resuming after Bakery full reload"); + runtime_state.paused_for_bakery_reload = false; + } + + false +} + /// Returns the current `set_node_virtual` override value for `node_name`, if present. /// /// This function is pure: it has no side effects. @@ -2135,6 +2116,347 @@ fn push_activity(activity: &mut VecDeque, entry: Treegua activity.push_back(entry); } +struct CircuitSqmApplyContext<'a> { + status: &'a mut TreeguardStatusData, + activity: &'a mut VecDeque, + now_unix: u64, + dry_run: bool, + persist_sqm_overrides: bool, + circuit_id: &'a str, + circuit_entity_id: &'a str, + circuit_label: &'a str, + devices: &'a [lqos_config::ShapedDevice], +} + +struct CircuitSqmTransition { + proposed_down: CircuitSqmState, + proposed_up: CircuitSqmState, + changed_down: bool, + changed_up: bool, +} + +struct CircuitTickContext<'a> { + status: &'a mut TreeguardStatusData, + activity: &'a mut VecDeque, + managed_device_ids: &'a mut FxHashSet, + now_unix: u64, + now_nanos_since_boot: Option, + cpu_max_pct: Option, + circuit_id: &'a str, + circuit_entity_id: &'a str, + circuit_label: &'a str, + devices: &'a [lqos_config::ShapedDevice], + allowlisted: bool, + cap_down: f32, + cap_up: f32, + bps: DownUpOrder, + last_rtt_seen_nanos: Option, + qoo: DownUpOrder>, + cpu_cfg: &'a lqos_config::TreeguardCpuConfig, + circuits_cfg: &'a lqos_config::TreeguardCircuitsConfig, + qoo_cfg: &'a lqos_config::TreeguardQooConfig, +} + +fn apply_circuit_sqm_change( + ctx: CircuitSqmApplyContext<'_>, + state: &mut CircuitState, + transition: CircuitSqmTransition, + mut persist_override: P, + mut live_apply: L, +) where + P: FnMut(&[String], &str) -> Result, + L: FnMut(&str, &[lqos_config::ShapedDevice], &str) -> Result<(), TreeguardError>, +{ + let CircuitSqmApplyContext { + status, + activity, + now_unix, + dry_run, + persist_sqm_overrides, + circuit_id, + circuit_entity_id, + circuit_label, + devices, + } = ctx; + let CircuitSqmTransition { + proposed_down, + proposed_up, + changed_down, + changed_up, + } = transition; + + let token = decisions::format_directional_sqm_override(proposed_down, proposed_up); + + if dry_run { + if changed_down { + state.down.desired = proposed_down; + state.down.last_change_unix = Some(now_unix); + state.down.recent_changes_unix.push_back(now_unix); + prune_recent_changes(&mut state.down.recent_changes_unix, now_unix); + } + if changed_up { + state.up.desired = proposed_up; + state.up.last_change_unix = Some(now_unix); + state.up.recent_changes_unix.push_back(now_unix); + prune_recent_changes(&mut state.up.recent_changes_unix, now_unix); + } + + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "circuit".to_string(), + entity_id: circuit_entity_id.to_string(), + action: format!("would_set_sqm_override:{token}"), + persisted: false, + reason: "Dry-run".to_string(), + }, + ); + status.last_action_summary = Some(format!( + "Would set SQM override for circuit '{}' -> {}", + circuit_label, token + )); + return; + } + + let mut persisted_ok = false; + let mut can_apply_live = true; + if persist_sqm_overrides { + let device_ids: Vec = devices + .iter() + .map(|device| device.device_id.clone()) + .collect(); + match persist_override(&device_ids, &token) { + Ok(_) => { + persisted_ok = true; + } + Err(e) => { + can_apply_live = false; + status.warnings.push(format!( + "TreeGuard circuits: failed to persist SQM overrides for circuit '{circuit_id}': {e}" + )); + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "circuit".to_string(), + entity_id: circuit_entity_id.to_string(), + action: "set_sqm_override_failed".to_string(), + persisted: false, + reason: format!("Overrides write failed: {e}"), + }, + ); + } + } + } + + let live_ok = if can_apply_live { + match live_apply(circuit_id, devices, &token) { + Ok(()) => true, + Err(e) => { + status.warnings.push(format!( + "TreeGuard circuits: live SQM apply failed for circuit '{circuit_id}': {e}" + )); + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "circuit".to_string(), + entity_id: circuit_entity_id.to_string(), + action: format!("apply_sqm_live_failed:{token}"), + persisted: persisted_ok, + reason: format!("Bakery live apply failed: {e}"), + }, + ); + false + } + } + } else { + false + }; + + if live_ok || persisted_ok { + if changed_down { + state.down.desired = proposed_down; + state.down.last_change_unix = Some(now_unix); + state.down.recent_changes_unix.push_back(now_unix); + prune_recent_changes(&mut state.down.recent_changes_unix, now_unix); + } + if changed_up { + state.up.desired = proposed_up; + state.up.last_change_unix = Some(now_unix); + state.up.recent_changes_unix.push_back(now_unix); + prune_recent_changes(&mut state.up.recent_changes_unix, now_unix); + } + + let (action, reason) = match (persisted_ok, live_ok) { + (true, true) => ( + "set_sqm_override".to_string(), + "Applied live + persisted".to_string(), + ), + (true, false) => ( + "set_sqm_override".to_string(), + "Persisted (live apply failed)".to_string(), + ), + (false, true) => ("set_sqm_live".to_string(), "Applied live".to_string()), + (false, false) => ("set_sqm_live".to_string(), "Not applied".to_string()), + }; + push_activity( + activity, + TreeguardActivityEntry { + time: now_unix.to_string(), + entity_type: "circuit".to_string(), + entity_id: circuit_entity_id.to_string(), + action: format!("{action}:{token}"), + persisted: persisted_ok, + reason, + }, + ); + + status.last_action_summary = Some(format!( + "SQM override for circuit '{}' -> {}", + circuit_label, token + )); + } +} + +fn process_circuit_tick( + ctx: CircuitTickContext<'_>, + state: &mut CircuitState, + persist_override: P, + live_apply: L, +) -> bool +where + P: FnMut(&[String], &str) -> Result, + L: FnMut(&str, &[lqos_config::ShapedDevice], &str) -> Result<(), TreeguardError>, +{ + let CircuitTickContext { + status, + activity, + managed_device_ids, + now_unix, + now_nanos_since_boot, + cpu_max_pct, + circuit_id, + circuit_entity_id, + circuit_label, + devices, + allowlisted, + cap_down, + cap_up, + bps, + last_rtt_seen_nanos, + qoo, + cpu_cfg, + circuits_cfg, + qoo_cfg, + } = ctx; + + prune_recent_changes(&mut state.down.recent_changes_unix, now_unix); + prune_recent_changes(&mut state.up.recent_changes_unix, now_unix); + + let capacity_known = cap_down > 0.0 && cap_up > 0.0; + if !capacity_known { + status.warnings.push(format!( + "TreeGuard circuits: circuit '{circuit_id}' has unknown capacity; no changes will be made." + )); + state.down.idle_since_unix = None; + state.up.idle_since_unix = None; + } else { + let mbps_down = (bps.down as f64 * 8.0) / 1_000_000.0; + let mbps_up = (bps.up as f64 * 8.0) / 1_000_000.0; + let util_down_pct = (mbps_down / cap_down as f64) * 100.0; + let util_up_pct = (mbps_up / cap_up as f64) * 100.0; + + let ewma_down = state + .down + .util_ewma_pct + .update(util_down_pct, UTIL_EWMA_ALPHA); + let ewma_up = state.up.util_ewma_pct.update(util_up_pct, UTIL_EWMA_ALPHA); + + update_idle_since( + &mut state.down.idle_since_unix, + now_unix, + ewma_down, + circuits_cfg.idle_util_pct as f64, + ); + update_idle_since( + &mut state.up.idle_since_unix, + now_unix, + ewma_up, + circuits_cfg.idle_util_pct as f64, + ); + } + + let rtt_missing = match (now_nanos_since_boot, last_rtt_seen_nanos) { + (Some(now_nanos), Some(last_seen)) if last_seen > 0 => { + now_nanos.saturating_sub(last_seen) + >= u64::from(circuits_cfg.rtt_missing_seconds).saturating_mul(1_000_000_000) + } + _ => true, + }; + + let mut proposed_down = state.down.desired; + let mut proposed_up = state.up.desired; + let decision = decisions::decide_circuit_sqm(decisions::CircuitSqmInput { + now_unix, + allowlisted: allowlisted && capacity_known, + cpu_max_pct, + cpu_cfg, + circuits_cfg, + qoo_cfg, + rtt_missing, + qoo, + state, + }); + if let Some(down) = decision.down { + proposed_down = down; + } + if let Some(up) = decision.up { + proposed_up = up; + } + + let changed_down = proposed_down != state.down.desired; + let changed_up = proposed_up != state.up.desired; + + if devices.is_empty() { + status.warnings.push(format!( + "TreeGuard circuits: circuit '{circuit_id}' has no devices in ShapedDevices.csv." + )); + } else { + for dev in devices { + managed_device_ids.insert(dev.device_id.clone()); + } + } + + if (changed_down || changed_up) && !devices.is_empty() { + apply_circuit_sqm_change( + CircuitSqmApplyContext { + status, + activity, + now_unix, + dry_run: false, + persist_sqm_overrides: circuits_cfg.persist_sqm_overrides, + circuit_id, + circuit_entity_id, + circuit_label, + devices, + }, + state, + CircuitSqmTransition { + proposed_down, + proposed_up, + changed_down, + changed_up, + }, + persist_override, + live_apply, + ); + } + + state.down.desired == CircuitSqmState::FqCodel || state.up.desired == CircuitSqmState::FqCodel +} + /// Removes entries older than one hour from a recent-changes ring buffer. /// /// This function is not pure: it mutates `recent_changes`. @@ -2178,6 +2500,454 @@ fn update_below_since( } } +#[cfg(test)] +mod tests { + use super::{ + CircuitSqmApplyContext, CircuitSqmTransition, CircuitTickContext, TreeguardRuntimeState, + apply_circuit_sqm_change, empty_status_snapshot, pause_for_bakery_reload_with_flag, + process_circuit_tick, run_tick, + }; + use crate::node_manager::ws::messages::TreeguardActivityEntry; + use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; + use crate::system_stats::SystemStats; + use crate::throughput_tracker::CIRCUIT_RTT_BUFFERS; + use crate::treeguard::{bakery, state::CircuitSqmState}; + use crossbeam_channel::bounded; + use fxhash::{FxHashMap, FxHashSet}; + use lqos_bakery::BakeryCommands; + use lqos_bus::TcHandle; + use lqos_config::ConfigShapedDevices; + use lqos_config::ShapedDevice; + use lqos_queue_tracker::{QUEUE_STRUCTURE, QueueNode, QueueStructure}; + use lqos_utils::rtt::RttBuffer; + use lqos_utils::units::DownUpOrder; + use std::collections::VecDeque; + use std::net::Ipv4Addr; + use std::path::PathBuf; + use std::sync::Arc; + use std::sync::Once; + + #[test] + fn bakery_reload_pause_updates_status_and_tick_backoff() { + let mut status = empty_status_snapshot(); + let mut tick_seconds = 1; + let mut runtime_state = TreeguardRuntimeState::default(); + + let paused = pause_for_bakery_reload_with_flag( + &mut status, + &mut tick_seconds, + &mut runtime_state, + true, + false, + true, + ); + + assert!(paused); + assert_eq!(tick_seconds, 5); + assert_eq!(status.enabled, true); + assert_eq!(status.dry_run, false); + assert_eq!( + status.last_action_summary.as_deref(), + Some("Paused while Bakery full reload is in progress") + ); + assert!( + status + .warnings + .iter() + .any(|warning| warning.contains("TreeGuard paused while Bakery full reload")) + ); + assert!(runtime_state.paused_for_bakery_reload); + + let resumed = pause_for_bakery_reload_with_flag( + &mut status, + &mut tick_seconds, + &mut runtime_state, + true, + false, + false, + ); + assert!(!resumed); + assert!(!runtime_state.paused_for_bakery_reload); + } + + #[test] + fn actor_live_sqm_change_updates_state_activity_and_bakery_command() { + let (tx, rx) = bounded(1); + let queues = vec![QueueNode { + circuit_id: Some("circuit-1".to_string()), + class_minor: 0x14af, + class_major: 0x0003, + up_class_major: 0x0043, + parent_class_id: TcHandle::from_string("3:20").expect("valid down parent"), + up_parent_class_id: TcHandle::from_string("43:20").expect("valid up parent"), + download_bandwidth_mbps_min: 50, + upload_bandwidth_mbps_min: 10, + download_bandwidth_mbps: 200, + upload_bandwidth_mbps: 50, + ..QueueNode::default() + }]; + let devices = vec![ShapedDevice { + circuit_id: "circuit-1".to_string(), + circuit_name: "Circuit One".to_string(), + device_id: "device-1".to_string(), + ipv4: vec![(Ipv4Addr::new(192, 0, 2, 10), 32)], + ..ShapedDevice::default() + }]; + let mut status = empty_status_snapshot(); + let mut activity: VecDeque = VecDeque::new(); + let mut state = crate::treeguard::state::CircuitState::default(); + + apply_circuit_sqm_change( + CircuitSqmApplyContext { + status: &mut status, + activity: &mut activity, + now_unix: 1_000, + dry_run: false, + persist_sqm_overrides: false, + circuit_id: "circuit-1", + circuit_entity_id: "Circuit One (circuit-1)", + circuit_label: "Circuit One", + devices: &devices, + }, + &mut state, + CircuitSqmTransition { + proposed_down: CircuitSqmState::FqCodel, + proposed_up: CircuitSqmState::Cake, + changed_down: true, + changed_up: false, + }, + |_device_ids, _token| Ok(false), + |circuit_id, devices, token| { + bakery::apply_circuit_sqm_override_live_with_sender_and_snapshot( + circuit_id, devices, token, &tx, &queues, + ) + }, + ); + + assert_eq!(state.down.desired, CircuitSqmState::FqCodel); + assert_eq!(state.up.desired, CircuitSqmState::Cake); + assert_eq!(state.down.last_change_unix, Some(1_000)); + assert_eq!(state.up.last_change_unix, None); + assert_eq!(state.down.recent_changes_unix.len(), 1); + assert!(state.up.recent_changes_unix.is_empty()); + assert_eq!( + status.last_action_summary.as_deref(), + Some("SQM override for circuit 'Circuit One' -> fq_codel/cake") + ); + + let last_activity = activity.back().expect("activity should be recorded"); + assert_eq!(last_activity.entity_type, "circuit"); + assert_eq!(last_activity.entity_id, "Circuit One (circuit-1)"); + assert_eq!(last_activity.action, "set_sqm_live:fq_codel/cake"); + assert!(!last_activity.persisted); + assert_eq!(last_activity.reason, "Applied live"); + + let command = rx.try_recv().expect("bakery command should be sent"); + let BakeryCommands::AddCircuit { + circuit_hash, + sqm_override, + down_qdisc_handle, + up_qdisc_handle, + .. + } = command + else { + panic!("expected AddCircuit"); + }; + assert_eq!(circuit_hash, lqos_utils::hash_to_i64("circuit-1")); + assert_eq!(sqm_override, Some("fq_codel/cake".to_string())); + assert_eq!(down_qdisc_handle, None); + assert_eq!(up_qdisc_handle, None); + } + + #[test] + fn circuit_tick_snapshot_decides_and_applies_live_override() { + let (tx, rx) = bounded(1); + let queues = vec![QueueNode { + circuit_id: Some("circuit-2".to_string()), + class_minor: 0x22af, + class_major: 0x0005, + up_class_major: 0x0045, + parent_class_id: TcHandle::from_string("5:20").expect("valid down parent"), + up_parent_class_id: TcHandle::from_string("45:20").expect("valid up parent"), + download_bandwidth_mbps_min: 25, + upload_bandwidth_mbps_min: 5, + download_bandwidth_mbps: 100, + upload_bandwidth_mbps: 20, + ..QueueNode::default() + }]; + let devices = vec![ShapedDevice { + circuit_id: "circuit-2".to_string(), + circuit_name: "Circuit Two".to_string(), + device_id: "device-2".to_string(), + ipv4: vec![(Ipv4Addr::new(198, 51, 100, 20), 32)], + ..ShapedDevice::default() + }]; + let mut managed_device_ids = FxHashSet::default(); + let mut status = empty_status_snapshot(); + let mut activity: VecDeque = VecDeque::new(); + let mut state = crate::treeguard::state::CircuitState::default(); + state.down.idle_since_unix = Some(0); + state.up.idle_since_unix = Some(0); + + let mut circuits_cfg = lqos_config::TreeguardCircuitsConfig::default(); + circuits_cfg.persist_sqm_overrides = false; + + let fq_codel = process_circuit_tick( + CircuitTickContext { + status: &mut status, + activity: &mut activity, + managed_device_ids: &mut managed_device_ids, + now_unix: 1_000, + now_nanos_since_boot: Some(2_000_000_000), + cpu_max_pct: Some(95), + circuit_id: "circuit-2", + circuit_entity_id: "Circuit Two (circuit-2)", + circuit_label: "Circuit Two", + devices: &devices, + allowlisted: true, + cap_down: 100.0, + cap_up: 20.0, + bps: DownUpOrder { down: 0, up: 0 }, + last_rtt_seen_nanos: Some(1_900_000_000), + qoo: DownUpOrder { + down: Some(90.0), + up: Some(90.0), + }, + cpu_cfg: &lqos_config::TreeguardCpuConfig::default(), + circuits_cfg: &circuits_cfg, + qoo_cfg: &lqos_config::TreeguardQooConfig::default(), + }, + &mut state, + |_device_ids, _token| Ok(false), + |circuit_id, devices, token| { + bakery::apply_circuit_sqm_override_live_with_sender_and_snapshot( + circuit_id, devices, token, &tx, &queues, + ) + }, + ); + + assert!(fq_codel); + assert_eq!(state.down.desired, CircuitSqmState::FqCodel); + assert_eq!(state.up.desired, CircuitSqmState::FqCodel); + assert!(managed_device_ids.contains("device-2")); + assert_eq!( + status.last_action_summary.as_deref(), + Some("SQM override for circuit 'Circuit Two' -> fq_codel/fq_codel") + ); + + let command = rx.try_recv().expect("bakery command should be sent"); + let BakeryCommands::AddCircuit { + circuit_hash, + sqm_override, + .. + } = command + else { + panic!("expected AddCircuit"); + }; + assert_eq!(circuit_hash, lqos_utils::hash_to_i64("circuit-2")); + assert_eq!(sqm_override, Some("fq_codel/fq_codel".to_string())); + } + + fn test_runtime_dir(name: &str) -> PathBuf { + let unique = format!( + "{}-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("thread"), + name + ) + .replace(['/', ' '], "-"); + let dir = std::env::temp_dir().join(format!("libreqos-treeguard-{unique}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp runtime dir should be created"); + dir + } + + fn write_test_config(runtime_dir: &std::path::Path) -> PathBuf { + let config_path = runtime_dir.join("lqos.test.toml"); + let raw = include_str!("../../../lqos_config/src/etc/v15/example.toml") + .replace( + "lqos_directory = \"/opt/libreqos/src\"", + &format!("lqos_directory = \"{}\"", runtime_dir.display()), + ) + .replace("use_xdp_bridge = true", "use_xdp_bridge = false") + .replace( + "[treeguard.links]\nenabled = true", + "[treeguard.links]\nenabled = false", + ) + .replace( + "persist_sqm_overrides = true", + "persist_sqm_overrides = false", + ); + std::fs::write(&config_path, raw).expect("test config should be written"); + config_path + } + + fn install_test_bakery_sender() -> crossbeam_channel::Receiver { + static INIT: Once = Once::new(); + let (tx, rx) = bounded(8); + INIT.call_once(|| { + let _ = lqos_bakery::BAKERY_SENDER.set(tx); + }); + rx + } + + #[test] + fn run_tick_end_to_end_switches_circuit_and_emits_bakery_update() { + let runtime_dir = test_runtime_dir("run-tick"); + let config_path = write_test_config(&runtime_dir); + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &runtime_dir); + } + lqos_config::clear_cached_config(); + + let devices = vec![ShapedDevice { + circuit_id: "circuit-3".to_string(), + circuit_name: "Circuit Three".to_string(), + device_id: "device-3".to_string(), + device_name: "Device Three".to_string(), + parent_node: "Node Three".to_string(), + ipv4: vec![(Ipv4Addr::new(203, 0, 113, 30), 32)], + download_min_mbps: 25.0, + upload_min_mbps: 5.0, + download_max_mbps: 100.0, + upload_max_mbps: 20.0, + circuit_hash: lqos_utils::hash_to_i64("circuit-3"), + device_hash: lqos_utils::hash_to_i64("device-3"), + parent_hash: lqos_utils::hash_to_i64("Node Three"), + ..ShapedDevice::default() + }]; + let mut shaped = ConfigShapedDevices::default(); + shaped.replace_with_new_data(devices.clone()); + let old_shaped = SHAPED_DEVICES.load_full(); + SHAPED_DEVICES.store(Arc::new(shaped)); + + let old_network = NETWORK_JSON.read().nodes.clone(); + NETWORK_JSON.write().nodes = Vec::new(); + + let old_queue_structure = QUEUE_STRUCTURE.load_full(); + QUEUE_STRUCTURE.store(Arc::new(QueueStructure { + maybe_queues: Some(vec![QueueNode { + circuit_id: Some("circuit-3".to_string()), + class_minor: 0x33af, + class_major: 0x0007, + up_class_major: 0x0047, + parent_class_id: TcHandle::from_string("7:20").expect("valid down parent"), + up_parent_class_id: TcHandle::from_string("47:20").expect("valid up parent"), + download_bandwidth_mbps_min: 25, + upload_bandwidth_mbps_min: 5, + download_bandwidth_mbps: 100, + upload_bandwidth_mbps: 20, + ..QueueNode::default() + }]), + })); + + let now_nanos = lqos_utils::unix_time::time_since_boot() + .map(std::time::Duration::from) + .map(|duration| duration.as_nanos() as u64) + .expect("time since boot should be available"); + let old_rtt = CIRCUIT_RTT_BUFFERS.load_full(); + let mut rtt = RttBuffer::default(); + rtt.last_seen = now_nanos.saturating_sub(100_000_000); + let mut rtt_map = FxHashMap::default(); + rtt_map.insert(lqos_utils::hash_to_i64("circuit-3"), rtt); + CIRCUIT_RTT_BUFFERS.store(Arc::new(rtt_map)); + + let rx = install_test_bakery_sender(); + while rx.try_recv().is_ok() {} + + let (system_tx, system_rx) = bounded::>(1); + let responder = std::thread::spawn(move || { + let reply = system_rx + .recv() + .expect("system stats request should arrive"); + let _ = reply.send(SystemStats { + cpu_usage: vec![95], + ram_used: 0, + total_ram: 0, + }); + }); + + let mut status = empty_status_snapshot(); + let mut activity: VecDeque = VecDeque::new(); + let mut runtime_state = TreeguardRuntimeState::default(); + runtime_state.circuit_states.insert( + "circuit-3".to_string(), + crate::treeguard::state::CircuitState { + down: crate::treeguard::state::CircuitDirectionState { + idle_since_unix: Some(0), + ..Default::default() + }, + up: crate::treeguard::state::CircuitDirectionState { + idle_since_unix: Some(0), + ..Default::default() + }, + }, + ); + let mut tick_seconds = 1; + run_tick( + &mut status, + &mut activity, + &system_tx, + &mut tick_seconds, + &mut runtime_state, + ); + responder + .join() + .expect("system stats responder should join"); + + assert_eq!(status.enabled, true); + assert_eq!(status.dry_run, false); + assert_eq!(status.managed_circuits, 1); + assert_eq!(status.fq_codel_circuits, 1); + assert_eq!( + status.last_action_summary.as_deref(), + Some("SQM override for circuit 'Circuit Three' -> fq_codel/fq_codel") + ); + let circuit_state = runtime_state + .circuit_states + .get("circuit-3") + .expect("circuit state should exist"); + assert_eq!(circuit_state.down.desired, CircuitSqmState::FqCodel); + assert_eq!(circuit_state.up.desired, CircuitSqmState::FqCodel); + assert!( + activity + .iter() + .any(|entry| entry.action == "set_sqm_live:fq_codel/fq_codel") + ); + + let command = rx + .try_recv() + .expect("run_tick should send a bakery command"); + let BakeryCommands::AddCircuit { + circuit_hash, + sqm_override, + .. + } = command + else { + panic!("expected AddCircuit"); + }; + assert_eq!(circuit_hash, lqos_utils::hash_to_i64("circuit-3")); + assert_eq!(sqm_override, Some("fq_codel/fq_codel".to_string())); + + SHAPED_DEVICES.store(old_shaped); + NETWORK_JSON.write().nodes = old_network; + QUEUE_STRUCTURE.store(old_queue_structure); + CIRCUIT_RTT_BUFFERS.store(old_rtt); + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + lqos_config::clear_cached_config(); + } +} + /// Returns the minimum of two optional floats, treating `None` as "unknown". /// /// This function is pure: it has no side effects. diff --git a/src/rust/lqosd/src/treeguard/bakery.rs b/src/rust/lqosd/src/treeguard/bakery.rs index d64a755d..e6f2aab0 100644 --- a/src/rust/lqosd/src/treeguard/bakery.rs +++ b/src/rust/lqosd/src/treeguard/bakery.rs @@ -3,9 +3,10 @@ //! This module implements live SQM switching via Bakery commands. use crate::treeguard::TreeguardError; +use crossbeam_channel::Sender; use lqos_bakery::BakeryCommands; use lqos_config::ShapedDevice; -use lqos_queue_tracker::QUEUE_STRUCTURE; +use lqos_queue_tracker::{QUEUE_STRUCTURE, QueueNode}; use lqos_utils::hash_to_i64; /// Applies a per-circuit SQM override token live via Bakery. @@ -28,17 +29,38 @@ pub(crate) fn apply_circuit_sqm_override_live( }); }; + apply_circuit_sqm_override_live_with_sender_and_snapshot( + circuit_id, + devices, + sqm_override, + sender, + queues, + ) +} + +#[doc(hidden)] +pub(crate) fn apply_circuit_sqm_override_live_with_sender_and_snapshot( + circuit_id: &str, + devices: &[ShapedDevice], + sqm_override: &str, + sender: &Sender, + queues: &[QueueNode], +) -> Result<(), TreeguardError> { + let node = find_circuit_queue_node(queues, circuit_id)?; + send_live_sqm_override(node, devices, sqm_override, sender) +} + +fn find_circuit_queue_node<'a>( + queues: &'a [QueueNode], + circuit_id: &str, +) -> Result<&'a QueueNode, TreeguardError> { // Find the circuit node in the queue structure. let mut stack = Vec::new(); - for q in queues.iter() { - stack.push(q); - } + stack.extend(queues.iter()); - let mut found = None; while let Some(node) = stack.pop() { if node.circuit_id.as_deref() == Some(circuit_id) && node.device_id.is_none() { - found = Some(node); - break; + return Ok(node); } for c in node.children.iter() { @@ -52,12 +74,23 @@ pub(crate) fn apply_circuit_sqm_override_live( } } - let Some(node) = found else { - return Err(TreeguardError::CircuitNotFound { - circuit_id: circuit_id.to_string(), - }); - }; + Err(TreeguardError::CircuitNotFound { + circuit_id: circuit_id.to_string(), + }) +} +fn send_live_sqm_override( + node: &QueueNode, + devices: &[ShapedDevice], + sqm_override: &str, + sender: &Sender, +) -> Result<(), TreeguardError> { + let circuit_id = node + .circuit_id + .as_deref() + .ok_or_else(|| TreeguardError::CircuitNotFound { + circuit_id: "".to_string(), + })?; let class_minor = u16::try_from(node.class_minor).map_err(|_| TreeguardError::InvalidClassId { details: format!("class_minor too large: {}", node.class_minor), @@ -86,6 +119,8 @@ pub(crate) fn apply_circuit_sqm_override_live( upload_bandwidth_max: node.upload_bandwidth_mbps as f32, class_major, up_class_major, + down_qdisc_handle: None, + up_qdisc_handle: None, ip_addresses, sqm_override: Some(sqm_override.to_string()), }) @@ -113,3 +148,83 @@ fn ip_list(devices: &[ShapedDevice]) -> String { ips.dedup(); ips.join(",") } + +#[cfg(test)] +mod tests { + use super::apply_circuit_sqm_override_live_with_sender_and_snapshot; + use crossbeam_channel::bounded; + use lqos_bakery::BakeryCommands; + use lqos_bus::TcHandle; + use lqos_config::ShapedDevice; + use lqos_queue_tracker::QueueNode; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn live_override_builds_bakery_update_from_snapshot() { + let (tx, rx) = bounded(1); + let queues = vec![QueueNode { + circuit_id: Some("circuit-1".to_string()), + class_minor: 0x14af, + class_major: 0x0003, + up_class_major: 0x0043, + parent_class_id: TcHandle::from_string("3:20").expect("valid down parent"), + up_parent_class_id: TcHandle::from_string("43:20").expect("valid up parent"), + download_bandwidth_mbps_min: 50, + upload_bandwidth_mbps_min: 10, + download_bandwidth_mbps: 200, + upload_bandwidth_mbps: 50, + ..QueueNode::default() + }]; + let devices = vec![ShapedDevice { + circuit_id: "circuit-1".to_string(), + device_id: "device-1".to_string(), + ipv4: vec![(Ipv4Addr::new(192, 0, 2, 10), 32)], + ipv6: vec![(Ipv6Addr::LOCALHOST, 128)], + ..ShapedDevice::default() + }]; + + apply_circuit_sqm_override_live_with_sender_and_snapshot( + "circuit-1", + &devices, + "cake/fq_codel", + &tx, + &queues, + ) + .expect("live override should send bakery update"); + + let command = rx.try_recv().expect("bakery command should be sent"); + let BakeryCommands::AddCircuit { + circuit_hash, + parent_class_id, + up_parent_class_id, + class_minor, + class_major, + up_class_major, + ip_addresses, + sqm_override, + down_qdisc_handle, + up_qdisc_handle, + .. + } = command + else { + panic!("expected AddCircuit"); + }; + + assert_eq!(circuit_hash, lqos_utils::hash_to_i64("circuit-1")); + assert_eq!( + parent_class_id, + TcHandle::from_string("3:20").expect("valid down parent") + ); + assert_eq!( + up_parent_class_id, + TcHandle::from_string("43:20").expect("valid up parent") + ); + assert_eq!(class_minor, 0x14af); + assert_eq!(class_major, 0x0003); + assert_eq!(up_class_major, 0x0043); + assert_eq!(ip_addresses, "192.0.2.10/32,::1/128"); + assert_eq!(sqm_override, Some("cake/fq_codel".to_string())); + assert_eq!(down_qdisc_handle, None); + assert_eq!(up_qdisc_handle, None); + } +} diff --git a/src/scheduler.py b/src/scheduler.py index 581548d8..2e1bd1de 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -456,11 +456,13 @@ def apply_network_adjustments(network: dict) -> bool: Currently supports: adjust_site_speed (preferring node_id, with legacy site_name fallback) updating downloadBandwidthMbps and uploadBandwidthMbps at the matching node, and set_node_virtual (by - node_name) updating the boolean 'virtual' flag. + node_name) updating the boolean 'virtual' flag for operator-authored + topology overrides. - This path intentionally excludes StormGuard adaptive site-speed overrides so - runtime StormGuard decisions do not overwrite the operator-authored - `network.json` source of truth. + This path intentionally excludes runtime automation changes such as + StormGuard adaptive site-speed overrides and TreeGuard virtual-node + decisions so they do not overwrite the operator-authored `network.json` + source of truth. Returns True if any changes were applied. """ try: diff --git a/src/test_scheduler.py b/src/test_scheduler.py index 1690b30f..ba7caa36 100644 --- a/src/test_scheduler.py +++ b/src/test_scheduler.py @@ -408,6 +408,42 @@ class TestSchedulerOverrideMerge(unittest.TestCase): self.assertEqual(site["downloadBandwidthMbps"], 940) self.assertEqual(site["uploadBandwidthMbps"], 500) + def test_apply_network_adjustments_does_not_materialize_runtime_treeguard_virtual_state(self): + network = { + "Root": { + "children": { + "REGION_01": { + "virtual": False, + "children": {}, + } + }, + } + } + + effective_adjustments = [ + { + "type": "set_node_virtual", + "node_name": "REGION_01", + "virtual": True, + } + ] + + with patch.dict( + scheduler.apply_network_adjustments.__globals__, + {"overrides_network_adjustments_effective": lambda: effective_adjustments}, + clear=False, + ): + with patch.object( + scheduler, + "overrides_network_adjustments_materialized", + return_value=[], + ) as mock_materialized: + changed = scheduler.apply_network_adjustments(network) + + mock_materialized.assert_called_once_with() + self.assertFalse(changed) + self.assertFalse(network["Root"]["children"]["REGION_01"]["virtual"]) + if __name__ == "__main__": unittest.main() From cfa01cf5e293349ce09b79acd862e083b9a1bccd Mon Sep 17 00:00:00 2001 From: rchac Date: Mon, 23 Mar 2026 02:24:20 +0000 Subject: [PATCH 021/102] changes --- .../v2.0/libreqos-backend-architecture-es.md | 10 + docs-es/v2.0/node-manager-ui-es.md | 1 + docs-es/v2.0/treeguard-es.md | 38 +- docs-es/v2.0/troubleshooting-es.md | 14 +- docs/v2.0/libreqos-backend-architecture.md | 10 + docs/v2.0/node-manager-ui.md | 1 + docs/v2.0/treeguard.md | 38 +- docs/v2.0/troubleshooting.md | 18 +- src/LibreQoS.py | 940 +++--- src/bakery_integration_test.py | 171 +- src/rust/Cargo.lock | 71 +- src/rust/Cargo.toml | 4 + src/rust/lqos_bakery/src/commands.rs | 99 +- src/rust/lqos_bakery/src/diff/circuits.rs | 137 +- src/rust/lqos_bakery/src/lib.rs | 2805 ++++++++++++++++- src/rust/lqos_bakery/src/qdisc_handles.rs | 21 - src/rust/lqos_bakery/src/utils.rs | 423 ++- src/rust/lqos_bus/src/bus/request.rs | 19 + src/rust/lqos_config/Cargo.toml | 2 + src/rust/lqos_config/src/authentication.rs | 383 ++- src/rust/lqos_config/src/lib.rs | 9 +- src/rust/lqos_config/src/network_json.rs | 2 + .../src/network_json/network_json_node.rs | 1 + .../network_json/network_json_transport.rs | 3 + src/rust/lqos_config/src/planner.rs | 698 ++++ src/rust/lqos_overrides/src/overrides_file.rs | 69 +- src/rust/lqos_python/src/lib.rs | 506 ++- .../lqos_queue_tracker/src/tracking/mod.rs | 37 +- src/rust/lqosd/Cargo.toml | 3 + src/rust/lqosd/src/main.rs | 17 +- src/rust/lqosd/src/node_manager.rs | 1 + src/rust/lqosd/src/node_manager/auth.rs | 732 ++++- .../src/dashlets/bakery_apply_summary.js | 56 +- .../js_build/src/dashlets/bakery_capacity.js | 255 +- .../js_build/src/dashlets/bakery_shared.js | 7 + .../src/dashlets/treeguard_controls.js | 14 +- .../node_manager/js_build/src/first-run.js | 19 +- .../src/node_manager/js_build/src/index.js | 8 + .../src/node_manager/js_build/src/login.js | 19 +- .../dashboard/dashboard_editor.js | 20 +- .../src/lq_js_common/dashboard/layout.js | 174 +- .../src/node_manager/js_build/src/template.js | 5 +- .../src/node_manager/js_build/src/tree.js | 14 + .../src/node_manager/local_api/directories.rs | 2 +- .../local_api/network_tree_lite.rs | 3 + src/rust/lqosd/src/node_manager/run.rs | 2 + .../src/node_manager/static2/first-run.html | 5 + .../lqosd/src/node_manager/static2/login.html | 2 +- .../src/node_manager/static2/template.html | 3 +- .../lqosd/src/node_manager/ws/messages.rs | 15 + .../src/node_manager/ws/ticker/bakery.rs | 13 + src/rust/lqosd/src/reload_lock.rs | 36 - .../lqosd/src/shaped_devices_tracker/mod.rs | 58 +- src/rust/lqosd/src/throughput_tracker/mod.rs | 35 +- src/rust/lqosd/src/treeguard/actor.rs | 2171 +++++++------ src/rust/lqosd/src/treeguard/bakery.rs | 38 +- src/rust/lqosd/src/treeguard/decisions.rs | 14 +- src/rust/lqosd/src/treeguard/errors.rs | 4 + src/rust/lqosd/src/treeguard/mod.rs | 1 - src/rust/lqosd/src/treeguard/overrides.rs | 46 +- src/rust/lqosd/src/treeguard/reload.rs | 219 -- src/rust/lqosd/src/treeguard/status.rs | 4 + src/rust/lqusers/Cargo.toml | 2 + src/rust/lqusers/src/main.rs | 17 + src/scheduler.py | 47 +- 65 files changed, 8167 insertions(+), 2444 deletions(-) create mode 100644 src/rust/lqos_config/src/planner.rs delete mode 100644 src/rust/lqosd/src/reload_lock.rs delete mode 100644 src/rust/lqosd/src/treeguard/reload.rs diff --git a/docs-es/v2.0/libreqos-backend-architecture-es.md b/docs-es/v2.0/libreqos-backend-architecture-es.md index 5222ef74..741bceff 100644 --- a/docs-es/v2.0/libreqos-backend-architecture-es.md +++ b/docs-es/v2.0/libreqos-backend-architecture-es.md @@ -242,6 +242,7 @@ Efecto práctico: |---|---|---|---| | Cambio solo de IP de circuito | Sí | No | Actualización de mapeo puede aplicarse sin reconstruir árbol | | Cambio de velocidad de circuito/sitio (subconjunto) | Sí | A veces | Depende del impacto estructural y class handles disponibles | +| Virtualización runtime de nodos TreeGuard (subárbol soportado / ruta de rebalanceo top-level) | Sí | No | Bakery ahora la aplica como un plan runtime vivo: nodos no top-level usan reparent/prune/restore, y nodos top-level soportados usan una ruta de rebalanceo y migración entre colas | | Cambios masivos en todos los circuitos | A veces | Frecuente | Límites de escala y cardinalidad de transacciones | | Re-parent/reestructura topológica | Rara vez | Sí | Restricciones de mutación de subárbol HTB | | Alta/baja de circuitos | Sí (pequeño/mediano) | A veces | Límites de handles y fronteras de corrección en el diff | @@ -269,6 +270,15 @@ flowchart TD 3. Esperar mayor riesgo cuando coinciden muchos circuitos y muchos cambios estructurales. 4. Diseñar la cadencia operativa para priorizar cambios incrementalmente seguros. +### 7.4 Guardrails de seguridad para recargas completas + +Las recargas completas actuales de Bakery aplican dos verificaciones conservadoras de seguridad antes y durante reconstrucciones grandes de colas: + +1. Un preflight de qdisc estima los qdisc planificados por interfaz y además separa qdisc de infraestructura, hojas `cake` y hojas `fq_codel`. +2. Ese mismo preflight aplica una proyección conservadora de memoria y bloquea de forma estricta las recargas completas claramente inseguras antes de arrancar `tc -batch`. +3. Durante la aplicación chunked de una recarga completa, Bakery vuelve a revisar la memoria del host en los límites de chunk y aborta el resto de la aplicación si la memoria disponible cae por debajo de su piso de seguridad. +4. Estos guardrails están sesgados intencionalmente hacia falsos positivos en recargas grandes para fallar temprano con diagnósticos en lugar de entrar en una espiral OOM. + ## 8) Límites de Diseño para Operadores ### 8.1 Límites de observabilidad diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 850cf65e..b00b1997 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -9,6 +9,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - El contenido puede variar según versión y funciones habilitadas. - Executive Summary ofrece una vista operativa compacta para redes grandes, con páginas de detalle para heatmaps y rankings ejecutivos. - Bakery ofrece una pestaña dedicada para el estado de aplicación de colas, eventos recientes de Bakery, resultados de seguridad/preflight de qdisc y el gráfico existente de actividad de circuitos. +- El preflight de qdisc de Bakery ahora presenta los qdisc planificados por interfaz como barras compactas de uso con badges del tipo de qdisc, además de una tabla separada de límites/memoria para presupuesto seguro, límite del kernel, memoria estimada de qdisc, memoria disponible actual y margen sobre el piso de seguridad. - Algunos gráficos pueden tardar un poco en poblarse al abrir una pestaña por primera vez, especialmente en sistemas ocupados o inmediatamente después de reiniciar servicios. - Durante un full reload de Bakery, las tarjetas de conteo de colas mantienen los últimos valores conocidos de HTB/CAKE/fq-codel y los marcan como `Reloading` en lugar de mostrar ceros momentáneamente. diff --git a/docs-es/v2.0/treeguard-es.md b/docs-es/v2.0/treeguard-es.md index 606ce238..ddacdd7f 100644 --- a/docs-es/v2.0/treeguard-es.md +++ b/docs-es/v2.0/treeguard-es.md @@ -32,11 +32,19 @@ TreeGuard evalúa utilización, frescura de RTT, guardrails de CPU y guardrails Comportamiento de alto nivel: 1. Bajo condiciones sostenidas de baja carga, TreeGuard puede cambiar una dirección de `cake` a `fq_codel`. -2. Si sube la utilización, los guardrails RTT/QoO no son seguros o se cumplen condiciones de reversión, TreeGuard vuelve a `cake`. +2. Si sube la utilización, los guardrails de QoO no son seguros o se cumplen condiciones de reversión, TreeGuard vuelve hacia la política SQM base del circuito. 3. Las decisiones pueden ser independientes por dirección cuando `independent_directions = true`. Esto crea un perfil dinámico en el que direcciones cargadas favorecen `cake diffserv4`, mientras direcciones de baja carga pueden usar `fq_codel` cuando las condiciones son seguras. +La política SQM base proviene de la intención del operador, no de defaults internos de TreeGuard. En la práctica, TreeGuard parte de la política efectiva configurada para cada circuito y solo persiste su propio overlay temporal cuando necesita diferir de esa base. + +Regla importante de política base: + +1. Si la política SQM base de una dirección es `cake`, TreeGuard puede cambiar temporalmente esa dirección a `fq_codel` y luego volver a la base. +2. Si la política SQM base de una dirección es `fq_codel`, TreeGuard no conmuta esa dirección de circuito hacia `cake`. +3. La virtualización de enlaces sigue disponible independientemente de la política SQM base del circuito. + ## Configuración (`/etc/lqos.conf`) La configuración de TreeGuard vive bajo `[treeguard]` y sus sub-secciones: @@ -80,20 +88,38 @@ enabled = true ## Overrides y Notas Operativas -Cuando está habilitado y no está en dry-run, TreeGuard puede persistir decisiones en: +Cuando está habilitado y no está en dry-run, TreeGuard puede persistir decisiones SQM de circuitos en: - `lqos_overrides.treeguard.json` TreeGuard está diseñado para no pelear con overrides del operador. Si existen overrides del operador para entidades enroladas, TreeGuard omite esas entidades y reporta advertencias. -Las decisiones de virtualización de nodos de TreeGuard son overrides de tiempo de ejecución. El -scheduler no las materializa de vuelta en el `network.json` base, por lo que deshabilitar o limpiar -TreeGuard no reescribe permanentemente la topología definida por el operador. +Las decisiones de virtualización de nodos de TreeGuard son operaciones de Bakery solo en tiempo de +ejecución. El scheduler no las materializa de vuelta en el `network.json` base, y tampoco se +persisten como entradas TreeGuard `set_node_virtual` dentro de la entrada efectiva de shaping. En +v1 son efímeras: un reinicio del daemon devuelve el árbol físico a la topología base definida por +el operador hasta que TreeGuard vuelva a decidir. -TreeGuard también se niega a gestionar nodos que ya estén marcados con `"virtual": true` en el `network.json` base. Si existen overrides viejos de TreeGuard para esos nodos, TreeGuard limpia su propia capa de overrides y vuelve a respetar la definición base de la topología. +Las decisiones SQM por circuito de TreeGuard también son overrides de tiempo de ejecución. El scheduler no materializa los cambios SQM propiedad de TreeGuard de vuelta en el `ShapedDevices.csv` base, por lo que limpiar TreeGuard no reescribe permanentemente la política SQM definida por el operador. + +TreeGuard también se niega a gestionar nodos que ya estén marcados con `"virtual": true` en el `network.json` base. Si existen overrides legados de TreeGuard para esos nodos, TreeGuard limpia ese estado legado y vuelve a respetar la definición base de la topología. Para la gestión SQM por circuito, TreeGuard trata los valores duplicados de `device_id` como colisiones de identidad inseguras. Si el mismo `device_id` aparece en más de un circuito dentro de `ShapedDevices.csv`, TreeGuard omite esos circuitos afectados y limpia cualquier override SQM de TreeGuard asociado a esos `device_id` duplicados. +Si la telemetría RTT no está disponible temporalmente después de un reinicio, TreeGuard no trata la ausencia de RTT por sí sola como evidencia para revertir direcciones en `fq_codel`. Siguen aplicando otros guardrails como utilización, QoO y presión de CPU. + +TreeGuard también aplica un presupuesto global conservador de cambios SQM por tick. En poblaciones muy grandes de circuitos enrolados, los cambios SQM excedentes se difieren a ticks posteriores en lugar de saturar Bakery en una sola pasada. + +Para escalar mejor, TreeGuard ya no reconstruye la membresía de circuitos desde `ShapedDevices.csv` en cada tick de circuitos. Ahora mantiene un inventario por circuito en caché derivado de `ShapedDevices.csv`, lee la telemetría viva por circuito desde el snapshot compartido de rollup por circuito actualizado una vez por segundo, y reparte las evaluaciones SQM grandes con `all_circuits = true` a lo largo de múltiples ticks en lugar de reescanear todos los circuitos enrolados cada segundo. + +En la práctica esto significa: + +1. La virtualización de enlaces sigue la cadencia normal de ticks de TreeGuard. +2. La evaluación SQM de circuitos para enrolamientos pequeños sigue completándose rápidamente. +3. Los enrolamientos muy grandes con `all_circuits` se recorren de forma incremental a lo largo de varios ticks, con un objetivo de barrido completo de alrededor de 15 segundos en vez de intentar un escaneo completo por segundo. +4. La virtualización de nodos de TreeGuard ahora usa rutas vivas de planificación/aplicación en Bakery en lugar de forzar una recarga completa de LibreQoS o de Bakery. +5. La virtualización runtime soportada de nodos top-level ahora usa un plan de rebalanceo/migración en Bakery que puede promover sitios hijos y circuitos directos entre raíces de cola, preservando la jerarquía lógica para reportes. + La actividad reciente de TreeGuard está disponible en dos lugares: - Las vistas de estado/actividad de TreeGuard en la WebUI. diff --git a/docs-es/v2.0/troubleshooting-es.md b/docs-es/v2.0/troubleshooting-es.md index 80112b0b..0dc21a88 100644 --- a/docs-es/v2.0/troubleshooting-es.md +++ b/docs-es/v2.0/troubleshooting-es.md @@ -46,14 +46,17 @@ Incluya también: ### La contraseña de usuario no funciona -Elimine el archivo de usuarios: +Los builds actuales: +- migran automáticamente archivos de autenticación antiguos +- redirigen `/login.html` a `/first-run.html` cuando no existen usuarios ```bash -sudo rm /opt/libreqos/src/lqusers.toml -sudo systemctl restart lqosd lqos_scheduler +sudo systemctl restart lqosd ``` -Luego abra: `IP_CAJA:9123/index.html`. +Si el usuario/contraseña correctos siguen fallando, pruebe primero con ese reinicio. + +Elimine `lqusers.toml` solo si desea reiniciar el acceso o si el archivo está corrupto y no se puede reparar. Después de eliminarlo, reinicie `lqosd` y abra `IP_CAJA:9123/login.html`; la WebUI debería redirigirlo automáticamente al flujo de primer inicio. ### No hay WebUI en x.x.x.x:9123 @@ -216,7 +219,8 @@ WebUI muestra códigos legibles por máquina para triage rápido. |---|---|---|---| | `MAPPED_CIRCUIT_LIMIT` | Bakery está forzando límite de circuitos mapeados. | Estado de licencia Insight y `journalctl -u lqosd` con requested/allowed/dropped. | Reducir circuitos mapeados o actualizar licencia/límites. | | `TC_U16_OVERFLOW` | IDs minor de clases/colas excedieron rango u16 de tc en una cola CPU. | `journalctl -u lqos_scheduler -u lqosd`, profundidad topológica y distribución por colas. | Aumentar paralelismo de colas y/o simplificar/rebalancear jerarquía. | -| `TC_QDISC_CAPACITY` | Los qdisc autoasignados planificados exceden el presupuesto seguro por interfaz antes de aplicar. | Conteos estimados de qdisc por interfaz en el contexto del urgent issue, `journalctl -u lqos_scheduler -u lqosd`, configuración `on_a_stick` y `monitor_only`. | Reducir la carga planificada de qdisc para esta ejecución (por ejemplo menos circuitos/dispositivos en la forma de prueba) antes de reintentar; no confiar en una aplicación parcial. | +| `TC_QDISC_CAPACITY` | Los qdisc autoasignados planificados exceden el presupuesto seguro por interfaz o el preflight conservador de seguridad de memoria de Bakery antes de aplicar. | Conteos estimados por interfaz, desglose por tipo de qdisc y campos de memoria en el contexto del urgent issue, `journalctl -u lqos_scheduler -u lqosd`, configuración `on_a_stick` y `monitor_only`. | Reducir la carga planificada de qdisc para esta ejecución (por ejemplo menos circuitos/dispositivos en la forma de prueba) antes de reintentar; no confiar en una aplicación parcial. | +| `BAKERY_MEMORY_GUARD` | Un full reload chunked de Bakery fue detenido a mitad de aplicación porque la memoria disponible del host cayó por debajo del piso de seguridad. | `journalctl -u lqosd`, memoria disponible/total en el contexto del urgent issue y progreso reciente de aplicación de Bakery. | Tratar la ejecución como fallida, reducir presión de memoria o huella de colas y reintentar solo cuando el host esté estable. | | `XDP_IP_MAPPING_CAPACITY` | Los mapeos IP requeridos exceden la capacidad actual del mapa XDP en el kernel. | Forma de `ShapedDevices.csv`, mezcla IPv4/IPv6, supuesto de un dispositivo frente a varios dispositivos, `journalctl -u lqos_scheduler -u lqosd`. | Reducir mapeos requeridos de inmediato (por ejemplo menos dispositivos o prueba IPv4-only), o aumentar la capacidad del mapa del kernel en un cambio coordinado. | | `XDP_IP_MAPPING_APPLY_FAILED` | Uno o más inserts de mapeo IP fallaron durante la aplicación. | `journalctl -u lqos_scheduler -u lqosd` para ejemplos resumidos y conteos de fallo. | Corregir la causa del fallo y volver a ejecutar; no confiar en shaping parcial. | diff --git a/docs/v2.0/libreqos-backend-architecture.md b/docs/v2.0/libreqos-backend-architecture.md index c35c1114..b4382b3d 100644 --- a/docs/v2.0/libreqos-backend-architecture.md +++ b/docs/v2.0/libreqos-backend-architecture.md @@ -248,6 +248,7 @@ Practical effect: |---|---|---|---| | Circuit IP-only change | Yes | No | Mapping update can often be applied without tree rebuild | | Circuit/site speed change (subset) | Yes | Sometimes | Depends on structural impact and available class handles | +| TreeGuard runtime node virtualization (supported subtree/top-level rebalance path) | Yes | No | Bakery now applies it as a live runtime plan: non-top-level nodes use reparent/prune/restore, and supported top-level nodes use a rebalance + cross-queue migration path | | Bulk all-circuit changes | Sometimes | Often | Scale and transaction/cardinality limits | | Topology re-parent/restructure | Rarely | Yes | HTB subtree mutation constraints | | Add/remove circuits | Yes (small/moderate) | Sometimes | Handle availability and diff correctness boundaries | @@ -275,6 +276,15 @@ flowchart TD 3. Expect higher risk when many circuits and many structure-affecting changes happen together. 4. Build operations cadence around incremental-safe updates by default. +### 7.4 Full-reload safety guards + +Current Bakery full reloads apply two conservative safety checks before and during large queue rebuilds: + +1. A qdisc preflight estimates planned qdiscs per interface and also separates infrastructure, `cake`, and `fq_codel` leaf qdiscs. +2. That same preflight applies a conservative memory forecast and hard-blocks clearly unsafe full reloads before `tc -batch` starts. +3. During chunked full reload apply, Bakery re-checks host memory at chunk boundaries and aborts the remaining apply if available memory drops below its safety floor. +4. These guards are intentionally biased toward false positives on large reloads so the system fails early with diagnostics instead of spiraling into an OOM event. + ## 8) Design Boundaries for Operators ### 8.1 Observability boundaries diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index e36e9510..478634c5 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -9,6 +9,7 @@ This page documents key WebUI (Node Manager) views and operational behavior in t - Dashboard content can vary by version and enabled features. - Executive Summary provides a compact operational view for large networks, with drilldown pages for executive heatmaps and leaderboards. - Bakery provides dedicated status for queue apply state, recent Bakery events, qdisc safety/preflight results, and the existing circuit-activity graph. +- Bakery qdisc preflight now presents per-interface planned qdiscs as compact usage bars with qdisc-kind mix badges, plus a separate limits/memory table for safe budget, kernel limit, estimated qdisc memory, current available memory, and safety-floor headroom. - Some charts may take a short time to populate after first opening a tab, especially on busy systems or immediately after a service restart. - During a Bakery full reload, queue-count cards keep showing the last known HTB/CAKE/fq-codel values and mark them as `Reloading` instead of briefly zeroing them. diff --git a/docs/v2.0/treeguard.md b/docs/v2.0/treeguard.md index d5b48c71..d4868171 100644 --- a/docs/v2.0/treeguard.md +++ b/docs/v2.0/treeguard.md @@ -32,11 +32,19 @@ TreeGuard evaluates utilization, RTT freshness, CPU guardrails, and optional QoO High-level behavior: 1. For sustained low-load conditions, TreeGuard may switch a direction from `cake` to `fq_codel`. -2. If utilization rises, RTT/QoO guardrails are unsafe, or revert conditions are met, TreeGuard switches back to `cake`. +2. If utilization rises, QoO guardrails are unsafe, or revert conditions are met, TreeGuard switches back toward the circuit's base SQM policy. 3. Decisions can be independent per direction when `independent_directions = true`. This gives a dynamic profile where loaded directions favor `cake diffserv4`, while low-load directions can use `fq_codel` when conditions are safe. +The base SQM policy comes from operator intent, not from TreeGuard defaults. In practice, TreeGuard starts from the effective configured policy for each circuit and only persists its own temporary overlay when it needs to differ from that base. + +Important base-policy rule: + +1. If a direction's base SQM policy is `cake`, TreeGuard may temporarily switch that direction to `fq_codel` and later return it to base. +2. If a direction's base SQM policy is `fq_codel`, TreeGuard does not circuit-switch that direction to `cake`. +3. Link virtualization remains available regardless of the circuit SQM base policy. + ## Configuration (`/etc/lqos.conf`) TreeGuard config lives under `[treeguard]` and sub-sections: @@ -80,20 +88,38 @@ enabled = true ## Overrides and Operational Notes -When enabled and not dry-run, TreeGuard may persist decisions to: +When enabled and not dry-run, TreeGuard may persist circuit SQM decisions to: - `lqos_overrides.treeguard.json` TreeGuard is designed to avoid fighting operator-owned overrides. If operator overrides exist for enrolled entities, TreeGuard skips those entities and reports warnings. -TreeGuard virtual-node decisions are runtime overrides. They are not materialized back into the -base `network.json` by the scheduler, so disabling or clearing TreeGuard does not permanently -rewrite operator-authored topology. +TreeGuard virtual-node decisions are runtime-only Bakery operations. They are not materialized back +into the base `network.json` by the scheduler, and they are not persisted as TreeGuard-owned +`set_node_virtual` entries in the effective shaping input. In v1 they are ephemeral: a daemon +restart returns the physical tree to the base operator-defined topology until TreeGuard decides +again. -TreeGuard also refuses to manage nodes that are already marked `"virtual": true` in the base `network.json`. If stale TreeGuard-owned node-virtualization overrides exist for those nodes, TreeGuard clears its own override layer and falls back to the base topology definition. +TreeGuard circuit SQM decisions are also runtime overrides. The scheduler does not materialize TreeGuard-owned SQM changes back into the base `ShapedDevices.csv`, so clearing TreeGuard does not permanently rewrite operator-authored circuit SQM policy. + +TreeGuard also refuses to manage nodes that are already marked `"virtual": true` in the base `network.json`. If stale legacy TreeGuard-owned node-virtualization overrides exist for those nodes, TreeGuard clears that legacy override state and falls back to the base topology definition. For circuit SQM management, TreeGuard treats duplicate `device_id` values as unsafe identity collisions. If the same `device_id` appears in more than one circuit in `ShapedDevices.csv`, TreeGuard skips those affected circuits and clears any TreeGuard-owned SQM overrides for those duplicate device IDs. +If RTT telemetry is temporarily unavailable after a restart, TreeGuard does not treat missing RTT alone as evidence that it should revert `fq_codel` directions. Other guardrails such as utilization, QoO, and CPU pressure still apply. + +TreeGuard also applies a conservative global per-tick circuit SQM change budget. On very large enrolled populations, excess circuit SQM changes are deferred to later ticks instead of stampeding Bakery in one pass. + +For scale, TreeGuard no longer rebuilds circuit membership from `ShapedDevices.csv` on every circuit tick. It now keeps a cached per-circuit inventory derived from `ShapedDevices.csv`, reads per-circuit live telemetry from the shared once-per-second circuit rollup snapshot, and spreads large `all_circuits = true` SQM evaluations across multiple ticks instead of rescanning every enrolled circuit every second. + +In practice, this means: + +1. Link virtualization still follows the normal TreeGuard tick cadence. +2. Circuit SQM evaluation for small enrollments still completes quickly. +3. Very large `all_circuits` enrollments are swept incrementally over multiple ticks, with a target full sweep around 15 seconds instead of attempting a full per-second scan. +4. TreeGuard node virtualization now goes through Bakery live runtime planning/apply paths instead of forcing a LibreQoS reload or Bakery full reload. +5. Supported top-level runtime virtualization now uses a Bakery-side rebalance/migration plan that can promote child sites and direct circuits across queue roots while preserving the logical hierarchy for reporting. + Recent TreeGuard activity is available in two places: - The WebUI TreeGuard status/activity views. diff --git a/docs/v2.0/troubleshooting.md b/docs/v2.0/troubleshooting.md index af4b142e..f35ea2d2 100644 --- a/docs/v2.0/troubleshooting.md +++ b/docs/v2.0/troubleshooting.md @@ -48,13 +48,16 @@ And include: ### User password not working -Delete the lqusers file: +Current builds will: +- migrate older auth files automatically +- redirect `/login.html` to `/first-run.html` when no users exist + +If the correct username/password still fails, first restart `lqosd` and try again: +```bash +sudo systemctl restart lqosd ``` -sudo rm /opt/libreqos/src/lqusers.toml -sudo systemctl restart lqosd lqos_scheduler -``` -Then visit: BOX_IP:9123/index.html -This will allow you to set up the user again from scratch using the WebUI. + +Only remove `lqusers.toml` if you are intentionally resetting access or if the file is corrupt and cannot be repaired. After removing it, restart `lqosd` and open `BOX_IP:9123/login.html`; the WebUI should redirect you to first-run setup automatically. ### No WebUI at x.x.x.x:9123 @@ -236,7 +239,8 @@ WebUI urgent issues include machine-readable codes. Use them to triage quickly. |---|---|---|---| | `MAPPED_CIRCUIT_LIMIT` | Bakery is enforcing a mapped-circuit limit. | Insight license status, `journalctl -u lqosd` for requested/allowed/dropped counts. | Reduce mapped circuits immediately or update license/limits. | | `TC_U16_OVERFLOW` | Queue/class minor IDs exceeded the Linux tc u16 range on a CPU queue. | `journalctl -u lqos_scheduler -u lqosd`, topology depth/queue distribution. | Increase queue count and/or simplify/rebalance hierarchy (for example with integration strategy or root promotion changes). | -| `TC_QDISC_CAPACITY` | Planned auto-allocated qdiscs exceed the per-interface safe budget before apply. | Estimated per-interface qdisc counts in the urgent issue context, `journalctl -u lqos_scheduler -u lqosd`, `on_a_stick` and `monitor_only` config. | Reduce the planned qdisc load for this run (for example fewer circuits/devices in the test shape) before retrying; do not trust partial apply. | +| `TC_QDISC_CAPACITY` | Planned auto-allocated qdiscs exceed the per-interface safe budget or Bakery's conservative memory-safety preflight before apply. | Estimated per-interface qdisc counts, qdisc-kind breakdown, and memory fields in the urgent issue context, `journalctl -u lqos_scheduler -u lqosd`, `on_a_stick` and `monitor_only` config. | Reduce the planned qdisc load for this run (for example fewer circuits/devices in the test shape) before retrying; do not trust partial apply. | +| `BAKERY_MEMORY_GUARD` | A chunked Bakery full reload was stopped mid-apply because available host memory fell below the safety floor. | `journalctl -u lqosd`, available/total memory in the urgent issue context, and recent Bakery apply progress. | Treat the run as failed, reduce memory pressure or queue footprint, and retry only after the host is stable. | | `XDP_IP_MAPPING_CAPACITY` | Required IP mappings exceed the current XDP kernel map capacity. | `ShapedDevices.csv` row shape, IPv4/IPv6 mix, one-device-vs-many-device assumptions, `journalctl -u lqos_scheduler -u lqosd`. | Reduce required mappings immediately (for example fewer devices or IPv4-only test shape), or raise kernel map capacity in a coordinated change. | | `XDP_IP_MAPPING_APPLY_FAILED` | One or more IP mapping inserts failed during apply. | `journalctl -u lqos_scheduler -u lqosd` for summarized failure examples and counts. | Fix the underlying mapping failure, then rerun; do not trust partial shaping. | diff --git a/src/LibreQoS.py b/src/LibreQoS.py index 63b9d471..4f0d988c 100755 --- a/src/LibreQoS.py +++ b/src/LibreQoS.py @@ -40,6 +40,8 @@ from liblqos_python import is_lqosd_alive, clear_ip_mappings, delete_ip_mapping, run_shell_commands_as_sudo, generated_pn_download_mbps, generated_pn_upload_mbps, queues_available_override, \ on_a_stick, get_tree_weights, get_weights, is_network_flat, get_libreqos_directory, enable_insight_topology, \ is_insight_enabled, scheduler_error, xdp_ip_mapping_capacity, \ + plan_top_level_cpu_bins, \ + plan_class_identities, \ fast_queues_fq_codel, \ shaping_cpu_count, \ Bakery @@ -112,6 +114,128 @@ def get_network_json_path(): # Either insight not enabled, or file doesn't exist return os.path.join(base_dir, "network.json") + +def get_planner_state_path(): + return os.path.join(get_libreqos_directory(), "planner_state.json") + + +def _load_json_dict(path): + try: + with open(path, "r") as infile: + data = json.load(infile) + if isinstance(data, dict): + return data + except Exception: + pass + return {} + + +def load_planner_state(state_path=None, planner_module=None): + if state_path is None: + state_path = get_planner_state_path() + if planner_module is not None: + try: + state = planner_module.load_state(state_path) + if isinstance(state, dict): + return state + except Exception: + pass + return _load_json_dict(state_path) + + +def save_planner_state(state, state_path=None, planner_module=None): + if state_path is None: + state_path = get_planner_state_path() + if planner_module is not None: + planner_module.save_state(state_path, state) + return + + parent = os.path.dirname(state_path) + if parent: + os.makedirs(parent, exist_ok=True) + temp_path = state_path + ".tmp" + with open(temp_path, "w") as outfile: + json.dump(state, outfile, indent=2, sort_keys=True) + os.replace(temp_path, state_path) + + +def _parse_int_token(value): + if value is None: + return None + try: + if isinstance(value, int): + return value + token = str(value).strip() + if not token: + return None + if token.lower().startswith("0x"): + return int(token, 16) + return int(token) + except Exception: + return None + + +def planner_circuit_identity_key(circuit): + circuit_id = str(circuit.get("circuitID", "") or "").strip() + if not circuit_id: + raise ValueError("Missing circuitID is unsupported for planner identity") + return circuit_id + + +def load_minor_state_from_queuing_structure(path=None): + if path is None: + path = os.path.join(get_libreqos_directory(), "queuingStructure.json") + data = _load_json_dict(path) + network = data.get("Network") + if not isinstance(network, dict): + return {"sites": {}, "circuits": {}} + + sites = {} + circuits = {} + + def walk(node_map, trail=()): + for node_name, node in sorted(node_map.items()): + if not isinstance(node, dict): + continue + node_path = trail + (node_name,) + site_key = "/".join(node_path) + parent_path = "/".join(trail) + queue = _parse_int_token(node.get("cpuNum")) + class_minor = _parse_int_token(node.get("classMinor")) + class_major = _parse_int_token(node.get("classMajor")) + up_class_major = _parse_int_token(node.get("up_classMajor")) + if queue is not None and class_minor is not None: + sites[site_key] = { + "class_minor": class_minor, + "queue": queue + 1, + "parent_path": parent_path, + "class_major": class_major, + "up_class_major": up_class_major, + } + + if isinstance(node.get("circuits"), list): + for circuit in node.get("circuits", []): + if not isinstance(circuit, dict): + continue + circuit_id = circuit.get("circuitID") + circuit_minor = _parse_int_token(circuit.get("classMinor")) + if queue is None or circuit_id is None or circuit_minor is None: + continue + circuits[str(circuit_id)] = { + "class_minor": circuit_minor, + "queue": queue + 1, + "parent_node": circuit.get("ParentNode", node_name), + "class_major": _parse_int_token(circuit.get("classMajor")), + "up_class_major": _parse_int_token(circuit.get("up_classMajor")), + } + + children = node.get("children") + if isinstance(children, dict): + walk(children, node_path) + + walk(network) + return {"sites": sites, "circuits": circuits} + def calculateR2q(maxRateInMbps): # So we've learned that r2q defaults to 10, and is used to calculate quantum. Quantum is rateInBytes/r2q by # default. This default gives errors at high rates, and tc clamps the quantum to 200000. Setting a high quantum @@ -551,42 +675,11 @@ def loadSubscriberCircuits(shapedDevicesFile): counterForCircuitsWithoutParentNodes += 1 subscriberCircuits.append(thisCircuit) circuitsById[circuitID] = thisCircuit - # If there is nothing in the circuit ID field else: - # Copy deviceName to circuitName if none defined already - if circuitName == "": - circuitName = deviceName - if ParentNode == "": - ParentNode = "none" - #ParentNode = ParentNode.strip() - deviceListForCircuit = [] - thisDevice = { - "deviceID": deviceID, - "deviceName": deviceName, - "mac": mac, - "ipv4s": ipv4_subnets_and_hosts, - "ipv6s": ipv6_subnets_and_hosts, - } - deviceListForCircuit.append(thisDevice) - thisCircuit = { - "circuitID": circuitID, - "circuitName": circuitName, - "ParentNode": ParentNode, - "devices": deviceListForCircuit, - "minDownload": float(downloadMin), - "minUpload": float(uploadMin), - "maxDownload": float(downloadMax), - "maxUpload": float(uploadMax), - "classid": '', - "comment": comment - } - if sqm_override_token != '': - thisCircuit['sqm'] = sqm_override_token - if thisCircuit['ParentNode'] == 'none': - thisCircuit['idForCircuitsWithoutParentNodes'] = counterForCircuitsWithoutParentNodes - dictForCircuitsWithoutParentNodes[counterForCircuitsWithoutParentNodes] = ((float(downloadMax))+(float(uploadMax))) - counterForCircuitsWithoutParentNodes += 1 - subscriberCircuits.append(thisCircuit) + raise ValueError( + "Missing circuitID is unsupported in ShapedDevices.csv " + f"(deviceID={deviceID}, deviceName={deviceName}, parent={ParentNode})" + ) return (subscriberCircuits, dictForCircuitsWithoutParentNodes) def refreshShapers(): @@ -834,10 +927,10 @@ def refreshShapers(): except ImportError: bin_planner = None # Store planner state directly in lqos_directory (no hidden subdirs) - state_path = os.path.join(get_libreqos_directory(), "planner_state.json") + state_path = get_planner_state_path() state = {} if bin_planner is not None: - state = bin_planner.load_state(state_path) + state = load_planner_state(state_path, bin_planner) now_ts = time.time() prev_assign = {} last_change_ts = {} @@ -903,7 +996,7 @@ def refreshShapers(): state["assignments"][iid] = b try: print(f"Saving planner state to {state_path} (generated PNs)") - bin_planner.save_state(state_path, state) + save_planner_state(state, state_path, bin_planner) except Exception as e: warnings.warn(f"Failed to save planner state at {state_path}: {e}", stacklevel=2) @@ -1058,26 +1151,34 @@ def refreshShapers(): print("Parsing network structure and tallying devices") parentNodes = [] minorByCPUpreloaded = {} - knownClassIDs = [] nodes_requiring_min_squashing = {} - # Persisted circuit minor assignments for stability across reloads - circuit_state_from_disk = {} - circuit_state_updated = {} - used_minors_by_queue = {cpu + 1: set() for cpu in range(queuesAvailable)} # Track minor counter by CPU. This way we can have > 32000 hosts (htb has u16 limit to minor handle) # Minor numbers start at 3 to reserve 1 for root qdisc and 2 for default class # With CIRCUIT_PADDING, we leave gaps between nodes to allow future circuit additions # without disrupting existing ClassID assignments. This maintains stability across reloads. for x in range(queuesAvailable): minorByCPUpreloaded[x+1] = 3 - def traverseNetwork(data, depth, major, minorByCPU, queue, parentClassID, upParentClassID, parentMaxDL, parentMaxUL, parentMinDL, parentMinUL): - # ClassID Assignment Strategy: - # - Nodes and circuits are processed in alphabetical order for stability - # - Each node gets a unique minor number that increments sequentially - # - After processing all circuits for a node, we add CIRCUIT_PADDING to the minor counter - # - This creates gaps that allow adding new circuits without affecting other ClassIDs - # - Children are also sorted before recursive processing to ensure deterministic traversal - # For top-level binpacked keys (CpueQueueN), enforce numeric ordering of N + def report_minor_overflow(queue, minor): + msg = f"Minor class ID overflow on CPU {queue}: {minor} exceeds TC's u16 limit (65535). Consider increasing queue count or restructuring network hierarchy." + logging.error(msg) + try: + ctx = json.dumps({"cpu": queue, "minor": minor}) + submit_urgent_issue("LibreQoS", "Error", "TC_U16_OVERFLOW", msg, ctx, f"TC_U16_OVERFLOW_CPU_{queue}") + except Exception: + pass + raise ValueError(msg) + + def ensure_minor_capacity(queue, minor): + if minor > 0xFFFF: + report_minor_overflow(queue, minor) + + def next_free_minor(start_minor, reserved): + candidate = max(3, start_minor) + while candidate in reserved: + candidate += 1 + return candidate + + def sorted_node_keys(data, depth): keys = list(data.keys()) if depth == 0 and len(keys) > 0 and all(k.startswith("CpueQueue") for k in keys): try: @@ -1086,227 +1187,7 @@ def refreshShapers(): keys = sorted(keys) else: keys = sorted(keys) - for node in keys: - #if data[node]['type'] == "virtual": - # print(node + " is a virtual node. Skipping.") - # if depth == 0: - # parentClassID = hex(major) + ':' - # upParentClassID = hex(major+stickOffset) + ':' - # data[node]['parentClassID'] = parentClassID - # data[node]['up_parentClassID'] = upParentClassID - # data[node]['classMajor'] = hex(major) - # data[node]['up_classMajor'] = hex(major + stickOffset) - # data[node]['classMinor'] = hex(minorByCPU[queue]) - # data[node]['cpuNum'] = hex(queue-1) - # data[node]['up_cpuNum'] = hex(queue-1+stickOffset) - # traverseNetwork(data[node]['children'], depth, major, minorByCPU, queue, parentClassID, upParentClassID, parentMaxDL, parentMaxUL) - # continue - circuitsForThisNetworkNode = [] - nodeClassID = hex(major) + ':' + hex(minorByCPU[queue]) - upNodeClassID = hex(major+stickOffset) + ':' + hex(minorByCPU[queue]) - data[node]['classid'] = nodeClassID - data[node]['up_classid'] = upNodeClassID - if depth == 0: - parentClassID = hex(major) + ':' - upParentClassID = hex(major+stickOffset) + ':' - data[node]['parentClassID'] = parentClassID - data[node]['up_parentClassID'] = upParentClassID - # If in monitorOnlyMode, override bandwidth rates to where no shaping will actually occur - if monitor_mode_only() == True: - data[node]['downloadBandwidthMbps'] = 100000 - data[node]['uploadBandwidthMbps'] = 100000 - data[node]['downloadBandwidthMbpsMin'] = 100000 - data[node]['uploadBandwidthMbpsMin'] = 100000 - # If not in monitorOnlyMode - else: - # Cap based on this node's max bandwidth, or parent node's max bandwidth, whichever is lower - data[node]['downloadBandwidthMbps'] = min(data[node]['downloadBandwidthMbps'],parentMaxDL) - data[node]['uploadBandwidthMbps'] = min(data[node]['uploadBandwidthMbps'],parentMaxUL) - data[node]['downloadBandwidthMbpsMin'] = min(data[node]['downloadBandwidthMbpsMin'], data[node]['downloadBandwidthMbps'], parentMinDL) - data[node]['uploadBandwidthMbpsMin'] = min(data[node]['uploadBandwidthMbpsMin'], data[node]['uploadBandwidthMbps'], parentMinUL) - # Calculations are done in findBandwidthMins() to determine optimal HTB rates (mins) and ceils (maxs) - data[node]['classMajor'] = hex(major) - data[node]['up_classMajor'] = hex(major + stickOffset) - data[node]['classMinor'] = hex(minorByCPU[queue]) - data[node]['cpuNum'] = hex(queue-1) - data[node]['up_cpuNum'] = hex(queue-1+stickOffset) - used_minors_by_queue.setdefault(queue, set()).add(minorByCPU[queue]) - thisParentNode = { - "parentNodeName": node, - "classID": nodeClassID, - "maxDownload": data[node]['downloadBandwidthMbps'], - "maxUpload": data[node]['uploadBandwidthMbps'], - } - parentNodes.append(thisParentNode) - minorByCPU[queue] = minorByCPU[queue] + 1 - # Check for overflow - TC uses u16 for minor class ID (max 65535) - if minorByCPU[queue] > 0xFFFF: - msg = f"Minor class ID overflow on CPU {queue}: {minorByCPU[queue]} exceeds TC's u16 limit (65535). Consider increasing queue count or restructuring network hierarchy." - logging.error(msg) - try: - ctx = json.dumps({"cpu": queue, "minor": minorByCPU[queue]}) - submit_urgent_issue("LibreQoS", "Error", "TC_U16_OVERFLOW", msg, ctx, f"TC_U16_OVERFLOW_CPU_{queue}") - except Exception: - pass - raise ValueError(f"Minor class ID overflow on CPU {queue}: {minorByCPU[queue]} exceeds limit of 65535") - # If a device from ShapedDevices.csv lists this node as its Parent Node, attach it as a leaf to this node HTB - if node in circuits_by_parent_node: - # If mins of circuits combined exceed min of parent node - set to 1 - override_min_down = None - override_min_up = None - if monitor_mode_only() == False: - if (circuit_min_down_combined_by_parent_node[node] > data[node]['downloadBandwidthMbpsMin']) or (circuit_min_up_combined_by_parent_node[node] > data[node]['uploadBandwidthMbpsMin']): - override_min_down = 1 - override_min_up = 1 - logging.info("The combined minimums of circuits in Parent Node [" + node + "] exceeded that of the parent node. Reducing these circuits' minimums to 1 now.", stacklevel=2) - if ((override_min_down * len(circuits_by_parent_node[node])) > data[node]['downloadBandwidthMbpsMin']) or ((override_min_up * len(circuits_by_parent_node[node])) > data[node]['uploadBandwidthMbpsMin']): - logging.info("Even with this change, minimums will exceed the min rate of the parent node. Using 10 kbps as the minimum for these circuits instead.", stacklevel=2) - nodes_requiring_min_squashing[node] = True - # Sort circuits by name for stable ordering - sorted_circuits = sorted(circuits_by_parent_node[node], - key=lambda c: c.get('circuitName', c.get('circuitID', ''))) - for circuit in sorted_circuits: - if node == circuit['ParentNode']: - if monitor_mode_only() == False: - if circuit['maxDownload'] > data[node]['downloadBandwidthMbps']: - logging.info("downloadMax of Circuit ID [" + circuit['circuitID'] + "] exceeded that of its parent node. Reducing to that of its parent node now.", stacklevel=2) - if circuit['maxUpload'] > data[node]['uploadBandwidthMbps']: - logging.info("uploadMax of Circuit ID [" + circuit['circuitID'] + "] exceeded that of its parent node. Reducing to that of its parent node now.", stacklevel=2) - parentString = hex(major) + ':' - # Attempt to reuse a stored minor for stability (only if parent/bin unchanged and not colliding) - reuse_minor = None - try: - circuit_id_str = str(circuit.get('circuitID')) - stored_entry = circuit_state_from_disk.get(circuit_id_str, {}) if isinstance(circuit_state_from_disk, dict) else {} - stored_minor = stored_entry.get("class_minor") - stored_queue = stored_entry.get("queue") - stored_parent = stored_entry.get("parent_node") - if stored_minor is not None and stored_queue is not None and stored_parent is not None: - try: - stored_minor = int(stored_minor) - except Exception: - stored_minor = None - try: - stored_queue = int(stored_queue) - except Exception: - stored_queue = None - if stored_minor is not None and stored_queue == queue and stored_parent == circuit['ParentNode']: - if stored_minor not in used_minors_by_queue.setdefault(queue, set()): - reuse_minor = stored_minor - except Exception: - pass - - candidate_minor = reuse_minor if reuse_minor is not None else minorByCPU[queue] - reserved = used_minors_by_queue.setdefault(queue, set()) - while candidate_minor in reserved: - candidate_minor += 1 - - flowIDstring = hex(major) + ':' + hex(candidate_minor) - upFlowIDstring = hex(major + stickOffset) + ':' + hex(candidate_minor) - circuit['classid'] = flowIDstring - circuit['up_classid'] = upFlowIDstring - logging.info("Added up_classid to circuit: " + circuit['up_classid']) - # Create circuit dictionary to be added to network structure, eventually output as queuingStructure.json - maxDownload = min(circuit['maxDownload'],data[node]['downloadBandwidthMbps']) - maxUpload = min(circuit['maxUpload'],data[node]['uploadBandwidthMbps']) - if override_min_down: - circuit['minDownload'] = 1 - if override_min_up: - circuit['minUpload'] = 1 - minDownload = min(circuit['minDownload'],maxDownload) - minUpload = min(circuit['minUpload'],maxUpload) - thisNewCircuitItemForNetwork = { - 'maxDownload' : maxDownload, - 'maxUpload' : maxUpload, - 'minDownload' : minDownload, - 'minUpload' : minUpload, - "circuitID": circuit['circuitID'], - "circuitName": circuit['circuitName'], - "ParentNode": circuit['ParentNode'], - "logicalParentNode": circuit.get('logicalParentNode', circuit['ParentNode']), - "devices": circuit['devices'], - "classid": flowIDstring, - "up_classid" : upFlowIDstring, - "classMajor": hex(major), - "up_classMajor" : hex(major + stickOffset), - "classMinor": hex(candidate_minor), - "comment": circuit['comment'] - } - # Attach the planner weight used by the planner/UI summary - # Priority: explicit weight -> fallback to maxDownload - try: - cid = str(circuit.get('circuitID','')) - w = None - if cid in weight_by_circuit_id: - w = float(weight_by_circuit_id[cid]) - if w is None: - w = float(maxDownload) - # Treat 1000 as a sentinel default from Insight; use maxDownload instead - if abs(w - 1000.0) < 1e-6: - w = float(maxDownload) - # If the circuit's configured rate equals the generated PN default, - # ignore it for weight purposes. - try: - default_rate = float(generated_pn_download_mbps()) - if abs(float(maxDownload) - default_rate) < 1e-6: - w = 0.0 - except Exception: - pass - thisNewCircuitItemForNetwork['planner_weight'] = w - except Exception: - pass - # Preserve optional per-circuit SQM override for downstream bakery call - if 'sqm' in circuit and circuit['sqm']: - thisNewCircuitItemForNetwork['sqm'] = circuit['sqm'] - # Generate TC commands to be executed later - thisNewCircuitItemForNetwork['devices'] = circuit['devices'] - circuitsForThisNetworkNode.append(thisNewCircuitItemForNetwork) - reserved.add(candidate_minor) - minorByCPU[queue] = max(minorByCPU[queue], candidate_minor) + 1 - try: - # Record the assigned minor for future stability - circuit_state_updated[str(circuit.get('circuitID'))] = { - "class_minor": candidate_minor, - "queue": queue, - "parent_node": circuit['ParentNode'], - "class_major": major, - "up_class_major": major + stickOffset, - } - except Exception: - pass - if len(circuitsForThisNetworkNode) > 0: - data[node]['circuits'] = circuitsForThisNetworkNode - - # Add padding for future circuit additions (applies to all nodes) - # This ensures space is reserved even for nodes without circuits - minorByCPU[queue] = minorByCPU[queue] + CIRCUIT_PADDING - - # Recursive call this function for children nodes attached to this node - if 'children' in data[node]: - # Sort children to ensure consistent traversal order - sorted_children = dict(sorted(data[node]['children'].items())) - # We need to keep tabs on the minor counter, because we can't have repeating class IDs. Here, we bring back the minor counter from the recursive function - minorByCPU[queue] = minorByCPU[queue] + 1 - # Check for overflow - TC uses u16 for minor class ID (max 65535) - if minorByCPU[queue] > 0xFFFF: - msg = f"Minor class ID overflow on CPU {queue}: {minorByCPU[queue]} exceeds TC's u16 limit (65535). Consider increasing queue count or restructuring network hierarchy." - logging.error(msg) - try: - ctx = json.dumps({"cpu": queue, "minor": minorByCPU[queue]}) - submit_urgent_issue("LibreQoS", "Error", "TC_U16_OVERFLOW", msg, ctx, f"TC_U16_OVERFLOW_CPU_{queue}") - except Exception: - pass - raise ValueError(f"Minor class ID overflow on CPU {queue}: {minorByCPU[queue]} exceeds limit of 65535") - minorByCPU = traverseNetwork(sorted_children, depth+1, major, minorByCPU, queue, nodeClassID, upNodeClassID, data[node]['downloadBandwidthMbps'], data[node]['uploadBandwidthMbps'], data[node]['downloadBandwidthMbpsMin'], data[node]['uploadBandwidthMbpsMin']) - # If top level node, increment to next queue / cpu core - if depth == 0: - if queue >= queuesAvailable: - queue = 1 - major = queue - else: - queue += 1 - major += 1 - return minorByCPU + return keys # If we're in binpacking mode, we need to sort the network structure a bit if use_bin_packing_to_balance_cpu() and not is_network_flat(): @@ -1345,16 +1226,6 @@ def refreshShapers(): except Exception: pass - def round_robin_assign(items_list, bins): - if not bins: - return {}, [] - names = [str(it["id"]) for it in items_list] - names.sort() - assignment_local = {} - for idx, name in enumerate(names): - assignment_local[name] = bins[idx % len(bins)] - return assignment_local, list(assignment_local.keys()) - for node in network: w = weight_by_name.get(str(node), 1.0) try: @@ -1367,135 +1238,67 @@ def refreshShapers(): w = 1.0 items.append({"id": str(node), "weight": w}) - def greedy_assign(items_list, bins): - bin_loads = {key: 0.0 for key in bins} - pairs = [(str(it["id"]), float(it["weight"])) for it in items_list] - pairs.sort(key=lambda nw: (-nw[1], nw[0])) - assignment_local = {} - for name, wgt in pairs: - target = min(bin_loads.items(), key=lambda kv: (kv[1], kv[0]))[0] - assignment_local[name] = target - bin_loads[target] += wgt - return assignment_local, list(assignment_local.keys()) - - # Prepare bins and capacities cpu_keys = ["CpueQueue" + str(cpu) for cpu in range(queuesAvailable)] - bins_list = [{"id": key} for key in cpu_keys] - capacities = {key: 1.0 for key in cpu_keys} - valid_bins = set(capacities.keys()) + valid_bins = set(cpu_keys) planner_used = False - state_path = os.path.join(get_libreqos_directory(), "planner_state.json") + state_path = get_planner_state_path() state = {} now_ts = time.time() assignment = {} changed = [] - bin_planner = None + try: + state = load_planner_state(state_path, None) + except Exception: + state = {} - # Load planner state and generate assignments when Insight is enabled/licensed - if insight_enabled: - try: - import bin_planner as bin_planner # type: ignore - except ImportError: - bin_planner = None + prev_assign = {} + last_change_ts = {} + if isinstance(state, dict): + prev_assign = state.get("assignments", {}) or {} + last_change_ts = state.get("last_change_ts", {}) or {} + item_ids = {str(it["id"]) for it in items} + prev_assign = { + iid: b for iid, b in prev_assign.items() if iid in item_ids and b in valid_bins + } + last_change_ts = {iid: last_change_ts.get(iid, 0.0) for iid in item_ids} - if bin_planner is not None: - try: - state = bin_planner.load_state(state_path) - except Exception: - state = {} - prev_assign = {} - last_change_ts = {} - if isinstance(state, dict): - prev_assign = state.get("assignments", {}) or {} - last_change_ts = state.get("last_change_ts", {}) or {} - item_ids = {str(it["id"]) for it in items} - prev_assign = { - iid: b for iid, b in prev_assign.items() if iid in item_ids and b in valid_bins - } - last_change_ts = {iid: last_change_ts.get(iid, 0.0) for iid in item_ids} + move_budget = max(1, min(32, int(0.01 * max(1, len(items))))) + if monitor_mode_only() == True: + move_budget = 0 - params = { - "candidate_set_size": 4, - "headroom": 0.05, - "alpha": 0.1, - "hysteresis_threshold": 0.03, - "cooldown_seconds": 3600, - "move_budget_per_run": max(1, min(32, int(0.01 * max(1, len(items))))), - "salt": state.get("salt", "default_salt") if isinstance(state, dict) else "default_salt", - "last_change_ts_by_item": last_change_ts, - } - if monitor_mode_only() == True: - params["move_budget_per_run"] = 0 - - try: - assignment, changed = bin_planner.plan_assignments( - items, bins_list, capacities, prev_assign, now_ts, params - ) - planner_used = True - except Exception as e: - warnings.warn( - f"Planner failed ({e}); falling back to greedy distribution.", - stacklevel=2, - ) - assignment, changed = greedy_assign(items, cpu_keys) - planner_used = False - else: - warnings.warn( - "Binpacking requested, but planner module is unavailable; using greedy distribution.", - stacklevel=2, - ) - assignment, changed = greedy_assign(items, cpu_keys) - planner_used = False - else: - # Insight is unavailable; use deterministic round-robin distribution. - assignment, changed = round_robin_assign(items, cpu_keys) + planner_mode = "stable_greedy" if insight_enabled else "round_robin" + try: + plan_result = plan_top_level_cpu_bins( + items, + queuesAvailable, + prev_assign=prev_assign, + last_change_ts=last_change_ts, + now_ts=now_ts, + mode=planner_mode, + move_budget_per_run=move_budget, + cooldown_seconds=3600.0, + hysteresis_threshold=0.03, + ) + assignment = dict(plan_result.get("assignment", {}) or {}) + changed = list(plan_result.get("changed", []) or []) + planner_used = bool(plan_result.get("planner_used", False)) + except Exception as e: + warnings.warn( + f"Shared Rust planner failed ({e}); falling back to deterministic local assignment.", + stacklevel=2, + ) + assignment = {} + names = sorted(str(it["id"]) for it in items) + if cpu_keys: + for idx, name in enumerate(names): + if insight_enabled: + assignment[name] = cpu_keys[idx % len(cpu_keys)] + else: + assignment[name] = cpu_keys[idx % len(cpu_keys)] + changed = list(assignment.keys()) planner_used = False - # Validate assignment covers all items and uses known bins (planner may return partial results) - try: - if not isinstance(assignment, dict): - assignment, changed = greedy_assign(items, cpu_keys) - except Exception: - assignment, changed = greedy_assign(items, cpu_keys) - try: - item_ids = {str(it["id"]) for it in items} - rr_fallback, _ = round_robin_assign(items, cpu_keys) - for iid in item_ids: - tgt = assignment.get(iid) - if tgt not in valid_bins: - assignment[iid] = rr_fallback.get( - iid, cpu_keys[-1] if cpu_keys else "CpueQueue0" - ) - except Exception: - pass - - # Sanity check: if planner returns a degenerate assignment (everything in one bin), - # fall back to a simple greedy distribution so the physical tree can spread. - if planner_used: - try: - # Exclude Generated_PN_* placeholders from the degeneracy test: they may not - # appear in the UI tree and can mask a collapsed real topology. - interesting_ids = { - str(it["id"]) - for it in items - if not str(it["id"]).startswith("Generated_PN_") - } - used = { - tgt - for iid, tgt in assignment.items() - if iid in interesting_ids and tgt in valid_bins - } - if queuesAvailable > 1 and len(interesting_ids) > 1 and len(used) <= 1: - warnings.warn( - "Planner produced degenerate CPU binning (single-bin result); falling back to greedy distribution.", - stacklevel=2, - ) - assignment, changed = greedy_assign(items, cpu_keys) - planner_used = False - except Exception: - pass - for x in range(queuesAvailable): key = "CpueQueue" + str(x) assigned = [name for name, tgt in assignment.items() if tgt == key] @@ -1522,7 +1325,7 @@ def refreshShapers(): network = binnedNetwork # Update and save state - if planner_used and bin_planner is not None and isinstance(state, dict): + if planner_used and isinstance(state, dict): if state.get("salt") is None: state["salt"] = "default_salt" if "assignments" not in state or not isinstance(state["assignments"], dict): @@ -1535,54 +1338,306 @@ def refreshShapers(): state["assignments"][iid] = b try: print(f"Saving planner state to {state_path} (top-level CPU binning)") - bin_planner.save_state(state_path, state) + save_planner_state(state, state_path, None) except Exception as e: warnings.warn( f"Failed to save planner state at {state_path}: {e}", stacklevel=2 ) - # Seed persisted circuit minor assignments (TTL already enforced in bin_planner.load_state()). + # Seed persisted site/circuit minor assignments. When planner state is absent, + # fall back to the previous queuing structure so the first run after an upgrade + # can preserve existing class IDs. try: state # noqa: B018 except NameError: state = {} - try: - import bin_planner # noqa: F401 - except ImportError: - bin_planner = None - if bin_planner is not None and (not isinstance(state, dict) or len(state.keys()) == 0): - try: - state_path = os.path.join(get_libreqos_directory(), "planner_state.json") - state = bin_planner.load_state(state_path) - except Exception: - state = {} + state_path = get_planner_state_path() + if not isinstance(state, dict) or len(state.keys()) == 0: + state = load_planner_state(state_path, None) try: circuit_state_from_disk = state.get("circuits", {}) if isinstance(state, dict) else {} except Exception: circuit_state_from_disk = {} + try: + site_state_from_disk = state.get("sites", {}) if isinstance(state, dict) else {} + except Exception: + site_state_from_disk = {} + if not isinstance(circuit_state_from_disk, dict): + circuit_state_from_disk = {} + if not isinstance(site_state_from_disk, dict): + site_state_from_disk = {} + if not circuit_state_from_disk or not site_state_from_disk: + fallback_minor_state = load_minor_state_from_queuing_structure() + if not site_state_from_disk: + site_state_from_disk = fallback_minor_state.get("sites", {}) or {} + if not circuit_state_from_disk: + circuit_state_from_disk = fallback_minor_state.get("circuits", {}) or {} circuit_state_updated = {} - used_minors_by_queue = {cpu + 1: set() for cpu in range(queuesAvailable)} + site_state_updated = {} + planner_site_inputs = [] + planner_circuit_groups = [] - # Here is the actual call to the recursive traverseNetwork() function. finalMinor is not used. - minorByCPU = traverseNetwork(network, 0, major=1, minorByCPU=minorByCPUpreloaded, queue=1, parentClassID=None, upParentClassID=None, parentMaxDL=upstream_bandwidth_capacity_download_mbps(), parentMaxUL=upstream_bandwidth_capacity_upload_mbps(), parentMinDL=upstream_bandwidth_capacity_download_mbps(), parentMinUL=upstream_bandwidth_capacity_upload_mbps()) + def collect_identity_planner_inputs(data, depth, queue, path=()): + for node in sorted_node_keys(data, depth): + current_queue = queue + node_path = path + (node,) + parent_path = '/'.join(path) + has_children = bool(data[node].get('children')) + planner_site_inputs.append( + { + "site_key": '/'.join(node_path), + "parent_path": parent_path, + "queue": current_queue, + "has_children": has_children, + } + ) + if node in circuits_by_parent_node: + sorted_circuits = sorted( + circuits_by_parent_node[node], + key=lambda c: c.get('circuitName', c.get('circuitID', '')), + ) + planner_circuit_groups.append( + { + "parent_node": node, + "queue": current_queue, + "circuit_ids": [ + planner_circuit_identity_key(circuit) + for circuit in sorted_circuits + if node == circuit['ParentNode'] + ], + } + ) - # Persist the updated circuit minor map for future stability - if 'bin_planner' not in locals(): - bin_planner = None - if bin_planner is not None and isinstance(state, dict): - if 'circuits' not in state or not isinstance(state.get('circuits'), dict): - state['circuits'] = {} - state['circuits'] = circuit_state_updated - try: - state_path = os.path.join(get_libreqos_directory(), "planner_state.json") - except Exception: - state_path = None - if state_path: - try: - print(f"Saving planner state to {state_path} (circuit minors)") - bin_planner.save_state(state_path, state) - except Exception as e: - warnings.warn(f"Failed to save planner circuit state at {state_path}: {e}", stacklevel=2) + if has_children: + sorted_children = dict(sorted(data[node]['children'].items())) + collect_identity_planner_inputs( + sorted_children, + depth + 1, + current_queue, + node_path, + ) + + if depth == 0: + if queue >= queuesAvailable: + queue = 1 + else: + queue += 1 + + collect_identity_planner_inputs(network, 0, 1) + + identity_plan = plan_class_identities( + planner_site_inputs, + planner_circuit_groups, + site_state=site_state_from_disk, + circuit_state=circuit_state_from_disk, + stick_offset=stickOffset, + circuit_padding=CIRCUIT_PADDING, + ) + site_assignment_by_key = { + entry["site_key"]: entry for entry in identity_plan.get("sites", []) + } + circuit_assignment_by_key = { + entry["circuit_id"]: entry for entry in identity_plan.get("circuits", []) + } + site_state_updated = identity_plan.get("site_state", {}) or {} + circuit_state_updated = identity_plan.get("circuit_state", {}) or {} + + def apply_site_assignments(data, depth, queue, parentClassID, upParentClassID, parentMaxDL, parentMaxUL, parentMinDL, parentMinUL, path=()): + for node in sorted_node_keys(data, depth): + current_queue = queue + node_path = path + (node,) + site_key = '/'.join(node_path) + assignment = site_assignment_by_key.get(site_key) + if assignment is None: + raise ValueError(f"Missing planned site identity for {site_key}") + assigned_site_minor = int(assignment["class_minor"]) + major = int(assignment["class_major"]) + up_major = int(assignment["up_class_major"]) + ensure_minor_capacity(current_queue, assigned_site_minor) + nodeClassID = hex(major) + ':' + hex(assigned_site_minor) + upNodeClassID = hex(up_major) + ':' + hex(assigned_site_minor) + data[node]['classid'] = nodeClassID + data[node]['up_classid'] = upNodeClassID + current_parent_classid = parentClassID + current_up_parent_classid = upParentClassID + if depth == 0: + current_parent_classid = hex(major) + ':' + current_up_parent_classid = hex(up_major) + ':' + data[node]['parentClassID'] = current_parent_classid + data[node]['up_parentClassID'] = current_up_parent_classid + if monitor_mode_only() == True: + data[node]['downloadBandwidthMbps'] = 100000 + data[node]['uploadBandwidthMbps'] = 100000 + data[node]['downloadBandwidthMbpsMin'] = 100000 + data[node]['uploadBandwidthMbpsMin'] = 100000 + else: + data[node]['downloadBandwidthMbps'] = min(data[node]['downloadBandwidthMbps'], parentMaxDL) + data[node]['uploadBandwidthMbps'] = min(data[node]['uploadBandwidthMbps'], parentMaxUL) + data[node]['downloadBandwidthMbpsMin'] = min(data[node]['downloadBandwidthMbpsMin'], data[node]['downloadBandwidthMbps'], parentMinDL) + data[node]['uploadBandwidthMbpsMin'] = min(data[node]['uploadBandwidthMbpsMin'], data[node]['uploadBandwidthMbps'], parentMinUL) + data[node]['classMajor'] = hex(major) + data[node]['up_classMajor'] = hex(up_major) + data[node]['classMinor'] = hex(assigned_site_minor) + data[node]['cpuNum'] = hex(current_queue-1) + data[node]['up_cpuNum'] = hex(current_queue-1+stickOffset) + parentNodes.append( + { + "parentNodeName": node, + "classID": nodeClassID, + "maxDownload": data[node]['downloadBandwidthMbps'], + "maxUpload": data[node]['uploadBandwidthMbps'], + } + ) + + if 'children' in data[node]: + sorted_children = dict(sorted(data[node]['children'].items())) + apply_site_assignments( + sorted_children, + depth+1, + current_queue, + nodeClassID, + upNodeClassID, + data[node]['downloadBandwidthMbps'], + data[node]['uploadBandwidthMbps'], + data[node]['downloadBandwidthMbpsMin'], + data[node]['uploadBandwidthMbpsMin'], + node_path, + ) + + if depth == 0: + if queue >= queuesAvailable: + queue = 1 + else: + queue += 1 + + apply_site_assignments( + network, + 0, + queue=1, + parentClassID=None, + upParentClassID=None, + parentMaxDL=upstream_bandwidth_capacity_download_mbps(), + parentMaxUL=upstream_bandwidth_capacity_upload_mbps(), + parentMinDL=upstream_bandwidth_capacity_download_mbps(), + parentMinUL=upstream_bandwidth_capacity_upload_mbps(), + ) + + def attach_circuits(data, depth, path=()): + for node in sorted_node_keys(data, depth): + node_data = data[node] + queue_token = _parse_int_token(node_data.get('cpuNum')) + major = _parse_int_token(node_data.get('classMajor')) + if queue_token is None or major is None: + continue + queue = queue_token + 1 + circuitsForThisNetworkNode = [] + if node in circuits_by_parent_node: + override_min_down = None + override_min_up = None + if monitor_mode_only() == False: + if (circuit_min_down_combined_by_parent_node[node] > node_data['downloadBandwidthMbpsMin']) or (circuit_min_up_combined_by_parent_node[node] > node_data['uploadBandwidthMbpsMin']): + override_min_down = 1 + override_min_up = 1 + logging.info("The combined minimums of circuits in Parent Node [" + node + "] exceeded that of the parent node. Reducing these circuits' minimums to 1 now.", stacklevel=2) + if ((override_min_down * len(circuits_by_parent_node[node])) > node_data['downloadBandwidthMbpsMin']) or ((override_min_up * len(circuits_by_parent_node[node])) > node_data['uploadBandwidthMbpsMin']): + logging.info("Even with this change, minimums will exceed the min rate of the parent node. Using 10 kbps as the minimum for these circuits instead.", stacklevel=2) + nodes_requiring_min_squashing[node] = True + sorted_circuits = sorted(circuits_by_parent_node[node], key=lambda c: c.get('circuitName', c.get('circuitID', ''))) + for circuit in sorted_circuits: + if node != circuit['ParentNode']: + continue + if monitor_mode_only() == False: + if circuit['maxDownload'] > node_data['downloadBandwidthMbps']: + logging.info("downloadMax of Circuit ID [" + circuit['circuitID'] + "] exceeded that of its parent node. Reducing to that of its parent node now.", stacklevel=2) + if circuit['maxUpload'] > node_data['uploadBandwidthMbps']: + logging.info("uploadMax of Circuit ID [" + circuit['circuitID'] + "] exceeded that of its parent node. Reducing to that of its parent node now.", stacklevel=2) + planner_key = planner_circuit_identity_key(circuit) + planned_identity = circuit_assignment_by_key.get(planner_key) + if planned_identity is None: + raise ValueError(f"Missing planned circuit identity for {planner_key}") + candidate_minor = int(planned_identity["class_minor"]) + major = int(planned_identity["class_major"]) + up_major = int(planned_identity["up_class_major"]) + ensure_minor_capacity(queue, candidate_minor) + flowIDstring = hex(major) + ':' + hex(candidate_minor) + upFlowIDstring = hex(up_major) + ':' + hex(candidate_minor) + circuit['classid'] = flowIDstring + circuit['up_classid'] = upFlowIDstring + logging.info("Added up_classid to circuit: " + circuit['up_classid']) + maxDownload = min(circuit['maxDownload'], node_data['downloadBandwidthMbps']) + maxUpload = min(circuit['maxUpload'], node_data['uploadBandwidthMbps']) + if override_min_down: + circuit['minDownload'] = 1 + if override_min_up: + circuit['minUpload'] = 1 + minDownload = min(circuit['minDownload'], maxDownload) + minUpload = min(circuit['minUpload'], maxUpload) + thisNewCircuitItemForNetwork = { + 'maxDownload': maxDownload, + 'maxUpload': maxUpload, + 'minDownload': minDownload, + 'minUpload': minUpload, + "circuitID": circuit['circuitID'], + "circuitName": circuit['circuitName'], + "ParentNode": circuit['ParentNode'], + "logicalParentNode": circuit.get('logicalParentNode', circuit['ParentNode']), + "devices": circuit['devices'], + "classid": flowIDstring, + "up_classid": upFlowIDstring, + "classMajor": hex(major), + "up_classMajor": hex(up_major), + "classMinor": hex(candidate_minor), + "comment": circuit['comment'], + } + try: + cid = str(circuit.get('circuitID', '')) + w = None + if cid in weight_by_circuit_id: + w = float(weight_by_circuit_id[cid]) + if w is None: + w = float(maxDownload) + if abs(w - 1000.0) < 1e-6: + w = float(maxDownload) + try: + default_rate = float(generated_pn_download_mbps()) + if abs(float(maxDownload) - default_rate) < 1e-6: + w = 0.0 + except Exception: + pass + thisNewCircuitItemForNetwork['planner_weight'] = w + except Exception: + pass + if 'sqm' in circuit and circuit['sqm']: + thisNewCircuitItemForNetwork['sqm'] = circuit['sqm'] + thisNewCircuitItemForNetwork['devices'] = circuit['devices'] + circuitsForThisNetworkNode.append(thisNewCircuitItemForNetwork) + + if len(circuitsForThisNetworkNode) > 0: + node_data['circuits'] = circuitsForThisNetworkNode + else: + node_data.pop('circuits', None) + + if 'children' in node_data: + sorted_children = dict(sorted(node_data['children'].items())) + attach_circuits(sorted_children, depth+1, path + (node,)) + + attach_circuits(network, 0) + minorByCPU = { + int(queue): int(minor) + for queue, minor in (identity_plan.get("last_used_minor_by_queue", {}) or {}).items() + } + for cpu in range(queuesAvailable): + minorByCPU.setdefault(cpu + 1, 3) + + if not isinstance(state, dict): + state = {} + state['circuits'] = circuit_state_updated + state['sites'] = site_state_updated + try: + print(f"Saving planner state to {state_path} (circuit/site minors)") + save_planner_state(state, state_path, None) + except Exception as e: + warnings.warn(f"Failed to save planner circuit state at {state_path}: {e}", stacklevel=2) bakery = Bakery() bakery.start_batch() # Initializes the bakery transaction @@ -1885,23 +1940,20 @@ def refreshShapers(): if not qdiscBudgetEstimate["ok"]: interfaceCounts = qdiscBudgetEstimate["interfaces"] sortedInterfaces = sorted(interfaceCounts.items()) - interfaceSummary = ", ".join( - f"{interface} estimated {count} qdiscs" - for interface, count in sortedInterfaces - ) + preflightSummary = qdiscBudgetEstimate.get("summary") or "" report_refresh_failure( "TC_QDISC_CAPACITY", - "Planned queue model exceeds qdisc budget. " - + interfaceSummary - + "; safe budget " - + str(qdiscBudgetEstimate["safe_budget"]) - + ", kernel limit " - + str(qdiscBudgetEstimate["hard_limit"]) - + ". Aborting refresh before apply.", + (preflightSummary + " Aborting refresh before apply.").strip(), { "interfaces": dict(sortedInterfaces), + "interface_details": qdiscBudgetEstimate.get("interface_details", {}), "safe_budget": qdiscBudgetEstimate["safe_budget"], "hard_limit": qdiscBudgetEstimate["hard_limit"], + "estimated_total_memory_bytes": qdiscBudgetEstimate.get("estimated_total_memory_bytes"), + "memory_ok": qdiscBudgetEstimate.get("memory_ok"), + "memory_guard_min_available_bytes": qdiscBudgetEstimate.get("memory_guard_min_available_bytes"), + "memory_total_bytes": qdiscBudgetEstimate.get("memory_total_bytes"), + "memory_available_bytes": qdiscBudgetEstimate.get("memory_available_bytes"), "on_a_stick": on_a_stick(), "monitor_only": monitor_mode_only(), "shaped_devices_file": shapedDevicesFile, @@ -2184,7 +2236,7 @@ if __name__ == '__main__': if getattr(args, 'planner_reset', False): try: - state_path = os.path.join(get_libreqos_directory(), "planner_state.json") + state_path = get_planner_state_path() if os.path.exists(state_path): os.remove(state_path) print(f"Removed planner state: {state_path}") diff --git a/src/bakery_integration_test.py b/src/bakery_integration_test.py index db8a481d..5c668bbc 100644 --- a/src/bakery_integration_test.py +++ b/src/bakery_integration_test.py @@ -646,6 +646,23 @@ def _hex_to_int(s: str) -> Optional[int]: return None +def _tc_id_token_to_int(tok: str) -> Optional[int]: + """Parse tc class/qdisc ID tokens. + + The live `tc` text output commonly emits bare hex tokens like `10f` rather than + `0x10f`. Treat bare tokens as hex so lookups line up with queuingStructure.json. + """ + s = tok.strip() + if not s: + return None + try: + if s.lower().startswith("0x"): + return int(s, 16) + return int(s, 16) + except Exception: + return None + + def _parse_tc_rate_to_mbps(token: str, unit: Optional[str]) -> float: try: val = float(token) @@ -675,24 +692,11 @@ def _read_htb_rate_ceil_mbps(iface: str, major: int, minor: int) -> Optional[Tup # Match the class line for this major/minor, accepting decimal or hex forms. cls_pat = re.compile(r"^class\s+htb\s+([0-9A-Fa-fx]+):([0-9A-Fa-fx]+)\b.*$", re.MULTILINE) - def _to_int(tok: str) -> Optional[int]: - s = tok.strip() - # Accept decimal, 0x-prefixed hex, or bare hex (e.g. "f") - try: - if s.lower().startswith("0x"): - return int(s, 16) - try: - return int(s) # decimal - except Exception: - return int(s, 16) # bare hex - except Exception: - return None - line = None for m in cls_pat.finditer(out): mj_tok, mn_tok = m.group(1), m.group(2) - mj = _to_int(mj_tok) - mn = _to_int(mn_tok) + mj = _tc_id_token_to_int(mj_tok) + mn = _tc_id_token_to_int(mn_tok) if mj == major and mn == minor: line = m.group(0) break @@ -760,6 +764,65 @@ def check_no_site_circuit_minor_collisions() -> Tuple[bool, List[str]]: return ok, msgs +def snapshot_site_class_assignments() -> Tuple[bool, str, Dict[str, Tuple[str, str, str, str]]]: + """Capture current site class assignment state from queuingStructure.json.""" + qs = _load_queuing_structure() + if not isinstance(qs, dict): + return False, "site snapshot: queuingStructure.json not found", {} + net = qs.get("Network") + if not isinstance(net, dict): + return False, "site snapshot: queuingStructure has no 'Network'", {} + + snapshot: Dict[str, Tuple[str, str, str, str]] = {} + + def walk(node_map: Dict[str, dict], trail: Tuple[str, ...] = ()) -> None: + for name, node in node_map.items(): + if not isinstance(node, dict): + continue + path = "/".join(trail + (name,)) + snapshot[path] = ( + str(node.get("classMinor", "")), + str(node.get("classid", "")), + str(node.get("parentClassID", "")), + str(node.get("cpuNum", "")), + ) + ch = node.get("children") + if isinstance(ch, dict): + walk(ch, trail + (name,)) + + walk(net) + return True, "", snapshot + + +def check_site_class_assignments_unchanged( + before: Dict[str, Tuple[str, str, str, str]] +) -> Tuple[bool, List[str]]: + """Verify that site class assignments did not drift between two snapshots.""" + ok, err, after = snapshot_site_class_assignments() + if not ok: + return False, [err] + + msgs: List[str] = [] + changed = [] + for path in sorted(set(before.keys()) | set(after.keys())): + if before.get(path) != after.get(path): + changed.append((path, before.get(path), after.get(path))) + + if changed: + for path, old_value, new_value in changed[:20]: + msgs.append( + f"site assignment stability: {path} changed {old_value} -> {new_value}" + ) + if len(changed) > 20: + msgs.append( + f"site assignment stability: additional changed sites omitted ({len(changed) - 20})" + ) + return False, msgs + + msgs.append("site assignment stability: all site class assignments unchanged") + return True, msgs + + # ----------------------- # IP mapping checks # ----------------------- @@ -780,8 +843,13 @@ def _list_ip_mappings() -> Tuple[bool, str, List[Tuple[str, int, str]]]: if "Socket" in out or "Socket" in err: return False, "ipmap check: lqosd bus socket not found; skipping", [] entries: List[Tuple[str, int, str]] = [] - # Lines look like: " CPU: TC: " - pat = re.compile(r"^\s*([0-9A-Fa-f:.]+)/\d+\s+CPU:\s+(\d+)\s+TC:\s+([0-9A-Fa-f]+:[0-9A-Fa-f]+)\s*$") + # Lines look like: + # " CPU: TC: " + # or newer output with trailing identity fields: + # " CPU: TC: CIRCUIT: DEVICE: " + pat = re.compile( + r"^\s*([0-9A-Fa-f:.]+)/\d+\s+CPU:\s+(\d+)\s+TC:\s+([0-9A-Fa-f]+:[0-9A-Fa-f]+)(?:\s+.*)?$" + ) for line in out.splitlines(): m = pat.match(line) if m: @@ -1588,24 +1656,11 @@ def run_tiered_suite(log: LogReader, timeout_s: float, results: List[str]) -> bo else: pat = re.compile(r"^qdisc\s+\S+\s+\S+:\s+parent\s+([0-9A-Fa-fx]+):([0-9A-Fa-fx]+)\b.*$", re.MULTILINE) - def _to_int(tok: str) -> Optional[int]: - s = tok.strip() - # Accept decimal, 0x-prefixed hex, or bare hex (e.g. "f") - try: - if s.lower().startswith("0x"): - return int(s, 16) - try: - return int(s) # decimal - except Exception: - return int(s, 16) # bare hex - except Exception: - return None - line = None for m in pat.finditer(out): mj_tok, mn_tok = m.group(1), m.group(2) - mj = _to_int(mj_tok) - mn = _to_int(mn_tok) + mj = _tc_id_token_to_int(mj_tok) + mn = _tc_id_token_to_int(mn_tok) if mj == maj and mn == mnr: line = m.group(0) break @@ -1630,24 +1685,11 @@ def run_tiered_suite(log: LogReader, timeout_s: float, results: List[str]) -> bo else: pat2 = re.compile(r"^qdisc\s+\S+\s+\S+:\s+parent\s+([0-9A-Fa-fx]+):([0-9A-Fa-fx]+)\b.*$", re.MULTILINE) - def _to_int2(tok: str) -> Optional[int]: - s = tok.strip() - # Accept decimal, 0x-prefixed hex, or bare hex (e.g. "f") - try: - if s.lower().startswith("0x"): - return int(s, 16) - try: - return int(s) # decimal - except Exception: - return int(s, 16) # bare hex - except Exception: - return None - line2 = None for m2 in pat2.finditer(out2): mj_tok, mn_tok = m2.group(1), m2.group(2) - mj = _to_int2(mj_tok) - mn = _to_int2(mn_tok) + mj = _tc_id_token_to_int(mj_tok) + mn = _tc_id_token_to_int(mn_tok) if mj == upm and mn == mnr: line2 = m2.group(0) break @@ -1861,9 +1903,42 @@ def run_realistic_tiered_suite(log: LogReader, timeout_s: float, results: List[s results.extend(msgs_map) ok &= passed_map + passed_site_snapshot, site_snapshot_msg, site_snapshot = snapshot_site_class_assignments() + if not passed_site_snapshot: + results.append(site_snapshot_msg) + ok = False + + # Circuit parent-node move between existing nodes (should not trigger full reload) + _mark_step("realistic: circuit parent move") + rows_parent = json.loads(json.dumps(rows2)) + old_parent = str(rows_parent[target_idx]["Parent Node"]) + new_parent = "NET1-1-2" if old_parent != "NET1-1-2" else "NET1-2" + rows_parent[target_idx]["Parent Node"] = new_parent + write_circuits(rows_parent) + step_t0 = time.time() + res = run_refresh_and_wait(log, timeout_s) + passed, msg = assert_no_full_reload("realistic: circuit parent move", res, step_t0) + results.append(msg) + ok &= passed + + _mark_step("realistic: site class assignment stability after parent move") + passed_sites_stable, msgs_sites_stable = check_site_class_assignments_unchanged(site_snapshot) + results.extend(msgs_sites_stable) + ok &= passed_sites_stable + + _mark_step("realistic: parent-moved circuit direction mapping (ceil)") + passed_parent_dir, msgs_parent_dir = check_circuit_direction_ceil(target_id) + results.extend(msgs_parent_dir) + ok &= passed_parent_dir + + _mark_step("realistic: parent-moved circuit ip mappings") + passed_parent_map, msgs_parent_map = check_ip_mappings_for_circuit(target_id) + results.extend(msgs_parent_map) + ok &= passed_parent_map + # Add circuit under a different leaf (NET3-2) _mark_step("realistic: add circuit") - rows3 = json.loads(json.dumps(rows2)) + rows3 = json.loads(json.dumps(rows_parent)) new_circuit_id = "99901" new_circuit_ip = "100.64.200.1" rows3.append({ diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 398bc331..7ed3500d 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -182,6 +182,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -207,9 +219,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -217,9 +229,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -315,6 +327,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -356,6 +374,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1027,6 +1054,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1462,6 +1490,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -2018,6 +2055,7 @@ dependencies = [ "allocative", "allocative_derive", "arc-swap", + "argon2", "csv", "encoding_rs", "ip_network", @@ -2025,6 +2063,7 @@ dependencies = [ "lqos_utils", "nix", "once_cell", + "rand_core 0.6.4", "serde", "serde_json", "sha2", @@ -2204,6 +2243,7 @@ dependencies = [ "arc-swap", "axum", "axum-extra", + "base64", "bincode", "crossbeam-channel", "crossbeam-queue", @@ -2213,6 +2253,7 @@ dependencies = [ "flate2", "futures-util", "fxhash", + "hmac", "ip_network", "ip_network_table", "itertools 0.13.0", @@ -2235,6 +2276,7 @@ dependencies = [ "serde", "serde_cbor", "serde_json", + "sha2", "sha256", "signal-hook", "smallvec", @@ -2275,7 +2317,9 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "lqos_bus", "lqos_config", + "tokio", ] [[package]] @@ -2744,6 +2788,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3238,9 +3293,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "log", @@ -3302,9 +3357,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 895e4bad..d0a1a253 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -54,6 +54,10 @@ csv = "1" ip_network_table = "0" ip_network = "0" sha2 = "0" +hmac = "0.12" +base64 = "0.22" +argon2 = "0.5" +rand_core = { version = "0.6", features = ["getrandom"] } uuid = { version = "1", features = ["v4", "fast-rng" ] } dashmap = "^5.5.3" toml = "0.8.8" diff --git a/src/rust/lqos_bakery/src/commands.rs b/src/rust/lqos_bakery/src/commands.rs index 3783aa5c..2700d7a7 100644 --- a/src/rust/lqos_bakery/src/commands.rs +++ b/src/rust/lqos_bakery/src/commands.rs @@ -8,6 +8,7 @@ use lqos_bus::TcHandle; use lqos_config::LazyQueueMode; use std::collections::HashSet; use std::sync::Arc; +use std::sync::mpsc::Sender as ReplySender; use tracing::{debug, info}; #[derive(Debug, Clone, Copy, Allocative)] @@ -172,6 +173,16 @@ pub enum BakeryCommands { /// New class ceiling rate in Mbps (the handler sets ceil and rate-1). new_rate: u64, }, + /// Runtime TreeGuard request to virtualize or restore a non-top-level site without a full reload. + TreeGuardSetNodeVirtual { + /// Stable Bakery site hash derived from the node name. + site_hash: i64, + /// Whether the site should be virtualized (`true`) or restored (`false`). + virtualized: bool, + /// Optional synchronous reply channel for immediate success/failure reporting. + #[allocative(skip)] + reply: Option>>, + }, } impl BakeryCommands { @@ -266,9 +277,8 @@ impl BakeryCommands { stick_offset: usize, ) -> Option>> { let mut result = Vec::new(); - info!("Clearing prior settings"); + info!("Clearing prior root qdiscs before MQ setup"); if config.on_a_stick_mode() { - // Clear just the MQ on the ISP-facing interface result.push(vec![ "qdisc".to_string(), "del".to_string(), @@ -962,3 +972,88 @@ impl BakeryCommands { Some(result) } } + +#[cfg(test)] +mod tests { + use super::{BakeryCommands, ExecutionMode}; + use lqos_config::{Config, SingleInterfaceConfig}; + use std::sync::Arc; + + fn is_root_delete(cmd: &[String], interface: &str) -> bool { + cmd.len() == 5 + && cmd[0] == "qdisc" + && cmd[1] == "del" + && cmd[2] == "dev" + && cmd[3] == interface + && cmd[4] == "root" + } + + fn is_root_replace_mq(cmd: &[String], interface: &str) -> bool { + cmd.len() == 8 + && cmd[0] == "qdisc" + && cmd[1] == "replace" + && cmd[2] == "dev" + && cmd[3] == interface + && cmd[4] == "root" + && cmd[5] == "handle" + && cmd[6] == "7FFF:" + && cmd[7] == "mq" + } + + #[test] + fn mq_setup_bridge_mode_emits_root_delete_before_replace() { + let config = Arc::new(Config::default()); + let commands = BakeryCommands::MqSetup { + queues_available: 1, + stick_offset: 0, + } + .to_commands(&config, ExecutionMode::Builder) + .expect("mq setup should emit commands"); + + assert!(is_root_delete(&commands[0], &config.isp_interface())); + assert!(is_root_delete(&commands[1], &config.internet_interface())); + assert!(is_root_replace_mq(&commands[2], &config.isp_interface())); + let internet_replace_idx = commands + .iter() + .position(|cmd| is_root_replace_mq(cmd, &config.internet_interface())) + .expect("expected internet-interface root replace"); + assert!( + internet_replace_idx > 1, + "internet-interface root replace should occur after both deletes" + ); + } + + #[test] + fn mq_setup_on_a_stick_emits_single_root_delete_before_replace() { + let mut cfg = Config::default(); + cfg.bridge = None; + cfg.single_interface = Some(SingleInterfaceConfig::default()); + let config = Arc::new(cfg); + + let commands = BakeryCommands::MqSetup { + queues_available: 1, + stick_offset: 3, + } + .to_commands(&config, ExecutionMode::Builder) + .expect("mq setup should emit commands"); + + assert!(is_root_delete(&commands[0], &config.isp_interface())); + assert!(is_root_replace_mq(&commands[1], &config.isp_interface())); + assert_eq!( + commands + .iter() + .filter(|cmd| is_root_delete(cmd, &config.isp_interface())) + .count(), + 1, + "expected a single root delete on the shared interface" + ); + assert_eq!( + commands + .iter() + .filter(|cmd| is_root_replace_mq(cmd, &config.isp_interface())) + .count(), + 1, + "expected a single root replace on the shared interface" + ); + } +} diff --git a/src/rust/lqos_bakery/src/diff/circuits.rs b/src/rust/lqos_bakery/src/diff/circuits.rs index 272152f5..c9fdfe1d 100644 --- a/src/rust/lqos_bakery/src/diff/circuits.rs +++ b/src/rust/lqos_bakery/src/diff/circuits.rs @@ -8,6 +8,7 @@ pub(crate) struct CircuitDiffCategories<'a> { pub removed_circuits: Vec, pub speed_changed: Vec<&'a Arc>, pub ip_changed: Vec<&'a Arc>, + pub migrated: Vec<&'a Arc>, pub structural_changed: Vec<&'a Arc>, } @@ -35,7 +36,8 @@ pub(crate) fn diff_circuits<'a>( let mut removed_circuits = Vec::new(); let mut speed_changed = Vec::new(); let mut ip_changed = Vec::new(); - let mut structural_changed = Vec::new(); + let mut migrated = Vec::new(); + let structural_changed = Vec::new(); for (circuit_hash, new_cmd) in &new_circuits { if !old_circuits.contains_key(circuit_hash) { @@ -53,7 +55,7 @@ pub(crate) fn diff_circuits<'a>( if let Some(new_cmd) = new_circuits.get(circuit_hash) { match classify_circuit_change(old_cmd.as_ref(), new_cmd.as_ref()) { CircuitChange::None => {} - CircuitChange::Structural => structural_changed.push(*new_cmd), + CircuitChange::Migration => migrated.push(*new_cmd), CircuitChange::Speed => speed_changed.push(*new_cmd), CircuitChange::Ip => ip_changed.push(*new_cmd), CircuitChange::SpeedAndIp => { @@ -68,6 +70,7 @@ pub(crate) fn diff_circuits<'a>( || !removed_circuits.is_empty() || !speed_changed.is_empty() || !ip_changed.is_empty() + || !migrated.is_empty() || !structural_changed.is_empty() { CircuitDiffResult::Categorized(CircuitDiffCategories { @@ -75,6 +78,7 @@ pub(crate) fn diff_circuits<'a>( removed_circuits, speed_changed, ip_changed, + migrated, structural_changed, }) } else { @@ -84,7 +88,7 @@ pub(crate) fn diff_circuits<'a>( enum CircuitChange { None, - Structural, + Migration, Speed, Ip, SpeedAndIp, @@ -141,7 +145,7 @@ fn classify_circuit_change(a: &BakeryCommands, b: &BakeryCommands) -> CircuitCha || up_class_major != other_up_class_major; if structural { - return CircuitChange::Structural; + return CircuitChange::Migration; } let speed = download_bandwidth_min != other_download_bandwidth_min @@ -159,3 +163,128 @@ fn classify_circuit_change(a: &BakeryCommands, b: &BakeryCommands) -> CircuitCha (true, true) => CircuitChange::SpeedAndIp, } } + +#[cfg(test)] +mod tests { + use super::{CircuitDiffResult, diff_circuits}; + use crate::BakeryCommands; + use lqos_bus::TcHandle; + use std::collections::HashMap; + use std::sync::Arc; + + fn add_circuit( + circuit_hash: i64, + parent_class_id: u32, + up_parent_class_id: u32, + class_minor: u16, + class_major: u16, + up_class_major: u16, + dl_min: f32, + ul_min: f32, + dl_max: f32, + ul_max: f32, + ) -> Arc { + Arc::new(BakeryCommands::AddCircuit { + circuit_hash, + parent_class_id: TcHandle::from_u32(parent_class_id), + up_parent_class_id: TcHandle::from_u32(up_parent_class_id), + class_minor, + download_bandwidth_min: dl_min, + upload_bandwidth_min: ul_min, + download_bandwidth_max: dl_max, + upload_bandwidth_max: ul_max, + class_major, + up_class_major, + down_qdisc_handle: Some(0x9000), + up_qdisc_handle: Some(0x9001), + ip_addresses: "192.0.2.1/32".to_string(), + sqm_override: None, + }) + } + + #[test] + fn parent_move_is_categorized_as_migration() { + let old = add_circuit(1, 0x10020, 0x20020, 0x21, 0x1, 0x2, 1.0, 1.0, 10.0, 10.0); + let new = add_circuit(1, 0x10034, 0x20034, 0x35, 0x1, 0x2, 1.0, 1.0, 10.0, 10.0); + let old_circuits = HashMap::from([(1, Arc::clone(&old))]); + let batch = vec![new]; + + let CircuitDiffResult::Categorized(categories) = diff_circuits(&batch, &old_circuits) + else { + panic!("expected categorized diff"); + }; + + assert_eq!(categories.migrated.len(), 1); + assert!(categories.structural_changed.is_empty()); + assert!(categories.speed_changed.is_empty()); + } + + #[test] + fn rate_change_stays_speed_only() { + let old = add_circuit(1, 0x10020, 0x20020, 0x21, 0x1, 0x2, 1.0, 1.0, 10.0, 10.0); + let new = add_circuit(1, 0x10020, 0x20020, 0x21, 0x1, 0x2, 2.0, 1.5, 20.0, 15.0); + let old_circuits = HashMap::from([(1, Arc::clone(&old))]); + let batch = vec![new]; + + let CircuitDiffResult::Categorized(categories) = diff_circuits(&batch, &old_circuits) + else { + panic!("expected categorized diff"); + }; + + assert_eq!(categories.speed_changed.len(), 1); + assert!(categories.migrated.is_empty()); + assert!(categories.structural_changed.is_empty()); + } + + #[test] + fn ip_change_stays_ip_only() { + let old = add_circuit(1, 0x10020, 0x20020, 0x21, 0x1, 0x2, 1.0, 1.0, 10.0, 10.0); + let BakeryCommands::AddCircuit { + circuit_hash, + parent_class_id, + up_parent_class_id, + class_minor, + download_bandwidth_min, + upload_bandwidth_min, + download_bandwidth_max, + upload_bandwidth_max, + class_major, + up_class_major, + down_qdisc_handle, + up_qdisc_handle, + sqm_override, + .. + } = old.as_ref() + else { + panic!("expected add circuit"); + }; + let new = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: *circuit_hash, + parent_class_id: *parent_class_id, + up_parent_class_id: *up_parent_class_id, + class_minor: *class_minor, + download_bandwidth_min: *download_bandwidth_min, + upload_bandwidth_min: *upload_bandwidth_min, + download_bandwidth_max: *download_bandwidth_max, + upload_bandwidth_max: *upload_bandwidth_max, + class_major: *class_major, + up_class_major: *up_class_major, + down_qdisc_handle: *down_qdisc_handle, + up_qdisc_handle: *up_qdisc_handle, + ip_addresses: "198.51.100.10/32".to_string(), + sqm_override: sqm_override.clone(), + }); + let old_circuits = HashMap::from([(1, Arc::clone(&old))]); + let batch = vec![new]; + + let CircuitDiffResult::Categorized(categories) = diff_circuits(&batch, &old_circuits) + else { + panic!("expected categorized diff"); + }; + + assert_eq!(categories.ip_changed.len(), 1); + assert!(categories.speed_changed.is_empty()); + assert!(categories.migrated.is_empty()); + assert!(categories.structural_changed.is_empty()); + } +} diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index 198dc0bd..cebd7c0e 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -28,7 +28,7 @@ mod utils; use crossbeam_channel::{Receiver, Sender}; use parking_lot::RwLock; use std::collections::VecDeque; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::Path; use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; @@ -40,13 +40,20 @@ use crate::commands::ExecutionMode; use crate::diff::{CircuitDiffResult, SiteDiffResult, diff_circuits, diff_sites}; use crate::qdisc_handles::QdiscHandleState; use crate::queue_math::{SqmKind, effective_sqm_kind, format_rate_for_tc_f32}; -use crate::utils::{ExecuteResult, execute_in_memory, write_command_file}; +use crate::utils::{ + ExecuteResult, MemorySnapshot, execute_in_memory, execute_in_memory_chunked, + read_live_qdisc_handle_majors, read_memory_snapshot, write_command_file, +}; pub use commands::BakeryCommands; use lqos_bus::{ BusRequest, BusResponse, InsightLicenseSummary, LibreqosBusClient, TcHandle, UrgentSeverity, UrgentSource, }; -use lqos_config::{Config, LazyQueueMode}; +use lqos_config::{ + CircuitIdentityGroupInput, Config, LazyQueueMode, PlannerCircuitIdentityState, + PlannerSiteIdentityState, SiteIdentityInput, TopLevelPlannerItem, TopLevelPlannerMode, + TopLevelPlannerParams, plan_class_identities, plan_top_level_assignments, +}; use qdisc_handles::MqDeviceLayout; // ---------------------- Live-Move Types and Helpers (module scope) ---------------------- @@ -63,7 +70,12 @@ enum MigrationStage { #[derive(Clone, Debug)] struct Migration { circuit_hash: i64, - // Parent handles and majors + // Old parent handles and majors + old_parent_class_id: TcHandle, + old_up_parent_class_id: TcHandle, + old_class_major: u16, + old_up_class_major: u16, + // New parent handles and majors parent_class_id: TcHandle, up_parent_class_id: TcHandle, class_major: u16, @@ -96,6 +108,15 @@ struct StormguardOverrideKey { class: TcHandle, } +#[derive(Clone, Debug)] +struct VirtualizedSiteState { + site: Arc, + saved_sites: HashMap>, + saved_circuits: HashMap>, + active_sites: HashMap>, + active_circuits: HashMap>, +} + fn parse_ip_list(s: &str) -> Vec { s.split(',') .map(|x| x.trim().to_string()) @@ -206,6 +227,91 @@ fn build_temp_add_cmd( } } +fn queue_live_migration( + old_cmd: &BakeryCommands, + new_cmd: &Arc, + circuits: &mut HashMap>, + live_circuits: &HashMap, + migrations: &mut HashMap, +) -> bool { + let BakeryCommands::AddCircuit { + circuit_hash, + parent_class_id, + up_parent_class_id, + class_minor, + download_bandwidth_min, + upload_bandwidth_min, + download_bandwidth_max, + upload_bandwidth_max, + class_major, + up_class_major, + down_qdisc_handle, + up_qdisc_handle, + ip_addresses, + sqm_override, + .. + } = new_cmd.as_ref() + else { + return false; + }; + + let Some(_) = live_circuits.get(circuit_hash) else { + return false; + }; + + let BakeryCommands::AddCircuit { + parent_class_id: old_parent_class_id, + up_parent_class_id: old_up_parent_class_id, + class_minor: old_minor, + download_bandwidth_min: old_down_min, + upload_bandwidth_min: old_up_min, + download_bandwidth_max: old_down_max, + upload_bandwidth_max: old_up_max, + class_major: old_class_major, + up_class_major: old_up_class_major, + .. + } = old_cmd + else { + return false; + }; + + let Some(shadow_minor) = find_free_minor(circuits, parent_class_id, up_parent_class_id) else { + return false; + }; + + let mig = Migration { + circuit_hash: *circuit_hash, + old_parent_class_id: *old_parent_class_id, + old_up_parent_class_id: *old_up_parent_class_id, + old_class_major: *old_class_major, + old_up_class_major: *old_up_class_major, + parent_class_id: *parent_class_id, + up_parent_class_id: *up_parent_class_id, + class_major: *class_major, + up_class_major: *up_class_major, + down_qdisc_handle: *down_qdisc_handle, + up_qdisc_handle: *up_qdisc_handle, + old_down_min: *old_down_min, + old_down_max: *old_down_max, + old_up_min: *old_up_min, + old_up_max: *old_up_max, + new_down_min: *download_bandwidth_min, + new_down_max: *download_bandwidth_max, + new_up_min: *upload_bandwidth_min, + new_up_max: *upload_bandwidth_max, + old_minor: *old_minor, + shadow_minor, + final_minor: *class_minor, + ips: parse_ip_list(ip_addresses), + sqm_override: sqm_override.clone(), + stage: MigrationStage::PrepareShadow, + }; + + migrations.insert(*circuit_hash, mig); + circuits.insert(*circuit_hash, Arc::clone(new_cmd)); + true +} + /// Count of Bakery-Managed circuits that are currently active. pub static ACTIVE_CIRCUITS: AtomicUsize = AtomicUsize::new(0); /// True while Bakery is applying a full reload batch to `tc`. @@ -214,6 +320,24 @@ static FULL_RELOAD_IN_PROGRESS: AtomicBool = AtomicBool::new(false); pub const HARD_QDISC_HANDLE_LIMIT_PER_INTERFACE: usize = 65_534; /// Conservative operational limit used to fail a full reload before qdisc handle exhaustion. pub const SAFE_QDISC_BUDGET_PER_INTERFACE: usize = 65_000; +/// Conservative estimated kernel-memory cost of a non-leaf infrastructure qdisc. +/// +/// These weights are not kernel ABI sizes. They are deliberately conservative +/// safety estimates used to block or stop obviously risky full reloads before +/// the host reaches OOM pressure. +pub const INFRA_QDISC_ESTIMATED_MEMORY_BYTES: u64 = 16 * 1024; +/// Conservative estimated kernel-memory cost of an `fq_codel` leaf qdisc. +/// +/// This remains intentionally lower than CAKE, but is biased high enough to +/// prefer false-positive safety blocks over OOM risk on large reloads. +pub const FQ_CODEL_QDISC_ESTIMATED_MEMORY_BYTES: u64 = 64 * 1024; +/// Conservative estimated kernel-memory cost of a `cake` leaf qdisc. +/// +/// Tuned upward after live production capture showed summed CAKE runtime memory +/// substantially exceeding the earlier heuristic during busy periods. +pub const CAKE_QDISC_ESTIMATED_MEMORY_BYTES: u64 = 512 * 1024; +/// Minimum memory headroom Bakery tries to leave unused after a projected or in-flight apply. +pub const BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES: u64 = 768 * 1024 * 1024; /// Maximum number of mapped circuits allowed without Insight. const DEFAULT_MAPPED_CIRCUITS_LIMIT: usize = 1000; /// Minimum interval between repeated mapped-circuit-limit urgent issues. @@ -221,6 +345,7 @@ const CIRCUIT_LIMIT_URGENT_INTERVAL_SECONDS: u64 = 30 * 60; /// Last timestamp at which we emitted a mapped-circuit-limit urgent issue. static LAST_CIRCUIT_LIMIT_URGENT_TS: AtomicU64 = AtomicU64::new(0); const BAKERY_EVENT_LIMIT: usize = 50; +const FULL_RELOAD_TC_CHUNK_SIZE: usize = 2_500; /// High-level Bakery execution mode for operator-facing status. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -251,6 +376,14 @@ pub struct BakeryCapacityInterfaceSnapshot { pub name: String, /// Planned qdisc count for that interface. pub planned_qdiscs: usize, + /// Planned infrastructure qdiscs (`mq`, `htb`, etc.) for that interface. + pub infra_qdiscs: usize, + /// Planned `cake` leaf qdiscs for that interface. + pub cake_qdiscs: usize, + /// Planned `fq_codel` leaf qdiscs for that interface. + pub fq_codel_qdiscs: usize, + /// Estimated kernel memory cost for that interface's planned qdiscs. + pub estimated_memory_bytes: u64, } /// Last qdisc preflight result retained for Bakery UI/status. @@ -264,6 +397,14 @@ pub struct BakeryPreflightSnapshot { pub safe_budget: usize, /// Hard kernel limit used for reference. pub hard_limit: usize, + /// Estimated total kernel memory cost of the planned qdisc model. + pub estimated_total_memory_bytes: u64, + /// Current host memory available during the preflight, if known. + pub memory_available_bytes: Option, + /// Memory floor that must remain available before the apply proceeds. + pub memory_guard_min_available_bytes: u64, + /// Whether the memory preflight passed. + pub memory_ok: bool, /// Per-interface planned qdisc counts. pub interfaces: Vec, } @@ -275,6 +416,16 @@ pub struct BakeryStatusSnapshot { pub mode: BakeryMode, /// Unix timestamp when the current action started, if any. pub current_action_started_unix: Option, + /// Current apply phase for the in-flight action, if any. + pub current_apply_phase: Option, + /// Total `tc` commands planned for the in-flight action. + pub current_apply_total_tc_commands: usize, + /// `tc` commands already completed for the in-flight action. + pub current_apply_completed_tc_commands: usize, + /// Total apply chunks planned for the in-flight action. + pub current_apply_total_chunks: usize, + /// Apply chunks already completed for the in-flight action. + pub current_apply_completed_chunks: usize, /// Currently active circuit count. pub active_circuits: usize, /// Unix timestamp of the last successful apply, if any. @@ -316,6 +467,11 @@ pub struct BakeryActivityEntry { struct BakeryTelemetryState { mode: BakeryMode, current_action_started_unix: Option, + current_apply_phase: Option, + current_apply_total_tc_commands: usize, + current_apply_completed_tc_commands: usize, + current_apply_total_chunks: usize, + current_apply_completed_chunks: usize, last_success_unix: Option, last_failure_unix: Option, last_failure_summary: Option, @@ -346,6 +502,11 @@ impl Default for BakeryTelemetryState { Self { mode: BakeryMode::Idle, current_action_started_unix: None, + current_apply_phase: None, + current_apply_total_tc_commands: 0, + current_apply_completed_tc_commands: 0, + current_apply_total_chunks: 0, + current_apply_completed_chunks: 0, last_success_unix: None, last_failure_unix: None, last_failure_summary: None, @@ -376,6 +537,7 @@ impl Drop for FullReloadScope { fn drop(&mut self) { FULL_RELOAD_IN_PROGRESS.store(false, Ordering::Relaxed); let mut state = telemetry_state().write(); + clear_bakery_apply_progress(&mut state); if state.mode == BakeryMode::ApplyingFullReload { state.mode = BakeryMode::Idle; state.current_action_started_unix = None; @@ -417,16 +579,40 @@ fn push_bakery_event(event: &str, status: &str, summary: String) { } } +fn clear_bakery_apply_progress(state: &mut BakeryTelemetryState) { + state.current_apply_phase = None; + state.current_apply_total_tc_commands = 0; + state.current_apply_completed_tc_commands = 0; + state.current_apply_total_chunks = 0; + state.current_apply_completed_chunks = 0; +} + fn mark_bakery_action_started(mode: BakeryMode, event: &str, summary: String) { let ts = current_timestamp(); { let mut state = telemetry_state().write(); state.mode = mode; state.current_action_started_unix = Some(ts); + clear_bakery_apply_progress(&mut state); } push_bakery_event(event, "info", summary); } +fn update_bakery_apply_progress( + phase: Option<&str>, + total_tc_commands: usize, + completed_tc_commands: usize, + total_chunks: usize, + completed_chunks: usize, +) { + let mut state = telemetry_state().write(); + state.current_apply_phase = phase.map(str::to_string); + state.current_apply_total_tc_commands = total_tc_commands; + state.current_apply_completed_tc_commands = completed_tc_commands; + state.current_apply_total_chunks = total_chunks; + state.current_apply_completed_chunks = completed_chunks; +} + fn mark_bakery_action_finished(metrics: BakeryApplyMetrics<'_>) { let ts = current_timestamp(); { @@ -443,6 +629,7 @@ fn mark_bakery_action_finished(metrics: BakeryApplyMetrics<'_>) { state.last_failure_unix = Some(ts); state.last_failure_summary = Some(metrics.summary.to_string()); } + clear_bakery_apply_progress(&mut state); if state.mode != BakeryMode::ApplyingFullReload { state.mode = BakeryMode::Idle; state.current_action_started_unix = None; @@ -465,6 +652,11 @@ pub fn bakery_status_snapshot() -> BakeryStatusSnapshot { BakeryStatusSnapshot { mode: state.mode, current_action_started_unix: state.current_action_started_unix, + current_apply_phase: state.current_apply_phase, + current_apply_total_tc_commands: state.current_apply_total_tc_commands, + current_apply_completed_tc_commands: state.current_apply_completed_tc_commands, + current_apply_total_chunks: state.current_apply_total_chunks, + current_apply_completed_chunks: state.current_apply_completed_chunks, active_circuits: ACTIVE_CIRCUITS.load(Ordering::Relaxed), last_success_unix: state.last_success_unix, last_failure_unix: state.last_failure_unix, @@ -513,10 +705,7 @@ fn summarize_apply_result(purpose: &str, result: &ExecuteResult) -> String { } } -fn execute_and_record_live_change( - command_buffer: &Vec>, - purpose: &str, -) -> ExecuteResult { +fn execute_and_record_live_change(command_buffer: &[Vec], purpose: &str) -> ExecuteResult { let (total, class_commands, qdisc_commands) = count_tc_command_types(command_buffer); mark_bakery_action_started( BakeryMode::ApplyingLiveChange, @@ -540,13 +729,36 @@ fn execute_and_record_live_change( /// Estimated qdisc-handle usage for a planned full reload. #[derive(Debug, Clone, PartialEq, Eq)] +pub struct QdiscInterfaceEstimate { + /// Total planned qdiscs for the interface. + pub planned_qdiscs: usize, + /// Planned infrastructure qdiscs (`mq`, `htb`, etc.) for the interface. + pub infra_qdiscs: usize, + /// Planned CAKE leaf qdiscs for the interface. + pub cake_qdiscs: usize, + /// Planned fq_codel leaf qdiscs for the interface. + pub fq_codel_qdiscs: usize, + /// Estimated kernel memory cost for the interface's planned qdiscs. + pub estimated_memory_bytes: u64, +} + +/// Estimated qdisc budget and conservative memory-safety forecast for a planned full reload. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct QdiscBudgetEstimate { /// Estimated total qdisc count grouped by network interface name. pub interfaces: BTreeMap, + /// Detailed per-interface qdisc breakdown. + pub interface_details: BTreeMap, /// Conservative operational limit enforced before a full reload is committed. pub safe_budget: usize, /// Kernel hard limit for the per-device qdisc-handle namespace. pub hard_limit: usize, + /// Estimated total kernel memory cost of the planned qdisc model. + pub estimated_total_memory_bytes: u64, + /// Current host memory snapshot used for preflight, if available. + pub memory_snapshot: Option, + /// Whether the memory preflight passed. + pub memory_ok: bool, } impl QdiscBudgetEstimate { @@ -555,6 +767,7 @@ impl QdiscBudgetEstimate { self.interfaces .values() .all(|count| *count <= self.safe_budget) + && self.memory_ok } } @@ -563,6 +776,57 @@ fn find_arg_value<'a>(argv: &'a [String], key: &str) -> Option<&'a str> { .find_map(|pair| (pair[0] == key).then_some(pair[1].as_str())) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PlannedQdiscKind { + Infra, + Cake, + FqCodel, +} + +fn planned_qdisc_kind(argv: &[String]) -> Option { + if argv.len() < 2 || argv[0] != "qdisc" { + return None; + } + if !matches!(argv[1].as_str(), "add" | "replace") { + return None; + } + + if argv.iter().any(|arg| arg == "cake") { + return Some(if planned_qdisc_is_leaf(argv) { + PlannedQdiscKind::Cake + } else { + PlannedQdiscKind::Infra + }); + } + if argv.iter().any(|arg| arg == "fq_codel") { + return Some(if planned_qdisc_is_leaf(argv) { + PlannedQdiscKind::FqCodel + } else { + PlannedQdiscKind::Infra + }); + } + Some(PlannedQdiscKind::Infra) +} + +fn planned_qdisc_is_leaf(argv: &[String]) -> bool { + let Some(parent) = find_arg_value(argv, "parent") else { + return false; + }; + let Ok(parent_handle) = TcHandle::from_string(parent) else { + return false; + }; + let (_, minor) = parent_handle.get_major_minor(); + minor != 1 && minor != 2 +} + +fn qdisc_kind_estimated_memory_bytes(kind: PlannedQdiscKind) -> u64 { + match kind { + PlannedQdiscKind::Infra => INFRA_QDISC_ESTIMATED_MEMORY_BYTES, + PlannedQdiscKind::Cake => CAKE_QDISC_ESTIMATED_MEMORY_BYTES, + PlannedQdiscKind::FqCodel => FQ_CODEL_QDISC_ESTIMATED_MEMORY_BYTES, + } +} + fn planned_qdisc_identity(argv: &[String]) -> Option<(String, String)> { if argv.len() < 2 || argv[0] != "qdisc" { return None; @@ -591,6 +855,7 @@ pub fn estimate_full_reload_auto_qdisc_budget( queue: &[BakeryCommands], ) -> QdiscBudgetEstimate { let mut interfaces = BTreeMap::new(); + let mut interface_details: BTreeMap = BTreeMap::new(); let mut seen_qdiscs = HashSet::new(); for command in queue { @@ -602,15 +867,50 @@ pub fn estimate_full_reload_auto_qdisc_budget( continue; }; if seen_qdiscs.insert((dev.clone(), identity)) { - *interfaces.entry(dev).or_insert(0) += 1; + let kind = planned_qdisc_kind(&argv).unwrap_or(PlannedQdiscKind::Infra); + *interfaces.entry(dev.clone()).or_insert(0) += 1; + let detail = + interface_details + .entry(dev) + .or_insert_with(|| QdiscInterfaceEstimate { + planned_qdiscs: 0, + infra_qdiscs: 0, + cake_qdiscs: 0, + fq_codel_qdiscs: 0, + estimated_memory_bytes: 0, + }); + detail.planned_qdiscs += 1; + match kind { + PlannedQdiscKind::Infra => detail.infra_qdiscs += 1, + PlannedQdiscKind::Cake => detail.cake_qdiscs += 1, + PlannedQdiscKind::FqCodel => detail.fq_codel_qdiscs += 1, + } + detail.estimated_memory_bytes = detail + .estimated_memory_bytes + .saturating_add(qdisc_kind_estimated_memory_bytes(kind)); } } } + let estimated_total_memory_bytes = interface_details.values().fold(0u64, |acc, detail| { + acc.saturating_add(detail.estimated_memory_bytes) + }); + let memory_snapshot = read_memory_snapshot().ok(); + let memory_ok = memory_snapshot.as_ref().is_none_or(|snapshot| { + snapshot + .available_bytes + .saturating_sub(BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES) + >= estimated_total_memory_bytes + }); + QdiscBudgetEstimate { interfaces, + interface_details, safe_budget: SAFE_QDISC_BUDGET_PER_INTERFACE, hard_limit: HARD_QDISC_HANDLE_LIMIT_PER_INTERFACE, + estimated_total_memory_bytes, + memory_snapshot, + memory_ok, } } @@ -641,6 +941,16 @@ fn with_assigned_qdisc_handles( config: &Arc, mq_layout: &MqDeviceLayout, qdisc_handles: &mut QdiscHandleState, +) -> Arc { + with_assigned_qdisc_handles_reserved(command, config, mq_layout, qdisc_handles, &HashMap::new()) +} + +fn with_assigned_qdisc_handles_reserved( + command: &Arc, + config: &Arc, + mq_layout: &MqDeviceLayout, + qdisc_handles: &mut QdiscHandleState, + extra_reserved_handles: &HashMap>, ) -> Arc { let BakeryCommands::AddCircuit { circuit_hash, .. } = command.as_ref() else { return Arc::clone(command); @@ -653,8 +963,14 @@ fn with_assigned_qdisc_handles( let mut enriched = command.as_ref().clone(); let isp_interface = config.isp_interface(); let internet_interface = config.internet_interface(); - let isp_reserved = mq_layout.reserved_handles(&isp_interface); - let up_reserved = mq_layout.reserved_handles(&internet_interface); + let mut isp_reserved = mq_layout.reserved_handles(&isp_interface); + if let Some(extra) = extra_reserved_handles.get(&isp_interface) { + isp_reserved.extend(extra.iter().copied()); + } + let mut up_reserved = mq_layout.reserved_handles(&internet_interface); + if let Some(extra) = extra_reserved_handles.get(&internet_interface) { + up_reserved.extend(extra.iter().copied()); + } if let BakeryCommands::AddCircuit { down_qdisc_handle, @@ -678,6 +994,29 @@ fn with_assigned_qdisc_handles( Arc::new(enriched) } +fn snapshot_live_qdisc_handle_majors( + config: &Arc, +) -> Result>, String> { + let mut reserved = HashMap::new(); + let isp_interface = config.isp_interface(); + reserved.insert( + isp_interface.clone(), + read_live_qdisc_handle_majors(&isp_interface)?, + ); + + if !config.on_a_stick_mode() { + let internet_interface = config.internet_interface(); + if internet_interface != isp_interface { + reserved.insert( + internet_interface.clone(), + read_live_qdisc_handle_majors(&internet_interface)?, + ); + } + } + + Ok(reserved) +} + fn parse_directional_sqm_override( sqm_override: &Option, ) -> (Option, Option) { @@ -1062,6 +1401,45 @@ fn maybe_emit_mapped_circuit_limit_urgent(stats: &MappedLimitStats) { }); } +fn maybe_emit_memory_guard_urgent(summary: &str) { + if !summary.contains("Bakery memory guard stopped") { + return; + } + + let message = summary.to_string(); + let context = read_memory_snapshot().ok().map(|snapshot| { + format!( + "{{\"available_bytes\":{},\"total_bytes\":{},\"memory_guard_floor_bytes\":{}}}", + snapshot.available_bytes, snapshot.total_bytes, BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES + ) + }); + + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + warn!("Bakery: failed to build runtime for urgent issue submission: {e:?}"); + return; + } + }; + rt.block_on(async { + if let Ok(mut bus) = LibreqosBusClient::new().await { + let _ = bus + .request(vec![BusRequest::SubmitUrgentIssue { + source: UrgentSource::System, + severity: UrgentSeverity::Error, + code: "BAKERY_MEMORY_GUARD".to_string(), + message, + context, + dedupe_key: Some("bakery_memory_guard".to_string()), + }]) + .await; + } + }); +} + fn log_mapped_limit_decision( context: &str, mapped_limit: ResolvedMappedLimit, @@ -1108,6 +1486,7 @@ fn bakery_main(rx: Receiver, tx: Sender) { .unwrap_or_default(); // Persist latest StormGuard ceilings keyed by interface + class so we can replay after rebuilds. let mut stormguard_overrides: HashMap = HashMap::new(); + let mut virtualized_sites: HashMap = HashMap::new(); // Mapping state #[derive(Clone, Hash, PartialEq, Eq, Debug)] @@ -1391,6 +1770,7 @@ fn bakery_main(rx: Receiver, tx: Sender) { &tx, &mut migrations, &stormguard_overrides, + &virtualized_sites, ); } BakeryCommands::MqSetup { .. } => { @@ -1529,19 +1909,19 @@ fn bakery_main(rx: Receiver, tx: Sender) { advanced += 1; } MigrationStage::BuildFinal => { - // Delete old classes/qdiscs and create final with NEW rates at original minor + // Delete old classes/qdiscs and create final with NEW rates at final minor. if let Some(old_cmd) = build_temp_add_cmd( &BakeryCommands::AddCircuit { circuit_hash: mig.circuit_hash, - parent_class_id: mig.parent_class_id, - up_parent_class_id: mig.up_parent_class_id, + parent_class_id: mig.old_parent_class_id, + up_parent_class_id: mig.old_up_parent_class_id, class_minor: mig.old_minor, download_bandwidth_min: mig.old_down_min, upload_bandwidth_min: mig.old_up_min, download_bandwidth_max: mig.old_down_max, upload_bandwidth_max: mig.old_up_max, - class_major: mig.class_major, - up_class_major: mig.up_class_major, + class_major: mig.old_class_major, + up_class_major: mig.old_up_class_major, down_qdisc_handle: None, up_qdisc_handle: None, ip_addresses: "".to_string(), @@ -1560,7 +1940,22 @@ fn bakery_main(rx: Receiver, tx: Sender) { } // Final add (new rates) at final_minor if let Some(final_cmd) = build_temp_add_cmd( - &old_cmd, + &BakeryCommands::AddCircuit { + circuit_hash: mig.circuit_hash, + parent_class_id: mig.parent_class_id, + up_parent_class_id: mig.up_parent_class_id, + class_minor: mig.final_minor, + download_bandwidth_min: mig.new_down_min, + upload_bandwidth_min: mig.new_up_min, + download_bandwidth_max: mig.new_down_max, + upload_bandwidth_max: mig.new_up_max, + class_major: mig.class_major, + up_class_major: mig.up_class_major, + down_qdisc_handle: mig.down_qdisc_handle, + up_qdisc_handle: mig.up_qdisc_handle, + ip_addresses: "".to_string(), + sqm_override: mig.sqm_override.clone(), + }, mig.final_minor, mig.new_down_min, mig.new_down_max, @@ -1757,6 +2152,26 @@ fn bakery_main(rx: Receiver, tx: Sender) { } } } + BakeryCommands::TreeGuardSetNodeVirtual { + site_hash, + virtualized, + reply, + } => { + let result = handle_treeguard_set_node_virtual_live( + site_hash, + virtualized, + &mut sites, + &mut circuits, + &live_circuits, + &mq_layout, + &mut qdisc_handles, + &mut migrations, + &mut virtualized_sites, + ); + if let Some(reply) = reply { + let _ = reply.send(result); + } + } } } error!("Bakery thread exited unexpectedly."); @@ -1773,6 +2188,7 @@ fn handle_commit_batch( tx: &Sender, migrations: &mut HashMap, stormguard_overrides: &HashMap, + virtualized_sites: &HashMap, ) { let Ok(config) = lqos_config::load_config() else { error!("Failed to load configuration, exiting Bakery thread."); @@ -1784,6 +2200,7 @@ fn handle_commit_batch( debug!("CommitBatch received without a batch to commit."); return; }; + let new_batch = apply_runtime_virtualization_overlay(new_batch, virtualized_sites); let resolved_mq_layout = current_mq_layout(&new_batch, &config, mq_layout); let mapped_limit = resolve_mapped_circuit_limit(); @@ -1965,11 +2382,12 @@ fn handle_commit_batch( if let CircuitDiffResult::Categorized(categories) = circuit_change_mode { // One-line summary of changes (info!) info!( - "Bakery changes: sites_speed={}, circuits_added={}, removed={}, speed={}, ip={}", + "Bakery changes: sites_speed={}, circuits_added={}, removed={}, speed={}, migrated={}, ip={}", site_speed_change_count, categories.newly_added.len(), categories.removed_circuits.len(), categories.speed_changed.len(), + categories.migrated.len(), categories.ip_changed.len() ); @@ -2022,9 +2440,14 @@ fn handle_commit_batch( for cmd in &categories.speed_changed { let mut enriched_cmd = with_assigned_qdisc_handles(cmd, &config, layout, qdisc_handles); - if let BakeryCommands::AddCircuit { circuit_hash, .. } = enriched_cmd.as_ref() - && let Some(old_cmd) = circuits.get(circuit_hash) + let old_cmd = if let BakeryCommands::AddCircuit { circuit_hash, .. } = + enriched_cmd.as_ref() { + circuits.get(circuit_hash).cloned() + } else { + None + }; + if let Some(old_cmd) = old_cmd.as_ref() { enriched_cmd = rotate_changed_qdisc_handles( old_cmd.as_ref(), &enriched_cmd, @@ -2033,70 +2456,19 @@ fn handle_commit_batch( qdisc_handles, ); } - if let BakeryCommands::AddCircuit { - circuit_hash, - parent_class_id, - up_parent_class_id, - class_minor, - download_bandwidth_min, - upload_bandwidth_min, - download_bandwidth_max, - upload_bandwidth_max, - class_major, - up_class_major, - ip_addresses, - sqm_override, - .. - } = enriched_cmd.as_ref() + if let Some(old_cmd) = old_cmd.as_ref() + && queue_live_migration( + old_cmd.as_ref(), + &enriched_cmd, + circuits, + live_circuits, + migrations, + ) { + continue; + } + if let BakeryCommands::AddCircuit { circuit_hash, .. } = enriched_cmd.as_ref() { let was_activated = live_circuits.contains_key(circuit_hash); - if was_activated { - // Attempt live-move - if let Some(shadow_minor) = - find_free_minor(circuits, parent_class_id, up_parent_class_id) - { - // Find old command for old rates - if let Some(old_cmd) = circuits.get(circuit_hash) - && let BakeryCommands::AddCircuit { - download_bandwidth_min: old_down_min, - upload_bandwidth_min: old_up_min, - download_bandwidth_max: old_down_max, - upload_bandwidth_max: old_up_max, - down_qdisc_handle, - up_qdisc_handle, - .. - } = old_cmd.as_ref() - { - let mig = Migration { - circuit_hash: *circuit_hash, - parent_class_id: *parent_class_id, - up_parent_class_id: *up_parent_class_id, - class_major: *class_major, - up_class_major: *up_class_major, - down_qdisc_handle: *down_qdisc_handle, - up_qdisc_handle: *up_qdisc_handle, - old_down_min: *old_down_min, - old_down_max: *old_down_max, - old_up_min: *old_up_min, - old_up_max: *old_up_max, - new_down_min: *download_bandwidth_min, - new_down_max: *download_bandwidth_max, - new_up_min: *upload_bandwidth_min, - new_up_max: *upload_bandwidth_max, - old_minor: *class_minor, - shadow_minor, - final_minor: *class_minor, - ips: parse_ip_list(ip_addresses), - sqm_override: sqm_override.clone(), - stage: MigrationStage::PrepareShadow, - }; - migrations.insert(*circuit_hash, mig); - // Update desired circuit definition now - circuits.insert(*circuit_hash, Arc::clone(&enriched_cmd)); - continue; // skip immediate path - } - } - } // Fallback: immediate safe update match config.queues.lazy_queues.as_ref() { None | Some(LazyQueueMode::No) => { @@ -2156,6 +2528,111 @@ fn handle_commit_batch( } } + // 2b) Parent/class migrations + if !categories.migrated.is_empty() { + let Some(layout) = resolved_mq_layout.as_ref() else { + warn!("Bakery: missing MQ layout during circuit migrations"); + return; + }; + let mut immediate_commands = Vec::new(); + for cmd in &categories.migrated { + let mut enriched_cmd = + with_assigned_qdisc_handles(cmd, &config, layout, qdisc_handles); + let Some(old_cmd) = (if let BakeryCommands::AddCircuit { circuit_hash, .. } = + enriched_cmd.as_ref() + { + circuits.get(circuit_hash).cloned() + } else { + None + }) else { + continue; + }; + + enriched_cmd = rotate_changed_qdisc_handles( + old_cmd.as_ref(), + &enriched_cmd, + &config, + layout, + qdisc_handles, + ); + + if queue_live_migration( + old_cmd.as_ref(), + &enriched_cmd, + circuits, + live_circuits, + migrations, + ) { + continue; + } + + let BakeryCommands::AddCircuit { circuit_hash, .. } = enriched_cmd.as_ref() else { + continue; + }; + let was_activated = live_circuits.contains_key(circuit_hash); + if was_activated { + warn!( + "Bakery: falling back to full reload for active migrated circuit {} because live migration setup failed", + circuit_hash + ); + let (new_batch, mapped_limit_stats) = filter_batch_by_mapped_circuit_limit( + new_batch.clone(), + circuits, + effective_limit, + ); + log_mapped_limit_decision( + "circuit-migration rebuild", + mapped_limit, + mapped_limit_stats, + ); + full_reload( + batch, + sites, + circuits, + live_circuits, + mq_layout, + qdisc_handles, + &config, + new_batch, + resolved_mq_layout.clone(), + stormguard_overrides, + ); + MQ_CREATED.store(true, std::sync::atomic::Ordering::Relaxed); + return; + } + + match config.queues.lazy_queues.as_ref() { + None | Some(LazyQueueMode::No) => { + if let Some(prune) = old_cmd.to_prune(&config, true) { + immediate_commands.extend(prune); + } + if let Some(add) = enriched_cmd.to_commands(&config, ExecutionMode::Builder) + { + immediate_commands.extend(add); + } + } + Some(LazyQueueMode::Htb) => { + if let Some(prune) = old_cmd.to_prune(&config, true) { + immediate_commands.extend(prune); + } + if let Some(add_htb) = + enriched_cmd.to_commands(&config, ExecutionMode::Builder) + { + immediate_commands.extend(add_htb); + } + } + Some(LazyQueueMode::Full) => {} + } + circuits.insert(*circuit_hash, enriched_cmd); + } + if !immediate_commands.is_empty() { + execute_and_record_live_change( + &immediate_commands, + "migrating circuits between parent nodes (fallback)", + ); + } + } + // 3) Additions if !categories.newly_added.is_empty() { let mut accepted_additions: Vec<&Arc> = Vec::new(); @@ -2516,6 +2993,1551 @@ fn handle_change_site_speed_live( } } +fn site_class_handles(site: &BakeryCommands) -> Option<(TcHandle, TcHandle)> { + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + class_minor, + .. + } = site + else { + return None; + }; + Some(( + tc_handle_from_major_minor(parent_class_id.get_major_minor().0, *class_minor), + tc_handle_from_major_minor(up_parent_class_id.get_major_minor().0, *class_minor), + )) +} + +fn site_is_top_level(site: &BakeryCommands) -> bool { + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + .. + } = site + else { + return false; + }; + parent_class_id.get_major_minor().1 == 0 && up_parent_class_id.get_major_minor().1 == 0 +} + +fn site_runtime_virtualization_eligibility_error(site: &BakeryCommands) -> Option { + let BakeryCommands::AddSite { + site_hash, + parent_class_id, + up_parent_class_id, + .. + } = site + else { + return Some("TreeGuard runtime virtualization requires an AddSite command".to_string()); + }; + + if site_is_top_level(site) { + return Some(format!( + "Site {} is top-level in HTB and cannot be runtime-virtualized in v1", + site_hash + )); + } + + let Some((site_down_class, site_up_class)) = site_class_handles(site) else { + return Some(format!("Site {} is not a valid AddSite command", site_hash)); + }; + + let (parent_down_major, _) = parent_class_id.get_major_minor(); + let (parent_up_major, _) = up_parent_class_id.get_major_minor(); + let (site_down_major, _) = site_down_class.get_major_minor(); + let (site_up_major, _) = site_up_class.get_major_minor(); + + if parent_down_major != site_down_major || parent_up_major != site_up_major { + return Some(format!( + "Site {} crosses queue/major domains (parent down/up majors {:x}/{:x}, site down/up majors {:x}/{:x}) and cannot be runtime-virtualized in v1", + site_hash, parent_down_major, parent_up_major, site_down_major, site_up_major + )); + } + + None +} + +fn site_prune_commands(config: &Arc, site: &BakeryCommands) -> Option>> { + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + class_minor, + .. + } = site + else { + return None; + }; + + Some(vec![ + vec![ + "class".to_string(), + "del".to_string(), + "dev".to_string(), + config.isp_interface(), + "parent".to_string(), + parent_class_id.as_tc_string(), + "classid".to_string(), + format!( + "0x{:x}:0x{:x}", + parent_class_id.get_major_minor().0, + class_minor + ), + ], + vec![ + "class".to_string(), + "del".to_string(), + "dev".to_string(), + config.internet_interface(), + "parent".to_string(), + up_parent_class_id.as_tc_string(), + "classid".to_string(), + format!( + "0x{:x}:0x{:x}", + up_parent_class_id.get_major_minor().0, + class_minor + ), + ], + ]) +} + +fn reparent_site_command( + site: &Arc, + parent_class_id: TcHandle, + up_parent_class_id: TcHandle, +) -> Option> { + let BakeryCommands::AddSite { + site_hash, + class_minor, + download_bandwidth_min, + upload_bandwidth_min, + download_bandwidth_max, + upload_bandwidth_max, + .. + } = site.as_ref() + else { + return None; + }; + + Some(Arc::new(BakeryCommands::AddSite { + site_hash: *site_hash, + parent_class_id, + up_parent_class_id, + class_minor: *class_minor, + download_bandwidth_min: *download_bandwidth_min, + upload_bandwidth_min: *upload_bandwidth_min, + download_bandwidth_max: *download_bandwidth_max, + upload_bandwidth_max: *upload_bandwidth_max, + })) +} + +fn reparent_circuit_command( + circuit: &Arc, + parent_class_id: TcHandle, + up_parent_class_id: TcHandle, +) -> Option> { + let BakeryCommands::AddCircuit { + circuit_hash, + class_minor, + download_bandwidth_min, + upload_bandwidth_min, + download_bandwidth_max, + upload_bandwidth_max, + class_major, + up_class_major, + ip_addresses, + sqm_override, + .. + } = circuit.as_ref() + else { + return None; + }; + + Some(Arc::new(BakeryCommands::AddCircuit { + circuit_hash: *circuit_hash, + parent_class_id, + up_parent_class_id, + class_minor: *class_minor, + download_bandwidth_min: *download_bandwidth_min, + upload_bandwidth_min: *upload_bandwidth_min, + download_bandwidth_max: *download_bandwidth_max, + upload_bandwidth_max: *upload_bandwidth_max, + class_major: *class_major, + up_class_major: *up_class_major, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: ip_addresses.clone(), + sqm_override: sqm_override.clone(), + })) +} + +fn collect_direct_child_site_hashes( + sites: &HashMap>, + target_down: TcHandle, + target_up: TcHandle, + target_hash: i64, +) -> Vec { + let mut hashes: Vec = sites + .iter() + .filter_map(|(site_hash, site)| { + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + .. + } = site.as_ref() + else { + return None; + }; + (*site_hash != target_hash + && *parent_class_id == target_down + && *up_parent_class_id == target_up) + .then_some(*site_hash) + }) + .collect(); + hashes.sort_unstable(); + hashes +} + +fn collect_direct_circuit_hashes( + circuits: &HashMap>, + target_down: TcHandle, + target_up: TcHandle, +) -> Vec { + let mut hashes: Vec = circuits + .iter() + .filter_map(|(circuit_hash, circuit)| { + let BakeryCommands::AddCircuit { + parent_class_id, + up_parent_class_id, + .. + } = circuit.as_ref() + else { + return None; + }; + (*parent_class_id == target_down && *up_parent_class_id == target_up) + .then_some(*circuit_hash) + }) + .collect(); + hashes.sort_unstable(); + hashes +} + +fn apply_direct_circuit_reparents( + config: &Arc, + circuit_hashes: &[i64], + new_parent_class_id: TcHandle, + new_up_parent_class_id: TcHandle, + circuits: &mut HashMap>, + live_circuits: &HashMap, + mq_layout: &Option, + qdisc_handles: &mut QdiscHandleState, + migrations: &mut HashMap, +) -> Result<(), String> { + if circuit_hashes.is_empty() { + return Ok(()); + } + let Some(layout) = mq_layout.as_ref() else { + return Err("Bakery runtime virtualization requires MQ layout to be available".to_string()); + }; + + for circuit_hash in circuit_hashes { + let Some(old_cmd) = circuits.get(circuit_hash).cloned() else { + continue; + }; + let Some(candidate_cmd) = + reparent_circuit_command(&old_cmd, new_parent_class_id, new_up_parent_class_id) + else { + continue; + }; + if live_circuits.contains_key(circuit_hash) + && let BakeryCommands::AddCircuit { + parent_class_id, + up_parent_class_id, + .. + } = candidate_cmd.as_ref() + && find_free_minor(circuits, parent_class_id, up_parent_class_id).is_none() + { + return Err(format!( + "Unable to queue live migration for active circuit {}: no shadow minor available", + circuit_hash + )); + } + } + + let mut immediate_commands = Vec::new(); + let mut updated_circuits: Vec<(i64, Arc)> = Vec::new(); + + for circuit_hash in circuit_hashes { + let Some(old_cmd) = circuits.get(circuit_hash).cloned() else { + continue; + }; + let Some(candidate_cmd) = + reparent_circuit_command(&old_cmd, new_parent_class_id, new_up_parent_class_id) + else { + continue; + }; + let enriched_cmd = rotate_changed_qdisc_handles( + old_cmd.as_ref(), + &candidate_cmd, + config, + layout, + qdisc_handles, + ); + + if queue_live_migration( + old_cmd.as_ref(), + &enriched_cmd, + circuits, + live_circuits, + migrations, + ) { + continue; + } + + let BakeryCommands::AddCircuit { circuit_hash, .. } = enriched_cmd.as_ref() else { + continue; + }; + let was_activated = live_circuits.contains_key(circuit_hash); + if was_activated { + return Err(format!( + "Bakery runtime virtualization could not live-migrate active circuit {}", + circuit_hash + )); + } + + match config.queues.lazy_queues.as_ref() { + None | Some(LazyQueueMode::No) => { + if let Some(prune) = old_cmd.to_prune(config, true) { + immediate_commands.extend(prune); + } + if let Some(add) = enriched_cmd.to_commands(config, ExecutionMode::Builder) { + immediate_commands.extend(add); + } + } + Some(LazyQueueMode::Htb) => { + if let Some(prune) = old_cmd.to_prune(config, true) { + immediate_commands.extend(prune); + } + if let Some(add_htb) = enriched_cmd.to_commands(config, ExecutionMode::Builder) { + immediate_commands.extend(add_htb); + } + } + Some(LazyQueueMode::Full) => {} + } + updated_circuits.push((*circuit_hash, enriched_cmd)); + } + + if !immediate_commands.is_empty() { + let result = execute_and_record_live_change( + &immediate_commands, + "TreeGuard runtime circuit reparent", + ); + if !result.ok { + return Err(summarize_apply_result( + "TreeGuard runtime circuit reparent", + &result, + )); + } + } + + for (circuit_hash, command) in updated_circuits { + circuits.insert(circuit_hash, command); + } + + Ok(()) +} + +#[derive(Clone, Debug)] +struct PlannedSiteUpdate { + queue: u32, + parent_site: Option, + command: Arc, +} + +#[derive(Clone, Debug)] +struct PlannedCircuitUpdate { + queue: u32, + parent_site: Option, + command: Arc, +} + +#[derive(Clone, Debug)] +struct TopLevelVirtualizationPlan { + saved_sites: HashMap>, + saved_circuits: HashMap>, + active_sites: HashMap, + active_circuits: HashMap, +} + +fn site_hash_from_command(site: &BakeryCommands) -> Option { + if let BakeryCommands::AddSite { site_hash, .. } = site { + Some(*site_hash) + } else { + None + } +} + +fn circuit_hash_from_command(circuit: &BakeryCommands) -> Option { + if let BakeryCommands::AddCircuit { circuit_hash, .. } = circuit { + Some(*circuit_hash) + } else { + None + } +} + +fn site_max_weight(site: &BakeryCommands) -> f64 { + if let BakeryCommands::AddSite { + download_bandwidth_max, + upload_bandwidth_max, + .. + } = site + { + f64::from(*download_bandwidth_max + *upload_bandwidth_max) + } else { + 1.0 + } +} + +fn circuit_max_weight(circuit: &BakeryCommands) -> f64 { + if let BakeryCommands::AddCircuit { + download_bandwidth_max, + upload_bandwidth_max, + .. + } = circuit + { + f64::from(*download_bandwidth_max + *upload_bandwidth_max) + } else { + 1.0 + } +} + +fn current_site_queue(site: &BakeryCommands) -> Option { + let BakeryCommands::AddSite { + parent_class_id, + class_minor, + .. + } = site + else { + return None; + }; + let (major, _) = parent_class_id.get_major_minor(); + let class_handle = tc_handle_from_major_minor(major, *class_minor); + Some(u32::from(class_handle.get_major_minor().0)) +} + +fn current_circuit_queue(circuit: &BakeryCommands) -> Option { + let BakeryCommands::AddCircuit { class_major, .. } = circuit else { + return None; + }; + Some(u32::from(*class_major)) +} + +fn site_parent_hash( + site_hash: i64, + sites: &HashMap>, + class_to_site: &HashMap<(TcHandle, TcHandle), i64>, +) -> Option { + let site = sites.get(&site_hash)?; + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + .. + } = site.as_ref() + else { + return None; + }; + class_to_site + .get(&(*parent_class_id, *up_parent_class_id)) + .copied() +} + +fn direct_child_sites_by_parent( + sites: &HashMap>, +) -> HashMap> { + let mut class_to_site = HashMap::new(); + for (site_hash, site) in sites { + if let Some(handles) = site_class_handles(site.as_ref()) { + class_to_site.insert(handles, *site_hash); + } + } + + let mut result: HashMap> = HashMap::new(); + for site_hash in sites.keys().copied() { + if let Some(parent_hash) = site_parent_hash(site_hash, sites, &class_to_site) { + result.entry(parent_hash).or_default().push(site_hash); + } + } + for children in result.values_mut() { + children.sort_unstable(); + } + result +} + +fn collect_site_subtree_hashes( + root_hash: i64, + children_by_parent: &HashMap>, +) -> Vec { + let mut ordered = Vec::new(); + let mut stack = vec![root_hash]; + while let Some(site_hash) = stack.pop() { + ordered.push(site_hash); + if let Some(children) = children_by_parent.get(&site_hash) { + for child in children.iter().rev() { + stack.push(*child); + } + } + } + ordered +} + +fn collect_circuits_attached_to_sites( + circuits: &HashMap>, + sites: &HashMap>, + site_hashes: &[i64], +) -> Vec { + let mut site_handles = HashSet::new(); + for site_hash in site_hashes { + if let Some(site) = sites.get(site_hash) + && let Some(handles) = site_class_handles(site.as_ref()) + { + site_handles.insert(handles); + } + } + + let mut result: Vec = circuits + .iter() + .filter_map(|(circuit_hash, circuit)| { + let BakeryCommands::AddCircuit { + parent_class_id, + up_parent_class_id, + .. + } = circuit.as_ref() + else { + return None; + }; + site_handles + .contains(&(*parent_class_id, *up_parent_class_id)) + .then_some(*circuit_hash) + }) + .collect(); + result.sort_unstable(); + result +} + +fn root_handle_for_queue(queue: u32) -> TcHandle { + TcHandle::from_u32(queue << 16) +} + +fn site_stick_offset(site: &BakeryCommands) -> u16 { + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + .. + } = site + else { + return 0; + }; + let (down_major, _) = parent_class_id.get_major_minor(); + let (up_major, _) = up_parent_class_id.get_major_minor(); + up_major.saturating_sub(down_major) +} + +fn top_level_bin_name(queue: u32) -> String { + format!("CpueQueue{}", queue.saturating_sub(1)) +} + +fn queue_from_bin_name(name: &str) -> Option { + name.strip_prefix("CpueQueue") + .and_then(|v| v.parse::().ok()) + .map(|cpu| cpu + 1) +} + +fn planner_site_key(site_hash: i64) -> String { + format!("site:{site_hash}") +} + +fn planner_circuit_key(circuit_hash: i64) -> String { + format!("circuit:{circuit_hash}") +} + +fn build_current_planner_state( + sites: &HashMap>, + circuits: &HashMap>, +) -> ( + BTreeMap, + BTreeMap, +) { + let mut class_to_site = HashMap::new(); + for (site_hash, site) in sites { + if let Some(handles) = site_class_handles(site.as_ref()) { + class_to_site.insert(handles, *site_hash); + } + } + + let mut previous_sites = BTreeMap::new(); + for (site_hash, site) in sites { + let Some(queue) = current_site_queue(site.as_ref()) else { + continue; + }; + let Some(class_minor) = (if let BakeryCommands::AddSite { class_minor, .. } = site.as_ref() { + Some(*class_minor) + } else { + None + }) else { + continue; + }; + let parent_path = site_parent_hash(*site_hash, sites, &class_to_site) + .map(planner_site_key) + .unwrap_or_default(); + previous_sites.insert( + planner_site_key(*site_hash), + PlannerSiteIdentityState { + class_minor, + queue, + parent_path, + class_major: queue as u16, + up_class_major: 0, + }, + ); + } + + let mut previous_circuits = BTreeMap::new(); + for (circuit_hash, circuit) in circuits { + let BakeryCommands::AddCircuit { + class_minor, + class_major, + up_class_major, + parent_class_id, + up_parent_class_id, + .. + } = circuit.as_ref() + else { + continue; + }; + let parent_node = class_to_site + .get(&(*parent_class_id, *up_parent_class_id)) + .map(|site_hash| planner_site_key(*site_hash)) + .unwrap_or_else(|| format!("root:{}", class_major)); + previous_circuits.insert( + planner_circuit_key(*circuit_hash), + PlannerCircuitIdentityState { + class_minor: *class_minor, + queue: u32::from(*class_major), + parent_node, + class_major: *class_major, + up_class_major: *up_class_major, + }, + ); + } + + (previous_sites, previous_circuits) +} + +fn top_level_site_hashes(sites: &HashMap>) -> Vec { + let mut hashes: Vec = sites + .iter() + .filter_map(|(site_hash, site)| site_is_top_level(site.as_ref()).then_some(*site_hash)) + .collect(); + hashes.sort_unstable(); + hashes +} + +fn site_descendant_depth( + site_hash: i64, + parents: &HashMap>, +) -> usize { + let mut depth = 0usize; + let mut current = parents.get(&site_hash).copied().flatten(); + while let Some(parent) = current { + depth += 1; + current = parents.get(&parent).copied().flatten(); + } + depth +} + +fn build_top_level_virtualization_plan( + target_site: Arc, + sites: &HashMap>, + circuits: &HashMap>, + stick_offset: u16, +) -> Result { + let Some(target_site_hash) = site_hash_from_command(target_site.as_ref()) else { + return Err("TreeGuard top-level virtualization target is not a site".to_string()); + }; + if !site_is_top_level(target_site.as_ref()) { + return Err(format!( + "Site {} is not top-level and should use the v1 same-queue runtime virtualization path", + target_site_hash + )); + } + + let children_by_parent = direct_child_sites_by_parent(sites); + let Some((target_down_class, target_up_class)) = site_class_handles(target_site.as_ref()) else { + return Err(format!("Site {} is not a valid AddSite command", target_site_hash)); + }; + + let promoted_site_roots = children_by_parent + .get(&target_site_hash) + .cloned() + .unwrap_or_default(); + let direct_circuits = collect_direct_circuit_hashes(circuits, target_down_class, target_up_class); + let top_level_sites = top_level_site_hashes(sites); + + let bins: Vec = top_level_sites + .iter() + .filter_map(|site_hash| { + sites.get(site_hash) + .and_then(|site| current_site_queue(site.as_ref())) + .map(top_level_bin_name) + }) + .collect::>() + .into_iter() + .collect(); + if bins.is_empty() { + return Err("No top-level queues available for runtime top-level virtualization".to_string()); + } + + let target_queue = current_site_queue(target_site.as_ref()).unwrap_or(1); + let mut planner_items = Vec::new(); + let mut prev_assign = BTreeMap::new(); + for site_hash in &top_level_sites { + if *site_hash == target_site_hash { + continue; + } + let Some(site) = sites.get(site_hash) else { + continue; + }; + let Some(queue) = current_site_queue(site.as_ref()) else { + continue; + }; + let id = planner_site_key(*site_hash); + planner_items.push(TopLevelPlannerItem { + id: id.clone(), + weight: site_max_weight(site.as_ref()), + }); + prev_assign.insert(id, top_level_bin_name(queue)); + } + for site_hash in &promoted_site_roots { + let Some(site) = sites.get(site_hash) else { + continue; + }; + let id = planner_site_key(*site_hash); + planner_items.push(TopLevelPlannerItem { + id: id.clone(), + weight: site_max_weight(site.as_ref()), + }); + prev_assign.insert(id, top_level_bin_name(target_queue)); + } + for circuit_hash in &direct_circuits { + let Some(circuit) = circuits.get(circuit_hash) else { + continue; + }; + let id = planner_circuit_key(*circuit_hash); + planner_items.push(TopLevelPlannerItem { + id: id.clone(), + weight: circuit_max_weight(circuit.as_ref()), + }); + prev_assign.insert(id, top_level_bin_name(target_queue)); + } + + let planner = plan_top_level_assignments( + &planner_items, + &bins, + &prev_assign, + &BTreeMap::new(), + current_timestamp() as f64, + &TopLevelPlannerParams { + mode: TopLevelPlannerMode::StableGreedy, + move_budget_per_run: std::cmp::max(1, std::cmp::min(32, planner_items.len())), + ..TopLevelPlannerParams::default() + }, + ); + + let mut future_top_level_roots = Vec::new(); + for site_hash in &top_level_sites { + if *site_hash != target_site_hash { + future_top_level_roots.push(*site_hash); + } + } + future_top_level_roots.extend(promoted_site_roots.iter().copied()); + future_top_level_roots.sort_unstable(); + + let mut future_top_level_queue = HashMap::new(); + for site_hash in &future_top_level_roots { + let key = planner_site_key(*site_hash); + let assigned = planner + .assignment + .get(&key) + .or_else(|| prev_assign.get(&key)) + .ok_or_else(|| format!("Planner did not assign top-level site {}", site_hash))?; + let queue = queue_from_bin_name(assigned) + .ok_or_else(|| format!("Invalid planner queue assignment {}", assigned))?; + future_top_level_queue.insert(*site_hash, queue); + } + + let mut moved_top_level_roots = Vec::new(); + for site_hash in &future_top_level_roots { + let Some(site) = sites.get(site_hash) else { + continue; + }; + let current_queue = current_site_queue(site.as_ref()).unwrap_or(1); + let new_queue = future_top_level_queue + .get(site_hash) + .copied() + .unwrap_or(current_queue); + if *site_hash == target_site_hash + || promoted_site_roots.contains(site_hash) + || new_queue != current_queue + { + moved_top_level_roots.push(*site_hash); + } + } + moved_top_level_roots.sort_unstable(); + moved_top_level_roots.dedup(); + + let mut saved_sites = HashMap::new(); + let mut saved_circuits = HashMap::new(); + let mut affected_site_hashes = HashSet::new(); + for site_hash in &moved_top_level_roots { + for hash in collect_site_subtree_hashes(*site_hash, &children_by_parent) { + affected_site_hashes.insert(hash); + } + } + for site_hash in &affected_site_hashes { + if let Some(site) = sites.get(site_hash) { + saved_sites.insert(*site_hash, Arc::clone(site)); + } + } + let affected_site_hashes_vec: Vec = affected_site_hashes.iter().copied().collect(); + for circuit_hash in collect_circuits_attached_to_sites(circuits, sites, &affected_site_hashes_vec) { + if let Some(circuit) = circuits.get(&circuit_hash) { + saved_circuits.insert(circuit_hash, Arc::clone(circuit)); + } + } + for circuit_hash in &direct_circuits { + if let Some(circuit) = circuits.get(circuit_hash) { + saved_circuits.insert(*circuit_hash, Arc::clone(circuit)); + } + } + + let (previous_sites, previous_circuits) = build_current_planner_state(sites, circuits); + + let mut future_parent_by_site: HashMap> = HashMap::new(); + for site_hash in &future_top_level_roots { + future_parent_by_site.insert(*site_hash, None); + } + for site_hash in &affected_site_hashes { + if future_parent_by_site.contains_key(site_hash) { + continue; + } + let parent_hash = site_parent_hash(*site_hash, sites, &{ + let mut class_to_site = HashMap::new(); + for (hash, site) in sites { + if let Some(handles) = site_class_handles(site.as_ref()) { + class_to_site.insert(handles, *hash); + } + } + class_to_site + }); + future_parent_by_site.insert(*site_hash, parent_hash); + } + + let mut planner_site_inputs = Vec::new(); + let mut planner_site_hashes: Vec = sites.keys().copied().filter(|hash| *hash != target_site_hash).collect(); + planner_site_hashes.sort_by_key(|hash| { + ( + site_descendant_depth(*hash, &future_parent_by_site), + *hash, + ) + }); + for site_hash in &planner_site_hashes { + let Some(site) = sites.get(site_hash) else { + continue; + }; + let queue = if let Some(root_queue) = future_top_level_queue.get(site_hash) { + *root_queue + } else { + let mut cursor = *site_hash; + loop { + let Some(parent) = future_parent_by_site.get(&cursor).copied().flatten() else { + break future_top_level_queue.get(&cursor).copied().unwrap_or(1); + }; + cursor = parent; + } + }; + let parent_path = future_parent_by_site + .get(site_hash) + .copied() + .flatten() + .map(planner_site_key) + .unwrap_or_default(); + let has_children = children_by_parent + .get(site_hash) + .map(|children| !children.is_empty()) + .unwrap_or(false); + planner_site_inputs.push(SiteIdentityInput { + site_key: planner_site_key(*site_hash), + parent_path, + queue, + has_children, + }); + let _ = site; + } + + let class_to_site = { + let mut m = HashMap::new(); + for (hash, site) in sites { + if let Some(handles) = site_class_handles(site.as_ref()) { + m.insert(handles, *hash); + } + } + m + }; + + let mut circuit_ids_by_parent: BTreeMap> = BTreeMap::new(); + for (circuit_hash, circuit) in circuits { + let BakeryCommands::AddCircuit { + parent_class_id, + up_parent_class_id, + .. + } = circuit.as_ref() + else { + continue; + }; + if *parent_class_id == target_down_class && *up_parent_class_id == target_up_class { + continue; + } + let parent = class_to_site + .get(&(*parent_class_id, *up_parent_class_id)) + .map(|site_hash| planner_site_key(*site_hash)) + .unwrap_or_else(|| format!("root:{}", current_circuit_queue(circuit.as_ref()).unwrap_or(1))); + circuit_ids_by_parent + .entry(parent) + .or_default() + .push(planner_circuit_key(*circuit_hash)); + } + for circuit_hash in &direct_circuits { + let id = planner_circuit_key(*circuit_hash); + let assigned = planner + .assignment + .get(&id) + .or_else(|| prev_assign.get(&id)) + .ok_or_else(|| format!("Planner did not assign direct circuit {}", circuit_hash))?; + let queue = queue_from_bin_name(assigned) + .ok_or_else(|| format!("Invalid planner queue assignment {}", assigned))?; + circuit_ids_by_parent + .entry(format!("root:{queue}")) + .or_default() + .push(id); + } + + let mut planner_circuit_groups = Vec::new(); + for (parent_node, mut circuit_ids) in circuit_ids_by_parent { + circuit_ids.sort(); + let queue = if let Some(root) = parent_node.strip_prefix("root:") { + root.parse::().unwrap_or(1) + } else if let Some(site_hash) = parent_node.strip_prefix("site:").and_then(|s| s.parse::().ok()) { + planner_site_inputs + .iter() + .find(|site| site.site_key == planner_site_key(site_hash)) + .map(|site| site.queue) + .unwrap_or(1) + } else { + 1 + }; + planner_circuit_groups.push(CircuitIdentityGroupInput { + parent_node, + queue, + circuit_ids, + }); + } + + let identity = plan_class_identities( + &planner_site_inputs, + &planner_circuit_groups, + &previous_sites, + &previous_circuits, + stick_offset, + 0, + ); + let site_identity_by_key: HashMap = identity + .sites + .iter() + .map(|entry| (entry.site_key.clone(), entry.clone())) + .collect(); + let circuit_identity_by_key: HashMap = identity + .circuits + .iter() + .map(|entry| (entry.circuit_id.clone(), entry.clone())) + .collect(); + + let mut active_sites = HashMap::new(); + for site_hash in &planner_site_hashes { + let Some(old_site) = sites.get(site_hash) else { + continue; + }; + let site_key = planner_site_key(*site_hash); + let Some(identity_entry) = site_identity_by_key.get(&site_key) else { + continue; + }; + let parent_handles = future_parent_by_site + .get(site_hash) + .copied() + .flatten() + .and_then(|parent_hash| { + site_identity_by_key.get(&planner_site_key(parent_hash)).map(|parent_identity| { + ( + tc_handle_from_major_minor(parent_identity.class_major, parent_identity.class_minor), + tc_handle_from_major_minor(parent_identity.up_class_major, parent_identity.class_minor), + ) + }) + }) + .unwrap_or_else(|| { + ( + root_handle_for_queue(identity_entry.queue), + root_handle_for_queue(u32::from(identity_entry.up_class_major)), + ) + }); + let BakeryCommands::AddSite { + site_hash, + download_bandwidth_min, + upload_bandwidth_min, + download_bandwidth_max, + upload_bandwidth_max, + .. + } = old_site.as_ref() + else { + continue; + }; + active_sites.insert( + *site_hash, + PlannedSiteUpdate { + queue: identity_entry.queue, + parent_site: future_parent_by_site.get(site_hash).copied().flatten(), + command: Arc::new(BakeryCommands::AddSite { + site_hash: *site_hash, + parent_class_id: parent_handles.0, + up_parent_class_id: parent_handles.1, + class_minor: identity_entry.class_minor, + download_bandwidth_min: *download_bandwidth_min, + upload_bandwidth_min: *upload_bandwidth_min, + download_bandwidth_max: *download_bandwidth_max, + upload_bandwidth_max: *upload_bandwidth_max, + }), + }, + ); + } + + let mut active_circuits = HashMap::new(); + for (circuit_hash, circuit) in circuits { + let circuit_key = planner_circuit_key(*circuit_hash); + let Some(identity_entry) = circuit_identity_by_key.get(&circuit_key) else { + continue; + }; + let parent_site = previous_circuits + .get(&circuit_key) + .and_then(|prev| prev.parent_node.strip_prefix("site:")) + .and_then(|s| s.parse::().ok()); + let future_parent_site = if direct_circuits.contains(circuit_hash) { + None + } else { + class_to_site + .get(&{ + let BakeryCommands::AddCircuit { + parent_class_id, + up_parent_class_id, + .. + } = circuit.as_ref() + else { + continue; + }; + (*parent_class_id, *up_parent_class_id) + }) + .copied() + }; + let parent_handles = if let Some(parent_site_hash) = future_parent_site { + if let Some(parent_identity) = site_identity_by_key.get(&planner_site_key(parent_site_hash)) { + ( + tc_handle_from_major_minor(parent_identity.class_major, parent_identity.class_minor), + tc_handle_from_major_minor(parent_identity.up_class_major, parent_identity.class_minor), + ) + } else { + ( + root_handle_for_queue(identity_entry.queue), + root_handle_for_queue(u32::from(identity_entry.up_class_major)), + ) + } + } else { + ( + root_handle_for_queue(identity_entry.queue), + root_handle_for_queue(u32::from(identity_entry.up_class_major)), + ) + }; + let BakeryCommands::AddCircuit { + circuit_hash, + download_bandwidth_min, + upload_bandwidth_min, + download_bandwidth_max, + upload_bandwidth_max, + ip_addresses, + sqm_override, + .. + } = circuit.as_ref() + else { + continue; + }; + active_circuits.insert( + *circuit_hash, + PlannedCircuitUpdate { + queue: identity_entry.queue, + parent_site: future_parent_site.or(parent_site), + command: Arc::new(BakeryCommands::AddCircuit { + circuit_hash: *circuit_hash, + parent_class_id: parent_handles.0, + up_parent_class_id: parent_handles.1, + class_minor: identity_entry.class_minor, + download_bandwidth_min: *download_bandwidth_min, + upload_bandwidth_min: *upload_bandwidth_min, + download_bandwidth_max: *download_bandwidth_max, + upload_bandwidth_max: *upload_bandwidth_max, + class_major: identity_entry.class_major, + up_class_major: identity_entry.up_class_major, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: ip_addresses.clone(), + sqm_override: sqm_override.clone(), + }), + }, + ); + } + active_circuits.retain(|hash, _| saved_circuits.contains_key(hash)); + + Ok(TopLevelVirtualizationPlan { + saved_sites, + saved_circuits, + active_sites, + active_circuits, + }) +} + +fn apply_site_command_updates( + config: &Arc, + sites: &mut HashMap>, + updates: &HashMap, + action_label: &str, +) -> Result<(), String> { + if updates.is_empty() { + return Ok(()); + } + let mut ordered: Vec<_> = updates.iter().collect(); + ordered.sort_by_key(|(_, update)| { + ( + update.parent_site.is_some(), + update.queue, + update.parent_site.unwrap_or_default(), + site_hash_from_command(update.command.as_ref()).unwrap_or_default(), + ) + }); + let mut commands = Vec::new(); + for (_, update) in &ordered { + if let Some(cmds) = update.command.to_commands(config, ExecutionMode::Builder) { + commands.extend(cmds); + } + } + if !commands.is_empty() { + let result = execute_and_record_live_change(&commands, action_label); + if !result.ok { + return Err(summarize_apply_result(action_label, &result)); + } + } + for (site_hash, update) in ordered { + sites.insert(*site_hash, Arc::clone(&update.command)); + } + Ok(()) +} + +fn apply_circuit_command_updates( + config: &Arc, + circuits: &mut HashMap>, + updates: &HashMap, + live_circuits: &HashMap, + mq_layout: &Option, + qdisc_handles: &mut QdiscHandleState, + migrations: &mut HashMap, + action_label: &str, +) -> Result<(), String> { + if updates.is_empty() { + return Ok(()); + } + let Some(layout) = mq_layout.as_ref() else { + return Err("Bakery runtime virtualization requires MQ layout to be available".to_string()); + }; + let mut immediate_commands = Vec::new(); + let mut updated_circuits = Vec::new(); + let mut ordered: Vec<_> = updates.iter().collect(); + ordered.sort_by_key(|(_, update)| { + ( + update.parent_site.is_none(), + update.queue, + update.parent_site.unwrap_or_default(), + circuit_hash_from_command(update.command.as_ref()).unwrap_or_default(), + ) + }); + + for (circuit_hash, update) in ordered { + let Some(old_cmd) = circuits.get(circuit_hash).cloned() else { + continue; + }; + let enriched_cmd = rotate_changed_qdisc_handles( + old_cmd.as_ref(), + &update.command, + config, + layout, + qdisc_handles, + ); + if live_circuits.contains_key(circuit_hash) + && let BakeryCommands::AddCircuit { + parent_class_id, + up_parent_class_id, + .. + } = enriched_cmd.as_ref() + && find_free_minor(circuits, parent_class_id, up_parent_class_id).is_none() + { + return Err(format!( + "Unable to queue live migration for active circuit {}: no shadow minor available", + circuit_hash + )); + } + if queue_live_migration( + old_cmd.as_ref(), + &enriched_cmd, + circuits, + live_circuits, + migrations, + ) { + continue; + } + if live_circuits.contains_key(circuit_hash) { + return Err(format!( + "Bakery runtime virtualization could not live-migrate active circuit {}", + circuit_hash + )); + } + match config.queues.lazy_queues.as_ref() { + None | Some(LazyQueueMode::No) | Some(LazyQueueMode::Htb) => { + if let Some(prune) = old_cmd.to_prune(config, true) { + immediate_commands.extend(prune); + } + if let Some(add) = enriched_cmd.to_commands(config, ExecutionMode::Builder) { + immediate_commands.extend(add); + } + } + Some(LazyQueueMode::Full) => {} + } + updated_circuits.push((*circuit_hash, enriched_cmd)); + } + + if !immediate_commands.is_empty() { + let result = execute_and_record_live_change(&immediate_commands, action_label); + if !result.ok { + return Err(summarize_apply_result(action_label, &result)); + } + } + for (circuit_hash, command) in updated_circuits { + circuits.insert(circuit_hash, command); + } + Ok(()) +} + +fn apply_runtime_virtualization_overlay( + batch: Vec>, + virtualized_sites: &HashMap, +) -> Vec> { + if virtualized_sites.is_empty() { + return batch; + } + + let mut hidden_site_hashes = HashSet::new(); + let mut active_site_overrides: HashMap> = HashMap::new(); + let mut active_circuit_overrides: HashMap> = HashMap::new(); + for (site_hash, state) in virtualized_sites { + hidden_site_hashes.insert(*site_hash); + for (hash, command) in &state.active_sites { + active_site_overrides.insert(*hash, Arc::clone(command)); + } + for (hash, command) in &state.active_circuits { + active_circuit_overrides.insert(*hash, Arc::clone(command)); + } + } + + batch + .into_iter() + .filter_map(|command| match command.as_ref() { + BakeryCommands::AddSite { site_hash, .. } => { + if hidden_site_hashes.contains(site_hash) { + return None; + } + if let Some(override_cmd) = active_site_overrides.get(site_hash) { + return Some(Arc::clone(override_cmd)); + } + Some(command) + } + BakeryCommands::AddCircuit { circuit_hash, .. } => { + if let Some(override_cmd) = active_circuit_overrides.get(circuit_hash) { + return Some(Arc::clone(override_cmd)); + } + Some(command) + } + _ => Some(command), + }) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn handle_treeguard_set_node_virtual_live( + site_hash: i64, + virtualized: bool, + sites: &mut HashMap>, + circuits: &mut HashMap>, + live_circuits: &HashMap, + mq_layout: &Option, + qdisc_handles: &mut QdiscHandleState, + migrations: &mut HashMap, + virtualized_sites: &mut HashMap, +) -> Result<(), String> { + let Ok(config) = lqos_config::load_config() else { + return Err("Failed to load configuration".to_string()); + }; + + if virtualized { + if virtualized_sites.contains_key(&site_hash) { + return Ok(()); + } + + let Some(target_site) = sites.get(&site_hash).cloned() else { + return Err(format!("Unknown site hash {}", site_hash)); + }; + if site_is_top_level(target_site.as_ref()) { + let plan = build_top_level_virtualization_plan( + Arc::clone(&target_site), + sites, + circuits, + site_stick_offset(target_site.as_ref()), + )?; + apply_site_command_updates( + &config, + sites, + &plan.active_sites, + "TreeGuard runtime top-level site reparent", + )?; + apply_circuit_command_updates( + &config, + circuits, + &plan.active_circuits, + live_circuits, + mq_layout, + qdisc_handles, + migrations, + "TreeGuard runtime top-level circuit reparent", + )?; + if let Some(prune) = site_prune_commands(&config, target_site.as_ref()) { + let result = + execute_and_record_live_change(&prune, "TreeGuard runtime top-level site prune"); + if !result.ok { + return Err(summarize_apply_result( + "TreeGuard runtime top-level site prune", + &result, + )); + } + } + sites.remove(&site_hash); + virtualized_sites.insert( + site_hash, + VirtualizedSiteState { + site: target_site, + saved_sites: plan.saved_sites, + saved_circuits: plan.saved_circuits, + active_sites: plan + .active_sites + .into_iter() + .map(|(hash, update)| (hash, update.command)) + .collect(), + active_circuits: plan + .active_circuits + .into_iter() + .map(|(hash, update)| (hash, update.command)) + .collect(), + }, + ); + return Ok(()); + } + + if let Some(reason) = site_runtime_virtualization_eligibility_error(target_site.as_ref()) { + return Err(reason); + } + let Some((target_down_class, target_up_class)) = site_class_handles(target_site.as_ref()) + else { + return Err(format!("Site {} is not a valid AddSite command", site_hash)); + }; + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + .. + } = target_site.as_ref() + else { + return Err(format!("Site {} is not a valid AddSite command", site_hash)); + }; + + let child_sites = + collect_direct_child_site_hashes(sites, target_down_class, target_up_class, site_hash); + let direct_circuits = + collect_direct_circuit_hashes(circuits, target_down_class, target_up_class); + let saved_sites: HashMap> = child_sites + .iter() + .filter_map(|hash| sites.get(hash).cloned().map(|cmd| (*hash, cmd))) + .collect(); + let saved_circuits: HashMap> = direct_circuits + .iter() + .filter_map(|hash| circuits.get(hash).cloned().map(|cmd| (*hash, cmd))) + .collect(); + + let mut active_sites = HashMap::new(); + for child_hash in &child_sites { + let Some(child_site) = sites.get(child_hash).cloned() else { + continue; + }; + let Some(updated_site) = + reparent_site_command(&child_site, *parent_class_id, *up_parent_class_id) + else { + continue; + }; + active_sites.insert( + *child_hash, + PlannedSiteUpdate { + queue: current_site_queue(updated_site.as_ref()).unwrap_or(1), + parent_site: Some(site_hash), + command: updated_site, + }, + ); + } + apply_site_command_updates( + &config, + sites, + &active_sites, + "TreeGuard runtime site reparent", + )?; + + apply_direct_circuit_reparents( + &config, + &direct_circuits, + *parent_class_id, + *up_parent_class_id, + circuits, + live_circuits, + mq_layout, + qdisc_handles, + migrations, + )?; + + if let Some(prune) = site_prune_commands(&config, target_site.as_ref()) { + let result = execute_and_record_live_change(&prune, "TreeGuard runtime site prune"); + if !result.ok { + return Err(summarize_apply_result( + "TreeGuard runtime site prune", + &result, + )); + } + } + sites.remove(&site_hash); + virtualized_sites.insert( + site_hash, + VirtualizedSiteState { + site: target_site, + saved_sites, + saved_circuits, + active_sites: active_sites + .into_iter() + .map(|(hash, update)| (hash, update.command)) + .collect(), + active_circuits: direct_circuits + .iter() + .filter_map(|hash| circuits.get(hash).cloned().map(|cmd| (*hash, cmd))) + .collect(), + }, + ); + return Ok(()); + } + + let Some(saved_state) = virtualized_sites.get(&site_hash).cloned() else { + return Ok(()); + }; + if !site_is_top_level(saved_state.site.as_ref()) + && let Some(reason) = site_runtime_virtualization_eligibility_error(saved_state.site.as_ref()) + { + return Err(reason); + } + + if let Some(cmds) = saved_state.site.to_commands(&config, ExecutionMode::Builder) { + let result = execute_and_record_live_change(&cmds, "TreeGuard runtime hidden site restore"); + if !result.ok { + return Err(summarize_apply_result( + "TreeGuard runtime hidden site restore", + &result, + )); + } + } + sites.insert(site_hash, saved_state.site.clone()); + + let restore_sites: HashMap = saved_state + .saved_sites + .iter() + .map(|(hash, command)| { + ( + *hash, + PlannedSiteUpdate { + queue: current_site_queue(command.as_ref()).unwrap_or(1), + parent_site: None, + command: Arc::clone(command), + }, + ) + }) + .collect(); + apply_site_command_updates( + &config, + sites, + &restore_sites, + "TreeGuard runtime site restore", + )?; + + let restore_circuits: HashMap = saved_state + .saved_circuits + .iter() + .map(|(hash, command)| { + ( + *hash, + PlannedCircuitUpdate { + queue: current_circuit_queue(command.as_ref()).unwrap_or(1), + parent_site: None, + command: Arc::clone(command), + }, + ) + }) + .collect(); + apply_circuit_command_updates( + &config, + circuits, + &restore_circuits, + live_circuits, + mq_layout, + qdisc_handles, + migrations, + "TreeGuard runtime circuit restore", + )?; + + virtualized_sites.remove(&site_hash); + Ok(()) +} + #[allow(clippy::too_many_arguments)] fn full_reload( batch: &mut Option>>, @@ -2537,40 +4559,68 @@ fn full_reload( "Full reload triggered due to site or circuit changes.".to_string(), ); let _reload_scope = FullReloadScope; - sites.clear(); - let previous_circuits = std::mem::take(circuits); - live_circuits.clear(); - if let Some(layout) = resolved_mq_layout.as_ref() { - process_batch( - new_batch, - config, - sites, - circuits, - &previous_circuits, - layout, - qdisc_handles, - ); - let active_hashes = circuits.keys().copied().collect::>(); - qdisc_handles.retain_circuits(&config.isp_interface(), &active_hashes); - if !config.on_a_stick_mode() { - qdisc_handles.retain_circuits(&config.internet_interface(), &active_hashes); + let previous_sites = sites.clone(); + let previous_circuits = circuits.clone(); + let previous_live_circuits = live_circuits.clone(); + let previous_mq_layout = mq_layout.clone(); + + let live_reserved_handles = match snapshot_live_qdisc_handle_majors(config) { + Ok(handles) => handles, + Err(error) => { + let summary = + format!("Failed to snapshot live qdisc handles before full reload: {error}"); + error!("{summary}"); + mark_bakery_action_finished(BakeryApplyMetrics { + apply_type: BakeryApplyType::FullReload, + summary: &summary, + build_duration_ms: 0, + apply_duration_ms: 0, + total_tc_commands: 0, + class_commands: 0, + qdisc_commands: 0, + ok: false, + }); + *batch = None; + return; } + }; + + let mut working_sites = HashMap::new(); + let mut working_circuits = HashMap::new(); + let mut working_qdisc_handles = QdiscHandleState::default(); + let layout = resolved_mq_layout.clone().unwrap_or_default(); + if resolved_mq_layout.is_none() { + warn!("Bakery: full reload skipped MQ layout restore because layout is unknown"); + } + + let result = process_batch( + new_batch, + config, + &mut working_sites, + &mut working_circuits, + &layout, + &mut working_qdisc_handles, + &live_reserved_handles, + ); + + if result.ok { + *sites = working_sites; + *circuits = working_circuits; + live_circuits.clear(); + *qdisc_handles = working_qdisc_handles; qdisc_handles.save(config); - *mq_layout = resolved_mq_layout; + if resolved_mq_layout.is_some() { + *mq_layout = resolved_mq_layout; + } + FIRST_COMMIT_APPLIED.store(true, Ordering::Relaxed); + apply_stormguard_overrides(stormguard_overrides, config); } else { - warn!("Bakery: full reload skipped qdisc-handle assignment because MQ layout is unknown"); - process_batch( - new_batch, - config, - sites, - circuits, - &previous_circuits, - &MqDeviceLayout::default(), - qdisc_handles, - ); + *sites = previous_sites; + *circuits = previous_circuits; + *live_circuits = previous_live_circuits; + *mq_layout = previous_mq_layout; } *batch = None; - apply_stormguard_overrides(stormguard_overrides, config); } fn process_batch( @@ -2578,29 +4628,23 @@ fn process_batch( config: &Arc, sites: &mut HashMap>, circuits: &mut HashMap>, - previous_circuits: &HashMap>, mq_layout: &MqDeviceLayout, qdisc_handles: &mut QdiscHandleState, -) { + extra_reserved_handles: &HashMap>, +) -> ExecuteResult { info!("Bakery: Processing batch of {} commands", batch.len()); + update_bakery_apply_progress(Some("Building tc command batch"), 0, 0, 0, 0); let build_started = std::time::Instant::now(); let mut circuit_count = 0u64; let commands = batch .into_iter() .map(|b| { - let enriched = with_assigned_qdisc_handles(&b, config, mq_layout, qdisc_handles); - let BakeryCommands::AddCircuit { circuit_hash, .. } = enriched.as_ref() else { - return enriched; - }; - let Some(previous) = previous_circuits.get(circuit_hash) else { - return enriched; - }; - rotate_changed_qdisc_handles( - previous.as_ref(), - &enriched, + with_assigned_qdisc_handles_reserved( + &b, config, mq_layout, qdisc_handles, + extra_reserved_handles, ) }) .filter_map(|b| { @@ -2624,8 +4668,35 @@ fn process_batch( write_command_file(&path, &commands); let build_duration_ms = build_started.elapsed().as_millis() as u64; let (total_tc_commands, class_commands, qdisc_commands) = count_tc_command_types(&commands); - let result = execute_in_memory(&commands, "processing batch"); + let total_chunks = if total_tc_commands == 0 { + 0 + } else { + total_tc_commands.div_ceil(FULL_RELOAD_TC_CHUNK_SIZE) + }; + update_bakery_apply_progress( + Some("Applying tc command chunks"), + total_tc_commands, + 0, + total_chunks, + 0, + ); + let result = execute_in_memory_chunked( + &commands, + "processing batch", + FULL_RELOAD_TC_CHUNK_SIZE, + Some(BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES), + |completed_tc_commands, total_tc_commands, completed_chunks, total_chunks| { + update_bakery_apply_progress( + Some("Applying tc command chunks"), + total_tc_commands, + completed_tc_commands, + total_chunks, + completed_chunks, + ); + }, + ); let summary = summarize_apply_result("processing batch", &result); + maybe_emit_memory_guard_urgent(&summary); mark_bakery_action_finished(BakeryApplyMetrics { apply_type: BakeryApplyType::FullReload, summary: &summary, @@ -2637,8 +4708,7 @@ fn process_batch( ok: result.ok, }); - // Mark that at least one batch has been applied, unblocking live activation. - FIRST_COMMIT_APPLIED.store(true, Ordering::Relaxed); + result } fn apply_stormguard_overrides( @@ -2700,6 +4770,24 @@ mod tests { }) } + fn mk_add_site( + site_hash: i64, + parent_class_id: u32, + up_parent_class_id: u32, + class_minor: u16, + ) -> Arc { + Arc::new(BakeryCommands::AddSite { + site_hash, + parent_class_id: TcHandle::from_u32(parent_class_id), + up_parent_class_id: TcHandle::from_u32(up_parent_class_id), + class_minor, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + }) + } + fn test_config_with_runtime_dir(name: &str) -> Arc { let mut cfg = Config::default(); let ts = SystemTime::now() @@ -2712,6 +4800,33 @@ mod tests { Arc::new(cfg) } + fn mk_test_circuit( + circuit_hash: i64, + parent_class_id: u32, + up_parent_class_id: u32, + class_minor: u16, + class_major: u16, + up_class_major: u16, + ip_addresses: &str, + ) -> Arc { + Arc::new(BakeryCommands::AddCircuit { + circuit_hash, + parent_class_id: TcHandle::from_u32(parent_class_id), + up_parent_class_id: TcHandle::from_u32(up_parent_class_id), + class_minor, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major, + up_class_major, + down_qdisc_handle: Some(0x9000), + up_qdisc_handle: Some(0x9001), + ip_addresses: ip_addresses.to_string(), + sqm_override: None, + }) + } + fn has_hash(batch: &[Arc], hash: i64) -> bool { batch.iter().any(|cmd| { matches!( @@ -2784,6 +4899,205 @@ mod tests { assert_eq!(filtered.len(), 1001); } + #[test] + fn runtime_virtualization_overlay_hides_virtualized_site_and_reparents_children() { + let parent_site = mk_add_site(10, 0x10001, 0x20001, 0x20); + let virtualized_site = mk_add_site(20, 0x10020, 0x20020, 0x21); + let child_site = mk_add_site(30, 0x10021, 0x20021, 0x22); + let child_circuit = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 40, + parent_class_id: TcHandle::from_u32(0x10021), + up_parent_class_id: TcHandle::from_u32(0x20021), + class_minor: 0x30, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x110, + up_class_major: 0x210, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.40/32".to_string(), + sqm_override: None, + }); + + let mut virtualized_sites = HashMap::new(); + virtualized_sites.insert( + 20, + VirtualizedSiteState { + site: virtualized_site.clone(), + saved_sites: HashMap::new(), + saved_circuits: HashMap::new(), + active_sites: HashMap::from([( + 30, + Arc::new(BakeryCommands::AddSite { + site_hash: 30, + parent_class_id: TcHandle::from_u32(0x10020), + up_parent_class_id: TcHandle::from_u32(0x20020), + class_minor: 0x22, + download_bandwidth_min: 50.0, + upload_bandwidth_min: 50.0, + download_bandwidth_max: 500.0, + upload_bandwidth_max: 500.0, + }), + )]), + active_circuits: HashMap::from([( + 40, + Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 40, + parent_class_id: TcHandle::from_u32(0x10020), + up_parent_class_id: TcHandle::from_u32(0x20020), + class_minor: 0x30, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x110, + up_class_major: 0x210, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.40/32".to_string(), + sqm_override: None, + }), + )]), + }, + ); + + let overlaid = apply_runtime_virtualization_overlay( + vec![parent_site, virtualized_site, child_site, child_circuit], + &virtualized_sites, + ); + + assert_eq!(overlaid.len(), 3); + assert!(!overlaid.iter().any(|cmd| matches!( + cmd.as_ref(), + BakeryCommands::AddSite { site_hash, .. } if *site_hash == 20 + ))); + + let child_site = overlaid + .iter() + .find_map(|cmd| match cmd.as_ref() { + BakeryCommands::AddSite { + site_hash, + parent_class_id, + up_parent_class_id, + .. + } if *site_hash == 30 => Some((*parent_class_id, *up_parent_class_id)), + _ => None, + }) + .expect("child site should remain in batch"); + assert_eq!(child_site.0, TcHandle::from_u32(0x10020)); + assert_eq!(child_site.1, TcHandle::from_u32(0x20020)); + + let child_circuit = overlaid + .iter() + .find_map(|cmd| match cmd.as_ref() { + BakeryCommands::AddCircuit { + circuit_hash, + parent_class_id, + up_parent_class_id, + .. + } if *circuit_hash == 40 => Some((*parent_class_id, *up_parent_class_id)), + _ => None, + }) + .expect("child circuit should remain in batch"); + assert_eq!(child_circuit.0, TcHandle::from_u32(0x10020)); + assert_eq!(child_circuit.1, TcHandle::from_u32(0x20020)); + } + + #[test] + fn top_level_virtualization_plan_promotes_children_and_direct_circuits() { + let target_site = mk_add_site(20, 0x10000, 0x20000, 0x21); + let sibling_site = mk_add_site(10, 0x10000, 0x20000, 0x20); + let child_site = mk_add_site(30, 0x10021, 0x20021, 0x22); + let grandchild_site = mk_add_site(31, 0x10022, 0x20022, 0x23); + let direct_circuit = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 40, + parent_class_id: TcHandle::from_u32(0x10021), + up_parent_class_id: TcHandle::from_u32(0x20021), + class_minor: 0x30, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x1, + up_class_major: 0x2, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.40/32".to_string(), + sqm_override: None, + }); + + let mut sites = HashMap::new(); + sites.insert(10, sibling_site); + sites.insert(20, Arc::clone(&target_site)); + sites.insert(30, child_site); + sites.insert(31, grandchild_site); + + let mut circuits = HashMap::new(); + circuits.insert(40, direct_circuit); + + let plan = + build_top_level_virtualization_plan(Arc::clone(&target_site), &sites, &circuits, 1) + .expect("top-level plan should build"); + + assert!(plan.saved_sites.contains_key(&30)); + assert!(plan.saved_sites.contains_key(&31)); + assert!(plan.saved_circuits.contains_key(&40)); + + let promoted_site = plan + .active_sites + .get(&30) + .expect("promoted child site should be rewritten"); + let BakeryCommands::AddSite { + parent_class_id, + up_parent_class_id, + .. + } = promoted_site.command.as_ref() + else { + panic!("expected AddSite"); + }; + assert_eq!(parent_class_id.get_major_minor().1, 0); + assert_eq!(up_parent_class_id.get_major_minor().1, 0); + + let promoted_circuit = plan + .active_circuits + .get(&40) + .expect("direct circuit should be rewritten"); + let BakeryCommands::AddCircuit { + parent_class_id, + up_parent_class_id, + .. + } = promoted_circuit.command.as_ref() + else { + panic!("expected AddCircuit"); + }; + assert_eq!(parent_class_id.get_major_minor().1, 0); + assert_eq!(up_parent_class_id.get_major_minor().1, 0); + } + + #[test] + fn site_runtime_virtualization_rejects_top_level_sites() { + let site = mk_add_site(99, 0x10000, 0x20000, 0x21); + let reason = site_runtime_virtualization_eligibility_error(site.as_ref()) + .expect("top-level site should be rejected"); + assert!(reason.contains("top-level")); + } + + #[test] + fn site_runtime_virtualization_accepts_normal_same_queue_site() { + let site = mk_add_site(77, 0x10020, 0x20020, 0x22); + assert!(site_runtime_virtualization_eligibility_error(site.as_ref()).is_none()); + } + + #[test] + fn site_runtime_virtualization_rejects_non_site_commands() { + let circuit = mk_add_circuit(77, "192.0.2.77/32"); + let reason = site_runtime_virtualization_eligibility_error(circuit.as_ref()) + .expect("non-site commands should be rejected"); + assert!(reason.contains("AddSite command")); + } + #[test] fn mapped_circuit_limit_uses_custom_limit() { let existing = HashMap::new(); @@ -2859,6 +5173,34 @@ mod tests { let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); assert_eq!(estimate.interfaces.get("eth1"), Some(&8)); assert_eq!(estimate.interfaces.get("eth0"), Some(&8)); + assert_eq!( + estimate + .interface_details + .get("eth0") + .map(|d| d.cake_qdiscs), + Some(1) + ); + assert_eq!( + estimate + .interface_details + .get("eth1") + .map(|d| d.cake_qdiscs), + Some(1) + ); + assert_eq!( + estimate + .interface_details + .get("eth0") + .map(|d| d.infra_qdiscs), + Some(7) + ); + assert_eq!( + estimate + .interface_details + .get("eth1") + .map(|d| d.infra_qdiscs), + Some(7) + ); assert!(estimate.ok()); } @@ -2893,6 +5235,70 @@ mod tests { let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); assert_eq!(estimate.interfaces.get("eth1"), Some(&4)); assert_eq!(estimate.interfaces.get("eth0"), Some(&4)); + assert_eq!( + estimate + .interface_details + .get("eth0") + .map(|d| d.cake_qdiscs), + Some(0) + ); + assert_eq!( + estimate + .interface_details + .get("eth1") + .map(|d| d.cake_qdiscs), + Some(0) + ); + } + + #[test] + fn qdisc_budget_estimate_counts_fq_codel_leaf_qdiscs_separately() { + let config = Arc::new(Config::default()); + let queue = vec![ + BakeryCommands::MqSetup { + queues_available: 1, + stick_offset: 0, + }, + BakeryCommands::AddCircuit { + circuit_hash: 2, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 2_000.0, + upload_bandwidth_max: 2_000.0, + class_major: 0x100, + up_class_major: 0x200, + down_qdisc_handle: Some(0x9000), + up_qdisc_handle: Some(0x9001), + ip_addresses: "192.0.2.1/32".to_string(), + sqm_override: Some("fq_codel".to_string()), + }, + ]; + + let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); + assert_eq!( + estimate + .interface_details + .get("eth0") + .map(|d| d.fq_codel_qdiscs), + Some(1) + ); + assert_eq!( + estimate + .interface_details + .get("eth1") + .map(|d| d.fq_codel_qdiscs), + Some(1) + ); + assert_eq!( + estimate + .interface_details + .get("eth0") + .map(|d| d.cake_qdiscs), + Some(0) + ); } #[test] @@ -3105,4 +5511,151 @@ mod tests { }; assert_eq!(*new_down, *original_down); } + + #[test] + fn queue_live_migration_succeeds_for_active_parent_move() { + let old_cmd = mk_test_circuit(9001, 0x10020, 0x20020, 0x21, 0x1, 0x2, "192.0.2.90/32"); + let new_cmd = mk_test_circuit(9001, 0x10034, 0x20034, 0x35, 0x3, 0x4, "192.0.2.90/32"); + + let mut circuits = HashMap::from([(9001, Arc::clone(&old_cmd))]); + let live_circuits = HashMap::from([(9001, 1u64)]); + let mut migrations = HashMap::new(); + + let queued = queue_live_migration( + old_cmd.as_ref(), + &new_cmd, + &mut circuits, + &live_circuits, + &mut migrations, + ); + + assert!(queued); + let migration = migrations.get(&9001).expect("migration should be queued"); + assert_eq!(migration.stage, MigrationStage::PrepareShadow); + assert_eq!(migration.old_parent_class_id, TcHandle::from_u32(0x10020)); + assert_eq!(migration.parent_class_id, TcHandle::from_u32(0x10034)); + assert_eq!(migration.final_minor, 0x35); + assert_ne!(migration.shadow_minor, migration.old_minor); + assert!(matches!( + circuits.get(&9001), + Some(cmd) if Arc::ptr_eq(cmd, &new_cmd) + )); + } + + #[test] + fn queue_live_migration_fails_when_target_parent_has_no_shadow_minor() { + let old_cmd = mk_test_circuit(9100, 0x10020, 0x20020, 0x21, 0x1, 0x2, "192.0.2.91/32"); + let new_cmd = mk_test_circuit(9100, 0x10034, 0x20034, 0x35, 0x3, 0x4, "192.0.2.91/32"); + + let mut circuits = HashMap::from([(9100, Arc::clone(&old_cmd))]); + for minor in 1..=0xFFFEu16 { + circuits.insert( + 100_000 + i64::from(minor), + mk_test_circuit( + 100_000 + i64::from(minor), + 0x10034, + 0x20034, + minor, + 0x3, + 0x4, + &format!("198.51.100.{}/32", (minor % 250) + 1), + ), + ); + } + + let live_circuits = HashMap::from([(9100, 1u64)]); + let mut migrations = HashMap::new(); + + let queued = queue_live_migration( + old_cmd.as_ref(), + &new_cmd, + &mut circuits, + &live_circuits, + &mut migrations, + ); + + assert!(!queued); + assert!(!migrations.contains_key(&9100)); + assert!(matches!( + circuits.get(&9100), + Some(cmd) if Arc::ptr_eq(cmd, &old_cmd) + )); + } + + #[test] + fn full_reload_allocation_uses_fresh_handles_reserved_from_live_tree() { + let config = test_config_with_runtime_dir("fresh-full-reload"); + let layout = MqDeviceLayout::from_setup(&config, 2, 0); + let mut persisted = QdiscHandleState::default(); + + let original = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 505, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.5/32".to_string(), + sqm_override: None, + }); + + let original = with_assigned_qdisc_handles(&original, &config, &layout, &mut persisted); + let BakeryCommands::AddCircuit { + down_qdisc_handle: Some(original_down), + up_qdisc_handle: Some(original_up), + .. + } = original.as_ref() + else { + panic!("expected assigned handles"); + }; + + let mut full_reload_handles = QdiscHandleState::default(); + let live_reserved = HashMap::from([ + (config.isp_interface(), HashSet::from([*original_down])), + (config.internet_interface(), HashSet::from([*original_up])), + ]); + let rebuilt_cmd = Arc::new(BakeryCommands::AddCircuit { + circuit_hash: 505, + parent_class_id: TcHandle::from_u32(0x10001), + up_parent_class_id: TcHandle::from_u32(0x20001), + class_minor: 0x20, + download_bandwidth_min: 10.0, + upload_bandwidth_min: 10.0, + download_bandwidth_max: 100.0, + upload_bandwidth_max: 100.0, + class_major: 0x100, + up_class_major: 0x200, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses: "192.0.2.5/32".to_string(), + sqm_override: None, + }); + let rebuilt = with_assigned_qdisc_handles_reserved( + &rebuilt_cmd, + &config, + &layout, + &mut full_reload_handles, + &live_reserved, + ); + + let BakeryCommands::AddCircuit { + down_qdisc_handle: Some(rebuilt_down), + up_qdisc_handle: Some(rebuilt_up), + .. + } = rebuilt.as_ref() + else { + panic!("expected rebuilt handles"); + }; + + assert_ne!(*rebuilt_down, *original_down); + assert_ne!(*rebuilt_up, *original_up); + assert_eq!(*rebuilt_down, *original_down + 1); + assert_eq!(*rebuilt_up, *original_up + 1); + } } diff --git a/src/rust/lqos_bakery/src/qdisc_handles.rs b/src/rust/lqos_bakery/src/qdisc_handles.rs index 42ebec07..7b7a93bc 100644 --- a/src/rust/lqos_bakery/src/qdisc_handles.rs +++ b/src/rust/lqos_bakery/src/qdisc_handles.rs @@ -261,13 +261,6 @@ impl QdiscHandleState { .and_then(|state| state.remove(circuit_hash)) .is_some() } - - /// Drops persisted handle assignments for circuits no longer present on an interface. - pub(crate) fn retain_circuits(&mut self, interface: &str, active: &HashSet) { - if let Some(state) = self.interfaces.get_mut(interface) { - state.retain_circuits(active); - } - } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -322,20 +315,6 @@ impl InterfaceHandleState { Some(removed) } - fn retain_circuits(&mut self, active: &HashSet) { - let removed = self - .circuits - .iter() - .filter_map(|(hash, handle)| (!active.contains(hash)).then_some((*hash, *handle))) - .collect::>(); - - for (hash, handle) in removed { - self.circuits.remove(&hash); - self.owners.remove(&handle); - self.note_reusable_handle(handle); - } - } - fn note_reusable_handle(&mut self, handle: u16) { if handle >= PREFERRED_CIRCUIT_START || self.next_candidate < PREFERRED_CIRCUIT_START { self.next_candidate = self.next_candidate.min(handle); diff --git a/src/rust/lqos_bakery/src/utils.rs b/src/rust/lqos_bakery/src/utils.rs index 3fc09a7b..dae1c5a4 100644 --- a/src/rust/lqos_bakery/src/utils.rs +++ b/src/rust/lqos_bakery/src/utils.rs @@ -1,4 +1,6 @@ +use lqos_bus::TcHandle; use parking_lot::Mutex; +use std::collections::HashSet; use std::fs::File; use std::io::{BufWriter, Write}; use std::path::Path; @@ -21,18 +23,158 @@ pub(crate) struct ExecuteResult { pub(crate) failure_summary: Option, } -pub(crate) fn execute_in_memory(command_buffer: &Vec>, purpose: &str) -> ExecuteResult { +/// Lightweight host-memory snapshot used by Bakery safety checks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MemorySnapshot { + /// Total installed RAM in bytes. + pub total_bytes: u64, + /// Currently available RAM in bytes. + pub available_bytes: u64, +} + +fn format_numbered_lines(lines: &str, starting_line_number: usize) -> String { + let mut numbered = String::new(); + for (i, line) in lines.lines().enumerate() { + numbered.push_str(&format!("{:>4}: {}\n", starting_line_number + i, line)); + } + numbered +} + +fn run_tc_batch(path: &Path, purpose: &str) -> Result { + std::process::Command::new("/sbin/tc") + .args(["-f", "-batch", path.to_str().unwrap_or_default()]) + .output() + .map_err(|_| format!("Failed to execute tc batch command for {purpose}.")) +} + +pub(crate) fn read_memory_snapshot() -> Result { + let raw = std::fs::read_to_string("/proc/meminfo") + .map_err(|e| format!("Failed to read /proc/meminfo: {e}"))?; + parse_memory_snapshot(&raw) +} + +fn parse_memory_snapshot(raw: &str) -> Result { + let mut total_kib = None; + let mut available_kib = None; + + for line in raw.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let kib = value + .split_whitespace() + .next() + .and_then(|n| n.parse::().ok()); + match key.trim() { + "MemTotal" => total_kib = kib, + "MemAvailable" => available_kib = kib, + _ => {} + } + } + + let total_kib = total_kib.ok_or_else(|| "MemTotal missing from /proc/meminfo".to_string())?; + let available_kib = + available_kib.ok_or_else(|| "MemAvailable missing from /proc/meminfo".to_string())?; + + Ok(MemorySnapshot { + total_bytes: total_kib.saturating_mul(1024), + available_bytes: available_kib.saturating_mul(1024), + }) +} + +fn memory_guard_failure_summary( + snapshot: MemorySnapshot, + min_available_bytes: u64, + purpose: &str, + chunk_number: usize, + total_chunks: usize, + phase: &str, +) -> String { + format!( + "Bakery memory guard stopped {purpose} {phase} chunk {}/{}: available memory {} bytes is below safety floor {} bytes (total RAM {} bytes).", + chunk_number, + total_chunks, + snapshot.available_bytes, + min_available_bytes, + snapshot.total_bytes + ) +} + +pub(crate) fn read_live_qdisc_handle_majors(interface: &str) -> Result, String> { + let output = std::process::Command::new("/sbin/tc") + .args(["-s", "-j", "qdisc", "show", "dev", interface]) + .output() + .map_err(|e| format!("Failed to snapshot live qdiscs on {interface}: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "Failed to snapshot live qdiscs on {interface}: {}", + stderr.trim() + )); + } + + let stdout = String::from_utf8(output.stdout) + .map_err(|e| format!("Live qdisc snapshot on {interface} was not UTF-8: {e}"))?; + + parse_live_qdisc_handle_majors(&stdout) + .map_err(|e| format!("Failed to parse live qdisc snapshot on {interface}: {e}")) +} + +fn parse_live_qdisc_handle_majors(raw_json: &str) -> Result, String> { + let parsed = serde_json::from_str::(raw_json) + .map_err(|e| format!("invalid JSON: {e}"))?; + let items = parsed + .as_array() + .ok_or_else(|| "expected JSON array from tc qdisc show -j".to_string())?; + + let mut handles = HashSet::new(); + for item in items { + let Some(handle) = item.get("handle").and_then(|value| value.as_str()) else { + continue; + }; + let Ok(tc_handle) = TcHandle::from_string(handle) else { + continue; + }; + let (major, _) = tc_handle.get_major_minor(); + if major != 0 { + handles.insert(major); + } + } + + Ok(handles) +} + +pub(crate) fn execute_in_memory(command_buffer: &[Vec], purpose: &str) -> ExecuteResult { + execute_in_memory_chunked( + command_buffer, + purpose, + command_buffer.len().max(1), + None, + |_, _, _, _| {}, + ) +} + +pub(crate) fn execute_in_memory_chunked( + command_buffer: &[Vec], + purpose: &str, + chunk_size: usize, + memory_guard_min_available_bytes: Option, + mut on_progress: F, +) -> ExecuteResult +where + F: FnMut(usize, usize, usize, usize), +{ let started = std::time::Instant::now(); - let lock = FILE_LOCK.lock(); + let _lock = FILE_LOCK.lock(); info!( "Bakery: Executing in-memory commands: {} lines, for {purpose}", command_buffer.len() ); - let path = Path::new("/tmp/lqos_bakery_commands.txt"); - let Some(lines) = write_command_file(path, command_buffer) else { + let full_path = Path::new("/tmp/lqos_bakery_commands.txt"); + let Some(_) = write_command_file(full_path, command_buffer) else { error!("Failed to write commands to file for {purpose}"); - drop(lock); return ExecuteResult { ok: false, duration_ms: started.elapsed().as_millis() as u64, @@ -40,73 +182,173 @@ pub(crate) fn execute_in_memory(command_buffer: &Vec>, purpose: &str }; }; - let Ok(output) = std::process::Command::new("/sbin/tc") - .args(["-f", "-batch", path.to_str().unwrap_or_default()]) - .output() - else { - let message = format!("Failed to execute tc batch command for {purpose}."); - error!(message); - drop(lock); + if command_buffer.is_empty() { return ExecuteResult { - ok: false, + ok: true, duration_ms: started.elapsed().as_millis() as u64, - failure_summary: Some(message), + failure_summary: None, }; - }; - - let output_str = String::from_utf8_lossy(&output.stdout) - .replace("Error: Exclusivity flag on, cannot modify.\n", ""); - if !output_str.is_empty() { - error!("Command output for ({purpose}): {:?}", output_str.trim()); } - let error_str = String::from_utf8_lossy(&output.stderr); - if !error_str.is_empty() { - // Add line numbers to aid debugging with tc -batch's reported line numbers - let mut numbered = String::new(); - for (i, line) in lines.lines().enumerate() { - // tc -batch reports 1-based line numbers - numbered.push_str(&format!("{:>4}: {}\n", i + 1, line)); + let chunk_size = chunk_size.max(1); + let total_chunks = command_buffer.len().div_ceil(chunk_size); + let chunk_path = Path::new("/tmp/lqos_bakery_commands_chunk.txt"); + let mut completed_commands = 0usize; + let mut completed_chunks = 0usize; + + for chunk in command_buffer.chunks(chunk_size) { + if let Some(min_available_bytes) = memory_guard_min_available_bytes { + match read_memory_snapshot() { + Ok(snapshot) if snapshot.available_bytes < min_available_bytes => { + let summary = memory_guard_failure_summary( + snapshot, + min_available_bytes, + purpose, + completed_chunks + 1, + total_chunks, + "before", + ); + error!("{summary}"); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(summary), + }; + } + Ok(_) => {} + Err(err) => { + let summary = format!( + "Bakery memory guard could not read host memory state before chunk {}/{} for {purpose}: {err}", + completed_chunks + 1, + total_chunks + ); + error!("{summary}"); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(summary), + }; + } + } } - let detailed = format!( - "Command error for ({purpose}): {}\nCommands with line numbers:\n{}", - error_str.trim(), - numbered + + let global_line_start = completed_commands + 1; + let Some(lines) = write_command_file(chunk_path, chunk) else { + error!("Failed to write chunked commands to file for {purpose}"); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(format!("Failed to write commands to file for {purpose}")), + }; + }; + + let output = match run_tc_batch(chunk_path, purpose) { + Ok(output) => output, + Err(message) => { + error!(message); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(message), + }; + } + }; + + let output_str = String::from_utf8_lossy(&output.stdout) + .replace("Error: Exclusivity flag on, cannot modify.\n", ""); + if !output_str.is_empty() { + error!("Command output for ({purpose}): {:?}", output_str.trim()); + } + + let error_str = String::from_utf8_lossy(&output.stderr); + if !error_str.is_empty() { + let numbered = format_numbered_lines(&lines, global_line_start); + let chunk_line_end = global_line_start + chunk.len().saturating_sub(1); + let detailed = format!( + "Command error for ({purpose}): {}\nFailed chunk {}/{} (global lines {}-{})\nFull batch: {}\nChunk batch: {}\nChunk commands with global line numbers:\n{}", + error_str.trim(), + completed_chunks + 1, + total_chunks, + global_line_start, + chunk_line_end, + full_path.display(), + chunk_path.display(), + numbered + ); + error!(detailed); + + let ts = current_timestamp(); + let path_ts = Path::new("/tmp").join(format!("lqos_bakery_failed_{}.txt", ts)); + if let Ok(mut f) = File::create(&path_ts) { + let _ = f.write_all(detailed.as_bytes()); + let _ = f.flush(); + error!( + "Bakery wrote numbered command failure to {}", + path_ts.display() + ); + } else { + error!( + "Bakery failed to write numbered command failure file: {}", + path_ts.display() + ); + } + let path_last = Path::new("/tmp/lqos_bakery_last_error.txt"); + if let Ok(mut f) = File::create(path_last) { + let _ = f.write_all(detailed.as_bytes()); + let _ = f.flush(); + } + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(error_str.trim().to_string()), + }; + } + + completed_commands += chunk.len(); + completed_chunks += 1; + + if let Some(min_available_bytes) = memory_guard_min_available_bytes { + match read_memory_snapshot() { + Ok(snapshot) if snapshot.available_bytes < min_available_bytes => { + let summary = memory_guard_failure_summary( + snapshot, + min_available_bytes, + purpose, + completed_chunks, + total_chunks, + "after", + ); + error!("{summary}"); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(summary), + }; + } + Ok(_) => {} + Err(err) => { + let summary = format!( + "Bakery memory guard could not read host memory state after chunk {}/{} for {purpose}: {err}", + completed_chunks, total_chunks + ); + error!("{summary}"); + return ExecuteResult { + ok: false, + duration_ms: started.elapsed().as_millis() as u64, + failure_summary: Some(summary), + }; + } + } + } + + on_progress( + completed_commands, + command_buffer.len(), + completed_chunks, + total_chunks, ); - // Log to console - error!(detailed); - - // Also persist to /tmp for easier debugging of large batches - let ts = current_timestamp(); - let path_ts = Path::new("/tmp").join(format!("lqos_bakery_failed_{}.txt", ts)); - if let Ok(mut f) = File::create(&path_ts) { - let _ = f.write_all(detailed.as_bytes()); - let _ = f.flush(); - error!( - "Bakery wrote numbered command failure to {}", - path_ts.display() - ); - } else { - error!( - "Bakery failed to write numbered command failure file: {}", - path_ts.display() - ); - } - // Convenience: keep a rolling 'last error' file - let path_last = Path::new("/tmp/lqos_bakery_last_error.txt"); - if let Ok(mut f) = File::create(path_last) { - let _ = f.write_all(detailed.as_bytes()); - let _ = f.flush(); - } - drop(lock); - return ExecuteResult { - ok: false, - duration_ms: started.elapsed().as_millis() as u64, - failure_summary: Some(error_str.trim().to_string()), - }; } - drop(lock); // Explicitly drop the lock to release it. This happens automatically at the end of the scope, but it's good to be explicit. ExecuteResult { ok: true, duration_ms: started.elapsed().as_millis() as u64, @@ -114,7 +356,7 @@ pub(crate) fn execute_in_memory(command_buffer: &Vec>, purpose: &str } } -pub(crate) fn write_command_file(path: &Path, commands: &Vec>) -> Option { +pub(crate) fn write_command_file(path: &Path, commands: &[Vec]) -> Option { let mut lines = String::new(); let Ok(file) = File::create(path) else { error!("Failed to create output file: {}", path.display()); @@ -149,3 +391,56 @@ pub(crate) fn write_command_file(path: &Path, commands: &Vec>) -> Op } Some(lines) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_live_qdisc_handle_majors_collects_non_zero_handles() { + let raw = r#" +[ + { "kind": "mq", "handle": "7fff:", "parent": "root" }, + { "kind": "cake", "handle": "90f1:", "parent": "2:1039" }, + { "kind": "fq_codel", "handle": "50c0:", "parent": "8:24dd" }, + { "kind": "ingress", "handle": "ffff:", "parent": "ffff:fff1" }, + { "kind": "fq_codel", "handle": "0:", "parent": "3:20" } +] +"#; + let handles = parse_live_qdisc_handle_majors(raw).expect("handles parsed"); + assert!(handles.contains(&0x7fff)); + assert!(handles.contains(&0x90f1)); + assert!(handles.contains(&0x50c0)); + assert!(handles.contains(&0xffff)); + assert!(!handles.contains(&0)); + } + + #[test] + fn parse_live_qdisc_handle_majors_rejects_non_arrays() { + let err = parse_live_qdisc_handle_majors(r#"{"handle":"90f1:"}"#) + .expect_err("non-array should fail"); + assert!(err.contains("expected JSON array")); + } + + #[test] + fn parse_memory_snapshot_extracts_total_and_available() { + let raw = "\ +MemTotal: 32768000 kB +MemFree: 1024000 kB +MemAvailable: 8192000 kB +"; + let snapshot = parse_memory_snapshot(raw).expect("meminfo parsed"); + assert_eq!(snapshot.total_bytes, 32_768_000_u64 * 1024); + assert_eq!(snapshot.available_bytes, 8_192_000_u64 * 1024); + } + + #[test] + fn parse_memory_snapshot_requires_memavailable() { + let raw = "\ +MemTotal: 32768000 kB +MemFree: 1024000 kB +"; + let err = parse_memory_snapshot(raw).expect_err("memavailable should be required"); + assert!(err.contains("MemAvailable")); + } +} diff --git a/src/rust/lqos_bus/src/bus/request.rs b/src/rust/lqos_bus/src/bus/request.rs index 86071f0f..45349679 100644 --- a/src/rust/lqos_bus/src/bus/request.rs +++ b/src/rust/lqos_bus/src/bus/request.rs @@ -13,6 +13,14 @@ pub struct BakeryCapacityReportInterface { pub name: String, /// Planned qdisc count for the interface. pub planned_qdiscs: usize, + /// Planned infrastructure qdiscs (`mq`, `htb`, etc.) for the interface. + pub infra_qdiscs: usize, + /// Planned `cake` leaf qdiscs for the interface. + pub cake_qdiscs: usize, + /// Planned `fq_codel` leaf qdiscs for the interface. + pub fq_codel_qdiscs: usize, + /// Estimated kernel memory cost for the interface's planned qdiscs. + pub estimated_memory_bytes: u64, } /// Source system for an urgent issue @@ -191,6 +199,9 @@ pub enum BusRequest { /// Requests that the configuration be updated UpdateLqosdConfig(Box), + /// Invalidate cached Node Manager authentication state. + InvalidateAuthCache, + /// Request that we start watching a circuit's queue WatchQueue(String), @@ -379,6 +390,14 @@ pub enum BusRequest { safe_budget: usize, /// Hard kernel limit for reference. hard_limit: usize, + /// Estimated total kernel memory cost of the planned qdisc model. + estimated_total_memory_bytes: u64, + /// Current host memory available during preflight, if known. + memory_available_bytes: Option, + /// Memory floor that must remain available before the apply proceeds. + memory_guard_min_available_bytes: u64, + /// Whether the memory preflight passed. + memory_ok: bool, /// Per-interface planned qdisc counts. interfaces: Vec, }, diff --git a/src/rust/lqos_config/Cargo.toml b/src/rust/lqos_config/Cargo.toml index 170ac5ab..64de7383 100644 --- a/src/rust/lqos_config/Cargo.toml +++ b/src/rust/lqos_config/Cargo.toml @@ -13,6 +13,8 @@ csv = { workspace = true } ip_network_table = { workspace = true } ip_network = { workspace = true } sha2 = { workspace = true } +argon2 = { workspace = true } +rand_core = { workspace = true } uuid = { workspace = true } tracing = { workspace = true } toml = { workspace = true } diff --git a/src/rust/lqos_config/src/authentication.rs b/src/rust/lqos_config/src/authentication.rs index 154bc84d..3262030b 100644 --- a/src/rust/lqos_config/src/authentication.rs +++ b/src/rust/lqos_config/src/authentication.rs @@ -2,11 +2,13 @@ //! local web UI on LibreQoS boxes. It maps to `//lqusers.toml` use allocative::Allocative; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use rand_core::OsRng; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ fmt::Display, - fs::{OpenOptions, read_to_string, remove_file}, + fs::{OpenOptions, read_to_string, remove_file, rename}, io::Write, path::{Path, PathBuf}, }; @@ -14,6 +16,21 @@ use thiserror::Error; use tracing::{error, warn}; use uuid::Uuid; +const AUTH_FILE_VERSION: u32 = 2; +const LEGACY_AUTH_FILE_VERSION: u32 = 1; +const INITIAL_AUTH_EPOCH: u64 = 1; +const LEGACY_AUTH_FILE_NAME: &str = "webusers.toml"; +const CURRENT_AUTH_FILE_NAME: &str = "lqusers.toml"; +const LEGACY_PASSWORD_PEPPER: &str = "_LibreQosLikesPasswordsForDinner"; + +fn default_auth_file_version() -> u32 { + LEGACY_AUTH_FILE_VERSION +} + +fn default_auth_epoch() -> u64 { + INITIAL_AUTH_EPOCH +} + /// Access rights of a user #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Allocative)] pub enum UserRole { @@ -59,29 +76,91 @@ impl Display for UserRole { pub struct WebUser { /// The user's username. pub username: String, - /// The user's password hash. + /// The user's password hash. This may be a legacy SHA-256 hash or an + /// Argon2id PHC string until the user next logs in. pub password_hash: String, /// The user's role. pub role: UserRole, - /// The user's token. - pub token: String, +} + +/// Result of authenticating a single user. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthenticatedUser { + /// The authenticated username. + pub username: String, + /// The user's role. + pub role: UserRole, + /// Current auth epoch after any migration-side rewrite. + pub auth_epoch: u64, + /// True when a legacy password hash was upgraded to Argon2id. + pub password_upgraded: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PasswordVerification { + valid: bool, + needs_rehash: bool, } /// Container holding the authorized web users. -#[derive(Clone, Debug, Deserialize, Serialize, Default, Allocative)] +#[derive(Clone, Debug, Deserialize, Serialize, Allocative)] pub struct WebUsers { + #[serde(default = "default_auth_file_version")] + version: u32, + #[serde(default = "default_auth_epoch")] + auth_epoch: u64, + #[serde(default)] allow_unauthenticated_to_view: bool, + #[serde(default)] users: Vec, } +impl Default for WebUsers { + fn default() -> Self { + Self { + version: AUTH_FILE_VERSION, + auth_epoch: INITIAL_AUTH_EPOCH, + allow_unauthenticated_to_view: false, + users: Vec::new(), + } + } +} + impl WebUsers { - fn path() -> Result { + fn base_path() -> Result { let base_path = crate::load_config() .map_err(|_| AuthenticationError::UnableToLoadEtcLqos)? .lqos_directory .clone(); - let filename = Path::new(&base_path).join("lqusers.toml"); - Ok(filename) + Ok(PathBuf::from(base_path)) + } + + fn primary_path() -> Result { + Ok(Self::base_path()?.join(CURRENT_AUTH_FILE_NAME)) + } + + fn legacy_path() -> Result { + Ok(Self::base_path()?.join(LEGACY_AUTH_FILE_NAME)) + } + + /// Returns the current `lqusers.toml` path. + pub fn path() -> Result { + Self::primary_path() + } + + /// Returns the existing auth file path, checking the legacy filename as a fallback. + pub fn existing_path() -> Result, AuthenticationError> { + let current = Self::primary_path()?; + if current.exists() { + return Ok(Some(current)); + } + + let legacy = Self::legacy_path()?; + if legacy.exists() { + return Ok(Some(legacy)); + } + + Ok(None) } /// Is the list of users empty? @@ -89,66 +168,156 @@ impl WebUsers { self.users.is_empty() } + fn normalize_for_save(&self) -> Self { + let mut normalized = self.clone(); + normalized.version = AUTH_FILE_VERSION; + if normalized.auth_epoch == 0 { + normalized.auth_epoch = INITIAL_AUTH_EPOCH; + } + normalized + } + fn save_to_disk(&self) -> Result<(), AuthenticationError> { - let path = Self::path()?; - let new_contents = - toml_edit::ser::to_string(&self).map_err(AuthenticationError::SerializationError)?; - if path.exists() && remove_file(&path).is_err() { - error!("Unable to delete web users file"); - return Err(AuthenticationError::UnableToDelete); + let path = Self::primary_path()?; + let tmp_path = path.with_extension(format!("toml.tmp-{}", Uuid::new_v4())); + let normalized = self.normalize_for_save(); + let new_contents = toml_edit::ser::to_string(&normalized) + .map_err(AuthenticationError::SerializationError)?; + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .map_err(|e| { + error!( + "Unable to open temporary auth file {:?} for writing: {e}", + tmp_path + ); + AuthenticationError::UnableToWrite + })?; + file.write_all(new_contents.as_bytes()).map_err(|e| { + error!("Unable to write temporary auth file {:?}: {e}", tmp_path); + AuthenticationError::UnableToWrite + })?; + drop(file); + + rename(&tmp_path, &path).map_err(|e| { + error!( + "Unable to rename temporary auth file {:?} to {:?}: {e}", + tmp_path, path + ); + let _ = remove_file(&tmp_path); + AuthenticationError::UnableToWrite + })?; + + let legacy_path = Self::legacy_path()?; + if legacy_path != path + && legacy_path.exists() + && let Err(e) = remove_file(&legacy_path) + { + warn!("Unable to remove legacy auth file {:?}: {e}", legacy_path); } - if let Ok(mut file) = OpenOptions::new().write(true).create_new(true).open(path) { - if file.write_all(new_contents.as_bytes()).is_err() { - error!("Unable to write web users file to disk."); - return Err(AuthenticationError::UnableToWrite); - } - } else { - error!("Unable to open web users file for writing."); - return Err(AuthenticationError::UnableToWrite); + + Ok(()) + } + + fn migrate_if_needed(&mut self, loaded_from: &Path) -> Result<(), AuthenticationError> { + let current_path = Self::primary_path()?; + let loaded_from_legacy_path = loaded_from != current_path; + let mut needs_rewrite = false; + + if self.version != AUTH_FILE_VERSION { + self.version = AUTH_FILE_VERSION; + needs_rewrite = true; } + + if self.auth_epoch == 0 { + self.auth_epoch = INITIAL_AUTH_EPOCH; + needs_rewrite = true; + } + + if loaded_from_legacy_path { + needs_rewrite = true; + } + + if needs_rewrite { + self.bump_auth_epoch(); + self.save_to_disk()?; + } + Ok(()) } /// Does the user's file exist? True if it does, false otherwise. pub fn does_users_file_exist() -> Result { - Ok(Self::path()?.exists()) + Ok(Self::existing_path()?.is_some()) } - /// Try to load `lqusers.toml`. If it is unavailable, create a new--empty-- - /// file. + /// Try to load `lqusers.toml`, creating a new version 2 file if no auth file exists. pub fn load_or_create() -> Result { - let path = Self::path()?; - if !path.exists() { - // Create a new users file, save it and return the - // empty file + if let Some(path) = Self::existing_path()? { + let raw = read_to_string(&path).map_err(|e| { + error!("Unable to read auth file {:?}: {e}", path); + AuthenticationError::UnableToRead + })?; + let mut users: Self = toml_edit::de::from_str(&raw).map_err(|e| { + error!("Unable to deserialize auth file {:?}: {e}", path); + AuthenticationError::UnableToParse + })?; + users.migrate_if_needed(&path)?; + Ok(users) + } else { let new_users = Self::default(); new_users.save_to_disk()?; Ok(new_users) - } else { - // Load from disk - if let Ok(raw) = read_to_string(path) { - let parse_result = toml_edit::de::from_str(&raw); - if let Ok(users) = parse_result { - Ok(users) - } else { - error!("Unable to deserialize lqusers.toml. Error in next message."); - error!("{:?}", parse_result); - Err(AuthenticationError::UnableToParse) - } - } else { - error!("Unable to read lqusers.toml"); - Err(AuthenticationError::UnableToRead) - } } } - fn hash_password(password: &str) -> String { - let salted = format!("!x{password}_LibreQosLikesPasswordsForDinner"); + fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(AuthenticationError::PasswordHashError) + } + + fn hash_password_legacy(password: &str) -> String { + let salted = format!("!x{password}{LEGACY_PASSWORD_PEPPER}"); let mut sha256 = Sha256::new(); sha256.update(salted); format!("{:X}", sha256.finalize()) } + fn verify_password( + password: &str, + password_hash: &str, + ) -> Result { + if password_hash.starts_with("$argon2") { + let parsed = + PasswordHash::new(password_hash).map_err(AuthenticationError::PasswordHashError)?; + Ok(PasswordVerification { + valid: Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok(), + needs_rehash: false, + }) + } else { + Ok(PasswordVerification { + valid: Self::hash_password_legacy(password) == password_hash, + needs_rehash: true, + }) + } + } + + fn bump_auth_epoch(&mut self) { + self.auth_epoch = self.auth_epoch.saturating_add(1).max(INITIAL_AUTH_EPOCH); + } + + /// Returns the current auth epoch used to revoke signed sessions. + pub fn auth_epoch(&self) -> u64 { + self.auth_epoch + } + /// If a user exists with this username, update their details to the /// provided values. If the user does not exist, create them with the /// provided values. @@ -157,25 +326,23 @@ impl WebUsers { username: &str, password: &str, role: UserRole, - ) -> Result { - let token; // Assigned in a branch + ) -> Result<(), AuthenticationError> { + let password_hash = Self::hash_password(password)?; if let Some(user) = self.users.iter_mut().find(|u| u.username == username) { - user.password_hash = Self::hash_password(password); + user.password_hash = password_hash; user.role = role; - token = user.token.clone(); } else { - token = Uuid::new_v4().to_string(); let new_user = WebUser { username: username.to_string(), - password_hash: Self::hash_password(password), + password_hash, role, - token: token.clone(), }; self.users.push(new_user); } + self.bump_auth_epoch(); self.save_to_disk()?; - Ok(token) + Ok(()) } /// Update an existing user, optionally changing their password. @@ -189,20 +356,18 @@ impl WebUsers { username: &str, password: Option<&str>, role: UserRole, - ) -> Result { - let token; - if let Some(user) = self.users.iter_mut().find(|u| u.username == username) { - if let Some(password) = password { - user.password_hash = Self::hash_password(password); - } - user.role = role; - token = user.token.clone(); - } else { + ) -> Result<(), AuthenticationError> { + let Some(user) = self.users.iter_mut().find(|u| u.username == username) else { return Err(AuthenticationError::UserNotFound); - } + }; + if let Some(password) = password { + user.password_hash = Self::hash_password(password)?; + } + user.role = role; + self.bump_auth_epoch(); self.save_to_disk()?; - Ok(token) + Ok(()) } /// Delete a user from `lqusers.toml` @@ -213,48 +378,42 @@ impl WebUsers { error!("User {username} not found, hence not deleted."); return Err(AuthenticationError::UserNotFound); } + self.bump_auth_epoch(); self.save_to_disk()?; Ok(()) } - /// Attempt a login with the specified username and password. If - /// the login succeeds, returns the publically shareable token that - /// uniquely identifies the user a a string. If it fails, returns an - /// `Err`. - pub fn login(&self, username: &str, password: &str) -> Result { - let hash = Self::hash_password(password); - if let Some(user) = self - .users - .iter() - .find(|u| u.username == username && u.password_hash == hash) - { - Ok(user.token.clone()) - } else if self.allow_unauthenticated_to_view { - Ok("default".to_string()) - } else { - Err(AuthenticationError::InvalidLogin) - } - } + /// Attempt a login with the specified username and password. If the login + /// succeeds, returns the authenticated user details and transparently + /// upgrades legacy password hashes to Argon2id. + pub fn authenticate( + &mut self, + username: &str, + password: &str, + ) -> Result { + let Some(index) = self.users.iter().position(|u| u.username == username) else { + return Err(AuthenticationError::InvalidLogin); + }; - /// Given a token, lookup the matching user and return their role. - pub fn get_role_from_token(&self, token: &str) -> Result { - if let Some(user) = self.users.iter().find(|u| u.token == token) { - Ok(user.role) - } else if self.allow_unauthenticated_to_view { - Ok(UserRole::ReadOnly) - } else { - warn!("Token {token} not found, invalid data access attempt."); - Err(AuthenticationError::InvalidToken) + let verification = Self::verify_password(password, &self.users[index].password_hash)?; + if !verification.valid { + return Err(AuthenticationError::InvalidLogin); } - } - /// Given a token, lookup the matching user and return their username. - pub fn get_username(&self, token: &str) -> String { - if let Some(user) = self.users.iter().find(|u| u.token == token) { - user.username.clone() - } else { - "Anonymous".to_string() + let mut password_upgraded = false; + if verification.needs_rehash { + self.users[index].password_hash = Self::hash_password(password)?; + self.bump_auth_epoch(); + self.save_to_disk()?; + password_upgraded = true; } + + Ok(AuthenticatedUser { + username: self.users[index].username.clone(), + role: self.users[index].role, + auth_epoch: self.auth_epoch, + password_upgraded, + }) } /// Dump all users to the console. @@ -275,6 +434,7 @@ impl WebUsers { /// for demonstration purposes. pub fn allow_anonymous(&mut self, allow: bool) -> Result<(), AuthenticationError> { self.allow_unauthenticated_to_view = allow; + self.bump_auth_epoch(); self.save_to_disk()?; Ok(()) } @@ -286,16 +446,6 @@ impl WebUsers { } /// Errors that can occur while managing web-UI authentication. -/// -/// This enum groups failures encountered by helpers in this module while -/// interacting with `lqusers.toml` and related configuration, including: -/// - Resolving the LibreQoS config directory via `crate::load_config`. -/// - Serializing/deserializing user data with `toml_edit`. -/// - Reading, writing, or deleting the `lqusers.toml` file on disk. -/// - Looking up users, validating credentials, and resolving tokens. -/// -/// Each variant implements a concise display message via `thiserror::Error` -/// and is suitable for logging or bubbling up to higher layers. #[derive(Error, Debug)] pub enum AuthenticationError { /// Failed to load the main configuration (`/etc/lqos.conf`) to @@ -306,11 +456,8 @@ pub enum AuthenticationError { /// `lqusers.toml` to disk. #[error("Unable to serialize to TOML")] SerializationError(toml_edit::ser::Error), - /// Failed to remove an existing `lqusers.toml` prior to rewriting it. - #[error("Unable to remove existing web users file")] - UnableToDelete, - /// Failed to create or write `lqusers.toml` (e.g., permissions or IO). - #[error("Unable to open lqusers.toml for writing. Check permissions?")] + /// Failed to persist `lqusers.toml` (e.g., permissions or IO). + #[error("Unable to write lqusers.toml")] UnableToWrite, /// Failed to read `lqusers.toml` from disk. #[error("Unable to read lqusers.toml")] @@ -318,13 +465,13 @@ pub enum AuthenticationError { /// Failed to parse `lqusers.toml` contents as valid TOML. #[error("Unable to parse lqusers.toml")] UnableToParse, + /// Password hash creation or verification failed. + #[error("Unable to process password hash")] + PasswordHashError(argon2::password_hash::Error), /// Attempted to remove or reference a user that does not exist. #[error("User not found")] UserNotFound, - /// Username/password did not match (and anonymous read-only is disabled). + /// Username/password did not match. #[error("Invalid Login")] InvalidLogin, - /// Provided token did not match any user (and anonymous read-only is disabled). - #[error("Invalid User Token")] - InvalidToken, } diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index 3ec74504..b01cdb62 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -11,11 +11,12 @@ pub mod authentication; mod cpu_topology; mod etc; mod network_json; +mod planner; mod program_control; mod qoo_profiles; mod shaped_devices; -pub use authentication::{UserRole, WebUser, WebUsers}; +pub use authentication::{AuthenticatedUser, UserRole, WebUser, WebUsers}; pub use cpu_topology::{ CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus, }; @@ -26,6 +27,12 @@ pub use etc::{ disable_xdp_bridge, enable_long_term_stats, load_config, update_config, }; pub use network_json::{NetworkJson, NetworkJsonNode, NetworkJsonTransport}; +pub use planner::{ + CircuitIdentityAssignment, CircuitIdentityGroupInput, ClassIdentityPlannerOutput, + PlannerCircuitIdentityState, PlannerSiteIdentityState, SiteIdentityAssignment, + SiteIdentityInput, TopLevelPlannerItem, TopLevelPlannerMode, TopLevelPlannerOutput, + TopLevelPlannerParams, plan_class_identities, plan_top_level_assignments, +}; pub use program_control::load_libreqos; pub use qoo_profiles::{ DEFAULT_QOO_PROFILE_ID, QooProfileInfo, QooProfilesError, active_qoo_profile, diff --git a/src/rust/lqos_config/src/network_json.rs b/src/rust/lqos_config/src/network_json.rs index f395ad2d..ca327a62 100644 --- a/src/rust/lqos_config/src/network_json.rs +++ b/src/rust/lqos_config/src/network_json.rs @@ -570,8 +570,10 @@ mod test { assert!(tiny.max_throughput.1 > 0.0); let transport = tiny.clone_to_transit(); + assert!(!transport.runtime_virtualized); let encoded = serde_json::to_value(&transport).expect("transport must serialize"); assert_eq!(encoded["max_throughput"], serde_json::json!([1.5, 0.5])); + assert_eq!(encoded["runtime_virtualized"], serde_json::json!(false)); } #[test] diff --git a/src/rust/lqos_config/src/network_json/network_json_node.rs b/src/rust/lqos_config/src/network_json/network_json_node.rs index 1ac1762e..993b2cb9 100644 --- a/src/rust/lqos_config/src/network_json/network_json_node.rs +++ b/src/rust/lqos_config/src/network_json/network_json_node.rs @@ -107,6 +107,7 @@ impl NetworkJsonNode { name: self.name.clone(), id: self.id.clone(), is_virtual: self.virtual_node, + runtime_virtualized: false, max_throughput: self.max_throughput, configured_max_throughput: self.max_throughput, effective_max_throughput: None, diff --git a/src/rust/lqos_config/src/network_json/network_json_transport.rs b/src/rust/lqos_config/src/network_json/network_json_transport.rs index 1017f99a..eb48d1e4 100644 --- a/src/rust/lqos_config/src/network_json/network_json_transport.rs +++ b/src/rust/lqos_config/src/network_json/network_json_transport.rs @@ -14,6 +14,9 @@ pub struct NetworkJsonTransport { /// True if this node is a "virtual" (logical-only) node. #[serde(rename = "virtual", default)] pub is_virtual: bool, + /// True if TreeGuard has runtime-virtualized this node in the active Bakery tree. + #[serde(default)] + pub runtime_virtualized: bool, /// Max throughput for node (not clamped) pub max_throughput: (f64, f64), /// Configured max throughput from `network.json`. diff --git a/src/rust/lqos_config/src/planner.rs b/src/rust/lqos_config/src/planner.rs new file mode 100644 index 00000000..fe1c832b --- /dev/null +++ b/src/rust/lqos_config/src/planner.rs @@ -0,0 +1,698 @@ +//! Shared planning helpers for physical queue placement. +//! +//! This module provides a reusable top-level CPU/bin planner so both the Python shaper build path +//! and future Bakery/TreeGuard runtime replans can use the same assignment logic. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// A top-level item to be assigned to a shaping queue/bin. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TopLevelPlannerItem { + /// Stable logical identifier for the item being planned. + pub id: String, + /// Relative weight used when balancing items across bins. + pub weight: f64, +} + +/// Supported planner strategies. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum TopLevelPlannerMode { + /// Deterministic round-robin assignment by item id. + RoundRobin, + /// Deterministic greedy balancing with no hysteresis or cooldown. + Greedy, + /// Greedy balancing that prefers to keep prior assignments unless a move is worthwhile. + StableGreedy, +} + +impl Default for TopLevelPlannerMode { + fn default() -> Self { + Self::StableGreedy + } +} + +/// Tunables for top-level queue planning. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TopLevelPlannerParams { + /// Planning mode. + pub mode: TopLevelPlannerMode, + /// Fraction of total weight improvement required before a move is considered worthwhile. + pub hysteresis_threshold: f64, + /// Minimum seconds between moves for a previously assigned item. + pub cooldown_seconds: f64, + /// Maximum number of assignment changes allowed in a single planning run. + pub move_budget_per_run: usize, +} + +impl Default for TopLevelPlannerParams { + fn default() -> Self { + Self { + mode: TopLevelPlannerMode::StableGreedy, + hysteresis_threshold: 0.03, + cooldown_seconds: 3600.0, + move_budget_per_run: 1, + } + } +} + +/// Result of a top-level assignment pass. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TopLevelPlannerOutput { + /// Final per-item bin assignment. + pub assignment: BTreeMap, + /// Item ids whose assigned bin changed relative to the previous assignment map. + pub changed: Vec, + /// Whether the stable planner path, rather than a simple fallback, was used. + pub planner_used: bool, +} + +/// Input describing a site that needs class identity assignment. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SiteIdentityInput { + /// Stable path key for the site within the logical hierarchy. + pub site_key: String, + /// Stable path key for the parent site, or empty for top-level nodes. + pub parent_path: String, + /// Target shaping queue number, 1-based. + pub queue: u32, + /// Whether this site has child sites in the physical tree. + pub has_children: bool, +} + +/// Assigned class identity for a site. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SiteIdentityAssignment { + /// Stable path key for the site. + pub site_key: String, + /// Assigned queue number, 1-based. + pub queue: u32, + /// Assigned class minor. + pub class_minor: u16, + /// Assigned downlink class major. + pub class_major: u16, + /// Assigned uplink class major. + pub up_class_major: u16, + /// Parent path used when deciding minor reuse. + pub parent_path: String, +} + +/// Input describing a circuit group under a single parent site. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitIdentityGroupInput { + /// Parent node name for this group of circuits. + pub parent_node: String, + /// Queue number, 1-based. + pub queue: u32, + /// Circuit ids in deterministic order for this parent. + pub circuit_ids: Vec, +} + +/// Assigned class identity for a circuit. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct CircuitIdentityAssignment { + /// Stable circuit id. + pub circuit_id: String, + /// Parent node used when deciding minor reuse. + pub parent_node: String, + /// Assigned queue number, 1-based. + pub queue: u32, + /// Assigned class minor. + pub class_minor: u16, + /// Assigned downlink class major. + pub class_major: u16, + /// Assigned uplink class major. + pub up_class_major: u16, +} + +/// Persisted planner identity for a site. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct PlannerSiteIdentityState { + /// Assigned class minor. + pub class_minor: u16, + /// Assigned queue number, 1-based. + pub queue: u32, + /// Stable parent path for reuse checks. + pub parent_path: String, + /// Downlink class major. + pub class_major: u16, + /// Uplink class major. + pub up_class_major: u16, +} + +/// Persisted planner identity for a circuit. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct PlannerCircuitIdentityState { + /// Assigned class minor. + pub class_minor: u16, + /// Assigned queue number, 1-based. + pub queue: u32, + /// Stable parent node for reuse checks. + pub parent_node: String, + /// Downlink class major. + pub class_major: u16, + /// Uplink class major. + pub up_class_major: u16, +} + +/// Output of site/circuit identity assignment. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ClassIdentityPlannerOutput { + /// Assigned site identities keyed in traversal order. + pub sites: Vec, + /// Assigned circuit identities keyed in traversal order. + pub circuits: Vec, + /// Planner-state site map suitable for persistence. + pub site_state: BTreeMap, + /// Planner-state circuit map suitable for persistence. + pub circuit_state: BTreeMap, + /// Highest allocated minor counter per queue after assignment. + pub last_used_minor_by_queue: BTreeMap, +} + +fn sanitize_weight(weight: f64) -> f64 { + if !weight.is_finite() || weight <= 0.0 { + 1.0 + } else { + weight + } +} + +fn next_free_minor(start_minor: u32, reserved: &std::collections::BTreeSet) -> u32 { + let mut candidate = start_minor.max(3); + while reserved.contains(&candidate) { + candidate += 1; + } + candidate +} + +fn round_robin_assign(items: &[TopLevelPlannerItem], bins: &[String]) -> BTreeMap { + let mut ids: Vec<&str> = items.iter().map(|item| item.id.as_str()).collect(); + ids.sort_unstable(); + + let mut assignment = BTreeMap::new(); + for (idx, id) in ids.into_iter().enumerate() { + if let Some(bin) = bins.get(idx % bins.len()) { + assignment.insert(id.to_string(), bin.clone()); + } + } + assignment +} + +fn greedy_assign(items: &[TopLevelPlannerItem], bins: &[String]) -> BTreeMap { + let mut pairs: Vec<(&str, f64)> = items + .iter() + .map(|item| (item.id.as_str(), sanitize_weight(item.weight))) + .collect(); + pairs.sort_by(|a, b| { + b.1.partial_cmp(&a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(b.0)) + }); + + let mut loads: BTreeMap = bins.iter().cloned().map(|bin| (bin, 0.0)).collect(); + let mut assignment = BTreeMap::new(); + for (id, weight) in pairs { + let target = loads + .iter() + .min_by(|a, b| { + a.1.partial_cmp(b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(b.0)) + }) + .map(|(bin, _)| bin.clone()) + .unwrap_or_else(|| bins[0].clone()); + assignment.insert(id.to_string(), target.clone()); + if let Some(load) = loads.get_mut(&target) { + *load += weight; + } + } + assignment +} + +fn load_by_bin( + assignment: &BTreeMap, + item_weights: &BTreeMap, + bins: &[String], +) -> BTreeMap { + let mut loads: BTreeMap = bins.iter().cloned().map(|bin| (bin, 0.0)).collect(); + for (id, bin) in assignment { + if let Some(weight) = item_weights.get(id) + && let Some(load) = loads.get_mut(bin) + { + *load += *weight; + } + } + loads +} + +/// Plans top-level queue assignments for the given logical items and bins. +/// +/// This function is pure: it computes a deterministic assignment and does not perform any I/O. +pub fn plan_top_level_assignments( + items: &[TopLevelPlannerItem], + bins: &[String], + prev_assign: &BTreeMap, + last_change_ts: &BTreeMap, + now_ts: f64, + params: &TopLevelPlannerParams, +) -> TopLevelPlannerOutput { + if bins.is_empty() || items.is_empty() { + return TopLevelPlannerOutput { + assignment: BTreeMap::new(), + changed: Vec::new(), + planner_used: !matches!(params.mode, TopLevelPlannerMode::RoundRobin), + }; + } + + let item_ids: Vec = items.iter().map(|item| item.id.clone()).collect(); + let item_weights: BTreeMap = items + .iter() + .map(|item| (item.id.clone(), sanitize_weight(item.weight))) + .collect(); + let valid_bins: std::collections::BTreeSet<&String> = bins.iter().collect(); + let prev_valid: BTreeMap = prev_assign + .iter() + .filter(|(id, bin)| item_weights.contains_key(*id) && valid_bins.contains(*bin)) + .map(|(id, bin)| (id.clone(), bin.clone())) + .collect(); + + let rr_assignment = round_robin_assign(items, bins); + let greedy_assignment = greedy_assign(items, bins); + + let (mut assignment, planner_used) = match params.mode { + TopLevelPlannerMode::RoundRobin => (rr_assignment.clone(), false), + TopLevelPlannerMode::Greedy => (greedy_assignment.clone(), false), + TopLevelPlannerMode::StableGreedy => { + let mut assignment = rr_assignment.clone(); + for id in &item_ids { + let chosen = prev_valid + .get(id) + .cloned() + .or_else(|| greedy_assignment.get(id).cloned()) + .or_else(|| rr_assignment.get(id).cloned()) + .unwrap_or_else(|| bins[0].clone()); + assignment.insert(id.clone(), chosen); + } + + let mut loads = load_by_bin(&assignment, &item_weights, bins); + let total_weight: f64 = item_weights.values().sum(); + let min_improvement = total_weight * params.hysteresis_threshold.max(0.0); + let mut weighted_ids = item_ids.clone(); + weighted_ids.sort_by(|a, b| { + item_weights[b] + .partial_cmp(&item_weights[a]) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.cmp(b)) + }); + + let mut moves_used = 0usize; + for id in weighted_ids { + let Some(current_bin) = assignment.get(&id).cloned() else { + continue; + }; + let Some(target_bin) = greedy_assignment.get(&id).cloned() else { + continue; + }; + if current_bin == target_bin { + continue; + } + if moves_used >= params.move_budget_per_run { + continue; + } + let last_changed = last_change_ts.get(&id).copied().unwrap_or(0.0); + if now_ts - last_changed < params.cooldown_seconds { + continue; + } + let current_load = loads.get(¤t_bin).copied().unwrap_or(0.0); + let target_load = loads.get(&target_bin).copied().unwrap_or(0.0); + if current_load - target_load <= min_improvement { + continue; + } + + let weight = item_weights.get(&id).copied().unwrap_or(1.0); + if let Some(load) = loads.get_mut(¤t_bin) { + *load -= weight; + } + if let Some(load) = loads.get_mut(&target_bin) { + *load += weight; + } + assignment.insert(id, target_bin); + moves_used += 1; + } + + (assignment, true) + } + }; + + let used_bins: std::collections::BTreeSet<&String> = assignment.values().collect(); + if bins.len() > 1 && assignment.len() > 1 && used_bins.len() <= 1 { + assignment = greedy_assignment.clone(); + } + + let mut changed: Vec = assignment + .iter() + .filter_map(|(id, bin)| { + prev_valid + .get(id) + .and_then(|prev| (prev != bin).then_some(id.clone())) + }) + .collect(); + changed.sort_unstable(); + + TopLevelPlannerOutput { + assignment, + changed, + planner_used, + } +} + +/// Plans site and circuit class identities for a physical queue tree. +/// +/// This function is pure: it computes deterministic minor/class-major assignments from the +/// provided planner state and traversal order without performing any I/O. +pub fn plan_class_identities( + sites: &[SiteIdentityInput], + circuit_groups: &[CircuitIdentityGroupInput], + previous_sites: &BTreeMap, + previous_circuits: &BTreeMap, + stick_offset: u16, + circuit_padding: u32, +) -> ClassIdentityPlannerOutput { + let mut reserved_site_minors: BTreeMap> = BTreeMap::new(); + let mut next_site_minor_by_queue: BTreeMap = BTreeMap::new(); + let mut site_assignments = Vec::with_capacity(sites.len()); + let mut site_state = BTreeMap::new(); + + for site in sites { + let reserved = reserved_site_minors.entry(site.queue).or_default(); + let next_minor = next_site_minor_by_queue.entry(site.queue).or_insert(3); + let reuse_minor = previous_sites.get(&site.site_key).and_then(|stored| { + (stored.queue == site.queue + && stored.parent_path == site.parent_path + && !reserved.contains(&(stored.class_minor as u32))) + .then_some(stored.class_minor as u32) + }); + + let class_minor_u32 = reuse_minor.unwrap_or_else(|| next_free_minor(*next_minor, reserved)); + let class_minor = u16::try_from(class_minor_u32).unwrap_or(u16::MAX); + let class_major = u16::try_from(site.queue).unwrap_or(u16::MAX); + let up_class_major = class_major.saturating_add(stick_offset); + reserved.insert(class_minor_u32); + *next_minor = (*next_minor).max(class_minor_u32).saturating_add(1); + if site.has_children { + *next_minor = (*next_minor).saturating_add(1); + } + + let assigned = SiteIdentityAssignment { + site_key: site.site_key.clone(), + queue: site.queue, + class_minor, + class_major, + up_class_major, + parent_path: site.parent_path.clone(), + }; + site_state.insert( + site.site_key.clone(), + PlannerSiteIdentityState { + class_minor, + queue: site.queue, + parent_path: site.parent_path.clone(), + class_major, + up_class_major, + }, + ); + site_assignments.push(assigned); + } + + let mut reserved_circuit_minors: BTreeMap> = + reserved_site_minors.clone(); + let mut next_circuit_minor_by_queue = BTreeMap::new(); + for (queue, reserved) in &reserved_circuit_minors { + let start = next_site_minor_by_queue + .get(queue) + .copied() + .unwrap_or_else(|| next_free_minor(3, reserved)); + next_circuit_minor_by_queue.insert(*queue, next_free_minor(start, reserved)); + } + + let mut circuit_assignments = Vec::new(); + let mut circuit_state = BTreeMap::new(); + for group in circuit_groups { + let reserved = reserved_circuit_minors.entry(group.queue).or_default(); + let next_minor = next_circuit_minor_by_queue + .entry(group.queue) + .or_insert_with(|| next_free_minor(3, reserved)); + for circuit_id in &group.circuit_ids { + let reuse_minor = previous_circuits.get(circuit_id).and_then(|stored| { + (stored.queue == group.queue + && stored.parent_node == group.parent_node + && !reserved.contains(&(stored.class_minor as u32))) + .then_some(stored.class_minor as u32) + }); + let class_minor_u32 = + reuse_minor.unwrap_or_else(|| next_free_minor(*next_minor, reserved)); + let class_minor = u16::try_from(class_minor_u32).unwrap_or(u16::MAX); + let class_major = u16::try_from(group.queue).unwrap_or(u16::MAX); + let up_class_major = class_major.saturating_add(stick_offset); + reserved.insert(class_minor_u32); + *next_minor = (*next_minor).max(class_minor_u32).saturating_add(1); + + circuit_assignments.push(CircuitIdentityAssignment { + circuit_id: circuit_id.clone(), + parent_node: group.parent_node.clone(), + queue: group.queue, + class_minor, + class_major, + up_class_major, + }); + circuit_state.insert( + circuit_id.clone(), + PlannerCircuitIdentityState { + class_minor, + queue: group.queue, + parent_node: group.parent_node.clone(), + class_major, + up_class_major, + }, + ); + } + *next_minor = (*next_minor).saturating_add(circuit_padding); + } + + let mut last_used_minor_by_queue = BTreeMap::new(); + for queue in reserved_circuit_minors + .keys() + .chain(next_site_minor_by_queue.keys()) + .cloned() + .collect::>() + { + let site_next = next_site_minor_by_queue.get(&queue).copied().unwrap_or(3); + let circuit_next = next_circuit_minor_by_queue + .get(&queue) + .copied() + .unwrap_or(3); + last_used_minor_by_queue.insert(queue, site_next.max(circuit_next)); + } + + ClassIdentityPlannerOutput { + sites: site_assignments, + circuits: circuit_assignments, + site_state, + circuit_state, + last_used_minor_by_queue, + } +} + +#[cfg(test)] +mod tests { + use super::{ + CircuitIdentityGroupInput, PlannerCircuitIdentityState, PlannerSiteIdentityState, + SiteIdentityInput, TopLevelPlannerItem, TopLevelPlannerMode, TopLevelPlannerParams, + plan_class_identities, plan_top_level_assignments, + }; + use std::collections::BTreeMap; + + fn bins() -> Vec { + vec!["CpueQueue0".to_string(), "CpueQueue1".to_string()] + } + + #[test] + fn round_robin_is_deterministic() { + let items = vec![ + TopLevelPlannerItem { + id: "b".to_string(), + weight: 1.0, + }, + TopLevelPlannerItem { + id: "a".to_string(), + weight: 1.0, + }, + ]; + let result = plan_top_level_assignments( + &items, + &bins(), + &BTreeMap::new(), + &BTreeMap::new(), + 0.0, + &TopLevelPlannerParams { + mode: TopLevelPlannerMode::RoundRobin, + ..Default::default() + }, + ); + assert_eq!( + result.assignment.get("a").map(String::as_str), + Some("CpueQueue0") + ); + assert_eq!( + result.assignment.get("b").map(String::as_str), + Some("CpueQueue1") + ); + assert!(!result.planner_used); + } + + #[test] + fn stable_greedy_breaks_degenerate_single_bin_assignments() { + let items = vec![ + TopLevelPlannerItem { + id: "heavy".to_string(), + weight: 100.0, + }, + TopLevelPlannerItem { + id: "light".to_string(), + weight: 1.0, + }, + ]; + let prev_assign = BTreeMap::from([ + ("heavy".to_string(), "CpueQueue0".to_string()), + ("light".to_string(), "CpueQueue0".to_string()), + ]); + let last_change_ts = + BTreeMap::from([("heavy".to_string(), 990.0), ("light".to_string(), 990.0)]); + let result = plan_top_level_assignments( + &items, + &bins(), + &prev_assign, + &last_change_ts, + 1000.0, + &TopLevelPlannerParams { + mode: TopLevelPlannerMode::StableGreedy, + cooldown_seconds: 3600.0, + move_budget_per_run: 1, + hysteresis_threshold: 0.0, + }, + ); + assert_eq!( + result.assignment.get("light").map(String::as_str), + Some("CpueQueue1") + ); + assert_eq!(result.changed, vec!["light".to_string()]); + } + + #[test] + fn stable_greedy_can_move_when_cooldown_expired() { + let items = vec![ + TopLevelPlannerItem { + id: "heavy".to_string(), + weight: 100.0, + }, + TopLevelPlannerItem { + id: "light".to_string(), + weight: 1.0, + }, + ]; + let prev_assign = BTreeMap::from([ + ("heavy".to_string(), "CpueQueue0".to_string()), + ("light".to_string(), "CpueQueue0".to_string()), + ]); + let result = plan_top_level_assignments( + &items, + &bins(), + &prev_assign, + &BTreeMap::new(), + 10_000.0, + &TopLevelPlannerParams { + mode: TopLevelPlannerMode::StableGreedy, + cooldown_seconds: 0.0, + move_budget_per_run: 1, + hysteresis_threshold: 0.0, + }, + ); + assert_eq!( + result.assignment.get("light").map(String::as_str), + Some("CpueQueue1") + ); + assert_eq!(result.changed, vec!["light".to_string()]); + assert!(result.planner_used); + } + + #[test] + fn class_identity_planner_reuses_site_and_circuit_minors() { + let site_inputs = vec![ + SiteIdentityInput { + site_key: "CpueQueue0/site-a".to_string(), + parent_path: "CpueQueue0".to_string(), + queue: 1, + has_children: true, + }, + SiteIdentityInput { + site_key: "CpueQueue0/site-a/child".to_string(), + parent_path: "CpueQueue0/site-a".to_string(), + queue: 1, + has_children: false, + }, + ]; + let circuit_groups = vec![CircuitIdentityGroupInput { + parent_node: "child".to_string(), + queue: 1, + circuit_ids: vec!["c1".to_string(), "c2".to_string()], + }]; + let prev_sites = BTreeMap::from([ + ( + "CpueQueue0/site-a".to_string(), + PlannerSiteIdentityState { + class_minor: 0x20, + queue: 1, + parent_path: "CpueQueue0".to_string(), + class_major: 1, + up_class_major: 0x41, + }, + ), + ( + "CpueQueue0/site-a/child".to_string(), + PlannerSiteIdentityState { + class_minor: 0x22, + queue: 1, + parent_path: "CpueQueue0/site-a".to_string(), + class_major: 1, + up_class_major: 0x41, + }, + ), + ]); + let prev_circuits = BTreeMap::from([( + "c1".to_string(), + PlannerCircuitIdentityState { + class_minor: 0x30, + queue: 1, + parent_node: "child".to_string(), + class_major: 1, + up_class_major: 0x41, + }, + )]); + + let result = plan_class_identities( + &site_inputs, + &circuit_groups, + &prev_sites, + &prev_circuits, + 0x40, + 8, + ); + + assert_eq!(result.sites[0].class_minor, 0x20); + assert_eq!(result.sites[1].class_minor, 0x22); + assert_eq!(result.circuits[0].class_minor, 0x30); + assert_eq!(result.circuits[1].queue, 1); + assert_eq!(result.circuits[1].class_major, 1); + } +} diff --git a/src/rust/lqos_overrides/src/overrides_file.rs b/src/rust/lqos_overrides/src/overrides_file.rs index 4459ce5e..7a5c1abd 100644 --- a/src/rust/lqos_overrides/src/overrides_file.rs +++ b/src/rust/lqos_overrides/src/overrides_file.rs @@ -371,7 +371,7 @@ fn merge_circuit_adjustments_owned( fn merge_network_adjustments_owned( operator_adjustments: &[NetworkAdjustment], stormguard_adjustments: &[NetworkAdjustment], - treeguard_adjustments: &[NetworkAdjustment], + _treeguard_adjustments: &[NetworkAdjustment], ) -> Vec { use std::collections::{HashMap, HashSet}; @@ -403,27 +403,7 @@ fn merge_network_adjustments_owned( } } - let mut treeguard_virtual: HashMap<&str, bool> = HashMap::new(); - let mut treeguard_virtual_order: Vec<&str> = Vec::new(); - let mut treeguard_virtual_seen: HashSet<&str> = HashSet::new(); - - for adj in treeguard_adjustments { - if let NetworkAdjustment::SetNodeVirtual { - node_name, - virtual_node, - } = adj - { - let name = node_name.as_str(); - treeguard_virtual.insert(name, *virtual_node); - if !treeguard_virtual_seen.contains(name) { - treeguard_virtual_order.push(name); - treeguard_virtual_seen.insert(name); - } - } - } - let mut out = Vec::new(); - let mut used_treeguard_virtual: HashSet<&str> = HashSet::new(); let mut operator_virtual_seen: HashSet<&str> = HashSet::new(); let mut operator_site_speed_seen: HashSet = HashSet::new(); let mut operator_site_name_seen: HashSet = HashSet::new(); @@ -439,19 +419,10 @@ fn merge_network_adjustments_owned( continue; } operator_virtual_seen.insert(name); - - if let Some(v) = treeguard_virtual.get(name) { - out.push(NetworkAdjustment::SetNodeVirtual { - node_name: node_name.clone(), - virtual_node: *v, - }); - used_treeguard_virtual.insert(name); - } else { - out.push(NetworkAdjustment::SetNodeVirtual { - node_name: node_name.clone(), - virtual_node: *virtual_node, - }); - } + out.push(NetworkAdjustment::SetNodeVirtual { + node_name: node_name.clone(), + virtual_node: *virtual_node, + }); } NetworkAdjustment::AdjustSiteSpeed { node_id, @@ -499,20 +470,6 @@ fn merge_network_adjustments_owned( }); } - // Append TreeGuard-only virtual-node entries (ignore other network adjustments). - for name in treeguard_virtual_order { - if used_treeguard_virtual.contains(name) { - continue; - } - let Some(v) = treeguard_virtual.get(name) else { - continue; - }; - out.push(NetworkAdjustment::SetNodeVirtual { - node_name: name.to_string(), - virtual_node: *v, - }); - } - out } @@ -1130,7 +1087,7 @@ mod tests { } #[test] - fn effective_merge_treeguard_wins_for_node_virtual_only() { + fn effective_merge_keeps_operator_node_virtual_and_ignores_treeguard_runtime_virtualization() { let mut operator = OverrideFile::default(); operator.set_network_node_virtual("NodeA".to_string(), false); operator.add_network_adjustment(NetworkAdjustment::AdjustSiteSpeed { @@ -1165,7 +1122,19 @@ mod tests { } if node_name == "NodeA" => Some(*virtual_node), _ => None, }); - assert_eq!(node_a_virtual, Some(true)); + assert_eq!(node_a_virtual, Some(false)); + + let node_three_virtual = merged + .network_adjustments() + .iter() + .find_map(|adj| match adj { + NetworkAdjustment::SetNodeVirtual { + node_name, + virtual_node, + } if node_name == "Node3" => Some(*virtual_node), + _ => None, + }); + assert_eq!(node_three_virtual, None); // Operator site speed should beat StormGuard; TreeGuard site speed should be ignored. let site_speed_names: Vec<&str> = merged diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index a9f2ce58..fb9ef326 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -12,7 +12,7 @@ use lqos_utils::rustls::ensure_rustls_crypto_provider; use nix::libc::getpid; use pyo3::exceptions::PyOSError; use pyo3::prelude::*; -use pyo3::types::PyDict; +use pyo3::types::{PyDict, PyList}; use std::{ fs::{File, read_to_string, remove_file}, io::Write, @@ -114,6 +114,273 @@ fn get_bool(d: &pyo3::Bound<'_, pyo3::types::PyDict>, key: &str, default: bool) } } +#[pyfunction( + signature = ( + items, + queues_available, + prev_assign=None, + last_change_ts=None, + now_ts=None, + mode=None, + move_budget_per_run=None, + cooldown_seconds=None, + hysteresis_threshold=None + ) +)] +fn plan_top_level_cpu_bins( + py: Python, + items: PyObject, + queues_available: usize, + prev_assign: Option, + last_change_ts: Option, + now_ts: Option, + mode: Option, + move_budget_per_run: Option, + cooldown_seconds: Option, + hysteresis_threshold: Option, +) -> PyResult { + let items_any = items.bind(py); + let items_list = items_any.downcast::()?; + let planner_items: Vec = items_list + .iter() + .map(|item| -> PyResult { + let dict = item.downcast::()?; + let id = get_string(dict, "id", String::new()); + let weight = get_f64(dict, "weight", 1.0); + Ok(lqos_config::TopLevelPlannerItem { id, weight }) + }) + .collect::>>()?; + + let prev_assign_map = match prev_assign { + Some(obj) => { + let any = obj.bind(py); + let dict = any.downcast::()?; + dict.iter() + .filter_map(|(k, v)| { + Some((k.extract::().ok()?, v.extract::().ok()?)) + }) + .collect::>() + } + None => BTreeMap::new(), + }; + let last_change_map = match last_change_ts { + Some(obj) => { + let any = obj.bind(py); + let dict = any.downcast::()?; + dict.iter() + .filter_map(|(k, v)| Some((k.extract::().ok()?, v.extract::().ok()?))) + .collect::>() + } + None => BTreeMap::new(), + }; + + let bins: Vec = (0..queues_available) + .map(|idx| format!("CpueQueue{idx}")) + .collect(); + let planner_mode = match mode.as_deref() { + Some("round_robin") => lqos_config::TopLevelPlannerMode::RoundRobin, + Some("greedy") => lqos_config::TopLevelPlannerMode::Greedy, + _ => lqos_config::TopLevelPlannerMode::StableGreedy, + }; + let params = lqos_config::TopLevelPlannerParams { + mode: planner_mode, + hysteresis_threshold: hysteresis_threshold.unwrap_or(0.03), + cooldown_seconds: cooldown_seconds.unwrap_or(3600.0), + move_budget_per_run: move_budget_per_run.unwrap_or(1), + }; + let result = lqos_config::plan_top_level_assignments( + &planner_items, + &bins, + &prev_assign_map, + &last_change_map, + now_ts.unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) + }), + ¶ms, + ); + + let out = PyDict::new(py); + let assignment = PyDict::new(py); + for (item, bin) in result.assignment { + assignment.set_item(item, bin)?; + } + out.set_item("assignment", assignment)?; + out.set_item("changed", result.changed)?; + out.set_item("planner_used", result.planner_used)?; + Ok(out.into()) +} + +#[pyfunction( + signature = ( + sites, + circuit_groups, + site_state=None, + circuit_state=None, + stick_offset=0, + circuit_padding=8 + ) +)] +fn plan_class_identities( + py: Python, + sites: PyObject, + circuit_groups: PyObject, + site_state: Option, + circuit_state: Option, + stick_offset: u16, + circuit_padding: u32, +) -> PyResult { + let sites_list = sites.bind(py).downcast::()?; + let site_inputs: Vec = sites_list + .iter() + .map(|item| -> PyResult { + let dict = item.downcast::()?; + Ok(lqos_config::SiteIdentityInput { + site_key: get_string(dict, "site_key", String::new()), + parent_path: get_string(dict, "parent_path", String::new()), + queue: get_i64(dict, "queue", 0).max(0) as u32, + has_children: get_bool(dict, "has_children", false), + }) + }) + .collect::>>()?; + + let groups_list = circuit_groups.bind(py).downcast::()?; + let circuit_group_inputs: Vec = groups_list + .iter() + .map(|item| -> PyResult { + let dict = item.downcast::()?; + let ids = match dict.get_item("circuit_ids")? { + Some(value) => value + .downcast::()? + .iter() + .filter_map(|entry| entry.extract::().ok()) + .collect::>(), + None => Vec::new(), + }; + Ok(lqos_config::CircuitIdentityGroupInput { + parent_node: get_string(dict, "parent_node", String::new()), + queue: get_i64(dict, "queue", 0).max(0) as u32, + circuit_ids: ids, + }) + }) + .collect::>>()?; + + let previous_sites = match site_state { + Some(obj) => { + let dict = obj.bind(py).downcast::()?; + dict.iter() + .filter_map(|(k, v)| { + let key = k.extract::().ok()?; + let entry = v.downcast::().ok()?; + Some(( + key, + lqos_config::PlannerSiteIdentityState { + class_minor: get_i64(entry, "class_minor", 0).max(0) as u16, + queue: get_i64(entry, "queue", 0).max(0) as u32, + parent_path: get_string(entry, "parent_path", String::new()), + class_major: get_i64(entry, "class_major", 0).max(0) as u16, + up_class_major: get_i64(entry, "up_class_major", 0).max(0) as u16, + }, + )) + }) + .collect::>() + } + None => BTreeMap::new(), + }; + + let previous_circuits = match circuit_state { + Some(obj) => { + let dict = obj.bind(py).downcast::()?; + dict.iter() + .filter_map(|(k, v)| { + let key = k.extract::().ok()?; + let entry = v.downcast::().ok()?; + Some(( + key, + lqos_config::PlannerCircuitIdentityState { + class_minor: get_i64(entry, "class_minor", 0).max(0) as u16, + queue: get_i64(entry, "queue", 0).max(0) as u32, + parent_node: get_string(entry, "parent_node", String::new()), + class_major: get_i64(entry, "class_major", 0).max(0) as u16, + up_class_major: get_i64(entry, "up_class_major", 0).max(0) as u16, + }, + )) + }) + .collect::>() + } + None => BTreeMap::new(), + }; + + let result = lqos_config::plan_class_identities( + &site_inputs, + &circuit_group_inputs, + &previous_sites, + &previous_circuits, + stick_offset, + circuit_padding, + ); + + let out = PyDict::new(py); + let sites_out = PyList::empty(py); + for site in result.sites { + let d = PyDict::new(py); + d.set_item("site_key", site.site_key)?; + d.set_item("queue", site.queue)?; + d.set_item("class_minor", site.class_minor)?; + d.set_item("class_major", site.class_major)?; + d.set_item("up_class_major", site.up_class_major)?; + d.set_item("parent_path", site.parent_path)?; + sites_out.append(d)?; + } + out.set_item("sites", sites_out)?; + + let circuits_out = PyList::empty(py); + for circuit in result.circuits { + let d = PyDict::new(py); + d.set_item("circuit_id", circuit.circuit_id)?; + d.set_item("parent_node", circuit.parent_node)?; + d.set_item("queue", circuit.queue)?; + d.set_item("class_minor", circuit.class_minor)?; + d.set_item("class_major", circuit.class_major)?; + d.set_item("up_class_major", circuit.up_class_major)?; + circuits_out.append(d)?; + } + out.set_item("circuits", circuits_out)?; + + let site_state_out = PyDict::new(py); + for (key, state) in result.site_state { + let d = PyDict::new(py); + d.set_item("class_minor", state.class_minor)?; + d.set_item("queue", state.queue)?; + d.set_item("parent_path", state.parent_path)?; + d.set_item("class_major", state.class_major)?; + d.set_item("up_class_major", state.up_class_major)?; + site_state_out.set_item(key, d)?; + } + out.set_item("site_state", site_state_out)?; + + let circuit_state_out = PyDict::new(py); + for (key, state) in result.circuit_state { + let d = PyDict::new(py); + d.set_item("class_minor", state.class_minor)?; + d.set_item("queue", state.queue)?; + d.set_item("parent_node", state.parent_node)?; + d.set_item("class_major", state.class_major)?; + d.set_item("up_class_major", state.up_class_major)?; + circuit_state_out.set_item(key, d)?; + } + out.set_item("circuit_state", circuit_state_out)?; + + let last_used = PyDict::new(py); + for (queue, minor) in result.last_used_minor_by_queue { + last_used.set_item(queue, minor)?; + } + out.set_item("last_used_minor_by_queue", last_used)?; + Ok(out.into()) +} + #[pyfunction] fn write_planner_cbor(py: Python, path: String, state: PyObject) -> PyResult { use std::fs; @@ -672,11 +939,19 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(get_libreqos_directory, m)?)?; m.add_function(wrap_pyfunction!(overrides_persistent_devices, m)?)?; m.add_function(wrap_pyfunction!(overrides_persistent_devices_effective, m)?)?; + m.add_function(wrap_pyfunction!( + overrides_persistent_devices_materialized, + m + )?)?; m.add_function(wrap_pyfunction!(overrides_circuit_adjustments, m)?)?; m.add_function(wrap_pyfunction!( overrides_circuit_adjustments_effective, m )?)?; + m.add_function(wrap_pyfunction!( + overrides_circuit_adjustments_materialized, + m + )?)?; m.add_function(wrap_pyfunction!(overrides_network_adjustments, m)?)?; m.add_function(wrap_pyfunction!( overrides_network_adjustments_effective, @@ -710,6 +985,8 @@ fn liblqos_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(store_planner_remote, m)?)?; m.add_function(wrap_pyfunction!(write_planner_cbor, m)?)?; m.add_function(wrap_pyfunction!(read_planner_cbor, m)?)?; + m.add_function(wrap_pyfunction!(plan_top_level_cpu_bins, m)?)?; + m.add_function(wrap_pyfunction!(plan_class_identities, m)?)?; m.add_class::()?; Ok(()) @@ -1114,6 +1391,59 @@ fn overrides_persistent_devices_effective(py: Python<'_>) -> PyResult) -> PyResult> { + let overrides = match lqos_overrides::OverrideStore::load_effective(false, false) { + Ok(o) => o, + Err(e) => return Err(PyOSError::new_err(e.to_string())), + }; + + let mut out: Vec = Vec::new(); + for dev in overrides.persistent_devices().iter() { + let ipv4s: Vec = dev + .ipv4 + .iter() + .map(|(ip, prefix)| format!("{}/{}", ip, prefix)) + .collect(); + let ipv6s: Vec = dev + .ipv6 + .iter() + .map(|(ip, prefix)| format!("{}/{}", ip, prefix)) + .collect(); + + let d = PyDict::new(py); + d.set_item("circuitID", dev.circuit_id.clone())?; + d.set_item("circuitName", dev.circuit_name.clone())?; + d.set_item("deviceID", dev.device_id.clone())?; + d.set_item("deviceName", dev.device_name.clone())?; + d.set_item("ParentNode", dev.parent_node.clone())?; + d.set_item("mac", dev.mac.clone())?; + d.set_item("ipv4s", ipv4s)?; + d.set_item("ipv6s", ipv6s)?; + d.set_item("minDownload", dev.download_min_mbps)?; + d.set_item("minUpload", dev.upload_min_mbps)?; + d.set_item("maxDownload", dev.download_max_mbps)?; + d.set_item("maxUpload", dev.upload_max_mbps)?; + d.set_item("comment", dev.comment.clone())?; + d.set_item( + "sqm", + dev.sqm_override + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(), + )?; + let obj: PyObject = d.unbind().into(); + out.push(obj); + } + + Ok(out) +} + /// Returns the list of circuit adjustments as Python dicts. #[pyfunction] fn overrides_circuit_adjustments(py: Python<'_>) -> PyResult> { @@ -1303,6 +1633,99 @@ fn overrides_circuit_adjustments_effective(py: Python<'_>) -> PyResult) -> PyResult> { + let overrides = match lqos_overrides::OverrideStore::load_effective(false, false) { + Ok(o) => o, + Err(e) => return Err(PyOSError::new_err(e.to_string())), + }; + + let mut out: Vec = Vec::new(); + for adj in overrides.circuit_adjustments().iter() { + let d = PyDict::new(py); + match adj { + lqos_overrides::CircuitAdjustment::CircuitAdjustSpeed { + circuit_id, + min_download_bandwidth, + max_download_bandwidth, + min_upload_bandwidth, + max_upload_bandwidth, + } => { + d.set_item("type", "circuit_adjust_speed")?; + d.set_item("circuit_id", circuit_id.clone())?; + if let Some(v) = min_download_bandwidth { + d.set_item("min_download_bandwidth", *v)?; + } + if let Some(v) = max_download_bandwidth { + d.set_item("max_download_bandwidth", *v)?; + } + if let Some(v) = min_upload_bandwidth { + d.set_item("min_upload_bandwidth", *v)?; + } + if let Some(v) = max_upload_bandwidth { + d.set_item("max_upload_bandwidth", *v)?; + } + } + lqos_overrides::CircuitAdjustment::DeviceAdjustSpeed { + device_id, + min_download_bandwidth, + max_download_bandwidth, + min_upload_bandwidth, + max_upload_bandwidth, + } => { + d.set_item("type", "device_adjust_speed")?; + d.set_item("device_id", device_id.clone())?; + if let Some(v) = min_download_bandwidth { + d.set_item("min_download_bandwidth", *v)?; + } + if let Some(v) = max_download_bandwidth { + d.set_item("max_download_bandwidth", *v)?; + } + if let Some(v) = min_upload_bandwidth { + d.set_item("min_upload_bandwidth", *v)?; + } + if let Some(v) = max_upload_bandwidth { + d.set_item("max_upload_bandwidth", *v)?; + } + } + lqos_overrides::CircuitAdjustment::DeviceAdjustSqm { + device_id, + sqm_override, + } => { + d.set_item("type", "device_adjust_sqm")?; + d.set_item("device_id", device_id.clone())?; + if let Some(value) = sqm_override { + d.set_item("sqm_override", value.clone())?; + } + } + lqos_overrides::CircuitAdjustment::RemoveCircuit { circuit_id } => { + d.set_item("type", "remove_circuit")?; + d.set_item("circuit_id", circuit_id.clone())?; + } + lqos_overrides::CircuitAdjustment::RemoveDevice { device_id } => { + d.set_item("type", "remove_device")?; + d.set_item("device_id", device_id.clone())?; + } + lqos_overrides::CircuitAdjustment::ReparentCircuit { + circuit_id, + parent_node, + } => { + d.set_item("type", "reparent_circuit")?; + d.set_item("circuit_id", circuit_id.clone())?; + d.set_item("parent_node", parent_node.clone())?; + } + } + let obj: PyObject = d.unbind().into(); + out.push(obj); + } + + Ok(out) +} + /// Returns the list of network adjustments as Python dicts. #[pyfunction] fn overrides_network_adjustments(py: Python<'_>) -> PyResult> { @@ -2346,26 +2769,56 @@ impl Bakery { let estimate = estimate_full_reload_auto_qdisc_budget(&config, &queue); let is_ok = estimate.ok(); let interface_reports = estimate - .interfaces + .interface_details .iter() - .map(|(name, planned_qdiscs)| BakeryCapacityReportInterface { + .map(|(name, detail)| BakeryCapacityReportInterface { name: name.clone(), - planned_qdiscs: *planned_qdiscs, + planned_qdiscs: detail.planned_qdiscs, + infra_qdiscs: detail.infra_qdiscs, + cake_qdiscs: detail.cake_qdiscs, + fq_codel_qdiscs: detail.fq_codel_qdiscs, + estimated_memory_bytes: detail.estimated_memory_bytes, }) .collect::>(); + let memory_summary = if let Some(snapshot) = estimate.memory_snapshot.as_ref() { + format!( + "estimated qdisc memory {} bytes with {} bytes currently available and safety floor {} bytes", + estimate.estimated_total_memory_bytes, + snapshot.available_bytes, + lqos_bakery::BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES + ) + } else { + format!( + "estimated qdisc memory {} bytes; host memory snapshot unavailable", + estimate.estimated_total_memory_bytes + ) + }; let summary = if interface_reports.is_empty() { format!( - "Planned queue model {} qdisc budget. No shaping interfaces were queued.", - if is_ok { "fits" } else { "exceeds" } + "Planned queue model {} preflight. No shaping interfaces were queued; {memory_summary}.", + if is_ok { "fits" } else { "exceeds" }, ) } else { let interface_summary = interface_reports .iter() - .map(|entry| format!("{} estimated {} qdiscs", entry.name, entry.planned_qdiscs)) + .map(|entry| { + let detail = estimate + .interface_details + .get(&entry.name) + .expect("detail should exist for interface"); + format!( + "{} estimated {} qdiscs (infra {}, cake {}, fq_codel {})", + entry.name, + entry.planned_qdiscs, + detail.infra_qdiscs, + detail.cake_qdiscs, + detail.fq_codel_qdiscs + ) + }) .collect::>() .join(", "); format!( - "Planned queue model {} qdisc budget. {interface_summary}; safe budget {}, kernel limit {}.", + "Planned queue model {} preflight. {interface_summary}; safe budget {}, kernel limit {}; {memory_summary}.", if is_ok { "fits" } else { "exceeds" }, estimate.safe_budget, estimate.hard_limit, @@ -2373,23 +2826,58 @@ impl Bakery { }; let _ = run_query(vec![BusRequest::BakeryReportPreflight { ok: is_ok, - message: summary, + message: summary.clone(), safe_budget: estimate.safe_budget, hard_limit: estimate.hard_limit, + estimated_total_memory_bytes: estimate.estimated_total_memory_bytes, + memory_available_bytes: estimate + .memory_snapshot + .as_ref() + .map(|snapshot| snapshot.available_bytes), + memory_guard_min_available_bytes: lqos_bakery::BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES, + memory_ok: estimate.memory_ok, interfaces: interface_reports, }]); let result = PyDict::new(py); let interfaces = PyDict::new(py); + let interface_details = PyDict::new(py); for (interface, count) in estimate.interfaces { interfaces.set_item(interface, count)?; } + for (interface, detail) in estimate.interface_details { + let detail_dict = PyDict::new(py); + detail_dict.set_item("planned_qdiscs", detail.planned_qdiscs)?; + detail_dict.set_item("infra_qdiscs", detail.infra_qdiscs)?; + detail_dict.set_item("cake_qdiscs", detail.cake_qdiscs)?; + detail_dict.set_item("fq_codel_qdiscs", detail.fq_codel_qdiscs)?; + detail_dict.set_item("estimated_memory_bytes", detail.estimated_memory_bytes)?; + interface_details.set_item(interface, detail_dict)?; + } result.set_item("interfaces", interfaces)?; + result.set_item("interface_details", interface_details)?; result.set_item("safe_budget", estimate.safe_budget)?; result.set_item("hard_limit", estimate.hard_limit)?; + result.set_item( + "estimated_total_memory_bytes", + estimate.estimated_total_memory_bytes, + )?; + result.set_item("memory_ok", estimate.memory_ok)?; + result.set_item( + "memory_guard_min_available_bytes", + lqos_bakery::BAKERY_MEMORY_GUARD_MIN_AVAILABLE_BYTES, + )?; + if let Some(snapshot) = estimate.memory_snapshot { + result.set_item("memory_total_bytes", snapshot.total_bytes)?; + result.set_item("memory_available_bytes", snapshot.available_bytes)?; + } else { + result.set_item("memory_total_bytes", py.None())?; + result.set_item("memory_available_bytes", py.None())?; + } result.set_item("ok", is_ok)?; + result.set_item("summary", summary)?; Ok(result.into()) } diff --git a/src/rust/lqos_queue_tracker/src/tracking/mod.rs b/src/rust/lqos_queue_tracker/src/tracking/mod.rs index 9c38ae79..8904bb31 100644 --- a/src/rust/lqos_queue_tracker/src/tracking/mod.rs +++ b/src/rust/lqos_queue_tracker/src/tracking/mod.rs @@ -21,6 +21,8 @@ use watched_queues::WATCHED_QUEUES; pub use watched_queues::{add_watched_queue, still_watching}; static QUEUE_STATS_STALE: AtomicBool = AtomicBool::new(false); +const ALL_QUEUE_MONITOR_INTERVAL_SECONDS: u64 = 2; +const RELOAD_QUEUE_MONITOR_INTERVAL_SECONDS: u64 = 10; /// Returns `true` when queue counts are intentionally held at their last-known values. /// @@ -31,6 +33,12 @@ pub fn queue_stats_stale() -> bool { } fn track_queues() { + if full_reload_in_progress() { + QUEUE_STATS_STALE.store(true, Ordering::Relaxed); + debug!("(TC monitor) Skipping watched queue reads during Bakery full reload"); + expire_watched_queues(); + return; + } if WATCHED_QUEUES.is_empty() { //info!("No queues marked for read."); return; // There's nothing to do - bail out fast @@ -261,7 +269,7 @@ pub fn spawn_queue_monitor() -> anyhow::Result<()> { std::thread::Builder::new() .name("All Queue Monitor".to_string()) .spawn(|| { - let mut interval_seconds = 2; + let mut interval_seconds = ALL_QUEUE_MONITOR_INTERVAL_SECONDS; let Ok(mut tfd) = TimerFd::new() else { error!("Unable to start timer file descriptor. All queue monitor cannot run."); return; @@ -269,7 +277,7 @@ pub fn spawn_queue_monitor() -> anyhow::Result<()> { assert_eq!(tfd.get_state(), TimerState::Disarmed); tfd.set_state( TimerState::Periodic { - current: Duration::new(2, 0), + current: Duration::new(interval_seconds, 0), interval: Duration::new(interval_seconds, 0), }, SetTimeFlags::Default, @@ -277,13 +285,34 @@ pub fn spawn_queue_monitor() -> anyhow::Result<()> { let _ = tfd.read(); // Initial pause loop { - all_queue_reader(); + let desired_interval_seconds = if full_reload_in_progress() { + RELOAD_QUEUE_MONITOR_INTERVAL_SECONDS + } else { + ALL_QUEUE_MONITOR_INTERVAL_SECONDS + }; + if interval_seconds != desired_interval_seconds { + interval_seconds = desired_interval_seconds; + tfd.set_state( + TimerState::Periodic { + current: Duration::new(interval_seconds, 0), + interval: Duration::new(interval_seconds, 0), + }, + SetTimeFlags::Default, + ); + } + + if full_reload_in_progress() { + QUEUE_STATS_STALE.store(true, Ordering::Relaxed); + debug!("(TC monitor) Backing off full queue reads during Bakery full reload"); + } else { + all_queue_reader(); + } // Sleep until the next second let missed_ticks = tfd.read(); if missed_ticks > 1 { warn!("All Queue Reader: Missed {} ticks", missed_ticks - 1); - interval_seconds = 2 + (missed_ticks - 1); + interval_seconds = desired_interval_seconds + (missed_ticks - 1); tfd.set_state( TimerState::Periodic { current: Duration::new(interval_seconds, 0), diff --git a/src/rust/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index 5ee808a6..7f710d2a 100644 --- a/src/rust/lqosd/Cargo.toml +++ b/src/rust/lqosd/Cargo.toml @@ -31,6 +31,9 @@ nix = { workspace = true } sysinfo = { workspace = true } itertools = "0.13.0" reqwest = { workspace = true } +hmac = { workspace = true } +base64 = { workspace = true } +sha2 = { workspace = true } flate2 = "1" bincode = { workspace = true } ip_network_table = { workspace = true } diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index 280b515a..d6c91c45 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -12,7 +12,6 @@ pub mod lts2_sys; mod node_manager; mod preflight_checks; mod program_control; -mod reload_lock; mod remote_commands; mod rtt_exclusions; mod scheduler_control; @@ -515,6 +514,10 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec } BusResponse::Ack } + BusRequest::InvalidateAuthCache => { + crate::node_manager::invalidate_auth_cache(); + BusResponse::Ack + } #[cfg(feature = "equinix_tests")] BusRequest::RequestLqosEquinixTest => lqos_daht_test::lqos_daht_test(), BusRequest::ValidateShapedDevicesCsv => validation::validate_shaped_devices_csv(), @@ -743,6 +746,10 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec message, safe_budget, hard_limit, + estimated_total_memory_bytes, + memory_available_bytes, + memory_guard_min_available_bytes, + memory_ok, interfaces, } => { lqos_bakery::record_qdisc_preflight_snapshot( @@ -751,11 +758,19 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec message: message.clone(), safe_budget: *safe_budget, hard_limit: *hard_limit, + estimated_total_memory_bytes: *estimated_total_memory_bytes, + memory_available_bytes: *memory_available_bytes, + memory_guard_min_available_bytes: *memory_guard_min_available_bytes, + memory_ok: *memory_ok, interfaces: interfaces .iter() .map(|entry| lqos_bakery::BakeryCapacityInterfaceSnapshot { name: entry.name.clone(), planned_qdiscs: entry.planned_qdiscs, + infra_qdiscs: entry.infra_qdiscs, + cake_qdiscs: entry.cake_qdiscs, + fq_codel_qdiscs: entry.fq_codel_qdiscs, + estimated_memory_bytes: entry.estimated_memory_bytes, }) .collect(), }, diff --git a/src/rust/lqosd/src/node_manager.rs b/src/rust/lqosd/src/node_manager.rs index 4e59893d..daf80a84 100644 --- a/src/rust/lqosd/src/node_manager.rs +++ b/src/rust/lqosd/src/node_manager.rs @@ -7,6 +7,7 @@ mod template; mod warnings; pub(crate) mod ws; +pub(crate) use auth::invalidate_auth_cache; pub use local_api::circuit_count::circuit_count_data; pub use local_api::device_counts::device_count; pub(crate) use local_api::executive_cache::invalidate_executive_cache_snapshot; diff --git a/src/rust/lqosd/src/node_manager/auth.rs b/src/rust/lqosd/src/node_manager/auth.rs index d4edbb1e..d0faac37 100644 --- a/src/rust/lqosd/src/node_manager/auth.rs +++ b/src/rust/lqosd/src/node_manager/auth.rs @@ -1,26 +1,89 @@ //! Provides authentication for the Node Manager. -//! This is designed to be broadly compatible with the original -//! cookie-based system but now uses an Axum layer to be largely -//! invisible. use axum::Json; use axum::http::StatusCode; -use axum::response::{Html, Response}; +use axum::response::{Html, IntoResponse, Redirect, Response}; use axum_extra::extract::CookieJar; -use axum_extra::extract::cookie::Cookie; -use lqos_config::{UserRole, WebUsers, load_config}; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use hmac::{Hmac, Mac}; +use lqos_config::{AuthenticatedUser, UserRole, WebUsers, load_config}; use once_cell::sync::Lazy; +use parking_lot::Mutex; +use rand::RngCore; use serde::{Deserialize, Serialize}; -use std::path::Path; +use sha2::Sha256; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering::Relaxed; use std::time::{SystemTime, UNIX_EPOCH}; -use tokio::sync::Mutex; -use tracing::warn; +use tracing::{error, warn}; -const COOKIE_PATH: &str = "User-Token"; +const COOKIE_NAME: &str = "User-Token"; +const SESSION_TOKEN_VERSION: &str = "v1"; +const SESSION_DURATION_SECS: u64 = 60 * 60 * 24 * 30; +const SESSION_KEY_FILE_NAME: &str = "lqusers.session.key"; -static WEB_USERS: Lazy>> = Lazy::new(|| Mutex::new(None)); +type HmacSha256 = Hmac; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct AuthFileFingerprint { + path: PathBuf, + modified_unix_secs: Option, + modified_subsec_nanos: Option, + len: u64, +} + +#[derive(Clone, Debug)] +struct CachedAuthSnapshot { + fingerprint: Option, + snapshot: AuthSnapshot, +} + +#[derive(Clone, Debug)] +struct AuthSnapshot { + bootstrap_state: AuthBootstrapState, + auth_epoch: u64, + allow_anonymous: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AuthBootstrapState { + MissingUsersFile, + NoUsersConfigured, + Ready, + CorruptUsersFile, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct SessionClaims { + sub: String, + role: UserRole, + auth_epoch: u64, + iat: u64, + exp: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LoginResponse { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +#[derive(Clone, Debug)] +struct SessionUser { + username: String, + role: UserRole, +} + +static AUTH_SNAPSHOT: Lazy>> = Lazy::new(|| Mutex::new(None)); +static SESSION_KEY: Lazy>>> = Lazy::new(|| Mutex::new(None)); pub static FIRST_LOAD: AtomicU64 = AtomicU64::new(0); fn record_first_login_timestamp_if_needed() { @@ -60,15 +123,291 @@ fn record_first_login_timestamp_if_needed() { } } -pub async fn get_username(jar: &CookieJar) -> String { - let lock = WEB_USERS.lock().await; - if let Some(users) = &*lock - && let Some(token) = jar.get(COOKIE_PATH) +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn auth_file_fingerprint(path: &Path) -> Result { + let metadata = std::fs::metadata(path)?; + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()); + + Ok(AuthFileFingerprint { + path: path.to_path_buf(), + modified_unix_secs: modified.map(|d| d.as_secs()), + modified_subsec_nanos: modified.map(|d| d.subsec_nanos()), + len: metadata.len(), + }) +} + +fn auth_snapshot() -> AuthSnapshot { + let current_fingerprint = match WebUsers::existing_path() { + Ok(Some(path)) => match auth_file_fingerprint(&path) { + Ok(fingerprint) => Some(fingerprint), + Err(e) => { + warn!("Unable to stat auth file {:?}: {e}", path); + None + } + }, + Ok(None) => None, + Err(e) => { + warn!("Unable to resolve auth file path: {e}"); + return AuthSnapshot { + bootstrap_state: AuthBootstrapState::CorruptUsersFile, + auth_epoch: 0, + allow_anonymous: false, + }; + } + }; + + let mut cache = AUTH_SNAPSHOT.lock(); + if let Some(cached) = &*cache + && cached.fingerprint == current_fingerprint { - return users.get_username(token.value()); + return cached.snapshot.clone(); } - "Anonymous".to_string() + let snapshot = match current_fingerprint { + None => AuthSnapshot { + bootstrap_state: AuthBootstrapState::MissingUsersFile, + auth_epoch: 0, + allow_anonymous: false, + }, + Some(_) => match WebUsers::load_or_create() { + Ok(users) => AuthSnapshot { + bootstrap_state: if users.is_empty() { + AuthBootstrapState::NoUsersConfigured + } else { + AuthBootstrapState::Ready + }, + auth_epoch: users.auth_epoch(), + allow_anonymous: users.do_we_allow_anonymous(), + }, + Err(e) => { + warn!("Unable to load auth state: {e}"); + AuthSnapshot { + bootstrap_state: AuthBootstrapState::CorruptUsersFile, + auth_epoch: 0, + allow_anonymous: false, + } + } + }, + }; + + let refreshed_fingerprint = match WebUsers::existing_path() { + Ok(Some(path)) => auth_file_fingerprint(&path).ok(), + Ok(None) => None, + Err(e) => { + warn!("Unable to refresh auth file path after load: {e}"); + None + } + }; + + *cache = Some(CachedAuthSnapshot { + fingerprint: refreshed_fingerprint, + snapshot: snapshot.clone(), + }); + snapshot +} + +fn session_key_path() -> Result { + let config = load_config().map_err(|_| { + std::io::Error::other("Unable to load /etc/lqos.conf while locating session key") + })?; + Ok(Path::new(&config.lqos_directory).join(SESSION_KEY_FILE_NAME)) +} + +fn session_key() -> Result, std::io::Error> { + let mut cache = SESSION_KEY.lock(); + if let Some(key) = &*cache { + return Ok(key.clone()); + } + + let path = session_key_path()?; + let key = if path.exists() { + let bytes = std::fs::read(&path)?; + if bytes.is_empty() { + return Err(std::io::Error::other(format!( + "Session key file {:?} is empty", + path + ))); + } + bytes + } else { + let mut new_key = vec![0u8; 32]; + rand::thread_rng().fill_bytes(&mut new_key); + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + + file.write_all(&new_key)?; + drop(file); + new_key + }; + + *cache = Some(key.clone()); + Ok(key) +} + +fn build_session_cookie(token: String) -> Cookie<'static> { + let mut cookie = Cookie::new(COOKIE_NAME, token); + cookie.set_path("/"); + cookie.set_same_site(SameSite::Lax); + cookie +} + +fn build_signed_session(key: &[u8], user: &AuthenticatedUser) -> Result { + let now = now_unix_secs(); + let claims = SessionClaims { + sub: user.username.clone(), + role: user.role, + auth_epoch: user.auth_epoch, + iat: now, + exp: now.saturating_add(SESSION_DURATION_SECS), + }; + let payload = serde_json::to_vec(&claims).map_err(|e| { + error!("Unable to serialize session claims: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let payload_b64 = URL_SAFE_NO_PAD.encode(payload); + + let mut mac = HmacSha256::new_from_slice(key).map_err(|e| { + error!("Unable to initialize session signer: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + mac.update(payload_b64.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + + Ok(format!("{SESSION_TOKEN_VERSION}.{payload_b64}.{signature}")) +} + +fn verify_signed_session( + key: &[u8], + token: &str, + snapshot: &AuthSnapshot, +) -> Result, StatusCode> { + let Some((version, remainder)) = token.split_once('.') else { + return Ok(None); + }; + if version != SESSION_TOKEN_VERSION { + return Ok(None); + } + let Some((payload_b64, signature_b64)) = remainder.rsplit_once('.') else { + return Ok(None); + }; + + let mut mac = HmacSha256::new_from_slice(key).map_err(|e| { + error!("Unable to initialize session verifier: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + mac.update(payload_b64.as_bytes()); + let signature = URL_SAFE_NO_PAD + .decode(signature_b64) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + if mac.verify_slice(&signature).is_err() { + return Ok(None); + } + + let payload = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let claims: SessionClaims = + serde_json::from_slice(&payload).map_err(|_| StatusCode::UNAUTHORIZED)?; + + let now = now_unix_secs(); + if claims.exp <= now || claims.auth_epoch != snapshot.auth_epoch { + return Ok(None); + } + + Ok(Some(SessionUser { + username: claims.sub, + role: claims.role, + })) +} + +fn session_from_cookie( + jar: &CookieJar, + snapshot: &AuthSnapshot, +) -> Result, StatusCode> { + let Some(token) = jar.get(COOKIE_NAME) else { + return Ok(None); + }; + let key = session_key().map_err(|e| { + error!("Unable to load session key: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + verify_signed_session(&key, token.value(), snapshot) +} + +fn standalone_page_path(page: &str) -> Result { + let config = load_config().map_err(|e| { + error!("Unable to load config for standalone page {page}: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Path::new(&config.lqos_directory) + .join("bin") + .join("static2") + .join(page)) +} + +fn serve_standalone_page(page: &str) -> Result { + let path = standalone_page_path(page)?; + let body = std::fs::read_to_string(&path).map_err(|e| { + error!("Unable to read standalone page {:?}: {e}", path); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Html(body).into_response()) +} + +pub async fn login_page(jar: CookieJar) -> Response { + let snapshot = auth_snapshot(); + match snapshot.bootstrap_state { + AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => { + Redirect::temporary("/first-run.html").into_response() + } + AuthBootstrapState::Ready => match session_from_cookie(&jar, &snapshot) { + Ok(Some(_)) => Redirect::temporary("/index.html").into_response(), + Ok(None) => serve_standalone_page("login.html") + .unwrap_or_else(|status| (status, "Unable to serve login page").into_response()), + Err(status) => (status, "Unable to validate login session").into_response(), + }, + AuthBootstrapState::CorruptUsersFile => serve_standalone_page("login.html") + .unwrap_or_else(|status| (status, "Unable to serve login page").into_response()), + } +} + +pub async fn first_run_page() -> Response { + let snapshot = auth_snapshot(); + match snapshot.bootstrap_state { + AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => { + serve_standalone_page("first-run.html") + .unwrap_or_else(|status| (status, "Unable to serve first-run page").into_response()) + } + AuthBootstrapState::Ready | AuthBootstrapState::CorruptUsersFile => { + Redirect::temporary("/index.html").into_response() + } + } +} + +pub async fn get_username(jar: &CookieJar) -> String { + let snapshot = auth_snapshot(); + match session_from_cookie(jar, &snapshot) { + Ok(Some(user)) => user.username, + Ok(None) | Err(_) => "Anonymous".to_string(), + } } #[derive(Copy, Clone, Eq, PartialEq)] @@ -78,100 +417,76 @@ pub enum LoginResult { Denied, } -async fn check_login(jar: &CookieJar, users: &WebUsers) -> LoginResult { - if let Some(token) = jar.get(COOKIE_PATH) { - // Validate the token - return match users.get_role_from_token(token.value()) { - Ok(UserRole::ReadOnly) => LoginResult::ReadOnly, - Ok(UserRole::Admin) => LoginResult::Admin, - Err(_e) => LoginResult::Denied, - }; +fn login_result_for_session(user: Option, allow_anonymous: bool) -> LoginResult { + match user { + Some(SessionUser { + role: UserRole::Admin, + .. + }) => LoginResult::Admin, + Some(SessionUser { + role: UserRole::ReadOnly, + .. + }) => LoginResult::ReadOnly, + None if allow_anonymous => LoginResult::ReadOnly, + None => LoginResult::Denied, } - LoginResult::Denied } -/// Checks an incoming request for a User-Token cookie. If found, -/// it validates the request against the web users file. If the -/// web users file isn't found, it redirects to 'first run'. If -/// it is found, the token is checked (and redirected to login if -/// it isn't good). Finally, the user's role is injected into -/// the middleware. +/// Checks an incoming request for a `User-Token` cookie. If found, +/// it validates the request against the signed session and current auth epoch. +/// Missing or empty auth state redirects to first-run; invalid sessions redirect +/// to login unless anonymous read-only is enabled. pub async fn auth_layer( jar: CookieJar, mut req: axum::extract::Request, next: axum::middleware::Next, -) -> Result> { - const BOUNCE: &str = - ""; - const FIRST_RUN: &str = - ""; - - let mut lock = WEB_USERS.lock().await; - if lock.is_none() { - // No lock - let's see if there's a file to use? - if WebUsers::does_users_file_exist().expect("Error checking if the users file exists") { - // It exists - we load it - let users = WebUsers::load_or_create().expect("Unable to load users file"); - *lock = Some(users); - } else { - // No users file - redirect to first run - return Err(Html(FIRST_RUN)); +) -> Response { + let snapshot = auth_snapshot(); + match snapshot.bootstrap_state { + AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => { + return Redirect::temporary("/first-run.html").into_response(); } + AuthBootstrapState::CorruptUsersFile => { + return Redirect::temporary("/login.html").into_response(); + } + AuthBootstrapState::Ready => {} } - if let Some(users) = &*lock { - let login_result = check_login(&jar, users).await; - return match login_result { - LoginResult::Admin | LoginResult::ReadOnly => { - record_first_login_timestamp_if_needed(); - req.extensions_mut().insert(login_result); - Ok(next.run(req).await) - } - LoginResult::Denied => { - let users = WebUsers::load_or_create().expect("Could not load users file"); - if users.do_we_allow_anonymous() { - req.extensions_mut().insert(LoginResult::ReadOnly); - Ok(next.run(req).await) - } else { - Err(Html(BOUNCE)) - } - } - }; + let login_result = match session_from_cookie(&jar, &snapshot) { + Ok(user) => login_result_for_session(user, snapshot.allow_anonymous), + Err(status) => return (status, "Unable to validate session").into_response(), + }; + + match login_result { + LoginResult::Admin | LoginResult::ReadOnly => { + record_first_login_timestamp_if_needed(); + req.extensions_mut().insert(login_result); + next.run(req).await + } + LoginResult::Denied => Redirect::temporary("/login.html").into_response(), } - Err(Html(BOUNCE)) } pub async fn login_from_token(token: &str) -> LoginResult { - let mut lock = WEB_USERS.lock().await; - if lock.is_none() { - match WebUsers::does_users_file_exist() { - Ok(true) => match WebUsers::load_or_create() { - Ok(users) => { - *lock = Some(users); - } - Err(e) => { - warn!("Unable to load users file for websocket auth: {e}"); - return LoginResult::Denied; - } - }, - Ok(false) => { - return LoginResult::Denied; - } - Err(e) => { - warn!("Unable to check users file for websocket auth: {e}"); - return LoginResult::Denied; - } - } + let snapshot = auth_snapshot(); + if snapshot.bootstrap_state != AuthBootstrapState::Ready { + return LoginResult::Denied; } - let Some(users) = &*lock else { - return LoginResult::Denied; + let key = match session_key() { + Ok(key) => key, + Err(e) => { + warn!("Unable to load session key for websocket auth: {e}"); + return LoginResult::Denied; + } }; - let login_result = match users.get_role_from_token(token) { - Ok(UserRole::ReadOnly) => LoginResult::ReadOnly, - Ok(UserRole::Admin) => LoginResult::Admin, - Err(_) => LoginResult::Denied, + let login_result = match verify_signed_session(&key, token, &snapshot) { + Ok(user) => login_result_for_session(user, snapshot.allow_anonymous), + Err(e) => { + warn!("Unable to verify websocket session token: {e}"); + LoginResult::Denied + } }; if login_result != LoginResult::Denied { @@ -181,25 +496,15 @@ pub async fn login_from_token(token: &str) -> LoginResult { login_result } -/// Reload the cached users from disk after user-management changes. +/// Invalidate the cached auth snapshot after user-management changes. +pub fn invalidate_auth_cache() { + let mut lock = AUTH_SNAPSHOT.lock(); + *lock = None; +} + +/// Reload the cached auth snapshot after user-management changes. pub async fn refresh_cached_users() { - let mut lock = WEB_USERS.lock().await; - match WebUsers::does_users_file_exist() { - Ok(true) => match WebUsers::load_or_create() { - Ok(users) => { - *lock = Some(users); - } - Err(e) => { - warn!("Unable to refresh users cache: {e}"); - } - }, - Ok(false) => { - *lock = None; - } - Err(e) => { - warn!("Unable to check users file while refreshing cache: {e}"); - } - } + invalidate_auth_cache(); } #[derive(Serialize, Deserialize)] @@ -211,25 +516,88 @@ pub struct LoginAttempt { pub async fn try_login( jar: CookieJar, Json(login): Json, -) -> Result<(CookieJar, StatusCode), StatusCode> { - let users = WEB_USERS.lock().await; - if let Some(users) = &*users { - return match users.login(&login.username, &login.password) { - Ok(token) => { - record_first_login_timestamp_if_needed(); - Ok((jar.add(Cookie::new(COOKIE_PATH, token)), StatusCode::OK)) - } - Err(..) => { - if users.do_we_allow_anonymous() { - record_first_login_timestamp_if_needed(); - Ok((jar, StatusCode::OK)) - } else { - Err(StatusCode::UNAUTHORIZED) - } - } - }; +) -> Result<(CookieJar, Json), (StatusCode, Json)> { + let snapshot = auth_snapshot(); + match snapshot.bootstrap_state { + AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => { + return Err(( + StatusCode::CONFLICT, + Json(LoginResponse { + ok: false, + reason: Some("first_run_required"), + message: Some("No users are configured yet.".to_string()), + }), + )); + } + AuthBootstrapState::CorruptUsersFile => { + return Err(( + StatusCode::CONFLICT, + Json(LoginResponse { + ok: false, + reason: Some("auth_corrupt"), + message: Some("The auth file is corrupt and must be repaired.".to_string()), + }), + )); + } + AuthBootstrapState::Ready => {} } - Err(StatusCode::UNAUTHORIZED) + + let mut users = WebUsers::load_or_create().map_err(|e| { + warn!("Unable to load users during login: {e}"); + ( + StatusCode::CONFLICT, + Json(LoginResponse { + ok: false, + reason: Some("auth_corrupt"), + message: Some("The auth file is corrupt and must be repaired.".to_string()), + }), + ) + })?; + let authenticated = users + .authenticate(&login.username, &login.password) + .map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(LoginResponse { + ok: false, + reason: Some("invalid_credentials"), + message: Some("Invalid username or password.".to_string()), + }), + ) + })?; + + invalidate_auth_cache(); + let key = session_key().map_err(|e| { + error!("Unable to load session key during login: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(LoginResponse { + ok: false, + reason: Some("session_error"), + message: Some("Unable to create session token.".to_string()), + }), + ) + })?; + let token = build_signed_session(&key, &authenticated).map_err(|status| { + ( + status, + Json(LoginResponse { + ok: false, + reason: Some("session_error"), + message: Some("Unable to create session token.".to_string()), + }), + ) + })?; + + record_first_login_timestamp_if_needed(); + Ok(( + jar.add(build_session_cookie(token)), + Json(LoginResponse { + ok: true, + reason: None, + message: None, + }), + )) } #[derive(Serialize, Deserialize)] @@ -242,16 +610,106 @@ pub struct FirstUser { pub async fn first_user( jar: CookieJar, Json(new_user): Json, -) -> (CookieJar, StatusCode) { - let mut users = WebUsers::load_or_create().expect("Could not load users file"); +) -> Result<(CookieJar, Json), (StatusCode, Json)> { + let snapshot = auth_snapshot(); + match snapshot.bootstrap_state { + AuthBootstrapState::Ready => { + return Err(( + StatusCode::CONFLICT, + Json(LoginResponse { + ok: false, + reason: Some("already_configured"), + message: Some("Web authentication is already configured.".to_string()), + }), + )); + } + AuthBootstrapState::CorruptUsersFile => { + return Err(( + StatusCode::CONFLICT, + Json(LoginResponse { + ok: false, + reason: Some("auth_corrupt"), + message: Some("The auth file is corrupt and must be repaired.".to_string()), + }), + )); + } + AuthBootstrapState::MissingUsersFile | AuthBootstrapState::NoUsersConfigured => {} + } + + let mut users = WebUsers::load_or_create().map_err(|e| { + warn!("Unable to load users during first-run setup: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(LoginResponse { + ok: false, + reason: Some("auth_corrupt"), + message: Some("Unable to initialize auth storage.".to_string()), + }), + ) + })?; users .allow_anonymous(new_user.allow_anonymous) - .expect("Unable to set property"); - let token = users + .map_err(|e| { + warn!("Unable to set anonymous auth policy: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(LoginResponse { + ok: false, + reason: Some("auth_corrupt"), + message: Some("Unable to update auth settings.".to_string()), + }), + ) + })?; + users .add_or_update_user(&new_user.username, &new_user.password, UserRole::Admin) - .expect("Unable to add or update user"); - let mut lock = WEB_USERS.lock().await; - *lock = Some(users); + .map_err(|e| { + warn!("Unable to create first user: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(LoginResponse { + ok: false, + reason: Some("auth_corrupt"), + message: Some("Unable to create the first user.".to_string()), + }), + ) + })?; + + invalidate_auth_cache(); + let authenticated = AuthenticatedUser { + username: new_user.username, + role: UserRole::Admin, + auth_epoch: users.auth_epoch(), + password_upgraded: false, + }; + let key = session_key().map_err(|e| { + error!("Unable to load session key during first-run setup: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(LoginResponse { + ok: false, + reason: Some("session_error"), + message: Some("Unable to create session token.".to_string()), + }), + ) + })?; + let token = build_signed_session(&key, &authenticated).map_err(|status| { + ( + status, + Json(LoginResponse { + ok: false, + reason: Some("session_error"), + message: Some("Unable to create session token.".to_string()), + }), + ) + })?; + record_first_login_timestamp_if_needed(); - (jar.add(Cookie::new(COOKIE_PATH, token)), StatusCode::OK) + Ok(( + jar.add(build_session_cookie(token)), + Json(LoginResponse { + ok: true, + reason: None, + message: None, + }), + )) } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_apply_summary.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_apply_summary.js index e52ea14d..a008b5d6 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_apply_summary.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_apply_summary.js @@ -24,7 +24,7 @@ export class BakeryApplySummaryDashlet extends BaseDashlet { } tooltip() { - return "
Apply Summary

Shows the most recent Bakery apply type, command counts, and recorded build/apply durations.

"; + return "
Apply Summary

Shows live full-reload progress and the most recent Bakery apply type, command counts, and recorded build/apply durations.

During large Bakery reloads, queue polling is intentionally backed off so progress reporting stays lightweight.

"; } subscribeTo() { @@ -39,6 +39,31 @@ export class BakeryApplySummaryDashlet extends BaseDashlet { const tableWrap = document.createElement("div"); tableWrap.classList.add("lqos-table-wrap"); + const progressWrap = document.createElement("div"); + progressWrap.classList.add("mb-3"); + + const progressHeader = document.createElement("div"); + progressHeader.classList.add("d-flex", "justify-content-between", "align-items-center", "small", "mb-1"); + this.progressSummaryEl = document.createElement("span"); + this.progressSummaryEl.classList.add("text-body-secondary"); + this.progressPercentEl = document.createElement("span"); + this.progressPercentEl.classList.add("fw-semibold"); + progressHeader.appendChild(this.progressSummaryEl); + progressHeader.appendChild(this.progressPercentEl); + + this.progressBarWrapEl = document.createElement("div"); + this.progressBarWrapEl.classList.add("progress"); + this.progressBarWrapEl.style.height = "0.85rem"; + this.progressBarEl = document.createElement("div"); + this.progressBarEl.classList.add("progress-bar"); + this.progressBarEl.setAttribute("role", "progressbar"); + this.progressBarEl.style.width = "0%"; + this.progressBarEl.textContent = "0%"; + this.progressBarWrapEl.appendChild(this.progressBarEl); + + progressWrap.appendChild(progressHeader); + progressWrap.appendChild(this.progressBarWrapEl); + const table = document.createElement("table"); table.classList.add("lqos-table", "lqos-table-compact", "mb-0", "small"); const tbody = document.createElement("tbody"); @@ -73,6 +98,7 @@ export class BakeryApplySummaryDashlet extends BaseDashlet { table.appendChild(tbody); tableWrap.appendChild(table); + wrap.appendChild(progressWrap); wrap.appendChild(tableWrap); base.appendChild(wrap); return base; @@ -83,6 +109,34 @@ export class BakeryApplySummaryDashlet extends BaseDashlet { return; } const status = msg?.data?.currentState || {}; + const totalCommands = Number.isFinite(status.currentApplyTotalTcCommands) + ? status.currentApplyTotalTcCommands + : 0; + const completedCommands = Number.isFinite(status.currentApplyCompletedTcCommands) + ? status.currentApplyCompletedTcCommands + : 0; + const totalChunks = Number.isFinite(status.currentApplyTotalChunks) + ? status.currentApplyTotalChunks + : 0; + const completedChunks = Number.isFinite(status.currentApplyCompletedChunks) + ? status.currentApplyCompletedChunks + : 0; + const percent = totalCommands > 0 + ? Math.max(0, Math.min(100, (completedCommands / totalCommands) * 100)) + : 0; + const activeFullReload = status.mode === "ApplyingFullReload" && totalCommands > 0; + this.progressSummaryEl.textContent = activeFullReload + ? `${status.currentApplyPhase || "Applying tc command chunks"} • ${completedCommands.toLocaleString()} / ${totalCommands.toLocaleString()} commands • chunk ${Math.min(completedChunks + 1, totalChunks).toLocaleString()} / ${totalChunks.toLocaleString()} • queue polling paused` + : "No full reload currently applying"; + this.progressPercentEl.textContent = activeFullReload ? `${percent.toFixed(1)}%` : "Idle"; + this.progressBarEl.style.width = `${percent}%`; + this.progressBarEl.textContent = activeFullReload ? `${percent.toFixed(1)}%` : "0%"; + this.progressBarEl.setAttribute("aria-valuenow", percent.toFixed(1)); + this.progressBarEl.setAttribute("aria-valuemin", "0"); + this.progressBarEl.setAttribute("aria-valuemax", "100"); + this.progressBarEl.className = activeFullReload + ? "progress-bar progress-bar-striped progress-bar-animated bg-warning" + : "progress-bar bg-secondary"; this.applyTypeEl.innerHTML = ""; this.applyTypeEl.appendChild(formatApplyType(status.lastApplyType)); this.totalCommandsEl.textContent = Number.isFinite(status.lastTotalTcCommands) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_capacity.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_capacity.js index 87f70281..5a8f023d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_capacity.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_capacity.js @@ -1,5 +1,64 @@ import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet"; -import {bakeryPreflightBadge} from "./bakery_shared"; +import {bakeryPreflightBadge, mkBadge} from "./bakery_shared"; + +function clamp(n, min, max) { + return Math.min(Math.max(n, min), max); +} + +function formatBinaryBytes(bytes) { + const value = typeof bytes === "number" ? bytes : parseInt(bytes, 10); + if (!Number.isFinite(value) || value < 0) { + return "—"; + } + if (value === 0) { + return "0 B"; + } + + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let scaled = value; + let unitIndex = 0; + while (scaled >= 1024 && unitIndex < units.length - 1) { + scaled /= 1024; + unitIndex += 1; + } + const decimals = scaled >= 100 || unitIndex === 0 ? 0 : 1; + return `${scaled.toFixed(decimals)} ${units[unitIndex]}`; +} + +function formatPercent(value, max) { + if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0) { + return "—"; + } + const pct = (value / max) * 100.0; + return `${pct >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%`; +} + +function usageBarClass(plannedQdiscs, safeBudget, hardLimit) { + if (!Number.isFinite(plannedQdiscs) || !Number.isFinite(safeBudget) || safeBudget <= 0) { + return "bg-secondary"; + } + if ((Number.isFinite(hardLimit) && plannedQdiscs > hardLimit) || plannedQdiscs > safeBudget) { + return "bg-danger"; + } + const pct = (plannedQdiscs / safeBudget) * 100.0; + if (pct >= 85) { + return "bg-warning"; + } + if (pct >= 60) { + return "bg-info"; + } + return "bg-success"; +} + +function bakeryMemoryBadge(preflight) { + if (!preflight) { + return mkBadge("Memory Unknown", "bg-light text-secondary border"); + } + if (preflight.memoryOk) { + return mkBadge("Memory OK", "bg-success-subtle text-success border border-success-subtle", preflight.message || ""); + } + return mkBadge("Memory Guard", "bg-danger-subtle text-danger border border-danger-subtle", preflight.message || ""); +} export class BakeryCapacityDashlet extends BaseDashlet { constructor(slot) { @@ -26,36 +85,65 @@ export class BakeryCapacityDashlet extends BaseDashlet { wrap.classList.add("p-2"); this.badgeWrap = document.createElement("div"); - this.badgeWrap.classList.add("mb-2"); + this.badgeWrap.classList.add("d-flex", "flex-wrap", "gap-2", "mb-3"); wrap.appendChild(this.badgeWrap); - this.summaryEl = document.createElement("div"); - this.summaryEl.classList.add("small", "text-muted", "mb-2"); - this.summaryEl.style.whiteSpace = "normal"; - this.summaryEl.style.wordBreak = "break-word"; - wrap.appendChild(this.summaryEl); - const tableWrap = document.createElement("div"); - tableWrap.classList.add("lqos-table-wrap"); + tableWrap.classList.add("lqos-table-wrap", "mb-3"); const table = document.createElement("table"); table.classList.add("lqos-table", "lqos-table-compact", "mb-0", "small"); const thead = document.createElement("thead"); const hr = document.createElement("tr"); - ["Interface", "Planned", "Budget"].forEach((label) => { + ["Interface", "Planned", "Usage", "Mix"].forEach((label) => { const th = document.createElement("th"); th.textContent = label; hr.appendChild(th); }); thead.appendChild(hr); - this.tbody = document.createElement("tbody"); + this.interfacesTbody = document.createElement("tbody"); table.appendChild(thead); - table.appendChild(this.tbody); + table.appendChild(this.interfacesTbody); tableWrap.appendChild(table); wrap.appendChild(tableWrap); + + const safetyWrap = document.createElement("div"); + safetyWrap.classList.add("lqos-table-wrap"); + + const safetyTable = document.createElement("table"); + safetyTable.classList.add("lqos-table", "lqos-table-compact", "mb-0", "small"); + const safetyBody = document.createElement("tbody"); + + const mkRow = (label) => { + const tr = document.createElement("tr"); + const tdL = document.createElement("td"); + tdL.classList.add("table-label-cell"); + tdL.style.width = "44%"; + tdL.textContent = label; + const tdV = document.createElement("td"); + tdV.classList.add("table-value-cell"); + const valueEl = document.createElement("span"); + valueEl.textContent = "—"; + tdV.appendChild(valueEl); + tr.appendChild(tdL); + tr.appendChild(tdV); + safetyBody.appendChild(tr); + return {tr, valueEl}; + }; + + this.safeBudgetRow = mkRow("Safe Budget"); + this.hardLimitRow = mkRow("Kernel Limit"); + this.estimatedMemoryRow = mkRow("Est. Memory"); + this.availableMemoryRow = mkRow("Avail. Memory"); + this.memoryFloorRow = mkRow("Safety Floor"); + this.memoryHeadroomRow = mkRow("Headroom"); + + safetyTable.appendChild(safetyBody); + safetyWrap.appendChild(safetyTable); + wrap.appendChild(safetyWrap); base.appendChild(wrap); return base; } @@ -71,18 +159,39 @@ export class BakeryCapacityDashlet extends BaseDashlet { renderPreflight() { this.badgeWrap.innerHTML = ""; this.badgeWrap.appendChild(bakeryPreflightBadge(this.lastPreflight)); + this.badgeWrap.appendChild(bakeryMemoryBadge(this.lastPreflight)); - this.summaryEl.textContent = this.lastPreflight?.message || "No preflight has been recorded yet."; - this.tbody.innerHTML = ""; + this.interfacesTbody.innerHTML = ""; + + if (!this.lastPreflight) { + this.setSafetySummary({}); + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 4; + td.textContent = "No interface budget data"; + tr.appendChild(td); + this.interfacesTbody.appendChild(tr); + return; + } + + const interfaces = Array.isArray(this.lastPreflight.interfaces) + ? [...this.lastPreflight.interfaces].sort((left, right) => { + const plannedDiff = (right?.plannedQdiscs || 0) - (left?.plannedQdiscs || 0); + if (plannedDiff !== 0) { + return plannedDiff; + } + return (left?.name || "").localeCompare(right?.name || ""); + }) + : []; + this.setSafetySummary(this.lastPreflight); - const interfaces = Array.isArray(this.lastPreflight?.interfaces) ? this.lastPreflight.interfaces : []; if (interfaces.length === 0) { const tr = document.createElement("tr"); const td = document.createElement("td"); - td.colSpan = 3; + td.colSpan = 4; td.textContent = "No interface budget data"; tr.appendChild(td); - this.tbody.appendChild(tr); + this.interfacesTbody.appendChild(tr); return; } @@ -90,26 +199,122 @@ export class BakeryCapacityDashlet extends BaseDashlet { const tr = document.createElement("tr"); const tdName = document.createElement("td"); const tdPlanned = document.createElement("td"); - const tdBudget = document.createElement("td"); + const tdUsage = document.createElement("td"); + const tdMix = document.createElement("td"); tdName.textContent = entry?.name || "—"; - tdPlanned.textContent = Number.isFinite(entry?.plannedQdiscs) + + const plannedValue = document.createElement("div"); + plannedValue.classList.add("fw-semibold"); + plannedValue.textContent = Number.isFinite(entry?.plannedQdiscs) ? entry.plannedQdiscs.toLocaleString() : "—"; + const plannedMeta = document.createElement("div"); + plannedMeta.classList.add("small", "text-body-secondary"); + plannedMeta.textContent = formatBinaryBytes(entry?.estimatedMemoryBytes); + tdPlanned.appendChild(plannedValue); + tdPlanned.appendChild(plannedMeta); - const hardLimit = Number.isFinite(this.lastPreflight?.hardLimit) ? this.lastPreflight.hardLimit : null; const safeBudget = Number.isFinite(this.lastPreflight?.safeBudget) ? this.lastPreflight.safeBudget : null; - tdBudget.textContent = safeBudget !== null && hardLimit !== null + const hardLimit = Number.isFinite(this.lastPreflight?.hardLimit) ? this.lastPreflight.hardLimit : null; + const usageText = document.createElement("div"); + usageText.classList.add("d-flex", "justify-content-between", "small", "mb-1"); + const usageLabel = document.createElement("span"); + usageLabel.textContent = Number.isFinite(entry?.plannedQdiscs) && safeBudget !== null + ? `${formatPercent(entry.plannedQdiscs, safeBudget)} of safe` + : "—"; + const usageCount = document.createElement("span"); + usageCount.classList.add("text-body-secondary"); + usageCount.textContent = safeBudget !== null && hardLimit !== null ? `${safeBudget.toLocaleString()} / ${hardLimit.toLocaleString()}` : "—"; - if (Number.isFinite(entry?.plannedQdiscs) && safeBudget !== null && entry.plannedQdiscs > safeBudget) { - tdPlanned.classList.add("text-danger"); + usageText.appendChild(usageLabel); + usageText.appendChild(usageCount); + + const progress = document.createElement("div"); + progress.classList.add("progress"); + progress.style.height = "0.55rem"; + const bar = document.createElement("div"); + bar.classList.add("progress-bar", usageBarClass(entry?.plannedQdiscs, safeBudget, hardLimit)); + bar.setAttribute("role", "progressbar"); + const pct = Number.isFinite(entry?.plannedQdiscs) && safeBudget !== null && safeBudget > 0 + ? clamp((entry.plannedQdiscs / safeBudget) * 100.0, 0, 100) + : 0; + bar.style.width = `${pct.toFixed(1)}%`; + bar.setAttribute("aria-valuemin", "0"); + bar.setAttribute("aria-valuemax", safeBudget !== null ? safeBudget.toString() : "0"); + bar.setAttribute("aria-valuenow", Number.isFinite(entry?.plannedQdiscs) ? entry.plannedQdiscs.toString() : "0"); + progress.appendChild(bar); + + tdUsage.appendChild(usageText); + tdUsage.appendChild(progress); + + const mixWrap = document.createElement("div"); + mixWrap.classList.add("d-flex", "flex-wrap", "gap-1", "mb-1"); + mixWrap.appendChild(mkBadge( + `infra ${Number.isFinite(entry?.infraQdiscs) ? entry.infraQdiscs.toLocaleString() : "—"}`, + "bg-light text-secondary border", + )); + if (Number.isFinite(entry?.cakeQdiscs) && entry.cakeQdiscs > 0) { + mixWrap.appendChild(mkBadge( + `cake ${entry.cakeQdiscs.toLocaleString()}`, + "bg-warning-subtle text-warning border border-warning-subtle", + )); } + if (Number.isFinite(entry?.fqCodelQdiscs) && entry.fqCodelQdiscs > 0) { + mixWrap.appendChild(mkBadge( + `fq_codel ${entry.fqCodelQdiscs.toLocaleString()}`, + "bg-info-subtle text-info border border-info-subtle", + )); + } + tdMix.appendChild(mixWrap); tr.appendChild(tdName); tr.appendChild(tdPlanned); - tr.appendChild(tdBudget); - this.tbody.appendChild(tr); + tr.appendChild(tdUsage); + tr.appendChild(tdMix); + this.interfacesTbody.appendChild(tr); }); } + + setSafetySummary(preflight) { + const setText = (row, value, className = "") => { + row.valueEl.textContent = value; + row.valueEl.className = className; + }; + const setVisible = (row, visible) => { + row.tr.hidden = !visible; + }; + + setText(this.safeBudgetRow, Number.isFinite(preflight?.safeBudget) ? preflight.safeBudget.toLocaleString() : "—"); + setText(this.hardLimitRow, Number.isFinite(preflight?.hardLimit) ? preflight.hardLimit.toLocaleString() : "—"); + setText( + this.estimatedMemoryRow, + Number.isFinite(preflight?.estimatedTotalMemoryBytes) ? formatBinaryBytes(preflight.estimatedTotalMemoryBytes) : "—", + ); + setVisible(this.availableMemoryRow, Number.isFinite(preflight?.memoryAvailableBytes)); + setVisible( + this.memoryFloorRow, + Number.isFinite(preflight?.memoryAvailableBytes) && Number.isFinite(preflight?.memoryGuardMinAvailableBytes), + ); + const headroom = Number.isFinite(preflight?.memoryAvailableBytes) + && Number.isFinite(preflight?.memoryGuardMinAvailableBytes) + ? preflight.memoryAvailableBytes - preflight.memoryGuardMinAvailableBytes + : null; + setVisible(this.memoryHeadroomRow, headroom !== null); + + if (Number.isFinite(preflight?.memoryAvailableBytes)) { + setText(this.availableMemoryRow, formatBinaryBytes(preflight.memoryAvailableBytes)); + } + if (Number.isFinite(preflight?.memoryGuardMinAvailableBytes)) { + setText(this.memoryFloorRow, formatBinaryBytes(preflight.memoryGuardMinAvailableBytes)); + } + if (headroom !== null) { + setText( + this.memoryHeadroomRow, + `${headroom >= 0 ? "+" : "-"}${formatBinaryBytes(Math.abs(headroom))}`, + headroom < 0 ? "text-danger" : "", + ); + } + } } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_shared.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_shared.js index 5425d49b..524f6d76 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_shared.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_shared.js @@ -74,5 +74,12 @@ export function bakeryPreflightBadge(preflight) { if (preflight.ok) { return mkBadge("Within Budget", "bg-success-subtle text-success border border-success-subtle", preflight.message || ""); } + const interfaces = Array.isArray(preflight.interfaces) ? preflight.interfaces : []; + const safeBudget = Number.isFinite(preflight.safeBudget) ? preflight.safeBudget : null; + const overBudget = safeBudget !== null + && interfaces.some((entry) => Number.isFinite(entry?.plannedQdiscs) && entry.plannedQdiscs > safeBudget); + if (!overBudget && preflight.memoryOk === false) { + return mkBadge("Memory Blocked", "bg-danger-subtle text-danger border border-danger-subtle", preflight.message || ""); + } return mkBadge("Over Budget", "bg-danger-subtle text-danger border border-danger-subtle", preflight.message || ""); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_controls.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_controls.js index 437eeb15..e774e58d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_controls.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/treeguard_controls.js @@ -123,12 +123,17 @@ export class TreeguardControlsDashlet extends BaseDashlet { syncControls() { const enabled = document.getElementById(this.enabledId()); const dryRun = document.getElementById(this.dryRunId()); + const controls = document.getElementById(this.controlsId()); const statusEnabled = this.liveStatus?.enabled; const statusDryRun = this.liveStatus?.dry_run; + const paused = !!this.liveStatus?.paused_for_bakery_reload; enabled.checked = this.configLoaded ? !!this.treeguard.enabled : !!(statusEnabled ?? this.treeguard.enabled); dryRun.checked = this.configLoaded ? !!this.treeguard.dry_run : !!(statusDryRun ?? this.treeguard.dry_run); - enabled.disabled = this.isSaving; - dryRun.disabled = this.isSaving; + enabled.disabled = this.isSaving || paused; + dryRun.disabled = this.isSaving || paused; + if (controls) { + controls.classList.toggle("opacity-50", paused); + } } renderStatusLine() { @@ -136,6 +141,8 @@ export class TreeguardControlsDashlet extends BaseDashlet { const liveStatus = this.liveStatus || {}; const enabled = (liveStatus.enabled ?? this.treeguard.enabled) ? "Enabled" : "Disabled"; const dryRun = !!(liveStatus.dry_run ?? this.treeguard.dry_run); + const paused = !!liveStatus.paused_for_bakery_reload; + const pauseReason = (liveStatus.pause_reason || "Bakery full reload in progress").trim(); const liveState = dryRun ? "dry run" : "live"; const mode = this.treeguard.cpu?.mode === "cpu_aware" ? "CPU-aware" : "Traffic/RTT only"; const linkScope = this.treeguard.links?.all_nodes ? "all links" : "allowlisted links"; @@ -154,6 +161,9 @@ export class TreeguardControlsDashlet extends BaseDashlet { linkScope, circuitScope, ]; + if (paused) { + summaryBits.push(`paused: ${pauseReason}`); + } if (managedNodes !== null || managedCircuits !== null) { const counts = []; if (managedNodes !== null) counts.push(`${managedNodes} node${managedNodes === 1 ? "" : "s"}`); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/first-run.js b/src/rust/lqosd/src/node_manager/js_build/src/first-run.js index c4a024cb..a605adb1 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/first-run.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/first-run.js @@ -1,5 +1,5 @@ $("#btnCreateUser").on('click', () => { - let username = $("#username").val(); + let username = ($("#username").val() || "").trim(); let password = $("#password").val(); let anon = document.getElementById("allowAnonymous").checked; if (username === "") { @@ -21,11 +21,22 @@ $("#btnCreateUser").on('click', () => { url: "/firstLogin", data: JSON.stringify(login), contentType: 'application/json', + beforeSend: () => { + $("#firstRunError").removeClass("show").addClass("d-none"); + }, success: () => { window.location.href = "/index.html"; }, - error: () => { - alert("Something went wrong"); + error: (xhr) => { + const response = xhr && xhr.responseJSON ? xhr.responseJSON : {}; + const reason = response.reason || ""; + if (reason === "already_configured") { + window.location.href = "/login.html"; + return; + } + + $("#firstRunErrorText").text(response.message || "Something went wrong"); + $("#firstRunError").removeClass("d-none").addClass("show"); } }) -}); \ No newline at end of file +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/index.js b/src/rust/lqosd/src/node_manager/js_build/src/index.js index 92bd093b..769840a5 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/index.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/index.js @@ -9,6 +9,7 @@ const defaultLayout = { activeTab: 0, tabs: [ { + id: "executive-summary", name: "Executive Summary", dashlets: [ { tag: "executiveSnapshot", size: 12 }, @@ -21,6 +22,7 @@ const defaultLayout = { ] }, { + id: "overview", name: "Overview", dashlets: [ { tag: "throughputRing", size: 12 }, @@ -33,6 +35,7 @@ const defaultLayout = { ] }, { + id: "system-overview", name: "System Overview", dashlets: [ { tag: "throughputBps", size: 2 }, @@ -45,6 +48,7 @@ const defaultLayout = { ] }, { + id: "network", name: "Network", dashlets: [ { tag: "networkTreeSankey", size: 6 }, @@ -52,6 +56,7 @@ const defaultLayout = { ] }, { + id: "top-10", name: "Top 10", dashlets: [ { tag: "top10downloaders", size: 6 }, @@ -64,6 +69,7 @@ const defaultLayout = { ] }, { + id: "protocols-cake", name: "Protocols & Cake", dashlets: [ { tag: "etherProtocols", size: 6 }, @@ -72,6 +78,7 @@ const defaultLayout = { ] }, { + id: "treeguard", name: "TreeGuard", dashlets: [ { tag: "treeguardControls", size: 6 }, @@ -80,6 +87,7 @@ const defaultLayout = { ] }, { + id: "bakery", name: "Bakery", dashlets: [ { tag: "bakeryStatusSummary", size: 4 }, diff --git a/src/rust/lqosd/src/node_manager/js_build/src/login.js b/src/rust/lqosd/src/node_manager/js_build/src/login.js index 3a523734..f1f35873 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/login.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/login.js @@ -1,5 +1,5 @@ $("#btnLogin").on('click', () => { - let username = $("#username").val(); + let username = ($("#username").val() || "").trim(); let password = $("#password").val(); if (username === "") { alert("You must enter a username"); @@ -21,12 +21,27 @@ $("#btnLogin").on('click', () => { data: JSON.stringify(login), contentType: 'application/json', beforeSend: () => { + $("#loginErrorText").html("Login failed. You can manage users via the lqusers CLI tool on the LibreQoS server."); $("#loginError").removeClass("show").addClass("d-none"); }, success: () => { window.location.href = "/index.html"; }, - error: () => { + error: (xhr) => { + const response = xhr && xhr.responseJSON ? xhr.responseJSON : {}; + const reason = response.reason || ""; + if (reason === "first_run_required") { + window.location.href = "/first-run.html"; + return; + } + + if (reason === "auth_corrupt") { + $("#loginErrorText").text(response.message || "The auth file is corrupt and must be repaired before anyone can log in."); + } else if (reason === "invalid_credentials") { + $("#loginErrorText").text(response.message || "Invalid username or password."); + } else { + $("#loginErrorText").html("Login failed. You can manage users via the lqusers CLI tool on the LibreQoS server."); + } $("#loginError").removeClass("d-none").addClass("show"); } }) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/dashboard_editor.js b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/dashboard_editor.js index 242548b4..6a83209f 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/dashboard_editor.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/dashboard_editor.js @@ -1,3 +1,5 @@ +import {DashboardLayout} from "./layout"; + /** * Opens a full screen modal that lets the user edit dashboard items with tab support. * @param {Object} layout - The current dashboard layout object with tabs @@ -8,7 +10,13 @@ export function openDashboardEditor(layout, availableElements, callback, cookieName) { // Keep track of current tab and layout state let currentLayout = JSON.parse(JSON.stringify(layout)); // Deep copy - let activeTabIndex = currentLayout.activeTab || 0; + currentLayout.tabs = DashboardLayout.normalizeTabs(currentLayout.tabs || []); + let activeTabIndex = DashboardLayout.resolveActiveTabIndex( + currentLayout.tabs, + currentLayout.activeTab, + currentLayout.tabs[currentLayout.activeTab || 0]?.id || null + ); + currentLayout.activeTab = activeTabIndex; const sortableInstances = []; let committedLayout = null; @@ -301,10 +309,12 @@ export function openDashboardEditor(layout, availableElements, callback, cookieN $('#addTabButton').on('click', function() { let newTabName = `Tab ${currentLayout.tabs.length + 1}`; currentLayout.tabs.push({ + id: DashboardLayout.makeTabId(newTabName), name: newTabName, dashlets: [] }); activeTabIndex = currentLayout.tabs.length - 1; + currentLayout.activeTab = activeTabIndex; renderTabs(); renderDashboard(); }); @@ -516,8 +526,12 @@ export function openDashboardEditor(layout, availableElements, callback, cookieN // Apply the layout currentLayout = { version: imported.version || 2, - tabs: imported.tabs, - activeTab: imported.activeTab || 0 + tabs: DashboardLayout.normalizeTabs(imported.tabs), + activeTab: DashboardLayout.resolveActiveTabIndex( + DashboardLayout.normalizeTabs(imported.tabs), + imported.activeTab, + imported.tabs?.[imported.activeTab || 0]?.id || null + ) }; // Re-render diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/layout.js b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/layout.js index 97efc038..8f071b78 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/layout.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/lq_js_common/dashboard/layout.js @@ -3,8 +3,10 @@ const CURRENT_VERSION = 3; export class DashboardLayout { constructor(cookieName, defaultLayout) { this.cookieName = cookieName; + this.activeTabStorageKey = `${cookieName}.activeTabId`; // Keep a normalized copy of the default (tabs + dashlets only) this._defaultTabs = DashboardLayout.normalizeToTabs(defaultLayout); + const persistedActiveTabId = this.loadPersistedActiveTabId(); let template = localStorage.getItem(cookieName); if (template !== null) { let parsed = JSON.parse(template); @@ -15,21 +17,30 @@ export class DashboardLayout { Array.isArray(parsed.tabs) ? parsed.tabs : [], this._defaultTabs ); - this.activeTab = parsed.activeTab || 0; + this.activeTab = DashboardLayout.resolveActiveTabIndex( + this.tabs, + parsed.activeTab, + persistedActiveTabId + ); } else { // Old format saved in localStorage (pre-tabs) // Treat as a signal to reset to the new default layout, // so users see the new Overview tab instead of a single-tab conversion. this.version = CURRENT_VERSION; if (defaultLayout && defaultLayout.version >= 2) { - this.tabs = defaultLayout.tabs || []; - this.activeTab = defaultLayout.activeTab || 0; + this.tabs = DashboardLayout.normalizeToTabs(defaultLayout); + this.activeTab = DashboardLayout.resolveActiveTabIndex( + this.tabs, + defaultLayout.activeTab, + persistedActiveTabId + ); } else { - this.tabs = [{ - name: "Dashboard", - dashlets: defaultLayout || [] - }]; - this.activeTab = 0; + this.tabs = DashboardLayout.normalizeToTabs(defaultLayout); + this.activeTab = DashboardLayout.resolveActiveTabIndex( + this.tabs, + 0, + persistedActiveTabId + ); } try { localStorage.removeItem(this.cookieName); } catch (e) {} } @@ -38,18 +49,24 @@ export class DashboardLayout { if (defaultLayout && defaultLayout.version >= 2) { // New format default this.version = defaultLayout.version; - this.tabs = defaultLayout.tabs || []; - this.activeTab = defaultLayout.activeTab || 0; + this.tabs = DashboardLayout.normalizeToTabs(defaultLayout); + this.activeTab = DashboardLayout.resolveActiveTabIndex( + this.tabs, + defaultLayout.activeTab, + persistedActiveTabId + ); } else { // Old format default - convert to new format this.version = CURRENT_VERSION; - this.tabs = [{ - name: "Dashboard", - dashlets: defaultLayout || [] - }]; - this.activeTab = 0; + this.tabs = DashboardLayout.normalizeToTabs(defaultLayout); + this.activeTab = DashboardLayout.resolveActiveTabIndex( + this.tabs, + 0, + persistedActiveTabId + ); } } + this.persistActiveTabId(); } save(layoutData) { @@ -60,9 +77,14 @@ export class DashboardLayout { } else { // New style - full layout object this.version = layoutData.version || CURRENT_VERSION; - this.tabs = layoutData.tabs || []; - this.activeTab = layoutData.activeTab || 0; + this.tabs = DashboardLayout.normalizeTabs(layoutData.tabs || []); + this.activeTab = DashboardLayout.resolveActiveTabIndex( + this.tabs, + layoutData.activeTab, + this.getActiveTabId() + ); } + this.persistActiveTabId(); // Only persist if layout differs from default tabs/dashlets const isDefault = DashboardLayout.tabsEqual(this.tabs, this._defaultTabs); if (isDefault) { @@ -92,24 +114,97 @@ export class DashboardLayout { getCurrentTabDashlets() { return this.tabs[this.activeTab]?.dashlets || []; } + + getActiveTabId() { + return this.tabs[this.activeTab]?.id || null; + } + + loadPersistedActiveTabId() { + try { + return localStorage.getItem(this.activeTabStorageKey); + } catch (e) { + return null; + } + } + + persistActiveTabId() { + try { + const activeTabId = this.getActiveTabId(); + if (activeTabId) { + localStorage.setItem(this.activeTabStorageKey, activeTabId); + } else { + localStorage.removeItem(this.activeTabStorageKey); + } + } catch (e) {} + } } // Static helpers DashboardLayout.normalizeToTabs = function(defaultLayout) { if (!defaultLayout) return []; if (defaultLayout.version >= 2 && Array.isArray(defaultLayout.tabs)) { - return defaultLayout.tabs.map(t => ({ - name: t.name || "Dashboard", - dashlets: Array.isArray(t.dashlets) ? t.dashlets.map(d => ({ tag: d.tag, size: d.size })) : [] - })); + return DashboardLayout.normalizeTabs(defaultLayout.tabs); } // Old format: list of dashlets if (Array.isArray(defaultLayout)) { - return [{ name: "Dashboard", dashlets: defaultLayout.map(d => ({ tag: d.tag, size: d.size })) }]; + return [DashboardLayout.normalizeTab({ + id: "dashboard", + name: "Dashboard", + dashlets: defaultLayout + })]; } return []; }; +DashboardLayout.makeTabId = function(name = "tab") { + const base = String(name || "tab") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "tab"; + return `${base}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +}; + +DashboardLayout.normalizeTab = function(tab, fallbackName = "Dashboard", fallbackId = null) { + const name = tab?.name || fallbackName; + return { + id: tab?.id || fallbackId || DashboardLayout.makeTabId(name), + name, + dashlets: Array.isArray(tab?.dashlets) + ? tab.dashlets.map((dashlet) => ({ tag: dashlet.tag, size: dashlet.size })) + : [] + }; +}; + +DashboardLayout.normalizeTabs = function(tabs) { + if (!Array.isArray(tabs) || tabs.length === 0) { + return []; + } + return tabs.map((tab, index) => + DashboardLayout.normalizeTab(tab, `Tab ${index + 1}`) + ); +}; + +DashboardLayout.resolveActiveTabIndex = function(tabs, requestedIndex, requestedTabId) { + if (!Array.isArray(tabs) || tabs.length === 0) { + return 0; + } + + if (requestedTabId) { + const indexById = tabs.findIndex((tab) => tab?.id === requestedTabId); + if (indexById >= 0) { + return indexById; + } + } + + const numericIndex = Number.parseInt(requestedIndex, 10); + if (Number.isFinite(numericIndex) && numericIndex >= 0 && numericIndex < tabs.length) { + return numericIndex; + } + + return 0; +}; + DashboardLayout.tabsEqual = function(tabsA, tabsB) { if (!Array.isArray(tabsA) || !Array.isArray(tabsB)) return false; if (tabsA.length !== tabsB.length) return false; @@ -131,23 +226,30 @@ DashboardLayout.tabsEqual = function(tabsA, tabsB) { }; DashboardLayout.mergeMissingDefaultTabs = function(existingTabs, defaultTabs) { - if (!Array.isArray(existingTabs) || !Array.isArray(defaultTabs) || defaultTabs.length === 0) { - return Array.isArray(existingTabs) ? existingTabs : []; + if (!Array.isArray(existingTabs) || existingTabs.length === 0) { + return Array.isArray(defaultTabs) ? DashboardLayout.normalizeTabs(defaultTabs) : []; } - const knownNames = new Set(existingTabs.map((tab) => tab?.name || "")); - const merged = existingTabs.slice(); - defaultTabs.forEach((tab) => { - const name = tab?.name || ""; - if (!name || knownNames.has(name)) { + + const normalizedExisting = DashboardLayout.normalizeTabs(existingTabs); + if (!Array.isArray(defaultTabs) || defaultTabs.length === 0) { + return normalizedExisting; + } + + const merged = normalizedExisting.slice(); + const knownIds = new Set(normalizedExisting.map((tab) => tab?.id || "")); + const knownNames = new Set(normalizedExisting.map((tab) => tab?.name || "")); + defaultTabs.forEach((tab, index) => { + const normalizedTab = DashboardLayout.normalizeTab(tab, `Tab ${index + 1}`); + if ( + !normalizedTab.name + || knownIds.has(normalizedTab.id) + || knownNames.has(normalizedTab.name) + ) { return; } - knownNames.add(name); - merged.push({ - name, - dashlets: Array.isArray(tab?.dashlets) - ? tab.dashlets.map((dashlet) => ({ tag: dashlet.tag, size: dashlet.size })) - : [] - }); + knownIds.add(normalizedTab.id); + knownNames.add(normalizedTab.name); + merged.push(normalizedTab); }); return merged; }; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/template.js b/src/rust/lqosd/src/node_manager/js_build/src/template.js index 7b921da1..5898d2ca 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/template.js @@ -55,23 +55,20 @@ function renderSchedulerStatus(container, state) { let color = "text-secondary"; let icon = "fa-spinner fa-spin"; let label = "Scheduler status is loading"; - let buttonText = "Scheduler starting..."; + const buttonText = "Scheduler"; if (state === "healthy") { color = "text-success"; icon = "fa-check-circle"; label = "Scheduler is available"; - buttonText = "Scheduler"; } else if (state === "unavailable") { color = "text-warning"; icon = "fa-clock"; label = "Scheduler is still unavailable"; - buttonText = "Scheduler unavailable"; } else if (state === "error") { color = "text-danger"; icon = "fa-triangle-exclamation"; label = "Scheduler has an internal error"; - buttonText = "Scheduler error"; } container.innerHTML = ` diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree.js b/src/rust/lqosd/src/node_manager/js_build/src/tree.js index be727e31..c4e2c1d1 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree.js @@ -302,6 +302,13 @@ function renderHeaderStatusIcons(node) { "Virtual node (logical only; not shaped in HTB)." )); } + if (node.runtime_virtualized === true) { + target.appendChild(buildStatusIcon( + "fa-layer-group", + "text-warning", + "TreeGuard runtime-virtualized node (physically bypassed in Bakery while preserved in the logical hierarchy)." + )); + } if (isStormguardNode(node)) { target.appendChild(buildStatusIcon( "fa-cloud-bolt", @@ -1247,6 +1254,12 @@ function buildRow(i, depth=0) { "Virtual node: Active", "Virtual node: Inactive", )); + statusWrap.appendChild(buildTreeRowStatusIcon( + "fa-layer-group", + node.runtime_virtualized === true, + "TreeGuard runtime virtualization: Active", + "TreeGuard runtime virtualization: Inactive", + )); statusWrap.appendChild(buildTreeRowStatusIcon( "fa-cloud-bolt", isStormguardNode(node), @@ -1400,6 +1413,7 @@ function treeUpdate(msg) { || tree[nodeId][1].subtree_circuit_count !== node.subtree_circuit_count || tree[nodeId][1].type !== node.type || tree[nodeId][1].virtual !== node.virtual + || tree[nodeId][1].runtime_virtualized !== node.runtime_virtualized || tree[nodeId][1].name !== node.name) { needsRebuild = true; } diff --git a/src/rust/lqosd/src/node_manager/local_api/directories.rs b/src/rust/lqosd/src/node_manager/local_api/directories.rs index e3b77469..63a5e525 100644 --- a/src/rust/lqosd/src/node_manager/local_api/directories.rs +++ b/src/rust/lqosd/src/node_manager/local_api/directories.rs @@ -206,7 +206,7 @@ pub fn treeguard_metadata_summary() -> TreeGuardMetadataSummary { .iter() .filter(|(_, node)| { let name = node.name.trim(); - !name.is_empty() && name != "Root" && node.is_virtual + !name.is_empty() && name != "Root" && node.runtime_virtualized }) .count(); diff --git a/src/rust/lqosd/src/node_manager/local_api/network_tree_lite.rs b/src/rust/lqosd/src/node_manager/local_api/network_tree_lite.rs index ecaa9b50..6862d9cd 100644 --- a/src/rust/lqosd/src/node_manager/local_api/network_tree_lite.rs +++ b/src/rust/lqosd/src/node_manager/local_api/network_tree_lite.rs @@ -12,6 +12,9 @@ pub struct NetworkTreeLiteNode { /// True if the node is virtual/logical-only. #[serde(rename = "virtual", default)] pub is_virtual: bool, + /// True if TreeGuard has runtime-virtualized this node in Bakery. + #[serde(default)] + pub runtime_virtualized: bool, /// Configured maximum throughput in Mbps. pub max_throughput: (f64, f64), /// Current throughput in bytes per second. diff --git a/src/rust/lqosd/src/node_manager/run.rs b/src/rust/lqosd/src/node_manager/run.rs index 9b398351..13075237 100644 --- a/src/rust/lqosd/src/node_manager/run.rs +++ b/src/rust/lqosd/src/node_manager/run.rs @@ -52,6 +52,8 @@ pub async fn spawn_webserver( // Construct the router from parts let router = Router::new() .route("/", get(redirect_to_index)) + .route("/login.html", get(auth::login_page)) + .route("/first-run.html", get(auth::first_run_page)) .route("/doLogin", post(auth::try_login)) .route("/firstLogin", post(auth::first_user)) .route("/health", get(health_check)) diff --git a/src/rust/lqosd/src/node_manager/static2/first-run.html b/src/rust/lqosd/src/node_manager/static2/first-run.html index 95683af3..7f4c89f2 100644 --- a/src/rust/lqosd/src/node_manager/static2/first-run.html +++ b/src/rust/lqosd/src/node_manager/static2/first-run.html @@ -72,6 +72,11 @@
+
+ + Unable to initialize web authentication. +
+
diff --git a/src/rust/lqosd/src/node_manager/static2/login.html b/src/rust/lqosd/src/node_manager/static2/login.html index 5867c5d3..48e872a8 100644 --- a/src/rust/lqosd/src/node_manager/static2/login.html +++ b/src/rust/lqosd/src/node_manager/static2/login.html @@ -55,7 +55,7 @@
- Login failed. You can manage users via the lqusers CLI tool on the LibreQoS server. + Login failed. You can manage users via the lqusers CLI tool on the LibreQoS server.
diff --git a/src/rust/lqosd/src/node_manager/static2/template.html b/src/rust/lqosd/src/node_manager/static2/template.html index 4b799f3d..8b1d2339 100644 --- a/src/rust/lqosd/src/node_manager/static2/template.html +++ b/src/rust/lqosd/src/node_manager/static2/template.html @@ -262,7 +262,7 @@ diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 89b5b9be..cb168835 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -405,6 +405,22 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { .lqos-circuit-panel .lqos-table { height: 100%; } +.lqos-circuit-header-table.lqos-table-compact tbody td { + padding: 0.5rem 0.55rem; +} +.lqos-table.lqos-circuit-header-table .table-label-cell { + width: 6.75rem; + white-space: nowrap; + padding-right: 0.25rem; +} +.lqos-table.lqos-circuit-header-table .table-value-cell { + width: auto; + padding-left: 0.25rem; +} +.lqos-circuit-parent-link { + display: block; + max-width: 100%; +} .lqos-circuit-gauge-panel { display: flex; align-items: center; diff --git a/src/rust/lqosd/src/node_manager/static_pages.rs b/src/rust/lqosd/src/node_manager/static_pages.rs index ed103145..8bd06e09 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -36,6 +36,7 @@ pub(super) fn static_routes() -> Result { "unknown_ips.html", "configuration.html", "circuit.html", + "ethernet_caps.html", "site_map.html", "flow_map.html", "all_tree_sankey.html", diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 08c5b8d9..81447c45 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -11,9 +11,9 @@ use std::sync::Arc; use crate::node_manager::auth::{LoginResult, login_from_token}; use crate::node_manager::local_api::{ circuit, circuit_count, config, cpu_affinity, dashboard_themes, device_counts, directories, - executive, flow_explorer, flow_map, lts, network_tree, network_tree_lite, node_rate_overrides, - packet_analysis, reload_libreqos, scheduler, search, shaped_device_api, shaped_devices_page, - unknown_ips, urgent, warnings, + ethernet_caps, executive, flow_explorer, flow_map, lts, network_tree, network_tree_lite, + node_rate_overrides, packet_analysis, reload_libreqos, scheduler, search, shaped_device_api, + shaped_devices_page, unknown_ips, urgent, warnings, }; use crate::node_manager::shaper_queries_actor::ShaperQueryCommand; use crate::node_manager::ws::messages::{ @@ -380,6 +380,14 @@ async fn receive_channel_message( return true; } } + WsRequest::EthernetCapsPage { query } => { + let response = WsResponse::EthernetCapsPage { + data: ethernet_caps::ethernet_caps_page(query), + }; + if send_ws_response(&tx, response).await { + return true; + } + } WsRequest::ExecutiveHeatmapPage { query } => { let response = WsResponse::ExecutiveHeatmapPage { data: executive::executive_heatmap_page(query), @@ -421,11 +429,11 @@ async fn receive_channel_message( } } WsRequest::CircuitById { id } => { - let (ok, devices) = match circuit::circuit_by_id_data(&id) { - Some(devices) => (true, devices), - None => (false, Vec::new()), + let (ok, data) = match circuit::circuit_by_id_data(&id) { + Some(data) => (true, Some(data)), + None => (false, None), }; - let response = WsResponse::CircuitByIdResult { id, devices, ok }; + let response = WsResponse::CircuitByIdResult { id, data, ok }; if send_ws_response(&tx, response).await { return true; } diff --git a/src/rust/lqosd/src/node_manager/ws/messages.rs b/src/rust/lqosd/src/node_manager/ws/messages.rs index 582b06e9..652be376 100644 --- a/src/rust/lqosd/src/node_manager/ws/messages.rs +++ b/src/rust/lqosd/src/node_manager/ws/messages.rs @@ -1,10 +1,12 @@ use crate::lts2_sys::control_channel::{SupportTicket, SupportTicketSummary}; use crate::node_manager::WarningLevel; +use crate::node_manager::local_api::circuit::CircuitByIdData; use crate::node_manager::local_api::dashboard_themes::{DashletIdentity, ThemeEntry}; use crate::node_manager::local_api::device_counts::DeviceCount; use crate::node_manager::local_api::directories::{ CircuitDirectoryPage, CircuitDirectoryQuery, NodeDirectoryEntry, TreeGuardMetadataSummary, }; +use crate::node_manager::local_api::ethernet_caps::{EthernetCapsPage, EthernetCapsPageQuery}; use crate::node_manager::local_api::executive::{ ExecutiveDashboardSummary, ExecutiveHeatmapPage, ExecutiveHeatmapPageQuery, ExecutiveLeaderboardPage, ExecutiveLeaderboardPageQuery, @@ -202,6 +204,9 @@ pub enum WsRequest { CircuitDirectoryPage { query: CircuitDirectoryQuery, }, + EthernetCapsPage { + query: EthernetCapsPageQuery, + }, NodeDirectory, TreeGuardMetadataSummary, GetUsers, @@ -636,6 +641,9 @@ pub enum WsResponse { CircuitDirectoryPage { data: CircuitDirectoryPage, }, + EthernetCapsPage { + data: EthernetCapsPage, + }, NodeDirectory { data: Vec, }, @@ -746,7 +754,7 @@ pub enum WsResponse { }, CircuitByIdResult { id: String, - devices: Vec, + data: Option, ok: bool, }, RequestAnalysisResult { diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs b/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs index e2949331..64d277ac 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs @@ -9,7 +9,7 @@ use crate::shaped_devices_tracker::NETWORK_JSON; use lqos_bakery::{ BakeryActivityEntry as BakeryActivitySnapshot, BakeryApplyType, BakeryMode, BakeryPreflightSnapshot, BakeryRuntimeNodeOperationAction, BakeryRuntimeNodeOperationStatus, - BakeryStatusSnapshot, + BakeryStatusSnapshot, bakery_runtime_node_branch_snapshot, }; use lqos_utils::hash_to_i64; use std::sync::Arc; @@ -86,6 +86,10 @@ fn resolve_site_name(site_hash: i64) -> Option { .find_map(|node| (hash_to_i64(&node.name) == site_hash).then(|| node.name.clone())) } +fn resolve_runtime_site_name(site_hash: i64) -> Option { + bakery_runtime_node_branch_snapshot(site_hash).map(|snapshot| snapshot.site_name) +} + fn extract_site_hash_from_summary(summary: &str) -> Option { let lower = summary.to_ascii_lowercase(); let site_index = lower.find("site ")?; @@ -134,6 +138,7 @@ fn map_status(snapshot: BakeryStatusSnapshot) -> BakeryStatusData { site_hash: entry.site_hash, site_name: entry .site_name + .or_else(|| resolve_runtime_site_name(entry.site_hash)) .or_else(|| resolve_site_name(entry.site_hash)), action: runtime_action_to_string(entry.action), status: runtime_status_to_string(entry.status), @@ -180,6 +185,7 @@ fn map_activity(entry: BakeryActivitySnapshot) -> BakeryActivityEntry { .or_else(|| extract_site_hash_from_summary(&entry.summary)); let site_name = entry .site_name + .or_else(|| site_hash.and_then(resolve_runtime_site_name)) .or_else(|| site_hash.and_then(resolve_site_name)); BakeryActivityEntry { ts: entry.ts, diff --git a/src/rust/lqosd/src/treeguard/actor.rs b/src/rust/lqosd/src/treeguard/actor.rs index 5c78e2f7..9f4d99fd 100644 --- a/src/rust/lqosd/src/treeguard/actor.rs +++ b/src/rust/lqosd/src/treeguard/actor.rs @@ -1850,12 +1850,12 @@ fn run_tick( cpu_max_pct, dry_run: tg.dry_run, circuit_id, - circuit_entity_id: &entry.circuit_entity_id, - circuit_label: &entry.circuit_label, - devices: &entry.devices, - sqm_batch_id: &sqm_batch_id, - allowlisted: tg.circuits.all_circuits - || allowlisted_circuits.contains(circuit_id), + circuit_entity_id: &entry.circuit_entity_id, + circuit_label: &entry.circuit_label, + devices: &entry.devices, + sqm_batch_id: &sqm_batch_id, + allowlisted: tg.circuits.all_circuits + || allowlisted_circuits.contains(circuit_id), cap_down: entry.cap_down, cap_up: entry.cap_up, bps: live_rollup @@ -3164,14 +3164,14 @@ where activity, now_unix, dry_run, - persist_sqm_overrides: circuits_cfg.persist_sqm_overrides, - circuit_id, - circuit_entity_id, - circuit_label, - devices, - base_sqm, - batch_id: sqm_batch_id, - }, + persist_sqm_overrides: circuits_cfg.persist_sqm_overrides, + circuit_id, + circuit_entity_id, + circuit_label, + devices, + base_sqm, + batch_id: sqm_batch_id, + }, state, transition, persist_override, diff --git a/src/rust/uisp/src/device.rs b/src/rust/uisp/src/device.rs index 1c5b5e0f..6b2524e0 100644 --- a/src/rust/uisp/src/device.rs +++ b/src/rust/uisp/src/device.rs @@ -185,6 +185,7 @@ pub struct DeviceInterface { #[allow(non_snake_case)] #[derive(Serialize, Deserialize, Debug, Allocative)] pub struct InterfaceIdentification { + pub r#type: Option, pub name: Option, pub mac: Option, } @@ -200,6 +201,7 @@ pub struct DeviceAddress { pub struct InterfaceStatus { pub status: Option, pub speed: Option, + pub currentSpeed: Option, } #[allow(non_snake_case)] diff --git a/src/rust/uisp_integration/src/ethernet_advisory.rs b/src/rust/uisp_integration/src/ethernet_advisory.rs new file mode 100644 index 00000000..7b077e2b --- /dev/null +++ b/src/rust/uisp_integration/src/ethernet_advisory.rs @@ -0,0 +1,405 @@ +use crate::errors::UispIntegrationError; +use crate::uisp_types::UispDevice; +use lqos_config::{CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadata, Config}; +use std::path::Path; +use tracing::error; + +const ETH_PORT_HEADROOM_FACTOR_SUB_GIG: f32 = 0.95; + +/// Requested and applied circuit rates after negotiated-Ethernet evaluation. +#[derive(Clone, Debug, PartialEq)] +pub struct EthernetRateDecision { + /// Minimum download after any cap was applied. + pub download_min: f32, + /// Minimum upload after any cap was applied. + pub upload_min: f32, + /// Maximum download after any cap was applied. + pub download_max: f32, + /// Maximum upload after any cap was applied. + pub upload_max: f32, + /// Optional UI advisory for the affected circuit. + pub advisory: Option, +} + +/// Applies a negotiated-Ethernet cap to a circuit and returns the adjusted rates plus advisory. +pub fn apply_ethernet_rate_cap<'a>( + circuit_id: &str, + circuit_name: &str, + devices: impl IntoIterator, + download_min: f32, + upload_min: f32, + download_max: f32, + upload_max: f32, +) -> EthernetRateDecision { + let devices: Vec<&UispDevice> = devices.into_iter().collect(); + let related_device_ids = devices.iter().map(|device| device.id.clone()).collect(); + let candidate_devices: Vec<&UispDevice> = { + let station_devices: Vec<&UispDevice> = devices + .iter() + .copied() + .filter(|device| device.is_wireless_station_cpe()) + .collect(); + if !station_devices.is_empty() { + station_devices + } else { + devices + .iter() + .copied() + .filter(|device| !device.is_router_like()) + .collect() + } + }; + let mut limiting_device: Option<&UispDevice> = None; + for device in &candidate_devices { + let Some(speed_mbps) = device.negotiated_ethernet_mbps else { + continue; + }; + if limiting_device + .as_ref() + .is_none_or(|current| speed_mbps < current.negotiated_ethernet_mbps.unwrap_or(u64::MAX)) + { + limiting_device = Some(device); + } + } + + let Some(limiting_device) = limiting_device else { + return EthernetRateDecision { + download_min, + upload_min, + download_max, + upload_max, + advisory: None, + }; + }; + + let usable_cap = ethernet_usable_cap_mbps( + limiting_device + .negotiated_ethernet_mbps + .expect("limiting device must have ethernet speed"), + ); + let applied_download_max = download_max.min(usable_cap); + let applied_upload_max = upload_max.min(usable_cap); + let applied_download_min = download_min.min(applied_download_max); + let applied_upload_min = upload_min.min(applied_upload_max); + let auto_capped = applied_download_max < download_max || applied_upload_max < upload_max; + + EthernetRateDecision { + download_min: applied_download_min, + upload_min: applied_upload_min, + download_max: applied_download_max, + upload_max: applied_upload_max, + advisory: Some(CircuitEthernetMetadata { + circuit_id: circuit_id.to_string(), + circuit_name: circuit_name.to_string(), + device_ids: related_device_ids, + source: "uisp".to_string(), + negotiated_ethernet_mbps: limiting_device.negotiated_ethernet_mbps.unwrap_or_default(), + requested_download_mbps: download_max, + requested_upload_mbps: upload_max, + applied_download_mbps: applied_download_max, + applied_upload_mbps: applied_upload_max, + auto_capped, + limiting_device_id: Some(limiting_device.id.clone()), + limiting_device_name: Some(limiting_device.name.clone()), + limiting_interface_name: limiting_device.negotiated_ethernet_interface.clone(), + }), + } +} + +/// Persists circuit Ethernet advisories alongside other generated runtime files. +pub fn write_ethernet_advisories( + config: &Config, + advisories: &[CircuitEthernetMetadata], +) -> Result<(), UispIntegrationError> { + let path = Path::new(&config.lqos_directory).join(CIRCUIT_ETHERNET_METADATA_FILENAME); + let payload = serde_json::to_vec_pretty(&lqos_config::CircuitEthernetMetadataFile { + circuits: advisories + .iter() + .filter(|advisory| advisory.auto_capped) + .cloned() + .collect(), + }) + .map_err(|e| { + error!("Unable to serialize circuit Ethernet metadata: {e:?}"); + UispIntegrationError::CsvError + })?; + std::fs::write(path, payload).map_err(|e| { + error!("Unable to write circuit Ethernet metadata: {e:?}"); + UispIntegrationError::CsvError + })?; + Ok(()) +} + +fn ethernet_usable_cap_mbps(negotiated_mbps: u64) -> f32 { + match negotiated_mbps { + 0..=999 => negotiated_mbps as f32 * ETH_PORT_HEADROOM_FACTOR_SUB_GIG, + 1000..=2499 => negotiated_mbps.saturating_sub(5) as f32, + 2500..=9999 => negotiated_mbps.saturating_sub(10) as f32, + _ => negotiated_mbps.saturating_sub(50) as f32, + } +} + +#[cfg(test)] +mod tests { + use super::{apply_ethernet_rate_cap, write_ethernet_advisories}; + use crate::uisp_types::UispDevice; + use lqos_config::{CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadataFile, Config}; + use std::collections::HashSet; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn sample_device( + id: &str, + name: &str, + speed: Option, + iface: Option<&str>, + role: Option<&str>, + wireless_mode: Option<&str>, + ) -> UispDevice { + UispDevice { + id: id.to_string(), + name: name.to_string(), + mac: String::new(), + role: role.map(str::to_string), + wireless_mode: wireless_mode.map(str::to_string), + site_id: "site-1".to_string(), + download: 1000, + upload: 1000, + ipv4: HashSet::new(), + ipv6: HashSet::new(), + negotiated_ethernet_mbps: speed, + negotiated_ethernet_interface: iface.map(str::to_string), + } + } + + #[test] + fn ethernet_cap_uses_lowest_detected_port_speed() { + let fast = sample_device( + "dev-fast", + "Fast", + Some(1000), + Some("eth0"), + Some("station"), + Some("sta-ptmp"), + ); + let slow = sample_device( + "dev-slow", + "Slow", + Some(100), + Some("eth1"), + Some("station"), + Some("sta-ptmp"), + ); + + let decision = apply_ethernet_rate_cap( + "circuit-1", + "Circuit 1", + [&fast, &slow], + 25.0, + 25.0, + 300.0, + 300.0, + ); + + assert_eq!(decision.download_max, 95.0); + assert_eq!(decision.upload_max, 95.0); + assert_eq!(decision.download_min, 25.0); + assert_eq!(decision.upload_min, 25.0); + let advisory = decision.advisory.expect("advisory should exist"); + assert!(advisory.auto_capped); + assert_eq!(advisory.negotiated_ethernet_mbps, 100); + assert_eq!(advisory.limiting_device_id.as_deref(), Some("dev-slow")); + } + + #[test] + fn ethernet_cap_preserves_lower_upload_plan() { + let slow = sample_device( + "dev-slow", + "Slow", + Some(100), + Some("eth1"), + Some("station"), + Some("sta-ptmp"), + ); + + let decision = + apply_ethernet_rate_cap("circuit-1", "Circuit 1", [&slow], 10.0, 10.0, 300.0, 50.0); + + assert_eq!(decision.download_max, 95.0); + assert_eq!(decision.upload_max, 50.0); + let advisory = decision.advisory.expect("advisory should exist"); + assert!(advisory.auto_capped); + assert_eq!(advisory.applied_download_mbps, 95.0); + assert_eq!(advisory.applied_upload_mbps, 50.0); + } + + #[test] + fn gigabit_ports_use_small_fixed_margin() { + let gig = sample_device( + "dev-gig", + "Gig", + Some(1000), + Some("eth0"), + Some("station"), + Some("sta-ptmp"), + ); + + let decision = + apply_ethernet_rate_cap("circuit-1", "Circuit 1", [&gig], 100.0, 100.0, 995.0, 995.0); + + assert_eq!(decision.download_max, 995.0); + assert_eq!(decision.upload_max, 995.0); + let advisory = decision.advisory.expect("advisory should exist"); + assert!(!advisory.auto_capped); + assert_eq!(advisory.applied_download_mbps, 995.0); + assert_eq!(advisory.applied_upload_mbps, 995.0); + } + + #[test] + fn gigabit_ports_cap_above_line_rate_to_995() { + let gig = sample_device( + "dev-gig", + "Gig", + Some(1000), + Some("eth0"), + Some("station"), + Some("sta-ptmp"), + ); + + let decision = apply_ethernet_rate_cap( + "circuit-1", + "Circuit 1", + [&gig], + 100.0, + 100.0, + 5000.0, + 5000.0, + ); + + assert_eq!(decision.download_max, 995.0); + assert_eq!(decision.upload_max, 995.0); + let advisory = decision.advisory.expect("advisory should exist"); + assert!(advisory.auto_capped); + assert_eq!(advisory.applied_download_mbps, 995.0); + assert_eq!(advisory.applied_upload_mbps, 995.0); + } + + #[test] + fn station_devices_win_over_router_devices() { + let station = sample_device( + "dev-station", + "Station", + Some(1000), + Some("data"), + Some("station"), + Some("sta-ptmp"), + ); + let router = sample_device( + "dev-router", + "Router", + Some(10), + Some("eth3"), + Some("homeWiFi"), + Some("ap"), + ); + + let decision = apply_ethernet_rate_cap( + "circuit-1", + "Circuit 1", + [&station, &router], + 10.0, + 10.0, + 115.0, + 22.0, + ); + + assert_eq!(decision.download_max, 115.0); + assert_eq!(decision.upload_max, 22.0); + let advisory = decision.advisory.expect("advisory should exist"); + assert!(!advisory.auto_capped); + assert_eq!(advisory.negotiated_ethernet_mbps, 1000); + assert_eq!(advisory.limiting_device_id.as_deref(), Some("dev-station")); + } + + #[test] + fn router_only_devices_do_not_drive_ethernet_cap() { + let router = sample_device( + "dev-router", + "Router", + Some(100), + Some("eth0"), + Some("router"), + None, + ); + + let decision = + apply_ethernet_rate_cap("circuit-1", "Circuit 1", [&router], 10.0, 10.0, 115.0, 22.0); + + assert_eq!(decision.download_max, 115.0); + assert_eq!(decision.upload_max, 22.0); + assert!(decision.advisory.is_none()); + } + + #[test] + fn writer_persists_only_auto_capped_advisories() { + let auto_capped = sample_device( + "dev-100m", + "Hundred", + Some(100), + Some("eth0"), + Some("station"), + Some("sta-ptmp"), + ); + let observed_only = sample_device( + "dev-1g", + "Gig", + Some(1000), + Some("eth0"), + Some("station"), + Some("sta-ptmp"), + ); + + let capped = apply_ethernet_rate_cap( + "circuit-capped", + "Capped Circuit", + [&auto_capped], + 10.0, + 10.0, + 115.0, + 22.0, + ) + .advisory + .expect("auto-capped advisory should exist"); + let observed = apply_ethernet_rate_cap( + "circuit-observed", + "Observed Circuit", + [&observed_only], + 100.0, + 100.0, + 995.0, + 995.0, + ) + .advisory + .expect("observed advisory should exist"); + + let mut config = Config::default(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should move forward") + .as_nanos(); + let temp_dir = std::env::temp_dir().join(format!("lqos-ethernet-writer-{unique}")); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + config.lqos_directory = temp_dir.to_string_lossy().to_string(); + + write_ethernet_advisories(&config, &[capped.clone(), observed]) + .expect("writer should succeed"); + + let payload = fs::read(temp_dir.join(CIRCUIT_ETHERNET_METADATA_FILENAME)) + .expect("metadata file should exist"); + let parsed: CircuitEthernetMetadataFile = + serde_json::from_slice(&payload).expect("metadata file should parse"); + assert_eq!(parsed.circuits, vec![capped]); + + let _ = fs::remove_dir_all(temp_dir); + } +} diff --git a/src/rust/uisp_integration/src/main.rs b/src/rust/uisp_integration/src/main.rs index dd486f8a..03f02f3c 100644 --- a/src/rust/uisp_integration/src/main.rs +++ b/src/rust/uisp_integration/src/main.rs @@ -5,6 +5,7 @@ mod blackboard; #[warn(missing_docs)] mod errors; +mod ethernet_advisory; pub mod ip_ranges; mod strategies; pub mod uisp_types; diff --git a/src/rust/uisp_integration/src/strategies/ap_only.rs b/src/rust/uisp_integration/src/strategies/ap_only.rs index 1ef045ac..704b72b4 100644 --- a/src/rust/uisp_integration/src/strategies/ap_only.rs +++ b/src/rust/uisp_integration/src/strategies/ap_only.rs @@ -1,8 +1,9 @@ use crate::errors::UispIntegrationError; +use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories}; use crate::ip_ranges::IpRanges; use crate::strategies::common::UispData; use crate::strategies::full::shaped_devices_writer::ShapedDevice; -use lqos_config::Config; +use lqos_config::{CircuitEthernetMetadata, Config}; use std::collections::HashSet; use std::fs::write; use std::path::Path; @@ -68,6 +69,7 @@ pub async fn build_ap_only_network( // Write ShapedDevices.csv let file_path = Path::new(&config.lqos_directory).join("ShapedDevices.csv"); let mut shaped_devices = Vec::new(); + let mut ethernet_advisories: Vec = Vec::new(); let mut seen_pairs = HashSet::new(); for (parent, client_ids) in mappings.iter() { for client_id in client_ids { @@ -77,50 +79,53 @@ pub async fn build_ap_only_network( .iter() .filter(|d| d.site_id == *client_id) .collect::>(); + let requested = if let Some((dl_min, dl_max, ul_min, ul_max)) = + site.burst_rates(&config) + { + ( + f32::max(0.1, dl_min), + f32::max(0.1, dl_max), + f32::max(0.1, ul_min), + f32::max(0.1, ul_max), + ) + } else if site.suspended && config.uisp_integration.suspended_strategy == "slow" { + (0.1, 0.1, 0.1, 0.1) + } else { + ( + f32::max( + 0.1, + site.max_down_mbps as f32 + * config.uisp_integration.commit_bandwidth_multiplier, + ), + f32::max( + 0.1, + site.max_down_mbps as f32 + * config.uisp_integration.bandwidth_overhead_factor, + ), + f32::max( + 0.1, + site.max_up_mbps as f32 + * config.uisp_integration.commit_bandwidth_multiplier, + ), + f32::max( + 0.1, + site.max_up_mbps as f32 * config.uisp_integration.bandwidth_overhead_factor, + ), + ) + }; + let ethernet_decision = apply_ethernet_rate_cap( + &site.id, + &site.name, + devices.iter().copied(), + requested.0, + requested.2, + requested.1, + requested.3, + ); + if let Some(advisory) = ethernet_decision.advisory.clone() { + ethernet_advisories.push(advisory); + } for device in devices.iter().filter(|d| d.has_address()) { - // Compute subscriber rates: prefer UISP QoS + burst - let (download_min, mut download_max, upload_min, mut upload_max) = - if let Some((dl_min, dl_max, ul_min, ul_max)) = site.burst_rates(&config) { - ( - f32::max(0.1, dl_min), - f32::max(0.1, dl_max), - f32::max(0.1, ul_min), - f32::max(0.1, ul_max), - ) - } else if site.suspended && config.uisp_integration.suspended_strategy == "slow" - { - (0.1, 0.1, 0.1, 0.1) - } else { - ( - f32::max( - 0.1, - site.max_down_mbps as f32 - * config.uisp_integration.commit_bandwidth_multiplier, - ), - f32::max( - 0.1, - site.max_down_mbps as f32 - * config.uisp_integration.bandwidth_overhead_factor, - ), - f32::max( - 0.1, - site.max_up_mbps as f32 - * config.uisp_integration.commit_bandwidth_multiplier, - ), - f32::max( - 0.1, - site.max_up_mbps as f32 - * config.uisp_integration.bandwidth_overhead_factor, - ), - ) - }; - if download_max < download_min { - download_max = download_min; - } - if upload_max < upload_min { - upload_max = upload_min; - } - let key = (site.id.clone(), device.id.clone()); if !seen_pairs.insert(key) { continue; @@ -135,10 +140,10 @@ pub async fn build_ap_only_network( mac: device.mac.clone(), ipv4: device.ipv4_list(), ipv6: device.ipv6_list(), - download_min, - upload_min, - download_max, - upload_max, + download_min: ethernet_decision.download_min, + upload_min: ethernet_decision.upload_min, + download_max: ethernet_decision.download_max, + upload_max: ethernet_decision.upload_max, comment: "".to_string(), }; shaped_devices.push(sd); @@ -158,6 +163,7 @@ pub async fn build_ap_only_network( error!("{e:?}"); UispIntegrationError::CsvError })?; + write_ethernet_advisories(&config, ðernet_advisories)?; info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len()); Ok(()) diff --git a/src/rust/uisp_integration/src/strategies/ap_site.rs b/src/rust/uisp_integration/src/strategies/ap_site.rs index 19d59bc1..c9e575f2 100644 --- a/src/rust/uisp_integration/src/strategies/ap_site.rs +++ b/src/rust/uisp_integration/src/strategies/ap_site.rs @@ -1,9 +1,10 @@ use crate::blackboard_blob; use crate::errors::UispIntegrationError; +use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories}; use crate::ip_ranges::IpRanges; use crate::strategies::common::UispData; use crate::strategies::full::shaped_devices_writer::ShapedDevice; -use lqos_config::Config; +use lqos_config::{CircuitEthernetMetadata, Config}; use std::collections::{HashMap, HashSet}; use std::fs::write; use std::path::Path; @@ -52,7 +53,14 @@ pub async fn build_ap_site_network( //println!("{:#?}", root); let mut shaped_devices = Vec::new(); - let net_json = root.walk_children(None, &uisp_data, &mut shaped_devices, &config); + let mut ethernet_advisories = Vec::new(); + let net_json = root.walk_children( + None, + &uisp_data, + &mut shaped_devices, + &mut ethernet_advisories, + &config, + ); let network_path = Path::new(&config.lqos_directory).join("network.json"); if network_path.exists() && !config.integration_common.always_overwrite_network_json { @@ -69,7 +77,7 @@ pub async fn build_ap_site_network( info!("Written network.json"); } - let _ = write_shaped_devices(&config, &mut shaped_devices); + let _ = write_shaped_devices(&config, &mut shaped_devices, ðernet_advisories); info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len()); Ok(()) @@ -78,6 +86,7 @@ pub async fn build_ap_site_network( pub(crate) fn write_shaped_devices( config: &Arc, shaped_devices: &mut Vec, + ethernet_advisories: &[CircuitEthernetMetadata], ) -> Result<(), UispIntegrationError> { let file_path = Path::new(&config.lqos_directory).join("ShapedDevices.csv"); let mut seen_pairs = HashSet::new(); @@ -95,6 +104,7 @@ pub(crate) fn write_shaped_devices( error!("{e:?}"); UispIntegrationError::CsvError })?; + write_ethernet_advisories(config, ethernet_advisories)?; info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len()); Ok(()) } @@ -166,6 +176,7 @@ impl Layer { parent: Option<&str>, uisp_data: &UispData, shaped_devices: &mut Vec, + ethernet_advisories: &mut Vec, config: &Config, ) -> serde_json::Map { let mut children = serde_json::Map::new(); @@ -181,13 +192,24 @@ impl Layer { children.insert( name.clone(), child - .walk_children(Some(&parent_name), uisp_data, shaped_devices, config) + .walk_children( + Some(&parent_name), + uisp_data, + shaped_devices, + ethernet_advisories, + config, + ) .into(), ); } GraphMapping::ClientById(_client_id) => { - let _ = - child.walk_children(Some(&parent_name), uisp_data, shaped_devices, config); + let _ = child.walk_children( + Some(&parent_name), + uisp_data, + shaped_devices, + ethernet_advisories, + config, + ); } _ => {} } @@ -244,67 +266,61 @@ impl Layer { .iter() .filter(|d| d.site_id == *client_id) .collect::>(); + let requested = if let Some(site) = + uisp_data.sites.iter().find(|s| s.id == *client_id) + { + if let Some((dl_min, dl_max, ul_min, ul_max)) = site.burst_rates(config) + { + ( + f32::max(0.1, dl_min), + f32::max(0.1, dl_max), + f32::max(0.1, ul_min), + f32::max(0.1, ul_max), + ) + } else if site.suspended + && config.uisp_integration.suspended_strategy == "slow" + { + (0.1, 0.1, 0.1, 0.1) + } else { + ( + f32::max( + 0.1, + site.max_down_mbps as f32 + * config.uisp_integration.commit_bandwidth_multiplier, + ), + f32::max( + 0.1, + site.max_down_mbps as f32 + * config.uisp_integration.bandwidth_overhead_factor, + ), + f32::max( + 0.1, + site.max_up_mbps as f32 + * config.uisp_integration.commit_bandwidth_multiplier, + ), + f32::max( + 0.1, + site.max_up_mbps as f32 + * config.uisp_integration.bandwidth_overhead_factor, + ), + ) + } + } else { + (0.1, 0.1, 0.1, 0.1) + }; + let ethernet_decision = apply_ethernet_rate_cap( + &site.id, + &site.name, + devices.iter().copied(), + requested.0, + requested.2, + requested.1, + requested.3, + ); + if let Some(advisory) = ethernet_decision.advisory.clone() { + ethernet_advisories.push(advisory); + } for device in devices.iter().filter(|d| d.has_address()) { - // Compute subscriber rates: prefer UISP QoS + burst - let (download_min, mut download_max, upload_min, mut upload_max) = - if let Some(site) = - uisp_data.sites.iter().find(|s| s.id == *client_id) - { - if let Some((dl_min, dl_max, ul_min, ul_max)) = - site.burst_rates(config) - { - ( - f32::max(0.1, dl_min), - f32::max(0.1, dl_max), - f32::max(0.1, ul_min), - f32::max(0.1, ul_max), - ) - } else if site.suspended - && config.uisp_integration.suspended_strategy == "slow" - { - (0.1, 0.1, 0.1, 0.1) - } else { - ( - f32::max( - 0.1, - site.max_down_mbps as f32 - * config - .uisp_integration - .commit_bandwidth_multiplier, - ), - f32::max( - 0.1, - site.max_down_mbps as f32 - * config - .uisp_integration - .bandwidth_overhead_factor, - ), - f32::max( - 0.1, - site.max_up_mbps as f32 - * config - .uisp_integration - .commit_bandwidth_multiplier, - ), - f32::max( - 0.1, - site.max_up_mbps as f32 - * config - .uisp_integration - .bandwidth_overhead_factor, - ), - ) - } - } else { - (0.1, 0.1, 0.1, 0.1) - }; - if download_max < download_min { - download_max = download_min; - } - if upload_max < upload_min { - upload_max = upload_min; - } - let sd = ShapedDevice { circuit_id: site.id.clone(), circuit_name: site.name.clone(), @@ -314,10 +330,10 @@ impl Layer { mac: device.mac.clone(), ipv4: device.ipv4_list(), ipv6: device.ipv6_list(), - download_min, - upload_min, - download_max, - upload_max, + download_min: ethernet_decision.download_min, + upload_min: ethernet_decision.upload_min, + download_max: ethernet_decision.download_max, + upload_max: ethernet_decision.upload_max, comment: "".to_string(), }; shaped_devices.push(sd); diff --git a/src/rust/uisp_integration/src/strategies/common.rs b/src/rust/uisp_integration/src/strategies/common.rs index 7fd37ad1..c3dcfc98 100644 --- a/src/rust/uisp_integration/src/strategies/common.rs +++ b/src/rust/uisp_integration/src/strategies/common.rs @@ -19,7 +19,54 @@ pub(crate) struct UispData { //pub data_links: Vec, } -/// Ensure site names are unique by appending the site ID to duplicates. +fn short_address_segment(site: &Site) -> Option { + let address = site.address()?; + let first_segment = address.split(',').next()?.trim(); + if first_segment.is_empty() { + None + } else { + Some(first_segment.to_string()) + } +} + +fn service_name(site: &Site) -> Option { + let name = site.ucrm.as_ref()?.service.as_ref()?.name.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +fn short_site_id(site: &Site) -> String { + site.id.chars().take(8).collect() +} + +fn disambiguation_candidates(site: &Site, base_name: &str) -> Vec { + let mut candidates = Vec::new(); + + if let Some(address_segment) = short_address_segment(site) + && address_segment != base_name + { + candidates.push(format!("{base_name} ({address_segment})")); + } + + if let Some(service_name) = service_name(site) + && service_name != base_name + { + candidates.push(format!("{base_name} ({service_name})")); + } + + let short_id = short_site_id(site); + if !short_id.is_empty() { + candidates.push(format!("{base_name} ({short_id})")); + } + candidates.push(format!("{base_name} ({})", site.id)); + + candidates +} + +/// Ensure site names are unique by appending a human-friendly disambiguator to duplicates. pub(crate) fn dedup_site_names(sites: &mut [Site]) { let mut name_counts: HashMap = HashMap::new(); for site in sites.iter() { @@ -30,6 +77,18 @@ pub(crate) fn dedup_site_names(sites: &mut [Site]) { *name_counts.entry(base_name).or_insert(0) += 1; } + let mut used_names: HashSet = sites + .iter() + .filter_map(|site| { + let base_name = site.name_or_blank(); + if base_name.is_empty() || name_counts.get(&base_name).copied().unwrap_or(0) > 1 { + None + } else { + Some(base_name) + } + }) + .collect(); + for site in sites.iter_mut() { let base_name = site.name_or_blank(); if base_name.is_empty() { @@ -37,7 +96,11 @@ pub(crate) fn dedup_site_names(sites: &mut [Site]) { } if name_counts.get(&base_name).copied().unwrap_or(0) > 1 { - let candidate = format!("{base_name} {}", site.id); + let candidate = disambiguation_candidates(site, &base_name) + .into_iter() + .find(|candidate| !used_names.contains(candidate)) + .unwrap_or_else(|| format!("{base_name} ({})", site.id)); + used_names.insert(candidate.clone()); if let Some(ident) = site.identification.as_mut() { ident.name = Some(candidate.clone()); } @@ -45,7 +108,7 @@ pub(crate) fn dedup_site_names(sites: &mut [Site]) { site_id = %site.id, original = %base_name, renamed = %candidate, - "Duplicate UISP site name detected; renaming with site ID for LibreQoS uniqueness" + "Duplicate UISP site name detected; renaming with human-friendly disambiguator for LibreQoS uniqueness" ); } } @@ -225,3 +288,94 @@ impl UispData { .collect() } } + +#[cfg(test)] +mod tests { + use super::dedup_site_names; + use serde_json::json; + use uisp::Site; + + fn mk_site(id: &str, name: &str, address: Option<&str>, service_name: Option<&str>) -> Site { + let mut value = json!({ + "id": id, + "identification": { + "id": id, + "name": name, + "type": "endpoint", + "suspended": false + } + }); + + if let Some(address) = address { + value["description"] = json!({ "address": address }); + } + if let Some(service_name) = service_name { + value["ucrm"] = json!({ + "service": { + "id": format!("svc-{id}"), + "name": service_name, + "status": 1, + "tariffId": "1", + "trafficShapingOverrideEnabled": false + } + }); + } + + serde_json::from_value(value).expect("site JSON must deserialize") + } + + #[test] + fn duplicate_site_names_prefer_address_segment() { + let mut sites = vec![ + mk_site( + "213f4b53-bddf-41dd-af65-2dbaa5bf0927", + "Pathway Communications", + Some("7100 Binational Way, Santa Teresa, 88008, New Mexico, United States"), + None, + ), + mk_site( + "96bb13cd-10cc-43a1-ae56-a389161178d7", + "Pathway Communications", + Some("175 Lindburgh Dr, El Paso, 79932, Texas, United States"), + None, + ), + ]; + + dedup_site_names(&mut sites); + + assert_eq!( + sites[0].name_or_blank(), + "Pathway Communications (7100 Binational Way)" + ); + assert_eq!( + sites[1].name_or_blank(), + "Pathway Communications (175 Lindburgh Dr)" + ); + } + + #[test] + fn duplicate_site_names_fall_back_to_service_name() { + let mut sites = vec![ + mk_site("site-1", "Acme", None, Some("1000/1000 Mbps - Primary")), + mk_site("site-2", "Acme", None, Some("1000/1000 Mbps - Backup")), + ]; + + dedup_site_names(&mut sites); + + assert_eq!(sites[0].name_or_blank(), "Acme (1000/1000 Mbps - Primary)"); + assert_eq!(sites[1].name_or_blank(), "Acme (1000/1000 Mbps - Backup)"); + } + + #[test] + fn duplicate_site_names_fall_back_to_short_id() { + let mut sites = vec![ + mk_site("abcd1234-0000-0000-0000-000000000000", "Acme", None, None), + mk_site("efgh5678-0000-0000-0000-000000000000", "Acme", None, None), + ]; + + dedup_site_names(&mut sites); + + assert_eq!(sites[0].name_or_blank(), "Acme (abcd1234)"); + assert_eq!(sites[1].name_or_blank(), "Acme (efgh5678)"); + } +} diff --git a/src/rust/uisp_integration/src/strategies/flat.rs b/src/rust/uisp_integration/src/strategies/flat.rs index 047d64d6..d8c95aa3 100644 --- a/src/rust/uisp_integration/src/strategies/flat.rs +++ b/src/rust/uisp_integration/src/strategies/flat.rs @@ -1,9 +1,10 @@ use crate::blackboard_blob; use crate::errors::UispIntegrationError; +use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories}; use crate::ip_ranges::IpRanges; use crate::strategies::common::dedup_site_names; use crate::uisp_types::UispDevice; -use lqos_config::Config; +use lqos_config::{CircuitEthernetMetadata, Config}; use serde::Serialize; use std::collections::HashSet; use std::fs; @@ -82,6 +83,7 @@ pub async fn build_flat_network( // Simple Shaped Devices File let mut shaped_devices = Vec::new(); + let mut ethernet_advisories: Vec = Vec::new(); let mut seen_pairs = HashSet::new(); let ipv4_to_v6 = Vec::new(); for site in sites.iter() { @@ -96,7 +98,7 @@ pub async fn build_flat_network( .map(|id| id.suspended) .unwrap_or(false) && config.uisp_integration.suspended_strategy == "slow"; - let (download_min, upload_min, mut download_max, mut upload_max) = if suspended_slow { + let requested = if suspended_slow { (0.1, 0.1, 0.1, 0.1) } else if let Some(qos) = &site.qos { let base_down = qos @@ -172,16 +174,25 @@ pub async fn build_flat_network( f32::max(0.1, ul_max_f), ) }; - // Ensure max >= min - if download_max < download_min { - download_max = download_min; - } - if upload_max < upload_min { - upload_max = upload_min; + let site_devices: Vec = devices + .iter() + .map(|device| UispDevice::from_uisp(device, &config, &ip_ranges, &ipv4_to_v6)) + .filter(|device| device.site_id == site.id && device.has_address()) + .collect(); + let ethernet_decision = apply_ethernet_rate_cap( + &site.id, + &site.name_or_blank(), + site_devices.iter(), + requested.0, + requested.1, + requested.2, + requested.3, + ); + if let Some(advisory) = ethernet_decision.advisory.clone() { + ethernet_advisories.push(advisory); } for device in devices.iter() { - let dev = UispDevice::from_uisp(device, &config, &ip_ranges, &ipv4_to_v6); - if dev.site_id == site.id { + if let Some(dev) = site_devices.iter().find(|dev| dev.id == device.get_id()) { // We're an endpoint in the right sight. We're getting there let key = (site.id.clone(), device.get_id()); if !seen_pairs.insert(key) { @@ -197,10 +208,10 @@ pub async fn build_flat_network( mac: device.identification.mac.clone().unwrap_or("".to_string()), ipv4: dev.ipv4_list(), ipv6: dev.ipv6_list(), - download_min, - download_max, - upload_min, - upload_max, + download_min: ethernet_decision.download_min, + download_max: ethernet_decision.download_max, + upload_min: ethernet_decision.upload_min, + upload_max: ethernet_decision.upload_max, comment: "".to_string(), }; shaped_devices.push(sd); @@ -224,6 +235,7 @@ pub async fn build_flat_network( error!("{e:?}"); UispIntegrationError::CsvError })?; + write_ethernet_advisories(&config, ðernet_advisories)?; info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len()); Ok(()) diff --git a/src/rust/uisp_integration/src/strategies/full/shaped_devices_writer.rs b/src/rust/uisp_integration/src/strategies/full/shaped_devices_writer.rs index 751e1dfd..10946375 100644 --- a/src/rust/uisp_integration/src/strategies/full/shaped_devices_writer.rs +++ b/src/rust/uisp_integration/src/strategies/full/shaped_devices_writer.rs @@ -1,5 +1,7 @@ use crate::errors::UispIntegrationError; +use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories}; use crate::uisp_types::{UispDevice, UispSite, UispSiteType}; +use lqos_config::CircuitEthernetMetadata; use lqos_config::Config; use serde::Serialize; use std::path::Path; @@ -24,6 +26,11 @@ pub struct ShapedDevice { pub comment: String, } +struct ShapedDeviceOutputs<'a> { + shaped_devices: &'a mut Vec, + ethernet_advisories: &'a mut Vec, +} + /// Writes the ShapedDevices.csv file for UISP /// /// # Arguments @@ -39,17 +46,14 @@ pub fn write_shaped_devices( ) -> Result<(), UispIntegrationError> { let file_path = Path::new(&config.lqos_directory).join("ShapedDevices.csv"); let mut shaped_devices = Vec::new(); + let mut ethernet_advisories: Vec = Vec::new(); // Traverse - traverse( - sites, - root_idx, - 0, - devices, - &mut shaped_devices, - config, - root_idx, - ); + let mut outputs = ShapedDeviceOutputs { + shaped_devices: &mut shaped_devices, + ethernet_advisories: &mut ethernet_advisories, + }; + traverse(sites, root_idx, 0, devices, &mut outputs, config, root_idx); // Write the CSV let mut writer = csv::WriterBuilder::new() @@ -65,6 +69,7 @@ pub fn write_shaped_devices( error!("{e:?}"); UispIntegrationError::CsvError })?; + write_ethernet_advisories(config, ðernet_advisories)?; info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len()); Ok(()) @@ -75,52 +80,60 @@ fn traverse( idx: usize, depth: u32, devices: &[UispDevice], - shaped_devices: &mut Vec, + outputs: &mut ShapedDeviceOutputs<'_>, config: &Config, root_idx: usize, ) { if !sites[idx].device_indices.is_empty() { // We have devices! if sites[idx].site_type == UispSiteType::Client { + let site_devices: Vec<&UispDevice> = sites[idx] + .device_indices + .iter() + .filter_map(|device_idx| devices.get(*device_idx)) + .collect(); + + let requested = + if let Some((dl_min, dl_max, ul_min, ul_max)) = sites[idx].burst_rates(config) { + ( + f32::max(0.1, dl_min), + f32::max(0.1, dl_max), + f32::max(0.1, ul_min), + f32::max(0.1, ul_max), + ) + } else { + let download_max_f32 = sites[idx].max_down_mbps as f32 + * config.uisp_integration.bandwidth_overhead_factor; + let upload_max_f32 = sites[idx].max_up_mbps as f32 + * config.uisp_integration.bandwidth_overhead_factor; + let download_min_f32 = + download_max_f32 * config.uisp_integration.commit_bandwidth_multiplier; + let upload_min_f32 = + upload_max_f32 * config.uisp_integration.commit_bandwidth_multiplier; + ( + f32::max(0.1, download_min_f32), + f32::max(0.1, download_max_f32), + f32::max(0.1, upload_min_f32), + f32::max(0.1, upload_max_f32), + ) + }; + let ethernet_decision = apply_ethernet_rate_cap( + &sites[idx].id, + &sites[idx].name, + site_devices.iter().copied(), + requested.0, + requested.2, + requested.1, + requested.3, + ); + if let Some(advisory) = ethernet_decision.advisory.clone() { + outputs.ethernet_advisories.push(advisory); + } + // Add as normal clients for device in sites[idx].device_indices.iter() { let device = &devices[*device]; if device.has_address() { - // Prefer UISP QoS + burst if available; else fallback to capacity-based - let (download_min, mut download_max, upload_min, mut upload_max) = - if let Some((dl_min, dl_max, ul_min, ul_max)) = - sites[idx].burst_rates(config) - { - ( - f32::max(0.1, dl_min), - f32::max(0.1, dl_max), - f32::max(0.1, ul_min), - f32::max(0.1, ul_max), - ) - } else { - let download_max_f32 = sites[idx].max_down_mbps as f32 - * config.uisp_integration.bandwidth_overhead_factor; - let upload_max_f32 = sites[idx].max_up_mbps as f32 - * config.uisp_integration.bandwidth_overhead_factor; - let download_min_f32 = download_max_f32 - * config.uisp_integration.commit_bandwidth_multiplier; - let upload_min_f32 = upload_max_f32 - * config.uisp_integration.commit_bandwidth_multiplier; - ( - f32::max(0.1, download_min_f32), - f32::max(0.1, download_max_f32), - f32::max(0.1, upload_min_f32), - f32::max(0.1, upload_max_f32), - ) - }; - // Ensure max >= min per LibreQoS requirements - if download_max < download_min { - download_max = download_min; - } - if upload_max < upload_min { - upload_max = upload_min; - } - let sd = ShapedDevice { circuit_id: sites[idx].id.clone(), circuit_name: sites[idx].name.clone(), @@ -130,13 +143,13 @@ fn traverse( mac: device.mac.clone(), ipv4: device.ipv4_list(), ipv6: device.ipv6_list(), - download_min, - download_max, - upload_min, - upload_max, + download_min: ethernet_decision.download_min, + download_max: ethernet_decision.download_max, + upload_min: ethernet_decision.upload_min, + upload_max: ethernet_decision.upload_max, comment: "".to_string(), }; - shaped_devices.push(sd); + outputs.shaped_devices.push(sd); } } } else { @@ -178,7 +191,7 @@ fn traverse( upload_max, comment: "Infrastructure Entry".to_string(), }; - shaped_devices.push(sd); + outputs.shaped_devices.push(sd); } } } @@ -194,7 +207,7 @@ fn traverse( child_idx, depth + 1, devices, - shaped_devices, + outputs, config, root_idx, ); diff --git a/src/rust/uisp_integration/src/strategies/full2.rs b/src/rust/uisp_integration/src/strategies/full2.rs index 1e9be81e..ce4781f0 100644 --- a/src/rust/uisp_integration/src/strategies/full2.rs +++ b/src/rust/uisp_integration/src/strategies/full2.rs @@ -6,6 +6,7 @@ mod net_json_parent; use crate::blackboard_blob; use crate::errors::UispIntegrationError; +use crate::ethernet_advisory::{apply_ethernet_rate_cap, write_ethernet_advisories}; use crate::ip_ranges::IpRanges; use crate::strategies::common::UispData; use crate::strategies::full::routes_override::RouteOverride; @@ -18,7 +19,7 @@ use crate::strategies::full2::graph_mapping::GraphMapping; use crate::strategies::full2::link_mapping::LinkMapping; use crate::strategies::full2::net_json_parent::{NetJsonParent, walk_parents}; use crate::uisp_types::UispDevice; -use lqos_config::Config; +use lqos_config::{CircuitEthernetMetadata, Config}; use petgraph::Directed; use petgraph::graph::NodeIndex; use petgraph::visit::{EdgeRef, NodeRef}; @@ -375,6 +376,7 @@ pub async fn build_full_network_v2( let mut seen_pairs = HashSet::new(); let mut processed_site_pairs = 0usize; let mut shaped_device_count = 0usize; + let mut ethernet_advisories: Vec = Vec::new(); for (ap_id, client_sites) in client_mappings.iter() { for site_id in client_sites.iter() { @@ -389,47 +391,55 @@ pub async fn build_full_network_v2( "Processing site: {} (ID: {}) with AP: {} (ID: {})", site.name, site.id, ap_device.name, ap_device.id ); + let site_devices: Vec<&UispDevice> = uisp_data + .devices + .iter() + .filter(|d| d.site_id == *site_id && d.has_address()) + .collect(); + + let requested = + if let Some((dl_min, dl_max, ul_min, ul_max)) = site.burst_rates(&config) { + ( + f32::max(0.1, dl_min), + f32::max(0.1, dl_max), + f32::max(0.1, ul_min), + f32::max(0.1, ul_max), + ) + } else if site.suspended && config.uisp_integration.suspended_strategy == "slow" { + (0.1, 0.1, 0.1, 0.1) + } else { + let download_f32 = (site.max_down_mbps as f32) + * config.uisp_integration.bandwidth_overhead_factor; + let upload_f32 = (site.max_up_mbps as f32) + * config.uisp_integration.bandwidth_overhead_factor; + let download_min_f32 = + download_f32 * config.uisp_integration.commit_bandwidth_multiplier; + let upload_min_f32 = + upload_f32 * config.uisp_integration.commit_bandwidth_multiplier; + ( + f32::max(0.1, download_min_f32), + f32::max(0.1, download_f32), + f32::max(0.1, upload_min_f32), + f32::max(0.1, upload_f32), + ) + }; + let ethernet_decision = apply_ethernet_rate_cap( + &site.id, + &site.name, + site_devices.iter().copied(), + requested.0, + requested.2, + requested.1, + requested.3, + ); + if let Some(advisory) = ethernet_decision.advisory.clone() { + ethernet_advisories.push(advisory); + } for device in uisp_data.devices.iter().filter(|d| d.site_id == *site_id) { if !device.has_address() { continue; } - // Compute subscriber rates: prefer UISP QoS + burst; fallback to capacity-based - let (download_min, mut download_max, upload_min, mut upload_max) = - if let Some((dl_min, dl_max, ul_min, ul_max)) = site.burst_rates(&config) { - ( - f32::max(0.1, dl_min), - f32::max(0.1, dl_max), - f32::max(0.1, ul_min), - f32::max(0.1, ul_max), - ) - } else if site.suspended && config.uisp_integration.suspended_strategy == "slow" - { - (0.1, 0.1, 0.1, 0.1) - } else { - let download_f32 = (site.max_down_mbps as f32) - * config.uisp_integration.bandwidth_overhead_factor; - let upload_f32 = (site.max_up_mbps as f32) - * config.uisp_integration.bandwidth_overhead_factor; - let download_min_f32 = - download_f32 * config.uisp_integration.commit_bandwidth_multiplier; - let upload_min_f32 = - upload_f32 * config.uisp_integration.commit_bandwidth_multiplier; - ( - f32::max(0.1, download_min_f32), - f32::max(0.1, download_f32), - f32::max(0.1, upload_min_f32), - f32::max(0.1, upload_f32), - ) - }; - // Ensure max >= min - if download_max < download_min { - download_max = download_min; - } - if upload_max < upload_min { - upload_max = upload_min; - } - let parent_node = { if parents.contains_key(&ap_device.name) { ap_device.name.clone() @@ -456,10 +466,10 @@ pub async fn build_full_network_v2( mac: device.mac.to_owned(), ipv4: device.ipv4_list(), ipv6: device.ipv6_list(), - download_min, - upload_min, - download_max, - upload_max, + download_min: ethernet_decision.download_min, + upload_min: ethernet_decision.upload_min, + download_max: ethernet_decision.download_max, + upload_max: ethernet_decision.upload_max, comment: "".to_string(), }; debug!( @@ -489,6 +499,7 @@ pub async fn build_full_network_v2( error!("{e:?}"); UispIntegrationError::CsvError })?; + write_ethernet_advisories(&config, ðernet_advisories)?; info!("Wrote {} lines to ShapedDevices.csv", shaped_devices.len()); Ok(()) diff --git a/src/rust/uisp_integration/src/uisp_types/uisp_device.rs b/src/rust/uisp_integration/src/uisp_types/uisp_device.rs index 75aa745e..ed1e04d2 100644 --- a/src/rust/uisp_integration/src/uisp_types/uisp_device.rs +++ b/src/rust/uisp_integration/src/uisp_types/uisp_device.rs @@ -15,14 +15,137 @@ pub struct UispDevice { pub id: String, pub name: String, pub mac: String, + pub role: Option, + pub wireless_mode: Option, pub site_id: String, pub download: u64, pub upload: u64, pub ipv4: HashSet, pub ipv6: HashSet, + pub negotiated_ethernet_mbps: Option, + pub negotiated_ethernet_interface: Option, } impl UispDevice { + fn parse_speed_mbps(raw: &str) -> Option { + let lower = raw.trim().to_lowercase(); + if lower.is_empty() { + return None; + } + + let digits: String = lower + .chars() + .take_while(|ch| ch.is_ascii_digit() || *ch == '.') + .collect(); + if digits.is_empty() { + return None; + } + + let value = digits.parse::().ok()?; + if !(value.is_finite() && value > 0.0) { + return None; + } + + let multiplier = if lower.contains("gbps") || lower.ends_with('g') { + 1000.0 + } else { + 1.0 + }; + + Some((value * multiplier).round() as u64) + } + + fn negotiated_ethernet_from_device(device: &Device) -> (Option, Option) { + let mut best: Option<(u64, Option)> = None; + let Some(interfaces) = &device.interfaces else { + return (None, None); + }; + + for interface in interfaces { + let interface_type = interface + .identification + .as_ref() + .and_then(|id| id.r#type.clone()) + .unwrap_or_default() + .to_lowercase(); + if matches!(interface_type.as_str(), "wlan" | "wifi" | "wireless") { + continue; + } + let Some(status) = &interface.status else { + continue; + }; + let speed_raw = if let Some(current_speed) = status.currentSpeed.as_deref() { + Some(current_speed) + } else if Self::allows_plain_speed(status.status.as_deref()) { + status.speed.as_deref() + } else { + None + }; + let Some(speed_raw) = speed_raw else { + continue; + }; + let Some(speed_mbps) = Self::parse_speed_mbps(speed_raw) else { + continue; + }; + let iface_name = interface + .identification + .as_ref() + .and_then(|id| id.name.clone()); + + if best + .as_ref() + .is_none_or(|(current_speed, _)| speed_mbps < *current_speed) + { + best = Some((speed_mbps, iface_name)); + } + } + + best.map_or((None, None), |(speed, iface)| (Some(speed), iface)) + } + + fn allows_plain_speed(status: Option<&str>) -> bool { + let Some(status) = status else { + return false; + }; + matches!( + status.trim().to_ascii_lowercase().as_str(), + "connected" | "up" | "active" + ) + } + + pub(crate) fn is_wireless_station_cpe(&self) -> bool { + let role = self + .role + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase); + if matches!(role.as_deref(), Some("station" | "sta" | "cpe")) { + return true; + } + + let wireless_mode = self + .wireless_mode + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase); + matches!( + wireless_mode.as_deref(), + Some(mode) if mode.starts_with("sta") || mode.starts_with("station") + ) + } + + pub(crate) fn is_router_like(&self) -> bool { + let role = self + .role + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase); + matches!( + role.as_deref(), + Some("router" | "homewifi" | "home-wifi" | "home wifi") + ) + } + /// Check if an IP/CIDR represents a network address (all host bits are zero) /// Returns true if it's a network address, false if it's a host address #[cfg_attr(test, allow(dead_code))] @@ -209,16 +332,25 @@ impl UispDevice { site_id = exception.parent.clone(); } } + let (negotiated_ethernet_mbps, negotiated_ethernet_interface) = + Self::negotiated_ethernet_from_device(device); Self { id: device.get_id(), name: device.get_name().unwrap_or_default(), mac, + role: device.identification.role.clone(), + wireless_mode: device + .overview + .as_ref() + .and_then(|o| o.wirelessMode.clone()), site_id, upload, download, ipv4, ipv6, + negotiated_ethernet_mbps, + negotiated_ethernet_interface, } } @@ -269,6 +401,8 @@ impl UispDevice { #[cfg(test)] mod tests { use super::UispDevice; + use serde_json::json; + use uisp::Device; #[test] fn test_is_network_address() { @@ -302,4 +436,74 @@ mod tests { assert!(!UispDevice::is_network_address("192.168.1.0/invalid")); // Invalid prefix assert!(!UispDevice::is_network_address("192.168.1.0/33")); // Invalid IPv4 prefix length } + + #[test] + fn test_parse_speed_mbps() { + assert_eq!(UispDevice::parse_speed_mbps("100Mbps-Full"), Some(100)); + assert_eq!(UispDevice::parse_speed_mbps("1000"), Some(1000)); + assert_eq!(UispDevice::parse_speed_mbps("1Gbps"), Some(1000)); + assert_eq!(UispDevice::parse_speed_mbps("2.5Gbps"), Some(2500)); + assert_eq!(UispDevice::parse_speed_mbps(""), None); + assert_eq!(UispDevice::parse_speed_mbps("unknown"), None); + } + + fn mk_device_with_interfaces(interfaces: serde_json::Value) -> Device { + serde_json::from_value(json!({ + "identification": { + "id": "dev-1", + "hostname": "dev-1", + "role": null, + }, + "overview": { + "wirelessMode": null + }, + "interfaces": interfaces, + })) + .expect("device JSON must deserialize") + } + + #[test] + fn disconnected_plain_speed_is_ignored_for_negotiated_ethernet() { + let device = mk_device_with_interfaces(json!([ + { + "identification": { "name": "eth0@1", "type": "eth" }, + "status": { "status": "disconnected", "speed": "10-half", "currentSpeed": null }, + "wireless": {} + } + ])); + + let (speed, iface) = UispDevice::negotiated_ethernet_from_device(&device); + assert_eq!(speed, None); + assert_eq!(iface, None); + } + + #[test] + fn disconnected_currentspeed_is_still_accepted() { + let device = mk_device_with_interfaces(json!([ + { + "identification": { "name": "data", "type": "eth" }, + "status": { "status": "disconnected", "speed": "auto", "currentSpeed": "100-full" }, + "wireless": {} + } + ])); + + let (speed, iface) = UispDevice::negotiated_ethernet_from_device(&device); + assert_eq!(speed, Some(100)); + assert_eq!(iface.as_deref(), Some("data")); + } + + #[test] + fn connected_plain_speed_is_accepted() { + let device = mk_device_with_interfaces(json!([ + { + "identification": { "name": "eth0", "type": "eth" }, + "status": { "status": "connected", "speed": "1000-full", "currentSpeed": null }, + "wireless": {} + } + ])); + + let (speed, iface) = UispDevice::negotiated_ethernet_from_device(&device); + assert_eq!(speed, Some(1000)); + assert_eq!(iface.as_deref(), Some("eth0")); + } } From e161addb5b1a2f8e380a48bc07eebdfd052bcaee Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Sun, 29 Mar 2026 16:58:01 -0500 Subject: [PATCH 076/102] Feat: add Insight signup handoff --- .../src/node_manager/js_build/entrypoints.txt | 1 + .../node_manager/js_build/src/lts_trial.js | 76 ++-- .../js_build/src/lts_trial_setup.js | 97 +++++ .../js_build/src/lts_trial_shared.js | 58 +++ .../lqosd/src/node_manager/local_api/lts.rs | 352 +++++++++++++++++- .../src/node_manager/static2/lts_trial.html | 5 + .../node_manager/static2/lts_trial_setup.html | 37 ++ .../lqosd/src/node_manager/static_pages.rs | 1 + src/rust/lqosd/src/node_manager/ws.rs | 28 ++ .../lqosd/src/node_manager/ws/messages.rs | 4 + 10 files changed, 593 insertions(+), 66 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/lts_trial_setup.js create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/lts_trial_shared.js create mode 100644 src/rust/lqosd/src/node_manager/static2/lts_trial_setup.html diff --git a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt index b8d41373..36fd33a5 100644 --- a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt +++ b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt @@ -13,6 +13,7 @@ flow_map.js all_tree_sankey.js asn_analysis.js lts_trial.js +lts_trial_setup.js config_general.js config_rtt.js config_tuning.js diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lts_trial.js b/src/rust/lqosd/src/node_manager/js_build/src/lts_trial.js index 86f93648..38ec2e6b 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/lts_trial.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/lts_trial.js @@ -1,37 +1,5 @@ import {PLACEHOLDER_TEASERS} from "./lts_teasers_shared"; -import {get_ws_client} from "./pubsub/ws"; - -const wsClient = get_ws_client(); -const listenOnce = (eventName, handler) => { - const wrapped = (msg) => { - wsClient.off(eventName, wrapped); - handler(msg); - }; - 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); - }); -} +import {buildSignupApiUrl, sendWsRequest, wsClient} from "./lts_trial_shared"; // Paddle-compatible countries (excluding embargoed nations) const ALLOWED_COUNTRIES = [ @@ -288,6 +256,7 @@ $(document).ready(async function() { loadTeasers(); // Then load teasers with the correct URL fetchCircuitCount(); attachEventHandlers(); + openInitialViewFromQuery(); // Apply dark mode if needed if (isDarkMode()) { @@ -434,7 +403,7 @@ function displayTeasers() { // Add click handlers to CTA buttons $('.btn-teaser-cta').on('click', function() { - showSection('signupSection'); + redirectToTrialSetup(); }); // Reinitialize carousel if needed @@ -448,19 +417,7 @@ function displayTeasers() { // Helper function to construct full LTS URLs function getLtsUrl(endpoint) { - // Ensure ltsBaseUrl starts with http:// or https://, otherwise prefix with https:// - let baseUrl = ltsBaseUrl; - if (!/^https?:\/\//i.test(baseUrl)) { - baseUrl = 'https://' + baseUrl; - } - // Ensure baseUrl ends with a single slash - let base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; - // Ensure 'signup-api/' is appended exactly once - base += base.endsWith('signup-api/') ? '' : 'signup-api/'; - // Remove any leading slash from endpoint - endpoint = endpoint.replace(/^\/+/, ''); - //console.log("Base: ", base, "Endpoint:", endpoint); - return base + endpoint; + return buildSignupApiUrl(ltsBaseUrl, endpoint); } // Fetch node ID and LTS URL from configuration @@ -517,6 +474,29 @@ function showSection(sectionId) { $(`#${sectionId}`).fadeIn(); } +function redirectToTrialSetup() { + window.location.href = 'lts_trial_setup.html'; +} + +function openInitialViewFromQuery() { + const params = new URLSearchParams(window.location.search); + const view = params.get('view'); + + if (view === 'signup') { + showSection('signupSection'); + return; + } + + if (view === 'license') { + showSection('licenseKeySection'); + return; + } + + if (view === 'recover') { + showSection('licenseRecoverySection'); + } +} + // Show loading modal function showLoading(message = 'Processing...') { $('#loadingMessage').text(message); @@ -579,7 +559,7 @@ function attachEventHandlers() { // Analytics tracking for new customer path const trackingImg = new Image(); trackingImg.src = getLtsUrl('signupPing') + '?t=' + Date.now() + '&type=new'; - showSection('signupSection'); + redirectToTrialSetup(); }); $('#btnBackFromLicense').on('click', () => showSection('teaserSection')); $('#btnBackFromSignup').on('click', () => showSection('teaserSection')); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lts_trial_setup.js b/src/rust/lqosd/src/node_manager/js_build/src/lts_trial_setup.js new file mode 100644 index 00000000..f6adb028 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/lts_trial_setup.js @@ -0,0 +1,97 @@ +import {buildInsightUrl, sendWsRequest} from "./lts_trial_shared"; + +const setupStatusClasses = [ + 'alert-primary', + 'alert-success', + 'alert-warning', + 'alert-danger', + 'alert-secondary', +]; +const defaultInsightBaseUrl = 'https://insight.libreqos.com/'; +let insightBaseUrl = defaultInsightBaseUrl; + +function setSetupStatus(message, tone = 'primary', spinning = true) { + const status = $('#signupSetupStatus'); + const statusText = $('#signupSetupStatusText'); + const spinner = status.find('.spinner-border'); + + if (!status.length || !statusText.length) { + return; + } + + status.removeClass(setupStatusClasses.join(' ')).addClass(`alert-${tone}`); + statusText.text(message); + + if (spinning) { + spinner.show(); + } else { + spinner.hide(); + } +} + +function setSetupHelp(message) { + $('#signupSetupHelp').text(message); +} + +function getErrorMessage(error, fallback) { + if (error && typeof error.message === 'string' && error.message.trim().length > 0) { + return error.message.trim(); + } + return fallback; +} + +async function fetchTrialConfig() { + try { + const response = await sendWsRequest("LtsTrialConfigResult", { LtsTrialConfig: {} }); + const data = response && response.data ? response.data : {}; + if (typeof data.lts_url === 'string' && data.lts_url.trim().length > 0) { + insightBaseUrl = data.lts_url; + } + } catch (error) { + console.warn('Unable to load LTS trial config, using default Insight URL.', error); + insightBaseUrl = defaultInsightBaseUrl; + } +} + +function redirectToInsightSignup(claimId) { + window.location.href = buildInsightUrl( + insightBaseUrl, + `su/signup1.html?${encodeURIComponent(claimId)}`, + ); +} + +async function startSignupSession() { + setSetupStatus('Creating your Insight signup session…'); + setSetupHelp('LibreQoS is contacting Insight and preparing your claim.'); + await fetchTrialConfig(); + + try { + const response = await sendWsRequest("LtsStartSignupResult", { LtsStartSignup: {} }); + const claimId = response && typeof response.claim_id === 'string' + ? response.claim_id.trim() + : ''; + + if (!claimId) { + throw new Error('Missing claim ID from signup response.'); + } + + setSetupStatus('Signup session ready. Redirecting to Insight…', 'success', false); + setSetupHelp('Your signup session is ready. Continuing to Insight now.'); + redirectToInsightSignup(claimId); + } catch (error) { + console.error('Failed to start Insight signup session.', error); + setSetupStatus( + getErrorMessage(error, 'Unable to start the Insight signup session right now.'), + 'danger', + false, + ); + setSetupHelp( + 'Retry this page, use the direct signup form, or validate an existing license key below.', + ); + } +} + +$(document).ready(() => { + setSetupStatus('Preparing the Insight signup handoff…'); + void startSignupSession(); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/lts_trial_shared.js b/src/rust/lqosd/src/node_manager/js_build/src/lts_trial_shared.js new file mode 100644 index 00000000..c2386e31 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/lts_trial_shared.js @@ -0,0 +1,58 @@ +import {get_ws_client} from "./pubsub/ws"; + +const DEFAULT_INSIGHT_BASE_URL = 'https://insight.libreqos.com/'; +const wsClient = get_ws_client(); + +function normalizeInsightBaseUrl(rawBaseUrl) { + let baseUrl = (rawBaseUrl || DEFAULT_INSIGHT_BASE_URL).trim(); + + if (!baseUrl) { + baseUrl = DEFAULT_INSIGHT_BASE_URL; + } + + if (!/^https?:\/\//i.test(baseUrl)) { + baseUrl = `https://${baseUrl}`; + } + + baseUrl = baseUrl.replace(/\/+$/, ''); + if (baseUrl.endsWith('/signup-api')) { + baseUrl = baseUrl.slice(0, -'/signup-api'.length); + } + + return `${baseUrl}/`; +} + +function buildInsightUrl(rawBaseUrl, endpoint) { + const path = String(endpoint || '').replace(/^\/+/, ''); + return `${normalizeInsightBaseUrl(rawBaseUrl)}${path}`; +} + +function buildSignupApiUrl(rawBaseUrl, endpoint) { + const path = String(endpoint || '').replace(/^\/+/, ''); + return buildInsightUrl(rawBaseUrl, `signup-api/${path}`); +} + +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); + }); +} + +export {buildInsightUrl, buildSignupApiUrl, normalizeInsightBaseUrl, sendWsRequest, wsClient}; diff --git a/src/rust/lqosd/src/node_manager/local_api/lts.rs b/src/rust/lqosd/src/node_manager/local_api/lts.rs index 9d12686f..89c690bf 100644 --- a/src/rust/lqosd/src/node_manager/local_api/lts.rs +++ b/src/rust/lqosd/src/node_manager/local_api/lts.rs @@ -1,8 +1,10 @@ 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::{BusRequest, bus_request}; @@ -10,9 +12,14 @@ use lqos_config::load_config; use serde::{Deserialize, Serialize}; pub use shaper_status::ShaperStatus; pub use shaper_status::shaper_status_data; -use std::ops::Deref; use std::process::Command; -use tracing::{info, warn}; +use std::time::Duration; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +const SIGNUP_POLL_INTERVAL: Duration = Duration::from_secs(10); +const INSIGHT_FREE_TRIAL_STATUS_CODE: i32 = 2; +const INSIGHT_TRIAL_DAYS_HINT: i32 = 30; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct LtsTrialConfig { @@ -20,6 +27,37 @@ pub struct LtsTrialConfig { pub lts_url: Option, } +#[derive(Debug, Serialize)] +struct SignupStartRequest { + node_id: String, + node_name: String, + active_circuits: usize, +} + +#[derive(Debug, Deserialize)] +struct SignupStartResponse { + claim_id: Uuid, +} + +#[derive(Debug, Serialize)] +struct SignupCheckRequest { + claim_id: String, +} + +#[derive(Debug, Deserialize)] +struct SignupCheckResponse { + status: String, + #[serde(default)] + account_key: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SignupCheckState { + Pending, + Provisioned { account_key: Uuid }, + Expired, +} + pub fn lts_trial_config_data(login: LoginResult) -> Result { if login != LoginResult::Admin { return Err(StatusCode::FORBIDDEN); @@ -31,6 +69,221 @@ pub fn lts_trial_config_data(login: LoginResult) -> Result) -> String { + let mut base_url = lts_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("https://insight.libreqos.com/") + .to_string(); + + if !base_url.starts_with("http://") && !base_url.starts_with("https://") { + base_url = format!("https://{base_url}"); + } + + while base_url.ends_with('/') { + base_url.pop(); + } + + if let Some(stripped) = base_url.strip_suffix("/signup-api") { + base_url = stripped.to_string(); + } + + format!("{base_url}/") +} + +fn build_insight_url(lts_url: Option<&str>, path: &str) -> String { + let path = path.trim_start_matches('/'); + format!("{}{}", normalize_insight_base_url(lts_url), path) +} + +fn insight_http_client() -> Result { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .danger_accept_invalid_certs(true) + .build() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn classify_signup_check_response(response: SignupCheckResponse) -> Result { + match response.status.as_str() { + "pending" => Ok(SignupCheckState::Pending), + "expired" => Ok(SignupCheckState::Expired), + "provisioned" => response + .account_key + .map(|account_key| SignupCheckState::Provisioned { account_key }) + .ok_or(()), + _ => Err(()), + } +} + +async fn apply_insight_license(license_key: String, restart_lqos_api: bool) -> Result<(), StatusCode> { + let license_key = license_key.trim().to_string(); + if license_key.is_empty() { + return Err(StatusCode::BAD_GATEWAY); + } + + let mut cfg = load_config() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .as_ref() + .clone(); + cfg.long_term_stats.gather_stats = true; + cfg.long_term_stats.license_key = Some(license_key.clone()); + bus_request(vec![BusRequest::UpdateLqosdConfig(Box::new(cfg))]) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + set_license_status(LicenseStatus { + license_type: INSIGHT_FREE_TRIAL_STATUS_CODE, + trial_expires: INSIGHT_TRIAL_DAYS_HINT, + }); + + info!("LQOSD configuration updated with new Insight license key."); + + if restart_lqos_api { + let _ = Command::new("/bin/systemctl") + .args(["restart", "lqos_api"]) + .output(); + } + + Ok(()) +} + +pub async fn lts_trial_start_signup_data() -> Result { + 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 { + node_id: config.node_id.clone(), + node_name: config.node_name.clone(), + active_circuits: circuit_count::circuit_count_data().count, + }; + debug!( + node_id = %request.node_id, + active_circuits = request.active_circuits, + url = %start_url, + "starting Insight signup session" + ); + + let client = insight_http_client()?; + + let response = client + .post(&start_url) + .json(&request) + .send() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + if !response.status().is_success() { + warn!( + status = %response.status(), + url = %start_url, + "Insight signup start request failed" + ); + return Err(StatusCode::BAD_GATEWAY); + } + + let response = response + .json::() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + Ok(response.claim_id.to_string()) +} + +pub fn spawn_signup_poll_loop(claim_id: String) { + tokio::spawn(async move { + if let Err(status) = poll_signup_until_complete(claim_id.clone()).await { + warn!( + claim_id = %claim_id, + status = %status, + "Insight signup poll loop stopped with an error" + ); + } + }); +} + +async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode> { + let config = load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let check_url = build_insight_url(config.long_term_stats.lts_url.as_deref(), "/su/check"); + let client = insight_http_client()?; + let request = SignupCheckRequest { + claim_id: claim_id.clone(), + }; + + loop { + tokio::time::sleep(SIGNUP_POLL_INTERVAL).await; + + let response = match client.post(&check_url).json(&request).send().await { + Ok(response) => response, + Err(error) => { + warn!( + claim_id = %claim_id, + error = ?error, + "Insight signup status poll failed; retrying" + ); + continue; + } + }; + + if !response.status().is_success() { + warn!( + claim_id = %claim_id, + status = %response.status(), + "Insight signup status poll returned non-success; retrying" + ); + continue; + } + + let response = match response.json::().await { + Ok(response) => response, + Err(error) => { + warn!( + claim_id = %claim_id, + error = ?error, + "Insight signup status poll returned invalid JSON; retrying" + ); + continue; + } + }; + + match classify_signup_check_response(response) { + Ok(SignupCheckState::Pending) => {} + Ok(SignupCheckState::Expired) => { + debug!(claim_id = %claim_id, "Insight signup claim expired"); + return Ok(()); + } + Ok(SignupCheckState::Provisioned { account_key }) => { + match apply_insight_license(account_key.to_string(), false).await { + Ok(()) => { + info!( + claim_id = %claim_id, + account_key = %account_key, + "Applied Insight license from signup flow" + ); + return Ok(()); + } + Err(status) => { + warn!( + claim_id = %claim_id, + account_key = %account_key, + status = %status, + "Failed to apply Insight license; stopping signup poll loop" + ); + return Err(status); + } + } + } + Err(()) => { + warn!( + claim_id = %claim_id, + "Insight signup status poll returned an unexpected payload" + ); + return Err(StatusCode::BAD_GATEWAY); + } + } + } +} + pub(crate) async fn insight_gate() -> Result<(), StatusCode> { let (status, _) = crate::lts2_sys::get_lts_license_status_async().await; match status { @@ -56,20 +309,83 @@ pub async fn lts_trial_signup_data(license_key: String) -> Result<(), StatusCode return Err(StatusCode::INTERNAL_SERVER_ERROR); } info!("Free trial request succeeded, license key: {}", license_key); - let mut cfg = load_config() - .expect("Unable to load LibreQoS config") - .deref() - .clone(); - cfg.long_term_stats.gather_stats = true; - cfg.long_term_stats.license_key = Some(license_key); - bus_request(vec![BusRequest::UpdateLqosdConfig(Box::new(cfg))]) - .await - .expect("Unable to update lqosd config"); - info!("LQOSD configuration updated with new license key."); - // Best-effort: ensure the bundled lqos_api also reloads to pick up the new license - // Ignore errors if systemctl isn't present or permission is denied. - let _ = Command::new("/bin/systemctl") - .args(["restart", "lqos_api"]) - .output(); - Ok(()) + apply_insight_license(license_key, true).await +} + +#[cfg(test)] +mod tests { + use super::{ + SignupCheckResponse, SignupCheckState, build_insight_url, classify_signup_check_response, + normalize_insight_base_url, + }; + use uuid::Uuid; + + #[test] + fn normalize_insight_base_url_defaults_to_hosted_insight() { + assert_eq!( + normalize_insight_base_url(None), + "https://insight.libreqos.com/" + ); + } + + #[test] + fn normalize_insight_base_url_adds_scheme_and_trailing_slash() { + assert_eq!( + normalize_insight_base_url(Some("insight.example.com")), + "https://insight.example.com/" + ); + } + + #[test] + fn normalize_insight_base_url_strips_signup_api_suffix() { + assert_eq!( + normalize_insight_base_url(Some("https://insight.example.com/signup-api/")), + "https://insight.example.com/" + ); + } + + #[test] + fn build_insight_url_joins_paths_cleanly() { + assert_eq!( + build_insight_url(Some("insight.example.com"), "/su/start"), + "https://insight.example.com/su/start" + ); + } + + #[test] + fn classify_signup_check_response_handles_pending() { + let response = SignupCheckResponse { + status: "pending".to_string(), + account_key: None, + }; + + assert_eq!( + classify_signup_check_response(response), + Ok(SignupCheckState::Pending) + ); + } + + #[test] + fn classify_signup_check_response_requires_account_key_for_provisioned() { + let response = SignupCheckResponse { + status: "provisioned".to_string(), + account_key: None, + }; + + assert!(classify_signup_check_response(response).is_err()); + } + + #[test] + fn classify_signup_check_response_returns_provisioned_account_key() { + let account_key = Uuid::new_v4(); + let response = SignupCheckResponse { + status: "provisioned".to_string(), + account_key: Some(account_key), + }; + + assert_eq!( + classify_signup_check_response(response), + Ok(SignupCheckState::Provisioned { account_key }) + ); + } } diff --git a/src/rust/lqosd/src/node_manager/static2/lts_trial.html b/src/rust/lqosd/src/node_manager/static2/lts_trial.html index 4bcbc422..427eca7e 100644 --- a/src/rust/lqosd/src/node_manager/static2/lts_trial.html +++ b/src/rust/lqosd/src/node_manager/static2/lts_trial.html @@ -164,6 +164,11 @@ body.dark-mode .card-header.bg-light { +
diff --git a/src/rust/lqosd/src/node_manager/static2/lts_trial_setup.html b/src/rust/lqosd/src/node_manager/static2/lts_trial_setup.html new file mode 100644 index 00000000..4808c4c6 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/lts_trial_setup.html @@ -0,0 +1,37 @@ +
+
+
+
+

+ + Preparing Your Insight Sign-Up Session +

+
+
+

+ LibreQoS will create a short-lived Insight claim and then hand your browser to the hosted signup flow. +

+
+ + Preparing the Insight signup handoff… +
+

+ Existing license validation and the direct signup form remain available if you need them. +

+ +
+
+
+
+ + diff --git a/src/rust/lqosd/src/node_manager/static_pages.rs b/src/rust/lqosd/src/node_manager/static_pages.rs index ed103145..b4d63ecf 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -44,6 +44,7 @@ pub(super) fn static_routes() -> Result { "api_docs.html", "chatbot.html", "lts_trial.html", + "lts_trial_setup.html", "lts_trial_success.html", "lts_trial_fail.html", "executive_worst_sites.html", diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 08c5b8d9..2648fc0c 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -988,6 +988,34 @@ async fn receive_channel_message( return true; } } + WsRequest::LtsStartSignup => { + let result = lts::lts_trial_start_signup_data().await; + match result { + Ok(claim_id) => { + lts::spawn_signup_poll_loop(claim_id.clone()); + let response = WsResponse::LtsStartSignupResult { claim_id }; + 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 start the Insight signup session.".to_string(), + }; + if send_ws_response(&tx, response).await { + return true; + } + } + } + } WsRequest::LtsSignUp { license_key } => { let result = lts::lts_trial_signup_data(license_key).await; let (ok, message) = match result { diff --git a/src/rust/lqosd/src/node_manager/ws/messages.rs b/src/rust/lqosd/src/node_manager/ws/messages.rs index 6a53a993..613c6015 100644 --- a/src/rust/lqosd/src/node_manager/ws/messages.rs +++ b/src/rust/lqosd/src/node_manager/ws/messages.rs @@ -126,6 +126,7 @@ pub enum WsRequest { ReloadLibreQoS, LtsTrialConfig, CircuitCount, + LtsStartSignup, LtsSignUp { license_key: String, }, @@ -670,6 +671,9 @@ pub enum WsResponse { CircuitCountResult { data: CircuitCount, }, + LtsStartSignupResult { + claim_id: String, + }, LtsSignUpResult { ok: bool, message: String, From e64b2b264398c489be7f33e1fd59876455d56ee5 Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Sun, 29 Mar 2026 17:29:47 -0600 Subject: [PATCH 077/102] changes --- .../src/node_manager/js_build/src/dashlets/bakery_status.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js index ef358bdd..a3393ad4 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/bakery_status.js @@ -1,7 +1,7 @@ import {DashletBaseInsight} from "./insight_dashlet_base"; import {formatUnixSecondsToLocalDateTime, mkBadge} from "./bakery_shared"; -const CIRCUIT_ACTIVITY_MAX_ITEMS = 6; +const CIRCUIT_ACTIVITY_MAX_ITEMS = 1; function truncateSummary(summary, maxChars = 180) { const normalized = (summary ?? "").toString().trim(); @@ -82,7 +82,7 @@ export class BakeryStatusDashlet extends DashletBaseInsight { } tooltip() { - return "
Bakery Circuit Activity

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

"; + return "
Bakery Circuit Activity

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

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

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

+
+
+
+
+
+ + Loading devices… +
diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 4e5d00ef..f21b0562 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -421,6 +421,11 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { display: block; max-width: 100%; } +.flow-rtt-exclude-btn.btn.btn-sm { + padding-top: 0.175rem; + padding-bottom: 0.175rem; + line-height: 1.1; +} .lqos-circuit-gauge-panel { display: flex; align-items: center; @@ -715,6 +720,73 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { font-weight: 600; line-height: 1.35; } +.lqos-circuit-devices-panel { + margin-top: 1rem; + border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent); + border-radius: var(--lqos-radius-md); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--lqos-surface-2) 88%, transparent) 0%, + var(--lqos-surface-solid) 100% + ); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 12px 24px rgba(6, 12, 20, 0.12); + padding: 0.95rem; +} +.lqos-circuit-devices-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.9rem; +} +.lqos-circuit-devices-header-copy { + min-width: 0; +} +.lqos-circuit-devices-header-copy h5 { + margin-bottom: 0.25rem; +} +.lqos-circuit-devices-meta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.4rem; +} +.lqos-circuit-devices-meta-pill { + display: inline-flex; + align-items: center; + padding: 0.34rem 0.72rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--lqos-border) 84%, transparent); + background: color-mix(in srgb, var(--lqos-surface-2) 72%, transparent); + color: var(--bs-secondary-color); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} +.lqos-circuit-device-card { + border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent); + border-radius: var(--lqos-radius-sm); + background: color-mix(in srgb, var(--lqos-surface-2) 66%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 0.8rem; +} +.lqos-circuit-device-summary, +.lqos-circuit-device-graphs { + min-width: 0; +} +.lqos-circuit-device-summary h5 { + margin-bottom: 0.5rem; +} +.lqos-circuit-device-graph { + border: 1px solid color-mix(in srgb, var(--lqos-border) 76%, transparent); + border-radius: var(--lqos-radius-sm); + background: color-mix(in srgb, var(--lqos-surface-2) 76%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 0.35rem; +} .lqos-funnel-panel { margin-top: 1rem; border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent); @@ -889,9 +961,11 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { .lqos-circuit-activity-chart { height: 296px; } + .lqos-circuit-devices-header, .lqos-cake-header { flex-direction: column; } + .lqos-circuit-devices-meta, .lqos-cake-meta { justify-content: flex-start; } @@ -923,9 +997,11 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon { .lqos-circuit-activity-kpi-value { font-size: 1.05rem; } + .lqos-circuit-devices-panel, .lqos-cake-panel { padding: 0.8rem; } + .lqos-circuit-device-card, .lqos-funnel-panel, .lqos-funnel-node-card, .lqos-funnel-path-card { @@ -1194,6 +1270,37 @@ h5 { width: 7.5rem; max-width: 7.5rem; } +.lqos-circuit-traffic-table { + width: 100%; + table-layout: fixed; +} + +.lqos-circuit-traffic-table th, +.lqos-circuit-traffic-table td { + white-space: nowrap; +} + +.lqos-circuit-traffic-table .lqos-circuit-traffic-protocol-cell { + width: 10rem; + max-width: 10rem; +} + +.lqos-circuit-traffic-table .lqos-circuit-traffic-asn-cell { + width: 12rem; + max-width: 12rem; +} + +.lqos-circuit-traffic-table .lqos-circuit-traffic-country-cell { + width: 8rem; + max-width: 8rem; +} + +.lqos-circuit-traffic-table .lqos-circuit-traffic-protocol-cell .lqos-table-cell-ellipsis, +.lqos-circuit-traffic-table .lqos-circuit-traffic-asn-cell .lqos-table-cell-ellipsis, +.lqos-circuit-traffic-table .lqos-circuit-traffic-country-cell .lqos-table-cell-ellipsis { + max-width: 100%; +} + .lqos-tree-stack { display: flex; flex-direction: column; diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels/flows_by_circuit.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels/flows_by_circuit.rs index 7c4c3667..e1f0c27c 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels/flows_by_circuit.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels/flows_by_circuit.rs @@ -3,6 +3,7 @@ use crate::shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}; use crate::throughput_tracker::flow_data::{ ALL_FLOWS, FlowAnalysis, FlowbeeLocalData, get_asn_name_and_country, }; +use lqos_utils::units::DownUpOrder; use lqos_utils::hash_to_i64; use lqos_utils::unix_time::time_since_boot; use std::time::Duration; @@ -10,6 +11,65 @@ use tokio::time::MissedTickBehavior; use tracing::debug; const RECENT_CIRCUIT_FLOWS_WINDOW_NANOS: u64 = 30 * 1_000_000_000; +const FLOW_RATE_SANITY_MULTIPLIER: f64 = 2.0; +const FLOW_RATE_SANITY_FLOOR_BPS: u64 = 25_000_000; + +fn circuit_display_rate_ceiling_bps( + shaped: &lqos_config::ConfigShapedDevices, + circuit_hash: i64, +) -> Option> { + let mut max_down_mbps = 0.0_f32; + let mut max_up_mbps = 0.0_f32; + + for device in &shaped.devices { + if device.circuit_hash != circuit_hash { + continue; + } + max_down_mbps = max_down_mbps.max(device.download_max_mbps); + max_up_mbps = max_up_mbps.max(device.upload_max_mbps); + } + + if max_down_mbps <= 0.0 && max_up_mbps <= 0.0 { + return None; + } + + Some(DownUpOrder { + down: sanitized_plan_ceiling_bps(max_down_mbps), + up: sanitized_plan_ceiling_bps(max_up_mbps), + }) +} + +const fn clamp_u64_to_u32(value: u64) -> u32 { + if value > u32::MAX as u64 { + u32::MAX + } else { + value as u32 + } +} + +fn sanitized_plan_ceiling_bps(plan_mbps: f32) -> u32 { + if !plan_mbps.is_finite() || plan_mbps <= 0.0 { + return clamp_u64_to_u32(FLOW_RATE_SANITY_FLOOR_BPS); + } + + let scaled = (plan_mbps as f64 * 1_000_000.0 * FLOW_RATE_SANITY_MULTIPLIER).round(); + let scaled = scaled.max(FLOW_RATE_SANITY_FLOOR_BPS as f64) as u64; + clamp_u64_to_u32(scaled) +} + +fn display_rate_bps( + local: &FlowbeeLocalData, + ceiling_bps: Option>, +) -> DownUpOrder { + let Some(ceiling_bps) = ceiling_bps else { + return local.rate_estimate_bps; + }; + + DownUpOrder { + down: local.rate_estimate_bps.down.min(ceiling_bps.down), + up: local.rate_estimate_bps.up.min(ceiling_bps.up), + } +} fn recent_flows_by_circuit( circuit_id: &str, @@ -17,6 +77,7 @@ fn recent_flows_by_circuit( let circuit_hash = hash_to_i64(circuit_id); let shaped = SHAPED_DEVICES.load(); let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let display_rate_ceiling = circuit_display_rate_ceiling_bps(&shaped, circuit_hash); if let Ok(now) = time_since_boot() { let now_as_nanos = Duration::from(now).as_nanos() as u64; let recent_cutoff = now_as_nanos.saturating_sub(RECENT_CIRCUIT_FLOWS_WINDOW_NANOS); @@ -51,6 +112,9 @@ fn recent_flows_by_circuit( geo.country, ); + let mut local = local.clone(); + local.set_display_rate_bps(Some(display_rate_bps(&local, display_rate_ceiling))); + Some(( FlowbeeKeyTransit { remote_ip: remote_ip_str, @@ -64,7 +128,7 @@ fn recent_flows_by_circuit( protocol_name: analysis.protocol_analysis.to_string(), last_seen_nanos: now_as_nanos.saturating_sub(local.last_seen), }, - local.clone(), + local, *analysis, )) }) @@ -101,3 +165,51 @@ pub(super) async fn flows_by_circuit( ticker.tick().await; } } + +#[cfg(test)] +mod tests { + use super::{ + DownUpOrder, FlowbeeLocalData, display_rate_bps, sanitized_plan_ceiling_bps, + }; + + #[test] + fn sanitized_plan_ceiling_uses_multiplier_with_floor() { + assert_eq!(sanitized_plan_ceiling_bps(50.0), 100_000_000); + assert_eq!(sanitized_plan_ceiling_bps(1.0), 25_000_000); + } + + #[test] + fn display_rate_is_clamped_to_circuit_plan_ceiling() { + let mut local = FlowbeeLocalData { + start_time: 0, + last_seen: 0, + bytes_sent: DownUpOrder::default(), + packets_sent: DownUpOrder::default(), + rate_estimate_bps: DownUpOrder::default(), + display_rate_bps: None, + tcp_retransmits: DownUpOrder::default(), + end_status: 0, + tos: 0, + tc_handle: 0, + cpu: 0, + circuit_hash: None, + device_hash: None, + tcp_info: None, + }; + local.set_rate_estimate_bps(DownUpOrder { + down: u32::MAX, + up: 100_000_000, + }); + + let display = display_rate_bps( + &local, + Some(DownUpOrder { + down: 100_000_000, + up: 25_000_000, + }), + ); + + assert_eq!(display.down, 100_000_000); + assert_eq!(display.up, 25_000_000); + } +} diff --git a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_tracker.rs b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_tracker.rs index 5253faff..12875b38 100644 --- a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_tracker.rs +++ b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_tracker.rs @@ -77,6 +77,9 @@ pub struct FlowbeeLocalData { pub packets_sent: DownUpOrder, /// Rate estimate pub rate_estimate_bps: DownUpOrder, + /// Optional UI-oriented display rate. This is populated by specific + /// websocket/API surfaces that need plan-aware presentation guards. + pub display_rate_bps: Option>, /// TCP Retransmission count (also counts duplicates) pub tcp_retransmits: DownUpOrder, /// Has the connection ended? @@ -102,12 +105,15 @@ impl Serialize for FlowbeeLocalData { S: Serializer, { // Note: Keep this wire format stable (UI compatibility) while we refactor internal storage. - let mut state = serializer.serialize_struct("FlowbeeLocalData", 13)?; + let mut state = serializer.serialize_struct("FlowbeeLocalData", 14)?; state.serialize_field("start_time", &self.start_time)?; state.serialize_field("last_seen", &self.last_seen)?; state.serialize_field("bytes_sent", &self.bytes_sent)?; state.serialize_field("packets_sent", &self.packets_sent)?; state.serialize_field("rate_estimate_bps", &self.rate_estimate_bps)?; + if let Some(display_rate_bps) = &self.display_rate_bps { + state.serialize_field("display_rate_bps", display_rate_bps)?; + } state.serialize_field("tcp_retransmits", &self.tcp_retransmits)?; state.serialize_field("end_status", &self.end_status)?; state.serialize_field("tos", &self.tos)?; @@ -133,6 +139,7 @@ impl FlowbeeLocalData { bytes_sent: data.bytes_sent, packets_sent: data.packets_sent, rate_estimate_bps: data.rate_estimate_bps, + display_rate_bps: None, tcp_retransmits: data.tcp_retransmits, end_status: data.end_status, tos: data.tos, @@ -302,6 +309,10 @@ impl FlowbeeLocalData { self.rate_estimate_bps = rate_estimate_bps; } + pub fn set_display_rate_bps(&mut self, display_rate_bps: Option>) { + self.display_rate_bps = display_rate_bps; + } + pub fn set_tcp_retransmits(&mut self, tcp_retransmits: DownUpOrder) { self.tcp_retransmits = tcp_retransmits; } From 660934f777e2e55400b222b01a71899209e71b64 Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Sun, 29 Mar 2026 19:46:21 -0600 Subject: [PATCH 080/102] changes --- docs-es/v2.0/node-manager-ui-es.md | 1 + docs/v2.0/node-manager-ui.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 5c18c91c..a8c0087d 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -66,6 +66,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - Cuando los metadatos de la integración informan la velocidad Ethernet negociada del CPE, la fila `Max` puede mostrar una insignia de advertencia como `100M`; al pasar el cursor sobre la insignia se explica cuándo LibreQoS redujo automáticamente el shaping por debajo del plan solicitado para respetar ese límite del puerto, y al hacer clic en la insignia se abre la página de revisión Ethernet. - `Queue Dynamics` muestra el comportamiento del throughput y RTT del circuito a lo largo del tiempo, incluyendo un KPI de `Active Flows` basado en la misma ventana reciente usada por la tabla `Traffic Flows`. - `Devices` usa el mismo tratamiento de panel/tarjeta envolvente que las otras pestañas del Circuit page, manteniendo al mismo tiempo la tabla de detalle por dispositivo y el diseño 2x2 de gráficos en vivo. +- Los gráficos por dispositivo de `Throughput`, `Retransmits`, `RTT Histogram` y `Ping Time Histogram` usan estilos de ejes, leyenda y tooltips alineados con el Circuit page, en lugar del antiguo cromado gris genérico del dashboard. - `Queue Stats` muestra los 3 minutos más recientes del historial en vivo de la cola del circuito como muestras scatter crudas de 1 segundo, incluyendo backlog, delay, longitud de cola, tráfico, marcas ECN y drops. - Los gráficos de Queue Stats usan hover sincronizado para inspeccionar el mismo segundo en todos los gráficos de cola al mismo tiempo. - `Queue Tree` muestra la ruta ascendente de colas del circuito, incluyendo un resumen de ruta y contexto de throughput, retransmisiones y latencia para cada nodo aguas arriba. diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index c9c5503c..2cff2341 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -69,6 +69,7 @@ This page documents key WebUI (Node Manager) views and operational behavior in t - When integration metadata reports a negotiated CPE Ethernet speed, the `Max` row can show a warning badge such as `100M`; hovering the badge explains when LibreQoS auto-capped shaping below the requested plan to stay within that port speed, and clicking the badge opens the Ethernet review page. - `Queue Dynamics` shows circuit throughput and RTT behavior over time, including an `Active Flows` KPI based on the same recent flow window used by the `Traffic Flows` table. - `Devices` uses the same enclosed panel/card treatment as the other Circuit tabs, while keeping the per-device detail table and 2x2 live chart layout. +- The per-device `Throughput`, `Retransmits`, `RTT Histogram`, and `Ping Time Histogram` charts use Circuit-page-aware axis, legend, and tooltip styling instead of the older generic dashboard gray chrome. - `Queue Stats` shows the most recent 3 minutes of live queue history for the circuit as raw 1-second scatter samples, including backlog, delay, queue length, traffic, ECN marks, and drops. - Queue Stats charts use synchronized hover so operators can inspect the same second across all queue charts together. - `Queue Tree` shows the circuit's live upstream queue path, including a path summary and per-node throughput, retransmit, and latency context. From 44051868565d827e2fe111df1e3b9310d0e1d4a9 Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Sun, 29 Mar 2026 20:21:39 -0600 Subject: [PATCH 081/102] changes --- .../src/graphs/queuing_activity_waveform.js | 143 +++++++++++------- 1 file changed, 86 insertions(+), 57 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js index db1c7c75..812927bc 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js @@ -24,8 +24,28 @@ function formatRttMs(value) { return `${n.toFixed(n >= 10 ? 0 : 1)} ms`; } -function getWaveformTheme() { +function hexToRgba(hex, alpha) { + if (typeof hex !== "string" || !hex.startsWith("#")) { + return hex; + } + const normalized = hex.length === 4 + ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` + : hex; + const red = parseInt(normalized.slice(1, 3), 16); + const green = parseInt(normalized.slice(3, 5), 16); + const blue = parseInt(normalized.slice(5, 7), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +function throughputPaletteColor(direction, fallback) { + const paletteIndex = direction === "up" ? 1 : 0; + return window.graphPalette?.[paletteIndex] || fallback; +} + +function getWaveformTheme(direction = "down") { const isDark = document.documentElement.getAttribute("data-bs-theme") !== "light"; + const throughputColor = throughputPaletteColor(direction, isDark ? "#4992ff" : "#d87c7c"); + if (isDark) { return { axisText: "rgba(216, 226, 244, 0.62)", @@ -33,14 +53,14 @@ function getWaveformTheme() { axisLine: "rgba(216, 226, 244, 0.28)", axisTick: "rgba(216, 226, 244, 0.2)", splitLine: "rgba(216, 226, 244, 0.045)", - throughputLine: "#32d3bd", - throughputGlow: "rgba(50, 211, 189, 0.34)", - throughputAreaTop: "rgba(50, 211, 189, 0.48)", - throughputAreaMid: "rgba(42, 156, 145, 0.3)", + throughputLine: throughputColor, + throughputGlow: hexToRgba(throughputColor, 0.34), + throughputAreaTop: hexToRgba(throughputColor, 0.48), + throughputAreaMid: hexToRgba(throughputColor, 0.28), throughputAreaBottom: "rgba(9, 20, 31, 0.03)", - rttLine: "#8fb0ff", - rttGlow: "rgba(143, 176, 255, 0.3)", - rttAreaTop: "rgba(143, 176, 255, 0.18)", + rttLine: "#b7a5ff", + rttGlow: "rgba(183, 165, 255, 0.32)", + rttAreaTop: "rgba(183, 165, 255, 0.18)", rttAreaBottom: "rgba(16, 22, 34, 0.01)", rttBandGood: "rgba(42, 168, 108, 0.08)", rttBandWarn: "rgba(228, 154, 44, 0.07)", @@ -58,14 +78,14 @@ function getWaveformTheme() { axisLine: "rgba(77, 94, 118, 0.28)", axisTick: "rgba(77, 94, 118, 0.18)", splitLine: "rgba(77, 94, 118, 0.08)", - throughputLine: "#129a87", - throughputGlow: "rgba(18, 154, 135, 0.18)", - throughputAreaTop: "rgba(18, 154, 135, 0.26)", - throughputAreaMid: "rgba(18, 154, 135, 0.14)", + throughputLine: throughputColor, + throughputGlow: hexToRgba(throughputColor, 0.2), + throughputAreaTop: hexToRgba(throughputColor, 0.24), + throughputAreaMid: hexToRgba(throughputColor, 0.13), throughputAreaBottom: "rgba(255, 255, 255, 0.02)", - rttLine: "#4f79d9", - rttGlow: "rgba(79, 121, 217, 0.18)", - rttAreaTop: "rgba(79, 121, 217, 0.1)", + rttLine: "#6f63cf", + rttGlow: "rgba(111, 99, 207, 0.2)", + rttAreaTop: "rgba(111, 99, 207, 0.1)", rttAreaBottom: "rgba(255, 255, 255, 0.01)", rttBandGood: "rgba(46, 163, 111, 0.06)", rttBandWarn: "rgba(214, 148, 40, 0.06)", @@ -191,15 +211,17 @@ function ceilingSeriesData(samples, direction, windowStart, windowEnd) { const ceilingBps = toNumber(ceilingData[i][1], 0); const throughputBps = toNumber(throughputData[i][1], 0); const atCeiling = ceilingBps > 0 && throughputBps >= (ceilingBps * 0.95); - const activeTimestamp = i > 0 ? timestamp : windowStart; + const activeValue = atCeiling ? ceilingBps : null; if (base.length === 0) { base.push([timestamp, ceilingBps]); - active.push([activeTimestamp, atCeiling ? ceilingBps : null]); + active.push([timestamp, activeValue]); + } else { + active.push([timestamp, activeValue]); } base.push([nextTimestamp, ceilingBps]); - active.push([nextTimestamp, atCeiling ? ceilingBps : null]); + active.push([nextTimestamp, activeValue]); } return { base, active }; } @@ -249,8 +271,8 @@ export class QueuingActivityWaveform extends DashboardGraph { if (this.dom && this.dom.classList) { this.dom.classList.remove("muted"); } - this.colors = getWaveformTheme(); this.direction = "down"; + this.colors = getWaveformTheme(this.direction); this.samples = []; this.sampleClockBase = null; this.lastSampleTimestamp = null; @@ -518,6 +540,46 @@ export class QueuingActivityWaveform extends DashboardGraph { this.scheduleRenderLoop(); } + applyThemeColors() { + this.option.xAxis[0].axisLine.lineStyle.color = this.colors.axisLine; + this.option.xAxis[1].axisLine.lineStyle.color = this.colors.axisLine; + this.option.xAxis[1].axisTick.lineStyle.color = this.colors.axisTick; + this.option.xAxis[1].axisLabel.color = this.colors.axisText; + this.option.yAxis[0].nameTextStyle.color = this.colors.axisName; + this.option.yAxis[0].axisLine.lineStyle.color = this.colors.axisLine; + this.option.yAxis[0].splitLine.lineStyle.color = this.colors.splitLine; + this.option.yAxis[0].axisLabel.color = this.colors.axisText; + this.option.yAxis[1].nameTextStyle.color = this.colors.axisName; + this.option.yAxis[1].axisLine.lineStyle.color = this.colors.axisLine; + this.option.yAxis[1].axisTick.lineStyle.color = this.colors.axisTick; + this.option.yAxis[1].splitLine.lineStyle.color = this.colors.splitLine; + this.option.yAxis[1].axisLabel.color = this.colors.axisText; + this.option.series[0].lineStyle.color = this.colors.throughputLine; + this.option.series[0].lineStyle.shadowColor = this.colors.throughputGlow; + this.option.series[0].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.colors.throughputAreaTop }, + { offset: 0.45, color: this.colors.throughputAreaMid }, + { offset: 1, color: this.colors.throughputAreaBottom }, + ]); + this.option.series[1].lineStyle.color = this.colors.ceilingInactive; + this.option.series[1].lineStyle.shadowColor = this.colors.ceilingInactiveGlow; + this.option.series[2].lineStyle.color = this.colors.ceilingActive; + this.option.series[2].lineStyle.shadowColor = this.colors.ceilingActiveGlow; + this.option.series[3].lineStyle.color = this.colors.ceilingActive; + this.option.series[3].lineStyle.shadowColor = this.colors.ceilingActiveGlow; + this.option.series[4].lineStyle.color = this.colors.rttLine; + this.option.series[4].lineStyle.shadowColor = this.colors.rttGlow; + this.option.series[4].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.colors.rttAreaTop }, + { offset: 1, color: this.colors.rttAreaBottom }, + ]); + this.option.series[4].markArea.data = rttMarkAreas( + this.colors, + this.rttThresholds, + this.option.yAxis[1].max || this.rttThresholds.red_ms, + ); + } + scheduleRenderLoop() { const step = (timestamp) => { if (!this.chart) { @@ -552,8 +614,11 @@ export class QueuingActivityWaveform extends DashboardGraph { setDirection(direction) { this.direction = direction === "up" ? "up" : "down"; + this.colors = getWaveformTheme(this.direction); this.cachedSeries.direction = this.direction; this.rebuildCachedSeries(); + this.applyThemeColors(); + this.chart.setOption(this.option); this.render(); } @@ -737,44 +802,8 @@ export class QueuingActivityWaveform extends DashboardGraph { } onThemeChange() { - this.colors = getWaveformTheme(); - this.option.xAxis[0].axisLine.lineStyle.color = this.colors.axisLine; - this.option.xAxis[1].axisLine.lineStyle.color = this.colors.axisLine; - this.option.xAxis[1].axisTick.lineStyle.color = this.colors.axisTick; - this.option.xAxis[1].axisLabel.color = this.colors.axisText; - this.option.yAxis[0].nameTextStyle.color = this.colors.axisName; - this.option.yAxis[0].axisLine.lineStyle.color = this.colors.axisLine; - this.option.yAxis[0].splitLine.lineStyle.color = this.colors.splitLine; - this.option.yAxis[0].axisLabel.color = this.colors.axisText; - this.option.yAxis[1].nameTextStyle.color = this.colors.axisName; - this.option.yAxis[1].axisLine.lineStyle.color = this.colors.axisLine; - this.option.yAxis[1].axisTick.lineStyle.color = this.colors.axisTick; - this.option.yAxis[1].splitLine.lineStyle.color = this.colors.splitLine; - this.option.yAxis[1].axisLabel.color = this.colors.axisText; - this.option.series[0].lineStyle.color = this.colors.throughputLine; - this.option.series[0].lineStyle.shadowColor = this.colors.throughputGlow; - this.option.series[0].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: this.colors.throughputAreaTop }, - { offset: 0.45, color: this.colors.throughputAreaMid }, - { offset: 1, color: this.colors.throughputAreaBottom }, - ]); - this.option.series[1].lineStyle.color = this.colors.ceilingInactive; - this.option.series[1].lineStyle.shadowColor = this.colors.ceilingInactiveGlow; - this.option.series[2].lineStyle.color = this.colors.ceilingActive; - this.option.series[2].lineStyle.shadowColor = this.colors.ceilingActiveGlow; - this.option.series[3].lineStyle.color = this.colors.ceilingActive; - this.option.series[3].lineStyle.shadowColor = this.colors.ceilingActiveGlow; - this.option.series[4].lineStyle.color = this.colors.rttLine; - this.option.series[4].lineStyle.shadowColor = this.colors.rttGlow; - this.option.series[4].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: this.colors.rttAreaTop }, - { offset: 1, color: this.colors.rttAreaBottom }, - ]); - this.option.series[4].markArea.data = rttMarkAreas( - this.colors, - this.rttThresholds, - this.option.yAxis[1].max || this.rttThresholds.red_ms, - ); + this.colors = getWaveformTheme(this.direction); + this.applyThemeColors(); this.chart.setOption(this.option); this.render(); } From fa9184ebffffbae376e79caa8d439bcfe3338f40 Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Sun, 29 Mar 2026 21:00:40 -0600 Subject: [PATCH 082/102] changes --- docs-es/v2.0/configuration-es.md | 2 + docs/v2.0/configuration.md | 2 + .../js_build/src/config/config_helper.js | 4 +- .../js_build/src/config_treeguard.js | 86 +++ .../node_manager/static2/config_devices.html | 18 +- .../node_manager/static2/config_flows.html | 130 ++-- .../node_manager/static2/config_general.html | 222 ++++--- .../static2/config_interface.html | 81 +-- .../node_manager/static2/config_iprange.html | 81 +-- .../node_manager/static2/config_queues.html | 197 +++--- .../src/node_manager/static2/config_rtt.html | 89 +-- .../static2/config_treeguard.html | 571 ++++++++++-------- .../src/node_manager/static2/node_manager.css | 140 ++++- 13 files changed, 1026 insertions(+), 597 deletions(-) diff --git a/docs-es/v2.0/configuration-es.md b/docs-es/v2.0/configuration-es.md index a27d3219..ed5b9e17 100644 --- a/docs-es/v2.0/configuration-es.md +++ b/docs-es/v2.0/configuration-es.md @@ -25,6 +25,8 @@ Para la mayoría de operadores: La mayoría de cambios operativos diarios se realizan en la WebUI (`http://tu_ip_del_shaper:9123/config_general.html`). +Las versiones actuales usan paneles compartidos de configuración en las páginas General, RTT, Queues, TreeGuard, Network Mode, IP Ranges, Flow Tracking y Shaped Devices para que los ajustes del nodo y la afinación operativa sigan la misma presentación visual que el resto de Node Manager. La página de TreeGuard ahora comienza con un resumen del comportamiento actual en lugar de un banner permanente de advertencia. + ### Dónde en la WebUI - Ajustes generales: `Configuration -> General` diff --git a/docs/v2.0/configuration.md b/docs/v2.0/configuration.md index 401708f4..2e7ceef8 100644 --- a/docs/v2.0/configuration.md +++ b/docs/v2.0/configuration.md @@ -27,6 +27,8 @@ For most operators: Most day-to-day LibreQoS configuration is handled in WebUI (`http://your_shaper_ip:9123/config_general.html`). +Current builds use shared configuration panels on the General, RTT, Queues, TreeGuard, Network Mode, IP Ranges, Flow Tracking, and Shaped Devices pages so node settings and operational tuning follow the same surface styling as the rest of Node Manager. The TreeGuard page now starts with a live behavior summary rather than a permanent warning banner. + ### Where in WebUI - General settings: `Configuration -> General` diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 8d666554..04d8ef04 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -484,8 +484,8 @@ export function renderConfigMenu(currentPage) {