Three deferred followups from the chunk-4 plan, all together: A. Restrictions editor - Resolver: time_of_week support (weekday set + time-of-day window), in addition to the existing time_of_day. - New OnCall.update_layer_restrictions/2 + clear_layer_restrictions/1. - schedule_live/show: per-layer Restrict on-call shifts form (Always on call / Time of day / Time of week with Mon-Sun checkboxes), changes patched live and persisted via the resolver. B. Drag-and-drop reorder - New OnCall.reorder_layers/2 + reorder_escalation_rules/2 (atomic transaction; validates id count + membership). - assets/js/sortable_list.ts: minimal HTML5 drag-and-drop hook (no external dep; mirrors the existing DeviceListReorder pattern), registered as SortableList in app.ts. - schedule_live/show + escalation_policy_live/show: layer/level cards now carry data-id + draggable + a visible drag handle. Up/down arrow buttons remain as a fallback for users on assistive tech. C. Unified new-schedule UX - schedule_live/show pre-opens the Add Layer form when the schedule has zero layers, so the new-schedule -> add-layers flow lands ready to act on. Two existing tests updated to seed a layer first; two new tests verify the auto-open behaviour both ways. Full suite 10,231 / 0 failures. Format/dialyzer clean.
2575 lines
81 KiB
TypeScript
2575 lines
81 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 cytoscape from "../vendor/cytoscape"
|
|
import type { SensorChartHook } from "./types/liveview"
|
|
import { DeviceListReorder } from "./device_list_reorder"
|
|
import { SortableList } from "./sortable_list"
|
|
import { initCookieConsent } from "./cookie_consent"
|
|
|
|
// Helper function to convert range string to milliseconds
|
|
function getTimeRangeMs(range: string): number {
|
|
switch (range) {
|
|
case '1h': return 1 * 60 * 60 * 1000
|
|
case '6h': return 6 * 60 * 60 * 1000
|
|
case '12h': return 12 * 60 * 60 * 1000
|
|
case '24h': return 24 * 60 * 60 * 1000
|
|
case '7d': return 7 * 24 * 60 * 60 * 1000
|
|
case '30d': return 30 * 24 * 60 * 60 * 1000
|
|
default: return 24 * 60 * 60 * 1000 // Default to 24 hours
|
|
}
|
|
}
|
|
|
|
// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.)
|
|
const SensorChart: SensorChartHook = {
|
|
mounted() {
|
|
this.isLiveMode = this.el?.dataset.range === 'live'
|
|
this.createChart()
|
|
|
|
// Listen for live updates
|
|
this.handleEvent?.("live_data_update", (data: any) => {
|
|
this.handleLiveDataUpdate(data)
|
|
})
|
|
},
|
|
|
|
updated() {
|
|
const newRange = this.el?.dataset.range
|
|
const wasLiveMode = this.isLiveMode
|
|
this.isLiveMode = newRange === 'live'
|
|
|
|
// Mode switch - recreate chart
|
|
if (wasLiveMode !== this.isLiveMode) {
|
|
if (this.chart) {
|
|
this.chart.destroy()
|
|
}
|
|
this.createChart()
|
|
} else if (!this.isLiveMode) {
|
|
// Historical mode - recreate chart (existing behavior)
|
|
if (this.chart) {
|
|
this.chart.destroy()
|
|
}
|
|
this.createChart()
|
|
}
|
|
// Live mode - updates handled by event, don't recreate
|
|
},
|
|
|
|
destroyed() {
|
|
if (this.chart) {
|
|
this.chart.destroy()
|
|
this.chart = null
|
|
}
|
|
},
|
|
|
|
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 dualAxis = this.el.dataset.dualAxis === 'true'
|
|
const range = this.el.dataset.range || '24h'
|
|
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,
|
|
}
|
|
|
|
// Preserve yAxisID from server data for dual-axis charts
|
|
if ((dataset as any).yAxisID) {
|
|
(baseConfig as any).yAxisID = (dataset as any).yAxisID
|
|
}
|
|
|
|
// Status dataset (dual axis, right side) uses stepped line with fill
|
|
if (dualAxis && (dataset as any).yAxisID === 'y1') {
|
|
(baseConfig as any).stepped = 'before'
|
|
baseConfig.tension = 0
|
|
baseConfig.fill = 'origin'
|
|
baseConfig.borderWidth = 1
|
|
// Use green with low opacity for the status fill area
|
|
baseConfig.borderColor = 'rgb(34, 197, 94)'
|
|
baseConfig.backgroundColor = 'rgba(34, 197, 94, 0.1)'
|
|
} else if (isTrafficChart) {
|
|
baseConfig.fill = 'origin'
|
|
} else {
|
|
baseConfig.fill = false
|
|
}
|
|
|
|
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') {
|
|
// Keep capacity overlays visible when traffic is flat, but avoid letting
|
|
// them dominate scaling when real traffic exists.
|
|
let maxAbsTrafficValue = 0
|
|
let maxAbsCapacityValue = 0
|
|
datasets.forEach(dataset => {
|
|
if (dataset.data && dataset.data.length > 0) {
|
|
dataset.data.forEach((point: ChartDataPoint) => {
|
|
if (point && typeof point.y === 'number') {
|
|
const absValue = Math.abs(point.y)
|
|
|
|
if (dataset.label?.startsWith('Capacity')) {
|
|
maxAbsCapacityValue = Math.max(maxAbsCapacityValue, absValue)
|
|
} else {
|
|
maxAbsTrafficValue = Math.max(maxAbsTrafficValue, absValue)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
const maxAbsValue =
|
|
maxAbsTrafficValue > 0
|
|
? maxAbsTrafficValue
|
|
: maxAbsCapacityValue
|
|
|
|
// Add 10% padding, ensure minimum of 1000 (1 Kbps)
|
|
const paddedMaxAbsValue = Math.max(maxAbsValue * 1.1, 1000)
|
|
|
|
yAxisConfig = {
|
|
min: -paddedMaxAbsValue,
|
|
max: paddedMaxAbsValue,
|
|
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)
|
|
}
|
|
if (unit === '') {
|
|
return Math.round(value)
|
|
}
|
|
return value.toFixed(1) + ' ' + 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)
|
|
}
|
|
if (unit === '') {
|
|
return Math.round(value)
|
|
}
|
|
return value.toFixed(1) + ' ' + unit
|
|
}
|
|
},
|
|
grid: {
|
|
color: 'rgba(0, 0, 0, 0.05)'
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate time range based on selected range
|
|
const now = Date.now()
|
|
let xAxisConfig: any
|
|
|
|
if (this.isLiveMode) {
|
|
// Live mode - 5 minute window
|
|
xAxisConfig = {
|
|
type: 'linear',
|
|
min: now - (5 * 60 * 1000),
|
|
max: now,
|
|
ticks: {
|
|
callback: function(value: number) {
|
|
const date = new Date(value)
|
|
return date.toLocaleTimeString('en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false
|
|
})
|
|
},
|
|
maxTicksLimit: 10
|
|
},
|
|
grid: {
|
|
display: false
|
|
}
|
|
}
|
|
} else {
|
|
// Historical mode - existing configuration
|
|
const timeRangeMs = getTimeRangeMs(range)
|
|
const rangeStart = now - timeRangeMs
|
|
|
|
xAxisConfig = {
|
|
type: 'linear',
|
|
min: rangeStart,
|
|
max: now,
|
|
ticks: {
|
|
callback: function(value: number) {
|
|
const date = new Date(value)
|
|
// For ranges > 24h, show date + time; otherwise just time
|
|
if (timeRangeMs > 24 * 60 * 60 * 1000) {
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
})
|
|
} else {
|
|
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
}
|
|
},
|
|
maxTicksLimit: 12
|
|
},
|
|
grid: {
|
|
display: false
|
|
}
|
|
}
|
|
}
|
|
|
|
this.chart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: { datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: 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) {
|
|
// Status dataset on dual-axis chart
|
|
if (dualAxis && context.dataset.yAxisID === 'y1') {
|
|
return context.dataset.label + ': ' + (context.parsed.y === 1 ? 'Up' : 'Down')
|
|
}
|
|
if (unit === 'bps') {
|
|
return context.dataset.label + ': ' + formatBps(context.parsed.y)
|
|
}
|
|
if (unit === '') {
|
|
return context.dataset.label + ': ' + Math.round(context.parsed.y)
|
|
}
|
|
return context.dataset.label + ': ' + context.parsed.y.toFixed(1) + ' ' + unit
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: xAxisConfig,
|
|
y: yAxisConfig,
|
|
...(dualAxis ? {
|
|
y1: {
|
|
position: 'right' as const,
|
|
min: -0.1,
|
|
max: 1.1,
|
|
grid: { drawOnChartArea: false },
|
|
ticks: {
|
|
stepSize: 1,
|
|
callback: function(value: number) {
|
|
if (value === 1) return 'Up'
|
|
if (value === 0) return 'Down'
|
|
return ''
|
|
}
|
|
}
|
|
}
|
|
} : {})
|
|
}
|
|
}
|
|
})
|
|
|
|
// Hide loading indicator after chart is created
|
|
const loadingEl = this.el.querySelector('[data-loading]')
|
|
if (loadingEl) {
|
|
loadingEl.remove()
|
|
}
|
|
},
|
|
|
|
handleLiveDataUpdate(event: any) {
|
|
if (!this.chart || !this.isLiveMode) return
|
|
|
|
const { points } = event
|
|
const now = Date.now()
|
|
|
|
// Update each dataset with new point
|
|
points.forEach((point: any) => {
|
|
const dataset = this.chart!.data.datasets.find(
|
|
(ds: any) => ds.label === point.label
|
|
)
|
|
|
|
if (dataset && dataset.data) {
|
|
// Add new point
|
|
(dataset.data as any[]).push({
|
|
x: point.timestamp,
|
|
y: point.value
|
|
})
|
|
|
|
// Remove points older than 5 minutes
|
|
const cutoff = now - (5 * 60 * 1000)
|
|
dataset.data = (dataset.data as any[]).filter(
|
|
(p: any) => p.x >= cutoff
|
|
)
|
|
} else if (!dataset) {
|
|
// New sensor - add 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 index = this.chart!.data.datasets.length
|
|
const color = colors[index % colors.length]
|
|
|
|
this.chart!.data.datasets.push({
|
|
label: point.label,
|
|
data: [{x: point.timestamp, y: point.value}],
|
|
borderColor: color,
|
|
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'),
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 4,
|
|
fill: false
|
|
} as any)
|
|
}
|
|
})
|
|
|
|
// Update x-axis range to keep 5-minute window
|
|
if (this.chart.options.scales?.x) {
|
|
this.chart.options.scales.x.min = now - (5 * 60 * 1000)
|
|
this.chart.options.scales.x.max = now
|
|
}
|
|
|
|
// Update chart without animation
|
|
this.chart.update('none')
|
|
}
|
|
}
|
|
|
|
// LiveView hook for copying to clipboard
|
|
const CopyToClipboard = {
|
|
handleClick: null as ((e: Event) => void) | null,
|
|
|
|
mounted(this: any) {
|
|
this.handleClick = (e: Event) => {
|
|
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 || ""
|
|
}
|
|
|
|
// Trim any whitespace from the text
|
|
text = text.trim()
|
|
|
|
if (text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
console.log('Copied to clipboard successfully')
|
|
// Show visual feedback
|
|
const originalHTML = this.el.innerHTML
|
|
const originalClasses = this.el.className
|
|
|
|
// Update button to show success state
|
|
this.el.innerHTML = '<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg><span>Copied!</span>'
|
|
this.el.className = originalClasses.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700').replace('bg-blue-500', 'bg-green-500').replace('hover:bg-blue-600', 'hover:bg-green-600')
|
|
|
|
setTimeout(() => {
|
|
this.el.innerHTML = originalHTML
|
|
this.el.className = originalClasses
|
|
}, 2000)
|
|
}).catch(err => {
|
|
console.error('Failed to copy:', err)
|
|
// Show error state
|
|
const originalHTML = this.el.innerHTML
|
|
const originalClasses = this.el.className
|
|
|
|
this.el.innerHTML = '<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg><span>Failed</span>'
|
|
this.el.className = originalClasses.replace('bg-blue-600', 'bg-red-600').replace('hover:bg-blue-700', 'hover:bg-red-700').replace('bg-blue-500', 'bg-red-500').replace('hover:bg-blue-600', 'hover:bg-red-600')
|
|
|
|
setTimeout(() => {
|
|
this.el.innerHTML = originalHTML
|
|
this.el.className = originalClasses
|
|
}, 2000)
|
|
})
|
|
} else {
|
|
console.error('No text found to copy')
|
|
}
|
|
}
|
|
|
|
this.el.addEventListener("click", this.handleClick)
|
|
},
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Network Map hook for Cytoscape.js topology visualization
|
|
const NetworkMap = {
|
|
cy: null as any,
|
|
_topologyData: null as any,
|
|
_zoomInHandler: null as ((e: Event) => void) | null,
|
|
_zoomOutHandler: null as ((e: Event) => void) | null,
|
|
_fitHandler: null as ((e: Event) => void) | null,
|
|
mounted(this: any) {
|
|
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
|
|
this._topologyData = topologyData
|
|
this.initializeCytoscape(topologyData)
|
|
|
|
// Listen for topology updates from LiveView
|
|
this.handleEvent("update_topology", (payload: any) => {
|
|
if (this.cy) {
|
|
this._topologyData = payload.topology
|
|
this.updateTopology(payload.topology)
|
|
}
|
|
})
|
|
|
|
// Filter handler
|
|
this.handleEvent("apply_filter", (payload: any) => {
|
|
if (!this.cy) return
|
|
this._applyFilter(payload.filter)
|
|
})
|
|
|
|
// Search handler
|
|
this.handleEvent("search_nodes", (payload: any) => {
|
|
if (!this.cy) return
|
|
this._searchNodes(payload.query)
|
|
})
|
|
|
|
// Layout toggle handler
|
|
this.handleEvent("change_layout", (payload: any) => {
|
|
if (!this.cy || !this._topologyData) return
|
|
this._changeLayout(payload.mode, this._topologyData)
|
|
})
|
|
},
|
|
|
|
updated(this: any) {
|
|
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
|
|
this.updateTopology(topologyData)
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
// Remove DOM event listeners
|
|
const zoomIn = document.getElementById('cy-zoom-in')
|
|
const zoomOut = document.getElementById('cy-zoom-out')
|
|
const fit = document.getElementById('cy-fit')
|
|
|
|
if (zoomIn && this._zoomInHandler) {
|
|
zoomIn.removeEventListener('click', this._zoomInHandler)
|
|
}
|
|
if (zoomOut && this._zoomOutHandler) {
|
|
zoomOut.removeEventListener('click', this._zoomOutHandler)
|
|
}
|
|
if (fit && this._fitHandler) {
|
|
fit.removeEventListener('click', this._fitHandler)
|
|
}
|
|
|
|
this._zoomInHandler = null
|
|
this._zoomOutHandler = null
|
|
this._fitHandler = null
|
|
|
|
if (this.cy) {
|
|
// Stop any running layout before destroying to prevent async callbacks
|
|
// from accessing a destroyed renderer
|
|
try {
|
|
this.cy.stop()
|
|
} catch (_e) { /* ignore */ }
|
|
this.cy.destroy()
|
|
this.cy = null
|
|
}
|
|
},
|
|
|
|
_initControls(this: any) {
|
|
const ZOOM_STEP = 0.25
|
|
|
|
const zoomIn = document.getElementById('cy-zoom-in')
|
|
const zoomOut = document.getElementById('cy-zoom-out')
|
|
const fit = document.getElementById('cy-fit')
|
|
|
|
if (zoomIn) {
|
|
this._zoomInHandler = () => {
|
|
if (!this.cy) return
|
|
this.cy.zoom({
|
|
level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()),
|
|
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
|
|
})
|
|
}
|
|
zoomIn.addEventListener('click', this._zoomInHandler)
|
|
}
|
|
|
|
if (zoomOut) {
|
|
this._zoomOutHandler = () => {
|
|
if (!this.cy) return
|
|
this.cy.zoom({
|
|
level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()),
|
|
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
|
|
})
|
|
}
|
|
zoomOut.addEventListener('click', this._zoomOutHandler)
|
|
}
|
|
|
|
if (fit) {
|
|
this._fitHandler = () => {
|
|
if (!this.cy) return
|
|
this.cy.fit(undefined, 50)
|
|
}
|
|
fit.addEventListener('click', this._fitHandler)
|
|
}
|
|
},
|
|
|
|
initializeCytoscape(this: any, topology: any) {
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
|
|
|
// Role-based node colors (keyed by the `type` field from backend)
|
|
const roleColors: Record<string, string> = {
|
|
router: '#3B82F6', // blue
|
|
switch: '#10B981', // green
|
|
wireless: '#8B5CF6', // purple
|
|
firewall: '#EF4444', // red
|
|
server: '#F59E0B', // amber
|
|
unknown: '#9CA3AF' // light gray
|
|
}
|
|
|
|
// Role-based node shapes
|
|
const roleShapes: Record<string, string> = {
|
|
router: 'round-rectangle',
|
|
switch: 'diamond',
|
|
wireless: 'triangle',
|
|
firewall: 'hexagon',
|
|
server: 'rectangle',
|
|
unknown: 'ellipse'
|
|
}
|
|
|
|
// Signal health color map for RF links
|
|
const signalHealthColors: Record<string, string> = {
|
|
good: '#10B981', // green
|
|
degraded: '#F59E0B', // yellow
|
|
critical: '#EF4444' // red
|
|
}
|
|
|
|
// Edge styling: prefer signal_health coloring for RF links, fall back to confidence
|
|
const getEdgeStyle = (confidence: number, signalHealth?: string) => {
|
|
if (signalHealth && signalHealthColors[signalHealth]) {
|
|
return { color: signalHealthColors[signalHealth], style: 'solid' as const, dashPattern: [] }
|
|
}
|
|
if (confidence > 0.9) {
|
|
return { color: '#10B981', style: 'solid' as const, dashPattern: [] }
|
|
} else if (confidence >= 0.5) {
|
|
return { color: '#F59E0B', style: 'dashed' as const, dashPattern: [6, 3] }
|
|
} else {
|
|
return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [2, 3] }
|
|
}
|
|
}
|
|
|
|
// Compute grid-based positions to prevent site overlap.
|
|
// Groups device nodes by parent site, arranges them in a tidy subgrid,
|
|
// then lays out the site groups in a row-wrapped grid.
|
|
const computePresetPositions = (nodes: any[]): Record<string, {x: number, y: number}> => {
|
|
const DEVICE_SPACING = 110
|
|
const SITE_PADDING = 50
|
|
const SITE_GAP = 90
|
|
const SITE_COLS = 3
|
|
|
|
const siteGroups = new Map<string, string[]>()
|
|
const unparented: string[] = []
|
|
|
|
nodes.forEach((node) => {
|
|
if (node.type === 'site') return
|
|
if (node.parent) {
|
|
if (!siteGroups.has(node.parent)) siteGroups.set(node.parent, [])
|
|
siteGroups.get(node.parent)!.push(node.id)
|
|
} else {
|
|
unparented.push(node.id)
|
|
}
|
|
})
|
|
|
|
const positions: Record<string, {x: number, y: number}> = {}
|
|
let curX = 0, curY = 0, maxH = 0, col = 0
|
|
|
|
siteGroups.forEach((deviceIds) => {
|
|
const n = deviceIds.length
|
|
const cols = Math.max(1, Math.ceil(Math.sqrt(n)))
|
|
const rows = Math.ceil(n / cols)
|
|
const innerW = (cols - 1) * DEVICE_SPACING
|
|
const innerH = (rows - 1) * DEVICE_SPACING
|
|
const cx = curX + SITE_PADDING + innerW / 2
|
|
const cy = curY + SITE_PADDING + innerH / 2
|
|
|
|
deviceIds.forEach((id, i) => {
|
|
const dc = i % cols
|
|
const dr = Math.floor(i / cols)
|
|
positions[id] = {
|
|
x: cx + dc * DEVICE_SPACING - innerW / 2,
|
|
y: cy + dr * DEVICE_SPACING - innerH / 2
|
|
}
|
|
})
|
|
|
|
const totalW = innerW + SITE_PADDING * 2 + DEVICE_SPACING
|
|
const totalH = innerH + SITE_PADDING * 2 + DEVICE_SPACING
|
|
maxH = Math.max(maxH, totalH)
|
|
col++
|
|
|
|
if (col >= SITE_COLS) {
|
|
col = 0
|
|
curX = 0
|
|
curY += maxH + SITE_GAP
|
|
maxH = 0
|
|
} else {
|
|
curX += totalW + SITE_GAP
|
|
}
|
|
})
|
|
|
|
// Place unparented nodes below all sites
|
|
const unY = curY + maxH + SITE_GAP
|
|
unparented.forEach((id, i) => {
|
|
const uc = i % 6
|
|
const ur = Math.floor(i / 6)
|
|
positions[id] = { x: uc * DEVICE_SPACING, y: unY + ur * DEVICE_SPACING }
|
|
})
|
|
|
|
return positions
|
|
}
|
|
|
|
const presetPositions = computePresetPositions(topology.nodes || [])
|
|
|
|
// Build Cytoscape elements from topology data
|
|
const elements: any[] = []
|
|
|
|
// Add nodes (site compound nodes first, then devices)
|
|
topology.nodes?.forEach((node: any) => {
|
|
const data: any = {
|
|
id: node.id,
|
|
label: node.label,
|
|
type: node.type,
|
|
status: node.status,
|
|
discovered: node.discovered,
|
|
ip_address: node.ip_address,
|
|
site_name: node.site_name,
|
|
manufacturer: node.manufacturer,
|
|
client_count: node.client_count || 0,
|
|
signal_health: node.signal_health,
|
|
has_alerts: node.has_alerts || false,
|
|
latitude: node.latitude,
|
|
longitude: node.longitude,
|
|
device_role: node.device_role
|
|
}
|
|
if (node.parent) data.parent = node.parent
|
|
elements.push({
|
|
data: data,
|
|
classes: node.type === 'site' ? 'site-group' : (node.discovered ? 'discovered' : node.status)
|
|
})
|
|
})
|
|
|
|
// Add edges
|
|
topology.edges?.forEach((edge: any) => {
|
|
// Build edge label with interface names
|
|
const sourceLabel = edge.source_interface || ''
|
|
const targetLabel = edge.target_interface || ''
|
|
const label = [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 ')
|
|
|
|
elements.push({
|
|
data: {
|
|
id: edge.id,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
source_interface: edge.source_interface,
|
|
target_interface: edge.target_interface,
|
|
link_type: edge.link_type,
|
|
confidence: edge.confidence ?? 0.5,
|
|
if_speed: edge.if_speed,
|
|
signal_health: edge.signal_health,
|
|
snr: edge.snr,
|
|
label: label
|
|
}
|
|
})
|
|
})
|
|
|
|
// Map interface speed to edge width — proportional to bandwidth capacity
|
|
const getEdgeWidth = (speed: number | null | undefined): number => {
|
|
if (!speed) return 1.5
|
|
if (speed >= 10_000_000_000) return 5 // 10Gbps+
|
|
if (speed >= 1_000_000_000) return 3.5 // 1Gbps
|
|
if (speed >= 100_000_000) return 2.5 // 100Mbps
|
|
return 1.5 // < 100Mbps or unknown
|
|
}
|
|
|
|
// Initialize Cytoscape
|
|
this.cy = cytoscape({
|
|
container: this.el,
|
|
elements: elements,
|
|
style: [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'background-color': function(ele: any) {
|
|
return roleColors[ele.data('type')] || roleColors.unknown
|
|
},
|
|
'shape': function(ele: any) {
|
|
return roleShapes[ele.data('type')] || roleShapes.unknown
|
|
},
|
|
'label': function(ele: any) {
|
|
const label = ele.data('label') || ''
|
|
const clients = ele.data('client_count')
|
|
if (clients > 0) {
|
|
return `${label}\n📡 ${clients} clients`
|
|
}
|
|
return label
|
|
},
|
|
'color': isDark ? '#fff' : '#000',
|
|
'text-valign': 'bottom',
|
|
'text-halign': 'center',
|
|
'text-margin-y': 6,
|
|
'font-size': '13px',
|
|
'font-weight': '500',
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': '140px',
|
|
'width': 44,
|
|
'height': 44,
|
|
'border-width': 2,
|
|
'border-color': function(ele: any) {
|
|
const status = ele.data('status')
|
|
if (status === 'down') return '#dc2626' // darker red
|
|
if (status === 'unknown') return '#ca8a04' // darker yellow
|
|
// Darken the role color for the border
|
|
const type = ele.data('type')
|
|
const darkerColors: Record<string, string> = {
|
|
router: '#2563EB',
|
|
switch: '#059669',
|
|
wireless: '#7C3AED',
|
|
firewall: '#DC2626',
|
|
server: '#D97706',
|
|
unknown: '#6B7280'
|
|
}
|
|
return darkerColors[type] || darkerColors.unknown
|
|
},
|
|
'overlay-color': function(ele: any) {
|
|
const status = ele.data('status')
|
|
if (status === 'down') return '#ef4444'
|
|
return '#000'
|
|
},
|
|
'overlay-opacity': function(ele: any) {
|
|
const status = ele.data('status')
|
|
if (status === 'down') return 0.15
|
|
return 0
|
|
}
|
|
} as any
|
|
},
|
|
{
|
|
selector: 'node[status = "down"]',
|
|
style: {
|
|
'border-width': 3,
|
|
'border-color': '#dc2626',
|
|
'background-blacken': -0.3
|
|
}
|
|
},
|
|
{
|
|
selector: 'node.discovered',
|
|
style: {
|
|
'border-style': 'dashed',
|
|
'border-width': 2,
|
|
'border-color': '#9ca3af',
|
|
'opacity': 0.6,
|
|
'width': 34,
|
|
'height': 34,
|
|
'font-size': '11px'
|
|
}
|
|
},
|
|
{
|
|
selector: 'node:selected',
|
|
style: {
|
|
'border-width': 4,
|
|
'border-color': '#fbbf24'
|
|
}
|
|
},
|
|
{
|
|
selector: 'node.highlighted',
|
|
style: {
|
|
'border-width': 4,
|
|
'border-color': '#3b82f6',
|
|
'overlay-color': '#3b82f6',
|
|
'overlay-opacity': 0.1
|
|
}
|
|
},
|
|
{
|
|
selector: 'node.search-match',
|
|
style: {
|
|
'border-width': 4,
|
|
'border-color': '#f59e0b',
|
|
'overlay-color': '#f59e0b',
|
|
'overlay-opacity': 0.15,
|
|
'z-index': 999
|
|
}
|
|
},
|
|
{
|
|
selector: '$node > node', // compound parent selector
|
|
style: {
|
|
'background-color': isDark ? '#1e293b' : '#f8fafc',
|
|
'background-opacity': 0.7,
|
|
'border-width': 2,
|
|
'border-color': isDark ? '#475569' : '#cbd5e1',
|
|
'border-style': 'solid',
|
|
'shape': 'round-rectangle',
|
|
'padding': '30px',
|
|
'text-valign': 'top',
|
|
'text-halign': 'center',
|
|
'text-margin-y': 8,
|
|
'label': 'data(label)',
|
|
'font-size': '14px',
|
|
'font-weight': '700',
|
|
'color': isDark ? '#94a3b8' : '#475569'
|
|
} as any
|
|
},
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': function(ele: any) {
|
|
return getEdgeWidth(ele.data('if_speed'))
|
|
},
|
|
'line-color': function(ele: any) {
|
|
return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).color
|
|
},
|
|
'line-style': function(ele: any) {
|
|
return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).style
|
|
},
|
|
'line-dash-pattern': function(ele: any) {
|
|
const pattern = getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).dashPattern
|
|
return pattern.length > 0 ? pattern : [1, 0]
|
|
},
|
|
'target-arrow-color': function(ele: any) {
|
|
return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).color
|
|
},
|
|
'curve-style': 'bezier',
|
|
'label': 'data(label)',
|
|
'font-size': '8px',
|
|
'color': isDark ? '#d1d5db' : '#4b5563',
|
|
'text-background-color': isDark ? '#1f2937' : '#ffffff',
|
|
'text-background-opacity': 0.85,
|
|
'text-background-padding': '2px',
|
|
'text-rotation': 'autorotate',
|
|
'text-margin-y': -8,
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': '120px'
|
|
} as any
|
|
},
|
|
{
|
|
selector: 'edge:selected',
|
|
style: {
|
|
'line-color': '#fbbf24',
|
|
'width': 3
|
|
}
|
|
}
|
|
],
|
|
layout: {
|
|
name: 'preset',
|
|
positions: (node: any) => presetPositions[node.id()] || { x: 0, y: 0 },
|
|
fit: true,
|
|
padding: 50
|
|
},
|
|
minZoom: 0.2,
|
|
maxZoom: 3,
|
|
wheelSensitivity: 1
|
|
})
|
|
|
|
// Add click handler for nodes (skip site compound nodes)
|
|
this.cy.on('tap', 'node', (evt: any) => {
|
|
const node = evt.target
|
|
const nodeData = node.data()
|
|
|
|
// Don't open detail panel for site group nodes
|
|
if (nodeData.type === 'site') return
|
|
|
|
// Push event to LiveView to open detail panel
|
|
this.pushEvent("node_clicked", {
|
|
node_id: nodeData.id,
|
|
node_type: nodeData.discovered ? 'discovered' : 'managed'
|
|
})
|
|
})
|
|
|
|
// Handle highlight_node event from LiveView
|
|
this.handleEvent("highlight_node", (payload: any) => {
|
|
if (!this.cy) return
|
|
// Remove highlight from all nodes
|
|
this.cy.nodes().removeClass('highlighted')
|
|
// Add highlight to selected node
|
|
const node = this.cy.getElementById(payload.node_id)
|
|
if (node.length > 0) {
|
|
node.addClass('highlighted')
|
|
}
|
|
})
|
|
|
|
// Handle clear_highlight event from LiveView
|
|
this.handleEvent("clear_highlight", () => {
|
|
if (!this.cy) return
|
|
this.cy.nodes().removeClass('highlighted')
|
|
})
|
|
|
|
// Add hover tooltips with WISP RF info
|
|
this.cy.on('mouseover', 'node', (evt: any) => {
|
|
const node = evt.target
|
|
const d = node.data()
|
|
|
|
let tooltip = d.label || ''
|
|
if (d.ip_address) tooltip += `\n${d.ip_address}`
|
|
if (d.site_name) tooltip += `\n📍 ${d.site_name}`
|
|
if (d.manufacturer) tooltip += `\n${d.manufacturer}`
|
|
if (d.client_count > 0) tooltip += `\n📡 ${d.client_count} clients`
|
|
if (d.signal_health) {
|
|
const healthIcon = d.signal_health === 'good' ? '🟢' : d.signal_health === 'degraded' ? '🟡' : '🔴'
|
|
tooltip += `\n${healthIcon} Signal: ${d.signal_health}`
|
|
}
|
|
|
|
node.style('label', tooltip)
|
|
})
|
|
|
|
this.cy.on('mouseout', 'node', (evt: any) => {
|
|
const node = evt.target
|
|
const d = node.data()
|
|
const clients = d.client_count
|
|
const label = d.label || ''
|
|
node.style('label', clients > 0 ? `${label}\n📡 ${clients} clients` : label)
|
|
})
|
|
|
|
// Enhanced edge hover with PTP/backhaul RF stats
|
|
this.cy.on('mouseover', 'edge', (evt: any) => {
|
|
const edge = evt.target
|
|
const data = edge.data()
|
|
|
|
// Get source and target node names for the tooltip
|
|
const srcNode = this.cy.getElementById(data.source)
|
|
const tgtNode = this.cy.getElementById(data.target)
|
|
const srcName = srcNode.data('label') || data.source
|
|
const tgtName = tgtNode.data('label') || data.target
|
|
|
|
let tooltip = `${srcName} ↔ ${tgtName}`
|
|
if (data.snr != null) tooltip += `\nSNR ${data.snr} dB`
|
|
if (data.signal_health) {
|
|
const healthIcon = data.signal_health === 'good' ? '🟢' : data.signal_health === 'degraded' ? '🟡' : '🔴'
|
|
tooltip += ` ${healthIcon}`
|
|
}
|
|
if (data.if_speed) {
|
|
const mbps = data.if_speed / 1_000_000
|
|
tooltip += `\n${mbps >= 1000 ? (mbps/1000).toFixed(1) + ' Gbps' : mbps + ' Mbps'}`
|
|
}
|
|
if (data.link_type) tooltip += `\n${data.link_type.toUpperCase()}`
|
|
const confidence = data.confidence
|
|
if (confidence != null) tooltip += ` (${Math.round(confidence * 100)}%)`
|
|
|
|
edge.style({
|
|
'line-color': '#fbbf24',
|
|
'width': Math.max(getEdgeWidth(data.if_speed), 3),
|
|
'label': tooltip
|
|
})
|
|
})
|
|
|
|
this.cy.on('mouseout', 'edge', (evt: any) => {
|
|
const edge = evt.target
|
|
const data = edge.data()
|
|
const edgeStyle = getEdgeStyle(data.confidence ?? 0.5, data.signal_health)
|
|
edge.style({
|
|
'line-color': edgeStyle.color,
|
|
'width': getEdgeWidth(data.if_speed),
|
|
'label': data.label || ''
|
|
})
|
|
})
|
|
|
|
this._initControls()
|
|
},
|
|
|
|
updateTopology(this: any, topology: any) {
|
|
if (!this.cy) return
|
|
|
|
// Build incoming element maps
|
|
const incomingNodes = new Map<string, any>()
|
|
topology.nodes?.forEach((node: any) => {
|
|
incomingNodes.set(node.id, node)
|
|
})
|
|
|
|
const incomingEdges = new Map<string, any>()
|
|
topology.edges?.forEach((edge: any) => {
|
|
incomingEdges.set(edge.id, edge)
|
|
})
|
|
|
|
// Track which nodes already exist
|
|
const existingNodeIds = new Set<string>()
|
|
const existingEdgeIds = new Set<string>()
|
|
|
|
// Update or remove existing nodes
|
|
this.cy.nodes().forEach((node: any) => {
|
|
const id = node.id()
|
|
if (incomingNodes.has(id)) {
|
|
// Update data
|
|
const incoming = incomingNodes.get(id)
|
|
node.data('label', incoming.label)
|
|
node.data('type', incoming.type)
|
|
node.data('status', incoming.status)
|
|
node.data('discovered', incoming.discovered)
|
|
node.data('ip_address', incoming.ip_address)
|
|
node.data('site_name', incoming.site_name)
|
|
node.data('manufacturer', incoming.manufacturer)
|
|
if (incoming.parent) node.data('parent', incoming.parent)
|
|
existingNodeIds.add(id)
|
|
} else {
|
|
node.remove()
|
|
}
|
|
})
|
|
|
|
// Update or remove existing edges
|
|
this.cy.edges().forEach((edge: any) => {
|
|
const id = edge.id()
|
|
if (incomingEdges.has(id)) {
|
|
const incoming = incomingEdges.get(id)
|
|
edge.data('confidence', incoming.confidence ?? 0.5)
|
|
edge.data('link_type', incoming.link_type)
|
|
edge.data('source_interface', incoming.source_interface)
|
|
edge.data('target_interface', incoming.target_interface)
|
|
const sourceLabel = incoming.source_interface || ''
|
|
const targetLabel = incoming.target_interface || ''
|
|
edge.data('label', [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 '))
|
|
existingEdgeIds.add(id)
|
|
} else {
|
|
edge.remove()
|
|
}
|
|
})
|
|
|
|
// Add new nodes
|
|
const newElements: any[] = []
|
|
incomingNodes.forEach((node, id) => {
|
|
if (!existingNodeIds.has(id)) {
|
|
newElements.push({
|
|
group: 'nodes',
|
|
data: {
|
|
id: node.id,
|
|
label: node.label,
|
|
type: node.type,
|
|
status: node.status,
|
|
discovered: node.discovered,
|
|
ip_address: node.ip_address,
|
|
site_name: node.site_name,
|
|
manufacturer: node.manufacturer,
|
|
parent: node.parent
|
|
},
|
|
classes: node.discovered ? 'discovered' : node.status
|
|
})
|
|
}
|
|
})
|
|
|
|
// Add new edges
|
|
incomingEdges.forEach((edge, id) => {
|
|
if (!existingEdgeIds.has(id)) {
|
|
const sourceLabel = edge.source_interface || ''
|
|
const targetLabel = edge.target_interface || ''
|
|
const label = [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 ')
|
|
|
|
newElements.push({
|
|
group: 'edges',
|
|
data: {
|
|
id: edge.id,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
source_interface: edge.source_interface,
|
|
target_interface: edge.target_interface,
|
|
link_type: edge.link_type,
|
|
confidence: edge.confidence ?? 0.5,
|
|
if_speed: edge.if_speed,
|
|
label: label
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
// When new elements arrive, recompute all positions from scratch
|
|
if (newElements.length > 0) {
|
|
this.cy.add(newElements)
|
|
const allNodes = topology.nodes || []
|
|
const newPositions = this._computePresetPositions(allNodes)
|
|
this.cy.layout({
|
|
name: 'preset',
|
|
positions: (node: any) => newPositions[node.id()] || { x: 0, y: 0 },
|
|
fit: true,
|
|
padding: 50
|
|
}).run()
|
|
}
|
|
},
|
|
|
|
// Shared position computation — same algorithm as in initializeCytoscape
|
|
_computePresetPositions(this: any, nodes: any[]): Record<string, {x: number, y: number}> {
|
|
const DEVICE_SPACING = 110
|
|
const SITE_PADDING = 50
|
|
const SITE_GAP = 90
|
|
const SITE_COLS = 3
|
|
|
|
const siteGroups = new Map<string, string[]>()
|
|
const unparented: string[] = []
|
|
|
|
nodes.forEach((node: any) => {
|
|
if (node.type === 'site') return
|
|
if (node.parent) {
|
|
if (!siteGroups.has(node.parent)) siteGroups.set(node.parent, [])
|
|
siteGroups.get(node.parent)!.push(node.id)
|
|
} else {
|
|
unparented.push(node.id)
|
|
}
|
|
})
|
|
|
|
const positions: Record<string, {x: number, y: number}> = {}
|
|
let curX = 0, curY = 0, maxH = 0, col = 0
|
|
|
|
siteGroups.forEach((deviceIds: string[]) => {
|
|
const n = deviceIds.length
|
|
const cols = Math.max(1, Math.ceil(Math.sqrt(n)))
|
|
const rows = Math.ceil(n / cols)
|
|
const innerW = (cols - 1) * DEVICE_SPACING
|
|
const innerH = (rows - 1) * DEVICE_SPACING
|
|
const cx = curX + SITE_PADDING + innerW / 2
|
|
const cy = curY + SITE_PADDING + innerH / 2
|
|
|
|
deviceIds.forEach((id: string, i: number) => {
|
|
const dc = i % cols
|
|
const dr = Math.floor(i / cols)
|
|
positions[id] = {
|
|
x: cx + dc * DEVICE_SPACING - innerW / 2,
|
|
y: cy + dr * DEVICE_SPACING - innerH / 2
|
|
}
|
|
})
|
|
|
|
const totalW = innerW + SITE_PADDING * 2 + DEVICE_SPACING
|
|
const totalH = innerH + SITE_PADDING * 2 + DEVICE_SPACING
|
|
maxH = Math.max(maxH, totalH)
|
|
col++
|
|
|
|
if (col >= SITE_COLS) {
|
|
col = 0
|
|
curX = 0
|
|
curY += maxH + SITE_GAP
|
|
maxH = 0
|
|
} else {
|
|
curX += totalW + SITE_GAP
|
|
}
|
|
})
|
|
|
|
const unY = curY + maxH + SITE_GAP
|
|
unparented.forEach((id: string, i: number) => {
|
|
const uc = i % 6
|
|
const ur = Math.floor(i / 6)
|
|
positions[id] = { x: uc * DEVICE_SPACING, y: unY + ur * DEVICE_SPACING }
|
|
})
|
|
|
|
return positions
|
|
},
|
|
|
|
// --- Filter, Search, Layout methods ---
|
|
|
|
_applyFilter(this: any, filter: string) {
|
|
if (!this.cy) return
|
|
|
|
// Show all first
|
|
this.cy.elements().style('display', 'element')
|
|
|
|
if (filter === 'degraded') {
|
|
// Show only edges with degraded/critical signal health and their connected nodes
|
|
const matchEdges = this.cy.edges().filter((e: any) => {
|
|
const health = e.data('signal_health')
|
|
return health === 'degraded' || health === 'critical'
|
|
})
|
|
const connectedNodes = matchEdges.connectedNodes()
|
|
const parents = connectedNodes.parents()
|
|
const visible = matchEdges.union(connectedNodes).union(parents)
|
|
this.cy.elements().not(visible).style('display', 'none')
|
|
} else if (filter === 'alerts') {
|
|
// Show only nodes with alerts and their connected edges
|
|
const alertNodes = this.cy.nodes().filter((n: any) => {
|
|
return n.data('has_alerts') === true || n.data('status') === 'down'
|
|
})
|
|
const connectedEdges = alertNodes.connectedEdges()
|
|
const parents = alertNodes.parents()
|
|
const visible = alertNodes.union(connectedEdges).union(parents)
|
|
this.cy.elements().not(visible).style('display', 'none')
|
|
}
|
|
// 'all' filter shows everything (already reset above)
|
|
},
|
|
|
|
_searchNodes(this: any, query: string) {
|
|
if (!this.cy) return
|
|
|
|
// Remove previous search highlights
|
|
this.cy.nodes().removeClass('search-match')
|
|
|
|
if (!query || query.trim() === '') return
|
|
|
|
const q = query.toLowerCase()
|
|
const matches = this.cy.nodes().filter((n: any) => {
|
|
const d = n.data()
|
|
return (d.label && d.label.toLowerCase().includes(q)) ||
|
|
(d.ip_address && d.ip_address.toLowerCase().includes(q)) ||
|
|
(d.site_name && d.site_name.toLowerCase().includes(q)) ||
|
|
(d.manufacturer && d.manufacturer.toLowerCase().includes(q))
|
|
})
|
|
|
|
matches.addClass('search-match')
|
|
|
|
// Zoom to fit matches if any found
|
|
if (matches.length > 0) {
|
|
this.cy.fit(matches, 80)
|
|
}
|
|
},
|
|
|
|
_changeLayout(this: any, mode: string, topology: any) {
|
|
if (!this.cy) return
|
|
|
|
if (mode === 'geo') {
|
|
// Geographic layout using lat/lng positions
|
|
const geoNodes = (topology.nodes || []).filter((n: any) => n.latitude != null && n.longitude != null)
|
|
|
|
if (geoNodes.length === 0) return
|
|
|
|
// Find bounds
|
|
const lats = geoNodes.map((n: any) => n.latitude)
|
|
const lngs = geoNodes.map((n: any) => n.longitude)
|
|
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
|
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
|
|
|
const width = this.cy.width() - 100
|
|
const height = this.cy.height() - 100
|
|
|
|
// Map geo coordinates to canvas positions
|
|
const positions: Record<string, {x: number, y: number}> = {}
|
|
const latRange = maxLat - minLat || 1
|
|
const lngRange = maxLng - minLng || 1
|
|
|
|
;(topology.nodes || []).forEach((n: any) => {
|
|
if (n.latitude != null && n.longitude != null) {
|
|
positions[n.id] = {
|
|
x: 50 + ((n.longitude - minLng) / lngRange) * width,
|
|
y: 50 + ((maxLat - n.latitude) / latRange) * height // invert Y for map orientation
|
|
}
|
|
}
|
|
})
|
|
|
|
// Nodes without geo get placed at the bottom
|
|
let offsetX = 0
|
|
;(topology.nodes || []).forEach((n: any) => {
|
|
if (n.type === 'site') return
|
|
if (!positions[n.id]) {
|
|
// If node has a parent site with geo, place near it
|
|
if (n.parent && positions[n.parent]) {
|
|
positions[n.id] = {
|
|
x: positions[n.parent].x + (Math.random() - 0.5) * 60,
|
|
y: positions[n.parent].y + (Math.random() - 0.5) * 60
|
|
}
|
|
} else {
|
|
positions[n.id] = { x: offsetX * 80, y: height + 100 }
|
|
offsetX++
|
|
}
|
|
}
|
|
})
|
|
|
|
this.cy.layout({
|
|
name: 'preset',
|
|
positions: (node: any) => positions[node.id()] || { x: 0, y: 0 },
|
|
fit: true,
|
|
padding: 50,
|
|
animate: true,
|
|
animationDuration: 500
|
|
}).run()
|
|
} else {
|
|
// Force-directed (default preset layout)
|
|
const allNodes = topology.nodes || []
|
|
const newPositions = this._computePresetPositions(allNodes)
|
|
this.cy.layout({
|
|
name: 'preset',
|
|
positions: (node: any) => newPositions[node.id()] || { x: 0, y: 0 },
|
|
fit: true,
|
|
padding: 50,
|
|
animate: true,
|
|
animationDuration: 500
|
|
}).run()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// MikroTik Port Sync - automatically switches port when SSL checkbox is toggled
|
|
const MikrotikPortSync = {
|
|
handleSslChange: null as ((e: Event) => void) | null,
|
|
sslCheckbox: null as HTMLInputElement | null,
|
|
|
|
setupListener(this: any) {
|
|
// Clean up existing listener first
|
|
if (this.sslCheckbox && this.handleSslChange) {
|
|
this.sslCheckbox.removeEventListener('change', this.handleSslChange)
|
|
}
|
|
|
|
const sslCheckbox = this.el.querySelector('[name="device[mikrotik_use_ssl]"]') as HTMLInputElement
|
|
const portInput = this.el.querySelector('[name="device[mikrotik_port]"]') as HTMLInputElement
|
|
|
|
if (!sslCheckbox || !portInput) return
|
|
|
|
this.sslCheckbox = sslCheckbox
|
|
|
|
this.handleSslChange = () => {
|
|
const isChecked = sslCheckbox.checked
|
|
const currentPort = portInput.value.trim()
|
|
|
|
// Only update port if it's empty or set to one of the default values
|
|
if (currentPort === '' || currentPort === '8729' || currentPort === '8728') {
|
|
portInput.value = isChecked ? '8729' : '8728'
|
|
// Trigger a change event so LiveView validates the new value
|
|
portInput.dispatchEvent(new Event('input', { bubbles: true }))
|
|
}
|
|
}
|
|
|
|
sslCheckbox.addEventListener('change', this.handleSslChange)
|
|
},
|
|
|
|
mounted(this: any) {
|
|
this.setupListener()
|
|
},
|
|
|
|
updated(this: any) {
|
|
// Re-attach listener when LiveView updates the DOM (e.g., when mikrotik_enabled changes)
|
|
this.setupListener()
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
if (this.sslCheckbox && this.handleSslChange) {
|
|
this.sslCheckbox.removeEventListener('change', this.handleSslChange)
|
|
this.handleSslChange = null
|
|
this.sslCheckbox = null
|
|
}
|
|
}
|
|
}
|
|
|
|
// Simple Leaflet map hook for showing a single marker (site detail page)
|
|
const LeafletMap = {
|
|
map: null as any,
|
|
mounted(this: any) {
|
|
const lat = parseFloat(this.el.dataset.lat)
|
|
const lng = parseFloat(this.el.dataset.lng)
|
|
const zoom = parseInt(this.el.dataset.zoom || '14')
|
|
const title = this.el.dataset.markerTitle || ''
|
|
if (isNaN(lat) || isNaN(lng)) return
|
|
|
|
const initMap = () => {
|
|
if (typeof L === 'undefined') {
|
|
setTimeout(initMap, 100)
|
|
return
|
|
}
|
|
this.map = L.map(this.el, { scrollWheelZoom: false, zoomControl: true })
|
|
this.map.setView([lat, lng], zoom)
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap'
|
|
}).addTo(this.map)
|
|
L.marker([lat, lng]).addTo(this.map).bindPopup(title)
|
|
setTimeout(() => this.map.invalidateSize(), 200)
|
|
}
|
|
initMap()
|
|
},
|
|
destroyed(this: any) {
|
|
if (this.map) { this.map.remove(); this.map = null }
|
|
}
|
|
}
|
|
|
|
// Sites Map hook for Leaflet.js geographic visualization
|
|
const SitesMap = {
|
|
map: null as any,
|
|
markers: null as any,
|
|
|
|
mounted(this: any) {
|
|
// Wait for Leaflet to load
|
|
if (typeof L === 'undefined') {
|
|
setTimeout(() => this.mounted(), 100)
|
|
return
|
|
}
|
|
|
|
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
|
|
this.initializeLeaflet(sitesData)
|
|
|
|
// Listen for site clicks from the map
|
|
this.handleEvent("site_clicked", (payload: any) => {
|
|
// Push event back to LiveView
|
|
this.pushEvent("site_clicked", payload)
|
|
})
|
|
},
|
|
|
|
updated(this: any) {
|
|
if (this.map) {
|
|
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
|
|
this.updateSites(sitesData)
|
|
}
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
if (this.map) {
|
|
this.map.remove()
|
|
this.map = null
|
|
this.markers = null
|
|
}
|
|
},
|
|
|
|
initializeLeaflet(this: any, sites: any[]) {
|
|
// Initialize the map
|
|
this.map = L.map(this.el, {
|
|
zoomControl: true,
|
|
scrollWheelZoom: true
|
|
})
|
|
|
|
// Add OpenStreetMap tiles
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 18
|
|
}).addTo(this.map)
|
|
|
|
this.updateSites(sites)
|
|
},
|
|
|
|
updateSites(this: any, sites: any[]) {
|
|
if (!this.map) return
|
|
|
|
// Clear existing markers
|
|
if (this.markers) {
|
|
this.map.removeLayer(this.markers)
|
|
}
|
|
|
|
// Create marker cluster group
|
|
this.markers = L.layerGroup()
|
|
|
|
if (sites.length === 0) {
|
|
// Default view if no sites
|
|
this.map.setView([39.8283, -98.5795], 4) // Center on USA
|
|
this.markers.addTo(this.map)
|
|
return
|
|
}
|
|
|
|
// Add markers for each site
|
|
const bounds = L.latLngBounds([])
|
|
|
|
sites.forEach((site: any) => {
|
|
if (site.latitude && site.longitude) {
|
|
const marker = L.marker([site.latitude, site.longitude])
|
|
|
|
// Create popup content
|
|
let popupContent = `<div class="p-2">
|
|
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>`
|
|
|
|
if (site.description) {
|
|
popupContent += `<p class="text-sm text-gray-600 mb-1">${site.description}</p>`
|
|
}
|
|
|
|
if (site.address) {
|
|
popupContent += `<p class="text-xs text-gray-500 mb-1">${site.address}</p>`
|
|
}
|
|
|
|
popupContent += `<p class="text-xs text-gray-500 mb-2">
|
|
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}
|
|
</p>`
|
|
|
|
if (site.device_count > 0) {
|
|
popupContent += `<p class="text-xs text-blue-600 mb-2">
|
|
${site.device_count} device${site.device_count !== 1 ? 's' : ''}
|
|
</p>`
|
|
}
|
|
|
|
popupContent += `<button
|
|
data-site-id="${site.id}"
|
|
data-action="view-site"
|
|
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
|
|
>
|
|
View Site →
|
|
</button></div>`
|
|
|
|
// Bind popup and attach event listener after marker is added to map
|
|
marker.bindPopup(popupContent)
|
|
marker.on('popupopen', () => {
|
|
const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement
|
|
if (button) {
|
|
button.addEventListener('click', () => {
|
|
this.pushEvent("site_clicked", { site_id: button.dataset.siteId })
|
|
})
|
|
}
|
|
})
|
|
this.markers.addLayer(marker)
|
|
bounds.extend([site.latitude, site.longitude])
|
|
}
|
|
})
|
|
|
|
// Add markers to map
|
|
this.markers.addTo(this.map)
|
|
|
|
// Fit map to show all markers
|
|
if (bounds.isValid()) {
|
|
this.map.fitBounds(bounds, { padding: [20, 20] })
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
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 <html> (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<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)
|
|
})
|
|
},
|
|
}
|
|
|
|
// WeathermapViewer hook - extends NetworkMap with utilization-based edge coloring
|
|
const WeathermapViewer = {
|
|
cy: null as any,
|
|
_topologyData: null as any,
|
|
_zoomInHandler: null as ((e: Event) => void) | null,
|
|
_zoomOutHandler: null as ((e: Event) => void) | null,
|
|
_fitHandler: null as ((e: Event) => void) | null,
|
|
|
|
mounted(this: any) {
|
|
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
|
|
this._topologyData = topologyData
|
|
this.initializeCytoscape(topologyData)
|
|
|
|
// Listen for weathermap updates from LiveView
|
|
this.handleEvent("update_weathermap", (payload: any) => {
|
|
if (this.cy) {
|
|
this._topologyData = payload.topology
|
|
this.updateTopology(payload.topology)
|
|
}
|
|
})
|
|
|
|
// Filter handler
|
|
this.handleEvent("apply_filter", (payload: any) => {
|
|
if (!this.cy) return
|
|
this._applyFilter(payload.filter)
|
|
})
|
|
|
|
// Search handler
|
|
this.handleEvent("search_nodes", (payload: any) => {
|
|
if (!this.cy) return
|
|
this._searchNodes(payload.query)
|
|
})
|
|
|
|
// Layout toggle handler
|
|
this.handleEvent("change_layout", (payload: any) => {
|
|
if (!this.cy || !this._topologyData) return
|
|
this._changeLayout(payload.mode, this._topologyData)
|
|
})
|
|
},
|
|
|
|
updated(this: any) {
|
|
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
|
|
this.updateTopology(topologyData)
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
// Remove DOM event listeners
|
|
const zoomIn = document.getElementById('cy-zoom-in')
|
|
const zoomOut = document.getElementById('cy-zoom-out')
|
|
const fit = document.getElementById('cy-fit')
|
|
|
|
if (zoomIn && this._zoomInHandler) {
|
|
zoomIn.removeEventListener('click', this._zoomInHandler)
|
|
}
|
|
if (zoomOut && this._zoomOutHandler) {
|
|
zoomOut.removeEventListener('click', this._zoomOutHandler)
|
|
}
|
|
if (fit && this._fitHandler) {
|
|
fit.removeEventListener('click', this._fitHandler)
|
|
}
|
|
|
|
this._zoomInHandler = null
|
|
this._zoomOutHandler = null
|
|
this._fitHandler = null
|
|
|
|
if (this.cy) {
|
|
try {
|
|
this.cy.stop()
|
|
} catch (_e) { /* ignore */ }
|
|
this.cy.destroy()
|
|
this.cy = null
|
|
}
|
|
},
|
|
|
|
initializeCytoscape(this: any, topology: any) {
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
|
|
|
// Role-based node colors
|
|
const roleColors: Record<string, string> = {
|
|
router: '#3B82F6', // blue
|
|
switch: '#10B981', // green
|
|
wireless: '#8B5CF6', // purple
|
|
firewall: '#EF4444', // red
|
|
server: '#F59E0B', // amber
|
|
unknown: '#9CA3AF' // light gray
|
|
}
|
|
|
|
// Role-based node shapes
|
|
const roleShapes: Record<string, string> = {
|
|
router: 'round-rectangle',
|
|
switch: 'diamond',
|
|
wireless: 'triangle',
|
|
firewall: 'hexagon',
|
|
server: 'rectangle',
|
|
unknown: 'ellipse'
|
|
}
|
|
|
|
// Utilization level colors for weathermap edges
|
|
const utilizationColors: Record<string, string> = {
|
|
low: '#10B981', // green (0-30%)
|
|
medium: '#F59E0B', // yellow (30-60%)
|
|
high: '#F97316', // orange (60-80%)
|
|
critical: '#EF4444' // red (80%+)
|
|
}
|
|
|
|
// Edge styling based on utilization level
|
|
const getWeathermapEdgeStyle = (edge: any) => {
|
|
const utilizationLevel = edge.utilization_level
|
|
const confidence = edge.confidence || 0.5
|
|
|
|
if (utilizationLevel && utilizationColors[utilizationLevel]) {
|
|
return {
|
|
color: utilizationColors[utilizationLevel],
|
|
style: 'solid' as const,
|
|
dashPattern: []
|
|
}
|
|
}
|
|
|
|
// Fallback to confidence-based styling if no utilization data
|
|
if (confidence > 0.9) {
|
|
return { color: '#9CA3AF', style: 'solid' as const, dashPattern: [] }
|
|
} else if (confidence >= 0.5) {
|
|
return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [6, 3] }
|
|
} else {
|
|
return { color: '#D1D5DB', style: 'dashed' as const, dashPattern: [2, 3] }
|
|
}
|
|
}
|
|
|
|
// Use simplified position computation
|
|
const computeSimplePositions = (nodes: any[]): Record<string, {x: number, y: number}> => {
|
|
const positions: Record<string, {x: number, y: number}> = {}
|
|
const SPACING = 120
|
|
const cols = Math.ceil(Math.sqrt(nodes.filter(n => n.type !== 'site').length))
|
|
|
|
let x = 0, y = 0, col = 0
|
|
|
|
nodes.forEach((node, i) => {
|
|
if (node.type === 'site') return
|
|
|
|
positions[node.id] = { x: x * SPACING, y: y * SPACING }
|
|
|
|
col++
|
|
if (col >= cols) {
|
|
col = 0
|
|
x = 0
|
|
y++
|
|
} else {
|
|
x++
|
|
}
|
|
})
|
|
|
|
return positions
|
|
}
|
|
|
|
const presetPositions = computeSimplePositions(topology.nodes || [])
|
|
|
|
// Build Cytoscape elements
|
|
const elements: any[] = []
|
|
|
|
// Add nodes (skip site nodes for now in simplified version)
|
|
topology.nodes?.forEach((node: any) => {
|
|
if (node.type === 'site') return // Skip site nodes in simplified version
|
|
|
|
const data: any = {
|
|
id: node.id,
|
|
label: node.label,
|
|
type: node.type,
|
|
status: node.status,
|
|
discovered: node.discovered,
|
|
ip_address: node.ip_address,
|
|
site_name: node.site_name,
|
|
manufacturer: node.manufacturer,
|
|
client_count: node.client_count || 0,
|
|
signal_health: node.signal_health,
|
|
has_alerts: node.has_alerts || false,
|
|
device_role: node.device_role
|
|
}
|
|
|
|
elements.push({
|
|
data: data,
|
|
classes: node.discovered ? 'discovered' : node.status
|
|
})
|
|
})
|
|
|
|
// Add edges with weathermap-specific data
|
|
topology.edges?.forEach((edge: any) => {
|
|
// Build edge label with throughput/capacity info
|
|
let label = ''
|
|
if (edge.utilization_text) {
|
|
label = edge.utilization_text
|
|
} else if (edge.source_interface && edge.target_interface) {
|
|
label = `${edge.source_interface} ↔ ${edge.target_interface}`
|
|
}
|
|
|
|
elements.push({
|
|
data: {
|
|
id: edge.id,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
source_interface: edge.source_interface,
|
|
target_interface: edge.target_interface,
|
|
link_type: edge.link_type,
|
|
confidence: edge.confidence ?? 0.5,
|
|
if_speed: edge.if_speed,
|
|
utilization_level: edge.utilization_level,
|
|
utilization_pct: edge.utilization_pct,
|
|
utilization_text: edge.utilization_text,
|
|
throughput_bps: edge.throughput_bps,
|
|
capacity_bps: edge.capacity_bps,
|
|
signal_health: edge.signal_health,
|
|
snr: edge.snr,
|
|
label: label
|
|
}
|
|
})
|
|
})
|
|
|
|
// Map interface speed to edge width
|
|
const getEdgeWidth = (speed: number | null | undefined): number => {
|
|
if (!speed) return 2
|
|
if (speed >= 10_000_000_000) return 6 // 10Gbps+
|
|
if (speed >= 1_000_000_000) return 4 // 1Gbps
|
|
if (speed >= 100_000_000) return 3 // 100Mbps
|
|
return 2 // < 100Mbps or unknown
|
|
}
|
|
|
|
// Initialize Cytoscape
|
|
this.cy = cytoscape({
|
|
container: this.el,
|
|
elements: elements,
|
|
style: [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'background-color': function(ele: any) {
|
|
return roleColors[ele.data('type')] || roleColors.unknown
|
|
},
|
|
'shape': function(ele: any) {
|
|
return roleShapes[ele.data('type')] || roleShapes.unknown
|
|
},
|
|
'label': 'data(label)',
|
|
'color': isDark ? '#fff' : '#000',
|
|
'text-valign': 'bottom',
|
|
'text-halign': 'center',
|
|
'text-margin-y': 6,
|
|
'font-size': '13px',
|
|
'font-weight': '500',
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': '140px',
|
|
'width': 44,
|
|
'height': 44,
|
|
'border-width': 2,
|
|
'border-color': function(ele: any) {
|
|
const status = ele.data('status')
|
|
if (status === 'down') return '#dc2626'
|
|
if (status === 'unknown') return '#ca8a04'
|
|
const type = ele.data('type')
|
|
const darkerColors: Record<string, string> = {
|
|
router: '#2563EB',
|
|
switch: '#059669',
|
|
wireless: '#7C3AED',
|
|
firewall: '#DC2626',
|
|
server: '#D97706',
|
|
unknown: '#6B7280'
|
|
}
|
|
return darkerColors[type] || darkerColors.unknown
|
|
}
|
|
} as any
|
|
},
|
|
{
|
|
selector: 'node[status = "down"]',
|
|
style: {
|
|
'border-width': 3,
|
|
'border-color': '#dc2626',
|
|
'background-blacken': -0.3
|
|
}
|
|
},
|
|
{
|
|
selector: 'node.discovered',
|
|
style: {
|
|
'border-style': 'dashed',
|
|
'border-width': 2,
|
|
'border-color': '#9ca3af',
|
|
'opacity': 0.6,
|
|
'width': 34,
|
|
'height': 34,
|
|
'font-size': '11px'
|
|
}
|
|
},
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': function(ele: any) {
|
|
return getEdgeWidth(ele.data('if_speed'))
|
|
},
|
|
'line-color': function(ele: any) {
|
|
return getWeathermapEdgeStyle(ele.data()).color
|
|
},
|
|
'line-style': function(ele: any) {
|
|
return getWeathermapEdgeStyle(ele.data()).style
|
|
},
|
|
'line-dash-pattern': function(ele: any) {
|
|
const pattern = getWeathermapEdgeStyle(ele.data()).dashPattern
|
|
return pattern.length > 0 ? pattern : [1, 0]
|
|
},
|
|
'curve-style': 'bezier',
|
|
'label': 'data(label)',
|
|
'font-size': '9px',
|
|
'color': isDark ? '#d1d5db' : '#4b5563',
|
|
'text-background-color': isDark ? '#1f2937' : '#ffffff',
|
|
'text-background-opacity': 0.9,
|
|
'text-background-padding': '3px',
|
|
'text-rotation': 'autorotate',
|
|
'text-margin-y': -10
|
|
} as any
|
|
}
|
|
],
|
|
layout: {
|
|
name: 'preset',
|
|
positions: (node: any) => presetPositions[node.id()] || { x: 0, y: 0 },
|
|
fit: true,
|
|
padding: 50
|
|
},
|
|
minZoom: 0.2,
|
|
maxZoom: 3,
|
|
wheelSensitivity: 1
|
|
})
|
|
|
|
// Add click handler for nodes
|
|
this.cy.on('tap', 'node', (evt: any) => {
|
|
const node = evt.target
|
|
const nodeData = node.data()
|
|
|
|
this.pushEvent("node_clicked", {
|
|
node_id: nodeData.id,
|
|
node_type: nodeData.discovered ? 'discovered' : 'managed'
|
|
})
|
|
})
|
|
|
|
// Handle highlight and clear highlight events
|
|
this.handleEvent("highlight_node", (payload: any) => {
|
|
if (!this.cy) return
|
|
this.cy.nodes().removeClass('highlighted')
|
|
const node = this.cy.getElementById(payload.node_id)
|
|
if (node.length > 0) {
|
|
node.addClass('highlighted')
|
|
}
|
|
})
|
|
|
|
this.handleEvent("clear_highlight", () => {
|
|
if (!this.cy) return
|
|
this.cy.nodes().removeClass('highlighted')
|
|
})
|
|
|
|
// Enhanced hover tooltips with utilization info
|
|
this.cy.on('mouseover', 'edge', (evt: any) => {
|
|
const edge = evt.target
|
|
const data = edge.data()
|
|
|
|
const srcNode = this.cy.getElementById(data.source)
|
|
const tgtNode = this.cy.getElementById(data.target)
|
|
const srcName = srcNode.data('label') || data.source
|
|
const tgtName = tgtNode.data('label') || data.target
|
|
|
|
let tooltip = `${srcName} ↔ ${tgtName}`
|
|
|
|
if (data.utilization_text) {
|
|
tooltip += `\n🚦 ${data.utilization_text}`
|
|
if (data.utilization_pct != null) {
|
|
tooltip += ` (${data.utilization_pct}%)`
|
|
}
|
|
}
|
|
|
|
edge.style({
|
|
'line-color': '#fbbf24',
|
|
'width': Math.max(getEdgeWidth(data.if_speed), 4),
|
|
'label': tooltip
|
|
})
|
|
})
|
|
|
|
this.cy.on('mouseout', 'edge', (evt: any) => {
|
|
const edge = evt.target
|
|
const data = edge.data()
|
|
const edgeStyle = getWeathermapEdgeStyle(data)
|
|
edge.style({
|
|
'line-color': edgeStyle.color,
|
|
'width': getEdgeWidth(data.if_speed),
|
|
'label': data.label || ''
|
|
})
|
|
})
|
|
|
|
this._initControls()
|
|
},
|
|
|
|
_initControls(this: any) {
|
|
const ZOOM_STEP = 0.25
|
|
|
|
const zoomIn = document.getElementById('cy-zoom-in')
|
|
const zoomOut = document.getElementById('cy-zoom-out')
|
|
const fit = document.getElementById('cy-fit')
|
|
|
|
if (zoomIn) {
|
|
this._zoomInHandler = () => {
|
|
if (!this.cy) return
|
|
this.cy.zoom({
|
|
level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()),
|
|
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
|
|
})
|
|
}
|
|
zoomIn.addEventListener('click', this._zoomInHandler)
|
|
}
|
|
|
|
if (zoomOut) {
|
|
this._zoomOutHandler = () => {
|
|
if (!this.cy) return
|
|
this.cy.zoom({
|
|
level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()),
|
|
renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }
|
|
})
|
|
}
|
|
zoomOut.addEventListener('click', this._zoomOutHandler)
|
|
}
|
|
|
|
if (fit) {
|
|
this._fitHandler = () => {
|
|
if (!this.cy) return
|
|
this.cy.fit(undefined, 50)
|
|
}
|
|
fit.addEventListener('click', this._fitHandler)
|
|
}
|
|
},
|
|
|
|
updateTopology(this: any, topology: any) {
|
|
if (!this.cy) return
|
|
|
|
// For now, just recreate the entire topology (can be optimized later)
|
|
this.cy.destroy()
|
|
this.initializeCytoscape(topology)
|
|
},
|
|
|
|
_applyFilter(this: any, filter: string) {
|
|
if (!this.cy) return
|
|
|
|
// Show all first
|
|
this.cy.elements().style('display', 'element')
|
|
|
|
if (filter === 'high_util') {
|
|
// Show only high and critical utilization edges and their connected nodes
|
|
const matchEdges = this.cy.edges().filter((e: any) => {
|
|
const level = e.data('utilization_level')
|
|
return level === 'high' || level === 'critical'
|
|
})
|
|
const connectedNodes = matchEdges.connectedNodes()
|
|
const visible = matchEdges.union(connectedNodes)
|
|
this.cy.elements().not(visible).style('display', 'none')
|
|
} else if (filter === 'critical') {
|
|
// Show only critical utilization edges and their connected nodes
|
|
const matchEdges = this.cy.edges().filter((e: any) => {
|
|
const level = e.data('utilization_level')
|
|
return level === 'critical'
|
|
})
|
|
const connectedNodes = matchEdges.connectedNodes()
|
|
const visible = matchEdges.union(connectedNodes)
|
|
this.cy.elements().not(visible).style('display', 'none')
|
|
}
|
|
},
|
|
|
|
_searchNodes(this: any, query: string) {
|
|
if (!this.cy) return
|
|
|
|
this.cy.nodes().removeClass('search-match')
|
|
|
|
if (!query || query.trim() === '') return
|
|
|
|
const q = query.toLowerCase()
|
|
const matches = this.cy.nodes().filter((n: any) => {
|
|
const d = n.data()
|
|
return (d.label && d.label.toLowerCase().includes(q)) ||
|
|
(d.ip_address && d.ip_address.toLowerCase().includes(q)) ||
|
|
(d.site_name && d.site_name.toLowerCase().includes(q)) ||
|
|
(d.manufacturer && d.manufacturer.toLowerCase().includes(q))
|
|
})
|
|
|
|
matches.addClass('search-match')
|
|
|
|
if (matches.length > 0) {
|
|
this.cy.fit(matches, 80)
|
|
}
|
|
},
|
|
|
|
_changeLayout(this: any, mode: string, topology: any) {
|
|
// Simplified layout for now
|
|
if (!this.cy) return
|
|
|
|
this.cy.layout({
|
|
name: 'circle',
|
|
animate: true,
|
|
animationDuration: 500
|
|
}).run()
|
|
}
|
|
}
|
|
|
|
const liveSocket = new LiveSocket("/live", Socket, {
|
|
longPollFallbackMs: 5000,
|
|
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
|
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, 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) => {
|
|
// 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)
|
|
}
|
|
})
|
|
|
|
// 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";
|
|
}
|
|
}
|
|
]
|
|
});
|
|
}
|