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 `
${primary}
`; } - 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
+
-
+
+