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 @@