Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions css/apcupsd.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
148 changes: 115 additions & 33 deletions includes/dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<script type="text/javascript" ' . plugin_apcupsd_csp_nonce() . '>'
. '$(function() {'
. 'if (!document.getElementById("apcupsd_dashboard") || typeof setupPageTimeout !== "function") { return; }'
. 'refreshIsLogout = false;'
. 'refreshPage = ' . json_encode($page, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';'
. 'refreshMSeconds = ' . ($seconds * 1000) . ';'
. 'setupPageTimeout();'
. '});</script>';
}
}

if (!function_exists('apcupsd_dashboard_refresh_save')) {
/**
* Persist the posted auto-refresh interval for the current user. Accepts only
Expand Down Expand Up @@ -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 '<script type="text/javascript" ' . plugin_apcupsd_csp_nonce() . ' src="' . $config['url_path'] . 'plugins/apcupsd/js/apcupsd_dashboard.js?v=' . filemtime($config['base_path'] . '/plugins/apcupsd/js/apcupsd_dashboard.js') . '"></script>';

Expand Down Expand Up @@ -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.
*/
Expand All @@ -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");

Expand All @@ -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],
];
Expand Down Expand Up @@ -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) {
Expand All @@ -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 '</div>';

break;
Expand Down Expand Up @@ -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 '<p class="apcupsdDashEmpty">' . __esc('Estimated power needs an enabled UPS with a nominal power rating and a load reading.', 'apcupsd') . '</p>';

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 '<div class="apcupsdGaugeGrid"><div class="apcupsdGaugeCell">'
. '<span class="apcupsdGaugeCellLabel">' . __esc('Estimated Power', 'apcupsd') . '</span>'
. 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)
. '</div></div>';

print '<p class="apcupsdDashNote">' . __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')) . '</p>';

break;
case 'gauges':
if ($enabled === 0) {
Expand Down Expand Up @@ -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 '<table class="apcupsdDashTable"><thead><tr>'
// 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 '<th class="apcupsdDashSortable' . ($class !== '' ? ' ' . $class : '') . '" data-sort-key="' . $key . '" data-sort-type="' . $type . '" data-sort-first="' . ($type === 'num' ? 'desc' : 'asc') . '" aria-sort="none">'
. '<button type="button" class="apcupsdDashSortBtn">' . html_escape($label) . '</button></th>';
};

// Empty data-sort values (N/A) always sort last.
$sort_num = function($value): string {
return $value !== null && is_numeric($value) ? (string) (float) $value : '';
};

print '<table class="apcupsdDashTable" data-sort-table="fleet_upses"><thead><tr>'
. '<th></th>'
. '<th>' . __esc('UPS', 'apcupsd') . '</th>'
. '<th>' . __esc('Status', 'apcupsd') . '</th>'
. '<th class="apcupsdDashNum">' . __esc('Load', 'apcupsd') . '</th>'
. '<th class="apcupsdDashNum">' . __esc('Charge', 'apcupsd') . '</th>'
. '<th class="apcupsdDashNum">' . __esc('Time Left', 'apcupsd') . '</th>'
. '<th>' . __esc('Last Updated', 'apcupsd') . '</th>'
. $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')
. '</tr></thead><tbody>';

if (cacti_sizeof($upses)) {
Expand All @@ -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 '<tr>'
. '<td><a class="apcupsdDashGlyph" href="' . html_escape($view) . '" title="' . __esc('Open UPS Dashboard', 'apcupsd') . '"><i class="fas fa-tachometer-alt" aria-hidden="true"></i></a></td>'
. '<td><a href="' . html_escape($view) . '">' . html_escape($ups['name']) . '</a></td>'
. '<td>' . apcupsd_dashboard_status_pill((string) ($ups['ups_status'] ?? '')) . '</td>'
. '<td class="apcupsdDashNum">' . html_escape($load) . '</td>'
. '<td class="apcupsdDashNum">' . html_escape($charge) . '</td>'
. '<td class="apcupsdDashNum">' . html_escape($time) . '</td>'
. '<td>' . html_escape((string) ($ups['last_updated'] ?? '')) . '</td>'
. '<td data-sort="' . html_escape((string) $ups['name']) . '"><a href="' . html_escape($view) . '">' . html_escape($ups['name']) . '</a></td>'
. '<td data-sort="' . html_escape((string) ($ups['ups_status'] ?? '')) . '">' . apcupsd_dashboard_status_pill((string) ($ups['ups_status'] ?? '')) . '</td>'
. '<td class="apcupsdDashNum" data-sort="' . $sort_num($ups['ups_load_percent'] ?? null) . '">' . html_escape($load) . '</td>'
. '<td class="apcupsdDashNum" data-sort="' . $sort_num($ups['ups_battery_charge'] ?? null) . '">' . html_escape($charge) . '</td>'
. '<td class="apcupsdDashNum" data-sort="' . $sort_num($ups['ups_timeleft'] ?? null) . '">' . html_escape($time) . '</td>'
. '<td class="apcupsdDashNum" data-sort="' . $sort_num($watts) . '">' . html_escape(apcupsd_dashboard_format_value($watts, ' W')) . '</td>'
. '<td data-sort="' . html_escape((string) ($ups['last_updated'] ?? '')) . '">' . html_escape((string) ($ups['last_updated'] ?? '')) . '</td>'
. '</tr>';
}
} else {
print '<tr class="apcupsdDashEmptyRow"><td colspan="7" class="apcupsdDashEmpty">' . __esc('No UPSes are configured yet.', 'apcupsd') . '</td></tr>';
print '<tr class="apcupsdDashEmptyRow"><td colspan="8" class="apcupsdDashEmpty">' . __esc('No UPSes are configured yet.', 'apcupsd') . '</td></tr>';
}

print '</tbody></table>';
print '</tbody>';

if ($est_count > 0) {
print '<tfoot><tr class="apcupsdDashTotalRow">'
. '<td></td>'
. '<td colspan="5">' . __esc('Fleet Total', 'apcupsd') . '</td>'
. '<td class="apcupsdDashNum">' . html_escape(apcupsd_dashboard_format_value($est_watts, ' W')) . '</td>'
. '<td></td>'
. '</tr></tfoot>';
}

print '</table>';

break;
}
Expand Down Expand Up @@ -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 '<script type="text/javascript" ' . plugin_apcupsd_csp_nonce() . ' src="' . $config['url_path'] . 'plugins/apcupsd/js/apcupsd_dashboard.js?v=' . filemtime($config['base_path'] . '/plugins/apcupsd/js/apcupsd_dashboard.js') . '"></script>';

Expand Down
30 changes: 30 additions & 0 deletions includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading