// ============================================================================= // 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)]); } }); } // ----------------------------------------------------------------------------- /** * Poll execution_queue.log for a pending "update_api|devices" entry and show * a spinner while it's queued; reload the page once it clears if a reload * was requested. * @param {boolean} [needsReload=false] - Reload the page once the queue clears. */ 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; }