// If you want to use Phoenix channels, run `mix help phx.gen.channel` // to get started and then uncomment the line below. // import "./user_socket.js" // You can include dependencies in two ways. // // The simplest option is to put them in assets/vendor and // import them using relative paths: // // import "../vendor/some-package.js" // // Alternatively, you can `npm install some-package --prefix assets` and import // them using a path starting with the package name: // // import "some-package" // // If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file. // To load it, simply add a second `` to your `root.html.heex` file. // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html" // Establish Phoenix Socket and LiveView configuration. 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 { 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 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, } // Add fill for traffic charts to create area effect if (isTrafficChart) { baseConfig.fill = 'origin' // Fill to the zero line } else { baseConfig.fill = false // No fill for CPU/memory/storage charts } 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') { // Find max absolute value to make symmetric scale let maxAbsValue = 0 datasets.forEach(dataset => { if (dataset.data && dataset.data.length > 0) { dataset.data.forEach((point: ChartDataPoint) => { if (point && typeof point.y === 'number') { maxAbsValue = Math.max(maxAbsValue, Math.abs(point.y)) } }) } }) // Add 10% padding, ensure minimum of 1000 (1 Kbps) maxAbsValue = Math.max(maxAbsValue * 1.1, 1000) yAxisConfig = { min: -maxAbsValue, max: maxAbsValue, 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 + 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 + 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) { 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 } } }) // 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 = { handleClick: null as ((e: Event) => void) | null, mounted(this: any) { this.handleClick = (e: Event) => { e.preventDefault() const targetId = this.el.dataset.target if (!targetId) { console.error("No data-target attribute found on copy button") return } const targetEl = document.querySelector(targetId) as HTMLInputElement | HTMLTextAreaElement | HTMLElement if (!targetEl) { console.error(`Target element not found: ${targetId}`) return } let text = "" if (targetEl.tagName === "INPUT" || targetEl.tagName === "TEXTAREA") { text = (targetEl as HTMLInputElement | HTMLTextAreaElement).value } else { text = targetEl.innerText || targetEl.textContent || "" } // Trim any whitespace from the text text = text.trim() if (text) { navigator.clipboard.writeText(text).then(() => { console.log('Copied to clipboard successfully') // Show visual feedback const originalHTML = this.el.innerHTML const originalClasses = this.el.className // Update button to show success state this.el.innerHTML = 'Copied!' this.el.className = originalClasses.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700').replace('bg-blue-500', 'bg-green-500').replace('hover:bg-blue-600', 'hover:bg-green-600') setTimeout(() => { this.el.innerHTML = originalHTML this.el.className = originalClasses }, 2000) }).catch(err => { console.error('Failed to copy:', err) // Show error state const originalHTML = this.el.innerHTML const originalClasses = this.el.className this.el.innerHTML = 'Failed' this.el.className = originalClasses.replace('bg-blue-600', 'bg-red-600').replace('hover:bg-blue-700', 'hover:bg-red-700').replace('bg-blue-500', 'bg-red-500').replace('hover:bg-blue-600', 'hover:bg-red-600') setTimeout(() => { this.el.innerHTML = originalHTML this.el.className = originalClasses }, 2000) }) } else { console.error('No text found to copy') } } this.el.addEventListener("click", this.handleClick) }, destroyed(this: any) { if (this.handleClick) { this.el.removeEventListener("click", this.handleClick) this.handleClick = null } } } // LiveView hook for scrolling to top const ScrollToTop = { mounted() { this.handleEvent("scroll_to_top", () => { window.scrollTo({ top: 0, behavior: "smooth" }) }) } } // LiveView hook for auto-dismissing flash messages const AutoDismissFlash = { timeout: null as ReturnType | null, mounted(this: any) { const dismissAfter = parseInt(this.el.dataset.dismissAfter || "30000", 10) this.timeout = setTimeout(() => { this.el.click() }, dismissAfter) }, destroyed(this: any) { if (this.timeout) { clearTimeout(this.timeout) this.timeout = null } } } // LiveView hook for dismissible beta banner const BetaBannerDismiss = { handleDismiss: null as ((e: Event) => void) | null, mounted(this: any) { this.applyBannerState() }, updated(this: any) { this.applyBannerState() }, applyBannerState(this: any) { const isHelpPage = this.el.dataset.alwaysShow === "true" if (isHelpPage) { // On help page: hide dismiss button only const dismissBtn = this.el.querySelector('[data-dismiss]') if (dismissBtn) { (dismissBtn as HTMLElement).style.display = 'none' } } else { // On other pages: check if dismissed and set up dismiss handler const isDismissed = localStorage.getItem('betaBannerDismissed') === 'true' if (isDismissed) { this.el.style.display = 'none' } // Only set up event listener once (in mounted) if (!this.handleDismiss) { const dismissBtn = this.el.querySelector('[data-dismiss]') if (dismissBtn) { this.handleDismiss = (e: Event) => { e.preventDefault() localStorage.setItem('betaBannerDismissed', 'true') this.el.style.display = 'none' } dismissBtn.addEventListener('click', this.handleDismiss) } } } }, destroyed(this: any) { const dismissBtn = this.el.querySelector('[data-dismiss]') if (dismissBtn && this.handleDismiss) { dismissBtn.removeEventListener('click', this.handleDismiss) this.handleDismiss = null } } } // Network Map hook for Cytoscape.js topology visualization const NetworkMap = { cy: null as any, mounted(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') this.initializeCytoscape(topologyData) // Listen for topology updates from LiveView this.handleEvent("update_topology", (payload: any) => { if (this.cy) { this.updateTopology(payload.topology) } }) }, updated(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') this.updateTopology(topologyData) }, destroyed(this: any) { 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 } }, 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' } // Confidence-based edge styling helper const getEdgeStyle = (confidence: number) => { if (confidence > 0.9) { return { color: '#10B981', style: 'solid', dashPattern: [] } } else if (confidence >= 0.5) { return { color: '#F59E0B', style: 'dashed', dashPattern: [6, 3] } } else { return { color: '#9CA3AF', style: 'dashed', dashPattern: [2, 3] } } } // 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 } 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, label: label } }) }) // Map interface speed to edge width const getEdgeWidth = (speed: number | null | undefined): number => { if (!speed) return 1 if (speed >= 10_000_000_000) return 4 // 10Gbps+ if (speed >= 1_000_000_000) return 2 // 1Gbps return 1 // < 1Gbps 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': 5, 'font-size': '11px', 'font-weight': '500', 'width': 40, 'height': 40, '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': 30, 'height': 30, 'font-size': '9px' } }, { 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 > node', // compound parent selector style: { 'background-color': isDark ? '#1e293b' : '#f8fafc', 'background-opacity': 0.6, 'border-width': 1, 'border-color': isDark ? '#334155' : '#e2e8f0', 'border-style': 'solid', 'shape': 'round-rectangle', 'padding': '20px', 'text-valign': 'top', 'text-halign': 'center', 'text-margin-y': -5, 'label': 'data(label)', 'font-size': '12px', 'font-weight': '600', 'color': isDark ? '#94a3b8' : '#64748b' } 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')).color }, 'line-style': function(ele: any) { return getEdgeStyle(ele.data('confidence')).style }, 'line-dash-pattern': function(ele: any) { const pattern = getEdgeStyle(ele.data('confidence')).dashPattern return pattern.length > 0 ? pattern : [1, 0] }, 'target-arrow-color': function(ele: any) { return getEdgeStyle(ele.data('confidence')).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: 'cose', animate: true, animationDuration: 500, nodeRepulsion: 30000, // Even more repulsion to spread nodes apart idealEdgeLength: 250, // Longer edges to give more room for labels edgeElasticity: 200, // More flexible edges nestingFactor: 1, // Less nesting gravity: 30, // Even less gravity to allow more spread numIter: 2000, // More iterations for better layout initialTemp: 500, // Higher initial temperature coolingFactor: 0.95, minTemp: 1.0, nodeOverlap: 50, // More overlap prevention avoidOverlap: true // Enable overlap avoidance }, 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 this.cy.on('mouseover', 'node', (evt: any) => { const node = evt.target const nodeData = node.data() let tooltip = nodeData.label if (nodeData.ip_address) tooltip += `\n${nodeData.ip_address}` if (nodeData.site_name) tooltip += `\n${nodeData.site_name}` if (nodeData.manufacturer) tooltip += `\n${nodeData.manufacturer}` node.style('label', tooltip) }) this.cy.on('mouseout', 'node', (evt: any) => { const node = evt.target node.style('label', node.data('label')) }) // Add hover for edges this.cy.on('mouseover', 'edge', (evt: any) => { const edge = evt.target const data = edge.data() // Build detailed tooltip with interfaces and link type let tooltip = '' if (data.source_interface) tooltip += `${data.source_interface}` if (data.target_interface) tooltip += ` \u2194 ${data.target_interface}` 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': 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) edge.style({ 'line-color': edgeStyle.color, 'width': getEdgeWidth(data.if_speed), 'label': data.label || '' }) }) }, 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 } }) } }) // Only add and layout new elements (preserves existing positions) if (newElements.length > 0) { const added = this.cy.add(newElements) // Run layout only on new elements if (added.nonempty()) { added.layout({ name: 'cose', animate: true, animationDuration: 300, fit: false, randomize: false, nodeRepulsion: 30000, idealEdgeLength: 250 }).run() } } } } // Dynamic Favicon - sets favicon color based on data-status from LiveView const DynamicFavicon = { statusColors: { green: '#22c55e', yellow: '#eab308', red: '#ef4444' } as Record, cache: {} as Record, originalHref: null as string | null, getFaviconPng(this: any, status: string): string { if (this.cache[status]) return this.cache[status] const color = this.statusColors[status] || this.statusColors.green const canvas = document.createElement('canvas') canvas.width = 64 canvas.height = 64 const ctx = canvas.getContext('2d')! ctx.beginPath() ctx.arc(32, 32, 32, 0, Math.PI * 2) ctx.fillStyle = color ctx.fill() this.cache[status] = canvas.toDataURL('image/png') return this.cache[status] }, applyStatus(this: any) { const faviconEl = document.getElementById('favicon') as HTMLLinkElement if (!faviconEl) return const status = this.el.dataset.status || 'green' faviconEl.type = 'image/png' faviconEl.href = this.getFaviconPng(status) }, mounted(this: any) { const faviconEl = document.getElementById('favicon') as HTMLLinkElement if (faviconEl) this.originalHref = faviconEl.href this.applyStatus() }, updated(this: any) { this.applyStatus() }, destroyed(this: any) { const faviconEl = document.getElementById('favicon') as HTMLLinkElement if (faviconEl && this.originalHref) { faviconEl.type = 'image/png' faviconEl.href = this.originalHref } } } // MikroTik Port Sync - automatically switches port when SSL checkbox is toggled const MikrotikPortSync = { handleSslChange: null as ((e: Event) => void) | null, sslCheckbox: null as HTMLInputElement | null, setupListener(this: any) { // Clean up existing listener first if (this.sslCheckbox && this.handleSslChange) { this.sslCheckbox.removeEventListener('change', this.handleSslChange) } const sslCheckbox = this.el.querySelector('[name="device[mikrotik_use_ssl]"]') as HTMLInputElement const portInput = this.el.querySelector('[name="device[mikrotik_port]"]') as HTMLInputElement if (!sslCheckbox || !portInput) return this.sslCheckbox = sslCheckbox this.handleSslChange = () => { const isChecked = sslCheckbox.checked const currentPort = portInput.value.trim() // Only update port if it's empty or set to one of the default values if (currentPort === '' || currentPort === '8729' || currentPort === '8728') { portInput.value = isChecked ? '8729' : '8728' // Trigger a change event so LiveView validates the new value portInput.dispatchEvent(new Event('input', { bubbles: true })) } } sslCheckbox.addEventListener('change', this.handleSslChange) }, mounted(this: any) { this.setupListener() }, updated(this: any) { // Re-attach listener when LiveView updates the DOM (e.g., when mikrotik_enabled changes) this.setupListener() }, destroyed(this: any) { if (this.sslCheckbox && this.handleSslChange) { this.sslCheckbox.removeEventListener('change', this.handleSslChange) this.handleSslChange = null this.sslCheckbox = null } } } // 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 } } } // Sites Map hook for Leaflet.js geographic visualization const SitesMap = { map: null as any, markers: null as any, mounted(this: any) { // Wait for Leaflet to load if (typeof L === 'undefined') { setTimeout(() => this.mounted(), 100) return } 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) }) }, 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 += `
` marker.bindPopup(popupContent) 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] }) } } } const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") if (!csrfToken) { throw new Error('CSRF token meta tag not found') } // Detect user's timezone const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" // Global Search (Cmd+K / Ctrl+K) hook const GlobalSearchTrigger = { mounted() { this.el.addEventListener("click", () => { // Simulate Cmd+K to open global search document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })) }) } } const GlobalSearch = { mounted() { this.handleKeydown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault() this.pushEventTo(this.el, "open", {}) } if (e.key === "Escape" && this.el.querySelector("[role=dialog]")) { this.pushEventTo(this.el, "close", {}) } } document.addEventListener("keydown", this.handleKeydown) }, updated() { const input = this.el.querySelector("#global-search-input") as HTMLInputElement if (input) input.focus() }, destroyed() { document.removeEventListener("keydown", this.handleKeydown) } } // Status Title - Updates page title with status emoji when alerts change const StatusTitle = { mounted(this: any) { this.handleEvent("update_status_emoji", ({ emoji }: { emoji: string }) => { const currentTitle = document.title const titleWithoutEmoji = currentTitle.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "") document.title = emoji ? `${emoji} ${titleWithoutEmoji}` : titleWithoutEmoji }) } } const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 5000, params: { _csrf_token: csrfToken, timezone: userTimezone }, hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle }, }) // Show progress bar on live navigation and form submits topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-stop", _info => { topbar.hide() // Re-check cookie consent on LiveView navigation initCookieConsent() }) // Handle clipboard copy events window.addEventListener("phx:copy", (event: any) => { // When using JS.dispatch with 'to:', the event is dispatched to that element // and event.target will be that element const el = event.target as HTMLElement let text = "" if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { text = (el as HTMLInputElement | HTMLTextAreaElement).value } else { text = el.innerText || el.textContent || "" } if (text) { navigator.clipboard.writeText(text).then(() => { console.log('Copied to clipboard:', text.substring(0, 50) + (text.length > 50 ? '...' : '')) }).catch(err => { console.error('Failed to copy text:', err) }) } else { console.warn('No text found to copy from element:', el) } }) // Handle file download events window.addEventListener("phx:download", (event: any) => { const { content, filename, mime_type } = event.detail // Create a blob from the content const blob = new Blob([content], { type: mime_type }) // Create a temporary download link const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename // Trigger the download document.body.appendChild(link) link.click() // Clean up document.body.removeChild(link) window.URL.revokeObjectURL(url) }) // Reconnect WebSocket when returning from background (iOS Safari optimization) document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { if (liveSocket.socket.connectionState() !== "open") { liveSocket.socket.disconnect(() => liveSocket.socket.connect()) } } }) // connect if there are any LiveViews on the page liveSocket.connect() // Initialize cookie consent handler document.addEventListener('DOMContentLoaded', () => { initCookieConsent() }) // expose liveSocket on window for web console debug logs and latency simulation: // >> liveSocket.enableDebug() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() declare global { interface Window { liveSocket: LiveSocket liveReloader?: any } } window.liveSocket = liveSocket // The lines below enable quality of life phoenix_live_reload // development features: // // 1. stream server logs to the browser console // 2. click on elements to jump to their definitions in your code editor // if (process.env.NODE_ENV === "development") { window.addEventListener("phx:live_reload:attached", ({ detail: reloader }: any) => { // Enable server log streaming to client. // Disable with reloader.disableServerLogs() reloader.enableServerLogs() // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component // // * click with "c" key pressed to open at caller location // * click with "d" key pressed to open at function component definition location let keyDown: string | null = null window.addEventListener("keydown", e => keyDown = e.key) window.addEventListener("keyup", _e => keyDown = null) window.addEventListener("click", e => { if (keyDown === "c") { e.preventDefault() e.stopImmediatePropagation() reloader.openEditorAtCaller(e.target) } else if (keyDown === "d") { e.preventDefault() e.stopImmediatePropagation() reloader.openEditorAtDef(e.target) } }, true) window.liveReloader = reloader }) }