diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d3d695..9534529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,22 @@ # ChangeLog ---- develop --- - +--- 1.4.0 --- + +* feature: Add support for monitoring UPSes through Network UPS Tools (NUT) as a third collector type, "NUT Based". The poller talks to `upsd` (TCP 3493 by default) over a native PHP socket implementation of the NUT protocol, so no `upsc` or other CLI tool is needed. Optional username/password authentication and STARTTLS (with certificate verification) are supported, and one NUT server can serve several UPSes through the new NUT UPS Name setting. NUT status flags are translated to the existing apcupsd vocabulary (OL to ONLINE, OB to ONBATT, and so on) so the dashboards, fleet totals and alerts keep working unchanged +* issue: Widen the status, model, serial number, firmware, driver and name columns of `apcupsd_ups_stats` so multi-flag NUT statuses and longer NUT strings are not truncated +* feature: Let NUT STARTTLS work with self-signed certificates: a per-UPS CA / Certificate File to verify the server against (certificate and host name are still checked), or an Allow Self-Signed Certificate option that encrypts without verifying the server +* feature: Add a per-UPS Timeout (seconds) setting, 5 by default and limited to 1-120, for `apcupsd` and NUT UPS'. NUT connects and conversations are now bounded by twice the timeout in total, and `apcaccess` is run through Cacti's `exec_with_timeout()` so a hung `apcaccess` can no longer stall the poller; a timed out `apcaccess` now records an error on the UPS +* feature: Make the UPSes table on the Fleet Dashboard sort like the HMIB dashboards: click (or press Enter/Space on) a column header to sort and click again to reverse, in the card and in its maximize dialog +* issue: Right-align the Load, Charge, Time Left, Est. Power and Last Updated columns of the Fleet Dashboard UPSes table * feature: Share the UPS Dashboard card arrangement across every UPS (graph cards stay per UPS since they belong to each device), hide N/A rows in the UPS Information card, combine Cable and Driver on one row, and add a per-UPS Estimated Power gauge card that reuses the shared nominal power x load % estimate * issue: Show N/A for a slave UPS's (e.g. ONLINE SLAVE) estimated power on the Fleet Dashboard and leave it out of the fleet power total, since its draw is already counted in its master's - * feature: Estimate each UPS's power draw as nominal power x load % for UPSes that do not report real wattage, and show it on the Fleet Dashboard (an "Est. Power" tile, a new "Fleet Power" gauge card of total estimated watts against rated capacity, and an "Est. Power" column with a fleet total in the UPSes card) and as a sortable "Est. Power" column in the UPSes list -* feature: Make the Fleet Dashboard UPSes table sortable by UPS, Status, Load, Charge, Time Left, Est. Power and Last Updated; the choice persists in the browser and survives card refreshes +* feature: Make the Fleet Dashboard UPSes table sortable by UPS, Status, Load, Charge, Time Left, Est. Power and Last Updated * issue: Stop the dashboard auto-refresh interval following the user to another page by driving Cacti's `refreshMSeconds`/`refreshPage` page-timer globals (as the Graphs page does) instead of storing the interval in the session +* feature: Auto-refresh now updates the dashboard's cards in place instead of reloading the whole page: through Cacti's `refreshFunction` page-timer hook where core has it (Cacti 1.3+), and with a timer of its own on Cacti 1.2.x, which has no such hook and stops when the dashboard leaves the screen. The chosen table sort is kept across a refresh (the live one, even if it was changed while the refresh was in flight), overlapping refresh rounds are skipped, and the full-page refresh remains the fallback when the dashboard script is not loaded +* issue: Mark a NUT UPS's linked Cacti host down when its NUT query fails (these devices have availability checking disabled, so core polling cannot detect it), and stop `upsd` from holding a poll open past the deadline by trickling bytes +* issue: Stop the UPS edit form from rewriting an existing UPS's port (3551 to 3493 or back) when it opens; the default port is now only swapped when the collector type is changed by hand +* issue: Store the start time, manufacture date, master update, last on battery and last self test of `apcupsd_ups_stats` as unknown (NULL) when the UPS does not report them, instead of letting each poll invent the current time * security: Replace the confirmation pages' inline `onClick='cactiReturnTo()'` Cancel buttons with the CSP-safe `cactiReturnTo` class so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive * security: Move the UPS filter selects' inline onChange handlers into the ready block so the page no longer trips Cacti's Content-Security-Policy script-src-attr directive * dev: Enforce patch coverage of changed lines in CI and remove the inert COMPOSER_ROOT_VERSION env from the Pest step diff --git a/INFO b/INFO index 6579fba..f581827 100644 --- a/INFO +++ b/INFO @@ -1,6 +1,6 @@ [info] name = apcupsd -version = 1.3.1 +version = 1.4.0 longname = UPS Monitor Plugin for Cacti author = The Cacti Group email = diff --git a/README.md b/README.md index 690300f..ed6c6c5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # apcupsd -This plugin is to be used to track UPS status from the `apcupsd` daemon as -well as SNMP enabled UPS'. The `apcupsd` daemon is able to provide information -on APC and other types of UPS' over various network typologies such as USB, -Modbus, Ethernet, etc. I have added basic SNMP support in order to provide -direct access to certain models over the SNMP protocol thus removing the need -for an `apcupsd` host to provide the monitoring details as well. +This plugin is to be used to track UPS status from the `apcupsd` daemon, from +the Network UPS Tools (NUT) `upsd` server, as well as from SNMP enabled UPS'. +The `apcupsd` daemon is able to provide information on APC and other types of +UPS' over various network typologies such as USB, Modbus, Ethernet, etc. NUT +provides the same for a very wide range of UPS vendors. I have added basic +SNMP support in order to provide direct access to certain models over the SNMP +protocol thus removing the need for an `apcupsd` host to provide the monitoring +details as well. Each `apcupsd` UPS is defined by a network address and port name. Cacti uses the `apcaccess` command to gather statistics from the UPS and store them @@ -16,7 +18,7 @@ Cacti database can be used for Reporting, Graphing, and Alerting. This plugin allows Cacti Administrators to easily track their Data Center UPS's that do not support SNMP protocol, but that are supported by the -APCUPSD daemon. +APCUPSD daemon or by NUT. ## Features @@ -24,6 +26,11 @@ APCUPSD daemon. * Log information to Cacti database +* Three collector types: `apcupsd` (via `apcaccess`), SNMP, and NUT (via the + native NUT network protocol) + +* Fleet and per-UPS dashboards, with sortable tables + ## Cacti compatibility If you are running a version of Cacti below 1.2.31, please add the function @@ -41,14 +48,77 @@ $(document).off('click.cactiReturnTo', '.cactiReturnTo') ## Installation Install just like any other plugin, just throw it in the plugin directory, and -Install and Enabled from the Plugin Management Interface. Your Cacti install -will require the Linux `apcupsd` package be installed and `apcaccess` be in the -system path. +Install and Enabled from the Plugin Management Interface. To monitor +`apcupsd` UPS', your Cacti install will require the Linux `apcupsd` package be +installed and `apcaccess` be in the system path. NUT UPS' need no extra +software on the Cacti poller. After you have installed the plugin, install the Device Package in the templates folder from Console > Import/Export > Import Package. This Device Template will be used for showing available graphs. +## Monitoring UPS' with NUT + +Support for Network UPS Tools (NUT) is new in version 1.4.0. The plugin talks +to the NUT `upsd` data server directly with a native PHP implementation of the +NUT network protocol, so the NUT client tools (`upsc`) do not have to be +installed on the Cacti poller, and `curl` is not involved. The only +requirement is that the poller can open a TCP connection to `upsd`, which +listens on port 3493 by default. + +To add a NUT UPS, go to the UPSes page, add a UPS, and choose the `NUT Based` +type: + +* **Hostname** and **TCP Port**: the host running `upsd` and its port (3493). + +* **NUT UPS Name**: the name of the UPS on that server, as listed by + `upsc -l ` (the `ups` in `ups@host`). One `upsd` can serve several + UPS', so add one UPS here for each of them. + +* **NUT Username** and **NUT Password**: optional. NUT servers have always + allowed anonymous read-only access and that is how they are normally + monitored, so leave these blank unless your `upsd.users` is set up to require + authentication. A user with no actions is enough, as the plugin only reads + data. The password is stored in the Cacti database the same way SNMP + community strings and passwords are. + +* **Use TLS (STARTTLS)**: encrypts the connection before any credentials are + sent (the `openssl` PHP extension is required). The server certificate and + host name are verified, so by default the certificate must be trusted by the + Cacti poller. For a homelab with a self-signed certificate there are two + ways around that: + + * **CA / Certificate File** (recommended): the full path, on the poller, to a + PEM file holding the CA certificate, or the self-signed server certificate + itself (for example `/etc/nut/upsd.pem` copied from the NUT server). The + server is still authenticated against that file, and the Hostname must + match a name in the certificate. + + * **Allow Self-Signed Certificate**: accepts any certificate without + checking it. The connection is encrypted but the server's identity is not + verified, so a man-in-the-middle could still read the NUT credentials. Use + it only on a network you trust, and prefer the certificate file. + +* **Timeout (seconds)**: how long the poller waits for the NUT server, from 1 + to 120 seconds (5 by default). It bounds the connection attempt and each + reply, and the connection plus the whole conversation together never take + longer than twice the timeout, so an unresponsive server cannot hold up the + poller. The same setting applies to `apcupsd` UPS', where `apcaccess` is + stopped if it has not finished in time. SNMP UPS' keep using the SNMP + timeout. + +On the NUT server, `upsd` only listens on localhost by default. To let a +Cacti poller reach it, add a `LISTEN` line to `upsd.conf` (for example +`LISTEN 0.0.0.0 3493`), restart `upsd`, and allow the poller through the +firewall. + +NUT reports power state with short flags. These are translated into the +`apcupsd` vocabulary when the data is saved (`OL` becomes `ONLINE`, `OB` becomes +`ONBATT`, `LB` becomes `LOWBATT`, `RB` becomes `REPLACEBATT`, and `OVER` +becomes `OVERLOAD`), so the dashboards and fleet totals treat NUT and `apcupsd` +UPS' the same way. A Cacti device is created for each NUT UPS automatically, +the same as for `apcupsd` UPS'. + ## Possible Bugs If you figure out this problem, see the Cacti forums! @@ -56,10 +126,11 @@ If you figure out this problem, see the Cacti forums! ## Future Changes Currently the SNMP based collection is functional but only covering APC UPS'. -I will likely fix that in the next release. Need some help from others.l +I will likely fix that in the next release. Need some help from others. -I've been looking at the NUT product to see if it's something I would add -support for down the line, but as of yet, I have not made that decision. +Measured real power (`ups.realpower`) is reported by many NUT UPS' but is not +stored yet; the plugin continues to estimate watts from the nominal power +rating and the load. Got any ideas or complaints, please log an issue on GitHub. diff --git a/css/apcupsd.css b/css/apcupsd.css index 7a83be1..d5794e3 100644 --- a/css/apcupsd.css +++ b/css/apcupsd.css @@ -524,36 +524,33 @@ white-space: nowrap; } -/* Sortable column headers: the button inherits the header look and shows an - arrow for the active direction (exposed to assistive tech via aria-sort). */ -.apcupsdDashSortBtn { - appearance: none; - background: none; - border: 0; - padding: 0; - margin: 0; - font: inherit; - font-weight: inherit; - color: inherit; - cursor: pointer; - text-align: inherit; +.apcupsdDashTable td.apcupsdDashNum, +.apcupsdDashTable th.apcupsdDashNum { + text-align: right; + white-space: nowrap; } -.apcupsdDashSortBtn:hover, -.apcupsdDashSortBtn:focus-visible { - text-decoration: underline; +/* Sortable column headers (same behaviour as the HMIB dashboards): click or + press Enter/Space to sort; the arrow shows the active direction. */ +.apcupsdDashTable th.apcupsdDashSortable { + cursor: pointer; + user-select: none; } -.apcupsdDashSortable[aria-sort="ascending"] .apcupsdDashSortBtn::after { - content: " \25B2"; - font-size: 0.75em; +.apcupsdDashTable th.apcupsdDashSortable:hover { + background: color-mix(in srgb, currentColor 8%, transparent); } -.apcupsdDashSortable[aria-sort="descending"] .apcupsdDashSortBtn::after { - content: " \25BC"; - font-size: 0.75em; +.apcupsdDashTable th.apcupsdDashSortable::after { + content: '\2195'; + margin-left: 5px; + opacity: 0.4; + font-size: 10px; } +.apcupsdDashTable th.apcupsdDashSortAsc::after { content: '\2191'; opacity: 0.9; } +.apcupsdDashTable th.apcupsdDashSortDesc::after { content: '\2193'; opacity: 0.9; } + /* Fleet total row under the UPSes table. */ .apcupsdDashTotalRow > td { border-top: 2px solid color-mix(in srgb, currentColor 22%, transparent); diff --git a/includes/dashboard.php b/includes/dashboard.php index 7d0e001..1ffc20d 100644 --- a/includes/dashboard.php +++ b/includes/dashboard.php @@ -1119,8 +1119,8 @@ function apcupsd_dashboard_card_body(string $key, $ups_id): string { print '' . html_escape($label) . '' . html_escape($value) . ''; }; - $type = (int) ($ups['type_id'] ?? 0) === 1 ? __('APC UPSD', 'apcupsd') : __('SNMP', 'apcupsd'); - $port = (int) ($ups['type_id'] ?? 0) === 1 ? ($ups['port'] ?? '') : ($ups['snmp_port'] ?? ''); + $type = [1 => __('APC UPSD', 'apcupsd'), 2 => __('SNMP', 'apcupsd'), 3 => __('NUT', 'apcupsd')][(int) ($ups['type_id'] ?? 0)] ?? ''; + $port = (int) ($ups['type_id'] ?? 0) !== 2 ? ($ups['port'] ?? '') : ($ups['snmp_port'] ?? ''); $host = trim((string) ($ups['hostname'] ?? '')) . ($port !== '' ? ':' . $port : ''); $volts = function($value): string { @@ -1676,9 +1676,14 @@ function apcupsd_dashboard_layout_save(): string { if (!function_exists('apcupsd_dashboard_print_refresh')) { /** - * Arm Cacti's page timer for a dashboard through the refreshMSeconds, - * refreshPage and refreshIsLogout globals that Cacti's setupPageTimeout() - * reads, the same way graph_view.php does. Nothing is stored in the session + * Arm automatic refresh for a dashboard. The refreshMSeconds, refreshPage and + * refreshIsLogout globals that Cacti's setupPageTimeout() reads are set the + * same way graph_view.php does, then apcupsdDashStartRefresh() (js/apcupsd_dashboard.js) + * picks how to use them. Cacti 1.3+ has a refreshFunction hook in that timer, + * so the cards are refreshed in place; Cacti 1.2.x has no hook, so the page + * timer is stopped and the script runs its own timer, which ends when the + * dashboard leaves the screen. Without the script, Cacti's timer is armed + * as usual and reloads the page. Nothing is stored in the session * (Cacti's session-based page refresh leaves its value there for whichever * page renders next), so the interval cannot follow the user to another * page: Cacti clears its timer when navigating, and the next page's footer @@ -1687,7 +1692,7 @@ function apcupsd_dashboard_layout_save(): string { * still shown. * * @param int $seconds The auto-refresh interval; 0 or less leaves Cacti's default timer. - * @param string $page The URL Cacti re-fetches on each tick. + * @param string $page The URL Cacti falls back to re-fetching on each tick. * * @return void */ @@ -1702,7 +1707,7 @@ function apcupsd_dashboard_print_refresh(int $seconds, string $page): void { . 'refreshIsLogout = false;' . 'refreshPage = ' . json_encode($page, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';' . 'refreshMSeconds = ' . ($seconds * 1000) . ';' - . 'setupPageTimeout();' + . 'if (typeof apcupsdDashStartRefresh === "function") { apcupsdDashStartRefresh(); } else { setupPageTimeout(); }' . '});'; } } @@ -2343,27 +2348,26 @@ function apcupsd_fleet_card_body(string $key): string { LEFT JOIN apcupsd_ups_stats AS stats ON stats.ups_id = ups.id ORDER BY ups.name"); - // Headers carry data-sort-* hooks that js/apcupsd_dashboard.js uses to sort - // the rows client-side; numeric columns open descending (largest first). - $th = function(string $key, string $label, string $type, string $class = '') { - return '' - . ''; + // Sortable headers follow the HMIB dashboards: js/apcupsd_dashboard.js sorts + // the rows client-side, numeric columns (data-sort="num") by the cell's + // data-sort-value and the rest by their text. + $th = function(string $label, bool $numeric = false, bool $right = false) { + return '' . html_escape($label) . ''; }; - // Empty data-sort values (N/A) always sort last. $sort_num = function($value): string { return $value !== null && is_numeric($value) ? (string) (float) $value : ''; }; - print '' + print '
' . '' - . $th('name', __('UPS', 'apcupsd'), 'text') - . $th('status', __('Status', 'apcupsd'), 'text') - . $th('load', __('Load', 'apcupsd'), 'num', 'apcupsdDashNum') - . $th('charge', __('Charge', 'apcupsd'), 'num', 'apcupsdDashNum') - . $th('timeleft', __('Time Left', 'apcupsd'), 'num', 'apcupsdDashNum') - . $th('power', __('Est. Power', 'apcupsd'), 'num', 'apcupsdDashNum') - . $th('updated', __('Last Updated', 'apcupsd'), 'text') + . $th(__('UPS', 'apcupsd')) + . $th(__('Status', 'apcupsd')) + . $th(__('Load', 'apcupsd'), true, true) + . $th(__('Charge', 'apcupsd'), true, true) + . $th(__('Time Left', 'apcupsd'), true, true) + . $th(__('Est. Power', 'apcupsd'), true, true) + . $th(__('Last Updated', 'apcupsd'), false, true) . ''; if (cacti_sizeof($upses)) { @@ -2380,13 +2384,13 @@ function apcupsd_fleet_card_body(string $key): string { print '' . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' . ''; } } else { diff --git a/includes/database.php b/includes/database.php index 5889f7e..476e06d 100644 --- a/includes/database.php +++ b/includes/database.php @@ -55,6 +55,13 @@ function apcupsd_ups_table_data(): array { $data['columns'][] = ['name' => 'status', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'default' => 0]; $data['columns'][] = ['name' => 'hostname', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'port', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'default' => 3551]; + $data['columns'][] = ['name' => 'nut_ups_name', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'nut_username', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'nut_password', 'type' => 'varchar(128)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'nut_tls', 'type' => 'char(2)', 'NULL' => true, 'default' => '']; + $data['columns'][] = ['name' => 'nut_cafile', 'type' => 'varchar(255)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'nut_tls_selfsigned', 'type' => 'char(2)', 'NULL' => true, 'default' => '']; + $data['columns'][] = ['name' => 'timeout', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'default' => 5]; $data['columns'][] = ['name' => 'enabled', 'type' => 'char(2)', 'NULL' => true, 'default' => 'on']; $data['columns'][] = ['name' => 'error_message', 'type' => 'varchar(255)', 'NULL' => true, 'default' => '']; $data['columns'][] = ['name' => 'last_updated', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; @@ -80,18 +87,18 @@ function apcupsd_ups_stats_table_data(): array { $data['columns'][] = ['name' => 'ups_date', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; $data['columns'][] = ['name' => 'ups_hostname', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_version', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; - $data['columns'][] = ['name' => 'ups_name', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'ups_name', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_master', 'type' => 'varchar(128)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_cable', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; - $data['columns'][] = ['name' => 'ups_driver', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'ups_driver', 'type' => 'varchar(40)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_mode', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; - $data['columns'][] = ['name' => 'ups_starttime', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; - $data['columns'][] = ['name' => 'ups_mandate', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; - $data['columns'][] = ['name' => 'ups_masterupd', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; - $data['columns'][] = ['name' => 'ups_xonbatt', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; - $data['columns'][] = ['name' => 'ups_laststest', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; - $data['columns'][] = ['name' => 'ups_model', 'type' => 'varchar(40)', 'NULL' => false, 'default' => '']; - $data['columns'][] = ['name' => 'ups_status', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'ups_starttime', 'type' => 'timestamp', 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ups_mandate', 'type' => 'timestamp', 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ups_masterupd', 'type' => 'timestamp', 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ups_xonbatt', 'type' => 'timestamp', 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ups_laststest', 'type' => 'timestamp', 'NULL' => true, 'default' => null]; + $data['columns'][] = ['name' => 'ups_model', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'ups_status', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_dipsw', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_extbatts', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => null]; $data['columns'][] = ['name' => 'ups_badbatts', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => true, 'default' => null]; @@ -129,7 +136,7 @@ function apcupsd_ups_stats_table_data(): array { $data['columns'][] = ['name' => 'ups_selftest', 'type' => 'varchar(10)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_selftest_interval', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_statflag', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; - $data['columns'][] = ['name' => 'ups_serialno', 'type' => 'varchar(20)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'ups_serialno', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_nominal_voltage', 'type' => 'double', 'NULL' => true, 'default' => null]; $data['columns'][] = ['name' => 'ups_nominal_batt_voltage','type' => 'double', 'NULL' => true, 'default' => null]; $data['columns'][] = ['name' => 'ups_nominal_power', 'type' => 'double', 'NULL' => true, 'default' => null]; @@ -137,7 +144,7 @@ function apcupsd_ups_stats_table_data(): array { $data['columns'][] = ['name' => 'ups_ambtemp', 'type' => 'double', 'NULL' => true, 'default' => null]; $data['columns'][] = ['name' => 'ups_humidity', 'type' => 'double', 'NULL' => true, 'default' => null]; $data['columns'][] = ['name' => 'ups_internal_temp', 'type' => 'double', 'NULL' => true, 'default' => null]; - $data['columns'][] = ['name' => 'ups_firmware', 'type' => 'varchar(40)', 'NULL' => false, 'default' => '']; + $data['columns'][] = ['name' => 'ups_firmware', 'type' => 'varchar(64)', 'NULL' => false, 'default' => '']; $data['columns'][] = ['name' => 'ups_end_rec', 'type' => 'timestamp', 'NULL' => false, 'default' => 'CURRENT_TIMESTAMP']; $data['primary'] = ['ups_id']; $data['type'] = 'InnoDB'; diff --git a/includes/functions.php b/includes/functions.php index 54e903a..35605e6 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -104,6 +104,25 @@ function apcupsd_normalize_positive_int($value, $default = 0): int { } } +if (!function_exists('apcupsd_normalize_timeout')) { + /** + * Coerces a configured timeout to whole seconds between 1 and 120, falling + * back to a default when the value is not a positive number. Used for the + * per-UPS timeout of the apcaccess and NUT collectors so a blank, zero or + * absurd value can neither disable the timeout nor stall the poller. + * + * @param mixed $value The configured timeout in seconds. + * @param int $default The timeout to use when $value is not a positive number. + * + * @return int The timeout in seconds, from 1 to 120. + */ + function apcupsd_normalize_timeout($value, $default = 5): int { + $seconds = apcupsd_normalize_positive_int($value, (int) $default); + + return max(1, min(120, $seconds)); + } +} + if (!function_exists('apcupsd_get_site_sql_where')) { /** * Builds a SQL condition restricting results to a single site id, when diff --git a/includes/nut.php b/includes/nut.php new file mode 100644 index 0000000..9c54f6e --- /dev/null +++ b/includes/nut.php @@ -0,0 +1,634 @@ +|null The words, or null when a quote is never closed. + */ + function apcupsd_nut_tokenize(string $line): ?array { + $tokens = []; + $length = strlen($line); + $i = 0; + + while ($i < $length) { + $char = $line[$i]; + + if ($char === ' ' || $char === "\t") { + $i++; + + continue; + } + + $token = ''; + + if ($char === '"') { + $closed = false; + $i++; + + while ($i < $length) { + $char = $line[$i]; + + if ($char === '\\' && $i + 1 < $length) { + $token .= $line[$i + 1]; + $i += 2; + + continue; + } + + $i++; + + if ($char === '"') { + $closed = true; + + break; + } + + $token .= $char; + } + + if (!$closed) { + return null; + } + } else { + while ($i < $length && $line[$i] !== ' ' && $line[$i] !== "\t") { + $token .= $line[$i]; + $i++; + } + } + + $tokens[] = $token; + } + + return $tokens; + } +} + +if (!function_exists('apcupsd_nut_error')) { + /** + * Turn an "ERR " reply into a message suitable for the UPS error column. + * + * @param string $line The ERR line sent by upsd. + * @param string $ups The NUT UPS name that was queried. + * + * @return string The translated message. + */ + function apcupsd_nut_error(string $line, string $ups): string { + $parts = explode(' ', trim($line)); + $code = preg_replace('/[^A-Za-z0-9\-]/', '', $parts[1] ?? ''); + + switch ($code) { + case 'ACCESS-DENIED': + case 'PASSWORD-REQUIRED': + case 'USERNAME-REQUIRED': + case 'INVALID-PASSWORD': + case 'INVALID-USERNAME': + return __('The NUT server denied access. Check the NUT username and password.', 'apcupsd'); + case 'UNKNOWN-UPS': + return sprintf(__('The NUT server does not know a UPS named "%s".', 'apcupsd'), $ups); + case 'DATA-STALE': + return __('The NUT server has stale data for this UPS: its driver is not responding.', 'apcupsd'); + default: + return sprintf(__('The NUT server reported an error: %s', 'apcupsd'), $code === '' ? 'ERR' : $code); + } + } +} + +if (!function_exists('apcupsd_nut_session')) { + /** + * Run one NUT conversation over a transport: optionally upgrade to TLS, + * identify the server, authenticate when credentials are given, and read + * every variable of one UPS. The transport is injected as callables so the + * protocol logic does not depend on a live socket. + * + * @param callable $send Sends one command line (no line ending). + * @param callable $recv Returns the next line without its ending, or null at EOF/timeout. + * @param string $ups The NUT UPS name to read. + * @param string $username The upsd.users user; empty for anonymous read-only access. + * @param string $password The upsd.users password. + * @param callable|null $enable_tls Upgrades the connection after STARTTLS; null to stay plain text. + * + * @return array{vars: array, version: string, error: string} An empty 'error' means success. + */ + function apcupsd_nut_session(callable $send, callable $recv, string $ups, string $username = '', string $password = '', ?callable $enable_tls = null): array { + $result = ['vars' => [], 'version' => '', 'error' => '']; + + if (!apcupsd_nut_valid_ups_name($ups)) { + $result['error'] = __('Invalid NUT hostname, port or UPS name configuration', 'apcupsd'); + + return $result; + } + + // Sends a command and returns [success, first reply line or error message]. + $reply = function(string $command) use ($send, $recv, $ups): array { + $send($command); + + $line = $recv(); + + if ($line === null) { + return [false, __('The NUT server closed the connection unexpectedly.', 'apcupsd')]; + } + + return strncmp($line, 'ERR', 3) === 0 ? [false, apcupsd_nut_error($line, $ups)] : [true, $line]; + }; + + $steps = []; + + if ($enable_tls !== null) { + $steps[] = ['STARTTLS', 'tls']; + } + + $steps[] = ['VER', 'version']; + + if ($username !== '') { + $quoted_user = apcupsd_nut_quote($username); + $quoted_pass = apcupsd_nut_quote($password); + + if ($quoted_user === null || $quoted_pass === null) { + $result['error'] = __('The NUT username or password contains characters that cannot be sent.', 'apcupsd'); + + return $result; + } + + $steps[] = ['USERNAME ' . $quoted_user, 'auth']; + $steps[] = ['PASSWORD ' . $quoted_pass, 'auth']; + } + + foreach ($steps as [$command, $kind]) { + [$ok, $text] = $reply($command); + + if (!$ok) { + $result['error'] = $text; + + return $result; + } + + if ($kind === 'version') { + $result['version'] = substr(trim($text), 0, 64); + } + + if ($kind === 'tls' && !$enable_tls()) { + $result['error'] = __('TLS negotiation with the NUT server failed.', 'apcupsd'); + + return $result; + } + } + + [$ok, $text] = $reply('LIST VAR ' . $ups); + + if (!$ok) { + $result['error'] = $text; + + return $result; + } + + $done = false; + + for ($count = 0; $count < APCUPSD_NUT_MAX_LINES && !$done; $count++) { + $line = $recv(); + + if ($line === null) { + $result['error'] = __('The NUT server closed the connection unexpectedly.', 'apcupsd'); + + return $result; + } + + $tokens = apcupsd_nut_tokenize($line) ?? []; + $done = ($tokens[0] ?? '') === 'END'; + + if (count($tokens) === 4 && $tokens[0] === 'VAR') { + $result['vars'][$tokens[2]] = $tokens[3]; + } + } + + if (!$done) { + $result['vars'] = []; + $result['error'] = __('The NUT server sent too much data.', 'apcupsd'); + + return $result; + } + + $send('LOGOUT'); + + return $result; + } +} + +if (!function_exists('apcupsd_nut_query_stream')) { + /** + * Run a NUT conversation over an already connected stream. + * + * @param resource $stream The connected stream. + * @param string $hostname The server name (kept for TLS peer verification, set on the stream context). + * @param string $ups The NUT UPS name to read. + * @param string $username The upsd.users user; empty for anonymous access. + * @param string $password The upsd.users password. + * @param bool $tls Whether to require STARTTLS before sending credentials. + * @param int $timeout Seconds the exchange may take per step; used for the default deadline and the timeout message. + * @param float|null $deadline Unix time (microtime) the whole exchange must end by; defaults to twice the timeout from now. + * Reads are polled against it without blocking, so it holds even when a server trickles bytes. + * + * @return array{vars: array, version: string, error: string} + */ + function apcupsd_nut_query_stream($stream, string $hostname, string $ups, string $username, string $password, bool $tls, int $timeout, ?float $deadline = null): array { + $deadline = $deadline ?? microtime(true) + ($timeout * 2); + $buffer = ''; + + // A command is a few hundred bytes written into an empty socket buffer, so it completes in one call. + $send = function(string $command) use ($stream): void { + fwrite($stream, $command . "\n"); + }; + + // Reads are non-blocking and polled against the deadline. fgets() would block for the + // stream timeout on every read, so a server dripping bytes without ever finishing a + // line could hold it open well past the deadline. + stream_set_blocking($stream, false); + + $recv = function() use ($stream, $deadline, &$buffer): ?string { + while (($end = strpos($buffer, "\n")) === false) { + if (microtime(true) >= $deadline || strlen($buffer) > 8192) { + return null; + } + + $chunk = fread($stream, 8192); + + if ($chunk === false || ($chunk === '' && feof($stream))) { + return null; + } + + if ($chunk === '') { + usleep(10000); + } + + $buffer .= $chunk; + } + + $line = substr($buffer, 0, $end); + $buffer = (string) substr($buffer, $end + 1); + + return rtrim($line, "\r"); + }; + + // The handshake needs a blocking stream, bounded by what is left of the deadline. + $enable_tls = $tls ? function() use ($stream, $deadline): bool { + stream_set_blocking($stream, true); + stream_set_timeout($stream, max(1, (int) ceil($deadline - microtime(true)))); + + $enabled = (bool) @stream_socket_enable_crypto($stream, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + + stream_set_blocking($stream, false); + + return $enabled; + } : null; + + $result = apcupsd_nut_session($send, $recv, $ups, $username, $password, $enable_tls); + + // A read that gave up because time ran out looks like a closed connection to the session. + if ($result['error'] === __('The NUT server closed the connection unexpectedly.', 'apcupsd') && microtime(true) >= $deadline) { + $result['error'] = sprintf(__('The NUT server did not answer within %s seconds.', 'apcupsd'), $timeout); + } + + return $result; + } +} + +if (!function_exists('apcupsd_nut_valid_cafile')) { + /** + * Whether a value is acceptable as the path of the CA / certificate file used + * to verify the NUT server: an absolute local path with no stream wrapper + * (such as phar:// or http://) and no control characters. + * + * @param mixed $path The configured path. + * + * @return bool True when the path is a plain absolute file path. + */ + function apcupsd_nut_valid_cafile($path): bool { + $path = (string) $path; + + return strlen($path) <= 255 + && strpos($path, '://') === false + && preg_match('#^(/|[A-Za-z]:[\\\\/])[^\x00-\x1f\x7f]+$#', $path) === 1; + } +} + +if (!function_exists('apcupsd_nut_ssl_options')) { + /** + * The SSL stream-context options used for the STARTTLS upgrade. By default + * the certificate chain and host name are verified against the system trust + * store. A CA / certificate file makes that file the trust anchor instead, + * which is how a self-signed server certificate is trusted without weakening + * verification. Allowing self-signed certificates turns verification off, so + * the traffic is encrypted but the server's identity is not checked. + * + * @param string $hostname The NUT server's hostname, used for SNI and name verification. + * @param string $cafile Optional PEM file to verify the server against. + * @param bool $allow_self_signed Accept any certificate without verifying it. + * + * @return array The 'ssl' context options. + */ + function apcupsd_nut_ssl_options(string $hostname, string $cafile = '', bool $allow_self_signed = false): array { + $options = [ + 'verify_peer' => !$allow_self_signed, + 'verify_peer_name' => !$allow_self_signed, + 'allow_self_signed' => $allow_self_signed, + 'peer_name' => $hostname, + 'SNI_enabled' => true, + ]; + + if ($cafile !== '' && !$allow_self_signed) { + $options['cafile'] = $cafile; + } + + return $options; + } +} + +if (!function_exists('apcupsd_nut_query')) { + /** + * Connect to a NUT server over TCP and read every variable of one UPS. The + * connect and the whole conversation together are bounded by twice the + * timeout. When TLS is requested the certificate and host name are verified + * (see apcupsd_nut_ssl_options()), so install the server's CA, point the UPS + * at a CA / certificate file, or allow self-signed certificates for STARTTLS + * to succeed. + * + * @param string $hostname The NUT server's hostname or IP address. + * @param mixed $port The NUT server's TCP port. + * @param string $ups The NUT UPS name to read. + * @param string $username The upsd.users user; empty for anonymous access. + * @param string $password The upsd.users password. + * @param bool $tls Whether to require STARTTLS. + * @param int $timeout Seconds allowed to connect and per read, clamped to 1-120. + * @param string $cafile Optional PEM file to verify the server certificate against. + * @param bool $allow_self_signed Accept any certificate without verifying it (encrypts only). + * + * @return array{vars: array, version: string, error: string} + */ + function apcupsd_nut_query(string $hostname, $port, string $ups, string $username = '', string $password = '', bool $tls = false, int $timeout = 5, string $cafile = '', bool $allow_self_signed = false): array { + $started = microtime(true); + $hostname = trim($hostname); + $port = apcupsd_normalize_positive_int($port, APCUPSD_NUT_DEFAULT_PORT); + $timeout = apcupsd_normalize_timeout($timeout); + $cafile = trim($cafile); + + if (!apcupsd_nut_valid_host($hostname) || $port > 65535 || !apcupsd_nut_valid_ups_name($ups)) { + return ['vars' => [], 'version' => '', 'error' => __('Invalid NUT hostname, port or UPS name configuration', 'apcupsd')]; + } + + if ($tls && !$allow_self_signed && $cafile !== '' && (!apcupsd_nut_valid_cafile($cafile) || !is_readable($cafile))) { + return ['vars' => [], 'version' => '', 'error' => __('The NUT CA / certificate file cannot be read.', 'apcupsd')]; + } + + $target = (strpos($hostname, ':') !== false ? '[' . $hostname . ']' : $hostname) . ':' . $port; + $context = stream_context_create(['ssl' => apcupsd_nut_ssl_options($hostname, $cafile, $allow_self_signed)]); + + $stream = @stream_socket_client('tcp://' . $target, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $context); + + if ($stream === false) { + return ['vars' => [], 'version' => '', 'error' => sprintf(__('Unable to connect to the NUT server %s: %s', 'apcupsd'), $target, $errstr)]; + } + + $result = apcupsd_nut_query_stream($stream, $hostname, $ups, $username, $password, $tls, $timeout, $started + ($timeout * 2)); + + fclose($stream); + + return $result; + } +} + +if (!function_exists('apcupsd_nut_status')) { + /** + * Translate NUT's ups.status flags (OL, OB, LB, RB, ...) into the apcupsd + * vocabulary the rest of the plugin matches on (ONLINE, ONBATT, LOWBATT, ...). + * Purely informational charge flags (CHRG, DISCHRG) are dropped; unknown + * flags are kept as is. + * + * @param string $nut_status The raw ups.status value, for example "OL CHRG". + * + * @return string The apcupsd-style status, at most 64 characters. + */ + function apcupsd_nut_status(string $nut_status): string { + $map = [ + 'OL' => 'ONLINE', + 'OB' => 'ONBATT', + 'LB' => 'LOWBATT', + 'RB' => 'REPLACEBATT', + 'OVER' => 'OVERLOAD', + 'FSD' => 'SHUTTING DOWN', + 'CHRG' => '', + 'DISCHRG' => '', + ]; + + $out = []; + + foreach (preg_split('/\s+/', trim($nut_status), -1, PREG_SPLIT_NO_EMPTY) as $flag) { + $flag = strtoupper($flag); + $flag = $map[$flag] ?? $flag; + + if ($flag !== '' && !in_array($flag, $out, true)) { + $out[] = $flag; + } + } + + return substr(implode(' ', $out), 0, 64); + } +} + +if (!function_exists('apcupsd_nut_status_online')) { + /** + * Whether an apcupsd-style status reports the UPS as running on line power. + * + * @param string $status A status from apcupsd_nut_status(). + * + * @return bool True when the ONLINE flag is present. + */ + function apcupsd_nut_status_online(string $status): bool { + return in_array('ONLINE', explode(' ', $status), true); + } +} + +if (!function_exists('apcupsd_nut_variable_map')) { + /** + * The NUT variables stored in apcupsd_ups_stats: each column maps to the NUT + * variables that can fill it (first one present wins) and how the value is + * stored: 'float', 'minutes' (seconds converted to minutes) or 'text:N' + * (trimmed to N characters). + * + * @return array + */ + function apcupsd_nut_variable_map(): array { + return [ + 'ups_model' => [['ups.model', 'device.model'], 'text:64'], + 'ups_serialno' => [['ups.serial', 'device.serial'], 'text:64'], + 'ups_firmware' => [['ups.firmware'], 'text:64'], + 'ups_driver' => [['driver.name'], 'text:40'], + 'ups_load_percent' => [['ups.load'], 'float'], + 'ups_internal_temp' => [['ups.temperature'], 'float'], + 'ups_ambtemp' => [['ambient.temperature'], 'float'], + 'ups_humidity' => [['ambient.humidity'], 'float'], + 'ups_line_voltage' => [['input.voltage'], 'float'], + 'ups_line_frequency' => [['input.frequency'], 'float'], + 'ups_max_line_voltage' => [['input.voltage.maximum'], 'float'], + 'ups_min_line_voltage' => [['input.voltage.minimum'], 'float'], + 'ups_lowtrans' => [['input.transfer.low'], 'float'], + 'ups_hitrans' => [['input.transfer.high'], 'float'], + 'ups_nominal_voltage' => [['input.voltage.nominal'], 'float'], + 'ups_output_voltage' => [['output.voltage'], 'float'], + 'ups_nominal_output_voltage' => [['output.voltage.nominal'], 'float'], + 'ups_battery_charge' => [['battery.charge'], 'float'], + 'ups_mbattchg' => [['battery.charge.low'], 'float'], + 'ups_timeleft' => [['battery.runtime'], 'minutes'], + 'ups_mintimel' => [['battery.runtime.low'], 'minutes'], + 'ups_battery_voltage' => [['battery.voltage'], 'float'], + 'ups_nominal_batt_voltage' => [['battery.voltage.nominal'], 'float'], + 'ups_battery_date' => [['battery.date'], 'text:20'], + 'ups_nominal_power' => [['ups.realpower.nominal'], 'float'], + 'ups_dshutd' => [['ups.delay.shutdown'], 'text:20'], + 'ups_dwake' => [['ups.delay.start'], 'text:20'], + ]; + } +} + +if (!function_exists('apcupsd_nut_build_stats')) { + /** + * Build the apcupsd_ups_stats row for a NUT UPS from its variables. Columns + * the server did not report are left out so they take their NULL/empty + * defaults (the poller replaces the whole row each poll). + * + * @param array $vars The variables read from upsd. + * @param array $ups The apcupsd_ups row (id, hostname, nut_ups_name). + * @param string $version The upsd version string, when known. + * + * @return array Column => value. + */ + function apcupsd_nut_build_stats(array $vars, array $ups, string $version = ''): array { + $now = date('Y-m-d H:i:s'); + + $stats = [ + 'ups_id' => (int) $ups['id'], + 'ups_key' => 'NUT', + 'ups_date' => $now, + 'ups_hostname' => substr((string) $ups['hostname'], 0, 64), + 'ups_version' => $version !== '' ? $version : 'NUT', + 'ups_name' => substr((string) $ups['nut_ups_name'], 0, 64), + 'ups_cable' => 'Ethernet Link', + 'ups_driver' => 'NUT', + 'ups_mode' => 'Stand Alone', + 'ups_status' => apcupsd_nut_status((string) ($vars['ups.status'] ?? '')), + 'ups_end_rec' => $now, + ]; + + $stats['ups_battery_status'] = in_array('LOWBATT', explode(' ', $stats['ups_status']), true) ? 'Low' : 'Normal'; + + foreach (apcupsd_nut_variable_map() as $column => [$names, $kind]) { + foreach ($names as $name) { + if (!isset($vars[$name])) { + continue; + } + + $value = trim($vars[$name]); + + if (strncmp($kind, 'text:', 5) === 0) { + if ($value !== '') { + $stats[$column] = substr($value, 0, (int) substr($kind, 5)); + } + } elseif (is_numeric($value)) { + $stats[$column] = $kind === 'minutes' ? round((float) $value / 60, 2) : (float) $value; + } + + break; + } + } + + return $stats; + } +} diff --git a/js/apcupsd_dashboard.js b/js/apcupsd_dashboard.js index 26bc209..218031e 100644 --- a/js/apcupsd_dashboard.js +++ b/js/apcupsd_dashboard.js @@ -54,118 +54,114 @@ function apcupsdDashPost(grid, extra) { return data; } -var apcupsdDashSortStorageKey = 'apcupsd_dashboard_sort'; -var apcupsdDashSortState = null; - -/** The sort choices ({tableId: {key, direction}}). They live in memory so sorting - * and direction toggling still work when browser storage is blocked, and are - * seeded once from localStorage so a saved choice survives page loads. */ -function apcupsdDashSortRead() { - if (apcupsdDashSortState === null) { - try { - var saved = JSON.parse(window.localStorage.getItem(apcupsdDashSortStorageKey)); - apcupsdDashSortState = saved && typeof saved === 'object' ? saved : {}; - } catch (e) { - apcupsdDashSortState = {}; - } +/** Make a container's sortable headers focusable and screen-reader friendly. */ +function apcupsdDashPrepSortHeaders(container) { + if (!container) { + return; } - - return apcupsdDashSortState; + container.querySelectorAll('th.apcupsdDashSortable').forEach(function(th) { + if (!th.hasAttribute('tabindex')) { + th.setAttribute('tabindex', '0'); + } + // Keep the native columnheader role: aria-sort is only valid there, and the + // tabindex + Enter/Space keydown handler already makes it operable. + if (!th.hasAttribute('aria-sort')) { + th.setAttribute('aria-sort', 'none'); + } + }); } -/** Record the sort choices in memory, then persist them best-effort: a blocked - * or failing storage write must never lose the choice just made. */ -function apcupsdDashSortWrite(state) { - apcupsdDashSortState = state; - - try { - window.localStorage.setItem(apcupsdDashSortStorageKey, JSON.stringify(state)); - } catch (e) { +/** Sort a card table by the clicked column header, toggling asc/desc. Numeric + * columns (data-sort="num") sort by the cell's data-sort-value when present. */ +function apcupsdDashSortTable(th) { + var table = th.closest('table'); + if (!table) { return; } -} - -/** Sort a table's body rows by the header with the given key. Cells carry their - * sortable value in data-sort; rows with an empty value (N/A) always sort last, - * and ties keep their original order. */ -function apcupsdDashSortTable(table, key, direction) { - var body = table.tBodies[0]; - var header = table.querySelector('thead th[data-sort-key="' + key + '"]'); - if (!body || !header) { + var tbody = table.tBodies[0]; + if (!tbody) { return; } - - var column = header.cellIndex; - var numeric = header.dataset.sortType === 'num'; - var factor = direction === 'desc' ? -1 : 1; - - var rows = Array.prototype.map.call(body.rows, function(row, index) { - // Remember the server's row order the first time, so ties always fall back - // to it rather than to whatever the previous sort left behind. - if (row.getAttribute('data-sort-index') === null) { - row.setAttribute('data-sort-index', String(index)); + var headers = Array.prototype.slice.call(th.parentNode.children); + var index = headers.indexOf(th); + var numeric = th.dataset.sort === 'num'; + var asc = !th.classList.contains('apcupsdDashSortAsc'); + + headers.forEach(function(header) { + header.classList.remove('apcupsdDashSortAsc', 'apcupsdDashSortDesc'); + if (header.classList.contains('apcupsdDashSortable')) { + header.setAttribute('aria-sort', 'none'); } + }); + th.classList.add(asc ? 'apcupsdDashSortAsc' : 'apcupsdDashSortDesc'); + th.setAttribute('aria-sort', asc ? 'ascending' : 'descending'); - var cell = row.cells[column]; - return {row: row, index: parseInt(row.getAttribute('data-sort-index'), 10), value: cell ? cell.getAttribute('data-sort') : null}; + var rows = Array.prototype.slice.call(tbody.rows).filter(function(row) { + return !row.classList.contains('apcupsdDashEmptyRow'); }); - rows.sort(function(a, b) { - var aEmpty = a.value === null || a.value === ''; - var bEmpty = b.value === null || b.value === ''; - if (aEmpty || bEmpty) { - return aEmpty && bEmpty ? a.index - b.index : (aEmpty ? 1 : -1); + var value = function(row) { + var cell = row.cells[index]; + if (!cell) { + return numeric ? 0 : ''; } + if (numeric) { + var raw = cell.dataset.sortValue !== undefined ? cell.dataset.sortValue : cell.textContent; + var num = parseFloat(String(raw).replace(/[^0-9.\-]/g, '')); + return isNaN(num) ? 0 : num; + } + return cell.textContent.trim().toLowerCase(); + }; - var result = numeric - ? parseFloat(a.value) - parseFloat(b.value) - : a.value.localeCompare(b.value, undefined, {numeric: true, sensitivity: 'base'}); - - return result !== 0 ? result * factor : a.index - b.index; - }); - - rows.forEach(function(item) { - body.appendChild(item.row); + rows.sort(function(a, b) { + var va = value(a); + var vb = value(b); + if (va < vb) { return asc ? -1 : 1; } + if (va > vb) { return asc ? 1 : -1; } + return 0; }); - table.querySelectorAll('thead th.apcupsdDashSortable').forEach(function(th) { - th.setAttribute('aria-sort', th === header ? (direction === 'desc' ? 'descending' : 'ascending') : 'none'); + rows.forEach(function(row) { + tbody.appendChild(row); }); } -/** Re-apply the saved sort to every sortable table under a root, so a sort - * survives card refreshes and newly added cards. */ -function apcupsdDashApplySort(root) { - var state = apcupsdDashSortRead(); - - root.querySelectorAll('table[data-sort-table]').forEach(function(table) { - var saved = state[table.dataset.sortTable]; - if (saved && typeof saved.key === 'string' && (saved.direction === 'asc' || saved.direction === 'desc')) { - apcupsdDashSortTable(table, saved.key, saved.direction); - } - }); +/** Remember which header a card's table is sorted by, and in which direction. */ +function apcupsdDashSortSnapshot(card) { + var th = card.querySelector('th.apcupsdDashSortAsc, th.apcupsdDashSortDesc'); + if (!th) { + return null; + } + return {index: Array.prototype.indexOf.call(th.parentNode.children, th), asc: th.classList.contains('apcupsdDashSortAsc')}; } -/** Handle a sort-button click: the first click on a column uses its natural - * direction (numbers largest first, text A-Z), later clicks toggle it. */ -function apcupsdDashSortClick(button) { - var header = button.closest('th'); - var table = header ? header.closest('table[data-sort-table]') : null; - if (!header || !table) { +/** Re-apply a remembered sort to a freshly rendered card, so an in-place refresh + * does not throw away the order the user picked. */ +function apcupsdDashSortRestore(card, snapshot) { + if (!snapshot) { + return; + } + var th = card.querySelector('th.apcupsdDashSortable:nth-child(' + (snapshot.index + 1) + ')'); + if (!th) { return; } + // apcupsdDashSortTable() toggles the direction, so seed the opposite one. + th.classList.toggle('apcupsdDashSortAsc', !snapshot.asc); + apcupsdDashSortTable(th); +} - var id = table.dataset.sortTable; - var key = header.dataset.sortKey; - var state = apcupsdDashSortRead(); - var current = state[id]; - var direction = current && current.key === key - ? (current.direction === 'asc' ? 'desc' : 'asc') - : (header.dataset.sortFirst === 'desc' ? 'desc' : 'asc'); - - state[id] = {key: key, direction: direction}; - apcupsdDashSortWrite(state); - apcupsdDashApplySort(document); +/** Wire click and Enter/Space sorting on the sortable headers under a root. The + * handlers are delegated, so cards that are refreshed or added later keep working. */ +function apcupsdDashBindSort(root) { + $(root).off('click.apcupsdsort').on('click.apcupsdsort', 'th.apcupsdDashSortable', function() { + apcupsdDashSortTable(this); + }); + $(root).off('keydown.apcupsdsort').on('keydown.apcupsdsort', 'th.apcupsdDashSortable', function(event) { + if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { + event.preventDefault(); + apcupsdDashSortTable(this); + } + }); } function initApcupsdDashboard() { @@ -175,11 +171,9 @@ function initApcupsdDashboard() { return; } - // Delegated so the sort buttons also work in cards that are refreshed, added - // later, or copied into the maximize dialog. - $(document).off('click.apcupsdsort').on('click.apcupsdsort', '.apcupsdDashSortBtn', function() { - apcupsdDashSortClick(this); - }); + // Delegated so the sortable headers also work in cards that are refreshed or + // added later. + apcupsdDashBindSort(grid); grid.querySelectorAll('.apcupsdDashCard').forEach(function(card) { apcupsdDashBindCard(grid, card); @@ -270,7 +264,7 @@ function initApcupsdDashboard() { /** Wire a single card's drag handle (mouse + keyboard) and drag events. */ function apcupsdDashBindCard(grid, card) { - apcupsdDashApplySort(card); + apcupsdDashPrepSortHeaders(card); var handle = card.querySelector('.apcupsdDashCardDrag'); if (!handle) { @@ -408,9 +402,10 @@ function apcupsdDashRefreshCard(grid, card, done) { settle(); return; } - // Re-read the card's expanded state at swap time: the user may have toggled - // it while this request was in flight, and that live state must win over the - // state captured when the request was sent. + // Read the card's expanded state and sort at swap time: the user may have toggled + // or sorted it while this request was in flight, and that live state must win over + // anything captured when the request was sent. + var sorted = apcupsdDashSortSnapshot(card); if (card.classList.contains('apcupsdDashCardExpanded')) { fresh.classList.add('apcupsdDashCardExpanded'); } else { @@ -418,33 +413,101 @@ function apcupsdDashRefreshCard(grid, card, done) { } card.parentNode.replaceChild(fresh, card); apcupsdDashBindCard(grid, fresh); + apcupsdDashSortRestore(fresh, sorted); if (done) { done(); } }).fail(settle); } -/** Refresh every card on the page, spinning the toolbar glyph until all land. */ +/** Refresh every card on the page, spinning the toolbar glyph until all land. A + * refresh that is still in flight is not stacked on, so a slow server is never + * hit by overlapping rounds. */ +var apcupsdDashRefreshing = false; + function apcupsdDashRefreshAll(grid, button) { var icon = button ? button.querySelector('.fa, .fas') : null; var cards = Array.prototype.slice.call(grid.querySelectorAll('.apcupsdDashCard')); var pending = cards.length; - if (!pending) { + if (!pending || apcupsdDashRefreshing) { return; } + apcupsdDashRefreshing = true; if (icon) { icon.classList.add('fa-spin'); } cards.forEach(function(card) { apcupsdDashRefreshCard(grid, card, function() { pending--; - if (pending <= 0 && icon) { - icon.classList.remove('fa-spin'); + if (pending <= 0) { + apcupsdDashRefreshing = false; + if (icon) { + icon.classList.remove('fa-spin'); + } } }); }); } +/** Called by Cacti's page timer (the refreshFunction global, the hook the Graphs + * page uses too): refresh the cards in place instead of reloading the page. Cacti + * re-arms the timer after this returns. The timer outlives ajax page loads, so if + * the dashboard is no longer on screen the page that replaced it gets Cacti's + * normal full-page refresh back. */ +function apcupsdDashAutoRefresh() { + var grid = document.getElementById('apcupsd_dashboard'); + if (grid) { + apcupsdDashRefreshAll(grid, document.getElementById('apcupsd_dashboard_refresh_now')); + return; + } + + refreshFunction = ''; + if (typeof loadUrl === 'function' && typeof refreshPage !== 'undefined' && refreshPage !== '') { + loadUrl({url: refreshPage}); + } +} + +var apcupsdDashTimer = null; +var apcupsdDashTimerMs = 0; + +/** One tick of the timer used where Cacti has no refreshFunction hook. It stops + * for good once the dashboard is no longer on screen, so it can never follow + * the user to another page. */ +function apcupsdDashOwnTick() { + apcupsdDashTimer = null; + + var grid = document.getElementById('apcupsd_dashboard'); + if (!grid) { + return; + } + + apcupsdDashRefreshAll(grid, document.getElementById('apcupsd_dashboard_refresh_now')); + apcupsdDashTimer = window.setTimeout(apcupsdDashOwnTick, apcupsdDashTimerMs); +} + +/** Start automatic refresh with the interval the page script put in Cacti's + * refreshMSeconds global. Cacti 1.3+ has a refreshFunction hook in its page + * timer, so use that. Cacti 1.2.x has no such hook and its timer would reload + * the whole page (losing the cards' state), so there the page timer is stopped + * and a timer of our own refreshes the cards instead. */ +function apcupsdDashStartRefresh() { + if (typeof refreshFunction !== 'undefined') { + refreshFunction = 'apcupsdDashAutoRefresh()'; + setupPageTimeout(); + return; + } + + if (typeof myRefresh !== 'undefined') { + clearTimeout(myRefresh); + } + if (apcupsdDashTimer !== null) { + clearTimeout(apcupsdDashTimer); + } + + apcupsdDashTimerMs = refreshMSeconds; + apcupsdDashTimer = window.setTimeout(apcupsdDashOwnTick, apcupsdDashTimerMs); +} + /** Open a copy of a card's body in a large modal dialog. */ function apcupsdDashMaximize(card) { var dialog = document.getElementById('apcupsd_dashboard_dialog'); @@ -455,6 +518,10 @@ function apcupsdDashMaximize(card) { var body = card.querySelector('.apcupsdDashCardBody'); dialog.innerHTML = '
' + (body ? body.innerHTML : '') + '
'; + // The dialog is a clone outside the grid, so wire its own sort handlers. + apcupsdDashPrepSortHeaders(dialog); + apcupsdDashBindSort(dialog); + $(dialog).dialog({ modal: true, appendTo: 'body', diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 96892b4..003735d 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -116,6 +116,10 @@ msgstr "" msgid "SNMP" msgstr "" +#: includes/dashboard.php +msgid "NUT" +msgstr "" + #: includes/dashboard.php upses.php msgid "UPS Name" msgstr "" @@ -637,7 +641,11 @@ msgid "SNMP Based" msgstr "" #: upses.php -msgid "The Name you would like to give this UPS. For APCUPSD Devices, a corresponding Cacti Device will be created automatically for the UPS using this name. For SNMP based UPS's, you must create the Cacti Device first." +msgid "NUT Based" +msgstr "" + +#: upses.php +msgid "The Name you would like to give this UPS. For APCUPSD and NUT Devices, a corresponding Cacti Device will be created automatically for the UPS using this name. For SNMP based UPS's, you must create the Cacti Device first." msgstr "" #: upses.php @@ -713,7 +721,7 @@ msgid "APC UPSD Information" msgstr "" #: upses.php -msgid "The hostname of the host running apcupsd." +msgid "The hostname of the host running apcupsd or the NUT server (upsd)." msgstr "" #: upses.php @@ -721,7 +729,7 @@ msgid "TCP Port" msgstr "" #: upses.php -msgid "Enter the TCP Port for this UPS." +msgid "Enter the TCP Port for this UPS. apcupsd defaults to 3551 and NUT defaults to 3493." msgstr "" #: upses.php @@ -854,7 +862,7 @@ msgid "The Status of the apcupsd daemon on the target Host." msgstr "" #: upses.php -msgid "The Type of Collector. Currently APCUPSD and SNMP are supported." +msgid "The Type of Collector. Currently APCUPSD, SNMP and NUT are supported." msgstr "" #: upses.php @@ -894,7 +902,7 @@ msgid "Hostname:Port" msgstr "" #: upses.php -msgid "The Hostname:Port that is running the apcupsd daemon." +msgid "The Hostname:Port that is running the apcupsd daemon or NUT server." msgstr "" #: upses.php @@ -1038,3 +1046,124 @@ msgstr "" #: includes/dashboard.php msgid "Cable / Driver" msgstr "" + +#: upses.php +msgid "NUT Options" +msgstr "" + +#: upses.php +msgid "NUT UPS Name" +msgstr "" + +#: upses.php +msgid "The name of the UPS on the NUT server, as listed by \"upsc -l\" (the part before the @ in ups@host). One NUT server can serve several UPSes." +msgstr "" + +#: upses.php +msgid "NUT Username" +msgstr "" + +#: upses.php +msgid "Optional. The upsd.users account to authenticate with. Leave blank for anonymous read-only access, which is how NUT servers are usually monitored." +msgstr "" + +#: upses.php +msgid "NUT Password" +msgstr "" + +#: upses.php +msgid "The password for the NUT Username." +msgstr "" + +#: upses.php +msgid "Use TLS (STARTTLS)" +msgstr "" + +#: upses.php +msgid "Encrypt the connection with STARTTLS before any credentials are sent. The NUT server certificate and host name are verified, so the certificate must be trusted by the Cacti poller, or you can point to its CA / certificate file below." +msgstr "" + +#: upses.php +msgid "CA / Certificate File" +msgstr "" + +#: upses.php +msgid "Optional. The full path on the Cacti poller to a PEM file holding the CA certificate (or the self-signed server certificate) to verify the NUT server against, for example /etc/nut/upsd.pem. The host name above must match the certificate." +msgstr "" + +#: upses.php +msgid "Allow Self-Signed Certificate" +msgstr "" + +#: upses.php +msgid "Accept any certificate without checking it. The connection is still encrypted, but the NUT server is not authenticated, so a man-in-the-middle could read the NUT credentials. Prefer the CA / Certificate File above." +msgstr "" + +#: includes/nut.php +msgid "The NUT CA / certificate file cannot be read." +msgstr "" + +#: poller_apcupsd.php +msgid "The NUT server did not return any variables for this UPS." +msgstr "" + +#: upses.php +msgid "Timeout (seconds)" +msgstr "" + +#: upses.php +msgid "How long the poller waits for apcupsd or the NUT server before giving up on this UPS, from 1 to 120 seconds. SNMP based UPS's use the SNMP timeout instead." +msgstr "" + +#: poller_apcupsd.php +#, php-format +msgid "apcaccess failed or did not answer within %s seconds." +msgstr "" + +#: includes/nut.php +msgid "Invalid NUT hostname, port or UPS name configuration" +msgstr "" + +#: includes/nut.php +msgid "The NUT server denied access. Check the NUT username and password." +msgstr "" + +#: includes/nut.php +#, php-format +msgid "The NUT server does not know a UPS named \"%s\"." +msgstr "" + +#: includes/nut.php +msgid "The NUT server has stale data for this UPS: its driver is not responding." +msgstr "" + +#: includes/nut.php +#, php-format +msgid "The NUT server reported an error: %s" +msgstr "" + +#: includes/nut.php +msgid "The NUT server closed the connection unexpectedly." +msgstr "" + +#: includes/nut.php +msgid "The NUT username or password contains characters that cannot be sent." +msgstr "" + +#: includes/nut.php +msgid "TLS negotiation with the NUT server failed." +msgstr "" + +#: includes/nut.php +msgid "The NUT server sent too much data." +msgstr "" + +#: includes/nut.php +#, php-format +msgid "The NUT server did not answer within %s seconds." +msgstr "" + +#: includes/nut.php +#, php-format +msgid "Unable to connect to the NUT server %s: %s" +msgstr "" diff --git a/phpunit.xml b/phpunit.xml index 180ca3e..0fdd4e1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,6 +30,7 @@ setup.php includes/functions.php includes/helpers.php + includes/nut.php diff --git a/poller_apcupsd.php b/poller_apcupsd.php index 661d599..217c428 100644 --- a/poller_apcupsd.php +++ b/poller_apcupsd.php @@ -42,6 +42,7 @@ require_once($config['base_path'] . '/lib/utility.php'); require('./plugins/apcupsd/includes/field_maps.php'); require_once('./plugins/apcupsd/includes/functions.php'); +require_once('./plugins/apcupsd/includes/nut.php'); // process calling arguments $parms = $_SERVER['argv']; @@ -171,12 +172,46 @@ printf('NOTE: Did not find any snmp enabled UPS\'s' . PHP_EOL); } +// NUT (Network UPS Tools) upses last +$upses = db_fetch_assoc_prepared('SELECT * + FROM apcupsd_ups + WHERE type_id = 3 + AND enabled = "on" + AND poller_id = ?', + [$config['poller_id']]); + +$nutupses = cacti_sizeof($upses); + +if ($nutupses > 0) { + printf('NOTE: Found %s NUT enabled UPS\'s' . PHP_EOL, $nutupses); + + foreach ($upses as $ups) { + debug(sprintf('=================== Collecting UPS Information for %s ===================', $ups['name'])); + + $ups_up = collect_nut_ups_data($ups); + + if ($ups['host_id'] > 0) { + $exists = db_fetch_cell_prepared('SELECT id FROM host WHERE id = ?', [$ups['host_id']]); + + if (!$exists) { + $ups['host_id'] = 0; + } + } + + if ($ups['host_id'] == 0 && $add_devices && $ups_up) { + add_ups_device($ups, $host_template_id); + } + } +} else { + printf('NOTE: Did not find any NUT enabled UPS\'s' . PHP_EOL); +} + $end = microtime(true); $cacti_stats = sprintf( - 'Time:%01.2f APCAUPSes:%s SNMPUPSes:%s', + 'Time:%01.2f APCAUPSes:%s SNMPUPSes:%s NUTUPSes:%s', $end - $start, - $apcupsd, $snmpupses + $apcupsd, $snmpupses, $nutupses ); cacti_log("APCUPSD STATS: $cacti_stats", false, 'SYSTEM'); @@ -186,7 +221,8 @@ /** * Creates a new Cacti device for a discovered UPS (apcaccess-based or - * SNMP-based, using the type-appropriate api_device_save() parameters), + * NUT-based UPSes get a local device, SNMP-based UPSes use the + * type-appropriate api_device_save() parameters), * links it back to the apcupsd_ups row, and triggers device automation. * Called from this script's main flow for each enabled UPS that doesn't * yet have an associated host_id. @@ -205,7 +241,7 @@ function add_ups_device($ups, $host_template_id, $force_up = false): void { $save = []; - if ($ups['type_id'] == 1) { + if ($ups['type_id'] != 2) { $host_id = api_device_save(0, $host_template_id, $ups['name'], 'localhost', // id, template_id, description, hostname '', 0, '', '', // snmp community, snmp_version, snmp_username, snmp_password 161, 500, '', 0, // snmp_port, snmp_timeout, disabled, availability_method @@ -396,7 +432,9 @@ function collect_snmp_ups_data($ups): bool { /** * Polls a single apcaccess-based UPS by locating the apcaccess binary, - * running it against the UPS's configured hostname/port, and parsing its + * running it against the UPS's configured hostname/port (killed by Cacti's + * exec_with_timeout() when it does not finish within the UPS's timeout), and + * parsing its * "KEY: value" output into apcupsd_ups_stats columns (per the global * $ups_database mapping), also updating the UPS's linked host's up/down * status based on the STATUS field. Called from this script's main flow @@ -450,10 +488,17 @@ function collect_ups_data($ups) { return 1; } - $results = exec($command, $output, $return); + $timeout = apcupsd_normalize_timeout($ups['timeout'] ?? null); + $results = exec_with_timeout($command, $output, $return, $timeout); - if ($return > 0) { - $message = implode(', ', $output); + // exec_with_timeout() kills apcaccess when it has not finished in time, which + // leaves a non-zero code (or none) and no output, and ends the output with a blank line. + $output = array_values(array_filter($output, function($line) { + return trim((string) $line) !== ''; + })); + + if ($return != 0 || !cacti_sizeof($output)) { + $message = cacti_sizeof($output) ? implode(', ', $output) : sprintf(__('apcaccess failed or did not answer within %s seconds.', 'apcupsd'), $timeout); db_execute_prepared('UPDATE apcupsd_ups SET status = 1, error_message = ? @@ -519,6 +564,94 @@ function collect_ups_data($ups) { } } +/** + * Records a UPS's linked Cacti host as up or down, stamping the recovery or + * failure time only when the state actually changes. Does nothing for a UPS + * that has no linked host yet. + * + * @param int|string $host_id The linked Cacti host id; 0 when none. + * @param bool $up True to mark the host up, false to mark it down. + * + * @return void + */ +function set_ups_host_state($host_id, bool $up): void { + if ($host_id <= 0) { + return; + } + + $status = db_fetch_cell_prepared('SELECT status FROM host WHERE id = ?', [$host_id]); + + if ($up && $status != 3) { + db_execute_prepared('UPDATE host SET status = 3, status_rec_date=NOW() WHERE id = ?', [$host_id]); + } elseif (!$up && $status != 4) { + db_execute_prepared('UPDATE host SET status = 4, status_fail_date=NOW() WHERE id = ?', [$host_id]); + } +} + +/** + * Polls a single NUT-based UPS by asking its upsd server for every variable + * over the native NUT protocol (see includes/nut.php), translating them into + * apcupsd_ups_stats columns, and updating the UPS's linked host up/down + * status from the translated ups.status, or marking it down when the query + * fails. Called from this script's main flow + * for each enabled NUT-type UPS. + * + * @param array $ups The apcupsd_ups row for the UPS being polled. + * + * @return bool True when the NUT server responded and a reading was + * recorded; false when the query failed, in which case the + * error is stored in the UPS's error_message. + */ +function collect_nut_ups_data($ups): bool { + $result = apcupsd_nut_query( + $ups['hostname'], + $ups['port'], + $ups['nut_ups_name'], + $ups['nut_username'], + $ups['nut_password'], + $ups['nut_tls'] == 'on', + apcupsd_normalize_timeout($ups['timeout'] ?? null), + (string) ($ups['nut_cafile'] ?? ''), + ($ups['nut_tls_selfsigned'] ?? '') == 'on' + ); + + if ($result['error'] === '' && !cacti_sizeof($result['vars'])) { + $result['error'] = __('The NUT server did not return any variables for this UPS.', 'apcupsd'); + } + + if ($result['error'] !== '') { + debug('NUT query failed: ' . $result['error']); + + // These devices have availability checking disabled, so core polling cannot + // notice the failure: mark the linked host down here. + set_ups_host_state($ups['host_id'], false); + + db_execute_prepared('UPDATE apcupsd_ups + SET status = 1, error_message = ? + WHERE id = ?', + [substr($result['error'], 0, 255), $ups['id']]); + + return false; + } + + $stats = apcupsd_nut_build_stats($result['vars'], $ups, $result['version']); + $online = apcupsd_nut_status_online($stats['ups_status']); + + set_ups_host_state($ups['host_id'], $online); + + // The column names come from apcupsd_nut_build_stats(), never from the server. + db_execute_prepared('REPLACE INTO apcupsd_ups_stats (`' . implode('`, `', array_keys($stats)) . '`) + VALUES (' . implode(', ', array_fill(0, count($stats), '?')) . ')', + array_values($stats)); + + db_execute_prepared('UPDATE apcupsd_ups + SET status = ?, error_message = "", last_updated = NOW() + WHERE id = ?', + [$online ? 3 : 1, $ups['id']]); + + return true; +} + /** * Writes $string to standard output, prefixed with the current time and * 'DEBUG:', when this script was invoked with the '-d'/'--debug' option. diff --git a/tests/Unit/DashboardRefreshTest.php b/tests/Unit/DashboardRefreshTest.php index abcbfce..81f48ca 100644 --- a/tests/Unit/DashboardRefreshTest.php +++ b/tests/Unit/DashboardRefreshTest.php @@ -37,7 +37,7 @@ expect($source)->toContain("'refreshMSeconds = '"); expect($source)->toContain("'refreshPage = '"); expect($source)->toContain("'refreshIsLogout = false;'"); - expect($source)->toContain("'setupPageTimeout();'"); + expect($source)->toContain('else { setupPageTimeout(); }'); expect($source)->toContain('document.getElementById("apcupsd_dashboard")'); }); @@ -48,10 +48,77 @@ expect($source)->toContain("apcupsd_dashboard_print_refresh(\$current_refresh, \$config['url_path'] . 'plugins/apcupsd/upses.php?action=view&id=' . \$ups_id . '&header=false')"); }); - it('does not run its own refresh timer in the browser', function () use ($read) { + it('hands the interval to the dashboard script and falls back to the page timer without it', function () use ($read) { + $source = $read('includes/dashboard.php'); + + expect($source)->toContain('if (typeof apcupsdDashStartRefresh === "function") { apcupsdDashStartRefresh(); } else { setupPageTimeout(); }'); + + // The interval has to be in refreshMSeconds before the script reads it. + expect(strpos($source, "'refreshMSeconds = '"))->toBeLessThan(strpos($source, 'apcupsdDashStartRefresh();')); + }); + + it('refreshes the cards in place through Cacti\'s refreshFunction hook where core has one', function () use ($read) { + $js = $read('js/apcupsd_dashboard.js'); + + expect($js)->toContain('function apcupsdDashAutoRefresh()'); + expect($js)->toContain("apcupsdDashRefreshAll(grid, document.getElementById('apcupsd_dashboard_refresh_now'))"); + expect($js)->toContain("refreshFunction = 'apcupsdDashAutoRefresh()';"); + + // Support is detected before refreshFunction is assigned, otherwise the assignment itself + // would make Cacti 1.2.x (whose timer ignores it and reloads the page) look supported. + expect(strpos($js, "typeof refreshFunction !== 'undefined'"))->toBeLessThan(strpos($js, "refreshFunction = 'apcupsdDashAutoRefresh()';")); + }); + + it('uses its own timer where core has no refreshFunction hook (Cacti 1.2.x)', function () use ($read) { + $js = $read('js/apcupsd_dashboard.js'); + + expect($js)->toContain('function apcupsdDashStartRefresh()'); + expect($js)->toContain('clearTimeout(myRefresh);'); + expect($js)->toContain('apcupsdDashTimer = window.setTimeout(apcupsdDashOwnTick, apcupsdDashTimerMs);'); + expect($js)->toContain('apcupsdDashTimerMs = refreshMSeconds;'); + }); + + it('ends its own timer once the dashboard has left the screen', function () use ($read) { + $js = $read('js/apcupsd_dashboard.js'); + + expect(preg_match('/function apcupsdDashOwnTick\(\) \{\s*apcupsdDashTimer = null;\s*var grid = document\.getElementById\(\'apcupsd_dashboard\'\);\s*if \(!grid\) \{\s*return;\s*\}/', $js))->toBe(1); + }); + + it('hands another page back to Cacti\'s own refresh when the dashboard has gone', function () use ($read) { + $js = $read('js/apcupsd_dashboard.js'); + + expect($js)->toContain("refreshFunction = '';"); + expect($js)->toContain('loadUrl({url: refreshPage});'); + }); + + it('does not stack refresh rounds and keeps the chosen sort across an in-place refresh', function () use ($read) { + $js = $read('js/apcupsd_dashboard.js'); + + expect($js)->toContain('if (!pending || apcupsdDashRefreshing) {'); + expect($js)->toContain('var sorted = apcupsdDashSortSnapshot(card);'); + expect($js)->toContain('apcupsdDashSortRestore(fresh, sorted);'); + }); + + it('captures the live sort when the refreshed card replaces the old one, not when the request is sent', function () use ($read) { + $js = $read('js/apcupsd_dashboard.js'); + + expect(strpos($js, 'var sorted = apcupsdDashSortSnapshot(card);'))->toBeGreaterThan(strpos($js, 'function apcupsdDashRefreshCard(')); + expect(strpos($js, 'var sorted = apcupsdDashSortSnapshot(card);'))->toBeGreaterThan(strpos($js, '.done(function(data) {')); + expect(strpos($js, 'var sorted = apcupsdDashSortSnapshot(card);'))->toBeLessThan(strpos($js, 'card.parentNode.replaceChild(fresh, card);')); + }); + + it('has no recurring browser timer other than the 1.2.x fallback', function () use ($read) { $js = $read('js/apcupsd_dashboard.js'); expect($js)->not->toContain('setInterval'); - expect(preg_match('/setTimeout\s*\(/', $js))->toBe(0); + + // Every setTimeout() is the fallback timer, which stops itself when the dashboard is gone. + preg_match_all('/setTimeout\s*\(\s*([A-Za-z_]+)/', $js, $matches); + + expect($matches[1])->not->toBe([]); + + foreach ($matches[1] as $callback) { + expect($callback)->toBe('apcupsdDashOwnTick'); + } }); }); diff --git a/tests/Unit/EstimatedWattsTest.php b/tests/Unit/EstimatedWattsTest.php index f726a20..27bba70 100644 --- a/tests/Unit/EstimatedWattsTest.php +++ b/tests/Unit/EstimatedWattsTest.php @@ -78,34 +78,64 @@ it('shows the estimate in the Fleet Dashboard table with a fleet total', function () use ($read) { $source = $read('includes/dashboard.php'); - expect($source)->toContain("\$th('power', __('Est. Power', 'apcupsd')"); + expect($source)->toContain("\$th(__('Est. Power', 'apcupsd')"); expect($source)->toContain('apcupsd_estimate_watts('); expect($source)->toContain('!apcupsd_is_slave_status('); expect($source)->toContain('apcupsdDashTotalRow'); }); - it('makes the Fleet Dashboard table columns sortable without inline handlers', function () use ($read) { + it('sorts the Fleet Dashboard table like the HMIB dashboards, without inline handlers', function () use ($read) { $source = $read('includes/dashboard.php'); - foreach (['name', 'status', 'load', 'charge', 'timeleft', 'power', 'updated'] as $key) { - expect($source)->toContain("\$th('{$key}'"); + foreach (['UPS', 'Status', 'Load', 'Charge', 'Time Left', 'Est. Power', 'Last Updated'] as $label) { + expect($source)->toContain("\$th(__('{$label}', 'apcupsd')"); } + expect($source)->toContain("class=\"apcupsdDashSortable'"); + expect($source)->toContain('data-sort="num"'); + expect($source)->toContain('data-sort-value="'); + expect($source)->not->toContain('apcupsdDashSortBtn'); + expect($source)->not->toContain('data-sort-key'); expect(preg_match('/\sonclick\s*=/i', $source))->toBe(0); $js = $read('js/apcupsd_dashboard.js'); - expect($js)->toContain("on('click.apcupsdsort', '.apcupsdDashSortBtn'"); - expect($js)->toContain('apcupsdDashApplySort(card)'); - expect($js)->toContain('aria-sort'); + expect($js)->toContain("on('click.apcupsdsort', 'th.apcupsdDashSortable'"); + expect($js)->toContain("on('keydown.apcupsdsort', 'th.apcupsdDashSortable'"); + expect($js)->toContain('function apcupsdDashSortTable(th)'); + expect($js)->toContain('apcupsdDashPrepSortHeaders(card)'); + expect($js)->toContain("setAttribute('aria-sort'"); }); - it('keeps the active sort in memory so it works without browser storage', function () use ($read) { + it('also sorts in the maximize dialog', function () use ($read) { $js = $read('js/apcupsd_dashboard.js'); - expect($js)->toContain('var apcupsdDashSortState = null;'); - expect($js)->toContain('apcupsdDashSortState = state;'); - expect(preg_match('/function apcupsdDashSortRead\(\) \{\s*if \(apcupsdDashSortState === null\)/', $js))->toBe(1); + expect($js)->toContain('apcupsdDashPrepSortHeaders(dialog);'); + expect($js)->toContain('apcupsdDashBindSort(dialog);'); + }); + + it('keeps no persisted sort state beyond a click-to-sort header like HMIB', function () use ($read) { + $js = $read('js/apcupsd_dashboard.js'); + + expect($js)->not->toContain('localStorage'); + expect($js)->not->toContain('apcupsdDashSortState'); + }); + + it('right-aligns the numeric and last-updated columns of the UPSes card', function () use ($read) { + $source = $read('includes/dashboard.php'); + + foreach (['Load', 'Charge', 'Time Left', 'Est. Power'] as $label) { + expect($source)->toContain("\$th(__('{$label}', 'apcupsd'), true, true)"); + } + + expect($source)->toContain("\$th(__('Last Updated', 'apcupsd'), false, true)"); + expect($source)->toContain("'
' . html_escape($ups['name']) . '' . apcupsd_dashboard_status_pill((string) ($ups['ups_status'] ?? '')) . '' . html_escape($load) . '' . html_escape($charge) . '' . html_escape($time) . '' . html_escape(apcupsd_dashboard_format_value($watts, ' W')) . '' . html_escape((string) ($ups['last_updated'] ?? '')) . '' . html_escape($ups['name']) . '' . apcupsd_dashboard_status_pill((string) ($ups['ups_status'] ?? '')) . '' . html_escape($load) . '' . html_escape($charge) . '' . html_escape($time) . '' . html_escape(apcupsd_dashboard_format_value($watts, ' W')) . '' . html_escape((string) ($ups['last_updated'] ?? '')) . '
' . html_escape((string) (\$ups['last_updated']"); + + $css = $read('css/apcupsd.css'); + + // Must outrank ".apcupsdDashTable th, .apcupsdDashTable td { text-align: left }". + expect($css)->toContain('.apcupsdDashTable td.apcupsdDashNum,'); + expect($css)->toContain('.apcupsdDashTable th.apcupsdDashNum {'); }); it('adds a sortable estimated power column to the UPS list', function () use ($read) { diff --git a/tests/Unit/NutClientTest.php b/tests/Unit/NutClientTest.php new file mode 100644 index 0000000..8956863 --- /dev/null +++ b/tests/Unit/NutClientTest.php @@ -0,0 +1,554 @@ + $replies + * + * @return array{0: callable, 1: callable, 2: ArrayObject} + */ +function apcupsd_test_nut_transport(array $replies): array { + $sent = new ArrayObject(); + + return [ + function(string $command) use ($sent): void { + $sent[] = $command; + }, + function() use (&$replies): ?string { + return $replies === [] ? null : array_shift($replies); + }, + $sent, + ]; +} + +describe('NUT input validation', function () { + it('accepts DNS names and IP addresses as hosts', function () { + foreach (['ups.example.com', 'rack-ups', '10.0.0.5', '::1', '2001:db8::1'] as $host) { + expect(apcupsd_nut_valid_host($host))->toBeTrue("host {$host}"); + } + }); + + it('rejects hosts that could be abused', function () { + foreach (['', ' ', 'bad host', 'a;b', '$(id)', 'host/../x', "h\nLOGOUT"] as $host) { + expect(apcupsd_nut_valid_host($host))->toBeFalse('host ' . json_encode($host)); + } + }); + + it('only accepts legal NUT UPS names', function () { + expect(apcupsd_nut_valid_ups_name('ups'))->toBeTrue(); + expect(apcupsd_nut_valid_ups_name('rack_1.ups-a'))->toBeTrue(); + expect(apcupsd_nut_valid_ups_name(''))->toBeFalse(); + expect(apcupsd_nut_valid_ups_name('ups name'))->toBeFalse(); + expect(apcupsd_nut_valid_ups_name("ups\nLOGOUT"))->toBeFalse(); + expect(apcupsd_nut_valid_ups_name(str_repeat('a', 65)))->toBeFalse(); + }); + + it('quotes protocol arguments and refuses control characters', function () { + expect(apcupsd_nut_quote('secret'))->toBe('"secret"'); + expect(apcupsd_nut_quote('a"b\\c'))->toBe('"a\\"b\\\\c"'); + expect(apcupsd_nut_quote("pw\nLOGOUT"))->toBeNull(); + expect(apcupsd_nut_quote("pw\0"))->toBeNull(); + }); +}); + +describe('NUT line tokenizer', function () { + it('splits plain and quoted words', function () { + expect(apcupsd_nut_tokenize('VAR ups battery.charge "100"'))->toBe(['VAR', 'ups', 'battery.charge', '100']); + expect(apcupsd_nut_tokenize("BEGIN\tLIST VAR ups"))->toBe(['BEGIN', 'LIST', 'VAR', 'ups']); + expect(apcupsd_nut_tokenize(''))->toBe([]); + }); + + it('keeps spaces and unescapes quotes and backslashes inside quotes', function () { + expect(apcupsd_nut_tokenize('VAR ups ups.model "Smart \\"X\\" 1500 \\\\ RM"')) + ->toBe(['VAR', 'ups', 'ups.model', 'Smart "X" 1500 \\ RM']); + }); + + it('returns null for an unterminated quote', function () { + expect(apcupsd_nut_tokenize('VAR ups x "oops'))->toBeNull(); + expect(apcupsd_nut_tokenize('"abc\\'))->toBeNull(); + }); +}); + +describe('NUT error messages', function () { + it('maps known ERR codes', function () { + expect(apcupsd_nut_error('ERR ACCESS-DENIED', 'ups'))->toContain('denied access'); + expect(apcupsd_nut_error('ERR INVALID-PASSWORD', 'ups'))->toContain('denied access'); + expect(apcupsd_nut_error('ERR UNKNOWN-UPS', 'rack1'))->toContain('"rack1"'); + expect(apcupsd_nut_error('ERR DATA-STALE', 'ups'))->toContain('stale data'); + }); + + it('reports other codes, sanitised, and a bare ERR', function () { + expect(apcupsd_nut_error('ERR FEATURE-NOT-CONFIGURED', 'ups'))->toContain('FEATURE-NOT-CONFIGURED'); + expect(apcupsd_nut_error('ERR BAD __('Collector', 'apcupsd'), 'align' => 'left', 'sort' => 'ASC', - 'tip' => __('The Type of Collector. Currently APCUPSD and SNMP are supported.', 'apcupsd') + 'tip' => __('The Type of Collector. Currently APCUPSD, SNMP and NUT are supported.', 'apcupsd') ], 'ups_status' => [ 'display' => __('UPS Status', 'apcupsd'), @@ -1039,7 +1173,7 @@ function clearFilter() { 'display' => __('Hostname:Port', 'apcupsd'), 'align' => 'right', 'sort' => 'DESC', - 'tip' => __('The Hostname:Port that is running the apcupsd daemon.', 'apcupsd') + 'tip' => __('The Hostname:Port that is running the apcupsd daemon or NUT server.', 'apcupsd') ], 'enabled' => [ 'display' => __('Enabled', 'apcupsd'), @@ -1075,7 +1209,7 @@ function clearFilter() { form_selectable_cell($ups['id'], $ups['id'], '', 'center'); form_selectable_cell($ups['host_id'], $ups['id'], '', 'center'); form_selectable_cell(get_colored_device_status(($ups['enabled'] == '' ? true : false), $ups['status']), $ups['id'], '', 'center'); - form_selectable_ecell($ups['type_id'] == 1 ? 'APCUPSD' : 'SNMPD', $ups['id'], '', 'left'); + form_selectable_ecell([1 => 'APCUPSD', 2 => 'SNMPD', 3 => 'NUT'][(int) $ups['type_id']] ?? '', $ups['id'], '', 'left'); form_selectable_ecell($ups['ups_status'], $ups['id'], '', 'left'); form_selectable_ecell($ups['ups_model'], $ups['id'], '', 'left'); form_selectable_ecell(checkNullandReturn($ups['ups_line_voltage']), $ups['id'], '', 'right'); @@ -1083,7 +1217,7 @@ function clearFilter() { form_selectable_ecell($ups['est_watts'] === null ? checkNullandReturn(null) : apcupsd_dashboard_format_value((float) $ups['est_watts'], ' W'), $ups['id'], '', 'right'); form_selectable_ecell(round((float)($ups['ups_timeleft'] ?? 0), 2), $ups['id'], '', 'right'); - if ($ups['type_id'] == 1) { + if ($ups['type_id'] != 2) { form_selectable_ecell($ups['hostname'] . ':' . $ups['port'], $ups['id'], '', 'right'); } else { form_selectable_ecell($ups['hostname'] . ':' . $ups['snmp_port'], $ups['id'], '', 'right');