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.
1523 lines
47 KiB
TypeScript
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: '© <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
|
|
})
|
|
}
|