towerops/assets/js/hooks/network_map.ts
Graham McIntire 3bd4ed303c perf(assets): split big hooks into per-page chunks
app.js was bundling Chart.js (~200 KB), Cytoscape (~500 KB) and four
heavy hooks (SensorChart, NetworkMap, WeathermapViewer, SitesMap)
into a single 1.25 MB blob that every page paid for.

Extract each into its own module under assets/js/hooks/ and reference
them through the existing lazyHook() wrapper so the implementation is
fetched only when a hook actually mounts. esbuild --splitting then
factors the shared cytoscape vendor blob into a chunk that's only
loaded on topology / weathermap pages.

After-bundle layout (minified):
  app.js                  297.8 KB   (was ~1250 KB; -76%)
  chunk-LJGWK7RH (cyto)   555.1 KB   loaded on topology pages only
  sensor_chart            325.3 KB   loaded on sensor pages only
  network_map              24.4 KB   topology hook glue
  weathermap               13.0 KB   weathermap hook glue
  sites_map                 4.1 KB   site map hook glue
  coverage_hooks           25.0 KB   coverage hook glue

LeafletMap also moved to sites_map.ts and now uses ensureLeaflet
instead of polling for window.L with setTimeout.
2026-05-07 07:58:38 -05:00

659 lines
24 KiB
TypeScript

