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.
This commit is contained in:
parent
38375dcebb
commit
3bd4ed303c
5 changed files with 1530 additions and 2003 deletions
2018
assets/js/app.ts
2018
assets/js/app.ts
File diff suppressed because it is too large
Load diff
659
assets/js/hooks/network_map.ts
Normal file
659
assets/js/hooks/network_map.ts
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
342
assets/js/hooks/sensor_chart.ts
Normal file
342
assets/js/hooks/sensor_chart.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
// Lazy-loaded sensor chart hook (CPU, Memory, Storage, Traffic).
|
||||
// Imports Chart.js (~200KB) so the main bundle stays lean — only pages
|
||||
// rendering a sensor chart pull this chunk in.
|
||||
|
||||
import Chart from "../../vendor/chart"
|
||||
import type { ChartData, ChartDataset, ChartDataPoint } from "../../vendor/chart"
|
||||
import type { SensorChartHook } from "../types/liveview"
|
||||
|
||||
function getTimeRangeMs(range: string): number {
|
||||
switch (range) {
|
||||
case '1h': return 1 * 60 * 60 * 1000
|
||||
case '6h': return 6 * 60 * 60 * 1000
|
||||
case '12h': return 12 * 60 * 60 * 1000
|
||||
case '24h': return 24 * 60 * 60 * 1000
|
||||
case '7d': return 7 * 24 * 60 * 60 * 1000
|
||||
case '30d': return 30 * 24 * 60 * 60 * 1000
|
||||
default: return 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
|
||||
export const SensorChart: SensorChartHook = {
|
||||
mounted() {
|
||||
this.isLiveMode = this.el?.dataset.range === 'live'
|
||||
this.createChart()
|
||||
|
||||
this.handleEvent?.("live_data_update", (data: any) => {
|
||||
this.handleLiveDataUpdate(data)
|
||||
})
|
||||
},
|
||||
|
||||
updated() {
|
||||
const newRange = this.el?.dataset.range
|
||||
const wasLiveMode = this.isLiveMode
|
||||
this.isLiveMode = newRange === 'live'
|
||||
|
||||
if (wasLiveMode !== this.isLiveMode) {
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
}
|
||||
this.createChart()
|
||||
} else if (!this.isLiveMode) {
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
}
|
||||
this.createChart()
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
this.chart = null
|
||||
}
|
||||
},
|
||||
|
||||
createChart() {
|
||||
if (!this.el) return
|
||||
|
||||
const dataAttr = this.el.dataset.chart
|
||||
if (!dataAttr) return
|
||||
|
||||
const data: ChartData = JSON.parse(dataAttr)
|
||||
const canvas = this.el.querySelector('canvas')
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const unit = this.el.dataset.unit ?? '%'
|
||||
const autoScale = this.el.dataset.autoScale === 'true'
|
||||
const showZeroLine = this.el.dataset.showZeroLine === 'true'
|
||||
const dualAxis = this.el.dataset.dualAxis === 'true'
|
||||
const range = this.el.dataset.range || '24h'
|
||||
const isTrafficChart = unit === 'bps'
|
||||
|
||||
const colors = [
|
||||
'rgb(59, 130, 246)',
|
||||
'rgb(239, 68, 68)',
|
||||
'rgb(34, 197, 94)',
|
||||
'rgb(234, 179, 8)',
|
||||
'rgb(168, 85, 247)',
|
||||
'rgb(236, 72, 153)',
|
||||
'rgb(14, 165, 233)',
|
||||
'rgb(249, 115, 22)',
|
||||
]
|
||||
|
||||
const datasets: ChartDataset[] = data.datasets.map((dataset, index) => {
|
||||
const color = colors[index % colors.length]
|
||||
const baseConfig: ChartDataset = {
|
||||
...dataset,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'),
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
}
|
||||
|
||||
if ((dataset as any).yAxisID) {
|
||||
(baseConfig as any).yAxisID = (dataset as any).yAxisID
|
||||
}
|
||||
|
||||
if (dualAxis && (dataset as any).yAxisID === 'y1') {
|
||||
(baseConfig as any).stepped = 'before'
|
||||
baseConfig.tension = 0
|
||||
baseConfig.fill = 'origin'
|
||||
baseConfig.borderWidth = 1
|
||||
baseConfig.borderColor = 'rgb(34, 197, 94)'
|
||||
baseConfig.backgroundColor = 'rgba(34, 197, 94, 0.1)'
|
||||
} else if (isTrafficChart) {
|
||||
baseConfig.fill = 'origin'
|
||||
} else {
|
||||
baseConfig.fill = false
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
})
|
||||
|
||||
const formatBps = (bps: number): string => {
|
||||
const absBps = Math.abs(bps)
|
||||
if (absBps >= 1_000_000_000) return (absBps / 1_000_000_000).toFixed(2) + ' Gbps'
|
||||
if (absBps >= 1_000_000) return (absBps / 1_000_000).toFixed(2) + ' Mbps'
|
||||
if (absBps >= 1_000) return (absBps / 1_000).toFixed(2) + ' Kbps'
|
||||
return absBps.toFixed(2) + ' bps'
|
||||
}
|
||||
|
||||
let yAxisConfig: any = {}
|
||||
if (autoScale) {
|
||||
if (showZeroLine && unit === 'bps') {
|
||||
let maxAbsTrafficValue = 0
|
||||
let maxAbsCapacityValue = 0
|
||||
datasets.forEach(dataset => {
|
||||
if (dataset.data && dataset.data.length > 0) {
|
||||
dataset.data.forEach((point: ChartDataPoint) => {
|
||||
if (point && typeof point.y === 'number') {
|
||||
const absValue = Math.abs(point.y)
|
||||
if (dataset.label?.startsWith('Capacity')) {
|
||||
maxAbsCapacityValue = Math.max(maxAbsCapacityValue, absValue)
|
||||
} else {
|
||||
maxAbsTrafficValue = Math.max(maxAbsTrafficValue, absValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const maxAbsValue = maxAbsTrafficValue > 0 ? maxAbsTrafficValue : maxAbsCapacityValue
|
||||
const paddedMaxAbsValue = Math.max(maxAbsValue * 1.1, 1000)
|
||||
|
||||
yAxisConfig = {
|
||||
min: -paddedMaxAbsValue,
|
||||
max: paddedMaxAbsValue,
|
||||
ticks: { callback: function(value: number) { return formatBps(value) } },
|
||||
grid: {
|
||||
color: function(context: any) {
|
||||
return context.tick.value === 0 ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.05)'
|
||||
},
|
||||
lineWidth: function(context: any) {
|
||||
return context.tick.value === 0 ? 2 : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
yAxisConfig = {
|
||||
beginAtZero: showZeroLine,
|
||||
ticks: {
|
||||
callback: function(value: number) {
|
||||
if (unit === 'bps') return formatBps(value)
|
||||
if (unit === '') return Math.round(value)
|
||||
return value.toFixed(1) + ' ' + unit
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: function(context: any) {
|
||||
if (showZeroLine && context.tick.value === 0) return 'rgba(0, 0, 0, 0.3)'
|
||||
return 'rgba(0, 0, 0, 0.05)'
|
||||
},
|
||||
lineWidth: function(context: any) {
|
||||
return showZeroLine && context.tick.value === 0 ? 2 : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
yAxisConfig = {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value: number) {
|
||||
if (unit === 'bps') return formatBps(value)
|
||||
if (unit === '') return Math.round(value)
|
||||
return value.toFixed(1) + ' ' + unit
|
||||
}
|
||||
},
|
||||
grid: { color: 'rgba(0, 0, 0, 0.05)' }
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
let xAxisConfig: any
|
||||
|
||||
if (this.isLiveMode) {
|
||||
xAxisConfig = {
|
||||
type: 'linear',
|
||||
min: now - (5 * 60 * 1000),
|
||||
max: now,
|
||||
ticks: {
|
||||
callback: function(value: number) {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||
})
|
||||
},
|
||||
maxTicksLimit: 10
|
||||
},
|
||||
grid: { display: false }
|
||||
}
|
||||
} else {
|
||||
const timeRangeMs = getTimeRangeMs(range)
|
||||
const rangeStart = now - timeRangeMs
|
||||
xAxisConfig = {
|
||||
type: 'linear',
|
||||
min: rangeStart,
|
||||
max: now,
|
||||
ticks: {
|
||||
callback: function(value: number) {
|
||||
const date = new Date(value)
|
||||
if (timeRangeMs > 24 * 60 * 60 * 1000) {
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false
|
||||
})
|
||||
} else {
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
},
|
||||
maxTicksLimit: 12
|
||||
},
|
||||
grid: { display: false }
|
||||
}
|
||||
}
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { usePointStyle: true, padding: 15 } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(context: any) {
|
||||
const timestamp = context[0].parsed.x
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false
|
||||
})
|
||||
},
|
||||
label: function(context: any) {
|
||||
if (dualAxis && context.dataset.yAxisID === 'y1') {
|
||||
return context.dataset.label + ': ' + (context.parsed.y === 1 ? 'Up' : 'Down')
|
||||
}
|
||||
if (unit === 'bps') return context.dataset.label + ': ' + formatBps(context.parsed.y)
|
||||
if (unit === '') return context.dataset.label + ': ' + Math.round(context.parsed.y)
|
||||
return context.dataset.label + ': ' + context.parsed.y.toFixed(1) + ' ' + unit
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: xAxisConfig,
|
||||
y: yAxisConfig,
|
||||
...(dualAxis ? {
|
||||
y1: {
|
||||
position: 'right' as const,
|
||||
min: -0.1,
|
||||
max: 1.1,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: function(value: number) {
|
||||
if (value === 1) return 'Up'
|
||||
if (value === 0) return 'Down'
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
} : {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const loadingEl = this.el.querySelector('[data-loading]')
|
||||
if (loadingEl) loadingEl.remove()
|
||||
},
|
||||
|
||||
handleLiveDataUpdate(event: any) {
|
||||
if (!this.chart || !this.isLiveMode) return
|
||||
|
||||
const { points } = event
|
||||
const now = Date.now()
|
||||
|
||||
points.forEach((point: any) => {
|
||||
const dataset = this.chart!.data.datasets.find((ds: any) => ds.label === point.label)
|
||||
|
||||
if (dataset && dataset.data) {
|
||||
(dataset.data as any[]).push({ x: point.timestamp, y: point.value })
|
||||
const cutoff = now - (5 * 60 * 1000)
|
||||
dataset.data = (dataset.data as any[]).filter((p: any) => p.x >= cutoff)
|
||||
} else if (!dataset) {
|
||||
const colors = [
|
||||
'rgb(59, 130, 246)', 'rgb(239, 68, 68)', 'rgb(34, 197, 94)',
|
||||
'rgb(234, 179, 8)', 'rgb(168, 85, 247)', 'rgb(236, 72, 153)',
|
||||
'rgb(14, 165, 233)', 'rgb(249, 115, 22)',
|
||||
]
|
||||
const index = this.chart!.data.datasets.length
|
||||
const color = colors[index % colors.length]
|
||||
|
||||
this.chart!.data.datasets.push({
|
||||
label: point.label,
|
||||
data: [{ x: point.timestamp, y: point.value }],
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'),
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
fill: false
|
||||
} as any)
|
||||
}
|
||||
})
|
||||
|
||||
if (this.chart.options.scales?.x) {
|
||||
this.chart.options.scales.x.min = now - (5 * 60 * 1000)
|
||||
this.chart.options.scales.x.max = now
|
||||
}
|
||||
|
||||
this.chart.update('none')
|
||||
}
|
||||
}
|
||||
152
assets/js/hooks/sites_map.ts
Normal file
152
assets/js/hooks/sites_map.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Lazy-loaded sites map hook (Leaflet). Leaflet itself is loaded on
|
||||
// demand via ensureLeaflet (no bundling), so this chunk is small.
|
||||
|
||||
import { ensureLeaflet } from "../lib/leaflet"
|
||||
|
||||
declare const L: any
|
||||
|
||||
export const SitesMap = {
|
||||
map: null as any,
|
||||
markers: null as any,
|
||||
|
||||
mounted(this: any) {
|
||||
ensureLeaflet().then(() => {
|
||||
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
|
||||
this.initializeLeaflet(sitesData)
|
||||
|
||||
this.handleEvent("site_clicked", (payload: any) => {
|
||||
this.pushEvent("site_clicked", payload)
|
||||
})
|
||||
}).catch((e: any) => {
|
||||
console.error('SitesMap: Leaflet failed to load', e)
|
||||
})
|
||||
},
|
||||
|
||||
updated(this: any) {
|
||||
if (this.map) {
|
||||
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
|
||||
this.updateSites(sitesData)
|
||||
}
|
||||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
this.map = null
|
||||
this.markers = null
|
||||
}
|
||||
},
|
||||
|
||||
initializeLeaflet(this: any, sites: any[]) {
|
||||
this.map = L.map(this.el, {
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true
|
||||
})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 18
|
||||
}).addTo(this.map)
|
||||
|
||||
this.updateSites(sites)
|
||||
},
|
||||
|
||||
updateSites(this: any, sites: any[]) {
|
||||
if (!this.map) return
|
||||
|
||||
if (this.markers) {
|
||||
this.map.removeLayer(this.markers)
|
||||
}
|
||||
|
||||
this.markers = L.layerGroup()
|
||||
|
||||
if (sites.length === 0) {
|
||||
this.map.setView([39.8283, -98.5795], 4)
|
||||
this.markers.addTo(this.map)
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds([])
|
||||
|
||||
sites.forEach((site: any) => {
|
||||
if (site.latitude && site.longitude) {
|
||||
const marker = L.marker([site.latitude, site.longitude])
|
||||
|
||||
let popupContent = `<div class="p-2">
|
||||
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>`
|
||||
|
||||
if (site.description) {
|
||||
popupContent += `<p class="text-sm text-gray-600 mb-1">${site.description}</p>`
|
||||
}
|
||||
if (site.address) {
|
||||
popupContent += `<p class="text-xs text-gray-500 mb-1">${site.address}</p>`
|
||||
}
|
||||
|
||||
popupContent += `<p class="text-xs text-gray-500 mb-2">
|
||||
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}
|
||||
</p>`
|
||||
|
||||
if (site.device_count > 0) {
|
||||
popupContent += `<p class="text-xs text-blue-600 mb-2">
|
||||
${site.device_count} device${site.device_count !== 1 ? 's' : ''}
|
||||
</p>`
|
||||
}
|
||||
|
||||
popupContent += `<button
|
||||
data-site-id="${site.id}"
|
||||
data-action="view-site"
|
||||
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
|
||||
>
|
||||
View Site →
|
||||
</button></div>`
|
||||
|
||||
marker.bindPopup(popupContent)
|
||||
marker.on('popupopen', () => {
|
||||
const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement
|
||||
if (button) {
|
||||
button.addEventListener('click', () => {
|
||||
this.pushEvent("site_clicked", { site_id: button.dataset.siteId })
|
||||
})
|
||||
}
|
||||
})
|
||||
this.markers.addLayer(marker)
|
||||
bounds.extend([site.latitude, site.longitude])
|
||||
}
|
||||
})
|
||||
|
||||
this.markers.addTo(this.map)
|
||||
|
||||
if (bounds.isValid()) {
|
||||
this.map.fitBounds(bounds, { padding: [20, 20] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-marker map for site detail page. Loads Leaflet via ensureLeaflet
|
||||
// rather than relying on a global script polled with setTimeout.
|
||||
export const LeafletMap = {
|
||||
map: null as any,
|
||||
mounted(this: any) {
|
||||
const lat = parseFloat(this.el.dataset.lat)
|
||||
const lng = parseFloat(this.el.dataset.lng)
|
||||
const zoom = parseInt(this.el.dataset.zoom || '14')
|
||||
const title = this.el.dataset.markerTitle || ''
|
||||
if (isNaN(lat) || isNaN(lng)) return
|
||||
|
||||
ensureLeaflet().then(() => {
|
||||
this.map = L.map(this.el, { scrollWheelZoom: false, zoomControl: true })
|
||||
this.map.setView([lat, lng], zoom)
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(this.map)
|
||||
L.marker([lat, lng]).addTo(this.map).bindPopup(title)
|
||||
setTimeout(() => this.map.invalidateSize(), 200)
|
||||
}).catch((e: any) => {
|
||||
console.error('LeafletMap: Leaflet failed to load', e)
|
||||
})
|
||||
},
|
||||
destroyed(this: any) {
|
||||
if (this.map) { this.map.remove(); this.map = null }
|
||||
}
|
||||
}
|
||||
362
assets/js/hooks/weathermap.ts
Normal file
362
assets/js/hooks/weathermap.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
// 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue