diff --git a/assets/js/app.ts b/assets/js/app.ts index 96a0f660..77e503d0 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -24,459 +24,10 @@ import { Socket } from "phoenix" import { LiveSocket } from "phoenix_live_view" import { hooks as colocatedHooks } from "phoenix-colocated/towerops" import topbar from "../vendor/topbar" -import Chart from "../vendor/chart" -import type { ChartData, ChartDataset, ChartDataPoint } from "../vendor/chart" -import cytoscape from "../vendor/cytoscape" -import type { SensorChartHook } from "./types/liveview" import { DeviceListReorder } from "./device_list_reorder" import { SortableList } from "./sortable_list" import { initCookieConsent } from "./cookie_consent" -// Helper function to convert range string to milliseconds -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 // Default to 24 hours - } -} - -// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.) -const SensorChart: SensorChartHook = { - mounted() { - this.isLiveMode = this.el?.dataset.range === 'live' - this.createChart() - - // Listen for live updates - 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' - - // Mode switch - recreate chart - if (wasLiveMode !== this.isLiveMode) { - if (this.chart) { - this.chart.destroy() - } - this.createChart() - } else if (!this.isLiveMode) { - // Historical mode - recreate chart (existing behavior) - if (this.chart) { - this.chart.destroy() - } - this.createChart() - } - // Live mode - updates handled by event, don't recreate - }, - - 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' || this.el.dataset.autoScale === 'true' - const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === 'true' - const dualAxis = this.el.dataset.dualAxis === 'true' - const range = this.el.dataset.range || '24h' - const isTrafficChart = unit === 'bps' - - // Generate colors for each dataset - const colors = [ - 'rgb(59, 130, 246)', // blue - 'rgb(239, 68, 68)', // red - 'rgb(34, 197, 94)', // green - 'rgb(234, 179, 8)', // yellow - 'rgb(168, 85, 247)', // purple - 'rgb(236, 72, 153)', // pink - 'rgb(14, 165, 233)', // sky - 'rgb(249, 115, 22)', // orange - ] - - 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, - } - - // Preserve yAxisID from server data for dual-axis charts - if ((dataset as any).yAxisID) { - (baseConfig as any).yAxisID = (dataset as any).yAxisID - } - - // Status dataset (dual axis, right side) uses stepped line with fill - if (dualAxis && (dataset as any).yAxisID === 'y1') { - (baseConfig as any).stepped = 'before' - baseConfig.tension = 0 - baseConfig.fill = 'origin' - baseConfig.borderWidth = 1 - // Use green with low opacity for the status fill area - 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 - }) - - // Format bits per second into human-readable units - const formatBps = (bps: number): string => { - // Use absolute value for display (inbound is negative, but we show it as positive) - const absBps = Math.abs(bps) - if (absBps >= 1_000_000_000) { - return (absBps / 1_000_000_000).toFixed(2) + ' Gbps' - } else if (absBps >= 1_000_000) { - return (absBps / 1_000_000).toFixed(2) + ' Mbps' - } else if (absBps >= 1_000) { - return (absBps / 1_000).toFixed(2) + ' Kbps' - } else { - return absBps.toFixed(2) + ' bps' - } - } - - // For traffic graphs, calculate symmetric scale to keep zero in the middle - let yAxisConfig: any = {} - if (autoScale) { - if (showZeroLine && unit === 'bps') { - // Keep capacity overlays visible when traffic is flat, but avoid letting - // them dominate scaling when real traffic exists. - 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 - - // Add 10% padding, ensure minimum of 1000 (1 Kbps) - 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) { - // Make the zero line more prominent - if (context.tick.value === 0) { - return 'rgba(0, 0, 0, 0.3)' - } - return 'rgba(0, 0, 0, 0.05)' - }, - lineWidth: function(context: any) { - // Make the zero line thicker - if (context.tick.value === 0) { - return 2 - } - return 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) { - // Make the zero line more prominent - if (showZeroLine && context.tick.value === 0) { - return 'rgba(0, 0, 0, 0.3)' - } - return 'rgba(0, 0, 0, 0.05)' - }, - lineWidth: function(context: any) { - // Make the zero line thicker - if (showZeroLine && context.tick.value === 0) { - return 2 - } - return 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)' - } - } - } - - // Calculate time range based on selected range - const now = Date.now() - let xAxisConfig: any - - if (this.isLiveMode) { - // Live mode - 5 minute window - 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 { - // Historical mode - existing configuration - 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) - // For ranges > 24h, show date + time; otherwise just time - 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) { - // Status dataset on dual-axis chart - 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 '' - } - } - } - } : {}) - } - } - }) - - // Hide loading indicator after chart is created - 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() - - // Update each dataset with new point - points.forEach((point: any) => { - const dataset = this.chart!.data.datasets.find( - (ds: any) => ds.label === point.label - ) - - if (dataset && dataset.data) { - // Add new point - (dataset.data as any[]).push({ - x: point.timestamp, - y: point.value - }) - - // Remove points older than 5 minutes - const cutoff = now - (5 * 60 * 1000) - dataset.data = (dataset.data as any[]).filter( - (p: any) => p.x >= cutoff - ) - } else if (!dataset) { - // New sensor - add dataset - const colors = [ - 'rgb(59, 130, 246)', // blue - 'rgb(239, 68, 68)', // red - 'rgb(34, 197, 94)', // green - 'rgb(234, 179, 8)', // yellow - 'rgb(168, 85, 247)', // purple - 'rgb(236, 72, 153)', // pink - 'rgb(14, 165, 233)', // sky - 'rgb(249, 115, 22)', // orange - ] - 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) - } - }) - - // Update x-axis range to keep 5-minute window - if (this.chart.options.scales?.x) { - this.chart.options.scales.x.min = now - (5 * 60 * 1000) - this.chart.options.scales.x.max = now - } - - // Update chart without animation - this.chart.update('none') - } -} // LiveView hook for copying to clipboard const CopyToClipboard = { @@ -630,888 +181,6 @@ const BetaBannerDismiss = { } } -// Network Map hook for Cytoscape.js topology visualization -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) - - // Listen for topology updates from LiveView - this.handleEvent("update_topology", (payload: any) => { - if (this.cy) { - this._topologyData = payload.topology - this.updateTopology(payload.topology) - } - }) - - // Filter handler - this.handleEvent("apply_filter", (payload: any) => { - if (!this.cy) return - this._applyFilter(payload.filter) - }) - - // Search handler - this.handleEvent("search_nodes", (payload: any) => { - if (!this.cy) return - this._searchNodes(payload.query) - }) - - // Layout toggle handler - 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) { - // Remove DOM event listeners - 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) { - // Stop any running layout before destroying to prevent async callbacks - // from accessing a destroyed renderer - 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' - - // Role-based node colors (keyed by the `type` field from backend) - const roleColors: Record = { - router: '#3B82F6', // blue - switch: '#10B981', // green - wireless: '#8B5CF6', // purple - firewall: '#EF4444', // red - server: '#F59E0B', // amber - unknown: '#9CA3AF' // light gray - } - - // Role-based node shapes - const roleShapes: Record = { - router: 'round-rectangle', - switch: 'diamond', - wireless: 'triangle', - firewall: 'hexagon', - server: 'rectangle', - unknown: 'ellipse' - } - - // Signal health color map for RF links - const signalHealthColors: Record = { - good: '#10B981', // green - degraded: '#F59E0B', // yellow - critical: '#EF4444' // red - } - - // Edge styling: prefer signal_health coloring for RF links, fall back to confidence - 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: [] } - } else if (confidence >= 0.5) { - return { color: '#F59E0B', style: 'dashed' as const, dashPattern: [6, 3] } - } else { - return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [2, 3] } - } - } - - // Compute grid-based positions to prevent site overlap. - // Groups device nodes by parent site, arranges them in a tidy subgrid, - // then lays out the site groups in a row-wrapped grid. - const computePresetPositions = (nodes: any[]): Record => { - 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 - } - }) - - // Place unparented nodes below all sites - const unY = curY + maxH + SITE_GAP - unparented.forEach((id, i) => { - const uc = i % 6 - const ur = Math.floor(i / 6) - positions[id] = { x: uc * DEVICE_SPACING, y: unY + ur * DEVICE_SPACING } - }) - - return positions - } - - const presetPositions = computePresetPositions(topology.nodes || []) - - // Build Cytoscape elements from topology data - const elements: any[] = [] - - // Add nodes (site compound nodes first, then devices) - 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: data, - classes: node.type === 'site' ? 'site-group' : (node.discovered ? 'discovered' : node.status) - }) - }) - - // Add edges - topology.edges?.forEach((edge: any) => { - // Build edge label with interface names - const sourceLabel = edge.source_interface || '' - const targetLabel = edge.target_interface || '' - const label = [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 ') - - 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: label - } - }) - }) - - // Map interface speed to edge width — proportional to bandwidth capacity - const getEdgeWidth = (speed: number | null | undefined): number => { - if (!speed) return 1.5 - if (speed >= 10_000_000_000) return 5 // 10Gbps+ - if (speed >= 1_000_000_000) return 3.5 // 1Gbps - if (speed >= 100_000_000) return 2.5 // 100Mbps - return 1.5 // < 100Mbps or unknown - } - - // Initialize Cytoscape - this.cy = cytoscape({ - container: this.el, - elements: 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') - if (clients > 0) { - return `${label}\nšŸ“” ${clients} clients` - } - return 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' // darker red - if (status === 'unknown') return '#ca8a04' // darker yellow - // Darken the role color for the border - 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) { - const status = ele.data('status') - if (status === 'down') return '#ef4444' - return '#000' - }, - 'overlay-opacity': function(ele: any) { - const status = ele.data('status') - if (status === 'down') return 0.15 - return 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', // compound parent selector - 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 - }) - - // Add click handler for nodes (skip site compound nodes) - this.cy.on('tap', 'node', (evt: any) => { - const node = evt.target - const nodeData = node.data() - - // Don't open detail panel for site group nodes - if (nodeData.type === 'site') return - - // Push event to LiveView to open detail panel - this.pushEvent("node_clicked", { - node_id: nodeData.id, - node_type: nodeData.discovered ? 'discovered' : 'managed' - }) - }) - - // Handle highlight_node event from LiveView - this.handleEvent("highlight_node", (payload: any) => { - if (!this.cy) return - // Remove highlight from all nodes - this.cy.nodes().removeClass('highlighted') - // Add highlight to selected node - const node = this.cy.getElementById(payload.node_id) - if (node.length > 0) { - node.addClass('highlighted') - } - }) - - // Handle clear_highlight event from LiveView - this.handleEvent("clear_highlight", () => { - if (!this.cy) return - this.cy.nodes().removeClass('highlighted') - }) - - // Add hover tooltips with WISP RF info - 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) - }) - - // Enhanced edge hover with PTP/backhaul RF stats - this.cy.on('mouseover', 'edge', (evt: any) => { - const edge = evt.target - const data = edge.data() - - // Get source and target node names for the tooltip - 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 - - // Build incoming element maps - 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) - }) - - // Track which nodes already exist - const existingNodeIds = new Set() - const existingEdgeIds = new Set() - - // Update or remove existing nodes - this.cy.nodes().forEach((node: any) => { - const id = node.id() - if (incomingNodes.has(id)) { - // Update data - 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() - } - }) - - // Update or remove existing edges - 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(' \u2194 ')) - existingEdgeIds.add(id) - } else { - edge.remove() - } - }) - - // Add new nodes - 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 - }) - } - }) - - // Add new edges - 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(' \u2194 ') - - 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: label - } - }) - } - }) - - // When new elements arrive, recompute all positions from scratch - 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() - } - }, - - // Shared position computation — same algorithm as in initializeCytoscape - _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 - }, - - // --- Filter, Search, Layout methods --- - - _applyFilter(this: any, filter: string) { - if (!this.cy) return - - // Show all first - this.cy.elements().style('display', 'element') - - if (filter === 'degraded') { - // Show only edges with degraded/critical signal health and their connected nodes - 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') { - // Show only nodes with alerts and their connected edges - 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') - } - // 'all' filter shows everything (already reset above) - }, - - _searchNodes(this: any, query: string) { - if (!this.cy) return - - // Remove previous search highlights - 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') - - // Zoom to fit matches if any found - if (matches.length > 0) { - this.cy.fit(matches, 80) - } - }, - - _changeLayout(this: any, mode: string, topology: any) { - if (!this.cy) return - - if (mode === 'geo') { - // Geographic layout using lat/lng positions - const geoNodes = (topology.nodes || []).filter((n: any) => n.latitude != null && n.longitude != null) - - if (geoNodes.length === 0) return - - // Find bounds - 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 - - // Map geo coordinates to canvas positions - 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 // invert Y for map orientation - } - } - }) - - // Nodes without geo get placed at the bottom - let offsetX = 0 - ;(topology.nodes || []).forEach((n: any) => { - if (n.type === 'site') return - if (!positions[n.id]) { - // If node has a parent site with geo, place near it - 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 { - // Force-directed (default preset layout) - 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() - } - } -} // Dynamic Favicon - sets favicon color based on data-status from LiveView const DynamicFavicon = { @@ -1612,49 +281,11 @@ const MikrotikPortSync = { } } -// Simple Leaflet map hook for showing a single marker (site detail page) -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 - - const initMap = () => { - if (typeof L === 'undefined') { - setTimeout(initMap, 100) - return - } - 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) - } - initMap() - }, - destroyed(this: any) { - if (this.map) { this.map.remove(); this.map = null } - } -} - -import { ensureLeaflet } from "./lib/leaflet" - -// Leaflet attaches itself to `window.L` when loaded. We treat that -// runtime as `any` here so the hooks that consume it don't have to -// chase upstream Leaflet typings. (Strict-TS exception, scoped.) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -declare const L: any // lazyHook wraps a Phoenix LiveView hook so its implementation is // loaded via dynamic import() only when the hook first mounts. esbuild // will emit each lazy module as its own chunk under -// /assets/js/chunks/, keeping app.js lean. +// /assets/js/, keeping app.js lean. function lazyHook(loader: () => Promise>, exportName: string) { return { mounted(this: any) { @@ -1674,144 +305,25 @@ function lazyHook(loader: () => Promise>, exportName: string } } -// Sites Map hook for Leaflet.js geographic visualization -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) - - // Listen for site clicks from the map - this.handleEvent("site_clicked", (payload: any) => { - // Push event back to LiveView - 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[]) { - // Initialize the map - this.map = L.map(this.el, { - zoomControl: true, - scrollWheelZoom: true - }) - - // Add OpenStreetMap tiles - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - maxZoom: 18 - }).addTo(this.map) - - this.updateSites(sites) - }, - - updateSites(this: any, sites: any[]) { - if (!this.map) return - - // Clear existing markers - if (this.markers) { - this.map.removeLayer(this.markers) - } - - // Create marker cluster group - this.markers = L.layerGroup() - - if (sites.length === 0) { - // Default view if no sites - this.map.setView([39.8283, -98.5795], 4) // Center on USA - this.markers.addTo(this.map) - return - } - - // Add markers for each site - const bounds = L.latLngBounds([]) - - sites.forEach((site: any) => { - if (site.latitude && site.longitude) { - const marker = L.marker([site.latitude, site.longitude]) - - // Create popup content - let popupContent = `
-

