From 6f7e99876098c803af1f8ceaccc1463ed69fb20b Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 10 Oct 2026 19:41:33 -0400 Subject: [PATCH 1/3] Monitor: flow large grouping cards across page, add Tree grouping cards, fix down-group header contrast - Large grouping cards now span multiple grid columns (data-span) so a device template with many devices flows across the page instead of forming one tall, narrow column that scrolls off screen; grid switched to auto-fill + dense flow for predictable spanning. - Tree grouping now renders as card panels (one per tree, plus a Non-Tree card) consistent with the Site and Device Template groupings, instead of the legacy section layout. - Grouping card title colours are now !important so Cacti core device-status classes can't override them and leave unreadable text on a coloured header (e.g. a down device template). --- CHANGELOG.md | 3 ++ css/monitor.css | 33 ++++++++++-------- includes/render.php | 83 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abc8f19..8d92784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ * 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 +* bug: Render the Tree grouping as card panels (one per tree, plus a Non-Tree card) like the Site and Device Template groupings, instead of leaving it on the legacy section layout +* bug: Force the grouping card title colours with !important so Cacti core's device-status classes can no longer override them and leave unreadable low-contrast text on a coloured header (e.g. a device template with down devices) * feature: Add a distinct 'Service Check Failed' device status with its own legend chip, tile/list colour and status-filter coverage, driven by a realm-gated, schema-aware servcheck host set * feature: Add an Actions column to the List view that opens a device detail dialog of intropage-style panels (host information, data collection timing, service checks, thresholds, last-hour syslog, and device links) * feature: Add a plugin_servcheck integration so a triggered/failing service check on a device promotes its hover status and adds a hover glyph linking to the failing check, alongside the existing thold and syslog integrations diff --git a/css/monitor.css b/css/monitor.css index ef83dc0..cf0f2ff 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -644,10 +644,12 @@ } /* Grouping card grid: responsive, flows full screen up to ~8 columns and - shrinks to fewer columns as the viewport narrows. */ + shrinks to fewer columns as the viewport narrows. Large groups span several + columns (see data-span) so their devices flow across the page. */ .monitorGroupGrid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-auto-flow: row dense; gap: 10px; align-items: start; width: 100%; @@ -673,16 +675,19 @@ min-height: 60px; } -/* Group card titles coloured by the group's worst device status. */ -.monitorGroupTitle.deviceUp { background-color: #2b8235; color: #ffffff; } -.monitorGroupTitle.deviceDown { background-color: #a12820; color: #ffffff; } -.monitorGroupTitle.deviceDownMuted { background-color: #93302a; color: #ffffff; } -.monitorGroupTitle.deviceRecovering { background-color: #9a6a1a; color: #ffffff; } -.monitorGroupTitle.deviceThreshold { background-color: #a85712; color: #ffffff; } -.monitorGroupTitle.deviceThresholdMuted { background-color: #6a4a9c; color: #ffffff; } -.monitorGroupTitle.deviceWarning { background-color: #7d6f18; color: #ffffff; } -.monitorGroupTitle.deviceAlert { background-color: #86407a; color: #ffffff; } -.monitorGroupTitle.deviceUnknown { background-color: #5f5f57; color: #ffffff; } -.monitorGroupTitle.deviceUnmonitored { background-color: #3f6a8a; color: #ffffff; } -.monitorGroupTitle.deviceServiceCheck { background-color: #157f87; color: #ffffff; } +/* Group card titles coloured by the group's worst device status. Marked + !important so the Cacti core device-status classes (e.g. .deviceDown, which + force a red text/background) cannot override the title's palette and leave + unreadable low-contrast text on the coloured header. */ +.monitorGroupTitle.deviceUp { background-color: #2b8235 !important; color: #ffffff !important; } +.monitorGroupTitle.deviceDown { background-color: #a12820 !important; color: #ffffff !important; } +.monitorGroupTitle.deviceDownMuted { background-color: #93302a !important; color: #ffffff !important; } +.monitorGroupTitle.deviceRecovering { background-color: #9a6a1a !important; color: #ffffff !important; } +.monitorGroupTitle.deviceThreshold { background-color: #a85712 !important; color: #ffffff !important; } +.monitorGroupTitle.deviceThresholdMuted { background-color: #6a4a9c !important; color: #ffffff !important; } +.monitorGroupTitle.deviceWarning { background-color: #7d6f18 !important; color: #ffffff !important; } +.monitorGroupTitle.deviceAlert { background-color: #86407a !important; color: #ffffff !important; } +.monitorGroupTitle.deviceUnknown { background-color: #5f5f57 !important; color: #ffffff !important; } +.monitorGroupTitle.deviceUnmonitored { background-color: #3f6a8a !important; color: #ffffff !important; } +.monitorGroupTitle.deviceServiceCheck { background-color: #157f87 !important; color: #ffffff !important; } diff --git a/includes/render.php b/includes/render.php index 276a223..be1c4d2 100644 --- a/includes/render.php +++ b/includes/render.php @@ -247,6 +247,10 @@ 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. + $span = (int) min(4, max(1, (int) ceil(cacti_sizeof($group['hosts']) / 6))); + $body = "
"; foreach ($group['hosts'] as $host) { @@ -255,7 +259,7 @@ function monitorRenderGroupCards(string $grouping, array $groups, int $maxlen): $body .= '
'; - $out .= "
+ $out .= "
" . html_escape($group['label']) . "
$body
"; @@ -674,6 +678,77 @@ function monitorRenderNonTreeSection(): string { return $result; } +/** + * Render tree grouping as device cards (one card per tree, plus a Non-Tree + * card), mirroring the Site and Device Template card layouts. + * + * @param array $tree_list Allowed trees for the current filter. + * + * @return string + */ +function monitorRenderTreeCards(array $tree_list): string { + $groups = []; + $maxlen = monitorGetTreeRenderMaxLength(); + + if (cacti_sizeof($tree_list)) { + $tree_ids = []; + + foreach ($tree_list as $tree) { + $tree_ids[$tree['id']] = $tree['id']; + } + + $sql_where = ''; + $sql_join = ''; + renderWhereJoin($sql_where, $sql_join); + + $hosts = db_fetch_assoc('SELECT DISTINCT h.*, gt.id AS graph_tree_id, gt.name AS graph_tree_name + FROM host AS h + INNER JOIN graph_tree_items AS gti + ON h.id = gti.host_id + INNER JOIN graph_tree AS gt + ON gt.id = gti.graph_tree_id + ' . $sql_join . ' + ' . $sql_where . ' + AND gti.host_id > 0 + AND gti.graph_tree_id IN (' . implode(',', $tree_ids) . ') + ORDER BY gt.sequence, h.description'); + + if (cacti_sizeof($hosts)) { + [$hosts] = monitorFilterAllowedHosts($hosts); + + foreach ($hosts as $host) { + $gid = 'tree_' . $host['graph_tree_id']; + + if (!isset($groups[$gid])) { + $groups[$gid] = ['label' => $host['graph_tree_name'], 'hosts' => []]; + } + + $groups[$gid]['hosts'][] = $host; + } + } + } + + // Devices not attached to any tree get their own card, unless a specific + // tree is selected in the filter. + if (get_request_var('tree') < 0) { + $nontree = getHostNonTreeArray(); + + if (cacti_sizeof($nontree)) { + [$nontree] = monitorFilterAllowedHosts($nontree); + + if (cacti_sizeof($nontree)) { + $groups['nontree'] = ['label' => __('Non-Tree Devices', 'monitor'), 'hosts' => $nontree]; + } + } + } + + if (!cacti_sizeof($groups)) { + return ''; + } + + return monitorRenderGroupCards('tree', $groups, $maxlen); +} + /** * Render monitor tree grouping view, including tree and non-tree sections. * @@ -694,6 +769,12 @@ function renderTree(): string { $tree_list = []; } + // Card-based views group each tree (and non-tree devices) into panels, the + // same as the Site and Device Template groupings. + if (monitorUseCardLayout()) { + return monitorRenderTreeCards($tree_list); + } + $function = 'renderHeader' . ucfirst(get_request_var('view')); if (function_exists($function)) { From a94fc78e0aed966b15b1863f7c40a7dce413c86a Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 10 Oct 2026 19:58:47 -0400 Subject: [PATCH 2/3] Monitor: address review - cap card spans responsively, size tree trim length from rendered hosts, persist tree card order - CSS: establish the grouping grid as a query container and cap each card's span to the tracks actually available (container queries) so wide cards never force an implicit track / horizontal overflow on narrow layouts. - renderTree cards: derive the default-view trim length from the hosts actually rendered (tree + Non-Tree), matching the Site/Template paths, instead of a tree-only measure that could trim against unrelated devices or collapse to 0. - saveCardOrder: allow the tree grouping and preserve string group ids (tree_/nontree) via a safe character allowlist instead of casting to int, so tree card drags persist. - Share the Non-Tree label through a helper so the string multiset is unchanged. --- css/monitor.css | 17 +++++++++++++++++ includes/controller.php | 11 +++++++++-- includes/render.php | 35 +++++++++++++++++++++++++++++------ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/css/monitor.css b/css/monitor.css index cf0f2ff..6c4bd7e 100644 --- a/css/monitor.css +++ b/css/monitor.css @@ -653,6 +653,23 @@ gap: 10px; align-items: 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; } +} + +@container (min-width: 800px) and (max-width: 1069px) { + .monitorGroupCard[data-span="4"] { grid-column: span 3 !important; } } .monitorGroupCard .monitorPanelBody { diff --git a/includes/controller.php b/includes/controller.php index 86622bb..9c03f38 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -105,7 +105,7 @@ function monitorDashboardOwnedByUser(int $dashboard): bool { function saveCardOrder(): void { $grouping = get_nfilter_request_var('grouping'); - if (!in_array($grouping, ['site', 'template'], true)) { + if (!in_array($grouping, ['site', 'template', 'tree'], true)) { return; } @@ -114,7 +114,14 @@ function saveCardOrder(): void { if (is_array($order)) { foreach ($order as $gid) { - $ids[] = (string) (int) $gid; + // Group ids may be numeric (site/template) or string tokens such as + // "tree_12" and "nontree", so preserve the string while stripping + // anything outside a safe id character set. + $gid = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $gid); + + if ($gid !== '') { + $ids[] = $gid; + } } } diff --git a/includes/render.php b/includes/render.php index be1c4d2..1c52ea1 100644 --- a/includes/render.php +++ b/includes/render.php @@ -627,6 +627,15 @@ function monitorRenderTreeTitleSections(array $titles, int $maxlen): string { return $result; } +/** + * Translatable label for the card/section holding devices not on any tree. + * + * @return string + */ +function monitorNonTreeLabel(): string { + return __('Non-Tree Devices', 'monitor'); +} + /** * Render section for monitored hosts that are not attached to any tree. * @@ -664,7 +673,7 @@ function monitorRenderNonTreeSection(): string { $result .= "
"; @@ -687,8 +696,8 @@ function monitorRenderNonTreeSection(): string { * @return string */ function monitorRenderTreeCards(array $tree_list): string { - $groups = []; - $maxlen = monitorGetTreeRenderMaxLength(); + $groups = []; + $host_ids = []; if (cacti_sizeof($tree_list)) { $tree_ids = []; @@ -714,7 +723,8 @@ function monitorRenderTreeCards(array $tree_list): string { ORDER BY gt.sequence, h.description'); if (cacti_sizeof($hosts)) { - [$hosts] = monitorFilterAllowedHosts($hosts); + [$hosts, $ids] = monitorFilterAllowedHosts($hosts); + $host_ids = array_merge($host_ids, $ids); foreach ($hosts as $host) { $gid = 'tree_' . $host['graph_tree_id']; @@ -734,10 +744,11 @@ function monitorRenderTreeCards(array $tree_list): string { $nontree = getHostNonTreeArray(); if (cacti_sizeof($nontree)) { - [$nontree] = monitorFilterAllowedHosts($nontree); + [$nontree, $ids] = monitorFilterAllowedHosts($nontree); if (cacti_sizeof($nontree)) { - $groups['nontree'] = ['label' => __('Non-Tree Devices', 'monitor'), 'hosts' => $nontree]; + $host_ids = array_merge($host_ids, $ids); + $groups['nontree'] = ['label' => monitorNonTreeLabel(), 'hosts' => $nontree]; } } } @@ -746,6 +757,18 @@ function monitorRenderTreeCards(array $tree_list): string { return ''; } + // Size the trim length from the hosts actually rendered here (including the + // Non-Tree card) so labels aren't trimmed against unrelated tree devices. + $maxlen = 10; + + if (get_request_var('view') == 'default' && cacti_sizeof($host_ids)) { + $maxlen = (int) db_fetch_cell('SELECT MAX(LENGTH(description)) + FROM host AS h + WHERE id IN (' . implode(',', $host_ids) . ')'); + } + + $maxlen = getMonitorTrimLength($maxlen); + return monitorRenderGroupCards('tree', $groups, $maxlen); } From 842d80e9a4071e4f170f69c181bb29217056b353 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 10 Oct 2026 20:02:15 -0400 Subject: [PATCH 3/3] Monitor: harden saveCardOrder id typing and card render formatting - Cast preg_replace result to string for strict typing. - Avoid mixing list destructuring with aligned assignments in the tree card builder. --- includes/controller.php | 2 +- includes/render.php | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/includes/controller.php b/includes/controller.php index 9c03f38..7bd9501 100644 --- a/includes/controller.php +++ b/includes/controller.php @@ -117,7 +117,7 @@ function saveCardOrder(): void { // Group ids may be numeric (site/template) or string tokens such as // "tree_12" and "nontree", so preserve the string while stripping // anything outside a safe id character set. - $gid = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $gid); + $gid = (string) preg_replace('/[^a-zA-Z0-9_]/', '', (string) $gid); if ($gid !== '') { $ids[] = $gid; diff --git a/includes/render.php b/includes/render.php index 1c52ea1..8a4d4c2 100644 --- a/includes/render.php +++ b/includes/render.php @@ -724,7 +724,8 @@ function monitorRenderTreeCards(array $tree_list): string { if (cacti_sizeof($hosts)) { [$hosts, $ids] = monitorFilterAllowedHosts($hosts); - $host_ids = array_merge($host_ids, $ids); + + $host_ids = array_merge($host_ids, $ids); foreach ($hosts as $host) { $gid = 'tree_' . $host['graph_tree_id']; @@ -747,7 +748,8 @@ function monitorRenderTreeCards(array $tree_list): string { [$nontree, $ids] = monitorFilterAllowedHosts($nontree); if (cacti_sizeof($nontree)) { - $host_ids = array_merge($host_ids, $ids); + $host_ids = array_merge($host_ids, $ids); + $groups['nontree'] = ['label' => monitorNonTreeLabel(), 'hosts' => $nontree]; } }