// If you want to use Phoenix channels, run `mix help phx.gen.channel` // to get started and then uncomment the line below. // import "./user_socket.js" // You can include dependencies in two ways. // // The simplest option is to put them in assets/vendor and // import them using relative paths: // // import "../vendor/some-package.js" // // Alternatively, you can `npm install some-package --prefix assets` and import // them using a path starting with the package name: // // import "some-package" // // If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file. // To load it, simply add a second `` to your `root.html.heex` file. // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html" // Establish Phoenix Socket and LiveView configuration. import { Socket } from "phoenix" import { LiveSocket } from "phoenix_live_view" import { hooks as colocatedHooks } from "phoenix-colocated/towerops" import topbar from "../vendor/topbar" import Chart from "../vendor/chart" import type { ChartData, ChartDataset, ChartDataPoint } from "../vendor/chart" import cytoscape from "../vendor/cytoscape" import type { SensorChartHook } from "./types/liveview" import { DeviceListReorder } from "./device_list_reorder" import { initCookieConsent } from "./cookie_consent" // Helper function to convert range string to milliseconds function getTimeRangeMs(range: string): number { switch (range) { case '1h': return 1 * 60 * 60 * 1000 case '6h': return 6 * 60 * 60 * 1000 case '12h': return 12 * 60 * 60 * 1000 case '24h': return 24 * 60 * 60 * 1000 case '7d': return 7 * 24 * 60 * 60 * 1000 case '30d': return 30 * 24 * 60 * 60 * 1000 default: return 24 * 60 * 60 * 1000 // Default to 24 hours } } // Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.) const SensorChart: SensorChartHook = { mounted() { this.isLiveMode = this.el?.dataset.range === 'live' this.createChart() // Listen for live updates this.handleEvent?.("live_data_update", (data: any) => { this.handleLiveDataUpdate(data) }) }, updated() { const newRange = this.el?.dataset.range const wasLiveMode = this.isLiveMode this.isLiveMode = newRange === 'live' // Mode switch - recreate chart if (wasLiveMode !== this.isLiveMode) { if (this.chart) { this.chart.destroy() } this.createChart() } else if (!this.isLiveMode) { // Historical mode - recreate chart (existing behavior) if (this.chart) { this.chart.destroy() } this.createChart() } // Live mode - updates handled by event, don't recreate }, destroyed() { if (this.chart) { this.chart.destroy() this.chart = null } }, createChart() { if (!this.el) return const dataAttr = this.el.dataset.chart if (!dataAttr) return const data: ChartData = JSON.parse(dataAttr) const canvas = this.el.querySelector('canvas') if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return const unit = this.el.dataset.unit ?? '%' const autoScale = this.el.dataset.autoScale === 'true' || this.el.dataset.autoScale === 'true' const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === 'true' const range = this.el.dataset.range || '24h' const isTrafficChart = unit === 'bps' // Generate colors for each dataset const colors = [ 'rgb(59, 130, 246)', // blue 'rgb(239, 68, 68)', // red 'rgb(34, 197, 94)', // green 'rgb(234, 179, 8)', // yellow 'rgb(168, 85, 247)', // purple 'rgb(236, 72, 153)', // pink 'rgb(14, 165, 233)', // sky 'rgb(249, 115, 22)', // orange ] const datasets: ChartDataset[] = data.datasets.map((dataset, index) => { const color = colors[index % colors.length] const baseConfig: ChartDataset = { ...dataset, borderColor: color, backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'), borderWidth: 2, tension: 0.4, pointRadius: 0, pointHoverRadius: 4, } // Add fill for traffic charts to create area effect if (isTrafficChart) { baseConfig.fill = 'origin' // Fill to the zero line } else { baseConfig.fill = false // No fill for CPU/memory/storage charts } return baseConfig }) // Format bits per second into human-readable units const formatBps = (bps: number): string => { // Use absolute value for display (inbound is negative, but we show it as positive) const absBps = Math.abs(bps) if (absBps >= 1_000_000_000) { return (absBps / 1_000_000_000).toFixed(2) + ' Gbps' } else if (absBps >= 1_000_000) { return (absBps / 1_000_000).toFixed(2) + ' Mbps' } else if (absBps >= 1_000) { return (absBps / 1_000).toFixed(2) + ' Kbps' } else { return absBps.toFixed(2) + ' bps' } } // For traffic graphs, calculate symmetric scale to keep zero in the middle let yAxisConfig: any = {} if (autoScale) { if (showZeroLine && unit === 'bps') { // Find max absolute value to make symmetric scale let maxAbsValue = 0 datasets.forEach(dataset => { if (dataset.data && dataset.data.length > 0) { dataset.data.forEach((point: ChartDataPoint) => { if (point && typeof point.y === 'number') { maxAbsValue = Math.max(maxAbsValue, Math.abs(point.y)) } }) } }) // Add 10% padding, ensure minimum of 1000 (1 Kbps) maxAbsValue = Math.max(maxAbsValue * 1.1, 1000) yAxisConfig = { min: -maxAbsValue, max: maxAbsValue, ticks: { callback: function(value: number) { return formatBps(value) } }, grid: { color: function(context: any) { // Make the zero line more prominent if (context.tick.value === 0) { return 'rgba(0, 0, 0, 0.3)' } return 'rgba(0, 0, 0, 0.05)' }, lineWidth: function(context: any) { // Make the zero line thicker if (context.tick.value === 0) { return 2 } return 1 } } } } else { yAxisConfig = { beginAtZero: showZeroLine, ticks: { callback: function(value: number) { if (unit === 'bps') { return formatBps(value) } 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 = 'Copied!' this.el.className = originalClasses.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700').replace('bg-blue-500', 'bg-green-500').replace('hover:bg-blue-600', 'hover:bg-green-600') setTimeout(() => { this.el.innerHTML = originalHTML this.el.className = originalClasses }, 2000) }).catch(err => { console.error('Failed to copy:', err) // Show error state const originalHTML = this.el.innerHTML const originalClasses = this.el.className this.el.innerHTML = 'Failed' this.el.className = originalClasses.replace('bg-blue-600', 'bg-red-600').replace('hover:bg-blue-700', 'hover:bg-red-700').replace('bg-blue-500', 'bg-red-500').replace('hover:bg-blue-600', 'hover:bg-red-600') setTimeout(() => { this.el.innerHTML = originalHTML this.el.className = originalClasses }, 2000) }) } else { console.error('No text found to copy') } } this.el.addEventListener("click", this.handleClick) }, destroyed(this: any) { if (this.handleClick) { this.el.removeEventListener("click", this.handleClick) this.handleClick = null } } } // LiveView hook for scrolling to top const ScrollToTop = { mounted() { this.handleEvent("scroll_to_top", () => { window.scrollTo({ top: 0, behavior: "smooth" }) }) } } // LiveView hook for auto-dismissing flash messages const AutoDismissFlash = { timeout: null as ReturnType | null, mounted(this: any) { const dismissAfter = parseInt(this.el.dataset.dismissAfter || "30000", 10) this.timeout = setTimeout(() => { this.el.click() }, dismissAfter) }, destroyed(this: any) { if (this.timeout) { clearTimeout(this.timeout) this.timeout = null } } } // LiveView hook for dismissible beta banner const BetaBannerDismiss = { handleDismiss: null as ((e: Event) => void) | null, mounted(this: any) { this.applyBannerState() }, updated(this: any) { this.applyBannerState() }, applyBannerState(this: any) { const isHelpPage = this.el.dataset.alwaysShow === "true" if (isHelpPage) { // On help page: hide dismiss button only const dismissBtn = this.el.querySelector('[data-dismiss]') if (dismissBtn) { (dismissBtn as HTMLElement).style.display = 'none' } } else { // On other pages: check if dismissed and set up dismiss handler const isDismissed = localStorage.getItem('betaBannerDismissed') === 'true' if (isDismissed) { this.el.style.display = 'none' } // Only set up event listener once (in mounted) if (!this.handleDismiss) { const dismissBtn = this.el.querySelector('[data-dismiss]') if (dismissBtn) { this.handleDismiss = (e: Event) => { e.preventDefault() localStorage.setItem('betaBannerDismissed', 'true') this.el.style.display = 'none' } dismissBtn.addEventListener('click', this.handleDismiss) } } } }, destroyed(this: any) { const dismissBtn = this.el.querySelector('[data-dismiss]') if (dismissBtn && this.handleDismiss) { dismissBtn.removeEventListener('click', this.handleDismiss) this.handleDismiss = null } } } // Network Map hook for Cytoscape.js topology visualization const NetworkMap = { cy: null as any, mounted(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') this.initializeCytoscape(topologyData) // Listen for topology updates from LiveView this.handleEvent("update_topology", (payload: any) => { if (this.cy) { this.updateTopology(payload.topology) } }) }, updated(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') this.updateTopology(topologyData) }, destroyed(this: any) { if (this.cy) { 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 = { 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) } } // 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("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 }) }