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:
parent
e5fc396d0b
commit
a1c5d593dc
4 changed files with 192 additions and 47 deletions
|
|
@ -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)
|
||||
|
|
|
|||
207
assets/js/app.ts
207
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<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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue