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 ''
. ' | '
- . '' . __esc('UPS', 'apcupsd') . ' | '
- . '' . __esc('Status', 'apcupsd') . ' | '
- . '' . __esc('Load', 'apcupsd') . ' | '
- . '' . __esc('Charge', 'apcupsd') . ' | '
- . '' . __esc('Time Left', 'apcupsd') . ' | '
- . '' . __esc('Last Updated', 'apcupsd') . ' | '
+ . $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 ''
. ' | '
- . '' . 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'] ?? '')) . ' | '
. '
';
}
} else {
- print '| ' . __esc('No UPSes are configured yet.', 'apcupsd') . ' |
';
+ print '| ' . __esc('No UPSes are configured yet.', 'apcupsd') . ' |
';
}
- print '
';
+ 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) {