// 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 = { router: '#3B82F6', switch: '#10B981', wireless: '#8B5CF6', firewall: '#EF4444', server: '#F59E0B', unknown: '#9CA3AF' } const roleShapes: Record = { router: 'round-rectangle', switch: 'diamond', wireless: 'triangle', firewall: 'hexagon', server: 'rectangle', unknown: 'ellipse' } const signalHealthColors: Record = { 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 => { 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 } }) 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 = { 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() topology.nodes?.forEach((node: any) => incomingNodes.set(node.id, node)) const incomingEdges = new Map() topology.edges?.forEach((edge: any) => incomingEdges.set(edge.id, edge)) const existingNodeIds = new Set() const existingEdgeIds = new Set() 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 { 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 }, _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 = {} 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() } } }