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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d92784..dcaaa8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ --- 3.0 --- +* 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 +* 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 +* 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 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 6c4bd7e..9bc522c 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -29,6 +29,19 @@ 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. 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 { display: flex; flex-wrap: wrap; @@ -79,10 +92,20 @@ } .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; +} + /* * Status legend. Rendered as a wrapping CSS grid whose columns share one width * (seeded from the longest label via --monitor-chip-min) so every chip is equal @@ -200,6 +223,42 @@ .monitorStatus.deviceUnmonitored { background-color: #3f6a8a; } .monitorStatus.deviceServiceCheck { background-color: #157f87; } +/* 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; + padding: 2px 8px; + border-radius: 10px; + color: #ffffff; + white-space: nowrap; + font-size: 11px; + line-height: 1.5; + margin: 1px; +} + +.monitorFilterPill { + cursor: pointer; +} + +.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) + -> 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; @@ -548,19 +607,47 @@ font-size: 16px; } +.monitorActions + .monitorActions { + 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 { - 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; +} + +/* 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; } .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; @@ -643,34 +730,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. */ .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; } -} +/* 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"], +.monitorGroupCard[data-span="4"] { flex-basis: 100%; } .monitorGroupCard .monitorPanelBody { padding: 6px; 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/controller.php b/includes/controller.php index 7bd9501..8c2d431 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', ''); @@ -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. * @@ -460,6 +389,95 @@ 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 $countText Noun shown in the 'N Selected' widget label. + * + * @return 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; + + 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))); +} + +/** + * Resolve the selected, validated Device Template tokens from the request. + * + * Templates are a multi-select (checkbox) filter like Events. Tokens are + * host_template_id values; 0 represents Non-Templated devices. Legacy + * single-select sentinels (-1 All) are dropped so an empty result means + * 'all templates'. + * + * @return array List of valid, non-negative template id tokens. + */ +function monitorGetSelectedTemplates(): array { + $raw = get_nfilter_request_var('template'); + + if (is_array($raw)) { + $keys = $raw; + } elseif ($raw === '' || $raw === null) { + return []; + } else { + $keys = explode(',', (string) $raw); + } + + $keys = array_filter(array_map('trim', $keys), static function ($k) { + return ctype_digit((string) $k); + }); + + return array_values(array_map('strval', array_map('intval', $keys))); +} + /** * Build dashboard dropdown option map for current user context. * @@ -530,17 +548,12 @@ 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); 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; @@ -553,6 +566,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 ''; } @@ -569,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') { @@ -633,12 +653,17 @@ function monitorRenderGroupingDropdowns(array $classes, array $criticalities, ar ); if (cacti_sizeof($templates_allowed)) { - $templates_prefix = [-1 => __('All Templates', 'monitor')]; - $templates_suffix = [-2 => __('Non-Templated Devices', 'monitor')]; - $templates = $templates_prefix + $templates_allowed + $templates_suffix; + // 0 = Non-Templated devices (host_template_id = 0). No selection + // means all templates, so there is no 'All' checkbox. + $templates = [0 => __('Non-Templated Devices', 'monitor')] + $templates_allowed; } - drawFilterDropdown('template', __('Template', 'monitor'), $templates); + 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); @@ -659,7 +684,7 @@ function monitorRenderHiddenFilterInputs(): void { } if (get_request_var('grouping') != 'template') { - print '' . PHP_EOL; + print '' . PHP_EOL; } if (get_request_var('view') == 'list') { @@ -740,7 +765,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; @@ -757,6 +782,7 @@ function drawFilterAndStatus(): void { monitorRenderPrimaryFilterRow( monitorGetDashboardOptions(), $monitor_status, + $monitor_events, $monitor_view_type, $monitor_grouping, $item_rows, @@ -770,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(); @@ -908,6 +935,14 @@ 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 'template': + set_user_setting('monitor_template', implode(',', monitorGetSelectedTemplates())); + break; case 'tree': set_user_setting('monitor_tree', get_request_var('tree')); @@ -934,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'), ]; @@ -1045,6 +1082,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, @@ -1056,9 +1099,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, @@ -1081,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 ================= } @@ -1481,6 +1534,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/functions.php b/includes/functions.php index 5bd6b9f..9f47b48 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -160,6 +160,92 @@ 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. * @@ -182,6 +268,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. * @@ -195,6 +329,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 = ''; } @@ -208,7 +345,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') { @@ -237,63 +374,98 @@ function renderWhereJoin(string &$sql_where, string &$sql_join): void { } } - if (!empty($awhere)) { - $awhere = ' AND ' . $awhere; + // 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) . ')'; + } } - 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'; + // Additive Events filter (OR-combined) from the multi-select. + $events_raw = get_nfilter_request_var('events'); - $servcheck_where = (isset($servcheck_hosts) && cacti_sizeof($servcheck_hosts)) ? ' - OR h.id IN (' . implode(',', array_map('intval', $servcheck_hosts)) . ')' : ''; + if (is_array($events_raw)) { + $events = $events_raw; + } elseif ($events_raw === '' || $events_raw === null) { + $events = []; + } else { + $events = array_map('trim', explode(',', (string) $events_raw)); + } - $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') == -1) { - $sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id'; + $event_sql = monitorEventFilterSql($events); - $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 ($event_sql !== '') { + $awhere .= ($awhere != '' ? ' AND ' : '') . $event_sql; + } + + if (!empty($awhere)) { + $awhere = ' AND ' . $awhere; + } + + 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; + } 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). 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 = "" + 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). 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 = "" + 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 8a4d4c2..6870ec4 100644 --- a/includes/render.php +++ b/includes/render.php @@ -247,11 +247,16 @@ 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 = "
"; + // 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); @@ -259,7 +264,7 @@ function monitorRenderGroupCards(string $grouping, array $groups, int $maxlen): $body .= '
'; - $out .= "
+ $out .= "
" . html_escape($group['label']) . "
$body
"; @@ -771,6 +776,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); } @@ -927,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']; @@ -955,14 +970,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 = ""; @@ -1080,6 +1095,68 @@ 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'; + } + } + + // 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; +} + /** * Render header and sortable column bar for list view. * @@ -1092,77 +1169,101 @@ 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' => [ - 'display' => __('Hostname', 'monitor'), - 'sort' => 'ASC', - 'align' => 'left', 'tip' => __('Hostname of device', 'monitor') - ], - 'id' => [ - 'display' => __('ID', 'monitor'), - 'sort' => 'ASC', - 'align' => 'left' - ], 'description' => [ 'display' => __('Description', 'monitor'), 'sort' => 'ASC', - 'align' => 'left' + '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' + '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', - 'align' => 'left' + 'align' => 'left', + 'tip' => __('Monitoring criticality assigned to the device', 'monitor') ], - 'status' => [ - 'display' => __('Status', 'monitor'), - 'sort' => 'DESC', - 'align' => 'center' + 'nosort_events' => [ + 'display' => __('Events', 'monitor'), + 'align' => 'center', + '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') ], - 'instate' => [ - 'display' => __('Length in Status', 'monitor'), - 'sort' => 'ASC', - 'align' => 'center' + 'nosort_tholds' => [ + 'display' => __('Thresholds', 'monitor'), + 'align' => 'center', + 'tip' => __('Breached / Triggered / Total thresholds (thold plugin) on the device; click to toggle the Triggered event filter', 'monitor') ], - 'avg_time' => [ - 'display' => __('Averages', 'monitor'), - 'sort' => 'DESC', - 'align' => 'left' + 'nosort_servchecks' => [ + 'display' => __('Service Checks', 'monitor'), + 'align' => 'center', + 'tip' => __('Failed / Total service checks (servcheck plugin) for the device; click to toggle the Failed Svc event filter', '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'), - 'align' => 'left' - ], - 'notes' => [ - 'display' => __('Notes', 'monitor'), - 'sort' => 'ASC', + 'tip' => __('Administrative monitor message shown while the device is Down or Recovering', 'monitor'), 'align' => 'left' ], 'availability' => [ 'display' => __('Availability', 'monitor'), 'sort' => 'DESC', - 'align' => 'right' + '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', - 'align' => 'right' + 'align' => 'right', + 'tip' => __('Date and time the device last failed its availability check', 'monitor') ], ]; + // 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') || !api_plugin_user_realm_auth('servcheck_test.php')) { + 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'); @@ -1265,7 +1366,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, $config; if ($host['status'] < 2 || $host['status'] == 5) { $dt = get_timeinstate($host); @@ -1281,10 +1382,54 @@ 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') . ''; + } + + // 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 = []; + + foreach ($event_keys as $ekey) { + if (isset($meta[$ekey])) { + $pills[] = "" . html_escape($meta[$ekey]['label']) . ''; + } + } + + $events_cell = implode(' ', $pills); + } else { + $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. 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'] . ''; + } 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. 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'] . ''; + } else { + $servcheck_cell = "" . __esc('Not Set', 'monitor') . ''; } if ($host['availability_method'] > 0) { @@ -1305,30 +1450,39 @@ function renderHostList(array $host): string { $host_datefail = $host['status_fail_date']; - $iclass = $iclasses[$host['status']]; - $sdisplay = getHostStatusDescription($host['real_status']); - ob_start(); print ""; - $url = $host['anchor']; - $actions = ""; - form_selectable_cell($actions, $host['id'], '1%', 'center'); - form_selectable_cell(filter_value($host['hostname'], '', $url), $host['id'], '', 'left'); - form_selectable_cell($host['id'], $host['id'], '', 'left'); + $actions .= ""; + + if (api_plugin_user_realm_auth('host.php')) { + $actions .= ""; + } + + form_selectable_cell($actions, $host['id'], '1%', '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("" . html_escape($sdisplay) . '', $host['id'], '', 'center'); - form_selectable_cell($dt, $host['id'], '', 'center'); - form_selectable_cell($host_avg, $host['id'], '', 'left'); + form_selectable_cell($events_cell, $host['id'], '', 'center'); + + if (api_plugin_is_enabled('thold')) { + form_selectable_cell($thold_cell, $host['id'], '', 'center'); + } + + if (api_plugin_is_enabled('servcheck') && api_plugin_user_realm_auth('servcheck_test.php')) { + form_selectable_cell($servcheck_cell, $host['id'], '', 'center'); + } + 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_avg, $host['id'], '', 'right'); + form_selectable_cell($dt, $host['id'], '', 'right'); form_selectable_cell($host_datefail, $host['id'], '', 'right'); form_end_row(); @@ -1405,11 +1559,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/js/monitor.js b/js/monitor.js index c803420..1e61c53 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(() => { @@ -113,12 +124,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, - modal: true, - position: { at: 'center top+60px', of: globalThis } + height: 'auto', + maxHeight: maxHeight, + modal: false, + closeOnEscape: true, + 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. + clearTimeout(myTimer); + }, + close() { + myTimer = setTimeout(timeStep, 1000); + } }); $.get(`monitor.php?action=ajax_hostpanel&header=false&id=${id}`, (data) => { @@ -145,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()}`; @@ -154,6 +177,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); @@ -168,7 +194,8 @@ function saveFilter() { grouping: $('#grouping').val(), tree: $('#tree').val(), site: $('#site').val(), - template: $('#template').val(), + template: serializeMultiVal($('#template').val()), + events: serializeMultiVal($('#events').val()), view: $('#view').val(), rows: $('#rows').val(), crit: $('#crit').val(), @@ -197,7 +224,8 @@ function saveNewDashboard(action) { grouping: $('#grouping').val(), tree: $('#tree').val(), site: $('#site').val(), - template: $('#template').val(), + template: serializeMultiVal($('#template').val()), + events: serializeMultiVal($('#events').val()), view: $('#view').val(), rows: $('#rows').val(), crit: $('#crit').val(), @@ -325,10 +353,29 @@ $(() => { } }); - $('#refresh, #view, #rows, #trim, #crit, #grouping, #size, #status, #tree, #site, #template').change(() => { + $('#devices').click(() => { + document.location = urlPath + 'host.php'; + }); + + $('#refresh, #view, #rows, #trim, #crit, #grouping, #size, #status, #tree, #site').change(() => { + applyFilter('change'); + }); + + // 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, #template').on('change', function () { + if (!$(this).data('select2')) { + applyFilter('change'); + } + }); + $('#dashboard').change(() => { applyFilter('dashboard'); }); @@ -359,6 +406,50 @@ $(() => { 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; + } + + if (sel.prop('multiple')) { + // Additive multi-select (Events): toggle this value in or out of the + // 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) { + current = current.filter((v) => v !== value); + } else { + current = current.concat([value]); + } + + sel.val(current).trigger('change.select2'); + applyFilter(); + } else { + // Single-select (Criticality / Show): toggle on, or clear to default. + // The bound change handler applies the filter. + sel.val(String(sel.val()) === value ? '-1' : value).trigger('change'); + } + }); + + // 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/locales/po/cacti.pot b/locales/po/cacti.pot index 1cc94ec..a5a0285 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 17:03-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 "" @@ -102,8 +45,8 @@ msgstr "" msgid "Layout" msgstr "" -#: includes/controller.php includes/render.php -msgid "Status" +#: includes/controller.php +msgid "Show" msgstr "" #: includes/controller.php setup.php @@ -114,10 +57,6 @@ msgstr "" msgid "Grouping" msgstr "" -#: includes/controller.php includes/render.php -msgid "Devices" -msgstr "" - #: includes/controller.php msgid "Refresh" msgstr "" @@ -176,6 +115,14 @@ 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 "" + #: includes/controller.php includes/render.php poller_functions.php setup.php msgid "Criticality" msgstr "" @@ -212,16 +159,20 @@ msgstr "" msgid "Sites" msgstr "" +#: includes/controller.php includes/render.php +msgid "Non-Templated Devices" +msgstr "" + #: includes/controller.php -msgid "All Templates" +msgid "Template" msgstr "" #: includes/controller.php -msgid "Non-Templated Devices" +msgid "Templates" msgstr "" #: includes/controller.php -msgid "Template" +msgid "Rows" msgstr "" #: includes/controller.php @@ -258,6 +209,10 @@ msgstr "" msgid "Search" msgstr "" +#: includes/controller.php includes/render.php +msgid "Events" +msgstr "" + #: includes/controller.php msgid "Mute" msgstr "" @@ -294,11 +249,11 @@ msgstr "" msgid "Dashboard '%s' could not be Saved!" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Edit Device" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "View Graphs" msgstr "" @@ -408,7 +363,7 @@ msgstr "" msgid "Unspecified" msgstr "" -#: includes/controller.php setup.php +#: includes/controller.php includes/render.php setup.php msgid "None" msgstr "" @@ -445,10 +400,14 @@ msgstr "" msgid "Description" msgstr "" -#: includes/controller.php includes/render.php poller_functions.php +#: includes/controller.php poller_functions.php msgid "Hostname" msgstr "" +#: includes/controller.php +msgid "Status" +msgstr "" + #: includes/controller.php includes/render.php monitor.php msgid "Site" msgstr "" @@ -457,7 +416,7 @@ msgstr "" msgid "Device Template" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Location" msgstr "" @@ -465,6 +424,10 @@ msgstr "" msgid "Contact" msgstr "" +#: includes/controller.php +msgid "Notes" +msgstr "" + #: includes/controller.php msgid "Host Information" msgstr "" @@ -522,7 +485,7 @@ msgstr "" msgid "OK" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Service Checks" msgstr "" @@ -531,11 +494,11 @@ msgstr "" msgid "Threshold %d" msgstr "" -#: includes/controller.php monitor.php +#: includes/controller.php includes/render.php monitor.php msgid "Triggered" msgstr "" -#: includes/controller.php +#: includes/controller.php includes/render.php msgid "Thresholds" msgstr "" @@ -581,6 +544,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 "" @@ -590,7 +581,15 @@ msgid "Open the device detail panels" msgstr "" #: includes/render.php -msgid "Hostname of device" +msgid "Device description as set on the Devices page" +msgstr "" + +#: includes/render.php +msgid "Physical location recorded for the device" +msgstr "" + +#: includes/render.php +msgid "Site the device is assigned to" msgstr "" #: includes/render.php @@ -598,27 +597,99 @@ msgid "ID" msgstr "" #: includes/render.php -msgid "Length in Status" +msgid "Cacti internal device id" msgstr "" #: includes/render.php -msgid "Averages" +msgid "Monitoring criticality assigned to the device" +msgstr "" + +#: includes/render.php +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 "Breached / Triggered / Total thresholds (thold plugin) on the device; click to toggle the Triggered event filter" +msgstr "" + +#: includes/render.php +msgid "Failed / Total service checks (servcheck plugin) for the device; click to toggle the Failed Svc event filter" 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" +msgid "Percentage of successful availability checks over the retained history" +msgstr "" + +#: includes/render.php +msgid "Averages" +msgstr "" + +#: includes/render.php +msgid "Current / average ping response time in milliseconds" +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 "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 "Click to add or remove this event from the filter" +msgstr "" + +#: includes/render.php +msgid "Breached / Triggered / Total thresholds - click to toggle the Triggered event filter" +msgstr "" + +#: includes/render.php +msgid "No thresholds defined for this device" +msgstr "" + +#: includes/render.php +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 "" + +#: monitor.php +msgid "All" msgstr "" #: monitor.php poller_functions.php setup.php @@ -637,14 +708,6 @@ msgstr "" msgid "Mission Critical" msgstr "" -#: monitor.php -msgid "Down" -msgstr "" - -#: monitor.php -msgid "Recovering" -msgstr "" - #: monitor.php msgid "Up" msgstr "" @@ -665,10 +728,6 @@ msgstr "" msgid "Warning Ping" msgstr "" -#: monitor.php -msgid "Alert Ping" -msgstr "" - #: monitor.php msgid "Service Check Failed" msgstr "" @@ -694,31 +753,31 @@ msgid "Zoom" msgstr "" #: monitor.php -msgid "Not Up" +msgid "All Monitored Devices" 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 not Monitored" +msgid "Devices without Thresholds" msgstr "" -#: monitor.php setup.php -msgid "Default" +#: monitor.php +msgid "Devices without Service Checks" msgstr "" #: monitor.php setup.php -msgid "List" +msgid "Default" msgstr "" #: monitor.php setup.php -msgid "Names only" +msgid "List" msgstr "" #: monitor.php setup.php @@ -934,6 +993,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 16d42b3..a7b432e 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'), @@ -79,19 +80,40 @@ ]; $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'), - -4 => __('Devices without Thresholds', 'monitor'), + -2 => __('All Devices', 'monitor'), -3 => __('Devices not Monitored', 'monitor'), + -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 = [ + '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'), ]; +// 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'), - 'names' => __('Names only', 'monitor'), 'tiles' => __('Tiles', 'monitor'), 'tilesadt' => __('Tiles & Time', 'monitor') ]; @@ -138,6 +160,18 @@ $servcheck_hosts = checkServchecks(); +// 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 = []; + +if (get_request_var('view') == 'list') { + $thold_counts = monitorTholdCounts(); + + $servcheck_counts = monitorServcheckCounts(); +} + switch (get_nfilter_request_var('action')) { case 'ajax_status': ajaxStatus(); 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'))"], diff --git a/tests/Unit/DbFunctionsTest.php b/tests/Unit/DbFunctionsTest.php index a49ba5c..b7f918f 100644 --- a/tests/Unit/DbFunctionsTest.php +++ b/tests/Unit/DbFunctionsTest.php @@ -181,28 +181,268 @@ ->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.monitor = "on"') + ->and($where)->toContain('(h.status = 1') + ->and($where)->toContain('(h.status = 2)') + ->and($where)->toContain(' OR '); +}); + +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)->toContain('h.cur_time > h.monitor_warn'); +}); + +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('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.disabled = ""')->and($where)->not->toContain('h.monitor = "on"'); +}); + +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.monitor = ""'); +}); + +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('td.id IS NULL'); +}); + +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']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('NOT EXISTS') + ->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']); + + $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']); $where = ''; $join = ''; renderWhereJoin($where, $join); - expect($where)->toContain('h.id IN (5,9)'); + 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']); - unset($GLOBALS['servcheck_hosts']); + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.host_template_id IN (0)'); }); -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('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.id IN'); + 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']); + + $where = ''; + $join = ''; + renderWhereJoin($where, $join); + + expect($where)->toContain('h.monitor = "on"')->and($where)->toContain('td.thold_enabled'); +}); + +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); - unset($GLOBALS['servcheck_hosts']); + $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'); });