${site.name}

` - - if (site.description) { - popupContent += `

${site.description}

` - } - - if (site.address) { - popupContent += `

${site.address}

` - } - - popupContent += `

- ${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)} -

` - - if (site.device_count > 0) { - popupContent += `

- ${site.device_count} device${site.device_count !== 1 ? 's' : ''} -

` - } - - popupContent += `
` - - // Bind popup and attach event listener after marker is added to map - 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]) - } - }) - - // Add markers to map - this.markers.addTo(this.map) - - // Fit map to show all markers - if (bounds.isValid()) { - this.map.fitBounds(bounds, { padding: [20, 20] }) - } - } -} - -// Coverage hooks live in a separately-emitted chunk loaded only on -// pages that mount one of these hooks (coverage form, coverage show, -// coverage map). Reduces the main bundle by ~40 KB minified. +// Coverage hooks (Leaflet + dBm overlay handling). const CoverageLocationPicker = lazyHook(() => import("./hooks/coverage_hooks"), "CoverageLocationPicker") const CoverageMap = lazyHook(() => import("./hooks/coverage_hooks"), "CoverageMap") const MultiCoverageMap = lazyHook(() => import("./hooks/coverage_hooks"), "MultiCoverageMap") +// Sensor chart hook (Chart.js — ~200KB) — only sensor pages load it. +const SensorChart = lazyHook(() => import("./hooks/sensor_chart"), "SensorChart") + +// Sites/Leaflet hooks — Leaflet itself loads externally via ensureLeaflet, +// but the hook glue is still per-page. +const SitesMap = lazyHook(() => import("./hooks/sites_map"), "SitesMap") +const LeafletMap = lazyHook(() => import("./hooks/sites_map"), "LeafletMap") + +// Topology hooks (Cytoscape.js — ~500KB). Both share a common cytoscape +// chunk that esbuild factors out automatically. +const NetworkMap = lazyHook(() => import("./hooks/network_map"), "NetworkMap") +const WeathermapViewer = lazyHook(() => import("./hooks/weathermap"), "WeathermapViewer") + const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") if (!csrfToken) { throw new Error('CSRF token meta tag not found') @@ -1934,506 +446,6 @@ const ThemeSelector = { }, } -// WeathermapViewer hook - extends NetworkMap with utilization-based edge coloring -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) - - // Listen for weathermap updates from LiveView - this.handleEvent("update_weathermap", (payload: any) => { - if (this.cy) { - this._topologyData = payload.topology - this.updateTopology(payload.topology) - } - }) - - // Filter handler - this.handleEvent("apply_filter", (payload: any) => { - if (!this.cy) return - this._applyFilter(payload.filter) - }) - - // Search handler - this.handleEvent("search_nodes", (payload: any) => { - if (!this.cy) return - this._searchNodes(payload.query) - }) - - // Layout toggle handler - 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) { - // Remove DOM event listeners - 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' - - // Role-based node colors - const roleColors: Record = { - router: '#3B82F6', // blue - switch: '#10B981', // green - wireless: '#8B5CF6', // purple - firewall: '#EF4444', // red - server: '#F59E0B', // amber - unknown: '#9CA3AF' // light gray - } - - // Role-based node shapes - const roleShapes: Record = { - router: 'round-rectangle', - switch: 'diamond', - wireless: 'triangle', - firewall: 'hexagon', - server: 'rectangle', - unknown: 'ellipse' - } - - // Utilization level colors for weathermap edges - const utilizationColors: Record = { - low: '#10B981', // green (0-30%) - medium: '#F59E0B', // yellow (30-60%) - high: '#F97316', // orange (60-80%) - critical: '#EF4444' // red (80%+) - } - - // Edge styling based on utilization level - 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: [] - } - } - - // Fallback to confidence-based styling if no utilization data - if (confidence > 0.9) { - return { color: '#9CA3AF', style: 'solid' as const, dashPattern: [] } - } else if (confidence >= 0.5) { - return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [6, 3] } - } else { - return { color: '#D1D5DB', style: 'dashed' as const, dashPattern: [2, 3] } - } - } - - // Use simplified position computation - const computeSimplePositions = (nodes: any[]): Record => { - const positions: Record = {} - 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, i) => { - 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 || []) - - // Build Cytoscape elements - const elements: any[] = [] - - // Add nodes (skip site nodes for now in simplified version) - topology.nodes?.forEach((node: any) => { - if (node.type === 'site') return // Skip site nodes in simplified version - - 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: data, - classes: node.discovered ? 'discovered' : node.status - }) - }) - - // Add edges with weathermap-specific data - topology.edges?.forEach((edge: any) => { - // Build edge label with throughput/capacity info - 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: label - } - }) - }) - - // Map interface speed to edge width - const getEdgeWidth = (speed: number | null | undefined): number => { - if (!speed) return 2 - if (speed >= 10_000_000_000) return 6 // 10Gbps+ - if (speed >= 1_000_000_000) return 4 // 1Gbps - if (speed >= 100_000_000) return 3 // 100Mbps - return 2 // < 100Mbps or unknown - } - - // Initialize Cytoscape - this.cy = cytoscape({ - container: this.el, - elements: 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 = { - 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 - }) - - // Add click handler for nodes - 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' - }) - }) - - // Handle highlight and clear highlight events - 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') - }) - - // Enhanced hover tooltips with utilization info - 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 - - // For now, just recreate the entire topology (can be optimized later) - this.cy.destroy() - this.initializeCytoscape(topology) - }, - - _applyFilter(this: any, filter: string) { - if (!this.cy) return - - // Show all first - this.cy.elements().style('display', 'element') - - if (filter === 'high_util') { - // Show only high and critical utilization edges and their connected nodes - 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') { - // Show only critical utilization edges and their connected nodes - 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) { - // Simplified layout for now - if (!this.cy) return - - this.cy.layout({ - name: 'circle', - animate: true, - animationDuration: 500 - }).run() - } -} const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 5000, diff --git a/assets/js/hooks/network_map.ts b/assets/js/hooks/network_map.ts new file mode 100644 index 00000000..b61f2c9c --- /dev/null +++ b/assets/js/hooks/network_map.ts @@ -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 = { + 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() + } + } +} diff --git a/assets/js/hooks/sensor_chart.ts b/assets/js/hooks/sensor_chart.ts new file mode 100644 index 00000000..69f555b0 --- /dev/null +++ b/assets/js/hooks/sensor_chart.ts @@ -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') + } +} diff --git a/assets/js/hooks/sites_map.ts b/assets/js/hooks/sites_map.ts new file mode 100644 index 00000000..a03bb26b --- /dev/null +++ b/assets/js/hooks/sites_map.ts @@ -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: '© OpenStreetMap 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 = `
+

${site.name}

` + + if (site.description) { + popupContent += `

${site.description}

` + } + if (site.address) { + popupContent += `

${site.address}

` + } + + popupContent += `

+ ${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)} +

` + + if (site.device_count > 0) { + popupContent += `

+ ${site.device_count} device${site.device_count !== 1 ? 's' : ''} +

` + } + + popupContent += `
` + + 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 } + } +} diff --git a/assets/js/hooks/weathermap.ts b/assets/js/hooks/weathermap.ts new file mode 100644 index 00000000..252bf50e --- /dev/null +++ b/assets/js/hooks/weathermap.ts @@ -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 = { + 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 utilizationColors: Record = { + 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 => { + const positions: Record = {} + 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 = { + 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() + } +}