From 19b4265dedd24f630d68970cbbc75b8f2516388d Mon Sep 17 00:00:00 2001 From: Robert Chacon Date: Mon, 16 Mar 2026 16:20:29 -0600 Subject: [PATCH] changes --- .../src/node_manager/js_build/src/circuit.js | 273 ++++++- .../src/graphs/queuing_activity_waveform.js | 686 ++++++++++++++++++ .../src/node_manager/static2/circuit.html | 75 +- .../src/node_manager/static2/node_manager.css | 206 ++++++ 4 files changed, 1220 insertions(+), 20 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js diff --git a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js index 546c2c21..030d6214 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/circuit.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/circuit.js @@ -19,6 +19,7 @@ import {CakeQueueLength} from "./graphs/cake_queue_length"; import {CakeTraffic} from "./graphs/cake_traffic"; import {CakeMarks} from "./graphs/cake_marks"; import {CakeDrops} from "./graphs/cake_drops"; +import {QueuingActivityWaveform} from "./graphs/queuing_activity_waveform"; import {getNodeIdMap, linkToTreeNode} from "./executive_utils"; const params = new Proxy(new URLSearchParams(window.location.search), { @@ -51,6 +52,12 @@ let latestFlowMsg = null; let latestCakeMsg = null; let cakeGraphs = null; let cakeQueueUnavailable = false; +let queuingActivityGraph = null; +let latestCircuitDevices = []; +let latestCircuitQooScore = null; +let queuingActivityDirection = "down"; +let deviceGraphSpecs = []; +let deviceGraphsInitialized = false; const wsClient = get_ws_client(); const listenOnce = (eventName, handler) => { const wrapped = (msg) => { @@ -64,6 +71,30 @@ function isElementVisible(el) { return !!(el && el.offsetWidth > 0 && el.offsetHeight > 0); } +function hasRenderableSize(el) { + if (!el) { + return false; + } + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +function runWhenRenderable(el, callback, attempts = 10) { + if (!el) { + return; + } + if (hasRenderableSize(el)) { + callback(); + return; + } + if (attempts <= 0) { + return; + } + window.setTimeout(() => { + runWhenRenderable(el, callback, attempts - 1); + }, 50); +} + function loadingBlockHtml(label, sizeClass = "") { const size = sizeClass ? ` ${sizeClass}` : ""; return `
${label}
`; @@ -123,6 +154,138 @@ function resizeGraphIfVisible(graph) { graph.chart.resize(); } +function formatBitsPerSecondLabel(bitsPerSecond) { + return `${scaleNumber(bitsPerSecond, 1)}bps`; +} + +function formatQooLabel(score) { + const value = toNumber(score, NaN); + if (!Number.isFinite(value)) { + return "-"; + } + return `${Math.round(Math.max(0, Math.min(100, value)))} / 100`; +} + +function currentActiveFlowCount() { + return Array.isArray(latestFlowMsg?.flows) ? latestFlowMsg.flows.length : 0; +} + +function currentDirectionValue(pair, direction, fallback = 0) { + return toNumber(pair?.[direction], fallback); +} + +function currentQueuingActivitySnapshot() { + const throughputBps = latestCircuitDevices.reduce((sum, device) => { + return sum + (currentDirectionValue(device?.bytes_per_second, queuingActivityDirection, 0) * 8); + }, 0); + const ceilingMbps = currentDirectionValue(plan, queuingActivityDirection, 0); + const ceilingBps = ceilingMbps * 1_000_000.0; + const atCeiling = ceilingBps > 0 && throughputBps >= (ceilingBps * 0.95); + const utilizationPercent = ceilingBps > 0 + ? Math.max(0, Math.min(999, (throughputBps / ceilingBps) * 100)) + : 0; + return { + throughputBps, + ceilingBps, + qooScore: latestCircuitQooScore, + activeFlows: currentActiveFlowCount(), + utilizationPercent, + atCeiling, + }; +} + +function updateQueuingActivityCards() { + const throughputEl = document.getElementById("queuingActivityThroughput"); + const qooEl = document.getElementById("queuingActivityQoo"); + const flowsEl = document.getElementById("queuingActivityFlows"); + const utilizationEl = document.getElementById("queuingActivityUtilization"); + const ceilingLegendEl = document.getElementById("queuingActivityLegendCeiling"); + if (!throughputEl || !qooEl || !flowsEl || !utilizationEl) { + return; + } + + const snapshot = currentQueuingActivitySnapshot(); + throughputEl.textContent = formatBitsPerSecondLabel(snapshot.throughputBps); + qooEl.textContent = formatQooLabel(snapshot.qooScore); + flowsEl.textContent = String(snapshot.activeFlows); + utilizationEl.textContent = `${snapshot.utilizationPercent.toFixed(0)}%`; + utilizationEl.classList.toggle("is-active", snapshot.atCeiling); + if (ceilingLegendEl) { + ceilingLegendEl.classList.toggle("is-active", snapshot.atCeiling); + } +} + +function pushQueuingActivitySample() { + if (!queuingActivityGraph || !latestCircuitDevices.length || !plan) { + updateQueuingActivityCards(); + return; + } + + const downThroughputBps = latestCircuitDevices.reduce((sum, device) => { + return sum + (currentDirectionValue(device?.bytes_per_second, "down", 0) * 8); + }, 0); + const upThroughputBps = latestCircuitDevices.reduce((sum, device) => { + return sum + (currentDirectionValue(device?.bytes_per_second, "up", 0) * 8); + }, 0); + + queuingActivityGraph.pushSample({ + timestamp: Date.now(), + throughputBps: { + down: downThroughputBps, + up: upThroughputBps, + }, + ceilingBps: { + down: currentDirectionValue(plan, "down", 0) * 1_000_000.0, + up: currentDirectionValue(plan, "up", 0) * 1_000_000.0, + }, + qooScore: latestCircuitQooScore, + }); + updateQueuingActivityCards(); +} + +function applyQueuingDirection(direction) { + queuingActivityDirection = direction === "up" ? "up" : "down"; + if (queuingActivityGraph) { + queuingActivityGraph.setDirection(queuingActivityDirection); + } + updateQueuingActivityCards(); +} + +function ensureQueuingActivityGraph() { + const target = document.getElementById("queuingActivityGraph"); + if (!target || queuingActivityGraph || !isElementVisible(target)) { + return; + } + runWhenRenderable(target, () => { + if (queuingActivityGraph || !hasRenderableSize(target)) { + return; + } + queuingActivityGraph = new QueuingActivityWaveform("queuingActivityGraph"); + queuingActivityGraph.setDirection(queuingActivityDirection); + pushQueuingActivitySample(); + }); +} + +function initQueuingActivityControls() { + const downloadToggle = document.getElementById("queuingDirectionDown"); + const uploadToggle = document.getElementById("queuingDirectionUp"); + if (downloadToggle) { + downloadToggle.addEventListener("change", () => { + if (downloadToggle.checked) { + applyQueuingDirection("down"); + } + }); + } + if (uploadToggle) { + uploadToggle.addEventListener("change", () => { + if (uploadToggle.checked) { + applyQueuingDirection("up"); + } + }); + } + applyQueuingDirection("down"); +} + function applyFlowSankeyMessage(msg) { if (!flowSankey || !msg) { return; @@ -137,8 +300,13 @@ function ensureFlowSankey() { if (!target || flowSankey || !isElementVisible(target)) { return; } - flowSankey = new FlowsSankey("flowSankey"); - applyFlowSankeyMessage(latestFlowMsg); + runWhenRenderable(target, () => { + if (flowSankey || !hasRenderableSize(target)) { + return; + } + flowSankey = new FlowsSankey("flowSankey"); + applyFlowSankeyMessage(latestFlowMsg); + }); } function resizeFunnelGraphs() { @@ -148,6 +316,29 @@ function resizeFunnelGraphs() { }); } +function initializeDeviceGraphs() { + if (deviceGraphsInitialized) { + return; + } + deviceGraphSpecs.forEach((spec) => { + if (!document.getElementById(spec.id) || deviceGraphs[spec.id]) { + return; + } + deviceGraphs[spec.id] = spec.factory(spec.id); + }); + deviceGraphsInitialized = true; + if (latestCircuitDevices.length > 0) { + fillLiveDevices(latestCircuitDevices); + if (speedometer && totalThroughput && totalRetransmits) { + updateSpeedometer(latestCircuitDevices); + } + } +} + +function resizeDeviceGraphs() { + Object.values(deviceGraphs).forEach((graph) => resizeGraphIfVisible(graph)); +} + function arrayEquals(a, b) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { return false; @@ -358,16 +549,21 @@ function ensureCakeGraphs() { if (!cakeTab || cakeGraphs || cakeQueueUnavailable || !isElementVisible(cakeTab)) { return; } - renderCakeGraphShell(); - cakeGraphs = { - backlog: new CakeBacklog("cakeBacklog"), - delays: new CakeDelays("cakeDelays"), - queueLength: new CakeQueueLength("cakeQueueLength"), - traffic: new CakeTraffic("cakeTraffic"), - marks: new CakeMarks("cakeMarks"), - drops: new CakeDrops("cakeDrops"), - }; - applyCakeMessage(latestCakeMsg); + runWhenRenderable(cakeTab, () => { + if (cakeGraphs || !hasRenderableSize(cakeTab)) { + return; + } + renderCakeGraphShell(); + cakeGraphs = { + backlog: new CakeBacklog("cakeBacklog"), + delays: new CakeDelays("cakeDelays"), + queueLength: new CakeQueueLength("cakeQueueLength"), + traffic: new CakeTraffic("cakeTraffic"), + marks: new CakeMarks("cakeMarks"), + drops: new CakeDrops("cakeDrops"), + }; + applyCakeMessage(latestCakeMsg); + }); } function initTabLifecycle(parentNode) { @@ -376,6 +572,16 @@ function initTabLifecycle(parentNode) { tab.addEventListener("shown.bs.tab", () => { window.requestAnimationFrame(() => { const target = tab.getAttribute("data-bs-target"); + if (target === "#queuing") { + ensureQueuingActivityGraph(); + updateQueuingActivityCards(); + return; + } + if (target === "#devs") { + initializeDeviceGraphs(); + resizeDeviceGraphs(); + return; + } if (target === "#sankey") { ensureFlowSankey(); applyFlowSankeyMessage(latestFlowMsg); @@ -397,6 +603,11 @@ function initTabLifecycle(parentNode) { }); }); }); + + window.requestAnimationFrame(() => { + ensureQueuingActivityGraph(); + updateQueuingActivityCards(); + }); } function formatIpBytes(bytes) { @@ -510,18 +721,24 @@ function connectPrivateChannel() { circuit: circuit_id } }, (msg) => { + latestCircuitQooScore = toNumber(msg.qoo_score, NaN); + if (!Number.isFinite(latestCircuitQooScore)) { + latestCircuitQooScore = null; + } if (msg.devices !== null) { - //console.log(msg.devices); + latestCircuitDevices = msg.devices || []; fillLiveDevices(msg.devices); updateSpeedometer(msg.devices); - if (qooGauge !== null) { - qooGauge.update(msg.qoo_score); - } + pushQueuingActivitySample(); if (excludeRttToggle && msg.rtt_excluded !== undefined) { excludeRttLastValue = !!msg.rtt_excluded; excludeRttToggle.checked = excludeRttLastValue; } } + if (qooGauge !== null) { + qooGauge.update(msg.qoo_score); + } + updateQueuingActivityCards(); }); } @@ -613,6 +830,7 @@ function connectFlowChannel() { latestFlowMsg = msg; applyFlowSankeyMessage(msg); updateTrafficTab(msg); + updateQueuingActivityCards(); }); } @@ -1042,6 +1260,9 @@ function formatRttMetricBlock(currentText, totalText) { function initialDevices(circuits) { let target = document.getElementById("devices"); clearDiv(target); + deviceGraphs = {}; + deviceGraphSpecs = []; + deviceGraphsInitialized = false; circuits.forEach((circuit) => { let outer = document.createElement("div"); @@ -1272,7 +1493,10 @@ function initialDevices(circuits) { div.innerHTML = loadingBlockHtml("Loading chart…", "lqos-loading-block-sm"); col.appendChild(div); graphRow.appendChild(col); - deviceGraphs[divId] = graphFactory(divId); + deviceGraphSpecs.push({ + id: divId, + factory: graphFactory, + }); } addGraph("throughputGraph_" + circuit.device_id, (id) => new CircuitTotalGraph(id, "Throughput")); @@ -1459,6 +1683,7 @@ function loadInitial() { initTooltipsWithin(document); initExcludeRttToggle(); initFlowFilters(); + initQueuingActivityControls(); requestCircuitById((circuits) => { let circuit = circuits[0]; $("#circuitName").text(circuit.circuit_name); @@ -1470,6 +1695,7 @@ function loadInitial() { down: toNumber(circuit.download_max_mbps, 0), up: toNumber(circuit.upload_max_mbps, 0), }; + latestCircuitDevices = circuits; setQueueTypeDisplay(circuit.sqm_override || ""); initialDevices(circuits); speedometer = new BitsPerSecondGauge("bitsGauge", "Plan"); @@ -1477,6 +1703,7 @@ function loadInitial() { totalThroughput = new CircuitTotalGraph("throughputGraph", "Total Circuit Throughput"); totalRetransmits = new CircuitRetransmitGraph("rxmitGraph", "Total Circuit Retransmits"); initTabLifecycle(circuit.parent_node); + updateQueuingActivityCards(); connectPrivateChannel(); connectPingers(circuits); @@ -1509,6 +1736,18 @@ function cleanupCircuitPage() { funnelSubscription.dispose(); funnelSubscription = null; } + if (queuingActivityGraph) { + queuingActivityGraph.dispose(); + queuingActivityGraph = null; + } + Object.values(deviceGraphs).forEach((graph) => { + if (graph && typeof graph.dispose === "function") { + graph.dispose(); + } + }); + deviceGraphs = {}; + deviceGraphSpecs = []; + deviceGraphsInitialized = false; } window.addEventListener("beforeunload", cleanupCircuitPage); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js b/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js new file mode 100644 index 00000000..53fe6529 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/graphs/queuing_activity_waveform.js @@ -0,0 +1,686 @@ +import {DashboardGraph} from "./dashboard_graph"; +import {scaleNumber, toNumber} from "../lq_js_common/helpers/scaling"; + +const WINDOW_MS = 30_000; +const MAX_SAMPLES = 90; +const RENDER_INTERVAL_MS = 100; +const DISPLAY_LAG_MS = 1_200; +const SAMPLE_INTERVAL_MS = 1_000; + +function formatSecondsAgo(nowMs, valueMs) { + const delta = Math.max(0, Math.round((nowMs - valueMs) / 1000)); + return delta === 0 ? "now" : `-${delta}s`; +} + +function formatBps(value) { + return `${scaleNumber(Math.abs(value), 1)}bps`; +} + +function formatQoo(value) { + const n = toNumber(value, NaN); + if (!Number.isFinite(n)) { + return "-"; + } + return `${Math.round(Math.max(0, Math.min(100, n)))}`; +} + +function getWaveformTheme() { + const isDark = document.documentElement.getAttribute("data-bs-theme") !== "light"; + if (isDark) { + return { + axisText: "rgba(216, 226, 244, 0.62)", + axisName: "rgba(216, 226, 244, 0.55)", + axisLine: "rgba(216, 226, 244, 0.28)", + axisTick: "rgba(216, 226, 244, 0.2)", + splitLine: "rgba(216, 226, 244, 0.045)", + throughputLine: "#32d3bd", + throughputGlow: "rgba(50, 211, 189, 0.34)", + throughputAreaTop: "rgba(50, 211, 189, 0.48)", + throughputAreaMid: "rgba(42, 156, 145, 0.3)", + throughputAreaBottom: "rgba(9, 20, 31, 0.03)", + qooLine: "#8fb0ff", + qooGlow: "rgba(143, 176, 255, 0.3)", + qooAreaTop: "rgba(143, 176, 255, 0.18)", + qooAreaBottom: "rgba(16, 22, 34, 0.01)", + qooBandGood: "rgba(42, 168, 108, 0.08)", + qooBandWarn: "rgba(228, 154, 44, 0.07)", + qooBandPoor: "rgba(225, 84, 84, 0.08)", + ceilingInactive: "rgba(255, 255, 255, 0.32)", + ceilingInactiveGlow: "rgba(255, 255, 255, 0.12)", + ceilingActive: "#ffffff", + ceilingActiveGlow: "rgba(255, 255, 255, 0.48)", + }; + } + + return { + axisText: "rgba(54, 67, 86, 0.72)", + axisName: "rgba(54, 67, 86, 0.62)", + axisLine: "rgba(77, 94, 118, 0.28)", + axisTick: "rgba(77, 94, 118, 0.18)", + splitLine: "rgba(77, 94, 118, 0.08)", + throughputLine: "#129a87", + throughputGlow: "rgba(18, 154, 135, 0.18)", + throughputAreaTop: "rgba(18, 154, 135, 0.26)", + throughputAreaMid: "rgba(18, 154, 135, 0.14)", + throughputAreaBottom: "rgba(255, 255, 255, 0.02)", + qooLine: "#4f79d9", + qooGlow: "rgba(79, 121, 217, 0.18)", + qooAreaTop: "rgba(79, 121, 217, 0.1)", + qooAreaBottom: "rgba(255, 255, 255, 0.01)", + qooBandGood: "rgba(46, 163, 111, 0.06)", + qooBandWarn: "rgba(214, 148, 40, 0.06)", + qooBandPoor: "rgba(196, 82, 82, 0.06)", + ceilingInactive: "rgba(110, 120, 134, 0.34)", + ceilingInactiveGlow: "rgba(110, 120, 134, 0.08)", + ceilingActive: "rgba(255, 255, 255, 0.96)", + ceilingActiveGlow: "rgba(104, 118, 138, 0.26)", + }; +} + +function directionalSeriesData(samples, direction, valueKey, windowStart, windowEnd) { + if (!samples || samples.length === 0) { + return []; + } + + const points = []; + let previous = null; + let next = null; + + for (const sample of samples) { + if (sample.timestamp < windowStart) { + previous = sample; + continue; + } + if (sample.timestamp > windowEnd) { + next = sample; + break; + } + points.push([ + sample.timestamp, + toNumber(sample?.[valueKey]?.[direction], 0), + ]); + } + + if (previous) { + points.unshift([ + windowStart, + toNumber(previous?.[valueKey]?.[direction], 0), + ]); + } + if (next) { + points.push([ + Math.min(next.timestamp, windowEnd), + toNumber(next?.[valueKey]?.[direction], 0), + ]); + } else if (points.length > 0) { + points.push([ + windowEnd, + points[points.length - 1][1], + ]); + } + + return points; +} + +function scalarSeriesData(samples, valueKey, windowStart, windowEnd) { + if (!samples || samples.length === 0) { + return []; + } + + const points = []; + let previous = null; + let next = null; + + for (const sample of samples) { + if (sample.timestamp < windowStart) { + previous = sample; + continue; + } + if (sample.timestamp > windowEnd) { + next = sample; + break; + } + points.push([ + sample.timestamp, + sample?.[valueKey] ?? null, + ]); + } + + if (previous) { + points.unshift([ + windowStart, + previous?.[valueKey] ?? null, + ]); + } + if (next) { + points.push([ + Math.min(next.timestamp, windowEnd), + next?.[valueKey] ?? null, + ]); + } else if (points.length > 0) { + points.push([ + windowEnd, + points[points.length - 1][1], + ]); + } + + return points; +} + +function ceilingSeriesData(samples, direction, windowStart, windowEnd) { + const throughputData = directionalSeriesData(samples, direction, "throughputBps", windowStart, windowEnd); + const ceilingData = directionalSeriesData(samples, direction, "ceilingBps", windowStart, windowEnd); + + const pointCount = Math.min(throughputData.length, ceilingData.length); + const base = []; + const active = []; + for (let i = 0; i < pointCount; i++) { + const timestamp = ceilingData[i][0]; + const nextTimestamp = i + 1 < pointCount ? ceilingData[i + 1][0] : windowEnd; + const ceilingBps = toNumber(ceilingData[i][1], 0); + const throughputBps = toNumber(throughputData[i][1], 0); + const atCeiling = ceilingBps > 0 && throughputBps >= (ceilingBps * 0.95); + + if (base.length === 0) { + base.push([timestamp, ceilingBps]); + active.push([timestamp, atCeiling ? ceilingBps : null]); + } + + base.push([nextTimestamp, ceilingBps]); + active.push([nextTimestamp, atCeiling ? ceilingBps : null]); + } + return { base, active }; +} + +function qooMarkAreas(colors) { + return [ + [ + { yAxis: 0, itemStyle: { color: colors.qooBandPoor } }, + { yAxis: 50 }, + ], + [ + { yAxis: 50, itemStyle: { color: colors.qooBandWarn } }, + { yAxis: 80 }, + ], + [ + { yAxis: 80, itemStyle: { color: colors.qooBandGood } }, + { yAxis: 100 }, + ], + ]; +} + +export class QueuingActivityWaveform extends DashboardGraph { + constructor(id) { + super(id); + if (this.dom && this.dom.classList) { + this.dom.classList.remove("muted"); + } + this.colors = getWaveformTheme(); + this.direction = "down"; + this.samples = []; + this.sampleClockBase = null; + this.lastSampleTimestamp = null; + this.lastPruneTimestamp = 0; + this.cachedSeries = { + direction: this.direction, + }; + this.renderNow = Date.now(); + this.renderRaf = null; + this.lastRenderTimestamp = 0; + + this.option = { + animation: false, + animationDurationUpdate: 0, + animationEasingUpdate: "linear", + backgroundColor: "transparent", + grid: [ + { + left: "9%", + right: "9%", + top: 12, + height: "45%", + }, + { + left: "9%", + right: "9%", + top: "57%", + height: "22%", + }, + ], + legend: { + show: false, + }, + tooltip: { + trigger: "axis", + axisPointer: { + type: "line", + }, + formatter: (params) => { + if (!params || params.length === 0) { + return ""; + } + const when = params[0].value?.[0] || this.renderNow; + const lines = [`
${formatSecondsAgo(this.renderNow, when)}
`]; + params.forEach((entry) => { + const y = Array.isArray(entry.value) ? entry.value[1] : entry.value; + const formatted = entry.seriesName === "Circuit QoO" + ? `${formatQoo(y)} / 100` + : formatBps(y); + lines.push( + `
${entry.seriesName}: ${formatted}
`, + ); + }); + return lines.join(""); + }, + }, + xAxis: [ + { + type: "value", + gridIndex: 0, + min: () => this.renderNow - WINDOW_MS, + max: () => this.renderNow, + axisLine: { + lineStyle: { + color: this.colors.axisLine, + }, + }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, + }, + { + type: "value", + gridIndex: 1, + min: () => this.renderNow - WINDOW_MS, + max: () => this.renderNow, + axisLine: { + lineStyle: { + color: this.colors.axisLine, + }, + }, + axisTick: { + lineStyle: { + color: this.colors.axisTick, + }, + }, + axisLabel: { + color: this.colors.axisText, + formatter: (value) => formatSecondsAgo(this.renderNow, value), + }, + splitLine: { show: false }, + }, + ], + yAxis: [ + { + type: "value", + gridIndex: 0, + name: "Throughput", + nameLocation: "middle", + nameGap: 52, + nameTextStyle: { + color: this.colors.axisName, + }, + axisLine: { + lineStyle: { + color: this.colors.axisLine, + }, + }, + splitLine: { + lineStyle: { + color: this.colors.splitLine, + }, + }, + axisLabel: { + color: this.colors.axisText, + formatter: (val) => formatBps(val), + }, + }, + { + type: "value", + gridIndex: 1, + min: 0, + max: 100, + name: "Circuit QoO", + nameLocation: "middle", + nameGap: 44, + nameTextStyle: { + color: this.colors.axisName, + }, + axisLine: { + lineStyle: { + color: this.colors.axisLine, + }, + }, + splitLine: { + lineStyle: { + color: this.colors.splitLine, + }, + }, + axisLabel: { + color: this.colors.axisText, + formatter: (val) => `${Math.round(val)}`, + }, + axisTick: { + lineStyle: { + color: this.colors.axisTick, + }, + }, + }, + ], + series: [ + { + name: "Throughput", + type: "line", + xAxisIndex: 0, + yAxisIndex: 0, + showSymbol: false, + smooth: false, + lineStyle: { + width: 2.4, + color: this.colors.throughputLine, + shadowBlur: 10, + shadowColor: this.colors.throughputGlow, + }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.colors.throughputAreaTop }, + { offset: 0.45, color: this.colors.throughputAreaMid }, + { offset: 1, color: this.colors.throughputAreaBottom }, + ]), + }, + data: [], + }, + { + name: "Ceiling Base", + type: "line", + xAxisIndex: 0, + yAxisIndex: 0, + showSymbol: false, + silent: true, + smooth: false, + connectNulls: true, + step: "start", + lineStyle: { + width: 2.8, + color: this.colors.ceilingInactive, + shadowBlur: 14, + shadowColor: this.colors.ceilingInactiveGlow, + }, + data: [], + }, + { + name: "Ceiling Active", + type: "line", + xAxisIndex: 0, + yAxisIndex: 0, + showSymbol: false, + silent: true, + smooth: false, + connectNulls: false, + step: "start", + lineStyle: { + width: 2.8, + color: this.colors.ceilingActive, + shadowBlur: 14, + shadowColor: this.colors.ceilingActiveGlow, + }, + data: [], + }, + { + name: "Circuit QoO", + type: "line", + xAxisIndex: 1, + yAxisIndex: 1, + showSymbol: false, + connectNulls: false, + smooth: false, + lineStyle: { + width: 2.1, + color: this.colors.qooLine, + shadowBlur: 10, + shadowColor: this.colors.qooGlow, + }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.colors.qooAreaTop }, + { offset: 1, color: this.colors.qooAreaBottom }, + ]), + }, + markArea: { + silent: true, + itemStyle: { + opacity: 1, + }, + data: qooMarkAreas(this.colors), + }, + data: [], + }, + ], + }; + + this.chart.hideLoading(); + this.chart.setOption(this.option); + this.scheduleRenderLoop(); + } + + scheduleRenderLoop() { + const step = (timestamp) => { + if (!this.chart) { + this.renderRaf = null; + return; + } + if (!this.lastRenderTimestamp || (timestamp - this.lastRenderTimestamp) >= RENDER_INTERVAL_MS) { + this.lastRenderTimestamp = timestamp; + this.render(); + } + this.renderRaf = window.requestAnimationFrame(step); + }; + this.renderRaf = window.requestAnimationFrame(step); + } + + quantizeTimestamp(arrivalTimestamp) { + const arrival = toNumber(arrivalTimestamp, Date.now()); + if (this.sampleClockBase === null) { + this.sampleClockBase = arrival; + this.lastSampleTimestamp = arrival; + return arrival; + } + + let quantized = this.sampleClockBase + + (Math.round((arrival - this.sampleClockBase) / SAMPLE_INTERVAL_MS) * SAMPLE_INTERVAL_MS); + if (this.lastSampleTimestamp !== null && quantized <= this.lastSampleTimestamp) { + quantized = this.lastSampleTimestamp + SAMPLE_INTERVAL_MS; + } + this.lastSampleTimestamp = quantized; + return quantized; + } + + setDirection(direction) { + this.direction = direction === "up" ? "up" : "down"; + this.cachedSeries.direction = this.direction; + this.rebuildCachedSeries(); + this.render(); + } + + pushSample(sample) { + const normalized = { + timestamp: this.quantizeTimestamp(sample.timestamp || Date.now()), + throughputBps: { + down: toNumber(sample.throughputBps?.down, 0), + up: toNumber(sample.throughputBps?.up, 0), + }, + ceilingBps: { + down: toNumber(sample.ceilingBps?.down, 0), + up: toNumber(sample.ceilingBps?.up, 0), + }, + qooScore: (() => { + const qoo = toNumber(sample.qooScore, NaN); + return Number.isFinite(qoo) ? Math.max(0, Math.min(100, qoo)) : null; + })(), + }; + + this.samples.push(normalized); + if (this.samples.length > MAX_SAMPLES) { + this.samples.shift(); + } + this.pruneSamples(normalized.timestamp); + this.rebuildCachedSeries(); + } + + pruneSamples(referenceTimestamp = Date.now()) { + if (referenceTimestamp - this.lastPruneTimestamp < SAMPLE_INTERVAL_MS) { + return; + } + this.lastPruneTimestamp = referenceTimestamp; + const cutoff = referenceTimestamp - WINDOW_MS - (SAMPLE_INTERVAL_MS * 2); + while (this.samples.length > 0 && this.samples[0].timestamp < cutoff) { + this.samples.shift(); + } + } + + rebuildCachedSeries() { + if (!this.samples.length) { + this.cachedSeries = { + direction: this.direction, + }; + return; + } + this.cachedSeries = { + direction: this.direction, + }; + } + + currentSeriesState() { + const latest = this.samples.length > 0 ? this.samples[this.samples.length - 1] : null; + if (!latest) { + return { + throughputBps: 0, + ceilingBps: 0, + atCeiling: false, + }; + } + const throughputBps = toNumber(latest.throughputBps[this.direction], 0); + const ceilingBps = toNumber(latest.ceilingBps[this.direction], 0); + return { + throughputBps, + ceilingBps, + atCeiling: ceilingBps > 0 && throughputBps >= (ceilingBps * 0.95), + }; + } + + render() { + if (!this.chart) { + return; + } + this.renderNow = Date.now(); + const displayNow = this.renderNow - DISPLAY_LAG_MS; + const windowStart = displayNow - WINDOW_MS; + const throughputData = directionalSeriesData( + this.samples, + this.direction, + "throughputBps", + windowStart, + displayNow, + ); + const qooData = scalarSeriesData( + this.samples, + "qooScore", + windowStart, + displayNow, + ); + const ceilingSeries = ceilingSeriesData( + this.samples, + this.direction, + windowStart, + displayNow, + ); + + const patch = { + xAxis: [ + { + min: windowStart, + max: displayNow, + }, + { + min: windowStart, + max: displayNow, + }, + ], + series: [ + { + name: "Throughput", + data: throughputData, + }, + { + name: "Ceiling Base", + data: ceilingSeries.base, + lineStyle: { + color: this.colors.ceilingInactive, + shadowColor: this.colors.ceilingInactiveGlow, + }, + }, + { + name: "Ceiling Active", + data: ceilingSeries.active, + lineStyle: { + color: this.colors.ceilingActive, + shadowColor: this.colors.ceilingActiveGlow, + }, + }, + { + name: "Circuit QoO", + data: qooData, + }, + ], + }; + + this.chart.setOption(patch, false, true); + } + + onThemeChange() { + this.colors = getWaveformTheme(); + this.option.xAxis[0].axisLine.lineStyle.color = this.colors.axisLine; + this.option.xAxis[1].axisLine.lineStyle.color = this.colors.axisLine; + this.option.xAxis[1].axisTick.lineStyle.color = this.colors.axisTick; + this.option.xAxis[1].axisLabel.color = this.colors.axisText; + this.option.yAxis[0].nameTextStyle.color = this.colors.axisName; + this.option.yAxis[0].axisLine.lineStyle.color = this.colors.axisLine; + this.option.yAxis[0].splitLine.lineStyle.color = this.colors.splitLine; + this.option.yAxis[0].axisLabel.color = this.colors.axisText; + this.option.yAxis[1].nameTextStyle.color = this.colors.axisName; + this.option.yAxis[1].axisLine.lineStyle.color = this.colors.axisLine; + this.option.yAxis[1].axisTick.lineStyle.color = this.colors.axisTick; + this.option.yAxis[1].splitLine.lineStyle.color = this.colors.splitLine; + this.option.yAxis[1].axisLabel.color = this.colors.axisText; + this.option.series[0].lineStyle.color = this.colors.throughputLine; + this.option.series[0].lineStyle.shadowColor = this.colors.throughputGlow; + this.option.series[0].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.colors.throughputAreaTop }, + { offset: 0.45, color: this.colors.throughputAreaMid }, + { offset: 1, color: this.colors.throughputAreaBottom }, + ]); + this.option.series[1].lineStyle.color = this.colors.ceilingInactive; + this.option.series[1].lineStyle.shadowColor = this.colors.ceilingInactiveGlow; + this.option.series[2].lineStyle.color = this.colors.ceilingActive; + this.option.series[2].lineStyle.shadowColor = this.colors.ceilingActiveGlow; + this.option.series[3].lineStyle.color = this.colors.qooLine; + this.option.series[3].lineStyle.shadowColor = this.colors.qooGlow; + this.option.series[3].areaStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.colors.qooAreaTop }, + { offset: 1, color: this.colors.qooAreaBottom }, + ]); + this.option.series[3].markArea.data = qooMarkAreas(this.colors); + this.chart.setOption(this.option); + this.render(); + } + + dispose() { + if (this.renderRaf) { + window.cancelAnimationFrame(this.renderRaf); + this.renderRaf = null; + } + if (this.chart) { + this.chart.dispose(); + } + } +} diff --git a/src/rust/lqosd/src/node_manager/static2/circuit.html b/src/rust/lqosd/src/node_manager/static2/circuit.html index b5f2bf14..840d23d9 100644 --- a/src/rust/lqosd/src/node_manager/static2/circuit.html +++ b/src/rust/lqosd/src/node_manager/static2/circuit.html @@ -74,9 +74,14 @@ -