feat: Add network weathermap with real-time utilization visualization (#172)
- Create WeathermapLive module at /weathermap with 60s auto-refresh - Extend Topology.get_topology_for_weathermap with bandwidth utilization data - Add WeathermapViewer JavaScript hook with color-coded edges: * Green (0-30%) → Yellow (30-60%) → Orange (60-80%) → Red (80%+) - Display throughput/capacity labels on edges (e.g., '847 Mbps / 1 Gbps') - Add utilization stats dashboard with link count by usage level - Support full-screen mode for NOC displays - Integrate with existing dark mode and responsive design - Add navigation links in sidebar and mobile menu Technical implementation: - Leverages existing Capacity module for interface utilization calculations - Extends topology edges with utilization_level, utilization_pct, throughput_bps - Builds on NetworkMapLive foundation with enhanced edge styling - Auto-refresh via Phoenix PubSub and 60s timer - Filter support for high/critical utilization links Reviewed-on: graham/towerops-web#172
This commit is contained in:
parent
f442c59ad0
commit
a97340873f
6 changed files with 1547 additions and 1 deletions
478
assets/js/app.ts
478
assets/js/app.ts
|
|
@ -1825,10 +1825,486 @@ const ThemeSelector = {
|
|||
},
|
||||
}
|
||||
|
||||
// WeathermapViewer hook - extends NetworkMap with utilization-based edge coloring
|
||||
const WeathermapViewer = {
|
||||
cy: null as any,
|
||||
_topologyData: null as any,
|
||||
|
||||
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) {
|
||||
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) {
|
||||
zoomIn.addEventListener('click', () => {
|
||||
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 }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (zoomOut) {
|
||||
zoomOut.addEventListener('click', () => {
|
||||
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 }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (fit) {
|
||||
fit.addEventListener('click', () => {
|
||||
if (!this.cy) return
|
||||
this.cy.fit(undefined, 50)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
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, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ defmodule Towerops.Topology do
|
|||
alias Towerops.Topology.DeviceNeighbor
|
||||
alias Towerops.Topology.Identifier
|
||||
alias Towerops.Topology.Lldp
|
||||
alias Towerops.Capacity
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -491,6 +492,94 @@ defmodule Towerops.Topology do
|
|||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Build topology data for the network weathermap with edge utilization info.
|
||||
Returns %{nodes, edges, stats, last_updated} with utilization data.
|
||||
Tab "added" = managed devices only. Tab "all" = managed + discovered.
|
||||
"""
|
||||
def get_topology_for_weathermap(organization_id, tab \\ "added") do
|
||||
devices = list_managed_devices(organization_id)
|
||||
device_ids = Enum.map(devices, & &1.id)
|
||||
links = list_links_for_devices(device_ids)
|
||||
|
||||
# Compute wireless stats (client counts + signal health) per device
|
||||
wireless_stats = compute_wireless_stats(device_ids)
|
||||
|
||||
# Compute RF signal health per link for edge enrichment
|
||||
rf_link_stats = compute_rf_link_stats(device_ids)
|
||||
|
||||
managed_nodes = Enum.map(devices, &device_to_node(&1, wireless_stats))
|
||||
discovered_nodes = build_discovered_nodes(links, tab)
|
||||
|
||||
# Build site compound nodes from devices that have sites
|
||||
site_nodes =
|
||||
devices
|
||||
|> Enum.filter(& &1.site)
|
||||
|> Enum.uniq_by(& &1.site_id)
|
||||
|> Enum.map(fn device ->
|
||||
%{
|
||||
id: "site_#{device.site_id}",
|
||||
label: device.site.name,
|
||||
type: :site,
|
||||
device_role: nil,
|
||||
status: nil,
|
||||
site_id: device.site_id,
|
||||
site_name: device.site.name,
|
||||
ip_address: nil,
|
||||
discovered: false,
|
||||
manufacturer: nil,
|
||||
parent: nil,
|
||||
latitude: device.site.latitude,
|
||||
longitude: device.site.longitude
|
||||
}
|
||||
end)
|
||||
|
||||
all_nodes = site_nodes ++ managed_nodes ++ discovered_nodes
|
||||
node_ids = MapSet.new(Enum.map(all_nodes, & &1.id))
|
||||
|
||||
# Build edges with utilization data
|
||||
edges =
|
||||
links
|
||||
|> build_edges_from_links(node_ids)
|
||||
|> merge_bidirectional_edges()
|
||||
|> enrich_edges_with_rf(rf_link_stats)
|
||||
|> enrich_edges_with_utilization(device_ids)
|
||||
|
||||
# Compute utilization stats for the dashboard
|
||||
utilization_stats = compute_utilization_stats(edges)
|
||||
|
||||
# Compute whether geographic layout is available
|
||||
has_geo =
|
||||
Enum.any?(all_nodes, fn n ->
|
||||
n[:latitude] != nil and n[:longitude] != nil
|
||||
end)
|
||||
|
||||
%{
|
||||
nodes: all_nodes,
|
||||
edges: edges,
|
||||
stats: %{
|
||||
total_devices: length(managed_nodes) + length(discovered_nodes),
|
||||
added_devices: length(managed_nodes),
|
||||
discovered_devices: length(discovered_nodes),
|
||||
total_links: length(edges),
|
||||
low_utilization_links: utilization_stats.low_utilization_links,
|
||||
medium_utilization_links: utilization_stats.medium_utilization_links,
|
||||
high_utilization_links: utilization_stats.high_utilization_links,
|
||||
overutilized_links: utilization_stats.overutilized_links,
|
||||
degraded_links: Enum.count(edges, fn e -> e[:signal_health] == "degraded" or e[:signal_health] == "critical" end),
|
||||
sites_with_alerts:
|
||||
devices
|
||||
|> Enum.filter(&(&1.status == :down))
|
||||
|> Enum.map(& &1.site_id)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|> length()
|
||||
},
|
||||
has_geo: has_geo,
|
||||
last_updated: DateTime.utc_now()
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get detail information for a node (managed or discovered) for the detail panel.
|
||||
|
||||
|
|
@ -782,6 +871,8 @@ defmodule Towerops.Topology do
|
|||
target: target_id,
|
||||
source_interface: if(link.source_interface, do: link.source_interface.if_name),
|
||||
target_interface: if(link.target_interface, do: link.target_interface.if_name),
|
||||
source_interface_id: link.source_interface_id,
|
||||
target_interface_id: if(link.target_interface, do: link.target_interface.id),
|
||||
link_type: link.link_type,
|
||||
confidence: link.confidence,
|
||||
metadata: link.metadata,
|
||||
|
|
@ -1305,4 +1396,163 @@ defmodule Towerops.Topology do
|
|||
priority = %{"good" => 0, "degraded" => 1, "critical" => 2}
|
||||
if (priority[a] || 0) >= (priority[b] || 0), do: a, else: b
|
||||
end
|
||||
|
||||
# Enriches edges with bandwidth utilization data by calculating interface throughput
|
||||
# and comparing it to configured capacity.
|
||||
defp enrich_edges_with_utilization(edges, device_ids) do
|
||||
# Get all interfaces with configured capacity for these devices
|
||||
interface_utilizations = get_interface_utilizations(device_ids)
|
||||
|
||||
Enum.map(edges, fn edge ->
|
||||
# Try to find utilization data for the source or target interface
|
||||
source_util = get_edge_utilization(edge, :source, interface_utilizations)
|
||||
target_util = get_edge_utilization(edge, :target, interface_utilizations)
|
||||
|
||||
# Use the higher utilization of the two interfaces
|
||||
utilization = combine_interface_utilizations(source_util, target_util)
|
||||
|
||||
Map.merge(edge, utilization)
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_interface_utilizations(device_ids) do
|
||||
# Query interfaces with capacity configuration and recent stats
|
||||
Repo.all(
|
||||
from i in Interface,
|
||||
join: sd in SnmpDevice, on: i.snmp_device_id == sd.id,
|
||||
join: d in Device, on: sd.device_id == d.id,
|
||||
where: d.id in ^device_ids and not is_nil(i.configured_capacity_bps),
|
||||
select: %{
|
||||
interface_id: i.id,
|
||||
device_id: d.id,
|
||||
interface_name: i.if_name,
|
||||
capacity_bps: i.configured_capacity_bps,
|
||||
if_index: i.if_index
|
||||
}
|
||||
)
|
||||
|> Enum.map(fn interface ->
|
||||
# Calculate current utilization using the Capacity module
|
||||
interface_struct = %Interface{
|
||||
id: interface.interface_id,
|
||||
configured_capacity_bps: interface.capacity_bps
|
||||
}
|
||||
|
||||
utilization = Capacity.get_utilization(interface_struct)
|
||||
|
||||
Map.merge(interface, %{
|
||||
utilization_data: utilization
|
||||
})
|
||||
end)
|
||||
|> Map.new(fn interface -> {interface.interface_id, interface} end)
|
||||
end
|
||||
|
||||
defp get_edge_utilization(edge, direction, interface_utilizations) do
|
||||
interface_id =
|
||||
case direction do
|
||||
:source -> edge[:source_interface_id]
|
||||
:target -> edge[:target_interface_id]
|
||||
end
|
||||
|
||||
case interface_id do
|
||||
nil -> nil
|
||||
id -> Map.get(interface_utilizations, id)
|
||||
end
|
||||
end
|
||||
|
||||
defp combine_interface_utilizations(source_util, target_util) do
|
||||
case {source_util, target_util} do
|
||||
{nil, nil} ->
|
||||
%{
|
||||
utilization_pct: nil,
|
||||
utilization_level: nil,
|
||||
throughput_bps: nil,
|
||||
capacity_bps: nil,
|
||||
utilization_text: nil
|
||||
}
|
||||
|
||||
{util, nil} ->
|
||||
process_single_utilization(util)
|
||||
|
||||
{nil, util} ->
|
||||
process_single_utilization(util)
|
||||
|
||||
{source, target} ->
|
||||
# Use the higher utilization of the two interfaces
|
||||
source_data = process_single_utilization(source)
|
||||
target_data = process_single_utilization(target)
|
||||
|
||||
if (source_data.utilization_pct || 0) >= (target_data.utilization_pct || 0) do
|
||||
source_data
|
||||
else
|
||||
target_data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp process_single_utilization(util_data) do
|
||||
case util_data.utilization_data do
|
||||
nil ->
|
||||
%{
|
||||
utilization_pct: nil,
|
||||
utilization_level: nil,
|
||||
throughput_bps: nil,
|
||||
capacity_bps: util_data.capacity_bps,
|
||||
utilization_text: nil
|
||||
}
|
||||
|
||||
util ->
|
||||
utilization_pct = Float.round(util.utilization_pct, 1)
|
||||
level = classify_utilization_level(utilization_pct)
|
||||
|
||||
%{
|
||||
utilization_pct: utilization_pct,
|
||||
utilization_level: level,
|
||||
throughput_bps: util.throughput.max_bps,
|
||||
capacity_bps: util_data.capacity_bps,
|
||||
utilization_text: format_utilization_text(util.throughput.max_bps, util_data.capacity_bps),
|
||||
interface_name: util_data.interface_name
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp classify_utilization_level(nil), do: nil
|
||||
defp classify_utilization_level(pct) when pct < 30, do: :low
|
||||
defp classify_utilization_level(pct) when pct < 60, do: :medium
|
||||
defp classify_utilization_level(pct) when pct < 80, do: :high
|
||||
defp classify_utilization_level(_pct), do: :critical
|
||||
|
||||
defp format_utilization_text(throughput_bps, capacity_bps) do
|
||||
throughput_text = format_bps(throughput_bps)
|
||||
capacity_text = format_bps(capacity_bps)
|
||||
"#{throughput_text} / #{capacity_text}"
|
||||
end
|
||||
|
||||
defp format_bps(nil), do: "N/A"
|
||||
defp format_bps(bps) when bps >= 1_000_000_000 do
|
||||
"#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
||||
end
|
||||
defp format_bps(bps) when bps >= 1_000_000 do
|
||||
"#{Float.round(bps / 1_000_000, 0)} Mbps"
|
||||
end
|
||||
defp format_bps(bps) when bps >= 1_000 do
|
||||
"#{Float.round(bps / 1_000, 0)} Kbps"
|
||||
end
|
||||
defp format_bps(bps) do
|
||||
"#{Float.round(bps, 0)} bps"
|
||||
end
|
||||
|
||||
# Compute utilization statistics for the weathermap dashboard.
|
||||
defp compute_utilization_stats(edges) do
|
||||
utilization_counts =
|
||||
edges
|
||||
|> Enum.map(& &1[:utilization_level])
|
||||
|> Enum.frequencies()
|
||||
|
||||
%{
|
||||
low_utilization_links: Map.get(utilization_counts, :low, 0),
|
||||
medium_utilization_links: Map.get(utilization_counts, :medium, 0),
|
||||
high_utilization_links: Map.get(utilization_counts, :high, 0),
|
||||
overutilized_links: Map.get(utilization_counts, :critical, 0)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -382,6 +382,12 @@ defmodule ToweropsWeb.Layouts do
|
|||
icon="hero-map"
|
||||
label={t("Network Map")}
|
||||
/>
|
||||
<.sidebar_link
|
||||
navigate={~p"/weathermap"}
|
||||
active={@active_page == "weathermap"}
|
||||
icon="hero-signal"
|
||||
label={t("Weathermap")}
|
||||
/>
|
||||
|
||||
<!-- RESPOND section -->
|
||||
<p
|
||||
|
|
@ -587,6 +593,9 @@ defmodule ToweropsWeb.Layouts do
|
|||
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
|
||||
<.icon name="hero-map" class="size-5" /> {t("Network Map")}
|
||||
</.mobile_nav_link>
|
||||
<.mobile_nav_link navigate={~p"/weathermap"} active={@active_page == "weathermap"}>
|
||||
<.icon name="hero-signal" class="size-5" /> {t("Weathermap")}
|
||||
</.mobile_nav_link>
|
||||
</div>
|
||||
|
||||
<!-- RESPOND -->
|
||||
|
|
|
|||
195
lib/towerops_web/live/weathermap_live.ex
Normal file
195
lib/towerops_web/live/weathermap_live.ex
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
defmodule ToweropsWeb.WeathermapLive do
|
||||
@moduledoc """
|
||||
Live view for real-time network utilization weathermap.
|
||||
Displays network topology with color-coded edges based on bandwidth utilization.
|
||||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Topology
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
# Subscribe to topology changes and capacity updates if connected
|
||||
_ =
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "topology:#{organization.id}")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "capacity:#{organization.id}")
|
||||
end
|
||||
|
||||
# Default empty topology
|
||||
default_topology = %{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
stats: %{
|
||||
total_devices: 0,
|
||||
added_devices: 0,
|
||||
discovered_devices: 0,
|
||||
total_links: 0,
|
||||
high_utilization_links: 0,
|
||||
overutilized_links: 0
|
||||
},
|
||||
last_updated: DateTime.utc_now()
|
||||
}
|
||||
|
||||
# Schedule auto-refresh every 60 seconds
|
||||
if connected?(socket) do
|
||||
Process.send_after(self(), :refresh_data, 60_000)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, t("Network Weathermap"))
|
||||
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
||||
|> assign(:loading, true)
|
||||
|> assign(:topology, default_topology)
|
||||
|> assign(:active_tab, "added")
|
||||
|> assign(:selected_node_detail, nil)
|
||||
|> assign(:filter, "all")
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:layout_mode, "force")
|
||||
|> assign(:fullscreen, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
tab = Map.get(params, "tab", "added")
|
||||
fullscreen = Map.get(params, "fullscreen") == "true"
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:active_tab, tab)
|
||||
|> assign(:fullscreen, fullscreen)
|
||||
|> load_weathermap_data(organization.id, tab)
|
||||
|
||||
# Handle ?node= param for deep-linking to a selected node
|
||||
socket =
|
||||
case Map.get(params, "node") do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
node_id ->
|
||||
load_node_detail(socket, node_id, organization.id)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("node_clicked", %{"node_id" => node_id} = params, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
node_type = Map.get(params, "node_type", "managed")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> load_node_detail(node_id, organization.id)
|
||||
|> then(fn s ->
|
||||
if s.assigns.selected_node_detail do
|
||||
push_event(s, "highlight_node", %{node_id: node_id, node_type: node_type})
|
||||
else
|
||||
s
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_detail_panel", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_node_detail, nil)
|
||||
|> push_event("clear_highlight", %{})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("set_filter", %{"filter" => filter}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:filter, filter)
|
||||
|> push_event("apply_filter", %{filter: filter})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"query" => query}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:search_query, query)
|
||||
|> push_event("search_nodes", %{query: query})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_layout", %{"mode" => mode}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:layout_mode, mode)
|
||||
|> push_event("change_layout", %{mode: mode})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_fullscreen", _params, socket) do
|
||||
new_fullscreen = not socket.assigns.fullscreen
|
||||
|
||||
path_with_params =
|
||||
if new_fullscreen do
|
||||
~p"/weathermap?#{%{tab: socket.assigns.active_tab, fullscreen: true}}"
|
||||
else
|
||||
~p"/weathermap?#{%{tab: socket.assigns.active_tab}}"
|
||||
end
|
||||
|
||||
{:noreply, push_navigate(socket, to: path_with_params)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:refresh_data, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
socket =
|
||||
load_weathermap_data(socket, organization.id, socket.assigns.active_tab)
|
||||
|
||||
# Schedule next refresh
|
||||
Process.send_after(self(), :refresh_data, 60_000)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:topology_updated, _organization_id}, socket) do
|
||||
# Real-time topology update
|
||||
{:noreply,
|
||||
load_weathermap_data(
|
||||
socket,
|
||||
socket.assigns.current_scope.organization.id,
|
||||
socket.assigns.active_tab
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:capacity_updated, _organization_id}, socket) do
|
||||
# Real-time capacity/utilization update
|
||||
{:noreply,
|
||||
load_weathermap_data(
|
||||
socket,
|
||||
socket.assigns.current_scope.organization.id,
|
||||
socket.assigns.active_tab
|
||||
)}
|
||||
end
|
||||
|
||||
defp load_weathermap_data(socket, organization_id, tab) do
|
||||
topology = Topology.get_topology_for_weathermap(organization_id, tab)
|
||||
|
||||
socket
|
||||
|> assign(:topology, topology)
|
||||
|> assign(:loading, false)
|
||||
|> push_event("update_weathermap", %{topology: topology})
|
||||
end
|
||||
|
||||
defp load_node_detail(socket, node_id, organization_id) do
|
||||
case Topology.get_node_detail(node_id, organization_id) do
|
||||
nil -> assign(socket, :selected_node_detail, nil)
|
||||
detail -> assign(socket, :selected_node_detail, detail)
|
||||
end
|
||||
end
|
||||
end
|
||||
615
lib/towerops_web/live/weathermap_live.html.heex
Normal file
615
lib/towerops_web/live/weathermap_live.html.heex
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
<div class={["h-screen flex flex-col", if(@fullscreen, do: "fixed inset-0 z-50 bg-white dark:bg-gray-900")]}>
|
||||
<%= if not @fullscreen do %>
|
||||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="weathermap"
|
||||
>
|
||||
<.header>
|
||||
<span class="flex items-center gap-2">
|
||||
<.icon name="hero-signal" class="h-6 w-6 text-green-600" />
|
||||
{t("Network Weathermap")}
|
||||
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30">
|
||||
{t("Live")}
|
||||
</span>
|
||||
</span>
|
||||
<:subtitle>{t("Real-time network utilization visualization")}</:subtitle>
|
||||
<:actions>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75">
|
||||
</span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
<span>60s refresh</span>
|
||||
</div>
|
||||
<button
|
||||
phx-click="toggle_fullscreen"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<.icon name="hero-arrows-pointing-out" class="h-4 w-4" />
|
||||
{t("Fullscreen")}
|
||||
</button>
|
||||
</div>
|
||||
</:actions>
|
||||
</.header>
|
||||
</Layouts.authenticated>
|
||||
<% else %>
|
||||
<!-- Fullscreen header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center gap-3">
|
||||
<.icon name="hero-signal" class="h-6 w-6 text-green-600" />
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Network Weathermap</h1>
|
||||
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75">
|
||||
</span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
<span>60s refresh</span>
|
||||
</div>
|
||||
<button
|
||||
phx-click="toggle_fullscreen"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<.icon name="hero-arrows-pointing-in" class="h-4 w-4" />
|
||||
{t("Exit Fullscreen")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-white/10 px-4 bg-white dark:bg-gray-900">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<.link
|
||||
patch={~p"/weathermap?tab=added#{if(@fullscreen, do: "&fullscreen=true", else: "")}"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "added" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
{t("Added Devices")}
|
||||
</.link>
|
||||
|
||||
<.link
|
||||
patch={~p"/weathermap?tab=all#{if(@fullscreen, do: "&fullscreen=true", else: "")}"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "all" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
{t("All Devices")}
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{@topology.stats.discovered_devices}
|
||||
</span>
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<%= if @loading do %>
|
||||
<div class="flex-1 flex items-center justify-center bg-white dark:bg-gray-900">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-arrow-path" class="h-12 w-12 text-gray-400 animate-spin mx-auto" />
|
||||
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading network weathermap...</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Stats Bar -->
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-6 p-4 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<.icon name="hero-server" class="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
{t("Devices")}
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.total_devices}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<.icon name="hero-link" class="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
{t("Links")}
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.total_links}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-5 h-5 rounded bg-green-400"></div>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
{t("Low (0-30%)")}
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.low_utilization_links || 0}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-5 h-5 rounded bg-yellow-400"></div>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
{t("Medium (30-60%)")}
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.medium_utilization_links || 0}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-5 h-5 rounded bg-orange-400"></div>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
{t("High (60-80%)")}
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.high_utilization_links || 0}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-5 h-5 rounded bg-red-400"></div>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
{t("Critical (80%+)")}
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.overutilized_links || 0}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-white/10 p-3">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
phx-click="set_filter"
|
||||
phx-value-filter="all"
|
||||
class={[
|
||||
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
|
||||
if(@filter == "all",
|
||||
do: "bg-blue-100 text-blue-700 dark:bg-blue-400/20 dark:text-blue-400",
|
||||
else: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
)
|
||||
]}
|
||||
>
|
||||
All Links
|
||||
</button>
|
||||
<button
|
||||
phx-click="set_filter"
|
||||
phx-value-filter="high_util"
|
||||
class={[
|
||||
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
|
||||
if(@filter == "high_util",
|
||||
do: "bg-orange-100 text-orange-700 dark:bg-orange-400/20 dark:text-orange-400",
|
||||
else: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
)
|
||||
]}
|
||||
>
|
||||
High Utilization (60%+)
|
||||
<%= if (@topology.stats[:high_utilization_links] || 0) + (@topology.stats[:overutilized_links] || 0) > 0 do %>
|
||||
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-orange-800 bg-orange-200 rounded-full dark:bg-orange-900 dark:text-orange-300">
|
||||
{(@topology.stats[:high_utilization_links] || 0) + (@topology.stats[:overutilized_links] || 0)}
|
||||
</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<button
|
||||
phx-click="set_filter"
|
||||
phx-value-filter="critical"
|
||||
class={[
|
||||
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
|
||||
if(@filter == "critical",
|
||||
do: "bg-red-100 text-red-700 dark:bg-red-400/20 dark:text-red-400",
|
||||
else: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
)
|
||||
]}
|
||||
>
|
||||
Critical (80%+)
|
||||
<%= if (@topology.stats[:overutilized_links] || 0) > 0 do %>
|
||||
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-red-800 bg-red-200 rounded-full dark:bg-red-900 dark:text-red-300">
|
||||
{@topology.stats.overutilized_links}
|
||||
</span>
|
||||
<% end %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<.icon
|
||||
name="hero-magnifying-glass"
|
||||
class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search devices..."
|
||||
phx-keyup="search"
|
||||
phx-key="Enter"
|
||||
phx-debounce="300"
|
||||
value={@search_query}
|
||||
class="pl-8 pr-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 w-48"
|
||||
/>
|
||||
</div>
|
||||
<%= if @topology[:has_geo] do %>
|
||||
<div class="flex items-center gap-1 border-l border-gray-200 dark:border-gray-600 pl-3">
|
||||
<button
|
||||
phx-click="toggle_layout"
|
||||
phx-value-mode="force"
|
||||
class={[
|
||||
"px-2 py-1 text-xs rounded transition-colors",
|
||||
if(@layout_mode == "force",
|
||||
do: "bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white font-medium",
|
||||
else: "text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
)
|
||||
]}
|
||||
title="Force-directed layout"
|
||||
>
|
||||
<.icon name="hero-share" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="toggle_layout"
|
||||
phx-value-mode="geo"
|
||||
class={[
|
||||
"px-2 py-1 text-xs rounded transition-colors",
|
||||
if(@layout_mode == "geo",
|
||||
do: "bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white font-medium",
|
||||
else: "text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
)
|
||||
]}
|
||||
title="Geographic layout"
|
||||
>
|
||||
<.icon name="hero-map" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Weathermap Container -->
|
||||
<div class="flex-1 bg-white dark:bg-gray-800 relative overflow-hidden">
|
||||
<div class="absolute inset-0 border-t border-gray-200 dark:border-white/10">
|
||||
<div
|
||||
id="cy-weathermap-container"
|
||||
phx-hook="WeathermapViewer"
|
||||
class="w-full h-full"
|
||||
data-topology={Jason.encode!(@topology)}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="absolute bottom-4 left-4 flex flex-col gap-1 z-10">
|
||||
<button
|
||||
id="cy-zoom-in"
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
class="flex items-center justify-center w-8 h-8 bg-white dark:bg-gray-700 border border-gray-200 dark:border-white/10 rounded shadow-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
id="cy-zoom-out"
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
class="flex items-center justify-center w-8 h-8 bg-white dark:bg-gray-700 border border-gray-200 dark:border-white/10 rounded shadow-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<.icon name="hero-minus" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
id="cy-fit"
|
||||
type="button"
|
||||
title="Fit to screen"
|
||||
class="flex items-center justify-center w-8 h-8 bg-white dark:bg-gray-700 border border-gray-200 dark:border-white/10 rounded shadow-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<.icon name="hero-arrows-pointing-out" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Utilization Legend -->
|
||||
<div class="absolute bottom-4 right-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-white/10 rounded-lg shadow-lg p-4 z-10">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Link Utilization</h4>
|
||||
<div class="space-y-2 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-1 rounded bg-green-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">0-30% (Good)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-1 rounded bg-yellow-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">30-60% (Moderate)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-1 rounded bg-orange-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">60-80% (High)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-1 rounded bg-red-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">80%+ (Critical)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-1 border border-gray-300 dark:border-gray-600"></div>
|
||||
<span class="text-gray-500 dark:text-gray-400">No capacity data</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-white/10 text-xs text-gray-500 dark:text-gray-400">
|
||||
Last updated: <.timestamp datetime={@topology.last_updated} timezone={@timezone} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Detail Panel (slide-out) -->
|
||||
<%= if @selected_node_detail do %>
|
||||
<div
|
||||
id="node-detail-panel"
|
||||
class="absolute top-0 right-0 h-full w-80 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-white/10 shadow-lg overflow-y-auto transition-transform duration-200"
|
||||
>
|
||||
<div class="p-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Node Details")}
|
||||
</h3>
|
||||
<button
|
||||
phx-click="close_detail_panel"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Device Info -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
{@selected_node_detail.name}
|
||||
</h4>
|
||||
<%= if @selected_node_detail.type == :discovered do %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300 mt-1">
|
||||
{t("Discovered")}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @selected_node_detail.ip_address do %>
|
||||
<div class="flex items-center text-sm">
|
||||
<.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{@selected_node_detail.ip_address}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_node_detail.site_name do %>
|
||||
<div class="flex items-center text-sm">
|
||||
<.icon name="hero-map-pin" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{@selected_node_detail.site_name}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_node_detail.manufacturer do %>
|
||||
<div class="flex items-center text-sm">
|
||||
<.icon
|
||||
name="hero-building-office"
|
||||
class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{@selected_node_detail.manufacturer}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center text-sm">
|
||||
<.icon name="hero-tag" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
|
||||
<span class="text-gray-700 dark:text-gray-300 capitalize">
|
||||
{@selected_node_detail.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<%= if @selected_node_detail.status && @selected_node_detail.status != :unknown do %>
|
||||
<div class="flex items-center text-sm">
|
||||
<span class={[
|
||||
"inline-block h-2 w-2 rounded-full mr-2",
|
||||
if(@selected_node_detail.status == :up,
|
||||
do: "bg-green-400",
|
||||
else: "bg-red-400"
|
||||
)
|
||||
]}>
|
||||
</span>
|
||||
<span class="text-gray-700 dark:text-gray-300 capitalize">
|
||||
{@selected_node_detail.status}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Utilization Stats (for devices with bandwidth data) -->
|
||||
<%= if @selected_node_detail[:utilization_stats] do %>
|
||||
<div class="mt-4 pt-3 border-t border-gray-200 dark:border-white/10">
|
||||
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Interface Utilization
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<%= for stat <- @selected_node_detail.utilization_stats do %>
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-md p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="text-xs font-medium text-gray-900 dark:text-white">
|
||||
{stat.interface_name}
|
||||
</div>
|
||||
<div class={[
|
||||
"text-xs font-semibold",
|
||||
case stat.utilization_level do
|
||||
:low -> "text-green-600 dark:text-green-400"
|
||||
:medium -> "text-yellow-600 dark:text-yellow-400"
|
||||
:high -> "text-orange-600 dark:text-orange-400"
|
||||
:critical -> "text-red-600 dark:text-red-400"
|
||||
_ -> "text-gray-500 dark:text-gray-400"
|
||||
end
|
||||
]}>
|
||||
{stat.utilization_pct}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{stat.throughput_text} / {stat.capacity_text}
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Connections -->
|
||||
<%= if length(@selected_node_detail.connections) > 0 do %>
|
||||
<div class="mt-5 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
{t("Connections")} ({length(@selected_node_detail.connections)})
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<%= for conn <- @selected_node_detail.connections do %>
|
||||
<div class="flex items-center justify-between py-1.5 px-2 rounded bg-gray-50 dark:bg-gray-700/50 text-sm">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-gray-900 dark:text-white truncate font-medium text-xs">
|
||||
{conn.device_name}
|
||||
</p>
|
||||
<%= if conn.interface do %>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{conn.interface}
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if conn[:utilization_text] do %>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
{conn.utilization_text}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-2 flex-shrink-0">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase">
|
||||
{conn.link_type}
|
||||
</span>
|
||||
<span class={[
|
||||
"text-xs font-medium",
|
||||
cond do
|
||||
conn.confidence > 0.9 -> "text-green-600 dark:text-green-400"
|
||||
conn.confidence >= 0.5 -> "text-yellow-600 dark:text-yellow-400"
|
||||
true -> "text-gray-500 dark:text-gray-400"
|
||||
end
|
||||
]}>
|
||||
{trunc(conn.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- View Device Link (managed only) -->
|
||||
<%= if @selected_node_detail.type == :managed do %>
|
||||
<div class="mt-4">
|
||||
<.link
|
||||
navigate={~p"/devices/#{@selected_node_detail.id}"}
|
||||
class="flex items-center justify-center w-full px-3 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 rounded-md hover:bg-blue-100 dark:hover:bg-blue-400/20"
|
||||
>
|
||||
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4 mr-1.5" />
|
||||
{t("View Device")}
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<%= if @topology.stats.total_devices == 0 do %>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center py-16">
|
||||
<.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t("No network data available")}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("Add devices with SNMP enabled and configure interface capacities to view utilization.")}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<.button navigate={~p"/devices/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> Add Your First Device
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -490,6 +490,7 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
# Network map routes
|
||||
live "/network-map", NetworkMapLive, :index
|
||||
live "/weathermap", WeathermapLive, :index
|
||||
live "/sites-map", MapLive.Index, :index
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue