From 337c862c739a93a03890f629de4e3450c85d45ae Mon Sep 17 00:00:00 2001 From: rchac Date: Sun, 28 Jun 2026 11:28:33 -0600 Subject: [PATCH 1/4] changes --- src/rust/Cargo.lock | 1 + src/rust/lqos_bakery/Cargo.toml | 1 + src/rust/lqos_bakery/src/lib.rs | 5 +- src/rust/lqos_network_devices/src/actor.rs | 5 +- src/rust/lqos_network_devices/src/catalog.rs | 6 +- .../src/combined_catalog.rs | 6 +- .../lqos_network_devices/src/dynamic_store.rs | 6 +- src/rust/lqos_queue_tracker/src/lib.rs | 4 +- .../src/queue_structure/mod.rs | 3 +- .../queing_structure_json_monitor.rs | 169 +++++++++++++++--- src/rust/lqos_utils/src/lib.rs | 15 ++ .../src/node_manager/js_build/src/circuit.js | 100 +++++++++-- .../js_build/src/circuit_rate_display.mjs | 69 +++++++ .../src/circuit_rate_display.test.mjs | 153 ++++++++++++++++ .../js_build/test-node-manager.sh | 1 + .../src/node_manager/local_api/circuit.rs | 130 +++++++++++++- .../node_manager/local_api/ethernet_caps.rs | 3 +- .../local_api/tree_attached_circuits.rs | 3 +- .../src/node_manager/static2/circuit.html | 26 +-- .../src/node_manager/static2/node_manager.css | 19 ++ .../lqosd/src/shaped_devices_tracker/mod.rs | 12 +- 21 files changed, 654 insertions(+), 83 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/circuit_rate_display.mjs create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/circuit_rate_display.test.mjs diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 010d228a..1988d9ba 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2213,6 +2213,7 @@ dependencies = [ "lqos_bus", "lqos_config", "lqos_sys", + "lqos_utils", "parking_lot", "serde", "serde_json", diff --git a/src/rust/lqos_bakery/Cargo.toml b/src/rust/lqos_bakery/Cargo.toml index 93ca5c97..377a6be7 100644 --- a/src/rust/lqos_bakery/Cargo.toml +++ b/src/rust/lqos_bakery/Cargo.toml @@ -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 diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index 624251b3..3771bbdf 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -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, used_up: &HashSet, diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs index f0011143..db822230 100644 --- a/src/rust/lqos_network_devices/src/actor.rs +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -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, hooks: Option 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); diff --git a/src/rust/lqos_network_devices/src/catalog.rs b/src/rust/lqos_network_devices/src/catalog.rs index bb1a085e..2d6538bc 100644 --- a/src/rust/lqos_network_devices/src/catalog.rs +++ b/src/rust/lqos_network_devices/src/catalog.rs @@ -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 { - 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() } diff --git a/src/rust/lqos_network_devices/src/combined_catalog.rs b/src/rust/lqos_network_devices/src/combined_catalog.rs index 0c9d105a..ade0a6f2 100644 --- a/src/rust/lqos_network_devices/src/combined_catalog.rs +++ b/src/rust/lqos_network_devices/src/combined_catalog.rs @@ -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 diff --git a/src/rust/lqos_network_devices/src/dynamic_store.rs b/src/rust/lqos_network_devices/src/dynamic_store.rs index 27a7cb86..a5785fd0 100644 --- a/src/rust/lqos_network_devices/src/dynamic_store.rs +++ b/src/rust/lqos_network_devices/src/dynamic_store.rs @@ -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(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"); diff --git a/src/rust/lqos_queue_tracker/src/lib.rs b/src/rust/lqos_queue_tracker/src/lib.rs index cebcae6a..a75734da 100644 --- a/src/rust/lqos_queue_tracker/src/lib.rs +++ b/src/rust/lqos_queue_tracker/src/lib.rs @@ -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; diff --git a/src/rust/lqos_queue_tracker/src/queue_structure/mod.rs b/src/rust/lqos_queue_tracker/src/queue_structure/mod.rs index 6d33280c..65008b7e 100644 --- a/src/rust/lqos_queue_tracker/src/queue_structure/mod.rs +++ b/src/rust/lqos_queue_tracker/src/queue_structure/mod.rs @@ -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)] diff --git a/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs b/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs index 7d1783b4..f23f59de 100644 --- a/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs +++ b/src/rust/lqos_queue_tracker/src/queue_structure/queing_structure_json_monitor.rs @@ -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> = Lazy::new(|| ArcSwap::new(Arc::new(QueueStructure::new()))); +static INITIAL_EFFECTIVE_RATES: Lazy = 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>> = 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>> = 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>, } +#[derive(Default)] +struct EffectiveRates { + nodes: HashMap, + circuits: HashMap, +} + 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 { - 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))); + } +} diff --git a/src/rust/lqos_utils/src/lib.rs b/src/rust/lqos_utils/src/lib.rs index 9eac26d7..59e77f98 100644 --- a/src/rust/lqos_utils/src/lib.rs +++ b/src/rust/lqos_utils/src/lib.rs @@ -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"); + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js index 3334f7c1..5ce42555 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js @@ -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; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/circuit_rate_display.mjs b/src/rust/lqosd/src/node_manager/js_build/src/circuit_rate_display.mjs new file mode 100644 index 00000000..1e6be746 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/circuit_rate_display.mjs @@ -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); + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/circuit_rate_display.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/circuit_rate_display.test.mjs new file mode 100644 index 00000000..65540847 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/circuit_rate_display.test.mjs @@ -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); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh index 1e3f83b8..8adc94ec 100755 --- a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh +++ b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh @@ -11,6 +11,7 @@ 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/config/shaped_device_wire.test.mjs" \ "${SCRIPT_DIR}/src/config/config_interface_mtu.test.mjs" \ diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit.rs b/src/rust/lqosd/src/node_manager/local_api/circuit.rs index 479a1eb1..2771cabe 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit.rs @@ -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, + /// Current programmed max rate for the circuit queue, in Mbps. + pub effective_rate_mbps: Option>, } 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> { + 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 { - 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 = catalog.devices_for_circuit_id(&safe_id); @@ -102,11 +120,121 @@ pub fn circuit_by_id_data(id: &str) -> Option { 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> = Lazy::new(|| Mutex::new(())); + + struct EffectiveRateSnapshot { + previous_rates: Arc>, + } + + impl EffectiveRateSnapshot { + fn replace(rates: HashMap) -> 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, + } + + impl ShapedDevicesSnapshot { + fn replace(devices: Vec) -> 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 + }) + ); + } +} diff --git a/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs b/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs index 4fd1bbad..8bb82705 100644 --- a/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs +++ b/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs @@ -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 { 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 } diff --git a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs index 0bdc42cc..e9cdabb1 100644 --- a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs +++ b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs @@ -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()) diff --git a/src/rust/lqosd/src/node_manager/static2/circuit.html b/src/rust/lqosd/src/node_manager/static2/circuit.html index c8419811..6106827d 100644 --- a/src/rust/lqosd/src/node_manager/static2/circuit.html +++ b/src/rust/lqosd/src/node_manager/static2/circuit.html @@ -11,18 +11,20 @@ Max - - - - + + + + + + diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 9713e807..b09977ff 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -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; diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index e1a64021..7e2f3a73 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -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 { - let trimmed = circuit_id.trim(); - if trimmed.is_empty() { +fn non_empty_circuit_id_key(circuit_id: &str) -> Option { + 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 { - 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() } From 326843530eeff72ff433f9a4b40356abd3ba39f3 Mon Sep 17 00:00:00 2001 From: rchac Date: Sun, 28 Jun 2026 13:00:35 -0600 Subject: [PATCH 2/4] changes --- docs-es/v2.0/node-manager-ui-es.md | 3 +- docs/v2.0/node-manager-ui.md | 3 +- .../js_build/src/dashlets/top_tree_sankey.js | 5 +- .../js_build/src/dashlets/top_tree_summary.js | 5 +- .../js_build/src/helpers/builders.js | 6 +- .../js_build/src/helpers/dom_clear.mjs | 8 + .../js_build/src/helpers/network_rates.mjs | 36 ++ .../src/node_manager/js_build/src/site_map.js | 11 +- .../src/node_manager/js_build/src/tree.js | 77 ++-- .../js_build/src/tree_limit_reason.mjs | 195 ++++++++++ .../js_build/src/tree_limit_reason.test.mjs | 368 ++++++++++++++++++ .../js_build/test-node-manager.sh | 1 + .../src/node_manager/static2/node_manager.css | 69 +++- .../lqosd/src/node_manager/static2/tree.html | 48 ++- 14 files changed, 757 insertions(+), 78 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/helpers/dom_clear.mjs create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/helpers/network_rates.mjs create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 8b9eb31c..0826efba 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -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. diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index bc0065ea..51ecd5b3 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -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. diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_sankey.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_sankey.js index e2b9810a..1b257b8d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_sankey.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_sankey.js @@ -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); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_summary.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_summary.js index cfc47aea..9dc94c14 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_summary.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_summary.js @@ -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( diff --git a/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js b/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js index 320b3791..5861b947 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js @@ -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() { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/helpers/dom_clear.mjs b/src/rust/lqosd/src/node_manager/js_build/src/helpers/dom_clear.mjs new file mode 100644 index 00000000..df138a7b --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/dom_clear.mjs @@ -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); + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/helpers/network_rates.mjs b/src/rust/lqosd/src/node_manager/js_build/src/helpers/network_rates.mjs new file mode 100644 index 00000000..12fe8afd --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/network_rates.mjs @@ -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]); +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 36e9dd8b..9d0c33bc 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -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); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree.js b/src/rust/lqosd/src/node_manager/js_build/src/tree.js index e5e545f8..bd8c26d8 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree.js @@ -11,6 +11,15 @@ 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, +} from "./tree_limit_reason.mjs"; var tree = null; var parent = 0; @@ -49,7 +58,6 @@ const QOO_TOOLTIP_HTML = "
Quality of Outcome (QoO)
" + "

