mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
changes
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user