mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
changes
This commit is contained in:
1 parent
94ab976a82
commit
691ff7dda4
11 files changed
+309
-17
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user