diff --git a/sysutils/nut/Makefile b/sysutils/nut/Makefile index 99334db4ce..6a95cd0cf3 100644 --- a/sysutils/nut/Makefile +++ b/sysutils/nut/Makefile @@ -1,6 +1,5 @@ PLUGIN_NAME= nut -PLUGIN_VERSION= 1.8.1 -PLUGIN_REVISION= 2 +PLUGIN_VERSION= 1.9.0 PLUGIN_COMMENT= Network UPS Tools PLUGIN_DEPENDS= nut PLUGIN_MAINTAINER= m.muenz@gmail.com diff --git a/sysutils/nut/pkg-descr b/sysutils/nut/pkg-descr index 9f9fe6b37a..15131a87ad 100644 --- a/sysutils/nut/pkg-descr +++ b/sysutils/nut/pkg-descr @@ -9,6 +9,10 @@ and management interface. Plugin Changelog ---------------- +1.9 + +* Add dashboard widget + 1.8 * Add apcupsd-ups driver support diff --git a/sysutils/nut/src/opnsense/www/js/widgets/Metadata/Nut.xml b/sysutils/nut/src/opnsense/www/js/widgets/Metadata/Nut.xml new file mode 100644 index 0000000000..a3ebebf193 --- /dev/null +++ b/sysutils/nut/src/opnsense/www/js/widgets/Metadata/Nut.xml @@ -0,0 +1,43 @@ + + + Nut.js + + /api/nut/service/status + /api/nut/settings/get + /api/nut/diagnostics/upsstatus + + + NUT + UPS Model + UPS Status + Battery status + UPS Load + UPS Efficiency + Battery level + Battery runtime + Output Power + Input Power + Self test + On line + On battery + Low battery + High battery + Battery needs to be replaced + Battery is charging + Battery is discharging + UPS bypass circuit is active + Performing runtime calibration + UPS is offline + UPS is overloaded + UPS is trimming voltage + UPS is boosting voltage + Forced Shutdown + Nut is not started. Click to configure Nut. + This widget only works with the Netclient driver. Click to configure the Netclient driver. + Remote NUT Server + h + m + s + + + diff --git a/sysutils/nut/src/opnsense/www/js/widgets/Nut.js b/sysutils/nut/src/opnsense/www/js/widgets/Nut.js new file mode 100644 index 0000000000..af2e4c928f --- /dev/null +++ b/sysutils/nut/src/opnsense/www/js/widgets/Nut.js @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2024 Ben 'DollarSign23' + * Copyright (C) 2024 Nicola Pellegrini + * 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 BaseTableWidget from 'widget-base-table'; + +export default class NutNetclient extends BaseTableWidget { + constructor() { + super(); + this.timeoutPeriod = 1000; // Set a timeout period for AJAX calls or other timed operations. + } + + getGridOptions() { + return { + // Trigger overflow-y:scroll after 650px height + sizeToContent: 650 + }; + } + + // Creates and returns the HTML structure for the widget, including a table without a header. + getMarkup() { + let $container = $('
'); // Create a container div. + let $nut_table = this.createTable('nut-table', { + headerPosition: 'none', // Disable table headers. + }); + $container.append($nut_table); // Append the table to the container. + return $container; // Return the container with the table. + } + + // Periodically called to update the widget's data and UI. + async onWidgetTick() { + // Fetch the NUT service status from the server. + const nut_service_status = await this.ajaxCall('/api/nut/service/status'); + + // If the service is not running, display a message and stop further processing. + if (!nut_service_status || nut_service_status.status !== 'running') { + $('#nut-table').html(`${this.translations.unconfigured}`); + return; + } + + // Fetch the NUT settings from the server. + const nut_settings = await this.ajaxCall('/api/nut/settings/get'); + + // // If netclient is not enabled, display a message and stop further processing. + // if (nut_settings.nut?.netclient?.enable !== "1") { + // $('#nut-table').html(`${this.translations.netclient_unconfigured}`); + // return; + // } + + // Fetch the UPS status data from the server. + const { response: nut_ups_status_response } = await this.ajaxCall('/api/nut/diagnostics/upsstatus'); + + // Parse the UPS status data into a key-value object. + const nut_ups_status = nut_ups_status_response.split('\n').reduce((acc, line) => { + const [key, value] = line.split(': '); + if (key) acc[key] = value; // Only add non-empty keys. + return acc; + }, {}); + + // Use the dataChanged method to check if the data has changed since the last tick + if (!this.dataChanged('ups_status', nut_ups_status)) { + return; + } + + // Prepare the rows for the table based on the fetched data. + const rows = [ + // Display the remote server address if available. + nut_settings.nut?.netclient?.address && nut_settings.nut?.netclient?.address && nut_settings.nut?.netclient?.user && this.makeTextRow("netclient_remote_server", `${nut_settings.nut?.netclient?.user}@${nut_settings.nut?.netclient?.address}:${nut_settings.nut?.netclient?.port}`), + // Display the manufacturer and model if available. + nut_ups_status['device.mfr'] && nut_ups_status['device.model'] && this.makeTextRow("status_model", `${nut_ups_status['device.mfr']} - ${nut_ups_status['device.model']}`), + // Display the UPS Status if available. + nut_ups_status['ups.status'] && this.makeColoredTextRow('status_status', this.nutMapStatus(nut_ups_status['ups.status']), /OL/, /OB|LB|RB|DISCHRG/, nut_ups_status['ups.status']), + // Display the UPS load with percentage and optional nominal power. + nut_ups_status['ups.load'] && nut_ups_status['ups.realpower'] && this.makeUpsLoadRow('status_load', parseFloat(nut_ups_status['ups.load']), parseFloat(nut_ups_status['ups.realpower'])), + // Display the battery charge as a progress bar if available. + nut_ups_status['battery.charge'] && this.makeProgressBarRow("status_bcharge", parseFloat(nut_ups_status['battery.charge'])), + // Display the battery status if available. + nut_ups_status['battery.charger.status'] && this.makeTextRow('status_battery', nut_ups_status['battery.charger.status']), + // Display the formatted battery runtime if available. + nut_ups_status['battery.runtime'] && this.makeTextRow('status_timeleft', this.formatRuntime(parseInt(nut_ups_status['battery.runtime'], 10))), + // Display the input voltage and frequency if available. + nut_ups_status['input.voltage'] && nut_ups_status['input.frequency'] && this.makeTextRow('status_input_power', `${nut_ups_status['input.voltage']} V | ${nut_ups_status['input.frequency']} Hz`), + // Display the output voltage and frequency if available. + nut_ups_status['output.voltage'] && nut_ups_status['output.frequency'] && this.makeTextRow('status_output_power', `${nut_ups_status['output.voltage']} V | ${nut_ups_status['output.frequency']} Hz`), + // Display the result of the UPS efficiency if available. + nut_ups_status['ups.efficiency'] && this.makeTextRow('status_efficiency', `${nut_ups_status['ups.efficiency']}%`), + // Display the result of the UPS self-test if available. + nut_ups_status['ups.test.result'] && this.makeTextRow('status_selftest', nut_ups_status['ups.test.result']), + ].filter(Boolean); // Remove any undefined or null rows. + + // Update the table with the prepared rows. + this.updateTable('nut-table', rows); + } + + // Formats the runtime (in seconds) into a human-readable format (hours, minutes, seconds). + formatRuntime(seconds) { + const hours = Math.floor(seconds / 3600); // Calculate full hours. + const minutes = Math.floor((seconds % 3600) / 60); // Calculate remaining full minutes. + const remainingSeconds = seconds % 60; // Calculate remaining seconds. + + let formattedTime = ''; + + if (hours > 0) { + formattedTime += `${hours}${this.translate('time_hours')} `; + } + if (minutes > 0 || hours > 0) { // Only show minutes if they are > 0 or hours are present. + formattedTime += `${minutes}${this.translate('time_minutes')} `; + } + formattedTime += `${remainingSeconds}${this.translate('time_seconds')}`; // Always show seconds. + + return formattedTime.trim(); // Remove any trailing spaces. + } + + // Create a mapping between UPS status codes and their corresponding translations + nutMapStatus(statusCode) { + const statusMapping = { + 'OL': this.translate('status_ol'), // On line (mains is present) + 'OB': this.translate('status_ob'), // On battery (mains is not present) + 'LB': this.translate('status_lb'), // Low battery + 'HB': this.translate('status_hb'), // High battery + 'RB': this.translate('status_rb'), // Battery needs to be replaced + 'CHRG': this.translate('status_chrg'), // Battery is charging + 'DISCHRG': this.translate('status_dischrg'), // Battery is discharging + 'BYPASS': this.translate('status_bypass'), // UPS bypass circuit is active (no battery protection available) + 'CAL': this.translate('status_cal'), // Performing runtime calibration (on battery) + 'OFF': this.translate('status_off'), // UPS is offline + 'OVER': this.translate('status_over'), // UPS is overloaded + 'TRIM': this.translate('status_trim'), // UPS is trimming incoming voltage + 'BOOST': this.translate('status_boost'), // UPS is boosting incoming voltage + 'FSD': this.translate('status_fsd'), // Forced Shutdown + }; + + // Return the mapped translation or the original status code if no translation is found + return statusMapping[statusCode] || statusCode; + } + + // Creates a row for the UPS load, including the percentage and optional nominal power. + makeUpsLoadRow(labelKey, loadpct, nompower) { + let text = loadpct.toFixed(1) + ' %'; + if (nompower) { + text += ` ( ~ ${nompower} W )`; + } + return this.makeProgressBarRow(labelKey, loadpct, text); + } + + // Creates a row with a progress bar, optionally including custom text. + makeProgressBarRow(labelKey, progress, progressText = `${progress.toFixed(1)} %`) { + const pb = this.makeProgressBar(progress, progressText); // Create the progress bar. + return this.makeRow(labelKey, pb); // Create a row with the progress bar. + } + + // Creates a row with text, applying color based on regular expressions. + makeColoredTextRow(labelKey, value, okRegexp, errRegexp, check_value = value) { + const textEl = $('').text(value); // Create a bold text element with the value. + + // Apply CSS classes based on regex matches. + if (okRegexp?.exec(check_value)) { + textEl.addClass('text-success'); + } else if (errRegexp?.exec(check_value)) { + textEl.addClass('text-danger'); + } else { + textEl.addClass('text-warning'); + } + + return this.makeRow(labelKey, textEl.prop('outerHTML')); // Create a row with the colored text. + } + + // Creates a progress bar with a text overlay. + makeProgressBar(progress, text) { + const $textEl = $('').text(text).css({ + position: 'absolute', + left: 0, + right: 0 + }); + + const $barEl = $('
').css({ + width: `${progress}%`, + zIndex: 0 + }); + + return $('
').append($barEl, $textEl).prop("outerHTML"); + } + + // Creates a text row for the table. + makeTextRow(labelKey, content) { + content = typeof content === 'string' ? content : content.value; // Ensure content is a string. + return this.makeRow(labelKey, content); // Create a row with the text content. + } + + // Creates a row with a label and content. + makeRow(labelKey, content) { + return [this.translate(labelKey), content]; + } + + // Translates a key into the corresponding text. + translate(key) { + let value = this.translations[key]; + if (value === undefined) { + console.error('Missing translation for ' + key); + value = key; // Fallback to the key itself if translation is missing. + } + return value; + } +}