This commit is contained in:
Name committed 2026-04-29 13:58:19 -06:00
1 parent 94ab976a82
commit 691ff7dda4
11 files changed
+309 -17

No files matched your search

+2
View File
@@ -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.
+3
View File
@@ -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"
+28 -3
View File
@@ -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
@@ -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()) {
@@ -39,9 +39,9 @@
</div>
<div class="mb-3">
<label for="uispSite" class="form-label">UISP Site</label>
<label for="uispSite" class="form-label" id="uispSiteLabel">Root UISP Site</label>
<input type="text" class="form-control" id="uispSite">
<div class="form-text">Optional site identifier in UISP.</div>
<div class="form-text" id="uispSiteHelp">Required only for full topology mode. Leave blank in other modes to let LibreQoS choose the import root.</div>
</div>
<div class="mb-3 form-check">
+2 -2
View File
@@ -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")]
@@ -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]
@@ -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
@@ -40,6 +40,8 @@ use std::time::Instant;
use tracing::{debug, info, warn};
type GraphType = petgraph::Graph<GraphMapping, LinkMapping, Directed>;
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<ResolvedRootSite, UispIntegrationError> {
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::<Vec<_>>()
.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::<Vec<_>>();
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<ResolvedRootSite, UispIntegrationError> {
let mut site_names = uisp_data
.sites_raw
.iter()
.filter(|site| !site.is_client_site())
.map(|site| site.name_or_blank())
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<Config>,
ip_ranges: IpRanges,
require_configured_root_site: bool,
) -> Result<ImportedTopologyBundle, UispIntegrationError> {
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());
@@ -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:?}");
+33
View File
@@ -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(