From 23373b796436236d2bb9cea4414736751db49ab5 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 11:11:49 -0400 Subject: [PATCH 01/26] Monitor: flex grouping cards to full width, order Tree cards by device count --- CHANGELOG.md | 2 ++ css/monitor.css | 41 ++++++++++++++++++++--------------------- includes/render.php | 13 ++++++++++--- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d92784..7435db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ --- 3.0 --- +* bug: Lay the grouping cards out with a wrapping flexbox instead of a CSS grid so they always stretch to fill the full horizontal space; inside Cacti's shrink-to-fit html_start_box div the grid mis-counted its tracks and clipped the cards to roughly half the page width +* bug: Default-order the Tree grouping cards by device count (busiest first), matching the Site and Device Template orderings while still honouring any saved drag order * feature: Restructure plugin_monitor_dashboards to store filter state as a JSON `properties` object (migrating the legacy raw `url` into it and dropping the url column on upgrade), and construct dashboard URLs from that structure instead of persisting raw urls * feature: Render the Site and Device Template groupings as a responsive full-width grid of draggable card panels (up to 8 columns, shrinking on narrower screens) whose titles are coloured by each group's worst device status, persisting card order per dashboard or per user * bug: Let a grouping card with many devices span several grid columns so it flows across the page instead of stacking into one tall, narrow column that scrolls off the screen diff --git a/css/monitor.css b/css/monitor.css index 6c4bd7e..7fbb941 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -643,34 +643,33 @@ color: gray; } -/* Grouping card grid: responsive, flows full screen up to ~8 columns and - shrinks to fewer columns as the viewport narrows. Large groups span several - columns (see data-span) so their devices flow across the page. */ +/* Grouping card grid: a wrapping flexbox so cards always stretch to fill the + full horizontal space. A CSS grid was tried first, but Cacti renders this + content inside a shrink-to-fit `display:table` box (html_start_box with + $div=true), which breaks grid auto-fill track counting and clipped the cards + to roughly half the page. Flexbox grows each card to fill its row regardless + of that containing block, so a lone busy card spans the whole width while + smaller cards share a row. */ .monitorGroupGrid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - grid-auto-flow: row dense; + display: flex; + flex-wrap: wrap; gap: 10px; - align-items: start; + align-items: flex-start; width: 100%; - container-type: inline-size; } -/* Cap each card's span to the number of 260px tracks the grid can actually - show at its current width (track + 10px gap), so a wide card never forces an - extra implicit track and horizontal overflow on narrow layouts. */ -@container (max-width: 529px) { - .monitorGroupCard { grid-column: auto !important; } -} - -@container (min-width: 530px) and (max-width: 799px) { - .monitorGroupCard[data-span="3"], - .monitorGroupCard[data-span="4"] { grid-column: span 2 !important; } +.monitorGroupCard { + flex: 1 1 260px; + min-width: 260px; + box-sizing: border-box; } -@container (min-width: 800px) and (max-width: 1069px) { - .monitorGroupCard[data-span="4"] { grid-column: span 3 !important; } -} +/* Busier groups get a wider basis (one extra 260px + 10px gap per step) and a + matching flex-grow, so when several cards share a row the busier ones take + proportionally more space; a card alone on a row always fills it. */ +.monitorGroupCard[data-span="2"] { flex-grow: 2; flex-basis: 530px; } +.monitorGroupCard[data-span="3"] { flex-grow: 3; flex-basis: 800px; } +.monitorGroupCard[data-span="4"] { flex-grow: 4; flex-basis: 1070px; } .monitorGroupCard .monitorPanelBody { padding: 6px; diff --git a/includes/render.php b/includes/render.php index 8a4d4c2..c6ae49b 100644 --- a/includes/render.php +++ b/includes/render.php @@ -247,8 +247,9 @@ function monitorRenderGroupCards(string $grouping, array $groups, int $maxlen): foreach ($groups as $gid => $group) { $worst = monitorGroupWorstStatusClass($group['hosts']); - // Let groups with many devices span several grid columns so their tiles - // flow across the page instead of stacking into one tall, narrow column. + // Let groups with many devices take a wider card so their tiles flow + // across the page instead of stacking into one tall, narrow column. The + // span maps to flex sizing in css (see .monitorGroupCard[data-span]). $span = (int) min(4, max(1, (int) ceil(cacti_sizeof($group['hosts']) / 6))); $body = "
"; @@ -259,7 +260,7 @@ function monitorRenderGroupCards(string $grouping, array $groups, int $maxlen): $body .= '
'; - $out .= "
+ $out .= "
" . html_escape($group['label']) . "
$body
"; @@ -771,6 +772,12 @@ function monitorRenderTreeCards(array $tree_list): string { $maxlen = getMonitorTrimLength($maxlen); + // Default-order the tree cards by device count (busiest first); any saved + // drag order the user set still wins in monitorRenderGroupCards(). + uasort($groups, static function (array $a, array $b): int { + return cacti_sizeof($b['hosts']) <=> cacti_sizeof($a['hosts']); + }); + return monitorRenderGroupCards('tree', $groups, $maxlen); } From 919cea514296fa410bf024e4faf2d74044c6552b Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 12:47:40 -0400 Subject: [PATCH 02/26] Monitor: drop inaccurate Site/Template ordering comparison from changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7435db4..c520b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ --- 3.0 --- * bug: Lay the grouping cards out with a wrapping flexbox instead of a CSS grid so they always stretch to fill the full horizontal space; inside Cacti's shrink-to-fit html_start_box div the grid mis-counted its tracks and clipped the cards to roughly half the page width -* bug: Default-order the Tree grouping cards by device count (busiest first), matching the Site and Device Template orderings while still honouring any saved drag order +* bug: Default-order the Tree grouping cards by device count (busiest first) while still honouring any saved drag order * feature: Restructure plugin_monitor_dashboards to store filter state as a JSON `properties` object (migrating the legacy raw `url` into it and dropping the url column on upgrade), and construct dashboard URLs from that structure instead of persisting raw urls * feature: Render the Site and Device Template groupings as a responsive full-width grid of draggable card panels (up to 8 columns, shrinking on narrower screens) whose titles are coloured by each group's worst device status, persisting card order per dashboard or per user * bug: Let a grouping card with many devices span several grid columns so it flows across the page instead of stacking into one tall, narrow column that scrolls off the screen From bd914e5700e18960314e398c762d3be2df85c0c9 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 12:53:35 -0400 Subject: [PATCH 03/26] CI: install the declared thold dependency so Monitor can install under Cacti's new install-failure reporting --- .github/workflows/plugin-ci-workflow.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 901e3a4..5d1b7f7 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -77,6 +77,15 @@ jobs: # patch-coverage.php diffs against the base branch below. fetch-depth: 0 + # Monitor declares `requires = thold:1.2.1` in its INFO file, so a Cacti + # that now reports install failures refuses to install Monitor without it. + - name: Checkout Thold Plugin + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: Cacti/plugin_thold + ref: develop + path: cacti/plugins/thold + - name: Install PHP ${{ matrix.php }} uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -168,6 +177,11 @@ jobs: cd ${{ github.workspace }}/cacti sudo php cli/install_cacti.php --accept-eula --install --force + - name: Install Thold Plugin + run: | + cd ${{ github.workspace }}/cacti + sudo php cli/plugin_manage.php --plugin=thold --install --enable + - name: Install Monitor Plugin run: | cd ${{ github.workspace }}/cacti From 06758d00c5e5214c03cf31b4735190979348f227 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 13:16:03 -0400 Subject: [PATCH 04/26] Monitor: remove the filter-status/criticality footer and prompt from the board --- includes/controller.php | 71 -------------------------------------- locales/po/cacti.pot | 75 +++++++++-------------------------------- 2 files changed, 15 insertions(+), 131 deletions(-) diff --git a/includes/controller.php b/includes/controller.php index 7bd9501..ab6b293 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -239,8 +239,6 @@ function drawPage(): void { } } - print '
' . getFilterText() . '
'; - bottom_footer(); } @@ -352,75 +350,6 @@ function unmuteUser(): void { set_user_setting('monitor_mute', 'false'); } -/** - * Build footer text describing currently active monitor filters. - * - * @return string - */ -function getFilterText(): string { - $filter = '
'; - - switch (get_request_var('status')) { - case '-4': - $filter .= __('Devices without Thresholds', 'monitor'); - - break; - case '-3': - $filter .= __('Not Monitored Devices', 'monitor'); - - break; - case '-2': - $filter .= __('All Devices', 'monitor'); - - break; - case '-1': - $filter .= __('All Monitored Devices', 'monitor'); - - break; - case '0': - $filter .= __('Monitored Devices either Down or Recovering', 'monitor'); - - break; - case '1': - $filter .= __('Monitored Devices either Down, Recovering, or with Triggered Thresholds', 'monitor'); - - break; - case '2': - $filter .= __('Monitored Devices either Down, Recovering, or with Breached or Triggered Thresholds', 'monitor'); - - break; - default: - $filter .= __('Unknown monitoring status (%s)', get_request_var('status'), 'monitor'); - } - - switch (get_request_var('crit')) { - case '0': - $filter .= __(', and All Criticalities', 'monitor'); - - break; - case '1': - $filter .= __(', and of Low Criticality or Higher', 'monitor'); - - break; - case '2': - $filter .= __(', and of Medium Criticality or Higher', 'monitor'); - - break; - case '3': - $filter .= __(', and of High Criticality or Higher', 'monitor'); - - break; - case '4': - $filter .= __(', and of Mission Critical Status', 'monitor'); - - break; - } - - $filter .= __('
Remember to first select eligible Devices to be Monitored from the Devices page!
', 'monitor'); - - return $filter; -} - /** * Render one filter dropdown (or hidden fallback input) for the monitor form. * diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 1cc94ec..1041bae 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,9 +8,9 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-10 18:20-0400\n" +"POT-Creation-Date: 2026-10-11 13:13-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Cacti Developers >\n" +"Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" "Language: \n" "MIME-Version: 1.0\n" @@ -37,63 +37,6 @@ msgstr "" msgid "Monitored Devices" msgstr "" -#: includes/controller.php monitor.php -msgid "Devices without Thresholds" -msgstr "" - -#: includes/controller.php -msgid "Not Monitored Devices" -msgstr "" - -#: includes/controller.php monitor.php -msgid "All Devices" -msgstr "" - -#: includes/controller.php monitor.php -msgid "All Monitored Devices" -msgstr "" - -#: includes/controller.php -msgid "Monitored Devices either Down or Recovering" -msgstr "" - -#: includes/controller.php -msgid "Monitored Devices either Down, Recovering, or with Triggered Thresholds" -msgstr "" - -#: includes/controller.php -msgid "Monitored Devices either Down, Recovering, or with Breached or Triggered Thresholds" -msgstr "" - -#: includes/controller.php -#, php-format -msgid "Unknown monitoring status (%s)" -msgstr "" - -#: includes/controller.php -msgid ", and All Criticalities" -msgstr "" - -#: includes/controller.php -msgid ", and of Low Criticality or Higher" -msgstr "" - -#: includes/controller.php -msgid ", and of Medium Criticality or Higher" -msgstr "" - -#: includes/controller.php -msgid ", and of High Criticality or Higher" -msgstr "" - -#: includes/controller.php -msgid ", and of Mission Critical Status" -msgstr "" - -#: includes/controller.php -msgid "
Remember to first select eligible Devices to be Monitored from the Devices page!
" -msgstr "" - #: includes/controller.php msgid "Unsaved" msgstr "" @@ -216,7 +159,7 @@ msgstr "" msgid "All Templates" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Non-Templated Devices" msgstr "" @@ -693,6 +636,14 @@ msgstr "" msgid "Zoom" msgstr "" +#: monitor.php +msgid "All Devices" +msgstr "" + +#: monitor.php +msgid "All Monitored Devices" +msgstr "" + #: monitor.php msgid "Not Up" msgstr "" @@ -705,6 +656,10 @@ msgstr "" msgid "Not Up, Triggered or Breached" msgstr "" +#: monitor.php +msgid "Devices without Thresholds" +msgstr "" + #: monitor.php msgid "Devices not Monitored" msgstr "" From db9a24ff96e934c9f7142b106e3cbeb1496d72c7 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 13:18:30 -0400 Subject: [PATCH 05/26] Monitor: give the busiest grouping cards (span 3-4) a full row so their tiles span the page --- css/monitor.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/css/monitor.css b/css/monitor.css index 7fbb941..d2f60d3 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -648,8 +648,7 @@ content inside a shrink-to-fit `display:table` box (html_start_box with $div=true), which breaks grid auto-fill track counting and clipped the cards to roughly half the page. Flexbox grows each card to fill its row regardless - of that containing block, so a lone busy card spans the whole width while - smaller cards share a row. */ + of that containing block. */ .monitorGroupGrid { display: flex; flex-wrap: wrap; @@ -664,12 +663,13 @@ box-sizing: border-box; } -/* Busier groups get a wider basis (one extra 260px + 10px gap per step) and a - matching flex-grow, so when several cards share a row the busier ones take - proportionally more space; a card alone on a row always fills it. */ +/* A busier group (span 2) gets a wider basis and matching flex-grow so it takes + proportionally more room when sharing a row with smaller cards. The busiest + groups (span 3-4) take a full row of their own so their many tiles flow the + whole page width instead of being squeezed beside smaller cards. */ .monitorGroupCard[data-span="2"] { flex-grow: 2; flex-basis: 530px; } -.monitorGroupCard[data-span="3"] { flex-grow: 3; flex-basis: 800px; } -.monitorGroupCard[data-span="4"] { flex-grow: 4; flex-basis: 1070px; } +.monitorGroupCard[data-span="3"], +.monitorGroupCard[data-span="4"] { flex-basis: 100%; } .monitorGroupCard .monitorPanelBody { padding: 6px; From db17e9338aeea3ccf643358531a2768ab668e32a Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 13:18:46 -0400 Subject: [PATCH 06/26] Monitor: changelog for full-width busy cards and footer removal --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c520b21..fff9f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ --- 3.0 --- +* bug: Give the busiest grouping cards (many devices) a full row of their own so their tiles span the whole page instead of being squeezed to part of the width beside smaller cards +* bug: Remove the filter status/criticality summary footer and the "Remember to first select eligible Devices" prompt from the Monitor board * bug: Lay the grouping cards out with a wrapping flexbox instead of a CSS grid so they always stretch to fill the full horizontal space; inside Cacti's shrink-to-fit html_start_box div the grid mis-counted its tracks and clipped the cards to roughly half the page width * bug: Default-order the Tree grouping cards by device count (busiest first) while still honouring any saved drag order * feature: Restructure plugin_monitor_dashboards to store filter state as a JSON `properties` object (migrating the legacy raw `url` into it and dropping the url column on upgrade), and construct dashboard URLs from that structure instead of persisting raw urls From d69b1063948e56ae143ee4161bcccac5b4cab29f Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 13:29:16 -0400 Subject: [PATCH 07/26] Monitor: drop Site grouping header, tighten tile uptime spacing, add List column tips --- css/monitor.css | 12 ++++++++++- includes/controller.php | 2 +- includes/render.php | 43 ++++++++++++++++++++++-------------- locales/po/cacti.pot | 48 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 85 insertions(+), 20 deletions(-) diff --git a/css/monitor.css b/css/monitor.css index d2f60d3..0650b25 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -79,8 +79,18 @@ } .monitor_device_frame a { - padding: 3px; + padding: 3px 3px 8px; + display: block; +} + +/* Uptime/time-in-state line under a tile hostname: normal weight (not bold), + pulled up tight to the hostname, with the frame padding below separating + rows. */ +.monitor_uptime { display: block; + font-weight: normal; + line-height: 1.1; + margin-top: -2px; } /* diff --git a/includes/controller.php b/includes/controller.php index ab6b293..573fb81 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -208,7 +208,7 @@ function drawPage(): void { $function = 'render' . ucfirst(get_request_var('grouping')); if (function_exists($function) && get_request_var('view') != 'list') { - if (get_request_var('grouping') == 'default' || get_request_var('grouping') == 'site') { + if (get_request_var('grouping') == 'default') { html_start_box(__('Monitored Devices', 'monitor'), '100%', true, 3, 'center', ''); } else { html_start_box('', '100%', true, 3, 'center', ''); diff --git a/includes/render.php b/includes/render.php index c6ae49b..91ab066 100644 --- a/includes/render.php +++ b/includes/render.php @@ -962,14 +962,14 @@ function renderHost(array $host, bool $float = true, int $maxlen = 10): ?string $tis = get_timeinstate($host); if ($monitor_times == 'on') { - $monitor_time_html = "
$tis"; + $monitor_time_html = "
$tis"; } $result = ""; } else { $tis = get_uptime($host); if ($monitor_times == 'on') { - $monitor_time_html = "
$tis
"; + $monitor_time_html = "
$tis
"; } $result = ""; @@ -1110,63 +1110,74 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { 'id' => [ 'display' => __('ID', 'monitor'), 'sort' => 'ASC', - 'align' => 'left' + 'align' => 'left', + 'tip' => __('Cacti internal device id', 'monitor') ], 'description' => [ 'display' => __('Description', 'monitor'), 'sort' => 'ASC', - 'align' => 'left' + 'align' => 'left', + 'tip' => __('Device description as set on the Devices page', 'monitor') ], 'site_name' => [ 'display' => __('Site', 'monitor'), 'sort' => 'ASC', - 'align' => 'left' + 'align' => 'left', + 'tip' => __('Site the device is assigned to', 'monitor') ], 'monitor_criticality' => [ 'display' => __('Criticality', 'monitor'), 'sort' => 'ASC', - 'align' => 'left' + 'align' => 'left', + 'tip' => __('Monitoring criticality assigned to the device', 'monitor') ], 'status' => [ 'display' => __('Status', 'monitor'), 'sort' => 'DESC', - 'align' => 'center' + 'align' => 'center', + 'tip' => __('Current monitored availability status of the device', 'monitor') ], 'instate' => [ 'display' => __('Length in Status', 'monitor'), 'sort' => 'ASC', - 'align' => 'center' + 'align' => 'center', + 'tip' => __('How long the device has been in its current status', 'monitor') ], 'avg_time' => [ 'display' => __('Averages', 'monitor'), 'sort' => 'DESC', - 'align' => 'left' + 'align' => 'left', + 'tip' => __('Current / average ping response time in milliseconds', 'monitor') ], 'monitor_warn' => [ 'display' => __('Warning', 'monitor'), 'sort' => 'DESC', - 'align' => 'left' + 'align' => 'left', + 'tip' => __('Ping latency Warning / Alert thresholds (ms) configured for this device; a notification is raised when the response time crosses them', 'monitor') ], 'monitor_text' => [ 'display' => __('Admin', 'monitor'), 'sort' => 'ASC', - 'tip' => __('Monitor Text Column represents \'Admin\'', 'monitor'), + 'tip' => __('Administrative monitor message shown while the device is Down or Recovering', 'monitor'), 'align' => 'left' ], 'notes' => [ 'display' => __('Notes', 'monitor'), 'sort' => 'ASC', - 'align' => 'left' + 'align' => 'left', + 'tip' => __('Free-form notes recorded for the device', 'monitor') ], 'availability' => [ 'display' => __('Availability', 'monitor'), 'sort' => 'DESC', - 'align' => 'right' + 'align' => 'right', + 'tip' => __('Percentage of successful availability checks over the retained history', 'monitor') ], 'status_fail_date' => [ 'display' => __('Last Fail', 'monitor'), 'sort' => 'DESC', - 'align' => 'right' + 'align' => 'right', + 'tip' => __('Date and time the device last failed its availability check', 'monitor') ], ]; @@ -1412,11 +1423,11 @@ function renderHostTilesadt(array $host): string { if ($host['status'] < 2 || $host['status'] == 5) { $tis = get_timeinstate($host); - return ""; + return ""; } else { $tis = get_uptime($host); - return ""; + return ""; } } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 1041bae..293ab83 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 13:13-0400\n" +"POT-Creation-Date: 2026-10-11 13:28-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -540,30 +540,74 @@ msgstr "" msgid "ID" msgstr "" +#: includes/render.php +msgid "Cacti internal device id" +msgstr "" + +#: includes/render.php +msgid "Device description as set on the Devices page" +msgstr "" + +#: includes/render.php +msgid "Site the device is assigned to" +msgstr "" + +#: includes/render.php +msgid "Monitoring criticality assigned to the device" +msgstr "" + +#: includes/render.php +msgid "Current monitored availability status of the device" +msgstr "" + #: includes/render.php msgid "Length in Status" msgstr "" +#: includes/render.php +msgid "How long the device has been in its current status" +msgstr "" + #: includes/render.php msgid "Averages" msgstr "" +#: includes/render.php +msgid "Current / average ping response time in milliseconds" +msgstr "" + #: includes/render.php msgid "Warning" msgstr "" +#: includes/render.php +msgid "Ping latency Warning / Alert thresholds (ms) configured for this device; a notification is raised when the response time crosses them" +msgstr "" + #: includes/render.php msgid "Admin" msgstr "" #: includes/render.php -msgid "Monitor Text Column represents 'Admin'" +msgid "Administrative monitor message shown while the device is Down or Recovering" msgstr "" #: includes/render.php msgid "Notes" msgstr "" +#: includes/render.php +msgid "Free-form notes recorded for the device" +msgstr "" + +#: includes/render.php +msgid "Percentage of successful availability checks over the retained history" +msgstr "" + +#: includes/render.php +msgid "Date and time the device last failed its availability check" +msgstr "" + #: monitor.php poller_functions.php setup.php msgid "Low" msgstr "" From 7bd63efde2a6ce1938559ba157cca7131f3366a6 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 13:46:58 -0400 Subject: [PATCH 08/26] Monitor: add clickable List-view thold/servcheck/ping/criticality pills with filter toggles --- CHANGELOG.md | 2 + css/monitor.css | 31 +++++++++++ includes/functions.php | 118 +++++++++++++++++++++++++++++++++++++++++ includes/render.php | 55 +++++++++++++++++-- js/monitor.js | 20 +++++++ locales/po/cacti.pot | 52 ++++++++++++++++-- monitor.php | 7 +++ 7 files changed, 277 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fff9f2c..d4d3796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ --- 3.0 --- +* feature: Add clickable List-view pills — Thresholds (breached / triggered / total), Service Checks (failed / total), a Ping latency pill beside Status, and a Criticality pill (including a 'Not Set' pill for unset devices) — that toggle the matching filter on and off +* feature: Add Status filter options for Warning Ping, Alert Ping and Service Check Failed, and make the Criticality 'None' selection filter to devices with no criticality set * bug: Give the busiest grouping cards (many devices) a full row of their own so their tiles span the whole page instead of being squeezed to part of the width beside smaller cards * bug: Remove the filter status/criticality summary footer and the "Remember to first select eligible Devices" prompt from the Monitor board * bug: Lay the grouping cards out with a wrapping flexbox instead of a CSS grid so they always stretch to fill the full horizontal space; inside Cacti's shrink-to-fit html_start_box div the grid mis-counted its tracks and clipped the cards to roughly half the page width diff --git a/css/monitor.css b/css/monitor.css index 0650b25..149559a 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -210,6 +210,37 @@ .monitorStatus.deviceUnmonitored { background-color: #3f6a8a; } .monitorStatus.deviceServiceCheck { background-color: #157f87; } +/* Clickable list-view pills (ping, thresholds, service checks, criticality). + Share the status palette for alert-type pills and a graduated, deliberately + non-alarming palette for criticality (no red for Mission Critical). */ +.monitorPill { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + color: #ffffff; + white-space: nowrap; + font-size: 11px; + line-height: 1.5; +} + +.monitorFilterPill { + cursor: pointer; +} + +.monitorPillMuted { background-color: #5f5f57; } +.monitorPill.deviceWarning { background-color: #7d6f18; } +.monitorPill.deviceAlert { background-color: #86407a; } +.monitorPill.deviceThreshold { background-color: #a85712; } +.monitorPill.deviceServiceCheck { background-color: #157f87; } + +/* Criticality: Not Set (grey) -> Low (blue) -> Medium (teal) -> High (amber) + -> Mission Critical (violet). */ +.monitorCrit0 { background-color: #5f5f57; } +.monitorCrit1 { background-color: #3f6a8a; } +.monitorCrit2 { background-color: #157f87; } +.monitorCrit3 { background-color: #a85712; } +.monitorCrit4 { background-color: #6a4a9c; } + /* extra small styles */ .monitor_exsmall { height: 55px; diff --git a/includes/functions.php b/includes/functions.php index 5bd6b9f..9843889 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -160,6 +160,91 @@ function checkServchecks(): array { ); } +/** * Per-host threshold counts for the list-view Thresholds pill. + * + * @return array Map of host_id => ['total' => int, 'triggered' => int, + * 'breached' => int]. Empty when thold is unavailable. + */ +function monitorTholdCounts(): array { + if (!api_plugin_is_enabled('thold') || !db_table_exists('thold_data')) { + return []; + } + + $rows = db_fetch_assoc("SELECT dl.host_id, + COUNT(DISTINCT td.id) AS total, + SUM(td.thold_enabled = 'on' + AND ((td.thold_alert != 0 AND td.thold_fail_count >= td.thold_fail_trigger) + OR (td.bl_alert > 0 AND td.bl_fail_count >= td.bl_fail_trigger))) AS triggered, + SUM(td.thold_enabled = 'on' + AND td.bl_alert > 0 AND td.bl_fail_count >= td.bl_fail_trigger) AS breached + FROM thold_data AS td + INNER JOIN data_local AS dl + ON td.local_data_id = dl.id + WHERE td.thold_enabled = 'on' + GROUP BY dl.host_id"); + + $map = []; + + foreach ($rows as $row) { + $map[$row['host_id']] = [ + 'total' => (int) $row['total'], + 'triggered' => (int) $row['triggered'], + 'breached' => (int) $row['breached'], + ]; + } + + return $map; +} + +/** + * Per-host service-check counts for the list-view Service Checks pill. + * + * Servcheck tests are keyed by hostname/ipaddress (no host id), schema-aware + * across servcheck v0.3 (lastcheck/failures) and v0.4+ (last_check/last_result), + * and realm-gated like checkServchecks(). + * + * @return array Map of host_id => ['total' => int, 'failed' => int]. Empty when + * servcheck is unavailable or not permitted. + */ +function monitorServcheckCounts(): array { + if (!api_plugin_is_enabled('servcheck') || !api_plugin_user_realm_auth('servcheck_test.php')) { + return []; + } + + if (!db_table_exists('plugin_servcheck_test')) { + return []; + } + + $check_col = db_column_exists('plugin_servcheck_test', 'last_check') ? 'last_check' : 'lastcheck'; + + if (db_column_exists('plugin_servcheck_test', 'last_result')) { + $fail_predicate = "(sct.triggered > 0 OR (sct.last_result != 'ok' AND sct.last_result != 'not yet'))"; + } else { + $fail_predicate = '(sct.triggered > 0 OR sct.failures > 0)'; + } + + $rows = db_fetch_assoc("SELECT h.id AS host_id, + COUNT(DISTINCT sct.id) AS total, + SUM(sct.$check_col > 0 AND $fail_predicate) AS failed + FROM host AS h + INNER JOIN plugin_servcheck_test AS sct + ON (sct.hostname = h.hostname OR sct.ipaddress = h.hostname) + WHERE sct.enabled = 'on' + AND h.deleted = '' + GROUP BY h.id"); + + $map = []; + + foreach ($rows as $row) { + $map[$row['host_id']] = [ + 'total' => (int) $row['total'], + 'failed' => (int) $row['failed'], + ]; + } + + return $map; +} + /** * Append an IN-clause fragment to an existing SQL where string. * @@ -195,6 +280,9 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { if (get_request_var('crit') > 0) { $awhere = 'h.monitor_criticality >= ' . get_request_var('crit'); + } elseif (get_request_var('crit') == 0) { + // 'Not Set' pill / None selection: devices with no criticality assigned. + $awhere = 'h.monitor_criticality = 0'; } else { $awhere = ''; } @@ -267,6 +355,36 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { AND ((h.cur_time > h.monitor_warn AND h.monitor_warn > 0) OR (h.cur_time > h.monitor_alert AND h.monitor_alert > 0)))' . $servcheck_where . ' )' . $awhere; + } elseif (get_request_var('status') == '10') { + // Service Check Failed: devices with a failing/triggered service check. + $sql_join = ''; + + $servcheck_ids = (isset($servcheck_hosts) && cacti_sizeof($servcheck_hosts)) ? implode(',', array_map('intval', $servcheck_hosts)) : '0'; + + $sql_where = 'WHERE h.disabled = "" + AND h.deleted = "" + AND h.id IN (' . $servcheck_ids . ')' . $awhere; + } elseif (get_request_var('status') == '7') { + // Warning Ping: up devices whose latency is over the warning (but not alert) threshold. + $sql_join = ''; + + $sql_where = 'WHERE h.disabled = "" + AND h.monitor = "on" + AND h.deleted = "" + AND h.status = 3 + AND h.monitor_warn > 0 + AND h.cur_time > h.monitor_warn + AND NOT (h.monitor_alert > 0 AND h.cur_time > h.monitor_alert)' . $awhere; + } elseif (get_request_var('status') == '8') { + // Alert Ping: up devices whose latency is over the alert threshold. + $sql_join = ''; + + $sql_where = 'WHERE h.disabled = "" + AND h.monitor = "on" + AND h.deleted = "" + AND h.status = 3 + AND h.monitor_alert > 0 + AND h.cur_time > h.monitor_alert' . $awhere; } elseif (get_request_var('status') == -1) { $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; diff --git a/includes/render.php b/includes/render.php index 91ab066..e8e9b65 100644 --- a/includes/render.php +++ b/includes/render.php @@ -1137,6 +1137,21 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { 'align' => 'center', 'tip' => __('Current monitored availability status of the device', 'monitor') ], + 'nosort_ping' => [ + 'display' => __('Ping', 'monitor'), + 'align' => 'center', + 'tip' => __('Ping latency alert state; click to filter by Warning or Alert Ping, click again to clear', 'monitor') + ], + 'nosort_tholds' => [ + 'display' => __('Thresholds', 'monitor'), + 'align' => 'center', + 'tip' => __('Breached / Triggered / Total thresholds (thold plugin) on the device; click to filter triggered devices, click again to clear', 'monitor') + ], + 'nosort_servchecks' => [ + 'display' => __('Service Checks', 'monitor'), + 'align' => 'center', + 'tip' => __('Failed / Total service checks (servcheck plugin) for the device; click to filter failing devices, click again to clear', 'monitor') + ], 'instate' => [ 'display' => __('Length in Status', 'monitor'), 'sort' => 'ASC', @@ -1283,7 +1298,7 @@ function renderFooterList(int $total_rows, int $rows): string { * @return string */ function renderHostList(array $host): string { - global $criticalities, $iclasses; + global $criticalities, $iclasses, $thold_counts, $servcheck_counts; if ($host['status'] < 2 || $host['status'] == 5) { $dt = get_timeinstate($host); @@ -1299,10 +1314,39 @@ function renderHostList(array $host): string { $host_admin = ''; } - if (isset($host['monitor_criticality']) && $host['monitor_criticality'] > 0) { - $host_crit = $criticalities[$host['monitor_criticality']]; + $crit_level = isset($host['monitor_criticality']) ? (int) $host['monitor_criticality'] : 0; + + if ($crit_level > 0) { + $host_crit = "" . html_escape($criticalities[$crit_level]) . ''; } else { - $host_crit = ''; + $host_crit = "" . __esc('Not Set', 'monitor') . ''; + } + + // Ping latency pill (Warning/Alert) shown right of the Status column. + $ping_cell = ''; + + if (!empty($host['monitor_alert']) && $host['cur_time'] > $host['monitor_alert']) { + $ping_cell = "" . __esc('Alert', 'monitor') . ''; + } elseif (!empty($host['monitor_warn']) && $host['cur_time'] > $host['monitor_warn']) { + $ping_cell = "" . __esc('Warning', 'monitor') . ''; + } + + // Thresholds pill: breached / triggered / total (thold plugin). + $thold_cell = ''; + + if (isset($thold_counts[$host['id']]) && $thold_counts[$host['id']]['total'] > 0) { + $tc = $thold_counts[$host['id']]; + $tcls = ($tc['triggered'] > 0 || $tc['breached'] > 0) ? 'deviceThreshold' : 'monitorPillMuted'; + $thold_cell = "" . $tc['breached'] . ' / ' . $tc['triggered'] . ' / ' . $tc['total'] . ''; + } + + // Service checks pill: failed / total (servcheck plugin). + $servcheck_cell = ''; + + if (isset($servcheck_counts[$host['id']]) && $servcheck_counts[$host['id']]['total'] > 0) { + $sc = $servcheck_counts[$host['id']]; + $scls = ($sc['failed'] > 0) ? 'deviceServiceCheck' : 'monitorPillMuted'; + $servcheck_cell = "" . $sc['failed'] . ' / ' . $sc['total'] . ''; } if ($host['availability_method'] > 0) { @@ -1341,6 +1385,9 @@ function renderHostList(array $host): string { form_selectable_cell($host['site_name'], $host['id'], '', 'left'); form_selectable_cell($host_crit, $host['id'], '', 'left'); form_selectable_cell("" . html_escape($sdisplay) . '', $host['id'], '', 'center'); + form_selectable_cell($ping_cell, $host['id'], '', 'center'); + form_selectable_cell($thold_cell, $host['id'], '', 'center'); + form_selectable_cell($servcheck_cell, $host['id'], '', 'center'); form_selectable_cell($dt, $host['id'], '', 'center'); form_selectable_cell($host_avg, $host['id'], '', 'left'); form_selectable_cell($host_warn, $host['id'], '', 'left'); diff --git a/js/monitor.js b/js/monitor.js index c803420..b6a339a 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -359,6 +359,26 @@ $(() => { openHostPanel($(event.currentTarget).data('id')); }); + $(document).on('click', '.monitorFilterPill', (event) => { + event.preventDefault(); + event.stopPropagation(); + + const pill = $(event.currentTarget); + const filter = pill.data('filter'); + const value = String(pill.data('value')); + const sel = $('#' + filter); + + if (!sel.length) { + return; + } + + // Toggle: a pill whose filter is already active clears it back to the + // default (All), otherwise it applies the pill's filter value. + sel.val(String(sel.val()) === value ? '-1' : value); + sel.trigger('change'); + applyFilter(); + }); + if ($.fn.sortable) { $('.monitorGroupGrid').sortable({ items: '.monitorGroupCard', diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 293ab83..18bda01 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 13:28-0400\n" +"POT-Creation-Date: 2026-10-11 13:45-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -363,7 +363,7 @@ msgstr "" msgid "SNMP Uptime" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Ping" msgstr "" @@ -465,7 +465,7 @@ msgstr "" msgid "OK" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Service Checks" msgstr "" @@ -478,7 +478,7 @@ msgstr "" msgid "Triggered" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Thresholds" msgstr "" @@ -560,6 +560,18 @@ msgstr "" msgid "Current monitored availability status of the device" msgstr "" +#: includes/render.php +msgid "Ping latency alert state; click to filter by Warning or Alert Ping, click again to clear" +msgstr "" + +#: includes/render.php +msgid "Breached / Triggered / Total thresholds (thold plugin) on the device; click to filter triggered devices, click again to clear" +msgstr "" + +#: includes/render.php +msgid "Failed / Total service checks (servcheck plugin) for the device; click to filter failing devices, click again to clear" +msgstr "" + #: includes/render.php msgid "Length in Status" msgstr "" @@ -608,6 +620,38 @@ msgstr "" msgid "Date and time the device last failed its availability check" msgstr "" +#: includes/render.php +msgid "Filter by this criticality (click again to clear)" +msgstr "" + +#: includes/render.php +msgid "Criticality not set - click to filter unset devices (click again to clear)" +msgstr "" + +#: includes/render.php +msgid "Not Set" +msgstr "" + +#: includes/render.php +msgid "Alert" +msgstr "" + +#: includes/render.php +msgid "Alert ping latency - click to filter (click again to clear)" +msgstr "" + +#: includes/render.php +msgid "Warning ping latency - click to filter (click again to clear)" +msgstr "" + +#: includes/render.php +msgid "Breached / Triggered / Total thresholds - click to filter triggered or breached devices (click again to clear)" +msgstr "" + +#: includes/render.php +msgid "Failed / Total service checks - click to filter failing devices (click again to clear)" +msgstr "" + #: monitor.php poller_functions.php setup.php msgid "Low" msgstr "" diff --git a/monitor.php b/monitor.php index 16d42b3..66e6b37 100644 --- a/monitor.php +++ b/monitor.php @@ -84,6 +84,9 @@ 0 => __('Not Up', 'monitor'), 1 => __('Not Up or Triggered', 'monitor'), 2 => __('Not Up, Triggered or Breached', 'monitor'), + 7 => __('Warning Ping', 'monitor'), + 8 => __('Alert Ping', 'monitor'), + 10 => __('Service Check Failed', 'monitor'), -4 => __('Devices without Thresholds', 'monitor'), -3 => __('Devices not Monitored', 'monitor'), ]; @@ -138,6 +141,10 @@ $servcheck_hosts = checkServchecks(); +$thold_counts = monitorTholdCounts(); + +$servcheck_counts = monitorServcheckCounts(); + switch (get_nfilter_request_var('action')) { case 'ajax_status': ajaxStatus(); From 7c925290541fe9c682ceb8427dc3928477326ed0 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 13:51:47 -0400 Subject: [PATCH 09/26] Monitor: unit tests for new filter branches and thold/servcheck count helpers --- tests/Unit/DbFunctionsTest.php | 139 +++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index a49ba5c..8ea1873 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -206,3 +206,142 @@ unset($GLOBALS['servcheck_hosts']); }); + +it('filters to unset criticality for the Not Set selection (crit 0)', function () { + test_set_request(['status' => '-2', 'crit' => '0', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.monitor_criticality = 0'); +}); + +it('filters to the warning ping band for status 7', function () { + test_set_request(['status' => '7', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.cur_time > h.monitor_warn') + ->and($where)->toContain('NOT (h.monitor_alert > 0 AND h.cur_time > h.monitor_alert)'); +}); + +it('filters to the alert ping band for status 8', function () { + test_set_request(['status' => '8', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.cur_time > h.monitor_alert'); +}); + +it('filters to failing service-check hosts for status 10', function () { + test_set_request(['status' => '10', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + $GLOBALS['servcheck_hosts'] = [3 => 3, 8 => 8]; + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.id IN (3,8)'); + + unset($GLOBALS['servcheck_hosts']); +}); + +it('uses an empty id set when no service checks fail for status 10', function () { + test_set_request(['status' => '10', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + $GLOBALS['servcheck_hosts'] = []; + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.id IN (0)'); + + unset($GLOBALS['servcheck_hosts']); +}); + +it('returns empty threshold counts when thold is disabled', function () { + monitor_test_reset_db_mocks(); + + expect(monitorTholdCounts())->toBe([]); +}); + +it('maps per-host threshold counts', function () { + monitor_test_reset_db_mocks(); + monitor_test_mock_db('api_plugin_is_enabled', 'thold', true); + monitor_test_mock_db('db_table_exists', 'thold_data', true); + + $seen = null; + monitor_test_mock_db('db_fetch_assoc', fn ($sql) => str_contains($sql, 'thold_data'), function ($sql) use (&$seen) { + $seen = $sql; + + return [ + ['host_id' => 4, 'total' => 5, 'triggered' => 2, 'breached' => 1], + ]; + }); + + expect(monitorTholdCounts())->toBe([4 => ['total' => 5, 'triggered' => 2, 'breached' => 1]]); + expect($seen)->toContain('thold_data'); +}); + +it('returns empty service-check counts when servcheck is disabled', function () { + monitor_test_reset_db_mocks(); + + expect(monitorServcheckCounts())->toBe([]); +}); + +it('returns empty service-check counts without the servcheck realm', function () { + monitor_test_reset_db_mocks(); + monitor_test_mock_db('api_plugin_is_enabled', 'servcheck', true); + + expect(monitorServcheckCounts())->toBe([]); +}); + +it('returns empty service-check counts when the table is absent', function () { + monitor_test_reset_db_mocks(); + monitor_test_mock_db('api_plugin_is_enabled', 'servcheck', true); + monitor_test_mock_db('api_plugin_user_realm_auth', 'servcheck_test.php', true); + + expect(monitorServcheckCounts())->toBe([]); +}); + +it('maps per-host service-check counts with the v0.4 predicate', function () { + monitor_test_reset_db_mocks(); + monitor_test_mock_db('api_plugin_is_enabled', 'servcheck', true); + monitor_test_mock_db('api_plugin_user_realm_auth', 'servcheck_test.php', true); + monitor_test_mock_db('db_table_exists', 'plugin_servcheck_test', true); + monitor_test_mock_db('db_column_exists', fn ($key) => $key === 'plugin_servcheck_test.last_check', true); + monitor_test_mock_db('db_column_exists', fn ($key) => $key === 'plugin_servcheck_test.last_result', true); + + $seen = null; + monitor_test_mock_db('db_fetch_assoc', fn ($sql) => str_contains($sql, 'plugin_servcheck_test'), function ($sql) use (&$seen) { + $seen = $sql; + + return [['host_id' => 2, 'total' => 3, 'failed' => 1]]; + }); + + expect(monitorServcheckCounts())->toBe([2 => ['total' => 3, 'failed' => 1]]); + expect($seen)->toContain("last_result != 'ok'"); +}); + +it('falls back to the v0.3 predicate for service-check counts', function () { + monitor_test_reset_db_mocks(); + monitor_test_mock_db('api_plugin_is_enabled', 'servcheck', true); + monitor_test_mock_db('api_plugin_user_realm_auth', 'servcheck_test.php', true); + monitor_test_mock_db('db_table_exists', 'plugin_servcheck_test', true); + + $seen = null; + monitor_test_mock_db('db_fetch_assoc', fn ($sql) => str_contains($sql, 'plugin_servcheck_test'), function ($sql) use (&$seen) { + $seen = $sql; + + return []; + }); + + expect(monitorServcheckCounts())->toBe([]); + expect($seen)->toContain('sct.lastcheck > 0') + ->and($seen)->toContain('sct.failures > 0'); +}); From eecdd9d399c73e991a1c8bd7eeb1a2d69aac5bc4 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 14:08:16 -0400 Subject: [PATCH 10/26] Monitor: selectable status pill, fallback/empty pills, hide absent plugin columns, tile sizing, dialog UX --- CHANGELOG.md | 4 +++ css/monitor.css | 14 ++++++++++ includes/render.php | 61 +++++++++++++++++++++++++++++++++++--------- js/monitor.js | 16 ++++++++++-- locales/po/cacti.pot | 20 +++++++++++++-- 5 files changed, 99 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d3796..629f501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ --- 3.0 --- +* bug: Make the List-view Status pill clickable to filter by that status, show a muted 'OK' / 'Not Set' pill (instead of a blank cell) when a device has no ping alert, thresholds or service checks, and hide the Thresholds / Service Checks columns entirely when their plugin is not installed +* bug: Size grouping-card tiles to the longest visible host description so long hostnames no longer overlap neighbouring tiles +* bug: Center the device Actions dialog, make it taller and scrollable so panel text is not cut off, and pause the page refresh countdown while it is open +* bug: Remove the stray 'Monitored Devices' box header on the Site grouping so it matches the other groupings * feature: Add clickable List-view pills — Thresholds (breached / triggered / total), Service Checks (failed / total), a Ping latency pill beside Status, and a Criticality pill (including a 'Not Set' pill for unset devices) — that toggle the matching filter on and off * feature: Add Status filter options for Warning Ping, Alert Ping and Service Check Failed, and make the Criticality 'None' selection filter to devices with no criticality set * bug: Give the busiest grouping cards (many devices) a full row of their own so their tiles span the whole page instead of being squeezed to part of the width beside smaller cards diff --git a/css/monitor.css b/css/monitor.css index 149559a..ce585b6 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -29,6 +29,13 @@ justify-content: space-between; } +/* When the server sets --monitor-title-ch (the longest trimmed description in + view), widen every tile title to it so long hostnames wrap within their own + tile instead of blending into neighbours. */ +.monitor_container[style*='--monitor-title-ch'] [class$='_title'] { + width: calc(var(--monitor-title-ch, 11) * 1ch + 8px) !important; +} + .monitor_sub_container { display: flex; flex-wrap: wrap; @@ -596,6 +603,13 @@ gap: 10px; } +/* Keep the Actions dialog body scrollable when the panels are taller than the + dialog, so nothing is cut off. */ +#monitorPanelDialog { + overflow-y: auto; + overflow-x: hidden; +} + .monitorPanel { border: 1px solid rgba(128, 128, 128, 0.35); border-radius: 6px; diff --git a/includes/render.php b/includes/render.php index e8e9b65..4a62992 100644 --- a/includes/render.php +++ b/includes/render.php @@ -252,7 +252,11 @@ function monitorRenderGroupCards(string $grouping, array $groups, int $maxlen): // span maps to flex sizing in css (see .monitorGroupCard[data-span]). $span = (int) min(4, max(1, (int) ceil(cacti_sizeof($group['hosts']) / 6))); - $body = "
"; + // Size every tile to the longest (trimmed) description in view so long + // hostnames never overlap their neighbours. css widens the titles from + // this custom property. + $title_ch = max(8, min(40, $maxlen)); + $body = "
"; foreach ($group['hosts'] as $host) { $body .= renderHost($host, true, $maxlen); @@ -1196,6 +1200,15 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { ], ]; + // Hide the Thresholds / Service Checks columns when their plugin is absent. + if (!api_plugin_is_enabled('thold')) { + unset($display_text['nosort_tholds']); + } + + if (!api_plugin_is_enabled('servcheck')) { + unset($display_text['nosort_servchecks']); + } + ob_start(); $nav = html_nav_bar('monitor.php?rfilter=' . rawurlencode(get_request_var('rfilter')), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, 12, __('Devices'), 'page', 'main'); @@ -1322,31 +1335,39 @@ function renderHostList(array $host): string { $host_crit = "" . __esc('Not Set', 'monitor') . ''; } - // Ping latency pill (Warning/Alert) shown right of the Status column. - $ping_cell = ''; + // Map the display status to the closest Status filter value so the status + // pill can filter (Up/Unknown/Unmonitored have no useful filter and stay + // inert). + $status_filter_map = [1 => '0', 2 => '0', 5 => '0', 4 => '1', 9 => '1', 7 => '7', 8 => '8', 10 => '10']; + // Ping latency pill (Warning/Alert) shown right of the Status column; a muted + // pill is shown when there is no ping alert so the column is never blank. if (!empty($host['monitor_alert']) && $host['cur_time'] > $host['monitor_alert']) { $ping_cell = "" . __esc('Alert', 'monitor') . ''; } elseif (!empty($host['monitor_warn']) && $host['cur_time'] > $host['monitor_warn']) { $ping_cell = "" . __esc('Warning', 'monitor') . ''; + } else { + $ping_cell = "" . __esc('OK', 'monitor') . ''; } - // Thresholds pill: breached / triggered / total (thold plugin). - $thold_cell = ''; - + // Thresholds pill: breached / triggered / total (thold plugin); a muted + // 'Not Set' pill is shown when the device has no thresholds. if (isset($thold_counts[$host['id']]) && $thold_counts[$host['id']]['total'] > 0) { $tc = $thold_counts[$host['id']]; $tcls = ($tc['triggered'] > 0 || $tc['breached'] > 0) ? 'deviceThreshold' : 'monitorPillMuted'; $thold_cell = "" . $tc['breached'] . ' / ' . $tc['triggered'] . ' / ' . $tc['total'] . ''; + } else { + $thold_cell = "" . __esc('Not Set', 'monitor') . ''; } - // Service checks pill: failed / total (servcheck plugin). - $servcheck_cell = ''; - + // Service checks pill: failed / total (servcheck plugin); a muted 'Not Set' + // pill is shown when the device has no service checks. if (isset($servcheck_counts[$host['id']]) && $servcheck_counts[$host['id']]['total'] > 0) { $sc = $servcheck_counts[$host['id']]; $scls = ($sc['failed'] > 0) ? 'deviceServiceCheck' : 'monitorPillMuted'; $servcheck_cell = "" . $sc['failed'] . ' / ' . $sc['total'] . ''; + } else { + $servcheck_cell = "" . __esc('Not Set', 'monitor') . ''; } if ($host['availability_method'] > 0) { @@ -1384,10 +1405,26 @@ function renderHostList(array $host): string { form_selectable_cell($host['description'], $host['id'], '', 'left'); form_selectable_cell($host['site_name'], $host['id'], '', 'left'); form_selectable_cell($host_crit, $host['id'], '', 'left'); - form_selectable_cell("" . html_escape($sdisplay) . '', $host['id'], '', 'center'); + + $rstatus = (int) $host['real_status']; + + if (isset($status_filter_map[$rstatus])) { + $status_pill = "" . html_escape($sdisplay) . ''; + } else { + $status_pill = "" . html_escape($sdisplay) . ''; + } + + form_selectable_cell($status_pill, $host['id'], '', 'center'); form_selectable_cell($ping_cell, $host['id'], '', 'center'); - form_selectable_cell($thold_cell, $host['id'], '', 'center'); - form_selectable_cell($servcheck_cell, $host['id'], '', 'center'); + + if (api_plugin_is_enabled('thold')) { + form_selectable_cell($thold_cell, $host['id'], '', 'center'); + } + + if (api_plugin_is_enabled('servcheck')) { + form_selectable_cell($servcheck_cell, $host['id'], '', 'center'); + } + form_selectable_cell($dt, $host['id'], '', 'center'); form_selectable_cell($host_avg, $host['id'], '', 'left'); form_selectable_cell($host_warn, $host['id'], '', 'left'); diff --git a/js/monitor.js b/js/monitor.js index b6a339a..7ad0bd4 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -113,12 +113,24 @@ function openHostPanel(id) { dlg.html(`
${monitorMessages.loading || 'Loading...'}
`); + const maxHeight = Math.max(360, $(globalThis).height() - 80); + dlg.dialog({ title: monitorMessages.deviceDetails || 'Device Details', width: 720, - height: 620, + height: Math.min(760, maxHeight), + maxHeight: maxHeight, modal: true, - position: { at: 'center top+60px', of: globalThis } + closeOnEscape: true, + position: { my: 'center', at: 'center', of: globalThis }, + open() { + // Pause the refresh countdown so the page does not reload underneath + // the open dialog. + clearTimeout(myTimer); + }, + close() { + myTimer = setTimeout(timeStep, 1000); + } }); $.get(`monitor.php?action=ajax_hostpanel&header=false&id=${id}`, (data) => { diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 18bda01..b523df4 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 13:45-0400\n" +"POT-Creation-Date: 2026-10-11 14:04-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -461,7 +461,7 @@ msgstr "" msgid "Not tested" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "OK" msgstr "" @@ -644,14 +644,30 @@ msgstr "" msgid "Warning ping latency - click to filter (click again to clear)" msgstr "" +#: includes/render.php +msgid "No ping latency alert" +msgstr "" + #: includes/render.php msgid "Breached / Triggered / Total thresholds - click to filter triggered or breached devices (click again to clear)" msgstr "" +#: includes/render.php +msgid "No thresholds defined for this device" +msgstr "" + #: includes/render.php msgid "Failed / Total service checks - click to filter failing devices (click again to clear)" msgstr "" +#: includes/render.php +msgid "No service checks defined for this device" +msgstr "" + +#: includes/render.php +msgid "Click to filter by this status (click again to clear)" +msgstr "" + #: monitor.php poller_functions.php setup.php msgid "Low" msgstr "" From ce3d00f5d909982423afeee3a810b3e38b4f40bc Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 14:36:22 -0400 Subject: [PATCH 11/26] Monitor: center and keep clickable the tile hostname, remove 'Names only' view option (backend retained) --- css/monitor.css | 8 +++++++- locales/po/cacti.pot | 10 +++++----- monitor.php | 1 - 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/css/monitor.css b/css/monitor.css index ce585b6..b0a9437 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -31,9 +31,15 @@ /* When the server sets --monitor-title-ch (the longest trimmed description in view), widen every tile title to it so long hostnames wrap within their own - tile instead of blending into neighbours. */ + tile instead of blending into neighbours. inline-block keeps the title + centred under the glyph (via the frame's text-align) in every view, and + max-width stops it overflowing a fixed-width tile. */ .monitor_container[style*='--monitor-title-ch'] [class$='_title'] { + display: inline-block; width: calc(var(--monitor-title-ch, 11) * 1ch + 8px) !important; + max-width: 100%; + box-sizing: border-box; + vertical-align: top; } .monitor_sub_container { diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index b523df4..147b09e 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 14:04-0400\n" +"POT-Creation-Date: 2026-10-11 14:35-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -776,10 +776,6 @@ msgstr "" msgid "List" msgstr "" -#: monitor.php setup.php -msgid "Names only" -msgstr "" - #: monitor.php setup.php msgid "Tiles" msgstr "" @@ -993,6 +989,10 @@ msgstr "" msgid "This is how monitor will render Devices." msgstr "" +#: setup.php +msgid "Names only" +msgstr "" + #: setup.php msgid "Tiles & Downtime" msgstr "" diff --git a/monitor.php b/monitor.php index 66e6b37..ede22ff 100644 --- a/monitor.php +++ b/monitor.php @@ -94,7 +94,6 @@ $monitor_view_type = [ 'default' => __('Default', 'monitor'), 'list' => __('List', 'monitor'), - 'names' => __('Names only', 'monitor'), 'tiles' => __('Tiles', 'monitor'), 'tilesadt' => __('Tiles & Time', 'monitor') ]; From ceb800c43cecc10808a7c44d3921e9a2c58205ec Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 14:54:10 -0400 Subject: [PATCH 12/26] Monitor: multi-select Events filter + Events column (additive), Status becomes scope-only Show --- CHANGELOG.md | 4 +- css/monitor.css | 9 +- includes/controller.php | 74 ++++++++++++++++- includes/functions.php | 147 ++++++++++++++++++--------------- includes/render.php | 131 ++++++++++++++++++++--------- js/monitor.js | 23 +++++- locales/po/cacti.pot | 110 ++++++++++++------------ monitor.php | 23 ++++-- tests/Unit/DbFunctionsTest.php | 91 +++++++++++++------- 9 files changed, 398 insertions(+), 214 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 629f501..dba51ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ --- 3.0 --- -* bug: Make the List-view Status pill clickable to filter by that status, show a muted 'OK' / 'Not Set' pill (instead of a blank cell) when a device has no ping alert, thresholds or service checks, and hide the Thresholds / Service Checks columns entirely when their plugin is not installed +* feature: Replace the single Status column/filter with a multi-select Events filter (additive) and an Events column that shows a short pill per active event (Down, Recovering, Triggered, Down/Triggered M/A, Warn/Alert Ping, Failed Svc), 'None' when clean; the Status scope dropdown now only selects All Monitored / All Devices / Not Monitored / Without Thresholds +* bug: Centre the tile hostname under the glyph and keep it clickable in the Tiles & Time view +* bug: Remove the 'Names only' View option from the dropdown (the renderer is retained for direct ?view=names use) * bug: Size grouping-card tiles to the longest visible host description so long hostnames no longer overlap neighbouring tiles * bug: Center the device Actions dialog, make it taller and scrollable so panel text is not cut off, and pause the page refresh countdown while it is open * bug: Remove the stray 'Monitored Devices' box header on the Site grouping so it matches the other groupings diff --git a/css/monitor.css b/css/monitor.css index b0a9437..fe4fe07 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -223,8 +223,8 @@ .monitorStatus.deviceUnmonitored { background-color: #3f6a8a; } .monitorStatus.deviceServiceCheck { background-color: #157f87; } -/* Clickable list-view pills (ping, thresholds, service checks, criticality). - Share the status palette for alert-type pills and a graduated, deliberately +/* Clickable list-view pills (events, thresholds, service checks, criticality). + Share the status palette for event pills and a graduated, deliberately non-alarming palette for criticality (no red for Mission Critical). */ .monitorPill { display: inline-block; @@ -234,6 +234,7 @@ white-space: nowrap; font-size: 11px; line-height: 1.5; + margin: 1px; } .monitorFilterPill { @@ -241,9 +242,13 @@ } .monitorPillMuted { background-color: #5f5f57; } +.monitorPill.deviceDown { background-color: #a12820; } +.monitorPill.deviceDownMuted { background-color: #93302a; } +.monitorPill.deviceRecovering { background-color: #9a6a1a; } .monitorPill.deviceWarning { background-color: #7d6f18; } .monitorPill.deviceAlert { background-color: #86407a; } .monitorPill.deviceThreshold { background-color: #a85712; } +.monitorPill.deviceThresholdMuted { background-color: #6a4a9c; } .monitorPill.deviceServiceCheck { background-color: #157f87; } /* Criticality: Not Set (grey) -> Low (blue) -> Medium (teal) -> High (amber) diff --git a/includes/controller.php b/includes/controller.php index 573fb81..988c6cc 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -389,6 +389,62 @@ function drawFilterDropdown(string $id, string $title, array $settings = [], mix } } +/** + * Render a checkbox-style multi-select filter (Cacti 'N Selected' widget). + * + * @param string $id Filter field id/name. + * @param string $title Filter display title. + * @param array $settings Option map of value => label. + * @param array $selected Currently selected option values. + * + * @return void + */ +function drawFilterMultiselect(string $id, string $title, array $settings, array $selected): void { + if (!cacti_sizeof($settings)) { + return; + } + + print '' . html_escape($title) . ''; + + // Cacti 1.2.32+ themes a multiple ' . PHP_EOL; + + foreach ($settings as $setting_value => $setting_name) { + $setting_selected = in_array((string) $setting_value, $selected, true) ? ' selected' : ''; + + print '' . PHP_EOL; + } + + print '' . PHP_EOL; +} + +/** + * Resolve the selected, validated Events filter tokens from the request. + * + * @return array List of valid event keys currently selected. + */ +function monitorGetSelectedEvents(): array { + global $monitor_events; + + $raw = get_nfilter_request_var('events'); + + if (is_array($raw)) { + $keys = $raw; + } elseif ($raw === '' || $raw === null) { + return []; + } else { + $keys = explode(',', (string) $raw); + } + + $keys = array_map('trim', $keys); + + return array_values(array_intersect($keys, array_keys($monitor_events))); +} + /** * Build dashboard dropdown option map for current user context. * @@ -459,9 +515,10 @@ function monitorGetZoomDropdownState(bool &$dozoombgndcolor): array { * * @return void */ -function monitorRenderPrimaryFilterRow(array $dashboards, array $monitor_status, array $monitor_view_type, array $monitor_grouping, array $item_rows, mixed $mon_zoom_status): void { +function monitorRenderPrimaryFilterRow(array $dashboards, array $monitor_status, array $monitor_events, array $monitor_view_type, array $monitor_grouping, array $item_rows, mixed $mon_zoom_status): void { drawFilterDropdown('dashboard', __('Layout', 'monitor'), $dashboards); - drawFilterDropdown('status', __('Status', 'monitor'), $monitor_status, $mon_zoom_status); + drawFilterDropdown('status', __('Show', 'monitor'), $monitor_status, $mon_zoom_status); + drawFilterMultiselect('events', __('Events', 'monitor'), $monitor_events, monitorGetSelectedEvents()); drawFilterDropdown('view', __('View', 'monitor'), $monitor_view_type); drawFilterDropdown('grouping', __('Grouping', 'monitor'), $monitor_grouping); @@ -669,7 +726,7 @@ function monitorPrintJsBootstrap(array $config, string $mbcolor, string $monitor */ function drawFilterAndStatus(): void { global $config, $criticalities, $page_refresh_interval, $classes, $monitor_grouping; - global $monitor_view_type, $monitor_status, $monitor_trim; + global $monitor_view_type, $monitor_status, $monitor_events, $monitor_trim; global $dozoombgndcolor, $dozoomrefresh, $zoom_hist_status, $zoom_hist_size, $mon_zoom_state; global $new_form, $new_title, $item_rows; @@ -686,6 +743,7 @@ function drawFilterAndStatus(): void { monitorRenderPrimaryFilterRow( monitorGetDashboardOptions(), $monitor_status, + $monitor_events, $monitor_view_type, $monitor_grouping, $item_rows, @@ -837,6 +895,10 @@ function saveSettings(): void { case 'status': set_user_setting('monitor_status', get_request_var('status')); + break; + case 'events': + set_user_setting('monitor_events', implode(',', monitorGetSelectedEvents())); + break; case 'tree': set_user_setting('monitor_tree', get_request_var('tree')); @@ -974,6 +1036,12 @@ function validateRequestVars(bool $force = false): void { 'pageset' => true, 'default' => read_user_setting('monitor_status', '-1', $force) ], + 'events' => [ + 'filter' => FILTER_CALLBACK, + 'pageset' => true, + 'options' => ['options' => 'sanitize_search_string'], + 'default' => read_user_setting('monitor_events', '', $force) + ], 'tree' => [ 'filter' => FILTER_VALIDATE_INT, 'pageset' => true, diff --git a/includes/functions.php b/includes/functions.php index 9843889..8f2e70d 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -267,6 +267,54 @@ function renderGroupConcat(string &$sql_where, string $sql_join, string $sql_fie } } +/** + * Build the additive (OR-combined) SQL predicate for the selected Events. + * + * Each event maps to a host-table predicate; a device matches when it satisfies + * any selected event. Triggered/muted sets come from the precomputed thold, + * servcheck and session mute id lists. + * + * @param array $events Validated event keys currently selected. + * + * @return string Parenthesised OR predicate, or '' when nothing is selected. + */ +function monitorEventFilterSql(array $events): string { + global $thold_hosts, $servcheck_hosts; + + if (!cacti_sizeof($events)) { + return ''; + } + + $thold_ids = (isset($thold_hosts) && cacti_sizeof($thold_hosts)) ? implode(',', array_map('intval', $thold_hosts)) : '0'; + $servcheck_ids = (isset($servcheck_hosts) && cacti_sizeof($servcheck_hosts)) ? implode(',', array_map('intval', $servcheck_hosts)) : '0'; + $muted = (isset($_SESSION['monitor_muted_hosts']) && cacti_sizeof($_SESSION['monitor_muted_hosts'])) ? implode(',', array_map('intval', $_SESSION['monitor_muted_hosts'])) : '0'; + + $map = [ + 'down' => "(h.status = 1 AND h.id NOT IN ($muted))", + 'recovering' => '(h.status = 2)', + 'triggered' => "(h.id IN ($thold_ids) AND h.id NOT IN ($muted))", + 'down_ma' => "(h.status = 1 AND h.id IN ($muted))", + 'triggered_ma' => "(h.id IN ($thold_ids) AND h.id IN ($muted))", + 'warn_ping' => '(h.status = 3 AND h.monitor_warn > 0 AND h.cur_time > h.monitor_warn AND NOT (h.monitor_alert > 0 AND h.cur_time > h.monitor_alert))', + 'alert_ping' => '(h.status = 3 AND h.monitor_alert > 0 AND h.cur_time > h.monitor_alert)', + 'failed_svc' => "(h.id IN ($servcheck_ids))", + ]; + + $preds = []; + + foreach ($events as $event) { + if (isset($map[$event])) { + $preds[] = $map[$event]; + } + } + + if (!cacti_sizeof($preds)) { + return ''; + } + + return '(' . implode(' OR ', $preds) . ')'; +} + /** * Build core monitor host query join/where fragments from current filters. * @@ -325,93 +373,58 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { } } - if (!empty($awhere)) { - $awhere = ' AND ' . $awhere; - } - - if (get_request_var('status') == '0') { - $sql_join = ''; - $sql_where = 'WHERE h.disabled = "" - AND h.monitor = "on" - AND h.status < 3 - AND h.deleted = "" - AND (h.availability_method > 0 - OR h.snmp_version > 0 - OR (h.cur_time >= h.monitor_warn AND h.monitor_warn > 0) - OR (h.cur_time >= h.monitor_alert AND h.monitor_alert > 0) - )' . $awhere; - } elseif (get_request_var('status') == '1' || get_request_var('status') == 2) { - $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; - - $servcheck_where = (isset($servcheck_hosts) && cacti_sizeof($servcheck_hosts)) ? ' - OR h.id IN (' . implode(',', array_map('intval', $servcheck_hosts)) . ')' : ''; + // Additive Events filter (OR-combined) from the multi-select. + $events_raw = get_nfilter_request_var('events'); - $sql_where = 'WHERE h.disabled = "" - AND h.monitor = "on" - AND h.deleted = "" - AND (h.status < 3 - OR ' . getTholdWhere() . ' - OR ((h.availability_method > 0 OR h.snmp_version > 0) - AND ((h.cur_time > h.monitor_warn AND h.monitor_warn > 0) - OR (h.cur_time > h.monitor_alert AND h.monitor_alert > 0)))' . $servcheck_where . ' - )' . $awhere; - } elseif (get_request_var('status') == '10') { - // Service Check Failed: devices with a failing/triggered service check. - $sql_join = ''; + if (is_array($events_raw)) { + $events = $events_raw; + } elseif ($events_raw === '' || $events_raw === null) { + $events = []; + } else { + $events = array_map('trim', explode(',', (string) $events_raw)); + } - $servcheck_ids = (isset($servcheck_hosts) && cacti_sizeof($servcheck_hosts)) ? implode(',', array_map('intval', $servcheck_hosts)) : '0'; + $event_sql = monitorEventFilterSql($events); - $sql_where = 'WHERE h.disabled = "" - AND h.deleted = "" - AND h.id IN (' . $servcheck_ids . ')' . $awhere; - } elseif (get_request_var('status') == '7') { - // Warning Ping: up devices whose latency is over the warning (but not alert) threshold. - $sql_join = ''; + if ($event_sql !== '') { + $awhere .= ($awhere != '' ? ' AND ' : '') . $event_sql; + } - $sql_where = 'WHERE h.disabled = "" - AND h.monitor = "on" - AND h.deleted = "" - AND h.status = 3 - AND h.monitor_warn > 0 - AND h.cur_time > h.monitor_warn - AND NOT (h.monitor_alert > 0 AND h.cur_time > h.monitor_alert)' . $awhere; - } elseif (get_request_var('status') == '8') { - // Alert Ping: up devices whose latency is over the alert threshold. - $sql_join = ''; + if (!empty($awhere)) { + $awhere = ' AND ' . $awhere; + } - $sql_where = 'WHERE h.disabled = "" - AND h.monitor = "on" - AND h.deleted = "" - AND h.status = 3 - AND h.monitor_alert > 0 - AND h.cur_time > h.monitor_alert' . $awhere; - } elseif (get_request_var('status') == -1) { - $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; - - $sql_where = 'WHERE h.disabled = "" - AND h.monitor = "on" - AND h.deleted = "" - AND (h.availability_method > 0 OR h.snmp_version > 0 - OR ((td.thold_enabled="on" AND td.thold_alert > 0) - OR td.id IS NULL) - )' . $awhere; - } elseif (get_request_var('status') == -2) { + if (get_request_var('status') == -2) { + // All Devices (monitored or not). $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; $sql_where = 'WHERE h.disabled = "" AND h.deleted = ""' . $awhere; } elseif (get_request_var('status') == -3) { + // Devices not Monitored. $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; $sql_where = 'WHERE h.disabled = "" AND h.monitor = "" AND h.deleted = ""' . $awhere; - } else { + } elseif (get_request_var('status') == -4) { + // Devices without Thresholds. $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; $sql_where = 'WHERE (h.disabled = "" AND h.deleted = "" AND td.id IS NULL)' . $awhere; + } else { + // All Monitored Devices (default scope, status -1). + $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; + + $sql_where = 'WHERE h.disabled = "" + AND h.monitor = "on" + AND h.deleted = "" + AND (h.availability_method > 0 OR h.snmp_version > 0 + OR ((td.thold_enabled="on" AND td.thold_alert > 0) + OR td.id IS NULL) + )' . $awhere; } } diff --git a/includes/render.php b/includes/render.php index 4a62992..c9ddcf0 100644 --- a/includes/render.php +++ b/includes/render.php @@ -938,6 +938,10 @@ function renderHost(array $host, bool $float = true, int $maxlen = 10): ?string $host['anchor'] = $config['url_path'] . 'graph_view.php?action=preview&reset=1&host_id=' . $host['id']; + // Preserve the raw availability status (0-3) before it is collapsed into the + // single display status, so the Events column can detect every active event. + $host['raw_status'] = (int) $host['status']; + if ($host['status'] == 3 && array_key_exists($host['id'], $thold_hosts)) { $host['status'] = 4; $host['anchor'] = $config['url_path'] . 'plugins/thold/thold_graph.php?action=thold&reset=true&status=1&host_id=' . $host['id']; @@ -1091,6 +1095,65 @@ function renderHeaderTilesadt(): string { return renderHeaderDefault(); } +/** + * Event key => short label and status CSS class for the Events column pills. + * + * @return array + */ +function monitorEventMeta(): array { + return [ + 'down' => ['label' => __('Down', 'monitor'), 'class' => 'deviceDown'], + 'recovering' => ['label' => __('Recovering', 'monitor'), 'class' => 'deviceRecovering'], + 'triggered' => ['label' => __('Triggered', 'monitor'), 'class' => 'deviceThreshold'], + 'down_ma' => ['label' => __('Down (M/A)', 'monitor'), 'class' => 'deviceDownMuted'], + 'triggered_ma' => ['label' => __('Triggered (M/A)', 'monitor'), 'class' => 'deviceThresholdMuted'], + 'warn_ping' => ['label' => __('Warn Ping', 'monitor'), 'class' => 'deviceWarning'], + 'alert_ping' => ['label' => __('Alert Ping', 'monitor'), 'class' => 'deviceAlert'], + 'failed_svc' => ['label' => __('Failed Svc', 'monitor'), 'class' => 'deviceServiceCheck'], + ]; +} + +/** + * Resolve every active event on a host for the Events column (a device can have + * several, e.g. Down and Triggered). Uses the preserved raw availability status + * plus the triggered-thold, failing-servcheck and session mute sets. + * + * @param array $host Host row data. + * + * @return array List of active event keys. + */ +function monitorHostEvents(array $host): array { + global $thold_hosts, $servcheck_hosts; + + $raw = isset($host['raw_status']) ? (int) $host['raw_status'] : (int) $host['status']; + $muted = isset($_SESSION['monitor_muted_hosts']) && in_array($host['id'], $_SESSION['monitor_muted_hosts'], true); + $events = []; + + if ($raw == 1) { + $events[] = $muted ? 'down_ma' : 'down'; + } elseif ($raw == 2) { + $events[] = 'recovering'; + } + + if (isset($thold_hosts) && array_key_exists($host['id'], $thold_hosts)) { + $events[] = $muted ? 'triggered_ma' : 'triggered'; + } + + if ($raw == 3) { + if (!empty($host['monitor_alert']) && $host['cur_time'] > $host['monitor_alert']) { + $events[] = 'alert_ping'; + } elseif (!empty($host['monitor_warn']) && $host['cur_time'] > $host['monitor_warn']) { + $events[] = 'warn_ping'; + } + + if (isset($servcheck_hosts) && array_key_exists($host['id'], $servcheck_hosts)) { + $events[] = 'failed_svc'; + } + } + + return $events; +} + /** * Render header and sortable column bar for list view. * @@ -1135,26 +1198,20 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { 'align' => 'left', 'tip' => __('Monitoring criticality assigned to the device', 'monitor') ], - 'status' => [ - 'display' => __('Status', 'monitor'), - 'sort' => 'DESC', + 'nosort_events' => [ + 'display' => __('Events', 'monitor'), 'align' => 'center', - 'tip' => __('Current monitored availability status of the device', 'monitor') - ], - 'nosort_ping' => [ - 'display' => __('Ping', 'monitor'), - 'align' => 'center', - 'tip' => __('Ping latency alert state; click to filter by Warning or Alert Ping, click again to clear', 'monitor') + 'tip' => __('Active events on the device (Down, Triggered, Warn/Alert Ping, Failed Svc, ...); click a pill to add or remove that event from the Events filter', 'monitor') ], 'nosort_tholds' => [ 'display' => __('Thresholds', 'monitor'), 'align' => 'center', - 'tip' => __('Breached / Triggered / Total thresholds (thold plugin) on the device; click to filter triggered devices, click again to clear', 'monitor') + 'tip' => __('Breached / Triggered / Total thresholds (thold plugin) on the device; click to toggle the Triggered event filter', 'monitor') ], 'nosort_servchecks' => [ 'display' => __('Service Checks', 'monitor'), 'align' => 'center', - 'tip' => __('Failed / Total service checks (servcheck plugin) for the device; click to filter failing devices, click again to clear', 'monitor') + 'tip' => __('Failed / Total service checks (servcheck plugin) for the device; click to toggle the Failed Svc event filter', 'monitor') ], 'instate' => [ 'display' => __('Length in Status', 'monitor'), @@ -1335,37 +1392,44 @@ function renderHostList(array $host): string { $host_crit = "" . __esc('Not Set', 'monitor') . ''; } - // Map the display status to the closest Status filter value so the status - // pill can filter (Up/Unknown/Unmonitored have no useful filter and stay - // inert). - $status_filter_map = [1 => '0', 2 => '0', 5 => '0', 4 => '1', 9 => '1', 7 => '7', 8 => '8', 10 => '10']; + // Events column: a short pill for every active event (a device can have + // several, e.g. Down + Triggered); each pill toggles that event in the + // multi-select filter. A muted 'None' pill is shown when the device is clean. + $event_keys = monitorHostEvents($host); + + if (cacti_sizeof($event_keys)) { + $meta = monitorEventMeta(); + $pills = []; - // Ping latency pill (Warning/Alert) shown right of the Status column; a muted - // pill is shown when there is no ping alert so the column is never blank. - if (!empty($host['monitor_alert']) && $host['cur_time'] > $host['monitor_alert']) { - $ping_cell = "" . __esc('Alert', 'monitor') . ''; - } elseif (!empty($host['monitor_warn']) && $host['cur_time'] > $host['monitor_warn']) { - $ping_cell = "" . __esc('Warning', 'monitor') . ''; + foreach ($event_keys as $ekey) { + if (isset($meta[$ekey])) { + $pills[] = "" . html_escape($meta[$ekey]['label']) . ''; + } + } + + $events_cell = implode(' ', $pills); } else { - $ping_cell = "" . __esc('OK', 'monitor') . ''; + $events_cell = "" . __esc('None', 'monitor') . ''; } // Thresholds pill: breached / triggered / total (thold plugin); a muted - // 'Not Set' pill is shown when the device has no thresholds. + // 'Not Set' pill is shown when the device has no thresholds. Clicking toggles + // the Triggered event. if (isset($thold_counts[$host['id']]) && $thold_counts[$host['id']]['total'] > 0) { $tc = $thold_counts[$host['id']]; $tcls = ($tc['triggered'] > 0 || $tc['breached'] > 0) ? 'deviceThreshold' : 'monitorPillMuted'; - $thold_cell = "" . $tc['breached'] . ' / ' . $tc['triggered'] . ' / ' . $tc['total'] . ''; + $thold_cell = "" . $tc['breached'] . ' / ' . $tc['triggered'] . ' / ' . $tc['total'] . ''; } else { $thold_cell = "" . __esc('Not Set', 'monitor') . ''; } // Service checks pill: failed / total (servcheck plugin); a muted 'Not Set' - // pill is shown when the device has no service checks. + // pill is shown when the device has no service checks. Clicking toggles the + // Failed Svc event. if (isset($servcheck_counts[$host['id']]) && $servcheck_counts[$host['id']]['total'] > 0) { $sc = $servcheck_counts[$host['id']]; $scls = ($sc['failed'] > 0) ? 'deviceServiceCheck' : 'monitorPillMuted'; - $servcheck_cell = "" . $sc['failed'] . ' / ' . $sc['total'] . ''; + $servcheck_cell = "" . $sc['failed'] . ' / ' . $sc['total'] . ''; } else { $servcheck_cell = "" . __esc('Not Set', 'monitor') . ''; } @@ -1388,9 +1452,6 @@ function renderHostList(array $host): string { $host_datefail = $host['status_fail_date']; - $iclass = $iclasses[$host['status']]; - $sdisplay = getHostStatusDescription($host['real_status']); - ob_start(); print ""; @@ -1405,17 +1466,7 @@ function renderHostList(array $host): string { form_selectable_cell($host['description'], $host['id'], '', 'left'); form_selectable_cell($host['site_name'], $host['id'], '', 'left'); form_selectable_cell($host_crit, $host['id'], '', 'left'); - - $rstatus = (int) $host['real_status']; - - if (isset($status_filter_map[$rstatus])) { - $status_pill = "" . html_escape($sdisplay) . ''; - } else { - $status_pill = "" . html_escape($sdisplay) . ''; - } - - form_selectable_cell($status_pill, $host['id'], '', 'center'); - form_selectable_cell($ping_cell, $host['id'], '', 'center'); + form_selectable_cell($events_cell, $host['id'], '', 'center'); if (api_plugin_is_enabled('thold')) { form_selectable_cell($thold_cell, $host['id'], '', 'center'); diff --git a/js/monitor.js b/js/monitor.js index 7ad0bd4..21021e5 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -166,6 +166,9 @@ function applyFilter(action = '') { strURL += `&mute=${$('#mute').val()}`; strURL += `&rfilter=${base64_encode($('#rfilter').val())}`; strURL += `&status=${$('#status').val()}`; + + const events = $('#events').val(); + strURL += `&events=${events ? events.join(',') : ''}`; } loadIt(strURL); @@ -384,9 +387,23 @@ $(() => { return; } - // Toggle: a pill whose filter is already active clears it back to the - // default (All), otherwise it applies the pill's filter value. - sel.val(String(sel.val()) === value ? '-1' : value); + if (sel.prop('multiple')) { + // Additive multi-select (Events): toggle this value in or out of the + // current selection. + let current = sel.val() || []; + + if (current.indexOf(value) >= 0) { + current = current.filter((v) => v !== value); + } else { + current = current.concat([value]); + } + + sel.val(current); + } else { + // Single-select (Criticality / Show): toggle on, or clear to default. + sel.val(String(sel.val()) === value ? '-1' : value); + } + sel.trigger('change'); applyFilter(); }); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 147b09e..5daa8c6 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 14:35-0400\n" +"POT-Creation-Date: 2026-10-11 14:53-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -37,6 +37,10 @@ msgstr "" msgid "Monitored Devices" msgstr "" +#: includes/controller.php includes/render.php +msgid "Events" +msgstr "" + #: includes/controller.php msgid "Unsaved" msgstr "" @@ -45,8 +49,8 @@ msgstr "" msgid "Layout" msgstr "" -#: includes/controller.php includes/render.php -msgid "Status" +#: includes/controller.php +msgid "Show" msgstr "" #: includes/controller.php setup.php @@ -351,7 +355,7 @@ msgstr "" msgid "Unspecified" msgstr "" -#: includes/controller.php setup.php +#: includes/controller.php includes/render.php setup.php msgid "None" msgstr "" @@ -363,7 +367,7 @@ msgstr "" msgid "SNMP Uptime" msgstr "" -#: includes/controller.php includes/render.php +#: includes/controller.php msgid "Ping" msgstr "" @@ -392,6 +396,10 @@ msgstr "" msgid "Hostname" msgstr "" +#: includes/controller.php +msgid "Status" +msgstr "" + #: includes/controller.php includes/render.php monitor.php msgid "Site" msgstr "" @@ -461,7 +469,7 @@ msgstr "" msgid "Not tested" msgstr "" -#: includes/controller.php includes/render.php +#: includes/controller.php msgid "OK" msgstr "" @@ -474,7 +482,7 @@ msgstr "" msgid "Threshold %d" msgstr "" -#: includes/controller.php monitor.php +#: includes/controller.php includes/render.php monitor.php msgid "Triggered" msgstr "" @@ -524,6 +532,34 @@ msgstr "" msgid "Just Up" msgstr "" +#: includes/render.php monitor.php +msgid "Down" +msgstr "" + +#: includes/render.php monitor.php +msgid "Recovering" +msgstr "" + +#: includes/render.php monitor.php +msgid "Down (M/A)" +msgstr "" + +#: includes/render.php monitor.php +msgid "Triggered (M/A)" +msgstr "" + +#: includes/render.php monitor.php +msgid "Warn Ping" +msgstr "" + +#: includes/render.php monitor.php +msgid "Alert Ping" +msgstr "" + +#: includes/render.php monitor.php +msgid "Failed Svc" +msgstr "" + #: includes/render.php msgid "Actions" msgstr "" @@ -557,19 +593,15 @@ msgid "Monitoring criticality assigned to the device" msgstr "" #: includes/render.php -msgid "Current monitored availability status of the device" +msgid "Active events on the device (Down, Triggered, Warn/Alert Ping, Failed Svc, ...); click a pill to add or remove that event from the Events filter" msgstr "" #: includes/render.php -msgid "Ping latency alert state; click to filter by Warning or Alert Ping, click again to clear" +msgid "Breached / Triggered / Total thresholds (thold plugin) on the device; click to toggle the Triggered event filter" msgstr "" #: includes/render.php -msgid "Breached / Triggered / Total thresholds (thold plugin) on the device; click to filter triggered devices, click again to clear" -msgstr "" - -#: includes/render.php -msgid "Failed / Total service checks (servcheck plugin) for the device; click to filter failing devices, click again to clear" +msgid "Failed / Total service checks (servcheck plugin) for the device; click to toggle the Failed Svc event filter" msgstr "" #: includes/render.php @@ -633,23 +665,11 @@ msgid "Not Set" msgstr "" #: includes/render.php -msgid "Alert" -msgstr "" - -#: includes/render.php -msgid "Alert ping latency - click to filter (click again to clear)" -msgstr "" - -#: includes/render.php -msgid "Warning ping latency - click to filter (click again to clear)" -msgstr "" - -#: includes/render.php -msgid "No ping latency alert" +msgid "Click to add or remove this event from the filter" msgstr "" #: includes/render.php -msgid "Breached / Triggered / Total thresholds - click to filter triggered or breached devices (click again to clear)" +msgid "Breached / Triggered / Total thresholds - click to toggle the Triggered event filter" msgstr "" #: includes/render.php @@ -657,17 +677,13 @@ msgid "No thresholds defined for this device" msgstr "" #: includes/render.php -msgid "Failed / Total service checks - click to filter failing devices (click again to clear)" +msgid "Failed / Total service checks - click to toggle the Failed Svc event filter" msgstr "" #: includes/render.php msgid "No service checks defined for this device" msgstr "" -#: includes/render.php -msgid "Click to filter by this status (click again to clear)" -msgstr "" - #: monitor.php poller_functions.php setup.php msgid "Low" msgstr "" @@ -684,14 +700,6 @@ msgstr "" msgid "Mission Critical" msgstr "" -#: monitor.php -msgid "Down" -msgstr "" - -#: monitor.php -msgid "Recovering" -msgstr "" - #: monitor.php msgid "Up" msgstr "" @@ -712,10 +720,6 @@ msgstr "" msgid "Warning Ping" msgstr "" -#: monitor.php -msgid "Alert Ping" -msgstr "" - #: monitor.php msgid "Service Check Failed" msgstr "" @@ -740,34 +744,22 @@ msgstr "" msgid "Zoom" msgstr "" -#: monitor.php -msgid "All Devices" -msgstr "" - #: monitor.php msgid "All Monitored Devices" msgstr "" #: monitor.php -msgid "Not Up" -msgstr "" - -#: monitor.php -msgid "Not Up or Triggered" +msgid "All Devices" msgstr "" #: monitor.php -msgid "Not Up, Triggered or Breached" +msgid "Devices not Monitored" msgstr "" #: monitor.php msgid "Devices without Thresholds" msgstr "" -#: monitor.php -msgid "Devices not Monitored" -msgstr "" - #: monitor.php setup.php msgid "Default" msgstr "" diff --git a/monitor.php b/monitor.php index ede22ff..6aff3ab 100644 --- a/monitor.php +++ b/monitor.php @@ -79,16 +79,23 @@ ]; $monitor_status = [ - -2 => __('All Devices', 'monitor'), -1 => __('All Monitored Devices', 'monitor'), - 0 => __('Not Up', 'monitor'), - 1 => __('Not Up or Triggered', 'monitor'), - 2 => __('Not Up, Triggered or Breached', 'monitor'), - 7 => __('Warning Ping', 'monitor'), - 8 => __('Alert Ping', 'monitor'), - 10 => __('Service Check Failed', 'monitor'), - -4 => __('Devices without Thresholds', 'monitor'), + -2 => __('All Devices', 'monitor'), -3 => __('Devices not Monitored', 'monitor'), + -4 => __('Devices without Thresholds', 'monitor'), +]; + +// Additive event classes for the multi-select Events filter and the Events +// column pills. Keys are stable filter tokens; a device can match several. +$monitor_events = [ + 'down' => __('Down', 'monitor'), + 'recovering' => __('Recovering', 'monitor'), + 'triggered' => __('Triggered', 'monitor'), + 'down_ma' => __('Down (M/A)', 'monitor'), + 'triggered_ma' => __('Triggered (M/A)', 'monitor'), + 'warn_ping' => __('Warn Ping', 'monitor'), + 'alert_ping' => __('Alert Ping', 'monitor'), + 'failed_svc' => __('Failed Svc', 'monitor'), ]; $monitor_view_type = [ diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index 8ea1873..f0105bd 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -181,30 +181,66 @@ ->and($seen)->not->toContain('last_result'); }); -it('adds failing service-check hosts to the triggered status filter', function () { - test_set_request(['status' => '1', 'crit' => '0', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); - $GLOBALS['servcheck_hosts'] = [5 => 5, 9 => 9]; +it('builds no event predicate when none are selected', function () { + $GLOBALS['thold_hosts'] = [1 => 1]; + $GLOBALS['servcheck_hosts'] = [2 => 2]; + + expect(monitorEventFilterSql([]))->toBe(''); + + unset($GLOBALS['thold_hosts'], $GLOBALS['servcheck_hosts']); +}); + +it('ORs the selected event predicates together', function () { + $GLOBALS['thold_hosts'] = [4 => 4, 7 => 7]; + $GLOBALS['servcheck_hosts'] = [8 => 8]; + $_SESSION['monitor_muted_hosts'] = [9 => 9]; + + $sql = monitorEventFilterSql(['down', 'triggered', 'alert_ping', 'failed_svc']); + + expect($sql)->toStartWith('(')->and($sql)->toEndWith(')') + ->and($sql)->toContain(' OR ') + ->and($sql)->toContain('h.status = 1 AND h.id NOT IN (9)') + ->and($sql)->toContain('h.id IN (4,7) AND h.id NOT IN (9)') + ->and($sql)->toContain('h.cur_time > h.monitor_alert') + ->and($sql)->toContain('h.id IN (8)'); + + unset($GLOBALS['thold_hosts'], $GLOBALS['servcheck_hosts'], $_SESSION['monitor_muted_hosts']); +}); + +it('uses empty id sets for muted/thold/servcheck events when none exist', function () { + unset($GLOBALS['thold_hosts'], $GLOBALS['servcheck_hosts'], $_SESSION['monitor_muted_hosts']); + + $sql = monitorEventFilterSql(['triggered', 'failed_svc', 'down_ma']); + + expect($sql)->toContain('h.id IN (0)') + ->and($sql)->toContain('h.id NOT IN (0)'); +}); + +it('ignores unknown event keys', function () { + expect(monitorEventFilterSql(['bogus', 'nope']))->toBe(''); +}); + +it('appends the OR-combined event filter to the scope query', function () { + test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1', 'events' => 'down,recovering']); $where = ''; $join = ''; renderWhereJoin($where, $join); - expect($where)->toContain('h.id IN (5,9)'); - - unset($GLOBALS['servcheck_hosts']); + expect($where)->toContain('h.monitor = "on"') + ->and($where)->toContain('(h.status = 1') + ->and($where)->toContain('(h.status = 2)') + ->and($where)->toContain(' OR '); }); -it('omits the service-check clause when none are failing', function () { - test_set_request(['status' => '2', 'crit' => '0', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); - $GLOBALS['servcheck_hosts'] = []; +it('accepts the events filter as an array (native multi-select submit)', function () { + test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1', 'events' => ['warn_ping']]); $where = ''; $join = ''; renderWhereJoin($where, $join); - expect($where)->not->toContain('h.id IN'); - - unset($GLOBALS['servcheck_hosts']); + expect($where)->toContain('h.cur_time > h.monitor_warn'); }); it('filters to unset criticality for the Not Set selection (crit 0)', function () { @@ -217,51 +253,44 @@ expect($where)->toContain('h.monitor_criticality = 0'); }); -it('filters to the warning ping band for status 7', function () { - test_set_request(['status' => '7', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); +it('selects the all-devices scope for status -2', function () { + test_set_request(['status' => '-2', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); $where = ''; $join = ''; renderWhereJoin($where, $join); - expect($where)->toContain('h.cur_time > h.monitor_warn') - ->and($where)->toContain('NOT (h.monitor_alert > 0 AND h.cur_time > h.monitor_alert)'); + expect($where)->toContain('h.disabled = ""')->and($where)->not->toContain('h.monitor = "on"'); }); -it('filters to the alert ping band for status 8', function () { - test_set_request(['status' => '8', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); +it('selects the not-monitored scope for status -3', function () { + test_set_request(['status' => '-3', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); $where = ''; $join = ''; renderWhereJoin($where, $join); - expect($where)->toContain('h.cur_time > h.monitor_alert'); + expect($where)->toContain('h.monitor = ""'); }); -it('filters to failing service-check hosts for status 10', function () { - test_set_request(['status' => '10', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); - $GLOBALS['servcheck_hosts'] = [3 => 3, 8 => 8]; +it('selects the without-thresholds scope for status -4', function () { + test_set_request(['status' => '-4', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); $where = ''; $join = ''; renderWhereJoin($where, $join); - expect($where)->toContain('h.id IN (3,8)'); - - unset($GLOBALS['servcheck_hosts']); + expect($where)->toContain('td.id IS NULL'); }); -it('uses an empty id set when no service checks fail for status 10', function () { - test_set_request(['status' => '10', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); - $GLOBALS['servcheck_hosts'] = []; +it('defaults to the monitored scope for status -1', function () { + test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); $where = ''; $join = ''; renderWhereJoin($where, $join); - expect($where)->toContain('h.id IN (0)'); - - unset($GLOBALS['servcheck_hosts']); + expect($where)->toContain('h.monitor = "on"')->and($where)->toContain('td.thold_enabled'); }); it('returns empty threshold counts when thold is disabled', function () { From 6cadfae2c95a6e9ae7b9396f1019251a79a649e4 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 15:07:02 -0400 Subject: [PATCH 13/26] Monitor: apply the Events multi-select on dropdown close (and on change for native selects) --- js/monitor.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/js/monitor.js b/js/monitor.js index 21021e5..5694f90 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -344,6 +344,20 @@ $(() => { applyFilter('change'); }); + // Events multi-select: let the user tick several events with the dropdown + // staying open (reloading mid-selection would close it, since the filter is + // re-rendered), then apply once when the dropdown closes. A plain multiple + // select (no select2) has no dropdown to keep open, so it applies on change. + $('#events').on('select2:close', () => { + applyFilter('change'); + }); + + $('#events').on('change', function () { + if (!$(this).data('select2')) { + applyFilter('change'); + } + }); + $('#dashboard').change(() => { applyFilter('dashboard'); }); @@ -389,7 +403,8 @@ $(() => { if (sel.prop('multiple')) { // Additive multi-select (Events): toggle this value in or out of the - // current selection. + // current selection, sync the select2 widget without firing the + // generic change (which is debounced), then apply immediately. let current = sel.val() || []; if (current.indexOf(value) >= 0) { @@ -398,14 +413,13 @@ $(() => { current = current.concat([value]); } - sel.val(current); + sel.val(current).trigger('change.select2'); + applyFilter(); } else { // Single-select (Criticality / Show): toggle on, or clear to default. - sel.val(String(sel.val()) === value ? '-1' : value); + // The bound change handler applies the filter. + sel.val(String(sel.val()) === value ? '-1' : value).trigger('change'); } - - sel.trigger('change'); - applyFilter(); }); if ($.fn.sortable) { From 9074412bdbef3b05f4db5d1631d6cf3b6c1b5c17 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 15:24:56 -0400 Subject: [PATCH 14/26] Add All criticality, Device Template multi-select, servcheck scope - Add an "All" option to the Criticality filter. Its value (-1) matches the default and the pill "clear to default" action, which previously set the select to a non-existent option and submitted crit=null, tripping validate_store_request_vars. - Convert the Device Template filter to a checkbox multi-select (like Events) and actually apply it: renderWhereJoin now adds h.host_template_id IN (...) for the template grouping (0 = Non-Templated). Previously the template selection was never applied, so all hosts returned. - Persist the template selection as a user setting and serialize the multi-value for request/save payloads. - Add a "Devices without Service Checks" scope to the Show filter, shown only when the servcheck plugin is installed and permitted. - Tests + regenerated .pot. --- includes/controller.php | 64 +++++++++++++++++++++++++++------- includes/functions.php | 34 ++++++++++++++++++ js/monitor.js | 32 +++++++++++------ locales/po/cacti.pot | 26 +++++++++----- monitor.php | 7 ++++ tests/Unit/DbFunctionsTest.php | 33 ++++++++++++++++++ 6 files changed, 164 insertions(+), 32 deletions(-) diff --git a/includes/controller.php b/includes/controller.php index 988c6cc..5c0c974 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -392,18 +392,23 @@ function drawFilterDropdown(string $id, string $title, array $settings = [], mix /** * Render a checkbox-style multi-select filter (Cacti 'N Selected' widget). * - * @param string $id Filter field id/name. - * @param string $title Filter display title. - * @param array $settings Option map of value => label. - * @param array $selected Currently selected option values. + * @param string $id Filter field id/name. + * @param string $title Filter display title. + * @param array $settings Option map of value => label. + * @param array $selected Currently selected option values. + * @param string $countText Noun shown in the 'N Selected' widget label. * * @return void */ -function drawFilterMultiselect(string $id, string $title, array $settings, array $selected): void { +function drawFilterMultiselect(string $id, string $title, array $settings, array $selected, string $countText = ''): void { if (!cacti_sizeof($settings)) { return; } + if ($countText === '') { + $countText = $title; + } + print '' . html_escape($title) . ''; // Cacti 1.2.32+ themes a multiple ' . PHP_EOL; + print '' . PHP_EOL; + print '' . PHP_EOL; } if (get_request_var('view') == 'list') { @@ -899,6 +932,10 @@ function saveSettings(): void { case 'events': set_user_setting('monitor_events', implode(',', monitorGetSelectedEvents())); + break; + case 'template': + set_user_setting('monitor_template', implode(',', monitorGetSelectedTemplates())); + break; case 'tree': set_user_setting('monitor_tree', get_request_var('tree')); @@ -1053,9 +1090,10 @@ function validateRequestVars(bool $force = false): void { 'default' => read_user_setting('monitor_site', '-1', $force) ], 'template' => [ - 'filter' => FILTER_VALIDATE_INT, + 'filter' => FILTER_CALLBACK, 'pageset' => true, - 'default' => read_user_setting('monitor_template', '-1', $force) + 'options' => ['options' => 'sanitize_search_string'], + 'default' => read_user_setting('monitor_template', '', $force) ], 'id' => [ 'filter' => FILTER_VALIDATE_INT, diff --git a/includes/functions.php b/includes/functions.php index 8f2e70d..b46cbb1 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -373,6 +373,28 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { } } + // Multi-select Device Template filter (host_template_id IN list; 0 = + // Non-Templated). Empty selection means all templates. + if (get_request_var('grouping') == 'template') { + $tpl_raw = get_nfilter_request_var('template'); + + if (is_array($tpl_raw)) { + $tpls = $tpl_raw; + } elseif ($tpl_raw === '' || $tpl_raw === null) { + $tpls = []; + } else { + $tpls = explode(',', (string) $tpl_raw); + } + + $tpls = array_map('intval', array_filter(array_map('trim', $tpls), static function ($t) { + return ctype_digit((string) $t); + })); + + if (cacti_sizeof($tpls)) { + $awhere .= ($awhere == '' ? '' : ' AND ') . 'h.host_template_id IN (' . implode(',', $tpls) . ')'; + } + } + // Additive Events filter (OR-combined) from the multi-select. $events_raw = get_nfilter_request_var('events'); @@ -414,6 +436,18 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { $sql_where = 'WHERE (h.disabled = "" AND h.deleted = "" AND td.id IS NULL)' . $awhere; + } elseif (get_request_var('status') == -5 && db_table_exists('plugin_servcheck_test')) { + // Devices without Service Checks (no enabled servcheck test matches the + // device hostname/ipaddress). + $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; + + $sql_where = 'WHERE h.disabled = "" + AND h.deleted = "" + AND NOT EXISTS ( + SELECT 1 FROM plugin_servcheck_test AS sct + WHERE sct.enabled = "on" + AND (sct.hostname = h.hostname OR sct.ipaddress = h.hostname) + )' . $awhere; } else { // All Monitored Devices (default scope, status -1). $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; diff --git a/js/monitor.js b/js/monitor.js index 5694f90..6449556 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -16,6 +16,17 @@ function setZoomErrorBackgrounds() { } } +// Multi-select filters (Events, Template) return an array when themed by +// select2; a plain hidden input returns a comma string. Normalize to a comma +// string for request/POST serialization. +function serializeMultiVal(value) { + if (Array.isArray(value)) { + return value.join(','); + } + + return value || ''; +} + function setIntervalX(callback, delay, repetitions) { let x = 0; const intervalID = globalThis.setInterval(() => { @@ -157,7 +168,7 @@ function applyFilter(action = '') { strURL += `&grouping=${$('#grouping').val()}`; strURL += `&tree=${$('#tree').val()}`; strURL += `&site=${$('#site').val()}`; - strURL += `&template=${$('#template').val()}`; + strURL += `&template=${serializeMultiVal($('#template').val())}`; strURL += `&view=${$('#view').val()}`; strURL += `&rows=${$('#rows').val()}`; strURL += `&crit=${$('#crit').val()}`; @@ -183,7 +194,7 @@ function saveFilter() { grouping: $('#grouping').val(), tree: $('#tree').val(), site: $('#site').val(), - template: $('#template').val(), + template: serializeMultiVal($('#template').val()), view: $('#view').val(), rows: $('#rows').val(), crit: $('#crit').val(), @@ -212,7 +223,7 @@ function saveNewDashboard(action) { grouping: $('#grouping').val(), tree: $('#tree').val(), site: $('#site').val(), - template: $('#template').val(), + template: serializeMultiVal($('#template').val()), view: $('#view').val(), rows: $('#rows').val(), crit: $('#crit').val(), @@ -340,19 +351,20 @@ $(() => { } }); - $('#refresh, #view, #rows, #trim, #crit, #grouping, #size, #status, #tree, #site, #template').change(() => { + $('#refresh, #view, #rows, #trim, #crit, #grouping, #size, #status, #tree, #site').change(() => { applyFilter('change'); }); - // Events multi-select: let the user tick several events with the dropdown - // staying open (reloading mid-selection would close it, since the filter is - // re-rendered), then apply once when the dropdown closes. A plain multiple - // select (no select2) has no dropdown to keep open, so it applies on change. - $('#events').on('select2:close', () => { + // Events and Template multi-selects: let the user tick several boxes with + // the dropdown staying open (reloading mid-selection would close it, since + // the filter is re-rendered), then apply once when the dropdown closes. A + // plain multiple select (no select2) has no dropdown to keep open, so it + // applies on change. + $('#events, #template').on('select2:close', () => { applyFilter('change'); }); - $('#events').on('change', function () { + $('#events, #template').on('change', function () { if (!$(this).data('select2')) { applyFilter('change'); } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 5daa8c6..e78a6ab 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 14:53-0400\n" +"POT-Creation-Date: 2026-10-11 15:22-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -37,10 +37,6 @@ msgstr "" msgid "Monitored Devices" msgstr "" -#: includes/controller.php includes/render.php -msgid "Events" -msgstr "" - #: includes/controller.php msgid "Unsaved" msgstr "" @@ -53,6 +49,10 @@ msgstr "" msgid "Show" msgstr "" +#: includes/controller.php includes/render.php +msgid "Events" +msgstr "" + #: includes/controller.php setup.php msgid "View" msgstr "" @@ -159,10 +159,6 @@ msgstr "" msgid "Sites" msgstr "" -#: includes/controller.php -msgid "All Templates" -msgstr "" - #: includes/controller.php includes/render.php msgid "Non-Templated Devices" msgstr "" @@ -171,6 +167,10 @@ msgstr "" msgid "Template" msgstr "" +#: includes/controller.php +msgid "Templates" +msgstr "" + #: includes/controller.php msgid " [ Filter Settings Saved ]" msgstr "" @@ -684,6 +684,10 @@ msgstr "" msgid "No service checks defined for this device" msgstr "" +#: monitor.php +msgid "All" +msgstr "" + #: monitor.php poller_functions.php setup.php msgid "Low" msgstr "" @@ -760,6 +764,10 @@ msgstr "" msgid "Devices without Thresholds" msgstr "" +#: monitor.php +msgid "Devices without Service Checks" +msgstr "" + #: monitor.php setup.php msgid "Default" msgstr "" diff --git a/monitor.php b/monitor.php index 6aff3ab..a851f54 100644 --- a/monitor.php +++ b/monitor.php @@ -34,6 +34,7 @@ $start = microtime(true); $criticalities = [ + -1 => __('All', 'monitor'), 0 => __('Disabled', 'monitor'), 1 => __('Low', 'monitor'), 2 => __('Medium', 'monitor'), @@ -85,6 +86,12 @@ -4 => __('Devices without Thresholds', 'monitor'), ]; +// Only offer the service-check scope when the servcheck plugin is installed +// and the user has access to it. +if (api_plugin_is_enabled('servcheck') && api_plugin_user_realm_auth('servcheck_test.php')) { + $monitor_status[-5] = __('Devices without Service Checks', 'monitor'); +} + // Additive event classes for the multi-select Events filter and the Events // column pills. Keys are stable filter tokens; a device can match several. $monitor_events = [ diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index f0105bd..4a181da 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -283,6 +283,39 @@ expect($where)->toContain('td.id IS NULL'); }); +it('selects the without-service-checks scope for status -5', function () { + monitor_test_mock_db('db_table_exists', 'plugin_servcheck_test', true); + + test_set_request(['status' => '-5', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('NOT EXISTS') + ->and($where)->toContain('plugin_servcheck_test'); +}); + +it('filters by selected device templates for the template grouping', function () { + test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'template', 'template' => '3,5', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.host_template_id IN (3,5)'); +}); + +it('includes non-templated devices (template 0) in the template filter', function () { + test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'template', 'template' => ['0'], 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.host_template_id IN (0)'); +}); + it('defaults to the monitored scope for status -1', function () { test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); From db9390f0093378c7ac22e25255c9de7c5ff7bdd6 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 15:27:28 -0400 Subject: [PATCH 15/26] Update template escaping assertions for the multiselect markup --- tests/Integration/MonitorRequestOutputWiringTest.php | 2 +- tests/Integration/OutputEscapingTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/MonitorRequestOutputWiringTest.php b/tests/Integration/MonitorRequestOutputWiringTest.php index bf19036..88ca059 100644 --- a/tests/Integration/MonitorRequestOutputWiringTest.php +++ b/tests/Integration/MonitorRequestOutputWiringTest.php @@ -12,7 +12,7 @@ 'controller mute' => ['includes/controller.php', "html_escape(get_request_var('mute'))"], 'controller tree' => ['includes/controller.php', "html_escape(get_request_var('tree'))"], 'controller site' => ['includes/controller.php', "html_escape(get_request_var('site'))"], - 'controller template' => ['includes/controller.php', "html_escape(get_request_var('template'))"], + 'controller template' => ['includes/controller.php', "html_escape(implode(',', monitorGetSelectedTemplates()))"], 'controller size' => ['includes/controller.php', "html_escape(get_request_var('size'))"], 'controller trim' => ['includes/controller.php', "html_escape(get_request_var('trim'))"], 'render rfilter' => ['includes/render.php', "rawurlencode(get_request_var('rfilter'))"], diff --git a/tests/Integration/OutputEscapingTest.php b/tests/Integration/OutputEscapingTest.php index 2cb6818..ae9b0bb 100644 --- a/tests/Integration/OutputEscapingTest.php +++ b/tests/Integration/OutputEscapingTest.php @@ -20,7 +20,7 @@ ['includes/controller.php', "html_escape(get_request_var('mute'))"], ['includes/controller.php', "html_escape(get_request_var('tree'))"], ['includes/controller.php', "html_escape(get_request_var('site'))"], - ['includes/controller.php', "html_escape(get_request_var('template'))"], + ['includes/controller.php', "html_escape(implode(',', monitorGetSelectedTemplates()))"], ['includes/controller.php', "html_escape(get_request_var('size'))"], ['includes/controller.php', "html_escape(get_request_var('trim'))"], ['includes/render.php', "rawurlencode(get_request_var('rfilter'))"], From e796c6b50299a3a35e9ee4ceae0e044ef242dfd8 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 15:30:11 -0400 Subject: [PATCH 16/26] Cover empty-template branch in renderWhereJoin --- tests/Unit/DbFunctionsTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index 4a181da..94bcb09 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -316,6 +316,16 @@ expect($where)->toContain('h.host_template_id IN (0)'); }); +it('applies no template filter when the template selection is empty', function () { + test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'template', 'template' => '', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->not->toContain('h.host_template_id'); +}); + it('defaults to the monitored scope for status -1', function () { test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); From 0b38bb28d6b170ef4a6d2a0072e657bdfb79f5c6 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 15:53:30 -0400 Subject: [PATCH 17/26] Left-align Actions glyph and harden Device Detail Panels dialog layout - Left-align the List-view Actions column header and fa-layer-group glyph. - Stack the detail panels as plain blocks and force the jQuery UI dialog content box to always fill the dialog width, so the panels can no longer collapse into a narrow right-hand strip. --- css/monitor.css | 29 +++++++++++++++++++++++++---- includes/render.php | 4 ++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/css/monitor.css b/css/monitor.css index fe4fe07..4f198f4 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -609,24 +609,45 @@ /* Device detail panels (intropage-style cards) in the Actions dialog */ .monitorPanels { - display: flex; - flex-direction: column; - gap: 10px; + /* Plain block stacking (not flex) so a card can never collapse to its + min-content width inside the jQuery UI dialog content box. */ + display: block; + width: 100%; + box-sizing: border-box; } /* Keep the Actions dialog body scrollable when the panels are taller than the - dialog, so nothing is cut off. */ + dialog, and force the content box to always fill the dialog width. Some + theme/runtime combinations otherwise let the jQuery UI content box shrink to + its min-content width and drift to one side, squashing the panels into a + narrow strip. The !important/auto width and block display defeat any + inherited inline-block, float or flex-item sizing on the content box. */ +#monitorPanelDialog.ui-dialog-content, #monitorPanelDialog { + box-sizing: border-box; + display: block; + float: none; + width: auto !important; + min-width: 0; + flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; } .monitorPanel { + display: block; + width: 100%; + box-sizing: border-box; + margin-bottom: 10px; border: 1px solid rgba(128, 128, 128, 0.35); border-radius: 6px; overflow: hidden; } +.monitorPanel:last-child { + margin-bottom: 0; +} + .monitorPanelHeader { background-color: rgba(128, 128, 128, 0.15); padding: 6px 10px; diff --git a/includes/render.php b/includes/render.php index c9ddcf0..47ab73c 100644 --- a/includes/render.php +++ b/includes/render.php @@ -1166,7 +1166,7 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { $display_text = [ 'nosort_actions' => [ 'display' => __('Actions', 'monitor'), - 'align' => 'center', + 'align' => 'left', 'tip' => __('Open the device detail panels', 'monitor') ], 'hostname' => [ @@ -1460,7 +1460,7 @@ function renderHostList(array $host): string { $actions = ""; - form_selectable_cell($actions, $host['id'], '1%', 'center'); + form_selectable_cell($actions, $host['id'], '1%', 'left'); form_selectable_cell(filter_value($host['hostname'], '', $url), $host['id'], '', 'left'); form_selectable_cell($host['id'], $host['id'], '', 'left'); form_selectable_cell($host['description'], $host['id'], '', 'left'); From fa9a1d16aa2aa6c5ff6a6f4f9f0fb7b923c80fa2 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 16:09:29 -0400 Subject: [PATCH 18/26] Size the detail dialog to its content instead of forcing overflow The #monitorPanelDialog overflow override (higher specificity than the theme's .ui-dialog .ui-dialog-content) turned the content into a fixed-height scroll box that fought the theme's overflow:visible and squashed the panels. Use height:auto (capped by maxHeight) and stop overriding overflow so the dialog sizes to its content and the theme governs scrolling. --- css/monitor.css | 17 ++++------------- js/monitor.js | 2 +- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/css/monitor.css b/css/monitor.css index 4f198f4..8494110 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -616,22 +616,13 @@ box-sizing: border-box; } -/* Keep the Actions dialog body scrollable when the panels are taller than the - dialog, and force the content box to always fill the dialog width. Some - theme/runtime combinations otherwise let the jQuery UI content box shrink to - its min-content width and drift to one side, squashing the panels into a - narrow strip. The !important/auto width and block display defeat any - inherited inline-block, float or flex-item sizing on the content box. */ +/* Don't override the content box's overflow: the dialog sizes to its content + (height: auto in openHostPanel), so forcing overflow here created a scroll + box that fought the theme's own .ui-dialog .ui-dialog-content rules and + squashed the panels. Keep border-box only so padding can't overflow. */ #monitorPanelDialog.ui-dialog-content, #monitorPanelDialog { box-sizing: border-box; - display: block; - float: none; - width: auto !important; - min-width: 0; - flex: 1 1 auto; - overflow-y: auto; - overflow-x: hidden; } .monitorPanel { diff --git a/js/monitor.js b/js/monitor.js index 6449556..634e8ed 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -129,7 +129,7 @@ function openHostPanel(id) { dlg.dialog({ title: monitorMessages.deviceDetails || 'Device Details', width: 720, - height: Math.min(760, maxHeight), + height: 'auto', maxHeight: maxHeight, modal: true, closeOnEscape: true, From c2848cea72714d98bb0e71fdb407d8279e1b1dd1 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 16:14:09 -0400 Subject: [PATCH 19/26] Anchor the detail dialog 300px from the top instead of centering --- js/monitor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/monitor.js b/js/monitor.js index 634e8ed..8ee421a 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -133,7 +133,7 @@ function openHostPanel(id) { maxHeight: maxHeight, modal: true, closeOnEscape: true, - position: { my: 'center', at: 'center', of: globalThis }, + position: { my: 'center top', at: 'center top+300', of: globalThis }, open() { // Pause the refresh countdown so the page does not reload underneath // the open dialog. From 72b156f10c06a56f490f53399ab3f169f5b193a3 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 16:18:13 -0400 Subject: [PATCH 20/26] Make the detail panels dialog non-modal --- js/monitor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/monitor.js b/js/monitor.js index 8ee421a..64053d2 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -131,7 +131,7 @@ function openHostPanel(id) { width: 720, height: 'auto', maxHeight: maxHeight, - modal: true, + modal: false, closeOnEscape: true, position: { my: 'center top', at: 'center top+300', of: globalThis }, open() { From e8d49e4d0ae6f1a553d960ca315f7476aa18be08 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 16:21:42 -0400 Subject: [PATCH 21/26] Move Notes out of the List view into the detail dialog The Notes column consumed a lot of horizontal space in List view. Drop it from the table and surface the notes in the Host Information panel of the Device Detail Panels dialog instead (shown only when the device has notes). --- includes/controller.php | 4 ++++ includes/render.php | 7 ------- locales/po/cacti.pot | 14 +++++--------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/includes/controller.php b/includes/controller.php index 5c0c974..f69a09e 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -1516,6 +1516,10 @@ function monitorPanelHostInfo(array $host, array $iclasses): string { $body .= monitorPanelRow(__('Contact', 'monitor'), html_escape(monitorTrim($host['snmp_sysContact']))); } + if (isset($host['notes']) && $host['notes'] != '') { + $body .= monitorPanelRow(__('Notes', 'monitor'), nl2br(html_escape($host['notes']))); + } + $body .= ''; return monitorPanelCard(__('Host Information', 'monitor'), $body, 'fa-server'); diff --git a/includes/render.php b/includes/render.php index 47ab73c..cba77a2 100644 --- a/includes/render.php +++ b/includes/render.php @@ -1237,12 +1237,6 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { 'tip' => __('Administrative monitor message shown while the device is Down or Recovering', 'monitor'), 'align' => 'left' ], - 'notes' => [ - 'display' => __('Notes', 'monitor'), - 'sort' => 'ASC', - 'align' => 'left', - 'tip' => __('Free-form notes recorded for the device', 'monitor') - ], 'availability' => [ 'display' => __('Availability', 'monitor'), 'sort' => 'DESC', @@ -1480,7 +1474,6 @@ function renderHostList(array $host): string { form_selectable_cell($host_avg, $host['id'], '', 'left'); form_selectable_cell($host_warn, $host['id'], '', 'left'); form_selectable_cell($host_admin, $host['id'], '', 'white-space:pre-wrap;text-align:left'); - form_selectable_cell(str_replace(["\n", "\r"], [' ', ''], $host['notes']), $host['id'], '', 'white-space:pre-wrap;text-align:left'); form_selectable_cell(round($host['availability'], 2) . ' %', $host['id'], '', 'right'); form_selectable_cell($host_datefail, $host['id'], '', 'right'); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index e78a6ab..b699a65 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 15:22-0400\n" +"POT-Creation-Date: 2026-10-11 16:21-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -416,6 +416,10 @@ msgstr "" msgid "Contact" msgstr "" +#: includes/controller.php +msgid "Notes" +msgstr "" + #: includes/controller.php msgid "Host Information" msgstr "" @@ -636,14 +640,6 @@ msgstr "" msgid "Administrative monitor message shown while the device is Down or Recovering" msgstr "" -#: includes/render.php -msgid "Notes" -msgstr "" - -#: includes/render.php -msgid "Free-form notes recorded for the device" -msgstr "" - #: includes/render.php msgid "Percentage of successful availability checks over the retained history" msgstr "" From 19531507a40570d06444eb7ab8ceaca84e6cb25a Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 16:28:36 -0400 Subject: [PATCH 22/26] Add device-edit action icon and Devices filter button (realm-gated) List view: add a pen-square action icon linking to host.php?action=edit for users with the host.php (device edit) realm, beside the detail-panels icon. Filter bar: add a Devices button (right of Acknowledge) that opens the Cacti Devices page, shown only to users with the same realm. Also move the detail dialog anchor from top+300 to top+150. --- css/monitor.css | 4 ++++ includes/controller.php | 5 +++++ includes/render.php | 6 +++++- js/monitor.js | 6 +++++- locales/po/cacti.pot | 8 ++++++-- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/css/monitor.css b/css/monitor.css index 8494110..8811642 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -607,6 +607,10 @@ font-size: 16px; } +.monitorActions + .monitorActions { + margin-left: 8px; +} + /* Device detail panels (intropage-style cards) in the Actions dialog */ .monitorPanels { /* Plain block stacking (not flex) so a card can never collapse to its diff --git a/includes/controller.php b/includes/controller.php index f69a09e..92755f8 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -572,6 +572,11 @@ function monitorRenderPrimaryFilterRow(array $dashboards, array $monitor_status, } print '' . PHP_EOL; + + if (api_plugin_user_realm_auth('host.php')) { + print '' . PHP_EOL; + } + print '' . PHP_EOL; print ''; } diff --git a/includes/render.php b/includes/render.php index cba77a2..db773fa 100644 --- a/includes/render.php +++ b/includes/render.php @@ -1362,7 +1362,7 @@ function renderFooterList(int $total_rows, int $rows): string { * @return string */ function renderHostList(array $host): string { - global $criticalities, $iclasses, $thold_counts, $servcheck_counts; + global $criticalities, $iclasses, $thold_counts, $servcheck_counts, $config; if ($host['status'] < 2 || $host['status'] == 5) { $dt = get_timeinstate($host); @@ -1454,6 +1454,10 @@ function renderHostList(array $host): string { $actions = ""; + if (api_plugin_user_realm_auth('host.php')) { + $actions .= ""; + } + form_selectable_cell($actions, $host['id'], '1%', 'left'); form_selectable_cell(filter_value($host['hostname'], '', $url), $host['id'], '', 'left'); form_selectable_cell($host['id'], $host['id'], '', 'left'); diff --git a/js/monitor.js b/js/monitor.js index 64053d2..507695c 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -133,7 +133,7 @@ function openHostPanel(id) { maxHeight: maxHeight, modal: false, closeOnEscape: true, - position: { my: 'center top', at: 'center top+300', of: globalThis }, + position: { my: 'center top', at: 'center top+150', of: globalThis }, open() { // Pause the refresh countdown so the page does not reload underneath // the open dialog. @@ -351,6 +351,10 @@ $(() => { } }); + $('#devices').click(() => { + document.location = urlPath + 'host.php'; + }); + $('#refresh, #view, #rows, #trim, #crit, #grouping, #size, #status, #tree, #site').change(() => { applyFilter('change'); }); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index b699a65..a16b821 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 16:21-0400\n" +"POT-Creation-Date: 2026-10-11 16:27-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -123,6 +123,10 @@ msgstr "" msgid "%s Alerts for downed Devices" msgstr "" +#: includes/controller.php +msgid "Go to the Devices management page" +msgstr "" + #: includes/controller.php includes/render.php poller_functions.php setup.php msgid "Criticality" msgstr "" @@ -241,7 +245,7 @@ msgstr "" msgid "Dashboard '%s' could not be Saved!" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Edit Device" msgstr "" From 543f20eed7c3174410f5134589ae8e262c71de60 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 16:30:50 -0400 Subject: [PATCH 23/26] Anchor the detail dialog at top+80 --- js/monitor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/monitor.js b/js/monitor.js index 507695c..18424f2 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -133,7 +133,7 @@ function openHostPanel(id) { maxHeight: maxHeight, modal: false, closeOnEscape: true, - position: { my: 'center top', at: 'center top+150', of: globalThis }, + position: { my: 'center top', at: 'center top+80', of: globalThis }, open() { // Pause the refresh countdown so the page does not reload underneath // the open dialog. From 5eedbaa241c994fdffb26db4733fede1c5376baa Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 16:52:46 -0400 Subject: [PATCH 24/26] List view: replace Hostname column with a Graphs action glyph Remove the Hostname column and add an orange fas fa-chart-line Graphs glyph (.monitorGraph, matching HMIB's colour) to the Actions column, linking to the device's graphs where the hostname hyperlink used to point. --- css/dark.css | 4 ++++ css/deepness.css | 4 ++++ css/midwinter.css | 4 ++++ css/monitor.css | 5 +++++ css/paper-plane.css | 4 ++++ css/sunrise.css | 4 ++++ includes/render.php | 10 ++-------- locales/po/cacti.pot | 10 +++------- 8 files changed, 30 insertions(+), 15 deletions(-) diff --git a/css/dark.css b/css/dark.css index ad186f1..cc82704 100644 --- a/css/dark.css +++ b/css/dark.css @@ -389,3 +389,7 @@ .monitorLog { white-space: pre-wrap !important; } + +.monitorGraph { + color: #d6a13a; +} \ No newline at end of file diff --git a/css/deepness.css b/css/deepness.css index bd4f843..4f25214 100644 --- a/css/deepness.css +++ b/css/deepness.css @@ -399,3 +399,7 @@ .monitorLog { white-space: pre-wrap !important; } + +.monitorGraph { + color: #d6a13a; +} \ No newline at end of file diff --git a/css/midwinter.css b/css/midwinter.css index 9cdea47..f7441ea 100644 --- a/css/midwinter.css +++ b/css/midwinter.css @@ -361,3 +361,7 @@ .monitorLog { white-space: pre-wrap !important; } + +.monitorGraph { + color: #d6a13a; +} \ No newline at end of file diff --git a/css/monitor.css b/css/monitor.css index 8811642..9bc522c 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -611,6 +611,11 @@ margin-left: 8px; } +/* Graphs action glyph (orange), matching the convention used by HMIB. */ +.monitorGraph { + color: #cc6600; +} + /* Device detail panels (intropage-style cards) in the Actions dialog */ .monitorPanels { /* Plain block stacking (not flex) so a card can never collapse to its diff --git a/css/paper-plane.css b/css/paper-plane.css index 48843f9..d95279c 100644 --- a/css/paper-plane.css +++ b/css/paper-plane.css @@ -366,3 +366,7 @@ .monitorLog { white-space: pre-wrap !important; } + +.monitorGraph { + color: #d6a13a; +} \ No newline at end of file diff --git a/css/sunrise.css b/css/sunrise.css index 78b499c..532dbb7 100644 --- a/css/sunrise.css +++ b/css/sunrise.css @@ -385,3 +385,7 @@ .monitorLog { white-space: pre-wrap !important; } + +.monitorGraph { + color: #d6a13a; +} \ No newline at end of file diff --git a/includes/render.php b/includes/render.php index db773fa..15aadf7 100644 --- a/includes/render.php +++ b/includes/render.php @@ -1169,11 +1169,6 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { 'align' => 'left', 'tip' => __('Open the device detail panels', 'monitor') ], - 'hostname' => [ - 'display' => __('Hostname', 'monitor'), - 'sort' => 'ASC', - 'align' => 'left', 'tip' => __('Hostname of device', 'monitor') - ], 'id' => [ 'display' => __('ID', 'monitor'), 'sort' => 'ASC', @@ -1450,16 +1445,15 @@ function renderHostList(array $host): string { print ""; - $url = $host['anchor']; - $actions = ""; + $actions .= ""; + if (api_plugin_user_realm_auth('host.php')) { $actions .= ""; } form_selectable_cell($actions, $host['id'], '1%', 'left'); - form_selectable_cell(filter_value($host['hostname'], '', $url), $host['id'], '', 'left'); form_selectable_cell($host['id'], $host['id'], '', 'left'); form_selectable_cell($host['description'], $host['id'], '', 'left'); form_selectable_cell($host['site_name'], $host['id'], '', 'left'); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index a16b821..2494838 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 16:27-0400\n" +"POT-Creation-Date: 2026-10-11 16:52-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -249,7 +249,7 @@ msgstr "" msgid "Edit Device" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "View Graphs" msgstr "" @@ -396,7 +396,7 @@ msgstr "" msgid "Description" msgstr "" -#: includes/controller.php includes/render.php poller_functions.php +#: includes/controller.php poller_functions.php msgid "Hostname" msgstr "" @@ -576,10 +576,6 @@ msgstr "" msgid "Open the device detail panels" msgstr "" -#: includes/render.php -msgid "Hostname of device" -msgstr "" - #: includes/render.php msgid "ID" msgstr "" From be18deb303729fd9e5978b9bdca2c9aea94f1c72 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 17:04:51 -0400 Subject: [PATCH 25/26] List columns + filter layout refinements - List columns reordered to Description, Location, Site, ID; add a Location column; Averages, Length in Status, Last Fail are now the last three columns, all right-aligned. - rfilter Search now also matches the device location. - Filter: move the Events multiselect to the second row (right of Search) and the Rows dropdown to the second row (left of Refresh); rename that dropdown from Devices to Rows. - Add a renderWhereJoin test for the description/location rfilter search. --- includes/controller.php | 14 +++++---- includes/functions.php | 2 +- includes/render.php | 49 +++++++++++++++++------------- locales/po/cacti.pot | 54 +++++++++++++++++++--------------- tests/Unit/DbFunctionsTest.php | 11 +++++++ 5 files changed, 79 insertions(+), 51 deletions(-) diff --git a/includes/controller.php b/includes/controller.php index 92755f8..a215689 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -551,15 +551,9 @@ function monitorGetZoomDropdownState(bool &$dozoombgndcolor): array { function monitorRenderPrimaryFilterRow(array $dashboards, array $monitor_status, array $monitor_events, array $monitor_view_type, array $monitor_grouping, array $item_rows, mixed $mon_zoom_status): void { drawFilterDropdown('dashboard', __('Layout', 'monitor'), $dashboards); drawFilterDropdown('status', __('Show', 'monitor'), $monitor_status, $mon_zoom_status); - drawFilterMultiselect('events', __('Events', 'monitor'), $monitor_events, monitorGetSelectedEvents()); drawFilterDropdown('view', __('View', 'monitor'), $monitor_view_type); drawFilterDropdown('grouping', __('Grouping', 'monitor'), $monitor_grouping); - // The Rows filter only applies to the List view; other views show all devices. - if (get_request_var('view') == 'list') { - drawFilterDropdown('rows', __('Devices', 'monitor'), $item_rows); - } - print '' . PHP_EOL; print '' . PHP_EOL; print '' . PHP_EOL; @@ -593,6 +587,8 @@ function monitorRenderPrimaryFilterRow(array $dashboards, array $monitor_status, * @return void */ function monitorRenderGroupingDropdowns(array $classes, array $criticalities, array $monitor_trim, array $page_refresh_interval, mixed $mon_zoom_size): void { + global $item_rows; + drawFilterDropdown('crit', __('Criticality', 'monitor'), $criticalities); if (get_request_var('view') != 'list') { @@ -665,6 +661,11 @@ function monitorRenderGroupingDropdowns(array $classes, array $criticalities, ar drawFilterMultiselect('template', __('Template', 'monitor'), $templates, monitorGetSelectedTemplates(), __('Templates', 'monitor')); } + // The Rows filter only applies to the List view; other views show all devices. + if (get_request_var('view') == 'list') { + drawFilterDropdown('rows', __('Rows', 'monitor'), $item_rows); + } + drawFilterDropdown('refresh', __('Refresh', 'monitor'), $page_refresh_interval); } @@ -795,6 +796,7 @@ function drawFilterAndStatus(): void { print '' . PHP_EOL; print '' . __('Search', 'monitor') . ''; print ''; + drawFilterMultiselect('events', __('Events', 'monitor'), $monitor_events, monitorGetSelectedEvents()); monitorRenderGroupingDropdowns($classes, $criticalities, $monitor_trim, $page_refresh_interval, $mon_zoom_size); monitorRenderHiddenFilterInputs(); diff --git a/includes/functions.php b/includes/functions.php index b46cbb1..d3f0244 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -344,7 +344,7 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { } if (get_request_var('rfilter') != '') { - $awhere .= ($awhere == '' ? '' : ' AND ') . " h.description RLIKE '" . get_request_var('rfilter') . "'"; + $awhere .= ($awhere == '' ? '' : ' AND ') . " (h.description RLIKE '" . get_request_var('rfilter') . "' OR h.location RLIKE '" . get_request_var('rfilter') . "')"; } if (get_request_var('grouping') == 'tree') { diff --git a/includes/render.php b/includes/render.php index 15aadf7..e6f2f06 100644 --- a/includes/render.php +++ b/includes/render.php @@ -1169,24 +1169,30 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { 'align' => 'left', 'tip' => __('Open the device detail panels', 'monitor') ], - 'id' => [ - 'display' => __('ID', 'monitor'), - 'sort' => 'ASC', - 'align' => 'left', - 'tip' => __('Cacti internal device id', 'monitor') - ], 'description' => [ 'display' => __('Description', 'monitor'), 'sort' => 'ASC', 'align' => 'left', 'tip' => __('Device description as set on the Devices page', 'monitor') ], + 'location' => [ + 'display' => __('Location', 'monitor'), + 'sort' => 'ASC', + 'align' => 'left', + 'tip' => __('Physical location recorded for the device', 'monitor') + ], 'site_name' => [ 'display' => __('Site', 'monitor'), 'sort' => 'ASC', 'align' => 'left', 'tip' => __('Site the device is assigned to', 'monitor') ], + 'id' => [ + 'display' => __('ID', 'monitor'), + 'sort' => 'ASC', + 'align' => 'left', + 'tip' => __('Cacti internal device id', 'monitor') + ], 'monitor_criticality' => [ 'display' => __('Criticality', 'monitor'), 'sort' => 'ASC', @@ -1208,18 +1214,6 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { 'align' => 'center', 'tip' => __('Failed / Total service checks (servcheck plugin) for the device; click to toggle the Failed Svc event filter', 'monitor') ], - 'instate' => [ - 'display' => __('Length in Status', 'monitor'), - 'sort' => 'ASC', - 'align' => 'center', - 'tip' => __('How long the device has been in its current status', 'monitor') - ], - 'avg_time' => [ - 'display' => __('Averages', 'monitor'), - 'sort' => 'DESC', - 'align' => 'left', - 'tip' => __('Current / average ping response time in milliseconds', 'monitor') - ], 'monitor_warn' => [ 'display' => __('Warning', 'monitor'), 'sort' => 'DESC', @@ -1238,6 +1232,18 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { 'align' => 'right', 'tip' => __('Percentage of successful availability checks over the retained history', 'monitor') ], + 'avg_time' => [ + 'display' => __('Averages', 'monitor'), + 'sort' => 'DESC', + 'align' => 'right', + 'tip' => __('Current / average ping response time in milliseconds', 'monitor') + ], + 'instate' => [ + 'display' => __('Length in Status', 'monitor'), + 'sort' => 'ASC', + 'align' => 'right', + 'tip' => __('How long the device has been in its current status', 'monitor') + ], 'status_fail_date' => [ 'display' => __('Last Fail', 'monitor'), 'sort' => 'DESC', @@ -1454,9 +1460,10 @@ function renderHostList(array $host): string { } form_selectable_cell($actions, $host['id'], '1%', 'left'); - form_selectable_cell($host['id'], $host['id'], '', 'left'); form_selectable_cell($host['description'], $host['id'], '', 'left'); + form_selectable_cell($host['location'], $host['id'], '', 'left'); form_selectable_cell($host['site_name'], $host['id'], '', 'left'); + form_selectable_cell($host['id'], $host['id'], '', 'left'); form_selectable_cell($host_crit, $host['id'], '', 'left'); form_selectable_cell($events_cell, $host['id'], '', 'center'); @@ -1468,11 +1475,11 @@ function renderHostList(array $host): string { form_selectable_cell($servcheck_cell, $host['id'], '', 'center'); } - form_selectable_cell($dt, $host['id'], '', 'center'); - form_selectable_cell($host_avg, $host['id'], '', 'left'); form_selectable_cell($host_warn, $host['id'], '', 'left'); form_selectable_cell($host_admin, $host['id'], '', 'white-space:pre-wrap;text-align:left'); form_selectable_cell(round($host['availability'], 2) . ' %', $host['id'], '', 'right'); + form_selectable_cell($host_avg, $host['id'], '', 'right'); + form_selectable_cell($dt, $host['id'], '', 'right'); form_selectable_cell($host_datefail, $host['id'], '', 'right'); form_end_row(); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 2494838..a5a0285 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-11 16:52-0400\n" +"POT-Creation-Date: 2026-10-11 17:03-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -49,10 +49,6 @@ msgstr "" msgid "Show" msgstr "" -#: includes/controller.php includes/render.php -msgid "Events" -msgstr "" - #: includes/controller.php setup.php msgid "View" msgstr "" @@ -61,10 +57,6 @@ msgstr "" msgid "Grouping" msgstr "" -#: includes/controller.php includes/render.php -msgid "Devices" -msgstr "" - #: includes/controller.php msgid "Refresh" msgstr "" @@ -123,6 +115,10 @@ msgstr "" msgid "%s Alerts for downed Devices" msgstr "" +#: includes/controller.php includes/render.php +msgid "Devices" +msgstr "" + #: includes/controller.php msgid "Go to the Devices management page" msgstr "" @@ -175,6 +171,10 @@ msgstr "" msgid "Templates" msgstr "" +#: includes/controller.php +msgid "Rows" +msgstr "" + #: includes/controller.php msgid " [ Filter Settings Saved ]" msgstr "" @@ -209,6 +209,10 @@ msgstr "" msgid "Search" msgstr "" +#: includes/controller.php includes/render.php +msgid "Events" +msgstr "" + #: includes/controller.php msgid "Mute" msgstr "" @@ -412,7 +416,7 @@ msgstr "" msgid "Device Template" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Location" msgstr "" @@ -577,19 +581,23 @@ msgid "Open the device detail panels" msgstr "" #: includes/render.php -msgid "ID" +msgid "Device description as set on the Devices page" msgstr "" #: includes/render.php -msgid "Cacti internal device id" +msgid "Physical location recorded for the device" msgstr "" #: includes/render.php -msgid "Device description as set on the Devices page" +msgid "Site the device is assigned to" msgstr "" #: includes/render.php -msgid "Site the device is assigned to" +msgid "ID" +msgstr "" + +#: includes/render.php +msgid "Cacti internal device id" msgstr "" #: includes/render.php @@ -609,39 +617,39 @@ msgid "Failed / Total service checks (servcheck plugin) for the device; click to msgstr "" #: includes/render.php -msgid "Length in Status" +msgid "Warning" msgstr "" #: includes/render.php -msgid "How long the device has been in its current status" +msgid "Ping latency Warning / Alert thresholds (ms) configured for this device; a notification is raised when the response time crosses them" msgstr "" #: includes/render.php -msgid "Averages" +msgid "Admin" msgstr "" #: includes/render.php -msgid "Current / average ping response time in milliseconds" +msgid "Administrative monitor message shown while the device is Down or Recovering" msgstr "" #: includes/render.php -msgid "Warning" +msgid "Percentage of successful availability checks over the retained history" msgstr "" #: includes/render.php -msgid "Ping latency Warning / Alert thresholds (ms) configured for this device; a notification is raised when the response time crosses them" +msgid "Averages" msgstr "" #: includes/render.php -msgid "Admin" +msgid "Current / average ping response time in milliseconds" msgstr "" #: includes/render.php -msgid "Administrative monitor message shown while the device is Down or Recovering" +msgid "Length in Status" msgstr "" #: includes/render.php -msgid "Percentage of successful availability checks over the retained history" +msgid "How long the device has been in its current status" msgstr "" #: includes/render.php diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index 94bcb09..851da59 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -296,6 +296,17 @@ ->and($where)->toContain('plugin_servcheck_test'); }); +it('searches both description and location for the rfilter term', function () { + test_set_request(['status' => '-2', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => 'dc1', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.description RLIKE') + ->and($where)->toContain('h.location RLIKE'); +}); + it('filters by selected device templates for the template grouping', function () { test_set_request(['status' => '-1', 'crit' => '-1', 'grouping' => 'template', 'template' => '3,5', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); From d216aea15689fb72910afea41c9c8ae75a39d873 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 11 Oct 2026 18:52:10 -0400 Subject: [PATCH 26/26] Monitor: realm-gate service-check scope/column, persist events+template, legacy status reset, failed_svc independent, defer list counts, pill keyboard a11y, PHPDoc/changelog fixes --- CHANGELOG.md | 4 ++-- includes/controller.php | 11 +++++++++++ includes/functions.php | 15 +++++++++++---- includes/render.php | 23 +++++++++++++---------- js/monitor.js | 12 ++++++++++++ monitor.php | 20 +++++++++++++++++--- tests/Unit/DbFunctionsTest.php | 18 ++++++++++++++++++ 7 files changed, 84 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dba51ef..dcaaa8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ * bug: Size grouping-card tiles to the longest visible host description so long hostnames no longer overlap neighbouring tiles * bug: Center the device Actions dialog, make it taller and scrollable so panel text is not cut off, and pause the page refresh countdown while it is open * bug: Remove the stray 'Monitored Devices' box header on the Site grouping so it matches the other groupings -* feature: Add clickable List-view pills — Thresholds (breached / triggered / total), Service Checks (failed / total), a Ping latency pill beside Status, and a Criticality pill (including a 'Not Set' pill for unset devices) — that toggle the matching filter on and off -* feature: Add Status filter options for Warning Ping, Alert Ping and Service Check Failed, and make the Criticality 'None' selection filter to devices with no criticality set +* feature: Add clickable List-view pills — Thresholds (breached / triggered / total), Service Checks (failed / total), Events, and a Criticality pill (including a 'Not Set' pill for unset devices) — that toggle the matching filter on and off +* feature: Offer Warning Ping, Alert Ping and Service Check Failed as additive selections in the multi-select Events filter, and make the Criticality 'None' selection filter to devices with no criticality set * bug: Give the busiest grouping cards (many devices) a full row of their own so their tiles span the whole page instead of being squeezed to part of the width beside smaller cards * bug: Remove the filter status/criticality summary footer and the "Remember to first select eligible Devices" prompt from the Monitor board * bug: Lay the grouping cards out with a wrapping flexbox instead of a CSS grid so they always stretch to fill the full horizontal space; inside Cacti's shrink-to-fit html_start_box div the grid mis-counted its tracks and clipped the cards to roughly half the page width diff --git a/includes/controller.php b/includes/controller.php index a215689..8c2d431 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -969,6 +969,8 @@ function saveSettings(): void { 'size' => get_request_var('size'), 'trim' => get_request_var('trim'), 'status' => get_request_var('status'), + 'events' => implode(',', monitorGetSelectedEvents()), + 'template' => implode(',', monitorGetSelectedTemplates()), 'tree' => get_request_var('tree'), 'site' => get_request_var('site'), ]; @@ -1123,6 +1125,15 @@ function validateRequestVars(bool $force = false): void { ]; validate_store_request_vars($filters, 'sess_monitor'); + + // Normalize any legacy or stale Status scope. Older releases stored + // availability-status codes (0, 1, 2, ...) in this field; those are no longer + // valid scopes, so map anything outside the current scope set to -1 (All + // Monitored Devices) rather than letting it fall through and silently broaden + // a saved layout's event scope on upgrade. + if (!in_array((int) get_request_var('status'), [-1, -2, -3, -4, -5], true)) { + set_request_var('status', '-1'); + } // ================= input validation ================= } diff --git a/includes/functions.php b/includes/functions.php index d3f0244..9f47b48 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -160,7 +160,8 @@ function checkServchecks(): array { ); } -/** * Per-host threshold counts for the list-view Thresholds pill. +/** + * Per-host threshold counts for the list-view Thresholds pill. * * @return array Map of host_id => ['total' => int, 'triggered' => int, * 'breached' => int]. Empty when thold is unavailable. @@ -436,9 +437,11 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { $sql_where = 'WHERE (h.disabled = "" AND h.deleted = "" AND td.id IS NULL)' . $awhere; - } elseif (get_request_var('status') == -5 && db_table_exists('plugin_servcheck_test')) { + } elseif (get_request_var('status') == -5 && api_plugin_is_enabled('servcheck') && api_plugin_user_realm_auth('servcheck_test.php') && db_table_exists('plugin_servcheck_test')) { // Devices without Service Checks (no enabled servcheck test matches the - // device hostname/ipaddress). + // device hostname/ipaddress). Realm-gated server-side: hiding the scope + // option in monitor.php is not authorization, so a user without Service + // Check access cannot submit status=-5 to probe which devices have them. $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; $sql_where = 'WHERE h.disabled = "" @@ -449,7 +452,11 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { AND (sct.hostname = h.hostname OR sct.ipaddress = h.hostname) )' . $awhere; } else { - // All Monitored Devices (default scope, status -1). + // All Monitored Devices (default scope, status -1). validateRequestVars() + // normalizes any legacy availability-status code (0/1/2/...) or other + // out-of-range value saved by an older release to -1 before we get here, + // so this branch only ever serves the -1 scope rather than silently + // broadening a stale status into All Monitored Devices. $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; $sql_where = 'WHERE h.disabled = "" diff --git a/includes/render.php b/includes/render.php index e6f2f06..6870ec4 100644 --- a/includes/render.php +++ b/includes/render.php @@ -1145,10 +1145,13 @@ function monitorHostEvents(array $host): array { } elseif (!empty($host['monitor_warn']) && $host['cur_time'] > $host['monitor_warn']) { $events[] = 'warn_ping'; } + } - if (isset($servcheck_hosts) && array_key_exists($host['id'], $servcheck_hosts)) { - $events[] = 'failed_svc'; - } + // A failing service check is additive and independent of ping availability: + // monitorEventFilterSql('failed_svc') matches the host regardless of its Up/ + // Down state, so a Down+Failed-Svc host must still get the Failed Svc pill. + if (isset($servcheck_hosts) && array_key_exists($host['id'], $servcheck_hosts)) { + $events[] = 'failed_svc'; } return $events; @@ -1257,7 +1260,7 @@ function renderHeaderList(int $total_rows = 0, int $rows = 0): string { unset($display_text['nosort_tholds']); } - if (!api_plugin_is_enabled('servcheck')) { + if (!api_plugin_is_enabled('servcheck') || !api_plugin_user_realm_auth('servcheck_test.php')) { unset($display_text['nosort_servchecks']); } @@ -1382,9 +1385,9 @@ function renderHostList(array $host): string { $crit_level = isset($host['monitor_criticality']) ? (int) $host['monitor_criticality'] : 0; if ($crit_level > 0) { - $host_crit = "" . html_escape($criticalities[$crit_level]) . ''; + $host_crit = "" . html_escape($criticalities[$crit_level]) . ''; } else { - $host_crit = "" . __esc('Not Set', 'monitor') . ''; + $host_crit = "" . __esc('Not Set', 'monitor') . ''; } // Events column: a short pill for every active event (a device can have @@ -1398,7 +1401,7 @@ function renderHostList(array $host): string { foreach ($event_keys as $ekey) { if (isset($meta[$ekey])) { - $pills[] = "" . html_escape($meta[$ekey]['label']) . ''; + $pills[] = "" . html_escape($meta[$ekey]['label']) . ''; } } @@ -1413,7 +1416,7 @@ function renderHostList(array $host): string { if (isset($thold_counts[$host['id']]) && $thold_counts[$host['id']]['total'] > 0) { $tc = $thold_counts[$host['id']]; $tcls = ($tc['triggered'] > 0 || $tc['breached'] > 0) ? 'deviceThreshold' : 'monitorPillMuted'; - $thold_cell = "" . $tc['breached'] . ' / ' . $tc['triggered'] . ' / ' . $tc['total'] . ''; + $thold_cell = "" . $tc['breached'] . ' / ' . $tc['triggered'] . ' / ' . $tc['total'] . ''; } else { $thold_cell = "" . __esc('Not Set', 'monitor') . ''; } @@ -1424,7 +1427,7 @@ function renderHostList(array $host): string { if (isset($servcheck_counts[$host['id']]) && $servcheck_counts[$host['id']]['total'] > 0) { $sc = $servcheck_counts[$host['id']]; $scls = ($sc['failed'] > 0) ? 'deviceServiceCheck' : 'monitorPillMuted'; - $servcheck_cell = "" . $sc['failed'] . ' / ' . $sc['total'] . ''; + $servcheck_cell = "" . $sc['failed'] . ' / ' . $sc['total'] . ''; } else { $servcheck_cell = "" . __esc('Not Set', 'monitor') . ''; } @@ -1471,7 +1474,7 @@ function renderHostList(array $host): string { form_selectable_cell($thold_cell, $host['id'], '', 'center'); } - if (api_plugin_is_enabled('servcheck')) { + if (api_plugin_is_enabled('servcheck') && api_plugin_user_realm_auth('servcheck_test.php')) { form_selectable_cell($servcheck_cell, $host['id'], '', 'center'); } diff --git a/js/monitor.js b/js/monitor.js index 18424f2..1e61c53 100644 --- a/js/monitor.js +++ b/js/monitor.js @@ -195,6 +195,7 @@ function saveFilter() { tree: $('#tree').val(), site: $('#site').val(), template: serializeMultiVal($('#template').val()), + events: serializeMultiVal($('#events').val()), view: $('#view').val(), rows: $('#rows').val(), crit: $('#crit').val(), @@ -224,6 +225,7 @@ function saveNewDashboard(action) { tree: $('#tree').val(), site: $('#site').val(), template: serializeMultiVal($('#template').val()), + events: serializeMultiVal($('#events').val()), view: $('#view').val(), rows: $('#rows').val(), crit: $('#crit').val(), @@ -438,6 +440,16 @@ $(() => { } }); + // The filter pills carry role="button" and tabindex="0"; activate them from + // the keyboard (Enter / Space) by reusing the click handler above, so they + // are operable without a mouse. + $(document).on('keydown', '.monitorFilterPill', (event) => { + if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar' || event.keyCode === 13 || event.keyCode === 32) { + event.preventDefault(); + $(event.currentTarget).trigger('click'); + } + }); + if ($.fn.sortable) { $('.monitorGroupGrid').sortable({ items: '.monitorGroupCard', diff --git a/monitor.php b/monitor.php index a851f54..a7b432e 100644 --- a/monitor.php +++ b/monitor.php @@ -102,9 +102,15 @@ 'triggered_ma' => __('Triggered (M/A)', 'monitor'), 'warn_ping' => __('Warn Ping', 'monitor'), 'alert_ping' => __('Alert Ping', 'monitor'), - 'failed_svc' => __('Failed Svc', 'monitor'), ]; +// Only offer the Failed Svc event when the servcheck plugin is installed and +// the user has access to it; otherwise the service-check scope is always empty +// and the option would only ever produce an empty result. +if (api_plugin_is_enabled('servcheck') && api_plugin_user_realm_auth('servcheck_test.php')) { + $monitor_events['failed_svc'] = __('Failed Svc', 'monitor'); +} + $monitor_view_type = [ 'default' => __('Default', 'monitor'), 'list' => __('List', 'monitor'), @@ -154,9 +160,17 @@ $servcheck_hosts = checkServchecks(); -$thold_counts = monitorTholdCounts(); +// The per-host Thresholds / Service Checks pill counts are only rendered in the +// List view, so skip these two full grouped scans on every other request +// (tiles/cards, tooltip and dialog AJAX, saves, and card-order updates). +$thold_counts = []; +$servcheck_counts = []; -$servcheck_counts = monitorServcheckCounts(); +if (get_request_var('view') == 'list') { + $thold_counts = monitorTholdCounts(); + + $servcheck_counts = monitorServcheckCounts(); +} switch (get_nfilter_request_var('action')) { case 'ajax_status': diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index 851da59..b7f918f 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -284,6 +284,8 @@ }); it('selects the without-service-checks scope for status -5', function () { + monitor_test_mock_db('api_plugin_is_enabled', 'servcheck', true); + monitor_test_mock_db('api_plugin_user_realm_auth', 'servcheck_test.php', true); monitor_test_mock_db('db_table_exists', 'plugin_servcheck_test', true); test_set_request(['status' => '-5', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); @@ -296,6 +298,22 @@ ->and($where)->toContain('plugin_servcheck_test'); }); +it('ignores the without-service-checks scope without the servcheck realm', function () { + monitor_test_reset_db_mocks(); + monitor_test_mock_db('api_plugin_is_enabled', 'servcheck', true); + monitor_test_mock_db('db_table_exists', 'plugin_servcheck_test', true); + + test_set_request(['status' => '-5', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => '', 'tree' => '-1', 'site' => '-1']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + // Without servcheck_test.php realm access the -5 scope must not run its + // query; it falls back to the default All Monitored Devices scope. + expect($where)->not->toContain('plugin_servcheck_test'); +}); + it('searches both description and location for the rfilter term', function () { test_set_request(['status' => '-2', 'crit' => '-1', 'grouping' => 'default', 'rfilter' => 'dc1', 'tree' => '-1', 'site' => '-1']);