From 0b01400da6ff76d93de1aee17f1b9eab537ba469 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Fri, 16 Oct 2026 12:20:23 -0400 Subject: [PATCH 1/2] feature: estimated watts, sortable fleet table, and page-timer refresh - Estimate each UPS's power as nominal power x load % for UPSes that do not report real wattage (apcupsd_estimate_watts()). Show it on the Fleet Dashboard as an "Est. Power" tile, a new "Fleet Power" gauge card (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. - Make the Fleet Dashboard UPSes table sortable by UPS, Status, Load, Charge, Time Left, Est. Power and Last Updated. N/A values sort last, ties keep server order, and the choice persists in the browser across refreshes. - Drive the dashboard auto-refresh through Cacti's refreshMSeconds / refreshPage / refreshIsLogout globals (as graph_view.php does) instead of set_page_refresh(), which parked the interval in the session so it could follow the user to another page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + css/apcupsd.css | 43 ++++++++ includes/dashboard.php | 148 +++++++++++++++++++++------- includes/functions.php | 30 ++++++ js/apcupsd_dashboard.js | 113 +++++++++++++++++++++ locales/po/cacti.pot | 36 +++++++ tests/Unit/DashboardRefreshTest.php | 57 +++++++++++ tests/Unit/EstimatedWattsTest.php | 93 +++++++++++++++++ upses.php | 12 ++- 9 files changed, 501 insertions(+), 34 deletions(-) create mode 100644 tests/Unit/DashboardRefreshTest.php create mode 100644 tests/Unit/EstimatedWattsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8243d19..3170c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ --- develop --- +* 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 +* 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 * 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/css/apcupsd.css b/css/apcupsd.css index 46fd010..7a83be1 100644 --- a/css/apcupsd.css +++ b/css/apcupsd.css @@ -524,6 +524,49 @@ 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; +} + +.apcupsdDashSortBtn:hover, +.apcupsdDashSortBtn:focus-visible { + text-decoration: underline; +} + +.apcupsdDashSortable[aria-sort="ascending"] .apcupsdDashSortBtn::after { + content: " \25B2"; + font-size: 0.75em; +} + +.apcupsdDashSortable[aria-sort="descending"] .apcupsdDashSortBtn::after { + content: " \25BC"; + font-size: 0.75em; +} + +/* Fleet total row under the UPSes table. */ +.apcupsdDashTotalRow > td { + border-top: 2px solid color-mix(in srgb, currentColor 22%, transparent); + font-weight: 600; +} + +.apcupsdDashNote { + margin: 8px 0 0; + text-align: center; + font-size: 11px; + color: color-mix(in srgb, currentColor 65%, transparent); +} + /* Narrow the grid before cards get cramped. */ @media (max-width: 1200px) { .apcupsdDashGrid { diff --git a/includes/dashboard.php b/includes/dashboard.php index d6151e1..472700a 100644 --- a/includes/dashboard.php +++ b/includes/dashboard.php @@ -1351,6 +1351,39 @@ 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 + * (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 + * re-emits its own refresh globals. The script runs from a ready callback so + * it applies after Cacti's footer script, and only while the dashboard is + * 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. + * + * @return void + */ + function apcupsd_dashboard_print_refresh(int $seconds, string $page): void { + if ($seconds <= 0) { + return; + } + + print ''; + } +} + if (!function_exists('apcupsd_dashboard_refresh_save')) { /** * Persist the posted auto-refresh interval for the current user. Accepts only @@ -1449,13 +1482,7 @@ function apcupsd_dashboard(): void { $current_refresh = (int) read_user_setting('apcupsd_dashboard_refresh', 0); - if ($current_refresh > 0 && function_exists('set_page_refresh')) { - set_page_refresh([ - 'seconds' => $current_refresh, - 'page' => $config['url_path'] . 'plugins/apcupsd/upses.php?action=view&id=' . $ups_id . '&header=false', - 'logout' => 'false' - ]); - } + apcupsd_dashboard_print_refresh($current_refresh, $config['url_path'] . 'plugins/apcupsd/upses.php?action=view&id=' . $ups_id . '&header=false'); print ''; @@ -1594,8 +1621,9 @@ function apcupsd_dashboard_tabs(string $current): void { if (!function_exists('apcupsd_fleet_stats')) { /** * Memoized fleet aggregates: UPS counts by status (online/on-battery/other/ - * disabled) and the min/avg/max of load, battery charge and runtime across - * the enabled UPSes. + * disabled), the min/avg/max of load, battery charge and runtime across the + * enabled UPSes, and the estimated total power draw (nominal power x load %) + * with the rated capacity and UPS count it was summed over. * * @return array The aggregate row. */ @@ -1617,7 +1645,10 @@ function apcupsd_fleet_stats(): array { MAX(CASE WHEN ups.enabled = 'on' THEN stats.ups_battery_charge END) AS maxCharge, MIN(CASE WHEN ups.enabled = 'on' THEN stats.ups_timeleft END) AS minTime, AVG(CASE WHEN ups.enabled = 'on' THEN stats.ups_timeleft END) AS avgTime, - MAX(CASE WHEN ups.enabled = 'on' THEN stats.ups_timeleft END) AS maxTime + MAX(CASE WHEN ups.enabled = 'on' THEN stats.ups_timeleft END) AS maxTime, + SUM(CASE WHEN ups.enabled = 'on' AND stats.ups_nominal_power > 0 AND stats.ups_load_percent >= 0 THEN stats.ups_nominal_power * stats.ups_load_percent / 100 END) AS estWatts, + SUM(CASE WHEN ups.enabled = 'on' AND stats.ups_nominal_power > 0 AND stats.ups_load_percent >= 0 THEN stats.ups_nominal_power END) AS estCapacity, + SUM(CASE WHEN ups.enabled = 'on' AND stats.ups_nominal_power > 0 AND stats.ups_load_percent >= 0 THEN 1 ELSE 0 END) AS estCount FROM apcupsd_ups AS ups LEFT JOIN apcupsd_ups_stats AS stats ON stats.ups_id = ups.id"); @@ -1640,6 +1671,7 @@ function apcupsd_fleet_card_meta(): array { 'overview' => ['title' => __('Fleet Overview', 'apcupsd'), 'span' => 4, 'expandable' => false], 'status' => ['title' => __('Status Distribution', 'apcupsd'), 'span' => 4, 'expandable' => false], 'readings' => ['title' => __('Fleet Readings', 'apcupsd'), 'span' => 4, 'expandable' => false], + 'power' => ['title' => __('Fleet Power', 'apcupsd'), 'span' => 4, 'expandable' => false], 'gauges' => ['title' => __('Fleet Gauges', 'apcupsd'), 'span' => 4, 'expandable' => false], 'upses' => ['title' => __('UPSes', 'apcupsd'), 'span' => 8, 'expandable' => true], ]; @@ -1838,6 +1870,10 @@ function apcupsd_fleet_card_body(string $key): string { $onbatt = (int) ($stats['onbatt'] ?? 0); $other = max(0, $enabled - $online - $onbatt); + $est_count = (int) ($stats['estCount'] ?? 0); + $est_watts = isset($stats['estWatts']) && is_numeric($stats['estWatts']) ? (float) $stats['estWatts'] : null; + $est_capacity = isset($stats['estCapacity']) && is_numeric($stats['estCapacity']) ? (float) $stats['estCapacity'] : null; + ob_start(); switch ($key) { @@ -1854,6 +1890,11 @@ function apcupsd_fleet_card_body(string $key): string { print apcupsd_fleet_tile(number_format_i18n($onbatt, 0), __('On Battery', 'apcupsd'), $onbatt > 0 ? 'down' : ''); print apcupsd_fleet_tile(number_format_i18n($other, 0), __('Other', 'apcupsd'), $other > 0 ? 'rec' : ''); print apcupsd_fleet_tile(number_format_i18n($disabled, 0), __('Disabled', 'apcupsd')); + + if ($est_count > 0) { + print apcupsd_fleet_tile(apcupsd_dashboard_format_value($est_watts, ' W'), __('Est. Power', 'apcupsd')); + } + print ''; break; @@ -1911,6 +1952,25 @@ function apcupsd_fleet_card_body(string $key): string { $time_scale = max(30.0, $max_time !== null ? $max_time * 1.1 : 30.0); print apcupsd_dashboard_bullet(__('Time Left', 'apcupsd'), null, $time['min'], $time['avg'], $max_time, $time_scale, ' min', apcupsd_dashboard_metric_severity('timeleft', $time['avg'])); + break; + case 'power': + if ($est_count === 0 || $est_watts === null || $est_capacity === null || $est_capacity <= 0) { + print '

' . __esc('Estimated power needs an enabled UPS with a nominal power rating and a load reading.', 'apcupsd') . '

'; + + break; + } + + $est_load = $est_watts / $est_capacity * 100; + $est_sub = __('%s of %s rated', apcupsd_dashboard_format_value($est_load, '%'), apcupsd_dashboard_format_value($est_capacity, ' W'), 'apcupsd'); + + print '
' + . '' . __esc('Estimated Power', 'apcupsd') . '' + . apcupsd_dashboard_gauge($est_watts, null, null, $est_capacity, apcupsd_dashboard_metric_severity('load_percent', $est_load), apcupsd_dashboard_format_value($est_watts, ' W'), $est_sub) + . '
'; + + print '

' . __esc('Estimated as nominal power x load %, from the latest poll.', 'apcupsd') . ' ' + . html_escape(__('Counted %s of %s enabled UPSes.', number_format_i18n($est_count, 0), number_format_i18n($enabled, 0), 'apcupsd')) . '

'; + break; case 'gauges': if ($enabled === 0) { @@ -1952,19 +2012,32 @@ function apcupsd_fleet_card_body(string $key): string { break; case 'upses': $upses = db_fetch_assoc("SELECT ups.id, ups.name, ups.host_id, ups.enabled, ups.last_updated, - stats.ups_status, stats.ups_load_percent, stats.ups_battery_charge, stats.ups_timeleft + stats.ups_status, stats.ups_load_percent, stats.ups_battery_charge, stats.ups_timeleft, stats.ups_nominal_power FROM apcupsd_ups AS ups LEFT JOIN apcupsd_ups_stats AS stats ON stats.ups_id = ups.id ORDER BY ups.name"); - print '' + // 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 ''; + }; + + // Empty data-sort values (N/A) always sort last. + $sort_num = function($value): string { + return $value !== null && is_numeric($value) ? (string) (float) $value : ''; + }; + + 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') . ''; if (cacti_sizeof($upses)) { @@ -1975,21 +2048,36 @@ function apcupsd_fleet_card_body(string $key): string { $charge = isset($ups['ups_battery_charge']) && is_numeric($ups['ups_battery_charge']) ? number_format_i18n((float) $ups['ups_battery_charge'], 0) . '%' : __('N/A', 'apcupsd'); $time = isset($ups['ups_timeleft']) && is_numeric($ups['ups_timeleft']) ? number_format_i18n((float) $ups['ups_timeleft'], 0) . ' ' . __('min', 'apcupsd') : __('N/A', 'apcupsd'); + // A disabled UPS is not being polled, so its last reading is not an estimate. + $watts = ($ups['enabled'] ?? '') === 'on' ? apcupsd_estimate_watts($ups['ups_nominal_power'] ?? null, $ups['ups_load_percent'] ?? null) : null; + print '' . '' - . '' - . '' - . '' - . '' - . '' - . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' . ''; } } else { - print ''; + print ''; } - print '
' . __esc('UPS', 'apcupsd') . '' . __esc('Status', 'apcupsd') . '' . __esc('Load', 'apcupsd') . '' . __esc('Charge', 'apcupsd') . '' . __esc('Time Left', 'apcupsd') . '' . __esc('Last Updated', 'apcupsd') . '
' . html_escape($ups['name']) . '' . apcupsd_dashboard_status_pill((string) ($ups['ups_status'] ?? '')) . '' . html_escape($load) . '' . html_escape($charge) . '' . html_escape($time) . '' . 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'] ?? '')) . '
' . __esc('No UPSes are configured yet.', 'apcupsd') . '
' . __esc('No UPSes are configured yet.', 'apcupsd') . '
'; + print ''; + + if ($est_count > 0) { + print '' + . '' + . '' . __esc('Fleet Total', 'apcupsd') . '' + . '' . html_escape(apcupsd_dashboard_format_value($est_watts, ' W')) . '' + . '' + . ''; + } + + print ''; break; } @@ -2185,13 +2273,7 @@ function apcupsd_fleet_dashboard(): void { $current_refresh = (int) read_user_setting('apcupsd_summary_refresh', 0); - if ($current_refresh > 0 && function_exists('set_page_refresh')) { - set_page_refresh([ - 'seconds' => $current_refresh, - 'page' => $config['url_path'] . 'plugins/apcupsd/upses.php?action=dashboard&header=false', - 'logout' => 'false' - ]); - } + apcupsd_dashboard_print_refresh($current_refresh, $config['url_path'] . 'plugins/apcupsd/upses.php?action=dashboard&header=false'); print ''; diff --git a/includes/functions.php b/includes/functions.php index 0f05b38..3b43ea5 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -26,6 +26,36 @@ function apcupsd_host_template_imported(): bool { } } +if (!function_exists('apcupsd_estimate_watts')) { + /** + * Estimates a UPS's real output power in watts as + * nominal power x load percent / 100, for UPSes that do not report a + * measured wattage. Called from the Fleet Dashboard aggregates and the UPS + * tables so every view derives the figure the same way. + * + * @param mixed $nominal_power The UPS nominal power rating in watts. + * @param mixed $load_percent The UPS load as a percentage of that rating. + * + * @return float|null The estimated watts, or null when either input is + * missing, non-numeric or negative, or when the + * nominal power is not a positive rating. + */ + function apcupsd_estimate_watts($nominal_power, $load_percent): ?float { + if (!is_numeric($nominal_power) || !is_numeric($load_percent)) { + return null; + } + + $nominal = (float) $nominal_power; + $load = (float) $load_percent; + + if ($nominal <= 0 || $load < 0) { + return null; + } + + return $nominal * $load / 100; + } +} + if (!function_exists('apcupsd_normalize_positive_int')) { /** * Coerces a value (int, numeric string, or otherwise) to a positive diff --git a/js/apcupsd_dashboard.js b/js/apcupsd_dashboard.js index 9bc355f..ad1bd98 100644 --- a/js/apcupsd_dashboard.js +++ b/js/apcupsd_dashboard.js @@ -54,6 +54,111 @@ function apcupsdDashPost(grid, extra) { return data; } +var apcupsdDashSortStorageKey = 'apcupsd_dashboard_sort'; + +/** Read the saved sort choices ({tableId: {key, direction}}) from localStorage. */ +function apcupsdDashSortRead() { + try { + var saved = JSON.parse(window.localStorage.getItem(apcupsdDashSortStorageKey)); + return saved && typeof saved === 'object' ? saved : {}; + } catch (e) { + return {}; + } +} + +/** Save the sort choices; storage can be unavailable, in which case the sort + * still applies for the current view. */ +function apcupsdDashSortWrite(state) { + try { + window.localStorage.setItem(apcupsdDashSortStorageKey, JSON.stringify(state)); + } catch (e) { + 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) { + 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 cell = row.cells[column]; + return {row: row, index: parseInt(row.getAttribute('data-sort-index'), 10), value: cell ? cell.getAttribute('data-sort') : null}; + }); + + 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 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); + }); + + table.querySelectorAll('thead th.apcupsdDashSortable').forEach(function(th) { + th.setAttribute('aria-sort', th === header ? (direction === 'desc' ? 'descending' : 'ascending') : 'none'); + }); +} + +/** 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); + } + }); +} + +/** 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) { + return; + } + + 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); +} + function initApcupsdDashboard() { $(function() { var grid = document.getElementById('apcupsd_dashboard'); @@ -61,6 +166,12 @@ 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); + }); + grid.querySelectorAll('.apcupsdDashCard').forEach(function(card) { apcupsdDashBindCard(grid, card); }); @@ -150,6 +261,8 @@ function initApcupsdDashboard() { /** Wire a single card's drag handle (mouse + keyboard) and drag events. */ function apcupsdDashBindCard(grid, card) { + apcupsdDashApplySort(card); + var handle = card.querySelector('.apcupsdDashCardDrag'); if (!handle) { return; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 64bc81a..30fd63b 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -989,3 +989,39 @@ msgstr "" #: includes/dashboard.php msgid "No readings are available for this gauge yet." msgstr "" + +#: includes/dashboard.php +msgid "Fleet Power" +msgstr "" + +#: includes/dashboard.php upses.php +msgid "Est. Power" +msgstr "" + +#: includes/dashboard.php +msgid "Estimated Power" +msgstr "" + +#: includes/dashboard.php +msgid "Estimated power needs an enabled UPS with a nominal power rating and a load reading." +msgstr "" + +#: includes/dashboard.php +msgid "%s of %s rated" +msgstr "" + +#: includes/dashboard.php +msgid "Estimated as nominal power x load %, from the latest poll." +msgstr "" + +#: includes/dashboard.php +msgid "Counted %s of %s enabled UPSes." +msgstr "" + +#: includes/dashboard.php +msgid "Fleet Total" +msgstr "" + +#: upses.php +msgid "The estimated power draw in watts: the nominal power rating multiplied by the Load Percent." +msgstr "" diff --git a/tests/Unit/DashboardRefreshTest.php b/tests/Unit/DashboardRefreshTest.php new file mode 100644 index 0000000..abcbfce --- /dev/null +++ b/tests/Unit/DashboardRefreshTest.php @@ -0,0 +1,57 @@ +not->toBeFalse("Unable to read {$relative}"); + + return (string) $contents; + }; + + it('does not store the refresh interval in the session', function () use ($read) { + foreach (['includes/dashboard.php', 'upses.php', 'includes/helpers.php', 'js/apcupsd_dashboard.js'] as $file) { + expect($read($file))->not->toContain('set_page_refresh'); + } + }); + + it('sets the Cacti refresh globals from a ready callback and re-arms the page timer', function () use ($read) { + $source = $read('includes/dashboard.php'); + + expect($source)->toContain('function apcupsd_dashboard_print_refresh('); + expect($source)->toContain("'refreshMSeconds = '"); + expect($source)->toContain("'refreshPage = '"); + expect($source)->toContain("'refreshIsLogout = false;'"); + expect($source)->toContain("'setupPageTimeout();'"); + expect($source)->toContain('document.getElementById("apcupsd_dashboard")'); + }); + + it('arms the timer from both the Fleet and the per-UPS dashboard', function () use ($read) { + $source = $read('includes/dashboard.php'); + + expect($source)->toContain("apcupsd_dashboard_print_refresh(\$current_refresh, \$config['url_path'] . 'plugins/apcupsd/upses.php?action=dashboard&header=false')"); + 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) { + $js = $read('js/apcupsd_dashboard.js'); + + expect($js)->not->toContain('setInterval'); + expect(preg_match('/setTimeout\s*\(/', $js))->toBe(0); + }); +}); diff --git a/tests/Unit/EstimatedWattsTest.php b/tests/Unit/EstimatedWattsTest.php new file mode 100644 index 0000000..7bfb410 --- /dev/null +++ b/tests/Unit/EstimatedWattsTest.php @@ -0,0 +1,93 @@ +toBe(600.0); + expect(apcupsd_estimate_watts(2200, 12.5))->toBe(275.0); + }); + + it('accepts numeric strings as polled from the database', function () { + expect(apcupsd_estimate_watts('1000', '13'))->toBe(130.0); + }); + + it('reports zero watts for an idle UPS', function () { + expect(apcupsd_estimate_watts(1500, 0))->toBe(0.0); + }); + + it('does not clamp an overloaded UPS', function () { + expect(apcupsd_estimate_watts(1000, 110))->toBe(1100.0); + }); + + it('returns null when either input is missing or not numeric', function () { + expect(apcupsd_estimate_watts(null, 40))->toBeNull(); + expect(apcupsd_estimate_watts(1500, null))->toBeNull(); + expect(apcupsd_estimate_watts('', 40))->toBeNull(); + expect(apcupsd_estimate_watts(1500, 'n/a'))->toBeNull(); + }); + + it('returns null without a positive nominal power or with a negative load', function () { + expect(apcupsd_estimate_watts(0, 40))->toBeNull(); + expect(apcupsd_estimate_watts(-1500, 40))->toBeNull(); + expect(apcupsd_estimate_watts(1500, -5))->toBeNull(); + }); +}); + +describe('estimated watts wiring', function () { + $read = function (string $relative): string { + $contents = file_get_contents(__DIR__ . '/../../' . $relative); + + expect($contents)->not->toBeFalse("Unable to read {$relative}"); + + return (string) $contents; + }; + + it('sums the estimate across enabled UPSes in the fleet aggregates', function () use ($read) { + $source = $read('includes/dashboard.php'); + + expect($source)->toContain('AS estWatts'); + expect($source)->toContain('AS estCapacity'); + expect($source)->toContain('AS estCount'); + expect($source)->toContain("'power' => ['title' => __('Fleet Power', 'apcupsd')"); + }); + + 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('apcupsd_estimate_watts('); + expect($source)->toContain('apcupsdDashTotalRow'); + }); + + it('makes the Fleet Dashboard table columns sortable 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}'"); + } + + 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'); + }); + + it('adds a sortable estimated power column to the UPS list', function () use ($read) { + $source = $read('upses.php'); + + expect($source)->toContain("'est_watts' => ["); + expect($source)->toContain('AS est_watts'); + expect($source)->toContain("\$ups['est_watts']"); + }); +}); diff --git a/upses.php b/upses.php index eb6f242..debcbc7 100644 --- a/upses.php +++ b/upses.php @@ -954,7 +954,10 @@ function clearFilter() { $sql_order = get_order_string(); $sql_limit = ' LIMIT ' . ($rows * (get_request_var('page') - 1)) . ',' . $rows; - $ups_list = db_fetch_assoc_prepared("SELECT ups.*, stats.* + $ups_list = db_fetch_assoc_prepared("SELECT ups.*, stats.*, + CASE WHEN ups.enabled = 'on' AND stats.ups_nominal_power > 0 AND stats.ups_load_percent >= 0 + THEN stats.ups_nominal_power * stats.ups_load_percent / 100 + END AS est_watts FROM apcupsd_ups AS ups LEFT JOIN apcupsd_ups_stats AS stats ON ups.id = stats.ups_id @@ -1020,6 +1023,12 @@ function clearFilter() { 'sort' => 'DESC', 'tip' => __('The Load Percent of the monitored UPS on the target Host.', 'apcupsd') ], + 'est_watts' => [ + 'display' => __('Est. Power', 'apcupsd'), + 'align' => 'right', + 'sort' => 'DESC', + 'tip' => __('The estimated power draw in watts: the nominal power rating multiplied by the Load Percent.', 'apcupsd') + ], 'ups_timeleft' => [ 'display' => __('Time Left', 'apcupsd'), 'align' => 'right', @@ -1071,6 +1080,7 @@ function clearFilter() { form_selectable_ecell($ups['ups_model'], $ups['id'], '', 'left'); form_selectable_ecell(checkNullandReturn($ups['ups_line_voltage']), $ups['id'], '', 'right'); form_selectable_ecell(checkNullandReturn($ups['ups_load_percent']), $ups['id'], '', 'right'); + 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) { From 9e2d371bb0bf37058d94f6eb60ccd933ce48ee1b Mon Sep 17 00:00:00 2001 From: TheWitness Date: Fri, 16 Oct 2026 12:41:17 -0400 Subject: [PATCH 2/2] fix: keep the fleet table sort in memory when browser storage is blocked apcupsdDashApplySort() re-read localStorage after each click, so when storage was blocked or a write failed the newly selected sort was lost (or an older saved sort was reapplied). Hold the active sort state in memory, seed it once from localStorage, and make persistence best-effort so sorting and direction toggling work without storage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- js/apcupsd_dashboard.js | 25 +++++++++++++++++-------- tests/Unit/EstimatedWattsTest.php | 8 ++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/js/apcupsd_dashboard.js b/js/apcupsd_dashboard.js index ad1bd98..26bc209 100644 --- a/js/apcupsd_dashboard.js +++ b/js/apcupsd_dashboard.js @@ -55,20 +55,29 @@ function apcupsdDashPost(grid, extra) { } var apcupsdDashSortStorageKey = 'apcupsd_dashboard_sort'; +var apcupsdDashSortState = null; -/** Read the saved sort choices ({tableId: {key, direction}}) from localStorage. */ +/** 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() { - try { - var saved = JSON.parse(window.localStorage.getItem(apcupsdDashSortStorageKey)); - return saved && typeof saved === 'object' ? saved : {}; - } catch (e) { - return {}; + if (apcupsdDashSortState === null) { + try { + var saved = JSON.parse(window.localStorage.getItem(apcupsdDashSortStorageKey)); + apcupsdDashSortState = saved && typeof saved === 'object' ? saved : {}; + } catch (e) { + apcupsdDashSortState = {}; + } } + + return apcupsdDashSortState; } -/** Save the sort choices; storage can be unavailable, in which case the sort - * still applies for the current view. */ +/** 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) { diff --git a/tests/Unit/EstimatedWattsTest.php b/tests/Unit/EstimatedWattsTest.php index 7bfb410..cdd7b8b 100644 --- a/tests/Unit/EstimatedWattsTest.php +++ b/tests/Unit/EstimatedWattsTest.php @@ -83,6 +83,14 @@ expect($js)->toContain('aria-sort'); }); + it('keeps the active sort in memory so it works without browser storage', 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); + }); + it('adds a sortable estimated power column to the UPS list', function () use ($read) { $source = $read('upses.php');