diff --git a/front/js/network-api.js b/front/js/network-api.js new file mode 100644 index 00000000..86e93fdb --- /dev/null +++ b/front/js/network-api.js @@ -0,0 +1,283 @@ +// network-api.js +// API calls and data loading functions for network topology + +/** + * Get API token, waiting if necessary for settings to load + * @returns {string} The API token + */ +function getApiToken() { + let token = getSetting("API_TOKEN"); + + // If token is not yet available, log warning + if (!token || token.trim() === '') { + console.warn("API_TOKEN not yet loaded from settings"); + } + + return token; +} + +/** + * Load network nodes (network device types) + * Creates top-level tabs for each network device + */ +function loadNetworkNodes() { + // Create Top level tabs (List of network devices), explanation of the terminology below: + // + // Switch 1 (node) + // /(p1) \ (p2) <----- port numbers + // / \ + // Smart TV (leaf) Switch 2 (node (for the PC) and leaf (for Switch 1)) + // \ + // PC (leaf) <------- leafs are not included in this SQL query + const rawSql = ` + SELECT + parent.devName AS node_name, + parent.devMac AS node_mac, + parent.devPresentLastScan AS online, + parent.devType AS node_type, + parent.devParentMAC AS parent_mac, + parent.devIcon AS node_icon, + parent.devAlertDown AS node_alert, + COUNT(child.devMac) AS node_ports_count + FROM Devices AS parent + LEFT JOIN Devices AS child + /* CRITICAL FIX: COLLATE NOCASE ensures the join works + even if devParentMAC is uppercase and devMac is lowercase + */ + ON child.devParentMAC = parent.devMac COLLATE NOCASE + WHERE parent.devType IN (${networkDeviceTypes}) + AND parent.devIsArchived = 0 + GROUP BY parent.devMac, parent.devName, parent.devPresentLastScan, + parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown + ORDER BY parent.devName; + `; + + const apiBase = getApiBase(); + const apiToken = getApiToken(); + + // Verify token is available + if (!apiToken || apiToken.trim() === '') { + console.error("API_TOKEN not available. Settings may not be loaded yet."); + return; + } + + const url = `${apiBase}/dbquery/read`; + + $.ajax({ + url, + method: "POST", + headers: { "Authorization": `Bearer ${apiToken}` }, + data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(rawSql))) }), + contentType: "application/json", + success: function(data) { + const nodes = data.results || []; + renderNetworkTabs(nodes); + loadUnassignedDevices(); + checkTabsOverflow(); + }, + error: function(xhr, status, error) { + console.error("Error loading network nodes:", status, error); + // Check if it's an auth error + if (xhr.status === 401) { + console.error("Authorization failed. API_TOKEN may be invalid or not yet loaded."); + } + } + }); +} + +/** + * Load device table with configurable SQL and rendering + * @param {Object} options - Configuration object + * @param {string} options.sql - SQL query to fetch devices + * @param {string} options.containerSelector - jQuery selector for container + * @param {string} options.tableId - ID for DataTable instance + * @param {string} options.wrapperHtml - HTML wrapper for table + * @param {boolean} options.assignMode - Whether to show assign/unassign buttons + */ +function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, assignMode = true }) { + const apiBase = getApiBase(); + const apiToken = getApiToken(); + + // Verify token is available + if (!apiToken || apiToken.trim() === '') { + console.error("API_TOKEN not available. Settings may not be loaded yet."); + return; + } + + const url = `${apiBase}/dbquery/read`; + + $.ajax({ + url, + method: "POST", + headers: { "Authorization": `Bearer ${apiToken}` }, + data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(sql))) }), + contentType: "application/json", + success: function(data) { + const devices = data.results || []; + const $container = $(containerSelector); + + // end if nothing to show + if(devices.length == 0) + { + return; + } + + $container.html(wrapperHtml); + + const $table = $(`#${tableId}`); + + const columns = [ + { + title: assignMode ? getString('Network_ManageAssign') : getString('Network_ManageUnassign'), + data: 'devMac', + orderable: false, + width: '5%', + render: function (mac) { + const label = assignMode ? 'assign' : 'unassign'; + const btnClass = assignMode ? 'btn-primary' : 'btn-primary bg-red'; + const btnText = assignMode ? getString('Network_ManageAssign') : getString('Network_ManageUnassign'); + return ``; + } + }, + { + title: getString('Device_TableHead_Name'), + data: 'devName', + width: '15%', + render: function (name, type, device) { + return ` + ${name || '-'} + `; + } + }, + { + title: getString('Device_TableHead_Status'), + data: 'devStatus', + width: '15%', + render: function (_, type, device) { + const badge = getStatusBadgeParts( + device.devPresentLastScan, + device.devAlertDown, + device.devMac, + device.devStatus + ); + return `${badge.iconHtml} ${badge.text}`; + } + }, + { + title: 'MAC', + data: 'devMac', + width: '5%', + render: (data) => `${data}` + }, + { + title: getString('Network_Table_IP'), + data: 'devLastIP', + width: '5%' + }, + { + title: getString('Device_TableHead_Port'), + data: 'devParentPort', + width: '5%' + }, + { + title: getString('Device_TableHead_Vendor'), + data: 'devVendor', + width: '20%' + } + ].filter(Boolean); + + tableConfig = { + data: devices, + columns: columns, + pageLength: 10, + order: assignMode ? [[2, 'asc']] : [], + responsive: true, + autoWidth: false, + searching: true, + createdRow: function (row, data) { + $(row).attr('data-mac', data.devMac); + } + }; + + if ($.fn.DataTable.isDataTable($table)) { + $table.DataTable(tableConfig).clear().rows.add(devices).draw(); + } else { + $table.DataTable(tableConfig); + } + }, + error: function(xhr, status, error) { + console.error("Error loading device table:", status, error); + } + }); +} + +/** + * Load unassigned devices (devices without parent) + */ +function loadUnassignedDevices() { + const sql = ` + SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort + FROM Devices + WHERE (devParentMAC IS NULL OR devParentMAC IN ("", " ", "undefined", "null")) + AND devMac NOT LIKE "%internet%" + AND devIsArchived = 0 + ORDER BY devName ASC`; + + const wrapperHtml = ` +
+
+
+
${getString('Network_UnassignedDevices')}
+
+
+
+
`; + + loadDeviceTable({ + sql, + containerSelector: '#unassigned-devices-wrapper', + tableId: 'unassignedDevicesTable', + wrapperHtml, + assignMode: true + }); +} + +/** + * Load devices connected to a specific node + * @param {string} node_mac - MAC address of the parent node + */ +function loadConnectedDevices(node_mac) { + // Standardize the input just in case + const normalized_mac = node_mac.toLowerCase(); + + const sql = ` + SELECT devName, devMac, devLastIP, devVendor, devPresentLastScan, devAlertDown, devParentPort, devVlan, + CASE + WHEN devIsNew = 1 THEN 'New' + WHEN devPresentLastScan = 1 THEN 'On-line' + WHEN devPresentLastScan = 0 AND devAlertDown != 0 THEN 'Down' + WHEN devIsArchived = 1 THEN 'Archived' + WHEN devPresentLastScan = 0 THEN 'Off-line' + ELSE 'Unknown status' + END AS devStatus + FROM Devices + /* Using COLLATE NOCASE here solves the 'TEXT' vs 'NOCASE' mismatch */ + WHERE devParentMac = '${normalized_mac}' COLLATE NOCASE`; + + // Keep the ID generation consistent + const id = normalized_mac.replace(/:/g, '_'); + + const wrapperHtml = ` + +
`; + + loadDeviceTable({ + sql, + containerSelector: `#leafs_${id}`, + tableId: `table_leafs_${id}`, + wrapperHtml, + assignMode: false + }); +} diff --git a/front/js/network-events.js b/front/js/network-events.js new file mode 100644 index 00000000..4241fc10 --- /dev/null +++ b/front/js/network-events.js @@ -0,0 +1,133 @@ +// network-events.js +// Event handlers and tree node click interactions + +/** + * Handle network node click - select correct tab and scroll to appropriate content + * @param {HTMLElement} el - The clicked element + */ +function handleNodeClick(el) +{ + + isNetworkDevice = $(el).data("devisnetworknodedynamic") == 1; + targetTabMAC = "" + thisDevMac= $(el).data("mac"); + + if (isNetworkDevice == false) + { + targetTabMAC = $(el).data("parentmac"); + } else + { + targetTabMAC = thisDevMac; + } + + var targetTab = $(`a[data-mytabmac="${targetTabMAC}"]`); + + if (targetTab.length) { + // Simulate a click event on the target tab + targetTab.click(); + + + } + + if (isNetworkDevice) { + // Smooth scroll to the tab content + $('html, body').animate({ + scrollTop: targetTab.offset().top - 50 + }, 500); // Adjust the duration as needed + } else { + $("tr.selected").removeClass("selected"); + $(`tr[data-mac="${thisDevMac}"]`).addClass("selected"); + + const tableId = "table_leafs_" + targetTabMAC.replace(/:/g, '_'); + const $table = $(`#${tableId}`).DataTable(); + + // Find the row index (in the full data set) that matches + const rowIndex = $table + .rows() + .eq(0) + .filter(function(idx) { + return $table.row(idx).node().getAttribute("data-mac") === thisDevMac; + }); + + if (rowIndex.length > 0) { + // Change to the page where this row is + $table.page(Math.floor(rowIndex[0] / $table.page.len())).draw(false); + + // Delay needed so the row is in the DOM after page draw + setTimeout(() => { + const rowNode = $table.row(rowIndex[0]).node(); + $(rowNode).addClass("selected"); + + // Smooth scroll to the row + $('html, body').animate({ + scrollTop: $(rowNode).offset().top - 50 + }, 500); + }, 0); + } + } +} + +/** + * Handle window resize events to recheck tab overflow + */ +let resizeTimeout; +$(window).on('resize', function () { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + checkTabsOverflow(); + }, 100); +}); + +/** + * Initialize page on document ready + * Sets up toggle filters and event handlers + */ +$(document).ready(function () { + // Restore cached values on load + const cachedOffline = getCache('showOffline'); + if (cachedOffline !== null) { + $('input[name="showOffline"]').prop('checked', cachedOffline === 'true'); + } + + const cachedArchived = getCache('showArchived'); + if (cachedArchived !== null) { + $('input[name="showArchived"]').prop('checked', cachedArchived === 'true'); + } + + // Function to enable/disable showArchived based on showOffline + function updateArchivedToggle() { + const isOfflineChecked = $('input[name="showOffline"]').is(':checked'); + const archivedToggle = $('input[name="showArchived"]'); + + if (!isOfflineChecked) { + archivedToggle.prop('checked', false); + archivedToggle.prop('disabled', true); + setCache('showArchived', false); + } else { + archivedToggle.prop('disabled', false); + } + } + + // Initial state on load + updateArchivedToggle(); + + // Bind change event for both toggles + $('input[name="showOffline"], input[name="showArchived"]').on('change', function () { + const name = $(this).attr('name'); + const value = $(this).is(':checked'); + setCache(name, value); + + // Update state of showArchived if showOffline changed + if (name === 'showOffline') { + updateArchivedToggle(); + } + + // Refresh page after a brief delay to ensure cache is written + setTimeout(() => { + location.reload(); + }, 100); + }); + + // init pop up hover boxes for device details + initHoverNodeInfo(); +}); diff --git a/front/js/network-init.js b/front/js/network-init.js new file mode 100644 index 00000000..a5e15d00 --- /dev/null +++ b/front/js/network-init.js @@ -0,0 +1,149 @@ +// network-init.js +// Main initialization and data loading logic for network topology + +// Global variables needed by other modules +var networkDeviceTypes = ""; +var showArchived = false; +var showOffline = false; + +/** + * Initialize network topology on page load + * Fetches all devices and sets up the tree visualization + */ +function initNetworkTopology() { + networkDeviceTypes = getSetting("NETWORK_DEVICE_TYPES").replace("[", "").replace("]", ""); + showArchived = getCache('showArchived') === "true"; + showOffline = getCache('showOffline') === "true"; + + console.log('showArchived:', showArchived); + console.log('showOffline:', showOffline); + + // Always get all devices + const rawSql = ` + SELECT *, + CASE + WHEN devAlertDown != 0 AND devPresentLastScan = 0 THEN "Down" + WHEN devPresentLastScan = 1 THEN "On-line" + ELSE "Off-line" + END AS devStatus, + CASE + WHEN devType IN (${networkDeviceTypes}) THEN 1 + ELSE 0 + END AS devIsNetworkNodeDynamic + FROM Devices a + `; + + const apiBase = getApiBase(); + const apiToken = getApiToken(); + + // Verify token is available before making API call + if (!apiToken || apiToken.trim() === '') { + console.error("API_TOKEN not available. Settings may not be loaded yet. Retrying in 500ms..."); + // Retry after a short delay to allow settings to load + setTimeout(() => { + initNetworkTopology(); + }, 500); + return; + } + + const url = `${apiBase}/dbquery/read`; + + $.ajax({ + url, + method: "POST", + headers: { "Authorization": `Bearer ${apiToken}` }, + data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(rawSql))) }), + contentType: "application/json", + success: function(data) { + console.log(data); + + const allDevices = data.results || []; + + console.log(allDevices); + + if (!allDevices || allDevices.length === 0) { + showModalOK(getString('Gen_Warning'), getString('Network_NoDevices')); + return; + } + + // Count totals for UI + let archivedCount = 0; + let offlineCount = 0; + + allDevices.forEach(device => { + if (parseInt(device.devIsArchived) === 1) archivedCount++; + if (parseInt(device.devPresentLastScan) === 0 && parseInt(device.devIsArchived) === 0) offlineCount++; + }); + + if(archivedCount > 0) + { + $('#showArchivedNumber').text(`(${archivedCount})`); + } + + if(offlineCount > 0) + { + $('#showOfflineNumber').text(`(${offlineCount})`); + } + + // Now apply UI filter based on toggles (always keep root) + const filteredDevices = allDevices.filter(device => { + const isRoot = (device.devMac || '').toLowerCase() === 'internet'; + + if (isRoot) return true; + if (!showArchived && parseInt(device.devIsArchived) === 1) return false; + if (!showOffline && parseInt(device.devPresentLastScan) === 0) return false; + return true; + }); + + // Sort filtered devices + const orderTopologyBy = createArray(getSetting("UI_TOPOLOGY_ORDER")); + const devicesSorted = filteredDevices.sort((a, b) => { + const parsePort = (port) => { + const parsed = parseInt(port, 10); + return isNaN(parsed) ? Infinity : parsed; + }; + + switch (orderTopologyBy[0]) { + case "Name": + // ensuring string + const nameA = (a.devName ?? "").toString(); + const nameB = (b.devName ?? "").toString(); + const nameCompare = nameA.localeCompare(nameB); + return nameCompare !== 0 + ? nameCompare + : parsePort(a.devParentPort) - parsePort(b.devParentPort); + + case "Port": + return parsePort(a.devParentPort) - parsePort(b.devParentPort); + + default: + return a.rowid - b.rowid; + } + }); + + setCache('devicesListNew', JSON.stringify(devicesSorted)); + deviceListGlobal = devicesSorted; + + // Render filtered result + initTree(getHierarchy()); + loadNetworkNodes(); + attachTreeEvents(); + }, + error: function(xhr, status, error) { + console.error("Error loading topology data:", status, error); + if (xhr.status === 401) { + console.error("Authorization failed! API_TOKEN may be invalid. Check that API_TOKEN setting is correct and not empty."); + showMessage("Authorization Failed: API_TOKEN setting may be invalid or not loaded. Please refresh the page."); + } + } + }); +} + +// Initialize on page load +$(document).ready(function () { + // show spinning icon + showSpinner(); + + // Start loading the network topology + initNetworkTopology(); +}); diff --git a/front/js/network-tabs.js b/front/js/network-tabs.js new file mode 100644 index 00000000..06096532 --- /dev/null +++ b/front/js/network-tabs.js @@ -0,0 +1,259 @@ +// network-tabs.js +// Tab management and tab content rendering functions + +/** + * Render network tabs from nodes + * @param {Array} nodes - Array of network node objects + */ +function renderNetworkTabs(nodes) { + let html = ''; + nodes.forEach((node, i) => { + const iconClass = node.online == 1 ? "text-green" : + (node.node_alert == 1 ? "text-red" : "text-gray50"); + + const portLabel = node.node_ports_count ? ` (${node.node_ports_count})` : ''; + const icon = atob(node.node_icon); + const id = node.node_mac.replace(/:/g, '_'); + + html += ` +
  • + +
    ${icon}
    + ${node.node_name}${portLabel} +
    +
  • `; + }); + + $('.nav-tabs').html(html); + + // populate tabs + renderNetworkTabContent(nodes); + + // init selected (first) tab + initTab(); + + // init selected node highlighting + initSelectedNodeHighlighting() + + // Register events on tab change + $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + initSelectedNodeHighlighting() + }); +} + +/** + * Render content for each network tab + * @param {Array} nodes - Array of network node objects + */ +function renderNetworkTabContent(nodes) { + $('.tab-content').empty(); + + nodes.forEach((node, i) => { + const id = node.node_mac.replace(/:/g, '_'); + + const badge = getStatusBadgeParts( + node.online, + node.node_alert, + node.node_mac + ); + + const badgeHtml = `${badge.iconHtml} ${badge.status}`; + const parentId = node.parent_mac.replace(/:/g, '_'); + + isRootNode = node.parent_mac == ""; + + const paneHtml = ` +
    +
    ${getString('Network_Node')}
    + +
    + + +
    + +
    + +
    ${node.node_mac}
    +
    + +
    + +
    ${node.node_type}
    +
    + +
    + +
    ${badgeHtml}
    +
    + +
    + + +
    +
    +
    +
    + + ${getString('Network_Connected')} +
    + +
    +
    +
    + `; + + $('.tab-content').append(paneHtml); + loadConnectedDevices(node.node_mac); + }); +} + +/** + * Initialize the active tab based on cache or query parameter + */ +function initTab() +{ + key = "activeNetworkTab" + + // default selection + selectedTab = "Internet_id" + + // the #target from the url + target = getQueryString('mac') + + // update cookie if target specified + if(target != "") + { + setCache(key, target.replaceAll(":","_")+'_id') // _id is added so it doesn't conflict with AdminLTE tab behavior + } + + // get the tab id from the cookie (already overridden by the target) + if(!emptyArr.includes(getCache(key))) + { + selectedTab = getCache(key); + } + + // Activate panel + $('.nav-tabs a[id='+ selectedTab +']').tab('show'); + + // When changed save new current tab + $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + setCache(key, $(e.target).attr('id')) + }); + +} + +/** + * Highlight the currently selected node in the tree + */ +function initSelectedNodeHighlighting() +{ + + var currentNodeMac = $(".networkNodeTabHeaders.active a").data("mytabmac"); + + // change highlighted node in the tree + selNode = $("#networkTree .highlightedNode")[0] + + console.log(selNode) + + if(selNode) + { + $(selNode).attr('class', $(selNode).attr('class').replace('highlightedNode')) + } + + newSelNode = $("#networkTree div[data-mac='"+currentNodeMac+"']")[0] + + console.log(newSelNode) + + $(newSelNode).attr('class', $(newSelNode).attr('class') + ' highlightedNode') +} + +/** + * Update a device's network assignment + * @param {string} leafMac - MAC address of device to update + * @param {string} action - 'assign' or 'unassign' + */ +function updateLeaf(leafMac, action) { + console.log(leafMac); // child + console.log(action); // action + + const nodeMac = $(".networkNodeTabHeaders.active a").data("mytabmac") || ""; + + if (action === "assign") { + if (!nodeMac) { + showMessage(getString("Network_Cant_Assign_No_Node_Selected")); + } else if (leafMac.toLowerCase().includes("internet")) { + showMessage(getString("Network_Cant_Assign")); + } else { + saveData("updateNetworkLeaf", leafMac, nodeMac); + setTimeout(() => location.reload(), 500); + } + + } else if (action === "unassign") { + saveData("updateNetworkLeaf", leafMac, ""); + setTimeout(() => location.reload(), 500); + + } else { + console.warn("Unknown action:", action); + } +} + +/** + * Dynamically show/hide tab names based on available space + * Hides tab names when tabs overflow, shows them again when space is available + */ +function checkTabsOverflow() { + const $ul = $('.nav-tabs'); + const $lis = $ul.find('li'); + + // First measure widths with current state + let totalWidth = 0; + $lis.each(function () { + totalWidth += $(this).outerWidth(true); + }); + + const ulWidth = $ul.width(); + const isOverflowing = totalWidth > ulWidth; + + if (isOverflowing) { + if (!$ul.hasClass('hide-node-names')) { + $ul.addClass('hide-node-names'); + + // Re-check: did hiding fix it? + requestAnimationFrame(() => { + let newTotal = 0; + $lis.each(function () { + newTotal += $(this).outerWidth(true); + }); + + if (newTotal > $ul.width()) { + // Still overflowing — do nothing, keep class + } + }); + } + } else { + if ($ul.hasClass('hide-node-names')) { + $ul.removeClass('hide-node-names'); + + // Re-check: did un-hiding break it? + requestAnimationFrame(() => { + let newTotal = 0; + $lis.each(function () { + newTotal += $(this).outerWidth(true); + }); + + if (newTotal > $ul.width()) { + // Oops, that broke it — re-hide + $ul.addClass('hide-node-names'); + } + }); + } + } +} diff --git a/front/js/network-tree.js b/front/js/network-tree.js new file mode 100644 index 00000000..256fa85d --- /dev/null +++ b/front/js/network-tree.js @@ -0,0 +1,346 @@ +// network-tree.js +// Tree hierarchy construction and rendering functions + +// Global state variables +var leafNodesCount = 0; +var visibleNodesCount = 0; +var parentNodesCount = 0; +var hiddenMacs = []; // hidden children +var hiddenChildren = []; +var deviceListGlobal = null; +var myTree; + +/** + * Recursively get children nodes and build a tree + * @param {Object} node - Current node + * @param {Array} list - Full device list + * @param {string} path - Path to current node + * @param {Array} visited - Visited nodes (for cycle detection) + * @returns {Object} Tree node with children + */ +function getChildren(node, list, path, visited = []) +{ + var children = []; + + // Check for infinite recursion by seeing if the node has been visited before + if (visited.includes(node.devMac.toLowerCase())) { + console.error("Infinite recursion detected at node:", node.devMac); + write_notification("[ERROR] ⚠ Infinite recursion detected. You probably have assigned the Internet node to another children node or to itself. Please open a new issue on GitHub and describe how you did it.", 'interrupt') + return { error: "Infinite recursion detected", node: node.devMac }; + } + + // Add current node to visited list + visited.push(node.devMac.toLowerCase()); + + // Loop through all items to find children of the current node + for (var i in list) { + const item = list[i]; + const parentMac = item.devParentMAC?.toLowerCase() || ""; // null-safe + const nodeMac = node.devMac?.toLowerCase() || ""; // null-safe + + if (parentMac != "" && parentMac == nodeMac && !hiddenMacs.includes(parentMac)) { + + visibleNodesCount++; + + // Process children recursively, passing a copy of the visited list + children.push(getChildren(list[i], list, path + ((path == "") ? "" : '|') + parentMac, visited)); + } + } + + // Track leaf and parent node counts + if (children.length == 0) { + leafNodesCount++; + } else { + parentNodesCount++; + } + + // console.log(node); + + return { + name: node.devName, + path: path, + mac: node.devMac, + port: node.devParentPort, + id: node.devMac, + parentMac: node.devParentMAC, + icon: node.devIcon, + type: node.devType, + devIsNetworkNodeDynamic: node.devIsNetworkNodeDynamic, + vendor: node.devVendor, + lastseen: node.devLastConnection, + firstseen: node.devFirstConnection, + ip: node.devLastIP, + status: node.devStatus, + presentLastScan: node.devPresentLastScan, + alertDown: node.devAlertDown, + hasChildren: children.length > 0 || hiddenMacs.includes(node.devMac), + relType: node.devParentRelType, + devVlan: node.devVlan, + devSSID: node.devSSID, + hiddenChildren: hiddenMacs.includes(node.devMac), + qty: children.length, + children: children + }; +} + +/** + * Build complete hierarchy starting from the Internet node + * @returns {Object} Root hierarchy object + */ +function getHierarchy() +{ + // reset counters before rebuilding the hierarchy + leafNodesCount = 0; + visibleNodesCount = 0; + parentNodesCount = 0; + + let internetNode = null; + + for(i in deviceListGlobal) + { + if(deviceListGlobal[i].devMac.toLowerCase() == 'internet') + { + internetNode = deviceListGlobal[i]; + + return (getChildren(internetNode, deviceListGlobal, '')) + break; + } + } + + if (!internetNode) { + showModalOk( + getString('Network_Configuration_Error'), + getString('Network_Root_Not_Configured') + ); + console.error("getHierarchy(): Internet node not found"); + return null; + } +} + +/** + * Toggle collapse/expand state of a subtree + * @param {string} parentMac - MAC address of parent node to toggle + * @param {string} treePath - Path in tree (colon-separated) + */ +function toggleSubTree(parentMac, treePath) +{ + treePath = treePath.split('|') + + parentMac = parentMac.toLowerCase() + + if(!hiddenMacs.includes(parentMac)) + { + hiddenMacs.push(parentMac) + } + else + { + removeItemFromArray(hiddenMacs, parentMac) + } + + updatedTree = getHierarchy() + myTree.refresh(updatedTree); + + // re-attach any onclick events + attachTreeEvents(); +} + +/** + * Attach click events to tree collapse/expand controls + */ +function attachTreeEvents() +{ + // toggle subtree functionality + $("div[data-mytreemac]").each(function(){ + $(this).attr('onclick', 'toggleSubTree("'+$(this).attr('data-mytreemac')+'","'+ $(this).attr('data-mytreepath')+'")') + }); +} + +/** + * Convert pixels to em units + * @param {number} px - Pixel value + * @param {HTMLElement} element - Reference element for font-size + * @returns {number} Value in em units + */ +function pxToEm(px, element) { + var baseFontSize = parseFloat($(element || "body").css("font-size")); + return px / baseFontSize; +} + +/** + * Convert em units to pixels + * @param {number} em - Value in em units + * @param {HTMLElement} element - Reference element for font-size + * @returns {number} Value in pixels (rounded) + */ +function emToPx(em, element) { + var baseFontSize = parseFloat($(element || "body").css("font-size")); + return Math.round(em * baseFontSize); +} + +/** + * Initialize tree visualization + * @param {Object} myHierarchy - Hierarchy object to render + */ +function initTree(myHierarchy) +{ + if(myHierarchy && myHierarchy.type !== "") + { + // calculate the drawing area based on the tree width and available screen size + let baseFontSize = parseFloat($('html').css('font-size')); + let treeAreaHeight = ($(window).height() - 155); ; + let minNodeWidth = 60 // min safe node width not breaking the tree + + // calculate the font size of the leaf nodes to fit everything into the tree area + leafNodesCount == 0 ? 1 : leafNodesCount; + + emSize = pxToEm((treeAreaHeight/(leafNodesCount)).toFixed(2)); + + // let screenWidthEm = pxToEm($('.networkTable').width()-15); + let minTreeWidthPx = parentNodesCount * minNodeWidth; + let actualWidthPx = $('.networkTable').width() - 15; + + let finalWidthPx = Math.max(actualWidthPx, minTreeWidthPx); + + // override original value + let screenWidthEm = pxToEm(finalWidthPx); + + // handle canvas and node size if only a few nodes + emSize > 1 ? emSize = 1 : emSize = emSize; + + let nodeHeightPx = emToPx(emSize*1); + let nodeWidthPx = emToPx(screenWidthEm / (parentNodesCount)); + + // handle if only a few nodes + nodeWidthPx > 160 ? nodeWidthPx = 160 : nodeWidthPx = nodeWidthPx; + if (nodeWidthPx < minNodeWidth) nodeWidthPx = minNodeWidth; // minimum safe width + + console.log("Calculated nodeWidthPx =", nodeWidthPx, "emSize =", emSize , " screenWidthEm:", screenWidthEm, " emToPx(screenWidthEm):" , emToPx(screenWidthEm)); + + // init the drawing area size + $("#networkTree").attr('style', `height:${treeAreaHeight}px; width:${emToPx(screenWidthEm)}px`) + + console.log(Treeviz); + + myTree = Treeviz.create({ + htmlId: "networkTree", + renderNode: nodeData => { + + (!emptyArr.includes(nodeData.data.port )) ? port = nodeData.data.port : port = ""; + + (port == "" || port == 0 || port == 'None' ) ? portBckgIcon = `` : portBckgIcon = ``; + + portHtml = (port == "" || port == 0 || port == 'None' ) ? "   " : port; + + // Build HTML for individual nodes in the network diagram + deviceIcon = (!emptyArr.includes(nodeData.data.icon )) ? + `
    + ${atob(nodeData.data.icon)} +
    ` : ""; + devicePort = `
    + ${portHtml}
    +
    + ${portBckgIcon} +
    `; + collapseExpandIcon = nodeData.data.hiddenChildren ? + "square-plus" : "square-minus"; + + // generate +/- icon if node has children nodes + collapseExpandHtml = nodeData.data.hasChildren ? + `
    + +
    ` : ""; + + selectedNodeMac = $(".nav-tabs-custom .active a").attr('data-mytabmac') + + highlightedCss = nodeData.data.mac == selectedNodeMac ? + " highlightedNode " : ""; + cssNodeType = nodeData.data.devIsNetworkNodeDynamic ? + " node-network-device " : " node-standard-device "; + + networkHardwareIcon = nodeData.data.devIsNetworkNodeDynamic ? ` + + ` : ""; + + const badgeConf = getStatusBadgeParts(nodeData.data.presentLastScan, nodeData.data.alertDown, nodeData.data.mac, statusText = '') + + return result = `
    +
    + ${devicePort} ${deviceIcon} + ${nodeData.data.name} + ${networkHardwareIcon} + +
    +
    + ${collapseExpandHtml}`; + }, + mainAxisNodeSpacing: 'auto', + // secondaryAxisNodeSpacing: 0.3, + nodeHeight: nodeHeightPx, + nodeWidth: nodeWidthPx, + marginTop: '5', + isHorizontal : true, + hasZoom: true, + hasPan: true, + marginLeft: '10', + marginRight: '10', + idKey: "mac", + hasFlatData: false, + relationnalField: "children", + linkLabel: { + render: (parent, child) => { + // Return text or HTML to display on the connection line + connectionLabel = (child?.data.devVlan ?? "") + "/" + (child?.data.devSSID ?? ""); + if(connectionLabel == "/") + { + connectionLabel = ""; + } + + return connectionLabel; + // or with HTML: + // return "reports to"; + }, + color: "#336c87ff", // Label text color (optional) + fontSize: nodeHeightPx - 5 // Label font size in px (optional) + }, + linkWidth: (nodeData) => 2, + linkColor: (nodeData) => { + relConf = getRelationshipConf(nodeData.data.relType) + return relConf.color; + } + // onNodeClick: (nodeData) => handleNodeClick(nodeData), + }); + + console.log(deviceListGlobal); + myTree.refresh(myHierarchy); + + // hide spinning icon + hideSpinner() + } else + { + console.error("getHierarchy() not returning expected result"); + } +} diff --git a/front/network.php b/front/network.php index 4ea6070d..eb57afd5 100755 --- a/front/network.php +++ b/front/network.php @@ -3,11 +3,6 @@ require 'php/templates/modals.php'; ?> - -
    @@ -72,1058 +67,12 @@ - - - - + + + + + +