Files
op-packages/luci-app-qosmate/htdocs/luci-static/resources/view/statistics.js
T

2252 lines
99 KiB
JavaScript

'use strict';
'require view';
'require poll';
'require rpc';
'require ui';
'require form';
'require dom';
'require fs';
'require uci';
// Define RPC calls
var callQoSmateStats = rpc.declare({
object: 'luci.qosmate_stats',
method: 'getStats',
expect: { }
});
var callQoSmateHistoricalStats = rpc.declare({
object: 'luci.qosmate_stats',
method: 'getHistoricalStats',
expect: { }
});
var callQoSmateRrdData = rpc.declare({
object: 'luci.qosmate_stats',
method: 'getRrdData',
expect: { }
});
// Utility functions
var formatSize = function(bytes) {
var sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
if (bytes == 0) return '0 B';
var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
var convertToKbps = function(bytesPerSecond) {
return (bytesPerSecond * 8 / 1000).toFixed(2) + ' Kbit/s';
};
// Format time value intelligently (microseconds to ms or s when appropriate)
var formatTime = function(microseconds) {
if (microseconds === undefined || microseconds === null) return '0 µs';
if (microseconds === 0) return '0 µs';
if (microseconds >= 1000000) {
// Convert to seconds for values >= 1s
return (microseconds / 1000000).toFixed(2) + ' s';
} else if (microseconds >= 1000) {
// Convert to milliseconds for values >= 1ms
return (microseconds / 1000).toFixed(2) + ' ms';
} else {
// Keep as microseconds for small values
return microseconds + ' µs';
}
};
// Parse size string like "10.5 MiB" to bytes
var parseSizeToBytes = function(sizeStr) {
if (!sizeStr || typeof sizeStr !== 'string') return 0;
var numericPart = parseFloat(sizeStr);
var unit = sizeStr.split(' ')[1];
var multiplier = 1;
if (unit === 'KiB') multiplier = 1024;
else if (unit === 'MiB') multiplier = 1024 * 1024;
else if (unit === 'GiB') multiplier = 1024 * 1024 * 1024;
else if (unit === 'TiB') multiplier = 1024 * 1024 * 1024 * 1024;
return numericPart * multiplier;
};
// Convert bytes to Kbits
var bytesToKbits = function(bytes) {
return (bytes * 8 / 1000).toFixed(2);
};
var callQoSmateAutorateStats = rpc.declare({
object: 'luci.qosmate_stats',
method: 'getAutorateStats',
expect: { }
});
// Standard chart colors
var chartColors = ['#4CAF50', '#2196F3', '#FFC107', '#F44336', '#9C27B0', '#795548', '#FF9800', '#607D8B'];
// Lightweight Canvas time-series chart renderer
var TimeSeriesChart = {
draw: function(canvas, config) {
var ctx = canvas.getContext('2d');
var dpr = window.devicePixelRatio || 1;
var w = canvas.clientWidth;
var h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
// Detect text color from page theme (works for light and dark themes)
var textColor = window.getComputedStyle(canvas.parentNode || canvas).color || '#ccc';
var pad = { top: 20, right: 15, bottom: 32, left: 58 };
// Determine data ranges
var xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
(config.series || []).forEach(function(s) {
(s.data || []).forEach(function(pt) {
if (pt[0] < xMin) xMin = pt[0];
if (pt[0] > xMax) xMax = pt[0];
if (pt[1] < yMin) yMin = pt[1];
if (pt[1] > yMax) yMax = pt[1];
});
});
(config.hLines || []).forEach(function(hl) {
if (hl.value < yMin) yMin = hl.value;
if (hl.value > yMax) yMax = hl.value;
});
if (!isFinite(xMin)) { xMin = 0; xMax = 300; }
if (!isFinite(yMin)) { yMin = 0; yMax = 100; }
var yr = yMax - yMin || 1;
yMin = Math.max(0, yMin - yr * 0.05);
yMax = yMax + yr * 0.1;
if (xMax - xMin < 1) xMax = xMin + 60;
// Measure longest Y-axis label to set dynamic left padding
ctx.font = '10px sans-serif';
var yTicks = this._ticks(yMin, yMax, 5);
var maxLabelW = 0;
yTicks.forEach(function(v) {
var label = config.yFmt ? config.yFmt(v) : String(v);
var lw = ctx.measureText(label).width;
if (lw > maxLabelW) maxLabelW = lw;
});
pad.left = Math.max(58, Math.ceil(maxLabelW + 12));
var cw = w - pad.left - pad.right;
var ch = h - pad.top - pad.bottom;
if (cw < 50 || ch < 50) return;
var xS = function(v) { return pad.left + (v - xMin) / (xMax - xMin) * cw; };
var yS = function(v) { return pad.top + ch - (v - yMin) / (yMax - yMin) * ch; };
// Grid and labels
ctx.fillStyle = textColor;
ctx.font = '10px sans-serif';
// Y axis
ctx.textAlign = 'right';
yTicks.forEach(function(v) {
var y = yS(v);
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + cw, y); ctx.stroke();
ctx.fillText(config.yFmt ? config.yFmt(v) : String(v), pad.left - 5, y + 3);
});
// X axis (relative time with time-aware ticks)
ctx.textAlign = 'center';
this._timeTicks(xMin, xMax, 6).forEach(function(v) {
var x = xS(v);
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(x, pad.top); ctx.lineTo(x, pad.top + ch); ctx.stroke();
var ago = Math.round(xMax - v);
var label;
if (ago <= 0) label = 'now';
else if (ago < 120) label = '-' + ago + 's';
else if (ago < 5400) label = '-' + Math.round(ago / 60) + 'min';
else if (ago < 172800) { var h = ago / 3600; label = '-' + (h % 1 === 0 ? h.toFixed(0) : h.toFixed(1)) + 'h'; }
else { var d = ago / 86400; label = '-' + (d % 1 === 0 ? d.toFixed(0) : d.toFixed(1)) + 'd'; }
ctx.fillText(label, x, pad.top + ch + 15);
});
// Chart border
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
ctx.strokeRect(pad.left, pad.top, cw, ch);
// Clip to chart area
ctx.save();
ctx.beginPath(); ctx.rect(pad.left, pad.top, cw, ch); ctx.clip();
// Horizontal reference lines
(config.hLines || []).forEach(function(hl) {
ctx.strokeStyle = hl.color || '#999';
ctx.lineWidth = hl.width || 1;
if (hl.dashed) ctx.setLineDash([5, 3]);
ctx.beginPath();
var y = yS(hl.value);
ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + cw, y);
ctx.stroke(); ctx.setLineDash([]);
});
// Data series
(config.series || []).forEach(function(s) {
if (!s.data || s.data.length < 2) return;
ctx.strokeStyle = s.color || '#2196F3';
ctx.lineWidth = s.width || 2;
if (s.dashed) ctx.setLineDash([5, 3]);
if (s.fill) {
ctx.fillStyle = s.fill;
ctx.beginPath();
ctx.moveTo(xS(s.data[0][0]), yS(yMin));
s.data.forEach(function(pt) { ctx.lineTo(xS(pt[0]), yS(pt[1])); });
ctx.lineTo(xS(s.data[s.data.length - 1][0]), yS(yMin));
ctx.closePath(); ctx.fill();
}
ctx.beginPath();
ctx.moveTo(xS(s.data[0][0]), yS(s.data[0][1]));
for (var i = 1; i < s.data.length; i++) ctx.lineTo(xS(s.data[i][0]), yS(s.data[i][1]));
ctx.stroke(); ctx.setLineDash([]);
});
ctx.restore();
// Legend is rendered as DOM elements outside the canvas (see updateAutorateUI)
},
_ticks: function(min, max, count) {
var range = max - min;
if (range <= 0) return [min];
var rough = range / count;
var mag = Math.pow(10, Math.floor(Math.log10(rough)));
var n = rough / mag;
var step = n <= 1.5 ? mag : n <= 3.5 ? 2 * mag : n <= 7.5 ? 5 * mag : 10 * mag;
var ticks = [];
for (var v = Math.ceil(min / step) * step; v <= max; v += step)
ticks.push(Math.round(v * 1000) / 1000);
return ticks;
},
// Time-aware tick generation: picks clean second/minute/hour/day intervals
_timeTicks: function(min, max, count) {
var span = max - min;
if (span <= 0) return [max];
var steps = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800,
3600, 7200, 10800, 21600, 43200, 86400, 172800, 432000, 604800];
var ideal = span / count;
var step = steps[steps.length - 1];
for (var i = 0; i < steps.length; i++) {
if (steps[i] >= ideal) { step = steps[i]; break; }
}
var ticks = [];
var maxAgo = Math.floor(span / step) * step;
for (var ago = maxAgo; ago >= 0; ago -= step)
ticks.push(max - ago);
return ticks;
}
};
// Statistics tables and charts are rendered with translated headings, so they
// have to be grouped into the Egress/Ingress tabs by comparing against the
// translations as well. A plain English keyword match such as
// title.includes('Egress') dropped every table and chart in a non-English UI.
var qosmateEgressHeadings = [
'CAKE Egress Statistics',
'CAKE Statistics for Default Class (Egress)',
'Egress Bytes Sent by Class',
'Egress Bytes Sent by Tin',
'Egress Dropped Packets by Class',
'Egress Dropped Packets by Tin',
'Egress Packets Sent by Class',
'Egress Packets Sent by Tin',
'HFSC Egress Class Statistics',
'HTB Egress Class Statistics',
'Hybrid Egress Class Statistics'
];
var qosmateIngressHeadings = [
'CAKE Ingress Statistics',
'CAKE Statistics for Default Class (Ingress)',
'HFSC Ingress Class Statistics',
'HTB Ingress Class Statistics',
'Hybrid Ingress Class Statistics',
'Ingress Bytes Sent by Class',
'Ingress Bytes Sent by Tin',
'Ingress Dropped Packets by Class',
'Ingress Dropped Packets by Tin',
'Ingress Packets Sent by Class',
'Ingress Packets Sent by Tin'
];
var isEgressHeading = function(heading) {
for (var i = 0; i < qosmateEgressHeadings.length; i++)
if (heading === _(qosmateEgressHeadings[i]))
return true;
return false;
};
var isIngressHeading = function(heading) {
for (var i = 0; i < qosmateIngressHeadings.length; i++)
if (heading === _(qosmateIngressHeadings[i]))
return true;
return false;
};
// Main view
return view.extend({
pollInterval: 5,
lastData: null,
charts: {},
pollHandler: null,
activeTabIndex: 0, // Track the active tab
autorateData: null, // Persistent autorate state across polls
autorateTimeRange: '60m', // Active time range for autorate charts
load: function() {
return Promise.all([
callQoSmateStats(),
callQoSmateHistoricalStats(),
callQoSmateRrdData(),
callQoSmateAutorateStats(),
uci.load('qosmate')
]);
},
// Helper function to create a poll handler with consistent behavior
createPollFunction: function() {
var view = this;
return function() {
return Promise.all([
callQoSmateStats(),
callQoSmateHistoricalStats(),
callQoSmateRrdData(),
callQoSmateAutorateStats()
]).then(function(data) {
view.updateContent(data);
return data;
}).catch(function(error) {
// Simple error handling to show feedback when API calls fail
console.error('Error fetching QoS statistics:', error);
// Find the content container
var contentContainer = document.querySelector('.qosmate-content-container');
if (contentContainer) {
// Show error message
dom.content(contentContainer, E('div', { 'class': 'cbi-section' }, [
E('p', { 'class': 'alert-message warning' }, [
_('Error loading QoS statistics. Please check if the QoSmate service is running.')
])
]));
}
return null;
});
};
},
// Create a table to display QoS statistics
createStatsTable: function(title, headers, rows) {
var table = E('table', { 'class': 'table cbi-section-table' });
// Add table headers
var headerRow = E('tr', { 'class': 'tr table-titles' });
headers.forEach(function(header) {
headerRow.appendChild(E('th', { 'class': 'th' }, header));
});
table.appendChild(headerRow);
// Add table rows
if (rows && rows.length > 0) {
rows.forEach(function(row) {
var tableRow = E('tr', { 'class': 'tr' });
row.forEach(function(cell) {
tableRow.appendChild(E('td', { 'class': 'td' }, cell));
});
table.appendChild(tableRow);
});
} else {
var emptyRow = E('tr', { 'class': 'tr' });
emptyRow.appendChild(E('td', {
'class': 'td',
'colspan': headers.length,
'style': 'text-align: center;'
}, _('No data available')));
table.appendChild(emptyRow);
}
return E('div', { 'class': 'stats-table-container', 'style': 'margin-bottom: 10px;' }, [
E('h3', { 'style': 'margin-bottom: 1.5em;' }, title),
table
]);
},
// Create a simple chart using DOM elements
createChart: function(id, title, data, labels, colors) {
var container = E('div', { 'class': 'chart-container', 'style': 'height: 250px; margin-bottom: 10px;' });
var chartTitle = E('h3', { 'style': 'margin-bottom: 1.5em;' }, title);
container.appendChild(chartTitle);
// Simple bar chart implementation
var chartData = E('div', { 'class': 'chart-data', 'style': 'display: flex; height: 180px; margin-top: 5px; align-items: flex-end;' });
if (data && data.length > 0) {
var maxValue = Math.max.apply(null, data);
for (var i = 0; i < data.length; i++) {
var value = data[i];
var barHeight = maxValue > 0 ? (value / maxValue * 160) : 0;
var barContainer = E('div', {
'style': 'flex: 1; text-align: center; padding: 0 2px; display: flex; flex-direction: column; align-items: center;'
});
// Add value at top of bar
var valueLabel = E('div', { 'style': 'font-size: 10px; margin-bottom: 5px;' },
(typeof value === 'number') ?
(value > 1000000 ? (value / 1000000).toFixed(1) + 'M' :
value > 1000 ? (value / 1000).toFixed(1) + 'K' : value) : '0');
var bar = E('div', {
'style': 'background-color: ' + (colors ? colors[i % colors.length] : '#0088cc') +
'; width: 80%; height: ' + (barHeight > 0 ? barHeight : 1) + 'px;'
});
var label = E('div', { 'style': 'font-size: 10px; margin-top: 5px; white-space: nowrap;' },
labels ? labels[i] : '');
barContainer.appendChild(valueLabel);
barContainer.appendChild(bar);
barContainer.appendChild(label);
chartData.appendChild(barContainer);
}
} else {
chartData.appendChild(E('div', { 'style': 'width: 100%; text-align: center; padding-top: 70px;' },
_('No data available')));
}
container.appendChild(chartData);
return container;
},
// Process CAKE statistics
processCakeStats: function(data) {
if (!data || (!data.cake_egress && !data.cake_ingress)) {
return { tables: [], charts: [] };
}
var result = { tables: [], charts: [] };
var self = this;
// Helper function to get tin name based on diffserv mode
function getTinName(tinIndex, diffservMode) {
if (diffservMode === 'diffserv3') {
return ['Bulk', 'Best Effort', 'Voice'][tinIndex] || 'Unknown';
} else if (diffservMode === 'diffserv4') {
return ['Bulk', 'Best Effort', 'Video', 'Voice'][tinIndex] || 'Unknown';
} else if (diffservMode === 'diffserv8') {
return ['Tin 0', 'Tin 1', 'Tin 2', 'Tin 3', 'Tin 4', 'Tin 5', 'Tin 6', 'Tin 7'][tinIndex] || 'Unknown';
} else {
return 'Tin ' + tinIndex;
}
}
// Process CAKE egress statistics
if (data.cake_egress) {
var egressRows = [];
var diffservMode = data.cake_egress.options ? data.cake_egress.options.diffserv : 'diffserv4';
if (data.cake_egress.tins) {
data.cake_egress.tins.forEach(function(tin, index) {
egressRows.push([
'Tin ' + index,
// Convert threshold_rate from Byte/s to Kbit/s
bytesToKbits(tin.threshold_rate) + ' Kbit/s',
formatTime(tin.target_us),
formatTime(tin.interval_us),
// Add delay metrics (µs)
formatTime(tin.peak_delay_us),
formatTime(tin.avg_delay_us),
formatTime(tin.base_delay_us),
formatSize(tin.sent_bytes),
tin.sent_packets,
tin.drops,
tin.ecn_mark
]);
});
}
// Create table for CAKE egress tins
if (egressRows.length > 0) {
result.tables.push(self.createStatsTable(
_('CAKE Egress Statistics'),
[_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')],
egressRows
));
// Create charts for CAKE egress stats
var tinLabels = egressRows.map(function(row, index) {
return getTinName(index, diffservMode);
});
var sentBytes = egressRows.map(function(row) {
return parseSizeToBytes(row[7]);
});
var sentPackets = egressRows.map(function(row) { return parseInt(row[8]); });
var droppedPackets = egressRows.map(function(row) { return parseInt(row[9]); });
result.charts.push(self.createChart(
'cake-egress-bytes',
_('Egress Bytes Sent by Tin'),
sentBytes,
tinLabels,
chartColors
));
result.charts.push(self.createChart(
'cake-egress-packets',
_('Egress Packets Sent by Tin'),
sentPackets,
tinLabels,
chartColors
));
result.charts.push(self.createChart(
'cake-egress-dropped',
_('Egress Dropped Packets by Tin'),
droppedPackets,
tinLabels,
chartColors
));
}
}
// Process CAKE ingress statistics
if (data.cake_ingress) {
var ingressRows = [];
var diffservMode = data.cake_ingress.options ? data.cake_ingress.options.diffserv : 'diffserv4';
if (data.cake_ingress.tins) {
data.cake_ingress.tins.forEach(function(tin, index) {
ingressRows.push([
'Tin ' + index,
// Convert threshold_rate from Byte/s to Kbit/s
bytesToKbits(tin.threshold_rate) + ' Kbit/s',
formatTime(tin.target_us),
formatTime(tin.interval_us),
// Add delay metrics (µs)
formatTime(tin.peak_delay_us),
formatTime(tin.avg_delay_us),
formatTime(tin.base_delay_us),
formatSize(tin.sent_bytes),
tin.sent_packets,
tin.drops,
tin.ecn_mark
]);
});
}
// Create table for CAKE ingress tins
if (ingressRows.length > 0) {
result.tables.push(self.createStatsTable(
_('CAKE Ingress Statistics'),
[_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')],
ingressRows
));
// Create charts for CAKE ingress stats
var tinLabels = ingressRows.map(function(row, index) {
return getTinName(index, diffservMode);
});
var sentBytes = ingressRows.map(function(row) {
return parseSizeToBytes(row[7]);
});
var sentPackets = ingressRows.map(function(row) { return parseInt(row[8]); });
var droppedPackets = ingressRows.map(function(row) { return parseInt(row[9]); });
result.charts.push(self.createChart(
'cake-ingress-bytes',
_('Ingress Bytes Sent by Tin'),
sentBytes,
tinLabels,
chartColors
));
result.charts.push(self.createChart(
'cake-ingress-packets',
_('Ingress Packets Sent by Tin'),
sentPackets,
tinLabels,
chartColors
));
result.charts.push(self.createChart(
'cake-ingress-dropped',
_('Ingress Dropped Packets by Tin'),
droppedPackets,
tinLabels,
chartColors
));
}
}
return result;
},
// Process HFSC statistics
processHfscStats: function(data) {
if (!data || (!data.egress_leaf_qdiscs && !data.ingress_leaf_qdiscs)) {
return { tables: [], charts: [] };
}
var result = { tables: [], charts: [] };
var self = this;
// Process egress leaf qdiscs
if (data.egress_leaf_qdiscs && data.egress_leaf_qdiscs.length > 0) {
var egressRows = [];
// Process all qdiscs first and collect data
var classData = {};
data.egress_leaf_qdiscs.forEach(function(qdisc) {
if (!qdisc.parent) return;
var classId = qdisc.parent;
var classDesc = '';
var sortOrder = 999; // Default high value for unknown classes
// Determine class description based on ID and skip those that don't match
if (classId.includes('1:11')) {
classDesc = _('High Priority (Realtime)');
sortOrder = 5;
}
else if (classId.includes('1:12')) {
classDesc = _('Fast Non-Realtime');
sortOrder = 4;
}
else if (classId.includes('1:13')) {
classDesc = _('Normal');
sortOrder = 3;
}
else if (classId.includes('1:14')) {
classDesc = _('Low Priority');
sortOrder = 2;
}
else if (classId.includes('1:15')) {
classDesc = _('Bulk');
sortOrder = 1;
}
else return; // Skip this qdisc if it doesn't match any of the expected classes
classData[classId] = {
classId: classId,
classDesc: classDesc,
sortOrder: sortOrder,
bytes: qdisc.bytes || 0,
packets: qdisc.packets || 0,
drops: qdisc.drops || 0,
overlimits: qdisc.overlimits || 0
};
});
// Convert to array and sort based on sortOrder
var sortedClassData = Object.values(classData).sort(function(a, b) {
return a.sortOrder - b.sortOrder;
});
// Create rows from sorted data
sortedClassData.forEach(function(item) {
egressRows.push([
item.classId,
item.classDesc,
formatSize(item.bytes),
item.packets,
item.drops,
item.overlimits
]);
});
// Create table for HFSC egress classes
result.tables.push(self.createStatsTable(
_('HFSC Egress Class Statistics'),
[_('Class ID'), _('Description'), _('Bytes'), _('Packets'), _('Drops'), _('Overlimits')],
egressRows
));
// Create charts for HFSC egress classes
var classLabels = sortedClassData.map(function(item) { return item.classDesc; });
var sentBytes = sortedClassData.map(function(item) {
return parseSizeToBytes(formatSize(item.bytes));
});
var sentPackets = sortedClassData.map(function(item) { return item.packets; });
var droppedPackets = sortedClassData.map(function(item) { return item.drops; });
result.charts.push(self.createChart(
'hfsc-egress-bytes',
_('Egress Bytes Sent by Class'),
sentBytes,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'hfsc-egress-packets',
_('Egress Packets Sent by Class'),
sentPackets,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'hfsc-egress-dropped',
_('Egress Dropped Packets by Class'),
droppedPackets,
classLabels,
chartColors
));
}
// Process ingress leaf qdiscs
if (data.ingress_leaf_qdiscs && data.ingress_leaf_qdiscs.length > 0) {
var ingressRows = [];
// Process all qdiscs first and collect data
var classData = {};
data.ingress_leaf_qdiscs.forEach(function(qdisc) {
if (!qdisc.parent) return;
var classId = qdisc.parent;
var classDesc = '';
var sortOrder = 999; // Default high value for unknown classes
// Determine class description based on ID and skip those that don't match
if (classId.includes('1:11')) {
classDesc = _('High Priority (Realtime)');
sortOrder = 5;
}
else if (classId.includes('1:12')) {
classDesc = _('Fast Non-Realtime');
sortOrder = 4;
}
else if (classId.includes('1:13')) {
classDesc = _('Normal');
sortOrder = 3;
}
else if (classId.includes('1:14')) {
classDesc = _('Low Priority');
sortOrder = 2;
}
else if (classId.includes('1:15')) {
classDesc = _('Bulk');
sortOrder = 1;
}
else return; // Skip this qdisc if it doesn't match any of the expected classes
classData[classId] = {
classId: classId,
classDesc: classDesc,
sortOrder: sortOrder,
bytes: qdisc.bytes || 0,
packets: qdisc.packets || 0,
drops: qdisc.drops || 0,
overlimits: qdisc.overlimits || 0
};
});
// Convert to array and sort based on sortOrder
var sortedClassData = Object.values(classData).sort(function(a, b) {
return a.sortOrder - b.sortOrder;
});
// Create rows from sorted data
sortedClassData.forEach(function(item) {
ingressRows.push([
item.classId,
item.classDesc,
formatSize(item.bytes),
item.packets,
item.drops,
item.overlimits
]);
});
// Create table for HFSC ingress classes
result.tables.push(self.createStatsTable(
_('HFSC Ingress Class Statistics'),
[_('Class ID'), _('Description'), _('Bytes'), _('Packets'), _('Drops'), _('Overlimits')],
ingressRows
));
// Create charts for HFSC ingress classes
var classLabels = sortedClassData.map(function(item) { return item.classDesc; });
var sentBytes = sortedClassData.map(function(item) {
return parseSizeToBytes(formatSize(item.bytes));
});
var sentPackets = sortedClassData.map(function(item) { return item.packets; });
var droppedPackets = sortedClassData.map(function(item) { return item.drops; });
result.charts.push(self.createChart(
'hfsc-ingress-bytes',
_('Ingress Bytes Sent by Class'),
sentBytes,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'hfsc-ingress-packets',
_('Ingress Packets Sent by Class'),
sentPackets,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'hfsc-ingress-dropped',
_('Ingress Dropped Packets by Class'),
droppedPackets,
classLabels,
chartColors
));
}
return result;
},
// Process Hybrid statistics (HFSC + CAKE)
processHybridStats: function(data) {
if (!data || (!data.egress_leaf_qdiscs && !data.ingress_leaf_qdiscs)) {
return { tables: [], charts: [] };
}
var result = { tables: [], charts: [] };
var self = this;
// Process egress hybrid statistics
if (data.egress_leaf_qdiscs && data.egress_leaf_qdiscs.length > 0) {
var egressRows = [];
// Process all qdiscs first and collect data (hybrid only has 1:11, 1:13, 1:15)
var classData = {};
data.egress_leaf_qdiscs.forEach(function(qdisc) {
if (!qdisc.parent) return;
var classId = qdisc.parent;
var classDesc = '';
var queueType = '';
var sortOrder = 999;
// Hybrid class mapping based on setup_hybrid() function
if (classId.includes('1:11')) {
classDesc = _('Realtime Gaming');
queueType = data.gameqdisc || 'pfifo';
sortOrder = 3;
}
else if (classId.includes('1:13')) {
classDesc = _('Default (CAKE)');
queueType = 'cake';
sortOrder = 2;
}
else if (classId.includes('1:15')) {
classDesc = _('Bulk');
queueType = 'fq_codel';
sortOrder = 1;
}
else return; // Skip unknown classes
classData[classId] = {
classId: classId,
classDesc: classDesc,
queueType: queueType,
sortOrder: sortOrder,
bytes: qdisc.bytes || 0,
packets: qdisc.packets || 0,
drops: qdisc.drops || 0,
overlimits: qdisc.overlimits || 0
};
});
// Convert to array and sort
var sortedClassData = Object.values(classData).sort(function(a, b) {
return b.sortOrder - a.sortOrder; // Higher priority first
});
// Create rows from sorted data
sortedClassData.forEach(function(item) {
egressRows.push([
item.classId,
item.classDesc,
item.queueType,
formatSize(item.bytes),
item.packets,
item.drops,
item.overlimits
]);
});
// Create table for Hybrid egress classes
result.tables.push(self.createStatsTable(
_('Hybrid Egress Class Statistics'),
[_('Class ID'), _('Description'), _('Queue Type'), _('Bytes'), _('Packets'), _('Drops'), _('Overlimits')],
egressRows
));
// Create charts
var classLabels = sortedClassData.map(function(item) { return item.classDesc; });
var sentBytes = sortedClassData.map(function(item) {
return parseSizeToBytes(formatSize(item.bytes));
});
var sentPackets = sortedClassData.map(function(item) { return item.packets; });
var droppedPackets = sortedClassData.map(function(item) { return item.drops; });
result.charts.push(self.createChart(
'hybrid-egress-bytes',
_('Egress Bytes Sent by Class'),
sentBytes,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'hybrid-egress-packets',
_('Egress Packets Sent by Class'),
sentPackets,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'hybrid-egress-dropped',
_('Egress Dropped Packets by Class'),
droppedPackets,
classLabels,
chartColors
));
}
// Process ingress hybrid statistics
if (data.ingress_leaf_qdiscs && data.ingress_leaf_qdiscs.length > 0) {
var ingressRows = [];
var classData = {};
data.ingress_leaf_qdiscs.forEach(function(qdisc) {
if (!qdisc.parent) return;
var classId = qdisc.parent;
var classDesc = '';
var queueType = '';
var sortOrder = 999;
// Hybrid class mapping
if (classId.includes('1:11')) {
classDesc = _('Realtime Gaming');
queueType = data.gameqdisc || 'pfifo';
sortOrder = 3;
}
else if (classId.includes('1:13')) {
classDesc = _('Default (CAKE)');
queueType = 'cake';
sortOrder = 2;
}
else if (classId.includes('1:15')) {
classDesc = _('Bulk');
queueType = 'fq_codel';
sortOrder = 1;
}
else return;
classData[classId] = {
classId: classId,
classDesc: classDesc,
queueType: queueType,
sortOrder: sortOrder,
bytes: qdisc.bytes || 0,
packets: qdisc.packets || 0,
drops: qdisc.drops || 0,
overlimits: qdisc.overlimits || 0
};
});
var sortedClassData = Object.values(classData).sort(function(a, b) {
return b.sortOrder - a.sortOrder;
});
sortedClassData.forEach(function(item) {
ingressRows.push([
item.classId,
item.classDesc,
item.queueType,
formatSize(item.bytes),
item.packets,
item.drops,
item.overlimits
]);
});
result.tables.push(self.createStatsTable(
_('Hybrid Ingress Class Statistics'),
[_('Class ID'), _('Description'), _('Queue Type'), _('Bytes'), _('Packets'), _('Drops'), _('Overlimits')],
ingressRows
));
var classLabels = sortedClassData.map(function(item) { return item.classDesc; });
var sentBytes = sortedClassData.map(function(item) {
return parseSizeToBytes(formatSize(item.bytes));
});
var sentPackets = sortedClassData.map(function(item) { return item.packets; });
var droppedPackets = sortedClassData.map(function(item) { return item.drops; });
result.charts.push(self.createChart(
'hybrid-ingress-bytes',
_('Ingress Bytes Sent by Class'),
sentBytes,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'hybrid-ingress-packets',
_('Ingress Packets Sent by Class'),
sentPackets,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'hybrid-ingress-dropped',
_('Ingress Dropped Packets by Class'),
droppedPackets,
classLabels,
chartColors
));
}
// Add CAKE details for the 1:13 class if available
if (data.hybrid_cake_egress && Object.keys(data.hybrid_cake_egress).length > 0) {
var cakeEgressData = data.hybrid_cake_egress;
if (cakeEgressData.tins && cakeEgressData.tins.length > 0) {
var cakeEgressRows = [];
cakeEgressData.tins.forEach(function(tin, index) {
cakeEgressRows.push([
'Tin ' + index,
bytesToKbits(tin.threshold_rate) + ' Kbit/s',
formatTime(tin.target_us),
formatTime(tin.interval_us),
formatTime(tin.peak_delay_us),
formatTime(tin.avg_delay_us),
formatTime(tin.base_delay_us),
formatSize(tin.sent_bytes),
tin.sent_packets,
tin.drops,
tin.ecn_mark
]);
});
result.tables.push(self.createStatsTable(
_('CAKE Statistics for Default Class (Egress)'),
[_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')],
cakeEgressRows
));
}
}
if (data.hybrid_cake_ingress && Object.keys(data.hybrid_cake_ingress).length > 0) {
var cakeIngressData = data.hybrid_cake_ingress;
if (cakeIngressData.tins && cakeIngressData.tins.length > 0) {
var cakeIngressRows = [];
cakeIngressData.tins.forEach(function(tin, index) {
cakeIngressRows.push([
'Tin ' + index,
bytesToKbits(tin.threshold_rate) + ' Kbit/s',
formatTime(tin.target_us),
formatTime(tin.interval_us),
formatTime(tin.peak_delay_us),
formatTime(tin.avg_delay_us),
formatTime(tin.base_delay_us),
formatSize(tin.sent_bytes),
tin.sent_packets,
tin.drops,
tin.ecn_mark
]);
});
result.tables.push(self.createStatsTable(
_('CAKE Statistics for Default Class (Ingress)'),
[_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')],
cakeIngressRows
));
}
}
return result;
},
// Process HTB statistics
processHtbStats: function(data) {
if (!data || (!data.egress_leaf_qdiscs && !data.ingress_leaf_qdiscs)) {
return { tables: [], charts: [] };
}
var result = { tables: [], charts: [] };
var self = this;
// Process egress HTB statistics
if (data.egress_leaf_qdiscs && data.egress_leaf_qdiscs.length > 0) {
var egressRows = [];
// Process all qdiscs first and collect data
var classData = {};
data.egress_leaf_qdiscs.forEach(function(qdisc) {
if (!qdisc.parent) return;
var classId = qdisc.parent;
var classDesc = '';
var sortOrder = 999; // Default high value for unknown classes
// Determine class description based on ID and skip those that don't match
if (classId.includes('1:11')) {
classDesc = _('Priority');
sortOrder = 3;
}
else if (classId.includes('1:13')) {
classDesc = _('Best Effort');
sortOrder = 2;
}
else if (classId.includes('1:15')) {
classDesc = _('Background');
sortOrder = 1;
}
else return; // Skip this qdisc if it doesn't match any of the expected classes
classData[classId] = {
classId: classId,
classDesc: classDesc,
sortOrder: sortOrder,
bytes: qdisc.bytes || 0,
packets: qdisc.packets || 0,
drops: qdisc.drops || 0,
overlimits: qdisc.overlimits || 0
};
});
// Convert to array and sort based on sortOrder
var sortedClassData = Object.values(classData).sort(function(a, b) {
return b.sortOrder - a.sortOrder; // Higher priority first
});
// Create rows from sorted data
sortedClassData.forEach(function(item) {
egressRows.push([
item.classId,
item.classDesc,
formatSize(item.bytes),
item.packets,
item.drops,
item.overlimits
]);
});
// Create table for HTB egress classes
result.tables.push(self.createStatsTable(
_('HTB Egress Class Statistics'),
[_('Class ID'), _('Description'), _('Bytes'), _('Packets'), _('Drops'), _('Overlimits')],
egressRows
));
// Create charts for HTB egress classes
var classLabels = sortedClassData.map(function(item) { return item.classDesc; });
var sentBytes = sortedClassData.map(function(item) {
return parseSizeToBytes(formatSize(item.bytes));
});
var sentPackets = sortedClassData.map(function(item) { return item.packets; });
var droppedPackets = sortedClassData.map(function(item) { return item.drops; });
result.charts.push(self.createChart(
'htb-egress-bytes',
_('Egress Bytes Sent by Class'),
sentBytes,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'htb-egress-packets',
_('Egress Packets Sent by Class'),
sentPackets,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'htb-egress-dropped',
_('Egress Dropped Packets by Class'),
droppedPackets,
classLabels,
chartColors
));
}
// Process ingress HTB statistics
if (data.ingress_leaf_qdiscs && data.ingress_leaf_qdiscs.length > 0) {
var ingressRows = [];
var classData = {};
data.ingress_leaf_qdiscs.forEach(function(qdisc) {
if (!qdisc.parent) return;
var classId = qdisc.parent;
var classDesc = '';
var sortOrder = 999;
if (classId.includes('1:11')) {
classDesc = _('Priority');
sortOrder = 3;
}
else if (classId.includes('1:13')) {
classDesc = _('Best Effort');
sortOrder = 2;
}
else if (classId.includes('1:15')) {
classDesc = _('Background');
sortOrder = 1;
}
else return;
classData[classId] = {
classId: classId,
classDesc: classDesc,
sortOrder: sortOrder,
bytes: qdisc.bytes || 0,
packets: qdisc.packets || 0,
drops: qdisc.drops || 0,
overlimits: qdisc.overlimits || 0
};
});
var sortedClassData = Object.values(classData).sort(function(a, b) {
return b.sortOrder - a.sortOrder;
});
sortedClassData.forEach(function(item) {
ingressRows.push([
item.classId,
item.classDesc,
formatSize(item.bytes),
item.packets,
item.drops,
item.overlimits
]);
});
result.tables.push(self.createStatsTable(
_('HTB Ingress Class Statistics'),
[_('Class ID'), _('Description'), _('Bytes'), _('Packets'), _('Drops'), _('Overlimits')],
ingressRows
));
var classLabels = sortedClassData.map(function(item) { return item.classDesc; });
var sentBytes = sortedClassData.map(function(item) {
return parseSizeToBytes(formatSize(item.bytes));
});
var sentPackets = sortedClassData.map(function(item) { return item.packets; });
var droppedPackets = sortedClassData.map(function(item) { return item.drops; });
result.charts.push(self.createChart(
'htb-ingress-bytes',
_('Ingress Bytes Sent by Class'),
sentBytes,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'htb-ingress-packets',
_('Ingress Packets Sent by Class'),
sentPackets,
classLabels,
chartColors
));
result.charts.push(self.createChart(
'htb-ingress-dropped',
_('Ingress Dropped Packets by Class'),
droppedPackets,
classLabels,
chartColors
));
}
return result;
},
render: function(data) {
var view = this;
var qosStats = data[0];
var historicalStats = data[1];
var rrdData = data[2];
var autorateStats = data[3];
view.lastData = qosStats;
if (autorateStats && autorateStats.autorate_enabled) view.autorateData = autorateStats;
// Create a separate container for settings to prevent it from being refreshed
var mainContainer;
var existingContainer = document.querySelector('.cbi-map-statistics');
var existingSettings = document.querySelector('.qosmate-settings-section');
if (!existingContainer) {
// First time render, create the full layout
mainContainer = E('div', { 'class': 'cbi-map cbi-map-statistics' });
// Create settings section that will remain stable across refreshes
var settingsSection = E('div', { 'class': 'cbi-section qosmate-settings-section', 'style': 'margin-bottom: 5px;' });
// Create header for settings
var settingsHeader = E('h3', {}, _('QoSmate Settings'));
settingsSection.appendChild(settingsHeader);
// Create completely custom styling independent of themes
var refreshRow = E('div', { 'style': 'margin-top: 0.5em; margin-left: 0.25em;' });
// Directly inline elements without any theme classes
refreshRow.appendChild(E('span', {}, _('Refresh Interval')));
refreshRow.appendChild(document.createTextNode(' '));
// Clean select with no special styling
var pollSelect = E('select', {
'id': 'poll-interval',
'change': function(ev) {
var newInterval = parseInt(ev.target.value, 10);
if (view.pollHandler) {
poll.remove(view.pollHandler);
}
view.pollInterval = newInterval;
view.pollHandler = view.createPollFunction();
poll.add(view.pollHandler, view.pollInterval);
}
});
[2, 5, 10, 15, 30, 60].forEach(function(seconds) {
var option = E('option', { 'value': seconds }, seconds + ' ' + _('seconds'));
if (parseInt(view.pollInterval, 10) === seconds) {
option.selected = true;
}
pollSelect.appendChild(option);
});
refreshRow.appendChild(pollSelect);
settingsSection.appendChild(refreshRow);
mainContainer.appendChild(settingsSection);
// Create content container that will be refreshed
var contentContainer = E('div', { 'class': 'qosmate-content-container' });
mainContainer.appendChild(contentContainer);
// Create persistent autorate container (survives poll rebuilds)
var autorateContainer = E('div', { 'class': 'qosmate-autorate-container', 'style': 'display: none;' });
// Status panel
autorateContainer.appendChild(E('div', { 'class': 'cbi-section autorate-status-panel', 'style': 'margin-bottom: 5px;' }));
// Time range selector
var timeRangeRow = E('div', { 'style': 'margin: 0.5em 0 0.8em 0.25em;' });
timeRangeRow.appendChild(E('span', {}, _('Time Range') + ' '));
var flashEnabled = uci.get('qosmate', 'autorate', 'flash_history') === '1';
var rangeSelect = E('select', {
'class': 'autorate-time-range',
'disabled': !flashEnabled ? '' : null,
'change': function(ev) {
view.autorateTimeRange = ev.target.value;
if (view.autorateData) view.updateAutorateUI(view.autorateData);
}
});
[['60m', '60 ' + _('minutes')]].concat(flashEnabled ? [
['6h', '6 ' + _('hours')], ['24h', '24 ' + _('hours')],
['7d', '7 ' + _('days')], ['30d', '30 ' + _('days')]
] : []).forEach(function(opt) {
var o = E('option', { 'value': opt[0] }, opt[1]);
if (opt[0] === view.autorateTimeRange) o.selected = true;
rangeSelect.appendChild(o);
});
timeRangeRow.appendChild(rangeSelect);
// Flash history toggle
var flashCb = E('input', {
'type': 'checkbox',
'id': 'autorate-flash-history',
'checked': flashEnabled ? '' : null,
'style': 'position: relative; top: -0.15rem; margin-right: 4px;',
'change': function(ev) {
var enabled = ev.target.checked;
var val = enabled ? '1' : '0';
// Apply UCI change immediately (same pattern as connections.js)
uci.load('qosmate').then(function() {
uci.set('qosmate', 'autorate', 'flash_history', val);
return uci.save();
}).then(function() {
return uci.apply();
}).then(function() {
uci.unload('qosmate');
return uci.load('qosmate');
});
// Update dropdown immediately without waiting for apply
var sel = document.querySelector('.autorate-time-range');
if (sel) {
view.autorateTimeRange = '60m';
sel.value = '60m';
while (sel.options.length > 1) sel.remove(1);
if (enabled) {
[['6h', '6 ' + _('hours')], ['24h', '24 ' + _('hours')],
['7d', '7 ' + _('days')], ['30d', '30 ' + _('days')]].forEach(function(opt) {
sel.appendChild(E('option', { 'value': opt[0] }, opt[1]));
});
sel.disabled = false;
} else {
sel.disabled = true;
}
}
}
});
var flashLabel = E('label', {
'for': 'autorate-flash-history',
'style': 'margin-left: 1.5em; font-size: 12px; cursor: pointer;'
}, [ flashCb, ' ' + _('Enable long-term history') ]);
timeRangeRow.appendChild(flashLabel);
autorateContainer.appendChild(timeRangeRow);
// Data availability info hint (hidden by default)
autorateContainer.appendChild(E('div', {
'class': 'autorate-data-info',
'style': 'display: none; margin: 0 0.25em 0.5em; padding: 0.4em 0.6em; font-size: 12px; color: #888; font-style: italic;'
}));
// Chart sections
var chartSection = E('div', { 'class': 'cbi-section', 'style': 'padding: 5px 0;' });
var latencyWrap = E('div', { 'style': 'margin-bottom: 15px;' });
latencyWrap.appendChild(E('h3', { 'style': 'margin-bottom: 0.3em;' }, _('Latency')));
latencyWrap.appendChild(E('div', { 'class': 'autorate-latency-legend', 'style': 'margin-bottom: 0.4em; font-size: 11px;' }));
latencyWrap.appendChild(E('canvas', { 'class': 'autorate-latency-canvas', 'style': 'width: 100%; height: 220px; display: block;' }));
chartSection.appendChild(latencyWrap);
var ulWrap = E('div', { 'style': 'margin-bottom: 15px;' });
ulWrap.appendChild(E('h3', { 'style': 'margin-bottom: 0.3em;' }, _('Upload Bandwidth')));
ulWrap.appendChild(E('div', { 'class': 'autorate-ul-legend', 'style': 'margin-bottom: 0.4em; font-size: 11px;' }));
ulWrap.appendChild(E('canvas', { 'class': 'autorate-ul-canvas', 'style': 'width: 100%; height: 220px; display: block;' }));
chartSection.appendChild(ulWrap);
var dlWrap = E('div', { 'style': 'margin-bottom: 15px;' });
dlWrap.appendChild(E('h3', { 'style': 'margin-bottom: 0.3em;' }, _('Download Bandwidth')));
dlWrap.appendChild(E('div', { 'class': 'autorate-dl-legend', 'style': 'margin-bottom: 0.4em; font-size: 11px;' }));
dlWrap.appendChild(E('canvas', { 'class': 'autorate-dl-canvas', 'style': 'width: 100%; height: 220px; display: block;' }));
chartSection.appendChild(dlWrap);
autorateContainer.appendChild(chartSection);
mainContainer.appendChild(autorateContainer);
// Setup initial polling using LuCI poll API
if (!view.pollHandler) {
view.pollHandler = view.createPollFunction();
poll.add(view.pollHandler, view.pollInterval);
}
} else {
// Re-use the existing container during subsequent renders
mainContainer = existingContainer;
}
return mainContainer;
},
// New method to update only the content part of the page
updateContent: function(data) {
var view = this;
var qosStats = data[0];
var historicalStats = data[1];
var rrdData = data[2];
var autorateStats = data[3];
// Update autorate charts (persistent container, not rebuilt)
var autorateEnabled = autorateStats && autorateStats.autorate_enabled;
if (autorateEnabled) {
view.autorateData = autorateStats;
view.updateAutorateUI(autorateStats);
} else {
// Hide autorate container if autorate was disabled
var arContainer = document.querySelector('.qosmate-autorate-container');
if (arContainer) arContainer.style.display = 'none';
}
// Save the active tab index before refreshing content
var activeTab = document.querySelector('.cbi-tab.cbi-tab-active');
if (activeTab) {
view.activeTabIndex = parseInt(activeTab.getAttribute('data-tab-index') || 0, 10);
}
// Get the content container
var contentContainer = document.querySelector('.qosmate-content-container');
if (!contentContainer) return;
// Clear the current content
dom.content(contentContainer, '');
// Add general info section
if (qosStats) {
// Create a standard section
var generalInfo = E('div', { 'class': 'cbi-section', 'style': 'margin-bottom: 5px;' });
// Create header for general info
var headerSection = E('h3', { 'style': 'margin-bottom: 0.5em;' }, _('General Information'));
generalInfo.appendChild(headerSection);
// Create completely custom container for info using flexbox for horizontal layout
var infoContainer = E('div', { 'style': 'display: flex; flex-wrap: wrap; margin-left: 0.25em;' });
// Create simple layout items that will display horizontally
if (qosStats.root_qdisc) {
var row = E('div', { 'style': 'margin-right: 1.5em; margin-bottom: 0.3em;' });
row.appendChild(E('span', { 'style': 'font-weight: bold;' }, _('Root Queueing Discipline:')));
row.appendChild(document.createTextNode(' '));
row.appendChild(E('span', {}, qosStats.root_qdisc || '-'));
infoContainer.appendChild(row);
}
if (qosStats.wan_interface) {
var row = E('div', { 'style': 'margin-right: 1.5em; margin-bottom: 0.3em;' });
row.appendChild(E('span', { 'style': 'font-weight: bold;' }, _('WAN Interface:')));
row.appendChild(document.createTextNode(' '));
row.appendChild(E('span', {}, qosStats.wan_interface || '-'));
infoContainer.appendChild(row);
}
// A rate of 0 disables that direction, so one of the tabs stays empty on purpose
var statsUpRate = parseInt(uci.get('qosmate', 'settings', 'UPRATE') || '0');
var statsDownRate = parseInt(uci.get('qosmate', 'settings', 'DOWNRATE') || '0');
if (statsUpRate === 0 || statsDownRate === 0) {
var dirText = statsUpRate === 0 && statsDownRate === 0
? _('disabled in both directions (both rates are 0)')
: (statsDownRate === 0 ? _('egress only (DOWNRATE=0, no ingress statistics)')
: _('ingress only (UPRATE=0, no egress statistics)'));
var row = E('div', { 'style': 'margin-right: 1.5em; margin-bottom: 0.3em; color: orange;' });
row.appendChild(E('span', { 'style': 'font-weight: bold;' }, _('Shaping:')));
row.appendChild(document.createTextNode(' '));
row.appendChild(E('span', {}, dirText));
infoContainer.appendChild(row);
}
// Add CAKE-specific or HFSC-specific info
if (qosStats.root_qdisc === 'cake') {
if (qosStats.priority_queue_ingress) {
var row = E('div', { 'style': 'margin-right: 1.5em; margin-bottom: 0.3em;' });
row.appendChild(E('span', { 'style': 'font-weight: bold;' }, _('Priority Queue Type (Ingress):')));
row.appendChild(document.createTextNode(' '));
row.appendChild(E('span', {}, qosStats.priority_queue_ingress || '-'));
infoContainer.appendChild(row);
}
if (qosStats.priority_queue_egress) {
var row = E('div', { 'style': 'margin-right: 1.5em; margin-bottom: 0.3em;' });
row.appendChild(E('span', { 'style': 'font-weight: bold;' }, _('Priority Queue Type (Egress):')));
row.appendChild(document.createTextNode(' '));
row.appendChild(E('span', {}, qosStats.priority_queue_egress || '-'));
infoContainer.appendChild(row);
}
} else if (qosStats.root_qdisc === 'hfsc' || qosStats.root_qdisc === 'hybrid') {
if (qosStats.gameqdisc) {
var row = E('div', { 'style': 'margin-right: 1.5em; margin-bottom: 0.3em;' });
row.appendChild(E('span', { 'style': 'font-weight: bold;' }, _('Game Queue Discipline:')));
row.appendChild(document.createTextNode(' '));
row.appendChild(E('span', {}, qosStats.gameqdisc || '-'));
infoContainer.appendChild(row);
}
}
generalInfo.appendChild(infoContainer);
contentContainer.appendChild(generalInfo);
// Create tabbed interface
var tabsContainer = E('div', { 'class': 'cbi-section', 'style': 'padding: 0; margin-bottom: 5px;' });
var tabNames = [_('Egress (Upload)'), _('Ingress (Download)')];
var autorateTabIndex = -1;
if (autorateEnabled) {
autorateTabIndex = tabNames.length;
tabNames.push(_('Autorate'));
}
// Reset to first tab if saved index is out of range (e.g. autorate was disabled)
if (view.activeTabIndex >= tabNames.length) view.activeTabIndex = 0;
var tabContents = [
E('div', { 'style': 'padding: 5px 0;' }),
E('div', { 'style': 'padding: 5px 0; display: none;' })
];
// Reference to persistent autorate container
var autorateContainer = document.querySelector('.qosmate-autorate-container');
// Add tab buttons container
var tabButtons = E('ul', { 'class': 'cbi-tabmenu', 'style': 'margin-left: 0; padding-left: 1px;' });
tabNames.forEach(function(name, index) {
var isActive = index === view.activeTabIndex;
var button = E('li', { 'class': isActive ? 'cbi-tab' : 'cbi-tab-disabled' }, [
E('a', {
'href': '#',
'data-tab-index': index,
'click': function(ev) {
ev.preventDefault();
// Store the active tab index
view.activeTabIndex = parseInt(ev.target.getAttribute('data-tab-index') || ev.target.parentNode.getAttribute('data-tab-index'));
// Update tab button styles
tabButtons.querySelectorAll('li').forEach(function(tab, i) {
if (i === view.activeTabIndex) {
tab.className = 'cbi-tab';
} else {
tab.className = 'cbi-tab-disabled';
}
});
// Show corresponding tab content and hide others
var isAutorate = autorateTabIndex >= 0 && view.activeTabIndex === autorateTabIndex;
tabContents.forEach(function(content, i) {
content.style.display = (!isAutorate && i === view.activeTabIndex) ? 'block' : 'none';
});
if (autorateContainer) {
autorateContainer.style.display = isAutorate ? 'block' : 'none';
if (isAutorate && view.autorateData) view.updateAutorateUI(view.autorateData);
}
}
}, name)
]);
tabButtons.appendChild(button);
});
// Set initial display state based on the active tab index
var isAutorateActive = autorateTabIndex >= 0 && view.activeTabIndex === autorateTabIndex;
tabContents.forEach(function(content, i) {
content.style.display = (!isAutorateActive && i === view.activeTabIndex) ? 'block' : 'none';
});
if (autorateContainer) {
autorateContainer.style.display = isAutorateActive ? 'block' : 'none';
}
tabsContainer.appendChild(tabButtons);
// Process and display statistics based on root qdisc
if (qosStats.root_qdisc === 'cake') {
var cakeResults = this.processCakeStats(qosStats);
// Process and display tables in appropriate tabs
if (cakeResults.tables.length > 0) {
// Find correct indices for egress and ingress tables
var egressTable = null;
var ingressTable = null;
// Find the egress and ingress tables by looking at their titles
for (var i = 0; i < cakeResults.tables.length; i++) {
var titleElement = cakeResults.tables[i].querySelector('h3, .table-title');
if (titleElement) {
var title = titleElement.textContent || titleElement.innerText;
if (isEgressHeading(title)) {
egressTable = cakeResults.tables[i];
} else if (isIngressHeading(title)) {
ingressTable = cakeResults.tables[i];
}
}
}
// Egress table section
if (egressTable) {
tabContents[0].appendChild(egressTable);
}
// Ingress table section
if (ingressTable) {
tabContents[1].appendChild(ingressTable);
}
// Process charts
if (cakeResults.charts.length > 0) {
var egressCharts = [];
var ingressCharts = [];
// Categorize charts as egress or ingress based on their titles
for (var i = 0; i < cakeResults.charts.length; i++) {
var chartTitle = cakeResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
if (isEgressHeading(titleText)) {
egressCharts.push(cakeResults.charts[i]);
} else if (isIngressHeading(titleText)) {
ingressCharts.push(cakeResults.charts[i]);
}
}
// Create egress charts section
if (egressCharts.length > 0) {
var egressChartsContainer = E('div', { 'class': 'cbi-section-node' });
var egressGrid = E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;'
});
egressCharts.forEach(function(chart) {
egressGrid.appendChild(chart);
});
egressChartsContainer.appendChild(egressGrid);
tabContents[0].appendChild(egressChartsContainer);
}
// Create ingress charts section
if (ingressCharts.length > 0) {
var ingressChartsContainer = E('div', { 'class': 'cbi-section-node' });
var ingressGrid = E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;'
});
ingressCharts.forEach(function(chart) {
ingressGrid.appendChild(chart);
});
ingressChartsContainer.appendChild(ingressGrid);
tabContents[1].appendChild(ingressChartsContainer);
}
}
}
} else if (qosStats.root_qdisc === 'hfsc') {
var hfscResults = this.processHfscStats(qosStats);
// Process and display tables in appropriate tabs
if (hfscResults.tables.length > 0) {
// Find correct indices for egress and ingress tables
var egressTable = null;
var ingressTable = null;
// Find the egress and ingress tables by looking at their titles
for (var i = 0; i < hfscResults.tables.length; i++) {
var titleElement = hfscResults.tables[i].querySelector('h3, .table-title');
if (titleElement) {
var title = titleElement.textContent || titleElement.innerText;
if (isEgressHeading(title)) {
egressTable = hfscResults.tables[i];
} else if (isIngressHeading(title)) {
ingressTable = hfscResults.tables[i];
}
}
}
// Egress table section
if (egressTable) {
tabContents[0].appendChild(egressTable);
}
// Ingress table section
if (ingressTable) {
tabContents[1].appendChild(ingressTable);
}
// Process charts
if (hfscResults.charts.length > 0) {
var egressCharts = [];
var ingressCharts = [];
// Categorize charts as egress or ingress based on their titles
for (var i = 0; i < hfscResults.charts.length; i++) {
var chartTitle = hfscResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
if (isEgressHeading(titleText)) {
egressCharts.push(hfscResults.charts[i]);
} else if (isIngressHeading(titleText)) {
ingressCharts.push(hfscResults.charts[i]);
}
}
// Create egress charts section
if (egressCharts.length > 0) {
var egressChartsContainer = E('div', { 'class': 'cbi-section-node' });
var egressGrid = E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;'
});
egressCharts.forEach(function(chart) {
egressGrid.appendChild(chart);
});
egressChartsContainer.appendChild(egressGrid);
tabContents[0].appendChild(egressChartsContainer);
}
// Create ingress charts section
if (ingressCharts.length > 0) {
var ingressChartsContainer = E('div', { 'class': 'cbi-section-node' });
var ingressGrid = E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;'
});
ingressCharts.forEach(function(chart) {
ingressGrid.appendChild(chart);
});
ingressChartsContainer.appendChild(ingressGrid);
tabContents[1].appendChild(ingressChartsContainer);
}
}
}
} else if (qosStats.root_qdisc === 'hybrid') {
var hybridResults = this.processHybridStats(qosStats);
// Process and display tables in appropriate tabs
if (hybridResults.tables.length > 0) {
// Separate tables by type
var egressClassTable = null;
var ingressClassTable = null;
var cakeEgressTable = null;
var cakeIngressTable = null;
// Categorize tables by their titles
for (var i = 0; i < hybridResults.tables.length; i++) {
var titleElement = hybridResults.tables[i].querySelector('h3, .table-title');
if (titleElement) {
var title = titleElement.textContent || titleElement.innerText;
if (title === _('Hybrid Egress Class Statistics')) {
egressClassTable = hybridResults.tables[i];
} else if (title === _('Hybrid Ingress Class Statistics')) {
ingressClassTable = hybridResults.tables[i];
} else if (title === _('CAKE Statistics for Default Class (Egress)')) {
cakeEgressTable = hybridResults.tables[i];
} else if (title === _('CAKE Statistics for Default Class (Ingress)')) {
cakeIngressTable = hybridResults.tables[i];
}
}
}
// Add tables to appropriate tabs
if (egressClassTable) {
tabContents[0].appendChild(egressClassTable);
}
if (cakeEgressTable) {
tabContents[0].appendChild(cakeEgressTable);
}
if (ingressClassTable) {
tabContents[1].appendChild(ingressClassTable);
}
if (cakeIngressTable) {
tabContents[1].appendChild(cakeIngressTable);
}
// Process charts
if (hybridResults.charts.length > 0) {
var egressCharts = [];
var ingressCharts = [];
// Categorize charts as egress or ingress based on their titles
for (var i = 0; i < hybridResults.charts.length; i++) {
var chartTitle = hybridResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
if (isEgressHeading(titleText)) {
egressCharts.push(hybridResults.charts[i]);
} else if (isIngressHeading(titleText)) {
ingressCharts.push(hybridResults.charts[i]);
}
}
// Create egress charts section
if (egressCharts.length > 0) {
var egressChartsContainer = E('div', { 'class': 'cbi-section-node' });
var egressGrid = E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;'
});
egressCharts.forEach(function(chart) {
egressGrid.appendChild(chart);
});
egressChartsContainer.appendChild(egressGrid);
tabContents[0].appendChild(egressChartsContainer);
}
// Create ingress charts section
if (ingressCharts.length > 0) {
var ingressChartsContainer = E('div', { 'class': 'cbi-section-node' });
var ingressGrid = E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;'
});
ingressCharts.forEach(function(chart) {
ingressGrid.appendChild(chart);
});
ingressChartsContainer.appendChild(ingressGrid);
tabContents[1].appendChild(ingressChartsContainer);
}
}
}
} else if (qosStats.root_qdisc === 'htb') {
var htbResults = this.processHtbStats(qosStats);
// Process and display tables in appropriate tabs
if (htbResults.tables.length > 0) {
// Separate tables by type
var egressClassTable = null;
var ingressClassTable = null;
// Categorize tables by their titles
for (var i = 0; i < htbResults.tables.length; i++) {
var titleElement = htbResults.tables[i].querySelector('h3, .table-title');
if (titleElement) {
var title = titleElement.textContent || titleElement.innerText;
if (title === _('HTB Egress Class Statistics')) {
egressClassTable = htbResults.tables[i];
} else if (title === _('HTB Ingress Class Statistics')) {
ingressClassTable = htbResults.tables[i];
}
}
}
// Add tables to appropriate tabs
if (egressClassTable) {
tabContents[0].appendChild(egressClassTable);
}
if (ingressClassTable) {
tabContents[1].appendChild(ingressClassTable);
}
// Process charts
if (htbResults.charts.length > 0) {
var egressCharts = [];
var ingressCharts = [];
// Categorize charts as egress or ingress based on their titles
for (var i = 0; i < htbResults.charts.length; i++) {
var chartTitle = htbResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
if (isEgressHeading(titleText)) {
egressCharts.push(htbResults.charts[i]);
} else if (isIngressHeading(titleText)) {
ingressCharts.push(htbResults.charts[i]);
}
}
// Create egress charts section
if (egressCharts.length > 0) {
var egressChartsContainer = E('div', { 'class': 'cbi-section-node' });
var egressGrid = E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;'
});
egressCharts.forEach(function(chart) {
egressGrid.appendChild(chart);
});
egressChartsContainer.appendChild(egressGrid);
tabContents[0].appendChild(egressChartsContainer);
}
// Create ingress charts section
if (ingressCharts.length > 0) {
var ingressChartsContainer = E('div', { 'class': 'cbi-section-node' });
var ingressGrid = E('div', {
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;'
});
ingressCharts.forEach(function(chart) {
ingressGrid.appendChild(chart);
});
ingressChartsContainer.appendChild(ingressGrid);
tabContents[1].appendChild(ingressChartsContainer);
}
}
}
}
// Append tab contents to container
tabContents.forEach(function(content) {
tabsContainer.appendChild(content);
});
contentContainer.appendChild(tabsContainer);
} else {
contentContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
E('p', {}, _('No QoS statistics available. Make sure QoSmate is running.'))
]));
}
},
// Build a DOM legend element from items array [{label, color, dashed}]
buildChartLegend: function(container, items) {
dom.content(container, '');
items.forEach(function(item, i) {
if (i > 0) container.appendChild(document.createTextNode(' '));
var lineStyle = 'display: inline-block; width: 18px; height: 0; vertical-align: middle;'
+ ' border-top: 2px ' + (item.dashed ? 'dashed' : 'solid') + ' ' + item.color + ';'
+ ' margin-right: 4px;';
container.appendChild(E('span', { 'style': 'white-space: nowrap; margin-right: 12px;' }, [
E('span', { 'style': lineStyle }),
E('span', {}, item.label)
]));
});
},
// Update autorate status panel and charts with current data
updateAutorateUI: function(ar) {
var cur = ar.current || {};
var cfg = ar.config || {};
var range = this.autorateTimeRange || '60m';
// Choose data source: RAM (60m) or flash (longer ranges)
// RAM format: [ts, ul, dl, a_ul, a_dl, lat, bl, ulp, dlp] (9 fields)
// Flash format: [ts, avg_ul, avg_dl, avg_a_ul, avg_a_dl, avg_lat, max_lat, avg_bl, avg_ulp, avg_dlp] (10 fields)
var isFlash = range !== '60m';
var rangeSec = { '60m': 3600, '6h': 21600, '24h': 86400, '7d': 604800, '30d': 2592000 };
var maxAge = rangeSec[range] || 3600;
var rawHistory = isFlash ? (ar.flash_history || []) : (ar.history || []);
// For flash views, replace overlapping flash data with full-resolution RAM data
// This gives the full 60min of RAM detail and avoids visual artifacts at the boundary
if (isFlash && ar.history && ar.history.length > 0) {
var oldestRamTs = ar.history[0][0];
// Keep only flash entries older than the RAM window
rawHistory = rawHistory.filter(function(h) { return h[0] < oldestRamTs; });
// Bridge: extend last flash value to RAM start for seamless visual transition
if (rawHistory.length > 0) {
var last = rawHistory[rawHistory.length - 1];
// Use a timestamp just before RAM start to avoid duplicate-x artifacts
var bridgeTs = oldestRamTs > last[0] ? (oldestRamTs - 1) : oldestRamTs;
rawHistory.push([bridgeTs, last[1], last[2], last[3], last[4],
last[5], last[6], last[7], last[8], last[9]]);
}
// Convert RAM [ts,ul,dl,a_ul,a_dl,lat,bl,ulp,dlp] to flash format
// by duplicating lat as max_lat: [ts,ul,dl,a_ul,a_dl,lat,lat,bl,ulp,dlp]
rawHistory = rawHistory.concat(ar.history.map(function(h) {
return [h[0], h[1], h[2], h[3], h[4], h[5], h[5], h[6], h[7], h[8]];
}));
}
// Ensure chronological order and collapse duplicate timestamps.
// Duplicate timestamps can produce crossing/looping visual artifacts in canvas line rendering.
if (rawHistory.length > 1) {
rawHistory.sort(function(a, b) { return (+a[0]) - (+b[0]); });
var normalized = [rawHistory[0]];
for (var ri = 1; ri < rawHistory.length; ri++) {
var prev = normalized[normalized.length - 1];
var curH = rawHistory[ri];
if ((+prev[0]) === (+curH[0])) normalized[normalized.length - 1] = curH;
else normalized.push(curH);
}
rawHistory = normalized;
}
// Filter data by requested time range
if (rawHistory.length > 0) {
var latestTs = rawHistory[rawHistory.length - 1][0];
var cutoff = latestTs - maxAge;
rawHistory = rawHistory.filter(function(h) { return h[0] >= cutoff; });
}
// Show info hint for long-range aggregation and incomplete history
var infoEl = document.querySelector('.autorate-data-info');
if (infoEl) {
var rangeNote = '';
var availabilityNote = '';
if (isFlash) {
rangeNote = _('For ranges above 60 minutes, recent data (last 60 min) is high-resolution, while older points are aggregated and less precise. "Latency (max)" highlights spikes that average latency may hide.');
}
if (isFlash && rawHistory.length < 2) {
availabilityNote = _('No hourly history available yet. Data is consolidated once per hour.');
} else if (isFlash && rawHistory.length > 0) {
var dataSpanHours = Math.round((rawHistory[rawHistory.length - 1][0] - rawHistory[0][0]) / 3600);
var requestedHours = Math.round(maxAge / 3600);
if (dataSpanHours < requestedHours * 0.9) {
availabilityNote = _('Showing %s of data (requested %s). History grows over time.')
.format(dataSpanHours <= 24 ? dataSpanHours + 'h' : Math.round(dataSpanHours / 24) + 'd',
requestedHours <= 24 ? requestedHours + 'h' : Math.round(requestedHours / 24) + 'd');
}
}
if (availabilityNote && rangeNote)
infoEl.textContent = availabilityNote + ' ' + rangeNote;
else
infoEl.textContent = availabilityNote || rangeNote;
if (infoEl.textContent)
infoEl.style.display = '';
else
infoEl.style.display = 'none';
}
// Update status panel
var panel = document.querySelector('.autorate-status-panel');
if (panel) {
var latMs = (cur.latency / 10).toFixed(1);
var blMs = (cur.baseline / 10).toFixed(1);
var ulMbps = (cur.ul_rate / 1000).toFixed(1);
var dlMbps = (cur.dl_rate / 1000).toFixed(1);
var baseUlMbps = (cfg.base_ul / 1000).toFixed(1);
var baseDlMbps = (cfg.base_dl / 1000).toFixed(1);
dom.content(panel, [
E('h3', { 'style': 'margin-bottom: 0.5em;' }, _('Autorate Status')),
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 1.5em; margin-left: 0.25em;' }, [
E('div', {}, [
E('span', { 'style': 'font-weight: bold;' }, _('Latency: ')),
E('span', {}, latMs + ' ms'),
E('span', { 'style': 'color: #888;' }, ' (' + _('Baseline') + ': ' + blMs + ' ms)')
]),
E('div', {}, [
E('span', { 'style': 'font-weight: bold;' }, _('Upload: ')),
E('span', {}, ulMbps + ' / ' + baseUlMbps + ' Mbit/s'),
E('span', { 'style': 'color: #888;' }, ' (' + (cur.ul_load_pct || 0) + '% ' + _('load') + ')')
]),
E('div', {}, [
E('span', { 'style': 'font-weight: bold;' }, _('Download: ')),
E('span', {}, dlMbps + ' / ' + baseDlMbps + ' Mbit/s'),
E('span', { 'style': 'color: #888;' }, ' (' + (cur.dl_load_pct || 0) + '% ' + _('load') + ')')
])
])
]);
}
if (rawHistory.length < 2) return;
// Extract time-series from history
// RAM: [ts, ul, dl, a_ul, a_dl, lat, bl, ulp, dlp]
// Flash: [ts, avg_ul, avg_dl, avg_a_ul, avg_a_dl, avg_lat, max_lat, avg_bl, avg_ulp, avg_dlp]
var latData = [], maxLatData = [], blData = [];
var ulRateData = [], ulAchData = [], dlRateData = [], dlAchData = [];
for (var i = 0; i < rawHistory.length; i++) {
var h = rawHistory[i];
var t = h[0];
if (isFlash) {
latData.push([t, h[5] / 10]); // avg latency
maxLatData.push([t, h[6] / 10]); // max latency (spikes)
blData.push([t, h[7] / 10]); // baseline at index 7 in flash
ulRateData.push([t, h[1]]);
ulAchData.push([t, h[3]]);
dlRateData.push([t, h[2]]);
dlAchData.push([t, h[4]]);
} else {
latData.push([t, h[5] / 10]); // tenths ms -> ms
blData.push([t, h[6] / 10]);
ulRateData.push([t, h[1]]);
ulAchData.push([t, h[3]]);
dlRateData.push([t, h[2]]);
dlAchData.push([t, h[4]]);
}
}
var decThrMs = cfg.decrease_threshold / 10;
var incThrMs = cfg.increase_threshold / 10;
var lastBl = blData.length > 0 ? blData[blData.length - 1][1] : 0;
// Latency legend items (add max latency entry for flash data)
var latLegendItems = [
{ label: _('Latency'), color: '#2196F3' },
{ label: _('Baseline'), color: '#4CAF50', dashed: true },
{ label: _('Dec. Threshold'), color: '#F44336', dashed: true },
{ label: _('Inc. Threshold'), color: '#FF9800', dashed: true }
];
if (isFlash) latLegendItems.splice(1, 0, { label: _('Latency (max)'), color: 'rgba(244, 67, 54, 0.5)' });
// Bandwidth legend items (shared for UL and DL)
var bwLegendItems = [
{ label: _('Rate Limit'), color: '#2196F3' },
{ label: _('Throughput'), color: '#4CAF50' },
{ label: _('Base Rate'), color: '#9E9E9E', dashed: true },
{ label: _('Max'), color: '#F44336', dashed: true },
{ label: _('Min'), color: '#FF9800', dashed: true }
];
// Populate DOM legends
var latLegend = document.querySelector('.autorate-latency-legend');
if (latLegend) this.buildChartLegend(latLegend, latLegendItems);
var ulLegend = document.querySelector('.autorate-ul-legend');
if (ulLegend) this.buildChartLegend(ulLegend, bwLegendItems);
var dlLegend = document.querySelector('.autorate-dl-legend');
if (dlLegend) this.buildChartLegend(dlLegend, bwLegendItems);
// Draw latency chart (include max_lat spike series for flash data)
var latCanvas = document.querySelector('.autorate-latency-canvas');
if (latCanvas && latCanvas.clientWidth > 0) {
var latSeries = [
{ data: latData, color: '#2196F3', width: 1.5 },
{ data: blData, color: '#4CAF50', dashed: true, width: 1 }
];
if (isFlash && maxLatData.length > 0)
latSeries.splice(0, 0, { data: maxLatData, color: 'rgba(244, 67, 54, 0.5)', width: 1, fill: 'rgba(244, 67, 54, 0.08)' });
TimeSeriesChart.draw(latCanvas, {
series: latSeries,
hLines: [
{ value: lastBl + decThrMs, color: '#F44336', dashed: true, width: 1 },
{ value: lastBl + incThrMs, color: '#FF9800', dashed: true, width: 1 }
],
yFmt: function(v) { return v.toFixed(1) + ' ms'; }
});
}
// Draw upload bandwidth chart
var ulCanvas = document.querySelector('.autorate-ul-canvas');
if (ulCanvas && ulCanvas.clientWidth > 0) {
TimeSeriesChart.draw(ulCanvas, {
series: [
{ data: ulAchData, color: '#4CAF50', fill: 'rgba(76, 175, 80, 0.15)', width: 1 },
{ data: ulRateData, color: '#2196F3', width: 2 }
],
hLines: [
{ value: cfg.base_ul, color: '#9E9E9E', dashed: true, width: 1 },
{ value: cfg.max_ul, color: '#F44336', dashed: true, width: 0.5 },
{ value: cfg.min_ul, color: '#FF9800', dashed: true, width: 0.5 }
],
yFmt: function(v) { return v >= 1000 ? (v / 1000).toFixed(1) + ' Mbit/s' : v + ' kbit/s'; }
});
}
// Draw download bandwidth chart
var dlCanvas = document.querySelector('.autorate-dl-canvas');
if (dlCanvas && dlCanvas.clientWidth > 0) {
TimeSeriesChart.draw(dlCanvas, {
series: [
{ data: dlAchData, color: '#4CAF50', fill: 'rgba(76, 175, 80, 0.15)', width: 1 },
{ data: dlRateData, color: '#2196F3', width: 2 }
],
hLines: [
{ value: cfg.base_dl, color: '#9E9E9E', dashed: true, width: 1 },
{ value: cfg.max_dl, color: '#F44336', dashed: true, width: 0.5 },
{ value: cfg.min_dl, color: '#FF9800', dashed: true, width: 0.5 }
],
yFmt: function(v) { return v >= 1000 ? (v / 1000).toFixed(1) + ' Mbit/s' : v + ' kbit/s'; }
});
}
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});