Set explicit min/max bounds on chart x-axis to ensure all graphs consistently show 24 hours of data, even when data points are sparse. Also fix AlertNotifierTest race condition by disabling async execution.
341 lines
11 KiB
JavaScript
341 lines
11 KiB
JavaScript
// 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 {WebAuthnRegister, WebAuthnLogin} from "./webauthn"
|
|
import "./passkey_settings"
|
|
import "./passkey_login"
|
|
import "./passkey_discoverable"
|
|
|
|
// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.)
|
|
const SensorChart = {
|
|
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() {
|
|
const data = JSON.parse(this.el.dataset.chart)
|
|
const canvas = this.el.querySelector('canvas')
|
|
const ctx = canvas.getContext('2d')
|
|
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 = data.datasets.map((dataset, index) => {
|
|
const baseConfig = {
|
|
...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) => {
|
|
// 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 = {}
|
|
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 => {
|
|
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) {
|
|
return formatBps(value)
|
|
}
|
|
},
|
|
grid: {
|
|
color: function(context) {
|
|
// 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) {
|
|
// Make the zero line thicker
|
|
if (context.tick.value === 0) {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
yAxisConfig = {
|
|
beginAtZero: showZeroLine,
|
|
ticks: {
|
|
callback: function(value) {
|
|
if (unit === 'bps') {
|
|
return formatBps(value)
|
|
}
|
|
return value + unit
|
|
}
|
|
},
|
|
grid: {
|
|
color: function(context) {
|
|
// 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) {
|
|
// 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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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")
|
|
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
|
|
let text = ""
|
|
|
|
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
text = el.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()
|
|
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}) => {
|
|
// 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
|
|
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
|
|
})
|
|
}
|
|
|