- L3: Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) to IPv4 tuples when matching against IP whitelist entries - L7: Verify interface belongs to current org before set/clear capacity - L12: Guard window.liveSocket behind NODE_ENV check for production safety - L13: Replace Alert.changeset with Ecto.Changeset.change for simple field updates (resolved_at, acknowledged_at, gaiia_impact) to prevent accidental overwrites from the 17-field cast
541 lines
18 KiB
TypeScript
541 lines
18 KiB
TypeScript
import "phoenix_html"
|
|
import { Socket } from "phoenix"
|
|
import { LiveSocket } from "phoenix_live_view"
|
|
import { hooks as colocatedHooks } from "phoenix-colocated/towerops"
|
|
import topbar from "../vendor/topbar"
|
|
import { initCookieConsent } from "./cookie_consent"
|
|
|
|
// Copy a target element's text to the clipboard, with brief inline feedback.
|
|
const CopyToClipboard = {
|
|
handleClick: null as ((e: Event) => void) | null,
|
|
|
|
mounted(this: any) {
|
|
this.handleClick = (e: Event) => {
|
|
e.preventDefault()
|
|
const targetEl = document.querySelector(this.el.dataset.target || '') as HTMLElement | null
|
|
if (!targetEl) return
|
|
|
|
const text = ((targetEl as HTMLInputElement | HTMLTextAreaElement).value
|
|
?? targetEl.innerText
|
|
?? targetEl.textContent
|
|
?? '').trim()
|
|
if (!text) return
|
|
|
|
navigator.clipboard.writeText(text)
|
|
.then(() => this.flash('Copied!', 'green'))
|
|
.catch(() => this.flash('Failed', 'red'))
|
|
}
|
|
this.el.addEventListener("click", this.handleClick)
|
|
},
|
|
|
|
flash(this: any, label: string, color: 'green' | 'red') {
|
|
const originalHTML = this.el.innerHTML
|
|
const originalClasses = this.el.className
|
|
this.el.innerHTML = `<span>${label}</span>`
|
|
this.el.className = originalClasses.replace(/bg-blue-(500|600)/g, `bg-${color}-$1`).replace(/hover:bg-blue-(600|700)/g, `hover:bg-${color}-$1`)
|
|
setTimeout(() => { this.el.innerHTML = originalHTML; this.el.className = originalClasses }, 2000)
|
|
},
|
|
|
|
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<typeof setTimeout> | 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
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Dynamic Favicon - sets favicon color based on data-status from LiveView
|
|
const DynamicFavicon = {
|
|
statusColors: { green: '#22c55e', yellow: '#eab308', red: '#ef4444' } as Record<string, string>,
|
|
cache: {} as Record<string, string>,
|
|
originalHref: null as string | null,
|
|
|
|
getFaviconPng(this: any, status: string): string {
|
|
if (this.cache[status]) return this.cache[status]
|
|
const color = this.statusColors[status] || this.statusColors.green
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = 64
|
|
canvas.height = 64
|
|
const ctx = canvas.getContext('2d')!
|
|
ctx.beginPath()
|
|
ctx.arc(32, 32, 32, 0, Math.PI * 2)
|
|
ctx.fillStyle = color
|
|
ctx.fill()
|
|
this.cache[status] = canvas.toDataURL('image/png')
|
|
return this.cache[status]
|
|
},
|
|
|
|
applyStatus(this: any) {
|
|
const faviconEl = document.getElementById('favicon') as HTMLLinkElement
|
|
if (!faviconEl) return
|
|
const status = this.el.dataset.status || 'green'
|
|
faviconEl.type = 'image/png'
|
|
faviconEl.href = this.getFaviconPng(status)
|
|
},
|
|
|
|
mounted(this: any) {
|
|
const faviconEl = document.getElementById('favicon') as HTMLLinkElement
|
|
if (faviconEl) this.originalHref = faviconEl.href
|
|
this.applyStatus()
|
|
},
|
|
|
|
updated(this: any) {
|
|
this.applyStatus()
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
const faviconEl = document.getElementById('favicon') as HTMLLinkElement
|
|
if (faviconEl && this.originalHref) {
|
|
faviconEl.type = 'image/png'
|
|
faviconEl.href = this.originalHref
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// lazyHook wraps a Phoenix LiveView hook so its implementation is
|
|
// loaded via dynamic import() only when the hook first mounts. esbuild
|
|
// will emit each lazy module as its own chunk under
|
|
// /assets/js/, keeping app.js lean.
|
|
function lazyHook(loader: () => Promise<Record<string, any>>, exportName: string) {
|
|
return {
|
|
mounted(this: any) {
|
|
const self = this
|
|
loader().then((mod) => {
|
|
const real = mod[exportName]
|
|
if (!real) {
|
|
console.error(`Lazy hook ${exportName} missing from module`)
|
|
return
|
|
}
|
|
// Copy real methods onto `self` so future updated/destroyed
|
|
// calls from the LiveView land on the real hook impl.
|
|
for (const key of Object.keys(real)) self[key] = real[key]
|
|
if (typeof real.mounted === "function") real.mounted.call(self)
|
|
}).catch((e) => console.error(`Failed to lazy-load ${exportName}`, e))
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
// Coverage hooks (Leaflet + dBm overlay handling).
|
|
const CoverageLocationPicker = lazyHook(() => import("./hooks/coverage_hooks"), "CoverageLocationPicker")
|
|
const CoverageMap = lazyHook(() => import("./hooks/coverage_hooks"), "CoverageMap")
|
|
const MultiCoverageMap = lazyHook(() => import("./hooks/coverage_hooks"), "MultiCoverageMap")
|
|
|
|
// Sensor chart hook (Chart.js — ~200KB) — only sensor pages load it.
|
|
const SensorChart = lazyHook(() => import("./hooks/sensor_chart"), "SensorChart")
|
|
|
|
// Config timeline chart — Chart.js (shared chunk with sensor_chart) plus
|
|
// a small custom plugin that overlays config-change markers on QoE data.
|
|
const ConfigTimelineChart = lazyHook(() => import("./hooks/config_timeline_chart"), "ConfigTimelineChart")
|
|
|
|
// Sites/Leaflet hooks — Leaflet itself loads externally via ensureLeaflet,
|
|
// but the hook glue is still per-page.
|
|
const SitesMap = lazyHook(() => import("./hooks/sites_map"), "SitesMap")
|
|
const LeafletMap = lazyHook(() => import("./hooks/sites_map"), "LeafletMap")
|
|
|
|
// Topology hooks (Cytoscape.js — ~500KB). Both share a common cytoscape
|
|
// chunk that esbuild factors out automatically.
|
|
const NetworkMap = lazyHook(() => import("./hooks/network_map"), "NetworkMap")
|
|
const WeathermapViewer = lazyHook(() => import("./hooks/weathermap"), "WeathermapViewer")
|
|
|
|
// Page-specific small hooks — page count keeps app.js tiny.
|
|
const DeviceListReorder = lazyHook(() => import("./device_list_reorder"), "DeviceListReorder")
|
|
const SortableList = lazyHook(() => import("./sortable_list"), "SortableList")
|
|
const MikrotikPortSync = lazyHook(() => import("./hooks/mikrotik_port_sync"), "MikrotikPortSync")
|
|
|
|
const csrfToken = document.querySelector<HTMLMetaElement>("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"
|
|
|
|
// Global Search (Cmd+K / Ctrl+K) hook
|
|
const GlobalSearchTrigger = {
|
|
handleClick: null as ((e: Event) => void) | null,
|
|
|
|
mounted(this: any) {
|
|
this.handleClick = () => {
|
|
// Simulate Cmd+K to open global search
|
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }))
|
|
}
|
|
this.el.addEventListener("click", this.handleClick)
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
if (this.handleClick) {
|
|
this.el.removeEventListener("click", this.handleClick)
|
|
this.handleClick = null
|
|
}
|
|
}
|
|
}
|
|
|
|
const GlobalSearch = {
|
|
mounted() {
|
|
this.handleKeydown = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
e.preventDefault()
|
|
this.pushEventTo(this.el, "open", {})
|
|
}
|
|
if (e.key === "Escape" && this.el.querySelector("[role=dialog]")) {
|
|
this.pushEventTo(this.el, "close", {})
|
|
}
|
|
}
|
|
document.addEventListener("keydown", this.handleKeydown)
|
|
},
|
|
updated() {
|
|
const input = this.el.querySelector("#global-search-input") as HTMLInputElement
|
|
if (input) input.focus()
|
|
},
|
|
destroyed() {
|
|
document.removeEventListener("keydown", this.handleKeydown)
|
|
}
|
|
}
|
|
|
|
// Status Title - Updates page title with status emoji when alerts change
|
|
const StatusTitle = {
|
|
baseTitle: null as string | null,
|
|
|
|
mounted(this: any) {
|
|
// Store the initial base title (without any existing emoji)
|
|
this.baseTitle = document.title.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "")
|
|
|
|
// Update base title whenever page title changes (from LiveView navigation).
|
|
// Stored on `this` so destroyed() can disconnect it — a plain `const`
|
|
// would leave the observer alive forever.
|
|
this.observer = new MutationObserver(() => {
|
|
const currentTitle = document.title
|
|
// Only update baseTitle if it's not an emoji update (i.e., it's a real navigation)
|
|
if (currentTitle && !currentTitle.startsWith('🔴') && !currentTitle.startsWith('🟡') && !currentTitle.startsWith('🟢')) {
|
|
this.baseTitle = currentTitle
|
|
}
|
|
})
|
|
|
|
this.observer.observe(document.querySelector('title')!, {
|
|
childList: true,
|
|
characterData: true,
|
|
subtree: true
|
|
})
|
|
|
|
this.handleEvent("update_status_emoji", ({ emoji }: { emoji: string }) => {
|
|
// Always use the stored baseTitle instead of reading from document.title
|
|
const baseTitle = this.baseTitle || document.title.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "")
|
|
document.title = emoji ? `${emoji} ${baseTitle}` : baseTitle
|
|
})
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
// Clean up observer if it exists
|
|
if (this.observer) {
|
|
this.observer.disconnect()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sidebar collapse - toggle class on <html> (which LiveView never patches)
|
|
// so the state persists across all navigations without flicker.
|
|
const SidebarCollapse = {
|
|
mounted(this: any) {
|
|
// Stash the handler so destroyed() can remove it. Anonymous listeners
|
|
// accumulate on LV nav and leak memory.
|
|
this.clickHandler = (e: Event) => {
|
|
if ((e.target as HTMLElement).closest('[data-sidebar-toggle]')) {
|
|
const html = document.documentElement
|
|
html.classList.toggle('sidebar-collapsed')
|
|
localStorage.setItem('sidebarCollapsed', String(html.classList.contains('sidebar-collapsed')))
|
|
}
|
|
}
|
|
this.el.addEventListener('click', this.clickHandler)
|
|
},
|
|
destroyed(this: any) {
|
|
if (this.clickHandler) {
|
|
this.el.removeEventListener('click', this.clickHandler)
|
|
}
|
|
}
|
|
}
|
|
|
|
const ThemeSelector = {
|
|
mounted() {
|
|
this.updateActive()
|
|
window.addEventListener("phx:set-theme", () => this.updateActive())
|
|
},
|
|
updateActive() {
|
|
const current = localStorage.getItem("phx:theme") || "system"
|
|
this.el.querySelectorAll<HTMLElement>("[data-phx-theme]").forEach((btn) => {
|
|
const isActive = btn.dataset.phxTheme === current
|
|
btn.setAttribute("aria-pressed", isActive ? "true" : "false")
|
|
btn.classList.toggle("ring-2", isActive)
|
|
btn.classList.toggle("ring-indigo-500", isActive)
|
|
btn.classList.toggle("bg-indigo-50", isActive)
|
|
btn.classList.toggle("dark:bg-indigo-900/20", isActive)
|
|
btn.classList.toggle("text-indigo-700", isActive)
|
|
btn.classList.toggle("dark:text-indigo-300", isActive)
|
|
})
|
|
},
|
|
}
|
|
|
|
|
|
const liveSocket = new LiveSocket("/live", Socket, {
|
|
longPollFallbackMs: 5000,
|
|
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
|
hooks: { ...colocatedHooks, SensorChart, ConfigTimelineChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, MultiCoverageMap, CoverageLocationPicker, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
|
|
})
|
|
|
|
// 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) => {
|
|
const el = event.target as HTMLElement
|
|
const text = el.tagName === "INPUT" || el.tagName === "TEXTAREA"
|
|
? (el as HTMLInputElement | HTMLTextAreaElement).value
|
|
: (el.innerText || el.textContent || "")
|
|
|
|
if (text) navigator.clipboard.writeText(text).catch(err => console.error('clipboard:', err))
|
|
})
|
|
|
|
const ALLOWED_MIME_TYPES = new Set([
|
|
"text/csv",
|
|
"text/plain",
|
|
"application/json",
|
|
"application/pdf",
|
|
"application/octet-stream",
|
|
"application/vnd.ms-excel",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
])
|
|
|
|
// Handle file download events
|
|
window.addEventListener("phx:download", (event: any) => {
|
|
const { content, filename, mime_type } = event.detail
|
|
|
|
if (!ALLOWED_MIME_TYPES.has(mime_type)) {
|
|
console.error(`phx:download blocked unsafe mime_type: "${mime_type}"`)
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
|
|
// Reconnect WebSocket when returning from background (iOS Safari optimization)
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") {
|
|
if (liveSocket.socket.connectionState() !== "open") {
|
|
liveSocket.socket.disconnect(() => liveSocket.socket.connect())
|
|
}
|
|
}
|
|
})
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Only expose for debugging in non-production environments
|
|
declare global { interface Window { NODE_ENV?: string } }
|
|
if (typeof window !== "undefined" && window.NODE_ENV !== "production") {
|
|
;(window as any).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
|
|
})
|
|
}
|
|
|
|
// WebMCP: expose site tools to AI agents
|
|
// https://webmachinelearning.github.io/webmcp/
|
|
if (typeof navigator !== "undefined" && (navigator as any).modelContext) {
|
|
(navigator as any).modelContext.provideContext({
|
|
tools: [
|
|
{
|
|
name: "navigate",
|
|
description: "Navigate to a page on TowerOps",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description: "The path to navigate to, e.g. /dashboard or /users/register"
|
|
}
|
|
},
|
|
required: ["path"]
|
|
},
|
|
execute({ path }: { path: string }) {
|
|
// Only allow same-origin paths — rejects javascript:, data:, and
|
|
// off-site URLs so a compromised WebMCP agent can't phish or run
|
|
// arbitrary JS via this tool.
|
|
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//")) {
|
|
return;
|
|
}
|
|
window.location.href = path;
|
|
}
|
|
},
|
|
{
|
|
name: "view_api_docs",
|
|
description: "Open the TowerOps API documentation",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {}
|
|
},
|
|
execute() {
|
|
window.location.href = "/docs/api";
|
|
}
|
|
},
|
|
{
|
|
name: "register",
|
|
description: "Start the registration flow for a new TowerOps account",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {}
|
|
},
|
|
execute() {
|
|
window.location.href = "/users/register";
|
|
}
|
|
}
|
|
]
|
|
});
|
|
}
|