From 2d739035296dd2bfb3dc53b057e5dd039be86551 Mon Sep 17 00:00:00 2001 From: Stephan de Wit Date: Fri, 7 Jun 2024 10:48:03 +0000 Subject: [PATCH] dashboard: add thermal sensors widget --- .../OPNsense/Core/Api/DashboardController.php | 7 +- .../OPNsense/Core/Api/SystemController.php | 20 ++ .../www/js/opnsense_widget_manager.js | 2 +- src/opnsense/www/js/widgets/ThermalSensors.js | 234 ++++++++++++++++++ 4 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 src/opnsense/www/js/widgets/ThermalSensors.js diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php index 4b59d875c..8a44c9094 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php @@ -132,7 +132,12 @@ class DashboardController extends ApiControllerBase 'rtt' => gettext('RTT'), 'rttd' => gettext('RTTd'), 'loss' => gettext('Loss'), - ] + ], + 'thermalsensors' => [ + 'title' => gettext('Thermal Sensors'), + 'help' => gettext('CPU thermal sensors often measure the same temperature for each core. If this is the case, only the first core is shown.'), + 'unconfigured' => gettext('Thermal sensors not available or not configured.') + ], ]; } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php index 71a09782e..7d243dd38 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php @@ -332,4 +332,24 @@ class SystemController extends ApiControllerBase { return json_decode((new Backend())->configdRun('system show swapinfo'), true); } + + public function systemTemperatureAction() + { + $result = []; + + foreach (explode("\n", (new Backend())->configdRun('system temp')) as $sysctl) { + $parts = explode('=', $); + if (count($parts) >= 2) { + $tempItem = array(); + $tempItem['device'] = $parts[0]; + $tempItem['device_seq'] = filter_var($tempItem['device'], FILTER_SANITIZE_NUMBER_INT); + $tempItem['temperature'] = trim(str_replace('C', '', $parts[1])); + $tempItem['type'] = strpos($tempItem['device'], 'hw.acpi') !== false ? "zone" : "core"; + $tempItem['type_translated'] = $tempItem['type'] == "zone" ? gettext("Zone") : gettext("Core"); + $result[] = $tempItem; + } + } + + return $result; + } } diff --git a/src/opnsense/www/js/opnsense_widget_manager.js b/src/opnsense/www/js/opnsense_widget_manager.js index 16bcf1fd0..45c34572d 100644 --- a/src/opnsense/www/js/opnsense_widget_manager.js +++ b/src/opnsense/www/js/opnsense_widget_manager.js @@ -501,7 +501,7 @@ class WidgetManager { let $header = $(`
-
${title}
+
${title}
diff --git a/src/opnsense/www/js/widgets/ThermalSensors.js b/src/opnsense/www/js/widgets/ThermalSensors.js new file mode 100644 index 000000000..e5e460a8c --- /dev/null +++ b/src/opnsense/www/js/widgets/ThermalSensors.js @@ -0,0 +1,234 @@ +// endpoint:/api/core/system/systemTemperature + +/* + * Copyright (C) 2024 Deciso B.V. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +import BaseWidget from "./BaseWidget.js"; + +export default class ThermalSensors extends BaseWidget { + constructor() { + super(); + + this.chart = null; + this.width = null; + this.height = null; + this.colors = []; + } + + getMarkup() { + return $(` +
+
+ +
+
+ `); + } + + async onMarkupRendered() { + let context = document.getElementById(`${this.id}-chart`).getContext("2d"); + + const data = { + datasets: [ + { + data: [], + metadata: [], + backgroundColor: (context) => { + const {chartArea} = context.chart; + + if (!chartArea || this.colors.length === 0) { + return; + } + + let dataIndex = context.dataIndex; + let value = parseInt(context.raw); + if (value >= 80) { + this.colors[dataIndex] = '#dc3545'; + } else if (value >= 70) { + this.colors[dataIndex] = '#ffc107'; + } else { + this.colors[dataIndex] = '#28a745'; + } + return this.colors; + }, + barPercentage: 0.8, + borderWidth: 1, + borderSkipped: false, + borderRadius: 20, + barThickness: 20 + }, + { + data: [], + backgroundColor: ['#E5E5E5'], + borderRadius: 20, + barPercentage: 0.5, + borderWidth: 1, + borderSkipped: true, + barThickness: 10, + pointHitRadius: 0 + } + ] + } + + const lines = { + id: 'lines', + afterDatasetsDraw: (chart, args, plugins) => { + const {ctx, data, chartArea} = chart; + if (data.datasets[0].data.length === 0) { + return; + } + let count = data.datasets[0].data.length; + ctx.save(); + for (let i = 0; i < count; i++) { + const meta = chart.getDatasetMeta(0); + const xPos = meta.data[i].x; + const yPos = meta.data[i].y; + const barHeight = meta.data[i].height; + + ctx.font = 'semibold 12px sans-serif'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(`${data.datasets[0].data[i]}°C`, xPos - 50, yPos + barHeight / 4); + } + ctx.restore(); + } + } + + const config = { + type: 'bar', + data: data, + options: { + responsive: true, + indexAxis: 'y', + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + enabled: true, + filter: function(tooltipItem) { + return tooltipItem.datasetIndex === 0; + }, + callbacks: { + label: (tooltipItem) => { + let idx = tooltipItem.dataIndex; + if (!tooltipItem.dataset.metadata) { + return; + } + let meta = tooltipItem.dataset.metadata[idx]; + return `${meta.device}: ${meta.temperature}°C / ${meta.temperature_fahrenheit}°F`; + } + } + } + }, + scales: { + x: { + stacked: true, + beginAtZero: true, + grid: { + display: false, + drawBorder: false, + }, + ticks: { + display: false, + } + }, + y: { + autoSkip: false, + stacked: true, + grid: { + display: false, + drawBorder: false, + }, + ticks: { + autoSkip: false, + } + } + } + }, + plugins: [ + lines + ] + } + + this.chart = new Chart(context, config); + + $(`#${this.id}-title`).append(` `); + $('[data-toggle="tooltip"]').tooltip({container: 'body', triger: 'hover'}); + } + + async onWidgetTick() { + ajaxGet('/api/core/system/systemTemperature', { }, (data, status) => { + if (!data || !data.length) { + $(`.${this.id}-chart-container`).html(` + ${this.translations.unconfigured} + `).css('margin', '2em auto') + return; + } + let parsed = this._parseSensors(data); + this._update(parsed); + }); + } + + _update(data = []) { + if (!this.chart || data.length === 0) { + return; + } + + this.colors = new Array(data.length).fill(0); + + data.forEach((value, index) => { + this.chart.data.labels[index] = `${value.type_translated} ${value.device_seq}`; + this.chart.data.datasets[0].data[index] = Math.max(1, Math.min(100, value.temperature)); + this.chart.data.datasets[0].metadata[index] = value; + this.chart.data.datasets[1].data[index] = 100 - value.temperature; + }); + this.chart.canvas.parentNode.style.height = `${30 + (data.length * 30)}px`; + this.chart.update(); + } + + _parseSensors(data) { + const toFahrenheit = (celsius) => (celsius * 9 / 5) + 32; + data.forEach(item => { + item.temperature_fahrenheit = toFahrenheit(parseFloat(item.temperature)).toFixed(1); + }); + + // Find cores with differing temperatures + const coreTemperatures = data.filter(item => item.type === 'core').map(item => parseFloat(item.temperature)); + const uniqueTemperatures = new Set(coreTemperatures); + + let result = []; + if (uniqueTemperatures.size === 1) { + // If all temperatures are the same, include only the first core + result.push(data.find(item => item.type === 'core')); + } else { + // Include all cores with differing temperatures + result = data.filter(item => item.type !== 'core' || coreTemperatures.filter(temp => temp !== parseFloat(item.temperature)).length > 0); + } + + return result; + } +}