This commit is contained in:
Robert Chacon committed 2026-03-16 16:20:29 -06:00
1 parent 92fd8aa5d7
commit 19b4265ded
4 files changed
+1220 -20

No files matched your search

@@ -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 `<div class="lqos-loading-block${size}"><i class="fa fa-spinner fa-spin"></i><span>${label}</span></div>`;
@@ -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);
@@ -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 = [`<div><b>${formatSecondsAgo(this.renderNow, when)}</b></div>`];
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(
`<div><span style="display:inline-block;margin-right:5px;border-radius:10px;width:9px;height:9px;background-color:${entry.color};"></span>${entry.seriesName}: <b>${formatted}</b></div>`,
);
});
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();
}
}
}
@@ -74,9 +74,14 @@
</div>
</div>
<ul class="nav nav-tabs lqos-pill-tabs mb-3" id="myTab" role="tablist">
<ul class="nav nav-tabs lqos-pill-tabs lqos-circuit-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="devs-tab" data-bs-toggle="tab" data-bs-target="#devs" type="button" role="tab" aria-controls="devs" aria-selected="true">
<button class="nav-link active" id="queuing-tab" data-bs-toggle="tab" data-bs-target="#queuing" type="button" role="tab" aria-controls="queuing" aria-selected="true">
<i class="fa fa-wave-square"></i> Queuing Activity
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="devs-tab" data-bs-toggle="tab" data-bs-target="#devs" type="button" role="tab" aria-controls="devs" aria-selected="false">
<i class="fa fa-server"></i> Devices
</button>
</li>
@@ -102,7 +107,71 @@
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="devs" role="tabpanel" aria-labelledby="devs-tab">
<div class="tab-pane fade show active" id="queuing" role="tabpanel" aria-labelledby="queuing-tab">
<div class="lqos-circuit-activity-panel">
<div class="lqos-circuit-activity-header">
<div class="lqos-circuit-activity-title">
<h5>Queuing Activity Live Waveform</h5>
</div>
<div class="lqos-circuit-activity-controls">
<div class="lqos-circuit-activity-legend" aria-label="Queuing Activity Legend">
<span class="lqos-circuit-activity-legend-item">
<span class="lqos-circuit-activity-legend-dot is-throughput"></span>
<span>Throughput</span>
</span>
<span class="lqos-circuit-activity-legend-item">
<span id="queuingActivityLegendCeiling" class="lqos-circuit-activity-legend-dot is-ceiling"></span>
<span>Ceiling</span>
</span>
<span class="lqos-circuit-activity-legend-item">
<span class="lqos-circuit-activity-legend-dot is-qoo"></span>
<span>Circuit QoO</span>
</span>
</div>
<div class="btn-group btn-group-sm lqos-circuit-activity-toggle" role="group" aria-label="Queuing Activity Direction">
<input type="radio" class="btn-check" name="queuingDirection" id="queuingDirectionDown" autocomplete="off" checked>
<label class="btn btn-outline-secondary" for="queuingDirectionDown">Download</label>
<input type="radio" class="btn-check" name="queuingDirection" id="queuingDirectionUp" autocomplete="off">
<label class="btn btn-outline-secondary" for="queuingDirectionUp">Upload</label>
</div>
</div>
</div>
<div id="queuingActivityGraph" class="lqos-circuit-activity-chart">
<div class="lqos-loading-block lqos-loading-block-lg">
<i class="fa fa-spinner fa-spin"></i>
<span>Loading queuing activity…</span>
</div>
</div>
<div class="row g-2 mt-2">
<div class="col-12 col-md-6 col-xl-3">
<div class="lqos-circuit-activity-kpi">
<div class="lqos-circuit-activity-kpi-label">Throughput</div>
<div id="queuingActivityThroughput" class="lqos-circuit-activity-kpi-value">-</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="lqos-circuit-activity-kpi">
<div class="lqos-circuit-activity-kpi-label">Circuit QoO</div>
<div id="queuingActivityQoo" class="lqos-circuit-activity-kpi-value">-</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="lqos-circuit-activity-kpi">
<div class="lqos-circuit-activity-kpi-label">Active Flows</div>
<div id="queuingActivityFlows" class="lqos-circuit-activity-kpi-value">0</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="lqos-circuit-activity-kpi">
<div class="lqos-circuit-activity-kpi-label">Utilization</div>
<div id="queuingActivityUtilization" class="lqos-circuit-activity-kpi-value">0%</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="devs" role="tabpanel" aria-labelledby="devs-tab">
<div class="row" id="devices">
<div class="col-12">
<div class="lqos-loading-block lqos-loading-block-lg">
@@ -425,6 +425,212 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
width: 100%;
height: 200px;
}
.lqos-circuit-activity-panel {
border: 1px solid var(--lqos-border);
border-radius: var(--lqos-radius-md);
background: linear-gradient(
180deg,
color-mix(in srgb, #0f1826 92%, transparent) 0%,
color-mix(in srgb, #0a111b 96%, transparent) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.035),
0 18px 38px rgba(0, 0, 0, 0.26);
padding: 1rem;
}
.lqos-circuit-tabs {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
.lqos-circuit-activity-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.lqos-circuit-activity-title {
min-width: 0;
}
.lqos-circuit-activity-controls {
display: flex;
align-items: center;
gap: 0.9rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.lqos-circuit-activity-legend {
display: inline-flex;
align-items: center;
gap: 0.85rem;
color: var(--bs-secondary-color);
font-size: 0.82rem;
white-space: nowrap;
}
.lqos-circuit-activity-legend-item {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.lqos-circuit-activity-legend-dot {
width: 0.78rem;
height: 0.78rem;
border-radius: 999px;
display: inline-block;
box-shadow: 0 0 0 1px color-mix(in srgb, white 8%, transparent);
}
.lqos-circuit-activity-legend-dot.is-throughput {
background: #32d3bd;
box-shadow: 0 0 10px rgba(50, 211, 189, 0.24);
}
.lqos-circuit-activity-legend-dot.is-qoo {
background: #8fb0ff;
box-shadow: 0 0 10px rgba(143, 176, 255, 0.22);
}
.lqos-circuit-activity-legend-dot.is-ceiling {
background: rgba(255, 255, 255, 0.32);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.12);
}
.lqos-circuit-activity-legend-dot.is-ceiling.is-active {
background: #ffffff;
box-shadow: 0 0 14px rgba(255, 255, 255, 0.34);
}
.lqos-circuit-activity-toggle {
align-self: center;
border-radius: 999px;
padding: 0.12rem;
background: color-mix(in srgb, var(--lqos-surface-2) 68%, transparent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 6px 16px rgba(0, 0, 0, 0.12);
}
.lqos-circuit-activity-toggle .btn {
border-radius: 999px !important;
min-width: 5.8rem;
border-color: transparent;
}
.lqos-circuit-activity-toggle .btn-outline-secondary {
color: color-mix(in srgb, var(--bs-emphasis-color) 78%, transparent);
}
.lqos-circuit-activity-toggle .btn-check:checked + .btn-outline-secondary {
color: var(--bs-emphasis-color);
border-color: color-mix(in srgb, var(--lqos-border) 72%, transparent);
background: color-mix(in srgb, var(--lqos-surface) 86%, transparent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 0 1px color-mix(in srgb, var(--lqos-border) 30%, transparent);
}
.lqos-circuit-activity-chart {
height: 338px;
width: 100%;
border: 1px solid color-mix(in srgb, var(--lqos-border) 82%, transparent);
border-radius: var(--lqos-radius-sm);
background:
radial-gradient(circle at 50% 0%, rgba(35, 74, 95, 0.18) 0%, rgba(12, 20, 31, 0) 42%),
linear-gradient(180deg, #111b28 0%, #0b1420 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
inset 0 -24px 60px rgba(5, 10, 18, 0.42);
overflow: hidden;
}
[data-bs-theme="light"] .lqos-circuit-activity-panel {
background: linear-gradient(
180deg,
color-mix(in srgb, #f7f9fc 96%, transparent) 0%,
color-mix(in srgb, #eef3f8 98%, transparent) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.75),
0 16px 32px rgba(26, 44, 68, 0.08);
}
[data-bs-theme="light"] .lqos-circuit-activity-legend-dot {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--lqos-border) 35%, transparent);
}
[data-bs-theme="light"] .lqos-circuit-activity-legend-dot.is-throughput {
background: #129a87;
box-shadow: 0 0 10px rgba(18, 154, 135, 0.16);
}
[data-bs-theme="light"] .lqos-circuit-activity-legend-dot.is-qoo {
background: #4f79d9;
box-shadow: 0 0 10px rgba(79, 121, 217, 0.12);
}
[data-bs-theme="light"] .lqos-circuit-activity-legend-dot.is-ceiling {
background: rgba(110, 120, 134, 0.34);
box-shadow: 0 0 8px rgba(110, 120, 134, 0.08);
}
[data-bs-theme="light"] .lqos-circuit-activity-legend-dot.is-ceiling.is-active {
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 0 10px rgba(104, 118, 138, 0.18);
}
[data-bs-theme="light"] .lqos-circuit-activity-toggle {
background: color-mix(in srgb, white 82%, var(--lqos-surface-2));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.8),
0 10px 18px rgba(26, 44, 68, 0.08);
}
[data-bs-theme="light"] .lqos-circuit-activity-chart {
background:
radial-gradient(circle at 50% 0%, rgba(108, 160, 184, 0.12) 0%, rgba(255, 255, 255, 0) 42%),
linear-gradient(180deg, #f8fbff 0%, #edf3f9 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.65),
inset 0 -18px 34px rgba(177, 193, 214, 0.12);
}
.lqos-circuit-activity-kpi {
height: 100%;
border: 1px solid color-mix(in srgb, var(--lqos-border) 80%, transparent);
border-radius: var(--lqos-radius-sm);
background: color-mix(in srgb, var(--lqos-surface-2) 76%, transparent);
padding: 0.8rem 0.9rem;
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
.lqos-circuit-activity-kpi-label {
color: var(--bs-secondary-color);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
margin-bottom: 0.35rem;
}
.lqos-circuit-activity-kpi-value {
color: var(--bs-emphasis-color);
font-size: 1.2rem;
font-weight: 700;
line-height: 1.2;
}
.lqos-circuit-activity-kpi-value.is-active {
color: #3fd279;
}
@media (max-width: 991.98px) {
.lqos-circuit-activity-header {
flex-direction: column;
align-items: stretch;
}
.lqos-circuit-activity-controls {
justify-content: space-between;
}
.lqos-circuit-activity-toggle {
align-self: flex-start;
}
.lqos-circuit-activity-chart {
height: 296px;
}
}
@media (max-width: 575.98px) {
.lqos-circuit-activity-panel {
padding: 0.85rem;
}
.lqos-circuit-activity-legend {
gap: 0.55rem;
font-size: 0.76rem;
}
.lqos-circuit-activity-chart {
height: 264px;
}
.lqos-circuit-activity-kpi-value {
font-size: 1.05rem;
}
}
.tiny {
font-size: 0.72rem;
}