mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 22:07:40 -05:00
13 files changed
+488
-97
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"
|
||||
|
||||
+32
-80
@@ -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'))
|
||||
@@ -664,60 +690,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
|
||||
@@ -1807,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
|
||||
@@ -2029,9 +2000,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 +2009,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 +2078,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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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