This commit is contained in:
rchac committed 2026-06-28 13:02:42 -06:00
1 parent 62e3007f93
commit 326843530e
14 files changed
+757 -78

No files matched your search

@@ -4,6 +4,7 @@ import {lerpGreenToRedViaOrange} from "../helpers/scaling";
import {isColorBlindMode} from "../helpers/colorblind";
import {colorByRttMs} from "../helpers/color_scales";
import {toNumber} from "../lq_js_common/helpers/scaling";
import {effectiveMax} from "../helpers/network_rates.mjs";
/**
* Viridis color scale interpolation (0-1 input).
@@ -33,10 +34,6 @@ import {isRedacted} from "../helpers/redact";
let lastRtt = {};
function effectiveMax(node) {
return node.effective_max_throughput || node.max_throughput || [0, 0];
}
class TopTreeSankeyGraph extends DashboardGraph {
constructor(id) {
super(id);
@@ -2,10 +2,7 @@ import {clearDiv, simpleRowTrustedHtml, theading} from "../helpers/builders";
import {formatThroughput, formatRetransmit, formatCakeStat} from "../helpers/scaling";
import {toNumber} from "../lq_js_common/helpers/scaling";
import {DashletBaseInsight} from "./insight_dashlet_base";
function effectiveMax(node) {
return node.effective_max_throughput || node.max_throughput || [0, 0];
}
import {effectiveMax} from "../helpers/network_rates.mjs";
function retransmitPacketsForNode(node, direction) {
return toNumber(
@@ -7,6 +7,7 @@ import {
import {scaleNanos} from "../lq_js_common/helpers/scaling";
import {redactCell} from "./redact";
import {disposeTooltipsWithin, enableTooltipsWithin} from "../lq_js_common/helpers/tooltips";
import {clearElement} from "./dom_clear.mjs";
export {safeRelativeHref, simpleLinkRow} from "./safe_dom.mjs";
export function heading5Icon(icon, text) {
@@ -58,10 +59,7 @@ export function clearDashDiv(id, target) {
}
export function clearDiv(target, targetLength=0) {
disposeTooltipsWithin(target);
while (target.children.length > targetLength) {
target.removeChild(target.lastChild);
}
clearElement(target, targetLength);
}
export function enableTooltips() {
@@ -0,0 +1,8 @@
import {disposeTooltipsWithin} from "../lq_js_common/helpers/tooltips.js";
export function clearElement(target, targetLength = 0) {
disposeTooltipsWithin(target);
while (target.children.length > targetLength) {
target.removeChild(target.lastChild);
}
}
@@ -0,0 +1,36 @@
import {toNumber} from "../lq_js_common/helpers/scaling.js";
export const RATE_COMPARE_EPSILON_MBPS = 0.01;
export function ratePairFromNode(node, field, fallback = [0, 0]) {
const pair = node?.[field];
if (Array.isArray(pair)) {
return [toNumber(pair[0], fallback[0]), toNumber(pair[1], fallback[1])];
}
return fallback;
}
export function configuredMax(node) {
const configured = node?.configured_max_throughput;
if (Array.isArray(configured)) {
return [toNumber(configured[0], 0), toNumber(configured[1], 0)];
}
return ratePairFromNode(node, "max_throughput");
}
export function effectiveMax(node) {
return ratePairFromNode(node, "effective_max_throughput", configuredMax(node));
}
export function ratesApproximatelyEqual(left, right) {
return Math.abs(toNumber(left, 0) - toNumber(right, 0)) <= RATE_COMPARE_EPSILON_MBPS;
}
export function rateBelow(left, right) {
return toNumber(left, 0) < toNumber(right, 0) - RATE_COMPARE_EPSILON_MBPS;
}
export function ratePairsMatch(left, right) {
return ratesApproximatelyEqual(left?.[0], right?.[0])
&& ratesApproximatelyEqual(left?.[1], right?.[1]);
}
@@ -2,6 +2,7 @@ import { colorByQoqScore, colorByRttMs } from "./helpers/color_scales";
import { isColorBlindMode } from "./helpers/colorblind";
import { isDarkMode } from "./helpers/dark_mode";
import { isRedacted } from "./helpers/redact";
import { effectiveMax } from "./helpers/network_rates.mjs";
import { scaleNumber, toNumber } from "./lq_js_common/helpers/scaling";
import { get_ws_client, subscribeWS } from "./pubsub/ws";
@@ -276,14 +277,6 @@ function metricColorForMode(mode, metricValue) {
return colorByRttMs(numeric);
}
function configuredMaxThroughput(node) {
return node?.configured_max_throughput || node?.max_throughput || [0, 0];
}
function effectiveMaxThroughput(node) {
return node?.effective_max_throughput || configuredMaxThroughput(node);
}
function averageOrNull(sum, count) {
return count > 0 ? (sum / count) : null;
}
@@ -1258,7 +1251,7 @@ class SiteMapPage {
const qooUp = averageOrNull(value.qooUpSum, value.qooUpCount);
const rttDownMs = averageOrNull(value.rttDownSum, value.rttDownCount);
const rttUpMs = averageOrNull(value.rttUpSum, value.rttUpCount);
const maxMbps = effectiveMaxThroughput(node);
const maxMbps = effectiveMax(node);
const limitDownMbps = toNumber(maxMbps?.[0], 0);
const limitUpMbps = toNumber(maxMbps?.[1], 0);
@@ -11,6 +11,15 @@ import {QooScoreGauge} from "./graphs/qoo_score_gauge";
import {enableTooltipsWithin} from "./lq_js_common/helpers/tooltips";
import {scaleNumber, toNumber} from "./lq_js_common/helpers/scaling";
import {get_ws_client, subscribeWS} from "./pubsub/ws";
import {
buildNodeLimitSummary,
configuredMax,
effectiveMax,
immediateParentNodeFromTree,
ratePairsMatch,
ratesApproximatelyEqual,
renderEffectiveNowDisplay,
} from "./tree_limit_reason.mjs";
var tree = null;
var parent = 0;
@@ -49,7 +58,6 @@ const QOO_TOOLTIP_HTML = "<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 THROUGHPUT_COMPARE_EPSILON_MBPS = 0.01;
const NODE_OVERRIDE_PENDING_TOOLTIP = "Stored as an operator override. Will be applied to generated network.json on the next scheduler run.";
const TOPOLOGY_OVERRIDE_PENDING_TOOLTIP = "Stored as an operator topology override. The selected parent will be applied on the next scheduler run.";
@@ -216,14 +224,6 @@ function ethernetBadgeClass(tierLabel) {
return "text-bg-info";
}
function configuredMax(node) {
return node.configured_max_throughput || node.max_throughput || [0, 0];
}
function effectiveMax(node) {
return node.effective_max_throughput || configuredMax(node);
}
function rootGaugeMaxAvailable() {
return Array.isArray(rootGaugeConfigMax)
&& rootGaugeConfigMax.length === 2
@@ -241,11 +241,6 @@ function nodeSnapshotGaugeMax(node) {
return effectiveMax(node);
}
function ratesMatch(a, b) {
return Math.abs(toNumber(a?.[0], 0) - toNumber(b?.[0], 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS
&& Math.abs(toNumber(a?.[1], 0) - toNumber(b?.[1], 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS;
}
function formatLimitValue(mbps) {
if (toNumber(mbps, 0) === 0) {
return "Unlimited";
@@ -264,10 +259,6 @@ function formatMbpsInputValue(mbps) {
return numeric.toFixed(2).replace(/\.?0+$/, "");
}
function ratesApproximatelyEqual(left, right) {
return Math.abs(toNumber(left, 0) - toNumber(right, 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS;
}
function formatRatePair(downMbps, upMbps) {
return `${formatLimitValue(downMbps)} / ${formatLimitValue(upMbps)}`;
}
@@ -283,6 +274,10 @@ function currentNode() {
return tree && tree[parent] ? tree[parent][1] : null;
}
function immediateParentNode(node) {
return immediateParentNodeFromTree(tree, node);
}
function currentNodeQuery() {
const node = currentNode();
if (!node) {
@@ -321,7 +316,7 @@ function buildEffectiveLimitCellHtml(node) {
function buildConfiguredLimitCellHtml(node) {
const configured = configuredMax(node);
const effective = effectiveMax(node);
const secondaryClass = ratesMatch(effective, configured)
const secondaryClass = ratePairsMatch(effective, configured)
? "lqos-limit-line lqos-limit-secondary is-match"
: "lqos-limit-line lqos-limit-secondary";
const configuredValue = `${formatLimitValue(configured[0])} / ${formatLimitValue(configured[1])}`;
@@ -333,7 +328,7 @@ function buildDirectionalLimitHtml(primaryMbps, configuredMbps, showConfigured)
if (!showConfigured) {
return `<div class="lqos-limit-block"><div class="lqos-limit-line">${primary}</div></div>`;
}
const match = Math.abs(toNumber(primaryMbps, 0) - toNumber(configuredMbps, 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS;
const match = ratesApproximatelyEqual(primaryMbps, configuredMbps);
const secondaryClass = match
? "lqos-limit-line lqos-limit-secondary is-match"
: "lqos-limit-line lqos-limit-secondary";
@@ -374,6 +369,9 @@ function buildStatusIcon(iconName, textClass, title) {
icon.classList.add("fa", "fa-fw", iconName, textClass);
icon.setAttribute("data-bs-toggle", "tooltip");
icon.setAttribute("data-bs-placement", "top");
icon.setAttribute("data-bs-trigger", "hover focus");
icon.setAttribute("data-bs-container", "body");
icon.setAttribute("tabindex", "0");
icon.setAttribute("title", title);
return icon;
}
@@ -625,6 +623,9 @@ function buildTreeRowStatusIcon(iconName, isActive, activeTitle, inactiveTitle)
}
icon.setAttribute("data-bs-toggle", "tooltip");
icon.setAttribute("data-bs-placement", "top");
icon.setAttribute("data-bs-trigger", "hover focus");
icon.setAttribute("data-bs-container", "body");
icon.setAttribute("tabindex", "0");
icon.setAttribute("title", isActive ? activeTitle : inactiveTitle);
return icon;
}
@@ -764,7 +765,10 @@ function renderCompatibilityWarningsIndicator(messages) {
icon.classList.add("lqos-tree-compat-indicator");
icon.setAttribute("data-bs-toggle", "tooltip");
icon.setAttribute("data-bs-placement", "top");
icon.setAttribute("data-bs-trigger", "hover focus");
icon.setAttribute("data-bs-container", "body");
icon.setAttribute("data-bs-html", "true");
icon.setAttribute("tabindex", "0");
icon.setAttribute("title", tooltipHtml);
icon.setAttribute("aria-label", `Compatibility warnings: ${visibleMessages.length}`);
icon.innerHTML = `<i class="fa fa-triangle-exclamation"></i><span class="lqos-tree-compat-count">${visibleMessages.length}</span>`;
@@ -864,6 +868,9 @@ function renderOverrideValue(node, overrideData) {
pending.classList.add("lqos-tree-pending");
pending.setAttribute("data-bs-toggle", "tooltip");
pending.setAttribute("data-bs-placement", "top");
pending.setAttribute("data-bs-trigger", "hover focus");
pending.setAttribute("data-bs-container", "body");
pending.setAttribute("tabindex", "0");
pending.setAttribute("title", NODE_OVERRIDE_PENDING_TOOLTIP);
const symbol = document.createElement("span");
@@ -967,6 +974,9 @@ function renderTopologyOverrideValue(overrideData) {
pending.classList.add("lqos-tree-pending");
pending.setAttribute("data-bs-toggle", "tooltip");
pending.setAttribute("data-bs-placement", "top");
pending.setAttribute("data-bs-trigger", "hover focus");
pending.setAttribute("data-bs-container", "body");
pending.setAttribute("tabindex", "0");
pending.setAttribute("title", TOPOLOGY_OVERRIDE_PENDING_TOOLTIP);
const symbol = document.createElement("span");
@@ -983,6 +993,21 @@ function renderTopologyOverrideValue(overrideData) {
target.appendChild(wrap);
}
function renderEffectiveNowValue(node) {
const target = document.getElementById("nodeSettingsEffectiveNow");
if (!target) {
return;
}
const effective = effectiveMax(node);
const summary = buildNodeLimitSummary({
node,
parentNode: immediateParentNode(node),
rateOverrideData: nodeRateOverrideState.data,
topologyOverrideData: nodeTopologyOverrideState.data,
});
renderEffectiveNowDisplay({target, effective, summary, formatRatePair});
}
function renderNodeTopologySettings(node) {
const statusTarget = document.getElementById("nodeTopologyStatus");
if (statusTarget) {
@@ -1045,11 +1070,7 @@ function renderNodeSettings() {
const configured = configuredMax(node);
baseConfiguredTarget.textContent = formatRatePair(configured[0], configured[1]);
}
const effectiveTarget = document.getElementById("nodeSettingsEffectiveNow");
if (effectiveTarget) {
const effective = effectiveMax(node);
effectiveTarget.textContent = formatRatePair(effective[0], effective[1]);
}
renderEffectiveNowValue(node);
const activeAttachmentTarget = document.getElementById("nodeSettingsActiveAttachment");
if (activeAttachmentTarget) {
activeAttachmentTarget.textContent = node.active_attachment_name || "-";
@@ -1423,10 +1444,10 @@ function fillHeader(node) {
const configuredUp = formatLimitValue(configured[1]);
const effectiveDown = formatLimitValue(effective[0]);
const effectiveUp = formatLimitValue(effective[1]);
const matchClassDown = Math.abs(toNumber(effective[0], 0) - toNumber(configured[0], 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS
const matchClassDown = ratesApproximatelyEqual(effective[0], configured[0])
? "lqos-limit-secondary is-match"
: "lqos-limit-secondary";
const matchClassUp = Math.abs(toNumber(effective[1], 0) - toNumber(configured[1], 0)) <= THROUGHPUT_COMPARE_EPSILON_MBPS
const matchClassUp = ratesApproximatelyEqual(effective[1], configured[1])
? "lqos-limit-secondary is-match"
: "lqos-limit-secondary";
$("#parentEffectiveD").text(effectiveDown);
@@ -1854,6 +1875,8 @@ function renderAttachedCircuitsRows(rows) {
badge.setAttribute("aria-label", `Review ${circuit.ethernet_cap_badge.tier_label} Ethernet-limited circuits`);
badge.setAttribute("data-bs-toggle", "tooltip");
badge.setAttribute("data-bs-placement", "top");
badge.setAttribute("data-bs-trigger", "hover focus");
badge.setAttribute("data-bs-container", "body");
badge.setAttribute("title", formatEthernetTooltip(circuit.ethernet_cap_badge));
badge.textContent = circuit.ethernet_cap_badge.tier_label;
planCell.appendChild(badge);
@@ -0,0 +1,195 @@
import {toNumber} from "./lq_js_common/helpers/scaling.js";
import {clearElement} from "./helpers/dom_clear.mjs";
import {
configuredMax,
effectiveMax,
rateBelow,
ratePairsMatch,
ratesApproximatelyEqual,
} from "./helpers/network_rates.mjs";
export {configuredMax, effectiveMax, ratePairsMatch, ratesApproximatelyEqual};
const COMPACT_REASON_LABELS = {
attachment: "Est. Attachment",
base: "Base rate",
mixed: "Est. Mixed",
override: "Est. Override",
parent: "Est. Parent",
queue: "Est. Queue",
};
export function immediateParentNodeFromTree(tree, node) {
const parentIndex = node?.immediate_parent;
if (parentIndex === null || parentIndex === undefined || !tree?.[parentIndex]) {
return null;
}
return tree[parentIndex][1] || null;
}
function formatMbps(value) {
const numeric = toNumber(value, 0);
if (numeric === 0) {
return "Unlimited";
}
return `${numeric.toFixed(1).replace(/\.0$/, "")}M`;
}
function overrideValue(rateOverrideData, direction) {
if (!rateOverrideData?.has_override) {
return null;
}
const field = direction === 0
? "override_download_bandwidth_mbps"
: "override_upload_bandwidth_mbps";
const value = rateOverrideData[field];
if (value === null || value === undefined) {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function topologyOverrideParentIsActive(topologyOverrideData) {
if (!topologyOverrideData?.has_override) {
return false;
}
const desired = Array.isArray(topologyOverrideData.override_parent_node_ids)
? topologyOverrideData.override_parent_node_ids[0]
: null;
return !!desired && desired === topologyOverrideData.current_parent_node_id;
}
function parentDisplayName(parentNode, topologyOverrideData) {
return topologyOverrideData?.current_parent_node_name
|| parentNode?.name
|| "upstream parent";
}
function reasonForDirection({node, parentNode, rateOverrideData, topologyOverrideData, direction}) {
const configured = configuredMax(node);
const effective = effectiveMax(node);
const configuredValue = configured[direction];
const effectiveValue = effective[direction];
const override = overrideValue(rateOverrideData, direction);
const parentEffective = parentNode ? effectiveMax(parentNode)[direction] : null;
const directionName = direction === 0 ? "Download" : "Upload";
if (
parentEffective !== null
&& ratesApproximatelyEqual(effectiveValue, parentEffective)
&& (rateBelow(effectiveValue, configuredValue) || (override !== null && rateBelow(effectiveValue, override)))
) {
const parentName = parentDisplayName(parentNode, topologyOverrideData);
const suffix = topologyOverrideParentIsActive(topologyOverrideData)
? " selected by topology override"
: "";
const attachment = node?.active_attachment_name
? ` Active attachment: ${node.active_attachment_name}.`
: "";
return {
kind: "parent",
label: "Parent",
detail: `${directionName}: inherited from parent ${parentName}${suffix} at ${formatMbps(effectiveValue)}.${attachment}`,
};
}
if (override !== null && ratesApproximatelyEqual(effectiveValue, override)) {
return {
kind: "override",
label: "Override",
detail: `${directionName}: matches operator rate override at ${formatMbps(effectiveValue)}.`,
};
}
if (node?.active_attachment_name && rateBelow(effectiveValue, configuredValue)) {
return {
kind: "attachment",
label: "Attachment",
detail: `${directionName}: capped by active attachment ${node.active_attachment_name} at ${formatMbps(effectiveValue)}.`,
};
}
if (rateBelow(effectiveValue, configuredValue)) {
return {
kind: "queue",
label: "Queue",
detail: `${directionName}: effective queue rate is below the configured rate at ${formatMbps(effectiveValue)}.`,
};
}
return {
kind: "base",
label: "Base",
detail: `${directionName}: uses configured rate at ${formatMbps(effectiveValue)}.`,
};
}
export function buildNodeLimitSummary({
node,
parentNode = null,
rateOverrideData = null,
topologyOverrideData = null,
} = {}) {
const directions = [0, 1].map((direction) => reasonForDirection({
node,
parentNode,
rateOverrideData,
topologyOverrideData,
direction,
}));
const sameKind = directions[0].kind === directions[1].kind;
const label = sameKind ? directions[0].label : "Mixed";
return {
label,
kind: sameKind ? directions[0].kind : "mixed",
title: directions.map((direction) => direction.detail).join(" "),
compactReason: COMPACT_REASON_LABELS[sameKind ? directions[0].kind : "mixed"],
directions,
};
}
export function limitSourceBadgeClass(kind) {
switch (kind) {
case "parent":
return "text-bg-warning";
case "override":
return "text-bg-info";
case "attachment":
return "text-bg-primary";
case "mixed":
return "text-bg-secondary";
case "queue":
return "text-bg-danger";
default:
return "text-bg-dark";
}
}
export function renderEffectiveNowDisplay({target, effective, summary, formatRatePair}) {
if (!target) {
return;
}
clearElement(target);
const doc = target.ownerDocument || document;
const wrap = doc.createElement("span");
wrap.classList.add("lqos-tree-detail-value", "lqos-tree-effective-now");
const value = doc.createElement("span");
value.textContent = formatRatePair(effective[0], effective[1]);
wrap.appendChild(value);
const badge = doc.createElement("span");
badge.classList.add("badge", "rounded-pill", "lqos-tree-limit-source", limitSourceBadgeClass(summary.kind));
badge.textContent = summary.compactReason || summary.label;
badge.setAttribute("data-bs-toggle", "tooltip");
badge.setAttribute("data-bs-placement", "top");
badge.setAttribute("data-bs-trigger", "hover focus");
badge.setAttribute("data-bs-container", "body");
badge.setAttribute("tabindex", "0");
badge.setAttribute("title", `Estimated effective-rate source. ${summary.title}`);
badge.setAttribute("aria-label", `Estimated effective-rate source. ${summary.title}`);
wrap.appendChild(badge);
target.appendChild(wrap);
}
@@ -0,0 +1,368 @@
import assert from "node:assert/strict";
import {readFileSync} from "node:fs";
import {dirname, resolve} from "node:path";
import test from "node:test";
import {fileURLToPath} from "node:url";
import {
buildNodeLimitSummary,
configuredMax,
effectiveMax,
immediateParentNodeFromTree,
ratePairsMatch,
ratesApproximatelyEqual,
renderEffectiveNowDisplay,
} from "./tree_limit_reason.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
class FakeElement {
constructor(ownerDocument, tagName) {
this.ownerDocument = ownerDocument;
this.tagName = tagName;
this.attributes = {};
this.children = [];
this.textContent = "";
this.classNames = [];
this.classList = {
add: (...names) => {
this.classNames.push(...names);
},
};
}
get lastChild() {
return this.children[this.children.length - 1] || null;
}
appendChild(child) {
this.children.push(child);
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) {
this.children.splice(index, 1);
}
return child;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
matches(selector) {
return selector === '[data-bs-toggle="tooltip"]'
&& this.attributes["data-bs-toggle"] === "tooltip";
}
querySelectorAll(selector) {
const matches = [];
const visit = (element) => {
if (element.matches?.(selector)) {
matches.push(element);
}
element.children?.forEach(visit);
};
this.children.forEach(visit);
return matches;
}
}
const fakeDocument = {
createElement(tagName) {
return new FakeElement(fakeDocument, tagName);
},
};
function node({
name = "Selected",
configured = [500, 100],
effective = configured,
activeAttachment = null,
} = {}) {
return {
name,
configured_max_throughput: configured,
max_throughput: configured,
effective_max_throughput: effective,
active_attachment_name: activeAttachment,
};
}
test("rate helpers fall back from effective to configured rates", () => {
const selected = {
max_throughput: [100, 50],
};
assert.deepEqual(configuredMax(selected), [100, 50]);
assert.deepEqual(effectiveMax(selected), [100, 50]);
});
test("summary identifies parent-limited nodes", () => {
const selected = node({configured: [500, 100], effective: [294, 60]});
const parent = node({name: "7232 Rochester", configured: [294, 60], effective: [294, 60]});
const summary = buildNodeLimitSummary({node: selected, parentNode: parent});
assert.equal(summary.label, "Parent");
assert.equal(summary.kind, "parent");
assert.equal(summary.compactReason, "Est. Parent");
assert.match(summary.title, /Download: inherited from parent 7232 Rochester at 294M\./);
assert.match(summary.title, /Upload: inherited from parent 7232 Rochester at 60M\./);
});
test("summary notes active topology parent overrides", () => {
const selected = node({
configured: [500, 100],
effective: [294, 60],
activeAttachment: "Backhaul A",
});
const parent = node({name: "Old Parent", configured: [294, 60], effective: [294, 60]});
const summary = buildNodeLimitSummary({
node: selected,
parentNode: parent,
topologyOverrideData: {
has_override: true,
override_parent_node_ids: ["parent-1"],
current_parent_node_id: "parent-1",
current_parent_node_name: "Pinned Parent",
},
});
assert.equal(summary.label, "Parent");
assert.match(summary.title, /Pinned Parent selected by topology override/);
assert.match(summary.title, /Active attachment: Backhaul A\./);
});
test("summary identifies operator rate overrides", () => {
const selected = node({configured: [500, 100], effective: [250, 80]});
const summary = buildNodeLimitSummary({
node: selected,
rateOverrideData: {
has_override: true,
override_download_bandwidth_mbps: 250,
override_upload_bandwidth_mbps: 80,
},
});
assert.equal(summary.label, "Override");
assert.match(summary.title, /Download: matches operator rate override at 250M\./);
assert.match(summary.title, /Upload: matches operator rate override at 80M\./);
});
test("summary identifies attachment caps when no parent or override explains the rate", () => {
const selected = node({
configured: [500, 100],
effective: [400, 80],
activeAttachment: "Backhaul A",
});
const summary = buildNodeLimitSummary({node: selected});
assert.equal(summary.label, "Attachment");
assert.match(summary.title, /Download: capped by active attachment Backhaul A at 400M\./);
assert.match(summary.title, /Upload: capped by active attachment Backhaul A at 80M\./);
});
test("summary reports mixed causes when directions differ", () => {
const selected = node({configured: [500, 100], effective: [294, 80]});
const parent = node({name: "Parent Node", configured: [294, 100], effective: [294, 100]});
const summary = buildNodeLimitSummary({
node: selected,
parentNode: parent,
rateOverrideData: {
has_override: true,
override_download_bandwidth_mbps: 400,
override_upload_bandwidth_mbps: 80,
},
});
assert.equal(summary.label, "Mixed");
assert.equal(summary.kind, "mixed");
assert.equal(summary.compactReason, "Est. Mixed");
assert.equal(summary.directions[0].kind, "parent");
assert.equal(summary.directions[1].kind, "override");
});
test("summary uses explicit compact label for unexplained queue caps", () => {
const selected = node({configured: [500, 100], effective: [450, 90]});
const summary = buildNodeLimitSummary({node: selected});
assert.equal(summary.label, "Queue");
assert.equal(summary.kind, "queue");
assert.equal(summary.compactReason, "Est. Queue");
});
test("summary does not treat a missing override direction as overridden", () => {
const selected = node({configured: [500, 100], effective: [500, 80]});
const summary = buildNodeLimitSummary({
node: selected,
rateOverrideData: {
has_override: true,
override_download_bandwidth_mbps: null,
override_upload_bandwidth_mbps: 80,
},
});
assert.equal(summary.label, "Mixed");
assert.equal(summary.directions[0].kind, "base");
assert.equal(summary.directions[1].kind, "override");
assert.match(summary.directions[0].detail, /Download: uses configured rate at 500M\./);
});
test("summary uses base when effective and configured rates match", () => {
const selected = node({configured: [294, 60], effective: [294, 60]});
const summary = buildNodeLimitSummary({node: selected});
assert.equal(summary.label, "Base");
assert.equal(summary.kind, "base");
assert.equal(summary.compactReason, "Base rate");
assert.match(summary.title, /Download: uses configured rate at 294M\./);
});
test("rate comparison boundary is shared and explicit", () => {
assert.equal(ratesApproximatelyEqual(100, 100.009), true);
assert.equal(ratesApproximatelyEqual(100, 100.011), false);
assert.equal(ratePairsMatch([100, 50], [100.009, 50]), true);
assert.equal(ratePairsMatch([100, 50], [100.011, 50]), false);
});
test("parent lookup follows the selected node's immediate parent index", () => {
const parent = node({name: "Parent", configured: [300, 60]});
const selected = {
...node({name: "Child", configured: [500, 100]}),
immediate_parent: 2,
};
const tree = [
[0, node({name: "Root"})],
[1, selected],
[2, parent],
];
assert.equal(immediateParentNodeFromTree(tree, selected), parent);
assert.equal(immediateParentNodeFromTree(tree, {...selected, immediate_parent: null}), null);
assert.equal(immediateParentNodeFromTree(tree, {...selected, immediate_parent: 99}), null);
});
test("effective rate renderer builds accessible source badge", () => {
const target = new FakeElement(fakeDocument, "dd");
const summary = {
kind: "parent",
label: "Parent",
compactReason: "Est. Parent",
title: "Download: inherited from parent Alpha at 100M. Upload: inherited from parent Alpha at 50M.",
};
renderEffectiveNowDisplay({
target,
effective: [100, 50],
summary,
formatRatePair: (down, up) => `${down}M / ${up}M`,
});
const wrap = target.children[0];
const value = wrap.children[0];
const badge = wrap.children[1];
assert.deepEqual(wrap.classNames, ["lqos-tree-detail-value", "lqos-tree-effective-now"]);
assert.equal(wrap.children.length, 2);
assert.equal(value.textContent, "100M / 50M");
assert.equal(badge.textContent, "Est. Parent");
assert.ok(badge.classNames.includes("lqos-tree-limit-source"));
assert.ok(badge.classNames.includes("text-bg-warning"));
assert.equal(badge.attributes["data-bs-toggle"], "tooltip");
assert.equal(badge.attributes["data-bs-trigger"], "hover focus");
assert.equal(badge.attributes["data-bs-container"], "body");
assert.equal(badge.attributes.tabindex, "0");
assert.equal(badge.attributes.title, `Estimated effective-rate source. ${summary.title}`);
assert.equal(badge.attributes["aria-label"], `Estimated effective-rate source. ${summary.title}`);
});
test("effective rate renderer disposes stale tooltip state before rebuilding", () => {
const target = new FakeElement(fakeDocument, "dd");
const summary = {
kind: "parent",
label: "Parent",
compactReason: "Est. Parent",
title: "Download: inherited from parent Alpha at 100M. Upload: inherited from parent Alpha at 50M.",
};
const args = {
target,
effective: [100, 50],
summary,
formatRatePair: (down, up) => `${down}M / ${up}M`,
};
renderEffectiveNowDisplay(args);
const oldBadge = target.children[0].children[1];
let disposed = false;
globalThis.bootstrap = {
Tooltip: {
getInstance(el) {
assert.equal(el, oldBadge);
return {
dispose() {
disposed = true;
},
};
},
},
};
try {
renderEffectiveNowDisplay(args);
} finally {
delete globalThis.bootstrap;
}
assert.equal(disposed, true);
assert.equal(target.children.length, 1);
});
test("shared rate helpers preserve configured and effective fallback behavior", () => {
const selected = {
max_throughput: [100, 50],
effective_max_throughput: [90, 40],
};
assert.deepEqual(configuredMax(selected), [100, 50]);
assert.deepEqual(effectiveMax(selected), [90, 40]);
});
test("tree details markup keeps compact definition grid IDs", () => {
const html = readFileSync(resolve(__dirname, "../../static2/tree.html"), "utf8");
assert.match(html, /<dl class="lqos-tree-detail-grid">/);
const expected = [
"nodeSettingsBaseConfigured",
"nodeSettingsEffectiveNow",
"nodeSettingsOverride",
"nodeTopologyOverrideValue",
"nodeSettingsActiveAttachment",
];
expected.forEach((id) => {
assert.match(html, new RegExp(`id="${id}"`));
});
assert.match(
html,
/<div class="lqos-tree-detail-item">\s*<dt class="lqos-tree-detail-label">Base Configured Rate<\/dt>\s*<dd class="lqos-tree-detail-body" id="nodeSettingsBaseConfigured">-/,
);
assert.match(
html,
/<div class="lqos-tree-detail-item">\s*<dt class="lqos-tree-detail-label">Effective Now<\/dt>\s*<dd class="lqos-tree-detail-body" id="nodeSettingsEffectiveNow">-/,
);
assert.match(
html,
/<div class="lqos-tree-detail-item lqos-tree-detail-item-wide">\s*<dt class="lqos-tree-detail-label">Active Attachment<\/dt>\s*<dd class="lqos-tree-detail-body" id="nodeSettingsActiveAttachment">-/,
);
const positions = expected.map((id) => html.indexOf(`id="${id}"`));
assert.deepEqual([...positions].sort((a, b) => a - b), positions);
});
@@ -13,6 +13,7 @@ node --test \
"${SCRIPT_DIR}/src/helpers/builders.test.mjs" \
"${SCRIPT_DIR}/src/circuit_rate_display.test.mjs" \
"${SCRIPT_DIR}/src/circuit_packet_capture_dom.test.mjs" \
"${SCRIPT_DIR}/src/tree_limit_reason.test.mjs" \
"${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs" \
"${SCRIPT_DIR}/src/config/config_interface_mtu.test.mjs" \
"${SCRIPT_DIR}/src/config/network_mode_storage.test.mjs" \
@@ -1853,16 +1853,63 @@ h5 {
flex: 1 1 auto;
align-content: center;
}
.lqos-tree-state-panel .lqos-table-wrap,
.lqos-tree-details-panel .lqos-table-wrap {
.lqos-tree-state-panel .lqos-table-wrap {
height: 100%;
}
.lqos-tree-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
overflow: hidden;
border: 1px solid var(--lqos-border);
border-radius: var(--lqos-radius-sm);
background: color-mix(in srgb, var(--lqos-surface-2) 60%, transparent);
}
.lqos-tree-detail-item {
display: grid;
grid-template-columns: minmax(7.75rem, max-content) minmax(0, 1fr);
gap: 0.6rem;
align-items: center;
min-width: 0;
padding: 0.58rem 0.7rem;
border-bottom: 1px solid var(--lqos-border);
}
.lqos-tree-detail-item:nth-child(odd) {
border-right: 1px solid var(--lqos-border);
}
.lqos-tree-detail-item-wide {
grid-column: 1 / -1;
border-right: 0;
border-bottom: 0;
}
.lqos-tree-detail-label {
color: var(--lqos-muted);
font-size: 0.86rem;
font-weight: 650;
line-height: 1.2;
}
.lqos-tree-detail-body {
min-width: 0;
margin: 0;
color: var(--lqos-text);
font-size: 0.95rem;
line-height: 1.25;
overflow-wrap: anywhere;
}
.lqos-tree-detail-value {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.lqos-tree-effective-now {
flex-wrap: nowrap;
max-width: 100%;
}
.lqos-tree-limit-source {
flex: 0 0 auto;
cursor: help;
}
.lqos-tree-inline-overrides {
display: flex;
flex-direction: column;
@@ -1871,6 +1918,24 @@ h5 {
.lqos-tree-inline-overrides > div:empty {
display: none;
}
@media (max-width: 1199.98px) {
.lqos-tree-detail-grid {
grid-template-columns: 1fr;
}
.lqos-tree-detail-item,
.lqos-tree-detail-item:nth-child(odd) {
border-right: 0;
}
}
@media (max-width: 575.98px) {
.lqos-tree-detail-item {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.lqos-tree-effective-now {
flex-wrap: wrap;
}
}
.lqos-tree-editor {
display: flex;
flex-direction: column;
@@ -76,32 +76,28 @@
<div class="d-flex align-items-center justify-content-between gap-2">
<h5 class="mb-0"><i class="fa fa-circle-info"></i> Node Details</h5>
</div>
<div class="lqos-table-wrap">
<table class="lqos-table lqos-table-compact">
<tbody>
<tr class="small">
<td class="table-label-cell">Base Configured Rate</td>
<td class="table-value-cell" id="nodeSettingsBaseConfigured">-</td>
</tr>
<tr class="small">
<td class="table-label-cell">Rate Override</td>
<td class="table-value-cell" id="nodeSettingsOverride"></td>
</tr>
<tr class="small">
<td class="table-label-cell">Topology Override</td>
<td class="table-value-cell" id="nodeTopologyOverrideValue"></td>
</tr>
<tr class="small">
<td class="table-label-cell">Active Attachment</td>
<td class="table-value-cell" id="nodeSettingsActiveAttachment">-</td>
</tr>
<tr class="small">
<td class="table-label-cell">Effective Now</td>
<td class="table-value-cell" id="nodeSettingsEffectiveNow">-</td>
</tr>
</tbody>
</table>
</div>
<dl class="lqos-tree-detail-grid">
<div class="lqos-tree-detail-item">
<dt class="lqos-tree-detail-label">Base Configured Rate</dt>
<dd class="lqos-tree-detail-body" id="nodeSettingsBaseConfigured">-</dd>
</div>
<div class="lqos-tree-detail-item">
<dt class="lqos-tree-detail-label">Effective Now</dt>
<dd class="lqos-tree-detail-body" id="nodeSettingsEffectiveNow">-</dd>
</div>
<div class="lqos-tree-detail-item">
<dt class="lqos-tree-detail-label">Rate Override</dt>
<dd class="lqos-tree-detail-body" id="nodeSettingsOverride"></dd>
</div>
<div class="lqos-tree-detail-item">
<dt class="lqos-tree-detail-label">Topology Override</dt>
<dd class="lqos-tree-detail-body" id="nodeTopologyOverrideValue"></dd>
</div>
<div class="lqos-tree-detail-item lqos-tree-detail-item-wide">
<dt class="lqos-tree-detail-label">Active Attachment</dt>
<dd class="lqos-tree-detail-body" id="nodeSettingsActiveAttachment">-</dd>
</div>
</dl>
<div class="lqos-tree-inline-overrides">
<div id="nodeOverrideDisabledReason"></div>
<div id="nodeOverrideEditorSection">