diff --git a/CHANGELOG.md b/CHANGELOG.md index 179f0e2e..c29fcde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ --- develop --- +* feature: On Cacti 1.2.32+, zebra-stripe the interfaces list and add an "Issues" column of clickable status pills (one per legend state, active states highlighted); clicking a pill toggles that state in a new multi-select Status filter styled like Base Cacti's Graph View Graph Templates selector +* feature: Add a status pill column to the 802.1x view after the username; clicking the pill drills down by setting the Status filter to that session status * feature: Lay the interfaces and dot1x status legends out as a centered footer in normal document flow via a shared `.mactrackLegendFooter` class, instead of a fixed-position overlay that could cover the last rows at the bottom of the page * feature: Restyle the interfaces and dot1x status legends as rounded, evenly-spaced solid-colour chips with theme-appropriate colours (matching the Thold/Monitor/Syslog legends and the Deepness emerald), sized to the longest label and laid out as a CSS grid so they stay equal width across wrapped rows; also fixed a stray `background-color: color:` typo in the `.int_no_device` rule * dev: Convert plugin bootstrap include/include_once calls to fail-fast require/require_once across the web, CLI, and hook entry points so a missing dependency halts execution instead of continuing with an undefined API diff --git a/css/classic.css b/css/classic.css index 1f828fae..4a6f0989 100644 --- a/css/classic.css +++ b/css/classic.css @@ -176,3 +176,65 @@ color: #000000; } +/* + * Status pills for the interfaces "Issues" column and the 802.1x status + * column. Outlined when inactive and solid when active; each pill takes + * its colour from the --pill custom property defined below so it matches + * this theme's status legend chips. + */ +.mactrackPills { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.mactrackPill { + appearance: none; + -webkit-appearance: none; + display: inline-block; + margin: 0; + padding: 1px 7px; + border-radius: 10px; + font: inherit; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: inherit; + background-color: transparent; + border: 1px solid var(--pill, #b5b5b5); +} + +.mactrackPill:hover { + background-color: var(--pill, #666666); + color: #ffffff; +} + +.mactrackPill:focus-visible { + outline: 2px solid var(--pill, #666666); + outline-offset: 1px; +} + +.mactrackPillActive { + color: #ffffff; + background-color: var(--pill, #666666); + border-color: var(--pill, #666666); +} + +.mactrackPill.int_up { --pill: #2b8435; } +.mactrackPill.int_up_wo_alias { --pill: #a85712; } +.mactrackPill.int_errors { --pill: #93302a; } +.mactrackPill.int_discards { --pill: #9a6a1a; } +.mactrackPill.int_no_graph { --pill: #5f5f57; } +.mactrackPill.int_down { --pill: #a12820; } + +.mactrackPill.dot1x_authn_success { --pill: #2b8435; } +.mactrackPill.dot1x_auth_success { --pill: #3f7a4a; } +.mactrackPill.dot1x_authn_failed { --pill: #a12820; } +.mactrackPill.dot1x_auth_failed { --pill: #a85712; } +.mactrackPill.dot1x_running { --pill: #93302a; } +.mactrackPill.dot1x_idle { --pill: #9a6a1a; } +.mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } + diff --git a/css/dark.css b/css/dark.css index f1d9866c..c774897e 100644 --- a/css/dark.css +++ b/css/dark.css @@ -175,3 +175,65 @@ background-color: rgba(205, 207, 196, 0.2); color: #eeeeee; } + +/* + * Status pills for the interfaces "Issues" column and the 802.1x status + * column. Outlined when inactive and solid when active; each pill takes + * its colour from the --pill custom property defined below so it matches + * this theme's status legend chips. + */ +.mactrackPills { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.mactrackPill { + appearance: none; + -webkit-appearance: none; + display: inline-block; + margin: 0; + padding: 1px 7px; + border-radius: 10px; + font: inherit; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: inherit; + background-color: transparent; + border: 1px solid var(--pill, #b5b5b5); +} + +.mactrackPill:hover { + background-color: var(--pill, #666666); + color: #ffffff; +} + +.mactrackPill:focus-visible { + outline: 2px solid var(--pill, #cccccc); + outline-offset: 1px; +} + +.mactrackPillActive { + color: #ffffff; + background-color: var(--pill, #666666); + border-color: var(--pill, #666666); +} + +.mactrackPill.int_up { --pill: #2b8435; } +.mactrackPill.int_up_wo_alias { --pill: #a85712; } +.mactrackPill.int_errors { --pill: #93302a; } +.mactrackPill.int_discards { --pill: #9a6a1a; } +.mactrackPill.int_no_graph { --pill: #5f5f57; } +.mactrackPill.int_down { --pill: #a12820; } + +.mactrackPill.dot1x_authn_success { --pill: #2b8435; } +.mactrackPill.dot1x_auth_success { --pill: #3f7a4a; } +.mactrackPill.dot1x_authn_failed { --pill: #a12820; } +.mactrackPill.dot1x_auth_failed { --pill: #a85712; } +.mactrackPill.dot1x_running { --pill: #93302a; } +.mactrackPill.dot1x_idle { --pill: #9a6a1a; } +.mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } diff --git a/css/deepness.css b/css/deepness.css index eda0a083..1920e902 100644 --- a/css/deepness.css +++ b/css/deepness.css @@ -175,3 +175,65 @@ background-color: rgba(205, 207, 196, 0.2); color: #eeeeee; } + +/* + * Status pills for the interfaces "Issues" column and the 802.1x status + * column. Outlined when inactive and solid when active; each pill takes + * its colour from the --pill custom property defined below so it matches + * this theme's status legend chips. + */ +.mactrackPills { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.mactrackPill { + appearance: none; + -webkit-appearance: none; + display: inline-block; + margin: 0; + padding: 1px 7px; + border-radius: 10px; + font: inherit; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: inherit; + background-color: transparent; + border: 1px solid var(--pill, #b5b5b5); +} + +.mactrackPill:hover { + background-color: var(--pill, #666666); + color: #ffffff; +} + +.mactrackPill:focus-visible { + outline: 2px solid var(--pill, #cccccc); + outline-offset: 1px; +} + +.mactrackPillActive { + color: #ffffff; + background-color: var(--pill, #666666); + border-color: var(--pill, #666666); +} + +.mactrackPill.int_up { --pill: #197a54; } +.mactrackPill.int_up_wo_alias { --pill: #a85712; } +.mactrackPill.int_errors { --pill: #93302a; } +.mactrackPill.int_discards { --pill: #9a6a1a; } +.mactrackPill.int_no_graph { --pill: #5f5f57; } +.mactrackPill.int_down { --pill: #a12820; } + +.mactrackPill.dot1x_authn_success { --pill: #197a54; } +.mactrackPill.dot1x_auth_success { --pill: #2c6f52; } +.mactrackPill.dot1x_authn_failed { --pill: #a12820; } +.mactrackPill.dot1x_auth_failed { --pill: #a85712; } +.mactrackPill.dot1x_running { --pill: #93302a; } +.mactrackPill.dot1x_idle { --pill: #9a6a1a; } +.mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } diff --git a/css/mactrack.css b/css/mactrack.css index e318835b..d6c7c75a 100644 --- a/css/mactrack.css +++ b/css/mactrack.css @@ -104,3 +104,65 @@ color: #eeeeee; } +/* + * Status pills for the interfaces "Issues" column and the 802.1x status + * column. Outlined when inactive and solid when active; each pill takes + * its colour from the --pill custom property defined below so it matches + * this theme's status legend chips. + */ +.mactrackPills { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.mactrackPill { + appearance: none; + -webkit-appearance: none; + display: inline-block; + margin: 0; + padding: 1px 7px; + border-radius: 10px; + font: inherit; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: inherit; + background-color: transparent; + border: 1px solid var(--pill, #b5b5b5); +} + +.mactrackPill:hover { + background-color: var(--pill, #666666); + color: #ffffff; +} + +.mactrackPill:focus-visible { + outline: 2px solid var(--pill, #666666); + outline-offset: 1px; +} + +.mactrackPillActive { + color: #ffffff; + background-color: var(--pill, #666666); + border-color: var(--pill, #666666); +} + +.mactrackPill.int_up { --pill: #2b8435; } +.mactrackPill.int_up_wo_alias { --pill: #a85712; } +.mactrackPill.int_errors { --pill: #93302a; } +.mactrackPill.int_discards { --pill: #9a6a1a; } +.mactrackPill.int_no_graph { --pill: #5f5f57; } +.mactrackPill.int_down { --pill: #a12820; } + +.mactrackPill.dot1x_authn_success { --pill: #2b8435; } +.mactrackPill.dot1x_auth_success { --pill: #3f7a4a; } +.mactrackPill.dot1x_authn_failed { --pill: #a12820; } +.mactrackPill.dot1x_auth_failed { --pill: #93302a; } +.mactrackPill.dot1x_running { --pill: #a85712; } +.mactrackPill.dot1x_idle { --pill: #5f5f57; } +.mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } + diff --git a/css/modern.css b/css/modern.css index aa2b347b..c1ee8748 100644 --- a/css/modern.css +++ b/css/modern.css @@ -182,3 +182,65 @@ background-color: rgba(205, 207, 196, 0.8); color: #000000; } + +/* + * Status pills for the interfaces "Issues" column and the 802.1x status + * column. Outlined when inactive and solid when active; each pill takes + * its colour from the --pill custom property defined below so it matches + * this theme's status legend chips. + */ +.mactrackPills { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.mactrackPill { + appearance: none; + -webkit-appearance: none; + display: inline-block; + margin: 0; + padding: 1px 7px; + border-radius: 10px; + font: inherit; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: inherit; + background-color: transparent; + border: 1px solid var(--pill, #b5b5b5); +} + +.mactrackPill:hover { + background-color: var(--pill, #666666); + color: #ffffff; +} + +.mactrackPill:focus-visible { + outline: 2px solid var(--pill, #666666); + outline-offset: 1px; +} + +.mactrackPillActive { + color: #ffffff; + background-color: var(--pill, #666666); + border-color: var(--pill, #666666); +} + +.mactrackPill.int_up { --pill: #2b8435; } +.mactrackPill.int_up_wo_alias { --pill: #a85712; } +.mactrackPill.int_errors { --pill: #93302a; } +.mactrackPill.int_discards { --pill: #9a6a1a; } +.mactrackPill.int_no_graph { --pill: #5f5f57; } +.mactrackPill.int_down { --pill: #a12820; } + +.mactrackPill.dot1x_authn_success { --pill: #2b8435; } +.mactrackPill.dot1x_auth_success { --pill: #3f7a4a; } +.mactrackPill.dot1x_authn_failed { --pill: #a12820; } +.mactrackPill.dot1x_auth_failed { --pill: #a85712; } +.mactrackPill.dot1x_running { --pill: #93302a; } +.mactrackPill.dot1x_idle { --pill: #9a6a1a; } +.mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } diff --git a/css/paper-plane.css b/css/paper-plane.css index 37ee764b..d86db3a2 100644 --- a/css/paper-plane.css +++ b/css/paper-plane.css @@ -175,3 +175,65 @@ background-color: rgba(205, 207, 196, 0.8); color: #000000; } + +/* + * Status pills for the interfaces "Issues" column and the 802.1x status + * column. Outlined when inactive and solid when active; each pill takes + * its colour from the --pill custom property defined below so it matches + * this theme's status legend chips. + */ +.mactrackPills { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.mactrackPill { + appearance: none; + -webkit-appearance: none; + display: inline-block; + margin: 0; + padding: 1px 7px; + border-radius: 10px; + font: inherit; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: inherit; + background-color: transparent; + border: 1px solid var(--pill, #b5b5b5); +} + +.mactrackPill:hover { + background-color: var(--pill, #666666); + color: #ffffff; +} + +.mactrackPill:focus-visible { + outline: 2px solid var(--pill, #666666); + outline-offset: 1px; +} + +.mactrackPillActive { + color: #ffffff; + background-color: var(--pill, #666666); + border-color: var(--pill, #666666); +} + +.mactrackPill.int_up { --pill: #2b8435; } +.mactrackPill.int_up_wo_alias { --pill: #a85712; } +.mactrackPill.int_errors { --pill: #93302a; } +.mactrackPill.int_discards { --pill: #9a6a1a; } +.mactrackPill.int_no_graph { --pill: #5f5f57; } +.mactrackPill.int_down { --pill: #a12820; } + +.mactrackPill.dot1x_authn_success { --pill: #2b8435; } +.mactrackPill.dot1x_auth_success { --pill: #3f7a4a; } +.mactrackPill.dot1x_authn_failed { --pill: #a12820; } +.mactrackPill.dot1x_auth_failed { --pill: #a85712; } +.mactrackPill.dot1x_running { --pill: #93302a; } +.mactrackPill.dot1x_idle { --pill: #9a6a1a; } +.mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } diff --git a/css/paw.css b/css/paw.css index 37ee764b..d86db3a2 100644 --- a/css/paw.css +++ b/css/paw.css @@ -175,3 +175,65 @@ background-color: rgba(205, 207, 196, 0.8); color: #000000; } + +/* + * Status pills for the interfaces "Issues" column and the 802.1x status + * column. Outlined when inactive and solid when active; each pill takes + * its colour from the --pill custom property defined below so it matches + * this theme's status legend chips. + */ +.mactrackPills { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.mactrackPill { + appearance: none; + -webkit-appearance: none; + display: inline-block; + margin: 0; + padding: 1px 7px; + border-radius: 10px; + font: inherit; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: inherit; + background-color: transparent; + border: 1px solid var(--pill, #b5b5b5); +} + +.mactrackPill:hover { + background-color: var(--pill, #666666); + color: #ffffff; +} + +.mactrackPill:focus-visible { + outline: 2px solid var(--pill, #666666); + outline-offset: 1px; +} + +.mactrackPillActive { + color: #ffffff; + background-color: var(--pill, #666666); + border-color: var(--pill, #666666); +} + +.mactrackPill.int_up { --pill: #2b8435; } +.mactrackPill.int_up_wo_alias { --pill: #a85712; } +.mactrackPill.int_errors { --pill: #93302a; } +.mactrackPill.int_discards { --pill: #9a6a1a; } +.mactrackPill.int_no_graph { --pill: #5f5f57; } +.mactrackPill.int_down { --pill: #a12820; } + +.mactrackPill.dot1x_authn_success { --pill: #2b8435; } +.mactrackPill.dot1x_auth_success { --pill: #3f7a4a; } +.mactrackPill.dot1x_authn_failed { --pill: #a12820; } +.mactrackPill.dot1x_auth_failed { --pill: #a85712; } +.mactrackPill.dot1x_running { --pill: #93302a; } +.mactrackPill.dot1x_idle { --pill: #9a6a1a; } +.mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } diff --git a/css/sunrise.css b/css/sunrise.css index 06f81aef..5eb89a5d 100644 --- a/css/sunrise.css +++ b/css/sunrise.css @@ -175,3 +175,65 @@ background-color: rgba(205, 207, 196, 0.8); color: #000000; } + +/* + * Status pills for the interfaces "Issues" column and the 802.1x status + * column. Outlined when inactive and solid when active; each pill takes + * its colour from the --pill custom property defined below so it matches + * this theme's status legend chips. + */ +.mactrackPills { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; +} + +.mactrackPill { + appearance: none; + -webkit-appearance: none; + display: inline-block; + margin: 0; + padding: 1px 7px; + border-radius: 10px; + font: inherit; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: inherit; + background-color: transparent; + border: 1px solid var(--pill, #b5b5b5); +} + +.mactrackPill:hover { + background-color: var(--pill, #666666); + color: #ffffff; +} + +.mactrackPill:focus-visible { + outline: 2px solid var(--pill, #666666); + outline-offset: 1px; +} + +.mactrackPillActive { + color: #ffffff; + background-color: var(--pill, #666666); + border-color: var(--pill, #666666); +} + +.mactrackPill.int_up { --pill: #2b8435; } +.mactrackPill.int_up_wo_alias { --pill: #a85712; } +.mactrackPill.int_errors { --pill: #93302a; } +.mactrackPill.int_discards { --pill: #9a6a1a; } +.mactrackPill.int_no_graph { --pill: #5f5f57; } +.mactrackPill.int_down { --pill: #a12820; } + +.mactrackPill.dot1x_authn_success { --pill: #2b8435; } +.mactrackPill.dot1x_auth_success { --pill: #3f7a4a; } +.mactrackPill.dot1x_authn_failed { --pill: #a12820; } +.mactrackPill.dot1x_auth_failed { --pill: #a85712; } +.mactrackPill.dot1x_running { --pill: #93302a; } +.mactrackPill.dot1x_idle { --pill: #9a6a1a; } +.mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } diff --git a/lib/mactrack_functions.php b/lib/mactrack_functions.php index 41f61df9..474f8f0e 100644 --- a/lib/mactrack_functions.php +++ b/lib/mactrack_functions.php @@ -3368,6 +3368,14 @@ function mactrack_format_interface_row($stat): string { form_selectable_cell(strtoupper($stat['device_type']), $stat['device_id']); form_selectable_cell($stat['ifName'], $stat['device_id']); form_selectable_cell($stat['ifDescr'], $stat['device_id']); + + // View glue: the pill markup itself is unit-tested via mactrack_interface_status_pills(). + // @codeCoverageIgnoreStart + if (mactrack_interfaces_show_issues()) { + form_selectable_cell(mactrack_interface_status_pills($stat), $stat['device_id']); + } + // @codeCoverageIgnoreEnd + form_selectable_cell($stat['ifAlias'], $stat['device_id']); form_selectable_cell(round($stat['inBound'],1) . ' %', $stat['device_id'], '', 'right'); form_selectable_cell(round($stat['outBound'],1) . ' %', $stat['device_id'], '', 'right'); @@ -3389,12 +3397,191 @@ function mactrack_format_interface_row($stat): string { } form_selectable_cell($stat['ifOperStatus'] == 1 ? __('Up', 'mactrack') : __('Down', 'mactrack'), $stat['device_id'], '', 'right'); + form_selectable_cell($upTime, $stat['device_id'], '', 'right'); form_selectable_cell(mactrack_date($stat['last_rundate']), $stat['device_id'], '', 'right'); return (string) ob_get_clean(); } +/** + * Determines whether the enhanced interface status detail (the Issues + * pill column and the multi-select Status filter) should be rendered. + * These rely on the select2-multi-count filter widget and pill styling + * available in Cacti 1.2.32 and newer, so they are hidden on older + * cores. + * + * @return bool True when running on Cacti 1.2.32 or newer. + */ +function mactrack_interfaces_show_issues(): bool { + return defined('CACTI_VERSION') && cacti_version_compare(CACTI_VERSION, '1.2.32', '>='); +} + +/** + * The ordered map of interface status legend classes to their display + * labels, shared by the interfaces legend, the Issues pill column and + * the multi-select Status filter so they always stay in sync. + * + * @return array Ordered map of legend CSS class => label text. + */ +function mactrack_interface_status_legend(): array { + return [ + 'int_up' => __('Interface Up', 'mactrack'), + 'int_up_wo_alias' => __('No Alias', 'mactrack'), + 'int_errors' => __('Errors Present', 'mactrack'), + 'int_discards' => __('Discards Present', 'mactrack'), + 'int_no_graph' => __('No Graphs', 'mactrack'), + 'int_down' => __('Interface Down', 'mactrack'), + ]; +} + +/** + * Reduces a comma-separated status filter string into a validated, + * de-duplicated list of known interface status legend classes. + * + * @param string|null $value The raw 'statuses' request value. + * + * @return array The validated status legend classes, in request order. + */ +function mactrack_parse_status_tokens($value): array { + $valid = array_keys(mactrack_interface_status_legend()); + $out = []; + + if ($value === null || $value === '') { + return $out; + } + + foreach (explode(',', (string) $value) as $token) { + $token = trim($token); + + if (in_array($token, $valid, true) && !in_array($token, $out, true)) { + $out[] = $token; + } + } + + return $out; +} + +/** + * Determines whether a given interface currently has any associated + * Cacti graphs, used to drive the 'No Graphs' status indicator. + * + * @param int $host_id The linked Cacti host id. + * @param int $ifIndex The SNMP interface index. + * + * @return bool True when at least one interface graph exists. + */ +function mactrack_interface_has_graphs($host_id, $ifIndex): bool { + if (empty($host_id)) { + return false; + } + + return (bool) db_fetch_cell_prepared('SELECT COUNT(*) + FROM mac_track_interface_graphs + WHERE host_id = ? + AND ifIndex = ?', + [$host_id, $ifIndex]); +} + +/** + * Builds the set of interface status legend classes that currently + * apply to a single interface record, used to highlight the active + * pills in the Issues column. + * + * @param array $stat The interface record being rendered. + * + * @return array Map of active legend CSS class => true. + */ +function mactrack_int_statuses($stat): array { + $active = []; + + if ($stat['ifOperStatus'] == 1) { + $active['int_up'] = true; + + if ($stat['ifAlias'] == '') { + $active['int_up_wo_alias'] = true; + } + } else { + $active['int_down'] = true; + } + + if ($stat['int_errors_present'] == 1) { + $active['int_errors'] = true; + } + + if ($stat['int_discards_present'] == 1) { + $active['int_discards'] = true; + } + + if (array_key_exists('has_graphs', $stat)) { + $has_graphs = (bool) $stat['has_graphs']; + } else { + $has_graphs = mactrack_interface_has_graphs($stat['host_id'], $stat['ifIndex']); + } + + if (!$has_graphs) { + $active['int_no_graph'] = true; + } + + return $active; +} + +/** + * Renders the Issues column for an interface: one clickable pill per + * legend status, with the statuses that currently apply to the + * interface highlighted. Clicking a pill toggles that status in the + * multi-select Status filter (see mactrack_view_interfaces.php). + * + * @param array $stat The interface record being rendered. + * + * @return string The HTML markup for the pill group. + */ +function mactrack_interface_status_pills($stat): string { + $legend = mactrack_interface_status_legend(); + $active = mactrack_int_statuses($stat); + + $pills = '
'; + + foreach ($legend as $class => $label) { + $is_active = isset($active[$class]); + + $pills .= ''; + } + + $pills .= '
'; + + return $pills; +} + +/** + * Renders the 802.1x status pill shown after the username column. The + * pill is a drill-down control that sets the Status filter to the + * clicked status (see mactrack_view_dot1x.php). + * + * @param array $port_result The 802.1x record being rendered. + * + * @return string The HTML markup for the status pill. + * + * @global array $mactrack_device_status Shared map of status code => + * label (defined once near the + * top of this file). + */ +function mactrack_dot1x_status_pill($port_result): string { + global $mactrack_device_status; + + $status = (int) $port_result['status']; + $class = mactrack_dot1x_row_class($port_result); + $label = $mactrack_device_status[$status] ?? __('Unknown', 'mactrack'); + + return '
' . + '' . + '
'; +} + /** * Renders a single 802.1x results-list table row (action icons, device * name/hostname, username, IP address, optional DNS hostname, MAC @@ -3405,15 +3592,11 @@ function mactrack_format_interface_row($stat): string { * * @return string The HTML markup for the table row's cells. * - * @global array $config Cacti global configuration - * array (declared but not used - * directly here). - * @global array $mactrack_device_status Map of status code => label - * used to render the - * authorization status column. + * @global array $config Cacti global configuration array (declared but + * not used directly here). */ function mactrack_format_dot1x_row($port_result): string { - global $config,$mactrack_device_status; + global $config; // we will make a row string $row = ''; @@ -3424,16 +3607,11 @@ function mactrack_format_dot1x_row($port_result): string { $scan_date = $port_result['max_scan_date']; } - $status = 'Unknown'; - - if (array_key_exists($port_result['status'],$mactrack_device_status)) { - $status = $mactrack_device_status[$port_result['status']]; - } - $row .= "" . mactrack_interface_actions($port_result['device_id'], $port_result['port_number']) . ''; $row .= '' . $port_result['device_name'] . ''; $row .= '' . $port_result['hostname'] . ''; $row .= '' . $port_result['username'] . ''; + $row .= '' . mactrack_dot1x_status_pill($port_result) . ''; $row .= '' . $port_result['ip_address'] . ''; if (read_config_option('mt_reverse_dns') != '') { @@ -3443,7 +3621,6 @@ function mactrack_format_dot1x_row($port_result): string { $row .= '' . mactrack_format_mac($port_result['mac_address']) . ''; $row .= '' . $port_result['ifName'] . ''; $row .= '' . ($port_result['domain'] == 2 ? __('Data', 'mactrack') : __('Voice', 'mactrack')) . ''; - $row .= '' . $status . ''; $row .= "" . $scan_date . ''; return $row; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 904f5dc6..76a91cc9 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-09-28 16:06-0400\n" +"POT-Creation-Date: 2026-10-08 19:41-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -136,6 +136,35 @@ msgstr "" msgid "Up" msgstr "" +#: lib/mactrack_functions.php mactrack_view_interfaces.php +msgid "Interface Up" +msgstr "" + +#: lib/mactrack_functions.php mactrack_view_interfaces.php +msgid "No Alias" +msgstr "" + +#: lib/mactrack_functions.php mactrack_view_interfaces.php +msgid "Errors Present" +msgstr "" + +#: lib/mactrack_functions.php mactrack_view_interfaces.php +msgid "Discards Present" +msgstr "" + +#: lib/mactrack_functions.php mactrack_view_interfaces.php +msgid "No Graphs" +msgstr "" + +#: lib/mactrack_functions.php mactrack_view_interfaces.php +msgid "Interface Down" +msgstr "" + +#: lib/mactrack_functions.php mactrack_device_types.php mactrack_devices.php +#: mactrack_view_devices.php setup.php +msgid "Unknown" +msgstr "" + #: lib/mactrack_functions.php msgid "Data" msgstr "" @@ -259,11 +288,6 @@ msgstr "" msgid "Graphs" msgstr "" -#: lib/mactrack_functions.php mactrack_device_types.php mactrack_devices.php -#: mactrack_view_devices.php setup.php -msgid "Unknown" -msgstr "" - #: lib/mactrack_functions.php mactrack_device_types.php mactrack_devices.php #: mactrack_macauth.php mactrack_macwatch.php mactrack_snmp.php #: mactrack_vendormacs.php mactrack_view_arp.php mactrack_view_devices.php @@ -1628,30 +1652,6 @@ msgstr "" msgid "No Mactrack Interfaces Found" msgstr "" -#: mactrack_view_interfaces.php -msgid "Interface Up" -msgstr "" - -#: mactrack_view_interfaces.php -msgid "No Alias" -msgstr "" - -#: mactrack_view_interfaces.php -msgid "Errors Present" -msgstr "" - -#: mactrack_view_interfaces.php -msgid "Discards Present" -msgstr "" - -#: mactrack_view_interfaces.php -msgid "No Graphs" -msgstr "" - -#: mactrack_view_interfaces.php -msgid "Interface Down" -msgstr "" - #: mactrack_view_interfaces.php msgid "Interface Scan Results" msgstr "" @@ -1664,6 +1664,10 @@ msgstr "" msgid "Description" msgstr "" +#: mactrack_view_interfaces.php +msgid "Issues" +msgstr "" + #: mactrack_view_interfaces.php msgid "Alias" msgstr "" @@ -1795,6 +1799,14 @@ msgstr "" msgid "Show Totals" msgstr "" +#: mactrack_view_interfaces.php +msgid "All Statuses" +msgstr "" + +#: mactrack_view_interfaces.php +msgid "Statuses Selected" +msgstr "" + #: mactrack_view_ips.php msgid "Mactrack - Site IP Range Report View" msgstr "" diff --git a/mactrack_view_dot1x.php b/mactrack_view_dot1x.php index f6eacdd6..a2a6c34d 100644 --- a/mactrack_view_dot1x.php +++ b/mactrack_view_dot1x.php @@ -469,12 +469,12 @@ function mactrack_view_dot1x(): void { 'device_name' => [__('Switch Name', 'mactrack'), 'ASC'], 'hostname' => [__('Switch Hostname', 'mactrack'), 'ASC'], 'username' => [__('Username', 'mactrack'), 'ASC'], + 'status' => [__('Status', 'mactrack'), 'ASC'], 'ip_address' => [__('ED IP Address', 'mactrack'), 'ASC'], 'dns_hostname' => [__('ED DNS Hostname', 'mactrack'), 'ASC'], 'mac_address' => [__('ED MAC Address', 'mactrack'), 'ASC'], 'ifName' => [__('Port Name', 'mactrack'), 'ASC'], 'domain' => [__('Domain', 'mactrack'), 'DESC'], - 'status' => [__('Status', 'mactrack'), 'ASC'], 'scan_date' => [__('Last Scan Date', 'mactrack'), 'DESC'] ]; } else { @@ -483,11 +483,11 @@ function mactrack_view_dot1x(): void { 'device_name' => [__('Switch Name', 'mactrack'), 'ASC'], 'hostname' => [__('Switch Hostname', 'mactrack'), 'ASC'], 'username' => [__('Username', 'mactrack'), 'ASC'], + 'status' => [__('Status', 'mactrack'), 'ASC'], 'ip_address' => [__('ED IP Address', 'mactrack'), 'ASC'], 'mac_address' => [__('ED MAC Address', 'mactrack'), 'ASC'], 'ifName' => [__('Port Name', 'mactrack'), 'ASC'], 'domain' => [__('Domain', 'mactrack'), 'DESC'], - 'status' => [__('Status', 'mactrack'), 'ASC'], 'scan_date' => [__('Last Scan Date', 'mactrack'), 'DESC'] ]; } @@ -827,6 +827,20 @@ function exportRows() { $('#export').on('click', function() { exportRows(); }); + + // Status pill drill-down: set the Status filter to the + // clicked session status, then re-apply the filter. + $('.mactrackPill').off('click').on('click', function() { + var status = $(this).attr('data-dot1x-status'); + + if (status === undefined || !$('#status').length) { + return; + } + + $('#status').val(status); + + applyFilter(); + }); }); diff --git a/mactrack_view_interfaces.php b/mactrack_view_interfaces.php index de225b84..e7ff2712 100644 --- a/mactrack_view_interfaces.php +++ b/mactrack_view_interfaces.php @@ -103,6 +103,52 @@ function mactrack_get_records(&$sql_where, $apply_limits = true, $rows = 30, &$s } else { } + // status legend sql where (multi-select Status filter / Issues pill drill-down) + // Only honoured on the enhanced view (Cacti 1.2.32+); on older cores the + // Status control and pills are hidden, so a persisted value must not filter. + $statuses = mactrack_interfaces_show_issues() + ? mactrack_parse_status_tokens(get_request_var('statuses')) + : []; + + if (cacti_sizeof($statuses)) { + $status_clauses = []; + + foreach ($statuses as $status) { + switch ($status) { + case 'int_up': + $status_clauses[] = 'mac_track_interfaces.ifOperStatus = 1'; + + break; + case 'int_down': + $status_clauses[] = 'mac_track_interfaces.ifOperStatus <> 1'; + + break; + case 'int_up_wo_alias': + $status_clauses[] = "(mac_track_interfaces.ifOperStatus = 1 AND mac_track_interfaces.ifAlias = '')"; + + break; + case 'int_errors': + $status_clauses[] = 'mac_track_interfaces.int_errors_present = 1'; + + break; + case 'int_discards': + $status_clauses[] = 'mac_track_interfaces.int_discards_present = 1'; + + break; + case 'int_no_graph': + $status_clauses[] = 'NOT EXISTS (SELECT 1 FROM mac_track_interface_graphs AS mtig + WHERE mtig.host_id = mac_track_devices.host_id + AND mtig.ifIndex = mac_track_interfaces.ifIndex)'; + + break; + } + } + + if (cacti_sizeof($status_clauses)) { + $sql_where .= ($sql_where != '' ? ' AND ' : 'WHERE ') . '(' . implode(' OR ', $status_clauses) . ')'; + } + } + // filter sql where $filter_where = mactrack_create_sql_filter(get_request_var('filter'), ['ifAlias', 'hostname', 'ifName', 'ifDescr']); @@ -144,7 +190,10 @@ function mactrack_get_records(&$sql_where, $apply_limits = true, $rows = 30, &$s mac_track_devices.device_name, mac_track_devices.host_id, mac_track_devices.disabled, - mac_track_devices.last_rundate + mac_track_devices.last_rundate, + EXISTS (SELECT 1 FROM mac_track_interface_graphs AS mtig + WHERE mtig.host_id = mac_track_devices.host_id + AND mtig.ifIndex = mac_track_interfaces.ifIndex) AS has_graphs FROM mac_track_interfaces INNER JOIN mac_track_devices ON mac_track_interfaces.device_id=mac_track_devices.device_id @@ -215,6 +264,12 @@ function mactrack_interfaces_request_validation(): void { 'default' => '-2', 'pageset' => true ], + 'statuses' => [ + 'filter' => FILTER_VALIDATE_REGEXP, + 'pageset' => true, + 'default' => '', + 'options' => ['options' => ['regexp' => '/^((int_up|int_down|int_up_wo_alias|int_errors|int_discards|int_no_graph)(,(int_up|int_down|int_up_wo_alias|int_errors|int_discards|int_no_graph))*)?$/']] + ], 'period' => [ 'filter' => FILTER_VALIDATE_INT, 'default' => '-2', @@ -363,20 +418,25 @@ function mactrack_view(): void { $i = 0; if (cacti_sizeof($stats)) { + $show_issues = mactrack_interfaces_show_issues(); + foreach ($stats as $stat) { - // find the background color and enclose it - $class = mactrack_int_row_class($stat); + if ($show_issues) { + // zebra striped rows, status is conveyed by the Issues pills + $class = (($i % 2) == 1) ? 'odd' : 'even'; - if ($class) { print ""; } else { - if (($i % 2) == 1) { - $class = 'odd'; + // find the background color and enclose it + $class = mactrack_int_row_class($stat); + + if ($class) { + print ""; } else { - $class = 'even'; - } + $class = (($i % 2) == 1) ? 'odd' : 'even'; - print ""; + print ""; + } } $i++; @@ -448,6 +508,19 @@ function mactrack_display_array(): array { 'display' => __('Description', 'mactrack'), 'sort' => 'ASC' ], + ]; + + if (mactrack_interfaces_show_issues()) { + $display_text += [ + 'issues_nosort' => [ + 'display' => __('Issues', 'mactrack'), + 'align' => 'left', + 'sort' => '' + ], + ]; + } + + $display_text += [ 'ifAlias' => [ 'display' => __('Alias', 'mactrack'), 'sort' => 'ASC' @@ -737,6 +810,22 @@ function mactrack_filter_table(): void { + + + + + + + + @@ -752,6 +841,10 @@ function applyFilter() { strURL += '&bwusage=' + $('#bwusage').val(); strURL += '&device_type_id=' + $('#device_type_id').val(); strURL += '&totals=' + $('#totals').is(':checked'); + + var mtStatuses = $('#statuses').length ? ($('#statuses').val() || []) : []; + strURL += '&statuses=' + mtStatuses.join(','); + loadPageNoHeader(strURL); } @@ -786,6 +879,30 @@ function exportRows() { scan_device_interface(parts[1], parts[2]); }); + + // Issues pill drill-down: toggle the clicked status in the + // multi-select Status filter, then re-apply the filter. + $('.mactrackPill').off('click').on('click', function() { + var status = $(this).attr('data-status'); + var $sel = $('#statuses'); + + if (status === undefined || !$sel.length) { + return; + } + + var vals = $sel.val() || []; + var idx = $.inArray(status, vals); + + if (idx > -1) { + vals.splice(idx, 1); + } else { + vals.push(status); + } + + $sel.val(vals).trigger('change'); + + applyFilter(); + }); }); diff --git a/tests/Unit/MactrackStatusPillsTest.php b/tests/Unit/MactrackStatusPillsTest.php new file mode 100644 index 00000000..ab29666b --- /dev/null +++ b/tests/Unit/MactrackStatusPillsTest.php @@ -0,0 +1,268 @@ + 1, + 'ifAlias' => 'uplink', + 'int_errors_present' => 0, + 'int_discards_present' => 0, + 'has_graphs' => 1, + ], $over); + } + + /** + * @return void + */ + public function test_show_issues_true_on_supported_core(): void { + $this->assertTrue(mactrack_interfaces_show_issues()); + } + + /** + * @return void + */ + public function test_legend_exposes_the_six_known_classes(): void { + $this->assertSame( + ['int_up', 'int_up_wo_alias', 'int_errors', 'int_discards', 'int_no_graph', 'int_down'], + array_keys(mactrack_interface_status_legend()) + ); + } + + /** + * @return void + */ + public function test_has_graphs_false_when_host_empty(): void { + $this->assertFalse(mactrack_interface_has_graphs(0, 5)); + } + + /** + * @return void + */ + public function test_has_graphs_true_when_db_reports_a_count(): void { + mactrack_test_queue_return('db_fetch_cell_prepared', 3); + + $this->assertTrue(mactrack_interface_has_graphs(10, 5)); + } + + /** + * @return void + */ + public function test_has_graphs_false_when_db_reports_zero(): void { + mactrack_test_queue_return('db_fetch_cell_prepared', 0); + + $this->assertFalse(mactrack_interface_has_graphs(10, 5)); + } + + /** + * @return void + */ + public function test_int_statuses_up_with_alias_and_graphs(): void { + $active = mactrack_int_statuses($this->stat([])); + + $this->assertArrayHasKey('int_up', $active); + $this->assertArrayNotHasKey('int_up_wo_alias', $active); + $this->assertArrayNotHasKey('int_errors', $active); + $this->assertArrayNotHasKey('int_discards', $active); + $this->assertArrayNotHasKey('int_no_graph', $active); + $this->assertArrayNotHasKey('int_down', $active); + } + + /** + * @return void + */ + public function test_int_statuses_up_without_alias_with_errors_discards_and_no_graphs(): void { + $active = mactrack_int_statuses($this->stat([ + 'ifAlias' => '', + 'int_errors_present' => 1, + 'int_discards_present' => 1, + 'has_graphs' => 0, + ])); + + $this->assertArrayHasKey('int_up', $active); + $this->assertArrayHasKey('int_up_wo_alias', $active); + $this->assertArrayHasKey('int_errors', $active); + $this->assertArrayHasKey('int_discards', $active); + $this->assertArrayHasKey('int_no_graph', $active); + } + + /** + * @return void + */ + public function test_int_statuses_down(): void { + $active = mactrack_int_statuses($this->stat(['ifOperStatus' => 2])); + + $this->assertArrayHasKey('int_down', $active); + $this->assertArrayNotHasKey('int_up', $active); + } + + /** + * @return void + */ + public function test_int_statuses_falls_back_to_db_when_has_graphs_absent(): void { + mactrack_test_queue_return('db_fetch_cell_prepared', 0); + + $active = mactrack_int_statuses([ + 'ifOperStatus' => 1, + 'ifAlias' => 'uplink', + 'int_errors_present' => 0, + 'int_discards_present' => 0, + 'host_id' => 10, + 'ifIndex' => 5, + ]); + + $this->assertArrayHasKey('int_no_graph', $active); + } + + /** + * @return void + */ + public function test_interface_pills_render_every_class_and_mark_active(): void { + $html = mactrack_interface_status_pills($this->stat([])); + + $this->assertStringContainsString('class="mactrackPills"', $html); + $this->assertStringContainsString('data-status="int_up"', $html); + $this->assertStringContainsString('mactrackPillActive', $html); + + foreach (array_keys(mactrack_interface_status_legend()) as $class) { + $this->assertStringContainsString($class, $html); + } + } + + /** + * @return void + */ + public function test_dot1x_pill_uses_row_class_and_status_hook(): void { + $html = mactrack_dot1x_status_pill(['status' => 2]); + + $this->assertStringContainsString('mactrackPill', $html); + $this->assertStringContainsString('data-dot1x-status="2"', $html); + $this->assertStringContainsString('dot1x_running', $html); + } + + /** + * @return void + */ + public function test_dot1x_pill_unknown_status_uses_default_class(): void { + $html = mactrack_dot1x_status_pill(['status' => 99]); + + $this->assertStringContainsString('dot1x_authn_success', $html); + } + + /** + * @return void + */ + public function test_format_dot1x_row_emits_single_status_pill_cell(): void { + mactrack_test_queue_return('db_fetch_row_prepared', ['host_id' => 0, 'disabled' => '']); + set_request_var('scan_date', '1'); + + $html = mactrack_format_dot1x_row([ + 'scan_date' => '2026-01-01 00:00:00', + 'max_scan_date' => '2026-01-01 00:00:00', + 'device_id' => 10, + 'port_number' => '1', + 'device_name' => 'switch-a', + 'hostname' => 'switch-a.example.net', + 'username' => 'jdoe', + 'status' => 2, + 'ip_address' => '10.0.0.5', + 'mac_address' => '001122334455', + 'ifName' => 'Gi1/0/1', + 'domain' => 2, + ]); + + $this->assertStringContainsString('data-dot1x-status="2"', $html); + $this->assertSame(1, substr_count($html, 'data-dot1x-status=')); + } + + /** + * @return void + */ + public function test_format_interface_row_inserts_issue_pills_after_description(): void { + mactrack_test_queue_return('db_fetch_row_prepared', ['host_id' => 0, 'disabled' => '']); + + $html = mactrack_format_interface_row([ + 'device_id' => 10, + 'ifIndex' => 2, + 'device_name' => 'switch-a', + 'device_type' => 'cisco', + 'ifName' => 'Gi1/0/1', + 'ifDescr' => 'GigabitEthernet1/0/1', + 'ifAlias' => 'uplink', + 'inBound' => 5, + 'outBound' => 7, + 'int_ifHCInOctets' => 0, + 'int_ifHCOutOctets' => 0, + 'int_ifInErrors' => 0, + 'int_ifInDiscards' => 0, + 'int_ifInUnknownProtos' => 0, + 'int_ifOutErrors' => 0, + 'int_ifOutDiscards' => 0, + 'ifOperStatus' => 1, + 'ifLastChange' => 0, + 'sysUptime' => 100, + 'last_rundate' => '2026-01-01 00:00:00', + 'int_errors_present' => 0, + 'int_discards_present' => 0, + 'has_graphs' => 1, + ]); + + $pos_descr = strpos($html, 'GigabitEthernet1/0/1'); + $pos_pill = strpos($html, 'mactrackPills'); + $pos_alias = strpos($html, 'uplink'); + + $this->assertNotFalse($pos_pill); + $this->assertGreaterThan($pos_descr, $pos_pill); + $this->assertGreaterThan($pos_pill, $pos_alias); + } +} diff --git a/tests/Unit/MactrackStatusTokensTest.php b/tests/Unit/MactrackStatusTokensTest.php new file mode 100644 index 00000000..40e66410 --- /dev/null +++ b/tests/Unit/MactrackStatusTokensTest.php @@ -0,0 +1,111 @@ +assertSame($valid, mactrack_parse_status_tokens(implode(',', $valid))); + } + + /** + * @return void + */ + public function test_preserves_request_order(): void { + $this->assertSame( + ['int_down', 'int_up', 'int_errors'], + mactrack_parse_status_tokens('int_down,int_up,int_errors') + ); + } + + /** + * @return void + */ + public function test_deduplicates_while_keeping_first_occurrence(): void { + $this->assertSame( + ['int_up', 'int_errors'], + mactrack_parse_status_tokens('int_up,int_errors,int_up') + ); + } + + /** + * @return void + */ + public function test_trims_surrounding_whitespace(): void { + $this->assertSame( + ['int_up', 'int_down'], + mactrack_parse_status_tokens(' int_up , int_down ') + ); + } + + /** + * @return void + */ + public function test_drops_unknown_tokens(): void { + $this->assertSame( + ['int_up', 'int_discards'], + mactrack_parse_status_tokens('int_up,bogus,DROP TABLE,int_discards') + ); + } + + /** + * @return void + */ + public function test_empty_string_returns_empty_list(): void { + $this->assertSame([], mactrack_parse_status_tokens('')); + } + + /** + * @return void + */ + public function test_null_returns_empty_list(): void { + $this->assertSame([], mactrack_parse_status_tokens(null)); + } + + /** + * @return void + */ + public function test_all_invalid_returns_empty_list(): void { + $this->assertSame([], mactrack_parse_status_tokens('bogus,,int_bogus')); + } +} diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index f2ac10c9..cad4377a 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -453,6 +453,29 @@ function sql_save($array, $table, $key = 'id') { define('MESSAGE_LEVEL_ERROR', 1); } +/* + * MacTrack's list views render cells through Cacti's form_selectable_cell() + * and gate the status-pill detail on CACTI_VERSION via cacti_version_compare(). + * Neither Cacti itself nor its constants are loaded in the isolated unit + * process, so stub them here. CACTI_VERSION is pinned to a release that + * supports the pill detail so mactrack_interfaces_show_issues() resolves true. + */ +if (!function_exists('form_selectable_cell')) { + function form_selectable_cell($contents, $id = '', $width = '', $align = '', $title = '') { + print '' . $contents . ''; + } +} + +if (!function_exists('cacti_version_compare')) { + function cacti_version_compare($version1, $version2, $operator = '>=') { + return version_compare((string) $version1, (string) $version2, $operator); + } +} + +if (!defined('CACTI_VERSION')) { + define('CACTI_VERSION', '1.2.32'); +} + if (!function_exists('plugin_test_read_source')) { function plugin_test_read_source($relative_file) { $path = realpath(__DIR__ . '/../' . $relative_file);