Merge pull request #1135 from LibreQoE/fix_eth_1

Show infrastructure Ethernet caps and remove the lone Tree Overview tab
This commit is contained in:
Robert Chacón
2026-07-26 12:33:34 -06:00
committed by GitHub
19 changed files with 488 additions and 71 deletions
+3 -2
View File
@@ -105,9 +105,10 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta
- `Flow Sankey` enfatiza los flujos recientes más activos en lugar de todos los flujos retenidos más antiguos.
### Ethernet Caps
- La página de revisión Ethernet es una tabla ligera para operadores con los circuitos reducidos automáticamente porque la velocidad Ethernet detectada quedó por debajo del plan solicitado.
- La página de revisión Ethernet es una tabla ligera para operadores con los circuitos y nodos de topología reducidos automáticamente porque la velocidad Ethernet detectada quedó por debajo de la velocidad solicitada.
- Intencionalmente no aparece en la navegación principal; los operadores llegan a ella haciendo clic en las insignias de advertencia Ethernet de la página de circuito o de la tabla de circuitos adjuntos del árbol.
- La página soporta búsqueda, filtro por tier (`10M`, `100M`, `1G+`) y paginación sobre los circuitos auto-capped.
- Las filas de circuito abren la página Circuit, y las filas de nodo de topología abren el nodo correspondiente en el árbol.
- La página soporta búsqueda, filtro por tier (`10M`, `100M`, `1G+`) y paginación sobre los objetivos con reducción automática.
### Árbol/ponderación de CPU
- Muestra distribución de colas/circuitos por núcleo de CPU.
+3 -2
View File
@@ -156,9 +156,10 @@ Practical meaning:
- `Flow Sankey` emphasizes the hottest recent flows rather than every older retained flow.
### Ethernet Caps
- The Ethernet review page is a lightweight operator table of circuits automatically down-rated because detected Ethernet speed was below the requested plan.
- The Ethernet review page is a lightweight operator table of circuits and topology nodes automatically down-rated because detected Ethernet speed was below the requested rate.
- It is intentionally not in the main navigation; operators reach it by clicking Ethernet warning badges on the Circuit page or Tree attached-circuits table.
- The page supports search, tier filtering (`10M`, `100M`, `1G+`), and paging across auto-capped circuits.
- Circuit rows open the Circuit page, while topology-node rows open the matching Tree node.
- The page supports search, tier filtering (`10M`, `100M`, `1G+`), and paging across auto-capped targets.
### CPU Tree / CPU Weights
- Shows queue/circuit distribution by CPU core.
@@ -17,12 +17,35 @@ pub struct CircuitEthernetMetadataFile {
pub circuits: Vec<CircuitEthernetMetadata>,
}
/// Describes a detected negotiated Ethernet speed and any automatic shaping cap applied to a circuit.
/// Identifies the topology object whose configured rate was reduced by an Ethernet limit.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum EthernetCapTargetKind {
/// A subscriber circuit represented by `ShapedDevices.csv`.
#[default]
Circuit,
/// A topology node such as an access point or infrastructure device.
Node,
}
/// Describes a detected negotiated Ethernet speed and any automatic rate cap applied to a circuit or topology node.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct CircuitEthernetMetadata {
/// Circuit identifier as emitted to `ShapedDevices.csv`.
/// The kind of topology object affected by the Ethernet rate cap.
#[serde(default)]
pub target_kind: EthernetCapTargetKind,
/// Stable target identity. Empty legacy values fall back to `circuit_id`.
#[serde(default)]
pub target_id: String,
/// Human-facing target name. Empty legacy values fall back to `circuit_name`.
#[serde(default)]
pub target_name: String,
/// Circuit identifier as emitted to `ShapedDevices.csv`, retained for compatibility.
///
/// For topology-node advisories, this matches `target_id`.
pub circuit_id: String,
/// Human-readable circuit name for UI display.
/// Human-readable circuit name for UI display, retained for compatibility.
///
/// For topology-node advisories, this matches `target_name`.
pub circuit_name: String,
/// Device IDs considered when determining the circuit Ethernet limit.
pub device_ids: Vec<String>,
@@ -47,3 +70,73 @@ pub struct CircuitEthernetMetadata {
/// Interface name that reported the limiting Ethernet speed when known.
pub limiting_interface_name: Option<String>,
}
impl CircuitEthernetMetadata {
/// Creates empty advisory metadata for a circuit or topology-node target.
///
/// The legacy circuit fields mirror the target identity so existing readers can safely
/// consume topology-node advisories.
pub fn for_target(
target_kind: EthernetCapTargetKind,
target_id: String,
target_name: String,
) -> Self {
Self {
target_kind,
circuit_id: target_id.clone(),
circuit_name: target_name.clone(),
target_id,
target_name,
..Default::default()
}
}
/// Returns the stable identity of the capped circuit or topology node.
pub fn target_id_or_circuit_id(&self) -> &str {
if self.target_id.trim().is_empty() {
&self.circuit_id
} else {
&self.target_id
}
}
/// Returns the display name of the capped circuit or topology node.
pub fn target_name_or_circuit_name(&self) -> &str {
if self.target_name.trim().is_empty() {
&self.circuit_name
} else {
&self.target_name
}
}
}
#[cfg(test)]
mod tests {
use super::{CircuitEthernetMetadata, EthernetCapTargetKind};
#[test]
fn legacy_circuit_metadata_uses_circuit_identity_as_the_target() {
let metadata = CircuitEthernetMetadata {
circuit_id: "circuit-1".to_string(),
circuit_name: "Circuit One".to_string(),
..Default::default()
};
assert_eq!(metadata.target_kind, EthernetCapTargetKind::Circuit);
assert_eq!(metadata.target_id_or_circuit_id(), "circuit-1");
assert_eq!(metadata.target_name_or_circuit_name(), "Circuit One");
}
#[test]
fn target_constructor_keeps_legacy_identity_in_sync() {
let metadata = CircuitEthernetMetadata::for_target(
EthernetCapTargetKind::Node,
"uisp:device:ap-1".to_string(),
"AP One".to_string(),
);
assert_eq!(metadata.target_kind, EthernetCapTargetKind::Node);
assert_eq!(metadata.circuit_id, metadata.target_id);
assert_eq!(metadata.circuit_name, metadata.target_name);
}
}
@@ -162,8 +162,6 @@ pub fn apply_ethernet_rate_cap<'a>(
download_max: applied_download_max,
upload_max: applied_upload_max,
advisory: Some(CircuitEthernetMetadata {
circuit_id: circuit_id.to_string(),
circuit_name: circuit_name.to_string(),
device_ids: unique_device_ids(&observations),
source: limiting_observation.source.clone(),
negotiated_ethernet_mbps: limiting_observation.negotiated_ethernet_mbps,
@@ -175,6 +173,11 @@ pub fn apply_ethernet_rate_cap<'a>(
limiting_device_id: limiting_observation.device_id.clone(),
limiting_device_name: limiting_observation.device_name.clone(),
limiting_interface_name: limiting_observation.interface_name.clone(),
..CircuitEthernetMetadata::for_target(
Default::default(),
circuit_id.to_string(),
circuit_name.to_string(),
)
}),
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ pub use circuit_anchors::{
};
pub use circuit_ethernet_metadata::{
CIRCUIT_ETHERNET_METADATA_FILENAME, CircuitEthernetMetadata, CircuitEthernetMetadataFile,
circuit_ethernet_metadata_path,
EthernetCapTargetKind, circuit_ethernet_metadata_path,
};
pub use cpu_topology::{
CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus,
@@ -53,18 +53,37 @@ function buildTierBadge(row) {
return badge;
}
function targetKindLabel(row) {
return row?.target_kind === "Node" ? "Node" : "Circuit";
}
function targetHref(row) {
if (row?.target_kind === "Node") {
return `/tree.html?nodeId=${encodeURIComponent(row.target_id)}`;
}
return `/circuit.html?id=${encodeURIComponent(row.target_id)}`;
}
function setRequestError(message = "") {
const error = document.getElementById("ethernetCapsError");
if (!error) return;
error.hidden = !message;
error.textContent = message;
}
function tableRow(row) {
const tr = document.createElement("tr");
tr.classList.add("small");
const circuitCell = document.createElement("td");
const link = document.createElement("a");
link.href = `/circuit.html?id=${encodeURIComponent(row.circuit_id)}`;
link.href = targetHref(row);
link.classList.add("redactable");
link.textContent = row.circuit_name || row.circuit_id;
link.textContent = row.target_name || row.target_id;
circuitCell.appendChild(link);
tr.appendChild(circuitCell);
tr.appendChild(simpleRow(targetKindLabel(row)));
tr.appendChild(simpleRow(row.parent_node || "-", true));
const ethernetCell = document.createElement("td");
@@ -87,7 +106,7 @@ function renderTable(rows) {
if (!Array.isArray(rows) || rows.length === 0) {
const empty = document.createElement("div");
empty.className = "text-muted small";
empty.textContent = "No Ethernet-limited circuits matched this filter.";
empty.textContent = "No Ethernet-limited circuits or nodes matched this filter.";
target.appendChild(empty);
return;
}
@@ -98,7 +117,8 @@ function renderTable(rows) {
table.classList.add("lqos-table", "lqos-table-tight");
const thead = document.createElement("thead");
thead.appendChild(theading("Circuit"));
thead.appendChild(theading("Target"));
thead.appendChild(theading("Type"));
thead.appendChild(theading("Parent"));
thead.appendChild(theading("Ethernet"));
thead.appendChild(theading("Requested"));
@@ -114,7 +134,7 @@ function renderTable(rows) {
target.appendChild(wrap);
}
function updateSummary(query, rows) {
function updateSummary(query, rows, requestFailed = false) {
const summary = document.getElementById("ethernetCapsSummary");
const pager = document.getElementById("ethernetCapsPager");
const prev = document.getElementById("ethernetCapsPrev");
@@ -126,9 +146,17 @@ function updateSummary(query, rows) {
const start = totalRows === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const end = totalRows === 0 ? 0 : start + Math.max(0, rows.length - 1);
if (requestFailed) {
summary.textContent = "Ethernet cap data unavailable";
pager.textContent = "Retry the page after the connection is restored.";
prev.disabled = true;
next.disabled = true;
return;
}
summary.textContent = totalRows === 0
? "No active Ethernet caps"
: `${totalRows} Ethernet-limited circuits`;
: `${totalRows} Ethernet-limited targets`;
pager.textContent = totalRows === 0
? "Page 1 / 1"
: `Showing ${start}${end} of ${totalRows} • Page ${currentPage} / ${totalPages}`;
@@ -153,12 +181,14 @@ async function requestPage() {
});
const data = msg?.data || { rows: [], total_rows: 0, query };
totalRows = Number.isFinite(Number(data.total_rows)) ? Number(data.total_rows) : 0;
setRequestError();
renderTable(data.rows || []);
updateSummary(data.query, data.rows || []);
} catch (_error) {
totalRows = 0;
setRequestError("Ethernet cap data could not be loaded. Check the node-manager connection and retry.");
renderTable([]);
updateSummary({ page: 0 }, []);
updateSummary({ page: 0 }, [], true);
}
}
@@ -35,6 +35,7 @@ import {
stormguardNodeContext,
summarizeStormguardHistory,
} from "./tree_stormguard.mjs";
import {setTreeDetailTabsVisibility} from "./tree_detail_tabs.mjs";
var tree = null;
var parent = 0;
@@ -335,19 +336,9 @@ function ensureTreeStormguardGraph() {
}
function renderTreeStormguard() {
const tabItem = document.getElementById("treeStormguardTabItem");
const pane = document.getElementById("treeStormguardPane");
const visible = shouldShowStormguardTab(stormguardRuntime);
const focusWillBeHidden = !visible
&& (tabItem?.contains(document.activeElement) || pane?.contains(document.activeElement));
tabItem?.classList.toggle("d-none", !visible);
setTreeDetailTabsVisibility(document, window.bootstrap, visible);
if (!visible) {
const stormguardTab = document.getElementById("tree-stormguard-tab");
const overviewTab = document.getElementById("tree-overview-tab");
if (stormguardTab?.classList.contains("active")) {
window.bootstrap?.Tab.getOrCreateInstance(overviewTab)?.show();
}
if (focusWillBeHidden) overviewTab?.focus();
return;
}
@@ -0,0 +1,25 @@
/**
* Shows StormGuard detail tabs only when StormGuard is available and prevents focus from
* remaining in a tab strip that has just been hidden.
*/
export function setTreeDetailTabsVisibility(document, bootstrap, visible) {
const tabsContainer = document.getElementById("treeDetailTabsContainer");
const stormguardTabItem = document.getElementById("treeStormguardTabItem");
const stormguardTab = document.getElementById("tree-stormguard-tab");
const overviewTab = document.getElementById("tree-overview-tab");
const overviewPane = document.getElementById("treeOverviewPane");
const stormguardPane = document.getElementById("treeStormguardPane");
const focusWillBeHidden = !visible
&& (tabsContainer?.contains(document.activeElement) || stormguardPane?.contains(document.activeElement));
stormguardTabItem?.classList.toggle("d-none", !visible);
if (!visible) {
if (stormguardTab?.classList.contains("active")) {
bootstrap?.Tab.getOrCreateInstance(overviewTab)?.show();
}
tabsContainer?.classList.add("d-none");
if (focusWillBeHidden) overviewPane?.focus();
return;
}
tabsContainer?.classList.remove("d-none");
}
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import test from "node:test";
import {setTreeDetailTabsVisibility} from "./tree_detail_tabs.mjs";
function fakeElement() {
const classes = new Set();
const element = {
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
contains: (name) => classes.has(name),
toggle: (name, force) => {
if (force) classes.add(name);
else classes.delete(name);
},
},
contains: (candidate) => candidate?.parent === element,
focusCount: 0,
focus() {
this.focusCount += 1;
},
};
return element;
}
function treeDocument(activeElement, stormguardTabActive = false) {
const tabsContainer = fakeElement();
const stormguardTabItem = fakeElement();
const stormguardTab = fakeElement();
const overviewTab = fakeElement();
const overviewPane = fakeElement();
const stormguardPane = fakeElement();
if (stormguardTabActive) stormguardTab.classList.add("active");
const elements = {
treeDetailTabsContainer: tabsContainer,
treeStormguardTabItem: stormguardTabItem,
"tree-stormguard-tab": stormguardTab,
"tree-overview-tab": overviewTab,
treeOverviewPane: overviewPane,
treeStormguardPane: stormguardPane,
};
return {
document: {
activeElement,
getElementById: (id) => elements[id] || null,
},
elements,
};
}
test("hiding StormGuard tabs moves tab focus to the visible overview pane", () => {
const focusedTab = {parent: null};
const {document, elements} = treeDocument(focusedTab, true);
focusedTab.parent = elements.treeDetailTabsContainer;
let overviewWasActivated = false;
setTreeDetailTabsVisibility(document, {
Tab: {getOrCreateInstance: () => ({show: () => { overviewWasActivated = true; }})},
}, false);
assert.equal(elements.treeDetailTabsContainer.classList.contains("d-none"), true);
assert.equal(elements.treeStormguardTabItem.classList.contains("d-none"), true);
assert.equal(elements.treeOverviewPane.focusCount, 1);
assert.equal(overviewWasActivated, true);
});
test("hiding StormGuard tabs does not steal focus from visible content", () => {
const {document, elements} = treeDocument({parent: null});
setTreeDetailTabsVisibility(document, null, false);
assert.equal(elements.treeDetailTabsContainer.classList.contains("d-none"), true);
assert.equal(elements.treeOverviewPane.focusCount, 0);
});
test("hiding StormGuard tabs moves StormGuard pane focus to the overview pane", () => {
const focusedContent = {parent: null};
const {document, elements} = treeDocument(focusedContent);
focusedContent.parent = elements.treeStormguardPane;
setTreeDetailTabsVisibility(document, null, false);
assert.equal(elements.treeOverviewPane.focusCount, 1);
});
test("showing StormGuard reveals the full tab strip", () => {
const {document, elements} = treeDocument({parent: null});
elements.treeDetailTabsContainer.classList.add("d-none");
elements.treeStormguardTabItem.classList.add("d-none");
setTreeDetailTabsVisibility(document, null, true);
assert.equal(elements.treeDetailTabsContainer.classList.contains("d-none"), false);
assert.equal(elements.treeStormguardTabItem.classList.contains("d-none"), false);
});
@@ -15,6 +15,7 @@ node --test \
"${SCRIPT_DIR}/src/circuit_packet_capture_dom.test.mjs" \
"${SCRIPT_DIR}/src/tree_limit_reason.test.mjs" \
"${SCRIPT_DIR}/src/tree_stormguard.test.mjs" \
"${SCRIPT_DIR}/src/tree_detail_tabs.test.mjs" \
"${SCRIPT_DIR}/src/config/shaped_device_identity.test.mjs" \
"${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs" \
"${SCRIPT_DIR}/src/config_radius_accounting_contract.test.mjs" \
@@ -1,4 +1,6 @@
use lqos_config::{CircuitEthernetMetadata, CircuitEthernetMetadataFile, load_config};
use lqos_config::{
CircuitEthernetMetadata, CircuitEthernetMetadataFile, EthernetCapTargetKind, load_config,
};
use lqos_utils::normalize_circuit_id_key;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
@@ -71,13 +73,19 @@ pub struct EthernetCapsPageQuery {
pub tier: Option<EthernetCapTier>,
}
/// One Ethernet-limited circuit row for the review page.
/// One Ethernet-limited circuit or topology-node row for the review page.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct EthernetCapsPageRow {
/// Stable circuit identifier used for deep-linking to the circuit page.
/// Stable circuit identifier retained for clients that only understand circuit rows.
pub circuit_id: String,
/// Human-facing circuit name.
/// Human-facing circuit name retained for clients that only understand circuit rows.
pub circuit_name: String,
/// Whether this row represents a circuit or topology node.
pub target_kind: EthernetCapTargetKind,
/// Stable target identifier used for deep-linking.
pub target_id: String,
/// Human-facing target name.
pub target_name: String,
/// Parent node from shaped devices when available.
pub parent_node: String,
/// Compact warning badge metadata.
@@ -161,6 +169,35 @@ fn sort_rank(tier: &EthernetCapTier) -> u8 {
}
}
fn advisory_to_page_row(
advisory: &CircuitEthernetMetadata,
parent_nodes: &HashMap<String, String>,
) -> Option<EthernetCapsPageRow> {
let badge = advisory_to_badge(advisory)?;
let target_id = advisory.target_id_or_circuit_id().to_string();
let target_name = advisory.target_name_or_circuit_name().to_string();
let parent_node = if advisory.target_kind == EthernetCapTargetKind::Circuit {
parent_nodes
.get(&advisory.circuit_id)
.cloned()
.unwrap_or_default()
} else {
String::new()
};
Some(EthernetCapsPageRow {
circuit_id: advisory.circuit_id.clone(),
circuit_name: advisory.circuit_name.clone(),
target_kind: advisory.target_kind.clone(),
target_id,
target_name,
parent_node,
badge,
limiting_device_name: advisory.limiting_device_name.clone(),
limiting_interface_name: advisory.limiting_interface_name.clone(),
})
}
/// Returns a compact badge map keyed by circuit ID for Ethernet auto-capped circuits.
pub(crate) fn ethernet_cap_badge_map() -> HashMap<String, EthernetCapBadge> {
let mut badges = HashMap::new();
@@ -168,6 +205,9 @@ pub(crate) fn ethernet_cap_badge_map() -> HashMap<String, EthernetCapBadge> {
return badges;
};
for advisory in file.circuits {
if advisory.target_kind != EthernetCapTargetKind::Circuit {
continue;
}
let Some(badge) = advisory_to_badge(&advisory) else {
continue;
};
@@ -183,7 +223,8 @@ pub(crate) fn ethernet_advisory_for_circuit(
) -> Option<CircuitEthernetMetadata> {
let file = load_advisory_file()?;
file.circuits.into_iter().find(|entry| {
entry.auto_capped
entry.target_kind == EthernetCapTargetKind::Circuit
&& entry.auto_capped
&& entry.circuit_id.eq_ignore_ascii_case(circuit_id)
&& entry
.device_ids
@@ -202,31 +243,19 @@ pub fn ethernet_caps_page(query: EthernetCapsPageQuery) -> EthernetCapsPage {
let mut rows = Vec::new();
if let Some(file) = load_advisory_file() {
for advisory in file.circuits {
let Some(badge) = advisory_to_badge(&advisory) else {
let Some(row) = advisory_to_page_row(&advisory, &parent_nodes) else {
continue;
};
if let Some(filter_tier) = query.tier.as_ref()
&& &badge.tier != filter_tier
&& &row.badge.tier != filter_tier
{
continue;
}
let parent_node = parent_nodes
.get(&advisory.circuit_id)
.cloned()
.unwrap_or_default();
let row = EthernetCapsPageRow {
circuit_id: advisory.circuit_id,
circuit_name: advisory.circuit_name,
parent_node,
badge,
limiting_device_name: advisory.limiting_device_name,
limiting_interface_name: advisory.limiting_interface_name,
};
if !search.is_empty() {
let limiting_device = row.limiting_device_name.as_deref().unwrap_or("");
let limiting_interface = row.limiting_interface_name.as_deref().unwrap_or("");
if !row.circuit_id.to_lowercase().contains(&search)
&& !row.circuit_name.to_lowercase().contains(&search)
if !row.target_id.to_lowercase().contains(&search)
&& !row.target_name.to_lowercase().contains(&search)
&& !row.parent_node.to_lowercase().contains(&search)
&& !row.badge.tier_label.to_lowercase().contains(&search)
&& !limiting_device.to_lowercase().contains(&search)
@@ -242,8 +271,8 @@ pub fn ethernet_caps_page(query: EthernetCapsPageQuery) -> EthernetCapsPage {
rows.sort_by(|left, right| {
sort_rank(&left.badge.tier)
.cmp(&sort_rank(&right.badge.tier))
.then_with(|| left.circuit_name.cmp(&right.circuit_name))
.then_with(|| left.circuit_id.cmp(&right.circuit_id))
.then_with(|| left.target_name.cmp(&right.target_name))
.then_with(|| left.target_id.cmp(&right.target_id))
});
let total_rows = rows.len();
@@ -273,7 +302,9 @@ pub fn ethernet_caps_page(query: EthernetCapsPageQuery) -> EthernetCapsPage {
#[cfg(test)]
mod tests {
use super::{EthernetCapTier, sort_rank, tier_for_speed};
use super::{EthernetCapTargetKind, EthernetCapTier, advisory_to_page_row, sort_rank, tier_for_speed};
use lqos_config::CircuitEthernetMetadata;
use std::collections::HashMap;
#[test]
fn ethernet_cap_tier_classifies_expected_speeds() {
@@ -287,4 +318,30 @@ mod tests {
assert!(sort_rank(&EthernetCapTier::TenM) < sort_rank(&EthernetCapTier::HundredM));
assert!(sort_rank(&EthernetCapTier::HundredM) < sort_rank(&EthernetCapTier::GigPlus));
}
#[test]
fn topology_node_advisory_becomes_a_node_page_row() {
let advisory = CircuitEthernetMetadata {
target_kind: EthernetCapTargetKind::Node,
target_id: "uisp:device:ap-1".to_string(),
target_name: "AP One".to_string(),
circuit_id: "uisp:device:ap-1".to_string(),
circuit_name: "AP One".to_string(),
negotiated_ethernet_mbps: 100,
requested_download_mbps: 500.0,
requested_upload_mbps: 200.0,
applied_download_mbps: 94.0,
applied_upload_mbps: 94.0,
auto_capped: true,
..Default::default()
};
let row = advisory_to_page_row(&advisory, &HashMap::new())
.expect("auto-capped topology node should appear on the page");
assert_eq!(row.target_kind, EthernetCapTargetKind::Node);
assert_eq!(row.target_id, "uisp:device:ap-1");
assert_eq!(row.target_name, "AP One");
assert!(row.parent_node.is_empty());
}
}
@@ -3,16 +3,17 @@
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title"><i class="fa fa-ethernet me-2"></i>Ethernet Caps</h5>
<div class="lqos-config-panel-subtitle">Circuits automatically down-rated because detected Ethernet speed was below the requested plan.</div>
<div class="lqos-config-panel-subtitle">Circuits and topology nodes automatically down-rated because detected Ethernet speed was below the requested rate.</div>
</div>
<div id="ethernetCapsSummary" class="small text-body-secondary"></div>
</div>
<div class="lqos-config-section">
<div id="ethernetCapsError" class="alert alert-warning small" role="alert" aria-live="assertive" hidden></div>
<div class="row g-2 align-items-end mb-3">
<div class="col-12 col-lg-5">
<label class="form-label small text-body-secondary" for="ethernetCapsSearch">Search</label>
<input id="ethernetCapsSearch" class="form-control" type="text" placeholder="Search circuits, parents, device names…">
<input id="ethernetCapsSearch" class="form-control" type="text" placeholder="Search targets, parents, device names…">
</div>
<div class="col-6 col-lg-2">
<label class="form-label small text-body-secondary" for="ethernetCapsTier">Ethernet</label>
@@ -32,11 +33,11 @@
</select>
</div>
<div class="col-12 col-lg-3 d-flex justify-content-lg-end gap-2">
<button id="ethernetCapsPrev" type="button" class="btn btn-outline-primary">
<i class="fa fa-arrow-left"></i>
<button id="ethernetCapsPrev" type="button" class="btn btn-outline-primary" aria-label="Previous page">
<i class="fa fa-arrow-left" aria-hidden="true"></i>
</button>
<button id="ethernetCapsNext" type="button" class="btn btn-outline-primary">
<i class="fa fa-arrow-right"></i>
<button id="ethernetCapsNext" type="button" class="btn btn-outline-primary" aria-label="Next page">
<i class="fa fa-arrow-right" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -11,7 +11,7 @@
</div>
</div>
</div>
<div class="col-12">
<div class="col-12 d-none" id="treeDetailTabsContainer">
<ul class="nav nav-tabs lqos-tree-tabs" id="treeDetailTabs" role="tablist" aria-label="Network tree detail views">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tree-overview-tab" data-bs-toggle="tab" data-bs-target="#treeOverviewPane" type="button" role="tab" aria-controls="treeOverviewPane" aria-selected="true">
@@ -127,6 +127,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: speed,
negotiated_ethernet_interface: iface.map(str::to_string),
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: crate::uisp_types::UispAttachmentRateSource::Static,
@@ -326,6 +326,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -386,6 +388,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -407,6 +411,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -428,6 +434,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -492,6 +500,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -513,6 +523,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -549,6 +549,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: crate::uisp_types::UispAttachmentRateSource::Static,
@@ -570,6 +572,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: crate::uisp_types::UispAttachmentRateSource::Static,
@@ -647,6 +651,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -525,6 +525,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: crate::uisp_types::UispAttachmentRateSource::Static,
@@ -19,12 +19,12 @@ use crate::strategies::legacy_routes_override::RouteOverride;
use crate::uisp_types::{UispAttachmentRateSource, UispDevice};
use lqos_config::{
CircuitAnchor, CircuitAnchorsFile, CircuitEthernetMetadata, Config, ConfigShapedDevices,
EthernetPortLimitPolicy, RequestedCircuitRates, ShapedDevice as ConfigShapedDevice,
TOPOLOGY_ATTACHMENT_AUTO_ID, TopologyAllowedParent, TopologyAttachmentOption,
TopologyAttachmentRateSource, TopologyAttachmentRole, TopologyCanonicalIngressKind,
TopologyCanonicalStateFile, TopologyEditorNode, TopologyEditorStateFile,
TopologyParentCandidate, TopologyParentCandidatesFile, TopologyParentCandidatesNode,
TopologyQueueVisibilityPolicy, topology_auto_attachment_option,
EthernetCapTargetKind, EthernetPortLimitPolicy, RequestedCircuitRates,
ShapedDevice as ConfigShapedDevice, TOPOLOGY_ATTACHMENT_AUTO_ID, TopologyAllowedParent,
TopologyAttachmentOption, TopologyAttachmentRateSource, TopologyAttachmentRole,
TopologyCanonicalIngressKind, TopologyCanonicalStateFile, TopologyEditorNode,
TopologyEditorStateFile, TopologyParentCandidate, TopologyParentCandidatesFile,
TopologyParentCandidatesNode, TopologyQueueVisibilityPolicy, topology_auto_attachment_option,
};
#[cfg(test)]
use lqos_overrides::{TopologyAttachmentMode, TopologyParentOverrideMode};
@@ -43,6 +43,41 @@ type GraphType = petgraph::Graph<GraphMapping, LinkMapping, Directed>;
const GENERATED_INTERNET_ROOT_NAME: &str = "INSERTED_INTERNET";
const GENERATED_INTERNET_ROOT_ID: &str = "ROOT-001";
/// Builds advisory metadata for a topology node whose infrastructure transport cap reduced it.
fn topology_node_ethernet_advisory(device: &UispDevice) -> Option<CircuitEthernetMetadata> {
let applied_ethernet_cap = device.transport_cap_mbps?;
let auto_capped = device.download < device.raw_download || device.upload < device.raw_upload;
if !auto_capped {
return None;
}
let target_id = format!("uisp:device:{}", device.id);
Some(CircuitEthernetMetadata {
device_ids: vec![device.id.clone()],
source: "uisp/infrastructure_transport".to_string(),
negotiated_ethernet_mbps: device
.transport_cap_line_rate_mbps
.or(device.negotiated_ethernet_mbps)
.unwrap_or(applied_ethernet_cap),
requested_download_mbps: device.raw_download as f32,
requested_upload_mbps: device.raw_upload as f32,
applied_download_mbps: device.download as f32,
applied_upload_mbps: device.upload as f32,
auto_capped,
limiting_device_id: Some(device.id.clone()),
limiting_device_name: Some(device.name.clone()),
limiting_interface_name: device
.transport_cap_interface
.clone()
.or_else(|| device.negotiated_ethernet_interface.clone()),
..CircuitEthernetMetadata::for_target(
EthernetCapTargetKind::Node,
target_id,
device.name.clone(),
)
})
}
#[derive(Clone, Debug)]
struct LegacyShapedDevice {
circuit_id: String,
@@ -771,6 +806,11 @@ async fn build_imported_full2_bundle_from_data(
ingress_identity: None,
nodes: topology_editor_nodes,
};
let exported_uisp_device_ids: HashSet<&str> = topology_editor_state
.nodes
.iter()
.filter_map(|node| node.node_id.strip_prefix("uisp:device:"))
.collect();
let canonical_state = TopologyCanonicalStateFile::from_editor_and_network(
&topology_editor_state,
&Value::Object(network_json.clone()),
@@ -927,6 +967,14 @@ async fn build_imported_full2_bundle_from_data(
"Completed UISP full2 import bundle"
);
ethernet_advisories.extend(
uisp_data
.devices
.iter()
.filter(|device| exported_uisp_device_ids.contains(device.id.as_str()))
.filter_map(topology_node_ethernet_advisory),
);
Ok(ImportedTopologyBundle {
source: "uisp/full2".to_string(),
generated_unix: topology_editor_state.generated_unix,
@@ -2325,6 +2373,8 @@ mod topology_override_tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -3142,6 +3192,8 @@ mod topology_override_tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
@@ -3472,12 +3524,38 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: UispAttachmentRateSource::Static,
}
}
#[test]
fn topology_node_ethernet_advisory_preserves_the_port_cap_details() {
let mut device = test_uisp_device("ap-1", "AP One");
device.raw_download = 500;
device.raw_upload = 200;
device.download = 94;
device.upload = 94;
device.negotiated_ethernet_mbps = Some(100);
device.negotiated_ethernet_interface = Some("eth0".to_string());
device.transport_cap_line_rate_mbps = Some(100);
device.transport_cap_interface = Some("eth0".to_string());
device.transport_cap_mbps = Some(94);
let advisory = topology_node_ethernet_advisory(&device)
.expect("reduced infrastructure node should produce an Ethernet advisory");
assert_eq!(advisory.target_kind, EthernetCapTargetKind::Node);
assert_eq!(advisory.target_id, "uisp:device:ap-1");
assert_eq!(advisory.negotiated_ethernet_mbps, 100);
assert_eq!(advisory.requested_download_mbps, 500.0);
assert_eq!(advisory.applied_upload_mbps, 94.0);
assert_eq!(advisory.limiting_interface_name.as_deref(), Some("eth0"));
}
fn add_site(graph: &mut GraphType, name: &str, id: &str) -> NodeIndex {
graph.add_node(GraphMapping::Site {
name: name.to_string(),
@@ -34,6 +34,10 @@ pub struct UispDevice {
pub probe_ipv6: HashSet<String>,
pub negotiated_ethernet_mbps: Option<u64>,
pub negotiated_ethernet_interface: Option<String>,
/// Physical line rate selected when applying an infrastructure transport cap.
pub transport_cap_line_rate_mbps: Option<u64>,
/// Interface selected when applying an observed infrastructure transport cap.
pub transport_cap_interface: Option<String>,
pub transport_cap_mbps: Option<u64>,
pub transport_cap_reason: Option<String>,
pub attachment_rate_source: UispAttachmentRateSource,
@@ -594,6 +598,8 @@ impl UispDevice {
}
let raw_download = download;
let raw_upload = upload;
let mut transport_cap_line_rate_mbps = None;
let mut transport_cap_interface = None;
let mut transport_cap_mbps = None;
let mut transport_cap_reason = None;
@@ -714,19 +720,24 @@ impl UispDevice {
.infrastructure_transport_caps_enabled
{
let policy = EthernetPortLimitPolicy::from(&config.integration_common);
let observed_transport = Self::infrastructure_transport_observation(device);
let (observed_line_rate, observed_interface, observed_reason) =
Self::infrastructure_transport_observation(device);
let model_transport = Self::model_transport_port_ceiling(device);
let selected_transport = observed_transport
.0
.zip(observed_transport.2)
.or(model_transport);
let selected_transport = observed_line_rate
.zip(observed_reason)
.map(|(line_rate_mbps, reason)| (line_rate_mbps, observed_interface, reason))
.or_else(|| {
model_transport.map(|(line_rate_mbps, reason)| (line_rate_mbps, None, reason))
});
if let Some((line_rate_mbps, reason)) = selected_transport
if let Some((line_rate_mbps, interface, reason)) = selected_transport
&& let Some(usable_cap_mbps) = usable_ethernet_cap_mbps(policy, line_rate_mbps)
&& (download > usable_cap_mbps || upload > usable_cap_mbps)
{
download = download.min(usable_cap_mbps);
upload = upload.min(usable_cap_mbps);
transport_cap_line_rate_mbps = Some(line_rate_mbps);
transport_cap_interface = interface;
transport_cap_mbps = Some(usable_cap_mbps);
transport_cap_reason = Some(reason);
}
@@ -752,6 +763,8 @@ impl UispDevice {
probe_ipv6,
negotiated_ethernet_mbps,
negotiated_ethernet_interface,
transport_cap_line_rate_mbps,
transport_cap_interface,
transport_cap_mbps,
transport_cap_reason,
attachment_rate_source,
@@ -1117,6 +1130,8 @@ mod tests {
assert_eq!(trimmed.raw_upload, 2000);
assert_eq!(trimmed.download, 940);
assert_eq!(trimmed.upload, 940);
assert_eq!(trimmed.transport_cap_line_rate_mbps, Some(1000));
assert_eq!(trimmed.transport_cap_interface, None);
assert_eq!(trimmed.transport_cap_mbps, Some(940));
assert!(
trimmed
@@ -1156,6 +1171,8 @@ mod tests {
assert_eq!(trimmed.raw_upload, 2700);
assert_eq!(trimmed.download, 2350);
assert_eq!(trimmed.upload, 2350);
assert_eq!(trimmed.transport_cap_line_rate_mbps, Some(2500));
assert_eq!(trimmed.transport_cap_interface.as_deref(), Some("eth0"));
assert_eq!(trimmed.transport_cap_mbps, Some(2350));
assert!(
trimmed
@@ -1289,6 +1306,8 @@ mod tests {
probe_ipv6: HashSet::new(),
negotiated_ethernet_mbps: None,
negotiated_ethernet_interface: None,
transport_cap_line_rate_mbps: None,
transport_cap_interface: None,
transport_cap_mbps: None,
transport_cap_reason: None,
attachment_rate_source: super::UispAttachmentRateSource::Static,