From 4caf00decd0dba2e9a25f9960e7b9eb15e348dd7 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 14 Oct 2026 23:09:26 -0400 Subject: [PATCH 1/4] dashboard: shared layout, hide N/A in info card, combine Cable/Driver, add Estimated Power card --- includes/dashboard.php | 143 +++++++++++++++++++++++++++++++++-------- locales/po/cacti.pot | 13 ++++ 2 files changed, 130 insertions(+), 26 deletions(-) diff --git a/includes/dashboard.php b/includes/dashboard.php index 472700a..ecf28b2 100644 --- a/includes/dashboard.php +++ b/includes/dashboard.php @@ -782,20 +782,18 @@ function apcupsd_dashboard_metric_reading(string $key, $ups_id): ?array { } } -if (!function_exists('apcupsd_dashboard_gauge_card_body')) { +if (!function_exists('apcupsd_dashboard_gauge_from_reading')) { /** - * Render a single-metric gauge card body: the current reading swept on the - * dial with the value in the centre and a min/max caption from the RRD window. + * Render a gauge from a resolved reading: the current value swept on the dial + * with the value in the centre and a min/avg/max caption from the RRD window. * - * @param string $metric_key A key from apcupsd_dashboard_metrics(). - * @param int $ups_id The UPS to render for. + * @param array|null $reading A reading from apcupsd_dashboard_metric_reading() + * or apcupsd_dashboard_est_power_reading(). * * @return string The gauge HTML, or an empty-state message. */ - function apcupsd_dashboard_gauge_card_body(string $metric_key, $ups_id): string { - $reading = apcupsd_dashboard_metric_reading($metric_key, $ups_id); - - if ($reading === null) { + function apcupsd_dashboard_gauge_from_reading(?array $reading): string { + if (!is_array($reading)) { return '

' . __esc('No readings have been collected for this UPS yet.', 'apcupsd') . '

'; } @@ -805,18 +803,84 @@ function apcupsd_dashboard_gauge_card_body(string $metric_key, $ups_id): string return '

' . __esc('No readings are available for this gauge yet.', 'apcupsd') . '

'; } - $center = apcupsd_dashboard_format_value($value, $reading['unit']); + $unit = $reading['unit']; + $center = apcupsd_dashboard_format_value($value, $unit); $sub = ''; - if ($reading['min'] !== null || $reading['max'] !== null) { - $sub = __('min %s · max %s', apcupsd_dashboard_format_value($reading['min'], $reading['unit']), apcupsd_dashboard_format_value($reading['max'], $reading['unit']), 'apcupsd'); + if ($reading['avg'] !== null) { + $sub = __('min %s · avg %s · max %s', apcupsd_dashboard_format_value($reading['min'], $unit), apcupsd_dashboard_format_value($reading['avg'], $unit), apcupsd_dashboard_format_value($reading['max'], $unit), 'apcupsd'); + } elseif ($reading['min'] !== null || $reading['max'] !== null) { + $sub = __('min %s · max %s', apcupsd_dashboard_format_value($reading['min'], $unit), apcupsd_dashboard_format_value($reading['max'], $unit), 'apcupsd'); } return apcupsd_dashboard_gauge($value, $reading['min'], $reading['max'], $reading['scale'], $reading['severity'], $center, $sub); } } +if (!function_exists('apcupsd_dashboard_gauge_card_body')) { + /** + * Render a single-metric gauge card body. + * + * @param string $metric_key A key from apcupsd_dashboard_metrics(). + * @param int $ups_id The UPS to render for. + * + * @return string The gauge HTML, or an empty-state message. + */ + function apcupsd_dashboard_gauge_card_body(string $metric_key, $ups_id): string { + return apcupsd_dashboard_gauge_from_reading(apcupsd_dashboard_metric_reading($metric_key, $ups_id)); + } +} + +if (!function_exists('apcupsd_dashboard_est_power_reading')) { + /** + * Estimate real output power for UPSes that do not report it: the nominal + * power rating scaled by the load percentage, with the min/avg/max derived + * from the load's RRD window. Returns null when no nominal power rating or + * load reading is available. + * + * @param int $ups_id The UPS to read. + * + * @return array{label: string, current: float|null, min: float|null, avg: float|null, max: float|null, scale: float, unit: string, severity: string}|null + */ + function apcupsd_dashboard_est_power_reading($ups_id): ?array { + $ups = apcupsd_dashboard_ups($ups_id); + + if (!cacti_sizeof($ups)) { + return null; + } + + $nominal = isset($ups['ups_nominal_power']) && is_numeric($ups['ups_nominal_power']) ? (float) $ups['ups_nominal_power'] : 0.0; + + if ($nominal <= 0) { + return null; + } + + $load = apcupsd_dashboard_metric_reading('load_percent', $ups_id); + + if ($load === null) { + return null; + } + + $watts = function(?float $pct) use ($nominal): ?float { + return $pct === null ? null : $nominal * $pct / 100.0; + }; + + $load_display = $load['current'] !== null ? $load['current'] : $load['avg']; + + return [ + 'label' => __('Estimated Power', 'apcupsd'), + 'current' => $watts($load['current']), + 'min' => $watts($load['min']), + 'avg' => $watts($load['avg']), + 'max' => $watts($load['max']), + 'scale' => $nominal, + 'unit' => ' W', + 'severity' => apcupsd_dashboard_metric_severity('load_percent', $load_display), + ]; + } +} + if (!function_exists('apcupsd_dashboard_card_meta')) { /** * The Dashboard card registry for a UPS: each fixed card plus one card per @@ -840,6 +904,8 @@ function apcupsd_dashboard_card_meta($ups_id): array { } } + $meta['gauge_est_power'] = ['title' => __('Estimated Power', 'apcupsd'), 'span' => 2, 'expandable' => false]; + $meta['power'] = ['title' => __('Input and Load', 'apcupsd'), 'span' => 2, 'expandable' => false]; $meta['battery'] = ['title' => __('Battery and Runtime', 'apcupsd'), 'span' => 2, 'expandable' => false]; $meta['events'] = ['title' => __('Events and Transfers', 'apcupsd'), 'span' => 4, 'expandable' => true]; @@ -892,6 +958,12 @@ function apcupsd_dashboard_card_state(string $key, $ups_id): string { $status = strtoupper((string) ($ups['ups_status'] ?? '')); if (strncmp($key, 'gauge_', 6) === 0) { + if ($key === 'gauge_est_power') { + $reading = apcupsd_dashboard_est_power_reading($ups_id); + + return $reading !== null ? $reading['severity'] : ''; + } + $cards = apcupsd_dashboard_gauge_cards(); if (isset($cards[$key])) { @@ -1006,6 +1078,16 @@ function apcupsd_dashboard_card_body(string $key, $ups_id): string { } if (strncmp($key, 'gauge_', 6) === 0) { + if ($key === 'gauge_est_power') { + $reading = apcupsd_dashboard_est_power_reading($ups_id); + + if ($reading === null) { + return '

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

'; + } + + return apcupsd_dashboard_gauge_from_reading($reading); + } + $cards = apcupsd_dashboard_gauge_cards(); if (!isset($cards[$key])) { @@ -1028,8 +1110,9 @@ function apcupsd_dashboard_card_body(string $key, $ups_id): string { $kv = function(string $label, $value): void { $value = trim((string) $value); + // Hide anything we have no value for: show only what the database holds. if ($value === '') { - $value = __('N/A', 'apcupsd'); + return; } print '' . html_escape($label) . '' . html_escape($value) . ''; @@ -1059,8 +1142,9 @@ function apcupsd_dashboard_card_body(string $key, $ups_id): string { $kv(__('Model', 'apcupsd'), $ups['ups_model'] ?? ''); $kv(__('Serial Number', 'apcupsd'), $ups['ups_serialno'] ?? ''); $kv(__('Firmware', 'apcupsd'), $ups['ups_firmware'] ?? ''); - $kv(__('Cable', 'apcupsd'), $ups['ups_cable'] ?? ''); - $kv(__('Driver', 'apcupsd'), $ups['ups_driver'] ?? ''); + $cable = trim((string) ($ups['ups_cable'] ?? '')); + $driver = trim((string) ($ups['ups_driver'] ?? '')); + $kv(__('Cable / Driver', 'apcupsd'), implode(' / ', array_filter([$cable, $driver], 'strlen'))); $kv(__('Collector', 'apcupsd'), $type); $kv(__('Hostname', 'apcupsd'), $host); $kv(__('Nominal Power', 'apcupsd'), $watts($ups['ups_nominal_power'] ?? null)); @@ -1203,25 +1287,26 @@ function apcupsd_dashboard_render_card(string $key, $ups_id, bool $expanded = fa if (!function_exists('apcupsd_dashboard_layout_key')) { /** - * The per-UPS settings_user key the Dashboard layout is stored under. Each UPS - * uses its own row so no single value can exceed Cacti 1.2.x's 4096-character - * settings_user.value limit. + * The settings_user key the per-UPS Dashboard layout is stored under. The + * arrangement is shared across every UPS, so the key is constant; $ups_id is + * accepted for call compatibility but ignored. * - * @param int $ups_id The UPS id. + * @param int $ups_id The UPS id (ignored). * * @return string The settings key. */ function apcupsd_dashboard_layout_key($ups_id): string { - return 'apcupsd_dashboard_layout_' . (int) $ups_id; + return 'apcupsd_dashboard_layout_shared'; } } if (!function_exists('apcupsd_dashboard_layout_read')) { /** - * Read a UPS's stored layout document, migrating a layout authored under the - * pre-release combined map when the per-UPS row is not yet present. + * Read the stored Dashboard layout document, migrating a layout authored under + * the earlier per-UPS row or the pre-release combined map when the shared row + * is not yet present. * - * @param int $ups_id The UPS id. + * @param int $ups_id The UPS id (used only to locate a legacy layout to migrate). * * @return array|null The {order, expanded} document, or null when none saved. */ @@ -1232,6 +1317,13 @@ function apcupsd_dashboard_layout_read($ups_id): ?array { return $saved; } + // Migrate a layout authored under the earlier per-UPS row. + $legacy = json_decode((string) read_user_setting('apcupsd_dashboard_layout_' . (int) $ups_id, '', true), true); + + if (is_array($legacy) && isset($legacy['order']) && is_array($legacy['order'])) { + return $legacy; + } + $all = json_decode((string) read_user_setting('apcupsd_dashboard_layout', '', true), true); if (is_array($all) && isset($all[(string) $ups_id]) && is_array($all[(string) $ups_id])) { @@ -1296,9 +1388,8 @@ function apcupsd_dashboard_layout($ups_id): array { if (!function_exists('apcupsd_dashboard_layout_save')) { /** - * Persist the posted layout (card order and expanded state) for a UPS for the - * current user, validated to the UPS's known cards and merged into the user's - * per-UPS layout map. + * Persist the posted layout (card order and expanded state) for the current + * user, validated to the current UPS's known cards and shared across every UPS. * * @return string A JSON status document. */ diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 30fd63b..96892b4 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -998,6 +998,11 @@ msgstr "" msgid "Est. Power" msgstr "" +#: includes/dashboard.php +#, php-format +msgid "min %s · avg %s · max %s" +msgstr "" + #: includes/dashboard.php msgid "Estimated Power" msgstr "" @@ -1025,3 +1030,11 @@ msgstr "" #: upses.php msgid "The estimated power draw in watts: the nominal power rating multiplied by the Load Percent." msgstr "" + +#: includes/dashboard.php +msgid "Estimated power needs a nominal power rating and a load reading." +msgstr "" + +#: includes/dashboard.php +msgid "Cable / Driver" +msgstr "" From b1a49f4fa1e38de0567ff473a47aadccafb8e413 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Fri, 16 Oct 2026 12:54:25 -0400 Subject: [PATCH 2/4] dashboard: keep graph cards per UPS, persist legacy layout migration, reuse apcupsd_estimate_watts() - Share only the fixed-card arrangement across UPSes; store each UPS's graph cards (local_graph_id keys) in their own row with a graphs_slot marker so saving from one UPS no longer overwrites or hides another UPS's graphs. - Persist a layout migrated from the per-UPS row or the pre-release combined map to the shared row on first read, with the existing 4096-char guard. - Compute the per-UPS Estimated Power card with the shared apcupsd_estimate_watts() added in #54 instead of duplicating the formula. - Add DashboardLayoutTest and a CHANGELOG entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 + includes/dashboard.php | 232 ++++++++++++++++++++++------- tests/Unit/DashboardLayoutTest.php | 105 +++++++++++++ 3 files changed, 282 insertions(+), 57 deletions(-) create mode 100644 tests/Unit/DashboardLayoutTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3170c81..aca7a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ --- develop --- +* feature: Share the UPS Dashboard card arrangement across every UPS (graph cards stay per UPS since they belong to each device), hide N/A rows in the UPS Information card, combine Cable and Driver on one row, and add a per-UPS Estimated Power gauge card that reuses the shared nominal power x load % estimate + * 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 diff --git a/includes/dashboard.php b/includes/dashboard.php index ecf28b2..e7d8abd 100644 --- a/includes/dashboard.php +++ b/includes/dashboard.php @@ -834,10 +834,11 @@ function apcupsd_dashboard_gauge_card_body(string $metric_key, $ups_id): string if (!function_exists('apcupsd_dashboard_est_power_reading')) { /** - * Estimate real output power for UPSes that do not report it: the nominal - * power rating scaled by the load percentage, with the min/avg/max derived - * from the load's RRD window. Returns null when no nominal power rating or - * load reading is available. + * Estimate real output power for UPSes that do not report it, via the shared + * apcupsd_estimate_watts() formula (nominal power x load %), with the + * min/avg/max derived from the load's RRD window. Complements the Fleet + * Dashboard's fleet-wide estimate with the per-UPS drilldown card. Returns + * null when no nominal power rating or load reading is available. * * @param int $ups_id The UPS to read. * @@ -863,7 +864,7 @@ function apcupsd_dashboard_est_power_reading($ups_id): ?array { } $watts = function(?float $pct) use ($nominal): ?float { - return $pct === null ? null : $nominal * $pct / 100.0; + return apcupsd_estimate_watts($nominal, $pct); }; $load_display = $load['current'] !== null ? $load['current'] : $load['avg']; @@ -1287,9 +1288,10 @@ function apcupsd_dashboard_render_card(string $key, $ups_id, bool $expanded = fa if (!function_exists('apcupsd_dashboard_layout_key')) { /** - * The settings_user key the per-UPS Dashboard layout is stored under. The - * arrangement is shared across every UPS, so the key is constant; $ups_id is - * accepted for call compatibility but ignored. + * The settings_user key the shared Dashboard layout is stored under. It holds + * the fixed-card arrangement and expanded state used by every UPS, so the key + * is constant; $ups_id is accepted for call compatibility but ignored. Graph + * cards are device-specific and live under apcupsd_dashboard_layout_graphs_key(). * * @param int $ups_id The UPS id (ignored). * @@ -1300,61 +1302,36 @@ function apcupsd_dashboard_layout_key($ups_id): string { } } -if (!function_exists('apcupsd_dashboard_layout_read')) { +if (!function_exists('apcupsd_dashboard_layout_graphs_key')) { /** - * Read the stored Dashboard layout document, migrating a layout authored under - * the earlier per-UPS row or the pre-release combined map when the shared row - * is not yet present. + * The settings_user key a UPS's graph-card order is stored under. Graph cards + * are keyed by local_graph_id, which only exists on that UPS's device, so + * they are kept per UPS while the fixed cards are shared. * - * @param int $ups_id The UPS id (used only to locate a legacy layout to migrate). + * @param int $ups_id The UPS id. * - * @return array|null The {order, expanded} document, or null when none saved. + * @return string The settings key. */ - function apcupsd_dashboard_layout_read($ups_id): ?array { - $saved = json_decode((string) read_user_setting(apcupsd_dashboard_layout_key($ups_id), '', true), true); - - if (is_array($saved) && isset($saved['order']) && is_array($saved['order'])) { - return $saved; - } - - // Migrate a layout authored under the earlier per-UPS row. - $legacy = json_decode((string) read_user_setting('apcupsd_dashboard_layout_' . (int) $ups_id, '', true), true); - - if (is_array($legacy) && isset($legacy['order']) && is_array($legacy['order'])) { - return $legacy; - } - - $all = json_decode((string) read_user_setting('apcupsd_dashboard_layout', '', true), true); - - if (is_array($all) && isset($all[(string) $ups_id]) && is_array($all[(string) $ups_id])) { - return $all[(string) $ups_id]; - } - - return null; + function apcupsd_dashboard_layout_graphs_key($ups_id): string { + return 'apcupsd_dashboard_layout_graphs_' . (int) $ups_id; } } -if (!function_exists('apcupsd_dashboard_layout')) { +if (!function_exists('apcupsd_dashboard_layout_normalize')) { /** - * The current user's saved layout for a UPS: the present cards in order plus - * their expanded state. With no saved layout every card is present in the - * default order; cards absent from a saved order live in the "Add" catalogue. + * Reduce a stored layout document to the cards a UPS has available: retired + * combined 'gauges' cards expand to the per-metric gauge cards, and unknown or + * duplicate keys are dropped. * - * @param int $ups_id The UPS the layout is rendered for. + * @param array $saved The {order, expanded} document. + * @param string[] $available The card keys available for the UPS. * * @return array{order: string[], expanded: array} */ - function apcupsd_dashboard_layout($ups_id): array { - $available = apcupsd_dashboard_available_cards($ups_id); - $saved = apcupsd_dashboard_layout_read($ups_id); - - if (!is_array($saved) || !isset($saved['order']) || !is_array($saved['order'])) { - return ['order' => $available, 'expanded' => []]; - } - + function apcupsd_dashboard_layout_normalize(array $saved, array $available): array { $order = []; - foreach ($saved['order'] as $key) { + foreach (isset($saved['order']) && is_array($saved['order']) ? $saved['order'] : [] as $key) { // Migrate the retired combined 'gauges' card to the per-metric gauge cards // so existing layouts surface the new gauges where the old card sat. if ($key === 'gauges') { @@ -1376,7 +1353,7 @@ function apcupsd_dashboard_layout($ups_id): array { if (isset($saved['expanded']) && is_array($saved['expanded'])) { foreach ($saved['expanded'] as $key => $on) { - if ($on && in_array($key, $available, true)) { + if ($on && in_array((string) $key, $available, true)) { $expanded[(string) $key] = true; } } @@ -1386,6 +1363,153 @@ function apcupsd_dashboard_layout($ups_id): array { } } +if (!function_exists('apcupsd_dashboard_layout_store')) { + /** + * Persist a layout for the current user. Fixed cards and expanded state go to + * the shared row; graph cards go to the UPS's own row, with a 'graphs_slot' + * marker in the shared order recording where the graph group sits so another + * UPS places its own graphs in the same spot. + * + * @param int $ups_id The UPS the layout was arranged for. + * @param string[] $order The validated card order. + * @param array $expanded The validated expanded state. + * + * @return bool False, without saving anything, when a document exceeds the settings size cap. + */ + function apcupsd_dashboard_layout_store($ups_id, array $order, array $expanded): bool { + $shared = []; + $graphs = []; + + foreach ($order as $key) { + if (strncmp($key, 'graph_', 6) === 0) { + if (!cacti_sizeof($graphs)) { + $shared[] = 'graphs_slot'; + } + + $graphs[] = $key; + } else { + $shared[] = $key; + } + } + + $shared_json = json_encode(['order' => $shared, 'expanded' => $expanded]); + $graphs_json = json_encode(['order' => $graphs]); + + // Cacti 1.2.x caps settings_user.value at 4096 characters; refuse rather than + // silently truncate a layout and lose the user's saved preferences. + if ($shared_json === false || $graphs_json === false || strlen($shared_json) > 4096 || strlen($graphs_json) > 4096) { + return false; + } + + set_user_setting(apcupsd_dashboard_layout_key($ups_id), $shared_json); + set_user_setting(apcupsd_dashboard_layout_graphs_key($ups_id), $graphs_json); + + return true; + } +} + +if (!function_exists('apcupsd_dashboard_layout_read')) { + /** + * Read the stored Dashboard layout for a UPS: the shared fixed-card order with + * the UPS's own graph cards placed at the stored graph slot (or, with no + * graph preference saved for it, all of its graphs in default order). When + * the shared row is not yet present, a layout authored under the earlier + * per-UPS row or the pre-release combined map is migrated: it is persisted to + * the shared row so every UPS picks it up, then returned. + * + * @param int $ups_id The UPS the layout is read for. + * @param string[] $available The card keys available for the UPS. + * + * @return array{order: string[], expanded: array}|null The layout, or null when none saved. + */ + function apcupsd_dashboard_layout_read($ups_id, array $available): ?array { + $saved = json_decode((string) read_user_setting(apcupsd_dashboard_layout_key($ups_id), '', true), true); + + if (is_array($saved) && isset($saved['order']) && is_array($saved['order'])) { + $own = json_decode((string) read_user_setting(apcupsd_dashboard_layout_graphs_key($ups_id), '', true), true); + + if (is_array($own) && isset($own['order']) && is_array($own['order'])) { + $graphs = $own['order']; + } else { + $graphs = []; + + foreach ($available as $key) { + if (strncmp($key, 'graph_', 6) === 0) { + $graphs[] = $key; + } + } + } + + $order = []; + $placed = false; + + foreach ($saved['order'] as $key) { + if ($key === 'graphs_slot') { + if (!$placed) { + foreach ($graphs as $graph_key) { + $order[] = $graph_key; + } + + $placed = true; + } + + continue; + } + + $order[] = $key; + } + + if (!$placed) { + foreach ($graphs as $graph_key) { + $order[] = $graph_key; + } + } + + return ['order' => $order, 'expanded' => isset($saved['expanded']) && is_array($saved['expanded']) ? $saved['expanded'] : []]; + } + + // Migrate a layout authored under the earlier per-UPS row, then the older combined map. + $legacy = json_decode((string) read_user_setting('apcupsd_dashboard_layout_' . (int) $ups_id, '', true), true); + + if (!is_array($legacy) || !isset($legacy['order']) || !is_array($legacy['order'])) { + $all = json_decode((string) read_user_setting('apcupsd_dashboard_layout', '', true), true); + $legacy = is_array($all) && isset($all[(string) $ups_id]) && is_array($all[(string) $ups_id]) ? $all[(string) $ups_id] : null; + } + + if (!is_array($legacy) || !isset($legacy['order']) || !is_array($legacy['order'])) { + return null; + } + + $migrated = apcupsd_dashboard_layout_normalize($legacy, $available); + + apcupsd_dashboard_layout_store($ups_id, $migrated['order'], $migrated['expanded']); + + return $migrated; + } +} + +if (!function_exists('apcupsd_dashboard_layout')) { + /** + * The current user's saved layout for a UPS: the present cards in order plus + * their expanded state. With no saved layout every card is present in the + * default order; cards absent from a saved order live in the "Add" catalogue. + * + * @param int $ups_id The UPS the layout is rendered for. + * + * @return array{order: string[], expanded: array} + */ + function apcupsd_dashboard_layout($ups_id): array { + $available = apcupsd_dashboard_available_cards($ups_id); + $saved = apcupsd_dashboard_layout_read($ups_id, $available); + + if ($saved === null) { + return ['order' => $available, 'expanded' => []]; + } + + return apcupsd_dashboard_layout_normalize($saved, $available); + } +} + if (!function_exists('apcupsd_dashboard_layout_save')) { /** * Persist the posted layout (card order and expanded state) for the current @@ -1428,16 +1552,10 @@ function apcupsd_dashboard_layout_save(): string { } } - $encoded = json_encode(['order' => $order, 'expanded' => $expanded]); - - // Cacti 1.2.x caps settings_user.value at 4096 characters; refuse rather than - // silently truncate a per-UPS layout and lose the user's saved preferences. - if ($encoded === false || strlen($encoded) > 4096) { + if (!apcupsd_dashboard_layout_store($ups_id, $order, $expanded)) { return (string) json_encode(['error' => __('This dashboard has too many cards to save its layout.', 'apcupsd')]); } - set_user_setting(apcupsd_dashboard_layout_key($ups_id), $encoded); - return (string) json_encode(['ok' => true]); } } diff --git a/tests/Unit/DashboardLayoutTest.php b/tests/Unit/DashboardLayoutTest.php new file mode 100644 index 0000000..70462ab --- /dev/null +++ b/tests/Unit/DashboardLayoutTest.php @@ -0,0 +1,105 @@ + true]))->toBeTrue(); + + expect(apcupsd_dashboard_layout_read(1, $a)['order'])->toBe(['battery', 'identity', 'graph_11', 'graph_10', 'power', 'events']); + + // UPS B has no graph preference yet: its graphs take the shared slot in default order. + $read_b = apcupsd_dashboard_layout_read(2, $b); + + expect($read_b['order'])->toBe(['battery', 'identity', 'graph_20', 'graph_21', 'power', 'events']); + expect($read_b['expanded'])->toBe(['events' => true]); + }); + + it('does not overwrite another UPS\'s graph selection when saving', function () { + apcupsd_dashboard_layout_store(1, ['identity', 'graph_10'], []); + apcupsd_dashboard_layout_store(2, ['identity', 'graph_21'], []); + + expect(apcupsd_dashboard_layout_read(1, ['identity', 'graph_10', 'graph_11'])['order'])->toBe(['identity', 'graph_10']); + expect(apcupsd_dashboard_layout_read(2, ['identity', 'graph_20', 'graph_21'])['order'])->toBe(['identity', 'graph_21']); + }); + + it('refuses to save a layout over the settings size cap without writing anything', function () { + $order = []; + + for ($i = 0; $i < 600; $i++) { + $order[] = 'graph_' . (100000 + $i); + } + + expect(apcupsd_dashboard_layout_store(1, $order, []))->toBeFalse(); + expect($GLOBALS['__user_settings'])->toBe([]); + }); + + it('returns null when nothing is saved', function () use ($fixed) { + expect(apcupsd_dashboard_layout_read(1, $fixed))->toBeNull(); + }); + + it('persists a legacy per-UPS layout to the shared row on first read', function () use ($fixed) { + $available = array_merge($fixed, ['graph_10']); + + $GLOBALS['__user_settings']['apcupsd_dashboard_layout_1'] = json_encode(['order' => ['power', 'graph_10', 'gauges', 'bogus'], 'expanded' => ['events' => true]]); + + $first = apcupsd_dashboard_layout_read(1, $available); + + expect($first['order'])->toBe(['power', 'graph_10']); + expect($GLOBALS['__user_settings'])->toHaveKey('apcupsd_dashboard_layout_shared'); + expect($GLOBALS['__user_settings'])->toHaveKey('apcupsd_dashboard_layout_graphs_1'); + + // A different UPS now inherits the migrated fixed-card arrangement. + expect(apcupsd_dashboard_layout_read(2, ['identity', 'power', 'graph_20'])['order'])->toBe(['power', 'graph_20']); + }); + + it('persists the pre-release combined-map layout to the shared row on first read', function () use ($fixed) { + $GLOBALS['__user_settings']['apcupsd_dashboard_layout'] = json_encode(['1' => ['order' => ['events', 'identity']]]); + + expect(apcupsd_dashboard_layout_read(1, $fixed)['order'])->toBe(['events', 'identity']); + expect($GLOBALS['__user_settings'])->toHaveKey('apcupsd_dashboard_layout_shared'); + }); + + it('normalizes to the available cards, dropping unknown and duplicate keys', function () use ($fixed) { + $layout = apcupsd_dashboard_layout_normalize(['order' => ['power', 'power', 'nope', 'identity'], 'expanded' => ['events' => true, 'nope' => true]], $fixed); + + expect($layout['order'])->toBe(['power', 'identity']); + expect($layout['expanded'])->toBe(['events' => true]); + }); +}); From 810f644f9fde8c79cf12a5b654849c2cd0fc3a03 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Fri, 16 Oct 2026 13:07:54 -0400 Subject: [PATCH 3/4] dashboard: exclude slave UPSes from fleet estimated power and show N/A A slave's draw is already part of its master's, so leave it out of the fleet power total, capacity and count, and show N/A for its Est. Power in the Fleet Dashboard table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + includes/dashboard.php | 16 ++++++++++------ includes/functions.php | 16 ++++++++++++++++ tests/Unit/EstimatedWattsTest.php | 17 +++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aca7a82..5d3d695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ --- develop --- * feature: Share the UPS Dashboard card arrangement across every UPS (graph cards stay per UPS since they belong to each device), hide N/A rows in the UPS Information card, combine Cable and Driver on one row, and add a per-UPS Estimated Power gauge card that reuses the shared nominal power x load % estimate +* issue: Show N/A for a slave UPS's (e.g. ONLINE SLAVE) estimated power on the Fleet Dashboard and leave it out of the fleet power total, since its draw is already counted in its master's * feature: Estimate each UPS's power draw as nominal power x load % for UPSes that do not report real wattage, and show it on the Fleet Dashboard (an "Est. Power" tile, a new "Fleet Power" gauge card of total estimated watts against rated capacity, and an "Est. Power" column with a fleet total in the UPSes card) and as a sortable "Est. Power" column in the UPSes list * feature: Make the Fleet Dashboard UPSes table sortable by UPS, Status, Load, Charge, Time Left, Est. Power and Last Updated; the choice persists in the browser and survives card refreshes diff --git a/includes/dashboard.php b/includes/dashboard.php index e7d8abd..aed58e4 100644 --- a/includes/dashboard.php +++ b/includes/dashboard.php @@ -1832,7 +1832,8 @@ function apcupsd_dashboard_tabs(string $current): void { * Memoized fleet aggregates: UPS counts by status (online/on-battery/other/ * 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. + * with the rated capacity and UPS count it was summed over. Slave UPSes are + * left out of the power figures: their draw is already part of the master's. * * @return array The aggregate row. */ @@ -1840,6 +1841,8 @@ function apcupsd_fleet_stats(): array { static $cache = null; if ($cache === null) { + $estimable = "ups.enabled = 'on' AND COALESCE(stats.ups_status, '') NOT LIKE '%SLAVE%' AND stats.ups_nominal_power > 0 AND stats.ups_load_percent >= 0"; + $row = db_fetch_row("SELECT COUNT(*) AS total, SUM(CASE WHEN ups.enabled = 'on' THEN 1 ELSE 0 END) AS enabled, @@ -1855,9 +1858,9 @@ function apcupsd_fleet_stats(): array { 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, - 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 + SUM(CASE WHEN $estimable THEN stats.ups_nominal_power * stats.ups_load_percent / 100 END) AS estWatts, + SUM(CASE WHEN $estimable THEN stats.ups_nominal_power END) AS estCapacity, + SUM(CASE WHEN $estimable 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"); @@ -2257,8 +2260,9 @@ 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; + // A disabled UPS is not being polled, so its last reading is not an estimate, + // and a slave's draw is already counted in its master's power. + $watts = ($ups['enabled'] ?? '') === 'on' && !apcupsd_is_slave_status($ups['ups_status'] ?? null) ? apcupsd_estimate_watts($ups['ups_nominal_power'] ?? null, $ups['ups_load_percent'] ?? null) : null; print '' . '' diff --git a/includes/functions.php b/includes/functions.php index 3b43ea5..54e903a 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -26,6 +26,22 @@ function apcupsd_host_template_imported(): bool { } } +if (!function_exists('apcupsd_is_slave_status')) { + /** + * Whether a UPS status string reports the UPS as a slave (e.g. "ONLINE SLAVE" + * or "SLAVEDOWN"). A slave draws power already attributed to the master UPS it + * is attached to, so its estimated power must not be counted again in fleet + * totals. + * + * @param mixed $status The apcupsd STATUS value. + * + * @return bool True when the status contains the SLAVE flag. + */ + function apcupsd_is_slave_status($status): bool { + return is_string($status) && stripos($status, 'SLAVE') !== false; + } +} + if (!function_exists('apcupsd_estimate_watts')) { /** * Estimates a UPS's real output power in watts as diff --git a/tests/Unit/EstimatedWattsTest.php b/tests/Unit/EstimatedWattsTest.php index cdd7b8b..f726a20 100644 --- a/tests/Unit/EstimatedWattsTest.php +++ b/tests/Unit/EstimatedWattsTest.php @@ -41,6 +41,21 @@ }); }); +describe('apcupsd_is_slave_status', function () { + it('flags slave statuses', function () { + expect(apcupsd_is_slave_status('ONLINE SLAVE'))->toBeTrue(); + expect(apcupsd_is_slave_status('SLAVEDOWN'))->toBeTrue(); + expect(apcupsd_is_slave_status('online slave'))->toBeTrue(); + }); + + it('does not flag master or non-string statuses', function () { + expect(apcupsd_is_slave_status('ONLINE'))->toBeFalse(); + expect(apcupsd_is_slave_status('ONBATT'))->toBeFalse(); + expect(apcupsd_is_slave_status(''))->toBeFalse(); + expect(apcupsd_is_slave_status(null))->toBeFalse(); + }); +}); + describe('estimated watts wiring', function () { $read = function (string $relative): string { $contents = file_get_contents(__DIR__ . '/../../' . $relative); @@ -56,6 +71,7 @@ expect($source)->toContain('AS estWatts'); expect($source)->toContain('AS estCapacity'); expect($source)->toContain('AS estCount'); + expect($source)->toContain("NOT LIKE '%SLAVE%'"); expect($source)->toContain("'power' => ['title' => __('Fleet Power', 'apcupsd')"); }); @@ -64,6 +80,7 @@ expect($source)->toContain("\$th('power', __('Est. Power', 'apcupsd')"); expect($source)->toContain('apcupsd_estimate_watts('); + expect($source)->toContain('!apcupsd_is_slave_status('); expect($source)->toContain('apcupsdDashTotalRow'); }); From 56e3c88f9d431f0ff3af47fa566c7e59658fa926 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Fri, 16 Oct 2026 13:16:24 -0400 Subject: [PATCH 4/4] dashboard: preserve graph placement per run, keep shared slots for graphless UPSes, migrate each UPS's legacy graphs - Store one graphs_slot marker per run of consecutive graph cards and one graph run per marker, so graphs interleaved with fixed cards keep their placement across a save/reload. - When a UPS with no graphs saves, re-anchor the existing shared slots to the nearest surviving fixed card instead of dropping them. - When the shared row already exists but a UPS has no graph row, migrate and persist that UPS's own legacy graph selection (per-UPS row, then combined map) before defaulting to all graphs; the shared fixed layout is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- includes/dashboard.php | 212 ++++++++++++++++++++++------- tests/Unit/DashboardLayoutTest.php | 52 +++++++ 2 files changed, 215 insertions(+), 49 deletions(-) diff --git a/includes/dashboard.php b/includes/dashboard.php index aed58e4..7d0e001 100644 --- a/includes/dashboard.php +++ b/includes/dashboard.php @@ -1363,41 +1363,130 @@ function apcupsd_dashboard_layout_normalize(array $saved, array $available): arr } } -if (!function_exists('apcupsd_dashboard_layout_store')) { +if (!function_exists('apcupsd_dashboard_layout_split')) { /** - * Persist a layout for the current user. Fixed cards and expanded state go to - * the shared row; graph cards go to the UPS's own row, with a 'graphs_slot' - * marker in the shared order recording where the graph group sits so another - * UPS places its own graphs in the same spot. + * Split a flat card order into the shared fixed-card order and the UPS's + * graph runs. Each run of consecutive graph cards becomes one 'graphs_slot' + * marker in the shared order and one list in the runs, so graphs interleaved + * with fixed cards keep their placement. * - * @param int $ups_id The UPS the layout was arranged for. - * @param string[] $order The validated card order. - * @param array $expanded The validated expanded state. + * @param string[] $order A validated card order. * - * @return bool False, without saving anything, when a document exceeds the settings size cap. + * @return array{0: string[], 1: array} The shared order and the graph runs. */ - function apcupsd_dashboard_layout_store($ups_id, array $order, array $expanded): bool { + function apcupsd_dashboard_layout_split(array $order): array { $shared = []; - $graphs = []; + $runs = []; + $in_run = false; foreach ($order as $key) { if (strncmp($key, 'graph_', 6) === 0) { - if (!cacti_sizeof($graphs)) { + if (!$in_run) { $shared[] = 'graphs_slot'; + $runs[] = []; + $in_run = true; } - $graphs[] = $key; + $runs[count($runs) - 1][] = $key; } else { $shared[] = $key; + $in_run = false; + } + } + + return [$shared, $runs]; + } +} + +if (!function_exists('apcupsd_dashboard_layout_keep_slots')) { + /** + * Carry the 'graphs_slot' markers of an existing shared order into a new + * fixed-card order. Each slot stays after the nearest preceding fixed card + * that is still present (or at the start), so a UPS with no graphs of its + * own can reorder or remove fixed cards without moving other UPSes' graphs. + * + * @param array $old_order The stored shared order, markers included. + * @param string[] $fixed_order The new fixed-card order. + * + * @return string[] The new shared order with the markers re-anchored. + */ + function apcupsd_dashboard_layout_keep_slots(array $old_order, array $fixed_order): array { + $present = array_flip($fixed_order); + $after = []; + $anchor = ''; + + foreach ($old_order as $key) { + if ($key === 'graphs_slot') { + $after[$anchor] = ($after[$anchor] ?? 0) + 1; + } elseif (is_string($key) && isset($present[$key])) { + $anchor = $key; + } + } + + $out = []; + + for ($i = 0; $i < ($after[''] ?? 0); $i++) { + $out[] = 'graphs_slot'; + } + + foreach ($fixed_order as $key) { + $out[] = $key; + + for ($i = 0; $i < ($after[$key] ?? 0); $i++) { + $out[] = 'graphs_slot'; + } + } + + return $out; + } +} + +if (!function_exists('apcupsd_dashboard_layout_graphs_encode')) { + /** + * Encode a UPS's graph runs for settings_user, or null when they do not fit. + * Cacti 1.2.x caps settings_user.value at 4096 characters; refuse rather than + * silently truncate a layout and lose the user's saved preferences. + * + * @param array $runs The graph runs. + * + * @return string|null The JSON document, or null when it cannot be saved. + */ + function apcupsd_dashboard_layout_graphs_encode(array $runs): ?string { + $json = json_encode(['runs' => $runs]); + + return $json === false || strlen($json) > 4096 ? null : $json; + } +} + +if (!function_exists('apcupsd_dashboard_layout_store')) { + /** + * Persist a layout for the current user. Fixed cards and expanded state go to + * the shared row; graph cards go to the UPS's own row, with one 'graphs_slot' + * marker per run of graphs in the shared order so another UPS places its own + * graphs in the same spots. A UPS with no graphs leaves the shared graph + * slots in place. + * + * @param int $ups_id The UPS the layout was arranged for. + * @param string[] $order The validated card order. + * @param array $expanded The validated expanded state. + * + * @return bool False, without saving anything, when a document exceeds the settings size cap. + */ + function apcupsd_dashboard_layout_store($ups_id, array $order, array $expanded): bool { + [$shared, $runs] = apcupsd_dashboard_layout_split($order); + + if (!cacti_sizeof($runs)) { + $existing = json_decode((string) read_user_setting(apcupsd_dashboard_layout_key($ups_id), '', true), true); + + if (is_array($existing) && isset($existing['order']) && is_array($existing['order'])) { + $shared = apcupsd_dashboard_layout_keep_slots($existing['order'], $shared); } } $shared_json = json_encode(['order' => $shared, 'expanded' => $expanded]); - $graphs_json = json_encode(['order' => $graphs]); + $graphs_json = apcupsd_dashboard_layout_graphs_encode($runs); - // Cacti 1.2.x caps settings_user.value at 4096 characters; refuse rather than - // silently truncate a layout and lose the user's saved preferences. - if ($shared_json === false || $graphs_json === false || strlen($shared_json) > 4096 || strlen($graphs_json) > 4096) { + if ($shared_json === false || strlen($shared_json) > 4096 || $graphs_json === null) { return false; } @@ -1408,14 +1497,35 @@ function apcupsd_dashboard_layout_store($ups_id, array $order, array $expanded): } } +if (!function_exists('apcupsd_dashboard_layout_legacy')) { + /** + * A layout authored under the earlier per-UPS row, or failing that the + * pre-release combined map, for a UPS. + * + * @param int $ups_id The UPS id. + * + * @return array|null The {order, expanded} document, or null when none exists. + */ + function apcupsd_dashboard_layout_legacy($ups_id): ?array { + $legacy = json_decode((string) read_user_setting('apcupsd_dashboard_layout_' . (int) $ups_id, '', true), true); + + if (!is_array($legacy) || !isset($legacy['order']) || !is_array($legacy['order'])) { + $all = json_decode((string) read_user_setting('apcupsd_dashboard_layout', '', true), true); + $legacy = is_array($all) && isset($all[(string) $ups_id]) && is_array($all[(string) $ups_id]) ? $all[(string) $ups_id] : null; + } + + return is_array($legacy) && isset($legacy['order']) && is_array($legacy['order']) ? $legacy : null; + } +} + if (!function_exists('apcupsd_dashboard_layout_read')) { /** * Read the stored Dashboard layout for a UPS: the shared fixed-card order with - * the UPS's own graph cards placed at the stored graph slot (or, with no - * graph preference saved for it, all of its graphs in default order). When - * the shared row is not yet present, a layout authored under the earlier - * per-UPS row or the pre-release combined map is migrated: it is persisted to - * the shared row so every UPS picks it up, then returned. + * the UPS's own graph runs filled into the shared graph slots (extra runs go + * last). A UPS with no graph row yet migrates its own legacy graph selection + * and persists it, or else shows all of its graphs in default order, leaving + * the shared fixed-card layout unchanged. When the shared row is not yet + * present, a legacy layout is persisted to it so every UPS picks it up. * * @param int $ups_id The UPS the layout is read for. * @param string[] $available The card keys available for the UPS. @@ -1428,39 +1538,49 @@ function apcupsd_dashboard_layout_read($ups_id, array $available): ?array { if (is_array($saved) && isset($saved['order']) && is_array($saved['order'])) { $own = json_decode((string) read_user_setting(apcupsd_dashboard_layout_graphs_key($ups_id), '', true), true); - if (is_array($own) && isset($own['order']) && is_array($own['order'])) { - $graphs = $own['order']; + if (is_array($own) && isset($own['runs']) && is_array($own['runs'])) { + $runs = array_values($own['runs']); } else { - $graphs = []; + $legacy = apcupsd_dashboard_layout_legacy($ups_id); + + if ($legacy !== null) { + [, $runs] = apcupsd_dashboard_layout_split(apcupsd_dashboard_layout_normalize($legacy, $available)['order']); + + $json = apcupsd_dashboard_layout_graphs_encode($runs); - foreach ($available as $key) { - if (strncmp($key, 'graph_', 6) === 0) { - $graphs[] = $key; + if ($json !== null) { + set_user_setting(apcupsd_dashboard_layout_graphs_key($ups_id), $json); + } + } else { + $runs = [[]]; + + foreach ($available as $key) { + if (strncmp($key, 'graph_', 6) === 0) { + $runs[0][] = $key; + } } } } - $order = []; - $placed = false; + $order = []; + $next = 0; foreach ($saved['order'] as $key) { - if ($key === 'graphs_slot') { - if (!$placed) { - foreach ($graphs as $graph_key) { - $order[] = $graph_key; - } - - $placed = true; - } + if ($key !== 'graphs_slot') { + $order[] = $key; continue; } - $order[] = $key; + foreach (is_array($runs[$next] ?? null) ? $runs[$next] : [] as $graph_key) { + $order[] = $graph_key; + } + + $next++; } - if (!$placed) { - foreach ($graphs as $graph_key) { + for (; $next < count($runs); $next++) { + foreach (is_array($runs[$next]) ? $runs[$next] : [] as $graph_key) { $order[] = $graph_key; } } @@ -1468,15 +1588,9 @@ function apcupsd_dashboard_layout_read($ups_id, array $available): ?array { return ['order' => $order, 'expanded' => isset($saved['expanded']) && is_array($saved['expanded']) ? $saved['expanded'] : []]; } - // Migrate a layout authored under the earlier per-UPS row, then the older combined map. - $legacy = json_decode((string) read_user_setting('apcupsd_dashboard_layout_' . (int) $ups_id, '', true), true); - - if (!is_array($legacy) || !isset($legacy['order']) || !is_array($legacy['order'])) { - $all = json_decode((string) read_user_setting('apcupsd_dashboard_layout', '', true), true); - $legacy = is_array($all) && isset($all[(string) $ups_id]) && is_array($all[(string) $ups_id]) ? $all[(string) $ups_id] : null; - } + $legacy = apcupsd_dashboard_layout_legacy($ups_id); - if (!is_array($legacy) || !isset($legacy['order']) || !is_array($legacy['order'])) { + if ($legacy === null) { return null; } diff --git a/tests/Unit/DashboardLayoutTest.php b/tests/Unit/DashboardLayoutTest.php index 70462ab..eec4fb9 100644 --- a/tests/Unit/DashboardLayoutTest.php +++ b/tests/Unit/DashboardLayoutTest.php @@ -51,6 +51,15 @@ function set_user_setting($name, $value) { expect($read_b['expanded'])->toBe(['events' => true]); }); + it('preserves graphs interleaved with fixed cards', function () { + $available = ['identity', 'power', 'battery', 'graph_10', 'graph_11']; + $order = ['identity', 'graph_10', 'battery', 'graph_11']; + + apcupsd_dashboard_layout_store(1, $order, []); + + expect(apcupsd_dashboard_layout_read(1, $available)['order'])->toBe($order); + }); + it('does not overwrite another UPS\'s graph selection when saving', function () { apcupsd_dashboard_layout_store(1, ['identity', 'graph_10'], []); apcupsd_dashboard_layout_store(2, ['identity', 'graph_21'], []); @@ -59,6 +68,49 @@ function set_user_setting($name, $value) { expect(apcupsd_dashboard_layout_read(2, ['identity', 'graph_20', 'graph_21'])['order'])->toBe(['identity', 'graph_21']); }); + it('keeps the shared graph position when a UPS without graphs saves', function () { + $a = ['identity', 'battery', 'graph_10']; + + apcupsd_dashboard_layout_store(1, ['identity', 'graph_10', 'battery'], []); + + // UPS B has no graphs: it reorders the fixed cards. + apcupsd_dashboard_layout_store(2, ['battery', 'identity'], []); + + expect(apcupsd_dashboard_layout_read(1, $a)['order'])->toBe(['battery', 'identity', 'graph_10']); + + // ...and then removes the card the graphs were anchored to: they stay after the nearest surviving card. + apcupsd_dashboard_layout_store(2, ['battery'], []); + + expect(apcupsd_dashboard_layout_read(1, $a)['order'])->toBe(['battery', 'graph_10']); + + // Removing every fixed card leaves the slot at the start. + apcupsd_dashboard_layout_store(2, [], []); + + expect(apcupsd_dashboard_layout_read(1, $a)['order'])->toBe(['graph_10']); + }); + + it('migrates a second UPS\'s legacy graph selection without changing the shared layout', function () { + apcupsd_dashboard_layout_store(1, ['power', 'identity', 'graph_10'], []); + + $shared = $GLOBALS['__user_settings']['apcupsd_dashboard_layout_shared']; + + $GLOBALS['__user_settings']['apcupsd_dashboard_layout_2'] = json_encode(['order' => ['battery', 'graph_21']]); + + $read_b = apcupsd_dashboard_layout_read(2, ['identity', 'power', 'battery', 'graph_20', 'graph_21']); + + expect($read_b['order'])->toBe(['power', 'identity', 'graph_21']); + expect($GLOBALS['__user_settings']['apcupsd_dashboard_layout_shared'])->toBe($shared); + expect($GLOBALS['__user_settings'])->toHaveKey('apcupsd_dashboard_layout_graphs_2'); + }); + + it('migrates a second UPS\'s combined-map graph selection', function () { + apcupsd_dashboard_layout_store(1, ['identity', 'graph_10'], []); + + $GLOBALS['__user_settings']['apcupsd_dashboard_layout'] = json_encode(['2' => ['order' => ['graph_21', 'identity']]]); + + expect(apcupsd_dashboard_layout_read(2, ['identity', 'graph_20', 'graph_21'])['order'])->toBe(['identity', 'graph_21']); + }); + it('refuses to save a layout over the settings size cap without writing anything', function () { $order = [];