towerops/assets/js/app.ts
Graham McIntire 26bb3d5ca1
color network map nodes by device status
green for online, yellow for unknown, red for offline. previously
nodes were colored by device type which didn't convey health state.
also remove agent last_seen_at display from device detail page since
the offline badge already communicates agent health.
2026-02-11 15:41:50 -06:00

1000 lines
31 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) {
const status = ele.data('status')
if (status === 'down') return '#ef4444' // red
if (status === 'unknown') return '#eab308' // yellow
return '#22c55e' // green (up/online)
},
'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
return '#16a34a' // darker green
}
}
},
{
selector: 'node.discovered',
style: {
'border-style': 'dashed',
'border-width': 3,
'border-color': '#9ca3af',
'background-opacity': 0.6
}
},
{
selector: 'node.down',
style: {
'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)
}
}
// 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
}
}
}
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, MikrotikPortSync },
})
// 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)
})
// 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
})
}