From 69cfd6d77b1bb6c96ab53c9d4bc85910ddffc231 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Wed, 4 Feb 2026 14:29:24 -0600 Subject: [PATCH] feat: virtual tree nodes + physical CPU site tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements logical-only (virtual) nodes in network.json to improve CPU spreading while preserving monitoring hierarchy (NLNet milestones a/b/c). Virtual nodes - network.json supports "virtual": true (and legacy type=virtual) as logical-only nodes. - Physical HTB topology skips virtual nodes and promotes their children to the nearest real ancestor. - Writes logical->physical mapping + virtual node list into queuingStructure.json for debugging/inspection. CPU placement + binpacking - When binpacking is enabled but Insight/planner is unavailable, fall back to deterministic round-robin CPU assignment (still honors virtual promotion). - Adds sanity checks/fallbacks to avoid degenerate single-bin assignments collapsing the physical tree. UI + diagnostics - Config/network UI supports toggling virtual state and shows a ghost icon for virtual nodes. - Adds cpu_tree.html (ECharts) and websocket endpoint to visualize the PHYSICAL per-CPU site tree from queuingStructure.json. Overrides - lqos_overrides: add SetNodeVirtual adjustment plus CLI commands to view/edit/delete by node name. - Scheduler applies virtual overrides onto network.json so periodic rebuilds don’t clobber them. Config loader hardening - lqos_config errors now reference /etc/lqos.conf (or LQOS_CONFIG) instead of deprecated ispConfig.py. - Avoids caching initial config-load failures; can recover if config appears later. - Treats versionless v1.5 configs as 1.5 by injecting version key; avoids accidental 1.4 migration. - Removes a panic() in legacy TOML parsing path. Misc - Fix LibreQoS.py exit calls (os.exit -> os._exit) and add Python unittest coverage for virtual node promotion. --- src/LibreQoS.py | 274 ++++++++++++++---- .../lqos_config/src/etc/etclqos_migration.rs | 3 +- src/rust/lqos_config/src/etc/migration.rs | 20 +- src/rust/lqos_config/src/etc/mod.rs | 116 ++++---- src/rust/lqos_config/src/network_json.rs | 108 +++++++ .../src/network_json/network_json_node.rs | 5 + .../network_json/network_json_transport.rs | 3 + src/rust/lqos_overrides/src/main.rs | 18 ++ src/rust/lqos_overrides/src/overrides_file.rs | 27 ++ src/rust/lqos_python/src/lib.rs | 5 + .../src/node_manager/js_build/esbuild.sh | 2 +- .../js_build/src/config_network.js | 24 +- .../js_build/src/configuration.js | 21 ++ .../src/node_manager/js_build/src/cpu_tree.js | 126 ++++++++ .../src/node_manager/js_build/src/tree.js | 8 + .../node_manager/local_api/cpu_affinity.rs | 155 ++++++++++ .../src/node_manager/static2/cpu_tree.html | 32 ++ .../src/node_manager/static2/cpu_weights.html | 5 + .../lqosd/src/node_manager/static_pages.rs | 1 + src/rust/lqosd/src/node_manager/ws.rs | 8 + .../lqosd/src/node_manager/ws/messages.rs | 7 +- .../lqosd/src/shaped_devices_tracker/mod.rs | 1 + src/scheduler.py | 31 +- src/test_virtual_tree_nodes.py | 80 +++++ src/virtual_tree_nodes.py | 93 ++++++ 25 files changed, 1060 insertions(+), 113 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/cpu_tree.js create mode 100644 src/rust/lqosd/src/node_manager/static2/cpu_tree.html create mode 100644 src/test_virtual_tree_nodes.py create mode 100644 src/virtual_tree_nodes.py diff --git a/src/LibreQoS.py b/src/LibreQoS.py index 19e83b0c..5492d8bc 100755 --- a/src/LibreQoS.py +++ b/src/LibreQoS.py @@ -21,12 +21,19 @@ import shutil import time from deepdiff import DeepDiff +from virtual_tree_nodes import ( + build_logical_to_physical_node_map, + build_physical_network, + is_virtual_node, +) + from liblqos_python import is_lqosd_alive, clear_ip_mappings, delete_ip_mapping, validate_shaped_devices, \ is_libre_already_running, create_lock_file, free_lock_file, add_ip_mapping, BatchedCommands, \ check_config, sqm, upstream_bandwidth_capacity_download_mbps, upstream_bandwidth_capacity_upload_mbps, \ 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, \ fast_queues_fq_codel, \ Bakery @@ -593,6 +600,9 @@ def refreshShapers(): # Load Subscriber Circuits & Devices subscriberCircuits, dictForCircuitsWithoutParentNodes = loadSubscriberCircuits(shapedDevicesFile) + # Preserve the logical parent (as configured in ShapedDevices.csv) before any shaping-time rewrites. + for circuit in subscriberCircuits: + circuit['logicalParentNode'] = circuit.get('ParentNode') # Load network hierarchy with open(networkJSONfile, 'r') as j: @@ -605,6 +615,53 @@ def refreshShapers(): flat_network = flat_network or is_network_flat() except Exception: pass + + # Virtual Nodes (logical-only): build a physical shaping topology that skips them, + # while leaving ShapedDevices.csv (and monitoring) unchanged. + logical_to_physical_node = {} + virtual_nodes = [] + if not flat_network and isinstance(network, dict) and len(network) > 0: + logical_to_physical_node, virtual_nodes = build_logical_to_physical_node_map(network) + if len(virtual_nodes) > 0: + print( + f"Detected {len(virtual_nodes)} virtual node(s) in network.json; building physical HTB tree without them." + ) + network = build_physical_network(network) + if len(network) == 0: + warnings.warn( + "All nodes were removed from the physical shaping tree after virtual-node promotion. Treating this as a flat network for shaping.", + stacklevel=2, + ) + flat_network = True + else: + # Avoid bloating queuingStructure.json when there are no virtual nodes. + logical_to_physical_node = {} + + # Re-map circuits that are directly parented to a virtual node to the nearest real ancestor (milestone c). + if not flat_network and len(virtual_nodes) > 0 and isinstance(logical_to_physical_node, dict): + next_id = max(dictForCircuitsWithoutParentNodes.keys(), default=-1) + 1 + for circuit in subscriberCircuits: + logical_parent = circuit.get('logicalParentNode', circuit.get('ParentNode')) + if logical_parent and logical_parent != 'none' and logical_parent in logical_to_physical_node: + physical_parent = logical_to_physical_node.get(logical_parent) + if physical_parent is None: + warnings.warn( + f"Circuit '{circuit.get('circuitID','')}' is parented to virtual top-level node '{logical_parent}'. Attaching it as unparented for shaping.", + stacklevel=2, + ) + circuit['ParentNode'] = 'none' + else: + circuit['ParentNode'] = physical_parent + + # If virtual-node mapping created new unparented circuits, ensure they have planner IDs. + if circuit.get('ParentNode') == 'none' and 'idForCircuitsWithoutParentNodes' not in circuit: + try: + weight = float(circuit.get('maxDownload', 0)) + float(circuit.get('maxUpload', 0)) + except Exception: + weight = 0.0 + dictForCircuitsWithoutParentNodes[next_id] = weight + circuit['idForCircuitsWithoutParentNodes'] = next_id + next_id += 1 if flat_network: print("Flat network detected; assigning circuits to generated parent nodes") next_id = max(dictForCircuitsWithoutParentNodes.keys(), default=-1) + 1 @@ -1129,6 +1186,7 @@ def refreshShapers(): "circuitID": circuit['circuitID'], "circuitName": circuit['circuitName'], "ParentNode": circuit['ParentNode'], + "logicalParentNode": circuit.get('logicalParentNode', circuit['ParentNode']), "devices": circuit['devices'], "classid": flowIDstring, "up_classid" : upFlowIDstring, @@ -1215,13 +1273,32 @@ def refreshShapers(): # 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(): - print("Planner is enabled, so we're going to sort your network across CPU queues.") + # Binpacking is an Insight feature; if Insight is not enabled/licensed, fall back to + # deterministic round-robin placement so "virtual node promotion" can still spread + # the physical tree across CPUs. + insight_enabled = False + try: + insight_enabled = bool(is_insight_enabled()) + except Exception: + insight_enabled = False + + if insight_enabled: + print("Planner is enabled, so we're going to sort your network across CPU queues.") + else: + warnings.warn( + "Binpacking is enabled but Insight is not available; using round-robin CPU distribution.", + stacklevel=2, + ) + # Build items from top-level nodes with weights items = [] try: weights = get_tree_weights() except Exception as e: - warnings.warn("get_tree_weights() failed; defaulting to equal weights (" + str(e) + ")", stacklevel=2) + warnings.warn( + "get_tree_weights() failed; defaulting to equal weights (" + str(e) + ")", + stacklevel=2, + ) weights = None weight_by_name = {} if weights is not None: @@ -1230,64 +1307,157 @@ def refreshShapers(): weight_by_name[str(w.name)] = float(w.weight) 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) - items.append({"id": str(node), "weight": float(w)}) + try: + w = float(w) + except Exception: + w = 1.0 + # Ensure we always spread items. Zero/negative weights can cause all items + # to collapse into a single CPU bin in tie cases. + if not math.isfinite(w) or w <= 0.0: + 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()) - # Load planner state - try: - import bin_planner - except ImportError: - bin_planner = None - # Store planner state directly in lqos_directory (no hidden subdirs) + planner_used = False state_path = os.path.join(get_libreqos_directory(), "planner_state.json") state = {} - if bin_planner is not None: - state = bin_planner.load_state(state_path) now_ts = time.time() - 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} - valid_bins = set(capacities.keys()) - 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} + assignment = {} + changed = [] + bin_planner = None - 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 + # 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 - if bin_planner is not None: - assignment, changed = bin_planner.plan_assignments( - items, bins_list, capacities, prev_assign, now_ts, params - ) + 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} + + 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: - # Fallback to simple greedy if planner unavailable - bin_loads = {key: 0.0 for key in cpu_keys} - pairs = [(str(it["id"]), float(it["weight"])) for it in items] - pairs.sort(key=lambda nw: (-nw[1], nw[0])) - assignment = {} - for name, w in pairs: - target = min(bin_loads.items(), key=lambda kv: (kv[1], kv[0]))[0] - assignment[name] = target - bin_loads[target] += w - changed = list(assignment.keys()) + # Insight is unavailable; use deterministic round-robin distribution. + assignment, changed = round_robin_assign(items, cpu_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) @@ -1315,7 +1485,7 @@ def refreshShapers(): network = binnedNetwork # Update and save state - if bin_planner is not None and isinstance(state, dict): + if planner_used and bin_planner is not None 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): @@ -1330,7 +1500,9 @@ def refreshShapers(): print(f"Saving planner state to {state_path} (top-level CPU binning)") bin_planner.save_state(state_path, state) except Exception as e: - warnings.warn(f"Failed to save planner state at {state_path}: {e}", stacklevel=2) + 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()). try: @@ -1620,6 +1792,8 @@ def refreshShapers(): queuingStructure['Network'] = network queuingStructure['lastUsedClassIDCounterByCPU'] = minorByCPU queuingStructure['generatedPNs'] = generatedPNs + queuingStructure['logical_to_physical_node'] = logical_to_physical_node + queuingStructure['virtual_nodes'] = virtual_nodes with open('queuingStructure.json', 'w') as infile: json.dump(queuingStructure, infile, indent=4) @@ -1824,7 +1998,7 @@ if __name__ == '__main__': print("Configuration from /etc/lqos.conf is usable") else: print("ERROR: Unable to load configuration from /etc/lqos.conf") - os.exit(-1) + os._exit(-1) # Check that we aren't running LibreQoS.py more than once at a time if is_libre_already_running(): @@ -1886,7 +2060,7 @@ if __name__ == '__main__': # 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) + os._exit(-1) refreshShapersUpdateOnly() else: # Refresh and/or set up queues diff --git a/src/rust/lqos_config/src/etc/etclqos_migration.rs b/src/rust/lqos_config/src/etc/etclqos_migration.rs index 51a60111..43769c50 100644 --- a/src/rust/lqos_config/src/etc/etclqos_migration.rs +++ b/src/rust/lqos_config/src/etc/etclqos_migration.rs @@ -184,8 +184,7 @@ impl EtcLqos { Err(e) => { error!("Unable to parse TOML from /etc/lqos.conf"); error!("Full error: {:?}", e); - panic!(); - //Err(EtcLqosError::CannotParseToml) + Err(EtcLqosError::CannotParseToml) } } } diff --git a/src/rust/lqos_config/src/etc/migration.rs b/src/rust/lqos_config/src/etc/migration.rs index 8ce9d7e8..f1797536 100644 --- a/src/rust/lqos_config/src/etc/migration.rs +++ b/src/rust/lqos_config/src/etc/migration.rs @@ -63,11 +63,25 @@ pub fn migrate_if_needed(config_location: &str) -> Result<(), MigrationError> { )); } } else { + // If the file otherwise looks like a v1.5 config but is missing the `version` key, + // add it in-place. This avoids triggering 1.4->1.5 migration (which requires + // legacy `ispConfig.py`) for modern installs with a versionless config. + info!("No version found in configuration file; attempting to treat it as version 1.5 without an explicit version key"); + let mut with_version = doc.clone(); + with_version.insert("version", toml_edit::value("1.5")); + if Config::load_from_string(&with_version.to_string()).is_ok() { + let backup_path = format!("{config_location}.backup_noversion"); + std::fs::copy(config_location, &backup_path).map_err(MigrationError::ReadError)?; + std::fs::write(config_location, with_version.to_string()).map_err(MigrationError::ReadError)?; + info!("Added missing `version = \"1.5\"` to {config_location}; backup written to {backup_path}"); + return Ok(()); + } + info!("No version found in configuration file, assuming 1.4x and migration is needed"); let new_config = migrate_14_to_15()?; // Back up the old configuration - std::fs::rename("/etc/lqos.conf", "/etc/lqos.conf.backup14") - .map_err(MigrationError::ReadError)?; + let backup_path = format!("{config_location}.backup14"); + std::fs::rename(config_location, &backup_path).map_err(MigrationError::ReadError)?; // Rename the old Python configuration let from = Path::new(new_config.lqos_directory.as_str()).join("ispConfig.py"); @@ -78,7 +92,7 @@ pub fn migrate_if_needed(config_location: &str) -> Result<(), MigrationError> { // Save the configuration let raw = toml::to_string_pretty(&new_config).map_err(|_| MigrationError::SerializeError)?; - std::fs::write("/etc/lqos.conf", raw).map_err(MigrationError::ReadError)?; + std::fs::write(config_location, raw).map_err(MigrationError::ReadError)?; } Ok(()) diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index fc29104a..c2824e5f 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -9,7 +9,6 @@ pub use etclqos_migration::*; use once_cell::sync::Lazy; use std::path::Path; use std::sync::Arc; -use std::sync::Once; use thiserror::Error; use tracing::{debug, error, info}; @@ -21,27 +20,19 @@ mod v15; pub use v15::{BridgeConfig, LazyQueueMode, SingleInterfaceConfig, StormguardConfig, Tunables}; static CONFIG: Lazy>>> = Lazy::new(|| ArcSwap::from_pointee(None)); -static INIT_ONCE: Once = Once::new(); /// Load the configuration from `/etc/lqos.conf`. pub fn load_config() -> Result, LibreQoSConfigError> { - // Ensure first load happens only once - INIT_ONCE.call_once(|| match actually_load_from_disk() { - Ok(config) => { - CONFIG.store(Some(config).into()); - } - Err(e) => { - error!("Initial config load failed: {:?}", e); - } - }); - // Fast path - just load the Arc - CONFIG - .load() - .as_ref() - .as_ref() - .cloned() - .ok_or(LibreQoSConfigError::FileNotFound) + if let Some(config) = CONFIG.load().as_ref().as_ref().cloned() { + return Ok(config); + } + + // Config wasn't cached (or a previous load failed). Attempt to load again so + // transient problems (e.g. config created after process start) can recover. + let config = actually_load_from_disk()?; + CONFIG.store(Some(config.clone()).into()); + Ok(config) } fn actually_load_from_disk() -> Result, LibreQoSConfigError> { @@ -55,28 +46,42 @@ fn actually_load_from_disk() -> Result, LibreQoSConfigError> { debug!("Loading configuration file {config_location}"); migrate_if_needed(&config_location).map_err(|e| { error!("Unable to migrate configuration: {:?}", e); - LibreQoSConfigError::FileNotFound + match &e { + migration::MigrationError::ReadError(io) + if io.kind() == std::io::ErrorKind::NotFound => + { + LibreQoSConfigError::NotFound { + path: config_location.clone(), + } + } + _ => LibreQoSConfigError::MigrationFailed { + path: config_location.clone(), + details: e.to_string(), + }, + } })?; - let file_result = std::fs::read_to_string(&config_location); - let Ok(raw) = file_result else { - if file_result.is_err() { - error!("Unable to open {config_location}"); + let raw = std::fs::read_to_string(&config_location).map_err(|e| { + error!("Unable to read {config_location}: {e:?}"); + if e.kind() == std::io::ErrorKind::NotFound { + LibreQoSConfigError::NotFound { + path: config_location.clone(), + } + } else { + LibreQoSConfigError::CannotRead { + path: config_location.clone(), + source: e, + } } - return Err(LibreQoSConfigError::FileNotFound); - }; + })?; - let config_result = Config::load_from_string(&raw); - let Ok(mut final_config) = config_result else { - if config_result.is_err() { - error!("Unable to parse /etc/lqos.conf"); - error!("Error: {:?}", config_result); + let mut final_config = Config::load_from_string(&raw).map_err(|e| { + error!("Unable to parse {config_location}"); + LibreQoSConfigError::ParseError { + path: config_location.clone(), + details: e, } - return Err(LibreQoSConfigError::ParseError(format!( - "{:?}", - config_result - ))); - }; + })?; // Check for environment variable overrides if let Ok(lqos_dir) = std::env::var("LQOS_DIRECTORY") { @@ -133,7 +138,10 @@ pub fn update_config(new_config: &Config) -> Result<(), LibreQoSConfigError> { })?; std::fs::write(config_path, serialized).map_err(|e| { error!("Unable to write new configuration: {e:?}"); - LibreQoSConfigError::CannotWrite + LibreQoSConfigError::CannotWrite { + path: config_path.display().to_string(), + source: e, + } })?; Ok(()) @@ -159,26 +167,26 @@ pub fn disable_xdp_bridge() -> Result<(), LibreQoSConfigError> { #[derive(Debug, Error)] pub enum LibreQoSConfigError { - #[error("Unable to read /etc/lqos.conf. See other errors for details.")] - CannotOpenEtcLqos, - #[error( - "Unable to locate (path to LibreQoS)/ispConfig.py. Check your path and that you have configured it." - )] - FileNotFound, - #[error("Unable to read the contents of ispConfig.py. Check file permissions.")] - CannotReadFile, - #[error("Unable to parse ispConfig.py")] - ParseError(String), + #[error("Unable to locate LibreQoS configuration at {path}. Set `LQOS_CONFIG` to override the path.")] + NotFound { path: String }, + #[error("Unable to read LibreQoS configuration at {path}: {source}")] + CannotRead { + path: String, + #[source] + source: std::io::Error, + }, + #[error("Configuration migration failed for {path}: {details}")] + MigrationFailed { path: String, details: String }, + #[error("Unable to parse LibreQoS configuration at {path}: {details}")] + ParseError { path: String, details: String }, #[error("Could not backup configuration")] CannotCopy, - #[error("Could not remove the previous configuration.")] - CannotRemove, - #[error("Could not open ispConfig.py for write")] - CannotOpenForWrite, - #[error("Unable to write to ispConfig.py")] - CannotWrite, - #[error("Unable to read IP")] - CannotReadIP, #[error("Unable to serialize config")] SerializeError, + #[error("Could not write LibreQoS configuration to {path}: {source}")] + CannotWrite { + path: String, + #[source] + source: std::io::Error, + }, } diff --git a/src/rust/lqos_config/src/network_json.rs b/src/rust/lqos_config/src/network_json.rs index 67b4eae6..a4577e2c 100644 --- a/src/rust/lqos_config/src/network_json.rs +++ b/src/rust/lqos_config/src/network_json.rs @@ -78,6 +78,7 @@ impl NetworkJson { pub fn load() -> Result { let mut nodes = vec![NetworkJsonNode { name: "Root".to_string(), + virtual_node: false, max_throughput: (0, 0), current_throughput: DownUpOrder::zeroed(), current_packets: DownUpOrder::zeroed(), @@ -345,6 +346,16 @@ fn recurse_node( } else { nodes.len() - 1 }; + let virtual_node = json + .get("virtual") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + || json + .get("type") + .and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("virtual")) + .unwrap_or(false); + let node = NetworkJsonNode { parents: parents.to_vec(), max_throughput: ( @@ -360,6 +371,7 @@ fn recurse_node( current_drops: DownUpOrder::zeroed(), current_marks: DownUpOrder::zeroed(), name: name.to_string(), + virtual_node, immediate_parent: Some(immediate_parent), rtt_buffer: RttBuffer::default(), node_type: json @@ -423,3 +435,99 @@ pub enum NetworkJsonError { #[error("network.json not found or does not exist")] FileNotFound, } + +#[cfg(test)] +mod test { + use super::*; + use serde_json::Value; + + fn parse_network_json_from_value(value: Value) -> NetworkJson { + let mut nodes = vec![NetworkJsonNode { + name: "Root".to_string(), + virtual_node: false, + max_throughput: (0, 0), + current_throughput: DownUpOrder::zeroed(), + current_packets: DownUpOrder::zeroed(), + current_tcp_packets: DownUpOrder::zeroed(), + current_udp_packets: DownUpOrder::zeroed(), + current_icmp_packets: DownUpOrder::zeroed(), + current_tcp_retransmits: DownUpOrder::zeroed(), + current_drops: DownUpOrder::zeroed(), + current_marks: DownUpOrder::zeroed(), + parents: Vec::new(), + immediate_parent: None, + rtt_buffer: RttBuffer::default(), + node_type: None, + heatmap: None, + qoq_heatmap: None, + }]; + + let parents = vec![0]; + if let Value::Object(map) = &value { + for (key, value) in map.iter() { + if let Value::Object(inner_map) = value { + recurse_node(&mut nodes, key, inner_map, &parents, 0); + } + } + } + + NetworkJson { nodes } + } + + #[test] + fn parses_virtual_and_legacy_virtual_type() { + let raw = serde_json::json!({ + "Site_1": { + "downloadBandwidthMbps": 1000, + "uploadBandwidthMbps": 1000, + "type": "Site", + "children": { + "VirtNode": { + "downloadBandwidthMbps": 500, + "uploadBandwidthMbps": 500, + "type": "AP", + "virtual": true, + "children": {} + }, + "LegacyVirt": { + "downloadBandwidthMbps": 400, + "uploadBandwidthMbps": 400, + "type": "virtual", + "children": {} + }, + "RealNode": { + "downloadBandwidthMbps": 300, + "uploadBandwidthMbps": 300, + "children": {} + } + } + } + }); + + let parsed = parse_network_json_from_value(raw); + + let virt = parsed + .nodes + .iter() + .find(|n| n.name == "VirtNode") + .expect("VirtNode must be present"); + assert!(virt.virtual_node); + assert!(virt.clone_to_transit().is_virtual); + + let legacy = parsed + .nodes + .iter() + .find(|n| n.name == "LegacyVirt") + .expect("LegacyVirt must be present"); + assert!(legacy.virtual_node); + assert!(legacy.clone_to_transit().is_virtual); + + let real = parsed + .nodes + .iter() + .find(|n| n.name == "RealNode") + .expect("RealNode must be present"); + assert!(!real.virtual_node); + assert!(!real.clone_to_transit().is_virtual); + } +} 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 2e844404..61900ffd 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 @@ -13,6 +13,10 @@ pub struct NetworkJsonNode { /// The node name, as it appears in `network.json` pub name: String, + /// If true, this node is "virtual" (logical only): it exists for monitoring/aggregation + /// but should be omitted from the physical HTB tree. + pub virtual_node: bool, + /// The maximum throughput allowed per `network.json` for this node pub max_throughput: (u32, u32), // In mbps @@ -94,6 +98,7 @@ impl NetworkJsonNode { NetworkJsonTransport { name: self.name.clone(), + is_virtual: self.virtual_node, max_throughput: self.max_throughput, current_throughput: ( self.current_throughput.get_down(), 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 96726801..4281f971 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 @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; pub struct NetworkJsonTransport { /// Display name pub name: String, + /// True if this node is a "virtual" (logical-only) node. + #[serde(rename = "virtual", default)] + pub is_virtual: bool, /// Max throughput for node (not clamped) pub max_throughput: (u32, u32), /// Current node throughput diff --git a/src/rust/lqos_overrides/src/main.rs b/src/rust/lqos_overrides/src/main.rs index ae01baa3..4f6b1b54 100644 --- a/src/rust/lqos_overrides/src/main.rs +++ b/src/rust/lqos_overrides/src/main.rs @@ -72,6 +72,10 @@ enum AdjustmentsCommand { enum NetworkAdjustmentsCommand { /// Add site speed adjustment AddSiteSpeed(AddSiteSpeedArgs), + /// Set whether a node is virtual (logical-only) in network.json + SetVirtual { node_name: String, #[arg(value_name = "VIRTUAL")] virtual_node: bool }, + /// Remove any virtual-node override for a specific node name + DeleteVirtual { node_name: String }, /// Remove a network adjustment by index (see list) DeleteIndex { #[arg(long)] index: usize }, /// List current network adjustments @@ -366,6 +370,20 @@ fn main() -> Result<()> { overrides.save()?; println!("Added site speed adjustment; overrides saved."); } + NetworkAdjustmentsCommand::SetVirtual { node_name, virtual_node } => { + overrides.set_network_node_virtual(node_name, virtual_node); + overrides.save()?; + println!("Set node virtual flag; overrides saved."); + } + NetworkAdjustmentsCommand::DeleteVirtual { node_name } => { + let removed = overrides.remove_network_node_virtual_by_name_count(&node_name); + if removed > 0 { + overrides.save()?; + println!("Removed {removed} virtual override(s) for node '{node_name}'; overrides saved."); + } else { + println!("No virtual override found for node '{node_name}'."); + } + } NetworkAdjustmentsCommand::DeleteIndex { index } => { let ok = overrides.remove_network_adjustment_by_index(index); if ok { diff --git a/src/rust/lqos_overrides/src/overrides_file.rs b/src/rust/lqos_overrides/src/overrides_file.rs index 564ca077..eaa18bc6 100644 --- a/src/rust/lqos_overrides/src/overrides_file.rs +++ b/src/rust/lqos_overrides/src/overrides_file.rs @@ -38,6 +38,11 @@ pub enum NetworkAdjustment { download_bandwidth_mbps: Option, upload_bandwidth_mbps: Option, }, + SetNodeVirtual { + node_name: String, + #[serde(rename = "virtual")] + virtual_node: bool, + }, } #[derive(Serialize, Deserialize, Clone, Debug, Default)] @@ -173,6 +178,28 @@ impl OverrideFile { self.network_adjustments.push(adj); } + /// Add or replace a virtual-node flag for a specific network.json node name. + pub fn set_network_node_virtual(&mut self, node_name: String, virtual_node: bool) { + self.network_adjustments.retain(|adj| match adj { + NetworkAdjustment::SetNodeVirtual { node_name: n, .. } => n != &node_name, + _ => true, + }); + self.network_adjustments.push(NetworkAdjustment::SetNodeVirtual { + node_name, + virtual_node, + }); + } + + /// Remove any virtual-node overrides for `node_name`. Returns number removed. + pub fn remove_network_node_virtual_by_name_count(&mut self, node_name: &str) -> usize { + let before = self.network_adjustments.len(); + self.network_adjustments.retain(|adj| match adj { + NetworkAdjustment::SetNodeVirtual { node_name: n, .. } => n != node_name, + _ => true, + }); + before.saturating_sub(self.network_adjustments.len()) + } + /// Remove a network adjustment by index. Returns true if removed. pub fn remove_network_adjustment_by_index(&mut self, index: usize) -> bool { if index < self.network_adjustments.len() { diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index 21351934..954b13d5 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -983,6 +983,11 @@ fn overrides_network_adjustments(py: Python<'_>) -> PyResult> { 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); diff --git a/src/rust/lqosd/src/node_manager/js_build/esbuild.sh b/src/rust/lqosd/src/node_manager/js_build/esbuild.sh index 5c81bbd2..272870df 100755 --- a/src/rust/lqosd/src/node_manager/js_build/esbuild.sh +++ b/src/rust/lqosd/src/node_manager/js_build/esbuild.sh @@ -9,7 +9,7 @@ set -e chmod a+x /tmp/esbuild/esbuild ESBUILD_BIN="/tmp/esbuild/esbuild" #fi -scripts=( index.js template.js login.js first-run.js shaped-devices.js tree.js help.js unknown-ips.js configuration.js circuit.js flow_map.js all_tree_sankey.js asn_explorer.js lts_trial.js config_general.js config_tuning.js config_queues.js config_stormguard.js config_lts.js config_iprange.js config_flows.js config_integration.js config_splynx.js config_netzur.js config_uisp.js config_powercode.js config_sonar.js config_interface.js config_network.js config_devices.js config_users.js config_wispgate.js chatbot.js cpu_weights.js executive_worst_sites.js executive_oversubscribed_sites.js executive_sites_due_upgrade.js executive_circuits_due_upgrade.js executive_top_asns.js executive_heatmap_rtt.js executive_heatmap_retransmit.js executive_heatmap_download.js executive_heatmap_upload.js) +scripts=( index.js template.js login.js first-run.js shaped-devices.js tree.js help.js unknown-ips.js configuration.js circuit.js flow_map.js all_tree_sankey.js asn_explorer.js lts_trial.js config_general.js config_tuning.js config_queues.js config_stormguard.js config_lts.js config_iprange.js config_flows.js config_integration.js config_splynx.js config_netzur.js config_uisp.js config_powercode.js config_sonar.js config_interface.js config_network.js config_devices.js config_users.js config_wispgate.js chatbot.js cpu_weights.js cpu_tree.js executive_worst_sites.js executive_oversubscribed_sites.js executive_sites_due_upgrade.js executive_circuits_due_upgrade.js executive_top_asns.js executive_heatmap_rtt.js executive_heatmap_retransmit.js executive_heatmap_download.js executive_heatmap_upload.js) for script in "${scripts[@]}" do echo "Building {$script}" 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 d67a4cdf..91a4371c 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 @@ -13,15 +13,19 @@ function renderNetworkNode(level, depth) { html += `
`; for (const [key, value] of Object.entries(level)) { + const isVirtual = value && value.virtual === true; // Node header with actions html += `
`; - html += `
${key}
`; + html += `
${key}${isVirtual ? ' Virtual' : ''}
`; html += `
`; if (depth > 0) { html += ``; } + html += ``; html += ``; @@ -115,6 +119,23 @@ function nodeSpeedChange(nodeId, direction) { renderNetwork(); } +function toggleVirtualNode(nodeId) { + function iterate(tree) { + for (const [key, value] of Object.entries(tree)) { + if (key === nodeId) { + value.virtual = !(value && value.virtual === true); + } + + if (value.children != null) { + iterate(value.children); + } + } + } + + iterate(network_json); + renderNetwork(); +} + function deleteNode(nodeId) { if (!confirm(`Are you sure you want to delete ${nodeId} and all its children?`)) { return; @@ -248,6 +269,7 @@ function start() { window.renameNode = renameNode; window.deleteNode = deleteNode; window.nodeSpeedChange = nodeSpeedChange; + window.toggleVirtualNode = toggleVirtualNode; // Add save button handler // Add network save button handler 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 ffbfb074..d6809af4 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 @@ -464,6 +464,7 @@ function saveConfig() { function iterateNetJson(level, depth) { let html = "
"; for (const [key, value] of Object.entries(level)) { + const isVirtual = value && value.virtual === true; html += "
"; html += "" + key + ""; if (depth > 0) { @@ -474,6 +475,8 @@ function iterateNetJson(level, depth) { html += "
"; html += "Download: " + value.downloadBandwidthMbps + " Mbps
"; html += "Upload: " + value.uploadBandwidthMbps + " Mbps
"; + html += "Virtual: " + (isVirtual ? " Yes" : "No") + + "
"; let num_children = 0; for (let i=0; i { + const wrapped = (msg) => { + wsClient.off(eventName, wrapped); + handler(msg); + }; + wsClient.on(eventName, wrapped); +}; + +function setStatus(html, isError = false) { + const status = document.getElementById("cpuTreeStatus"); + if (!status) return; + status.className = isError ? "text-danger small mb-2" : "text-muted small mb-2"; + status.innerHTML = html; +} + +function requestCpuSiteTree() { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(null), 10000); + listenOnce("CpuAffinitySiteTree", (msg) => { + clearTimeout(timeout); + resolve(msg && msg.data ? msg.data : null); + }); + wsClient.send({ CpuAffinitySiteTree: {} }); + }); +} + +function buildTreeOption(root) { + return { + tooltip: { + trigger: "item", + triggerOn: "mousemove", + }, + series: [ + { + type: "tree", + data: [root], + top: "2%", + left: "2%", + bottom: "2%", + right: "18%", + roam: true, + symbolSize: 10, + edgeShape: "polyline", + expandAndCollapse: true, + initialTreeDepth: -1, + lineStyle: { + width: 1.25, + curveness: 0.5, + }, + label: { + position: "left", + verticalAlign: "middle", + align: "right", + fontSize: 12, + }, + leaves: { + label: { + position: "right", + verticalAlign: "middle", + align: "left", + fontSize: 12, + }, + }, + emphasis: { + focus: "descendant", + }, + animationDuration: 550, + animationDurationUpdate: 750, + }, + ], + }; +} + +function renderTree(root) { + if (!root) { + setStatus("No tree data found. Run LibreQoS to generate queuingStructure.json.", true); + return; + } + if (!treeGraph) { + treeGraph = new DashboardGraph("cpuTreeChart"); + } + treeGraph.option = buildTreeOption(root); + try { + treeGraph.chart.setOption(treeGraph.option, true); + treeGraph.chart.hideLoading(); + } catch (e) { + setStatus("Failed to render chart (echarts unavailable).", true); + } +} + +async function refresh() { + setStatus(' Loading...'); + const root = await requestCpuSiteTree(); + if (!root || !root.children || root.children.length === 0) { + setStatus("No site placement data found. Run LibreQoS to generate queuingStructure.json.", true); + const chartDom = document.getElementById("cpuTreeChart"); + if (chartDom) clearDiv(chartDom); + return; + } + setStatus("Pan/zoom with mouse. Click nodes to expand/collapse."); + renderTree(root); +} + +// Wire up controls +const btnRefresh = document.getElementById("btnRefresh"); +if (btnRefresh) { + btnRefresh.onclick = () => refresh(); +} + +window.addEventListener("resize", () => { + if (treeGraph && treeGraph.chart) { + try { + treeGraph.chart.resize(); + } catch (_) {} + } +}); + +refresh(); 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 81e99dbf..99d10e32 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 @@ -239,6 +239,14 @@ function buildRow(i, depth=0) { spacer.style.visibility = "hidden"; 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); + } let link = document.createElement("a"); link.href = "/tree.html?parent=" + nodeId + "&upParent=" + parent; link.classList.add("redactable"); diff --git a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs index be7ce663..d3101944 100644 --- a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs @@ -40,6 +40,13 @@ pub struct CpuAffinityCircuitsPage { pub items: Vec, } +#[derive(Serialize, Clone, Debug)] +pub struct CpuAffinitySiteTreeNode { + pub name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + #[derive(Debug, Default, Clone)] struct CircuitRecord { cpu_down: Option, @@ -248,6 +255,154 @@ fn load_all_circuits() -> Vec { circuits } +fn trailing_u32(s: &str) -> Option { + let digits: String = s + .chars() + .rev() + .take_while(|c| c.is_ascii_digit()) + .collect::>() + .into_iter() + .rev() + .collect(); + if digits.is_empty() { + None + } else { + digits.parse::().ok() + } +} + +fn is_network_node(node: &serde_json::Map) -> bool { + // Prefer explicit type checks where present + if node + .get("type") + .and_then(|v| v.as_str()) + .is_some_and(|t| t.eq_ignore_ascii_case("site") || t.eq_ignore_ascii_case("ap")) + { + return true; + } + + // Some production `network.json` files omit `type`. In `queuingStructure.json`, + // network nodes always include bandwidth keys (circuits do not). + node.contains_key("downloadBandwidthMbps") + || node.contains_key("uploadBandwidthMbps") + || node.contains_key("children") +} + +fn build_site_tree(name: &str, node: &serde_json::Map) -> CpuAffinitySiteTreeNode { + let mut children = Vec::new(); + if let Some(Value::Object(child_map)) = node.get("children") { + for (child_name, child_value) in child_map.iter() { + if let Value::Object(child_node) = child_value { + if is_network_node(child_node) { + children.push(build_site_tree(child_name, child_node)); + } + } + } + } + children.sort_by(|a, b| a.name.cmp(&b.name)); + CpuAffinitySiteTreeNode { + name: name.to_string(), + children, + } +} + +pub fn cpu_affinity_site_tree_data() -> Option { + let path = queuing_structure_path()?; + let raw = std::fs::read_to_string(path).ok()?; + let json: Value = serde_json::from_str(&raw).ok()?; + let net = json.get("Network")?.as_object()?; + + let looks_binned = net.keys().any(|k| k.starts_with("CpueQueue")); + + let mut cpus: Vec<(Option, String, CpuAffinitySiteTreeNode)> = Vec::new(); + + if looks_binned { + for (cpu_key, cpu_value) in net.iter() { + let Some(cpu_node) = cpu_value.as_object() else { + continue; + }; + let mut site_children: Vec = Vec::new(); + if let Some(Value::Object(child_map)) = cpu_node.get("children") { + for (child_name, child_value) in child_map.iter() { + if let Value::Object(child_node) = child_value { + if is_network_node(child_node) { + site_children.push(build_site_tree(child_name, child_node)); + } + } + } + } + site_children.sort_by(|a, b| a.name.cmp(&b.name)); + + let cpu_idx = trailing_u32(cpu_key); + let cpu_label = cpu_idx + .map(|n| format!("CPU {n}")) + .unwrap_or_else(|| cpu_key.to_string()); + + cpus.push(( + cpu_idx, + cpu_key.to_string(), + CpuAffinitySiteTreeNode { + name: cpu_label, + children: site_children, + }, + )); + } + } else { + // Non-binned network: nodes are top-level and each contains cpuNum/classMajor. + let mut by_cpu: HashMap, Vec> = HashMap::new(); + for (child_name, child_value) in net.iter() { + let Some(child_node) = child_value.as_object() else { + continue; + }; + if !is_network_node(child_node) { + continue; + } + let cpu_idx = child_node + .get("cpuNum") + .and_then(|v| v.as_str()) + .and_then(parse_hex_u32) + .or_else(|| { + child_node + .get("classMajor") + .and_then(|v| v.as_str()) + .and_then(parse_hex_u32) + .map(|v| v.saturating_sub(1)) + }); + by_cpu + .entry(cpu_idx) + .or_default() + .push(build_site_tree(child_name, child_node)); + } + + for (cpu_idx, mut nodes) in by_cpu.into_iter() { + nodes.sort_by(|a, b| a.name.cmp(&b.name)); + let cpu_label = cpu_idx + .map(|n| format!("CPU {n}")) + .unwrap_or_else(|| "Unknown CPU".to_string()); + cpus.push(( + cpu_idx, + cpu_label.clone(), + CpuAffinitySiteTreeNode { + name: cpu_label, + children: nodes, + }, + )); + } + } + + cpus.sort_by(|a, b| match (a.0, b.0) { + (Some(x), Some(y)) => x.cmp(&y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.1.cmp(&b.1), + }); + + Some(CpuAffinitySiteTreeNode { + name: "CPUs".to_string(), + children: cpus.into_iter().map(|(_, _, n)| n).collect(), + }) +} + pub fn cpu_affinity_summary_data() -> Vec { let circuits = load_all_circuits(); let mut down: HashMap = HashMap::new(); diff --git a/src/rust/lqosd/src/node_manager/static2/cpu_tree.html b/src/rust/lqosd/src/node_manager/static2/cpu_tree.html new file mode 100644 index 00000000..66e6aef6 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/cpu_tree.html @@ -0,0 +1,32 @@ +
+
+
+
+
CPU Site Tree
+

Physical node placement per CPU/queue from the active TC plan (queuingStructure.json).

+
+
+
+ +
+ +
+
+
+
+ +
+ Loading... +
+ +
+
+
+ + diff --git a/src/rust/lqosd/src/node_manager/static2/cpu_weights.html b/src/rust/lqosd/src/node_manager/static2/cpu_weights.html index b1ef93e7..3d3c7ea2 100644 --- a/src/rust/lqosd/src/node_manager/static2/cpu_weights.html +++ b/src/rust/lqosd/src/node_manager/static2/cpu_weights.html @@ -14,6 +14,11 @@
Binpacking: …
+