diff --git a/CHANGELOG.md b/CHANGELOG.md
index c29fcde5..ce4d800f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
--- 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
* 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/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/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/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 e1729ee2..caf65549 100644
--- a/lib/mactrack_functions.php
+++ b/lib/mactrack_functions.php
@@ -3582,6 +3582,29 @@ 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. 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.
+ *
+ * @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');
+ $value = $authorized ? '1' : '0';
+
+ return '' .
+ '' .
+ html_escape($label) . ' ' .
+ '
';
+}
+
/**
* Renders a single 802.1x results-list table row (action icons, device
* name/hostname, username, IP address, optional DNS hostname, MAC
@@ -4296,8 +4319,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
@@ -4310,6 +4334,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 f10285aa..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-09 17:42-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,10 +155,18 @@ 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 ""
+#: 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 ""
@@ -230,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 ""
@@ -247,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 ""
@@ -676,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 ""
@@ -692,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 ""
@@ -955,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 ""
@@ -1218,7 +1231,7 @@ msgstr ""
msgid "Mactrack Process Status"
msgstr ""
-#: mactrack_utilities.php
+#: mactrack_utilities.php mactrack_view_dashboard.php
msgid "Refresh"
msgstr ""
@@ -1282,7 +1295,7 @@ msgstr ""
msgid "Maximum Per Device Scan Time:"
msgstr ""
-#: mactrack_utilities.php
+#: mactrack_utilities.php mactrack_view_dashboard.php
msgid "minutes"
msgstr ""
@@ -1550,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 ""
@@ -1891,10 +2112,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 +2124,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 ""
@@ -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..e20ced2e
--- /dev/null
+++ b/mactrack_view_dashboard.php
@@ -0,0 +1,568 @@
+ 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,
+ (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 : []);
+
+ 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 '' . __esc('Slowest devices', 'mactrack') . ' ' . __esc('Duration', 'mactrack') . ' ' . __esc('Last scan', 'mactrack') . ' ';
+
+ if (cacti_sizeof($slow)) {
+ foreach ($slow as $d) {
+ print '' . html_escape($d['device_name']) . ' ' . html_escape(mactrack_dashboard_seconds($d['last_runduration'])) . ' ' . html_escape(mactrack_dashboard_time($d['last_rundate'])) . ' ';
+ }
+ } else {
+ print '' . __esc('No scan runtimes recorded yet.', 'mactrack') . ' ';
+ }
+
+ print '
';
+
+ 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 '' . __esc('Table', 'mactrack') . ' ' . __esc('Rows', 'mactrack') . ' ' . __esc('Size', 'mactrack') . ' ';
+
+ 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 '' . html_escape($t['name']) . ' '
+ . '' . html_escape(mactrack_dashboard_count($t['rows'])) . ' '
+ . '
' . html_escape(mactrack_dashboard_bytes($t['bytes'])) . ' ';
+ }
+ } else {
+ print '' . __esc('No table statistics available.', 'mactrack') . ' ';
+ }
+
+ print '
';
+
+ 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 '' . __esc('Vendor', 'mactrack') . ' ' . __esc('Devices', 'mactrack') . ' ';
+
+ if (cacti_sizeof($top)) {
+ foreach ($top as $v) {
+ print '' . html_escape($v['vendor']) . ' ' . html_escape(mactrack_dashboard_count($v['devices'])) . ' ';
+ }
+ } else {
+ print '' . __esc('No vendor data available.', 'mactrack') . ' ';
+ }
+
+ print '
';
+
+ 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 ''
+ . ''
+ . '' . 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 '' . __esc('Add card', 'mactrack') . ' ';
+ print '' . __esc('Add a dashboard card…', 'mactrack') . ' ';
+
+ foreach ($absent as $key) {
+ print '' . html_escape($meta[$key]['title']) . ' ';
+ }
+
+ 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/mactrack_view_macs.php b/mactrack_view_macs.php
index 66de3cbc..2a2f5f6f 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);
@@ -1547,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/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/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/Unit/MactrackStatusPillsTest.php b/tests/Unit/MactrackStatusPillsTest.php
index ab29666b..734eb5e2 100644
--- a/tests/Unit/MactrackStatusPillsTest.php
+++ b/tests/Unit/MactrackStatusPillsTest.php
@@ -265,4 +265,28 @@ 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('data-mac-authorized="1"', $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('data-mac-authorized="0"', $html);
+ $this->assertStringContainsString('Unauthorized', $html);
+ }
}
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')
+ );
+ }
+}
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',
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);