towerops/assets/js/app.ts
Graham McIntire ab6ecbdfdc
Convert JavaScript to TypeScript with proper type definitions
- Add comprehensive type definitions for vendor libraries (Chart.js, topbar)
- Add TypeScript types for LiveView hooks and WebAuthn interfaces
- Convert all JS files to TypeScript with proper typing:
  - app.js → app.ts
  - webauthn.js → webauthn.ts
  - webauthn_utils.js → webauthn_utils.ts
  - passkey_settings.js → passkey_settings.ts
  - passkey_login.js → passkey_login.ts
  - passkey_discoverable.js → passkey_discoverable.ts
- Update tsconfig.json with modern ES2022 and strict type checking
- Update esbuild config to use app.ts as entry point
- All assets compile successfully with esbuild's native TypeScript support
- No node_modules required, following Phoenix/Elixir conventions
2026-01-15 13:29:49 -06:00

363 lines
11 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 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<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 },
})
// 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
})
}