// 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 { WebAuthnRegister, WebAuthnLogin } from "./webauthn" import "./passkey_settings" import "./passkey_login" import "./passkey_discoverable" import type { SensorChartHook } from "./types/liveview" // Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.) const SensorChart: SensorChartHook = { mounted() { this.createChart() }, updated() { // Destroy and recreate chart to properly handle scale changes if (this.chart) { this.chart.destroy() } this.createChart() }, destroyed() { if (this.chart) { this.chart.destroy() } }, 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 chartType = unit === 'bps' ? 'bar' : 'line' // 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 baseConfig: ChartDataset = { ...dataset, borderColor: colors[index % colors.length], backgroundColor: colors[index % colors.length].replace('rgb', 'rgba').replace(')', ', 0.5)'), borderWidth: chartType === 'bar' ? 0 : 2, } // Add line-specific properties if (chartType === 'line') { baseConfig.tension = 0.4 baseConfig.pointRadius = 0 baseConfig.pointHoverRadius = 4 } 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 24-hour time range for consistent x-axis const now = Date.now() const twentyFourHoursAgo = now - (24 * 60 * 60 * 1000) this.chart = new Chart(ctx, { type: chartType, data: { datasets }, options: { responsive: true, maintainAspectRatio: false, // Make bars connect with no gaps for traffic charts barPercentage: chartType === 'bar' ? 1.0 : undefined, categoryPercentage: chartType === 'bar' ? 1.0 : undefined, 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: { type: 'linear', min: twentyFourHoursAgo, max: now, ticks: { callback: function(value: number) { const date = new Date(value) return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) }, maxTicksLimit: 12 }, grid: { display: false } }, y: yAxisConfig } } }) } } const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") if (!csrfToken) { throw new Error('CSRF token meta tag not found') } const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin }, }) // Show progress bar on live navigation and form submits topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) // Handle clipboard copy events window.addEventListener("phx:copy", (event) => { 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 || "" } navigator.clipboard.writeText(text).then(() => { // Optional: Show a success message or visual feedback console.log('Copied to clipboard') }).catch(err => { console.error('Failed to copy text: ', err) }) }) // connect if there are any LiveViews on the page liveSocket.connect() // 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 }) }