- Replace phx:copy event with CopyToClipboard LiveView hook for more reliable clipboard functionality - Add visual feedback (checkmark) when text is copied to clipboard - Apply hook to both agent token and docker compose copy buttons - Add readonly attribute to docker compose textarea - Fix org settings page to stay on settings after save instead of redirecting to dashboard The clipboard copy now works correctly by using a dedicated LiveView hook that directly handles the click event and clipboard API, rather than relying on custom event dispatching which was causing issues.
416 lines
13 KiB
TypeScript
416 lines
13 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 { 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 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 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: 'line',
|
|
data: { datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: 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: {
|
|
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<HTMLMetaElement>("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, CopyToClipboard },
|
|
})
|
|
|
|
// Show progress bar on live navigation and form submits
|
|
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
|
|
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
|
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
|
|
|
// Handle clipboard copy events
|
|
window.addEventListener("phx:copy", (event: any) => {
|
|
// When using JS.dispatch with 'to:', the event is dispatched to that element
|
|
// and event.target will be that element
|
|
const el = event.target as HTMLElement
|
|
|
|
let text = ""
|
|
|
|
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
text = (el as HTMLInputElement | HTMLTextAreaElement).value
|
|
} else {
|
|
text = el.innerText || el.textContent || ""
|
|
}
|
|
|
|
if (text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
console.log('Copied to clipboard:', text.substring(0, 50) + (text.length > 50 ? '...' : ''))
|
|
}).catch(err => {
|
|
console.error('Failed to copy text:', err)
|
|
})
|
|
} else {
|
|
console.warn('No text found to copy from element:', el)
|
|
}
|
|
})
|
|
|
|
// LiveView hook for copying to clipboard
|
|
const CopyToClipboard = {
|
|
mounted() {
|
|
this.el.addEventListener("click", (e) => {
|
|
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 || ""
|
|
}
|
|
|
|
if (text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
console.log('Copied to clipboard successfully')
|
|
// Optional: Show visual feedback
|
|
const originalText = this.el.innerHTML
|
|
this.el.innerHTML = '<svg class="h-3 w-3 inline" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> Copied!'
|
|
setTimeout(() => {
|
|
this.el.innerHTML = originalText
|
|
}, 2000)
|
|
}).catch(err => {
|
|
console.error('Failed to copy:', err)
|
|
})
|
|
} else {
|
|
console.error('No text found to copy')
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const Hooks = {
|
|
CopyToClipboard
|
|
}
|
|
|
|
// 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
|
|
})
|
|
}
|