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.
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
// Lazy-loaded weathermap viewer (Cytoscape.js with utilization-based
|
|
// edge coloring). Pulls cytoscape into a shared chunk with NetworkMap.
|
|
|
|
import cytoscape from "../../vendor/cytoscape"
|
|
|
|
export const WeathermapViewer = {
|
|
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_weathermap", (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
|
|
}
|
|
},
|
|
|
|
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 utilizationColors: Record<string, string> = {
|
|
low: '#10B981', medium: '#F59E0B', high: '#F97316', critical: '#EF4444'
|
|
}
|
|
|
|
const getWeathermapEdgeStyle = (edge: any) => {
|
|
const utilizationLevel = edge.utilization_level
|
|
const confidence = edge.confidence || 0.5
|
|
|
|
if (utilizationLevel && utilizationColors[utilizationLevel]) {
|
|
return { color: utilizationColors[utilizationLevel], style: 'solid' as const, dashPattern: [] }
|
|
}
|
|
if (confidence > 0.9) return { color: '#9CA3AF', style: 'solid' as const, dashPattern: [] }
|
|
if (confidence >= 0.5) return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [6, 3] }
|
|
return { color: '#D1D5DB', style: 'dashed' as const, dashPattern: [2, 3] }
|
|
}
|
|
|
|
const computeSimplePositions = (nodes: any[]): Record<string, { x: number, y: number }> => {
|
|
const positions: Record<string, { x: number, y: number }> = {}
|
|
const SPACING = 120
|
|
const cols = Math.ceil(Math.sqrt(nodes.filter(n => n.type !== 'site').length))
|
|
|
|
let x = 0, y = 0, col = 0
|
|
|
|
nodes.forEach((node) => {
|
|
if (node.type === 'site') return
|
|
positions[node.id] = { x: x * SPACING, y: y * SPACING }
|
|
col++
|
|
if (col >= cols) { col = 0; x = 0; y++ } else { x++ }
|
|
})
|
|
|
|
return positions
|
|
}
|
|
|
|
const presetPositions = computeSimplePositions(topology.nodes || [])
|
|
const elements: any[] = []
|
|
|
|
topology.nodes?.forEach((node: any) => {
|
|
if (node.type === 'site') return
|
|
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,
|
|
device_role: node.device_role
|
|
}
|
|
elements.push({ data, classes: node.discovered ? 'discovered' : node.status })
|
|
})
|
|
|
|
topology.edges?.forEach((edge: any) => {
|
|
let label = ''
|
|
if (edge.utilization_text) {
|
|
label = edge.utilization_text
|
|
} else if (edge.source_interface && edge.target_interface) {
|
|
label = `${edge.source_interface} ↔ ${edge.target_interface}`
|
|
}
|
|
|
|
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, utilization_level: edge.utilization_level,
|
|
utilization_pct: edge.utilization_pct, utilization_text: edge.utilization_text,
|
|
throughput_bps: edge.throughput_bps, capacity_bps: edge.capacity_bps,
|
|
signal_health: edge.signal_health, snr: edge.snr, label
|
|
}
|
|
})
|
|
})
|
|
|
|
const getEdgeWidth = (speed: number | null | undefined): number => {
|
|
if (!speed) return 2
|
|
if (speed >= 10_000_000_000) return 6
|
|
if (speed >= 1_000_000_000) return 4
|
|
if (speed >= 100_000_000) return 3
|
|
return 2
|
|
}
|
|
|
|
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': 'data(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
|
|
}
|
|
} 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: 'edge',
|
|
style: {
|
|
'width': function(ele: any) { return getEdgeWidth(ele.data('if_speed')) },
|
|
'line-color': function(ele: any) { return getWeathermapEdgeStyle(ele.data()).color },
|
|
'line-style': function(ele: any) { return getWeathermapEdgeStyle(ele.data()).style },
|
|
'line-dash-pattern': function(ele: any) {
|
|
const pattern = getWeathermapEdgeStyle(ele.data()).dashPattern
|
|
return pattern.length > 0 ? pattern : [1, 0]
|
|
},
|
|
'curve-style': 'bezier', 'label': 'data(label)', 'font-size': '9px',
|
|
'color': isDark ? '#d1d5db' : '#4b5563',
|
|
'text-background-color': isDark ? '#1f2937' : '#ffffff',
|
|
'text-background-opacity': 0.9, 'text-background-padding': '3px',
|
|
'text-rotation': 'autorotate', 'text-margin-y': -10
|
|
} as any
|
|
}
|
|
],
|
|
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()
|
|
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', '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.utilization_text) {
|
|
tooltip += `\n🚦 ${data.utilization_text}`
|
|
if (data.utilization_pct != null) tooltip += ` (${data.utilization_pct}%)`
|
|
}
|
|
|
|
edge.style({
|
|
'line-color': '#fbbf24',
|
|
'width': Math.max(getEdgeWidth(data.if_speed), 4),
|
|
'label': tooltip
|
|
})
|
|
})
|
|
|
|
this.cy.on('mouseout', 'edge', (evt: any) => {
|
|
const edge = evt.target
|
|
const data = edge.data()
|
|
const edgeStyle = getWeathermapEdgeStyle(data)
|
|
edge.style({
|
|
'line-color': edgeStyle.color,
|
|
'width': getEdgeWidth(data.if_speed),
|
|
'label': data.label || ''
|
|
})
|
|
})
|
|
|
|
this._initControls()
|
|
},
|
|
|
|
_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)
|
|
}
|
|
},
|
|
|
|
updateTopology(this: any, topology: any) {
|
|
if (!this.cy) return
|
|
this.cy.destroy()
|
|
this.initializeCytoscape(topology)
|
|
},
|
|
|
|
_applyFilter(this: any, filter: string) {
|
|
if (!this.cy) return
|
|
this.cy.elements().style('display', 'element')
|
|
|
|
if (filter === 'high_util') {
|
|
const matchEdges = this.cy.edges().filter((e: any) => {
|
|
const level = e.data('utilization_level')
|
|
return level === 'high' || level === 'critical'
|
|
})
|
|
const connectedNodes = matchEdges.connectedNodes()
|
|
const visible = matchEdges.union(connectedNodes)
|
|
this.cy.elements().not(visible).style('display', 'none')
|
|
} else if (filter === 'critical') {
|
|
const matchEdges = this.cy.edges().filter((e: any) => {
|
|
const level = e.data('utilization_level')
|
|
return level === 'critical'
|
|
})
|
|
const connectedNodes = matchEdges.connectedNodes()
|
|
const visible = matchEdges.union(connectedNodes)
|
|
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
|
|
this.cy.layout({
|
|
name: 'circle',
|
|
animate: true,
|
|
animationDuration: 500
|
|
}).run()
|
|
}
|
|
}
|