towerops/assets/js/app.ts
Graham McIntire 7f11a890a8
feat: move theme selector to user settings, remove from nav
Add an Appearance section to the Personal tab in user settings with
three clearly labelled options (Auto/Light/Dark). A ThemeSelector hook
reads localStorage to highlight the active choice and updates it when
selection changes. Remove the dropdown toggle from both the main nav
and admin nav.
2026-03-12 15:11:35 -05:00

1523 lines
47 KiB
TypeScript

// 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 `<link>` 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 = '<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg><span>Copied!</span>'
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 = '<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg><span>Failed</span>'
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<typeof setTimeout> | 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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, any>()
topology.nodes?.forEach((node: any) => {
incomingNodes.set(node.id, node)
})
const incomingEdges = new Map<string, any>()
topology.edges?.forEach((edge: any) => {
incomingEdges.set(edge.id, edge)
})
// Track which nodes already exist
const existingNodeIds = new Set<string>()
const existingEdgeIds = new Set<string>()
// 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<string, string>,
cache: {} as Record<string, string>,
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18
}).addTo(this.map)
this.updateSites(sites)
},
updateSites(this: any, sites: any[]) {
if (!this.map) return
// 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 = `<div class="p-2">
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>`
if (site.description) {
popupContent += `<p class="text-sm text-gray-600 mb-1">${site.description}</p>`
}
if (site.address) {
popupContent += `<p class="text-xs text-gray-500 mb-1">${site.address}</p>`
}
popupContent += `<p class="text-xs text-gray-500 mb-2">
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}
</p>`
if (site.device_count > 0) {
popupContent += `<p class="text-xs text-blue-600 mb-2">
${site.device_count} device${site.device_count !== 1 ? 's' : ''}
</p>`
}
popupContent += `<button
onclick="window.liveSocket.execJS(document.querySelector('#leaflet-map'), 'this.pushEvent(\\'site_clicked\\', {site_id: \\'${site.id}\\'})')"
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
>
View Site →
</button></div>`
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<HTMLMetaElement>("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 ThemeSelector = {
mounted() {
this.updateActive()
window.addEventListener("phx:set-theme", () => this.updateActive())
},
updateActive() {
const current = localStorage.getItem("phx:theme") || "system"
this.el.querySelectorAll<HTMLElement>("[data-phx-theme]").forEach((btn) => {
const isActive = btn.dataset.phxTheme === current
btn.setAttribute("aria-pressed", isActive ? "true" : "false")
btn.classList.toggle("ring-2", isActive)
btn.classList.toggle("ring-indigo-500", isActive)
btn.classList.toggle("bg-indigo-50", isActive)
btn.classList.toggle("dark:bg-indigo-900/20", isActive)
btn.classList.toggle("text-indigo-700", isActive)
btn.classList.toggle("dark:text-indigo-300", isActive)
})
},
}
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, ThemeSelector },
})
// 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
})
}