diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md
index 8b9eb31c..0826efba 100644
--- a/docs-es/v2.0/node-manager-ui-es.md
+++ b/docs-es/v2.0/node-manager-ui-es.md
@@ -34,7 +34,8 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta
- Las ediciones de tasa desde la página del árbol escriben overrides operativos `AdjustSiteSpeed` en `lqos_overrides.json`.
- En compilaciones UISP `full`, un `integrationUISPbandwidths.csv` heredado se auto-migra a esos overrides en la siguiente ejecución de la integración cuando todavía no existen overrides operativos de tasa; en caso contrario, el CSV se ignora.
- Las ediciones de tasa desde la página del árbol requieren sesión de administrador, un `node_id` estable y un nodo no generado. Los nodos generados/sintéticos de la integración permanecen en solo lectura en este editor.
-- La página del árbol mantiene `Node Details` como una tarjeta compacta de resumen y coloca los editores de override como filas compactas directamente debajo de la tabla de detalles.
+- La página del árbol mantiene `Node Details` como una cuadrícula compacta de resumen y coloca los editores de override como filas compactas directamente debajo.
+- `Effective Now` muestra una insignia de origen estimado, como Parent, Override, Attachment o Mixed. Pase el cursor o enfoque la insignia para ver los detalles de descarga y subida.
- La página del árbol ahora muestra el estado de `Topology Override` solo en modo lectura. Los cambios de padre y adjunto se movieron a `Topology`, y el panel de detalles del árbol enlaza directamente a Topology Manager para el nodo seleccionado.
- Los saltos de adjuntos seleccionados en runtime se colapsan fuera de la jerarquía principal del árbol. Cuando un sitio usa actualmente un backhaul o radio path específico, `Node Details` lo muestra como metadato `Active Attachment` en el sitio en lugar de exponer ese adjunto como un nodo propio del árbol.
- Los adjuntos alternativos inactivos de backhaul PtP/cableado también se eliminan del árbol runtime. `tree.html` muestra solo la ruta efectiva; los alternativos inactivos quedan visibles en Topology Manager y no como nodos runtime independientes.
diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md
index bc0065ea..51ecd5b3 100644
--- a/docs/v2.0/node-manager-ui.md
+++ b/docs/v2.0/node-manager-ui.md
@@ -37,7 +37,8 @@ For the full logical-topology versus queue-topology file flow, see [Topology Dat
- Tree-page rate edits save persistent `AdjustSiteSpeed` overrides in `lqos_overrides.json`.
- On UISP `full` builds, legacy `integrationUISPbandwidths.csv` files are auto-migrated into those operator overrides on the next integration run when no operator rate overrides exist yet; otherwise the CSV is ignored.
- Tree-page operator rate edits require an administrator session, a stable node ID, and a non-generated node. Generated/integration-synthetic nodes remain read-only in this editor.
-- The tree page keeps `Node Details` as a compact summary card and places the override editors as compact inline rows directly beneath the details table.
+- The tree page keeps `Node Details` as a compact summary grid and places the override editors as compact inline rows directly beneath it.
+- `Effective Now` shows an estimated source badge, such as Parent, Override, Attachment, or Mixed. Hover or focus the badge to see the download and upload details.
- The tree page now shows topology override state as read-only summary only. Parent and attachment edits have moved to `Topology`, and the tree details panel links directly into Topology Manager for the selected node.
- Runtime-selected attachment hops are collapsed out of the main tree hierarchy. When a site is currently using a specific backhaul or radio path, `Node Details` shows that as `Active Attachment` metadata on the site instead of exposing the attachment as its own branch node.
- Inactive alternate PtP/wired backhaul attachment stubs are also pruned from the runtime tree. `tree.html` shows the effective path only; dormant alternates remain visible in Topology Manager rather than as standalone runtime nodes.
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_sankey.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_sankey.js
index e2b9810a..1b257b8d 100644
--- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_sankey.js
+++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_sankey.js
@@ -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);
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_summary.js b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_summary.js
index cfc47aea..9dc94c14 100644
--- a/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_summary.js
+++ b/src/rust/lqosd/src/node_manager/js_build/src/dashlets/top_tree_summary.js
@@ -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(
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js b/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js
index 320b3791..5861b947 100644
--- a/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js
+++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/builders.js
@@ -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() {
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/helpers/dom_clear.mjs b/src/rust/lqosd/src/node_manager/js_build/src/helpers/dom_clear.mjs
new file mode 100644
index 00000000..df138a7b
--- /dev/null
+++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/dom_clear.mjs
@@ -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);
+ }
+}
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/helpers/network_rates.mjs b/src/rust/lqosd/src/node_manager/js_build/src/helpers/network_rates.mjs
new file mode 100644
index 00000000..12fe8afd
--- /dev/null
+++ b/src/rust/lqosd/src/node_manager/js_build/src/helpers/network_rates.mjs
@@ -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]);
+}
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js
index 36e9dd8b..9d0c33bc 100644
--- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js
+++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js
@@ -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);
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree.js b/src/rust/lqosd/src/node_manager/js_build/src/tree.js
index e5e545f8..bd8c26d8 100644
--- a/src/rust/lqosd/src/node_manager/js_build/src/tree.js
+++ b/src/rust/lqosd/src/node_manager/js_build/src/tree.js
@@ -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 = "
Quality of Outcome (QoO)
" +
"Quality of Outcome (QoO) is IETF IPPM “Internet Quality” (draft-ietf-ippm-qoo).
" +
"https://datatracker.ietf.org/doc/draft-ietf-ippm-qoo/
" +
"LibreQoS implements a latency and loss-based model to estimate quality of outcome.
";
-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 ``;
}
- 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 = `${visibleMessages.length}`;
@@ -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);
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs
new file mode 100644
index 00000000..ab89e456
--- /dev/null
+++ b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.mjs
@@ -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);
+}
diff --git a/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs
new file mode 100644
index 00000000..77a3c341
--- /dev/null
+++ b/src/rust/lqosd/src/node_manager/js_build/src/tree_limit_reason.test.mjs
@@ -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, //);
+ const expected = [
+ "nodeSettingsBaseConfigured",
+ "nodeSettingsEffectiveNow",
+ "nodeSettingsOverride",
+ "nodeTopologyOverrideValue",
+ "nodeSettingsActiveAttachment",
+ ];
+ expected.forEach((id) => {
+ assert.match(html, new RegExp(`id="${id}"`));
+ });
+ assert.match(
+ html,
+ /\s*
- Base Configured Rate<\/dt>\s*
- -/,
+ );
+ assert.match(
+ html,
+ /
\s*
- Effective Now<\/dt>\s*
- -/,
+ );
+ assert.match(
+ html,
+ /
\s*
- Active Attachment<\/dt>\s*
- -/,
+ );
+ const positions = expected.map((id) => html.indexOf(`id="${id}"`));
+ assert.deepEqual([...positions].sort((a, b) => a - b), positions);
+});
diff --git a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh
index 8adc94ec..554554dd 100755
--- a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh
+++ b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh
@@ -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" \
diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css
index b09977ff..8991982a 100644
--- a/src/rust/lqosd/src/node_manager/static2/node_manager.css
+++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css
@@ -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;
diff --git a/src/rust/lqosd/src/node_manager/static2/tree.html b/src/rust/lqosd/src/node_manager/static2/tree.html
index 2e8b8a47..ca31c631 100644
--- a/src/rust/lqosd/src/node_manager/static2/tree.html
+++ b/src/rust/lqosd/src/node_manager/static2/tree.html
@@ -76,32 +76,28 @@
Node Details
-
-
-
-
- | Base Configured Rate |
- - |
-
-
- | Rate Override |
- |
-
-
- | Topology Override |
- |
-
-
- | Active Attachment |
- - |
-
-
- | Effective Now |
- - |
-
-
-
-
+
+
+
- Base Configured Rate
+ - -
+
+
+
- Effective Now
+ - -
+
+
+
- Rate Override
+
+
+
+
- Topology Override
+
+
+
+
- Active Attachment
+ - -
+
+