Merge pull request #1061 from LibreQoE/dynamic_circuit_fix

Fix dynamic circuit saves and improve dynamic inventory view
This commit is contained in:
Robert Chacón authored and GitHub committed 2026-04-21 16:24:55 -06:00
commit b6b8cdcc02
8 files changed
+588 -95

No files matched your search

@@ -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);
}
}
+10 -8
View File
@@ -616,16 +616,18 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec<BusResponse>
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())
}
@@ -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.",
},
);
}
@@ -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 =
"<h5>Quality of Outcome (QoO)</h5>" +
@@ -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 = "<i class='fa fa-th-large me-1'></i>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 = "<i class='fa fa-list me-1'></i>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) {
@@ -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
@@ -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<_>>(),
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<_>>(),
vec!["alpha", "beta"]
);
}
}
@@ -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;
+1 -2
View File
@@ -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 {