From 6e82dad7cd6b531935cf7b716d76afbaa5db8d7b Mon Sep 17 00:00:00 2001 From: Name <2718612@2178972821991.net> Date: Tue, 28 Apr 2026 08:47:33 -0600 Subject: [PATCH 1/3] changes --- src/LibreQoS.py | 81 ++----------------------- src/rust/lqos_config/src/planner.rs | 91 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 77 deletions(-) diff --git a/src/LibreQoS.py b/src/LibreQoS.py index c1c1e289..657741b8 100755 --- a/src/LibreQoS.py +++ b/src/LibreQoS.py @@ -664,60 +664,6 @@ def planner_circuit_identity_key(circuit): return circuit_id -def load_minor_state_from_queuing_structure(path=None): - if path is None: - path = get_queuing_structure_path() - 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 @@ -2029,9 +1975,8 @@ def refreshShapers(): f"Failed to save planner state at {state_path}: {e}", stacklevel=2 ) - # 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. + # Load persisted top-level planner state. Full reloads rebuild TC, so + # site/circuit class minors are runtime artifacts and are not reused below. try: state # noqa: B018 except NameError: @@ -2039,24 +1984,6 @@ def refreshShapers(): 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 = {} site_state_updated = {} planner_site_inputs = [] @@ -2126,8 +2053,8 @@ def refreshShapers(): identity_plan = plan_class_identities( planner_site_inputs, planner_circuit_groups, - site_state=site_state_from_disk, - circuit_state=circuit_state_from_disk, + site_state={}, + circuit_state={}, stick_offset=stickOffset, circuit_padding=CIRCUIT_PADDING, ) diff --git a/src/rust/lqos_config/src/planner.rs b/src/rust/lqos_config/src/planner.rs index 9b2c87ac..44edbee1 100644 --- a/src/rust/lqos_config/src/planner.rs +++ b/src/rust/lqos_config/src/planner.rs @@ -834,6 +834,97 @@ mod tests { assert_ne!(result.sites[0].class_minor, 0x29); } + #[test] + fn class_identity_planner_compacts_when_full_reload_ignores_saved_minors() { + const FRAGMENTED_SITE_MINOR: u16 = 0xff00; + const FRAGMENTED_CHILD_MINOR: u16 = 0xff02; + const FRAGMENTED_CIRCUIT_MINOR: u16 = 0xff10; + const CIRCUIT_PADDING: u32 = 8; + + 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 fragmented_sites = BTreeMap::from([ + ( + "CpueQueue0/site-a".to_string(), + PlannerSiteIdentityState { + class_minor: FRAGMENTED_SITE_MINOR, + queue: 1, + parent_path: "CpueQueue0".to_string(), + class_major: 1, + up_class_major: 0x41, + }, + ), + ( + "CpueQueue0/site-a/child".to_string(), + PlannerSiteIdentityState { + class_minor: FRAGMENTED_CHILD_MINOR, + queue: 1, + parent_path: "CpueQueue0/site-a".to_string(), + class_major: 1, + up_class_major: 0x41, + }, + ), + ]); + let fragmented_circuits = BTreeMap::from([( + "c1".to_string(), + PlannerCircuitIdentityState { + class_minor: FRAGMENTED_CIRCUIT_MINOR, + queue: 1, + parent_node: "child".to_string(), + class_major: 1, + up_class_major: 0x41, + }, + )]); + + let sticky_result = plan_class_identities( + &site_inputs, + &circuit_groups, + &fragmented_sites, + &fragmented_circuits, + 0x40, + CIRCUIT_PADDING, + ); + assert!(sticky_result.last_used_minor_by_queue[&1] > u32::from(FRAGMENTED_CIRCUIT_MINOR)); + + let full_reload_result = plan_class_identities( + &site_inputs, + &circuit_groups, + &BTreeMap::new(), + &BTreeMap::new(), + 0x40, + CIRCUIT_PADDING, + ); + + let expected_last_minor = u32::from(full_reload_result.circuits[1].class_minor) + .saturating_add(1) + .saturating_add(CIRCUIT_PADDING); + assert_eq!(full_reload_result.sites[0].class_minor, 3); + assert_eq!(full_reload_result.sites[1].class_minor, 5); + assert_eq!(full_reload_result.circuits[0].class_minor, 6); + assert_eq!(full_reload_result.circuits[1].class_minor, 7); + assert_eq!( + full_reload_result.last_used_minor_by_queue[&1], + expected_last_minor + ); + } + #[test] fn class_identity_planner_runtime_constraints_prefer_runtime_band() { let site_inputs = vec![SiteIdentityInput { From 94ab976a828f9751852d244eea0fc78c4234e846 Mon Sep 17 00:00:00 2001 From: Name <2718612@2178972821991.net> Date: Tue, 28 Apr 2026 22:17:10 -0600 Subject: [PATCH 2/3] changes --- .../node_manager/js_build/src/pubsub/ws.js | 87 ++++++++++++++++++- 1 file changed, 84 insertions(+), 3 deletions(-) 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 71b2ed3b..46b4f9e3 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 @@ -3,6 +3,7 @@ import { Encoder, decode } from "../lq_js_common/helpers/cbor-x"; const ACK_TEXT = "I accept that this is an unstable, internal API and is unsupported"; const EXPECTED_UI_VERSION = (window.LQOS_UI_VERSION || "").trim() || null; const USER_TOKEN_COOKIE = "User-Token"; +const VERSION_RELOAD_KEY = "lqosWsVersionReload"; const encoder = new Encoder({ useRecords: false, variableMapSize: true }); const DIAGNOSTIC_CHANNELS = new Set(["Cpu", "Ram", "RttHistogram"]); @@ -78,6 +79,28 @@ function get_user_token() { return get_cookie_value(USER_TOKEN_COOKIE); } +function versionReloadKey(serverVersion) { + const clientVersion = EXPECTED_UI_VERSION || "missing"; + const wsVersion = (serverVersion || "missing").toString().trim() || "missing"; + return `${clientVersion}->${wsVersion}`; +} + +function hasReloadedForVersionMismatch(mismatchKey) { + try { + if (window.sessionStorage.getItem(VERSION_RELOAD_KEY) === mismatchKey) { + return true; + } + window.sessionStorage.setItem(VERSION_RELOAD_KEY, mismatchKey); + return false; + } catch (_) { + if (window.__lqosWsVersionReload === mismatchKey) { + return true; + } + window.__lqosWsVersionReload = mismatchKey; + return false; + } +} + export function ws_proto() { if (window.location.protocol.startsWith("https")) { return "wss://"; @@ -95,6 +118,7 @@ export class WsClient { this.reconnectTimer = null; this.reconnectDelayMs = 1000; this.manualClose = false; + this.versionReloading = false; } connect() { @@ -159,7 +183,9 @@ export class WsClient { }); this.ws = null; this.handshake_done = false; - this._scheduleReconnect(); + if (!this.versionReloading) { + this._scheduleReconnect(); + } }; socket.onerror = () => { @@ -171,7 +197,9 @@ export class WsClient { }); this.ws = null; this.handshake_done = false; - this._scheduleReconnect(); + if (!this.versionReloading) { + this._scheduleReconnect(); + } }; } @@ -194,6 +222,54 @@ export class WsClient { this.handlers.clear(); } + _dropSocket(reason = "unspecified") { + dashboardWsDebug("drop-socket", { + reason, + desiredChannels: Array.from(this.desiredChannels.keys()), + }); + const oldSocket = this.ws; + this.ws = null; + this.handshake_done = false; + if (oldSocket) { + try { + oldSocket.close(); + } catch (_) { + // Ignore close errors; reconnect/reload handling continues below. + } + } + } + + _reloadForVersionMismatch(serverVersion) { + const mismatchKey = versionReloadKey(serverVersion); + dashboardWsDebug("version-mismatch", { + expected: EXPECTED_UI_VERSION || "missing", + actual: serverVersion || "missing", + mismatchKey, + }); + + if (!hasReloadedForVersionMismatch(mismatchKey)) { + this.versionReloading = true; + this._dropSocket("version-mismatch"); + window.location.reload(); + window.setTimeout(() => { + if (this.versionReloading) { + this.versionReloading = false; + this._scheduleReconnect(); + } + }, 3000); + return true; + } + + console.error( + "Websocket version mismatch persisted after reload:", + `client=${EXPECTED_UI_VERSION || "missing"}`, + `server=${serverVersion || "missing"}`, + ); + this._dropSocket("version-mismatch-persistent"); + this._scheduleReconnect(); + return true; + } + refreshSubscriptions(reason = "unspecified") { dashboardWsDebug("refresh-subscriptions", { reason, @@ -318,9 +394,14 @@ export class WsClient { "Websocket version mismatch:", hello ? hello.version : "missing", ); - this.close(); + this._reloadForVersionMismatch(hello ? hello.version : null); return; } + try { + window.sessionStorage.removeItem(VERSION_RELOAD_KEY); + } catch (_) { + // Storage cleanup is best-effort. + } this.handshake_done = true; dashboardWsDebug("handshake-complete", { desiredChannels: Array.from(this.desiredChannels.keys()), From 691ff7dda4c1477201d85f22eda527338c3b5663 Mon Sep 17 00:00:00 2001 From: Name <2718612@2178972821991.net> Date: Wed, 29 Apr 2026 13:58:19 -0600 Subject: [PATCH 3/3] changes --- docs-es/v2.0/integrations-uisp-es.md | 2 + docs/v2.0/integrations-uisp.md | 3 + src/LibreQoS.py | 31 ++- .../node_manager/js_build/src/config_uisp.js | 31 ++- .../src/node_manager/static2/config_uisp.html | 4 +- src/rust/uisp_integration/src/errors.rs | 4 +- .../src/strategies/full/root_site.rs | 15 +- .../src/strategies/full/tree_walk.rs | 2 +- .../uisp_integration/src/strategies/full2.rs | 195 +++++++++++++++++- .../uisp_integration/src/strategies/mod.rs | 6 +- src/test_libreqos_shaping_inputs.py | 33 +++ 11 files changed, 309 insertions(+), 17 deletions(-) diff --git a/docs-es/v2.0/integrations-uisp-es.md b/docs-es/v2.0/integrations-uisp-es.md index b2a233e1..22177b1a 100644 --- a/docs-es/v2.0/integrations-uisp-es.md +++ b/docs-es/v2.0/integrations-uisp-es.md @@ -19,6 +19,8 @@ Use esta guía para evitar confusión con opciones: 3. Use `full` cuando necesite jerarquía/backhaul completo, tenga margen de CPU y ya haya validado topología/overrides. 4. Use `flat` solo si la jerarquía no es necesaria y prioriza rendimiento máximo. +El ajuste `Root UISP Site` solo es obligatorio en modo de topología `full`. En los modos `flat`, `ap_only` y `ap_site`, puede dejarse vacío; LibreQoS usará los data-links de Internet de UISP para elegir la raíz de importación, o creará una raíz de Internet generada cuando UISP reporte varios sitios conectados a Internet. + ## Expectativas en router mode - Router mode en UISP es compatible, pero depende de que la topología en UISP esté bien definida. diff --git a/docs/v2.0/integrations-uisp.md b/docs/v2.0/integrations-uisp.md index 649fa2d3..5b0b2497 100644 --- a/docs/v2.0/integrations-uisp.md +++ b/docs/v2.0/integrations-uisp.md @@ -11,6 +11,8 @@ Use this section first to avoid common topology-mode confusion. 3. Use `full` when you need full hierarchy/backhaul representation and have CPU headroom. 4. Use `flat` only when hierarchy is not needed and maximum performance is the priority. +The `Root UISP Site` setting is required only for `full` topology mode. In `flat`, `ap_only`, and `ap_site` modes, you may leave it blank; LibreQoS will use UISP Internet data-links to pick the import root, or create a generated Internet root when UISP reports multiple Internet-connected sites. + ## Router-Mode Expectations - UISP router mode is supported, but parent/route discovery quality depends on your UISP topology data quality. @@ -124,6 +126,7 @@ enable_uisp = true token = "your-api-token-here" url = "https://uisp.your_domain.com" site = "Root_Site_Name" # Root site for topology perspective +# Required for full topology mode; optional for flat, ap_only, and ap_site # Suspension Handling (see table above) suspended_strategy = "none" diff --git a/src/LibreQoS.py b/src/LibreQoS.py index 657741b8..93f17ac4 100755 --- a/src/LibreQoS.py +++ b/src/LibreQoS.py @@ -363,6 +363,32 @@ def _circuit_attachment_name_candidates(circuit): return candidates +def _normalize_circuit_shaping_parent(circuit, parent_node_ids=None): + parent_name = str(circuit.get('ParentNode', '') or '').strip() + effective_name = str(circuit.get('effectiveParentNodeName', '') or '').strip() + parent_id = str(circuit.get('effectiveParentNodeID', '') or circuit.get('ParentNodeID', '') or '').strip() + resolved_id_parent = '' + if parent_node_ids and parent_id: + resolved_id_parent = str(parent_node_ids.get(parent_id, '') or '').strip() + + if not parent_name: + parent_name = effective_name + if (not parent_name or parent_name == 'none') and resolved_id_parent: + parent_name = resolved_id_parent + if not parent_name: + parent_name = 'none' + + if is_generated_parent_node_name(parent_name) or ( + resolved_id_parent and resolved_id_parent != parent_name + ): + parent_id = '' + + circuit['shapingParentNode'] = parent_name + circuit['shapingParentNodeID'] = parent_id + circuit['shapingParentKey'] = f"id:{parent_id}" if parent_id else f"name:{parent_name}" + return parent_name, parent_id + + def _validate_planned_circuit_attachment(node_name, node_data, circuit, planned_identity): site_major = _parse_int_token(node_data.get('classMajor')) site_up_major = _parse_int_token(node_data.get('up_classMajor')) @@ -1753,11 +1779,10 @@ def refreshShapers(): circuits_by_parent_id = {} circuits_by_parent_name = {} for circuit in subscriberCircuits: - parent_id = str(circuit.get('effectiveParentNodeID', '') or circuit.get('ParentNodeID', '') or '').strip() + parent_name, parent_id = _normalize_circuit_shaping_parent(circuit, parent_node_ids) if parent_id: circuits_by_parent_id.setdefault(parent_id, []).append(circuit) - - for parent_name in _circuit_attachment_name_candidates(circuit): + elif parent_name and parent_name != 'none': circuits_by_parent_name.setdefault(parent_name, []).append(circuit) # Parse network structure and add devices from ShapedDevices.csv diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_uisp.js b/src/rust/lqosd/src/node_manager/js_build/src/config_uisp.js index 7aeb4c92..1f7a5994 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_uisp.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_uisp.js @@ -6,6 +6,30 @@ import { secretWillExistAfterSave, } from "./config/config_helper"; +function currentTopologyMode() { + return ( + window.config?.topology?.compile_mode + ?? document.getElementById("uispStrategy").value.trim() + ); +} + +function rootSiteRequiredForMode(mode) { + return mode === "full" || mode === "full2"; +} + +function updateRootSiteRequirement() { + const required = rootSiteRequiredForMode(currentTopologyMode()); + const siteInput = document.getElementById("uispSite"); + const siteLabel = document.getElementById("uispSiteLabel"); + const siteHelp = document.getElementById("uispSiteHelp"); + + siteInput.required = required; + siteLabel.textContent = required ? "Root UISP Site" : "Root UISP Site (Optional)"; + siteHelp.textContent = required + ? "Required for full topology mode. Enter the exact UISP site name at the root of the network." + : "Optional in this topology mode. Leave blank to let LibreQoS choose the import root."; +} + function validateConfig() { // Validate required fields when enabled if (document.getElementById("enableUisp").checked) { @@ -27,8 +51,8 @@ function validateConfig() { } const site = document.getElementById("uispSite").value.trim(); - if (!site) { - alert("UISP Site is required when UISP integration is enabled"); + if (rootSiteRequiredForMode(currentTopologyMode()) && !site) { + alert("Root UISP Site is required when full topology mode is selected"); return false; } @@ -148,6 +172,7 @@ loadConfig(() => { document.getElementById("uispSite").value = uisp.site ?? ""; document.getElementById("uispStrategy").value = window.config.topology?.compile_mode ?? uisp.strategy ?? "ap_site"; document.getElementById("uispSuspendedStrategy").value = uisp.suspended_strategy ?? "none"; + updateRootSiteRequirement(); // Numeric fields document.getElementById("uispAirmaxCapacity").value = uisp.airmax_capacity ?? 1.0; @@ -170,6 +195,8 @@ loadConfig(() => { clearButtonId: "clearUispToken", }); + document.getElementById("uispStrategy").addEventListener("change", updateRootSiteRequirement); + // Add save button click handler document.getElementById('saveButton').addEventListener('click', () => { if (validateConfig()) { diff --git a/src/rust/lqosd/src/node_manager/static2/config_uisp.html b/src/rust/lqosd/src/node_manager/static2/config_uisp.html index e52c81f5..c6d51f80 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_uisp.html +++ b/src/rust/lqosd/src/node_manager/static2/config_uisp.html @@ -39,9 +39,9 @@
- + -
Optional site identifier in UISP.
+
Required only for full topology mode. Leave blank in other modes to let LibreQoS choose the import root.
diff --git a/src/rust/uisp_integration/src/errors.rs b/src/rust/uisp_integration/src/errors.rs index caa7c9eb..5f3af4ae 100644 --- a/src/rust/uisp_integration/src/errors.rs +++ b/src/rust/uisp_integration/src/errors.rs @@ -11,8 +11,8 @@ pub enum UispIntegrationError { UnknownIntegrationStrategy, #[error("Error contacting UISP")] UispConnectError, - #[error("Root site not found")] - NoRootSite, + #[error("Root site not found: {0}")] + NoRootSite(String), #[error("Unknown Site Type")] UnknownSiteType, #[error("CSV Error")] diff --git a/src/rust/uisp_integration/src/strategies/full/root_site.rs b/src/rust/uisp_integration/src/strategies/full/root_site.rs index ee1973c8..38de3506 100644 --- a/src/rust/uisp_integration/src/strategies/full/root_site.rs +++ b/src/rust/uisp_integration/src/strategies/full/root_site.rs @@ -23,7 +23,7 @@ pub fn find_root_site( if !sites.iter().any(|s| s.name == root_site_name) { error!("Site {root_site_name} (from /etc/lqos.conf) not found in the UISP sites list"); - return Err(UispIntegrationError::NoRootSite); + return Err(UispIntegrationError::NoRootSite(root_site_name)); } else { info!("{root_site_name} found in the sites list."); } @@ -45,7 +45,9 @@ fn handle_multiple_internet_connected_sites( if candidates.is_empty() { error!("Unable to find a root site in the sites/data-links."); - return Err(UispIntegrationError::NoRootSite); + return Err(UispIntegrationError::NoRootSite( + "no Internet-connected UISP sites were found".to_string(), + )); } else if candidates.len() == 1 { info!( "Found only one site with an Internet connection: {root_site_name}, using it as root" @@ -81,7 +83,9 @@ pub fn set_root_site(sites: &mut [UispSite], root_site: &str) -> Result<(), Uisp .count(); if number_of_roots > 1 { error!("More than one root present in the tree! That's not going to work. Bailing."); - return Err(UispIntegrationError::NoRootSite); + return Err(UispIntegrationError::NoRootSite( + "more than one root site was tagged".to_string(), + )); } else { info!("Single root tagged in the tree"); } @@ -123,7 +127,10 @@ mod test { let data_links = vec![]; let result = find_root_site(&cfg, &mut sites, &data_links); assert!(result.is_err()); - assert_eq!(result.unwrap_err(), UispIntegrationError::NoRootSite); + assert_eq!( + result.unwrap_err(), + UispIntegrationError::NoRootSite("TEST".to_string()) + ); } #[test] diff --git a/src/rust/uisp_integration/src/strategies/full/tree_walk.rs b/src/rust/uisp_integration/src/strategies/full/tree_walk.rs index aab7965f..2ee5ab1f 100644 --- a/src/rust/uisp_integration/src/strategies/full/tree_walk.rs +++ b/src/rust/uisp_integration/src/strategies/full/tree_walk.rs @@ -22,7 +22,7 @@ pub fn walk_tree_for_routing( // Make sure we know where the root is let Some(root_idx) = sites.iter().position(|s| s.name == root_site) else { tracing::error!("Unable to build a path-weights graph because I can't find the root node"); - return Err(UispIntegrationError::NoRootSite); + return Err(UispIntegrationError::NoRootSite(root_site.to_string())); }; // Now we iterate through every node that ISN'T the root diff --git a/src/rust/uisp_integration/src/strategies/full2.rs b/src/rust/uisp_integration/src/strategies/full2.rs index 8d78d143..63b2b6a6 100644 --- a/src/rust/uisp_integration/src/strategies/full2.rs +++ b/src/rust/uisp_integration/src/strategies/full2.rs @@ -40,6 +40,8 @@ use std::time::Instant; use tracing::{debug, info, warn}; type GraphType = petgraph::Graph; +const GENERATED_INTERNET_ROOT_NAME: &str = "INSERTED_INTERNET"; +const GENERATED_INTERNET_ROOT_ID: &str = "ROOT-001"; #[derive(Clone, Debug)] struct LegacyShapedDevice { @@ -152,9 +154,145 @@ fn anchor_for_site_ap( }) } +#[derive(Debug, PartialEq, Eq)] +enum ResolvedRootSite { + Existing(String), + GeneratedInternet, +} + +impl ResolvedRootSite { + fn name(&self) -> String { + match self { + ResolvedRootSite::Existing(name) => name.clone(), + ResolvedRootSite::GeneratedInternet => GENERATED_INTERNET_ROOT_NAME.to_string(), + } + } +} + +fn generated_internet_root() -> GraphMapping { + GraphMapping::Root { + name: GENERATED_INTERNET_ROOT_NAME.to_string(), + id: GENERATED_INTERNET_ROOT_ID.to_string(), + latitude: None, + longitude: None, + } +} + +fn resolve_root_site_from_names( + configured_site: &str, + site_names: &[String], + internet_site_candidates: &[String], + require_configured_root_site: bool, +) -> Result { + let configured_site = configured_site.trim(); + if !configured_site.is_empty() { + if site_names.iter().any(|site_name| site_name == configured_site) { + info!("Using root UISP site from /etc/lqos.conf: {configured_site}"); + return Ok(ResolvedRootSite::Existing(configured_site.to_string())); + } + + let mut sorted_site_names = site_names.to_vec(); + sorted_site_names.sort_unstable(); + let sample_sites = sorted_site_names + .iter() + .take(10) + .cloned() + .collect::>() + .join(", "); + if require_configured_root_site { + tracing::error!( + "Configured UISP root site '{configured_site}' was not found. Available non-client site sample: {sample_sites}" + ); + return Err(UispIntegrationError::NoRootSite(configured_site.to_string())); + } + + warn!( + "Configured UISP root site '{configured_site}' was not found. Non-full topology mode will auto-detect a root instead." + ); + } else { + warn!("Root UISP site is blank; attempting to auto-detect an import root."); + } + + let mut candidates = internet_site_candidates + .iter() + .filter(|candidate| site_names.iter().any(|site_name| site_name == *candidate)) + .cloned() + .collect::>(); + candidates.sort_unstable(); + candidates.dedup(); + + match candidates.len() { + 0 => { + tracing::error!("Unable to auto-detect a UISP root site from Internet data-links."); + Err(UispIntegrationError::NoRootSite( + "no Internet-connected UISP sites were found".to_string(), + )) + } + 1 => { + info!( + "Auto-detected root UISP site from Internet data-links: {}", + candidates[0] + ); + Ok(ResolvedRootSite::Existing(candidates.remove(0))) + } + _ => { + warn!( + "Multiple Internet-connected UISP sites detected. Creating a generated Internet root." + ); + Ok(ResolvedRootSite::GeneratedInternet) + } + } +} + +fn resolve_root_site( + config: &Config, + uisp_data: &UispData, + require_configured_root_site: bool, +) -> Result { + let mut site_names = uisp_data + .sites_raw + .iter() + .filter(|site| !site.is_client_site()) + .map(|site| site.name_or_blank()) + .collect::>(); + site_names.sort_unstable(); + site_names.dedup(); + + let mut internet_site_ids = uisp_data + .data_links_raw + .iter() + .filter(|link| !link.can_delete) + .filter_map(|link| { + link.from + .site + .as_ref() + .map(|site| site.identification.id.clone()) + }) + .collect::>(); + internet_site_ids.sort_unstable(); + internet_site_ids.dedup(); + + let mut internet_site_candidates = uisp_data + .sites_raw + .iter() + .filter(|site| internet_site_ids.iter().any(|site_id| site_id == &site.id)) + .map(|site| site.name_or_blank()) + .collect::>(); + internet_site_candidates.sort_unstable(); + internet_site_candidates.dedup(); + + resolve_root_site_from_names( + &config.uisp_integration.site, + &site_names, + &internet_site_candidates, + require_configured_root_site, + ) +} + pub async fn build_imported_full2_bundle( config: Arc, ip_ranges: IpRanges, + require_configured_root_site: bool, ) -> Result { let build_started = Instant::now(); let ethernet_policy = EthernetPortLimitPolicy::from(&config.integration_common); @@ -170,7 +308,9 @@ pub async fn build_imported_full2_bundle( let mut graph = GraphType::new(); // Find the root - let root_site_name = config.uisp_integration.site.clone(); + let resolved_root_site = + resolve_root_site(&config, &uisp_data, require_configured_root_site)?; + let root_site_name = resolved_root_site.name(); // Add all sites to the graph let graph_build_started = Instant::now(); @@ -183,7 +323,14 @@ pub async fn build_imported_full2_bundle( &mut site_map, &mut root_idx, ); - let root_idx = root_idx.expect("Root site not found"); + let root_idx = match (root_idx, resolved_root_site) { + (Some(root_idx), _) => root_idx, + (None, ResolvedRootSite::GeneratedInternet) => graph.add_node(generated_internet_root()), + (None, ResolvedRootSite::Existing(root_site_name)) => { + tracing::error!("Root UISP site '{root_site_name}' was not added to the graph."); + return Err(UispIntegrationError::NoRootSite(root_site_name)); + } + }; let site_graph_elapsed_ms = graph_build_started.elapsed().as_millis(); // Iterate all UISP devices and if their parent site is in the graph, add them @@ -3090,6 +3237,50 @@ mod tests { .expect("expected node to exist") } + #[test] + fn root_resolution_accepts_configured_site() { + let sites = vec!["Main".to_string(), "Backup".to_string()]; + let internet_candidates = vec!["Backup".to_string()]; + + let root = + resolve_root_site_from_names("Main", &sites, &internet_candidates, true).unwrap(); + + assert_eq!(root, ResolvedRootSite::Existing("Main".to_string())); + } + + #[test] + fn root_resolution_rejects_bad_configured_site_for_full_mode() { + let sites = vec!["Main".to_string(), "Backup".to_string()]; + let internet_candidates = vec!["Backup".to_string()]; + + let error = + resolve_root_site_from_names("Old Root", &sites, &internet_candidates, true) + .unwrap_err(); + + assert_eq!(error, UispIntegrationError::NoRootSite("Old Root".to_string())); + } + + #[test] + fn root_resolution_auto_detects_for_non_full_mode() { + let sites = vec!["Main".to_string(), "Backup".to_string()]; + let internet_candidates = vec!["Backup".to_string()]; + + let root = + resolve_root_site_from_names("Old Root", &sites, &internet_candidates, false).unwrap(); + + assert_eq!(root, ResolvedRootSite::Existing("Backup".to_string())); + } + + #[test] + fn root_resolution_uses_generated_root_for_multiple_internet_sites() { + let sites = vec!["Main".to_string(), "Backup".to_string()]; + let internet_candidates = vec!["Main".to_string(), "Backup".to_string()]; + + let root = resolve_root_site_from_names("", &sites, &internet_candidates, false).unwrap(); + + assert_eq!(root, ResolvedRootSite::GeneratedInternet); + } + #[test] fn squash_candidate_collection_is_deterministic() { let config = Arc::new(Config::default()); diff --git a/src/rust/uisp_integration/src/strategies/mod.rs b/src/rust/uisp_integration/src/strategies/mod.rs index 78a62b98..fd1da0bd 100644 --- a/src/rust/uisp_integration/src/strategies/mod.rs +++ b/src/rust/uisp_integration/src/strategies/mod.rs @@ -79,7 +79,11 @@ pub async fn build_with_strategy( blackboard(BlackboardSystem::System, "UISP", strategy.as_str()).await; let mode = compile_mode_for_strategy(&strategy)?; info!("Strategy selected: {strategy}"); - let imported = full2::build_imported_full2_bundle(config.clone(), ip_ranges).await?; + let require_configured_root_site = + matches!(mode, TopologyCompileMode::Full | TopologyCompileMode::Full2); + let imported = + full2::build_imported_full2_bundle(config.clone(), ip_ranges, require_configured_root_site) + .await?; let topology_import = TopologyImportFile::from_imported_bundle(&imported, strategy.clone()); let compiled = compile_topology(imported, mode).map_err(|e| { error!("Unable to compile UISP topology for mode '{strategy}': {e:?}"); diff --git a/src/test_libreqos_shaping_inputs.py b/src/test_libreqos_shaping_inputs.py index 57531a3c..afd303c5 100644 --- a/src/test_libreqos_shaping_inputs.py +++ b/src/test_libreqos_shaping_inputs.py @@ -111,6 +111,39 @@ class TestLibreQoSShapingInputs(unittest.TestCase): ) self.assertEqual(candidates, ["Generated_PN_2"]) + def test_canonical_shaping_parent_ignores_logical_anchor_for_generated_parent(self): + circuit = { + "ParentNode": "Generated_PN_1", + "effectiveParentNodeName": "Generated_PN_1", + "ParentNodeID": "uisp:device:glenn-s1", + "effectiveParentNodeID": "uisp:device:glenn-s1", + "logicalParentNode": "glenn-s1.streamitnet.com", + "parentResolutionSource": "topology_anchor", + } + + parent_name, parent_id = LibreQoS._normalize_circuit_shaping_parent(circuit) + + self.assertEqual(parent_name, "Generated_PN_1") + self.assertEqual(parent_id, "") + self.assertEqual(circuit["shapingParentNode"], "Generated_PN_1") + self.assertEqual(circuit["shapingParentNodeID"], "") + self.assertEqual(circuit["shapingParentKey"], "name:Generated_PN_1") + + def test_canonical_shaping_parent_drops_mismatched_parent_id(self): + circuit = { + "ParentNode": "Physical_AP", + "ParentNodeID": "uisp:device:logical-ap", + } + + parent_name, parent_id = LibreQoS._normalize_circuit_shaping_parent( + circuit, + {"uisp:device:logical-ap": "Logical_AP"}, + ) + + self.assertEqual(parent_name, "Physical_AP") + self.assertEqual(parent_id, "") + self.assertEqual(circuit["shapingParentKey"], "name:Physical_AP") + def test_validate_planned_circuit_attachment_rejects_major_mismatch(self): with self.assertRaisesRegex(ValueError, "Planned circuit class majors do not match"): LibreQoS._validate_planned_circuit_attachment(