Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sysutils/nut: new dashboard widget #4188

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
4 changes: 2 additions & 2 deletions sysutils/nut/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
PLUGIN_NAME= nut
PLUGIN_VERSION= 1.8.1
PLUGIN_REVISION= 2
PLUGIN_VERSION= 1.9.0
PLUGIN_REVISION= 1
fichtner marked this conversation as resolved.
Show resolved Hide resolved
PLUGIN_COMMENT= Network UPS Tools
PLUGIN_DEPENDS= nut
PLUGIN_MAINTAINER= [email protected]
Expand Down
4 changes: 4 additions & 0 deletions sysutils/nut/pkg-descr
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and management interface.
Plugin Changelog
----------------

1.9

* Add dashboard widget

1.8

* Add apcupsd-ups driver support
Expand Down
42 changes: 42 additions & 0 deletions sysutils/nut/src/opnsense/www/js/widgets/Metadata/Nut.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<metadata>
<nut>
<filename>Nut.js</filename>
<endpoints>
<endpoint>/api/nut/service/status</endpoint>
<endpoint>/api/nut/settings/get</endpoint>
<endpoint>/api/nut/diagnostics/upsstatus</endpoint>
</endpoints>
<translations>
<title>NUT</title>
<status_model>UPS Model</status_model>
<status_status>UPS Status</status_status>
<status_battery>Battery status</status_battery>
<status_load>UPS Load</status_load>
<status_bcharge>Battery level</status_bcharge>
<status_timeleft>Battery runtime</status_timeleft>
<status_output_power>Output Power</status_output_power>
<status_input_power>Input Power</status_input_power>
<status_selftest>Self test</status_selftest>
<status_ol>On line</status_ol>
<status_ob>On battery</status_ob>
<status_lb>Low battery</status_lb>
<status_hb>High battery</status_hb>
<status_rb>Battery needs to be replaced</status_rb>
<status_chrg>Battery is charging</status_chrg>
<status_dischrg>Battery is discharging</status_dischrg>
<status_bypass>UPS bypass circuit is active</status_bypass>
<status_cal>Performing runtime calibration</status_cal>
<status_off>UPS is offline</status_off>
<status_over>UPS is overloaded</status_over>
<status_trim>UPS is trimming voltage</status_trim>
<status_boost>UPS is boosting voltage</status_boost>
<status_fsd>Forced Shutdown</status_fsd>
<unconfigured>Nut is not started. Click to configure Nut.</unconfigured>
<netclient_unconfigured>This widget only works with the Netclient driver. Click to configure the Netclient driver.</netclient_unconfigured>
<netclient_remote_server>Remote NUT Server</netclient_remote_server>
<time_hours>h</time_hours>
<time_minutes>m</time_minutes>
<time_seconds>s</time_seconds>
</translations>
</nut>
</metadata>
fichtner marked this conversation as resolved.
Show resolved Hide resolved
214 changes: 214 additions & 0 deletions sysutils/nut/src/opnsense/www/js/widgets/Nut.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/*
* 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.
}

// Creates and returns the HTML structure for the widget, including a table without a header.
getMarkup() {
let $container = $('<div></div>'); // Create a container div.
let $nut_netclient_table = this.createTable('nut-netclient-table', {
headerPosition: 'none', // Disable table headers.
});
$container.append($nut_netclient_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_status = await this.ajaxCall('/api/nut/service/status');

// If the service is not running, display a message and stop further processing.
if (nut_status.status !== 'running') {
$('#nut-netclient-table').html(`<a href="/ui/nut/index">${this.translations.unconfigured}</a>`);
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-netclient-table').html(`<a href="/ui/nut/index#subtab_nut-ups-netclient">${this.translations.netclient_unconfigured}</a>`);
// return;
// }

// Fetch the UPS status data from the server.
const { response } = await this.ajaxCall('/api/nut/diagnostics/upsstatus');

// Parse the UPS status data into a key-value object.
const data = response.split('\n').reduce((acc, line) => {
const [key, value] = line.split(': ');
if (key) acc[key] = value; // Only add non-empty keys.
return acc;
}, {});

// 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.
data['device.mfr'] && data['device.model'] && this.makeTextRow("status_model", `${data['device.mfr']} - ${data['device.model']}`),
// Display the UPS Status if available.
data['ups.status'] && this.makeColoredTextRow('status_status', this.nutMapStatus(data['ups.status']), /OL/, /OB|LB|RB|DISCHRG/, data['ups.status']),
// Display the UPS load with percentage and optional nominal power.
data['ups.load'] && data['ups.realpower'] && this.makeUpsLoadRow('status_load', parseFloat(data['ups.load']), parseFloat(data['ups.realpower'])),
// Display the battery charge as a progress bar if available.
data['battery.charge'] && this.makeProgressBarRow("status_bcharge", parseFloat(data['battery.charge'])),
// Display the battery status if available.
data['battery.charger.status'] && this.makeTextRow('status_battery', data['battery.charger.status']),
// Display the formatted battery runtime if available.
data['battery.runtime'] && this.makeTextRow('status_timeleft', this.formatRuntime(parseInt(data['battery.runtime'], 10))),
// Display the input voltage and frequency if available.
data['input.voltage'] && data['input.frequency'] && this.makeTextRow('status_input_power', `${data['input.voltage']} V | ${data['input.frequency']} Hz`),
// Display the output voltage and frequency if available.
data['output.voltage'] && data['output.frequency'] && this.makeTextRow('status_output_power', `${data['output.voltage']} V | ${data['output.frequency']} Hz`),
// Display the result of the UPS self-test if available.
data['ups.test.result'] && this.makeTextRow('status_selftest', data['ups.test.result']),
].filter(Boolean); // Remove any undefined or null rows.

// Update the table with the prepared rows.
this.updateTable('nut-netclient-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 = $('<b></b>').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 = $('<span class="text-center"></span>').text(text).css({
position: 'absolute',
left: 0,
right: 0
});

const $barEl = $('<div class="progress-bar"></div>').css({
width: `${progress}%`,
zIndex: 0
});

return $('<div class="progress"></div>').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;
}
}
fichtner marked this conversation as resolved.
Show resolved Hide resolved