mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-09-14 15:25:32 -04:00
FE+DOCS: extract js into files , cust props clarification
This commit is contained in:
1 parent
4d0175c660
commit
3cd13f2f33
7 files changed
+1222
-1182
No files matched your search
@@ -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`.
|
||||
|
||||
|
||||
+5
-1181
File diff suppressed because it is too large.
Load diff
@@ -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 += `<div class="pointer devicePropAction"
|
||||
data-action="${encodeSpecialChars(type)}"
|
||||
data-args="${encodeSpecialChars(argsForAction)}"
|
||||
data-name="${encodeSpecialChars(namePlain)}"
|
||||
data-notes="${encodeSpecialChars(notesPlain)}"
|
||||
data-mac="${encodeSpecialChars(mac)}"
|
||||
title="${encodeSpecialChars(`${namePlain} ${argsPlain}`)}">
|
||||
${atob(propMap["CUSTPROP_icon"])}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
});
|
||||
@@ -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<tableColumnOrder.length;i++)
|
||||
{
|
||||
if(tableColumnOrder[i] == oldIndex)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Map column index to column name for GraphQL query
|
||||
function mapColumnIndexToFieldName(index, tableColumnVisible) {
|
||||
// Derives field name from the authoritative DEVICE_COLUMN_FIELDS constant.
|
||||
return DEVICE_COLUMN_FIELDS[tableColumnOrder[index]] || null;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Status badge helper for DataTables rowData (positional array).
|
||||
// Uses mapIndx(COL.*) for reordered display fields and COL_EXTRA.* for extra fields.
|
||||
function badgeFromRowData(rowData) {
|
||||
return getStatusBadgeParts(
|
||||
rowData[mapIndx(COL.devPresentLastScan)],
|
||||
rowData[mapIndx(COL.devAlertDown)],
|
||||
rowData[mapIndx(COL.devFlapping)],
|
||||
rowData[mapIndx(COL.devMac)],
|
||||
'',
|
||||
rowData[COL_EXTRA.devIsSleeping] || 0,
|
||||
rowData[COL_EXTRA.devIsArchived] || 0,
|
||||
rowData[COL_EXTRA.devIsNew] || 0
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Build a plain {fieldName: value} device object from a DataTables positional
|
||||
// row - for callers (e.g. renderCustomProps' {{fieldName}} wildcards) that
|
||||
// need to look up an arbitrary device field by name, not just a fixed few.
|
||||
function deviceObjectFromRowData(rowData) {
|
||||
const device = {};
|
||||
DEVICE_COLUMN_FIELDS.forEach((field, oldIndex) => {
|
||||
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
|
||||
? '<small class="text-muted" style="margin-top:6px;display:block;">' + nextScanLabel + '</small>'
|
||||
: '';
|
||||
return '<div class="text-center" style="padding:20px;">' +
|
||||
'<i class="fa fa-search fa-2x text-muted" style="margin-bottom:10px;"></i><br>' +
|
||||
'<strong>' + getString('Device_NoData_Title') + '</strong><br>' +
|
||||
'<span class="text-muted">' + getString('Device_NoData_Scanning') + '</span><br>' +
|
||||
etaLine +
|
||||
'<small style="margin-top:6px;display:block;">' + getString('Device_NoData_Help') + '</small>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 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 += '<th>' + headersDefaultOrder[tableColumnOrder[index]] + '</th>';
|
||||
}
|
||||
|
||||
$('#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 (
|
||||
`<b class="anonymizeDev ">` +
|
||||
`<a href="deviceDetails.php?mac=${row[mapIndx(COL.devMac)]}" class="hover-node-info"` +
|
||||
` data-name="${displayedValue}"` +
|
||||
` data-ip="${row[mapIndx(COL.devLastIP)]}"` +
|
||||
` data-mac="${row[mapIndx(COL.devMac)]}"` +
|
||||
` data-vendor="${row[mapIndx(COL.devVendor)]}"` +
|
||||
` data-type="${row[mapIndx(COL.devType)]}"` +
|
||||
` data-firstseen="${localizeTimestamp(row[mapIndx(COL.devFirstConnection)])}"` +
|
||||
` data-lastseen="${localizeTimestamp(row[mapIndx(COL.devLastConnection)])}"` +
|
||||
` data-relationship="${row[mapIndx(COL.devParentRelType)]}"` +
|
||||
` data-status="${row[mapIndx(COL.devStatus)]}"` +
|
||||
` data-present="${row[mapIndx(COL.devPresentLastScan)]}"` +
|
||||
` data-alertdown="${row[mapIndx(COL.devAlertDown)]}"` +
|
||||
` data-flapping="${row[mapIndx(COL.devFlapping)]}"` +
|
||||
` data-sleeping="${row[COL_EXTRA.devIsSleeping] || 0}"` +
|
||||
` data-archived="${row[COL_EXTRA.devIsArchived] || 0}"` +
|
||||
` data-isnew="${row[COL_EXTRA.devIsNew] || 0}"` +
|
||||
` data-icon="${row[mapIndx(COL.devIcon)]}"` +
|
||||
`>${displayedValue}</a>` +
|
||||
`</b>`
|
||||
);
|
||||
} },
|
||||
|
||||
// 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 ('<b><a href="./network.php?mac='+ rowData[mapIndx(COL.devMac)] +'" class="">'+ cellData +'</a></b>');
|
||||
}
|
||||
else
|
||||
{
|
||||
$(td).html (`<i class="fa-solid fa-xmark" title="${getString("Device_Table_Not_Network_Device")}"></i>`)
|
||||
}
|
||||
|
||||
} },
|
||||
|
||||
// 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 ('<span class="anonymizeMac">'+cellData+'</span>');
|
||||
} else {
|
||||
$(td).html ('');
|
||||
}
|
||||
} },
|
||||
|
||||
// IP address
|
||||
{targets: [mapIndx(COL.devLastIP)],
|
||||
'createdCell': function (td, cellData, rowData, row, col) {
|
||||
if (!emptyArr.includes(cellData)){
|
||||
$(td).html (`<div class="anonymizeIp alignRight">
|
||||
<a href="http://${cellData}" class="pointer" target="_blank">
|
||||
${cellData}
|
||||
</a>
|
||||
<a href="https://${cellData}" class="pointer" target="_blank">
|
||||
<i class="fa fa-lock "></i>
|
||||
</a>
|
||||
</div>`);
|
||||
} else {
|
||||
$(td).html ('');
|
||||
}
|
||||
}
|
||||
},
|
||||
// IP address (orderable)
|
||||
{targets: [mapIndx(COL.devIpLong)],
|
||||
'createdCell': function (td, cellData, rowData, row, col) {
|
||||
if (!emptyArr.includes(cellData)){
|
||||
$(td).html (`<span class="anonymizeIp">${cellData}<span>`);
|
||||
} else {
|
||||
$(td).html ('');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Custom Properties
|
||||
{targets: [mapIndx(COL.devCustomProps)],
|
||||
'createdCell': function (td, cellData, rowData, row, col) {
|
||||
if (!emptyArr.includes(cellData)){
|
||||
$(td).html (`<span>${renderCustomProps(cellData, deviceObjectFromRowData(rowData))}</span>`);
|
||||
} else {
|
||||
$(td).html ('');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Favorite
|
||||
{targets: [mapIndx(COL.devFavorite)],
|
||||
'createdCell': function (td, cellData, rowData, row, col) {
|
||||
if (cellData == 1){
|
||||
$(td).html ('<i class="fa fa-star text-yellow" style="font-size:16px"></i>');
|
||||
} 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 ('<i data-toggle="tooltip" data-placement="right" title="Random MAC" class="fa-solid fa-shuffle"></i>');
|
||||
} 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 = $(`<span class="custom-badge text-white"></span>`)
|
||||
|
||||
$(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(`<a href="${badge.url}" class="badge ${badge.cssClass}">${badge.iconHtml} ${badge.label}</a>`);
|
||||
} },
|
||||
],
|
||||
|
||||
// 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(
|
||||
`<span type="submit" id="multiEdit" class="pointer " style="display:none" onclick="multiEditDevices();">
|
||||
<a href="#"><i class="fa fa-pencil " ></i> ${getString("Device_MultiEdit")} </a>
|
||||
</span>`)
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user