UI: Redesign shaped devices cards + QoO

This commit is contained in:
Herbert Wolverson committed 2026-02-03 12:38:19 -06:00
1 parent 0b98cb0cc5
commit 3f338909e0
3 files changed
+538 -264

No files matched your search

+3
View File
@@ -209,6 +209,9 @@ pub struct Circuit {
/// Total (lifetime) RTT p95 (nanoseconds), per direction.
#[serde(default)]
pub rtt_total_p95_nanos: DownUpOrder<Option<u64>>,
/// QoO score (0..100), per direction.
#[serde(default)]
pub qoo: DownUpOrder<Option<f32>>,
/// TCP Retransmits for this host at the current time.
pub tcp_retransmits: DownUpOrder<u64>,
/// The number of TCP packets per second.
@@ -1,14 +1,24 @@
import {clearDiv, clientTableHeader, formatLastSeen, simpleRow} from "./helpers/builders";
import {clearDiv, formatLastSeen} from "./helpers/builders";
import {get_ws_client, subscribeWS} from "./pubsub/ws";
import {formatRetransmit, formatRtt, formatThroughput} from "./helpers/scaling";
import {colorByQoqScore} from "./helpers/color_scales";
import {toNumber} from "./lq_js_common/helpers/scaling";
let shapedDevices = null;
let displayDevices = null;
let devicesPerPage = 10;
let shapedDevices = [];
let displayDevices = [];
let devicesPerPage = 24;
let page = 0;
let searchTerm = "";
let metricElsByCircuitId = new Map();
const latestByCircuitId = new Map();
const wsClient = get_ws_client();
const QOO_TOOLTIP_HTML =
"<h5>Quality of Outcome (QoO)</h5>" +
"<p>Quality of Outcome (QoO) is IETF IPPM “Internet Quality” (draft-ietf-ippm-qoo).<br>" +
"https://datatracker.ietf.org/doc/draft-ietf-ippm-qoo/<br>" +
"LibreQoS implements a latency and loss-based model to estimate quality of outcome.</p>";
const listenOnce = (eventName, handler) => {
const wrapped = (msg) => {
wsClient.off(eventName, wrapped);
@@ -17,234 +27,24 @@ const listenOnce = (eventName, handler) => {
wsClient.on(eventName, wrapped);
};
function tableRow(device) {
let tr = document.createElement("tr");
if (device.circuit_id !== "") {
tr.id = "row_" + device.circuit_id;
}
let link = document.createElement("a");
link.href = "circuit.html?id=" + device.circuit_id;
link.innerText = device.circuit_name;
link.classList.add("redactable");
let td = document.createElement("td");
td.appendChild(link);
tr.appendChild(td);
link = document.createElement("a");
link.href = "circuit.html?id=" + device.circuit_id;
link.innerText = device.device_name;
link.classList.add("redactable");
td = document.createElement("td");
td.appendChild(link);
tr.appendChild(td);
tr.appendChild(simpleRow(device.download_max_mbps + " / " + device.upload_max_mbps));
let parentNode = document.createElement("td");
parentNode.classList.add("redactable");
parentNode.innerText = device.parent_node;
parentNode.title = device.parent_node;
parentNode.style.whiteSpace = "nowrap";
parentNode.style.overflow = "hidden";
parentNode.style.textOverflow = "ellipsis";
tr.appendChild(parentNode);
let ipList = "";
device.ipv4.forEach((ip) => {
ipList += ip[0] + "/" + ip[1] + "<br />";
});
device.ipv6.forEach((ip) => {
ipList += ip[0] + "/" + ip[1] + "<br />";
});
let ip = document.createElement("td");
ip.innerHTML = ipList;
tr.appendChild(ip);
let lastSeen = document.createElement("td");
if (device.circuit_id !== "") {
lastSeen.id = "lastSeen_" + device.circuit_id;
}
lastSeen.innerHTML = "-";
tr.appendChild(lastSeen);
let throughputDown = document.createElement("td");
if (device.circuit_id !== "") {
throughputDown.id = "throughputDown_" + device.circuit_id;
}
throughputDown.innerHTML = "-";
tr.appendChild(throughputDown);
let throughputUp = document.createElement("td");
if (device.circuit_id !== "") {
throughputUp.id = "throughputUp_" + device.circuit_id;
}
throughputUp.innerHTML = "-";
tr.appendChild(throughputUp);
let rttDown = document.createElement("td");
if (device.circuit_id !== "") {
rttDown.id = "rttDown_" + device.circuit_id;
}
rttDown.innerHTML = "-";
tr.appendChild(rttDown);
let rttUp = document.createElement("td");
if (device.circuit_id !== "") {
rttUp.id = "rttUp_" + device.circuit_id;
}
rttUp.innerHTML = "-";
tr.appendChild(rttUp);
let reXmitDown = document.createElement("td");
if (device.circuit_id !== "") {
reXmitDown.id = "reXmitDown_" + device.circuit_id;
}
reXmitDown.innerHTML = "-";
tr.appendChild(reXmitDown);
let reXmitUp = document.createElement("td");
if (device.circuit_id !== "") {
reXmitUp.id = "reXmitUp_" + device.circuit_id;
}
reXmitUp.innerHTML = "-";
tr.appendChild(reXmitUp);
return tr;
}
function make_table() {
let table = document.createElement("table");
table.classList.add("table", "table-striped");
table.style.tableLayout = "fixed";
table.style.width = "100%";
let colgroup = document.createElement("colgroup");
let colWidths = [
"16%", // Circuit
"16%", // Device
"8%", // Plan (Mbps)
"10%", // Parent
"18%", // IP
"120px", // Last Seen
"120px", // Throughput Down
"120px", // Throughput Up
"80px", // RTT Down
"80px", // RTT Up
"80px", // Re-Xmit Down
"80px" // Re-Xmit Up
];
colWidths.forEach((width) => {
let col = document.createElement("col");
col.style.width = width;
colgroup.appendChild(col);
});
table.appendChild(colgroup);
table.appendChild(clientTableHeader());
let tb = document.createElement("tbody");
let start = page * devicesPerPage;
let end = Math.min((page + 1) * devicesPerPage, displayDevices.length);
for (let i = start; i < end; i++) {
tb.appendChild(tableRow(displayDevices[i]));
}
table.appendChild(tb);
return table;
}
function filterDevices() {
displayDevices = [];
let term = searchTerm.toLowerCase();
shapedDevices.forEach((d) => {
if (
d.device_name.toLowerCase().indexOf(term) > -1 ||
d.circuit_name.toLowerCase().indexOf(term) > -1
) {
displayDevices.push(d);
function initTooltipsWithin(rootEl) {
if (!rootEl) return;
if (typeof bootstrap === "undefined" || !bootstrap.Tooltip) return;
const elements = rootEl.querySelectorAll('[data-bs-toggle="tooltip"]');
elements.forEach((element) => {
if (bootstrap.Tooltip.getOrCreateInstance) {
bootstrap.Tooltip.getOrCreateInstance(element);
} else {
new bootstrap.Tooltip(element);
}
});
page = 0;
renderTable();
}
function ensureFilter() {
let content = document.getElementById("deviceTableContent");
if (content !== null) {
let existingSearch = document.getElementById("sdSearch");
if (existingSearch !== null) {
existingSearch.value = searchTerm;
}
return content;
}
let filter = document.createElement("div");
//let label = document.createElement("label");
//label.classList.add("text-secondary");
//label.innerText = "Search";
//label.htmlFor = "sdSearch";
let sdSearch = document.createElement("input");
sdSearch.id = "sdSearch";
sdSearch.placeholder = "Search";
sdSearch.value = searchTerm;
sdSearch.oninput = () => {
searchTerm = $("#sdSearch").val();
filterDevices();
}
sdSearch.onkeydown = (event) => {
if (event.keyCode == 13) {
searchTerm = $("#sdSearch").val();
filterDevices();
}
}
let searchButton = document.createElement("button");
searchButton.type = "button"
searchButton.classList.add("btn", "btn-sm");
searchButton.innerHTML = "<i class='fa fa-search'></i>";
searchButton.onclick = () => {
searchTerm = $("#sdSearch").val();
filterDevices();
}
//filter.appendChild(label);
filter.appendChild(sdSearch);
filter.appendChild(searchButton);
let target = document.getElementById("deviceTable");
clearDiv(target);
target.appendChild(filter);
content = document.createElement("div");
content.id = "deviceTableContent";
target.appendChild(content);
return content;
}
function renderTable() {
let table = make_table();
let pages = document.createElement("div");
pages.classList.add("mt-2", "mb-1");
let numPages = (displayDevices.length / devicesPerPage) - 2;
if (numPages > 1) {
if (page > 0) {
let left = document.createElement("button");
left.classList.add("btn", "btn-sm", "btn-secondary", "me-2");
left.innerHTML = "<i class='fa fa-arrow-left'></i>";
left.onclick = () => {
page -= 1;
renderTable();
}
pages.appendChild(left);
}
let counter = document.createElement("span");
counter.classList.add("text-secondary");
counter.innerText = page + " / " + numPages.toFixed(0);
pages.appendChild(counter);
if (page < numPages - 2) {
let right = document.createElement("button");
right.classList.add("btn", "btn-sm", "btn-secondary", "ms-2");
right.innerHTML = "<i class='fa fa-arrow-right'></i>";
right.onclick = () => {
page += 1;
renderTable();
}
pages.appendChild(right);
}
}
let content = ensureFilter();
clearDiv(content);
content.appendChild(pages);
content.appendChild(table);
function formatPlanValue(value) {
const asNumber = toNumber(value, 0);
let formatted = parseFloat(asNumber).toFixed(3);
formatted = formatted.replace(/\.?0+$/, "");
return formatted;
}
function countCircuits() {
@@ -253,20 +53,510 @@ function countCircuits() {
if (!entries.hasOwnProperty(d.circuit_id)) {
entries[d.circuit_id] = 1;
}
})
});
let count = 0;
for (const i in entries) {
for (const _ in entries) {
count++;
}
return count;
}
function filterDevices() {
const term = (searchTerm || "").toLowerCase().trim();
if (term === "") {
displayDevices = shapedDevices;
} else {
displayDevices = shapedDevices.filter((d) => {
const deviceName = (d.device_name || "").toLowerCase();
const circuitName = (d.circuit_name || "").toLowerCase();
const parentNode = (d.parent_node || "").toLowerCase();
return (
deviceName.indexOf(term) > -1 ||
circuitName.indexOf(term) > -1 ||
parentNode.indexOf(term) > -1
);
});
}
page = 0;
renderCards();
}
function formatQooScore(score0to100, fallback = "-") {
if (score0to100 === null || score0to100 === undefined) {
return fallback;
}
const numeric = Number(score0to100);
if (!Number.isFinite(numeric) || numeric === 255) {
return fallback;
}
const clamped = Math.min(100, Math.max(0, Math.round(numeric)));
const color = colorByQoqScore(clamped);
return "<span class='muted' style='color: " + color + "'>■</span>" + clamped;
}
function formatRttFromNanosOpt(nanosOpt, fallback = "-") {
if (nanosOpt === null || nanosOpt === undefined) {
return fallback;
}
const nanos = toNumber(nanosOpt, 0);
if (!Number.isFinite(nanos) || nanos <= 0) {
return fallback;
}
return formatRtt(nanos / 1_000_000.0);
}
function registerMetricEl(circuitId, metricName, el) {
if (!circuitId || circuitId === "") return;
let metrics = metricElsByCircuitId.get(circuitId);
if (!metrics) {
metrics = {};
metricElsByCircuitId.set(circuitId, metrics);
}
if (!metrics[metricName]) {
metrics[metricName] = [];
}
metrics[metricName].push(el);
}
function updateMetricHtml(circuitId, metricName, html) {
const metrics = metricElsByCircuitId.get(circuitId);
if (!metrics || !metrics[metricName]) return;
metrics[metricName].forEach((el) => {
el.innerHTML = html;
});
}
function updateMetricText(circuitId, metricName, text) {
const metrics = metricElsByCircuitId.get(circuitId);
if (!metrics || !metrics[metricName]) return;
metrics[metricName].forEach((el) => {
el.innerText = text;
});
}
function applyCircuitUpdate(device) {
if (!device || !device.circuit_id) return;
const circuitId = device.circuit_id;
latestByCircuitId.set(circuitId, device);
updateMetricText(circuitId, "lastSeen", formatLastSeen(device.last_seen_nanos));
updateMetricHtml(
circuitId,
"tpDown",
formatThroughput(toNumber(device.bytes_per_second.down, 0) * 8, device.plan.down),
);
updateMetricHtml(
circuitId,
"tpUp",
formatThroughput(toNumber(device.bytes_per_second.up, 0) * 8, device.plan.up),
);
updateMetricHtml(
circuitId,
"rttDown",
formatRttFromNanosOpt(device.rtt_current_p50_nanos ? device.rtt_current_p50_nanos.down : null),
);
updateMetricHtml(
circuitId,
"rttUp",
formatRttFromNanosOpt(device.rtt_current_p50_nanos ? device.rtt_current_p50_nanos.up : null),
);
updateMetricHtml(circuitId, "qooDown", formatQooScore(device.qoo ? device.qoo.down : null));
updateMetricHtml(circuitId, "qooUp", formatQooScore(device.qoo ? device.qoo.up : null));
const packetsDown = toNumber(device.tcp_packets.down, 0);
const packetsUp = toNumber(device.tcp_packets.up, 0);
const retransDown = toNumber(device.tcp_retransmits.down, 0);
const retransUp = toNumber(device.tcp_retransmits.up, 0);
const fractionDown = packetsDown > 0 ? retransDown / packetsDown : 0;
const fractionUp = packetsUp > 0 ? retransUp / packetsUp : 0;
updateMetricHtml(circuitId, "reXmitDown", formatRetransmit(fractionDown));
updateMetricHtml(circuitId, "reXmitUp", formatRetransmit(fractionUp));
}
function buildIpListEl(device) {
const wrapper = document.createElement("div");
wrapper.classList.add("small", "text-body-secondary");
const addLine = (text) => {
const div = document.createElement("div");
div.innerText = text;
wrapper.appendChild(div);
};
if (Array.isArray(device.ipv4)) {
device.ipv4.forEach((ip) => {
if (ip && ip.length >= 2) addLine(ip[0] + "/" + ip[1]);
});
}
if (Array.isArray(device.ipv6)) {
device.ipv6.forEach((ip) => {
if (ip && ip.length >= 2) addLine(ip[0] + "/" + ip[1]);
});
}
if (wrapper.children.length === 0) {
addLine("-");
}
return wrapper;
}
function metricTableRow(labelEl, downEl, upEl) {
const tr = document.createElement("tr");
tr.classList.add("small");
const tdLabel = document.createElement("td");
tdLabel.classList.add("text-body-secondary");
tdLabel.style.width = "34%";
tdLabel.appendChild(labelEl);
tr.appendChild(tdLabel);
const tdDown = document.createElement("td");
tdDown.classList.add("text-end");
tdDown.appendChild(downEl);
tr.appendChild(tdDown);
const tdUp = document.createElement("td");
tdUp.classList.add("text-end");
tdUp.appendChild(upEl);
tr.appendChild(tdUp);
return tr;
}
function buildDeviceCard(device) {
const card = document.createElement("div");
card.classList.add("executive-card", "h-100");
// Header
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";
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");
circuitName.innerText = device.circuit_name || "(Unknown circuit)";
titleWrap.appendChild(circuitName);
const deviceName = document.createElement("div");
deviceName.classList.add("small", "text-body-secondary");
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";
header.appendChild(titleWrap);
header.appendChild(planBadge);
card.appendChild(header);
// Meta: parent + IPs
const parent = document.createElement("div");
parent.classList.add("small", "text-body-secondary", "mt-1", "redactable");
parent.style.whiteSpace = "nowrap";
parent.style.overflow = "hidden";
parent.style.textOverflow = "ellipsis";
parent.title = device.parent_node || "";
parent.innerText = device.parent_node || "-";
card.appendChild(parent);
const ipList = buildIpListEl(device);
ipList.classList.add("mt-1");
card.appendChild(ipList);
// Last seen
const lastSeenRow = document.createElement("div");
lastSeenRow.classList.add("small", "text-body-secondary", "mt-2");
const lastSeenLabel = document.createElement("span");
lastSeenLabel.innerText = "Last seen: ";
const lastSeenValue = document.createElement("span");
lastSeenValue.innerText = "-";
lastSeenRow.appendChild(lastSeenLabel);
lastSeenRow.appendChild(lastSeenValue);
card.appendChild(lastSeenRow);
registerMetricEl(device.circuit_id, "lastSeen", lastSeenValue);
// Metrics table
const table = document.createElement("table");
table.classList.add("table", "table-sm", "mb-0", "mt-2");
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
const headMetric = document.createElement("th");
headMetric.innerText = "";
const headDl = document.createElement("th");
headDl.classList.add("text-end");
headDl.innerHTML = "DL <i class='fa fa-arrow-down'></i>";
const headUl = document.createElement("th");
headUl.classList.add("text-end");
headUl.innerHTML = "UL <i class='fa fa-arrow-up'></i>";
headRow.appendChild(headMetric);
headRow.appendChild(headDl);
headRow.appendChild(headUl);
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
const tpLabel = document.createElement("span");
tpLabel.innerText = "Throughput";
const tpDown = document.createElement("span");
tpDown.innerHTML = "-";
const tpUp = document.createElement("span");
tpUp.innerHTML = "-";
tbody.appendChild(metricTableRow(tpLabel, tpDown, tpUp));
registerMetricEl(device.circuit_id, "tpDown", tpDown);
registerMetricEl(device.circuit_id, "tpUp", tpUp);
const rttLabel = document.createElement("span");
rttLabel.innerText = "RTT";
const rttDown = document.createElement("span");
rttDown.innerHTML = "-";
const rttUp = document.createElement("span");
rttUp.innerHTML = "-";
tbody.appendChild(metricTableRow(rttLabel, rttDown, rttUp));
registerMetricEl(device.circuit_id, "rttDown", rttDown);
registerMetricEl(device.circuit_id, "rttUp", rttUp);
const qooLabelWrap = document.createElement("span");
qooLabelWrap.innerHTML = "QoO <i class='fas fa-info-circle'></i>";
qooLabelWrap.setAttribute("data-bs-toggle", "tooltip");
qooLabelWrap.setAttribute("data-bs-placement", "top");
qooLabelWrap.setAttribute("data-bs-html", "true");
qooLabelWrap.setAttribute("title", QOO_TOOLTIP_HTML);
const qooDown = document.createElement("span");
qooDown.innerHTML = "-";
const qooUp = document.createElement("span");
qooUp.innerHTML = "-";
tbody.appendChild(metricTableRow(qooLabelWrap, qooDown, qooUp));
registerMetricEl(device.circuit_id, "qooDown", qooDown);
registerMetricEl(device.circuit_id, "qooUp", qooUp);
const rxLabel = document.createElement("span");
rxLabel.innerText = "Retransmits";
const rxDown = document.createElement("span");
rxDown.innerHTML = "-";
const rxUp = document.createElement("span");
rxUp.innerHTML = "-";
tbody.appendChild(metricTableRow(rxLabel, rxDown, rxUp));
registerMetricEl(device.circuit_id, "reXmitDown", rxDown);
registerMetricEl(device.circuit_id, "reXmitUp", rxUp);
table.appendChild(tbody);
card.appendChild(table);
return card;
}
function ensureLayout() {
const target = document.getElementById("deviceTable");
if (!target) return null;
let toolbar = document.getElementById("sdToolbar");
let grid = document.getElementById("sdCardsGrid");
if (toolbar && grid) {
return {
target,
toolbar,
grid,
searchInput: document.getElementById("sdSearch"),
perPageSelect: document.getElementById("sdPerPage"),
prevButton: document.getElementById("sdPrevPage"),
nextButton: document.getElementById("sdNextPage"),
pageCounter: document.getElementById("sdPageCounter"),
summary: document.getElementById("sdSummary"),
};
}
clearDiv(target);
toolbar = document.createElement("div");
toolbar.id = "sdToolbar";
toolbar.classList.add("d-flex", "flex-wrap", "align-items-center", "gap-2", "mb-1");
const searchGroup = document.createElement("div");
searchGroup.classList.add("input-group", "input-group-sm");
searchGroup.style.maxWidth = "340px";
const searchIcon = document.createElement("span");
searchIcon.classList.add("input-group-text");
searchIcon.innerHTML = "<i class='fa fa-search'></i>";
searchGroup.appendChild(searchIcon);
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.classList.add("form-control");
searchInput.id = "sdSearch";
searchInput.placeholder = "Search circuits, devices, parents…";
searchInput.value = searchTerm;
searchInput.oninput = () => {
searchTerm = $("#sdSearch").val();
filterDevices();
};
searchInput.onkeydown = (event) => {
if (event.keyCode === 13) {
searchTerm = $("#sdSearch").val();
filterDevices();
}
};
searchGroup.appendChild(searchInput);
toolbar.appendChild(searchGroup);
const perPageWrap = document.createElement("div");
perPageWrap.classList.add("d-flex", "align-items-center", "gap-1");
const perPageLabel = document.createElement("span");
perPageLabel.classList.add("small", "text-body-secondary");
perPageLabel.innerText = "Per page";
const perPageSelect = document.createElement("select");
perPageSelect.id = "sdPerPage";
perPageSelect.classList.add("form-select", "form-select-sm");
[12, 24, 48, 96].forEach((n) => {
const opt = document.createElement("option");
opt.value = String(n);
opt.innerText = String(n);
if (n === devicesPerPage) opt.selected = true;
perPageSelect.appendChild(opt);
});
perPageSelect.onchange = () => {
devicesPerPage = parseInt(perPageSelect.value, 10);
if (!Number.isFinite(devicesPerPage) || devicesPerPage <= 0) {
devicesPerPage = 24;
}
page = 0;
renderCards();
};
perPageWrap.appendChild(perPageLabel);
perPageWrap.appendChild(perPageSelect);
toolbar.appendChild(perPageWrap);
const pagerWrap = document.createElement("div");
pagerWrap.classList.add("d-flex", "align-items-center", "gap-2");
const pager = document.createElement("div");
pager.classList.add("btn-group", "btn-group-sm");
const prev = document.createElement("button");
prev.id = "sdPrevPage";
prev.type = "button";
prev.classList.add("btn", "btn-secondary");
prev.innerHTML = "<i class='fa fa-arrow-left'></i>";
prev.onclick = () => {
page = Math.max(0, page - 1);
renderCards();
};
const next = document.createElement("button");
next.id = "sdNextPage";
next.type = "button";
next.classList.add("btn", "btn-secondary");
next.innerHTML = "<i class='fa fa-arrow-right'></i>";
next.onclick = () => {
const totalPages = Math.max(1, Math.ceil(displayDevices.length / devicesPerPage));
page = Math.min(totalPages - 1, page + 1);
renderCards();
};
pager.appendChild(prev);
pager.appendChild(next);
pagerWrap.appendChild(pager);
const pageCounter = document.createElement("span");
pageCounter.id = "sdPageCounter";
pageCounter.classList.add("small", "text-body-secondary");
pagerWrap.appendChild(pageCounter);
toolbar.appendChild(pagerWrap);
const summary = document.createElement("div");
summary.id = "sdSummary";
summary.classList.add("small", "text-body-secondary", "ms-auto");
toolbar.appendChild(summary);
target.appendChild(toolbar);
grid = document.createElement("div");
grid.id = "sdCardsGrid";
grid.classList.add("row", "row-cols-1", "row-cols-md-2", "row-cols-xl-3", "g-3");
target.appendChild(grid);
return {
target,
toolbar,
grid,
searchInput,
perPageSelect,
prevButton: prev,
nextButton: next,
pageCounter,
summary,
};
}
function renderCards() {
const layout = ensureLayout();
if (!layout) return;
const totalPages = Math.max(1, Math.ceil(displayDevices.length / devicesPerPage));
if (page >= totalPages) page = totalPages - 1;
if (page < 0) page = 0;
if (layout.perPageSelect && String(devicesPerPage) !== layout.perPageSelect.value) {
layout.perPageSelect.value = String(devicesPerPage);
}
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;
if (layout.summary) {
if (displayDevices.length === 0) {
layout.summary.innerText = "No matches";
} else {
const start = page * devicesPerPage + 1;
const end = Math.min((page + 1) * devicesPerPage, displayDevices.length);
layout.summary.innerText = "Showing " + start + "–" + end + " of " + displayDevices.length;
}
}
metricElsByCircuitId = new Map();
clearDiv(layout.grid);
const start = page * devicesPerPage;
const end = Math.min(start + devicesPerPage, displayDevices.length);
for (let i = start; i < end; i++) {
const device = displayDevices[i];
const col = document.createElement("div");
col.classList.add("col");
col.appendChild(buildDeviceCard(device));
layout.grid.appendChild(col);
}
// Fill visible cards from cached live data immediately.
metricElsByCircuitId.forEach((_metrics, circuitId) => {
const latest = latestByCircuitId.get(circuitId);
if (latest) {
applyCircuitUpdate(latest);
}
});
initTooltipsWithin(layout.target);
}
function loadDevices() {
listenOnce("DevicesAll", (msg) => {
const data = msg && msg.data ? msg.data : [];
shapedDevices = data;
displayDevices = data;
renderTable();
renderCards();
$("#count").text(shapedDevices.length + " devices");
$("#countCircuit").text(countCircuits() + " circuits");
});
@@ -275,37 +565,10 @@ function loadDevices() {
loadDevices();
subscribeWS(["NetworkTreeClients"], (msg) => {
if (msg.event === "NetworkTreeClients") {
//console.log(msg);
msg.data.forEach((d) => {
let lastSeen = document.getElementById("lastSeen_" + d.circuit_id);
if (lastSeen !== null) {
lastSeen.innerText = formatLastSeen(d.last_seen_nanos);
}
let throughputDown = document.getElementById("throughputDown_" + d.circuit_id);
if (throughputDown !== null) {
throughputDown.innerHTML = formatThroughput(toNumber(d.bytes_per_second.down, 0) * 8, d.plan.down);
}
let throughputUp = document.getElementById("throughputUp_" + d.circuit_id);
if (throughputUp !== null) {
throughputUp.innerHTML = formatThroughput(toNumber(d.bytes_per_second.up, 0) * 8, d.plan.up);
}
let rttDown = document.getElementById("rttDown_" + d.circuit_id);
if (rttDown !== null && d.median_latency != null) {
rttDown.innerHTML = formatRtt(d.median_latency.down);
}
let rttUp = document.getElementById("rttUp_" + d.circuit_id);
if (rttUp !== null && d.median_latency != null) {
rttUp.innerHTML = formatRtt(d.median_latency.up);
}
let reXmitDown = document.getElementById("reXmitDown_" + d.circuit_id);
if (reXmitDown !== null) {
reXmitDown.innerHTML = formatRetransmit(d.tcp_retransmits.down);
}
let reXmitUp = document.getElementById("reXmitUp_" + d.circuit_id);
if (reXmitUp !== null) {
reXmitUp.innerHTML = formatRetransmit(d.tcp_retransmits.up);
}
});
}
if (msg.event !== "NetworkTreeClients") return;
const data = msg && msg.data ? msg.data : [];
data.forEach((d) => {
if (!d || !d.circuit_id) return;
applyCircuitUpdate(d);
});
});
@@ -270,6 +270,10 @@ pub fn get_all_circuits() -> BusResponse {
.percentile(RttBucket::Total, FlowbeeEffectiveDirection::Upload, 95)
.map(|rtt| rtt.as_nanos()),
},
qoo: DownUpOrder {
down: v.qoq.download_total_f32(),
up: v.qoq.upload_total_f32(),
},
tcp_retransmits: v.tcp_retransmits,
tcp_packets: v.tcp_packets.checked_sub_or_zero(v.prev_tcp_packets),
circuit_id,
@@ -368,6 +372,10 @@ pub fn get_circuit_by_id(desired_circuit_id: String) -> BusResponse {
.percentile(RttBucket::Total, FlowbeeEffectiveDirection::Upload, 95)
.map(|rtt| rtt.as_nanos()),
},
qoo: DownUpOrder {
down: v.qoq.download_total_f32(),
up: v.qoq.upload_total_f32(),
},
tcp_retransmits: v.tcp_retransmits,
tcp_packets: v.tcp_packets.checked_sub_or_zero(v.prev_tcp_packets),
circuit_id: Some(found_circuit_id),