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 += `
+ `;
+ });
+
+ $('.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 @@
-
-
-
-
+
+
+
+
+
+