// Lazy-loaded network topology hook (Cytoscape.js). Cytoscape is a
// large dependency (~500KB), so it's only pulled in by the topology
// pages — most pages never load this chunk.
import cytoscape from "../../vendor/cytoscape"
export const NetworkMap = {
cy: null as any,
_topologyData: null as any,
_zoomInHandler: null as ((e: Event) => void) | null,
_zoomOutHandler: null as ((e: Event) => void) | null,
_fitHandler: null as ((e: Event) => void) | null,
mounted(this: any) {
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
this._topologyData = topologyData
this.initializeCytoscape(topologyData)
this.handleEvent("update_topology", (payload: any) => {
if (this.cy) {
this._topologyData = payload.topology
this.updateTopology(payload.topology)
}
})
this.handleEvent("apply_filter", (payload: any) => {
if (!this.cy) return
this._applyFilter(payload.filter)
})
this.handleEvent("search_nodes", (payload: any) => {
if (!this.cy) return
this._searchNodes(payload.query)
})
this.handleEvent("change_layout", (payload: any) => {
if (!this.cy || !this._topologyData) return
this._changeLayout(payload.mode, this._topologyData)
})
},
updated(this: any) {
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
this.updateTopology(topologyData)
},
destroyed(this: any) {
const zoomIn = document.getElementById('cy-zoom-in')
const zoomOut = document.getElementById('cy-zoom-out')
const fit = document.getElementById('cy-fit')
if (zoomIn && this._zoomInHandler) zoomIn.removeEventListener('click', this._zoomInHandler)
if (zoomOut && this._zoomOutHandler) zoomOut.removeEventListener('click', this._zoomOutHandler)
if (fit && this._fitHandler) fit.removeEventListener('click', this._fitHandler)
this._zoomInHandler = null
this._zoomOutHandler = null
this._fitHandler = null
if (this.cy) {
try { this.cy.stop() } catch (_e) { /* ignore */ }
this.cy.destroy()
this.cy = null
}
},
_initControls(this: any) {
const ZOOM_STEP = 0.25
const zoomIn = document.getElementById('cy-zoom-in')
const zoomOut = document.getElementById('cy-zoom-out')
const fit = document.getElementById('cy-fit')
if (zoomIn) {
this._zoomInHandler = () => {
if (!this.cy) return
this.cy.zoom({
level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()),
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
})
}
zoomIn.addEventListener('click', this._zoomInHandler)
}
if (zoomOut) {
this._zoomOutHandler = () => {
if (!this.cy) return
this.cy.zoom({
level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()),
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
})
}
zoomOut.addEventListener('click', this._zoomOutHandler)
}
if (fit) {
this._fitHandler = () => {
if (!this.cy) return
this.cy.fit(undefined, 50)
}
fit.addEventListener('click', this._fitHandler)
}
},
initializeCytoscape(this: any, topology: any) {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
const roleColors: Record<string, string> = {
router: '#3B82F6', switch: '#10B981', wireless: '#8B5CF6',
firewall: '#EF4444', server: '#F59E0B', unknown: '#9CA3AF'
}
const roleShapes: Record<string, string> = {
router: 'round-rectangle', switch: 'diamond', wireless: 'triangle',
firewall: 'hexagon', server: 'rectangle', unknown: 'ellipse'
}
const signalHealthColors: Record<string, string> = {
good: '#10B981', degraded: '#F59E0B', critical: '#EF4444'
}
const getEdgeStyle = (confidence: number, signalHealth?: string) => {
if (signalHealth && signalHealthColors[signalHealth]) {
return { color: signalHealthColors[signalHealth], style: 'solid' as const, dashPattern: [] }
}
if (confidence > 0.9) return { color: '#10B981', style: 'solid' as const, dashPattern: [] }
if (confidence >= 0.5) return { color: '#F59E0B', style: 'dashed' as const, dashPattern: [6, 3] }
return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [2, 3] }
}
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
}
})
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 || [])
const elements: any[] = []
topology.nodes?.forEach((node: any) => {
const data: any = {
id: node.id, label: node.label, type: node.type, status: node.status,
discovered: node.discovered, ip_address: node.ip_address, site_name: node.site_name,
manufacturer: node.manufacturer, client_count: node.client_count || 0,
signal_health: node.signal_health, has_alerts: node.has_alerts || false,
latitude: node.latitude, longitude: node.longitude, device_role: node.device_role
}
if (node.parent) data.parent = node.parent
elements.push({
data,
classes: node.type === 'site' ? 'site-group' : (node.discovered ? 'discovered' : node.status)
})
})
topology.edges?.forEach((edge: any) => {
const sourceLabel = edge.source_interface || ''
const targetLabel = edge.target_interface || ''
const label = [sourceLabel, targetLabel].filter(Boolean).join(' ↔ ')
elements.push({
data: {
id: edge.id, source: edge.source, target: edge.target,
source_interface: edge.source_interface, target_interface: edge.target_interface,
link_type: edge.link_type, confidence: edge.confidence ?? 0.5,
if_speed: edge.if_speed, signal_health: edge.signal_health,
snr: edge.snr, label
}
})
})
const getEdgeWidth = (speed: number | null | undefined): number => {
if (!speed) return 1.5
if (speed >= 10_000_000_000) return 5
if (speed >= 1_000_000_000) return 3.5
if (speed >= 100_000_000) return 2.5
return 1.5
}
this.cy = cytoscape({
container: this.el,
elements,
style: [
{
selector: 'node',
style: {
'background-color': function(ele: any) { return roleColors[ele.data('type')] || roleColors.unknown },
'shape': function(ele: any) { return roleShapes[ele.data('type')] || roleShapes.unknown },
'label': function(ele: any) {
const label = ele.data('label') || ''
const clients = ele.data('client_count')
return clients > 0 ? `${label}\n📡 ${clients} clients` : label
},
'color': isDark ? '#fff' : '#000',
'text-valign': 'bottom', 'text-halign': 'center', 'text-margin-y': 6,
'font-size': '13px', 'font-weight': '500',
'text-wrap': 'wrap', 'text-max-width': '140px',
'width': 44, 'height': 44, 'border-width': 2,
'border-color': function(ele: any) {
const status = ele.data('status')
if (status === 'down') return '#dc2626'
if (status === 'unknown') return '#ca8a04'
const type = ele.data('type')
const darkerColors: Record<string, string> = {
router: '#2563EB', switch: '#059669', wireless: '#7C3AED',
firewall: '#DC2626', server: '#D97706', unknown: '#6B7280'
}
return darkerColors[type] || darkerColors.unknown
},
'overlay-color': function(ele: any) {
return ele.data('status') === 'down' ? '#ef4444' : '#000'
},
'overlay-opacity': function(ele: any) {
return ele.data('status') === 'down' ? 0.15 : 0
}
} as any
},
{ selector: 'node[status = "down"]', style: { 'border-width': 3, 'border-color': '#dc2626', 'background-blacken': -0.3 } },
{ selector: 'node.discovered', style: { 'border-style': 'dashed', 'border-width': 2, 'border-color': '#9ca3af', 'opacity': 0.6, 'width': 34, 'height': 34, 'font-size': '11px' } },
{ selector: 'node:selected', style: { 'border-width': 4, 'border-color': '#fbbf24' } },
{ selector: 'node.highlighted', style: { 'border-width': 4, 'border-color': '#3b82f6', 'overlay-color': '#3b82f6', 'overlay-opacity': 0.1 } },
{ selector: 'node.search-match', style: { 'border-width': 4, 'border-color': '#f59e0b', 'overlay-color': '#f59e0b', 'overlay-opacity': 0.15, 'z-index': 999 } },
{
selector: '$node > node',
style: {
'background-color': isDark ? '#1e293b' : '#f8fafc', 'background-opacity': 0.7,
'border-width': 2, 'border-color': isDark ? '#475569' : '#cbd5e1',
'border-style': 'solid', 'shape': 'round-rectangle', 'padding': '30px',
'text-valign': 'top', 'text-halign': 'center', 'text-margin-y': 8,
'label': 'data(label)', 'font-size': '14px', 'font-weight': '700',
'color': isDark ? '#94a3b8' : '#475569'
} as any
},
{
selector: 'edge',
style: {
'width': function(ele: any) { return getEdgeWidth(ele.data('if_speed')) },
'line-color': function(ele: any) { return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).color },
'line-style': function(ele: any) { return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).style },
'line-dash-pattern': function(ele: any) {
const pattern = getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).dashPattern
return pattern.length > 0 ? pattern : [1, 0]
},
'target-arrow-color': function(ele: any) { return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).color },
'curve-style': 'bezier', 'label': 'data(label)', 'font-size': '8px',
'color': isDark ? '#d1d5db' : '#4b5563',
'text-background-color': isDark ? '#1f2937' : '#ffffff',
'text-background-opacity': 0.85, 'text-background-padding': '2px',
'text-rotation': 'autorotate', 'text-margin-y': -8,
'text-wrap': 'wrap', 'text-max-width': '120px'
} as any
},
{ selector: 'edge:selected', style: { 'line-color': '#fbbf24', 'width': 3 } }
],
layout: {
name: 'preset',
positions: (node: any) => presetPositions[node.id()] || { x: 0, y: 0 },
fit: true, padding: 50
},
minZoom: 0.2, maxZoom: 3, wheelSensitivity: 1
})
this.cy.on('tap', 'node', (evt: any) => {
const node = evt.target
const nodeData = node.data()
if (nodeData.type === 'site') return
this.pushEvent("node_clicked", {
node_id: nodeData.id,
node_type: nodeData.discovered ? 'discovered' : 'managed'
})
})
this.handleEvent("highlight_node", (payload: any) => {
if (!this.cy) return
this.cy.nodes().removeClass('highlighted')
const node = this.cy.getElementById(payload.node_id)
if (node.length > 0) node.addClass('highlighted')
})
this.handleEvent("clear_highlight", () => {
if (!this.cy) return
this.cy.nodes().removeClass('highlighted')
})
this.cy.on('mouseover', 'node', (evt: any) => {
const node = evt.target
const d = node.data()
let tooltip = d.label || ''
if (d.ip_address) tooltip += `\n${d.ip_address}`
if (d.site_name) tooltip += `\n📍 ${d.site_name}`
if (d.manufacturer) tooltip += `\n${d.manufacturer}`
if (d.client_count > 0) tooltip += `\n📡 ${d.client_count} clients`
if (d.signal_health) {
const healthIcon = d.signal_health === 'good' ? '🟢' : d.signal_health === 'degraded' ? '🟡' : '🔴'
tooltip += `\n${healthIcon} Signal: ${d.signal_health}`
}
node.style('label', tooltip)
})
this.cy.on('mouseout', 'node', (evt: any) => {
const node = evt.target
const d = node.data()
const clients = d.client_count
const label = d.label || ''
node.style('label', clients > 0 ? `${label}\n📡 ${clients} clients` : label)
})
this.cy.on('mouseover', 'edge', (evt: any) => {
const edge = evt.target
const data = edge.data()
const srcNode = this.cy.getElementById(data.source)
const tgtNode = this.cy.getElementById(data.target)
const srcName = srcNode.data('label') || data.source
const tgtName = tgtNode.data('label') || data.target
let tooltip = `${srcName}${tgtName}`
if (data.snr != null) tooltip += `\nSNR ${data.snr} dB`
if (data.signal_health) {
const healthIcon = data.signal_health === 'good' ? '🟢' : data.signal_health === 'degraded' ? '🟡' : '🔴'
tooltip += ` ${healthIcon}`
}
if (data.if_speed) {
const mbps = data.if_speed / 1_000_000
tooltip += `\n${mbps >= 1000 ? (mbps / 1000).toFixed(1) + ' Gbps' : mbps + ' Mbps'}`
}
if (data.link_type) tooltip += `\n${data.link_type.toUpperCase()}`
const confidence = data.confidence
if (confidence != null) tooltip += ` (${Math.round(confidence * 100)}%)`
edge.style({
'line-color': '#fbbf24',
'width': Math.max(getEdgeWidth(data.if_speed), 3),
'label': tooltip
})
})
this.cy.on('mouseout', 'edge', (evt: any) => {
const edge = evt.target
const data = edge.data()
const edgeStyle = getEdgeStyle(data.confidence ?? 0.5, data.signal_health)
edge.style({
'line-color': edgeStyle.color,
'width': getEdgeWidth(data.if_speed),
'label': data.label || ''
})
})
this._initControls()
},
updateTopology(this: any, topology: any) {
if (!this.cy) return
const incomingNodes = new Map<string, any>()
topology.nodes?.forEach((node: any) => incomingNodes.set(node.id, node))
const incomingEdges = new Map<string, any>()
topology.edges?.forEach((edge: any) => incomingEdges.set(edge.id, edge))
const existingNodeIds = new Set<string>()
const existingEdgeIds = new Set<string>()
this.cy.nodes().forEach((node: any) => {
const id = node.id()
if (incomingNodes.has(id)) {
const incoming = incomingNodes.get(id)
node.data('label', incoming.label)
node.data('type', incoming.type)
node.data('status', incoming.status)
node.data('discovered', incoming.discovered)
node.data('ip_address', incoming.ip_address)
node.data('site_name', incoming.site_name)
node.data('manufacturer', incoming.manufacturer)
if (incoming.parent) node.data('parent', incoming.parent)
existingNodeIds.add(id)
} else {
node.remove()
}
})
this.cy.edges().forEach((edge: any) => {
const id = edge.id()
if (incomingEdges.has(id)) {
const incoming = incomingEdges.get(id)
edge.data('confidence', incoming.confidence ?? 0.5)
edge.data('link_type', incoming.link_type)
edge.data('source_interface', incoming.source_interface)
edge.data('target_interface', incoming.target_interface)
const sourceLabel = incoming.source_interface || ''
const targetLabel = incoming.target_interface || ''
edge.data('label', [sourceLabel, targetLabel].filter(Boolean).join(' ↔ '))
existingEdgeIds.add(id)
} else {
edge.remove()
}
})
const newElements: any[] = []
incomingNodes.forEach((node, id) => {
if (!existingNodeIds.has(id)) {
newElements.push({
group: 'nodes',
data: {
id: node.id, label: node.label, type: node.type, status: node.status,
discovered: node.discovered, ip_address: node.ip_address,
site_name: node.site_name, manufacturer: node.manufacturer, parent: node.parent
},
classes: node.discovered ? 'discovered' : node.status
})
}
})
incomingEdges.forEach((edge, id) => {
if (!existingEdgeIds.has(id)) {
const sourceLabel = edge.source_interface || ''
const targetLabel = edge.target_interface || ''
const label = [sourceLabel, targetLabel].filter(Boolean).join(' ↔ ')
newElements.push({
group: 'edges',
data: {
id: edge.id, source: edge.source, target: edge.target,
source_interface: edge.source_interface, target_interface: edge.target_interface,
link_type: edge.link_type, confidence: edge.confidence ?? 0.5,
if_speed: edge.if_speed, label
}
})
}
})
if (newElements.length > 0) {
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()
}
},
_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
},
_applyFilter(this: any, filter: string) {
if (!this.cy) return
this.cy.elements().style('display', 'element')
if (filter === 'degraded') {
const matchEdges = this.cy.edges().filter((e: any) => {
const health = e.data('signal_health')
return health === 'degraded' || health === 'critical'
})
const connectedNodes = matchEdges.connectedNodes()
const parents = connectedNodes.parents()
const visible = matchEdges.union(connectedNodes).union(parents)
this.cy.elements().not(visible).style('display', 'none')
} else if (filter === 'alerts') {
const alertNodes = this.cy.nodes().filter((n: any) => {
return n.data('has_alerts') === true || n.data('status') === 'down'
})
const connectedEdges = alertNodes.connectedEdges()
const parents = alertNodes.parents()
const visible = alertNodes.union(connectedEdges).union(parents)
this.cy.elements().not(visible).style('display', 'none')
}
},
_searchNodes(this: any, query: string) {
if (!this.cy) return
this.cy.nodes().removeClass('search-match')
if (!query || query.trim() === '') return
const q = query.toLowerCase()
const matches = this.cy.nodes().filter((n: any) => {
const d = n.data()
return (d.label && d.label.toLowerCase().includes(q)) ||
(d.ip_address && d.ip_address.toLowerCase().includes(q)) ||
(d.site_name && d.site_name.toLowerCase().includes(q)) ||
(d.manufacturer && d.manufacturer.toLowerCase().includes(q))
})
matches.addClass('search-match')
if (matches.length > 0) this.cy.fit(matches, 80)
},
_changeLayout(this: any, mode: string, topology: any) {
if (!this.cy) return
if (mode === 'geo') {
const geoNodes = (topology.nodes || []).filter((n: any) => n.latitude != null && n.longitude != null)
if (geoNodes.length === 0) return
const lats = geoNodes.map((n: any) => n.latitude)
const lngs = geoNodes.map((n: any) => n.longitude)
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
const width = this.cy.width() - 100
const height = this.cy.height() - 100
const positions: Record<string, { x: number, y: number }> = {}
const latRange = maxLat - minLat || 1
const lngRange = maxLng - minLng || 1
;(topology.nodes || []).forEach((n: any) => {
if (n.latitude != null && n.longitude != null) {
positions[n.id] = {
x: 50 + ((n.longitude - minLng) / lngRange) * width,
y: 50 + ((maxLat - n.latitude) / latRange) * height
}
}
})
let offsetX = 0
;(topology.nodes || []).forEach((n: any) => {
if (n.type === 'site') return
if (!positions[n.id]) {
if (n.parent && positions[n.parent]) {
positions[n.id] = {
x: positions[n.parent].x + (Math.random() - 0.5) * 60,
y: positions[n.parent].y + (Math.random() - 0.5) * 60
}
} else {
positions[n.id] = { x: offsetX * 80, y: height + 100 }
offsetX++
}
}
})
this.cy.layout({
name: 'preset',
positions: (node: any) => positions[node.id()] || { x: 0, y: 0 },
fit: true, padding: 50, animate: true, animationDuration: 500
}).run()
} else {
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, animate: true, animationDuration: 500
}).run()
}
}
}