mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-24 13:57:40 -05:00
Merge pull request #1061 from LibreQoE/dynamic_circuit_fix
Fix dynamic circuit saves and improve dynamic inventory view
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user