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 (`
+ + ${cellData} + + + + +
`); + } 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( + ``) + + // 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); + } + }); + } +}