Quality of Outcome (QoO) is IETF IPPM “Internet Quality” (draft-ietf-ippm-qoo).
" + "https://datatracker.ietf.org/doc/draft-ietf-ippm-qoo/
" + "LibreQoS implements a latency and loss-based model to estimate quality of outcome.

"; -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 +224,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 +241,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 +259,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 +274,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 +316,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 +328,7 @@ function buildDirectionalLimitHtml(primaryMbps, configuredMbps, showConfigured) if (!showConfigured) { return `
${primary}
`; } - 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 +369,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 +623,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 +765,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 = `${visibleMessages.length}`; @@ -864,6 +868,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 +974,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 +993,21 @@ function renderTopologyOverrideValue(overrideData) { target.appendChild(wrap); } +function renderEffectiveNowValue(node) { + const target = document.getElementById("nodeSettingsEffectiveNow"); + if (!target) { + return; + } + const effective = effectiveMax(node); + const summary = buildNodeLimitSummary({ + node, + parentNode: immediateParentNode(node), + rateOverrideData: nodeRateOverrideState.data, + topologyOverrideData: nodeTopologyOverrideState.data, + }); + renderEffectiveNowDisplay({target, effective, summary, formatRatePair}); +} + function renderNodeTopologySettings(node) { const statusTarget = document.getElementById("nodeTopologyStatus"); if (statusTarget) { @@ -1045,11 +1070,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 +1444,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 +1875,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); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs new file mode 100644 index 00000000..ab89e456 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs @@ -0,0 +1,195 @@ +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", +}; + +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 limitSourceBadgeClass(kind) { + switch (kind) { + case "parent": + return "text-bg-warning"; + case "override": + return "text-bg-info"; + case "attachment": + return "text-bg-primary"; + case "mixed": + return "text-bg-secondary"; + case "queue": + return "text-bg-danger"; + default: + return "text-bg-dark"; + } +} + +export function renderEffectiveNowDisplay({target, effective, summary, formatRatePair}) { + if (!target) { + return; + } + clearElement(target); + const doc = target.ownerDocument || document; + const wrap = doc.createElement("span"); + wrap.classList.add("lqos-tree-detail-value", "lqos-tree-effective-now"); + + const value = doc.createElement("span"); + value.textContent = formatRatePair(effective[0], effective[1]); + wrap.appendChild(value); + + const badge = doc.createElement("span"); + badge.classList.add("badge", "rounded-pill", "lqos-tree-limit-source", limitSourceBadgeClass(summary.kind)); + badge.textContent = summary.compactReason || summary.label; + 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("tabindex", "0"); + badge.setAttribute("title", `Estimated effective-rate source. ${summary.title}`); + badge.setAttribute("aria-label", `Estimated effective-rate source. ${summary.title}`); + wrap.appendChild(badge); + + target.appendChild(wrap); +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs new file mode 100644 index 00000000..77a3c341 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs @@ -0,0 +1,368 @@ +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, +} 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); + } + + 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 builds accessible source badge", () => { + 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.", + }; + + renderEffectiveNowDisplay({ + target, + effective: [100, 50], + summary, + formatRatePair: (down, up) => `${down}M / ${up}M`, + }); + + const wrap = target.children[0]; + const value = wrap.children[0]; + const badge = wrap.children[1]; + + assert.deepEqual(wrap.classNames, ["lqos-tree-detail-value", "lqos-tree-effective-now"]); + assert.equal(wrap.children.length, 2); + assert.equal(value.textContent, "100M / 50M"); + assert.equal(badge.textContent, "Est. Parent"); + assert.ok(badge.classNames.includes("lqos-tree-limit-source")); + assert.ok(badge.classNames.includes("text-bg-warning")); + assert.equal(badge.attributes["data-bs-toggle"], "tooltip"); + assert.equal(badge.attributes["data-bs-trigger"], "hover focus"); + assert.equal(badge.attributes["data-bs-container"], "body"); + assert.equal(badge.attributes.tabindex, "0"); + assert.equal(badge.attributes.title, `Estimated effective-rate source. ${summary.title}`); + assert.equal(badge.attributes["aria-label"], `Estimated effective-rate source. ${summary.title}`); +}); + +test("effective rate renderer disposes stale tooltip state before rebuilding", () => { + 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, + effective: [100, 50], + summary, + formatRatePair: (down, up) => `${down}M / ${up}M`, + }; + + renderEffectiveNowDisplay(args); + const oldBadge = target.children[0].children[1]; + let disposed = false; + globalThis.bootstrap = { + Tooltip: { + getInstance(el) { + assert.equal(el, oldBadge); + return { + dispose() { + disposed = true; + }, + }; + }, + }, + }; + + try { + renderEffectiveNowDisplay(args); + } 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, /
/); + const expected = [ + "nodeSettingsBaseConfigured", + "nodeSettingsEffectiveNow", + "nodeSettingsOverride", + "nodeTopologyOverrideValue", + "nodeSettingsActiveAttachment", + ]; + expected.forEach((id) => { + assert.match(html, new RegExp(`id="${id}"`)); + }); + assert.match( + html, + /
\s*
Base Configured Rate<\/dt>\s*
-/, + ); + assert.match( + html, + /
\s*
Effective Now<\/dt>\s*
-/, + ); + assert.match( + html, + /
\s*
Active Attachment<\/dt>\s*
-/, + ); + const positions = expected.map((id) => html.indexOf(`id="${id}"`)); + assert.deepEqual([...positions].sort((a, b) => a - b), positions); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh index 8adc94ec..554554dd 100755 --- a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh +++ b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh @@ -13,6 +13,7 @@ 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" \ diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index b09977ff..8991982a 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -1853,16 +1853,63 @@ 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: repeat(2, 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: grid; + grid-template-columns: minmax(7.75rem, max-content) minmax(0, 1fr); + gap: 0.6rem; + align-items: center; + min-width: 0; + padding: 0.58rem 0.7rem; + border-bottom: 1px solid var(--lqos-border); +} +.lqos-tree-detail-item:nth-child(odd) { + border-right: 1px solid var(--lqos-border); +} +.lqos-tree-detail-item-wide { + grid-column: 1 / -1; + border-right: 0; + border-bottom: 0; +} +.lqos-tree-detail-label { + color: var(--lqos-muted); + font-size: 0.86rem; + font-weight: 650; + line-height: 1.2; +} +.lqos-tree-detail-body { + min-width: 0; + 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 { + flex-wrap: nowrap; + max-width: 100%; +} +.lqos-tree-limit-source { + flex: 0 0 auto; + cursor: help; +} .lqos-tree-inline-overrides { display: flex; flex-direction: column; @@ -1871,6 +1918,24 @@ h5 { .lqos-tree-inline-overrides > div:empty { display: none; } +@media (max-width: 1199.98px) { + .lqos-tree-detail-grid { + grid-template-columns: 1fr; + } + .lqos-tree-detail-item, + .lqos-tree-detail-item:nth-child(odd) { + border-right: 0; + } +} +@media (max-width: 575.98px) { + .lqos-tree-detail-item { + grid-template-columns: 1fr; + gap: 0.25rem; + } + .lqos-tree-effective-now { + flex-wrap: wrap; + } +} .lqos-tree-editor { display: flex; flex-direction: column; diff --git a/src/rust/lqosd/src/node_manager/static2/tree.html b/src/rust/lqosd/src/node_manager/static2/tree.html index 2e8b8a47..ca31c631 100644 --- a/src/rust/lqosd/src/node_manager/static2/tree.html +++ b/src/rust/lqosd/src/node_manager/static2/tree.html @@ -76,32 +76,28 @@
Node Details
-
- - - - - - - - - - - - - - - - - - - - - - - -
Base Configured Rate-
Rate Override
Topology Override
Active Attachment-
Effective Now-
-
+
+
+
Base Configured Rate
+
-
+
+
+
Effective Now
+
-
+
+
+
Rate Override
+
+
+
+
Topology Override
+
+
+
+
Active Attachment
+
-
+
+
From f45204119a0201147d27e8e09096911ba94bbe3d Mon Sep 17 00:00:00 2001 From: rchac Date: Sun, 28 Jun 2026 13:46:11 -0600 Subject: [PATCH 3/4] changes --- .../src/node_manager/js_build/src/tree.js | 9 ++- .../js_build/src/tree_limit_reason.mjs | 65 ++++++++-------- .../js_build/src/tree_limit_reason.test.mjs | 74 +++++++++++-------- .../src/node_manager/static2/node_manager.css | 38 +++++++--- .../lqosd/src/node_manager/static2/tree.html | 14 ++-- 5 files changed, 117 insertions(+), 83 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree.js b/src/rust/lqosd/src/node_manager/js_build/src/tree.js index bd8c26d8..ef2475d4 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree.js @@ -19,6 +19,7 @@ import { ratePairsMatch, ratesApproximatelyEqual, renderEffectiveNowDisplay, + renderLimitedByDisplay, } from "./tree_limit_reason.mjs"; var tree = null; @@ -994,8 +995,9 @@ function renderTopologyOverrideValue(overrideData) { } function renderEffectiveNowValue(node) { - const target = document.getElementById("nodeSettingsEffectiveNow"); - if (!target) { + const effectiveTarget = document.getElementById("nodeSettingsEffectiveNow"); + const limitedByTarget = document.getElementById("nodeSettingsLimitedBy"); + if (!effectiveTarget && !limitedByTarget) { return; } const effective = effectiveMax(node); @@ -1005,7 +1007,8 @@ function renderEffectiveNowValue(node) { rateOverrideData: nodeRateOverrideState.data, topologyOverrideData: nodeTopologyOverrideState.data, }); - renderEffectiveNowDisplay({target, effective, summary, formatRatePair}); + renderEffectiveNowDisplay({target: effectiveTarget, effective, formatRatePair}); + renderLimitedByDisplay({target: limitedByTarget, summary}); } function renderNodeTopologySettings(node) { diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs index ab89e456..f69e2cf7 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs @@ -149,24 +149,7 @@ export function buildNodeLimitSummary({ }; } -export function limitSourceBadgeClass(kind) { - switch (kind) { - case "parent": - return "text-bg-warning"; - case "override": - return "text-bg-info"; - case "attachment": - return "text-bg-primary"; - case "mixed": - return "text-bg-secondary"; - case "queue": - return "text-bg-danger"; - default: - return "text-bg-dark"; - } -} - -export function renderEffectiveNowDisplay({target, effective, summary, formatRatePair}) { +export function renderEffectiveNowDisplay({target, effective, formatRatePair}) { if (!target) { return; } @@ -174,22 +157,34 @@ export function renderEffectiveNowDisplay({target, effective, summary, formatRat const doc = target.ownerDocument || document; const wrap = doc.createElement("span"); wrap.classList.add("lqos-tree-detail-value", "lqos-tree-effective-now"); - - const value = doc.createElement("span"); - value.textContent = formatRatePair(effective[0], effective[1]); - wrap.appendChild(value); - - const badge = doc.createElement("span"); - badge.classList.add("badge", "rounded-pill", "lqos-tree-limit-source", limitSourceBadgeClass(summary.kind)); - badge.textContent = summary.compactReason || summary.label; - 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("tabindex", "0"); - badge.setAttribute("title", `Estimated effective-rate source. ${summary.title}`); - badge.setAttribute("aria-label", `Estimated effective-rate source. ${summary.title}`); - wrap.appendChild(badge); - + wrap.textContent = formatRatePair(effective[0], effective[1]); target.appendChild(wrap); } + +export function renderLimitedByDisplay({target, summary}) { + if (!target) { + return; + } + clearElement(target); + 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", `Estimated effective-rate source. ${summary.title}`); + source.setAttribute("aria-label", `Estimated effective-rate source. ${summary.title}`); + + const text = doc.createElement("span"); + text.textContent = summary.compactReason || summary.label; + 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); +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs index 77a3c341..f83f7d0d 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs @@ -12,6 +12,7 @@ import { ratePairsMatch, ratesApproximatelyEqual, renderEffectiveNowDisplay, + renderLimitedByDisplay, } from "./tree_limit_reason.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -252,7 +253,23 @@ test("parent lookup follows the selected node's immediate parent index", () => { assert.equal(immediateParentNodeFromTree(tree, {...selected, immediate_parent: 99}), null); }); -test("effective rate renderer builds accessible source badge", () => { +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"); +}); + +test("limited-by renderer builds accessible source detail", () => { const target = new FakeElement(fakeDocument, "dd"); const summary = { kind: "parent", @@ -261,32 +278,28 @@ test("effective rate renderer builds accessible source badge", () => { title: "Download: inherited from parent Alpha at 100M. Upload: inherited from parent Alpha at 50M.", }; - renderEffectiveNowDisplay({ + renderLimitedByDisplay({ target, - effective: [100, 50], summary, - formatRatePair: (down, up) => `${down}M / ${up}M`, }); - const wrap = target.children[0]; - const value = wrap.children[0]; - const badge = wrap.children[1]; + const source = target.children[0]; + const value = source.children[0]; + const icon = source.children[1]; - assert.deepEqual(wrap.classNames, ["lqos-tree-detail-value", "lqos-tree-effective-now"]); - assert.equal(wrap.children.length, 2); - assert.equal(value.textContent, "100M / 50M"); - assert.equal(badge.textContent, "Est. Parent"); - assert.ok(badge.classNames.includes("lqos-tree-limit-source")); - assert.ok(badge.classNames.includes("text-bg-warning")); - assert.equal(badge.attributes["data-bs-toggle"], "tooltip"); - assert.equal(badge.attributes["data-bs-trigger"], "hover focus"); - assert.equal(badge.attributes["data-bs-container"], "body"); - assert.equal(badge.attributes.tabindex, "0"); - assert.equal(badge.attributes.title, `Estimated effective-rate source. ${summary.title}`); - assert.equal(badge.attributes["aria-label"], `Estimated effective-rate source. ${summary.title}`); + 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}`); }); -test("effective rate renderer disposes stale tooltip state before rebuilding", () => { +test("limited-by renderer disposes stale tooltip state before rebuilding", () => { const target = new FakeElement(fakeDocument, "dd"); const summary = { kind: "parent", @@ -296,18 +309,16 @@ test("effective rate renderer disposes stale tooltip state before rebuilding", ( }; const args = { target, - effective: [100, 50], summary, - formatRatePair: (down, up) => `${down}M / ${up}M`, }; - renderEffectiveNowDisplay(args); - const oldBadge = target.children[0].children[1]; + renderLimitedByDisplay(args); + const oldSource = target.children[0]; let disposed = false; globalThis.bootstrap = { Tooltip: { getInstance(el) { - assert.equal(el, oldBadge); + assert.equal(el, oldSource); return { dispose() { disposed = true; @@ -318,7 +329,7 @@ test("effective rate renderer disposes stale tooltip state before rebuilding", ( }; try { - renderEffectiveNowDisplay(args); + renderLimitedByDisplay(args); } finally { delete globalThis.bootstrap; } @@ -345,6 +356,7 @@ test("tree details markup keeps compact definition grid IDs", () => { "nodeSettingsBaseConfigured", "nodeSettingsEffectiveNow", "nodeSettingsOverride", + "nodeSettingsLimitedBy", "nodeTopologyOverrideValue", "nodeSettingsActiveAttachment", ]; @@ -353,15 +365,19 @@ test("tree details markup keeps compact definition grid IDs", () => { }); assert.match( html, - /
\s*
Base Configured Rate<\/dt>\s*
-/, + /
\s*
Base Configured Rate<\/dt>\s*
-/, ); assert.match( html, - /
\s*
Effective Now<\/dt>\s*
-/, + /
\s*
Effective Now<\/dt>\s*
-/, ); assert.match( html, - /
\s*
Active Attachment<\/dt>\s*
-/, + /
\s*
Limited By<\/dt>\s*
-/, + ); + assert.match( + html, + /
\s*
Active Attachment<\/dt>\s*
-/, ); const positions = expected.map((id) => html.indexOf(`id="${id}"`)); assert.deepEqual([...positions].sort((a, b) => a - b), positions); diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index 8991982a..c18a818f 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -1874,12 +1874,10 @@ h5 { padding: 0.58rem 0.7rem; border-bottom: 1px solid var(--lqos-border); } -.lqos-tree-detail-item:nth-child(odd) { +.lqos-tree-detail-col-start { border-right: 1px solid var(--lqos-border); } -.lqos-tree-detail-item-wide { - grid-column: 1 / -1; - border-right: 0; +.lqos-tree-detail-last-row { border-bottom: 0; } .lqos-tree-detail-label { @@ -1903,13 +1901,28 @@ h5 { gap: 0.4rem; } .lqos-tree-effective-now { - flex-wrap: nowrap; - max-width: 100%; + white-space: nowrap; } .lqos-tree-limit-source { - flex: 0 0 auto; + 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; @@ -1923,18 +1936,21 @@ h5 { grid-template-columns: 1fr; } .lqos-tree-detail-item, - .lqos-tree-detail-item:nth-child(odd) { + .lqos-tree-detail-col-start { border-right: 0; } + .lqos-tree-detail-row-end:not(.lqos-tree-detail-final-cell) { + border-bottom: 1px solid var(--lqos-border); + } + .lqos-tree-detail-last-row:not(.lqos-tree-detail-final-cell) { + border-bottom: 1px solid var(--lqos-border); + } } @media (max-width: 575.98px) { .lqos-tree-detail-item { grid-template-columns: 1fr; gap: 0.25rem; } - .lqos-tree-effective-now { - flex-wrap: wrap; - } } .lqos-tree-editor { display: flex; diff --git a/src/rust/lqosd/src/node_manager/static2/tree.html b/src/rust/lqosd/src/node_manager/static2/tree.html index ca31c631..fb0e6510 100644 --- a/src/rust/lqosd/src/node_manager/static2/tree.html +++ b/src/rust/lqosd/src/node_manager/static2/tree.html @@ -77,23 +77,27 @@
Node Details
-
+
Base Configured Rate
-
-
+
Effective Now
-
-
+
Rate Override
-
+
+
Limited By
+
-
+
+
Topology Override
-
+
Active Attachment
-
From 63f76d99ec5e21071b25a0b3c59e7214d1b541c9 Mon Sep 17 00:00:00 2001 From: rchac Date: Sun, 28 Jun 2026 13:54:32 -0600 Subject: [PATCH 4/4] changes --- .../js_build/src/tree_limit_reason.mjs | 41 ++++++++++++++--- .../js_build/src/tree_limit_reason.test.mjs | 44 ++++++++++++++++++- .../src/node_manager/static2/node_manager.css | 40 +++++++++++------ 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs index f69e2cf7..ca7b7737 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs @@ -19,6 +19,22 @@ const COMPACT_REASON_LABELS = { 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]) { @@ -153,11 +169,17 @@ export function renderEffectiveNowDisplay({target, effective, formatRatePair}) { if (!target) { return; } - clearElement(target); + 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 = formatRatePair(effective[0], effective[1]); + wrap.textContent = display; target.appendChild(wrap); } @@ -165,7 +187,14 @@ export function renderLimitedByDisplay({target, summary}) { if (!target) { return; } - clearElement(target); + 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"); @@ -174,11 +203,11 @@ export function renderLimitedByDisplay({target, summary}) { source.setAttribute("data-bs-trigger", "hover focus"); source.setAttribute("data-bs-container", "body"); source.setAttribute("tabindex", "0"); - source.setAttribute("title", `Estimated effective-rate source. ${summary.title}`); - source.setAttribute("aria-label", `Estimated effective-rate source. ${summary.title}`); + source.setAttribute("title", title); + source.setAttribute("aria-label", title); const text = doc.createElement("span"); - text.textContent = summary.compactReason || summary.label; + text.textContent = display; source.appendChild(text); const icon = doc.createElement("i"); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs index f83f7d0d..d0c212ba 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs +++ b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs @@ -53,6 +53,10 @@ class FakeElement { 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"; @@ -267,6 +271,7 @@ test("effective rate renderer keeps the rate value compact", () => { 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", () => { @@ -297,9 +302,13 @@ test("limited-by renderer builds accessible source detail", () => { 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 disposes stale tooltip state before rebuilding", () => { +test("limited-by renderer preserves stable tooltip state between refreshes", () => { const target = new FakeElement(fakeDocument, "dd"); const summary = { kind: "parent", @@ -314,6 +323,23 @@ test("limited-by renderer disposes stale tooltip state before rebuilding", () => 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: { @@ -329,7 +355,14 @@ test("limited-by renderer disposes stale tooltip state before rebuilding", () => }; try { - renderLimitedByDisplay(args); + 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; } @@ -382,3 +415,10 @@ test("tree details markup keeps compact definition grid IDs", () => { 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*}/); +}); diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index c18a818f..1a765e92 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -1858,7 +1858,9 @@ h5 { } .lqos-tree-detail-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + 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); @@ -1866,18 +1868,21 @@ h5 { background: color-mix(in srgb, var(--lqos-surface-2) 60%, transparent); } .lqos-tree-detail-item { - display: grid; - grid-template-columns: minmax(7.75rem, max-content) minmax(0, 1fr); - gap: 0.6rem; + 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-col-start > .lqos-tree-detail-body { border-right: 1px solid var(--lqos-border); } -.lqos-tree-detail-last-row { +.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 { @@ -1885,9 +1890,9 @@ h5 { font-size: 0.86rem; font-weight: 650; line-height: 1.2; + white-space: nowrap; } .lqos-tree-detail-body { - min-width: 0; margin: 0; color: var(--lqos-text); font-size: 0.95rem; @@ -1933,23 +1938,30 @@ h5 { } @media (max-width: 1199.98px) { .lqos-tree-detail-grid { - grid-template-columns: 1fr; + grid-template-columns: minmax(7.75rem, max-content) minmax(0, 1fr); } - .lqos-tree-detail-item, - .lqos-tree-detail-col-start { + .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-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-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-item { + .lqos-tree-detail-grid { grid-template-columns: 1fr; - gap: 0.25rem; + } + .lqos-tree-detail-label { + padding-bottom: 0.2rem; + border-bottom: 0; + } + .lqos-tree-detail-body { + padding-top: 0; } } .lqos-tree-editor {