From a97340873fb1409f8e42c2d5d6ce04cea70605f0 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Wed, 25 Mar 2026 16:26:11 -0500 Subject: [PATCH] feat: Add network weathermap with real-time utilization visualization (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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: https://git.mcintire.me/graham/towerops-web/pulls/172 --- assets/js/app.ts | 478 +++++++++++++- lib/towerops/topology.ex | 250 +++++++ lib/towerops_web/components/layouts.ex | 9 + lib/towerops_web/live/weathermap_live.ex | 195 ++++++ .../live/weathermap_live.html.heex | 615 ++++++++++++++++++ lib/towerops_web/router.ex | 1 + 6 files changed, 1547 insertions(+), 1 deletion(-) create mode 100644 lib/towerops_web/live/weathermap_live.ex create mode 100644 lib/towerops_web/live/weathermap_live.html.heex diff --git a/assets/js/app.ts b/assets/js/app.ts index 681c6d77..d7ce5900 100644 --- a/assets/js/app.ts +++ b/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 = { + 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 = { + router: 'round-rectangle', + switch: 'diamond', + wireless: 'triangle', + firewall: 'hexagon', + server: 'rectangle', + unknown: 'ellipse' + } + + // Utilization level colors for weathermap edges + const utilizationColors: Record = { + 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 => { + const positions: Record = {} + 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 = { + 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 diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index eb950100..fdfbf287 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -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 diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index c7bdc49c..842de4c6 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -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")} + />

<.icon name="hero-map" class="size-5" /> {t("Network Map")} + <.mobile_nav_link navigate={~p"/weathermap"} active={@active_page == "weathermap"}> + <.icon name="hero-signal" class="size-5" /> {t("Weathermap")} + diff --git a/lib/towerops_web/live/weathermap_live.ex b/lib/towerops_web/live/weathermap_live.ex new file mode 100644 index 00000000..841b41bb --- /dev/null +++ b/lib/towerops_web/live/weathermap_live.ex @@ -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 \ No newline at end of file diff --git a/lib/towerops_web/live/weathermap_live.html.heex b/lib/towerops_web/live/weathermap_live.html.heex new file mode 100644 index 00000000..5b76576e --- /dev/null +++ b/lib/towerops_web/live/weathermap_live.html.heex @@ -0,0 +1,615 @@ +

+ <%= if not @fullscreen do %> + + <.header> + + <.icon name="hero-signal" class="h-6 w-6 text-green-600" /> + {t("Network Weathermap")} + + {t("Live")} + + + <:subtitle>{t("Real-time network utilization visualization")} + <:actions> +
+
+ + + + + + 60s refresh +
+ +
+ + +
+ <% else %> + +
+
+ <.icon name="hero-signal" class="h-6 w-6 text-green-600" /> +

Network Weathermap

+ + Live + +
+
+
+ + + + + + 60s refresh +
+ +
+
+ <% end %> + + +
+ +
+ + <%= if @loading do %> +
+
+ <.icon name="hero-arrow-path" class="h-12 w-12 text-gray-400 animate-spin mx-auto" /> +

Loading network weathermap...

+
+
+ <% else %> + +
+
+
+
+
+ <.icon name="hero-server" class="h-5 w-5 text-gray-400" /> +
+
+
+
+ {t("Devices")} +
+
+ {@topology.stats.total_devices} +
+
+
+
+
+
+ +
+
+
+
+ <.icon name="hero-link" class="h-5 w-5 text-blue-400" /> +
+
+
+
+ {t("Links")} +
+
+ {@topology.stats.total_links} +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ {t("Low (0-30%)")} +
+
+ {@topology.stats.low_utilization_links || 0} +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ {t("Medium (30-60%)")} +
+
+ {@topology.stats.medium_utilization_links || 0} +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ {t("High (60-80%)")} +
+
+ {@topology.stats.high_utilization_links || 0} +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ {t("Critical (80%+)")} +
+
+ {@topology.stats.overutilized_links || 0} +
+
+
+
+
+
+
+ + +
+
+
+ + + +
+
+
+ <.icon + name="hero-magnifying-glass" + class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" + /> + +
+ <%= if @topology[:has_geo] do %> +
+ + +
+ <% end %> +
+
+
+ + +
+
+
+
+ + +
+ + + +
+ + +
+

Link Utilization

+
+
+
+ 0-30% (Good) +
+
+
+ 30-60% (Moderate) +
+
+
+ 60-80% (High) +
+
+
+ 80%+ (Critical) +
+
+
+ No capacity data +
+
+
+ Last updated: <.timestamp datetime={@topology.last_updated} timezone={@timezone} /> +
+
+ + + <%= if @selected_node_detail do %> +
+
+ +
+

+ {t("Node Details")} +

+ +
+ + +
+
+

+ {@selected_node_detail.name} +

+ <%= if @selected_node_detail.type == :discovered do %> + + {t("Discovered")} + + <% end %> +
+ + <%= if @selected_node_detail.ip_address do %> +
+ <.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" /> + + {@selected_node_detail.ip_address} + +
+ <% end %> + + <%= if @selected_node_detail.site_name do %> +
+ <.icon name="hero-map-pin" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" /> + + {@selected_node_detail.site_name} + +
+ <% end %> + + <%= if @selected_node_detail.manufacturer do %> +
+ <.icon + name="hero-building-office" + class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" + /> + + {@selected_node_detail.manufacturer} + +
+ <% end %> + +
+ <.icon name="hero-tag" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" /> + + {@selected_node_detail.role} + +
+ + <%= if @selected_node_detail.status && @selected_node_detail.status != :unknown do %> +
+ + + + {@selected_node_detail.status} + +
+ <% end %> +
+ + + <%= if @selected_node_detail[:utilization_stats] do %> +
+

+ Interface Utilization +

+
+ <%= for stat <- @selected_node_detail.utilization_stats do %> +
+
+
+ {stat.interface_name} +
+
"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}% +
+
+
+ {stat.throughput_text} / {stat.capacity_text} +
+
+ <% end %> +
+
+ <% end %> + + + <%= if length(@selected_node_detail.connections) > 0 do %> +
+

+ {t("Connections")} ({length(@selected_node_detail.connections)}) +

+
+ <%= for conn <- @selected_node_detail.connections do %> +
+
+

+ {conn.device_name} +

+ <%= if conn.interface do %> +

+ {conn.interface} +

+ <% end %> + <%= if conn[:utilization_text] do %> +

+ {conn.utilization_text} +

+ <% end %> +
+
+ + {conn.link_type} + + 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)}% + +
+
+ <% end %> +
+
+ <% end %> + + + <%= if @selected_node_detail.type == :managed do %> +
+ <.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")} + +
+ <% end %> +
+
+ <% end %> +
+
+ + + <%= if @topology.stats.total_devices == 0 do %> +
+
+ <.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" /> +

+ {t("No network data available")} +

+

+ {t("Add devices with SNMP enabled and configure interface capacities to view utilization.")} +

+
+ <.button navigate={~p"/devices/new"} variant="primary"> + <.icon name="hero-plus" class="h-5 w-5" /> Add Your First Device + +
+
+
+ <% end %> + <% end %> +
\ No newline at end of file diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index b67fe064..65135ea2 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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