diff --git a/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs index f725db28..0a0b3565 100644 --- a/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs +++ b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs @@ -207,4 +207,50 @@ mod tests { 1 ); } + + #[test] + fn config_roundtrips_with_multiple_dynamic_circuit_ranges() { + let mut config = Config::default(); + + let ipv4_rule = DynamicCircuitRangeRule { + name: "IPv4 default".to_string(), + ip_range: "0.0.0.0/0" + .parse() + .expect("default ipv4 network should parse"), + download_min_mbps: 10.0, + upload_min_mbps: 10.0, + download_max_mbps: 100.0, + upload_max_mbps: 100.0, + attach_to: "Tower-A".to_string(), + }; + let ipv6_rule = DynamicCircuitRangeRule { + name: "IPv6 subscribers".to_string(), + ip_range: "2001:db8::/48" + .parse() + .expect("default ipv6 network should parse"), + download_min_mbps: 25.0, + upload_min_mbps: 5.0, + download_max_mbps: 250.0, + upload_max_mbps: 50.0, + attach_to: String::new(), + }; + + config.dynamic_circuits = Some(DynamicCircuitsConfig { + enabled: true, + ttl_seconds: 300, + enable_unknown_ip_promotion: true, + ranges: vec![ipv4_rule.clone(), ipv6_rule.clone()], + }); + + let raw = toml::to_string_pretty(&config).expect("config should serialize to TOML"); + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + let ranges = &parsed + .dynamic_circuits + .as_ref() + .expect("dynamic circuits should deserialize") + .ranges; + assert_eq!(ranges.len(), 2); + assert_eq!(ranges[0], ipv4_rule); + assert_eq!(ranges[1], ipv6_rule); + } } diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index 9bc37563..1bd0c6da 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -616,16 +616,18 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec lqos_bus::BusResponse::Ack } BusRequest::UpdateLqosDTuning(..) => tuning::tune_lqosd_from_bus(req), - BusRequest::UpdateLqosdConfig(config) => { - let result = lqos_config::update_config(config); - if result.is_err() { - error!("Error updating config: {:?}", result); + BusRequest::UpdateLqosdConfig(config) => match lqos_config::update_config(config) { + Ok(()) => { + if let Ok(cfg) = lqos_config::load_config() { + let _ = stick::recompute_stick_offset(&cfg); + } + BusResponse::Ack } - if let Ok(cfg) = lqos_config::load_config() { - let _ = stick::recompute_stick_offset(&cfg); + Err(err) => { + error!("Error updating config: {err:?}"); + BusResponse::Fail(err.to_string()) } - BusResponse::Ack - } + }, BusRequest::CreateDynamicCircuit { shaped_device } => { crate::dynamic_circuits::create_dynamic_circuit((**shaped_device).clone()) } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index cd677401..536cfe1c 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -2,27 +2,39 @@ import { get_ws_client } from "../pubsub/ws"; const wsClient = get_ws_client(); const secretBindings = []; +const CONFIG_SAVE_TIMEOUT_MS = 10000; -export function sendWsRequest(responseEvent, request, onComplete, onError) { +export function sendWsRequest(responseEvent, request, onComplete, onError, options = {}) { let done = false; - const responseHandler = (msg) => { + let timeoutId = null; + const finish = (callback, msg) => { if (done) return; done = true; + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } wsClient.off(responseEvent, responseHandler); wsClient.off("Error", errorHandler); - onComplete(msg); + if (callback) { + callback(msg); + } + }; + const responseHandler = (msg) => { + finish(onComplete, msg); }; const errorHandler = (msg) => { - if (done) return; - done = true; - wsClient.off(responseEvent, responseHandler); - wsClient.off("Error", errorHandler); - if (onError) { - onError(msg); - } + finish(onError, msg); }; wsClient.on(responseEvent, responseHandler); wsClient.on("Error", errorHandler); + if (Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) { + timeoutId = setTimeout(() => { + finish(onError, { + message: options.timeoutMessage || `Timed out waiting for ${responseEvent}`, + }); + }, options.timeoutMs); + } wsClient.send(request); } @@ -144,6 +156,17 @@ export function loadConfig(onComplete, onError) { export function saveConfig(onComplete, onError) { const clearSecrets = window.configSecretClears || {}; + const reportError = (msg) => { + const errorMsg = (msg && msg.message) ? msg.message : "Request failed"; + const detail = (msg && typeof msg === "object") + ? { ...msg, message: errorMsg } + : { message: errorMsg }; + if (onError) { + onError(detail); + } else { + alert("Error saving configuration: " + errorMsg); + } + }; sendWsRequest( "UpdateConfigResult", { @@ -153,29 +176,29 @@ export function saveConfig(onComplete, onError) { }, }, (msg) => { - if (msg && msg.ok) { - secretBindings.forEach((binding) => { - const sectionState = secretFieldMap(window.configSecretState || {}, binding.section); - const inputValue = binding.input.value.trim(); - sectionState[binding.field] = clearSecrets?.[binding.section]?.[binding.field] - ? false - : (inputValue.length > 0 || sectionState[binding.field]); - if (window.config?.[binding.section] && typeof window.config[binding.section] === "object") { - window.config[binding.section][binding.field] = ""; - } - binding.input.value = ""; - setSecretClear(binding.section, binding.field, false); - binding.updateStatus(); - }); + if (!msg || !msg.ok) { + reportError(msg); + return; } + secretBindings.forEach((binding) => { + const sectionState = secretFieldMap(window.configSecretState || {}, binding.section); + const inputValue = binding.input.value.trim(); + sectionState[binding.field] = clearSecrets?.[binding.section]?.[binding.field] + ? false + : (inputValue.length > 0 || sectionState[binding.field]); + if (window.config?.[binding.section] && typeof window.config[binding.section] === "object") { + window.config[binding.section][binding.field] = ""; + } + binding.input.value = ""; + setSecretClear(binding.section, binding.field, false); + binding.updateStatus(); + }); if (onComplete) onComplete(msg); }, - (msg) => { - if (onError) { - onError(msg); - } else { - alert("That didn't work"); - } + reportError, + { + timeoutMs: CONFIG_SAVE_TIMEOUT_MS, + timeoutMessage: "Timed out waiting for the config save result. The websocket may have disconnected.", }, ); } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js b/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js index f485de9b..bb82c4aa 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js @@ -21,6 +21,8 @@ let totalRows = 0; let totalCircuits = 0; let circuitMetricsWatchSignature = null; const wsClient = get_ws_client(); +const DYNAMIC_VIEW_MODE_STORAGE_KEY = "lqosShapedDevicesDynamicViewMode"; +let dynamicViewMode = loadDynamicViewMode(); const QOO_TOOLTIP_HTML = "
Quality of Outcome (QoO)
" + @@ -75,6 +77,38 @@ function formatPlanValue(value) { return formatted; } +function normalizeDynamicViewMode(mode) { + return mode === "list" ? "list" : "cards"; +} + +function loadDynamicViewMode() { + try { + if (window.localStorage) { + return normalizeDynamicViewMode(window.localStorage.getItem(DYNAMIC_VIEW_MODE_STORAGE_KEY)); + } + } catch (_error) { + // Ignore storage failures and fall back to the default cards view. + } + return "cards"; +} + +function persistDynamicViewMode(mode) { + try { + if (window.localStorage) { + window.localStorage.setItem( + DYNAMIC_VIEW_MODE_STORAGE_KEY, + normalizeDynamicViewMode(mode), + ); + } + } catch (_error) { + // Ignore storage failures and keep the current in-memory preference. + } +} + +function isDynamicListView() { + return shapedDevicesKind === "Dynamic" && dynamicViewMode === "list"; +} + function countCircuits() { return totalCircuits; } @@ -125,6 +159,16 @@ function setShapedDevicesKind(kind) { requestShapedDevicesPage(); } +function setDynamicViewMode(mode) { + const normalized = normalizeDynamicViewMode(mode); + if (dynamicViewMode === normalized) { + return; + } + dynamicViewMode = normalized; + persistDynamicViewMode(dynamicViewMode); + renderDevices(); +} + function filterDevices() { page = 0; requestShapedDevicesPage(); @@ -304,6 +348,96 @@ function metricTableRow(labelEl, downEl, upEl) { return tr; } +function buildDeviceIdentity(device, options = {}) { + const { + circuitClasses = [], + deviceClasses = [], + } = options; + const wrapper = document.createElement("div"); + wrapper.style.minWidth = "0"; + + if (device.circuit_id) { + const circuitLink = document.createElement("a"); + circuitLink.href = "circuit.html?id=" + encodeURI(device.circuit_id); + circuitClasses.forEach((className) => circuitLink.classList.add(className)); + circuitLink.innerText = device.circuit_name || "(Unknown circuit)"; + wrapper.appendChild(circuitLink); + + if (device.device_name) { + const deviceLink = document.createElement("a"); + deviceLink.href = "circuit.html?id=" + encodeURI(device.circuit_id); + deviceClasses.forEach((className) => deviceLink.classList.add(className)); + deviceLink.innerText = device.device_name; + wrapper.appendChild(deviceLink); + } + } else { + const circuitName = document.createElement("div"); + circuitClasses.forEach((className) => circuitName.classList.add(className)); + circuitName.innerText = device.circuit_name || "(Unknown circuit)"; + wrapper.appendChild(circuitName); + + if (device.device_name) { + const deviceName = document.createElement("div"); + deviceClasses.forEach((className) => deviceName.classList.add(className)); + deviceName.innerText = device.device_name; + wrapper.appendChild(deviceName); + } + } + + return wrapper; +} + +function buildPlanBadge(device, extraClasses = []) { + const badge = document.createElement("span"); + badge.classList.add("badge", "text-bg-secondary", "exec-badge", ...extraClasses); + badge.innerText = + formatPlanValue(device.download_max_mbps) + " / " + formatPlanValue(device.upload_max_mbps) + " Mbps"; + return badge; +} + +function buildMetricValueEl() { + const span = document.createElement("span"); + span.innerHTML = "-"; + return span; +} + +function buildMetricPairCell(circuitId, downMetricName, upMetricName) { + const wrapper = document.createElement("div"); + wrapper.classList.add("lqos-direction-metric"); + + const addLine = (label, metricName) => { + const line = document.createElement("div"); + line.classList.add("lqos-direction-metric-line"); + + const labelEl = document.createElement("span"); + labelEl.classList.add("lqos-direction-metric-label"); + labelEl.innerText = label; + line.appendChild(labelEl); + + const valueEl = buildMetricValueEl(); + line.appendChild(valueEl); + wrapper.appendChild(line); + registerMetricEl(circuitId, metricName, valueEl); + }; + + addLine("DL", downMetricName); + addLine("UL", upMetricName); + + return wrapper; +} + +function appendListCell(row, content, classNames = []) { + const td = document.createElement("td"); + classNames.forEach((className) => td.classList.add(className)); + if (typeof content === "string") { + td.innerText = content; + } else if (content) { + td.appendChild(content); + } + row.appendChild(td); + return td; +} + function buildDeviceCard(device) { const card = document.createElement("div"); card.classList.add("executive-card", "h-100"); @@ -312,37 +446,12 @@ function buildDeviceCard(device) { const header = document.createElement("div"); header.classList.add("d-flex", "justify-content-between", "align-items-start", "gap-2"); - const titleWrap = document.createElement("div"); - titleWrap.style.minWidth = "0"; + const titleWrap = buildDeviceIdentity(device, { + circuitClasses: ["redactable", "fw-semibold", "text-decoration-none"], + deviceClasses: ["redactable", "d-block", "small", "text-body-secondary", "text-decoration-none"], + }); - if (device.circuit_id) { - const circuitLink = document.createElement("a"); - circuitLink.href = "circuit.html?id=" + encodeURI(device.circuit_id); - circuitLink.classList.add("redactable", "fw-semibold", "text-decoration-none"); - circuitLink.innerText = device.circuit_name || "(Unknown circuit)"; - titleWrap.appendChild(circuitLink); - - const deviceLink = document.createElement("a"); - deviceLink.href = "circuit.html?id=" + encodeURI(device.circuit_id); - deviceLink.classList.add("redactable", "d-block", "small", "text-body-secondary", "text-decoration-none"); - deviceLink.innerText = device.device_name || ""; - titleWrap.appendChild(deviceLink); - } else { - const circuitName = document.createElement("div"); - circuitName.classList.add("fw-semibold", "redactable"); - circuitName.innerText = device.circuit_name || "(Unknown circuit)"; - titleWrap.appendChild(circuitName); - - const deviceName = document.createElement("div"); - deviceName.classList.add("small", "text-body-secondary", "redactable"); - deviceName.innerText = device.device_name || ""; - titleWrap.appendChild(deviceName); - } - - const planBadge = document.createElement("span"); - planBadge.classList.add("badge", "text-bg-secondary", "exec-badge", "ms-auto"); - planBadge.innerText = - formatPlanValue(device.download_max_mbps) + " / " + formatPlanValue(device.upload_max_mbps) + " Mbps"; + const planBadge = buildPlanBadge(device, ["ms-auto"]); header.appendChild(titleWrap); header.appendChild(planBadge); @@ -446,19 +555,57 @@ function buildDeviceCard(device) { return card; } +function buildDeviceListRow(device) { + const row = document.createElement("tr"); + row.classList.add("align-middle"); + + const identity = buildDeviceIdentity(device, { + circuitClasses: ["redactable", "fw-semibold", "text-decoration-none"], + deviceClasses: ["redactable", "d-block", "small", "text-body-secondary", "text-decoration-none"], + }); + appendListCell(row, identity); + + const parent = document.createElement("div"); + parent.classList.add("small", "redactable"); + parent.innerText = device.parent_node || "-"; + appendListCell(row, parent); + + appendListCell(row, buildPlanBadge(device)); + appendListCell(row, buildMetricPairCell(device.circuit_id, "tpDown", "tpUp"), ["text-nowrap"]); + appendListCell(row, buildMetricPairCell(device.circuit_id, "rttDown", "rttUp"), ["text-nowrap"]); + appendListCell(row, buildMetricPairCell(device.circuit_id, "qooDown", "qooUp"), ["text-nowrap"]); + appendListCell(row, buildMetricPairCell(device.circuit_id, "reXmitDown", "reXmitUp"), ["text-nowrap"]); + + const lastSeenValue = document.createElement("span"); + lastSeenValue.innerText = "-"; + appendListCell(row, lastSeenValue, ["text-nowrap"]); + registerMetricEl(device.circuit_id, "lastSeen", lastSeenValue); + + appendListCell(row, buildIpListEl(device), ["text-nowrap"]); + + return row; +} + function ensureLayout() { const target = document.getElementById("deviceTable"); if (!target) return null; let toolbar = document.getElementById("sdToolbar"); let grid = document.getElementById("sdCardsGrid"); - if (toolbar && grid) { + let listWrap = document.getElementById("sdListWrap"); + let listBody = document.getElementById("sdListBody"); + if (toolbar && grid && listWrap && listBody) { return { target, toolbar, grid, + listWrap, + listBody, searchInput: document.getElementById("sdSearch"), perPageSelect: document.getElementById("sdPerPage"), + viewToggleWrap: document.getElementById("sdViewToggleWrap"), + viewCardsButton: document.getElementById("sdViewCards"), + viewListButton: document.getElementById("sdViewList"), prevButton: document.getElementById("sdPrevPage"), nextButton: document.getElementById("sdNextPage"), pageCounter: document.getElementById("sdPageCounter"), @@ -529,6 +676,41 @@ function ensureLayout() { perPageWrap.appendChild(perPageSelect); toolbar.appendChild(perPageWrap); + const viewToggleWrap = document.createElement("div"); + viewToggleWrap.id = "sdViewToggleWrap"; + viewToggleWrap.classList.add("d-none", "d-flex", "align-items-center", "gap-1"); + const viewLabel = document.createElement("span"); + viewLabel.classList.add("small", "text-body-secondary"); + viewLabel.innerText = "View"; + const viewToggle = document.createElement("div"); + viewToggle.classList.add("btn-group", "btn-group-sm", "lqos-shaped-devices-view-toggle"); + viewToggle.setAttribute("role", "group"); + viewToggle.setAttribute("aria-label", "Dynamic circuits view"); + + const viewCards = document.createElement("button"); + viewCards.id = "sdViewCards"; + viewCards.type = "button"; + viewCards.classList.add("btn", "btn-outline-secondary"); + viewCards.innerHTML = "Cards"; + viewCards.onclick = () => { + setDynamicViewMode("cards"); + }; + + const viewList = document.createElement("button"); + viewList.id = "sdViewList"; + viewList.type = "button"; + viewList.classList.add("btn", "btn-outline-secondary"); + viewList.innerHTML = "List"; + viewList.onclick = () => { + setDynamicViewMode("list"); + }; + + viewToggle.appendChild(viewCards); + viewToggle.appendChild(viewList); + viewToggleWrap.appendChild(viewLabel); + viewToggleWrap.appendChild(viewToggle); + toolbar.appendChild(viewToggleWrap); + const pagerWrap = document.createElement("div"); pagerWrap.classList.add("d-flex", "align-items-center", "gap-2"); const pager = document.createElement("div"); @@ -580,12 +762,50 @@ function ensureLayout() { grid.classList.add("row", "row-cols-1", "row-cols-md-2", "row-cols-xl-3", "g-3"); target.appendChild(grid); + listWrap = document.createElement("div"); + listWrap.id = "sdListWrap"; + listWrap.classList.add("table-responsive", "lqos-table-wrap", "d-none"); + + const listTable = document.createElement("table"); + listTable.classList.add("lqos-table", "lqos-table-tight", "mb-0", "lqos-shaped-devices-list-table"); + + const listHead = document.createElement("thead"); + const listHeadRow = document.createElement("tr"); + [ + "Circuit", + "Parent", + "Plan", + "Throughput", + "RTT", + "QoO", + "Retransmits", + "Last Seen", + "IPs", + ].forEach((label) => { + const th = document.createElement("th"); + th.innerText = label; + listHeadRow.appendChild(th); + }); + listHead.appendChild(listHeadRow); + listTable.appendChild(listHead); + + listBody = document.createElement("tbody"); + listBody.id = "sdListBody"; + listTable.appendChild(listBody); + listWrap.appendChild(listTable); + target.appendChild(listWrap); + return { target, toolbar, grid, + listWrap, + listBody, searchInput, perPageSelect, + viewToggleWrap, + viewCardsButton: viewCards, + viewListButton: viewList, prevButton: prev, nextButton: next, pageCounter, @@ -656,19 +876,36 @@ async function requestShapedDevicesPage() { current.up = Math.max(toNumber(current.up, 0), toNumber(device.upload_max_mbps, 0)); planByCircuitId.set(device.circuit_id, current); }); - renderCards(); + renderDevices(); requestCircuitMetricsWatch(true); renderCounts(); } catch (_error) { shapedDevices = []; totalRows = 0; totalCircuits = 0; - renderCards(); + renderDevices(); renderCounts(); } } -function renderCards() { +function updateViewToggle(layout) { + const showToggle = shapedDevicesKind === "Dynamic"; + if (layout.viewToggleWrap) { + layout.viewToggleWrap.classList.toggle("d-none", !showToggle); + layout.viewToggleWrap.classList.toggle("d-flex", showToggle); + } + const cardsActive = !isDynamicListView(); + if (layout.viewCardsButton) { + layout.viewCardsButton.classList.toggle("active", cardsActive); + layout.viewCardsButton.setAttribute("aria-pressed", cardsActive.toString()); + } + if (layout.viewListButton) { + layout.viewListButton.classList.toggle("active", !cardsActive); + layout.viewListButton.setAttribute("aria-pressed", (!cardsActive).toString()); + } +} + +function renderDevices() { const layout = ensureLayout(); if (!layout) return; @@ -679,6 +916,7 @@ function renderCards() { if (layout.perPageSelect && String(devicesPerPage) !== layout.perPageSelect.value) { layout.perPageSelect.value = String(devicesPerPage); } + updateViewToggle(layout); if (layout.prevButton) layout.prevButton.disabled = page <= 0; if (layout.nextButton) layout.nextButton.disabled = page >= totalPages - 1; if (layout.pageCounter) layout.pageCounter.innerText = "Page " + (page + 1) + " / " + totalPages; @@ -688,21 +926,36 @@ function renderCards() { } else { const start = page * devicesPerPage + 1; const end = Math.min((page + 1) * devicesPerPage, totalRows); - layout.summary.innerText = "Showing " + start + "–" + end + " of " + totalRows; + let summary = "Showing " + start + "–" + end + " of " + totalRows; + if (shapedDevicesKind === "Dynamic") { + summary += " (highest throughput first)"; + } + layout.summary.innerText = summary; } } metricElsByCircuitId = new Map(); clearDiv(layout.grid); + clearDiv(layout.listBody); - shapedDevices.forEach((device) => { - const col = document.createElement("div"); - col.classList.add("col"); - col.appendChild(buildDeviceCard(device)); - layout.grid.appendChild(col); - }); + const showList = isDynamicListView(); + layout.grid.classList.toggle("d-none", showList); + layout.listWrap.classList.toggle("d-none", !showList); - // Fill visible cards from cached live data immediately. + if (showList) { + shapedDevices.forEach((device) => { + layout.listBody.appendChild(buildDeviceListRow(device)); + }); + } else { + shapedDevices.forEach((device) => { + const col = document.createElement("div"); + col.classList.add("col"); + col.appendChild(buildDeviceCard(device)); + layout.grid.appendChild(col); + }); + } + + // Fill visible metrics from cached live data immediately. metricElsByCircuitId.forEach((_metrics, circuitId) => { const latest = latestByCircuitId.get(circuitId); if (latest) { diff --git a/src/rust/lqosd/src/node_manager/local_api/config.rs b/src/rust/lqosd/src/node_manager/local_api/config.rs index f72a85a6..93b40394 100644 --- a/src/rust/lqosd/src/node_manager/local_api/config.rs +++ b/src/rust/lqosd/src/node_manager/local_api/config.rs @@ -7,7 +7,7 @@ use axum::body::Bytes; use axum::http::StatusCode; use axum::http::header; use default_net::get_interfaces; -use lqos_bus::{BusRequest, bus_request}; +use lqos_bus::{BusRequest, BusResponse, bus_request}; use lqos_config::{Config, ConfigShapedDevices, ShapedDevice, UserRole, WebUser, WebUsers}; use lqos_utils::hash_to_i64; use serde::{Deserialize, Serialize}; @@ -793,16 +793,23 @@ pub async fn update_lqosd_config_data( login: LoginResult, mut config: Config, clear_secrets: ConfigSecretClearRequest, -) -> Result<(), StatusCode> { +) -> Result<(), String> { if login != LoginResult::Admin { - return Err(StatusCode::FORBIDDEN); + return Err("Unauthorized".to_string()); } - let existing = lqos_config::load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let existing = + lqos_config::load_config().map_err(|_| "Unable to load the current config".to_string())?; apply_secret_updates(existing.as_ref(), &mut config, &clear_secrets); - bus_request(vec![BusRequest::UpdateLqosdConfig(Box::new(config))]) + let mut responses = bus_request(vec![BusRequest::UpdateLqosdConfig(Box::new(config))]) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(()) + .map_err(|err| format!("Unable to update config: {err}"))?; + + match responses.pop() { + Some(BusResponse::Ack) => Ok(()), + Some(BusResponse::Fail(message)) => Err(message), + Some(other) => Err(format!("Unexpected config update response: {other:?}")), + None => Err("No response received for config update".to_string()), + } } /// Persists both `network.json` and `ShapedDevices.csv` for administrative diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs index dc21a98f..5ed297da 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs @@ -1,5 +1,9 @@ +use crate::shaped_devices_tracker::circuit_live::{ + CircuitLiveRollup, CircuitLiveSnapshot, fresh_circuit_live_snapshot, +}; use lqos_config::ShapedDevice; use serde::{Deserialize, Deserializer, Serialize}; +use std::cmp::Ordering; use std::collections::HashSet; const DEFAULT_SHAPED_DEVICES_PAGE_SIZE: usize = 24; @@ -57,7 +61,49 @@ fn normalized_page_size(query: &ShapedDevicesPageQuery) -> usize { .clamp(1, MAX_SHAPED_DEVICES_PAGE_SIZE) } +fn alphabetic_inventory_order(left: &ShapedDevice, right: &ShapedDevice) -> Ordering { + left.circuit_name + .cmp(&right.circuit_name) + .then_with(|| left.device_name.cmp(&right.device_name)) + .then_with(|| left.device_id.cmp(&right.device_id)) +} + +fn live_sort_rank(snapshot: &CircuitLiveSnapshot, device: &ShapedDevice) -> (u64, u64, u64, u64) { + let live = snapshot.by_circuit_id.get(&device.circuit_id); + let bytes_per_second = live + .map(|row: &CircuitLiveRollup| row.bytes_per_second) + .unwrap_or_default(); + let total_bytes_per_second = bytes_per_second.down.saturating_add(bytes_per_second.up); + let last_seen_nanos = live.map(|row| row.last_seen_nanos).unwrap_or(u64::MAX); + ( + total_bytes_per_second, + bytes_per_second.down, + bytes_per_second.up, + last_seen_nanos, + ) +} + +fn dynamic_inventory_order( + snapshot: &CircuitLiveSnapshot, + left: &ShapedDevice, + right: &ShapedDevice, +) -> Ordering { + let left_rank = live_sort_rank(snapshot, left); + let right_rank = live_sort_rank(snapshot, right); + right_rank + .0 + .cmp(&left_rank.0) + .then_with(|| right_rank.1.cmp(&left_rank.1)) + .then_with(|| right_rank.2.cmp(&left_rank.2)) + .then_with(|| left_rank.3.cmp(&right_rank.3)) + .then_with(|| alphabetic_inventory_order(left, right)) +} + /// Returns one filtered, sorted page of shaped-device rows. +/// +/// Static inventory rows are ordered alphabetically. Dynamic inventory rows are +/// ordered by current observed throughput before pagination so the busiest +/// circuits stay at the top of the page. pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); @@ -103,12 +149,13 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { .cloned() .collect(), }; - filtered.sort_by(|left, right| { - left.circuit_name - .cmp(&right.circuit_name) - .then_with(|| left.device_name.cmp(&right.device_name)) - .then_with(|| left.device_id.cmp(&right.device_id)) - }); + match kind { + ShapedDevicesPageKind::Static => filtered.sort_by(alphabetic_inventory_order), + ShapedDevicesPageKind::Dynamic => { + let snapshot = fresh_circuit_live_snapshot(); + filtered.sort_by(|left, right| dynamic_inventory_order(snapshot.as_ref(), left, right)); + } + } let total_rows = filtered.len(); let total_circuits = filtered @@ -140,3 +187,84 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { rows, } } + +#[cfg(test)] +mod tests { + use super::*; + use fxhash::FxHashMap; + use lqos_utils::units::DownUpOrder; + + fn test_device(circuit_id: &str, circuit_name: &str, device_name: &str) -> ShapedDevice { + ShapedDevice { + circuit_id: circuit_id.to_string(), + circuit_name: circuit_name.to_string(), + device_id: format!("{circuit_id}-device"), + device_name: device_name.to_string(), + ..Default::default() + } + } + + fn test_snapshot(entries: &[(&str, u64, u64, u64)]) -> CircuitLiveSnapshot { + let mut by_circuit_id = FxHashMap::default(); + for (circuit_id, down, up, last_seen_nanos) in entries { + by_circuit_id.insert( + (*circuit_id).to_string(), + CircuitLiveRollup { + circuit_id: (*circuit_id).to_string(), + circuit_name: String::new(), + parent_node: String::new(), + device_names: Vec::new(), + ip_addrs: Vec::new(), + plan_mbps: DownUpOrder::default(), + bytes_per_second: DownUpOrder::new(*down, *up), + rtt_current_p50_nanos: DownUpOrder::default(), + qoo: DownUpOrder::default(), + tcp_retransmit_sample: DownUpOrder::default(), + last_seen_nanos: *last_seen_nanos, + }, + ); + } + CircuitLiveSnapshot { by_circuit_id } + } + + #[test] + fn dynamic_inventory_order_prefers_higher_combined_throughput() { + let snapshot = test_snapshot(&[ + ("slow", 10, 20, 3_000), + ("fast", 40, 90, 2_000), + ("idle", 0, 0, u64::MAX), + ]); + let mut rows = vec![ + test_device("slow", "Slow Circuit", "Slow Device"), + test_device("idle", "Idle Circuit", "Idle Device"), + test_device("fast", "Fast Circuit", "Fast Device"), + ]; + + rows.sort_by(|left, right| dynamic_inventory_order(&snapshot, left, right)); + + assert_eq!( + rows.iter() + .map(|row| row.circuit_id.as_str()) + .collect::>(), + vec!["fast", "slow", "idle"] + ); + } + + #[test] + fn dynamic_inventory_order_uses_alphabetic_tie_breakers() { + let snapshot = test_snapshot(&[("alpha", 25, 25, 100), ("beta", 25, 25, 100)]); + let mut rows = vec![ + test_device("beta", "Beta Circuit", "Second Device"), + test_device("alpha", "Alpha Circuit", "First Device"), + ]; + + rows.sort_by(|left, right| dynamic_inventory_order(&snapshot, left, right)); + + assert_eq!( + rows.iter() + .map(|row| row.circuit_id.as_str()) + .collect::>(), + vec!["alpha", "beta"] + ); + } +} 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 8d0aa92c..dd83d134 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -2501,6 +2501,41 @@ table tr td a { background: var(--lqos-surface-solid); box-shadow: var(--lqos-shadow-sm); } +.lqos-shaped-devices-view-toggle .btn { + border-color: color-mix(in srgb, var(--lqos-border) 80%, transparent); +} +.lqos-shaped-devices-view-toggle .btn.active, +.lqos-shaped-devices-view-toggle .btn:active { + color: var(--bs-emphasis-color); + border-color: color-mix(in srgb, var(--lqos-border) 72%, transparent); + background: color-mix(in srgb, var(--lqos-surface) 86%, transparent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 0 0 1px color-mix(in srgb, var(--lqos-border) 30%, transparent); +} +.lqos-shaped-devices-list-table td { + vertical-align: middle; +} +.lqos-shaped-devices-list-table td .badge { + white-space: nowrap; +} +.lqos-direction-metric { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 7rem; +} +.lqos-direction-metric-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} +.lqos-direction-metric-label { + font-size: 0.74rem; + font-weight: 600; + color: var(--bs-secondary-color); +} .exec-icon { display: inline-flex; align-items: center; diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 65bc0c31..4e359bd8 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -1579,8 +1579,7 @@ async fn receive_channel_message( config::update_lqosd_config_data(*request_state.login, cfg, clear_secrets).await; let (ok, message) = match result { Ok(()) => (true, "Ok".to_string()), - Err(StatusCode::FORBIDDEN) => (false, "Unauthorized".to_string()), - Err(_) => (false, "Error".to_string()), + Err(message) => (false, message), }; let response = WsResponse::UpdateConfigResult { ok, message }; if send_ws_response(&tx, response).await {