";
+ // 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 .= "
";
@@ -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');
});