From fdd8cf2a76203025e3b189ebda7911b819f25f81 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 10 Oct 2026 17:28:08 -0400 Subject: [PATCH 1/5] mactrack: show MAC authorization as a themed status pill On the MAC Addresses tab, render the authorized column as a status pill consistent with the interface/802.1x pills: green 'Authorized' / red 'Unauthorized' via new .mactrackPill.mac_authorized/.mac_unauthorized colors in all 8 theme stylesheets. Markup is produced by a unit-tested mactrack_mac_auth_pill() helper; the two view call sites are view glue. POT + CHANGELOG updated. --- CHANGELOG.md | 1 + css/classic.css | 2 ++ css/dark.css | 2 ++ css/deepness.css | 2 ++ css/mactrack.css | 2 ++ css/modern.css | 2 ++ css/paper-plane.css | 2 ++ css/paw.css | 2 ++ css/sunrise.css | 2 ++ lib/mactrack_functions.php | 21 +++++++++++++++++++++ locales/po/cacti.pot | 18 +++++++++--------- mactrack_view_macs.php | 18 ++++++++---------- tests/Unit/MactrackStatusPillsTest.php | 22 ++++++++++++++++++++++ 13 files changed, 77 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c29fcde5..794a7fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* feature: On the MAC Addresses tab, show the authorization state as a themed status pill (green "Authorized" / red "Unauthorized") consistent with the interface and 802.1x pills, instead of plain coloured text * 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 diff --git a/css/classic.css b/css/classic.css index 4a6f0989..789fef37 100644 --- a/css/classic.css +++ b/css/classic.css @@ -237,4 +237,6 @@ .mactrackPill.dot1x_running { --pill: #93302a; } .mactrackPill.dot1x_idle { --pill: #9a6a1a; } .mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } +.mactrackPill.mac_authorized { --pill: #2b8435; } +.mactrackPill.mac_unauthorized { --pill: #a12820; } diff --git a/css/dark.css b/css/dark.css index c774897e..eae0a038 100644 --- a/css/dark.css +++ b/css/dark.css @@ -237,3 +237,5 @@ .mactrackPill.dot1x_running { --pill: #93302a; } .mactrackPill.dot1x_idle { --pill: #9a6a1a; } .mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } +.mactrackPill.mac_authorized { --pill: #2b8435; } +.mactrackPill.mac_unauthorized { --pill: #a12820; } diff --git a/css/deepness.css b/css/deepness.css index 1920e902..d5cb8aef 100644 --- a/css/deepness.css +++ b/css/deepness.css @@ -237,3 +237,5 @@ .mactrackPill.dot1x_running { --pill: #93302a; } .mactrackPill.dot1x_idle { --pill: #9a6a1a; } .mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } +.mactrackPill.mac_authorized { --pill: #197a54; } +.mactrackPill.mac_unauthorized { --pill: #a12820; } diff --git a/css/mactrack.css b/css/mactrack.css index d6c7c75a..4271dfb3 100644 --- a/css/mactrack.css +++ b/css/mactrack.css @@ -165,4 +165,6 @@ .mactrackPill.dot1x_running { --pill: #a85712; } .mactrackPill.dot1x_idle { --pill: #5f5f57; } .mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } +.mactrackPill.mac_authorized { --pill: #2b8435; } +.mactrackPill.mac_unauthorized { --pill: #a12820; } diff --git a/css/modern.css b/css/modern.css index c1ee8748..2f210e92 100644 --- a/css/modern.css +++ b/css/modern.css @@ -244,3 +244,5 @@ .mactrackPill.dot1x_running { --pill: #93302a; } .mactrackPill.dot1x_idle { --pill: #9a6a1a; } .mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } +.mactrackPill.mac_authorized { --pill: #2b8435; } +.mactrackPill.mac_unauthorized { --pill: #a12820; } diff --git a/css/paper-plane.css b/css/paper-plane.css index d86db3a2..da2213ef 100644 --- a/css/paper-plane.css +++ b/css/paper-plane.css @@ -237,3 +237,5 @@ .mactrackPill.dot1x_running { --pill: #93302a; } .mactrackPill.dot1x_idle { --pill: #9a6a1a; } .mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } +.mactrackPill.mac_authorized { --pill: #2b8435; } +.mactrackPill.mac_unauthorized { --pill: #a12820; } diff --git a/css/paw.css b/css/paw.css index d86db3a2..da2213ef 100644 --- a/css/paw.css +++ b/css/paw.css @@ -237,3 +237,5 @@ .mactrackPill.dot1x_running { --pill: #93302a; } .mactrackPill.dot1x_idle { --pill: #9a6a1a; } .mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } +.mactrackPill.mac_authorized { --pill: #2b8435; } +.mactrackPill.mac_unauthorized { --pill: #a12820; } diff --git a/css/sunrise.css b/css/sunrise.css index 5eb89a5d..c7b02035 100644 --- a/css/sunrise.css +++ b/css/sunrise.css @@ -237,3 +237,5 @@ .mactrackPill.dot1x_running { --pill: #93302a; } .mactrackPill.dot1x_idle { --pill: #9a6a1a; } .mactrackPill.dot1x_auth_no_method { --pill: #5f5f57; } +.mactrackPill.mac_authorized { --pill: #2b8435; } +.mactrackPill.mac_unauthorized { --pill: #a12820; } diff --git a/lib/mactrack_functions.php b/lib/mactrack_functions.php index e1729ee2..5c4e0f16 100644 --- a/lib/mactrack_functions.php +++ b/lib/mactrack_functions.php @@ -3582,6 +3582,27 @@ function mactrack_dot1x_status_pill($port_result): string { ''; } +/** + * Renders the authorization status of a MAC address (the MAC Addresses + * tab) as a themed status pill: green when authorized, red when not, + * mirroring the 802.1x status pill styling so the two tabs stay + * visually consistent. + * + * @param bool $authorized Whether the MAC address is authorized. + * + * @return string The HTML markup for the status pill. + */ +function mactrack_mac_auth_pill($authorized): string { + $class = $authorized ? 'mac_authorized' : 'mac_unauthorized'; + $label = $authorized ? __('Authorized', 'mactrack') : __('Unauthorized', 'mactrack'); + + return '
' . + '' . + '
'; +} + /** * Renders a single 802.1x results-list table row (action icons, device * name/hostname, username, IP address, optional DNS hostname, MAC diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index f10285aa..7d3dd179 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-09 17:42-0400\n" +"POT-Creation-Date: 2026-10-10 17:26-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -159,6 +159,14 @@ msgstr "" msgid "Unknown" msgstr "" +#: lib/mactrack_functions.php mactrack_view_macs.php +msgid "Authorized" +msgstr "" + +#: lib/mactrack_functions.php +msgid "Unauthorized" +msgstr "" + #: lib/mactrack_functions.php msgid "Data" msgstr "" @@ -1891,10 +1899,6 @@ msgstr "" msgid "Click 'Continue' to Delete the following rows from Aggregated table." msgstr "" -#: mactrack_view_macs.php -msgid "Authorized" -msgstr "" - #: mactrack_view_macs.php msgid "VLAN ID" msgstr "" @@ -1907,10 +1911,6 @@ msgstr "" msgid "MAC Addresses" msgstr "" -#: mactrack_view_macs.php -msgid "Not Authorized" -msgstr "" - #: mactrack_view_macs.php msgid "You must choose a Site, Device or other search criteria." msgstr "" diff --git a/mactrack_view_macs.php b/mactrack_view_macs.php index 66de3cbc..edb342ee 100644 --- a/mactrack_view_macs.php +++ b/mactrack_view_macs.php @@ -965,11 +965,10 @@ function mactrack_view_macs(): void { form_selectable_cell(filter_value($port_result['dns_hostname'], get_request_var('filter')), $key); } - if ($port_result['mac_id'] > 0) { - $auth = '' . __('Authorized', 'mactrack'); - } else { - $auth = '' . __('Not Authorized', 'mactrack'); - } + // View glue: pill markup is unit-tested via mactrack_mac_auth_pill(). + // @codeCoverageIgnoreStart + $auth = mactrack_mac_auth_pill($port_result['mac_id'] > 0); + // @codeCoverageIgnoreEnd // echo get_request_var('filter') . "
"; form_selectable_cell(filter_value(mactrack_format_mac($port_result['mac_address']), get_request_var('filter')), $key); @@ -1206,11 +1205,10 @@ function mactrack_view_aggregated_macs(): void { form_selectable_cell(filter_value($port_result['dns_hostname'], get_request_var('filter')), $key); } - if ($port_result['mac_id'] > 0) { - $auth = '' . __('Authorized', 'mactrack'); - } else { - $auth = '' . __('Not Authorized', 'mactrack'); - } + // View glue: pill markup is unit-tested via mactrack_mac_auth_pill(). + // @codeCoverageIgnoreStart + $auth = mactrack_mac_auth_pill($port_result['mac_id'] > 0); + // @codeCoverageIgnoreEnd form_selectable_cell(filter_value(mactrack_format_mac($port_result['mac_address']), get_request_var('filter')), $key); form_selectable_cell($auth, $key); diff --git a/tests/Unit/MactrackStatusPillsTest.php b/tests/Unit/MactrackStatusPillsTest.php index ab29666b..1479461f 100644 --- a/tests/Unit/MactrackStatusPillsTest.php +++ b/tests/Unit/MactrackStatusPillsTest.php @@ -265,4 +265,26 @@ public function test_format_interface_row_inserts_issue_pills_after_description( $this->assertGreaterThan($pos_descr, $pos_pill); $this->assertGreaterThan($pos_pill, $pos_alias); } + + /** + * @return void + */ + public function test_mac_auth_pill_authorized_uses_green_class_and_label(): void { + $html = mactrack_mac_auth_pill(true); + + $this->assertStringContainsString('class="mactrackPills"', $html); + $this->assertStringContainsString('mactrackPill mactrackPillActive mac_authorized', $html); + $this->assertStringContainsString('Authorized', $html); + $this->assertStringNotContainsString('mac_unauthorized', $html); + } + + /** + * @return void + */ + public function test_mac_auth_pill_unauthorized_uses_red_class_and_label(): void { + $html = mactrack_mac_auth_pill(false); + + $this->assertStringContainsString('mac_unauthorized', $html); + $this->assertStringContainsString('Unauthorized', $html); + } } From 68855bce3f71c2c61edceafe4f4bb837c0b4f15d Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 10 Oct 2026 17:55:42 -0400 Subject: [PATCH 2/5] mactrack: add a Dashboard tab with admin status cards New first MacTrack tab presenting admin metrics as a draggable, add/remove card grid in the same layout as the syslog Status tab (self-contained bundled css/cards.css + js/cards.js, no cross-plugin dependency; theme-adaptive via currentColor). Cards: inventory, device status (bars), address/port totals, collection settings, scan runtimes + slowest devices, database table sizes, MacWatch activity, vendor breakdown. Layout persists per user (settings_user) via AJAX dash_card/dash_layout endpoints. Registers the page (Viewer realm), prepends the Dashboard tab, loads assets in page_head, adds a breadcrumb. mactrack_tabs() unit-tested; POT/manifest/CHANGELOG updated. --- CHANGELOG.md | 1 + css/cards.css | 276 ++++++++++++++++ js/cards.js | 342 +++++++++++++++++++ lib/mactrack_functions.php | 6 +- locales/po/cacti.pot | 251 +++++++++++++- mactrack_view_dashboard.php | 569 ++++++++++++++++++++++++++++++++ manifest.json | 1 + setup.php | 12 +- tests/Unit/MactrackTabsTest.php | 53 +++ 9 files changed, 1491 insertions(+), 20 deletions(-) create mode 100644 css/cards.css create mode 100644 js/cards.js create mode 100644 mactrack_view_dashboard.php create mode 100644 tests/Unit/MactrackTabsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 794a7fe8..ce4d800f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* feature: Add a Dashboard tab (the first MacTrack tab) presenting admin status cards in a draggable, add/remove grid like the syslog Status tab: inventory and device status, address/port totals, collection settings, scan runtimes (with slowest devices), database table sizes, MacWatch activity and vendor breakdown; card layout persists per user * feature: On the MAC Addresses tab, show the authorization state as a themed status pill (green "Authorized" / red "Unauthorized") consistent with the interface and 802.1x pills, instead of plain coloured text * 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 diff --git a/css/cards.css b/css/cards.css new file mode 100644 index 00000000..c61bdbd9 --- /dev/null +++ b/css/cards.css @@ -0,0 +1,276 @@ +/* + +-------------------------------------------------------------------------+ + | Copyright (C) 2004-2026 The Cacti Group | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDtool-based Graphing Solution | + +-------------------------------------------------------------------------+ +*/ + +/* + * MacTrack Dashboard card chrome. A grid of status cards with a draggable + * header, per-card tools and optional expand. Colours derive from currentColor + * (the active theme's text colour) via color-mix, so the cards follow every + * theme without per-theme overrides. See js/cards.js for the behaviour and + * mactrack_view_dashboard.php for the markup. + */ + +.mtdashToolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 10px 4px 0; +} + +.mtdashToolbarLabel { + font-size: 12px; + font-weight: 600; +} + +.mtdashAdd { + box-sizing: border-box; + min-width: 220px; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, currentColor 28%, transparent); + border-radius: 7px; + background: color-mix(in srgb, currentColor 4%, transparent); + color: inherit; + font-size: 12px; +} + +#mactrack_dashboard.mtdashGrid { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + align-items: start; + gap: 14px; + margin: 10px 4px; + font-variant-numeric: tabular-nums; +} + +.mtdashSpan1 { grid-column: span 2; } +.mtdashSpan2 { grid-column: span 2; } +.mtdashSpan3 { grid-column: span 4; } +.mtdashSpan4 { grid-column: span 4; } + +@media (min-width: 1200px) { + .mtdashSpan1 { grid-column: span 1; } + .mtdashSpan2 { grid-column: span 2; } + .mtdashSpan3 { grid-column: span 3; } + .mtdashSpan4 { grid-column: span 4; } +} + +.mtdashCard { + box-sizing: border-box; + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + background: color-mix(in srgb, currentColor 5%, transparent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + border-radius: 4px; + overflow: hidden; +} + +.mtdashCardDragging { + opacity: 0.5; +} + +.mtdashCardHeader { + box-sizing: border-box; + /* fixed row height so a tool/handle hover highlight never reflows the header */ + min-height: 34px; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); + background: color-mix(in srgb, currentColor 9%, transparent); +} + +.mtdashCardDrag { + flex: 0 0 auto; + box-sizing: border-box; + padding: 2px 5px; + /* reserve the border so a hover/focus highlight recolours it instead of resizing the header */ + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: color-mix(in srgb, currentColor 50%, transparent); + font-size: 13px; + line-height: 1; + cursor: grab; +} + +.mtdashCardDrag:hover, +.mtdashCardDrag:focus-visible { + color: currentColor; + border-color: transparent; + background: color-mix(in srgb, currentColor 12%, transparent); + outline: none; +} + +.mtdashCardDrag:active { + cursor: grabbing; +} + +.mtdashCardTitle { + flex: 1 1 auto; + min-width: 0; + margin: 0; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mtdashCardTools { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 1px; +} + +.mtdashCardTool { + box-sizing: border-box; + padding: 2px 5px; + /* reserve the border so a hover/focus highlight recolours it instead of resizing the header */ + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: color-mix(in srgb, currentColor 50%, transparent); + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +.mtdashCardTool:hover, +.mtdashCardTool:focus-visible { + color: currentColor; + border-color: transparent; + background: color-mix(in srgb, currentColor 12%, transparent); + outline: none; +} + +/* Only the control matching the card's current state is shown. */ +.mtdashCard:not(.mtdashCardExpanded) .mtdashCardTool[data-tool="collapse"], +.mtdashCardExpanded .mtdashCardTool[data-tool="expand"] { + display: none; +} + +.mtdashCardBody { + padding: 6px 12px 10px; + overflow-x: auto; +} + +/* Expandable cards clip to a fixed height until "show more"; a fade hints at the + clipped content. */ +.mtdashCardExpandable:not(.mtdashCardExpanded) .mtdashCardBody { + position: relative; + max-height: 220px; + overflow: hidden; +} + +.mtdashCardExpandable:not(.mtdashCardExpanded) .mtdashCardBody::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 30px; + pointer-events: none; + background: linear-gradient(transparent, color-mix(in srgb, currentColor 5%, transparent)); +} + +.mtdashTable { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.mtdashTable th, +.mtdashTable td { + padding: 6px 8px; + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} + +.mtdashTable thead th { + font-weight: 600; + border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); +} + +.mtdashKv tbody th { + font-weight: normal; + color: color-mix(in srgb, currentColor 75%, transparent); +} + +.mtdashKv td { + text-align: right; + font-weight: 600; +} + +.mtdashTable tbody tr + tr > th, +.mtdashTable tbody tr + tr > td { + border-top: 1px solid color-mix(in srgb, currentColor 9%, transparent); +} + +.mtdashEmpty { + color: color-mix(in srgb, currentColor 60%, transparent); + font-style: italic; +} + +/* Proportion bars (device status, table sizes). */ +.mtdashBars { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mtdashBarRow { + display: flex; + flex-direction: column; + gap: 3px; +} + +.mtdashBarHead { + display: flex; + justify-content: space-between; + font-size: 12px; +} + +.mtdashBar, +.mtdashInlineBar { + position: relative; + height: 8px; + border-radius: 4px; + background: color-mix(in srgb, currentColor 12%, transparent); + overflow: hidden; +} + +.mtdashInlineBar { + display: block; + margin-bottom: 2px; +} + +.mtdashBarFill { + display: block; + height: 100%; + border-radius: 4px; + background: color-mix(in srgb, currentColor 45%, transparent); +} + +.mtdashBarFill.mtdash-up { background: #2b8435; } +.mtdashBarFill.mtdash-warn { background: #a85712; } +.mtdashBarFill.mtdash-down { background: #a12820; } +.mtdashBarFill.mtdash-none { background: color-mix(in srgb, currentColor 40%, transparent); } + +.mtdashDialogBody { + font-variant-numeric: tabular-nums; +} + +.mtdashDialogBody .mtdashTable { + font-size: 13px; +} diff --git a/js/cards.js b/js/cards.js new file mode 100644 index 00000000..f1f9b843 --- /dev/null +++ b/js/cards.js @@ -0,0 +1,342 @@ +/* + +-------------------------------------------------------------------------+ + | Copyright (C) 2004-2026 The Cacti Group | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDtool-based Graphing Solution | + +-------------------------------------------------------------------------+ +*/ + +/* + * MacTrack Dashboard card controller. Wires the card grid on the Dashboard tab: + * drag/keyboard reordering, the per-card tools (show more/less, maximize, + * refresh, remove) and the "Add card" catalogue. Layout changes persist + * server-side (settings_user) so they survive page revisits. The card markup + * is rendered by mactrack_view_dashboard.php; this file only drives behaviour. + */ + +var mactrackDashboardDragging = null; + +function initMactrackDashboard() { + $(function() { + var grid = document.getElementById('mactrack_dashboard'); + + if (!grid) { + return; + } + + grid.querySelectorAll('.mtdashCard').forEach(function(card) { + mactrackDashboardBindCard(grid, card); + }); + + grid.addEventListener('dragover', function(event) { + if (!mactrackDashboardDragging) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + + var before = mactrackDashboardDragReference(grid, event.clientX, event.clientY); + + if (before == null) { + grid.appendChild(mactrackDashboardDragging); + } else if (before !== mactrackDashboardDragging) { + grid.insertBefore(mactrackDashboardDragging, before); + } + }); + + // Per-card tool buttons bubble to the grid. + $(grid).off('click.mtdash').on('click.mtdash', '.mtdashCardTool', function() { + var card = this.closest('.mtdashCard'); + + if (!card) { + return; + } + + switch (this.dataset.tool) { + case 'expand': card.classList.add('mtdashCardExpanded'); mactrackDashboardSaveLayout(grid); break; + case 'collapse': card.classList.remove('mtdashCardExpanded'); mactrackDashboardSaveLayout(grid); break; + case 'maximize': mactrackDashboardMaximize(card); break; + case 'refresh': mactrackDashboardRefreshCard(grid, card); break; + case 'remove': mactrackDashboardRemoveCard(grid, card); break; + } + }); + + var add = document.getElementById('mactrack_dashboard_add'); + + if (add) { + $(add).off('change.mtdash').on('change.mtdash', function() { + var key = this.value; + this.value = ''; + + if (key) { + mactrackDashboardAddCard(grid, key); + } + }); + } + }); +} + +/** Wire a single card's drag handle (mouse + keyboard) and drag events. */ +function mactrackDashboardBindCard(grid, card) { + var handle = card.querySelector('.mtdashCardDrag'); + + if (!handle) { + return; + } + + // Arm HTML5 drag only from the handle, and disarm on release when no drag + // began (a plain click/tap) so selecting content elsewhere can't drag the card. + var disarm = function() { card.draggable = false; }; + + handle.addEventListener('mousedown', function() { + card.draggable = true; + document.addEventListener('mouseup', disarm, {once: true}); + }); + + handle.addEventListener('touchstart', function() { + card.draggable = true; + document.addEventListener('touchend', disarm, {once: true}); + document.addEventListener('touchcancel', disarm, {once: true}); + }, {passive: true}); + + // Keyboard-accessible reordering: move the card with the arrow keys. + handle.addEventListener('keydown', function(event) { + var back = event.key === 'ArrowLeft' || event.key === 'ArrowUp'; + var fwd = event.key === 'ArrowRight' || event.key === 'ArrowDown'; + + if (!back && !fwd) { + return; + } + + event.preventDefault(); + + if (back && card.previousElementSibling) { + grid.insertBefore(card, card.previousElementSibling); + } else if (fwd && card.nextElementSibling) { + grid.insertBefore(card.nextElementSibling, card); + } else { + return; + } + + handle.focus(); + mactrackDashboardSaveLayout(grid); + }); + + card.addEventListener('dragstart', function(event) { + mactrackDashboardDragging = card; + card.classList.add('mtdashCardDragging'); + event.dataTransfer.effectAllowed = 'move'; + try { event.dataTransfer.setData('text/plain', card.dataset.card || ''); } catch (error) { /* IE guard */ } + }); + + card.addEventListener('dragend', function() { + card.draggable = false; + card.classList.remove('mtdashCardDragging'); + + if (mactrackDashboardDragging) { + mactrackDashboardDragging = null; + mactrackDashboardSaveLayout(grid); + } + }); +} + +/** The card the dragged card should be inserted before for the current pointer + * position, or null to append at the end. */ +function mactrackDashboardDragReference(grid, x, y) { + var cards = Array.prototype.slice.call(grid.querySelectorAll('.mtdashCard:not(.mtdashCardDragging)')); + + for (var i = 0; i < cards.length; i++) { + var rect = cards[i].getBoundingClientRect(); + + if (y < rect.top - 1) { + return cards[i]; + } + + if (y <= rect.bottom && x < rect.left + rect.width / 2) { + return cards[i]; + } + } + + return null; +} + +/** Add a card from the catalogue: fetch its HTML, append it, persist and + * refresh the "Add" options. */ +function mactrackDashboardAddCard(grid, key) { + if (mactrackDashboardHasCard(grid, key)) { + return; + } + + $.post('mactrack_view_dashboard.php', {action: 'dash_card', card: key, __csrf_magic: csrfMagicToken}, null, 'json').done(function(data) { + if (!data || !data.html || mactrackDashboardHasCard(grid, key)) { + return; + } + + var card = mactrackDashboardParseCard(data.html); + + if (!card) { + return; + } + + grid.appendChild(card); + mactrackDashboardBindCard(grid, card); + mactrackDashboardSaveLayout(grid); + mactrackDashboardSyncAddOptions(grid); + }); +} + +/** Whether a card with the given key is currently on the grid. */ +function mactrackDashboardHasCard(grid, key) { + var present = false; + + grid.querySelectorAll('.mtdashCard').forEach(function(card) { + if (card.dataset.card === key) { + present = true; + } + }); + + return present; +} + +/** Remove a card from the page and return it to the "Add" catalogue. */ +function mactrackDashboardRemoveCard(grid, card) { + card.parentNode.removeChild(card); + mactrackDashboardSaveLayout(grid); + mactrackDashboardSyncAddOptions(grid); +} + +/** Re-fetch a single card's HTML and swap it in place, keeping expanded state. */ +function mactrackDashboardRefreshCard(grid, card, done) { + var icon = card.querySelector('.mtdashCardTool[data-tool="refresh"] .fa'); + + if (icon) { + icon.classList.add('fa-spin'); + } + + var settle = function() { + if (icon) { + icon.classList.remove('fa-spin'); + } + + if (done) { + done(); + } + }; + + var expanded = card.classList.contains('mtdashCardExpanded') ? '1' : '0'; + + $.post('mactrack_view_dashboard.php', {action: 'dash_card', card: card.dataset.card, expanded: expanded, __csrf_magic: csrfMagicToken}, null, 'json').done(function(data) { + if (!data || !data.html || !card.parentNode) { + settle(); + return; + } + + var fresh = mactrackDashboardParseCard(data.html); + + if (!fresh) { + settle(); + return; + } + + card.parentNode.replaceChild(fresh, card); + mactrackDashboardBindCard(grid, fresh); + + if (done) { + done(); + } + }).fail(settle); +} + +/** Open a copy of a card's body in a large modal dialog. */ +function mactrackDashboardMaximize(card) { + var dialog = document.getElementById('mactrack_dashboard_dialog'); + + if (!dialog) { + return; + } + + var title = card.querySelector('.mtdashCardTitle'); + var body = card.querySelector('.mtdashCardBody'); + + dialog.innerHTML = '
' + (body ? body.innerHTML : '') + '
'; + + $(dialog).dialog({ + modal: true, + appendTo: 'body', + width: Math.min(900, $(window).width() - 40), + title: title ? title.textContent : '' + }); +} + +/** Parse a card HTML string into its
element. */ +function mactrackDashboardParseCard(html) { + var tmp = document.createElement('div'); + tmp.innerHTML = html; + + return tmp.querySelector('.mtdashCard'); +} + +/** Rebuild the "Add" dropdown so it lists only cards not currently on the page. */ +function mactrackDashboardSyncAddOptions(grid) { + var add = document.getElementById('mactrack_dashboard_add'); + + if (!add) { + return; + } + + var present = {}; + + grid.querySelectorAll('.mtdashCard').forEach(function(card) { + if (card.dataset.card) { + present[card.dataset.card] = true; + } + }); + + // Options for present cards are removed; absent ones are kept/restored by + // leaving the server-rendered option list and only hiding present ones. + for (var i = 1; i < add.options.length; i++) { + add.options[i].hidden = !!present[add.options[i].value]; + } +} + +/** Serialize layout POSTs so rapid actions can't persist out of order. */ +var mactrackDashboardSaveInFlight = false; +var mactrackDashboardSaveQueued = false; + +function mactrackDashboardSaveLayout(grid) { + if (mactrackDashboardSaveInFlight) { + mactrackDashboardSaveQueued = true; + return; + } + + mactrackDashboardSaveInFlight = true; + + var order = []; + var expanded = {}; + + grid.querySelectorAll('.mtdashCard').forEach(function(card) { + if (!card.dataset.card) { + return; + } + + order.push(card.dataset.card); + + if (card.classList.contains('mtdashCardExpanded')) { + expanded[card.dataset.card] = true; + } + }); + + $.post('mactrack_view_dashboard.php', { + action: 'dash_layout', + layout: JSON.stringify({order: order, expanded: expanded}), + __csrf_magic: csrfMagicToken + }, null, 'json').always(function() { + mactrackDashboardSaveInFlight = false; + + if (mactrackDashboardSaveQueued) { + mactrackDashboardSaveQueued = false; + mactrackDashboardSaveLayout(grid); + } + }); +} diff --git a/lib/mactrack_functions.php b/lib/mactrack_functions.php index 5c4e0f16..051b6939 100644 --- a/lib/mactrack_functions.php +++ b/lib/mactrack_functions.php @@ -4317,8 +4317,9 @@ function mactrack_sanitize_load_report(): void { } /** - * Renders the top-level MacTrack tab bar (Sites, Devices, IP Ranges, IP - * Address, MAC Address, Interfaces, Dot1x, Graphs), highlighting the + * Renders the top-level MacTrack tab bar (Dashboard, Sites, Devices, IP + * Ranges, IP Address, MAC Address, Interfaces, Dot1x, Graphs), + * highlighting the * currently selected tab as resolved by mactrack_sanitize_load_report(). * * @return void @@ -4331,6 +4332,7 @@ function mactrack_tabs(): void { // present a tabbed interface $tabs_mactrack = [ + 'dashboard' => __('Dashboard', 'mactrack'), 'sites' => __('Sites', 'mactrack'), 'devices' => __('Devices', 'mactrack'), 'ips' => __('IP Ranges', 'mactrack'), diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 7d3dd179..c542063f 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-10 17:26-0400\n" +"POT-Creation-Date: 2026-10-10 17:52-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -128,13 +128,13 @@ msgstr "" msgid "Since Restart" msgstr "" -#: lib/mactrack_functions.php mactrack_devices.php mactrack_view_devices.php -#: mactrack_view_interfaces.php +#: lib/mactrack_functions.php mactrack_devices.php mactrack_view_dashboard.php +#: mactrack_view_devices.php mactrack_view_interfaces.php msgid "Down" msgstr "" -#: lib/mactrack_functions.php mactrack_devices.php mactrack_view_devices.php -#: mactrack_view_interfaces.php +#: lib/mactrack_functions.php mactrack_devices.php mactrack_view_dashboard.php +#: mactrack_view_devices.php mactrack_view_interfaces.php msgid "Up" msgstr "" @@ -155,7 +155,7 @@ msgid "Graphless" msgstr "" #: lib/mactrack_functions.php mactrack_device_types.php mactrack_devices.php -#: mactrack_view_devices.php setup.php +#: mactrack_view_dashboard.php mactrack_view_devices.php setup.php msgid "Unknown" msgstr "" @@ -238,7 +238,8 @@ msgid "Status: Idle, LastRuntime: %2.1f seconds, Processes: %d processes, Device msgstr "" #: lib/mactrack_functions.php mactrack_device_types.php mactrack_devices.php -#: mactrack_utilities.php mactrack_view_devices.php setup.php +#: mactrack_utilities.php mactrack_view_dashboard.php mactrack_view_devices.php +#: setup.php msgid "Disabled" msgstr "" @@ -255,14 +256,18 @@ msgstr "" msgid "Not Detected" msgstr "" -#: lib/mactrack_functions.php mactrack_sites.php mactrack_view_sites.php -#: setup.php +#: lib/mactrack_functions.php +msgid "Dashboard" +msgstr "" + +#: lib/mactrack_functions.php mactrack_sites.php mactrack_view_dashboard.php +#: mactrack_view_sites.php setup.php msgid "Sites" msgstr "" #: lib/mactrack_functions.php mactrack_device_types.php mactrack_devices.php -#: mactrack_sites.php mactrack_utilities.php mactrack_view_devices.php -#: mactrack_view_sites.php setup.php +#: mactrack_sites.php mactrack_utilities.php mactrack_view_dashboard.php +#: mactrack_view_devices.php mactrack_view_sites.php setup.php msgid "Devices" msgstr "" @@ -684,8 +689,8 @@ msgstr "" msgid "Export Device Types to Share with Others" msgstr "" -#: mactrack_device_types.php mactrack_sites.php mactrack_view_sites.php -#: setup.php +#: mactrack_device_types.php mactrack_sites.php mactrack_view_dashboard.php +#: mactrack_view_sites.php setup.php msgid "Vendor" msgstr "" @@ -700,7 +705,7 @@ msgstr "" msgid "Type" msgstr "" -#: mactrack_device_types.php mactrack_utilities.php +#: mactrack_device_types.php mactrack_utilities.php mactrack_view_dashboard.php msgid "Enabled" msgstr "" @@ -963,7 +968,7 @@ msgstr "" msgid "None" msgstr "" -#: mactrack_devices.php mactrack_view_devices.php +#: mactrack_devices.php mactrack_view_dashboard.php mactrack_view_devices.php msgid "Error" msgstr "" @@ -1226,7 +1231,7 @@ msgstr "" msgid "Mactrack Process Status" msgstr "" -#: mactrack_utilities.php +#: mactrack_utilities.php mactrack_view_dashboard.php msgid "Refresh" msgstr "" @@ -1290,7 +1295,7 @@ msgstr "" msgid "Maximum Per Device Scan Time:" msgstr "" -#: mactrack_utilities.php +#: mactrack_utilities.php mactrack_view_dashboard.php msgid "minutes" msgstr "" @@ -1558,6 +1563,214 @@ msgstr "" msgid "MAC" msgstr "" +#: mactrack_view_dashboard.php +msgid "MacTrack - Dashboard" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Inventory" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Device status" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Address and port totals" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Collection settings" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Scan runtimes" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Table sizes" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "MacWatch activity" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Vendors" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Enabled devices" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Disabled devices" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Device types" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Interfaces tracked" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Active MACs" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "IP addresses" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Total ports" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Active ports" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Trunk ports" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Unique OUI vendors" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Scanning frequency" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Concurrent processes" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Scanner max runtime" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "MAC retention" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "days" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "IP retention" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Reverse DNS" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Most recent scan" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Average duration" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Longest duration" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Scanned in last hour" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Duration" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Last scan" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Slowest devices" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "No scan runtimes recorded yet." +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Rows" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Size" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Table" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "No table statistics available." +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Watched MACs" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Discovered" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Last match" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "OUI database entries" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "No vendor data available." +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Show more" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Show less" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Open in a dialog" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Remove from page" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Drag to reorder card" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Invalid request. Please try again." +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Unknown card." +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Add card" +msgstr "" + +#: mactrack_view_dashboard.php +msgid "Add a dashboard card…" +msgstr "" + #: mactrack_view_devices.php msgid "Mactrack - Device Report View" msgstr "" @@ -2292,6 +2505,10 @@ msgstr "" msgid "If your Internet Proxy requires a password, enter it here." msgstr "" +#: setup.php +msgid "Mactrack Dashboard" +msgstr "" + #: setup.php msgid "Mactrack Devices" msgstr "" diff --git a/mactrack_view_dashboard.php b/mactrack_view_dashboard.php new file mode 100644 index 00000000..3b51fa33 --- /dev/null +++ b/mactrack_view_dashboard.php @@ -0,0 +1,569 @@ + definition. + */ +function mactrack_dashboard_card_meta(): array { + return [ + 'inventory' => ['title' => __('Inventory', 'mactrack'), 'span' => 2, 'expandable' => false], + 'device_status' => ['title' => __('Device status', 'mactrack'), 'span' => 2, 'expandable' => false], + 'address_totals' => ['title' => __('Address and port totals', 'mactrack'), 'span' => 2, 'expandable' => false], + 'settings' => ['title' => __('Collection settings', 'mactrack'), 'span' => 2, 'expandable' => false], + 'scan_runtimes' => ['title' => __('Scan runtimes', 'mactrack'), 'span' => 4, 'expandable' => true], + 'table_sizes' => ['title' => __('Table sizes', 'mactrack'), 'span' => 4, 'expandable' => true], + 'macwatch' => ['title' => __('MacWatch activity', 'mactrack'), 'span' => 2, 'expandable' => false], + 'vendors' => ['title' => __('Vendors', 'mactrack'), 'span' => 2, 'expandable' => true], + ]; +} + +/** + * The card keys available in this environment, in default order. + * + * @return string[] + */ +function mactrack_dashboard_available_cards(): array { + return array_keys(mactrack_dashboard_card_meta()); +} + +/** + * Formats an integer for display with thousands separators. + * + * @param mixed $value The raw value. + * + * @return string The formatted count. + */ +function mactrack_dashboard_count($value): string { + return number_format((float) $value, 0); +} + +/** + * Formats a byte count into a human-readable size (KB/MB/GB). + * + * @param mixed $bytes The raw byte count. + * + * @return string The formatted size. + */ +function mactrack_dashboard_bytes($bytes): string { + $bytes = (float) $bytes; + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = 0; + + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + + return round($bytes, $i < 2 ? 0 : 1) . ' ' . $units[$i]; +} + +/** + * Formats a duration in seconds as a compact human string. + * + * @param mixed $seconds The raw seconds. + * + * @return string The formatted duration. + */ +function mactrack_dashboard_seconds($seconds): string { + $seconds = (int) $seconds; + + if ($seconds <= 0) { + return '0s'; + } + + if ($seconds < 60) { + return $seconds . 's'; + } + + $minutes = intdiv($seconds, 60); + $rest = $seconds % 60; + + if ($minutes < 60) { + return $minutes . 'm ' . $rest . 's'; + } + + $hours = intdiv($minutes, 60); + $minutes = $minutes % 60; + + return $hours . 'h ' . $minutes . 'm'; +} + +/** + * Formats a timestamp for display, or a dash when empty. + * + * @param mixed $value The raw timestamp. + * + * @return string The formatted time, or '—' when empty. + */ +function mactrack_dashboard_time($value): string { + $value = (string) $value; + + if ($value === '' || $value === '0000-00-00 00:00:00' || $value === null) { + return '—'; + } + + return $value; +} + +/** + * Builds a two-column key/value table row for a card body. + * + * @param string $label The row label. + * @param string $value The already-formatted value. + * + * @return string The markup. + */ +function mactrack_dashboard_kv_row($label, $value): string { + return '' . html_escape($label) . '' . html_escape($value) . ''; +} + +/** + * Builds a labelled proportion bar for a card body. + * + * @param string $label The bar label. + * @param int $value The value represented. + * @param int $max The maximum (full-width) value. + * @param string $state Severity class suffix (up/warn/down/none). + * + * @return string The bar markup. + */ +function mactrack_dashboard_bar($label, $value, $max, $state = 'none'): string { + $pct = ($max > 0) ? max(0, min(100, round(($value / $max) * 100))) : 0; + + return '
' + . '
' . html_escape($label) . '' . html_escape(mactrack_dashboard_count($value)) . '
' + . '
' + . '
'; +} + +/** + * Request-scoped device/site/address aggregate counts. + * + * @return array The aggregate counts. + */ +function mactrack_dashboard_counts(): array { + static $counts = null; + + if ($counts !== null) { + return $counts; + } + + $row = db_fetch_row('SELECT + COUNT(*) AS devices, + SUM(disabled = "") AS enabled, + SUM(disabled = "on") AS disabled, + SUM(disabled = "" AND snmp_status = 3) AS up, + SUM(disabled = "" AND snmp_status = 1) AS down, + SUM(disabled = "" AND snmp_status = 0) AS unknown, + SUM(disabled = "" AND snmp_status = 4) AS error, + SUM(ports_total) AS ports_total, + SUM(ports_active) AS ports_active, + SUM(ports_trunk) AS ports_trunk, + SUM(macs_active) AS macs_active, + SUM(ips_total) AS ips_total + FROM mac_track_devices'); + + $counts = array_map('intval', is_array($row) ? $row : []); + + $counts['sites'] = (int) db_fetch_cell('SELECT COUNT(*) FROM mac_track_sites'); + $counts['device_types'] = (int) db_fetch_cell('SELECT COUNT(*) FROM mac_track_device_types'); + $counts['interfaces'] = (int) db_fetch_cell('SELECT COUNT(*) FROM mac_track_interfaces'); + $counts['oui'] = (int) db_fetch_cell('SELECT COUNT(*) FROM mac_track_oui_database'); + + return $counts; +} + +/** + * Renders the inner HTML body for a single dashboard card. + * + * @param string $key The card key. + * + * @return string The card body HTML. + */ +function mactrack_dashboard_card_body(string $key): string { + ob_start(); + + switch ($key) { + case 'inventory': + $c = mactrack_dashboard_counts(); + print ''; + print mactrack_dashboard_kv_row(__('Sites', 'mactrack'), mactrack_dashboard_count($c['sites'])); + print mactrack_dashboard_kv_row(__('Devices', 'mactrack'), mactrack_dashboard_count($c['devices'] ?? 0)); + print mactrack_dashboard_kv_row(__('Enabled devices', 'mactrack'), mactrack_dashboard_count($c['enabled'] ?? 0)); + print mactrack_dashboard_kv_row(__('Disabled devices', 'mactrack'), mactrack_dashboard_count($c['disabled'] ?? 0)); + print mactrack_dashboard_kv_row(__('Device types', 'mactrack'), mactrack_dashboard_count($c['device_types'])); + print mactrack_dashboard_kv_row(__('Interfaces tracked', 'mactrack'), mactrack_dashboard_count($c['interfaces'])); + print '
'; + + break; + case 'device_status': + $c = mactrack_dashboard_counts(); + $max = max(1, (int) ($c['enabled'] ?? 0)); + print '
'; + print mactrack_dashboard_bar(__('Up', 'mactrack'), (int) ($c['up'] ?? 0), $max, 'up'); + print mactrack_dashboard_bar(__('Down', 'mactrack'), (int) ($c['down'] ?? 0), $max, 'down'); + print mactrack_dashboard_bar(__('Unknown', 'mactrack'), (int) ($c['unknown'] ?? 0), $max, 'warn'); + print mactrack_dashboard_bar(__('Error', 'mactrack'), (int) ($c['error'] ?? 0), $max, 'warn'); + print mactrack_dashboard_bar(__('Disabled', 'mactrack'), (int) ($c['disabled'] ?? 0), max(1, (int) ($c['devices'] ?? 1)), 'none'); + print '
'; + + break; + case 'address_totals': + $c = mactrack_dashboard_counts(); + print ''; + print mactrack_dashboard_kv_row(__('Active MACs', 'mactrack'), mactrack_dashboard_count($c['macs_active'] ?? 0)); + print mactrack_dashboard_kv_row(__('IP addresses', 'mactrack'), mactrack_dashboard_count($c['ips_total'] ?? 0)); + print mactrack_dashboard_kv_row(__('Total ports', 'mactrack'), mactrack_dashboard_count($c['ports_total'] ?? 0)); + print mactrack_dashboard_kv_row(__('Active ports', 'mactrack'), mactrack_dashboard_count($c['ports_active'] ?? 0)); + print mactrack_dashboard_kv_row(__('Trunk ports', 'mactrack'), mactrack_dashboard_count($c['ports_trunk'] ?? 0)); + print mactrack_dashboard_kv_row(__('Unique OUI vendors', 'mactrack'), mactrack_dashboard_count($c['oui'])); + print '
'; + + break; + case 'settings': + $frequency = read_config_option('mt_collection_timing'); + print ''; + print mactrack_dashboard_kv_row(__('Scanning frequency', 'mactrack'), $frequency === 'disabled' || $frequency === '' ? __('Disabled', 'mactrack') : $frequency . ' ' . __('minutes', 'mactrack')); + print mactrack_dashboard_kv_row(__('Concurrent processes', 'mactrack'), (string) (read_config_option('mt_processes') ?: '—')); + print mactrack_dashboard_kv_row(__('Scanner max runtime', 'mactrack'), (read_config_option('mt_script_runtime') ?: '—') . ' ' . __('minutes', 'mactrack')); + print mactrack_dashboard_kv_row(__('MAC retention', 'mactrack'), (read_config_option('mt_data_retention') ?: '—') . ' ' . __('days', 'mactrack')); + print mactrack_dashboard_kv_row(__('IP retention', 'mactrack'), (read_config_option('mt_data_retention_ip') ?: '—') . ' ' . __('days', 'mactrack')); + print mactrack_dashboard_kv_row(__('Reverse DNS', 'mactrack'), read_config_option('mt_reverse_dns') != '' ? __('Enabled', 'mactrack') : __('Disabled', 'mactrack')); + print '
'; + + break; + case 'scan_runtimes': + $run = db_fetch_row('SELECT + MAX(last_rundate) AS last_scan, + AVG(last_runduration) AS avg_dur, + MAX(last_runduration) AS max_dur, + SUM(last_rundate >= DATE_SUB(NOW(), INTERVAL 1 HOUR)) AS last_hour + FROM mac_track_devices + WHERE disabled = ""'); + + print ''; + print mactrack_dashboard_kv_row(__('Most recent scan', 'mactrack'), mactrack_dashboard_time($run['last_scan'] ?? '')); + print mactrack_dashboard_kv_row(__('Average duration', 'mactrack'), mactrack_dashboard_seconds($run['avg_dur'] ?? 0)); + print mactrack_dashboard_kv_row(__('Longest duration', 'mactrack'), mactrack_dashboard_seconds($run['max_dur'] ?? 0)); + print mactrack_dashboard_kv_row(__('Scanned in last hour', 'mactrack'), mactrack_dashboard_count($run['last_hour'] ?? 0)); + print '
'; + + $slow = db_fetch_assoc('SELECT device_name, last_runduration, last_rundate + FROM mac_track_devices + WHERE disabled = "" AND last_runduration > 0 + ORDER BY last_runduration DESC + LIMIT 8'); + + print ''; + + if (cacti_sizeof($slow)) { + foreach ($slow as $d) { + print ''; + } + } else { + print ''; + } + + print '
' . __esc('Slowest devices', 'mactrack') . '' . __esc('Duration', 'mactrack') . '' . __esc('Last scan', 'mactrack') . '
' . html_escape($d['device_name']) . '' . html_escape(mactrack_dashboard_seconds($d['last_runduration'])) . '' . html_escape(mactrack_dashboard_time($d['last_rundate'])) . '
' . __esc('No scan runtimes recorded yet.', 'mactrack') . '
'; + + break; + case 'table_sizes': + $tables = db_fetch_assoc('SELECT TABLE_NAME AS name, TABLE_ROWS AS rows, + (DATA_LENGTH + INDEX_LENGTH) AS bytes + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE "mac\\_track%" + ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC + LIMIT 12'); + + print ''; + + if (cacti_sizeof($tables)) { + $max = 1; + foreach ($tables as $t) { + $max = max($max, (int) $t['bytes']); + } + + foreach ($tables as $t) { + $pct = round(((int) $t['bytes'] / $max) * 100); + print '' + . '' + . ''; + } + } else { + print ''; + } + + print '
' . __esc('Table', 'mactrack') . '' . __esc('Rows', 'mactrack') . '' . __esc('Size', 'mactrack') . '
' . html_escape($t['name']) . '' . html_escape(mactrack_dashboard_count($t['rows'])) . '
' . html_escape(mactrack_dashboard_bytes($t['bytes'])) . '
' . __esc('No table statistics available.', 'mactrack') . '
'; + + break; + case 'macwatch': + $mw = db_fetch_row('SELECT COUNT(*) AS entries, + SUM(discovered) AS discovered, + MAX(date_last_seen) AS last_seen + FROM mac_track_macwatch'); + + print ''; + print mactrack_dashboard_kv_row(__('Watched MACs', 'mactrack'), mactrack_dashboard_count($mw['entries'] ?? 0)); + print mactrack_dashboard_kv_row(__('Discovered', 'mactrack'), mactrack_dashboard_count($mw['discovered'] ?? 0)); + print mactrack_dashboard_kv_row(__('Last match', 'mactrack'), mactrack_dashboard_time($mw['last_seen'] ?? '')); + print '
'; + + break; + case 'vendors': + $c = mactrack_dashboard_counts(); + print ''; + print mactrack_dashboard_kv_row(__('OUI database entries', 'mactrack'), mactrack_dashboard_count($c['oui'])); + print '
'; + + $top = db_fetch_assoc('SELECT dt.vendor AS vendor, COUNT(*) AS devices + FROM mac_track_devices AS d + INNER JOIN mac_track_device_types AS dt + ON d.device_type_id = dt.device_type_id + WHERE dt.vendor != "" + GROUP BY dt.vendor + ORDER BY devices DESC + LIMIT 8'); + + print ''; + + if (cacti_sizeof($top)) { + foreach ($top as $v) { + print ''; + } + } else { + print ''; + } + + print '
' . __esc('Vendor', 'mactrack') . '' . __esc('Devices', 'mactrack') . '
' . html_escape($v['vendor']) . '' . html_escape(mactrack_dashboard_count($v['devices'])) . '
' . __esc('No vendor data available.', 'mactrack') . '
'; + + break; + } + + return (string) ob_get_clean(); +} + +/** + * Renders a complete dashboard card (header + tools + body) for the given key. + * + * @param string $key The card key. + * @param bool $expanded Whether the card starts expanded. + * + * @return string The card HTML, or '' when the key is unknown. + */ +function mactrack_dashboard_render_card(string $key, bool $expanded = false): string { + $meta = mactrack_dashboard_card_meta(); + + if (!isset($meta[$key])) { + return ''; + } + + $def = $meta[$key]; + + $tools = ''; + + if ($def['expandable']) { + $tools .= ''; + $tools .= ''; + } + + $tools .= ''; + $tools .= ''; + $tools .= ''; + + $classes = 'mtdashCard mtdashSpan' . (int) $def['span']; + + if ($def['expandable']) { + $classes .= ' mtdashCardExpandable'; + } + + if ($expanded) { + $classes .= ' mtdashCardExpanded'; + } + + return '
' + . '
' + . '' + . '

' . html_escape($def['title']) . '

' + . '' . $tools . '' + . '
' + . '
' . mactrack_dashboard_card_body($key) . '
' + . '
'; +} + +/** + * The current user's saved dashboard layout (present cards in order plus + * expanded state). With no saved layout every card is present in default order. + * + * @return array{order: string[], expanded: array} + */ +function mactrack_dashboard_layout(): array { + $available = mactrack_dashboard_available_cards(); + $saved = json_decode((string) read_user_setting('mactrack_dashboard_layout', '', true), true); + + if (!is_array($saved) || !isset($saved['order']) || !is_array($saved['order'])) { + return ['order' => $available, 'expanded' => []]; + } + + $order = []; + + foreach ($saved['order'] as $key) { + if (is_string($key) && in_array($key, $available, true) && !in_array($key, $order, true)) { + $order[] = $key; + } + } + + $expanded = []; + + if (isset($saved['expanded']) && is_array($saved['expanded'])) { + foreach ($saved['expanded'] as $key => $on) { + if ($on && in_array($key, $available, true)) { + $expanded[(string) $key] = true; + } + } + } + + return ['order' => $order, 'expanded' => $expanded]; +} + +/** + * Persists the posted dashboard layout (card order and expanded state) for the + * current user in a single settings_user row, validated to available cards. + * + * @return string A JSON status document. + */ +function mactrack_dashboard_layout_save(): string { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !function_exists('csrf_check') || !csrf_check(false)) { + return (string) json_encode(['error' => __('Invalid request. Please try again.', 'mactrack')]); + } + + $available = mactrack_dashboard_available_cards(); + $posted = json_decode(isset_request_var('layout') ? (string) get_nfilter_request_var('layout') : '', true); + + $order = []; + $expanded = []; + + if (is_array($posted)) { + if (isset($posted['order']) && is_array($posted['order'])) { + foreach ($posted['order'] as $key) { + if (is_string($key) && in_array($key, $available, true) && !in_array($key, $order, true)) { + $order[] = $key; + } + } + } + + if (isset($posted['expanded']) && is_array($posted['expanded'])) { + foreach ($posted['expanded'] as $key => $on) { + if ($on && is_string($key) && in_array($key, $available, true)) { + $expanded[$key] = true; + } + } + } + } + + set_user_setting('mactrack_dashboard_layout', json_encode(['order' => $order, 'expanded' => $expanded])); + + return (string) json_encode(['ok' => true]); +} + +/** + * AJAX: render a single dashboard card, used when adding one from the + * catalogue or refreshing one in place. + * + * @return string A JSON document with the card key and rendered HTML. + */ +function mactrack_dashboard_card_ajax(): string { + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !function_exists('csrf_check') || !csrf_check(false)) { + return (string) json_encode(['error' => __('Invalid request. Please try again.', 'mactrack')]); + } + + $key = isset_request_var('card') ? (string) get_nfilter_request_var('card') : ''; + + if (!in_array($key, mactrack_dashboard_available_cards(), true)) { + return (string) json_encode(['error' => __('Unknown card.', 'mactrack')]); + } + + $expanded = isset_request_var('expanded') && get_nfilter_request_var('expanded') === '1'; + + return (string) json_encode(['card' => $key, 'html' => mactrack_dashboard_render_card($key, $expanded)]); +} + +/** + * Renders the MacTrack Dashboard tab: the add-card toolbar and the grid of + * admin status cards. + * + * @return void + */ +function mactrack_dashboard(): void { + mactrack_tabs(); + + $layout = mactrack_dashboard_layout(); + $meta = mactrack_dashboard_card_meta(); + $absent = array_values(array_diff(mactrack_dashboard_available_cards(), $layout['order'])); + + print '
'; + + print '
'; + print ''; + print ''; + print '
'; + + print '
'; + + foreach ($layout['order'] as $key) { + print mactrack_dashboard_render_card($key, !empty($layout['expanded'][$key])); + } + + print '
'; + + // Modal container reused by the maximize tool. + print ''; + + print '
'; + + print ""; +} diff --git a/manifest.json b/manifest.json index 1a4b575c..5b3f414f 100644 --- a/manifest.json +++ b/manifest.json @@ -37,6 +37,7 @@ "mactrack_utilities.php", "mactrack_vendormacs.php", "mactrack_view_arp.php", + "mactrack_view_dashboard.php", "mactrack_view_devices.php", "mactrack_view_dot1x.php", "mactrack_view_graphs.php", diff --git a/setup.php b/setup.php index 60b2b8dc..0323b859 100644 --- a/setup.php +++ b/setup.php @@ -58,7 +58,7 @@ function plugin_mactrack_install($operator_initiated = true): bool { api_plugin_register_hook('mactrack', 'device_action_execute', 'mactrack_device_action_execute', 'mactrack_actions.php'); // Register our realms - api_plugin_register_realm('mactrack', 'mactrack_view_ips.php,mactrack_view_arp.php,mactrack_view_macs.php,mactrack_view_dot1x.php,mactrack_view_sites.php,mactrack_view_devices.php,mactrack_view_interfaces.php,mactrack_view_graphs.php,mactrack_ajax.php', 'Mactrack Viewer', 1); + api_plugin_register_realm('mactrack', 'mactrack_view_dashboard.php,mactrack_view_ips.php,mactrack_view_arp.php,mactrack_view_macs.php,mactrack_view_dot1x.php,mactrack_view_sites.php,mactrack_view_devices.php,mactrack_view_interfaces.php,mactrack_view_graphs.php,mactrack_ajax.php', 'Mactrack Viewer', 1); api_plugin_register_realm('mactrack', 'mactrack_ajax_admin.php,mactrack_devices.php,mactrack_snmp.php,mactrack_sites.php,mactrack_device_types.php,mactrack_utilities.php,mactrack_macwatch.php,mactrack_macauth.php,mactrack_vendormacs.php', 'Mactrack Administrator', 1); $site_ready = mactrack_setup_table_new($operator_initiated); @@ -453,6 +453,9 @@ function mactrack_page_head() { print get_md5_include_js('plugins/mactrack/js/mactrack.js'); print get_md5_include_js('plugins/mactrack/js/mactrack_snmp.js'); + print get_md5_include_js('plugins/mactrack/js/cards.js'); + + print get_md5_include_css('plugins/mactrack/css/cards.css'); if (file_exists($config['base_path'] . '/plugins/mactrack/css/' . get_selected_theme() . '.css')) { print get_md5_include_css('plugins/mactrack/css/' . get_selected_theme() . '.css'); @@ -889,6 +892,13 @@ function mactrack_config_settings() { * added. */ function mactrack_draw_navigation_text($nav) { + $nav['mactrack_view_dashboard.php:'] = [ + 'title' => __('Mactrack Dashboard', 'mactrack'), + 'mapping' => '', + 'url' => 'mactrack_view_dashboard.php', + 'level' => '0' + ]; + $nav['mactrack_devices.php:'] = [ 'title' => __('Mactrack Devices', 'mactrack'), 'mapping' => 'index.php:', diff --git a/tests/Unit/MactrackTabsTest.php b/tests/Unit/MactrackTabsTest.php new file mode 100644 index 00000000..99c58c33 --- /dev/null +++ b/tests/Unit/MactrackTabsTest.php @@ -0,0 +1,53 @@ +assertStringContainsString('mactrack_view_dashboard.php', $html); + $this->assertStringContainsString('Dashboard', $html); + $this->assertLessThan( + strpos($html, 'mactrack_view_sites.php'), + strpos($html, 'mactrack_view_dashboard.php') + ); + } +} From 1d5b84a46090a82454c51771a4dc9a0639f6eb89 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 10 Oct 2026 18:01:26 -0400 Subject: [PATCH 3/5] mactrack: fix dashboard CI (prepared baseline, tabs test stubs) Fold the inventory count queries into one row query (6 raw literal admin queries, recorded in the prepared-statement baseline since they take no user input); add read_user_setting/sanitize_search_string unit-test stubs so MactrackTabsTest can exercise mactrack_tabs(). --- mactrack_view_dashboard.php | 11 +++++------ tests/Security/PreparedStatementConsistencyTest.php | 1 + tests/bootstrap-unit.php | 12 ++++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/mactrack_view_dashboard.php b/mactrack_view_dashboard.php index 3b51fa33..e20ced2e 100644 --- a/mactrack_view_dashboard.php +++ b/mactrack_view_dashboard.php @@ -199,16 +199,15 @@ function mactrack_dashboard_counts(): array { SUM(ports_active) AS ports_active, SUM(ports_trunk) AS ports_trunk, SUM(macs_active) AS macs_active, - SUM(ips_total) AS ips_total + SUM(ips_total) AS ips_total, + (SELECT COUNT(*) FROM mac_track_sites) AS sites, + (SELECT COUNT(*) FROM mac_track_device_types) AS device_types, + (SELECT COUNT(*) FROM mac_track_interfaces) AS interfaces, + (SELECT COUNT(*) FROM mac_track_oui_database) AS oui FROM mac_track_devices'); $counts = array_map('intval', is_array($row) ? $row : []); - $counts['sites'] = (int) db_fetch_cell('SELECT COUNT(*) FROM mac_track_sites'); - $counts['device_types'] = (int) db_fetch_cell('SELECT COUNT(*) FROM mac_track_device_types'); - $counts['interfaces'] = (int) db_fetch_cell('SELECT COUNT(*) FROM mac_track_interfaces'); - $counts['oui'] = (int) db_fetch_cell('SELECT COUNT(*) FROM mac_track_oui_database'); - return $counts; } diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index 6ba06a0a..20a9bf2a 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -43,6 +43,7 @@ 'mactrack_utilities.php' => 25, 'mactrack_vendormacs.php' => 2, 'mactrack_view_arp.php' => 7, + 'mactrack_view_dashboard.php' => 6, 'mactrack_view_devices.php' => 4, 'mactrack_view_dot1x.php' => 3, 'mactrack_view_graphs.php' => 2, diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index cad4377a..a780b492 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -405,6 +405,18 @@ function set_request_var($name, $value) { } } +if (!function_exists('read_user_setting')) { + function read_user_setting($name, $default = false, $force = false, $user_id = 0) { + return $default; + } +} + +if (!function_exists('sanitize_search_string')) { + function sanitize_search_string($string) { + return $string; + } +} + if (!function_exists('api_user_realm_auth')) { function api_user_realm_auth($filename) { return mactrack_test_next_return('api_user_realm_auth', true); From 9692b5314944e54dd5952bfd026312247853d83f Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 10 Oct 2026 18:03:58 -0400 Subject: [PATCH 4/5] mactrack: allowlist mactrack_view_dashboard.php for patch coverage The dashboard view page runs under a live Cacti (auth + general_header) so it is not loaded by the unit suite; add it to the patch-coverage unmeasured allowlist alongside the other view pages. --- tests/bin/patch-coverage.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index fa54695d..492919e5 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -184,6 +184,7 @@ function changed_lines($base_ref) { 'mactrack_utilities.php', 'mactrack_vendormacs.php', 'mactrack_view_arp.php', + 'mactrack_view_dashboard.php', 'mactrack_view_devices.php', 'mactrack_view_dot1x.php', 'mactrack_view_graphs.php', From f8c3af6c6c6051ffb76a496f0dec5430ece8e91d Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 10 Oct 2026 18:11:06 -0400 Subject: [PATCH 5/5] mactrack: make the MAC authorization pill a working drill-down Address PR review: the pill was a focusable button with no action. It now carries data-mac-authorized and, like the interface/802.1x pills, clicking it sets the Authorized filter (#authorized) to that state and re-applies the filter, so it is no longer a no-op control. --- lib/mactrack_functions.php | 6 ++++-- mactrack_view_macs.php | 14 ++++++++++++++ tests/Unit/MactrackStatusPillsTest.php | 2 ++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/mactrack_functions.php b/lib/mactrack_functions.php index 051b6939..caf65549 100644 --- a/lib/mactrack_functions.php +++ b/lib/mactrack_functions.php @@ -3586,7 +3586,8 @@ function mactrack_dot1x_status_pill($port_result): string { * Renders the authorization status of a MAC address (the MAC Addresses * tab) as a themed status pill: green when authorized, red when not, * mirroring the 802.1x status pill styling so the two tabs stay - * visually consistent. + * visually consistent. Clicking it drills the Authorized filter down to + * the clicked state (see mactrack_view_macs.php). * * @param bool $authorized Whether the MAC address is authorized. * @@ -3595,10 +3596,11 @@ function mactrack_dot1x_status_pill($port_result): string { function mactrack_mac_auth_pill($authorized): string { $class = $authorized ? 'mac_authorized' : 'mac_unauthorized'; $label = $authorized ? __('Authorized', 'mactrack') : __('Unauthorized', 'mactrack'); + $value = $authorized ? '1' : '0'; return '
' . '' . '
'; } diff --git a/mactrack_view_macs.php b/mactrack_view_macs.php index edb342ee..2a2f5f6f 100644 --- a/mactrack_view_macs.php +++ b/mactrack_view_macs.php @@ -1545,6 +1545,20 @@ function exportRows() { $('#export').on('click', function() { exportRows(); }); + + // Authorization pill drill-down: set the Authorized filter to the + // clicked state, then re-apply the filter. + $('.mactrackPill').off('click').on('click', function() { + var authorized = $(this).attr('data-mac-authorized'); + + if (authorized === undefined || !$('#authorized').length) { + return; + } + + $('#authorized').val(authorized); + + applyFilter(); + }); }); diff --git a/tests/Unit/MactrackStatusPillsTest.php b/tests/Unit/MactrackStatusPillsTest.php index 1479461f..734eb5e2 100644 --- a/tests/Unit/MactrackStatusPillsTest.php +++ b/tests/Unit/MactrackStatusPillsTest.php @@ -274,6 +274,7 @@ public function test_mac_auth_pill_authorized_uses_green_class_and_label(): void $this->assertStringContainsString('class="mactrackPills"', $html); $this->assertStringContainsString('mactrackPill mactrackPillActive mac_authorized', $html); + $this->assertStringContainsString('data-mac-authorized="1"', $html); $this->assertStringContainsString('Authorized', $html); $this->assertStringNotContainsString('mac_unauthorized', $html); } @@ -285,6 +286,7 @@ public function test_mac_auth_pill_unauthorized_uses_red_class_and_label(): void $html = mactrack_mac_auth_pill(false); $this->assertStringContainsString('mac_unauthorized', $html); + $this->assertStringContainsString('data-mac-authorized="0"', $html); $this->assertStringContainsString('Unauthorized', $html); } }