diff --git a/docs/CUSTOM_PROPERTIES.md b/docs/CUSTOM_PROPERTIES.md
index 8394a2a2..0a090f38 100755
--- a/docs/CUSTOM_PROPERTIES.md
+++ b/docs/CUSTOM_PROPERTIES.md
@@ -89,7 +89,7 @@ CUSTPROP_type: link_new_tab
CUSTPROP_args: http://my_adguard_url/#logs?search={{devLastIP}}
```
-Because this is set once (either directly on a device, or as a default applied to every new device via the `NEWDEV_devCustomProps` setting), it applies across your whole device list, not just one device at a time.
+This same property definition can be reused two ways: set directly on a single device (affecting that device alone), or set once via the `NEWDEV_devCustomProps` setting, which becomes the default `devCustomProps` for devices discovered *after* the setting is saved. It is not applied retroactively - existing devices keep their current `devCustomProps` and need to be updated individually (or reset via the device's "reset properties" action) to pick up a changed default.
Commonly useful fields: `devMac`, `devLastIP`, `devName`, `devVendor`, `devType`, `devGUID`.
diff --git a/front/devices.php b/front/devices.php
index 453f21ed..fb06cdc7 100755
--- a/front/devices.php
+++ b/front/devices.php
@@ -151,1184 +151,8 @@
-
-
+
+
+
+
+
diff --git a/front/js/devices-custom-props.js b/front/js/devices-custom-props.js
new file mode 100644
index 00000000..5ce20471
--- /dev/null
+++ b/front/js/devices-custom-props.js
@@ -0,0 +1,97 @@
+// =============================================================================
+// devices-custom-props.js — Devices list page: per-device custom property
+// action icons (see docs/CUSTOM_PROPERTIES.md).
+// =============================================================================
+
+// -----------------------------------------------------------------------------
+// Handle custom actions/properties on a device.
+//
+// CUSTPROP_name and CUSTPROP_args support {{fieldName}} wildcards (e.g.
+// {{devLastIP}}) resolved against this row's own fields - see GH #1773. The
+// resulting action is dispatched via a single delegated click handler reading
+// data-* attributes (see below) rather than an inline onclick="..." string,
+// so a device-controlled value (a DHCP hostname containing a quote, say)
+// can't break out of inline JS the way it could with string-built onclick
+// handlers.
+function renderCustomProps(custProps, device) {
+ const mac = device.devMac;
+
+ if (!isBase64(custProps)) {
+ console.error(`Unable to decode CustomProps for ${mac}`);
+ console.error(custProps);
+ return "Error, check browser Console log";
+ }
+
+ const props = JSON.parse(atob(custProps));
+ let html = "";
+
+ props.forEach((propGroup) => {
+ const propMap = Object.fromEntries(
+ propGroup.map(prop => Object.entries(prop)[0]) // Convert array of objects to key-value pairs
+ );
+
+ if (propMap["CUSTPROP_show"] !== true) {
+ return; // Not visible
+ }
+
+ const type = propMap["CUSTPROP_type"];
+ const isUrlType = (type === "link" || type === "link_new_tab");
+
+ // Plain (unescaped) resolved values, for the human-readable tooltip.
+ const namePlain = resolveDeviceWildcards(propMap["CUSTPROP_name"], device);
+ const argsPlain = resolveDeviceWildcards(propMap["CUSTPROP_args"], device);
+
+ // The value actually used for navigation: URL-encode substituted fields
+ // when this prop's args is a URL, so e.g. a device name with a space or
+ // "&" in it can't corrupt the query string.
+ const argsForAction = isUrlType
+ ? resolveDeviceWildcards(propMap["CUSTPROP_args"], device, encodeURIComponent)
+ : argsPlain;
+
+ const notesPlain = propMap["CUSTPROP_notes"] || "";
+
+ html += `
+ ${atob(propMap["CUSTPROP_icon"])}
+
`;
+ });
+
+ return html;
+}
+
+// Single delegated handler for every custom-property action rendered by
+// renderCustomProps() above - bound once, so it keeps working after
+// DataTables redraws without needing to be re-attached per row.
+$(document).on('click', '.devicePropAction', function () {
+ const el = $(this);
+ const action = el.data('action');
+ const args = el.data('args') ?? '';
+ const name = el.data('name') ?? '';
+ const notes = el.data('notes') ?? '';
+ const mac = el.data('mac') ?? '';
+
+ switch (action) {
+ case "show_notes":
+ showModalOK(name, notes);
+ break;
+ case "link":
+ window.location.href = args;
+ break;
+ case "link_new_tab":
+ openInNewTab(args);
+ break;
+ case "run_plugin":
+ runPlugin(args, name);
+ break;
+ case "delete_dev":
+ askDeleteDeviceByMac(mac);
+ break;
+ default:
+ break;
+ }
+});
diff --git a/front/js/devices-filters.js b/front/js/devices-filters.js
new file mode 100644
index 00000000..1296a9ba
--- /dev/null
+++ b/front/js/devices-filters.js
@@ -0,0 +1,195 @@
+// =============================================================================
+// devices-filters.js — Devices list page: column filter dropdowns.
+//
+// Fetches the available filter values, renders the filter dropdowns, and
+// collects the currently-selected filter values for the DataTable's ajax
+// query (see devices-table.js).
+// =============================================================================
+
+// -----------------------------------------------------------------------------
+//Render filters if specified
+// NOTE: this is the only top-level `let` among the devices-*.js files (everything
+// else is `var`) — a second top-level `let`/`const columnFilters` anywhere else
+// on this page would throw a page-breaking SyntaxError at parse time, unlike a
+// duplicate `var` which is silently allowed. Keep this declaration unique.
+let columnFilters = [];
+
+function initFilters() {
+ // Attempt to fetch data
+ $.ajax({
+ url: 'php/server/query_json.php',
+ type: "GET",
+ dataType: "json",
+ data: {
+ file: 'table_devices_filters.json', // Pass the file parameter
+ nocache: Date.now() // Prevent caching with a timestamp
+ },
+ success: function(response) {
+ if (response && response.data) {
+
+ let resultJSON = response.data;
+
+ // Save the result to cache
+ setCache("devicesFilters", JSON.stringify(resultJSON));
+
+ // Get the displayed filters from settings
+ const displayedFilters = createArray(getSetting("UI_columns_filters"));
+
+ // Clear any existing filters in the DOM
+ $('#columnFilters').empty();
+
+ // Ensure displayedFilters is an array and not empty
+ if (Array.isArray(displayedFilters) && displayedFilters.length > 0) {
+ $('#columnFiltersWrap').removeClass("hidden");
+
+ displayedFilters.forEach(columnHeaderStringKey => {
+ // Get the column name using the mapping function
+ const columnName = getColumnNameFromLangString(columnHeaderStringKey);
+
+ // Ensure columnName is valid before proceeding
+ if (columnName) {
+ // Add the filter to the columnFilters array as [columnName, columnHeaderStringKey]
+ columnFilters.push([columnName, columnHeaderStringKey]);
+ } else {
+ console.warn(`Invalid column header string key: ${columnHeaderStringKey}`);
+ }
+ });
+
+ // Filter resultJSON to include only entries with columnName in columnFilters
+ resultJSON = resultJSON.filter(entry =>
+ columnFilters.some(filter => filter[0] === entry.columnName)
+ );
+
+ // Expand resultJSON to include the columnHeaderStringKey
+ resultJSON.forEach(entry => {
+ // Find the matching columnHeaderStringKey from columnFilters
+ const matchingFilter = columnFilters.find(filter => filter[0] === entry.columnName);
+
+ // Add the columnHeaderStringKey to the entry
+ if (matchingFilter) {
+ entry['columnHeaderStringKey'] = matchingFilter[1];
+ }
+ });
+
+ console.log(resultJSON);
+
+ // Transforming the data
+ const transformed = {
+ filters: []
+ };
+
+ // Build filters in the exact order of columnFilters
+ columnFilters.forEach(([columnName, headerKey]) => {
+ // Get matching entries for this column
+ const entries = resultJSON.filter(e => e.columnName === columnName);
+
+ if (entries.length === 0) return;
+
+ // Build options (unique)
+ const optionsMap = new Map();
+
+ entries.forEach(entry => {
+ const value = entry.columnValue;
+ const label = entry.columnLabel || value;
+
+ if (!optionsMap.has(value)) {
+ optionsMap.set(value, { value, label });
+ }
+ });
+
+ const options = Array.from(optionsMap.values());
+
+ // Sort options alphabetically
+ options.sort((a, b) => a.label.localeCompare(b.label));
+
+ transformed.filters.push({
+ column: columnName,
+ headerKey: headerKey,
+ options: options
+ });
+ });
+
+ // Sort options alphabetically by label for better readability
+ transformed.filters.forEach(filter => {
+ filter.options.sort((a, b) => a.label.localeCompare(b.label));
+ });
+
+ // Output the result
+ transformedJson = transformed
+
+ // Process the fetched data
+ renderFilters(transformedJson);
+ } else {
+ console.log("No filters to display.");
+ }
+ } else {
+ console.error("Invalid response format from API");
+ }
+ },
+ error: function(xhr, status, error) {
+ console.error("Failed to fetch devices data 'table_devices_filters.json':", error);
+ }
+ });
+}
+
+
+// -------------------------------------------
+// Server side component
+function renderFilters(customData) {
+
+ // console.log(JSON.stringify(customData));
+
+ // Load filter data from the JSON file
+ $.ajax({
+ url: 'php/components/devices_filters.php', // PHP script URL
+ data: { filterObject: JSON.stringify(customData) }, // Send customData as JSON
+ type: 'POST',
+ dataType: 'html',
+ success: function(response) {
+ // console.log(response);
+
+ $('#columnFilters').html(response); // Replace container content with fetched HTML
+ $('#columnFilters').removeClass('hidden'); // Show the filters container
+
+ // Trigger the draw after select change
+ $('.filter-dropdown').on('change', function() {
+ // Collect filters
+ const columnFilters = collectFilters();
+
+ // Apply column filters then draw once (previously drew twice — bug fixed).
+ const table = $('#tableDevices').DataTable();
+ table.columnFilters = columnFilters;
+ table.draw();
+ });
+
+ },
+ error: function(xhr, status, error) {
+ console.error('Error fetching filters:', error);
+ }
+ });
+}
+
+// -------------------------------------------
+// Function to collect filters
+function collectFilters() {
+ const columnFilters = [];
+
+ // Loop through each filter group
+ document.querySelectorAll('.filter-group').forEach(filterGroup => {
+ const dropdown = filterGroup.querySelector('.filter-dropdown');
+
+ if (dropdown) {
+ const filterColumn = dropdown.getAttribute('data-column');
+ const filterValue = dropdown.value;
+
+ if (filterValue && filterColumn) {
+ columnFilters.push({
+ filterColumn: filterColumn,
+ filterValue: filterValue
+ });
+ }
+ }
+ });
+
+ return columnFilters;
+}
diff --git a/front/js/devices-init.js b/front/js/devices-init.js
new file mode 100644
index 00000000..575a0d4e
--- /dev/null
+++ b/front/js/devices-init.js
@@ -0,0 +1,109 @@
+// =============================================================================
+// devices-init.js — Page bootstrap for devices.php.
+//
+// Declares the shared page-level state (deviceStatus, tableColumnOrder, etc.)
+// used across the other devices-*.js files, wires up the mobile-collapse
+// behavior, and defines main() — the entry point run once the app has
+// finished initializing (see callAfterAppInitialized below).
+//
+// Loaded last (after devices-totals.js, devices-filters.js, devices-table.js,
+// devices-custom-props.js) since it's the orchestrator that calls into them —
+// though since these are plain global var/function declarations sharing one
+// scope, load order among the devices-*.js files doesn't affect correctness.
+// =============================================================================
+
+var deviceStatus = 'all';
+
+var tableOrder = getCache ("nax_parTableOrder") == "" ? [[3,'desc'], [0,'asc']] : JSON.parse(getCache ("nax_parTableOrder")) ;
+
+var tableColumnHide = [];
+var tableColumnOrder = [];
+var tableColumnVisible = [];
+headersDefaultOrder = [];
+missingNumbers = [];
+
+// DEVICE_COLUMN_FIELDS, COL, NUMERIC_DEFAULTS, GRAPHQL_EXTRA_FIELDS, COLUMN_NAME_MAP
+// are all defined in js/device-columns.js — edit that file to add new columns.
+
+// Collapse DevicePresence and Filters sections by default on small/mobile screens
+(function collapseOnMobile() {
+ if (window.innerWidth < 768) {
+ ['#clients', '#columnFiltersWrap'].forEach(function(sel) {
+ var $box = $(sel);
+ if ($box.length) {
+ $box.addClass('collapsed-box');
+ $box.find('.box-body, .box-footer').hide();
+ $box.find('[data-widget="collapse"] i').removeClass('fa-minus').addClass('fa-plus');
+ }
+ });
+ }
+})();
+
+// Read parameters & Initialize components
+callAfterAppInitialized(main)
+showSpinner();
+
+// -----------------------------------------------------------------------------
+function hideDevicesSkeleton() {
+ $('#devices-skeleton').fadeOut(0, function() { $(this).remove(); });
+}
+
+// Fallback: ensure skeleton is removed even if DataTable fails to initialize
+setTimeout(hideDevicesSkeleton, 15000);
+
+// -----------------------------------------------------------------------------
+function main () {
+
+ showSpinner();
+
+ initFilters();
+
+ // render tiles
+ getDevicesTotals();
+
+ //initialize the table headers in the correct order
+ var availableColumns = getSettingOptions("UI_device_columns").split(",");
+ headersDefaultOrder = availableColumns.map(val => getString(val));
+
+ var selectedColumns = JSON.parse(getSetting("UI_device_columns").replace(/'/g, '"'));
+
+ // generate default order lists of given length
+ var columnsStr = JSON.stringify(Array.from({ length: headersDefaultOrder.length }, (_, i) => i));
+ tableColumnOrder = Array.from({ length: headersDefaultOrder.length }, (_, i) => i);
+ tableColumnVisible = [];
+
+ // Initialize tableColumnVisible by including all columns from selectedColumns, preserving their order.
+ tableColumnVisible = selectedColumns.map(column => availableColumns.indexOf(column)).filter(index => index !== -1);
+
+ // Add any columns from availableColumns that are not in selectedColumns to the end.
+ const remainingColumns = availableColumns.map((column, index) => index).filter(index => !tableColumnVisible.includes(index));
+
+ // Combine both arrays.
+ tableColumnOrder = tableColumnVisible.concat(remainingColumns);
+
+ // Generate the full array of numbers from 0 to totalLength - 1 of tableColumnOrder
+ const fullArray = Array.from({ length: tableColumnOrder.length }, (_, i) => i);
+
+ // Filter out the elements already present in inputArray
+ missingNumbers = fullArray.filter(num => !tableColumnVisible.includes(num));
+
+ // Concatenate the inputArray with the missingNumbers
+ tableColumnOrder = [...tableColumnVisible, ...missingNumbers];
+
+ // Initialize components with parameters
+ initializeDatatable(getUrlAnchor('my_devices'));
+
+ // check if data outdated and show spinner if so
+ handleLoadingDialog()
+
+}
+
+// -----------------------------------------------------------------------------
+// Update cache with shown devices before navigating away
+window.addEventListener('beforeunload', function(event) {
+ // Call your function here
+ macs = getMacsOfShownDevices();
+
+ setCache("ntx_visible_macs", macs)
+
+});
diff --git a/front/js/devices-table.js b/front/js/devices-table.js
new file mode 100644
index 00000000..8eb004a9
--- /dev/null
+++ b/front/js/devices-table.js
@@ -0,0 +1,686 @@
+// =============================================================================
+// devices-table.js — Devices list page: the main devices DataTable.
+//
+// Everything about the devices DataTable in one cohesive file: column-index
+// mapping helpers, the scan-ETA/empty-state messaging it displays while no
+// devices have been discovered yet, and the DataTable itself (server-side
+// GraphQL-backed ajax source, column rendering, multi-edit, MAC collection).
+// =============================================================================
+
+// -----------------------------------------------------------------------------
+// mapping the default order to the user specified one
+function mapIndx(oldIndex)
+{
+ // console.log(oldIndex);
+ // console.log(tableColumnOrder);
+
+ for(i=0;i {
+ device[field] = rowData[mapIndx(oldIndex)];
+ });
+ return device;
+}
+
+// ---------------------------------------------------------
+// Build the rich empty-table onboarding message (HTML).
+// Used as the DataTables 'emptyTable' language option.
+function buildEmptyDeviceTableMessage(nextScanLabel) {
+ var etaLine = nextScanLabel
+ ? '' + nextScanLabel + ''
+ : '';
+ return '' +
+ '
' +
+ '' + getString('Device_NoData_Title') + '
' +
+ '' + getString('Device_NoData_Scanning') + '
' +
+ etaLine +
+ '' + getString('Device_NoData_Help') + '' +
+ '
';
+}
+
+// ---------------------------------------------------------
+// Compute a live countdown label from an ISO next_scan_time string.
+// next_scan_time is the earliest scheduled run time across enabled device_scanner plugins,
+// computed by the backend and broadcast via SSE — no guesswork needed on the frontend.
+function computeNextScanLabel(nextScanTime) {
+ if (!nextScanTime) return getString('Device_NextScan_Imminent');
+ // Append Z if no UTC offset marker present — backend may emit naive UTC ISO strings.
+ var isoStr = /Z$|[+-]\d{2}:?\d{2}$/.test(nextScanTime.trim()) ? nextScanTime : nextScanTime + 'Z';
+ var secsLeft = Math.round((new Date(isoStr).getTime() - Date.now()) / 1000);
+ if (secsLeft <= 0) return getString('Device_NextScan_Imminent');
+ if (secsLeft >= 60) {
+ var m = Math.floor(secsLeft / 60);
+ var s = secsLeft % 60;
+ return getString('Device_NextScan_In') + m + 'm ' + s + 's';
+ }
+ return getString('Device_NextScan_In') + secsLeft + 's';
+}
+
+// Anchor for next scheduled scan time, ticker handle, plugins data, and current state — module-level.
+var _nextScanTimeAnchor = null;
+var _currentStateAnchor = null;
+var _scanEtaTickerId = null;
+var _pluginsData = null;
+var _wasImminent = false; // true once the countdown displayed "imminent"; gates the Scanning... label
+var _imminentForTime = null; // the _nextScanTimeAnchor value that last set _wasImminent
+ // prevents re-arming on the same (already-consumed) timestamp
+
+// Returns true when the backend is actively scanning (not idle).
+// Uses an exclusion approach — only "Process: Idle" and an empty/null state are non-scanning.
+// This future-proofs against new states added to the scan pipeline (e.g. "Plugin: AVAHISCAN").
+function isScanningState(state) {
+ return !!state && state !== 'Process: Idle';
+}
+
+// Fetch plugins.json once on page load so we can guard ETA display to device_scanner plugins only.
+$.get('php/server/query_json.php', { file: 'plugins.json', nocache: Date.now() }, function(res) {
+ _pluginsData = res['data'] || [];
+});
+
+// Returns true only when at least one device_scanner plugin is loaded and not disabled.
+function hasEnabledDeviceScanners() {
+ if (!_pluginsData || !_pluginsData.length) return false;
+ return getPluginsByType(_pluginsData, 'device_scanner', true).length > 0;
+}
+
+// ---------------------------------------------------------
+// Update the title-bar ETA subtitle and the DataTables empty-state message.
+// Called on every nax:scanEtaUpdate; the inner ticker keeps the title bar live between events.
+function updateScanEtaDisplay(nextScanTime, currentState) {
+ // Detect scan-finished transition BEFORE updating _currentStateAnchor.
+ // justFinishedScanning is true only when the backend transitions scanning → idle.
+ var justFinishedScanning = (currentState === 'Process: Idle') && isScanningState(_currentStateAnchor);
+
+ // Prefer the backend-computed values; keep previous anchors if not yet received.
+ _nextScanTimeAnchor = nextScanTime || _nextScanTimeAnchor;
+ _currentStateAnchor = currentState || _currentStateAnchor;
+
+ // Reset the imminent gate when the scan finishes back to idle so the next cycle starts clean.
+ if (currentState === 'Process: Idle') { _wasImminent = false; }
+
+ // Restart the per-second title-bar ticker
+ if (_scanEtaTickerId !== null) { clearInterval(_scanEtaTickerId); }
+
+ function getEtaLabel() {
+ if (!hasEnabledDeviceScanners()) return '';
+ if (isScanningState(_currentStateAnchor) && _wasImminent) return getString('Device_Scanning');
+ var label = computeNextScanLabel(_nextScanTimeAnchor);
+ // Arm _wasImminent only for a NEW next_scan_time anchor — not the already-consumed one.
+ // This prevents the ticker from re-arming immediately after "Process: Idle" resets the flag
+ // while _nextScanTimeAnchor still holds the now-past timestamp.
+ if (label === getString('Device_NextScan_Imminent') && _nextScanTimeAnchor !== _imminentForTime) {
+ _wasImminent = true;
+ _imminentForTime = _nextScanTimeAnchor;
+ }
+ return label;
+ }
+
+ function tickTitleBar() {
+ var eta = document.getElementById('nextScanEta');
+ if (!eta) return;
+ var label = getEtaLabel();
+ if (!label) { eta.style.display = 'none'; return; }
+ eta.textContent = label;
+ eta.style.display = '';
+ }
+
+ // Update DataTables empty message once per SSE event.
+ // NOTE: Do NOT call dt.draw() here — on page load the SSE queue replays all
+ // accumulated events at once, causing a draw() (= GraphQL AJAX call) per event.
+ // Instead, update the visible empty-state DOM cell directly.
+ var label = getEtaLabel();
+ if ($.fn.DataTable.isDataTable('#tableDevices')) {
+ var dt = $('#tableDevices').DataTable();
+ var newEmptyMsg = buildEmptyDeviceTableMessage(label);
+ dt.settings()[0].oLanguage.sEmptyTable = newEmptyMsg;
+ if (dt.page.info().recordsTotal === 0) {
+ // Patch the visible cell text without triggering a server-side AJAX reload.
+ $('#tableDevices tbody .dataTables_empty').html(newEmptyMsg);
+ }
+
+ // When scanning just finished and the table is still empty, reload data so
+ // newly discovered devices appear automatically. Skip reload if there are
+ // already rows — no need to disturb the user's current view.
+ if (justFinishedScanning && dt.page.info().recordsTotal === 0) {
+ dt.ajax.reload(null, false); // false = keep current page position
+ }
+ }
+
+ tickTitleBar();
+ _scanEtaTickerId = setInterval(tickTitleBar, 1000);
+}
+
+// Listen for scan ETA updates dispatched by sse_manager.js (SSE push or poll fallback)
+document.addEventListener('nax:scanEtaUpdate', function(e) {
+ updateScanEtaDisplay(e.detail.nextScanTime, e.detail.currentState);
+});
+
+// ---------------------------------------------------------
+// Initializes the main devices list datatable
+function initializeDatatable (status) {
+
+ if(!status)
+ {
+ status = 'my_devices'
+ }
+
+ // retrieve page size
+ var tableRows = getCache ("nax_parTableRows") == "" ? parseInt(getSetting("UI_DEFAULT_PAGE_SIZE")) : getCache ("nax_parTableRows") ;
+
+ // Save status selected
+ deviceStatus = status;
+
+ // Define color & title for the status selected
+ switch (deviceStatus) {
+ case 'my_devices': tableTitle = getString('Device_Shortcut_AllDevices'); color = 'aqua'; break;
+ case 'connected': tableTitle = getString('Device_Shortcut_Connected'); color = 'green'; break;
+ case 'all': tableTitle = getString('Gen_All_Devices'); color = 'aqua'; break;
+ case 'favorites': tableTitle = getString('Device_Shortcut_Favorites'); color = 'yellow'; break;
+ case 'new': tableTitle = getString('Device_Shortcut_NewDevices'); color = 'yellow'; break;
+ case 'down': tableTitle = getString('Device_Shortcut_DownOnly'); color = 'red'; break;
+ case 'archived': tableTitle = getString('Device_Shortcut_Archived'); color = 'gray'; break;
+ case 'offline': tableTitle = getString('Gen_Offline'); color = 'gray'; break;
+ case 'all_devices': tableTitle = getString('Gen_All_Devices'); color = 'gray'; break;
+ case 'network_devices': tableTitle = getString('Network_Devices'); color = 'aqua'; break;
+ default: tableTitle = getString('Device_Shortcut_Devices'); color = 'gray'; break;
+ }
+
+ // Set title and color
+ $('#tableDevicesTitle')[0].className = 'box-title text-'+ color;
+ $('#tableDevicesBox')[0].className = 'box box-'+ color;
+ $('#tableDevicesTitle').html (tableTitle);
+
+ // render table headers
+ html = '';
+
+ for(index = 0; index < tableColumnOrder.length; index++)
+ {
+ html += '' + headersDefaultOrder[tableColumnOrder[index]] + ' | ';
+ }
+
+ $('#tableDevices tr').html(html);
+
+ hideUIelements("UI_DEV_SECTIONS")
+
+ for(i = 0; i < tableColumnOrder.length; i++)
+ {
+ // hide this column if not in the tableColumnVisible variable (we need to keep the MAC address (index 11) for functionality reasons)
+ if(tableColumnVisible.includes(tableColumnOrder[i]) == false)
+ {
+ tableColumnHide.push(mapIndx(tableColumnOrder[i]));
+ }
+ }
+
+ var table = $('#tableDevices').DataTable({
+ "serverSide": true,
+ "processing": true,
+ "ajax": {
+ "url": 'php/server/query_graphql.php', // PHP endpoint that proxies to the GraphQL server
+ "type": "POST",
+ "contentType": "application/json",
+ "data": function (d) {
+ // GraphQL fields are derived from DEVICE_COLUMN_FIELDS + GRAPHQL_EXTRA_FIELDS
+ // (both defined in js/device-columns.js). No manual field list to maintain.
+ const _gqlFields = [...new Set([...DEVICE_COLUMN_FIELDS, ...GRAPHQL_EXTRA_FIELDS])]
+ .join('\n ');
+ let graphqlQuery = `
+ query devices($options: PageQueryOptionsInput) {
+ devices(options: $options) {
+ devices {
+ ${_gqlFields}
+ }
+ count
+ dbCount
+ }
+ }
+ `;
+
+ console.log(d);
+
+ // Handle empty filters
+ let columnFilters = collectFilters();
+ if (columnFilters.length === 0) {
+ columnFilters = [];
+ }
+
+
+ // Prepare query variables for pagination, sorting, and search
+ let query = {
+ "operationName": null,
+ "query": graphqlQuery,
+ "variables": {
+ "options": {
+ "page": Math.floor(d.start / d.length) + 1, // Page number (1-based)
+ "limit": parseInt(d.length, 10), // Page size (ensure it's an integer)
+ "sort": d.order && d.order[0] ? [{
+ "field": mapColumnIndexToFieldName(d.order[0].column, tableColumnVisible), // Sort field from DataTable column
+ "order": d.order[0].dir.toUpperCase() // Sort direction (ASC/DESC)
+ }] : [], // Default to an empty array if no sorting is defined
+ "search": d.search.value, // Search query
+ "status": deviceStatus,
+ "filters" : columnFilters
+ }
+
+ }
+ };
+
+ return JSON.stringify(query); // Send the JSON request
+ },
+ "dataSrc": function (res) {
+
+ console.log("Raw response:", res);
+ const json = res["data"];
+
+ // recordsTotal = raw DB count (before filters/search) so DataTables uses emptyTable
+ // only when the DB is genuinely empty, and zeroRecords when a filter returns nothing.
+ res.recordsTotal = json.devices.dbCount || 0;
+ res.recordsFiltered = json.devices.count || 0;
+
+ // console.log("recordsTotal:", res.recordsTotal, "recordsFiltered:", res.recordsFiltered);
+ // console.log("tableRows:", tableRows);
+
+ // Return only the array of rows for the table
+ return json.devices.devices.map(device => {
+ // Build positional row directly from DEVICE_COLUMN_FIELDS.
+ // NUMERIC_DEFAULTS controls which fields default to 0 vs "".
+ // Adding a new column: add to DEVICE_COLUMN_FIELDS (and NUMERIC_DEFAULTS
+ // if needed) in js/device-columns.js — nothing to change here.
+ const originalRow = DEVICE_COLUMN_FIELDS.map(
+ field => device[field] ?? (NUMERIC_DEFAULTS.has(field) ? 0 : "")
+ );
+
+ const newRow = [];
+ // Reorder data based on user-defined columns order
+ for (let index = 0; index < tableColumnOrder.length; index++) {
+ newRow.push(originalRow[tableColumnOrder[index]]);
+ }
+ // Append extra (non-display) fields after the display columns so
+ // they are accessible in createdCell via COL_EXTRA.*
+ GRAPHQL_EXTRA_FIELDS.forEach(field => {
+ newRow.push(device[field] ?? (NUMERIC_DEFAULTS.has(field) ? 0 : ""));
+ });
+ return newRow;
+ });
+ }
+ },
+ 'paging' : true,
+ 'lengthChange' : true,
+ 'lengthMenu' : getLengthMenu(parseInt(getSetting("UI_DEFAULT_PAGE_SIZE"))),
+ 'searching' : true,
+
+ 'ordering' : true,
+ 'info' : true,
+ 'autoWidth' : false,
+ 'dom': '<"top"f>rtl<"bottom"ip><"clear">',
+
+ // Parameters
+ 'pageLength' : tableRows,
+ 'order' : tableOrder,
+ 'select' : true, // Enable selection
+
+ 'fixedHeader': true,
+ 'fixedHeader': {
+ 'header': true,
+ 'footer': true
+ },
+
+ 'columnDefs' : [
+ {visible: false, targets: tableColumnHide },
+ {className: 'text-center', targets: [mapIndx(COL.devFavorite), mapIndx(COL.devIsRandomMac), mapIndx(COL.devStatus), mapIndx(COL.devParentChildrenCount), mapIndx(COL.devParentPort)] },
+ {className: 'iconColumn text-center', targets: [mapIndx(COL.devIcon)]},
+ {width: '80px', targets: [mapIndx(COL.devFirstConnection), mapIndx(COL.devLastConnection), mapIndx(COL.devParentChildrenCount), mapIndx(COL.devFQDN)] },
+ {width: '85px', targets: [mapIndx(COL.devIsRandomMac)] },
+ {width: '130px', targets: [mapIndx(COL.devLastIP), mapIndx(COL.devIpLong)] },
+ {width: '30px', targets: [mapIndx(COL.devIcon), mapIndx(COL.devStatus), mapIndx(COL.rowid), mapIndx(COL.devParentPort)] },
+ {orderData: [mapIndx(COL.devIpLong)], targets: mapIndx(COL.devLastIP) },
+
+ // Device Name and FQDN
+ // Use `render` (not `createdCell`) so the HTML is built before DataTables
+ // sets td.innerHTML – preventing raw cellData from being parsed as HTML.
+ {targets: [mapIndx(COL.devName), mapIndx(COL.devFQDN)],
+ 'render': function (data, type, row) {
+ if (type !== 'display') {
+ return data; // raw value for sort / filter / type detection
+ }
+
+ var displayedValue = encodeSpecialChars(data);
+
+ if (isEmpty(displayedValue)) {
+ displayedValue = "N/A";
+ }
+
+ return (
+ `` +
+ `${displayedValue}` +
+ ``
+ );
+ } },
+
+ // Connected Devices
+ {targets: [mapIndx(COL.devParentChildrenCount)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ // check if this is a network device
+ if(getSetting("NETWORK_DEVICE_TYPES").includes(`'${rowData[mapIndx(COL.devType)]}'`) )
+ {
+ $(td).html (''+ cellData +'');
+ }
+ else
+ {
+ $(td).html (``)
+ }
+
+ } },
+
+ // Icon
+ {targets: [mapIndx(COL.devIcon)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+
+ if (!emptyArr.includes(cellData)){
+ $(td).html (atob(cellData));
+ } else {
+ $(td).html ('');
+ }
+ } },
+
+ // Full MAC
+ {targets: [mapIndx(COL.devMac)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ if (!emptyArr.includes(cellData)){
+ $(td).html (''+cellData+'');
+ } else {
+ $(td).html ('');
+ }
+ } },
+
+ // IP address
+ {targets: [mapIndx(COL.devLastIP)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ if (!emptyArr.includes(cellData)){
+ $(td).html (``);
+ } else {
+ $(td).html ('');
+ }
+ }
+ },
+ // IP address (orderable)
+ {targets: [mapIndx(COL.devIpLong)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ if (!emptyArr.includes(cellData)){
+ $(td).html (`${cellData}`);
+ } else {
+ $(td).html ('');
+ }
+ }
+ },
+
+ // Custom Properties
+ {targets: [mapIndx(COL.devCustomProps)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ if (!emptyArr.includes(cellData)){
+ $(td).html (`${renderCustomProps(cellData, deviceObjectFromRowData(rowData))}`);
+ } else {
+ $(td).html ('');
+ }
+ }
+ },
+
+ // Favorite
+ {targets: [mapIndx(COL.devFavorite)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ if (cellData == 1){
+ $(td).html ('');
+ } else {
+ $(td).html ('');
+ }
+ } },
+
+ // Dates
+ {targets: [mapIndx(COL.devFirstConnection), mapIndx(COL.devLastConnection)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ var result = cellData.toString(); // Convert to string
+ if (result.includes("+")) { // Check if timezone offset is present
+ result = result.split('+')[0]; // Remove timezone offset
+ }
+ $(td).html (translateHTMLcodes (result));
+ } },
+
+ // Random MAC
+ {targets: [mapIndx(COL.devIsRandomMac)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ // console.log(cellData)
+ if (cellData == 1){
+ $(td).html ('');
+ } else {
+ $(td).html ('');
+ }
+ } },
+
+ // Parent Mac
+ {targets: [mapIndx(COL.devParentMAC)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+ if (!isValidMac(cellData)) {
+ $(td).html('');
+ return;
+ }
+
+ const data = {
+ id: cellData, // MAC address
+ text: cellData // Optional display text (you could use a name or something else)
+ };
+
+ spanWrap = $(``)
+
+ $(td).html(spanWrap);
+
+ const chipHtml = renderDeviceLink(data, spanWrap, true); // pass the td as container
+
+ $(spanWrap).append(chipHtml);
+ }
+ },
+ // Status color
+ {targets: [mapIndx(COL.devStatus)],
+ 'createdCell': function (td, cellData, rowData, row, col) {
+
+ const badge = badgeFromRowData(rowData);
+
+ $(td).html(`${badge.iconHtml} ${badge.label}`);
+ } },
+ ],
+
+ // Processing
+ 'processing' : true,
+ 'language' : {
+ emptyTable: buildEmptyDeviceTableMessage(getString('Device_NextScan_Imminent')),
+ zeroRecords: getString('Device_NoMatch_Title'),
+ "lengthMenu": getString('Device_Tablelenght'),
+ "search": getString('Device_Searchbox') + ": ",
+ "paginate": {
+ "next": getString('Device_Table_nav_next'),
+ "previous": getString('Device_Table_nav_prev')
+ },
+ "info": getString('Device_Table_info'),
+ },
+ initComplete: function (settings, devices) {
+ // Handle any additional interactions or event listeners as required
+
+ // Save cookie Rows displayed, and Parameters rows & order
+ $('#tableDevices').on( 'length.dt', function ( e, settings, len ) {
+ setCache ("nax_parTableRows", len, 129600); // save for 90 days
+ } );
+
+ $('#tableDevices').on( 'order.dt', function () {
+ setCache ("nax_parTableOrder", JSON.stringify (table.order()), 129600); // save for 90 days
+ } );
+
+ // add multi-edit button
+ $('#multiEditPlc').append(
+ `
+ ${getString("Device_MultiEdit")}
+ `)
+
+ // Event listener for row selection in DataTable
+ $('#tableDevices').on('click', 'tr', function (e) {
+ setTimeout(function(){
+ // Check if any row is selected
+ var anyRowSelected = $('#tableDevices tr.selected').length > 0;
+
+ // Toggle visibility of element with ID 'multiEdit'
+ $('#multiEdit').toggle(anyRowSelected);
+ }, 100);
+
+ });
+
+ // search only after idle
+ var typingTimer; // Timer identifier
+ var debounceTime = 750; // Delay in milliseconds
+
+ $('input[aria-controls="tableDevices"]').off().on('keyup', function () {
+ clearTimeout(typingTimer); // Clear the previous timer
+ var searchValue = this.value;
+
+ typingTimer = setTimeout(function () {
+ $('#tableDevices').DataTable().search(searchValue).draw(); // Trigger the search after delay
+ }, debounceTime);
+ });
+
+ initHoverNodeInfo();
+ hideSpinner();
+ hideDevicesSkeleton();
+
+ },
+ createdRow: function(row, data, dataIndex) {
+ // add devMac to the table row
+ $(row).attr('my-devMac', data[mapIndx(COL.devMac)]);
+
+ }
+
+ });
+}
+
+
+// -----------------------------------------------------------------------------
+function handleLoadingDialog(needsReload = false)
+{
+ // console.log(`needsReload: ${needsReload}`);
+
+ $.get('php/server/query_logs.php?file=execution_queue.log&nocache=' + Date.now(), function(data) {
+
+ if(data.includes("update_api|devices"))
+ {
+ showSpinner("devices_old")
+
+ setTimeout(handleLoadingDialog(true), 1000);
+
+ } else if (needsReload)
+ {
+ location.reload();
+ }else
+ {
+ // hideSpinner();
+ }
+
+ })
+
+}
+
+// -----------------------------------------------------------------------------
+// Function collects selected devices in the DataTable and redirects the user to
+// the Miantenance section with a 'macs' query string identifying selected devices
+function multiEditDevices()
+{
+ // get selected devices
+ var selectedDevicesDataTableData = $('#tableDevices').DataTable().rows({ selected: true, page: 'current' }).data().toArray();
+
+ console.log(selectedDevicesDataTableData);
+
+ macs = ""
+
+ for (var j = 0; j < selectedDevicesDataTableData.length; j++) {
+ macs += selectedDevicesDataTableData[j][mapIndx(COL.devMac)] + ","; // MAC
+ }
+
+ // redirect to the Maintenance section
+ window.location.href = './maintenance.php#tab_multiEdit?macs=' + macs.slice(0, -1);
+}
+
+
+// -----------------------------------------------------------------------------
+// Function collects shown devices from the DataTable
+function getMacsOfShownDevices() {
+ var table = $('#tableDevices').DataTable();
+
+ var macs = [];
+
+ // Get all row indexes on current page, in display order
+ var allIndexes = table.rows({ page: 'current' }).indexes();
+
+ allIndexes.each(function(idx) {
+ var rowData = table.row(idx).data();
+ if (rowData) {
+ macs.push(rowData[mapIndx(COL.devMac)]); // MAC column
+ }
+ });
+
+ return macs;
+}
diff --git a/front/js/devices-totals.js b/front/js/devices-totals.js
new file mode 100644
index 00000000..b8670197
--- /dev/null
+++ b/front/js/devices-totals.js
@@ -0,0 +1,129 @@
+// =============================================================================
+// devices-totals.js — Devices list page: tile-card totals.
+//
+// Fetches per-status device counts and renders them as the tile cards at the
+// top of devices.php (#TileCards).
+// =============================================================================
+
+//------------------------------------------------------------------------------
+// Query total numbers of Devices by status
+//------------------------------------------------------------------------------
+function getDevicesTotals() {
+ maxDelay = 180; //cap at 180 seconds
+
+ let maxRetries = Math.ceil(Math.log2(maxDelay)); // Calculate maximum retries to cap at maxDelay seconds
+ let attempt = 0;
+ let calledUpdateAPI = false;
+
+ function fetchDataWithBackoff() {
+ // Calculate the delay (2^attempt seconds, capped at maxDelay seconds)
+ const delay = Math.min(2 ** attempt, maxDelay) * 1000;
+
+ // Attempt to fetch data
+ $.ajax({
+ url: 'php/server/query_json.php',
+ type: "GET",
+ dataType: "json",
+ data: {
+ file: 'table_devices_tiles.json', // Pass the file parameter
+ nocache: Date.now() // Prevent caching with a timestamp
+ },
+ success: function(response) {
+ if (response && response.data) {
+ const resultJSON = response.data[0]; // Assuming the structure {"data": [ ... ]}
+
+ // Save the result to cache
+ setCache("getDevicesTotals", JSON.stringify(resultJSON));
+
+ // Process the fetched data
+ processDeviceTotals(resultJSON);
+ } else {
+ console.error("Invalid response format from API");
+ }
+ },
+ error: function(xhr, status, error) {
+ console.error("Failed to fetch devices data (Attempt " + (attempt + 1) + "):", error);
+
+ // try updating the API once
+ if(calledUpdateAPI == false)
+ {
+ calledUpdateAPI = true;
+ updateApi("devices_tiles");
+ }
+
+ // Retry logic
+ if (attempt < maxRetries) {
+ attempt++;
+ setTimeout(fetchDataWithBackoff, delay);
+ } else {
+ console.error("Maximum retries reached. Unable to fetch devices data.");
+ }
+ }
+ });
+ }
+
+ // Start the first fetch attempt
+ fetchDataWithBackoff();
+}
+
+function processDeviceTotals(devicesData) {
+ // Define filter conditions and corresponding objects
+ const filters = [
+ { status: 'my_devices', color: 'bg-aqua', label: getString('Device_Shortcut_AllDevices'), icon: 'fa-laptop' },
+ { status: 'all', color: 'bg-aqua', label: getString('Gen_All_Devices'), icon: 'fa-laptop' },
+ { status: 'connected', color: 'bg-green', label: getString('Device_Shortcut_Connected'), icon: 'fa-plug' },
+ { status: 'favorites', color: 'bg-yellow', label: getString('Device_Shortcut_Favorites'), icon: 'fa-star' },
+ { status: 'new', color: 'bg-yellow', label: getString('Device_Shortcut_NewDevices'), icon: 'fa-plus' },
+ { status: 'down', color: 'bg-red', label: getString('Device_Shortcut_DownOnly'), icon: 'fa-warning' },
+ { status: 'archived', color: 'bg-gray', label: getString('Device_Shortcut_Archived'), icon: 'fa-eye-slash' },
+ { status: 'offline', color: 'bg-gray', label: getString('Gen_Offline'), icon: 'fa-xmark' },
+ { status: 'all_devices', color: 'bg-gray', label: getString('Gen_All_Devices'), icon: 'fa-laptop' },
+ { status: 'network_devices', color: 'bg-aqua', label: getString('Network_Devices'), icon: 'fa-sitemap fa-rotate-270' }
+ ];
+
+ // Initialize an empty array to store the final objects
+ let dataArray = [];
+
+ // Loop through each filter condition
+ filters.forEach(filter => {
+ // Get count directly from API response data
+ let count = devicesData[filter.status] || 0;
+
+ // Check any condition to skip adding the object to dataArray
+ if (
+ (['', 'False'].includes(getSetting('UI_hide_empty')) || (getSetting('UI_hide_empty') == "True" && count > 0)) &&
+ (getSetting('UI_shown_cards') == "" || getSetting('UI_shown_cards').includes(filter.status))
+ ) {
+ dataArray.push({
+ onclickEvent: `forceLoadUrl('devices.php#${filter.status}')`,
+ color: filter.color,
+ title: count,
+ label: filter.label,
+ icon: filter.icon
+ });
+ }
+ });
+
+ // Render info boxes/tile cards
+ renderInfoboxes(dataArray);
+}
+
+//------------------------------------------------------------------------------
+// Render the info boxes/tiles on top
+function renderInfoboxes(customData) {
+ if(customData.length > 0)
+ {
+ $.ajax({
+ url: 'php/components/tile_cards.php', // PHP script URL
+ type: 'POST', // Use POST method to send data
+ dataType: 'html', // Expect HTML response
+ data: { items: JSON.stringify(customData) }, // Send customData as JSON
+ success: function(response) {
+ $('#TileCards').html(response); // Replace container content with fetched HTML
+ },
+ error: function(xhr, status, error) {
+ console.error('Error fetching infoboxes:', error);
+ }
+ });
+ }
+}