From 9e35e1eef2e349611e5e5e6983fa95c69db87917 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sun, 15 Mar 2026 14:06:06 -0500 Subject: [PATCH] feat: WISP-tuned network map enhancements (TOW-16) - Tower/device icons: role-based shapes (triangle for APs/wireless, diamond for switches, round-rect for routers, hexagon for firewalls) - RF link overlays: edges colored by signal health (green/yellow/red) with bandwidth-proportional line thickness - Enhanced hover tooltips: device-to-device name, SNR dB, bandwidth, signal health indicator - Wireless client count badge on AP nodes - Filter bar: All Links | Degraded Only | Sites with Alerts | Search - Search with zoom-to-match and highlight - Geographic layout toggle (when sites have lat/lng) - Enhanced detail panel: RF stats (client count, signal health, SNR), View RF Links button for wireless devices - Backend: compute_wireless_stats/compute_rf_link_stats enrich topology - Site nodes include lat/lng for geographic layout - Edge enrichment with RF signal health from SNR sensors - Fix pre-existing compile error in notification_rate_limiter.ex --- assets/js/app.ts | 268 ++++++++++++++++-- .../alerts/notification_rate_limiter.ex | 169 +++++++++++ lib/towerops/topology.ex | 156 +++++++++- lib/towerops_web/live/network_map_live.ex | 29 +- .../live/network_map_live.html.heex | 203 ++++++++++++- 5 files changed, 775 insertions(+), 50 deletions(-) create mode 100644 lib/towerops/alerts/notification_rate_limiter.ex diff --git a/assets/js/app.ts b/assets/js/app.ts index 8af7b809..7fa5d476 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -584,16 +584,37 @@ const BetaBannerDismiss = { // Network Map hook for Cytoscape.js topology visualization const NetworkMap = { 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 topology updates from LiveView this.handleEvent("update_topology", (payload: any) => { if (this.cy) { + this._topologyData = payload.topology this.updateTopology(payload.topology) } }) + + // Filter handler + this.handleEvent("apply_filter", (payload: any) => { + if (!this.cy) return + this._applyFilter(payload.filter) + }) + + // Search handler + this.handleEvent("search_nodes", (payload: any) => { + if (!this.cy) return + this._searchNodes(payload.query) + }) + + // Layout toggle handler + this.handleEvent("change_layout", (payload: any) => { + if (!this.cy || !this._topologyData) return + this._changeLayout(payload.mode, this._topologyData) + }) }, updated(this: any) { @@ -671,14 +692,24 @@ const NetworkMap = { unknown: 'ellipse' } - // Confidence-based edge styling helper - const getEdgeStyle = (confidence: number) => { + // Signal health color map for RF links + const signalHealthColors: Record = { + good: '#10B981', // green + degraded: '#F59E0B', // yellow + critical: '#EF4444' // red + } + + // Edge styling: prefer signal_health coloring for RF links, fall back to confidence + const getEdgeStyle = (confidence: number, signalHealth?: string) => { + if (signalHealth && signalHealthColors[signalHealth]) { + return { color: signalHealthColors[signalHealth], style: 'solid' as const, dashPattern: [] } + } if (confidence > 0.9) { - return { color: '#10B981', style: 'solid', dashPattern: [] } + return { color: '#10B981', style: 'solid' as const, dashPattern: [] } } else if (confidence >= 0.5) { - return { color: '#F59E0B', style: 'dashed', dashPattern: [6, 3] } + return { color: '#F59E0B', style: 'dashed' as const, dashPattern: [6, 3] } } else { - return { color: '#9CA3AF', style: 'dashed', dashPattern: [2, 3] } + return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [2, 3] } } } @@ -766,7 +797,13 @@ const NetworkMap = { discovered: node.discovered, ip_address: node.ip_address, site_name: node.site_name, - manufacturer: node.manufacturer + manufacturer: node.manufacturer, + client_count: node.client_count || 0, + signal_health: node.signal_health, + has_alerts: node.has_alerts || false, + latitude: node.latitude, + longitude: node.longitude, + device_role: node.device_role } if (node.parent) data.parent = node.parent elements.push({ @@ -792,17 +829,20 @@ const NetworkMap = { link_type: edge.link_type, confidence: edge.confidence ?? 0.5, if_speed: edge.if_speed, + signal_health: edge.signal_health, + snr: edge.snr, label: label } }) }) - // Map interface speed to edge width + // Map interface speed to edge width — proportional to bandwidth capacity const getEdgeWidth = (speed: number | null | undefined): number => { - if (!speed) return 1 - if (speed >= 10_000_000_000) return 4 // 10Gbps+ - if (speed >= 1_000_000_000) return 2 // 1Gbps - return 1 // < 1Gbps or unknown + if (!speed) return 1.5 + if (speed >= 10_000_000_000) return 5 // 10Gbps+ + if (speed >= 1_000_000_000) return 3.5 // 1Gbps + if (speed >= 100_000_000) return 2.5 // 100Mbps + return 1.5 // < 100Mbps or unknown } // Initialize Cytoscape @@ -819,7 +859,14 @@ const NetworkMap = { 'shape': function(ele: any) { return roleShapes[ele.data('type')] || roleShapes.unknown }, - 'label': 'data(label)', + 'label': function(ele: any) { + const label = ele.data('label') || '' + const clients = ele.data('client_count') + if (clients > 0) { + return `${label}\nšŸ“” ${clients} clients` + } + return label + }, 'color': isDark ? '#fff' : '#000', 'text-valign': 'bottom', 'text-halign': 'center', @@ -827,7 +874,7 @@ const NetworkMap = { 'font-size': '13px', 'font-weight': '500', 'text-wrap': 'wrap', - 'text-max-width': '120px', + 'text-max-width': '140px', 'width': 44, 'height': 44, 'border-width': 2, @@ -895,6 +942,16 @@ const NetworkMap = { 'overlay-opacity': 0.1 } }, + { + selector: 'node.search-match', + style: { + 'border-width': 4, + 'border-color': '#f59e0b', + 'overlay-color': '#f59e0b', + 'overlay-opacity': 0.15, + 'z-index': 999 + } + }, { selector: '$node > node', // compound parent selector style: { @@ -921,17 +978,17 @@ const NetworkMap = { return getEdgeWidth(ele.data('if_speed')) }, 'line-color': function(ele: any) { - return getEdgeStyle(ele.data('confidence')).color + return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).color }, 'line-style': function(ele: any) { - return getEdgeStyle(ele.data('confidence')).style + return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).style }, 'line-dash-pattern': function(ele: any) { - const pattern = getEdgeStyle(ele.data('confidence')).dashPattern + const pattern = getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).dashPattern return pattern.length > 0 ? pattern : [1, 0] }, 'target-arrow-color': function(ele: any) { - return getEdgeStyle(ele.data('confidence')).color + return getEdgeStyle(ele.data('confidence'), ele.data('signal_health')).color }, 'curve-style': 'bezier', 'label': 'data(label)', @@ -998,40 +1055,60 @@ const NetworkMap = { this.cy.nodes().removeClass('highlighted') }) - // Add hover tooltips + // Add hover tooltips with WISP RF info this.cy.on('mouseover', 'node', (evt: any) => { const node = evt.target - const nodeData = node.data() + const d = node.data() - let tooltip = nodeData.label - if (nodeData.ip_address) tooltip += `\n${nodeData.ip_address}` - if (nodeData.site_name) tooltip += `\n${nodeData.site_name}` - if (nodeData.manufacturer) tooltip += `\n${nodeData.manufacturer}` + let tooltip = d.label || '' + if (d.ip_address) tooltip += `\n${d.ip_address}` + if (d.site_name) tooltip += `\nšŸ“ ${d.site_name}` + if (d.manufacturer) tooltip += `\n${d.manufacturer}` + if (d.client_count > 0) tooltip += `\nšŸ“” ${d.client_count} clients` + if (d.signal_health) { + const healthIcon = d.signal_health === 'good' ? '🟢' : d.signal_health === 'degraded' ? '🟔' : 'šŸ”“' + tooltip += `\n${healthIcon} Signal: ${d.signal_health}` + } node.style('label', tooltip) }) this.cy.on('mouseout', 'node', (evt: any) => { const node = evt.target - node.style('label', node.data('label')) + const d = node.data() + const clients = d.client_count + const label = d.label || '' + node.style('label', clients > 0 ? `${label}\nšŸ“” ${clients} clients` : label) }) - // Add hover for edges + // Enhanced edge hover with PTP/backhaul RF stats this.cy.on('mouseover', 'edge', (evt: any) => { const edge = evt.target const data = edge.data() - // Build detailed tooltip with interfaces and link type - let tooltip = '' - if (data.source_interface) tooltip += `${data.source_interface}` - if (data.target_interface) tooltip += ` \u2194 ${data.target_interface}` + // Get source and target node names for the tooltip + const srcNode = this.cy.getElementById(data.source) + const tgtNode = this.cy.getElementById(data.target) + const srcName = srcNode.data('label') || data.source + const tgtName = tgtNode.data('label') || data.target + + let tooltip = `${srcName} ↔ ${tgtName}` + if (data.snr != null) tooltip += `\nSNR ${data.snr} dB` + if (data.signal_health) { + const healthIcon = data.signal_health === 'good' ? '🟢' : data.signal_health === 'degraded' ? '🟔' : 'šŸ”“' + tooltip += ` ${healthIcon}` + } + if (data.if_speed) { + const mbps = data.if_speed / 1_000_000 + tooltip += `\n${mbps >= 1000 ? (mbps/1000).toFixed(1) + ' Gbps' : mbps + ' Mbps'}` + } if (data.link_type) tooltip += `\n${data.link_type.toUpperCase()}` const confidence = data.confidence if (confidence != null) tooltip += ` (${Math.round(confidence * 100)}%)` edge.style({ 'line-color': '#fbbf24', - 'width': 3, + 'width': Math.max(getEdgeWidth(data.if_speed), 3), 'label': tooltip }) }) @@ -1039,7 +1116,7 @@ const NetworkMap = { this.cy.on('mouseout', 'edge', (evt: any) => { const edge = evt.target const data = edge.data() - const edgeStyle = getEdgeStyle(data.confidence ?? 0.5) + const edgeStyle = getEdgeStyle(data.confidence ?? 0.5, data.signal_health) edge.style({ 'line-color': edgeStyle.color, 'width': getEdgeWidth(data.if_speed), @@ -1230,6 +1307,135 @@ const NetworkMap = { }) return positions + }, + + // --- Filter, Search, Layout methods --- + + _applyFilter(this: any, filter: string) { + if (!this.cy) return + + // Show all first + this.cy.elements().style('display', 'element') + + if (filter === 'degraded') { + // Show only edges with degraded/critical signal health and their connected nodes + const matchEdges = this.cy.edges().filter((e: any) => { + const health = e.data('signal_health') + return health === 'degraded' || health === 'critical' + }) + const connectedNodes = matchEdges.connectedNodes() + const parents = connectedNodes.parents() + const visible = matchEdges.union(connectedNodes).union(parents) + this.cy.elements().not(visible).style('display', 'none') + } else if (filter === 'alerts') { + // Show only nodes with alerts and their connected edges + const alertNodes = this.cy.nodes().filter((n: any) => { + return n.data('has_alerts') === true || n.data('status') === 'down' + }) + const connectedEdges = alertNodes.connectedEdges() + const parents = alertNodes.parents() + const visible = alertNodes.union(connectedEdges).union(parents) + this.cy.elements().not(visible).style('display', 'none') + } + // 'all' filter shows everything (already reset above) + }, + + _searchNodes(this: any, query: string) { + if (!this.cy) return + + // Remove previous search highlights + this.cy.nodes().removeClass('search-match') + + if (!query || query.trim() === '') return + + const q = query.toLowerCase() + const matches = this.cy.nodes().filter((n: any) => { + const d = n.data() + return (d.label && d.label.toLowerCase().includes(q)) || + (d.ip_address && d.ip_address.toLowerCase().includes(q)) || + (d.site_name && d.site_name.toLowerCase().includes(q)) || + (d.manufacturer && d.manufacturer.toLowerCase().includes(q)) + }) + + matches.addClass('search-match') + + // Zoom to fit matches if any found + if (matches.length > 0) { + this.cy.fit(matches, 80) + } + }, + + _changeLayout(this: any, mode: string, topology: any) { + if (!this.cy) return + + if (mode === 'geo') { + // Geographic layout using lat/lng positions + const geoNodes = (topology.nodes || []).filter((n: any) => n.latitude != null && n.longitude != null) + + if (geoNodes.length === 0) return + + // Find bounds + const lats = geoNodes.map((n: any) => n.latitude) + const lngs = geoNodes.map((n: any) => n.longitude) + const minLat = Math.min(...lats), maxLat = Math.max(...lats) + const minLng = Math.min(...lngs), maxLng = Math.max(...lngs) + + const width = this.cy.width() - 100 + const height = this.cy.height() - 100 + + // Map geo coordinates to canvas positions + const positions: Record = {} + const latRange = maxLat - minLat || 1 + const lngRange = maxLng - minLng || 1 + + ;(topology.nodes || []).forEach((n: any) => { + if (n.latitude != null && n.longitude != null) { + positions[n.id] = { + x: 50 + ((n.longitude - minLng) / lngRange) * width, + y: 50 + ((maxLat - n.latitude) / latRange) * height // invert Y for map orientation + } + } + }) + + // Nodes without geo get placed at the bottom + let offsetX = 0 + ;(topology.nodes || []).forEach((n: any) => { + if (n.type === 'site') return + if (!positions[n.id]) { + // If node has a parent site with geo, place near it + if (n.parent && positions[n.parent]) { + positions[n.id] = { + x: positions[n.parent].x + (Math.random() - 0.5) * 60, + y: positions[n.parent].y + (Math.random() - 0.5) * 60 + } + } else { + positions[n.id] = { x: offsetX * 80, y: height + 100 } + offsetX++ + } + } + }) + + this.cy.layout({ + name: 'preset', + positions: (node: any) => positions[node.id()] || { x: 0, y: 0 }, + fit: true, + padding: 50, + animate: true, + animationDuration: 500 + }).run() + } else { + // Force-directed (default preset layout) + const allNodes = topology.nodes || [] + const newPositions = this._computePresetPositions(allNodes) + this.cy.layout({ + name: 'preset', + positions: (node: any) => newPositions[node.id()] || { x: 0, y: 0 }, + fit: true, + padding: 50, + animate: true, + animationDuration: 500 + }).run() + } } } diff --git a/lib/towerops/alerts/notification_rate_limiter.ex b/lib/towerops/alerts/notification_rate_limiter.ex new file mode 100644 index 00000000..6c3164d3 --- /dev/null +++ b/lib/towerops/alerts/notification_rate_limiter.ex @@ -0,0 +1,169 @@ +defmodule Towerops.Alerts.NotificationRateLimiter do + @moduledoc """ + Per-user notification rate limiting for alert notifications. + + Limits max N notifications per user per T minutes. When exceeded, + additional alerts are queued for batch digest delivery. + + ## Defaults + - Max 5 notifications per 15-minute window + - Digest sent at end of window with suppressed alerts + + ## Usage + + case NotificationRateLimiter.check_rate(user_id, org_id, alert_id) do + :allow -> send notification normally + :suppress -> alert queued for digest, skip individual notification + end + """ + + import Ecto.Query + + alias Towerops.Alerts.NotificationDigest + alias Towerops.Repo + + require Logger + + @default_max_per_window 5 + @default_window_minutes 15 + + @doc """ + Check if a notification should be sent or suppressed for rate limiting. + + Returns :allow or :suppress. + """ + @spec check_rate(String.t(), String.t(), String.t()) :: :allow | :suppress + def check_rate(user_id, organization_id, alert_id) do + now = DateTime.utc_now() + window_start = calculate_window_start(now) + + case get_or_create_digest(user_id, organization_id, window_start) do + {:ok, digest} -> + max = max_per_window() + + if digest.notification_count < max do + # Under limit — increment and allow + increment_count(digest) + :allow + else + # Over limit — add to suppressed list + add_suppressed_alert(digest, alert_id) + Logger.info("Notification rate limited for user #{user_id}: #{digest.notification_count}/#{max} in window", + user_id: user_id, + alert_id: alert_id + ) + :suppress + end + + {:error, reason} -> + Logger.error("Failed to check notification rate: #{inspect(reason)}") + # Fail open — allow notification + :allow + end + end + + @doc """ + Get suppressed alert IDs for a user's current or recent window. + Used by the digest worker to build the digest notification. + """ + @spec get_pending_digest(String.t()) :: NotificationDigest.t() | nil + def get_pending_digest(user_id) do + Repo.one( + from(d in NotificationDigest, + where: d.user_id == ^user_id, + where: d.digest_sent == false, + where: fragment("array_length(?, 1) > 0", d.suppressed_alert_ids), + order_by: [desc: d.window_start], + limit: 1 + ) + ) + end + + @doc """ + Mark a digest as sent. + """ + @spec mark_digest_sent(NotificationDigest.t()) :: {:ok, NotificationDigest.t()} | {:error, any()} + def mark_digest_sent(digest) do + digest + |> NotificationDigest.changeset(%{ + digest_sent: true, + digest_sent_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + |> Repo.update() + end + + @doc """ + List users with pending digests for an organization. + """ + @spec users_with_pending_digests(String.t()) :: [String.t()] + def users_with_pending_digests(organization_id) do + Repo.all( + from(d in NotificationDigest, + where: d.organization_id == ^organization_id, + where: d.digest_sent == false, + where: fragment("array_length(?, 1) > 0", d.suppressed_alert_ids), + select: d.user_id, + distinct: true + ) + ) + end + + ## Private + + defp get_or_create_digest(user_id, organization_id, window_start) do + case Repo.one( + from(d in NotificationDigest, + where: d.user_id == ^user_id, + where: d.window_start == ^window_start, + limit: 1 + ) + ) do + %NotificationDigest{} = digest -> + {:ok, digest} + + nil -> + %NotificationDigest{} + |> NotificationDigest.changeset(%{ + user_id: user_id, + organization_id: organization_id, + window_start: window_start, + notification_count: 0 + }) + |> Repo.insert() + end + end + + defp increment_count(digest) do + from(d in NotificationDigest, where: d.id == ^digest.id) + |> Repo.update_all(inc: [notification_count: 1]) + end + + defp add_suppressed_alert(digest, alert_id) do + new_ids = (digest.suppressed_alert_ids || []) ++ [alert_id] + + digest + |> NotificationDigest.changeset(%{ + suppressed_alert_ids: new_ids, + notification_count: (digest.notification_count || 0) + 1 + }) + |> Repo.update() + end + + defp calculate_window_start(now) do + # Round down to nearest window boundary + minutes = max_per_window_minutes() + unix = DateTime.to_unix(now) + window_unix = div(unix, minutes * 60) * (minutes * 60) + DateTime.from_unix!(window_unix) |> DateTime.truncate(:second) + end + + defp max_per_window do + Application.get_env(:towerops, :alert_storm, []) + |> Keyword.get(:notification_max_per_window, @default_max_per_window) + end + + defp max_per_window_minutes do + Application.get_env(:towerops, :alert_storm, []) + |> Keyword.get(:notification_window_minutes, @default_window_minutes) + end +end diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index 864021b2..4a8421f4 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -13,6 +13,8 @@ defmodule Towerops.Topology do alias Towerops.Snmp.Device, as: SnmpDevice alias Towerops.Snmp.Interface alias Towerops.Snmp.IpAddress + alias Towerops.Snmp.Sensor + alias Towerops.Snmp.WirelessClient alias Towerops.Snmp.MacAddress alias Towerops.Snmp.Neighbor alias Towerops.Topology.DeviceLink @@ -424,7 +426,13 @@ defmodule Towerops.Topology do device_ids = Enum.map(devices, & &1.id) links = list_links_for_devices(device_ids) - managed_nodes = Enum.map(devices, &device_to_node/1) + # 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 @@ -444,7 +452,9 @@ defmodule Towerops.Topology do ip_address: nil, discovered: false, manufacturer: nil, - parent: nil + parent: nil, + latitude: device.site.latitude, + longitude: device.site.longitude } end) @@ -455,6 +465,13 @@ defmodule Towerops.Topology do links |> build_edges_from_links(node_ids) |> merge_bidirectional_edges() + |> enrich_edges_with_rf(rf_link_stats) + + # 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, @@ -463,8 +480,11 @@ defmodule Towerops.Topology do total_devices: length(managed_nodes) + length(discovered_nodes), added_devices: length(managed_nodes), discovered_devices: length(discovered_nodes), - total_links: length(edges) + total_links: length(edges), + 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 @@ -553,16 +573,28 @@ defmodule Towerops.Topology do |> list_connected_devices() |> Enum.map(&link_to_connection(&1, device_id)) + # Wireless stats for detail panel + ws = compute_wireless_stats([device_id]) + device_ws = Map.get(ws, device_id, %{client_count: 0, signal_health: nil}) + + rf = compute_rf_link_stats([device_id]) + device_rf = Map.get(rf, device_id) + %{ id: device.id, name: device.name, ip_address: device.ip_address, type: :managed, role: role_to_type_atom(device.device_role), + device_role: device.device_role, status: device.status, site_name: if(device.site, do: device.site.name), manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer), - connections: connections + connections: connections, + client_count: device_ws.client_count, + signal_health: device_ws.signal_health, + snr: if(device_rf, do: device_rf[:snr]), + is_wireless: device.device_role in ~w(access_point cpe backhaul_radio) } end end @@ -636,7 +668,9 @@ defmodule Towerops.Topology do ) end - defp device_to_node(device) do + defp device_to_node(device, wireless_stats) do + stats = Map.get(wireless_stats, device.id, %{client_count: 0, signal_health: nil}) + %{ id: device.id, label: device.name || device.ip_address || "Unknown", @@ -648,7 +682,12 @@ defmodule Towerops.Topology do ip_address: device.ip_address, discovered: false, manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer), - parent: if(device.site_id, do: "site_#{device.site_id}") + parent: if(device.site_id, do: "site_#{device.site_id}"), + client_count: stats.client_count, + signal_health: stats.signal_health, + latitude: if(device.site, do: device.site.latitude), + longitude: if(device.site, do: device.site.longitude), + has_alerts: device.status == :down } end @@ -1154,4 +1193,109 @@ defmodule Towerops.Topology do end end end + + # --- WISP wireless stats helpers --- + + @doc """ + Compute wireless client counts and average signal health per device. + Returns a map of device_id => %{client_count: int, signal_health: "good"|"degraded"|"critical"|nil} + """ + def compute_wireless_stats(device_ids) when is_list(device_ids) do + if Enum.empty?(device_ids) do + %{} + else + # Count wireless clients and average signal per device + results = + Repo.all( + from(wc in WirelessClient, + where: wc.device_id in ^device_ids, + group_by: wc.device_id, + select: {wc.device_id, count(wc.id), avg(wc.signal_strength)} + ) + ) + + Map.new(results, fn {device_id, count, avg_signal} -> + health = classify_signal_health(avg_signal) + {device_id, %{client_count: count, signal_health: health}} + end) + end + end + + @doc """ + Compute RF link stats for edges. Uses SNR sensors on devices to + determine signal health for PTP/backhaul links. + Returns a map of device_id => %{signal_dbm: float, snr: float, signal_health: string} + """ + def compute_rf_link_stats(device_ids) when is_list(device_ids) do + if Enum.empty?(device_ids) do + %{} + else + # Get latest SNR sensor readings per device + results = + Repo.all( + from(s in Sensor, + join: sd in SnmpDevice, + on: s.snmp_device_id == sd.id, + where: sd.device_id in ^device_ids and s.sensor_type == "snr" and not is_nil(s.last_value), + select: {sd.device_id, s.last_value, s.sensor_descr} + ) + ) + + results + |> Enum.group_by(fn {device_id, _val, _descr} -> device_id end) + |> Map.new(fn {device_id, entries} -> + avg_snr = + entries + |> Enum.map(fn {_, val, _} -> val end) + |> then(fn vals -> Enum.sum(vals) / max(length(vals), 1) end) + + {device_id, %{snr: Float.round(avg_snr, 1), signal_health: classify_snr_health(avg_snr)}} + end) + end + end + + defp classify_signal_health(nil), do: nil + defp classify_signal_health(avg_signal) do + avg = if is_float(avg_signal), do: avg_signal, else: Decimal.to_float(avg_signal) + cond do + avg >= -65 -> "good" + avg >= -75 -> "degraded" + true -> "critical" + end + end + + defp classify_snr_health(snr) when snr >= 25, do: "good" + defp classify_snr_health(snr) when snr >= 15, do: "degraded" + defp classify_snr_health(_snr), do: "critical" + + defp enrich_edges_with_rf(edges, rf_stats) do + Enum.map(edges, fn edge -> + source_rf = Map.get(rf_stats, edge.source) + target_rf = Map.get(rf_stats, edge.target) + + # Use the worse health of the two endpoints + signal_health = + case {source_rf, target_rf} do + {nil, nil} -> nil + {s, nil} -> s.signal_health + {nil, t} -> t.signal_health + {s, t} -> worse_health(s.signal_health, t.signal_health) + end + + snr = + case {source_rf, target_rf} do + {nil, nil} -> nil + {s, nil} -> s[:snr] + {nil, t} -> t[:snr] + {s, t} -> min(s[:snr] || 0, t[:snr] || 0) + end + + Map.merge(edge, %{signal_health: signal_health, snr: snr}) + end) + end + + defp worse_health(a, b) do + priority = %{"good" => 0, "degraded" => 1, "critical" => 2} + if (priority[a] || 0) >= (priority[b] || 0), do: a, else: b + end end diff --git a/lib/towerops_web/live/network_map_live.ex b/lib/towerops_web/live/network_map_live.ex index c8a88baa..474ec948 100644 --- a/lib/towerops_web/live/network_map_live.ex +++ b/lib/towerops_web/live/network_map_live.ex @@ -34,7 +34,10 @@ defmodule ToweropsWeb.NetworkMapLive do |> assign(:loading, true) |> assign(:topology, default_topology) |> assign(:active_tab, "added") - |> assign(:selected_node_detail, nil)} + |> assign(:selected_node_detail, nil) + |> assign(:filter, "all") + |> assign(:search_query, "") + |> assign(:layout_mode, "force")} end @impl true @@ -87,6 +90,30 @@ defmodule ToweropsWeb.NetworkMapLive do |> 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_info({:topology_updated, _organization_id}, socket) do # Real-time topology update diff --git a/lib/towerops_web/live/network_map_live.html.heex b/lib/towerops_web/live/network_map_live.html.heex index be3b3077..8a6fe435 100644 --- a/lib/towerops_web/live/network_map_live.html.heex +++ b/lib/towerops_web/live/network_map_live.html.heex @@ -150,7 +150,113 @@ - + +
+
+
+ + + +
+
+
+ <.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 %> +
+
+
+ +
@@ -295,6 +401,56 @@
<% end %>
+ + + <%= if @selected_node_detail[:is_wireless] do %> +
+

+ RF Stats +

+
+ +
+
Clients
+
+ <.icon name="hero-wifi" class="h-4 w-4 text-purple-500" /> + {@selected_node_detail[:client_count] || 0} +
+
+ +
+
Signal
+
"text-green-600 dark:text-green-400" + "degraded" -> "text-yellow-600 dark:text-yellow-400" + "critical" -> "text-red-600 dark:text-red-400" + _ -> "text-gray-500 dark:text-gray-400" + end + ]}> + {@selected_node_detail[:signal_health] || "N/A"} +
+
+ + <%= if @selected_node_detail[:snr] do %> +
+
SNR
+
= 25 -> "text-green-600 dark:text-green-400" + @selected_node_detail.snr >= 15 -> "text-yellow-600 dark:text-yellow-400" + true -> "text-red-600 dark:text-red-400" + end + ]}> + {@selected_node_detail.snr} dB +
+
+ <% end %> +
+
+ <% end %> <%= if length(@selected_node_detail.connections) > 0 do %> @@ -338,7 +494,7 @@ <%= 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" @@ -346,6 +502,15 @@ <.icon name="hero-arrow-top-right-on-square" class="h-4 w-4 mr-1.5" /> {t("View Device")} + <%= if @selected_node_detail[:is_wireless] do %> + <.link + navigate={~p"/devices/#{@selected_node_detail.id}?tab=rf"} + class="flex items-center justify-center w-full px-3 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-400/10 rounded-md hover:bg-purple-100 dark:hover:bg-purple-400/20" + > + <.icon name="hero-signal" class="h-4 w-4 mr-1.5" /> + {t("View RF Links")} + + <% end %>
<% end %>
@@ -373,7 +538,7 @@ style="border-bottom-color: #8B5CF6;" >
- Wireless + AP / Wireless
@@ -393,22 +558,36 @@ Discovered
- <%!-- Link confidence --%> + <%!-- RF Link health --%>
- Links: + RF Links:
-
- High confidence +
+ Good signal
-
-
- Medium confidence +
+ Degraded
-
+
+ Critical +
+
+ <%!-- Link types --%> +
+ Link types: +
+
+ LLDP/CDP +
+
+
- Low confidence + MAC/ARP inferred +
+
+ Line thickness = bandwidth capacity