From a1c5d593dc8e643e1e0f81ba652e17789fdf1bf8 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 14 Mar 2026 18:27:28 -0500 Subject: [PATCH] fix: network map label sizes, site layout, and missing links - Increase node font from 11px to 13px, compound labels to 14px - Replace cose physics layout with deterministic grid preset layout to eliminate site group overlap - Fix nil source_interface_id crash in topology.ex find_existing_link/1 (was using == nil instead of is_nil/1) - Fix nil device label in device_to_node/1 fallback to IP or "Unknown" --- CHANGELOG.txt | 9 ++ assets/js/app.ts | 211 ++++++++++++++++++++++++++++++-------- lib/towerops/topology.ex | 14 ++- priv/static/changelog.txt | 5 + 4 files changed, 192 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f3e4755c..898d2f85 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,12 @@ +2026-03-14 +fix: network map label size, site layout, and missing links + - Increased node label font from 11px to 13px and compound node label to 14px + - Replaced physics-based cose layout with manual grid preset layout to prevent site group overlap + - Fixed nil source_interface_id crash in topology.ex find_existing_link/1 (compared nil with == instead of is_nil/1) + - Fixed nil device label in device_to_node/1 (falls back to ip_address then "Unknown") + - Ran topology inference for all devices to build DeviceLink records from 259 existing SNMP neighbors + Files: assets/js/app.ts, lib/towerops/topology.ex + 2026-03-13 ux: overhaul Preseem devices page to match Gaiia mapping UX - Replace filter tabs with pill buttons (All/Unmatched/Matched) diff --git a/assets/js/app.ts b/assets/js/app.ts index 85c3c6a7..ca2095bc 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -647,6 +647,77 @@ const NetworkMap = { } } + // Compute grid-based positions to prevent site overlap. + // Groups device nodes by parent site, arranges them in a tidy subgrid, + // then lays out the site groups in a row-wrapped grid. + const computePresetPositions = (nodes: any[]): Record => { + const DEVICE_SPACING = 110 + const SITE_PADDING = 50 + const SITE_GAP = 90 + const SITE_COLS = 3 + + const siteGroups = new Map() + const unparented: string[] = [] + + nodes.forEach((node) => { + if (node.type === 'site') return + if (node.parent) { + if (!siteGroups.has(node.parent)) siteGroups.set(node.parent, []) + siteGroups.get(node.parent)!.push(node.id) + } else { + unparented.push(node.id) + } + }) + + const positions: Record = {} + let curX = 0, curY = 0, maxH = 0, col = 0 + + siteGroups.forEach((deviceIds) => { + const n = deviceIds.length + const cols = Math.max(1, Math.ceil(Math.sqrt(n))) + const rows = Math.ceil(n / cols) + const innerW = (cols - 1) * DEVICE_SPACING + const innerH = (rows - 1) * DEVICE_SPACING + const cx = curX + SITE_PADDING + innerW / 2 + const cy = curY + SITE_PADDING + innerH / 2 + + deviceIds.forEach((id, i) => { + const dc = i % cols + const dr = Math.floor(i / cols) + positions[id] = { + x: cx + dc * DEVICE_SPACING - innerW / 2, + y: cy + dr * DEVICE_SPACING - innerH / 2 + } + }) + + const totalW = innerW + SITE_PADDING * 2 + DEVICE_SPACING + const totalH = innerH + SITE_PADDING * 2 + DEVICE_SPACING + maxH = Math.max(maxH, totalH) + col++ + + if (col >= SITE_COLS) { + col = 0 + curX = 0 + curY += maxH + SITE_GAP + maxH = 0 + } else { + curX += totalW + SITE_GAP + } + }) + + // Place unparented nodes below all sites + const unY = curY + maxH + SITE_GAP + unparented.forEach((id, i) => { + const uc = i % 6 + const ur = Math.floor(i / 6) + positions[id] = { x: uc * DEVICE_SPACING, y: unY + ur * DEVICE_SPACING } + }) + + return positions + } + + const presetPositions = computePresetPositions(topology.nodes || []) + // Build Cytoscape elements from topology data const elements: any[] = [] @@ -717,11 +788,13 @@ const NetworkMap = { 'color': isDark ? '#fff' : '#000', 'text-valign': 'bottom', 'text-halign': 'center', - 'text-margin-y': 5, - 'font-size': '11px', + 'text-margin-y': 6, + 'font-size': '13px', 'font-weight': '500', - 'width': 40, - 'height': 40, + 'text-wrap': 'wrap', + 'text-max-width': '120px', + 'width': 44, + 'height': 44, 'border-width': 2, 'border-color': function(ele: any) { const status = ele.data('status') @@ -766,9 +839,9 @@ const NetworkMap = { 'border-width': 2, 'border-color': '#9ca3af', 'opacity': 0.6, - 'width': 30, - 'height': 30, - 'font-size': '9px' + 'width': 34, + 'height': 34, + 'font-size': '11px' } }, { @@ -791,19 +864,19 @@ const NetworkMap = { selector: '$node > node', // compound parent selector style: { 'background-color': isDark ? '#1e293b' : '#f8fafc', - 'background-opacity': 0.6, - 'border-width': 1, - 'border-color': isDark ? '#334155' : '#e2e8f0', + 'background-opacity': 0.7, + 'border-width': 2, + 'border-color': isDark ? '#475569' : '#cbd5e1', 'border-style': 'solid', 'shape': 'round-rectangle', - 'padding': '20px', + 'padding': '30px', 'text-valign': 'top', 'text-halign': 'center', - 'text-margin-y': -5, + 'text-margin-y': 8, 'label': 'data(label)', - 'font-size': '12px', - 'font-weight': '600', - 'color': isDark ? '#94a3b8' : '#64748b' + 'font-size': '14px', + 'font-weight': '700', + 'color': isDark ? '#94a3b8' : '#475569' } as any }, { @@ -847,20 +920,10 @@ const NetworkMap = { } ], layout: { - name: 'cose', - animate: true, - animationDuration: 500, - nodeRepulsion: 30000, // Even more repulsion to spread nodes apart - idealEdgeLength: 250, // Longer edges to give more room for labels - edgeElasticity: 200, // More flexible edges - nestingFactor: 1, // Less nesting - gravity: 30, // Even less gravity to allow more spread - numIter: 2000, // More iterations for better layout - initialTemp: 500, // Higher initial temperature - coolingFactor: 0.95, - minTemp: 1.0, - nodeOverlap: 50, // More overlap prevention - avoidOverlap: true // Enable overlap avoidance + name: 'preset', + positions: (node: any) => presetPositions[node.id()] || { x: 0, y: 0 }, + fit: true, + padding: 50 }, minZoom: 0.2, maxZoom: 3, @@ -1052,22 +1115,84 @@ const NetworkMap = { } }) - // Only add and layout new elements (preserves existing positions) + // When new elements arrive, recompute all positions from scratch if (newElements.length > 0) { - const added = this.cy.add(newElements) - // Run layout only on new elements - if (added.nonempty()) { - added.layout({ - name: 'cose', - animate: true, - animationDuration: 300, - fit: false, - randomize: false, - nodeRepulsion: 30000, - idealEdgeLength: 250 - }).run() - } + this.cy.add(newElements) + const allNodes = topology.nodes || [] + const newPositions = this._computePresetPositions(allNodes) + this.cy.layout({ + name: 'preset', + positions: (node: any) => newPositions[node.id()] || { x: 0, y: 0 }, + fit: true, + padding: 50 + }).run() } + }, + + // Shared position computation — same algorithm as in initializeCytoscape + _computePresetPositions(this: any, nodes: any[]): Record { + const DEVICE_SPACING = 110 + const SITE_PADDING = 50 + const SITE_GAP = 90 + const SITE_COLS = 3 + + const siteGroups = new Map() + const unparented: string[] = [] + + nodes.forEach((node: any) => { + if (node.type === 'site') return + if (node.parent) { + if (!siteGroups.has(node.parent)) siteGroups.set(node.parent, []) + siteGroups.get(node.parent)!.push(node.id) + } else { + unparented.push(node.id) + } + }) + + const positions: Record = {} + let curX = 0, curY = 0, maxH = 0, col = 0 + + siteGroups.forEach((deviceIds: string[]) => { + const n = deviceIds.length + const cols = Math.max(1, Math.ceil(Math.sqrt(n))) + const rows = Math.ceil(n / cols) + const innerW = (cols - 1) * DEVICE_SPACING + const innerH = (rows - 1) * DEVICE_SPACING + const cx = curX + SITE_PADDING + innerW / 2 + const cy = curY + SITE_PADDING + innerH / 2 + + deviceIds.forEach((id: string, i: number) => { + const dc = i % cols + const dr = Math.floor(i / cols) + positions[id] = { + x: cx + dc * DEVICE_SPACING - innerW / 2, + y: cy + dr * DEVICE_SPACING - innerH / 2 + } + }) + + const totalW = innerW + SITE_PADDING * 2 + DEVICE_SPACING + const totalH = innerH + SITE_PADDING * 2 + DEVICE_SPACING + maxH = Math.max(maxH, totalH) + col++ + + if (col >= SITE_COLS) { + col = 0 + curX = 0 + curY += maxH + SITE_GAP + maxH = 0 + } else { + curX += totalW + SITE_GAP + } + }) + + const unY = curY + maxH + SITE_GAP + unparented.forEach((id: string, i: number) => { + const uc = i % 6 + const ur = Math.floor(i / 6) + positions[id] = { x: uc * DEVICE_SPACING, y: unY + ur * DEVICE_SPACING } + }) + + return positions } } diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index a1eaa888..864021b2 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -639,7 +639,7 @@ defmodule Towerops.Topology do defp device_to_node(device) do %{ id: device.id, - label: device.name, + label: device.name || device.ip_address || "Unknown", type: role_to_type_atom(device.device_role), device_role: device.device_role, status: device.status, @@ -915,9 +915,15 @@ defmodule Towerops.Topology do source_interface_id = Map.get(link_attrs, :source_interface_id) base_query = - from(l in DeviceLink, - where: l.source_device_id == ^source_device_id and l.source_interface_id == ^source_interface_id - ) + if is_nil(source_interface_id) do + from(l in DeviceLink, + where: l.source_device_id == ^source_device_id and is_nil(l.source_interface_id) + ) + else + from(l in DeviceLink, + where: l.source_device_id == ^source_device_id and l.source_interface_id == ^source_interface_id + ) + end link_attrs |> existing_link_query(base_query) diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index d21bfdbc..0763f0ce 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -1,3 +1,8 @@ +2026-03-14 — Network Map Improvements +* Node labels are now larger and easier to read +* Sites no longer overlap — each site group is laid out in its own section +* Links between equipment now visible based on LLDP and ARP topology data + 2026-03-12 — Mobile Responsive Improvements * Improved layout across all pages for mobile devices * Tables now hide less critical columns on small screens to prevent horizontal scrolling