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"
This commit is contained in:
Graham McIntire 2026-03-14 18:27:28 -05:00
parent e5fc396d0b
commit a1c5d593dc
No known key found for this signature in database
4 changed files with 192 additions and 47 deletions

View file

@ -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)

View file

@ -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<string, {x: number, y: number}> => {
const DEVICE_SPACING = 110
const SITE_PADDING = 50
const SITE_GAP = 90
const SITE_COLS = 3
const siteGroups = new Map<string, string[]>()
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<string, {x: number, y: number}> = {}
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
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<string, {x: number, y: number}> {
const DEVICE_SPACING = 110
const SITE_PADDING = 50
const SITE_GAP = 90
const SITE_COLS = 3
const siteGroups = new Map<string, string[]>()
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<string, {x: number, y: number}> = {}
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
}
}

View file

@ -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 =
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)

View file

@ -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