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 = `${label}` 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 | 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, cache: {} as Record, 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>, 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("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) const 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 } }) 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 (which LiveView never patches) // so the state persists across all navigations without flicker. const SidebarCollapse = { mounted(this: any) { this.el.addEventListener('click', (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'))) } }) } } const ThemeSelector = { mounted() { this.updateActive() window.addEventListener("phx:set-theme", () => this.updateActive()) }, updateActive() { const current = localStorage.getItem("phx:theme") || "system" this.el.querySelectorAll("[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)) }) // Handle file download events window.addEventListener("phx:download", (event: any) => { const { content, filename, mime_type } = event.detail // 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 } } 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 }) } // 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 }) { 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"; } } ] }); }