920 lines
28 KiB
TypeScript
920 lines
28 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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
return context.dataset.label + ': ' + context.parsed.y.toFixed(1) + unit
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: xAxisConfig,
|
|
y: yAxisConfig
|
|
}
|
|
}
|
|
})
|
|
},
|
|
|
|
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) {
|
|
this.cy.destroy()
|
|
this.cy = null
|
|
}
|
|
},
|
|
|
|
initializeCytoscape(this: any, topology: any) {
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
|
|
|
// Define color palette for device types
|
|
const deviceColors: Record<string, string> = {
|
|
router: '#3b82f6', // blue
|
|
switch: '#a855f7', // purple
|
|
wireless: '#22c55e', // green
|
|
server: '#f97316', // orange
|
|
workstation: '#6b7280', // gray
|
|
unknown: '#9ca3af' // light gray
|
|
}
|
|
|
|
// Build Cytoscape elements from topology data
|
|
const elements: any[] = []
|
|
|
|
// Add nodes
|
|
topology.nodes?.forEach((node: any) => {
|
|
const color = deviceColors[node.type] || deviceColors.unknown
|
|
|
|
elements.push({
|
|
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
|
|
},
|
|
classes: node.discovered ? 'discovered' : node.status
|
|
})
|
|
})
|
|
|
|
// Add edges
|
|
topology.edges?.forEach((edge: any) => {
|
|
// Build edge label with IP addresses
|
|
const sourceLabel = edge.source_ips?.length > 0 ? edge.source_ips.join(', ') : ''
|
|
const targetLabel = edge.target_ip || ''
|
|
|
|
elements.push({
|
|
data: {
|
|
id: edge.id,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
source_interface: edge.source_interface,
|
|
target_interface: edge.target_interface,
|
|
source_ips: edge.source_ips,
|
|
target_ip: edge.target_ip,
|
|
protocol: edge.protocol,
|
|
label: `${sourceLabel} ↔ ${targetLabel}`.trim()
|
|
}
|
|
})
|
|
})
|
|
|
|
// Initialize Cytoscape
|
|
this.cy = cytoscape({
|
|
container: this.el,
|
|
elements: elements,
|
|
style: [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'background-color': function(ele: any) {
|
|
return deviceColors[ele.data('type')] || deviceColors.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) {
|
|
if (ele.data('status') === 'down') return '#ef4444'
|
|
if (ele.data('status') === 'unknown') return '#6b7280'
|
|
return deviceColors[ele.data('type')] || deviceColors.unknown
|
|
}
|
|
}
|
|
},
|
|
{
|
|
selector: 'node.discovered',
|
|
style: {
|
|
'border-style': 'dashed',
|
|
'border-width': 3,
|
|
'border-color': '#9ca3af',
|
|
'background-opacity': 0.6
|
|
}
|
|
},
|
|
{
|
|
selector: 'node.down',
|
|
style: {
|
|
'border-color': '#ef4444',
|
|
'border-width': 3
|
|
}
|
|
},
|
|
{
|
|
selector: 'node:selected',
|
|
style: {
|
|
'border-width': 4,
|
|
'border-color': '#fbbf24'
|
|
}
|
|
},
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': 2,
|
|
'line-color': '#22c55e',
|
|
'target-arrow-color': '#22c55e',
|
|
'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'
|
|
}
|
|
},
|
|
{
|
|
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: 0.2
|
|
})
|
|
|
|
// Add click handler for nodes
|
|
this.cy.on('tap', 'node', (evt: any) => {
|
|
const node = evt.target
|
|
const nodeData = node.data()
|
|
|
|
// Push event to LiveView with node info
|
|
this.pushEvent("node_clicked", { node_id: nodeData.id })
|
|
|
|
// Show tooltip or navigate
|
|
if (!nodeData.discovered) {
|
|
// Navigate to device detail page
|
|
window.location.href = `/devices/${nodeData.id}`
|
|
}
|
|
})
|
|
|
|
// 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
|
|
let tooltip = ''
|
|
if (data.source_interface) tooltip += `${data.source_interface}`
|
|
if (data.target_interface) tooltip += ` ↔ ${data.target_interface}`
|
|
if (data.protocol) tooltip += `\n${data.protocol.toUpperCase()}`
|
|
|
|
edge.style({
|
|
'line-color': '#fbbf24',
|
|
'width': 3,
|
|
'label': tooltip
|
|
})
|
|
})
|
|
|
|
this.cy.on('mouseout', 'edge', (evt: any) => {
|
|
const edge = evt.target
|
|
const data = edge.data()
|
|
edge.style({
|
|
'line-color': '#22c55e',
|
|
'width': 2,
|
|
'label': data.label || '' // Restore IP address label
|
|
})
|
|
})
|
|
},
|
|
|
|
updateTopology(this: any, topology: any) {
|
|
if (!this.cy) return
|
|
|
|
// Rebuild the graph with new data
|
|
this.cy.destroy()
|
|
this.initializeCytoscape(topology)
|
|
}
|
|
}
|
|
|
|
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"
|
|
|
|
const liveSocket = new LiveSocket("/live", Socket, {
|
|
longPollFallbackMs: 2500,
|
|
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
|
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, DeviceListReorder },
|
|
})
|
|
|
|
// 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())
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
|
|
// 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
|
|
})
|
|
}
|