diff --git a/css/style.css b/css/style.css index 2d4f4f0a..b3603e7d 100644 --- a/css/style.css +++ b/css/style.css @@ -9,7 +9,6 @@ .monitoring-wrapper { display: -webkit-flex; display: flex; - position: left; max-width: 50%; min-width: 50%; } @@ -22,16 +21,16 @@ margin-top: 0; } -.rambox { +.rambox, .cpubox { height: 10px; width: 10px; - background-color: #555; + background-color: var(--color-primary-element); } .swapbox { height: 10px; width: 10px; - background-color: #222; + background-color: var(--color-background-darker); } .info { @@ -53,11 +52,6 @@ border-radius: var(--border-radius-large); } -.infobox.cpuSection, -.infobox.memorySection { - padding: 16px 0 0 0; -} - .diskchart-container { width: 100px; margin-right: 25px; @@ -80,14 +74,24 @@ background-color: var(--color-primary-element); } -@media only screen and (max-width: $breakpoint-mobile) { +@media (width <= 1280px) { .infobox { display: block; + word-break: break-word; + } + + .text-center-mobile { text-align: center; } + .diskchart-container { margin: 0 auto; } + + .monitoring-wrapper { + min-width: 100%; + max-width: 100%; + } } .diskinfo-container { @@ -102,7 +106,7 @@ .infoicon { width: 28px; - float: left; + vertical-align: middle; padding: 5px; filter: var(--background-invert-if-dark); } @@ -211,7 +215,7 @@ min-width: 100%; } -/* + @media only screen and (max-width: 1200px) { .col-xl-6 { flex-basis: 50%; @@ -234,7 +238,7 @@ } } -@media only screen and (max-width: $breakpoint-mobile) { +@media only screen and (max-width: 768px) { .col-m-6 { flex-basis: 50%; max-width: 50%; @@ -247,7 +251,7 @@ min-width: 100%; } } -*/ + .monitoring-url-params { margin-top: 3px; @@ -256,8 +260,14 @@ .monitoring-url-param { display: flex; - align-items: center; - height: 24px; + align-items: start; + margin-bottom: 3px; +} + +.monitoring-url-param input { + margin-top: 4px; + min-height: initial; + height: initial; } .active-users-wrapper { @@ -291,3 +301,18 @@ .active-users-box .info { font-size: 2rem; } + +.smoothie-chart-tooltip { + display: flex; + flex-wrap: nowrap; + align-items: center; + padding: 16px; + margin: 0 0.25rem; + background-color: var(--color-main-background); + border: 2px solid var(--color-border); + border-radius: var(--border-radius-large); +} + +.cpu-wrapper { + width: 100%; +} diff --git a/js/script.js b/js/script.js index aa88be2a..6e3f91d3 100644 --- a/js/script.js +++ b/js/script.js @@ -9,17 +9,21 @@ memoryUsageLine, swapUsageLine, cpuLoadChart, - cpuLoadLine, - activeUsersChart, - sharesChart; - - $(document).ready(function () { - var rambox = document.getElementById('rambox'); - rambox.style.backgroundColor = OCA.Theming ? OCA.Theming.color : 'rgb(54, 129, 195)'; - - var swapbox = document.getElementById('swapbox'); - swapbox.style.backgroundColor = 'rgba(100, 100, 100, 0.8)'; + cpuLoadLine + + const chartOptions = { + millisPerPixel: 100, + minValue: 0, + grid: {fillStyle: 'rgba(0,0,0,0)', strokeStyle: 'transparent'}, + labels: {fillStyle: getThemedPassiveColor(), fontSize: 12, precision: 1}, + responsive: true, + tooltip: true, + tooltipLine: { + strokeStyle: getThemedPassiveColor() + } + }; + $(function () { initDiskCharts(); setHumanReadableSizeToElement("databaseSize"); @@ -37,7 +41,7 @@ $.get(url) .done(function (response) { - updateCPUStatistics(response.system.cpuload) + updateCPUStatistics(response.system.cpuload, response.system.cpunum) updateMemoryStatistics(response.system.mem_total, response.system.mem_free, response.system.swap_total, response.system.swap_free) }) .always(function () { @@ -63,10 +67,10 @@ * Reset all canvas widths on window resize so canvas is responsive */ function resizeSystemCharts() { - var cpuCanvas = $("#cpuloadcanvas"), - cpuCanvasWidth = cpuCanvas.parents('.infobox').width() - 30, + let cpuCanvas = $("#cpuloadcanvas"), + cpuCanvasWidth = cpuCanvas.parents('.infobox').width(), memCanvas = $("#memorycanvas"), - memCanvasWidth = memCanvas.parents('.infobox').width() - 30; + memCanvasWidth = memCanvas.parents('.infobox').width(); // We have to set css width AND attribute width @@ -76,43 +80,60 @@ memCanvas.attr('width', memCanvasWidth); } - function updateCPUStatistics(cpuload) { - var $cpuFooterInfo = $('#cpuFooterInfo'); - var $cpuLoadCanvas = $('#cpuloadcanvas'); + function updateCPUStatistics(cpuload, numCpus) { + let $cpuFooterInfo = $('#cpuFooterInfo'); + let $cpuLoadCanvas = $('#cpuloadcanvas'); + + // We need to stop touch events here, since they cause the tooltip to open, but never close again + $cpuLoadCanvas[0].addEventListener('touchstart', (e) => { + e.preventDefault(); + }) - if (cpuload === 'N/A') { + if (cpuload === 'N/A' || numCpus === -1) { $cpuFooterInfo.text(t('serverinfo', 'CPU info not available')); $cpuLoadCanvas.addClass('hidden'); return; - } else if ($cpuLoadCanvas.hasClass('hidden')) { $cpuLoadCanvas.removeClass('hidden'); } - var cpu1 = cpuload[0], - cpu2 = cpuload[1], - cpu3 = cpuload[2]; + let cpuloadFixed = cpuload.map((load) => load.toFixed(2)); + let cpuloadPercentageFixed = cpuload.map((load) => ((load / numCpus) * 100).toFixed(1)); if (typeof cpuLoadChart === 'undefined') { - cpuLoadChart = new SmoothieChart( - { - millisPerPixel: 100, - minValue: 0, - grid: {fillStyle: 'rgba(0,0,0,0)', strokeStyle: 'transparent'}, - labels: {fillStyle: 'rgba(0,0,0,0.4)', fontSize: 12}, - responsive: true - }); + const percentageFormatter = (val, precision) => val.toFixed(precision) + " %"; + + cpuLoadChart = new SmoothieChart({ + ...chartOptions, + yMinFormatter: percentageFormatter, + yMaxFormatter: percentageFormatter, + maxValue: 100 + }); cpuLoadChart.streamTo(document.getElementById("cpuloadcanvas"), 1000/*delay*/); cpuLoadLine = new TimeSeries(); cpuLoadChart.addTimeSeries(cpuLoadLine, { lineWidth: 1, strokeStyle: getThemedPassiveColor(), - fillStyle: getThemedPrimaryColor() + fillStyle: getThemedPrimaryColor(), + tooltipLabel: t('serverinfo', 'CPU Usage:') }); } - $cpuFooterInfo.text(t('serverinfo', 'Load average: {cpu} (last minute)', { cpu: cpu1.toFixed(2) })); - cpuLoadLine.append(new Date().getTime(), cpu1); + $cpuFooterInfo.text(t('serverinfo', 'Load average: {percentage} % ({load}) last minute', { percentage: cpuloadPercentageFixed[0], load: cpuloadFixed[0] })); + $cpuFooterInfo[0].title = t( + 'serverinfo', + '{lastMinutePercentage} % ({lastMinute}) last Minute\n{last5MinutesPercentage} % ({last5Minutes}) last 5 Minutes\n{last15MinutesPercentage} % ({last15Minutes}) last 15 Minutes', + { + lastMinute: cpuloadFixed[0], + lastMinutePercentage: cpuloadPercentageFixed[0], + last5Minutes: cpuloadFixed[1], + last5MinutesPercentage: cpuloadPercentageFixed[1], + last15Minutes: cpuloadFixed[2], + last15MinutesPercentage: cpuloadPercentageFixed[2] + } + ); + + cpuLoadLine.append(new Date().getTime(), cpuload[0] / numCpus * 100); } function isMemoryStat(memTotal, memFree) { @@ -136,6 +157,11 @@ var $swapFooterInfo = $('#swapFooterInfo'); var $memoryCanvas = $('#memorycanvas'); + // We need to stop touch events here, since they cause the tooltip to open, but never close again + $memoryCanvas[0].addEventListener('touchstart', (e) => { + e.preventDefault(); + }) + var memTotalBytes = memTotal * 1024, memUsageBytes = (memTotal - memFree) * 1024, memTotalGB = memTotal / (1024 * 1024), @@ -152,27 +178,29 @@ } if (typeof memoryUsageChart === 'undefined') { + const gbFormatter = (val, precision) => val.toFixed(precision) + " GB"; + memoryUsageChart = new SmoothieChart( { - millisPerPixel: 100, + ...chartOptions, maxValue: maxValueOfChart, - minValue: 0, - grid: {fillStyle: 'rgba(0,0,0,0)', strokeStyle: 'transparent'}, - labels: {fillStyle: 'rgba(0,0,0,0.4)', fontSize: 12}, - responsive: true + yMinFormatter: gbFormatter, + yMaxFormatter: gbFormatter }); memoryUsageChart.streamTo(document.getElementById("memorycanvas"), 1000/*delay*/); memoryUsageLine = new TimeSeries(); memoryUsageChart.addTimeSeries(memoryUsageLine, { lineWidth: 1, strokeStyle: getThemedPassiveColor(), - fillStyle: getThemedPrimaryColor() + fillStyle: getThemedPrimaryColor(), + tooltipLabel: t('serverinfo', 'RAM Usage:') }); swapUsageLine = new TimeSeries(); memoryUsageChart.addTimeSeries(swapUsageLine, { lineWidth: 1, - strokeStyle: 'rgb(100, 100, 100)', - fillStyle: 'rgba(100, 100, 100, 0.2)' + strokeStyle: getThemedPassiveColor(), + fillStyle: 'rgba(100, 100, 100, 0.2)', + tooltipLabel: t('serverinfo', 'SWAP Usage:') }); } diff --git a/js/smoothie.js b/js/smoothie.js index 27f5151b..c1cdfa55 100644 --- a/js/smoothie.js +++ b/js/smoothie.js @@ -1,7 +1,7 @@ // MIT License: // // Copyright (c) 2010-2013, Joe Walnes -// 2013-2014, Drew Noakes +// 2013-2018, Drew Noakes // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -24,7 +24,7 @@ /** * Smoothie Charts - http://smoothiecharts.org/ * (c) 2010-2013, Joe Walnes - * 2013-2014, Drew Noakes + * 2013-2018, Drew Noakes * * v1.0: Main charting library, by Joe Walnes * v1.1: Auto scaling of axis, by Neil Dunn @@ -73,9 +73,43 @@ * v1.26: Add support for resizing on high device pixel ratio screens, by @copacetic * v1.27: Fix bug introduced in v1.26 for non whole number devicePixelRatio values, by @zmbush * v1.28: Add 'minValueScale' option, by @megawac + * Fix 'labelPos' for different size of 'minValueString' 'maxValueString', by @henryn + * v1.29: Support responsive sizing, by @drewnoakes + * v1.29.1: Include types in package, and make property optional, by @TrentHouliston + * v1.30: Fix inverted logic in devicePixelRatio support, by @scanlime + * v1.31: Support tooltips, by @Sly1024 and @drewnoakes + * v1.32: Support frame rate limit, by @dpuyosa + * v1.33: Use Date static method instead of instance, by @nnnoel + * Fix bug with tooltips when multiple charts on a page, by @jpmbiz70 + * v1.34: Add disabled option to TimeSeries, by @TechGuard (#91) + * Add nonRealtimeData option, by @annazhelt (#92, #93) + * Add showIntermediateLabels option, by @annazhelt (#94) + * Add displayDataFromPercentile option, by @annazhelt (#95) + * Fix bug when hiding tooltip element, by @ralphwetzel (#96) + * Support intermediate y-axis labels, by @beikeland (#99) + * v1.35: Fix issue with responsive mode at high DPI, by @drewnoakes (#101) + * v1.36: Add tooltipLabel to ITimeSeriesPresentationOptions. + * If tooltipLabel is present, tooltipLabel displays inside tooltip + * next to value, by @jackdesert (#102) + * Fix bug rendering issue in series fill when using scroll backwards, by @olssonfredrik + * Add title option, by @mesca + * Fix data drop stoppage by rejecting NaNs in append(), by @timdrysdale + * Allow setting interpolation per time series, by @WofWca (#123) + * Fix chart constantly jumping in 1-2 pixel steps, by @WofWca (#131) + * Fix a memory leak appearing when some `timeSeries.disabled === true`, by @WofWca (#132) + * Fix: make all lines sharp, remove the `grid.sharpLines` option by @WofWca (#134) + * Improve performance, by @WofWca (#135) + * Fix `this.delay` not being respected with `nonRealtimeData: true`, by @WofWca (#137) + * Fix series fill & stroke being inconsistent for last data time < render time, by @WofWca (#138) + * v1.36.1: Fix a potential XSS when `tooltipLabel` or `strokeStyle` are controlled by users, by @WofWca + * v1.36.2: fix: 1px lines jumping 1px left and right at rational `millisPerPixel`, by @WofWca + * perf: improve `render()` performane a bit, by @WofWca + * v1.37: Add `fillToBottom` option to fill timeSeries to 0 instead of to the bottom of the canvas, by @socketpair & @WofWca (#140) */ ;(function(exports) { + // Date.now polyfill + Date.now = Date.now || function() { return new Date().getTime(); }; var Util = { extend: function() { @@ -99,7 +133,29 @@ } } return arguments[0]; - } + }, + binarySearch: function(data, value) { + var low = 0, + high = data.length; + while (low < high) { + var mid = (low + high) >> 1; + if (value < data[mid][0]) + high = mid; + else + low = mid + 1; + } + return low; + }, + // So lines (especially vertical and horizontal) look a) consistent along their length and b) sharp. + pixelSnap: function(position, lineWidth) { + if (lineWidth % 2 === 0) { + // Closest pixel edge. + return Math.round(position); + } else { + // Closest pixel center. + return Math.floor(position) + 0.5; + } + }, }; /** @@ -120,6 +176,7 @@ */ function TimeSeries(options) { this.options = Util.extend({}, TimeSeries.defaultOptions, options); + this.disabled = false; this.clear(); } @@ -172,30 +229,46 @@ * whether it is replaced, or the values summed (defaults to false.) */ TimeSeries.prototype.append = function(timestamp, value, sumRepeatedTimeStampValues) { - // Rewind until we hit an older timestamp - var i = this.data.length - 1; - while (i >= 0 && this.data[i][0] > timestamp) { - i--; - } - - if (i === -1) { - // This new item is the oldest data - this.data.splice(0, 0, [timestamp, value]); - } else if (this.data.length > 0 && this.data[i][0] === timestamp) { - // Update existing values in the array - if (sumRepeatedTimeStampValues) { - // Sum this value into the existing 'bucket' - this.data[i][1] += value; - value = this.data[i][1]; - } else { - // Replace the previous value - this.data[i][1] = value; + // Reject NaN + if (isNaN(timestamp) || isNaN(value)){ + return + } + + var lastI = this.data.length - 1; + if (lastI >= 0) { + // Rewind until we find the place for the new data + var i = lastI; + while (true) { + var iThData = this.data[i]; + if (timestamp >= iThData[0]) { + if (timestamp === iThData[0]) { + // Update existing values in the array + if (sumRepeatedTimeStampValues) { + // Sum this value into the existing 'bucket' + iThData[1] += value; + value = iThData[1]; + } else { + // Replace the previous value + iThData[1] = value; + } + } else { + // Splice into the correct position to keep timestamps in order + this.data.splice(i + 1, 0, [timestamp, value]); + } + + break; + } + + i--; + if (i < 0) { + // This new item is the oldest data + this.data.splice(0, 0, [timestamp, value]); + + break; + } } - } else if (i < this.data.length - 1) { - // Splice into the correct position to keep timestamps in order - this.data.splice(i + 1, 0, [timestamp, value]); } else { - // Add to the end of the array + // It's the first element this.data.push([timestamp, value]); } @@ -237,6 +310,9 @@ * yMaxFormatter: function(max, precision) { // callback function that formats the max y value label * return parseFloat(max).toFixed(precision); * }, + * yIntermediateFormatter: function(intermediate, precision) { // callback function that formats the intermediate y value labels + * return parseFloat(intermediate).toFixed(precision); + * }, * maxDataSetLength: 2, * interpolation: 'bezier' // one of 'bezier', 'linear', or 'step' * timestampFormatter: null, // optional function to format time stamps for bottom of chart @@ -249,7 +325,6 @@ * lineWidth: 1, // the pixel width of grid lines * strokeStyle: '#777777', // colour of grid lines * millisPerLine: 1000, // distance between vertical grid lines - * sharpLines: false, // controls whether grid lines are 1px sharp, or softened * verticalSections: 2, // number of vertical sections marked out by horizontal grid lines * borderVisible: true // whether the grid lines trace the border of the chart or not * }, @@ -259,8 +334,30 @@ * fillStyle: '#ffffff', // colour for text of labels, * fontSize: 15, * fontFamily: 'sans-serif', - * precision: 2 - * } + * precision: 2, + * showIntermediateLabels: false, // shows intermediate labels between min and max values along y axis + * intermediateLabelSameAxis: true, + * }, + * title + * { + * text: '', // the text to display on the left side of the chart + * fillStyle: '#ffffff', // colour for text + * fontSize: 15, + * fontFamily: 'sans-serif', + * verticalAlign: 'middle' // one of 'top', 'middle', or 'bottom' + * }, + * tooltip: false // show tooltip when mouse is over the chart + * tooltipLine: { // properties for a vertical line at the cursor position + * lineWidth: 1, + * strokeStyle: '#BBBBBB' + * }, + * tooltipFormatter: SmoothieChart.tooltipFormatter, // formatter function for tooltip text + * nonRealtimeData: false, // use time of latest data as current time + * displayDataFromPercentile: 1, // display not latest data, but data from the given percentile + * // useful when trying to see old data saved by setting a high value for maxDataSetLength + * // should be a value between 0 and 1 + * responsive: false, // whether the chart should adapt to the size of the canvas + * limitFPS: 0 // maximum frame rate the chart will render at, in FPS (zero means no limit) * } * * @@ -272,8 +369,39 @@ this.currentValueRange = 1; this.currentVisMinValue = 0; this.lastRenderTimeMillis = 0; + this.lastChartTimestamp = 0; + + this.mousemove = this.mousemove.bind(this); + this.mouseout = this.mouseout.bind(this); } + /** Formats the HTML string content of the tooltip. */ + SmoothieChart.tooltipFormatter = function (timestamp, data) { + var timestampFormatter = this.options.timestampFormatter || SmoothieChart.timeFormatter, + // A dummy element to hold children. Maybe there's a better way. + elements = document.createElement('div'), + label; + elements.appendChild(document.createTextNode( + timestampFormatter(new Date(timestamp)) + )); + + for (var i = 0; i < data.length; ++i) { + label = data[i].series.options.tooltipLabel || '' + if (label !== ''){ + label = label + ' '; + } + var dataEl = document.createElement('span'); + dataEl.style.color = data[i].series.options.strokeStyle; + dataEl.appendChild(document.createTextNode( + label + this.options.yMaxFormatter(data[i].value, this.options.labels.precision) + )); + elements.appendChild(document.createElement('br')); + elements.appendChild(dataEl); + } + + return elements.innerHTML; + }; + SmoothieChart.defaultChartOptions = { millisPerPixel: 20, enableDpiScaling: true, @@ -283,17 +411,20 @@ yMaxFormatter: function(max, precision) { return parseFloat(max).toFixed(precision); }, + yIntermediateFormatter: function(intermediate, precision) { + return parseFloat(intermediate).toFixed(precision); + }, maxValueScale: 1, minValueScale: 1, interpolation: 'bezier', scaleSmoothing: 0.125, maxDataSetLength: 2, scrollBackwards: false, + displayDataFromPercentile: 1, grid: { fillStyle: '#000000', strokeStyle: '#777777', - lineWidth: 1, - sharpLines: false, + lineWidth: 2, millisPerLine: 1000, verticalSections: 2, borderVisible: true @@ -303,9 +434,27 @@ disabled: false, fontSize: 10, fontFamily: 'monospace', - precision: 2 + precision: 2, + showIntermediateLabels: false, + intermediateLabelSameAxis: true, }, - horizontalLines: [] + title: { + text: '', + fillStyle: '#ffffff', + fontSize: 15, + fontFamily: 'monospace', + verticalAlign: 'middle' + }, + horizontalLines: [], + tooltip: false, + tooltipLine: { + lineWidth: 1, + strokeStyle: '#BBBBBB' + }, + tooltipFormatter: SmoothieChart.tooltipFormatter, + nonRealtimeData: false, + responsive: false, + limitFPS: 0 }; // Based on http://inspirit.github.com/jsfeat/js/compatibility.js @@ -319,7 +468,7 @@ window.msRequestAnimationFrame || function(callback) { return window.setTimeout(function() { - callback(new Date().getTime()); + callback(Date.now()); }, 16); }; return requestAnimationFrame.call(window, callback, element); @@ -341,7 +490,9 @@ SmoothieChart.defaultSeriesPresentationOptions = { lineWidth: 1, - strokeStyle: '#ffffff' + strokeStyle: '#ffffff', + // Maybe default to false in the next breaking version. + fillToBottom: true, }; /** @@ -353,7 +504,10 @@ * { * lineWidth: 1, * strokeStyle: '#ffffff', - * fillStyle: undefined + * fillStyle: undefined, + * interpolation: undefined; + * tooltipLabel: undefined, + * fillToBottom: true, * } * */ @@ -428,34 +582,144 @@ */ SmoothieChart.prototype.streamTo = function(canvas, delayMillis) { this.canvas = canvas; + + this.clientWidth = parseInt(this.canvas.getAttribute('width')); + this.clientHeight = parseInt(this.canvas.getAttribute('height')); + this.delay = delayMillis; this.start(); }; - /** - * Make sure the canvas has the optimal resolution for the device's pixel ratio. - */ - SmoothieChart.prototype.resize = function() { - // TODO this function doesn't handle the value of enableDpiScaling changing during execution - if (!this.options.enableDpiScaling || !window || window.devicePixelRatio === 1) + SmoothieChart.prototype.getTooltipEl = function () { + // Create the tool tip element lazily + if (!this.tooltipEl) { + this.tooltipEl = document.createElement('div'); + this.tooltipEl.className = 'smoothie-chart-tooltip'; + this.tooltipEl.style.pointerEvents = 'none'; + this.tooltipEl.style.position = 'absolute'; + this.tooltipEl.style.display = 'none'; + document.body.appendChild(this.tooltipEl); + } + return this.tooltipEl; + }; + + SmoothieChart.prototype.updateTooltip = function () { + if(!this.options.tooltip){ + return; + } + var el = this.getTooltipEl(); + + if (!this.mouseover || !this.options.tooltip) { + el.style.display = 'none'; return; + } + + var time = this.lastChartTimestamp; - var dpr = window.devicePixelRatio; - var width = parseInt(this.canvas.getAttribute('width')); - var height = parseInt(this.canvas.getAttribute('height')); + // x pixel to time + var t = this.options.scrollBackwards + ? time - this.mouseX * this.options.millisPerPixel + : time - (this.clientWidth - this.mouseX) * this.options.millisPerPixel; - if (!this.originalWidth || (Math.floor(this.originalWidth * dpr) !== width)) { - this.originalWidth = width; - this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); - this.canvas.style.width = width + 'px'; - this.canvas.getContext('2d').scale(dpr, dpr); + var data = []; + + // For each data set... + for (var d = 0; d < this.seriesSet.length; d++) { + var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + + // find datapoint closest to time 't' + var closeIdx = Util.binarySearch(timeSeries.data, t); + if (closeIdx > 0 && closeIdx < timeSeries.data.length) { + data.push({ series: this.seriesSet[d], index: closeIdx, value: timeSeries.data[closeIdx][1] }); + } + } + + if (data.length) { + // TODO make `tooltipFormatter` return element(s) instead of an HTML string so it's harder for users + // to introduce an XSS. This would be a breaking change. + el.innerHTML = this.options.tooltipFormatter.call(this, t, data); + el.style.display = 'block'; + } else { + el.style.display = 'none'; + } + }; + + SmoothieChart.prototype.mousemove = function (evt) { + this.mouseover = true; + this.mouseX = evt.offsetX; + this.mouseY = evt.offsetY; + this.mousePageX = evt.pageX; + this.mousePageY = evt.pageY; + if(!this.options.tooltip){ + return; } + var el = this.getTooltipEl(); + el.style.top = Math.round(this.mousePageY) + 'px'; + el.style.right = `calc(100vw - ${Math.round(this.mousePageX)}px)`; + this.updateTooltip(); + }; + + SmoothieChart.prototype.mouseout = function () { + this.mouseover = false; + this.mouseX = this.mouseY = -1; + if (this.tooltipEl) + this.tooltipEl.style.display = 'none'; + }; + + /** + * Make sure the canvas has the optimal resolution for the device's pixel ratio. + */ + SmoothieChart.prototype.resize = function () { + var dpr = !this.options.enableDpiScaling || !window ? 1 : window.devicePixelRatio, + width, height; + if (this.options.responsive) { + // Newer behaviour: Use the canvas's size in the layout, and set the internal + // resolution according to that size and the device pixel ratio (eg: high DPI) + width = this.canvas.offsetWidth; + height = this.canvas.offsetHeight; + + if (width !== this.lastWidth || dpr !== this.lastDpr) { + this.lastWidth = width; + this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); + this.canvas.getContext('2d').scale(dpr, dpr); + } + if (height !== this.lastHeight || dpr !== this.lastDpr) { + this.lastHeight = height; + this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); + this.canvas.getContext('2d').scale(dpr, dpr); + } - if (!this.originalHeight || (Math.floor(this.originalHeight * dpr) !== height)) { - this.originalHeight = height; - this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); - this.canvas.style.height = height + 'px'; - this.canvas.getContext('2d').scale(dpr, dpr); + this.clientWidth = width; + this.clientHeight = height; + this.lastDpr = dpr; + } else { + width = parseInt(this.canvas.getAttribute('width')); + height = parseInt(this.canvas.getAttribute('height')); + + if (dpr !== 1) { + // Older behaviour: use the canvas's inner dimensions and scale the element's size + // according to that size and the device pixel ratio (eg: high DPI) + + if (Math.floor(this.clientWidth * dpr) !== width) { + this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); + this.canvas.style.width = width + 'px'; + this.clientWidth = width; + this.canvas.getContext('2d').scale(dpr, dpr); + } + + if (Math.floor(this.clientHeight * dpr) !== height) { + this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); + this.canvas.style.height = height + 'px'; + this.clientHeight = height; + this.canvas.getContext('2d').scale(dpr, dpr); + } + } else { + this.clientWidth = width; + this.clientHeight = height; + } } }; @@ -468,10 +732,33 @@ return; } + this.canvas.addEventListener('mousemove', this.mousemove); + this.canvas.addEventListener('mouseout', this.mouseout); + // Renders a frame, and queues the next frame for later rendering var animate = function() { this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(function() { - this.render(); + if(this.options.nonRealtimeData){ + var dateZero = new Date(0); + // find the data point with the latest timestamp + var maxTimeStamp = this.seriesSet.reduce(function(max, series){ + var dataSet = series.timeSeries.data; + var indexToCheck = Math.round(this.options.displayDataFromPercentile * dataSet.length) - 1; + indexToCheck = indexToCheck >= 0 ? indexToCheck : 0; + indexToCheck = indexToCheck <= dataSet.length -1 ? indexToCheck : dataSet.length -1; + if(dataSet && dataSet.length > 0) + { + // timestamp corresponds to element 0 of the data point + var lastDataTimeStamp = dataSet[indexToCheck][0]; + max = max > lastDataTimeStamp ? max : lastDataTimeStamp; + } + return max; + }.bind(this), dateZero); + // use the max timestamp as current time + this.render(this.canvas, maxTimeStamp > dateZero ? maxTimeStamp : null); + } else { + this.render(); + } animate(); }.bind(this)); }.bind(this); @@ -486,6 +773,8 @@ if (this.frame) { SmoothieChart.AnimateCompatibility.cancelAnimationFrame(this.frame); delete this.frame; + this.canvas.removeEventListener('mousemove', this.mousemove); + this.canvas.removeEventListener('mouseout', this.mouseout); } }; @@ -498,6 +787,10 @@ for (var d = 0; d < this.seriesSet.length; d++) { // TODO(ndunn): We could calculate / track these values as they stream in. var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + if (!isNaN(timeSeries.maxValue)) { chartMaxValue = !isNaN(chartMaxValue) ? Math.max(chartMaxValue, timeSeries.maxValue) : timeSeries.maxValue; } @@ -541,47 +834,68 @@ }; SmoothieChart.prototype.render = function(canvas, time) { - var nowMillis = new Date().getTime(); + var chartOptions = this.options, + nowMillis = Date.now(); + + // Respect any frame rate limit. + if (chartOptions.limitFPS > 0 && nowMillis - this.lastRenderTimeMillis < (1000/chartOptions.limitFPS)) + return; + + time = (time || nowMillis) - (this.delay || 0); + + // Round time down to pixel granularity, so that pixel sample values remain the same, + // just shifted 1px to the left, so motion appears smoother. + time -= time % chartOptions.millisPerPixel; if (!this.isAnimatingScale) { // We're not animating. We can use the last render time and the scroll speed to work out whether // we actually need to paint anything yet. If not, we can return immediately. - - // Render at least every 1/6th of a second. The canvas may be resized, which there is - // no reliable way to detect. - var maxIdleMillis = Math.min(1000/6, this.options.millisPerPixel); - - if (nowMillis - this.lastRenderTimeMillis < maxIdleMillis) { - return; + var sameTime = this.lastChartTimestamp === time; + if (sameTime) { + // Render at least every 1/6th of a second. The canvas may be resized, which there is + // no reliable way to detect. + var needToRenderInCaseCanvasResized = nowMillis - this.lastRenderTimeMillis > 1000/6; + if (!needToRenderInCaseCanvasResized) { + return; + } } } - this.resize(); - this.lastRenderTimeMillis = nowMillis; + this.lastChartTimestamp = time; - canvas = canvas || this.canvas; - time = time || nowMillis - (this.delay || 0); - - // Round time down to pixel granularity, so motion appears smoother. - time -= time % this.options.millisPerPixel; + this.resize(); + canvas = canvas || this.canvas; var context = canvas.getContext('2d'), - chartOptions = this.options, - dimensions = { top: 0, left: 0, width: canvas.clientWidth, height: canvas.clientHeight }, + // Using `this.clientWidth` instead of `canvas.clientWidth` because the latter is slow. + dimensions = { top: 0, left: 0, width: this.clientWidth, height: this.clientHeight }, // Calculate the threshold time for the oldest data points. oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel), - valueToYPixel = function(value) { - var offset = value - this.currentVisMinValue; - return this.currentValueRange === 0 - ? dimensions.height - : dimensions.height - (Math.round((offset / this.currentValueRange) * dimensions.height)); + valueToYPosition = function(value, lineWidth) { + var offset = value - this.currentVisMinValue, + unsnapped = this.currentValueRange === 0 + ? dimensions.height + : dimensions.height * (1 - offset / this.currentValueRange); + return Util.pixelSnap(unsnapped, lineWidth); }.bind(this), - timeToXPixel = function(t) { - if(chartOptions.scrollBackwards) { - return Math.round((time - t) / chartOptions.millisPerPixel); - } - return Math.round(dimensions.width - ((time - t) / chartOptions.millisPerPixel)); + timeToXPosition = function(t, lineWidth) { + // Why not write it as `(time - t) / chartOptions.millisPerPixel`: + // If a datapoint's `t` is very close or is at the center of a pixel, that expression, + // due to floating point error, may take value whose `% 1` sometimes is very close to + // 0 and sometimes is close to 1, depending on the value of render time (`time`), + // which would make `pixelSnap` snap it sometimes to the right and sometimes to the left, + // which would look like it's jumping. + // You can try the default examples, with `millisPerPixel = 100 / 3` and + // `grid.lineWidth = 1`. The grid would jump. + // Writing it this way seems to avoid such inconsistency because in the above example + // `offset` is (almost?) always a whole number. + // TODO Maybe there's a more elegant (and reliable?) way. + var offset = time / chartOptions.millisPerPixel - t / chartOptions.millisPerPixel; + var unsnapped = chartOptions.scrollBackwards + ? offset + : dimensions.width - offset; + return Util.pixelSnap(unsnapped, lineWidth); }; this.updateValueRange(); @@ -619,34 +933,24 @@ for (var t = time - (time % chartOptions.grid.millisPerLine); t >= oldestValidTime; t -= chartOptions.grid.millisPerLine) { - var gx = timeToXPixel(t); - if (chartOptions.grid.sharpLines) { - gx -= 0.5; - } + var gx = timeToXPosition(t, chartOptions.grid.lineWidth); context.moveTo(gx, 0); context.lineTo(gx, dimensions.height); } context.stroke(); - context.closePath(); } // Horizontal (value) dividers. for (var v = 1; v < chartOptions.grid.verticalSections; v++) { - var gy = Math.round(v * dimensions.height / chartOptions.grid.verticalSections); - if (chartOptions.grid.sharpLines) { - gy -= 0.5; - } + var gy = Util.pixelSnap(v * dimensions.height / chartOptions.grid.verticalSections, chartOptions.grid.lineWidth); context.beginPath(); context.moveTo(0, gy); context.lineTo(dimensions.width, gy); context.stroke(); - context.closePath(); } // Bounding rectangle. if (chartOptions.grid.borderVisible) { - context.beginPath(); context.strokeRect(0, 0, dimensions.width, dimensions.height); - context.closePath(); } context.restore(); @@ -654,107 +958,154 @@ if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) { for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) { var line = chartOptions.horizontalLines[hl], - hly = Math.round(valueToYPixel(line.value)) - 0.5; + lineWidth = line.lineWidth || 1, + hly = valueToYPosition(line.value, lineWidth); context.strokeStyle = line.color || '#ffffff'; - context.lineWidth = line.lineWidth || 1; + context.lineWidth = lineWidth; context.beginPath(); context.moveTo(0, hly); context.lineTo(dimensions.width, hly); context.stroke(); - context.closePath(); } } // For each data set... for (var d = 0; d < this.seriesSet.length; d++) { - context.save(); var timeSeries = this.seriesSet[d].timeSeries, - dataSet = timeSeries.data, - seriesOptions = this.seriesSet[d].options; + dataSet = timeSeries.data; // Delete old data that's moved off the left of the chart. timeSeries.dropOldData(oldestValidTime, chartOptions.maxDataSetLength); + if (dataSet.length <= 1 || timeSeries.disabled) { + continue; + } + context.save(); + + var seriesOptions = this.seriesSet[d].options, + // Keep in mind that `context.lineWidth = 0` doesn't actually set it to `0`. + drawStroke = seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none', + lineWidthMaybeZero = drawStroke ? seriesOptions.lineWidth : 0; - // Set style for this dataSet. - context.lineWidth = seriesOptions.lineWidth; - context.strokeStyle = seriesOptions.strokeStyle; // Draw the line... context.beginPath(); // Retain lastX, lastY for calculating the control points of bezier curves. - var firstX = 0, lastX = 0, lastY = 0; - for (var i = 0; i < dataSet.length && dataSet.length !== 1; i++) { - var x = timeToXPixel(dataSet[i][0]), - y = valueToYPixel(dataSet[i][1]); - - if (i === 0) { - firstX = x; - context.moveTo(x, y); - } else { - switch (chartOptions.interpolation) { - case "linear": - case "line": { - context.lineTo(x,y); - break; - } - case "bezier": - default: { - // Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves - // - // Assuming A was the last point in the line plotted and B is the new point, - // we draw a curve with control points P and Q as below. - // - // A---P - // | - // | - // | - // Q---B - // - // Importantly, A and P are at the same y coordinate, as are B and Q. This is - // so adjacent curves appear to flow as one. - // - context.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop - Math.round((lastX + x) / 2), lastY, // controlPoint1 (P) - Math.round((lastX + x)) / 2, y, // controlPoint2 (Q) - x, y); // endPoint (B) - break; - } - case "step": { - context.lineTo(x,lastY); - context.lineTo(x,y); - break; - } + var firstX = timeToXPosition(dataSet[0][0], lineWidthMaybeZero), + firstY = valueToYPosition(dataSet[0][1], lineWidthMaybeZero), + lastX = firstX, + lastY = firstY, + draw; + context.moveTo(firstX, firstY); + switch (seriesOptions.interpolation || chartOptions.interpolation) { + case "linear": + case "line": { + draw = function(x, y, lastX, lastY) { + context.lineTo(x,y); + } + break; + } + case "bezier": + default: { + // Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves + // + // Assuming A was the last point in the line plotted and B is the new point, + // we draw a curve with control points P and Q as below. + // + // A---P + // | + // | + // | + // Q---B + // + // Importantly, A and P are at the same y coordinate, as are B and Q. This is + // so adjacent curves appear to flow as one. + // + draw = function(x, y, lastX, lastY) { + context.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop + Math.round((lastX + x) / 2), lastY, // controlPoint1 (P) + Math.round((lastX + x)) / 2, y, // controlPoint2 (Q) + x, y); // endPoint (B) } + break; } + case "step": { + draw = function(x, y, lastX, lastY) { + context.lineTo(x,lastY); + context.lineTo(x,y); + } + break; + } + } + for (var i = 1; i < dataSet.length; i++) { + var iThData = dataSet[i], + x = timeToXPosition(iThData[0], lineWidthMaybeZero), + y = valueToYPosition(iThData[1], lineWidthMaybeZero); + draw(x, y, lastX, lastY); lastX = x; lastY = y; } - if (dataSet.length > 1) { - if (seriesOptions.fillStyle) { - // Close up the fill region. - context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, lastY); - context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, dimensions.height + seriesOptions.lineWidth + 1); - context.lineTo(firstX, dimensions.height + seriesOptions.lineWidth); - context.fillStyle = seriesOptions.fillStyle; - context.fill(); - } + if (drawStroke) { + context.lineWidth = seriesOptions.lineWidth; + context.strokeStyle = seriesOptions.strokeStyle; + context.stroke(); + } - if (seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none') { - context.stroke(); - } - context.closePath(); + if (seriesOptions.fillStyle) { + // Close up the fill region. + var fillEndY = seriesOptions.fillToBottom + ? dimensions.height + lineWidthMaybeZero + 1 + : valueToYPosition(0, 0); + context.lineTo(lastX, fillEndY); + context.lineTo(firstX, fillEndY); + + context.fillStyle = seriesOptions.fillStyle; + context.fill(); } + context.restore(); } + if (chartOptions.tooltip && this.mouseX >= 0) { + // Draw vertical bar to show tooltip position + context.lineWidth = chartOptions.tooltipLine.lineWidth; + context.strokeStyle = chartOptions.tooltipLine.strokeStyle; + context.beginPath(); + context.moveTo(this.mouseX, 0); + context.lineTo(this.mouseX, dimensions.height); + context.stroke(); + } + this.updateTooltip(); + + var labelsOptions = chartOptions.labels; // Draw the axis values on the chart. - if (!chartOptions.labels.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) { - var maxValueString = chartOptions.yMaxFormatter(this.valueRange.max, chartOptions.labels.precision), - minValueString = chartOptions.yMinFormatter(this.valueRange.min, chartOptions.labels.precision), - labelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(maxValueString).width - 2; - context.fillStyle = chartOptions.labels.fillStyle; - context.fillText(maxValueString, labelPos, chartOptions.labels.fontSize); - context.fillText(minValueString, labelPos, dimensions.height - 2); + if (!labelsOptions.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) { + var maxValueString = chartOptions.yMaxFormatter(this.valueRange.max, labelsOptions.precision), + minValueString = chartOptions.yMinFormatter(this.valueRange.min, labelsOptions.precision), + maxLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(maxValueString).width - 2, + minLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(minValueString).width - 2; + context.fillStyle = labelsOptions.fillStyle; + context.fillText(maxValueString, maxLabelPos, labelsOptions.fontSize); + context.fillText(minValueString, minLabelPos, dimensions.height - 2); + } + + // Display intermediate y axis labels along y-axis to the left of the chart + if ( labelsOptions.showIntermediateLabels + && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max) + && chartOptions.grid.verticalSections > 0) { + // show a label above every vertical section divider + var step = (this.valueRange.max - this.valueRange.min) / chartOptions.grid.verticalSections; + var stepPixels = dimensions.height / chartOptions.grid.verticalSections; + for (var v = 1; v < chartOptions.grid.verticalSections; v++) { + var gy = dimensions.height - Math.round(v * stepPixels), + yValue = chartOptions.yIntermediateFormatter(this.valueRange.min + (v * step), labelsOptions.precision), + //left of right axis? + intermediateLabelPos = + labelsOptions.intermediateLabelSameAxis + ? (chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(yValue).width - 2) + : (chartOptions.scrollBackwards ? dimensions.width - context.measureText(yValue).width - 2 : 0); + + context.fillText(yValue, intermediateLabelPos, gy - chartOptions.grid.lineWidth); + } } // Display timestamps along x-axis at the bottom of the chart. @@ -765,7 +1116,7 @@ for (var t = time - (time % chartOptions.grid.millisPerLine); t >= oldestValidTime; t -= chartOptions.grid.millisPerLine) { - var gx = timeToXPixel(t); + var gx = timeToXPosition(t, 0); // Only draw the timestamp if it won't overlap with the previously drawn one. if ((!chartOptions.scrollBackwards && gx < textUntilX) || (chartOptions.scrollBackwards && gx > textUntilX)) { // Formats the timestamp based on user specified formatting function @@ -788,6 +1139,24 @@ } } + // Display title. + if (chartOptions.title.text !== '') { + context.font = chartOptions.title.fontSize + 'px ' + chartOptions.title.fontFamily; + var titleXPos = chartOptions.scrollBackwards ? dimensions.width - context.measureText(chartOptions.title.text).width - 2 : 2; + if (chartOptions.title.verticalAlign == 'bottom') { + context.textBaseline = 'bottom'; + var titleYPos = dimensions.height; + } else if (chartOptions.title.verticalAlign == 'middle') { + context.textBaseline = 'middle'; + var titleYPos = dimensions.height / 2; + } else { + context.textBaseline = 'top'; + var titleYPos = 0; + } + context.fillStyle = chartOptions.title.fillStyle; + context.fillText(chartOptions.title.text, titleXPos, titleYPos); + } + context.restore(); // See .save() above. }; diff --git a/lib/OperatingSystems/Dummy.php b/lib/OperatingSystems/Dummy.php index 74ecb250..77cf8b91 100644 --- a/lib/OperatingSystems/Dummy.php +++ b/lib/OperatingSystems/Dummy.php @@ -49,4 +49,8 @@ public function getDiskInfo(): array { public function getThermalZones(): array { return []; } + + public function getCpuCount(): int { + return 1; + } } diff --git a/lib/OperatingSystems/FreeBSD.php b/lib/OperatingSystems/FreeBSD.php index 6a6f3dec..0b138235 100644 --- a/lib/OperatingSystems/FreeBSD.php +++ b/lib/OperatingSystems/FreeBSD.php @@ -75,6 +75,17 @@ public function getCpuName(): string { return $data; } + public function getCpuCount(): int { + $numCpu = -1; + + try { + $numCpu = intval($this->executeCommand('sysctl -n hw.ncpu')); //TODO: this should be tested if it actually works on FreeBSD + } catch (RuntimeException) { + } + + return $numCpu; + } + public function getTime(): string { try { return $this->executeCommand('date'); @@ -247,4 +258,5 @@ protected function getNetInterfaces(): array { } return $data; } + } diff --git a/lib/OperatingSystems/IOperatingSystem.php b/lib/OperatingSystems/IOperatingSystem.php index 7c99bdaf..00edcd51 100644 --- a/lib/OperatingSystems/IOperatingSystem.php +++ b/lib/OperatingSystems/IOperatingSystem.php @@ -24,6 +24,13 @@ public function supported(): bool; */ public function getCpuName(): string; + /** + * Get number of processors (threads). + * + * @return int + */ + public function getCpuCount(): int; + /** * Get disk info returns a list of Disk objects. Used and Available in bytes. * diff --git a/lib/OperatingSystems/Linux.php b/lib/OperatingSystems/Linux.php index b6c51555..71931534 100644 --- a/lib/OperatingSystems/Linux.php +++ b/lib/OperatingSystems/Linux.php @@ -92,10 +92,7 @@ public function getCpuName(): string { $model = $matches[1][0]; - $pattern = '/processor\s+:\s(.+)/'; - - preg_match_all($pattern, $cpuinfo, $matches); - $threads = count($matches[1]); + $threads = $this->getCpuCount(); if ($threads === 1) { $data = $model . ' (1 thread)'; @@ -106,6 +103,21 @@ public function getCpuName(): string { return $data; } + public function getCpuCount(): int { + $numCpu = -1; + + try { + $cpuinfo = $this->readContent('/proc/cpuinfo'); + } catch (RuntimeException $e) { + return $numCpu; + } + + $pattern = '/processor\s+:\s(.+)/'; + + preg_match_all($pattern, $cpuinfo, $matches); + return count($matches[1]); + } + public function getTime(): string { try { return $this->executeCommand('date'); diff --git a/lib/Os.php b/lib/Os.php index 883aea15..ee9ef0d9 100644 --- a/lib/Os.php +++ b/lib/Os.php @@ -45,6 +45,10 @@ public function getCpuName(): string { return $this->backend->getCpuName(); } + public function getCpuCount(): int { + return $this->backend->getCpuCount(); + } + public function getTime(): string { return $this->backend->getTime(); } diff --git a/lib/SystemStatistics.php b/lib/SystemStatistics.php index cd67491c..9169fdc2 100644 --- a/lib/SystemStatistics.php +++ b/lib/SystemStatistics.php @@ -39,6 +39,7 @@ public function __construct(IConfig $config, IAppManager $appManager, Installer public function getSystemStatistics(bool $skipApps = false, bool $skipUpdate = true): array { $processorUsage = $this->getProcessorUsage(); $memoryUsage = $this->os->getMemory(); + $numCPU = $this->os->getCpuCount(); $data = [ 'version' => $this->config->getSystemValue('version'), @@ -52,6 +53,7 @@ public function getSystemStatistics(bool $skipApps = false, bool $skipUpdate = t 'debug' => $this->config->getSystemValue('debug', false) ? 'yes' : 'no', 'freespace' => $this->getFreeSpace(), 'cpuload' => $processorUsage['loadavg'], + 'cpunum' => $numCPU, 'mem_total' => $memoryUsage->getMemTotal() * 1024, 'mem_free' => $memoryUsage->getMemAvailable() * 1024, 'swap_total' => $memoryUsage->getSwapTotal() * 1024, diff --git a/templates/settings-admin.php b/templates/settings-admin.php index b19cbb67..cbd5df21 100644 --- a/templates/settings-admin.php +++ b/templates/settings-admin.php @@ -105,7 +105,7 @@ function FormatMegabytes(int $byte): string { -
+