mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 22:07:40 -05:00
Merge pull request #1119 from LibreQoE/effective_circuit_rate
Display effective circuit rates and limiting source in Node Manager
This commit is contained in:
33 files changed
+1526
-161
No files matched your search
@@ -34,7 +34,8 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta
|
||||
- Las ediciones de tasa desde la página del árbol escriben overrides operativos `AdjustSiteSpeed` en `lqos_overrides.json`.
|
||||
- En compilaciones UISP `full`, un `integrationUISPbandwidths.csv` heredado se auto-migra a esos overrides en la siguiente ejecución de la integración cuando todavía no existen overrides operativos de tasa; en caso contrario, el CSV se ignora.
|
||||
- Las ediciones de tasa desde la página del árbol requieren sesión de administrador, un `node_id` estable y un nodo no generado. Los nodos generados/sintéticos de la integración permanecen en solo lectura en este editor.
|
||||
- La página del árbol mantiene `Node Details` como una tarjeta compacta de resumen y coloca los editores de override como filas compactas directamente debajo de la tabla de detalles.
|
||||
- La página del árbol mantiene `Node Details` como una cuadrícula compacta de resumen y coloca los editores de override como filas compactas directamente debajo.
|
||||
- `Effective Now` muestra una insignia de origen estimado, como Parent, Override, Attachment o Mixed. Pase el cursor o enfoque la insignia para ver los detalles de descarga y subida.
|
||||
- La página del árbol ahora muestra el estado de `Topology Override` solo en modo lectura. Los cambios de padre y adjunto se movieron a `Topology`, y el panel de detalles del árbol enlaza directamente a Topology Manager para el nodo seleccionado.
|
||||
- Los saltos de adjuntos seleccionados en runtime se colapsan fuera de la jerarquía principal del árbol. Cuando un sitio usa actualmente un backhaul o radio path específico, `Node Details` lo muestra como metadato `Active Attachment` en el sitio en lugar de exponer ese adjunto como un nodo propio del árbol.
|
||||
- Los adjuntos alternativos inactivos de backhaul PtP/cableado también se eliminan del árbol runtime. `tree.html` muestra solo la ruta efectiva; los alternativos inactivos quedan visibles en Topology Manager y no como nodos runtime independientes.
|
||||
|
||||
@@ -37,7 +37,8 @@ For the full logical-topology versus queue-topology file flow, see [Topology Dat
|
||||
- Tree-page rate edits save persistent `AdjustSiteSpeed` overrides in `lqos_overrides.json`.
|
||||
- On UISP `full` builds, legacy `integrationUISPbandwidths.csv` files are auto-migrated into those operator overrides on the next integration run when no operator rate overrides exist yet; otherwise the CSV is ignored.
|
||||
- Tree-page operator rate edits require an administrator session, a stable node ID, and a non-generated node. Generated/integration-synthetic nodes remain read-only in this editor.
|
||||
- The tree page keeps `Node Details` as a compact summary card and places the override editors as compact inline rows directly beneath the details table.
|
||||
- The tree page keeps `Node Details` as a compact summary grid and places the override editors as compact inline rows directly beneath it.
|
||||
- `Effective Now` shows an estimated source badge, such as Parent, Override, Attachment, or Mixed. Hover or focus the badge to see the download and upload details.
|
||||
- The tree page now shows topology override state as read-only summary only. Parent and attachment edits have moved to `Topology`, and the tree details panel links directly into Topology Manager for the selected node.
|
||||
- Runtime-selected attachment hops are collapsed out of the main tree hierarchy. When a site is currently using a specific backhaul or radio path, `Node Details` shows that as `Active Attachment` metadata on the site instead of exposing the attachment as its own branch node.
|
||||
- Inactive alternate PtP/wired backhaul attachment stubs are also pruned from the runtime tree. `tree.html` shows the effective path only; dormant alternates remain visible in Topology Manager rather than as standalone runtime nodes.
|
||||
|
||||
Generated
+1
@@ -2213,6 +2213,7 @@ dependencies = [
|
||||
"lqos_bus",
|
||||
"lqos_config",
|
||||
"lqos_sys",
|
||||
"lqos_utils",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -7,6 +7,7 @@ edition = "2024"
|
||||
lqos_config = { path = "../lqos_config" }
|
||||
lqos_bus = { path = "../lqos_bus" }
|
||||
lqos_sys = { path = "../lqos_sys" }
|
||||
lqos_utils = { path = "../lqos_utils" }
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
crossbeam-channel.workspace = true
|
||||
|
||||
@@ -26,6 +26,7 @@ mod queue_math;
|
||||
mod utils;
|
||||
|
||||
use crossbeam_channel::{Receiver, RecvTimeoutError, Sender};
|
||||
use lqos_utils::normalize_circuit_id_key;
|
||||
use parking_lot::RwLock;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::VecDeque;
|
||||
@@ -588,10 +589,6 @@ fn ip_list_from_shaped_device(device: &lqos_config::ShapedDevice) -> String {
|
||||
ips.join(",")
|
||||
}
|
||||
|
||||
fn normalize_circuit_id_key(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn find_free_dynamic_circuit_minor(
|
||||
used_down: &HashSet<u16>,
|
||||
used_up: &HashSet<u16>,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use crossbeam_channel::{Receiver, RecvTimeoutError, SendTimeoutError, Sender, bounded};
|
||||
use ip_network_table::IpNetworkTable;
|
||||
use lqos_utils::normalize_circuit_id_key;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
@@ -260,10 +261,6 @@ fn actor_loop(rx: Receiver<NetworkDevicesCommand>, hooks: Option<Arc<dyn DaemonH
|
||||
error!("lqos_network_devices actor stopped");
|
||||
}
|
||||
|
||||
fn normalize_circuit_id_key(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn recompute_hashes(device: &mut lqos_config::ShapedDevice) {
|
||||
device.circuit_hash = lqos_utils::hash_to_i64(&device.circuit_id);
|
||||
device.device_hash = lqos_utils::hash_to_i64(&device.device_id);
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::hash_cache::ShapedDeviceHashCache;
|
||||
use fxhash::FxHashSet;
|
||||
use ip_network::IpNetwork;
|
||||
use lqos_config::{ConfigShapedDevices, ShapedDevice};
|
||||
use lqos_utils::XdpIpAddress;
|
||||
use lqos_utils::{XdpIpAddress, normalize_circuit_id_key};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
@@ -101,11 +101,11 @@ impl ShapedDevicesCatalog {
|
||||
|
||||
/// Returns a cloned list of shaped-device rows that belong to a circuit identifier.
|
||||
pub fn devices_for_circuit_id(&self, circuit_id: &str) -> Vec<ShapedDevice> {
|
||||
let safe_id = circuit_id.to_lowercase().trim().to_string();
|
||||
let safe_id = normalize_circuit_id_key(circuit_id);
|
||||
self.shaped
|
||||
.devices
|
||||
.iter()
|
||||
.filter(|device| device.circuit_id.to_lowercase().trim() == safe_id)
|
||||
.filter(|device| normalize_circuit_id_key(&device.circuit_id) == safe_id)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -3,14 +3,10 @@ use fxhash::FxHashMap;
|
||||
use ip_network::IpNetwork;
|
||||
use ip_network_table::IpNetworkTable;
|
||||
use lqos_config::ShapedDevice;
|
||||
use lqos_utils::XdpIpAddress;
|
||||
use lqos_utils::{XdpIpAddress, normalize_circuit_id_key};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn normalize_circuit_id_key(circuit_id: &str) -> String {
|
||||
circuit_id.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Snapshot handle for shaped devices plus runtime dynamic circuits.
|
||||
///
|
||||
/// This catalog is intended for read-heavy paths (dashboards, APIs) that need to
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::DynamicCircuit;
|
||||
use anyhow::{Context, Result};
|
||||
use lqos_config::ShapedDevice;
|
||||
use lqos_utils::hash_to_i64;
|
||||
use lqos_utils::{hash_to_i64, normalize_circuit_id_key};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
@@ -32,10 +32,6 @@ fn recompute_hashes(device: &mut ShapedDevice) {
|
||||
device.parent_hash = hash_to_i64(&device.parent_node);
|
||||
}
|
||||
|
||||
fn normalize_circuit_id_key(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
let raw = serde_json::to_string_pretty(value).context("serialize dynamic circuits JSON")?;
|
||||
let temp_path = path.with_extension("tmp");
|
||||
|
||||
@@ -22,8 +22,8 @@ pub use bus::get_raw_circuit_data;
|
||||
pub use interval::set_queue_refresh_interval;
|
||||
#[doc(hidden)]
|
||||
pub use queue_structure::{
|
||||
EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD, QueueNode,
|
||||
QueueStructure, spawn_queue_structure_monitor,
|
||||
EFFECTIVE_CIRCUIT_RATES, EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE,
|
||||
QUEUE_STRUCTURE_CHANGED_STORMGUARD, QueueNode, QueueStructure, spawn_queue_structure_monitor,
|
||||
};
|
||||
pub use queue_types::deserialize_tc_tree; // Exported for the benchmarker
|
||||
pub use tracking::spawn_queue_monitor;
|
||||
|
||||
@@ -4,7 +4,8 @@ mod queue_node;
|
||||
pub use queing_structure_json_monitor::spawn_queue_structure_monitor;
|
||||
#[doc(hidden)]
|
||||
pub use queing_structure_json_monitor::{
|
||||
EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE, QUEUE_STRUCTURE_CHANGED_STORMGUARD, QueueStructure,
|
||||
EFFECTIVE_CIRCUIT_RATES, EFFECTIVE_NODE_RATES, QUEUE_STRUCTURE,
|
||||
QUEUE_STRUCTURE_CHANGED_STORMGUARD, QueueStructure,
|
||||
};
|
||||
use queue_network::QueueNetwork;
|
||||
#[doc(hidden)]
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::queue_structure::{
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use lqos_utils::file_watcher::FileWatcher;
|
||||
use lqos_utils::normalize_circuit_id_key;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -13,18 +14,26 @@ use tracing::{debug, error, info, warn};
|
||||
/// Global queue structure (from `queueingStructure.json`)
|
||||
pub static QUEUE_STRUCTURE: Lazy<ArcSwap<QueueStructure>> =
|
||||
Lazy::new(|| ArcSwap::new(Arc::new(QueueStructure::new())));
|
||||
static INITIAL_EFFECTIVE_RATES: Lazy<EffectiveRates> = Lazy::new(|| {
|
||||
let initial = QUEUE_STRUCTURE.load();
|
||||
initial
|
||||
.maybe_queues
|
||||
.as_deref()
|
||||
.map(build_effective_rates)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
/// Global effective node-rate overlay derived from `queuingStructure.json`.
|
||||
///
|
||||
/// This contains only named queue nodes that map cleanly back to authored network-tree
|
||||
/// entries. Circuit/device rows and generated placeholder nodes are intentionally excluded.
|
||||
pub static EFFECTIVE_NODE_RATES: Lazy<ArcSwap<HashMap<String, (f64, f64)>>> = Lazy::new(|| {
|
||||
let initial = QUEUE_STRUCTURE.load();
|
||||
let rates = initial
|
||||
.maybe_queues
|
||||
.as_deref()
|
||||
.map(build_effective_node_rates)
|
||||
.unwrap_or_default();
|
||||
ArcSwap::new(Arc::new(rates))
|
||||
ArcSwap::new(Arc::new(INITIAL_EFFECTIVE_RATES.nodes.clone()))
|
||||
});
|
||||
/// Global effective circuit-rate overlay derived from `queuingStructure.json`.
|
||||
///
|
||||
/// This contains the currently programmed circuit queue rates keyed by normalized circuit ID.
|
||||
pub static EFFECTIVE_CIRCUIT_RATES: Lazy<ArcSwap<HashMap<String, (f64, f64)>>> = Lazy::new(|| {
|
||||
ArcSwap::new(Arc::new(INITIAL_EFFECTIVE_RATES.circuits.clone()))
|
||||
});
|
||||
/// Set to true when the queue structure changes. This is here rather than in StormGuard
|
||||
/// to avoid circular dependencies.
|
||||
@@ -37,6 +46,12 @@ pub struct QueueStructure {
|
||||
pub maybe_queues: Option<Vec<QueueNode>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EffectiveRates {
|
||||
nodes: HashMap<String, (f64, f64)>,
|
||||
circuits: HashMap<String, (f64, f64)>,
|
||||
}
|
||||
|
||||
impl QueueStructure {
|
||||
fn new() -> Self {
|
||||
if let Ok(queues) = read_queueing_structure() {
|
||||
@@ -49,28 +64,48 @@ impl QueueStructure {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_effective_node_rates(queues: &[QueueNode]) -> HashMap<String, (f64, f64)> {
|
||||
let mut rates = HashMap::with_capacity(queues.len());
|
||||
fn build_effective_rates(queues: &[QueueNode]) -> EffectiveRates {
|
||||
let mut node_rates = HashMap::with_capacity(queues.len());
|
||||
let mut circuit_rates = HashMap::new();
|
||||
for queue in queues {
|
||||
let Some(name) = queue.name.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if name.starts_with("Generated_PN_")
|
||||
|| queue.circuit_id.is_some()
|
||||
|| queue.device_id.is_some()
|
||||
if let Some(name) = queue.name.as_ref()
|
||||
&& !name.starts_with("Generated_PN_")
|
||||
&& queue.circuit_id.is_none()
|
||||
&& queue.device_id.is_none()
|
||||
{
|
||||
continue;
|
||||
node_rates.insert(
|
||||
name.clone(),
|
||||
(
|
||||
queue.download_bandwidth_mbps as f64,
|
||||
queue.upload_bandwidth_mbps as f64,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
rates.insert(
|
||||
name.clone(),
|
||||
if queue.device_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(circuit_id) = queue
|
||||
.circuit_id
|
||||
.as_deref()
|
||||
.map(normalize_circuit_id_key)
|
||||
.filter(|key| !key.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
circuit_rates.insert(
|
||||
circuit_id,
|
||||
(
|
||||
queue.download_bandwidth_mbps as f64,
|
||||
queue.upload_bandwidth_mbps as f64,
|
||||
),
|
||||
);
|
||||
}
|
||||
rates
|
||||
EffectiveRates {
|
||||
nodes: node_rates,
|
||||
circuits: circuit_rates,
|
||||
}
|
||||
}
|
||||
|
||||
/// Global file watched for `queueStructure.json`.
|
||||
@@ -91,12 +126,13 @@ fn update_queue_structure() {
|
||||
debug!("queueingStructure.json reload requested");
|
||||
match read_queueing_structure() {
|
||||
Ok(queues) => {
|
||||
let effective_rates = build_effective_rates(&queues);
|
||||
let new_queue_structure = QueueStructure {
|
||||
maybe_queues: Some(queues.clone()),
|
||||
maybe_queues: Some(queues),
|
||||
};
|
||||
let effective_node_rates = build_effective_node_rates(&queues);
|
||||
QUEUE_STRUCTURE.store(Arc::new(new_queue_structure));
|
||||
EFFECTIVE_NODE_RATES.store(Arc::new(effective_node_rates));
|
||||
EFFECTIVE_NODE_RATES.store(Arc::new(effective_rates.nodes));
|
||||
EFFECTIVE_CIRCUIT_RATES.store(Arc::new(effective_rates.circuits));
|
||||
QUEUE_STRUCTURE_CHANGED_STORMGUARD.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -110,6 +146,7 @@ fn update_queue_structure() {
|
||||
);
|
||||
QUEUE_STRUCTURE.store(Arc::new(QueueStructure { maybe_queues: None }));
|
||||
EFFECTIVE_NODE_RATES.store(Arc::new(HashMap::new()));
|
||||
EFFECTIVE_CIRCUIT_RATES.store(Arc::new(HashMap::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,3 +178,91 @@ pub enum QueueWatcherError {
|
||||
#[error("Could not create the path buffer to find queuingStructure.json")]
|
||||
CannotCreatePath,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn named_node(name: &str, down: u64, up: u64) -> QueueNode {
|
||||
QueueNode {
|
||||
name: Some(name.to_string()),
|
||||
download_bandwidth_mbps: down,
|
||||
upload_bandwidth_mbps: up,
|
||||
..QueueNode::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn circuit_node(circuit_id: &str, down: u64, up: u64) -> QueueNode {
|
||||
QueueNode {
|
||||
circuit_id: Some(circuit_id.to_string()),
|
||||
download_bandwidth_mbps: down,
|
||||
upload_bandwidth_mbps: up,
|
||||
..QueueNode::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_circuit_rates_include_programmed_circuit_queues() {
|
||||
let queues = [
|
||||
named_node("Tower", 500, 200),
|
||||
circuit_node(" Circuit-42 ", 115, 25),
|
||||
];
|
||||
|
||||
let rates = build_effective_rates(&queues).circuits;
|
||||
|
||||
assert_eq!(rates.get("circuit-42"), Some(&(115.0, 25.0)));
|
||||
assert!(!rates.contains_key("Tower"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_circuit_rates_ignore_device_rows() {
|
||||
let queues = [QueueNode {
|
||||
circuit_id: Some("Circuit-42".to_string()),
|
||||
device_id: Some("device-1".to_string()),
|
||||
download_bandwidth_mbps: 50,
|
||||
upload_bandwidth_mbps: 10,
|
||||
..QueueNode::default()
|
||||
}];
|
||||
|
||||
let rates = build_effective_rates(&queues).circuits;
|
||||
|
||||
assert!(rates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_node_rates_keep_node_overlay_filters() {
|
||||
let queues = [
|
||||
named_node("Tower", 500, 200),
|
||||
named_node("Generated_PN_Tower", 100, 40),
|
||||
QueueNode {
|
||||
name: Some("Device Row".to_string()),
|
||||
device_id: Some("device-1".to_string()),
|
||||
download_bandwidth_mbps: 50,
|
||||
upload_bandwidth_mbps: 10,
|
||||
..QueueNode::default()
|
||||
},
|
||||
];
|
||||
|
||||
let rates = build_effective_rates(&queues).nodes;
|
||||
|
||||
assert_eq!(rates.get("Tower"), Some(&(500.0, 200.0)));
|
||||
assert!(!rates.contains_key("Generated_PN_Tower"));
|
||||
assert!(!rates.contains_key("Device Row"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_circuit_queue_goes_only_to_circuit_overlay() {
|
||||
let queues = [QueueNode {
|
||||
name: Some("Named Circuit Queue".to_string()),
|
||||
circuit_id: Some("Circuit-42".to_string()),
|
||||
download_bandwidth_mbps: 115,
|
||||
upload_bandwidth_mbps: 25,
|
||||
..QueueNode::default()
|
||||
}];
|
||||
|
||||
let rates = build_effective_rates(&queues);
|
||||
|
||||
assert!(!rates.nodes.contains_key("Named Circuit Queue"));
|
||||
assert_eq!(rates.circuits.get("circuit-42"), Some(&(115.0, 25.0)));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ mod xdp_ip_address;
|
||||
/// XDP compatible IP Address
|
||||
pub use xdp_ip_address::XdpIpAddress;
|
||||
|
||||
/// Normalizes a circuit identifier for case-insensitive catalog and overlay lookups.
|
||||
pub fn normalize_circuit_id_key(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Insight standard hasher for strings
|
||||
pub fn hash_to_i64(text: &str) -> i64 {
|
||||
use std::hash::{DefaultHasher, Hasher};
|
||||
@@ -50,3 +55,13 @@ pub fn hash_to_i64(text: &str) -> i64 {
|
||||
hasher.write(text.as_bytes());
|
||||
hasher.finish() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_circuit_id_key;
|
||||
|
||||
#[test]
|
||||
fn circuit_id_key_trims_and_ascii_lowercases() {
|
||||
assert_eq!(normalize_circuit_id_key(" Circuit-42 "), "circuit-42");
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,18 @@ import {
|
||||
setIconText,
|
||||
setPacketCaptureDownloadButton,
|
||||
} from "./circuit_packet_capture_dom.mjs";
|
||||
import {
|
||||
applyMaxRateDisplay,
|
||||
formatRatePairValue,
|
||||
hasUsableRatePair,
|
||||
} from "./circuit_rate_display.mjs";
|
||||
|
||||
const params = new Proxy(new URLSearchParams(window.location.search), {
|
||||
get: (searchParams, prop) => searchParams.get(prop),
|
||||
});
|
||||
|
||||
const CIRCUIT_RATE_REFRESH_MS = 30000;
|
||||
|
||||
let circuit_id = decodeURI(params.id);
|
||||
let plan = null;
|
||||
let channelLink = null;
|
||||
@@ -86,6 +93,8 @@ let topAsnPollTimer = null;
|
||||
let topAsnRequestInFlight = false;
|
||||
let trafficPollTimer = null;
|
||||
let trafficRequestInFlight = false;
|
||||
let circuitRatePollTimer = null;
|
||||
let circuitRateRequestInFlight = false;
|
||||
let queuingActivityVisibilityHandler = null;
|
||||
const DEFAULT_RTT_THRESHOLDS = { green_ms: 0, yellow_ms: 100, red_ms: 200 };
|
||||
let currentRttThresholds = { ...DEFAULT_RTT_THRESHOLDS };
|
||||
@@ -118,9 +127,7 @@ function ethernetTooltipHtml(advisory) {
|
||||
}
|
||||
|
||||
function formatPlanSpeedPair(downloadMbps, uploadMbps) {
|
||||
const down = toNumber(downloadMbps, 0).toFixed(1);
|
||||
const up = toNumber(uploadMbps, 0).toFixed(1);
|
||||
return `${down} / ${up} Mbps`;
|
||||
return `${formatRatePairValue({down: downloadMbps, up: uploadMbps})} Mbps`;
|
||||
}
|
||||
|
||||
function renderEthernetAdvisory(advisory) {
|
||||
@@ -1180,6 +1187,72 @@ function requestCircuitById(onSuccess, onError) {
|
||||
wsClient.send({ CircuitById: { id: circuit_id } });
|
||||
}
|
||||
|
||||
function applyCircuitRatePayload(payload) {
|
||||
const circuits = payload.devices || [];
|
||||
const circuit = circuits[0];
|
||||
if (!circuit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignedRate = {
|
||||
down: toNumber(circuit.download_max_mbps, 0),
|
||||
up: toNumber(circuit.upload_max_mbps, 0),
|
||||
};
|
||||
const effectiveRate = hasUsableRatePair(payload.effective_rate_mbps)
|
||||
? payload.effective_rate_mbps
|
||||
: null;
|
||||
applyMaxRateDisplay(
|
||||
document.getElementById("bwMax"),
|
||||
assignedRate,
|
||||
effectiveRate,
|
||||
initTooltipsWithin,
|
||||
);
|
||||
return {assignedRate, circuit};
|
||||
}
|
||||
|
||||
function applyCircuitHeaderPayload(payload) {
|
||||
const circuits = payload.devices || [];
|
||||
const rateHeader = applyCircuitRatePayload(payload);
|
||||
if (!rateHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {assignedRate, circuit} = rateHeader;
|
||||
const advisory = payload.ethernet_advisory || null;
|
||||
const parentNode = resolveCircuitParentNode(payload, circuits);
|
||||
$("#circuitName").text(circuit.circuit_name);
|
||||
$("#circuitName").attr("title", circuit.circuit_name || "");
|
||||
applyParentNodeLink(parentNode?.name || "");
|
||||
$("#bwMin").text(formatPlanSpeedPair(circuit.download_min_mbps, circuit.upload_min_mbps));
|
||||
renderEthernetAdvisory(advisory);
|
||||
return {assignedRate, circuit, parentNode};
|
||||
}
|
||||
|
||||
function requestCircuitRateRefresh() {
|
||||
if (circuitRateRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
circuitRateRequestInFlight = true;
|
||||
requestCircuitById(
|
||||
(payload) => {
|
||||
circuitRateRequestInFlight = false;
|
||||
const header = applyCircuitRatePayload(payload);
|
||||
if (header) {
|
||||
plan = header.assignedRate;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
circuitRateRequestInFlight = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function startCircuitRateRefresh() {
|
||||
if (circuitRatePollTimer === null) {
|
||||
circuitRatePollTimer = window.setInterval(requestCircuitRateRefresh, CIRCUIT_RATE_REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function applyCircuitSummary(summary) {
|
||||
latestCircuitSummary = summary || null;
|
||||
latestCircuitQooScore = toNumber(summary?.qoo_score, NaN);
|
||||
@@ -2582,21 +2655,14 @@ function loadInitial() {
|
||||
loadRttThresholds();
|
||||
requestCircuitById((payload) => {
|
||||
const circuits = payload.devices || [];
|
||||
const advisory = payload.ethernet_advisory || null;
|
||||
queueStatsMode = payload.queue_stats_mode || "live";
|
||||
let circuit = circuits[0];
|
||||
const parentNode = resolveCircuitParentNode(payload, circuits);
|
||||
const header = applyCircuitHeaderPayload(payload);
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
const {circuit, parentNode, assignedRate} = header;
|
||||
circuitConfigDevices = circuits;
|
||||
$("#circuitName").text(circuit.circuit_name);
|
||||
$("#circuitName").attr("title", circuit.circuit_name || "");
|
||||
applyParentNodeLink(parentNode?.name || "");
|
||||
$("#bwMax").text(formatPlanSpeedPair(circuit.download_max_mbps, circuit.upload_max_mbps));
|
||||
$("#bwMin").text(formatPlanSpeedPair(circuit.download_min_mbps, circuit.upload_min_mbps));
|
||||
renderEthernetAdvisory(advisory);
|
||||
plan = {
|
||||
down: toNumber(circuit.download_max_mbps, 0),
|
||||
up: toNumber(circuit.upload_max_mbps, 0),
|
||||
};
|
||||
plan = assignedRate;
|
||||
latestCircuitDevices = circuits;
|
||||
circuitSqmOverride = circuit.sqm_override || "";
|
||||
setQueueTypeDisplayFromKinds(latestCakeMsg?.kind_down, latestCakeMsg?.kind_up);
|
||||
@@ -2611,6 +2677,7 @@ function loadInitial() {
|
||||
connectCircuitSummaryChannel();
|
||||
subscribeToCake();
|
||||
wireupAnalysis(circuits);
|
||||
startCircuitRateRefresh();
|
||||
}, () => {
|
||||
alert("Circuit with id " + circuit_id + " not found");
|
||||
});
|
||||
@@ -2638,6 +2705,7 @@ function cleanupCircuitPage() {
|
||||
sankeyPollTimer = clearPollingTimer(sankeyPollTimer);
|
||||
topAsnPollTimer = clearPollingTimer(topAsnPollTimer);
|
||||
trafficPollTimer = clearPollingTimer(trafficPollTimer);
|
||||
circuitRatePollTimer = clearPollingTimer(circuitRatePollTimer);
|
||||
if (funnelSubscription) {
|
||||
funnelSubscription.dispose();
|
||||
funnelSubscription = null;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {toNumber} from "./lq_js_common/helpers/scaling.js";
|
||||
import {disposeTooltipsWithin} from "./lq_js_common/helpers/tooltips.js";
|
||||
|
||||
export function formatRatePairValue(ratePair) {
|
||||
const down = toNumber(ratePair?.down, 0).toFixed(1);
|
||||
const up = toNumber(ratePair?.up, 0).toFixed(1);
|
||||
return `${down} / ${up}`;
|
||||
}
|
||||
|
||||
export function hasUsableRatePair(ratePair) {
|
||||
const down = toNumber(ratePair?.down, NaN);
|
||||
const up = toNumber(ratePair?.up, NaN);
|
||||
return Number.isFinite(down) && Number.isFinite(up) && down > 0 && up > 0;
|
||||
}
|
||||
|
||||
function roundedTenths(value) {
|
||||
return Math.round(toNumber(value, 0) * 10);
|
||||
}
|
||||
|
||||
export function ratePairsDiffer(left, right) {
|
||||
if (!hasUsableRatePair(left) || !hasUsableRatePair(right)) {
|
||||
return false;
|
||||
}
|
||||
return roundedTenths(left.down) !== roundedTenths(right.down)
|
||||
|| roundedTenths(left.up) !== roundedTenths(right.up);
|
||||
}
|
||||
|
||||
export function formatMaxRatePair(assignedRate, effectiveRate) {
|
||||
if (ratePairsDiffer(assignedRate, effectiveRate)) {
|
||||
return `${formatRatePairValue(assignedRate)} -> ${formatRatePairValue(effectiveRate)} Mbps`;
|
||||
}
|
||||
return `${formatRatePairValue(assignedRate)} Mbps`;
|
||||
}
|
||||
|
||||
export function applyMaxRateDisplay(maxRateEl, assignedRate, effectiveRate, initTooltipsWithin = null) {
|
||||
if (!maxRateEl) {
|
||||
return;
|
||||
}
|
||||
maxRateEl.textContent = formatMaxRatePair(assignedRate, effectiveRate);
|
||||
disposeTooltipsWithin(maxRateEl);
|
||||
|
||||
if (!ratePairsDiffer(assignedRate, effectiveRate)) {
|
||||
maxRateEl.removeAttribute("title");
|
||||
maxRateEl.removeAttribute("aria-label");
|
||||
maxRateEl.removeAttribute("data-bs-toggle");
|
||||
maxRateEl.removeAttribute("data-bs-placement");
|
||||
maxRateEl.removeAttribute("data-bs-trigger");
|
||||
maxRateEl.removeAttribute("tabindex");
|
||||
maxRateEl.removeAttribute("data-bs-original-title");
|
||||
return;
|
||||
}
|
||||
|
||||
maxRateEl.setAttribute("data-bs-toggle", "tooltip");
|
||||
maxRateEl.setAttribute("data-bs-placement", "top");
|
||||
maxRateEl.setAttribute("data-bs-trigger", "hover focus");
|
||||
maxRateEl.setAttribute("tabindex", "0");
|
||||
maxRateEl.setAttribute(
|
||||
"title",
|
||||
`Assigned max ${formatRatePairValue(assignedRate)} Mbps; effective queue max ${formatRatePairValue(effectiveRate)} Mbps`,
|
||||
);
|
||||
maxRateEl.setAttribute(
|
||||
"aria-label",
|
||||
`Assigned max ${formatRatePairValue(assignedRate)} Mbps. Effective queue max ${formatRatePairValue(effectiveRate)} Mbps.`,
|
||||
);
|
||||
|
||||
if (typeof initTooltipsWithin === "function") {
|
||||
initTooltipsWithin(maxRateEl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyMaxRateDisplay,
|
||||
formatMaxRatePair,
|
||||
hasUsableRatePair,
|
||||
ratePairsDiffer,
|
||||
} from "./circuit_rate_display.mjs";
|
||||
|
||||
class FakeElement {
|
||||
constructor(parentElement = null) {
|
||||
this.parentElement = parentElement;
|
||||
this.attributes = {};
|
||||
this.textContent = "";
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
}
|
||||
|
||||
removeAttribute(name) {
|
||||
delete this.attributes[name];
|
||||
}
|
||||
|
||||
matches(selector) {
|
||||
return selector === '[data-bs-toggle="tooltip"]'
|
||||
&& this.attributes["data-bs-toggle"] === "tooltip";
|
||||
}
|
||||
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
test("max rate display stays compact when effective rate matches assigned rate", () => {
|
||||
const assigned = {down: 115, up: 115};
|
||||
const effective = {down: 115, up: 115};
|
||||
|
||||
assert.equal(formatMaxRatePair(assigned, effective), "115.0 / 115.0 Mbps");
|
||||
assert.equal(ratePairsDiffer(assigned, effective), false);
|
||||
});
|
||||
|
||||
test("max rate display shows assigned to effective only when rates differ", () => {
|
||||
const assigned = {down: 115, up: 115};
|
||||
const effective = {down: 100, up: 50};
|
||||
|
||||
assert.equal(formatMaxRatePair(assigned, effective), "115.0 / 115.0 -> 100.0 / 50.0 Mbps");
|
||||
assert.equal(ratePairsDiffer(assigned, effective), true);
|
||||
});
|
||||
|
||||
test("unavailable effective rate falls back to assigned rate", () => {
|
||||
const assigned = {down: 115, up: 115};
|
||||
|
||||
assert.equal(formatMaxRatePair(assigned, null), "115.0 / 115.0 Mbps");
|
||||
assert.equal(hasUsableRatePair(null), false);
|
||||
});
|
||||
|
||||
test("rate comparison ignores differences smaller than display precision", () => {
|
||||
const assigned = {down: 115, up: 115};
|
||||
const effective = {down: 115.04, up: 115};
|
||||
|
||||
assert.equal(ratePairsDiffer(assigned, effective), false);
|
||||
assert.equal(formatMaxRatePair(assigned, effective), "115.0 / 115.0 Mbps");
|
||||
});
|
||||
|
||||
test("rate comparison changes when displayed precision changes", () => {
|
||||
const assigned = {down: 115, up: 115};
|
||||
const effective = {down: 115.06, up: 115};
|
||||
|
||||
assert.equal(ratePairsDiffer(assigned, effective), true);
|
||||
assert.equal(formatMaxRatePair(assigned, effective), "115.0 / 115.0 -> 115.1 / 115.0 Mbps");
|
||||
});
|
||||
|
||||
test("zero or invalid rates are treated as unavailable", () => {
|
||||
assert.equal(hasUsableRatePair({down: 0, up: 115}), false);
|
||||
assert.equal(hasUsableRatePair({down: 115, up: Number.NaN}), false);
|
||||
});
|
||||
|
||||
test("max rate display exposes assigned and effective meaning accessibly", () => {
|
||||
const parent = new FakeElement();
|
||||
const maxRateEl = new FakeElement(parent);
|
||||
let tooltipRoot = null;
|
||||
|
||||
applyMaxRateDisplay(
|
||||
maxRateEl,
|
||||
{down: 115, up: 115},
|
||||
{down: 100, up: 50},
|
||||
(root) => {
|
||||
tooltipRoot = root;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(maxRateEl.textContent, "115.0 / 115.0 -> 100.0 / 50.0 Mbps");
|
||||
assert.equal(maxRateEl.attributes["data-bs-toggle"], "tooltip");
|
||||
assert.equal(maxRateEl.attributes["data-bs-trigger"], "hover focus");
|
||||
assert.equal(maxRateEl.attributes.tabindex, "0");
|
||||
assert.equal(
|
||||
maxRateEl.attributes.title,
|
||||
"Assigned max 115.0 / 115.0 Mbps; effective queue max 100.0 / 50.0 Mbps",
|
||||
);
|
||||
assert.equal(
|
||||
maxRateEl.attributes["aria-label"],
|
||||
"Assigned max 115.0 / 115.0 Mbps. Effective queue max 100.0 / 50.0 Mbps.",
|
||||
);
|
||||
assert.equal(tooltipRoot, maxRateEl);
|
||||
});
|
||||
|
||||
test("max rate display removes tooltip state when rates match", () => {
|
||||
const maxRateEl = new FakeElement();
|
||||
maxRateEl.setAttribute("title", "old");
|
||||
maxRateEl.setAttribute("aria-label", "old");
|
||||
maxRateEl.setAttribute("data-bs-toggle", "tooltip");
|
||||
maxRateEl.setAttribute("data-bs-trigger", "hover focus");
|
||||
maxRateEl.setAttribute("tabindex", "0");
|
||||
maxRateEl.setAttribute("data-bs-original-title", "old");
|
||||
|
||||
applyMaxRateDisplay(maxRateEl, {down: 115, up: 115}, {down: 115, up: 115});
|
||||
|
||||
assert.equal(maxRateEl.textContent, "115.0 / 115.0 Mbps");
|
||||
assert.equal(maxRateEl.attributes.title, undefined);
|
||||
assert.equal(maxRateEl.attributes["aria-label"], undefined);
|
||||
assert.equal(maxRateEl.attributes["data-bs-toggle"], undefined);
|
||||
assert.equal(maxRateEl.attributes["data-bs-trigger"], undefined);
|
||||
assert.equal(maxRateEl.attributes.tabindex, undefined);
|
||||
assert.equal(maxRateEl.attributes["data-bs-original-title"], undefined);
|
||||
});
|
||||
|
||||
test("max rate display disposes stale Bootstrap tooltip before removing tooltip attributes", () => {
|
||||
const maxRateEl = new FakeElement();
|
||||
maxRateEl.setAttribute("data-bs-toggle", "tooltip");
|
||||
let disposed = false;
|
||||
globalThis.bootstrap = {
|
||||
Tooltip: {
|
||||
getInstance(el) {
|
||||
assert.equal(el, maxRateEl);
|
||||
return {
|
||||
dispose() {
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
applyMaxRateDisplay(maxRateEl, {down: 115, up: 115}, {down: 115, up: 115});
|
||||
} finally {
|
||||
delete globalThis.bootstrap;
|
||||
}
|
||||
|
||||
assert.equal(disposed, true);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {lerpGreenToRedViaOrange} from "../helpers/scaling";
|
||||
import {isColorBlindMode} from "../helpers/colorblind";
|
||||
import {colorByRttMs} from "../helpers/color_scales";
|
||||
import {toNumber} from "../lq_js_common/helpers/scaling";
|
||||
import {effectiveMax} from "../helpers/network_rates.mjs";
|
||||
|
||||
/**
|
||||
* Viridis color scale interpolation (0-1 input).
|
||||
@@ -33,10 +34,6 @@ import {isRedacted} from "../helpers/redact";
|
||||
|
||||
let lastRtt = {};
|
||||
|
||||
function effectiveMax(node) {
|
||||
return node.effective_max_throughput || node.max_throughput || [0, 0];
|
||||
}
|
||||
|
||||
class TopTreeSankeyGraph extends DashboardGraph {
|
||||
constructor(id) {
|
||||
super(id);
|
||||
|
||||
@@ -2,10 +2,7 @@ import {clearDiv, simpleRowTrustedHtml, theading} from "../helpers/builders";
|
||||
import {formatThroughput, formatRetransmit, formatCakeStat} from "../helpers/scaling";
|
||||
import {toNumber} from "../lq_js_common/helpers/scaling";
|
||||
import {DashletBaseInsight} from "./insight_dashlet_base";
|
||||
|
||||
function effectiveMax(node) {
|
||||
return node.effective_max_throughput || node.max_throughput || [0, 0];
|
||||
}
|
||||
import {effectiveMax} from "../helpers/network_rates.mjs";
|
||||
|
||||
function retransmitPacketsForNode(node, direction) {
|
||||
return toNumber(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {scaleNanos} from "../lq_js_common/helpers/scaling";
|
||||
import {redactCell} from "./redact";
|
||||
import {disposeTooltipsWithin, enableTooltipsWithin} from "../lq_js_common/helpers/tooltips";
|
||||
import {clearElement} from "./dom_clear.mjs";
|
||||
export {safeRelativeHref, simpleLinkRow} from "./safe_dom.mjs";
|
||||
|
||||
export function heading5Icon(icon, text) {
|
||||
@@ -58,10 +59,7 @@ export function clearDashDiv(id, target) {
|
||||
}
|
||||
|
||||
export function clearDiv(target, targetLength=0) {
|
||||
disposeTooltipsWithin(target);
|
||||
while (target.children.length > targetLength) {
|
||||
target.removeChild(target.lastChild);
|
||||
}
|
||||
clearElement(target, targetLength);
|
||||
}
|
||||
|
||||
export function enableTooltips() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {disposeTooltipsWithin} from "../lq_js_common/helpers/tooltips.js";
|
||||
|
||||
export function clearElement(target, targetLength = 0) {
|
||||
disposeTooltipsWithin(target);
|
||||
while (target.children.length > targetLength) {
|
||||
target.removeChild(target.lastChild);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {toNumber} from "../lq_js_common/helpers/scaling.js";
|
||||
|
||||
export const RATE_COMPARE_EPSILON_MBPS = 0.01;
|
||||
|
||||
export function ratePairFromNode(node, field, fallback = [0, 0]) {
|
||||
const pair = node?.[field];
|
||||
if (Array.isArray(pair)) {
|
||||
return [toNumber(pair[0], fallback[0]), toNumber(pair[1], fallback[1])];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function configuredMax(node) {
|
||||
const configured = node?.configured_max_throughput;
|
||||
if (Array.isArray(configured)) {
|
||||
return [toNumber(configured[0], 0), toNumber(configured[1], 0)];
|
||||
}
|
||||
return ratePairFromNode(node, "max_throughput");
|
||||
}
|
||||
|
||||
export function effectiveMax(node) {
|
||||
return ratePairFromNode(node, "effective_max_throughput", configuredMax(node));
|
||||
}
|
||||
|
||||
export function ratesApproximatelyEqual(left, right) {
|
||||
return Math.abs(toNumber(left, 0) - toNumber(right, 0)) <= RATE_COMPARE_EPSILON_MBPS;
|
||||
}
|
||||
|
||||
export function rateBelow(left, right) {
|
||||
return toNumber(left, 0) < toNumber(right, 0) - RATE_COMPARE_EPSILON_MBPS;
|
||||
}
|
||||
|
||||
export function ratePairsMatch(left, right) {
|
||||
return ratesApproximatelyEqual(left?.[0], right?.[0])
|
||||
&& ratesApproximatelyEqual(left?.[1], right?.[1]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { colorByQoqScore, colorByRttMs } from "./helpers/color_scales";
|
||||
import { isColorBlindMode } from "./helpers/colorblind";
|
||||
import { isDarkMode } from "./helpers/dark_mode";
|
||||
import { isRedacted } from "./helpers/redact";
|
||||
import { effectiveMax } from "./helpers/network_rates.mjs";
|
||||
import { scaleNumber, toNumber } from "./lq_js_common/helpers/scaling";
|
||||
import { get_ws_client, subscribeWS } from "./pubsub/ws";
|
||||
|
||||
@@ -276,14 +277,6 @@ function metricColorForMode(mode, metricValue) {
|
||||
return colorByRttMs(numeric);
|
||||
}
|
||||
|
||||
function configuredMaxThroughput(node) {
|
||||
return node?.configured_max_throughput || node?.max_throughput || [0, 0];
|
||||
}
|
||||
|
||||
function effectiveMaxThroughput(node) {
|
||||
return node?.effective_max_throughput || configuredMaxThroughput(node);
|
||||
}
|
||||
|
||||
function averageOrNull(sum, count) {
|
||||
return count > 0 ? (sum / count) : null;
|
||||
}
|
||||
@@ -1258,7 +1251,7 @@ class SiteMapPage {
|
||||
const qooUp = averageOrNull(value.qooUpSum, value.qooUpCount);
|
||||
const rttDownMs = averageOrNull(value.rttDownSum, value.rttDownCount);
|
||||
const rttUpMs = averageOrNull(value.rttUpSum, value.rttUpCount);
|
||||
const maxMbps = effectiveMaxThroughput(node);
|
||||
const maxMbps = effectiveMax(node);
|
||||
const limitDownMbps = toNumber(maxMbps?.[0], 0);
|
||||
const limitUpMbps = toNumber(maxMbps?.[1], 0);
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ import {QooScoreGauge} from "./graphs/qoo_score_gauge";
|
||||
import {enableTooltipsWithin} from "./lq_js_common/helpers/tooltips";
|
||||
import {scaleNumber, toNumber} from "./lq_js_common/helpers/scaling";
|
||||
import {get_ws_client, subscribeWS} from "./pubsub/ws";
|
||||
import {
|
||||
buildNodeLimitSummary,
|
||||
configuredMax,
|
||||
effectiveMax,
|
||||
immediateParentNodeFromTree,
|
||||
ratePairsMatch,
|
||||
ratesApproximatelyEqual,
|
||||
renderEffectiveNowDisplay,
|
||||
renderLimitedByDisplay,
|
||||
} from "./tree_limit_reason.mjs";
|
||||
|
||||
var tree = null;
|
||||
var parent = 0;
|
||||
@@ -49,7 +59,6 @@ const QOO_TOOLTIP_HTML = "<h5>Quality of Outcome (QoO)</h5>" +
|
||||
"<p>Quality of Outcome (QoO) is IETF IPPM “Internet Quality” (draft-ietf-ippm-qoo).<br>" +
|
||||
"https://datatracker.ietf.org/doc/draft-ietf-ippm-qoo/<br>" +
|
||||
"LibreQoS implements a latency and loss-based model to estimate quality of outcome.</p>";
|
||||
const THROUGHPUT_COMPARE_EPSILON_MBPS = 0.01;
|
||||
const NODE_OVERRIDE_PENDING_TOOLTIP = "Stored as an operator override. Will be applied to generated network.json on the next scheduler run.";
|
||||
const TOPOLOGY_OVERRIDE_PENDING_TOOLTIP = "Stored as an operator topology override. The selected parent will be applied on the next scheduler run.";
|
||||
|
||||
@@ -216,14 +225,6 @@ function ethernetBadgeClass(tierLabel) {
|
||||
return "text-bg-info";
|
||||
}
|
||||
|
||||
function configuredMax(node) {
|
||||
return node.configured_max_throughput || node.max_throughput || [0, 0];
|
||||
}
|
||||
|
||||
function effectiveMax(node) {
|
||||
return node.effective_max_throughput || configuredMax(node);
|
||||
}
|
||||
|
||||
function rootGaugeMaxAvailable() {
|
||||
return Array.isArray(rootGaugeConfigMax)
|
||||
&& rootGaugeConfigMax.length === 2
|
||||
@@ -241,11 +242,6 @@ function nodeSnapshotGaugeMax(node) {
|
||||
return effectiveMax(node);
|
||||
}
|
||||
|
||||
function ratesMatch(a, b) {
|
||||
return Math.abs(toNumber(a?.[0], 0) - toNumber(b?.[0], 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS
|
||||
&& Math.abs(toNumber(a?.[1], 0) - toNumber(b?.[1], 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS;
|
||||
}
|
||||
|
||||
function formatLimitValue(mbps) {
|
||||
if (toNumber(mbps, 0) === 0) {
|
||||
return "Unlimited";
|
||||
@@ -264,10 +260,6 @@ function formatMbpsInputValue(mbps) {
|
||||
return numeric.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function ratesApproximatelyEqual(left, right) {
|
||||
return Math.abs(toNumber(left, 0) - toNumber(right, 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS;
|
||||
}
|
||||
|
||||
function formatRatePair(downMbps, upMbps) {
|
||||
return `${formatLimitValue(downMbps)} / ${formatLimitValue(upMbps)}`;
|
||||
}
|
||||
@@ -283,6 +275,10 @@ function currentNode() {
|
||||
return tree && tree[parent] ? tree[parent][1] : null;
|
||||
}
|
||||
|
||||
function immediateParentNode(node) {
|
||||
return immediateParentNodeFromTree(tree, node);
|
||||
}
|
||||
|
||||
function currentNodeQuery() {
|
||||
const node = currentNode();
|
||||
if (!node) {
|
||||
@@ -321,7 +317,7 @@ function buildEffectiveLimitCellHtml(node) {
|
||||
function buildConfiguredLimitCellHtml(node) {
|
||||
const configured = configuredMax(node);
|
||||
const effective = effectiveMax(node);
|
||||
const secondaryClass = ratesMatch(effective, configured)
|
||||
const secondaryClass = ratePairsMatch(effective, configured)
|
||||
? "lqos-limit-line lqos-limit-secondary is-match"
|
||||
: "lqos-limit-line lqos-limit-secondary";
|
||||
const configuredValue = `${formatLimitValue(configured[0])} / ${formatLimitValue(configured[1])}`;
|
||||
@@ -333,7 +329,7 @@ function buildDirectionalLimitHtml(primaryMbps, configuredMbps, showConfigured)
|
||||
if (!showConfigured) {
|
||||
return `<div class="lqos-limit-block"><div class="lqos-limit-line">${primary}</div></div>`;
|
||||
}
|
||||
const match = Math.abs(toNumber(primaryMbps, 0) - toNumber(configuredMbps, 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS;
|
||||
const match = ratesApproximatelyEqual(primaryMbps, configuredMbps);
|
||||
const secondaryClass = match
|
||||
? "lqos-limit-line lqos-limit-secondary is-match"
|
||||
: "lqos-limit-line lqos-limit-secondary";
|
||||
@@ -374,6 +370,9 @@ function buildStatusIcon(iconName, textClass, title) {
|
||||
icon.classList.add("fa", "fa-fw", iconName, textClass);
|
||||
icon.setAttribute("data-bs-toggle", "tooltip");
|
||||
icon.setAttribute("data-bs-placement", "top");
|
||||
icon.setAttribute("data-bs-trigger", "hover focus");
|
||||
icon.setAttribute("data-bs-container", "body");
|
||||
icon.setAttribute("tabindex", "0");
|
||||
icon.setAttribute("title", title);
|
||||
return icon;
|
||||
}
|
||||
@@ -625,6 +624,9 @@ function buildTreeRowStatusIcon(iconName, isActive, activeTitle, inactiveTitle)
|
||||
}
|
||||
icon.setAttribute("data-bs-toggle", "tooltip");
|
||||
icon.setAttribute("data-bs-placement", "top");
|
||||
icon.setAttribute("data-bs-trigger", "hover focus");
|
||||
icon.setAttribute("data-bs-container", "body");
|
||||
icon.setAttribute("tabindex", "0");
|
||||
icon.setAttribute("title", isActive ? activeTitle : inactiveTitle);
|
||||
return icon;
|
||||
}
|
||||
@@ -764,7 +766,10 @@ function renderCompatibilityWarningsIndicator(messages) {
|
||||
icon.classList.add("lqos-tree-compat-indicator");
|
||||
icon.setAttribute("data-bs-toggle", "tooltip");
|
||||
icon.setAttribute("data-bs-placement", "top");
|
||||
icon.setAttribute("data-bs-trigger", "hover focus");
|
||||
icon.setAttribute("data-bs-container", "body");
|
||||
icon.setAttribute("data-bs-html", "true");
|
||||
icon.setAttribute("tabindex", "0");
|
||||
icon.setAttribute("title", tooltipHtml);
|
||||
icon.setAttribute("aria-label", `Compatibility warnings: ${visibleMessages.length}`);
|
||||
icon.innerHTML = `<i class="fa fa-triangle-exclamation"></i><span class="lqos-tree-compat-count">${visibleMessages.length}</span>`;
|
||||
@@ -864,6 +869,9 @@ function renderOverrideValue(node, overrideData) {
|
||||
pending.classList.add("lqos-tree-pending");
|
||||
pending.setAttribute("data-bs-toggle", "tooltip");
|
||||
pending.setAttribute("data-bs-placement", "top");
|
||||
pending.setAttribute("data-bs-trigger", "hover focus");
|
||||
pending.setAttribute("data-bs-container", "body");
|
||||
pending.setAttribute("tabindex", "0");
|
||||
pending.setAttribute("title", NODE_OVERRIDE_PENDING_TOOLTIP);
|
||||
|
||||
const symbol = document.createElement("span");
|
||||
@@ -967,6 +975,9 @@ function renderTopologyOverrideValue(overrideData) {
|
||||
pending.classList.add("lqos-tree-pending");
|
||||
pending.setAttribute("data-bs-toggle", "tooltip");
|
||||
pending.setAttribute("data-bs-placement", "top");
|
||||
pending.setAttribute("data-bs-trigger", "hover focus");
|
||||
pending.setAttribute("data-bs-container", "body");
|
||||
pending.setAttribute("tabindex", "0");
|
||||
pending.setAttribute("title", TOPOLOGY_OVERRIDE_PENDING_TOOLTIP);
|
||||
|
||||
const symbol = document.createElement("span");
|
||||
@@ -983,6 +994,23 @@ function renderTopologyOverrideValue(overrideData) {
|
||||
target.appendChild(wrap);
|
||||
}
|
||||
|
||||
function renderEffectiveNowValue(node) {
|
||||
const effectiveTarget = document.getElementById("nodeSettingsEffectiveNow");
|
||||
const limitedByTarget = document.getElementById("nodeSettingsLimitedBy");
|
||||
if (!effectiveTarget && !limitedByTarget) {
|
||||
return;
|
||||
}
|
||||
const effective = effectiveMax(node);
|
||||
const summary = buildNodeLimitSummary({
|
||||
node,
|
||||
parentNode: immediateParentNode(node),
|
||||
rateOverrideData: nodeRateOverrideState.data,
|
||||
topologyOverrideData: nodeTopologyOverrideState.data,
|
||||
});
|
||||
renderEffectiveNowDisplay({target: effectiveTarget, effective, formatRatePair});
|
||||
renderLimitedByDisplay({target: limitedByTarget, summary});
|
||||
}
|
||||
|
||||
function renderNodeTopologySettings(node) {
|
||||
const statusTarget = document.getElementById("nodeTopologyStatus");
|
||||
if (statusTarget) {
|
||||
@@ -1045,11 +1073,7 @@ function renderNodeSettings() {
|
||||
const configured = configuredMax(node);
|
||||
baseConfiguredTarget.textContent = formatRatePair(configured[0], configured[1]);
|
||||
}
|
||||
const effectiveTarget = document.getElementById("nodeSettingsEffectiveNow");
|
||||
if (effectiveTarget) {
|
||||
const effective = effectiveMax(node);
|
||||
effectiveTarget.textContent = formatRatePair(effective[0], effective[1]);
|
||||
}
|
||||
renderEffectiveNowValue(node);
|
||||
const activeAttachmentTarget = document.getElementById("nodeSettingsActiveAttachment");
|
||||
if (activeAttachmentTarget) {
|
||||
activeAttachmentTarget.textContent = node.active_attachment_name || "-";
|
||||
@@ -1423,10 +1447,10 @@ function fillHeader(node) {
|
||||
const configuredUp = formatLimitValue(configured[1]);
|
||||
const effectiveDown = formatLimitValue(effective[0]);
|
||||
const effectiveUp = formatLimitValue(effective[1]);
|
||||
const matchClassDown = Math.abs(toNumber(effective[0], 0) - toNumber(configured[0], 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS
|
||||
const matchClassDown = ratesApproximatelyEqual(effective[0], configured[0])
|
||||
? "lqos-limit-secondary is-match"
|
||||
: "lqos-limit-secondary";
|
||||
const matchClassUp = Math.abs(toNumber(effective[1], 0) - toNumber(configured[1], 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS
|
||||
const matchClassUp = ratesApproximatelyEqual(effective[1], configured[1])
|
||||
? "lqos-limit-secondary is-match"
|
||||
: "lqos-limit-secondary";
|
||||
$("#parentEffectiveD").text(effectiveDown);
|
||||
@@ -1854,6 +1878,8 @@ function renderAttachedCircuitsRows(rows) {
|
||||
badge.setAttribute("aria-label", `Review ${circuit.ethernet_cap_badge.tier_label} Ethernet-limited circuits`);
|
||||
badge.setAttribute("data-bs-toggle", "tooltip");
|
||||
badge.setAttribute("data-bs-placement", "top");
|
||||
badge.setAttribute("data-bs-trigger", "hover focus");
|
||||
badge.setAttribute("data-bs-container", "body");
|
||||
badge.setAttribute("title", formatEthernetTooltip(circuit.ethernet_cap_badge));
|
||||
badge.textContent = circuit.ethernet_cap_badge.tier_label;
|
||||
planCell.appendChild(badge);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import {toNumber} from "./lq_js_common/helpers/scaling.js";
|
||||
import {clearElement} from "./helpers/dom_clear.mjs";
|
||||
import {
|
||||
configuredMax,
|
||||
effectiveMax,
|
||||
rateBelow,
|
||||
ratePairsMatch,
|
||||
ratesApproximatelyEqual,
|
||||
} from "./helpers/network_rates.mjs";
|
||||
|
||||
export {configuredMax, effectiveMax, ratePairsMatch, ratesApproximatelyEqual};
|
||||
|
||||
const COMPACT_REASON_LABELS = {
|
||||
attachment: "Est. Attachment",
|
||||
base: "Base rate",
|
||||
mixed: "Est. Mixed",
|
||||
override: "Est. Override",
|
||||
parent: "Est. Parent",
|
||||
queue: "Est. Queue",
|
||||
};
|
||||
|
||||
function renderKey(target) {
|
||||
if (target?.getAttribute) {
|
||||
return target.getAttribute("data-lqos-render-key");
|
||||
}
|
||||
return target?.attributes?.["data-lqos-render-key"] ?? null;
|
||||
}
|
||||
|
||||
function setRenderKey(target, key) {
|
||||
target.setAttribute("data-lqos-render-key", key);
|
||||
}
|
||||
|
||||
function clearRenderedTarget(target) {
|
||||
clearElement(target);
|
||||
target.textContent = "";
|
||||
}
|
||||
|
||||
export function immediateParentNodeFromTree(tree, node) {
|
||||
const parentIndex = node?.immediate_parent;
|
||||
if (parentIndex === null || parentIndex === undefined || !tree?.[parentIndex]) {
|
||||
return null;
|
||||
}
|
||||
return tree[parentIndex][1] || null;
|
||||
}
|
||||
|
||||
function formatMbps(value) {
|
||||
const numeric = toNumber(value, 0);
|
||||
if (numeric === 0) {
|
||||
return "Unlimited";
|
||||
}
|
||||
return `${numeric.toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
|
||||
function overrideValue(rateOverrideData, direction) {
|
||||
if (!rateOverrideData?.has_override) {
|
||||
return null;
|
||||
}
|
||||
const field = direction === 0
|
||||
? "override_download_bandwidth_mbps"
|
||||
: "override_upload_bandwidth_mbps";
|
||||
const value = rateOverrideData[field];
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function topologyOverrideParentIsActive(topologyOverrideData) {
|
||||
if (!topologyOverrideData?.has_override) {
|
||||
return false;
|
||||
}
|
||||
const desired = Array.isArray(topologyOverrideData.override_parent_node_ids)
|
||||
? topologyOverrideData.override_parent_node_ids[0]
|
||||
: null;
|
||||
return !!desired && desired === topologyOverrideData.current_parent_node_id;
|
||||
}
|
||||
|
||||
function parentDisplayName(parentNode, topologyOverrideData) {
|
||||
return topologyOverrideData?.current_parent_node_name
|
||||
|| parentNode?.name
|
||||
|| "upstream parent";
|
||||
}
|
||||
|
||||
function reasonForDirection({node, parentNode, rateOverrideData, topologyOverrideData, direction}) {
|
||||
const configured = configuredMax(node);
|
||||
const effective = effectiveMax(node);
|
||||
const configuredValue = configured[direction];
|
||||
const effectiveValue = effective[direction];
|
||||
const override = overrideValue(rateOverrideData, direction);
|
||||
const parentEffective = parentNode ? effectiveMax(parentNode)[direction] : null;
|
||||
const directionName = direction === 0 ? "Download" : "Upload";
|
||||
|
||||
if (
|
||||
parentEffective !== null
|
||||
&& ratesApproximatelyEqual(effectiveValue, parentEffective)
|
||||
&& (rateBelow(effectiveValue, configuredValue) || (override !== null && rateBelow(effectiveValue, override)))
|
||||
) {
|
||||
const parentName = parentDisplayName(parentNode, topologyOverrideData);
|
||||
const suffix = topologyOverrideParentIsActive(topologyOverrideData)
|
||||
? " selected by topology override"
|
||||
: "";
|
||||
const attachment = node?.active_attachment_name
|
||||
? ` Active attachment: ${node.active_attachment_name}.`
|
||||
: "";
|
||||
return {
|
||||
kind: "parent",
|
||||
label: "Parent",
|
||||
detail: `${directionName}: inherited from parent ${parentName}${suffix} at ${formatMbps(effectiveValue)}.${attachment}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (override !== null && ratesApproximatelyEqual(effectiveValue, override)) {
|
||||
return {
|
||||
kind: "override",
|
||||
label: "Override",
|
||||
detail: `${directionName}: matches operator rate override at ${formatMbps(effectiveValue)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (node?.active_attachment_name && rateBelow(effectiveValue, configuredValue)) {
|
||||
return {
|
||||
kind: "attachment",
|
||||
label: "Attachment",
|
||||
detail: `${directionName}: capped by active attachment ${node.active_attachment_name} at ${formatMbps(effectiveValue)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (rateBelow(effectiveValue, configuredValue)) {
|
||||
return {
|
||||
kind: "queue",
|
||||
label: "Queue",
|
||||
detail: `${directionName}: effective queue rate is below the configured rate at ${formatMbps(effectiveValue)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "base",
|
||||
label: "Base",
|
||||
detail: `${directionName}: uses configured rate at ${formatMbps(effectiveValue)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNodeLimitSummary({
|
||||
node,
|
||||
parentNode = null,
|
||||
rateOverrideData = null,
|
||||
topologyOverrideData = null,
|
||||
} = {}) {
|
||||
const directions = [0, 1].map((direction) => reasonForDirection({
|
||||
node,
|
||||
parentNode,
|
||||
rateOverrideData,
|
||||
topologyOverrideData,
|
||||
direction,
|
||||
}));
|
||||
const sameKind = directions[0].kind === directions[1].kind;
|
||||
const label = sameKind ? directions[0].label : "Mixed";
|
||||
return {
|
||||
label,
|
||||
kind: sameKind ? directions[0].kind : "mixed",
|
||||
title: directions.map((direction) => direction.detail).join(" "),
|
||||
compactReason: COMPACT_REASON_LABELS[sameKind ? directions[0].kind : "mixed"],
|
||||
directions,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderEffectiveNowDisplay({target, effective, formatRatePair}) {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const display = formatRatePair(effective[0], effective[1]);
|
||||
const key = `effective:${display}`;
|
||||
if (renderKey(target) === key) {
|
||||
return;
|
||||
}
|
||||
clearRenderedTarget(target);
|
||||
setRenderKey(target, key);
|
||||
const doc = target.ownerDocument || document;
|
||||
const wrap = doc.createElement("span");
|
||||
wrap.classList.add("lqos-tree-detail-value", "lqos-tree-effective-now");
|
||||
wrap.textContent = display;
|
||||
target.appendChild(wrap);
|
||||
}
|
||||
|
||||
export function renderLimitedByDisplay({target, summary}) {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const display = summary.compactReason || summary.label;
|
||||
const title = `Estimated effective-rate source. ${summary.title}`;
|
||||
const key = `limited:${display}:${title}`;
|
||||
if (renderKey(target) === key) {
|
||||
return;
|
||||
}
|
||||
clearRenderedTarget(target);
|
||||
setRenderKey(target, key);
|
||||
const doc = target.ownerDocument || document;
|
||||
const source = doc.createElement("span");
|
||||
source.classList.add("lqos-tree-limit-source");
|
||||
source.setAttribute("data-bs-toggle", "tooltip");
|
||||
source.setAttribute("data-bs-placement", "top");
|
||||
source.setAttribute("data-bs-trigger", "hover focus");
|
||||
source.setAttribute("data-bs-container", "body");
|
||||
source.setAttribute("tabindex", "0");
|
||||
source.setAttribute("title", title);
|
||||
source.setAttribute("aria-label", title);
|
||||
|
||||
const text = doc.createElement("span");
|
||||
text.textContent = display;
|
||||
source.appendChild(text);
|
||||
|
||||
const icon = doc.createElement("i");
|
||||
icon.classList.add("fa", "fa-circle-info");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
source.appendChild(icon);
|
||||
|
||||
target.appendChild(source);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {readFileSync} from "node:fs";
|
||||
import {dirname, resolve} from "node:path";
|
||||
import test from "node:test";
|
||||
import {fileURLToPath} from "node:url";
|
||||
|
||||
import {
|
||||
buildNodeLimitSummary,
|
||||
configuredMax,
|
||||
effectiveMax,
|
||||
immediateParentNodeFromTree,
|
||||
ratePairsMatch,
|
||||
ratesApproximatelyEqual,
|
||||
renderEffectiveNowDisplay,
|
||||
renderLimitedByDisplay,
|
||||
} from "./tree_limit_reason.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
class FakeElement {
|
||||
constructor(ownerDocument, tagName) {
|
||||
this.ownerDocument = ownerDocument;
|
||||
this.tagName = tagName;
|
||||
this.attributes = {};
|
||||
this.children = [];
|
||||
this.textContent = "";
|
||||
this.classNames = [];
|
||||
this.classList = {
|
||||
add: (...names) => {
|
||||
this.classNames.push(...names);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get lastChild() {
|
||||
return this.children[this.children.length - 1] || null;
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.children.indexOf(child);
|
||||
if (index >= 0) {
|
||||
this.children.splice(index, 1);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes[name] ?? null;
|
||||
}
|
||||
|
||||
matches(selector) {
|
||||
return selector === '[data-bs-toggle="tooltip"]'
|
||||
&& this.attributes["data-bs-toggle"] === "tooltip";
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const matches = [];
|
||||
const visit = (element) => {
|
||||
if (element.matches?.(selector)) {
|
||||
matches.push(element);
|
||||
}
|
||||
element.children?.forEach(visit);
|
||||
};
|
||||
this.children.forEach(visit);
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
|
||||
const fakeDocument = {
|
||||
createElement(tagName) {
|
||||
return new FakeElement(fakeDocument, tagName);
|
||||
},
|
||||
};
|
||||
|
||||
function node({
|
||||
name = "Selected",
|
||||
configured = [500, 100],
|
||||
effective = configured,
|
||||
activeAttachment = null,
|
||||
} = {}) {
|
||||
return {
|
||||
name,
|
||||
configured_max_throughput: configured,
|
||||
max_throughput: configured,
|
||||
effective_max_throughput: effective,
|
||||
active_attachment_name: activeAttachment,
|
||||
};
|
||||
}
|
||||
|
||||
test("rate helpers fall back from effective to configured rates", () => {
|
||||
const selected = {
|
||||
max_throughput: [100, 50],
|
||||
};
|
||||
|
||||
assert.deepEqual(configuredMax(selected), [100, 50]);
|
||||
assert.deepEqual(effectiveMax(selected), [100, 50]);
|
||||
});
|
||||
|
||||
test("summary identifies parent-limited nodes", () => {
|
||||
const selected = node({configured: [500, 100], effective: [294, 60]});
|
||||
const parent = node({name: "7232 Rochester", configured: [294, 60], effective: [294, 60]});
|
||||
|
||||
const summary = buildNodeLimitSummary({node: selected, parentNode: parent});
|
||||
|
||||
assert.equal(summary.label, "Parent");
|
||||
assert.equal(summary.kind, "parent");
|
||||
assert.equal(summary.compactReason, "Est. Parent");
|
||||
assert.match(summary.title, /Download: inherited from parent 7232 Rochester at 294M\./);
|
||||
assert.match(summary.title, /Upload: inherited from parent 7232 Rochester at 60M\./);
|
||||
});
|
||||
|
||||
test("summary notes active topology parent overrides", () => {
|
||||
const selected = node({
|
||||
configured: [500, 100],
|
||||
effective: [294, 60],
|
||||
activeAttachment: "Backhaul A",
|
||||
});
|
||||
const parent = node({name: "Old Parent", configured: [294, 60], effective: [294, 60]});
|
||||
|
||||
const summary = buildNodeLimitSummary({
|
||||
node: selected,
|
||||
parentNode: parent,
|
||||
topologyOverrideData: {
|
||||
has_override: true,
|
||||
override_parent_node_ids: ["parent-1"],
|
||||
current_parent_node_id: "parent-1",
|
||||
current_parent_node_name: "Pinned Parent",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(summary.label, "Parent");
|
||||
assert.match(summary.title, /Pinned Parent selected by topology override/);
|
||||
assert.match(summary.title, /Active attachment: Backhaul A\./);
|
||||
});
|
||||
|
||||
test("summary identifies operator rate overrides", () => {
|
||||
const selected = node({configured: [500, 100], effective: [250, 80]});
|
||||
|
||||
const summary = buildNodeLimitSummary({
|
||||
node: selected,
|
||||
rateOverrideData: {
|
||||
has_override: true,
|
||||
override_download_bandwidth_mbps: 250,
|
||||
override_upload_bandwidth_mbps: 80,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(summary.label, "Override");
|
||||
assert.match(summary.title, /Download: matches operator rate override at 250M\./);
|
||||
assert.match(summary.title, /Upload: matches operator rate override at 80M\./);
|
||||
});
|
||||
|
||||
test("summary identifies attachment caps when no parent or override explains the rate", () => {
|
||||
const selected = node({
|
||||
configured: [500, 100],
|
||||
effective: [400, 80],
|
||||
activeAttachment: "Backhaul A",
|
||||
});
|
||||
|
||||
const summary = buildNodeLimitSummary({node: selected});
|
||||
|
||||
assert.equal(summary.label, "Attachment");
|
||||
assert.match(summary.title, /Download: capped by active attachment Backhaul A at 400M\./);
|
||||
assert.match(summary.title, /Upload: capped by active attachment Backhaul A at 80M\./);
|
||||
});
|
||||
|
||||
test("summary reports mixed causes when directions differ", () => {
|
||||
const selected = node({configured: [500, 100], effective: [294, 80]});
|
||||
const parent = node({name: "Parent Node", configured: [294, 100], effective: [294, 100]});
|
||||
|
||||
const summary = buildNodeLimitSummary({
|
||||
node: selected,
|
||||
parentNode: parent,
|
||||
rateOverrideData: {
|
||||
has_override: true,
|
||||
override_download_bandwidth_mbps: 400,
|
||||
override_upload_bandwidth_mbps: 80,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(summary.label, "Mixed");
|
||||
assert.equal(summary.kind, "mixed");
|
||||
assert.equal(summary.compactReason, "Est. Mixed");
|
||||
assert.equal(summary.directions[0].kind, "parent");
|
||||
assert.equal(summary.directions[1].kind, "override");
|
||||
});
|
||||
|
||||
test("summary uses explicit compact label for unexplained queue caps", () => {
|
||||
const selected = node({configured: [500, 100], effective: [450, 90]});
|
||||
|
||||
const summary = buildNodeLimitSummary({node: selected});
|
||||
|
||||
assert.equal(summary.label, "Queue");
|
||||
assert.equal(summary.kind, "queue");
|
||||
assert.equal(summary.compactReason, "Est. Queue");
|
||||
});
|
||||
|
||||
test("summary does not treat a missing override direction as overridden", () => {
|
||||
const selected = node({configured: [500, 100], effective: [500, 80]});
|
||||
|
||||
const summary = buildNodeLimitSummary({
|
||||
node: selected,
|
||||
rateOverrideData: {
|
||||
has_override: true,
|
||||
override_download_bandwidth_mbps: null,
|
||||
override_upload_bandwidth_mbps: 80,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(summary.label, "Mixed");
|
||||
assert.equal(summary.directions[0].kind, "base");
|
||||
assert.equal(summary.directions[1].kind, "override");
|
||||
assert.match(summary.directions[0].detail, /Download: uses configured rate at 500M\./);
|
||||
});
|
||||
|
||||
test("summary uses base when effective and configured rates match", () => {
|
||||
const selected = node({configured: [294, 60], effective: [294, 60]});
|
||||
|
||||
const summary = buildNodeLimitSummary({node: selected});
|
||||
|
||||
assert.equal(summary.label, "Base");
|
||||
assert.equal(summary.kind, "base");
|
||||
assert.equal(summary.compactReason, "Base rate");
|
||||
assert.match(summary.title, /Download: uses configured rate at 294M\./);
|
||||
});
|
||||
|
||||
test("rate comparison boundary is shared and explicit", () => {
|
||||
assert.equal(ratesApproximatelyEqual(100, 100.009), true);
|
||||
assert.equal(ratesApproximatelyEqual(100, 100.011), false);
|
||||
assert.equal(ratePairsMatch([100, 50], [100.009, 50]), true);
|
||||
assert.equal(ratePairsMatch([100, 50], [100.011, 50]), false);
|
||||
});
|
||||
|
||||
test("parent lookup follows the selected node's immediate parent index", () => {
|
||||
const parent = node({name: "Parent", configured: [300, 60]});
|
||||
const selected = {
|
||||
...node({name: "Child", configured: [500, 100]}),
|
||||
immediate_parent: 2,
|
||||
};
|
||||
const tree = [
|
||||
[0, node({name: "Root"})],
|
||||
[1, selected],
|
||||
[2, parent],
|
||||
];
|
||||
|
||||
assert.equal(immediateParentNodeFromTree(tree, selected), parent);
|
||||
assert.equal(immediateParentNodeFromTree(tree, {...selected, immediate_parent: null}), null);
|
||||
assert.equal(immediateParentNodeFromTree(tree, {...selected, immediate_parent: 99}), null);
|
||||
});
|
||||
|
||||
test("effective rate renderer keeps the rate value compact", () => {
|
||||
const target = new FakeElement(fakeDocument, "dd");
|
||||
|
||||
renderEffectiveNowDisplay({
|
||||
target,
|
||||
effective: [100, 50],
|
||||
formatRatePair: (down, up) => `${down}M / ${up}M`,
|
||||
});
|
||||
|
||||
const wrap = target.children[0];
|
||||
|
||||
assert.deepEqual(wrap.classNames, ["lqos-tree-detail-value", "lqos-tree-effective-now"]);
|
||||
assert.equal(wrap.children.length, 0);
|
||||
assert.equal(wrap.textContent, "100M / 50M");
|
||||
assert.equal(target.attributes["data-lqos-render-key"], "effective:100M / 50M");
|
||||
});
|
||||
|
||||
test("limited-by renderer builds accessible source detail", () => {
|
||||
const target = new FakeElement(fakeDocument, "dd");
|
||||
const summary = {
|
||||
kind: "parent",
|
||||
label: "Parent",
|
||||
compactReason: "Est. Parent",
|
||||
title: "Download: inherited from parent Alpha at 100M. Upload: inherited from parent Alpha at 50M.",
|
||||
};
|
||||
|
||||
renderLimitedByDisplay({
|
||||
target,
|
||||
summary,
|
||||
});
|
||||
|
||||
const source = target.children[0];
|
||||
const value = source.children[0];
|
||||
const icon = source.children[1];
|
||||
|
||||
assert.deepEqual(source.classNames, ["lqos-tree-limit-source"]);
|
||||
assert.equal(source.children.length, 2);
|
||||
assert.equal(value.textContent, "Est. Parent");
|
||||
assert.equal(icon.attributes["aria-hidden"], "true");
|
||||
assert.equal(source.attributes["data-bs-toggle"], "tooltip");
|
||||
assert.equal(source.attributes["data-bs-trigger"], "hover focus");
|
||||
assert.equal(source.attributes["data-bs-container"], "body");
|
||||
assert.equal(source.attributes.tabindex, "0");
|
||||
assert.equal(source.attributes.title, `Estimated effective-rate source. ${summary.title}`);
|
||||
assert.equal(source.attributes["aria-label"], `Estimated effective-rate source. ${summary.title}`);
|
||||
assert.equal(
|
||||
target.attributes["data-lqos-render-key"],
|
||||
`limited:Est. Parent:Estimated effective-rate source. ${summary.title}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("limited-by renderer preserves stable tooltip state between refreshes", () => {
|
||||
const target = new FakeElement(fakeDocument, "dd");
|
||||
const summary = {
|
||||
kind: "parent",
|
||||
label: "Parent",
|
||||
compactReason: "Est. Parent",
|
||||
title: "Download: inherited from parent Alpha at 100M. Upload: inherited from parent Alpha at 50M.",
|
||||
};
|
||||
const args = {
|
||||
target,
|
||||
summary,
|
||||
};
|
||||
|
||||
renderLimitedByDisplay(args);
|
||||
const oldSource = target.children[0];
|
||||
renderLimitedByDisplay(args);
|
||||
|
||||
assert.equal(target.children.length, 1);
|
||||
assert.equal(target.children[0], oldSource);
|
||||
});
|
||||
|
||||
test("limited-by renderer disposes stale tooltip state when the source changes", () => {
|
||||
const target = new FakeElement(fakeDocument, "dd");
|
||||
const summary = {
|
||||
kind: "parent",
|
||||
label: "Parent",
|
||||
compactReason: "Est. Parent",
|
||||
title: "Download: inherited from parent Alpha at 100M. Upload: inherited from parent Alpha at 50M.",
|
||||
};
|
||||
|
||||
renderLimitedByDisplay({target, summary});
|
||||
const oldSource = target.children[0];
|
||||
let disposed = false;
|
||||
globalThis.bootstrap = {
|
||||
Tooltip: {
|
||||
getInstance(el) {
|
||||
assert.equal(el, oldSource);
|
||||
return {
|
||||
dispose() {
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
renderLimitedByDisplay({
|
||||
target,
|
||||
summary: {
|
||||
...summary,
|
||||
compactReason: "Est. Override",
|
||||
title: "Download: matches operator rate override at 100M. Upload: matches operator rate override at 50M.",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
delete globalThis.bootstrap;
|
||||
}
|
||||
|
||||
assert.equal(disposed, true);
|
||||
assert.equal(target.children.length, 1);
|
||||
});
|
||||
|
||||
test("shared rate helpers preserve configured and effective fallback behavior", () => {
|
||||
const selected = {
|
||||
max_throughput: [100, 50],
|
||||
effective_max_throughput: [90, 40],
|
||||
};
|
||||
|
||||
assert.deepEqual(configuredMax(selected), [100, 50]);
|
||||
assert.deepEqual(effectiveMax(selected), [90, 40]);
|
||||
});
|
||||
|
||||
test("tree details markup keeps compact definition grid IDs", () => {
|
||||
const html = readFileSync(resolve(__dirname, "../../static2/tree.html"), "utf8");
|
||||
|
||||
assert.match(html, /<dl class="lqos-tree-detail-grid">/);
|
||||
const expected = [
|
||||
"nodeSettingsBaseConfigured",
|
||||
"nodeSettingsEffectiveNow",
|
||||
"nodeSettingsOverride",
|
||||
"nodeSettingsLimitedBy",
|
||||
"nodeTopologyOverrideValue",
|
||||
"nodeSettingsActiveAttachment",
|
||||
];
|
||||
expected.forEach((id) => {
|
||||
assert.match(html, new RegExp(`id="${id}"`));
|
||||
});
|
||||
assert.match(
|
||||
html,
|
||||
/<div class="lqos-tree-detail-item lqos-tree-detail-col-start">\s*<dt class="lqos-tree-detail-label">Base Configured Rate<\/dt>\s*<dd class="lqos-tree-detail-body" id="nodeSettingsBaseConfigured">-/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<div class="lqos-tree-detail-item lqos-tree-detail-row-end">\s*<dt class="lqos-tree-detail-label">Effective Now<\/dt>\s*<dd class="lqos-tree-detail-body" id="nodeSettingsEffectiveNow">-/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<div class="lqos-tree-detail-item lqos-tree-detail-row-end">\s*<dt class="lqos-tree-detail-label">Limited By<\/dt>\s*<dd class="lqos-tree-detail-body" id="nodeSettingsLimitedBy">-/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<div class="lqos-tree-detail-item lqos-tree-detail-row-end lqos-tree-detail-last-row lqos-tree-detail-final-cell">\s*<dt class="lqos-tree-detail-label">Active Attachment<\/dt>\s*<dd class="lqos-tree-detail-body" id="nodeSettingsActiveAttachment">-/,
|
||||
);
|
||||
const positions = expected.map((id) => html.indexOf(`id="${id}"`));
|
||||
assert.deepEqual([...positions].sort((a, b) => a - b), positions);
|
||||
});
|
||||
|
||||
test("tree details CSS keeps desktop detail values aligned in four columns", () => {
|
||||
const css = readFileSync(resolve(__dirname, "../../static2/node_manager.css"), "utf8");
|
||||
|
||||
assert.match(css, /\.lqos-tree-detail-grid\s*{[^}]*grid-template-columns:\s*minmax\(8\.5rem, max-content\) minmax\(0, 1fr\)\s*minmax\(8\.5rem, max-content\) minmax\(0, 1fr\);/s);
|
||||
assert.match(css, /\.lqos-tree-detail-item\s*{\s*display: contents;\s*}/);
|
||||
});
|
||||
@@ -11,7 +11,9 @@ fi
|
||||
echo "[test-node-manager] Running frontend contract tests..."
|
||||
node --test \
|
||||
"${SCRIPT_DIR}/src/helpers/builders.test.mjs" \
|
||||
"${SCRIPT_DIR}/src/circuit_rate_display.test.mjs" \
|
||||
"${SCRIPT_DIR}/src/circuit_packet_capture_dom.test.mjs" \
|
||||
"${SCRIPT_DIR}/src/tree_limit_reason.test.mjs" \
|
||||
"${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs" \
|
||||
"${SCRIPT_DIR}/src/config/config_interface_mtu.test.mjs" \
|
||||
"${SCRIPT_DIR}/src/config/network_mode_storage.test.mjs" \
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::node_manager::local_api::ethernet_caps::ethernet_advisory_for_circuit;
|
||||
use crate::shaped_devices_tracker::effective_parent_for_circuit;
|
||||
use lqos_config::{CircuitEthernetMetadata, ShapedDevice};
|
||||
use lqos_queue_tracker::EFFECTIVE_CIRCUIT_RATES;
|
||||
use lqos_utils::normalize_circuit_id_key;
|
||||
use lqos_utils::units::DownUpOrder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Canonical circuit parent resolved from `network.json`.
|
||||
@@ -31,6 +34,8 @@ pub struct CircuitByIdData {
|
||||
pub queue_stats_mode: CircuitQueueStatsMode,
|
||||
/// Optional negotiated-Ethernet advisory derived from integration metadata.
|
||||
pub ethernet_advisory: Option<CircuitEthernetMetadata>,
|
||||
/// Current programmed max rate for the circuit queue, in Mbps.
|
||||
pub effective_rate_mbps: Option<DownUpOrder<f32>>,
|
||||
}
|
||||
|
||||
fn load_ethernet_advisory(
|
||||
@@ -84,8 +89,21 @@ fn queue_stats_mode() -> CircuitQueueStatsMode {
|
||||
CircuitQueueStatsMode::Live
|
||||
}
|
||||
|
||||
fn effective_circuit_rate_mbps_for_key(circuit_key: &str) -> Option<DownUpOrder<f32>> {
|
||||
if circuit_key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
EFFECTIVE_CIRCUIT_RATES
|
||||
.load()
|
||||
.get(circuit_key)
|
||||
.map(|(down, up)| DownUpOrder {
|
||||
down: *down as f32,
|
||||
up: *up as f32,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn circuit_by_id_data(id: &str) -> Option<CircuitByIdData> {
|
||||
let safe_id = id.to_lowercase().trim().to_string();
|
||||
let safe_id = normalize_circuit_id_key(id);
|
||||
let catalog = lqos_network_devices::shaped_devices_catalog();
|
||||
let mut devices: Vec<ShapedDevice> = catalog.devices_for_circuit_id(&safe_id);
|
||||
|
||||
@@ -102,11 +120,121 @@ pub fn circuit_by_id_data(id: &str) -> Option<CircuitByIdData> {
|
||||
let parent_node = circuit_parent_node(&safe_id, &mut devices);
|
||||
let queue_stats_mode = queue_stats_mode();
|
||||
let ethernet_advisory = load_ethernet_advisory(&safe_id, &devices);
|
||||
let effective_rate_mbps = effective_circuit_rate_mbps_for_key(&safe_id);
|
||||
Some(CircuitByIdData {
|
||||
devices,
|
||||
parent_node,
|
||||
queue_stats_mode,
|
||||
ethernet_advisory,
|
||||
effective_rate_mbps,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::runtime_config_test_lock;
|
||||
use lqos_config::ConfigShapedDevices;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, MutexGuard};
|
||||
|
||||
static EFFECTIVE_RATE_TEST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
|
||||
struct EffectiveRateSnapshot {
|
||||
previous_rates: Arc<HashMap<String, (f64, f64)>>,
|
||||
}
|
||||
|
||||
impl EffectiveRateSnapshot {
|
||||
fn replace(rates: HashMap<String, (f64, f64)>) -> Self {
|
||||
let previous_rates = EFFECTIVE_CIRCUIT_RATES.load_full();
|
||||
EFFECTIVE_CIRCUIT_RATES.store(Arc::new(rates));
|
||||
Self { previous_rates }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EffectiveRateSnapshot {
|
||||
fn drop(&mut self) {
|
||||
EFFECTIVE_CIRCUIT_RATES.store(self.previous_rates.clone());
|
||||
}
|
||||
}
|
||||
|
||||
struct ShapedDevicesSnapshot {
|
||||
_runtime_guard: MutexGuard<'static, ()>,
|
||||
previous_shaped: Arc<ConfigShapedDevices>,
|
||||
}
|
||||
|
||||
impl ShapedDevicesSnapshot {
|
||||
fn replace(devices: Vec<ShapedDevice>) -> Self {
|
||||
let runtime_guard = runtime_config_test_lock()
|
||||
.lock()
|
||||
.expect("runtime config test lock should not be poisoned");
|
||||
let mut shaped = ConfigShapedDevices::default();
|
||||
shaped.replace_with_new_data(devices);
|
||||
let previous_shaped = lqos_network_devices::swap_shaped_devices_snapshot(
|
||||
"circuit-effective-rate-test",
|
||||
Arc::new(shaped),
|
||||
);
|
||||
Self {
|
||||
_runtime_guard: runtime_guard,
|
||||
previous_shaped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ShapedDevicesSnapshot {
|
||||
fn drop(&mut self) {
|
||||
lqos_network_devices::swap_shaped_devices_snapshot(
|
||||
"circuit-effective-rate-test-restore",
|
||||
self.previous_shaped.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_circuit_rate_lookup_uses_normalized_circuit_key() {
|
||||
let _guard = EFFECTIVE_RATE_TEST_LOCK.lock();
|
||||
let mut rates = HashMap::new();
|
||||
rates.insert("circuit-42".to_string(), (115.0, 25.0));
|
||||
let _snapshot = EffectiveRateSnapshot::replace(rates);
|
||||
|
||||
let rate = effective_circuit_rate_mbps_for_key("circuit-42");
|
||||
|
||||
assert_eq!(
|
||||
rate,
|
||||
Some(DownUpOrder {
|
||||
down: 115.0,
|
||||
up: 25.0
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circuit_by_id_data_includes_effective_circuit_rate() {
|
||||
let _guard = EFFECTIVE_RATE_TEST_LOCK.lock();
|
||||
let _shaped_snapshot = ShapedDevicesSnapshot::replace(vec![ShapedDevice {
|
||||
circuit_id: "Circuit-42".to_string(),
|
||||
circuit_name: "Circuit 42".to_string(),
|
||||
device_id: "device-42".to_string(),
|
||||
device_name: "Device 42".to_string(),
|
||||
download_max_mbps: 115.0,
|
||||
upload_max_mbps: 25.0,
|
||||
..ShapedDevice::default()
|
||||
}]);
|
||||
let mut rates = HashMap::new();
|
||||
rates.insert("circuit-42".to_string(), (100.0, 20.0));
|
||||
let _rate_snapshot = EffectiveRateSnapshot::replace(rates);
|
||||
|
||||
let data = circuit_by_id_data(" Circuit-42 ").expect("test circuit should resolve");
|
||||
|
||||
assert_eq!(
|
||||
data.effective_rate_mbps,
|
||||
Some(DownUpOrder {
|
||||
down: 100.0,
|
||||
up: 20.0
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use lqos_config::{CircuitEthernetMetadata, CircuitEthernetMetadataFile, load_config};
|
||||
use lqos_utils::normalize_circuit_id_key;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -170,7 +171,7 @@ pub(crate) fn ethernet_cap_badge_map() -> HashMap<String, EthernetCapBadge> {
|
||||
let Some(badge) = advisory_to_badge(&advisory) else {
|
||||
continue;
|
||||
};
|
||||
badges.insert(advisory.circuit_id.to_ascii_lowercase(), badge);
|
||||
badges.insert(normalize_circuit_id_key(&advisory.circuit_id), badge);
|
||||
}
|
||||
badges
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot;
|
||||
use crate::shaped_devices_tracker::effective_parent_for_circuit;
|
||||
use fxhash::{FxHashMap, FxHashSet};
|
||||
use lqos_config::ShapedDevice;
|
||||
use lqos_utils::normalize_circuit_id_key;
|
||||
use lqos_utils::units::{DownUpOrder, TcpRetransmitSample};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
@@ -285,7 +286,7 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC
|
||||
circuit_id: row.circuit_id.clone(),
|
||||
circuit_name: row.circuit_name.clone(),
|
||||
ethernet_cap_badge: ethernet_badges
|
||||
.get(&row.circuit_id.to_ascii_lowercase())
|
||||
.get(&normalize_circuit_id_key(&row.circuit_id))
|
||||
.cloned(),
|
||||
parent_node: live
|
||||
.map(|live| live.parent_node.clone())
|
||||
|
||||
@@ -11,18 +11,20 @@
|
||||
<tr>
|
||||
<td class="table-label-cell">Max</td>
|
||||
<td class="table-value-cell">
|
||||
<span id="bwMax"></span>
|
||||
<a
|
||||
id="ethernetAdvisoryBadge"
|
||||
class="badge rounded-pill text-bg-warning ms-2 d-none"
|
||||
href="#"
|
||||
tabindex="0"
|
||||
aria-label="Ethernet speed warning"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
>
|
||||
<i class="fa fa-ethernet me-1"></i><span id="ethernetAdvisoryBadgeText"></span>
|
||||
</a>
|
||||
<span class="lqos-inline-value-badge">
|
||||
<span id="bwMax" class="lqos-inline-value-badge-text"></span>
|
||||
<a
|
||||
id="ethernetAdvisoryBadge"
|
||||
class="badge rounded-pill text-bg-warning ms-2 d-none lqos-inline-value-badge-item"
|
||||
href="#"
|
||||
tabindex="0"
|
||||
aria-label="Ethernet speed warning"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
>
|
||||
<i class="fa fa-ethernet me-1"></i><span id="ethernetAdvisoryBadgeText"></span>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -707,6 +707,25 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.lqos-inline-value-badge {
|
||||
align-items: baseline;
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lqos-inline-value-badge-text {
|
||||
display: inline-block;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lqos-inline-value-badge-item {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.flow-rtt-exclude-btn.btn.btn-sm {
|
||||
padding-top: 0.175rem;
|
||||
padding-bottom: 0.175rem;
|
||||
@@ -1834,16 +1853,81 @@ h5 {
|
||||
flex: 1 1 auto;
|
||||
align-content: center;
|
||||
}
|
||||
.lqos-tree-state-panel .lqos-table-wrap,
|
||||
.lqos-tree-details-panel .lqos-table-wrap {
|
||||
.lqos-tree-state-panel .lqos-table-wrap {
|
||||
height: 100%;
|
||||
}
|
||||
.lqos-tree-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(8.5rem, max-content) minmax(0, 1fr)
|
||||
minmax(8.5rem, max-content) minmax(0, 1fr);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--lqos-border);
|
||||
border-radius: var(--lqos-radius-sm);
|
||||
background: color-mix(in srgb, var(--lqos-surface-2) 60%, transparent);
|
||||
}
|
||||
.lqos-tree-detail-item {
|
||||
display: contents;
|
||||
}
|
||||
.lqos-tree-detail-label,
|
||||
.lqos-tree-detail-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border-bottom: 1px solid var(--lqos-border);
|
||||
}
|
||||
.lqos-tree-detail-col-start > .lqos-tree-detail-body {
|
||||
border-right: 1px solid var(--lqos-border);
|
||||
}
|
||||
.lqos-tree-detail-last-row > .lqos-tree-detail-label,
|
||||
.lqos-tree-detail-last-row > .lqos-tree-detail-body {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lqos-tree-detail-label {
|
||||
color: var(--lqos-muted);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lqos-tree-detail-body {
|
||||
margin: 0;
|
||||
color: var(--lqos-text);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.lqos-tree-detail-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.lqos-tree-effective-now {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lqos-tree-limit-source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.08rem 0.35rem;
|
||||
margin-left: -0.35rem;
|
||||
border-radius: var(--lqos-radius-sm);
|
||||
color: var(--lqos-text);
|
||||
text-decoration: underline dotted color-mix(in srgb, var(--lqos-muted) 80%, transparent);
|
||||
text-underline-offset: 0.22rem;
|
||||
cursor: help;
|
||||
}
|
||||
.lqos-tree-limit-source:focus-visible {
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.lqos-tree-limit-source .fa {
|
||||
color: var(--bs-primary);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.lqos-tree-inline-overrides {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1852,6 +1936,34 @@ h5 {
|
||||
.lqos-tree-inline-overrides > div:empty {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1199.98px) {
|
||||
.lqos-tree-detail-grid {
|
||||
grid-template-columns: minmax(7.75rem, max-content) minmax(0, 1fr);
|
||||
}
|
||||
.lqos-tree-detail-col-start > .lqos-tree-detail-body {
|
||||
border-right: 0;
|
||||
}
|
||||
.lqos-tree-detail-row-end:not(.lqos-tree-detail-final-cell) > .lqos-tree-detail-label,
|
||||
.lqos-tree-detail-row-end:not(.lqos-tree-detail-final-cell) > .lqos-tree-detail-body {
|
||||
border-bottom: 1px solid var(--lqos-border);
|
||||
}
|
||||
.lqos-tree-detail-last-row:not(.lqos-tree-detail-final-cell) > .lqos-tree-detail-label,
|
||||
.lqos-tree-detail-last-row:not(.lqos-tree-detail-final-cell) > .lqos-tree-detail-body {
|
||||
border-bottom: 1px solid var(--lqos-border);
|
||||
}
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
.lqos-tree-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.lqos-tree-detail-label {
|
||||
padding-bottom: 0.2rem;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lqos-tree-detail-body {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
.lqos-tree-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -76,32 +76,32 @@
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<h5 class="mb-0"><i class="fa fa-circle-info"></i> Node Details</h5>
|
||||
</div>
|
||||
<div class="lqos-table-wrap">
|
||||
<table class="lqos-table lqos-table-compact">
|
||||
<tbody>
|
||||
<tr class="small">
|
||||
<td class="table-label-cell">Base Configured Rate</td>
|
||||
<td class="table-value-cell" id="nodeSettingsBaseConfigured">-</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<td class="table-label-cell">Rate Override</td>
|
||||
<td class="table-value-cell" id="nodeSettingsOverride"></td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<td class="table-label-cell">Topology Override</td>
|
||||
<td class="table-value-cell" id="nodeTopologyOverrideValue"></td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<td class="table-label-cell">Active Attachment</td>
|
||||
<td class="table-value-cell" id="nodeSettingsActiveAttachment">-</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<td class="table-label-cell">Effective Now</td>
|
||||
<td class="table-value-cell" id="nodeSettingsEffectiveNow">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<dl class="lqos-tree-detail-grid">
|
||||
<div class="lqos-tree-detail-item lqos-tree-detail-col-start">
|
||||
<dt class="lqos-tree-detail-label">Base Configured Rate</dt>
|
||||
<dd class="lqos-tree-detail-body" id="nodeSettingsBaseConfigured">-</dd>
|
||||
</div>
|
||||
<div class="lqos-tree-detail-item lqos-tree-detail-row-end">
|
||||
<dt class="lqos-tree-detail-label">Effective Now</dt>
|
||||
<dd class="lqos-tree-detail-body" id="nodeSettingsEffectiveNow">-</dd>
|
||||
</div>
|
||||
<div class="lqos-tree-detail-item lqos-tree-detail-col-start">
|
||||
<dt class="lqos-tree-detail-label">Rate Override</dt>
|
||||
<dd class="lqos-tree-detail-body" id="nodeSettingsOverride"></dd>
|
||||
</div>
|
||||
<div class="lqos-tree-detail-item lqos-tree-detail-row-end">
|
||||
<dt class="lqos-tree-detail-label">Limited By</dt>
|
||||
<dd class="lqos-tree-detail-body" id="nodeSettingsLimitedBy">-</dd>
|
||||
</div>
|
||||
<div class="lqos-tree-detail-item lqos-tree-detail-col-start lqos-tree-detail-last-row">
|
||||
<dt class="lqos-tree-detail-label">Topology Override</dt>
|
||||
<dd class="lqos-tree-detail-body" id="nodeTopologyOverrideValue"></dd>
|
||||
</div>
|
||||
<div class="lqos-tree-detail-item lqos-tree-detail-row-end lqos-tree-detail-last-row lqos-tree-detail-final-cell">
|
||||
<dt class="lqos-tree-detail-label">Active Attachment</dt>
|
||||
<dd class="lqos-tree-detail-body" id="nodeSettingsActiveAttachment">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="lqos-tree-inline-overrides">
|
||||
<div id="nodeOverrideDisabledReason"></div>
|
||||
<div id="nodeOverrideEditorSection">
|
||||
|
||||
@@ -58,12 +58,12 @@ pub(crate) fn invalidate_executive_cache_snapshot() {
|
||||
crate::node_manager::invalidate_executive_cache_snapshot();
|
||||
}
|
||||
|
||||
fn normalize_circuit_id_key(circuit_id: &str) -> Option<String> {
|
||||
let trimmed = circuit_id.trim();
|
||||
if trimmed.is_empty() {
|
||||
fn non_empty_circuit_id_key(circuit_id: &str) -> Option<String> {
|
||||
let key = lqos_utils::normalize_circuit_id_key(circuit_id);
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_ascii_lowercase())
|
||||
Some(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ fn build_effective_circuit_parent_map(
|
||||
let mut by_circuit_id = FxHashMap::default();
|
||||
by_circuit_id.reserve(shaping_inputs.circuits.len());
|
||||
for circuit in &shaping_inputs.circuits {
|
||||
let Some(circuit_key) = normalize_circuit_id_key(&circuit.circuit_id) else {
|
||||
let Some(circuit_key) = non_empty_circuit_id_key(&circuit.circuit_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(parent_name) = optional_trimmed_string(&circuit.effective_parent_node_name) else {
|
||||
@@ -358,7 +358,7 @@ fn watch_for_topology_runtime_status_changing() -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn effective_parent_for_circuit(circuit_id: &str) -> Option<RuntimeCircuitParent> {
|
||||
let circuit_key = normalize_circuit_id_key(circuit_id)?;
|
||||
let circuit_key = non_empty_circuit_id_key(circuit_id)?;
|
||||
EFFECTIVE_CIRCUIT_PARENTS.load().get(&circuit_key).cloned()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user