Merge pull request #2378 from unraid/feat/smoothie-charts

feat: swap to smoothiecharts
This commit is contained in:
tom mortensen
2025-09-17 15:26:33 -07:00
committed by GitHub
2 changed files with 1418 additions and 211 deletions

View File

@@ -196,7 +196,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout
?>
<link type="text/css" rel="stylesheet" href="<?autov('/webGui/styles/jquery.switchbutton.css')?>">
<script src="<?autov('/webGui/javascript/jquery.apexcharts.js')?>"></script>
<script src="<?autov('/webGui/javascript/smoothie.js')?>"></script>
<script src="<?autov('/webGui/javascript/jquery.switchbutton.js')?>"></script>
<script src="<?autov('/plugins/dynamix.docker.manager/javascript/docker.js')?>"></script>
<script src="<?autov('/plugins/dynamix.vm.manager/javascript/vmmanager.js')?>"></script>
@@ -412,7 +412,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout
?>
<tr id='cpu_chart'>
<td>
<div id='cpuchart'></div>
<canvas id='cpuchart' style='width:100%; height:120px'></canvas>
</td>
</tr>
</tbody>
@@ -505,6 +505,12 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout
<?=mk_option("",$port,_($port))?>
<?endforeach;?>
</select>
<select name="enter_view" style="min-width: 140px" onchange="changeView(this.value)">
<?=mk_option("", "0", _("General info"))?>
<?=mk_option("", "1", _("Counters info"))?>
<?=mk_option("", "2", _("Errors info"))?>
<?=mk_option("", "3", _("Network traffic"))?>
</select>
</span><br>
</span>
<span class='flex flex-row flex-wrap items-center gap-4'>
@@ -531,12 +537,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout
<tr>
<td>
<span class='w26'>
<select name="enter_view" onchange="changeView(this.value)">
<?=mk_option("", "0", _("General info"))?>
<?=mk_option("", "1", _("Counters info"))?>
<?=mk_option("", "2", _("Errors info"))?>
<?=mk_option("", "3", _("Network traffic"))?>
</select>
&nbsp;
</span>
<span class='w36'>
<i class='view1'>_(Mode of operation)_</i>
@@ -584,7 +585,7 @@ switch ($themeHelper->getThemeName()) { // $themeHelper set in DefaultPageLayout
?>
<tr class="view4">
<td>
<div id="netchart"></div>
<canvas id="netchart" style='width:100%; height:120px'></canvas>
</td>
</tr>
</tbody>
@@ -1427,8 +1428,8 @@ var rxd = [];
var txd = [];
var cputime = 0;
var nettime = 0;
var cpuline = cookie.cpuline||30;
var netline = cookie.netline||30;
var cpuline = parseInt(cookie.cpuline)||30;
var netline = parseInt(cookie.netline)||30;
var update2 = true;
var box = null;
var startup = true;
@@ -1436,56 +1437,92 @@ var stopgap = '<thead class="stopgap"><tr><td class="stopgap"></td></tr></thead>
var recall = null;
var recover = null;
var options_cpu = {
series:[{name:'load', data:cpu.slice()}],
chart:{height:120, type:'line', fontFamily:'clear-sans', animations:{enabled:true, easing:'linear', dynamicAnimation:{speed:980}}, toolbar:{show:false}, zoom:{enabled:false},events:{updated:function(){if (cpuchart.customData.updateCount == 0) {cpuchart.customData.animationPending = false}cpuchart.customData.updateCount++;},animationEnd:function(){cpuchart.customData.animationPending = false;updateCPUChart();}}},
dataLabels:{enabled:false},
tooltip:{enabled:false},
stroke:{curve:'smooth', width:1},
colors:['#ff8c2f'],
markers:{size:0},
xaxis:{type:'datetime', range:cpuline-1, labels:{show:false}, axisTicks:{show:false}, axisBorder:{show:false}},
yaxis:{max:100, min:0, tickAmount:4, labels:{formatter:function(v,i){return v.toFixed(0)+' %';}, style:{colors:'<?=$color?>'}}, axisBorder:{show:false}, axisTicks:{show:false}},
grid:{show:true, borderColor:'<?=$grid?>'},
legend:{show:false}
};
var options_net = {
series:[{name:'receive', data:rxd.slice()},{name:'transmit', data:txd.slice()}],
chart:{height:120, type:'line', fontFamily:'clear-sans', animations:{enabled:true, easing:'linear', dynamicAnimation:{speed:980}}, toolbar:{show:false}, zoom:{enabled:false},events:{updated:function(){if (netchart.customData.updateCount == 0) {netchart.customData.animationPending = false}netchart.customData.updateCount++;},animationEnd:function(){netchart.customData.animationPending = false;updateNetChart();}}},
dataLabels:{enabled:false},
tooltip:{enabled:false},
stroke:{curve:'smooth', width:1},
colors:['#e22828','#ff8c2f'],
markers:{size:0},
xaxis:{type:'datetime', range:netline-1, labels:{show:false}, axisTicks:{show:false}, axisBorder:{show:false}},
yaxis:{tickAmount:4, labels:{formatter:function(v,i){return autoscale(v,'bps',1);}, style:{colors:'<?=$color?>'}}, axisBorder:{show:false}, axisTicks:{show:false}},
grid:{show:true, borderColor:'<?=$grid?>'},
legend:{show:false}
};
// Helper function to calculate millisPerPixel based on container width
function getMillisPerPixel(timeInSeconds, containerId) {
var container = document.getElementById(containerId);
var width = (container && container.offsetWidth > 0) ? container.offsetWidth : 600; // fallback to 600 if not found or zero width
return (timeInSeconds * 1000) / width;
}
var cpuchart = new ApexCharts(document.querySelector('#cpuchart'), options_cpu);
var netchart = new ApexCharts(document.querySelector('#netchart'), options_net);
// SmoothieCharts initialization
var cpuchart = new SmoothieChart({
millisPerPixel: (cpuline * 1000) / 600, // Safe initial value
minValue: -0.5,
maxValue: 100,
responsive: true,
grid: {
strokeStyle: '<?=$grid?>',
fillStyle: 'transparent',
lineWidth: 1,
millisPerLine: 5000,
verticalSections: 4
},
labels: {
fillStyle: '<?=$color?>',
fontSize: 11,
precision: 0
},
timestampFormatter: function(date) { return ''; },
yRangeFunction: function(range) {
return { min: -0.5, max: 100 };
},
yMinFormatter: function(value) {
return Math.max(0, Math.floor(value));
},
yMaxFormatter: function(value) {
return Math.floor(value);
}
});
// Add custom global variable to ncharts (won't affect ApexCharts functionality)
var netchart = new SmoothieChart({
millisPerPixel: (netline * 1000) / 600, // Safe initial value
minValue: 0,
responsive: true,
grid: {
strokeStyle: '<?=$grid?>',
fillStyle: 'transparent',
lineWidth: 1,
millisPerLine: 5000,
verticalSections: 4
},
labels: {
fillStyle: '<?=$color?>',
fontSize: 11,
placement: 'left'
},
timestampFormatter: function(date) { return ''; },
maxValueScale: 1.0,
yMaxFormatter: function(value) {
if (value >= 1000) {
return Math.floor(value / 1000) + ' Mbps';
}
return Math.floor(value) + ' kbps';
},
yMinFormatter: function(value) {
return Math.floor(value) + ' kbps';
}
});
// Create TimeSeries for CPU and Network data
var cpuTimeSeries = new TimeSeries();
var rxTimeSeries = new TimeSeries();
var txTimeSeries = new TimeSeries();
// Add TimeSeries to charts with colors
cpuchart.addTimeSeries(cpuTimeSeries, { strokeStyle: '#ff8c2f', lineWidth: 1 });
netchart.addTimeSeries(rxTimeSeries, { strokeStyle: '#e22828', lineWidth: 1 });
netchart.addTimeSeries(txTimeSeries, { strokeStyle: '#ff8c2f', lineWidth: 1 });
// Custom data for chart state management
netchart.customData = {
isVisible: false,
BrowserVisibility: true,
animationPending: false,
netAnimationInterval: null,
newData: false,
updateCount: 0,
initialized: false
isVisible: false
};
cpuchart.customData = {
isVisible: false,
BrowserVisibility: true,
animationPending: false,
cpuAnimationInterval: null,
coresVisible: false,
newData: false,
updateCount: 0,
initialized: false
cpuData: null,
cpuLoad: 0
};
$(function() {
@@ -1495,9 +1532,9 @@ $(function() {
entries.forEach(entry => {
if (entry.target === netchartElement) {
netchart.customData.isVisible = entry.isIntersecting;
// Reset the update count as the chart doesn't always fire animationEnd in this case
// Chart visibility changed
if (netchart.customData.isVisible) {
resetNetUpdateCount();
// SmoothieCharts handles visibility automatically
}
}
});
@@ -1520,9 +1557,9 @@ $(function() {
entries.forEach(entry => {
if (entry.target === cpuchartElement) {
cpuchart.customData.isVisible = entry.isIntersecting;
// Reset the update count as the chart doesn't always fire animationEnd in this case
// Chart visibility changed
if (cpuchart.customData.isVisible) {
resetCPUUpdateCount();
// SmoothieCharts handles visibility automatically
}
}
});
@@ -1588,8 +1625,9 @@ let viewportResizeTimeout;
window.addEventListener('resize', function(){
clearTimeout(viewportResizeTimeout);
viewportResizeTimeout = setTimeout(function(){
resetNetUpdateCount();
resetCPUUpdateCount();
// Update millisPerPixel based on new container sizes
cpuchart.options.millisPerPixel = getMillisPerPixel(cpuline, 'cpuchart');
netchart.options.millisPerPixel = getMillisPerPixel(netline, 'netchart');
}, 250); // Wait 250ms after resize stops before executing
});
@@ -1627,80 +1665,45 @@ function sanitizeMultiCookie(cookieName, delimiter, removeDuplicates=false) {
}
function initCharts(clear) {
// initialize graphs entries
var data = [];
data.cpu = data.rxd = data.txd = "";
var now = new Date().getTime();
if (!clear) {
var c = data.cpu.split(';');
var r = data.rxd.split(';');
var t = data.txd.split(';');
for (var i=0; i < cpuline; i++) {
var x = now + i;
var y = c[i]||0; cpu.push({x,y});
}
cputime = x + 1;
} else {
// clear network graph
var r = ''; var t = '';
rxd = []; txd = [];
// SmoothieCharts manages its own data through TimeSeries
if (clear) {
// Clear the TimeSeries data if needed
rxTimeSeries.clear();
txTimeSeries.clear();
}
for (var i=0; i < netline; i++) {
var x = now + i;
var y = r[i]||0; rxd.push({x,y});
var y = t[i]||0; txd.push({x,y});
}
nettime = x + 1;
}
function resetCharts() {
// prevent unlimited graph growing - limit to 300 (5 minutes) of data
cpu = cpu.slice(-300);
rxd = rxd.slice(-300);
txd = txd.slice(-300);
// SmoothieCharts automatically manages data retention
// No manual cleanup needed
}
function addChartCpu(load) {
cputime++;
var i = cpu.length - cpuline;
if (i > 0) { // clear value outside graph
i = i - 1;
cpu[i].x = cputime - cpuline;
cpu[i].y = 0;
}
cpu.push({x:cputime, y:load});
// Add data point to SmoothieCharts TimeSeries
cpuTimeSeries.append(new Date().getTime(), load);
}
function addChartNet(rx, tx) {
nettime++;
var i = rxd.length - netline;
if (i > 0) { // clear value outside graph
i = i - 1;
rxd[i].x = nettime - netline;
rxd[i].y = 0;
txd[i].x = nettime - netline;
txd[i].y = 0;
}
rxd.push({x:nettime, y:rx});
txd.push({x:nettime, y:tx});
// Add data points to SmoothieCharts TimeSeries (convert bps to kbps)
var now = new Date().getTime();
rxTimeSeries.append(now, Math.floor(rx / 1000));
txTimeSeries.append(now, Math.floor(tx / 1000));
}
function resetCPUUpdateCount() {
cpuchart.customData.updateCount = 0;
cpuchart.customData.animationPending = false;
}
// Cache for last values to avoid unnecessary DOM updates
var lastCpuValues = {
load: -1,
color: '',
fontColor: '',
coreValues: {}
};
function resetNetUpdateCount() {
netchart.customData.updateCount = 0;
netchart.customData.animationPending = false;
}
function updateCPUBarCharts() {
if (!isPageVisible()) {
return;
}
// prevent an initial JS error if the first datapoint isn't available yet
// (cpuchart.customData.newData is reset by the updateCPUChart function so can't be used)
// Update CPU bar charts based on current data
const customData = cpuchart.customData;
if (!customData.cpuData?.cpus || typeof customData.cpuLoad === 'undefined') {
return;
@@ -1709,90 +1712,91 @@ function updateCPUBarCharts() {
const cpuLoad = customData.cpuLoad;
const critical = <?=$display['critical']?>;
const warning = <?=$display['warning']?>;
// Cache DOM elements and calculations for overall CPU load
const cpuLoadText = cpuLoad + '%';
const cpuLoadColor = setColor(cpuLoad, critical, warning);
const cpuLoadFontColor = fontColor(cpuLoad, critical, warning);
// Batch DOM updates for overall CPU load
const $cpuElements = $('.cpu_, .cpu');
const $cpuAliveElements = $('#cpu_, #cpu');
$cpuElements.text(cpuLoadText).css({'color': cpuLoadFontColor});
$cpuAliveElements.alive(cpuLoadText, cpuLoadColor);
// Only update DOM if values have changed
if (cpuLoad !== lastCpuValues.load) {
const cpuLoadText = cpuLoad + '%';
const cpuLoadColor = setColor(cpuLoad, critical, warning);
const cpuLoadFontColor = fontColor(cpuLoad, critical, warning);
// Batch DOM updates for overall CPU load
const $cpuElements = $('.cpu_, .cpu');
const $cpuAliveElements = $('#cpu_, #cpu');
$cpuElements.text(cpuLoadText).css({'color': cpuLoadFontColor});
// Only call alive() if color actually changed
if (cpuLoadColor !== lastCpuValues.color) {
$cpuAliveElements.alive(cpuLoadText, cpuLoadColor);
lastCpuValues.color = cpuLoadColor;
}
lastCpuValues.load = cpuLoad;
lastCpuValues.fontColor = cpuLoadFontColor;
}
// Update individual CPU cores if they are visible
if (customData.coresVisible) {
const cpus = customData.cpuData.cpus;
// Batch DOM updates for CPU cores
const cpuCoreUpdates = [];
const cpuAliveUpdates = [];
cpus.forEach((cpuCore, index) => {
const coreLoad = Math.round(cpuCore.percentTotal);
const coreLoadText = coreLoad + '%';
const coreColor = setColor(coreLoad, critical, warning);
const coreFontColor = fontColor(coreLoad, critical, warning);
cpuCoreUpdates.push({
selector: '.cpu' + index,
text: coreLoadText,
color: coreFontColor
const coreLoad = Math.floor(cpuCore.percentTotal);
// Only process if value changed
if (!lastCpuValues.coreValues[index] || lastCpuValues.coreValues[index].load !== coreLoad) {
const coreLoadText = coreLoad + '%';
const coreColor = setColor(coreLoad, critical, warning);
const coreFontColor = fontColor(coreLoad, critical, warning);
cpuCoreUpdates.push({
selector: '.cpu' + index,
text: coreLoadText,
color: coreFontColor
});
// Only update alive() if color changed
const lastCore = lastCpuValues.coreValues[index] || {};
if (coreColor !== lastCore.color) {
cpuAliveUpdates.push({
selector: '#cpu' + index,
text: coreLoadText,
color: coreColor
});
lastCore.color = coreColor;
}
// Update cache
lastCpuValues.coreValues[index] = {
load: coreLoad,
color: coreColor,
fontColor: coreFontColor
};
}
});
// Apply batch updates only for changed values
if (cpuCoreUpdates.length > 0) {
cpuCoreUpdates.forEach(update => {
$(update.selector).text(update.text).css({'color': update.color});
});
cpuAliveUpdates.push({
selector: '#cpu' + index,
text: coreLoadText,
color: coreColor
}
if (cpuAliveUpdates.length > 0) {
cpuAliveUpdates.forEach(update => {
$(update.selector).alive(update.text, update.color);
});
});
// Apply all CPU core updates in batches
cpuCoreUpdates.forEach(update => {
$(update.selector).text(update.text).css({'color': update.color});
});
cpuAliveUpdates.forEach(update => {
$(update.selector).alive(update.text, update.color);
});
}
}
}
function updateCPUChart() {
if (!cpuchart.customData.newData || !cpuchart.customData.isVisible || !isPageVisible()) {
return;
}
// Check if the animation is still running and a timeout hasn't been set
if (!cpuchart.customData.animationPending) {
// No animation running. Clear out the timeout and update the chart
cpuchart.customData.animationPending = true;
cpuchart.customData.newData = false;
cpuchart.updateSeries([{data:cpu}]);
}
}
function updateNetChart() {
if (!netchart.customData.newData || !netchart.customData.isVisible || !isPageVisible()) {
return;
}
// Check if the animation is still running and a timeout hasn't been set
if (!netchart.customData.animationPending) {
// No animation running. Clear out the timeout and update the chart
netchart.customData.animationPending = true;
netchart.customData.newData = false;
netchart.updateSeries([{data:rxd},{data:txd}]);
}
}
function isPageVisible() {
// Check if charts are good to go
if (netchart.customData.initialized && cpuchart.customData.initialized) {
return !document.hidden;
} else {
return false;
}
// SmoothieCharts handles visibility automatically
return !document.hidden;
}
<?if ($wireguard):?>
@@ -1905,21 +1909,23 @@ function changeView(item) {
}
function changeCPUline(val) {
cpuline = val;
cpuline = parseInt(val);
if (val==30) delete cookie.cpuline; else cookie.cpuline = val;
saveCookie();
// Reset the update count as the chart doesn't always fire animationEnd
resetCPUUpdateCount();
cpuchart.updateOptions({xaxis:{range:cpuline-1}});
// Update chart range
// Update SmoothieChart time window based on actual chart width
cpuchart.options.millisPerPixel = getMillisPerPixel(cpuline, 'cpuchart');
// No need to stop and restart, just update the option
}
function changeNetline(val) {
netline = val;
netline = parseInt(val);
if (val==30) delete cookie.netline; else cookie.netline = val;
saveCookie();
// Reset the update count as the chart doesn't always fire animationEnd
resetNetUpdateCount();
netchart.updateOptions({xaxis:{range:netline-1}});
// Update chart range
// Update SmoothieChart time window based on actual chart width
netchart.options.millisPerPixel = getMillisPerPixel(netline, 'netchart');
// No need to stop and restart, just update the option
}
function smartMenu(table) {
@@ -2585,20 +2591,41 @@ dashboard.on('message',function(msg,meta) {
// rx & tx speeds
for (let i=0,port; port=get.port[i]; i++) {
if (port[0] == port_select) {
$('#inbound').text(port[1]);
$('#outbound').text(port[2]);
// Cache for network values to avoid unnecessary DOM updates
if (!window.lastNetValues) window.lastNetValues = { inbound: '', outbound: '' };
// Only update DOM if values changed
if (port[1] !== window.lastNetValues.inbound) {
$('#inbound').text(port[1]);
window.lastNetValues.inbound = port[1];
}
if (port[2] !== window.lastNetValues.outbound) {
$('#outbound').text(port[2]);
window.lastNetValues.outbound = port[2];
}
// update the netchart but only send to ApexCharts if the chart is visible
addChartNet(port[3], port[4]);
netchart.customData.newData = true;
updateNetChart();
addChartNet(port[3], port[4]);
break;
}
}
// port counters
for (let k=0; k < get.mode.length; k++) $('#main'+k).html(get.mode[k]);
for (let k=0; k < get.rxtx.length; k++) $('#port'+k).html(get.rxtx[k]);
for (let k=0; k < get.stat.length; k++) $('#link'+k).html(get.stat[k]);
// port counters - batch check for changes
for (let k=0; k < get.mode.length; k++) {
const $elem = $('#main'+k);
const newVal = get.mode[k];
if ($elem.html() !== newVal) $elem.html(newVal);
}
for (let k=0; k < get.rxtx.length; k++) {
const $elem = $('#port'+k);
const newVal = get.rxtx[k];
if ($elem.html() !== newVal) $elem.html(newVal);
}
for (let k=0; k < get.stat.length; k++) {
const $elem = $('#link'+k);
const newVal = get.stat[k];
if ($elem.html() !== newVal) $elem.html(newVal);
}
// current date and time
$('#current_time').html(get.time[0]);
$('#current_time_').html(get.time[0]);
@@ -2653,8 +2680,15 @@ $(function() {
initCharts();
cpuchart.render();
netchart.render();
// Start SmoothieCharts streaming
cpuchart.streamTo(document.getElementById('cpuchart'), 1000);
netchart.streamTo(document.getElementById('netchart'), 1000);
// Set millisPerPixel after DOM is ready and charts are attached
setTimeout(function() {
cpuchart.options.millisPerPixel = getMillisPerPixel(cpuline, 'cpuchart');
netchart.options.millisPerPixel = getMillisPerPixel(netline, 'netchart');
}, 100);
addProperties();
<?if ($group):?>
@@ -2686,13 +2720,11 @@ $(function() {
next: (result) => {
if (result.data?.systemMetricsCpu) {
cpuchart.customData.cpuData = result.data.systemMetricsCpu;
cpuchart.customData.cpuLoad = Math.round(cpuchart.customData.cpuData.percentTotal);
cpuchart.customData.cpuLoad = Math.floor(cpuchart.customData.cpuData.percentTotal);
//update cpu chart data
addChartCpu(cpuchart.customData.cpuLoad);
cpuchart.customData.newData = true;
//update cpu chart data
addChartCpu(cpuchart.customData.cpuLoad);
updateCPUBarCharts();
updateCPUChart();
}
},
@@ -2731,8 +2763,7 @@ $(function() {
// Inhibit chart updates until DOM quiets down
setTimeout(function() {
netchart.customData.initialized = true;
cpuchart.customData.initialized = true;
// Charts initialized
},500);
// Cleanup GraphQL subscription on page unload

File diff suppressed because it is too large Load Diff