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..26bc209 100644 --- a/js/apcupsd_dashboard.js +++ b/js/apcupsd_dashboard.js @@ -54,6 +54,120 @@ 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 = {}; + } + } + + return apcupsdDashSortState; +} + +/** 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) { + 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 +175,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 +270,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..cdd7b8b --- /dev/null +++ b/tests/Unit/EstimatedWattsTest.php @@ -0,0 +1,101 @@ +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('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'); + + 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) {